@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +13 -0
- package/src/data/queries/useCourseAccess.ts +81 -0
- package/src/data/queries/useCourses.ts +19 -1
- package/src/data/queries/useFinalize.ts +24 -4
- package/src/index.ts +4 -0
- package/src/types/models.ts +7 -0
- package/src/ui/headless/booking/useBookingSecret.ts +41 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +26 -3
- package/src/ui/headless/course/_tests/courseAccessGate.spec.ts +135 -0
- package/src/ui/headless/course/useCourseCheckout.ts +18 -1
- package/src/ui/headless/course/useCourseClassroom.ts +242 -0
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/shell/TribeNestApp.tsx +16 -1
- package/src/ui/shell/shellGating.spec.ts +26 -1
- package/src/ui/shell/shellGating.ts +16 -0
- package/src/ui/styled/CoachingBooking.tsx +9 -1
- package/src/ui/styled/CoachingConfirmation.tsx +44 -3
- package/src/ui/styled/Confirmation.tsx +94 -0
- package/src/ui/styled/CourseAccess.tsx +191 -51
- package/src/ui/styled/CourseCheckout.tsx +9 -1
- package/src/ui/styled/CourseConfirmation.tsx +42 -3
- package/src/ui/styled/InvoicePayment.tsx +65 -11
- package/src/utils/_tests/bookingSecret.spec.ts +115 -0
- package/src/utils/bookingSecret.ts +100 -0
|
@@ -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
|
+
}
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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; } }
|