@tribe-nest/forge 2.2.0 → 3.4.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.
Files changed (54) hide show
  1. package/package.json +1 -1
  2. package/src/client/createForgeClient.ts +85 -2
  3. package/src/client/tokenStorage.ts +31 -0
  4. package/src/contexts/AppAuthContext.tsx +100 -15
  5. package/src/contexts/PublicAuthContext.tsx +46 -9
  6. package/src/data/queries/useCheckouts.ts +84 -1
  7. package/src/data/queries/useCoachingAvailability.ts +18 -3
  8. package/src/data/queries/useCourseAccess.ts +1 -1
  9. package/src/data/queries/useCourses.ts +42 -1
  10. package/src/data/queries/useEvents.ts +15 -1
  11. package/src/data/queries/usePaymentFlow.ts +12 -0
  12. package/src/data/queries/useWebsite.ts +6 -0
  13. package/src/index.ts +19 -2
  14. package/src/provider/ForgeProvider.tsx +30 -1
  15. package/src/server/_tests/platformEvents.spec.ts +315 -0
  16. package/src/server/index.ts +17 -0
  17. package/src/server/jobs.ts +41 -10
  18. package/src/server/platform.ts +234 -9
  19. package/src/server/platformEvents.generated.ts +422 -0
  20. package/src/types/models.ts +110 -3
  21. package/src/ui/headless/auth/useSignupForm.ts +69 -4
  22. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  23. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  24. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  25. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  26. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  27. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  28. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  29. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  30. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  31. package/src/ui/headless/index.ts +15 -0
  32. package/src/ui/headless/work/useWorkPortal.ts +24 -21
  33. package/src/ui/index.ts +7 -0
  34. package/src/ui/shell/PoweredBy.tsx +60 -0
  35. package/src/ui/shell/TribeNestApp.tsx +15 -1
  36. package/src/ui/shell/shellGating.spec.ts +21 -1
  37. package/src/ui/shell/shellGating.ts +14 -0
  38. package/src/ui/styled/AddToCalendar.tsx +104 -0
  39. package/src/ui/styled/Checkout.tsx +45 -14
  40. package/src/ui/styled/CoachingBooking.tsx +28 -8
  41. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  42. package/src/ui/styled/CourseCheckout.tsx +49 -18
  43. package/src/ui/styled/DiscountCode.tsx +206 -0
  44. package/src/ui/styled/EventConfirmation.tsx +68 -22
  45. package/src/ui/styled/EventDetail.tsx +18 -5
  46. package/src/ui/styled/EventTickets.tsx +49 -5
  47. package/src/ui/styled/SignupForm.tsx +86 -35
  48. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  49. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  50. package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
  51. package/src/utils/_tests/safeRedirect.spec.ts +117 -0
  52. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  53. package/src/utils/safeRedirect.ts +41 -0
  54. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -0,0 +1,422 @@
1
+ // GENERATED FILE — DO NOT EDIT.
2
+ // Produced from the platform event catalog by:
3
+ // cd apps/backend && npm run generate:forge-events
4
+ //
5
+ // The catalog (apps/backend/src/services/admin/app/eventCatalog.ts) is the single
6
+ // definition of what an app can receive and what shape it arrives in. Editing
7
+ // this file by hand creates a second one, and the copy that drifts is the one
8
+ // nobody is testing.
9
+
10
+ /**
11
+ * Every platform event an app can subscribe to, and the exact body it carries.
12
+ *
13
+ * Delivery is at-least-once — dedupe on `eventId`. The same event WILL arrive
14
+ * again after a retry, and a handler that acts twice will double-send.
15
+ *
16
+ * A body may additionally carry `partial: true`. That means the platform could
17
+ * not build the full payload (the record was missing, or the enriched body did
18
+ * not match its own schema) and sent the identifying ids instead. Re-read what
19
+ * you need through the platform client rather than trusting the missing fields.
20
+ */
21
+ export type PlatformEventMap = {
22
+ /**
23
+ * Affiliate applied — Someone applied to the affiliate program.
24
+ *
25
+ * Requires the `affiliates.read` grant.
26
+ */
27
+ "affiliate.applied": {
28
+ affiliateId: string;
29
+ };
30
+ /**
31
+ * Checkout abandoned — Someone started a checkout and did not finish it.
32
+ *
33
+ * Requires the `products.read` grant.
34
+ */
35
+ "checkout.abandoned": {
36
+ checkoutId: string;
37
+ source: string;
38
+ sourceRecordId?: string | null;
39
+ contactId?: string | null;
40
+ email?: string | null;
41
+ totalCents?: number | null;
42
+ currency?: string | null;
43
+ };
44
+ /**
45
+ * Checkout recovered — An abandoned checkout was completed after all.
46
+ *
47
+ * Requires the `products.read` grant.
48
+ */
49
+ "checkout.recovered": {
50
+ checkoutId: string;
51
+ source: string | null;
52
+ sourceRecordId: string | null;
53
+ contactId: string | null;
54
+ email: string | null;
55
+ totalCents: number | null;
56
+ currency: string | null;
57
+ };
58
+ /**
59
+ * Collection entry submitted — A visitor submitted an entry to a collection.
60
+ *
61
+ * Requires the `collections.read` grant.
62
+ */
63
+ "collection.submission": {
64
+ entryId: string;
65
+ collectionId: string;
66
+ collectionName?: string | null;
67
+ status?: string | null;
68
+ };
69
+ /**
70
+ * Community post created — A member posted in a community space.
71
+ *
72
+ * Requires the `community.new_post` grant.
73
+ */
74
+ "community_post.created": {
75
+ postId: string;
76
+ spaceId: string;
77
+ authorAccountId?: string | null;
78
+ };
79
+ /**
80
+ * Community reply created — A member replied to a community post.
81
+ *
82
+ * Requires the `community.new_comment` grant.
83
+ */
84
+ "community_reply.created": {
85
+ replyId: string;
86
+ postId: string;
87
+ spaceId?: string | null;
88
+ authorAccountId?: string | null;
89
+ parentReplyId?: string | null;
90
+ };
91
+ /**
92
+ * Contact form message — Someone sent a message through a website contact form.
93
+ *
94
+ * Requires the `contacts.read` grant.
95
+ */
96
+ "contact_message.created": {
97
+ senderName?: string | null;
98
+ senderEmail?: string | null;
99
+ message?: string | null;
100
+ };
101
+ /**
102
+ * Form submitted — A form was submitted. Carries the submission id — read the answers through the platform client.
103
+ *
104
+ * Requires the `forms.read` grant.
105
+ */
106
+ "contact.form_submitted": {
107
+ formId: string;
108
+ formTitle: string | null;
109
+ submissionId: string | null;
110
+ contactId: string | null;
111
+ };
112
+ /**
113
+ * Quiz completed — A contact completed a graded form, with their score and outcome.
114
+ *
115
+ * Requires the `contacts.read` grant.
116
+ */
117
+ "contact.quiz_completed": {
118
+ contactId: string | null;
119
+ formId: string;
120
+ submissionId?: string | null;
121
+ quizResultId?: string | null;
122
+ percent?: number | null;
123
+ passed?: boolean | null;
124
+ outcomeKey?: string | null;
125
+ };
126
+ /**
127
+ * Tag added to contact — A tag was applied to a contact.
128
+ *
129
+ * Requires the `contacts.read` grant.
130
+ */
131
+ "contact.tag_added": {
132
+ contactId: string;
133
+ tagId: string;
134
+ };
135
+ /**
136
+ * Course completed — A learner finished a course.
137
+ *
138
+ * Requires the `courses.read` grant.
139
+ */
140
+ "course.completed": {
141
+ courseId: string;
142
+ courseTitle: string | null;
143
+ courseAccessId: string | null;
144
+ contactId: string | null;
145
+ completedAt: string | null;
146
+ };
147
+ /**
148
+ * Lesson progressed — A learner completed a lesson.
149
+ *
150
+ * Requires the `courses.read` grant.
151
+ */
152
+ "course.lesson_progressed": {
153
+ courseId: string;
154
+ courseAccessId?: string | null;
155
+ lessonId?: string | null;
156
+ contactId?: string | null;
157
+ cohortId?: string | null;
158
+ };
159
+ /**
160
+ * Payment disputed — A customer disputed a payment (chargeback).
161
+ *
162
+ * Requires the `income.read` grant.
163
+ */
164
+ "dispute.created": {
165
+ disputeId: string;
166
+ amountCents?: number | null;
167
+ reason?: string | null;
168
+ };
169
+ /**
170
+ * Document signed or decided — A proposal or contract was signed, accepted or declined.
171
+ *
172
+ * Requires the `deals.read` grant.
173
+ */
174
+ "document.completed": {
175
+ documentId: string;
176
+ event: string;
177
+ title?: string | null;
178
+ kind?: string | null;
179
+ };
180
+ /**
181
+ * Email link clicked — A recipient clicked a link in a marketing email.
182
+ *
183
+ * Requires the `emails.read` grant.
184
+ */
185
+ "email.clicked": {
186
+ emailId: string;
187
+ contactId?: string | null;
188
+ linkUrl?: string | null;
189
+ occurredAt?: string | null;
190
+ };
191
+ /**
192
+ * Email opened — A recipient opened a marketing email.
193
+ *
194
+ * Requires the `emails.read` grant.
195
+ */
196
+ "email.opened": {
197
+ emailId: string;
198
+ contactId?: string | null;
199
+ occurredAt?: string | null;
200
+ };
201
+ /**
202
+ * Event tickets bought — Someone paid for tickets to an event.
203
+ *
204
+ * Requires the `events.read` grant.
205
+ */
206
+ "event_ticket_order.paid": {
207
+ orderId: string;
208
+ eventId: string | null;
209
+ status: string | null;
210
+ total: number | null;
211
+ currency: string | null;
212
+ buyer: {
213
+ contactId: string | null;
214
+ email: string | null;
215
+ name: string | null;
216
+ };
217
+ tickets: Array<{
218
+ ticketId: string | null;
219
+ quantity: number;
220
+ unitPrice: number | null;
221
+ }>;
222
+ };
223
+ /**
224
+ * Membership tier archived — A membership tier was archived and can no longer be joined.
225
+ *
226
+ * Requires the `membership.tier_archived` grant.
227
+ */
228
+ "membership_tier.archived": {
229
+ membershipTierId: string;
230
+ name: string | null;
231
+ archived: boolean;
232
+ };
233
+ /**
234
+ * Membership tier created — A new membership tier was published.
235
+ *
236
+ * Requires the `membership.tier_created` grant.
237
+ */
238
+ "membership_tier.created": {
239
+ membershipTierId: string;
240
+ name: string | null;
241
+ priceMonthly: number | null;
242
+ priceYearly: number | null;
243
+ isDefault: boolean;
244
+ archived: boolean;
245
+ };
246
+ /**
247
+ * Inbound message — An SMS or WhatsApp message arrived from a contact.
248
+ *
249
+ * Requires the `contacts.read` grant.
250
+ */
251
+ "message.received": {
252
+ channel: string;
253
+ inboundId: string;
254
+ contactId?: string | null;
255
+ fromPhone?: string | null;
256
+ body?: string | null;
257
+ occurredAt?: string | null;
258
+ };
259
+ /**
260
+ * Content reported — A member reported community content for moderation.
261
+ *
262
+ * Requires the `community.content_reported` grant.
263
+ */
264
+ "moderation.report_created": {
265
+ reportId: string;
266
+ entityId?: string | null;
267
+ entityType?: string | null;
268
+ };
269
+ /**
270
+ * Order paid — A store order was paid for. Carries the order, its line items and a buyer reference.
271
+ *
272
+ * Requires the `products.read` grant.
273
+ */
274
+ "order.paid": {
275
+ orderId: string;
276
+ status: string | null;
277
+ total: number | null;
278
+ currency: string | null;
279
+ placedAt: string | null;
280
+ buyer: {
281
+ contactId: string | null;
282
+ email: string | null;
283
+ name: string | null;
284
+ };
285
+ items: Array<{
286
+ productId: string | null;
287
+ variantId: string | null;
288
+ title: string | null;
289
+ quantity: number;
290
+ unitPrice: number | null;
291
+ }>;
292
+ };
293
+ /**
294
+ * Payout settled — A payout to the creator's bank account settled.
295
+ *
296
+ * Requires the `income.read` grant.
297
+ */
298
+ "payout.settled": {
299
+ payoutId: string | null;
300
+ status: string;
301
+ amountCents: number;
302
+ currency: string;
303
+ };
304
+ /**
305
+ * Member post published — A post was published to the member feed, with the tiers that can see it.
306
+ *
307
+ * Requires the `membership.new_post` grant.
308
+ */
309
+ "post.created": {
310
+ postId: string;
311
+ membershipTiers?: Array<unknown> | null;
312
+ };
313
+ /**
314
+ * Refund failed — A refund could not be completed.
315
+ *
316
+ * Requires the `income.read` grant.
317
+ */
318
+ "refund.failed": {
319
+ refundId: string;
320
+ amountCents: number;
321
+ currency?: string | null;
322
+ reason?: string | null;
323
+ sourceType?: string | null;
324
+ sourceId?: string | null;
325
+ };
326
+ /**
327
+ * Refund succeeded — A refund completed.
328
+ *
329
+ * Requires the `income.read` grant.
330
+ */
331
+ "refund.succeeded": {
332
+ refundId: string;
333
+ sourceType?: string | null;
334
+ sourceId?: string | null;
335
+ amountCents: number;
336
+ capturedCents?: number | null;
337
+ };
338
+ /**
339
+ * Review submitted — A customer left a review, with its rating and moderation status.
340
+ *
341
+ * Requires the `reviews.read` grant.
342
+ */
343
+ "review_submitted": {
344
+ reviewId: string;
345
+ entityType: string;
346
+ entityId: string;
347
+ contactId?: string | null;
348
+ rating?: number | null;
349
+ status?: string | null;
350
+ isEdit?: boolean | null;
351
+ };
352
+ /**
353
+ * Review published — A review passed moderation and is now public.
354
+ *
355
+ * Requires the `reviews.read` grant.
356
+ */
357
+ "review.published": {
358
+ reviewId: string;
359
+ };
360
+ /**
361
+ * Shipment status changed — A shipment for an order moved — dispatched, in transit, delivered.
362
+ *
363
+ * Requires the `products.read` grant.
364
+ */
365
+ "shipping.status_updated": {
366
+ orderId: string;
367
+ deliveryGroupId?: string | null;
368
+ shippingLabelId?: string | null;
369
+ status?: string | null;
370
+ trackingNumber?: string | null;
371
+ carrier?: string | null;
372
+ };
373
+ /**
374
+ * Website published — A website was published.
375
+ *
376
+ * Requires the `websites.read` grant.
377
+ */
378
+ "website.published": Record<string, unknown>;
379
+ };
380
+
381
+ /** The name of any event an app can receive. */
382
+ export type PlatformEventName = keyof PlatformEventMap;
383
+
384
+ /** The body carried by one named event. */
385
+ export type PlatformEventData<E extends PlatformEventName> = PlatformEventMap[E] & { partial?: boolean };
386
+
387
+ /** Which grant unlocks each event, for error messages and docs. */
388
+ export const PLATFORM_EVENT_PERMISSIONS: Record<PlatformEventName, string> = {
389
+ "affiliate.applied": "affiliates.read",
390
+ "checkout.abandoned": "products.read",
391
+ "checkout.recovered": "products.read",
392
+ "collection.submission": "collections.read",
393
+ "community_post.created": "community.new_post",
394
+ "community_reply.created": "community.new_comment",
395
+ "contact_message.created": "contacts.read",
396
+ "contact.form_submitted": "forms.read",
397
+ "contact.quiz_completed": "contacts.read",
398
+ "contact.tag_added": "contacts.read",
399
+ "course.completed": "courses.read",
400
+ "course.lesson_progressed": "courses.read",
401
+ "dispute.created": "income.read",
402
+ "document.completed": "deals.read",
403
+ "email.clicked": "emails.read",
404
+ "email.opened": "emails.read",
405
+ "event_ticket_order.paid": "events.read",
406
+ "membership_tier.archived": "membership.tier_archived",
407
+ "membership_tier.created": "membership.tier_created",
408
+ "message.received": "contacts.read",
409
+ "moderation.report_created": "community.content_reported",
410
+ "order.paid": "products.read",
411
+ "payout.settled": "income.read",
412
+ "post.created": "membership.new_post",
413
+ "refund.failed": "income.read",
414
+ "refund.succeeded": "income.read",
415
+ "review_submitted": "reviews.read",
416
+ "review.published": "reviews.read",
417
+ "shipping.status_updated": "products.read",
418
+ "website.published": "websites.read",
419
+ };
420
+
421
+ /** Every event name, in one array — handy for building a UI or validating input. */
422
+ export const PLATFORM_EVENT_NAMES = Object.keys(PLATFORM_EVENT_PERMISSIONS) as PlatformEventName[];
@@ -88,6 +88,44 @@ export type PaymentFlowResult = PaymentStartResponse & {
88
88
  provider?: PaymentProviderName;
89
89
  };
90
90
 
91
+ /**
92
+ * One coupon that ACTUALLY applied to a checkout, exactly as the ticket,
93
+ * booking and course pillars report it. Entered codes and automatic (no-code)
94
+ * discounts are reported identically — which is the only way a buyer ever finds
95
+ * out an automatic discount happened, since nothing else names it.
96
+ *
97
+ * `discountKind` is the engine's kind (`simple`, `free_shipping`, `bogo`,
98
+ * `volume`, `membership_grant`), kept as a string so a new kind added on the
99
+ * server does not fail to type-check here.
100
+ */
101
+ export type AppliedDiscountCoupon = {
102
+ code: string;
103
+ discountKind: string;
104
+ /** MAJOR units — this coupon's own contribution, not the running total. */
105
+ discountAmount: number;
106
+ };
107
+
108
+ /**
109
+ * What a pillar's pre-payment call reports about pricing after discounts.
110
+ * `POST /public/events/:id/orders`, `POST /public/coaching/products/:id/booking/update`
111
+ * and `POST /public/courses/:id/booking/update` all return this same shape
112
+ * (S.3, `89a721af`).
113
+ *
114
+ * Every figure here is the SERVER'S, in major units. Nothing in the UI
115
+ * recomputes a discount: a buyer-visible number that disagrees with the charge
116
+ * is worse than no number at all.
117
+ */
118
+ export type PillarDiscountQuote = {
119
+ /** GROSS, before any discount. */
120
+ subTotal: number;
121
+ /** NET — what the buyer is being asked to pay. */
122
+ totalAmount: number;
123
+ /** The whole discount, summed across `appliedCoupons`. */
124
+ discountAmount: number;
125
+ couponId: string | null;
126
+ appliedCoupons: AppliedDiscountCoupon[];
127
+ };
128
+
91
129
  export enum ProductDeliveryType {
92
130
  Digital = "digital",
93
131
  Physical = "physical",
@@ -265,9 +303,40 @@ export interface PublicAuthState {
265
303
  } | null;
266
304
  }
267
305
 
306
+ /**
307
+ * What `register` returns: the platform emailed a 6-digit code and issued NO
308
+ * session. Collect the code and call `verifyRegistration` — there is no variant
309
+ * of signup that logs a fan straight in.
310
+ */
311
+ export type PublicAuthChallenge = { status: "verify_email"; email: string };
312
+
268
313
  export interface PublicAuthContextType extends PublicAuthState {
314
+ /**
315
+ * @deprecated Broken — do not use. Fan login is a two-step code flow, so
316
+ * `POST /public/sessions` returns `{ message, email }` and never a token; this
317
+ * method still expects one and will store `undefined`. Use
318
+ * `validateLoginCredentials` (emails the code) then `verifyLoginCode`.
319
+ */
269
320
  login: (data: LoginInput & { profileId: string }) => Promise<{ smartLinkPath: string | undefined }>;
270
- register: (data: PublicCreateAccountInput) => Promise<{ smartLinkPath: string | undefined }>;
321
+ /**
322
+ * Step 1 of 2. Creates the account and emails a code; issues no session.
323
+ * Returns the same challenge whether or not the address is already known to the
324
+ * platform, so the response can't be used to discover registered emails.
325
+ */
326
+ register: (data: PublicCreateAccountInput) => Promise<PublicAuthChallenge>;
327
+ /**
328
+ * Step 2 of 2. Redeems the emailed code, signs the fan in, and applies what the
329
+ * signup earned (member identity, a membership from `couponCode`, consent) —
330
+ * which is why the coupon/consent flags are repeated here.
331
+ */
332
+ verifyRegistration: (data: {
333
+ email: string;
334
+ code: string;
335
+ profileId?: string;
336
+ couponCode?: string;
337
+ acceptedTerms?: boolean;
338
+ acceptedPrivacy?: boolean;
339
+ }) => Promise<{ smartLinkPath: string | undefined }>;
271
340
  validateLoginCredentials: (data: LoginInput & { profileId: string }) => Promise<string>;
272
341
  verifyLoginCode: (email: string, code: string, profileId: string) => Promise<{ smartLinkPath: string | undefined }>;
273
342
  resendLoginCode: (email: string) => Promise<void>;
@@ -385,8 +454,18 @@ export type CollectionSearchResult = PaginatedData<CollectionEntry> & {
385
454
 
386
455
  // A leaf { field, op, value } or an and/or group — the structured filter tree.
387
456
  export type CollectionFilterOp =
388
- | "eq" | "ne" | "gt" | "gte" | "lt" | "lte"
389
- | "in" | "between" | "contains" | "startsWith" | "arrayContains" | "isNull";
457
+ | "eq"
458
+ | "ne"
459
+ | "gt"
460
+ | "gte"
461
+ | "lt"
462
+ | "lte"
463
+ | "in"
464
+ | "between"
465
+ | "contains"
466
+ | "startsWith"
467
+ | "arrayContains"
468
+ | "isNull";
390
469
  export type CollectionFilterNode =
391
470
  | { and: CollectionFilterNode[] }
392
471
  | { or: CollectionFilterNode[] }
@@ -772,6 +851,23 @@ export interface IEvent {
772
851
  id: string;
773
852
  profileId: string;
774
853
  dateTime: string;
854
+ /**
855
+ * When the event ends. Nullable — plenty of events are published with only a
856
+ * start. Calendar links fall back to a default duration when it is absent.
857
+ */
858
+ endDateTime: string | null;
859
+ /** Doors/arrival time, when the host publishes one separately from the start. */
860
+ doorsOpenAt: string | null;
861
+ /**
862
+ * Event lifecycle (mirrors the backend `EVENT_STATUS` const — Forge cannot
863
+ * import from the API, so the union is restated here).
864
+ * `draft` never reaches a public surface.
865
+ */
866
+ status: "draft" | "scheduled" | "on_sale" | "sold_out" | "cancelled" | "completed";
867
+ /** Set when the host cancels; distinct from `archivedAt` (a hide-from-lists flag). */
868
+ cancelledAt: string | null;
869
+ /** Host-supplied reason shown to ticket-holders on a cancelled event. */
870
+ cancellationReason: string | null;
775
871
  timezone?: string;
776
872
  type: "physical" | "virtual" | "hybrid";
777
873
  ticketSaleMessage?: string;
@@ -1052,6 +1148,17 @@ export type ITicketOrder = {
1052
1148
  taxInclusive?: boolean;
1053
1149
  currency?: string;
1054
1150
  status: OrderStatus;
1151
+ /**
1152
+ * Refund tracking, written by the refund service and returned by the public
1153
+ * finalize endpoint (`getOrderById` is `selectAll` over the order row).
1154
+ *
1155
+ * These are what tell a refund-voided `cancelled` order apart from an
1156
+ * abandoned one — `status` alone cannot. See `getTicketOrderOutcome`.
1157
+ * `refundedAmountCents` is a bigint column, so it arrives as a string.
1158
+ */
1159
+ refundedAmountCents?: number | string | null;
1160
+ refundState?: "none" | "partial" | "full" | string | null;
1161
+ lastRefundedAt?: string | null;
1055
1162
  customerName: string;
1056
1163
  customerEmail: string;
1057
1164
  createdAt: string;
@@ -13,10 +13,17 @@ export interface UseSignupFormOptions {
13
13
  requireAcceptance?: boolean;
14
14
  }
15
15
 
16
- /** Headless account signup. Bring your own UI (or use the styled <SignupForm>). */
16
+ /**
17
+ * Headless account signup. Bring your own UI (or use the styled <SignupForm>).
18
+ *
19
+ * TWO STEPS: `submit()` creates the account and the platform emails a 6-digit
20
+ * code — no session yet. `awaitingCode` then flips true; collect the code into
21
+ * `code` and call `submitCode()`, which signs the fan in and fires `onSuccess`.
22
+ * Render the code input whenever `awaitingCode` is true.
23
+ */
17
24
  export function useSignupForm(opts: UseSignupFormOptions = {}) {
18
25
  const profileId = useForge().profileId ?? "";
19
- const { register } = usePublicAuth();
26
+ const { register, verifyRegistration } = usePublicAuth();
20
27
 
21
28
  const [firstName, setFirstName] = useState("");
22
29
  const [lastName, setLastName] = useState("");
@@ -25,6 +32,9 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
25
32
  const [acceptedTerms, setAcceptedTerms] = useState(false);
26
33
  const [error, setError] = useState<string | null>(null);
27
34
  const [isSubmitting, setIsSubmitting] = useState(false);
35
+ // Step 2: set once the code has been emailed.
36
+ const [awaitingCode, setAwaitingCode] = useState(false);
37
+ const [code, setCode] = useState("");
28
38
 
29
39
  const requireAcceptance = opts.requireAcceptance ?? true;
30
40
 
@@ -48,7 +58,7 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
48
58
  }
49
59
  setIsSubmitting(true);
50
60
  try {
51
- const { smartLinkPath } = await register({
61
+ await register({
52
62
  email: email.trim(),
53
63
  password,
54
64
  firstName: firstName.trim(),
@@ -59,7 +69,8 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
59
69
  acceptedTerms: true,
60
70
  acceptedPrivacy: true,
61
71
  });
62
- opts.onSuccess?.(smartLinkPath);
72
+ // No session yet — the emailed code is the next step.
73
+ setAwaitingCode(true);
63
74
  } catch (e) {
64
75
  setError(msg(e) || "Could not create your account.");
65
76
  } finally {
@@ -67,6 +78,55 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
67
78
  }
68
79
  };
69
80
 
81
+ /** Step 2: redeem the emailed code. Signs the fan in and fires `onSuccess`. */
82
+ const submitCode = async () => {
83
+ setError(null);
84
+ if (!/^\d{6}$/.test(code.trim())) {
85
+ setError("Enter the 6-digit code we emailed you.");
86
+ return;
87
+ }
88
+ setIsSubmitting(true);
89
+ try {
90
+ const { smartLinkPath } = await verifyRegistration({
91
+ email: email.trim(),
92
+ code: code.trim(),
93
+ profileId,
94
+ // Repeated here because this is the request that actually applies them.
95
+ couponCode: opts.couponCode,
96
+ acceptedTerms: true,
97
+ acceptedPrivacy: true,
98
+ });
99
+ opts.onSuccess?.(smartLinkPath);
100
+ } catch (e) {
101
+ setError(msg(e) || "That code did not work.");
102
+ } finally {
103
+ setIsSubmitting(false);
104
+ }
105
+ };
106
+
107
+ /** Re-send a code (throttled to one per 60s server-side). */
108
+ const resendCode = async () => {
109
+ setError(null);
110
+ setIsSubmitting(true);
111
+ try {
112
+ await register({
113
+ email: email.trim(),
114
+ password,
115
+ firstName: firstName.trim(),
116
+ lastName: lastName.trim(),
117
+ profileId,
118
+ membershipTierId: opts.membershipTierId,
119
+ couponCode: opts.couponCode,
120
+ acceptedTerms: true,
121
+ acceptedPrivacy: true,
122
+ });
123
+ } catch (e) {
124
+ setError(msg(e) || "Could not resend the code.");
125
+ } finally {
126
+ setIsSubmitting(false);
127
+ }
128
+ };
129
+
70
130
  return {
71
131
  firstName,
72
132
  setFirstName,
@@ -81,5 +141,10 @@ export function useSignupForm(opts: UseSignupFormOptions = {}) {
81
141
  error,
82
142
  isSubmitting,
83
143
  submit,
144
+ awaitingCode,
145
+ code,
146
+ setCode,
147
+ submitCode,
148
+ resendCode,
84
149
  };
85
150
  }