@tribe-nest/forge 3.17.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import type { CourseAccessData, CourseLesson } from "../../../types/models";
3
+ import {
4
+ courseAccessDenial,
5
+ isLegacyCourseAccess,
6
+ useCourseAccess,
7
+ useUpdateCourseProgress,
8
+ type CourseAccessDenial,
9
+ } from "../../../data/queries/useCourseAccess";
10
+ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
11
+ import { safeRedirectPath } from "../../../utils/safeRedirect";
12
+
13
+ /**
14
+ * What the classroom should draw INSTEAD of the player.
15
+ *
16
+ * `sign_in_required` and `wrong_account` are the two halves of account-bound
17
+ * course access, and they must never be merged: one caller needs a way IN, the
18
+ * other needs a way OUT of the session they are already in. `not_found` keeps
19
+ * the pre-existing behaviour for a revoked or unknown grant.
20
+ */
21
+ export type CourseAccessGateKind = "sign_in_required" | "wrong_account" | "not_found";
22
+
23
+ export interface CourseAccessGate {
24
+ kind: CourseAccessGateKind;
25
+ title: string;
26
+ /** The API's sentence when it sent one, else a local fallback. */
27
+ body: string;
28
+ /** Primary CTA label. */
29
+ actionLabel: string | null;
30
+ /**
31
+ * Where the primary CTA goes. `null` on `wrong_account`, whose action is
32
+ * signing OUT — a state change, not a navigation, so the caller runs
33
+ * `signOutAndRetry` instead.
34
+ */
35
+ actionHref: string | null;
36
+ }
37
+
38
+ // Not re-exported from the headless barrel — `COURSE_SIGNUP_PATH` is already
39
+ // taken there by the posts upsell, and these are only defaults for the options.
40
+ export const COURSE_LOGIN_PATH = "/login";
41
+ export const COURSE_SIGNUP_PATH = "/signup";
42
+
43
+ /** `loginPath?redirect=<here>` — a sign-in that lands back on the course, never on a generic home. */
44
+ export const courseSignInHref = (loginPath: string, currentPath: string): string => {
45
+ const target = safeRedirectPath(currentPath, "");
46
+ return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
47
+ };
48
+
49
+ /**
50
+ * The gate for a failed access read, or `null` when there is nothing to gate.
51
+ *
52
+ * Pure and total so the two 4xx branches can be exercised without a DOM, a
53
+ * client or a session — this mapping is the whole feature, and getting the
54
+ * 401/403 arms backwards produces a page that is technically correct and
55
+ * practically a dead end.
56
+ */
57
+ export function buildCourseAccessGate(input: {
58
+ denial: CourseAccessDenial | null;
59
+ /** The query failed for some OTHER reason (404, 500, offline). */
60
+ isError: boolean;
61
+ loginPath?: string;
62
+ /** Path (+ search) to return to after signing in. */
63
+ currentPath?: string;
64
+ }): CourseAccessGate | null {
65
+ const { denial, isError } = input;
66
+ const loginPath = input.loginPath ?? COURSE_LOGIN_PATH;
67
+
68
+ if (denial?.reason === "account_required") {
69
+ return {
70
+ kind: "sign_in_required",
71
+ title: "Sign in to open this course",
72
+ // Said plainly because the person reading it is almost always the buyer,
73
+ // arriving from an email they were sent months ago.
74
+ body:
75
+ denial.message ??
76
+ "This enrolment is tied to an account. Sign in and you'll come straight back to your course.",
77
+ actionLabel: "Sign in",
78
+ actionHref: courseSignInHref(loginPath, input.currentPath ?? ""),
79
+ };
80
+ }
81
+
82
+ if (denial?.reason === "wrong_account") {
83
+ return {
84
+ kind: "wrong_account",
85
+ title: "This enrolment belongs to a different account",
86
+ // NOT "sign in" — they already are. The only useful move is leaving the
87
+ // session they are in.
88
+ body:
89
+ denial.message ??
90
+ "You're signed in with a different account. Sign out and sign back in with the account that bought this course.",
91
+ actionLabel: "Sign out and switch account",
92
+ actionHref: null,
93
+ };
94
+ }
95
+
96
+ if (isError) {
97
+ return {
98
+ kind: "not_found",
99
+ title: "Course access not found",
100
+ body: "This link may have expired or the enrolment may have been revoked.",
101
+ actionLabel: null,
102
+ actionHref: null,
103
+ };
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * The nudge shown ON TOP of a course that opened fine, when the grant is still
111
+ * legacy (`account_id` NULL) — i.e. the link alone is the credential.
112
+ *
113
+ * Only offered to a signed-OUT reader. There is no bind-my-grant endpoint, so
114
+ * the honest thing to offer is an account on the grant's own email; telling a
115
+ * reader who is already signed in to "secure" something we cannot bind for them
116
+ * would be noise on every page view.
117
+ */
118
+ export interface CourseAccessSecurePrompt {
119
+ /** The email the grant was issued to — shown so the reader knows which one to use. */
120
+ email: string;
121
+ href: string;
122
+ }
123
+
124
+ export function buildCourseAccessSecurePrompt(input: {
125
+ data?: CourseAccessData | null;
126
+ isAuthenticated: boolean;
127
+ signupPath?: string;
128
+ currentPath?: string;
129
+ }): CourseAccessSecurePrompt | null {
130
+ if (!input.data || input.isAuthenticated) return null;
131
+ if (!isLegacyCourseAccess(input.data)) return null;
132
+ const email = input.data.access.email;
133
+ if (!email) return null;
134
+ return {
135
+ email,
136
+ href: courseSignInHref(input.signupPath ?? COURSE_SIGNUP_PATH, input.currentPath ?? ""),
137
+ };
138
+ }
139
+
140
+ const currentPath = (): string =>
141
+ typeof window === "undefined" ? "" : `${window.location.pathname}${window.location.search}`;
142
+
143
+ export interface UseCourseClassroomOptions {
144
+ /** Sign-in path (default `/login`; site-starter uses `/i/login`). */
145
+ loginPath?: string;
146
+ /** Signup path for the secure-your-access nudge (default `/signup`). */
147
+ signupPath?: string;
148
+ /** Suppress the legacy-grant nudge entirely. */
149
+ showSecurePrompt?: boolean;
150
+ }
151
+
152
+ /**
153
+ * Headless enrolled-course player: the account-binding gate, lesson selection
154
+ * and throttled video-progress reporting over `useCourseAccess` +
155
+ * `useUpdateCourseProgress`. Bring your own UI.
156
+ */
157
+ export function useCourseClassroom(accessId?: string, opts: UseCourseClassroomOptions = {}) {
158
+ const { isAuthenticated, isInitialized, user, logout } = usePublicAuth();
159
+ /**
160
+ * Held until the session is restored. `PublicAuthProvider` reads the stored
161
+ * token in an effect, so a query fired on the first render goes out with no
162
+ * Authorization header — and a BOUND grant would answer 401 to the very person
163
+ * it belongs to, showing "sign in" to someone already signed in. Costs one
164
+ * `/public/accounts/me` round trip that the page makes anyway.
165
+ */
166
+ const { data, isLoading, isError, error, refetch } = useCourseAccess(isInitialized ? accessId : undefined);
167
+ const updateProgress = useUpdateCourseProgress(accessId);
168
+ const lastSent = useRef(0);
169
+
170
+ const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
171
+ const [securePromptDismissed, setSecurePromptDismissed] = useState(false);
172
+
173
+ const denial = useMemo(() => (isError ? courseAccessDenial(error) : null), [isError, error]);
174
+
175
+ const gate = useMemo(
176
+ () => buildCourseAccessGate({ denial, isError, loginPath: opts.loginPath, currentPath: currentPath() }),
177
+ [denial, isError, opts.loginPath],
178
+ );
179
+
180
+ const modules = data?.course.modules ?? [];
181
+ const lessons: CourseLesson[] = modules.flatMap((m) => m.lessons ?? []);
182
+
183
+ // The server's resume point wins until the reader picks a lesson; deriving it
184
+ // rather than seeding state means it is not frozen at whatever the very first
185
+ // (undefined) render saw.
186
+ const selected =
187
+ lessons.find((l) => l.id === selectedLessonId) ??
188
+ lessons.find((l) => l.id === data?.access.currentLessonId) ??
189
+ lessons[0];
190
+
191
+ const securePrompt = useMemo(() => {
192
+ if (opts.showSecurePrompt === false || securePromptDismissed) return null;
193
+ return buildCourseAccessSecurePrompt({
194
+ data,
195
+ isAuthenticated,
196
+ signupPath: opts.signupPath,
197
+ currentPath: currentPath(),
198
+ });
199
+ }, [data, isAuthenticated, opts.showSecurePrompt, opts.signupPath, securePromptDismissed]);
200
+
201
+ /** Throttled to ~1/s — `timeupdate` fires several times a second. */
202
+ const reportProgress = (videoProgress: number, duration?: number) => {
203
+ if (!selected || !duration) return;
204
+ const now = Date.now();
205
+ if (now - lastSent.current < 1000) return;
206
+ lastSent.current = now;
207
+ const percent = (videoProgress / duration) * 100;
208
+ updateProgress.mutate({ lessonId: selected.id, videoProgress: percent, isCompleted: percent >= 95 });
209
+ };
210
+
211
+ /**
212
+ * The `wrong_account` exit. Clears the session and re-asks: a LEGACY grant
213
+ * opens immediately once anonymous, and a bound one falls through to the
214
+ * sign-in gate — either way the reader moves forward instead of in a circle.
215
+ */
216
+ const signOutAndRetry = async () => {
217
+ await logout();
218
+ await refetch();
219
+ };
220
+
221
+ return {
222
+ data,
223
+ // A disabled query is not "loading" to react-query, so the pre-session
224
+ // window has to be folded in by hand or the page flashes its error state.
225
+ isLoading: isLoading || !isInitialized,
226
+ /** Non-null when the player must not be drawn. */
227
+ gate,
228
+ denial,
229
+ securePrompt,
230
+ dismissSecurePrompt: () => setSecurePromptDismissed(true),
231
+ signOutAndRetry,
232
+ /** The email of the account currently signed in — names it in the wrong-account copy. */
233
+ signedInEmail: user?.email ?? null,
234
+ isAuthenticated,
235
+ modules,
236
+ lessons,
237
+ selected,
238
+ selectLesson: setSelectedLessonId,
239
+ completedCount: lessons.filter((l) => l.progress?.isCompleted).length,
240
+ reportProgress,
241
+ };
242
+ }
@@ -31,11 +31,22 @@ export {
31
31
  type CouponQuoteFn,
32
32
  } from "./coupon/useCouponField";
33
33
  export { useCourseCheckout, type UseCourseCheckoutOptions, type CourseCheckoutStep } from "./course/useCourseCheckout";
34
+ export {
35
+ useCourseClassroom,
36
+ buildCourseAccessGate,
37
+ buildCourseAccessSecurePrompt,
38
+ courseSignInHref,
39
+ type UseCourseClassroomOptions,
40
+ type CourseAccessGate,
41
+ type CourseAccessGateKind,
42
+ type CourseAccessSecurePrompt,
43
+ } from "./course/useCourseClassroom";
34
44
  export {
35
45
  useCoachingBooking,
36
46
  type UseCoachingBookingOptions,
37
47
  type CoachingBookingStep,
38
48
  } from "./coaching/useCoachingBooking";
49
+ export { useBookingSecret, type BookingSecretLookup } from "./booking/useBookingSecret";
39
50
  export {
40
51
  useMembershipCheckout,
41
52
  type UseMembershipCheckoutOptions,
package/src/ui/index.ts CHANGED
@@ -116,6 +116,7 @@ export {
116
116
  CheckSeal,
117
117
  WarnSeal,
118
118
  Perforation,
119
+ ConfirmationLostCheckout,
119
120
  alpha,
120
121
  } from "./styled/Confirmation";
121
122
  export { PlayButton, type PlayButtonProps } from "./styled/PlayButton";
@@ -5,11 +5,12 @@ import { ForgeAnalytics } from "../analytics/ForgeAnalytics";
5
5
  import { PwaRegistration } from "../styled/PwaRegistration";
6
6
  import { InstallBanner } from "../styled/InstallBanner";
7
7
  import { CookieConsent } from "../styled/CookieConsent";
8
+ import { AiAgentWidget } from "../styled/AiAgentWidget";
8
9
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
10
  import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
10
11
  import { PoweredBy } from "./PoweredBy";
11
12
  import { PreviewDiagnostics } from "./PreviewDiagnostics";
12
- import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
13
+ import { shellAiAgentEnabled, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
13
14
  import { previewDiagnosticsEnabled, resolvePreviewDiagnostics } from "./diagnosticsGating";
14
15
  import type { ForgeSsrDiagnostics } from "../../types/diagnostics";
15
16
  import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
@@ -52,6 +53,14 @@ export interface TribeNestAppProps extends Omit<ForgeProviderProps, "children">
52
53
  analytics?: boolean;
53
54
  pwa?: boolean;
54
55
  cookieConsent?: boolean;
56
+ /**
57
+ * The website AI agent bubble. Defaults to on for a fan site and off for a
58
+ * mini-app; set it explicitly to override either default. Renders nothing
59
+ * unless the creator has also enabled the agent for the profile — the widget
60
+ * fetches its own config and self-hides. For custom chat UI, leave this off
61
+ * and build on the `useAiAgent()` hook instead.
62
+ */
63
+ aiAgent?: boolean;
55
64
  }
56
65
 
57
66
  // Ensure the PWA `<link rel="manifest">` + a `theme-color` meta exist even if the
@@ -114,6 +123,7 @@ export function TribeNestApp({
114
123
  analytics = true,
115
124
  pwa = true,
116
125
  cookieConsent = true,
126
+ aiAgent,
117
127
  ...forgeProps
118
128
  }: TribeNestAppProps) {
119
129
  // Register the SW + offer install only on the live published site — never in
@@ -154,6 +164,11 @@ export function TribeNestApp({
154
164
  {/* Consent banner — shown on first visit, re-openable from anywhere via
155
165
  useCookieConsent().reopen(). Hidden in the editor. */}
156
166
  {cookieConsent && !editable && <CookieConsent useTribeNestPrivacy />}
167
+ {/* Website AI agent — floating bubble, self-hiding when the profile has no
168
+ agent enabled. Lives here rather than in each __root so enabling the
169
+ agent in admin is enough to make it appear; before this it shipped only
170
+ on the Craft stack and no code site ever rendered it. */}
171
+ {shellAiAgentEnabled({ aiAgent, editable, appId: forgeProps.appId }) && <AiAgentWidget />}
157
172
  </ForgeProvider>
158
173
  );
159
174
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { poweredByHref, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
2
+ import { poweredByHref, shellAiAgentEnabled, shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
3
3
 
4
4
  // The PWA (SW register + install prompt) must be ON only for the live published
5
5
  // site, and OFF everywhere else (editor, preview/draft), so review Workers never
@@ -47,6 +47,31 @@ describe("shellPoweredByEnabled", () => {
47
47
  });
48
48
  });
49
49
 
50
+ // The agent bubble defaults ON for fan sites and OFF for mini-apps, and is
51
+ // blocked in the editor because an answer there spends the creator's AI credits.
52
+ // The widget itself still renders nothing when the agent is disabled for the
53
+ // profile — this gate is about the SURFACE, not the entitlement.
54
+ describe("shellAiAgentEnabled", () => {
55
+ it("is on by default for a fan site, live or preview", () => {
56
+ expect(shellAiAgentEnabled({ editable: false })).toBe(true);
57
+ expect(shellAiAgentEnabled({})).toBe(true);
58
+ });
59
+
60
+ it("is off by default for a mini-app", () => {
61
+ expect(shellAiAgentEnabled({ editable: false, appId: "app-7" })).toBe(false);
62
+ });
63
+
64
+ it("is off in the editor even when explicitly asked for", () => {
65
+ expect(shellAiAgentEnabled({ aiAgent: true, editable: true })).toBe(false);
66
+ expect(shellAiAgentEnabled({ editable: true })).toBe(false);
67
+ });
68
+
69
+ it("lets an explicit prop override the per-surface default in both directions", () => {
70
+ expect(shellAiAgentEnabled({ aiAgent: false, editable: false })).toBe(false); // site opts out
71
+ expect(shellAiAgentEnabled({ aiAgent: true, editable: false, appId: "app-7" })).toBe(true); // app opts in
72
+ });
73
+ });
74
+
50
75
  // The badge is only worth carrying if we can tell WHICH site sent the visitor,
51
76
  // so the identifying params are the point of the link, not decoration.
52
77
  describe("poweredByHref", () => {
@@ -24,6 +24,22 @@ export function shellPoweredByEnabled(opts: {
24
24
  return !opts.hideBadge && !opts.editable && opts.state === "published";
25
25
  }
26
26
 
27
+ // The website AI agent bubble. Unlike the PWA it IS wanted on preview/draft
28
+ // deploys — that is where a creator checks the assistant before going live — so
29
+ // the only hard block is the HMR editor, where a live answer would spend the
30
+ // creator's AI credits every time they typed into their own site.
31
+ //
32
+ // The default differs by surface. A fan site gets it (the agent answers from the
33
+ // artist's content + knowledge base, which is what a visitor is there for); a
34
+ // mini-app does NOT, because an app is a utility and a floating "ask me about
35
+ // the artist" bubble is noise on top of it. `appId` is baked at build time, so
36
+ // this is decided without a runtime lookup. An explicit `aiAgent` prop wins
37
+ // either way — that is how an app opts in, or a site opts out.
38
+ export function shellAiAgentEnabled(opts: { aiAgent?: boolean; editable?: boolean; appId?: string }): boolean {
39
+ if (opts.editable) return false;
40
+ return opts.aiAgent ?? !opts.appId;
41
+ }
42
+
27
43
  /** Marketing site the badge points at. */
28
44
  const TRIBENEST_URL = "https://tribenest.co/";
29
45
 
@@ -29,7 +29,15 @@ export interface CoachingBookingProps {
29
29
  formatAmount?: (amount: number) => string;
30
30
  /** Optional payment renderer — falls back to a registered <ForgePaymentProvider>. */
31
31
  renderPayment?: (props: PaymentRenderProps) => ReactNode;
32
- /** Stripe return URL for a PAID booking. Default `/i/coaching/:slug/finalise`. */
32
+ /**
33
+ * Stripe return URL for a PAID booking. Default `/i/coaching/:slug/finalise`.
34
+ *
35
+ * Put ONLY the bookingId in this URL. The booking's secret is handled for you
36
+ * by the hook (stashed in `sessionStorage`, read back by
37
+ * `CoachingConfirmation`) — putting it in the URL would hand it to history,
38
+ * referrers and shared links, which is the exact leak it exists to close. See
39
+ * `utils/bookingSecret`.
40
+ */
33
41
  finalisePath?: (slug: string, bookingId: string) => string;
34
42
  /** Host-owned navigation on FREE completion (e.g. router navigate). */
35
43
  onComplete?: (info: { slug: string; bookingId: string }) => void;
@@ -3,8 +3,18 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
3
3
  import { readableTextOn } from "../theme/contrast";
4
4
  import { useAmountFormatter } from "../format/useFormatCurrency";
5
5
  import { Loading } from "./Loading";
6
- import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, Perforation, ConfirmationRow, alpha } from "./Confirmation";
6
+ import {
7
+ ConfirmationStage,
8
+ ConfirmationCard,
9
+ CheckSeal,
10
+ WarnSeal,
11
+ Perforation,
12
+ ConfirmationRow,
13
+ ConfirmationLostCheckout,
14
+ alpha,
15
+ } from "./Confirmation";
7
16
  import { useCoachingBookingFinalize } from "../../data/queries/useFinalize";
17
+ import { useBookingSecret } from "../headless/booking/useBookingSecret";
8
18
  import { AddToCalendar } from "./AddToCalendar";
9
19
 
10
20
  export interface CoachingConfirmationProps {
@@ -14,6 +24,11 @@ export interface CoachingConfirmationProps {
14
24
  formatAmount?: (n: number) => string;
15
25
  /** Where the "explore more" action links. */
16
26
  explorePath?: string;
27
+ /**
28
+ * Where "start over" goes when this browser has no credential for the booking
29
+ * — i.e. back to the session, to book again. Defaults to `/i/coaching/:slug`.
30
+ */
31
+ restartPath?: string;
17
32
  }
18
33
 
19
34
  // e.g. "Fri, Jul 4 · 2:30 PM EDT" in the visitor's own timezone — matching how
@@ -30,15 +45,25 @@ export function CoachingConfirmation({
30
45
  orderId,
31
46
  formatAmount,
32
47
  explorePath = "/i/coaching",
48
+ restartPath,
33
49
  }: CoachingConfirmationProps) {
34
50
  const theme = useForgeTheme();
35
51
  const fmt = useAmountFormatter(formatAmount);
36
- const { data, isLoading, error } = useCoachingBookingFinalize(slug, orderId);
52
+ // The credential the booking flow stashed before the payment redirect.
53
+ // `resolved` gates the finalise: storage can only be read on the client, so
54
+ // firing before then would send the call without a secret it actually has and
55
+ // get a 404.
56
+ const { secret, resolved, isMissing } = useBookingSecret(orderId);
57
+ const { data, isLoading, error } = useCoachingBookingFinalize(
58
+ slug,
59
+ resolved ? orderId : undefined,
60
+ secret ?? undefined,
61
+ );
37
62
 
38
63
  const primary = theme.colors.primary;
39
64
  const text = theme.colors.text;
40
65
 
41
- if (isLoading) {
66
+ if (!resolved || isLoading) {
42
67
  return (
43
68
  <ConfirmationStage>
44
69
  <Loading label="Confirming your booking…" />
@@ -46,6 +71,22 @@ export function CoachingConfirmation({
46
71
  );
47
72
  }
48
73
 
74
+ // A booking with no credential in THIS browser is indistinguishable
75
+ // server-side from a stranger holding a leaked id, so it 404s on purpose.
76
+ // Explain that rather than implying the booking never existed.
77
+ if ((error || !data) && isMissing) {
78
+ return (
79
+ <ConfirmationStage>
80
+ <ConfirmationLostCheckout
81
+ title="We can’t confirm your booking in this browser"
82
+ body="Confirming a booking needs the checkout details saved in the tab you started in — and they aren’t here. That usually means the tab was closed, this link was opened on a different device or browser, or private browsing cleared them. If your payment went through you’ll still get your confirmation email, so check your inbox before booking again. Otherwise, pick a new time and start over."
83
+ restartPath={restartPath ?? `/i/coaching/${slug}`}
84
+ restartLabel="Pick a new time"
85
+ />
86
+ </ConfirmationStage>
87
+ );
88
+ }
89
+
49
90
  const confirmed = !error && data?.status === "confirmed";
50
91
 
51
92
  return (
@@ -1,5 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
+ import { AlertTriangle, ArrowRight } from "lucide-react";
2
3
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
4
+ import { readableTextOn } from "../theme/contrast";
3
5
 
4
6
  // Shared building blocks for post-purchase "ticket" confirmation pages
5
7
  // (coaching / event / course / checkout / invoice / payment-link). All colors
@@ -149,6 +151,98 @@ export function ConfirmationRow({
149
151
  );
150
152
  }
151
153
 
154
+ /**
155
+ * The "this browser can't confirm that purchase" screen.
156
+ *
157
+ * A checkout is now credentialed by a per-booking secret that is issued once
158
+ * and kept in `sessionStorage` (see `utils/bookingSecret`). If the buyer lands
159
+ * on a confirmation URL WITHOUT it — the original tab was closed, the link was
160
+ * forwarded or reopened on another device, storage is blocked — the server has
161
+ * no way to tell them apart from a stranger holding a leaked id, and answers
162
+ * 404 by design.
163
+ *
164
+ * That is a legitimate state, not a bug, so it gets its own screen rather than
165
+ * a bare "not found": say plainly what happened, reassure about the money (a
166
+ * completed payment is still completed — the receipt email is the proof, and
167
+ * this page failing does not undo it), and offer the one action that actually
168
+ * works, which is to start again.
169
+ */
170
+ export function ConfirmationLostCheckout({
171
+ title,
172
+ body,
173
+ restartPath,
174
+ restartLabel,
175
+ }: {
176
+ title: string;
177
+ body: string;
178
+ restartPath: string;
179
+ restartLabel: string;
180
+ }) {
181
+ const t = useForgeTheme();
182
+ const primary = t.colors.primary;
183
+ const text = t.colors.text;
184
+ return (
185
+ <ConfirmationCard width={460}>
186
+ <div style={{ padding: "40px 32px 12px", textAlign: "center" }}>
187
+ <WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
188
+ <p
189
+ className="cf-rise"
190
+ style={{
191
+ margin: "22px 0 6px",
192
+ fontSize: 12,
193
+ fontWeight: 700,
194
+ letterSpacing: "0.22em",
195
+ textTransform: "uppercase",
196
+ color: "#f59e0b",
197
+ animationDelay: "0.15s",
198
+ }}
199
+ >
200
+ Can’t confirm here
201
+ </p>
202
+ <h1
203
+ className="cf-rise"
204
+ style={{ fontSize: 27, fontWeight: 800, lineHeight: 1.14, margin: "0 0 10px", animationDelay: "0.22s" }}
205
+ >
206
+ {title}
207
+ </h1>
208
+ <p
209
+ className="cf-rise"
210
+ style={{
211
+ fontSize: 15,
212
+ lineHeight: 1.55,
213
+ color: alpha(text, 0.7),
214
+ margin: "0 auto",
215
+ maxWidth: 360,
216
+ animationDelay: "0.3s",
217
+ }}
218
+ >
219
+ {body}
220
+ </p>
221
+ </div>
222
+ <div className="cf-rise" style={{ padding: "22px 32px 32px", animationDelay: "0.38s" }}>
223
+ <a
224
+ href={restartPath}
225
+ style={{
226
+ display: "inline-flex",
227
+ alignItems: "center",
228
+ justifyContent: "center",
229
+ gap: 8,
230
+ width: "100%",
231
+ padding: "13px 18px",
232
+ borderRadius: t.cornerRadius,
233
+ background: primary,
234
+ color: readableTextOn(primary),
235
+ fontWeight: 700,
236
+ textDecoration: "none",
237
+ }}
238
+ >
239
+ {restartLabel} <ArrowRight size={16} />
240
+ </a>
241
+ </div>
242
+ </ConfirmationCard>
243
+ );
244
+ }
245
+
152
246
  const KEYFRAMES = `
153
247
  @keyframes cf-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
154
248
  @keyframes cf-pop { 0% { transform: scale(.5); opacity: 0; } 55% { transform: scale(1.08); } 100% { transform: scale(1); opacity: 1; } }