Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ export default defineConfig({
},
},
}),
coursebuilder({ configFile: './coursebuilder.config.ts' }),
// CodeTV owns /api/coursebuilder/* in src/pages/api/coursebuilder/[...coursebuilder].ts
coursebuilder({
configFile: './coursebuilder.config.ts',
injectEndpoints: false,
}),
expressiveCode({ themes: ['night-owl'] }),
mdx(),
react(),
Expand Down
20 changes: 14 additions & 6 deletions apps/website/coursebuilder.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { defineConfig } from '@coursebuilder/astro';
import { userSchema } from '@coursebuilder/core/schemas';

import { getStripeProvider } from './src/coursebuilder/stripe-provider';
import { createInlineCommerceEvents } from './src/coursebuilder/fulfillment';
import {
getCommerceBaseUrl,
getStripeProvider,
} from './src/coursebuilder/stripe-provider';
import { getCourseBuilderUserForClerkUser } from './src/coursebuilder/users';
import { getCourseBuilderAdapter } from './src/db';

Expand All @@ -16,15 +20,19 @@ export default defineConfig(async (context) => {
return courseBuilderUser ? userSchema.parse(courseBuilderUser) : null;
};

const baseUrl = getCommerceBaseUrl();

return {
baseUrl:
process.env.COURSEBUILDER_URL ??
process.env.PUBLIC_SITE_URL ??
process.env.URL ??
'http://localhost:4321',
baseUrl,
basePath: '/api/coursebuilder',
adapter: getCourseBuilderAdapter(),
providers: stripeProvider ? [stripeProvider] : [],
// Webhook fulfillment runs in-process; see src/coursebuilder/fulfillment.ts.
inngest: createInlineCommerceEvents({
getAdapter: getCourseBuilderAdapter,
getPaymentProvider: () => stripeProvider,
siteRootUrl: baseUrl,
}),
getCurrentUser,
callbacks: {
session: async (request) => ({
Expand Down
1 change: 1 addition & 0 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"test": "node --test 'src/**/*.test.ts'",
"astro": "astro",
"db:generate": "dotenv drizzle-kit generate",
"db:push": "dotenv drizzle-kit push",
Expand Down
186 changes: 186 additions & 0 deletions apps/website/src/components/workshops/purchase-processing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';

import {
getPollStep,
type CheckoutStatus,
type PollPhase,
} from '../../coursebuilder/purchase-polling';

type Status = CheckoutStatus & { workshopPath?: string | null };

type PurchaseProcessingProps = {
sessionId: string;
initialStatus: Status;
supportEmail: string;
};

/**
* Purchase Processing screen, mirroring the AI Hero / Code with Antonio
* thanks-page pattern: reassure the buyer right away, poll until the purchase
* is verified in the database, then show the welcome state. After about two
* minutes, show the shared "payment succeeded, setup delayed" message.
*
* TODO: invoice, Ticket Transfer and team distribution actions unlock here
* once verified. Port ai-hero/apps/ai-hero/src/app/(commerce)/thanks/purchase/page.tsx.
*/
export function PurchaseProcessing({
sessionId,
initialStatus,
supportEmail,
}: PurchaseProcessingProps) {
const [status, setStatus] = useState<Status>(initialStatus);
const [phase, setPhase] = useState<PollPhase>('processing');

useEffect(() => {
if (status.status !== 'processing') return;

let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const startedAt = Date.now();

const schedule = (attempt: number) => {
const step = getPollStep(Date.now() - startedAt, attempt);
setPhase(step.phase);

if (step.phase === 'failed') {
// One last request so the server logs that this buyer saw the fallback.
void fetch(
`/api/commerce/checkout-status?${new URLSearchParams({
session_id: sessionId,
attempt: 'gave-up',
})}`,
{ cache: 'no-store' },
).catch(() => undefined);
return;
}

timeoutId = setTimeout(() => poll(attempt + 1), step.delayMs);
};

const poll = async (attempt: number) => {
try {
const response = await fetch(
`/api/commerce/checkout-status?${new URLSearchParams({
session_id: sessionId,
attempt: String(attempt),
})}`,
{ cache: 'no-store' },
);
const next = (await response.json().catch(() => null)) as Status | null;
if (cancelled) return;

if (next && next.status !== 'processing') {
setStatus(next);
return;
}
} catch {
if (cancelled) return;
}

schedule(attempt);
};

schedule(0);

return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}, [sessionId, status.status]);

if (status.status === 'ready') {
return (
<div className="purchase-processing verified">
<p className="eyebrow">Purchase confirmed</p>
<h1>You’re in!</h1>
<p>
Your Workshop Ticket
{status.productName ? (
<>
{' '}
for <strong>{status.productName}</strong>
</>
) : null}{' '}
is confirmed and linked to your CodeTV account.
</p>
{status.purchaseStatus === 'Restricted' && (
<p>
You bought this ticket with regional pricing, so it’s tied to the
country you bought it from.
</p>
)}
<p>
We’ll share everything you need before the cohort starts. Questions?
Email <a href={`mailto:${supportEmail}`}>{supportEmail}</a>.
</p>
{status.workshopPath && (
<a className="button" href={status.workshopPath}>
Back to the workshop
</a>
)}
</div>
);
}

if (status.status === 'not-paid') {
return (
<div className="purchase-processing">
<h1>Your payment didn’t go through</h1>
<p>
Stripe didn’t complete this checkout, so you haven’t been charged.
Head back to the workshop page to try again, or email{' '}
<a href={`mailto:${supportEmail}`}>{supportEmail}</a>.
</p>
</div>
);
}

if (status.status === 'error') {
return (
<div className="purchase-processing">
<h1>We couldn’t find that checkout</h1>
<p>
{status.message} If you were charged, email{' '}
<a href={`mailto:${supportEmail}`}>{supportEmail}</a> and we’ll sort
it out.
</p>
</div>
);
}

if (phase === 'failed') {
return (
<div className="purchase-processing delayed">
<p className="eyebrow">Payment successful</p>
<h1>Your payment went through</h1>
<p>
We’re having a temporary problem setting up your ticket. You don’t
need to pay again.
</p>
<p>
If you haven’t received a confirmation email after 20–30 minutes,
email <a href={`mailto:${supportEmail}`}>{supportEmail}</a> and we’ll
help as soon as possible.
</p>
</div>
);
}

return (
<div className="purchase-processing" aria-live="polite">
<p className="eyebrow">Payment received</p>
<h1>Thanks for your purchase!</h1>
<p>
{phase === 'slow'
? 'Still setting up your ticket…'
: 'Finalizing your ticket…'}
</p>
{phase === 'slow' && (
<p className="hint">
This usually takes a few seconds. You can leave this tab open while we
finish.
</p>
)}
</div>
);
}
Loading