@tribe-nest/forge 3.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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCheckouts.ts +84 -1
  3. package/src/data/queries/useCoachingAvailability.ts +18 -3
  4. package/src/data/queries/useCourses.ts +42 -1
  5. package/src/data/queries/useEvents.ts +15 -1
  6. package/src/data/queries/usePaymentFlow.ts +12 -0
  7. package/src/data/queries/useWebsite.ts +6 -0
  8. package/src/index.ts +9 -0
  9. package/src/types/models.ts +66 -0
  10. package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
  11. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
  12. package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
  13. package/src/ui/headless/checkout/useCheckout.ts +156 -8
  14. package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
  15. package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
  16. package/src/ui/headless/coupon/useCouponField.ts +164 -0
  17. package/src/ui/headless/course/useCourseCheckout.ts +113 -18
  18. package/src/ui/headless/event/useEventCheckout.ts +53 -2
  19. package/src/ui/headless/index.ts +15 -0
  20. package/src/ui/index.ts +7 -0
  21. package/src/ui/shell/PoweredBy.tsx +60 -0
  22. package/src/ui/shell/TribeNestApp.tsx +15 -1
  23. package/src/ui/shell/shellGating.spec.ts +21 -1
  24. package/src/ui/shell/shellGating.ts +14 -0
  25. package/src/ui/styled/AddToCalendar.tsx +104 -0
  26. package/src/ui/styled/Checkout.tsx +45 -14
  27. package/src/ui/styled/CoachingBooking.tsx +28 -8
  28. package/src/ui/styled/CoachingConfirmation.tsx +12 -0
  29. package/src/ui/styled/CourseCheckout.tsx +49 -18
  30. package/src/ui/styled/DiscountCode.tsx +206 -0
  31. package/src/ui/styled/EventConfirmation.tsx +68 -22
  32. package/src/ui/styled/EventDetail.tsx +18 -5
  33. package/src/ui/styled/EventTickets.tsx +49 -5
  34. package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
  35. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
  36. package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
  37. package/src/utils/ticketOrderOutcome.ts +125 -0
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { apiErrorMessage, couponFailureMessage } from "../useCouponField";
3
+
4
+ /**
5
+ * The one decision that determines whether a buyer learns anything from a
6
+ * refused code. The backend answers with the reason ALREADY translated —
7
+ * "This coupon has expired", "Your order does not reach this coupon's minimum
8
+ * spend" — so the only correct behaviour is to pass that string through.
9
+ */
10
+
11
+ /** An axios-shaped rejection, which is what every checkout actually catches. */
12
+ const axiosError = (message: string) => {
13
+ const e = new Error("Request failed with status code 400") as Error & {
14
+ response: { status: number; data: { status: number; message: string } };
15
+ };
16
+ e.response = { status: 400, data: { status: 400, message } };
17
+ return e;
18
+ };
19
+
20
+ describe("apiErrorMessage", () => {
21
+ it("reads the server's message off the response body", () => {
22
+ expect(apiErrorMessage(axiosError("This coupon has expired"))).toBe("This coupon has expired");
23
+ });
24
+
25
+ it("returns nothing for a failure that carried no server message", () => {
26
+ expect(apiErrorMessage(new Error("boom"))).toBeUndefined();
27
+ expect(apiErrorMessage(undefined)).toBeUndefined();
28
+ expect(apiErrorMessage(null)).toBeUndefined();
29
+ expect(apiErrorMessage({ response: {} })).toBeUndefined();
30
+ });
31
+ });
32
+
33
+ describe("couponFailureMessage", () => {
34
+ it("shows the API's reason verbatim, never the house fallback", () => {
35
+ // Every distinct reason the three pillars can produce for an ENTERED code.
36
+ for (const reason of [
37
+ "This coupon is no longer active",
38
+ "This coupon is not available yet",
39
+ "This coupon has expired",
40
+ "This coupon is not valid for your email",
41
+ "This coupon can only be used with an email address",
42
+ "Your order does not reach this coupon's minimum spend",
43
+ "Your order does not reach this coupon's minimum quantity",
44
+ "This coupon is only valid on a first order",
45
+ "This coupon is only available to members of a specific tier",
46
+ "This coupon is not available to you",
47
+ "This coupon has reached its maximum redemptions",
48
+ "You have already used this coupon the maximum number of times",
49
+ "This coupon does not apply to anything in this order",
50
+ "This coupon is not set up correctly and cannot be used",
51
+ "This coupon cannot be combined with another discount on this order",
52
+ "Invalid coupon code",
53
+ "This coupon cannot be used here",
54
+ ]) {
55
+ expect(couponFailureMessage(axiosError(reason), "This code could not be applied.")).toBe(reason);
56
+ }
57
+ });
58
+
59
+ it("never surfaces axios's own transport wording", () => {
60
+ // `Error.message` on a 400 is "Request failed with status code 400" — a
61
+ // string that tells the buyer nothing and looks like a bug.
62
+ const shown = couponFailureMessage(axiosError("This coupon has expired"), "fallback");
63
+ expect(shown).not.toContain("status code");
64
+ expect(shown).not.toBe("fallback");
65
+ });
66
+
67
+ it("suppresses the transport wording even when the body carried no message", () => {
68
+ const e = new Error("Request failed with status code 500") as Error & { response: { data: unknown } };
69
+ e.response = { data: {} };
70
+ expect(couponFailureMessage(e, "This code could not be applied.")).toBe("This code could not be applied.");
71
+ });
72
+
73
+ it("keeps a locally thrown precondition, which is ours to word", () => {
74
+ // "Reserve a slot first" is not a coupon rejection and has no server key.
75
+ expect(couponFailureMessage(new Error("Reserve a time slot before entering a code."), "fallback")).toBe(
76
+ "Reserve a time slot before entering a code.",
77
+ );
78
+ });
79
+
80
+ it("falls back only when there is genuinely nothing to say", () => {
81
+ expect(couponFailureMessage({}, "This code could not be applied.")).toBe("This code could not be applied.");
82
+ expect(couponFailureMessage(undefined, "This code could not be applied.")).toBe("This code could not be applied.");
83
+ });
84
+ });
@@ -0,0 +1,164 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { AppliedDiscountCoupon, PillarDiscountQuote } from "../../../types/models";
3
+
4
+ /**
5
+ * Re-quote a pillar with (or, with `null`, without) a discount code and return
6
+ * what the SERVER says the buyer now owes.
7
+ *
8
+ * Only the pillars whose pre-payment step is re-runnable can supply one:
9
+ * coaching and courses both hang a `booking/update` endpoint off an already
10
+ * reserved booking, and that endpoint re-derives the gross price from the
11
+ * product on every call precisely so it can be fired repeatedly.
12
+ *
13
+ * Event tickets and bundles have no such endpoint — `POST /public/events/:id/orders`
14
+ * and `POST /public/checkouts` CREATE the record, so calling either one twice
15
+ * to preview a code would leave a real abandoned order behind. Those two pass
16
+ * no quote function and instead stage the code, hand it to the one call they do
17
+ * make, and record the quote that call returns via `record()`.
18
+ */
19
+ export type CouponQuoteFn = (code: string | null) => Promise<PillarDiscountQuote>;
20
+
21
+ /**
22
+ * The API's own message for a failed request.
23
+ *
24
+ * A coupon rejection comes back as a 400 whose body carries the reason ALREADY
25
+ * translated ("This coupon has expired", "Your order does not reach this
26
+ * coupon's minimum spend", …) — the server sends the rendered string, not the
27
+ * `errors.coupon.*` key, so there is nothing to map and nothing to invent.
28
+ * Replacing it with a house "invalid code" would throw away the only
29
+ * explanation the buyer is ever given.
30
+ */
31
+ export function apiErrorMessage(e: unknown): string | undefined {
32
+ return (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
33
+ }
34
+
35
+ /**
36
+ * The message to show for a failure: the API's own wording when the failure
37
+ * came from the server, otherwise a locally thrown precondition ("reserve a
38
+ * slot first"). A transport `Error` from axios carries a `response`, so its
39
+ * useless "Request failed with status code 400" is never surfaced.
40
+ */
41
+ export function couponFailureMessage(e: unknown, fallback: string): string {
42
+ const fromApi = apiErrorMessage(e);
43
+ if (fromApi) return fromApi;
44
+ const hasResponse = !!(e as { response?: unknown })?.response;
45
+ if (!hasResponse && e instanceof Error && e.message) return e.message;
46
+ return fallback;
47
+ }
48
+
49
+ export interface CouponField {
50
+ /** The raw code in the input (upper-cased by the UI, trimmed on send). */
51
+ code: string;
52
+ setCode: (code: string) => void;
53
+ /** Trimmed code, or `undefined` when the field is empty — send this. */
54
+ submittedCode: string | undefined;
55
+ /** Whether the "Have a discount code?" affordance has been expanded. */
56
+ showInput: boolean;
57
+ setShowInput: (show: boolean) => void;
58
+ /** The API's verbatim rejection message, or a local precondition message. */
59
+ error: string | null;
60
+ setError: (message: string | null) => void;
61
+ isApplying: boolean;
62
+ /** The server's last pricing answer — `null` until something has quoted. */
63
+ quote: PillarDiscountQuote | null;
64
+ /** Every discount that applied, entered OR automatic. */
65
+ appliedCoupons: AppliedDiscountCoupon[];
66
+ /** MAJOR units. `0` when nothing applied. */
67
+ discountAmount: number;
68
+ /** True once the server has confirmed a discount is on this checkout. */
69
+ hasDiscount: boolean;
70
+ /** Re-quote with the entered code. No-op without a quote function. */
71
+ apply: () => Promise<void>;
72
+ /** Re-quote with NO code, restoring the undiscounted total. */
73
+ remove: () => Promise<void>;
74
+ /** Record a quote produced by a call this field did not make. */
75
+ record: (quote: PillarDiscountQuote | null) => void;
76
+ /** Report a failure, taking the API's own wording. */
77
+ fail: (e: unknown, fallback?: string) => void;
78
+ /** Forget everything — used when the underlying record is discarded. */
79
+ reset: () => void;
80
+ /** Whether an Apply/Remove button should be offered at all. */
81
+ canQuote: boolean;
82
+ }
83
+
84
+ /**
85
+ * The discount-code field shared by every checkout: the input, the API's
86
+ * rejection message, and the server-computed quote that lets the summary show
87
+ * the buyer a Discount LINE rather than a total that silently shrank.
88
+ */
89
+ export function useCouponField(quoteFn?: CouponQuoteFn): CouponField {
90
+ const [code, setCode] = useState("");
91
+ const [showInput, setShowInput] = useState(false);
92
+ const [error, setError] = useState<string | null>(null);
93
+ const [isApplying, setIsApplying] = useState(false);
94
+ const [quote, setQuote] = useState<PillarDiscountQuote | null>(null);
95
+
96
+ const submittedCode = code.trim() ? code.trim() : undefined;
97
+
98
+ const fail = useCallback((e: unknown, fallback = "Something went wrong.") => {
99
+ setError(couponFailureMessage(e, fallback));
100
+ }, []);
101
+
102
+ const run = useCallback(
103
+ async (next: string | null) => {
104
+ if (!quoteFn) return;
105
+ setError(null);
106
+ setIsApplying(true);
107
+ try {
108
+ setQuote(await quoteFn(next));
109
+ } catch (e) {
110
+ // The failed code is NOT retained as applied: the previous quote stands
111
+ // (or none does), so the total on screen keeps matching the server.
112
+ setError(couponFailureMessage(e, "This code could not be applied."));
113
+ } finally {
114
+ setIsApplying(false);
115
+ }
116
+ },
117
+ [quoteFn],
118
+ );
119
+
120
+ const apply = useCallback(async () => {
121
+ const trimmed = code.trim();
122
+ if (!trimmed) return;
123
+ await run(trimmed);
124
+ }, [code, run]);
125
+
126
+ const remove = useCallback(async () => {
127
+ await run(null);
128
+ setCode("");
129
+ setShowInput(false);
130
+ }, [run]);
131
+
132
+ const record = useCallback((next: PillarDiscountQuote | null) => {
133
+ setError(null);
134
+ setQuote(next);
135
+ }, []);
136
+
137
+ const reset = useCallback(() => {
138
+ setCode("");
139
+ setShowInput(false);
140
+ setError(null);
141
+ setQuote(null);
142
+ }, []);
143
+
144
+ return {
145
+ code,
146
+ setCode,
147
+ submittedCode,
148
+ showInput,
149
+ setShowInput,
150
+ error,
151
+ setError,
152
+ isApplying,
153
+ quote,
154
+ appliedCoupons: quote?.appliedCoupons ?? [],
155
+ discountAmount: quote?.discountAmount ?? 0,
156
+ hasDiscount: (quote?.discountAmount ?? 0) > 0,
157
+ apply,
158
+ remove,
159
+ record,
160
+ fail,
161
+ reset,
162
+ canQuote: !!quoteFn,
163
+ };
164
+ }
@@ -1,7 +1,8 @@
1
- import { useState } from "react";
1
+ import { useCallback, useRef, useState } from "react";
2
2
  import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
- import { useGetCourse, useCreateCourseBooking } from "../../../data/queries/useCourses";
3
+ import { useGetCourse, useCreateCourseBooking, useUpdateCourseBooking } from "../../../data/queries/useCourses";
4
4
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
5
+ import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
5
6
  import { readAttributionRef } from "../../../utils/attribution";
6
7
  import { readLanding } from "../../../utils/landing";
7
8
 
@@ -21,8 +22,15 @@ const errMessage = (e: unknown) =>
21
22
 
22
23
  /**
23
24
  * Headless course purchase: buyer details → payment. Composes `useGetCourse`,
24
- * `useCreateCourseBooking`, `usePaymentFlow`. Free courses skip payment and
25
- * redirect to the finalise page. Finalize via `useCourseBookingFinalize`.
25
+ * `useCreateCourseBooking`, `useUpdateCourseBooking`, `usePaymentFlow`. Free
26
+ * courses skip payment and redirect to the finalise page. Finalize via
27
+ * `useCourseBookingFinalize`.
28
+ *
29
+ * `POST /public/courses/:id/bookings` takes NO coupon code — the course pillar
30
+ * accepts one only on `booking/update`, which exists to be called repeatedly and
31
+ * re-derives the gross price from the course each time. So a booking is created
32
+ * as soon as the buyer applies a code, and reused from then on. A buyer who
33
+ * never touches the discount field takes exactly the path this hook always took.
26
34
  */
27
35
  export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions = {}) {
28
36
  const { user } = usePublicAuth();
@@ -30,6 +38,7 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
30
38
  const { data: course, isLoading } = useGetCourse(slug);
31
39
  const courseId = course?.id;
32
40
  const createBooking = useCreateCourseBooking(courseId);
41
+ const updateBooking = useUpdateCourseBooking(courseId);
33
42
  const flow = usePaymentFlow({ path: `/public/courses/${courseId}/start-payment`, autoStart: false });
34
43
 
35
44
  const [step, setStep] = useState<CourseCheckoutStep>("details");
@@ -39,10 +48,58 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
39
48
  const [questionnaire, setQuestionnaire] = useState<unknown>(undefined);
40
49
  const [returnUrl, setReturnUrl] = useState("");
41
50
  const [error, setError] = useState<string | null>(null);
51
+ /** The booking created for quoting, reused so a code never mints a second one. */
52
+ const bookingIdRef = useRef<string | null>(null);
42
53
 
43
54
  const finalisePath =
44
55
  opts.finalisePath ?? ((s, bookingId) => `/i/courses/${s}/finalise?bookingId=${bookingId}`);
45
56
 
57
+ const requireBuyerDetails = () => {
58
+ if (!firstName.trim() || !lastName.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
59
+ throw new Error("Enter your name and a valid email before applying a code.");
60
+ }
61
+ };
62
+
63
+ /** Create the booking once; every later call reuses it. */
64
+ const ensureBooking = async (): Promise<string> => {
65
+ if (bookingIdRef.current) return bookingIdRef.current;
66
+ const created = await createBooking.mutateAsync({
67
+ email,
68
+ firstName,
69
+ lastName,
70
+ questionnaire,
71
+ attributionRefId: readAttributionRef() ?? undefined,
72
+ ...(readLanding() ?? {}),
73
+ });
74
+ bookingIdRef.current = created.bookingId;
75
+ return created.bookingId;
76
+ };
77
+
78
+ /**
79
+ * `confirmIfFree` is false here on purpose: quoting a code must never be able
80
+ * to complete an enrollment. Only "Continue" confirms.
81
+ */
82
+ const quoteCoupon: CouponQuoteFn = useCallback(
83
+ async (code) => {
84
+ if (!courseId) throw new Error("This course is not available.");
85
+ requireBuyerDetails();
86
+ const bookingId = await ensureBooking();
87
+ return updateBooking.mutateAsync({
88
+ bookingId,
89
+ email,
90
+ firstName,
91
+ lastName,
92
+ confirmIfFree: false,
93
+ questionnaire,
94
+ couponCode: code ?? undefined,
95
+ });
96
+ },
97
+ // `createBooking`/`updateBooking` are stable react-query mutation objects.
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ [courseId, firstName, lastName, email, questionnaire],
100
+ );
101
+ const coupon = useCouponField(quoteCoupon);
102
+
46
103
  const continueToPayment = async () => {
47
104
  setError(null);
48
105
  if (!slug || !courseId) return;
@@ -51,27 +108,56 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
51
108
  return;
52
109
  }
53
110
  try {
54
- const data = await createBooking.mutateAsync({
55
- email,
56
- firstName,
57
- lastName,
58
- questionnaire,
59
- attributionRefId: readAttributionRef() ?? undefined,
60
- ...(readLanding() ?? {}),
61
- });
111
+ // A discount can only be recorded through `booking/update`, so any buyer
112
+ // who touched the code field settles through that endpoint. Everyone else
113
+ // keeps the original create-only path, unchanged.
114
+ const usesCoupon = !!coupon.submittedCode || !!bookingIdRef.current;
115
+ let bookingId: string;
116
+ let isFree: boolean;
117
+
118
+ if (usesCoupon) {
119
+ bookingId = await ensureBooking();
120
+ const updated = await updateBooking.mutateAsync({
121
+ bookingId,
122
+ email,
123
+ firstName,
124
+ lastName,
125
+ confirmIfFree: true,
126
+ questionnaire,
127
+ couponCode: coupon.submittedCode,
128
+ });
129
+ coupon.record(updated);
130
+ isFree = updated.isConfirmed;
131
+ } else {
132
+ const data = await createBooking.mutateAsync({
133
+ email,
134
+ firstName,
135
+ lastName,
136
+ questionnaire,
137
+ attributionRefId: readAttributionRef() ?? undefined,
138
+ ...(readLanding() ?? {}),
139
+ });
140
+ bookingIdRef.current = data.bookingId;
141
+ bookingId = data.bookingId;
142
+ isFree = !!data.isFree;
143
+ }
144
+
62
145
  const origin = typeof window !== "undefined" ? window.location.origin : "";
63
- const ru = `${origin}${finalisePath(slug, data.bookingId)}`;
146
+ const ru = `${origin}${finalisePath(slug, bookingId)}`;
64
147
  setReturnUrl(ru);
65
- if (data.isFree) {
66
- if (opts.onComplete) opts.onComplete({ slug, bookingId: data.bookingId });
148
+ if (isFree) {
149
+ if (opts.onComplete) opts.onComplete({ slug, bookingId });
67
150
  else if (typeof window !== "undefined") window.location.href = ru;
68
151
  return;
69
152
  }
70
- const result = await flow.start({ bookingId: data.bookingId, returnUrl: ru });
153
+ const result = await flow.start({ bookingId, returnUrl: ru });
71
154
  if (result.provider && result.provider !== "stripe") return;
72
155
  setStep("payment");
73
156
  } catch (e) {
74
- setError(errMessage(e));
157
+ // With a code entered, the API's rejection reason belongs at the field the
158
+ // buyer used, not in the generic error slot.
159
+ if (coupon.submittedCode) coupon.fail(e);
160
+ else setError(errMessage(e));
75
161
  }
76
162
  };
77
163
 
@@ -90,10 +176,19 @@ export function useCourseCheckout(slug?: string, opts: UseCourseCheckoutOptions
90
176
  setQuestionnaire,
91
177
  continueToPayment,
92
178
  clientSecret: flow.clientSecret,
179
+ // The booking quote WINS over start-payment: every `continueToPayment`
180
+ // re-quotes before starting the payment, and applying or removing a code
181
+ // re-quotes again — so the quote is never staler, and preferring the
182
+ // payment result would leave a removed discount on screen.
183
+ totalAmount: coupon.quote?.totalAmount ?? flow.result?.totalAmount ?? (course ? Number(course.price) : undefined),
184
+ /** GROSS course price, before any discount. */
185
+ subTotal: coupon.quote?.subTotal ?? (course ? Number(course.price) : undefined),
186
+ /** Discount code state + the server's quote. See `useCouponField`. */
187
+ coupon,
93
188
  /** Authoritative sales-tax quote from start-payment (display only). */
94
189
  taxQuote: flow.result?.taxQuote ?? null,
95
190
  returnUrl,
96
- isProcessing: createBooking.isPending || flow.isStarting,
191
+ isProcessing: createBooking.isPending || updateBooking.isPending || flow.isStarting,
97
192
  error,
98
193
  };
99
194
  }
@@ -3,6 +3,7 @@ import { usePublicAuth } from "../../../contexts/PublicAuthContext";
3
3
  import { useCart } from "../../../contexts/CartContext";
4
4
  import { useEvent, useCreateEventOrder } from "../../../data/queries/useEvents";
5
5
  import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
6
+ import { useCouponField } from "../coupon/useCouponField";
6
7
  import { readAttributionRef } from "../../../utils/attribution";
7
8
  import { readLanding } from "../../../utils/landing";
8
9
 
@@ -47,6 +48,19 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
47
48
  const [returnUrl, setReturnUrl] = useState("");
48
49
  const [error, setError] = useState<string | null>(null);
49
50
 
51
+ /**
52
+ * Discount code — STAGED, not quoted.
53
+ *
54
+ * Tickets have no apply-coupon endpoint and no quote endpoint: `POST
55
+ * /public/events/:id/orders` is what redeems a code, and it creates the order
56
+ * row. Pricing a code by calling it would leave a real abandoned order (and a
57
+ * released inventory hold) behind every keystroke, so no quote function is
58
+ * passed here. The code rides along on `continueToPayment`, and the order
59
+ * response — which carries `subTotal`, `discountAmount` and every applied
60
+ * coupon — is recorded as the quote.
61
+ */
62
+ const coupon = useCouponField();
63
+
50
64
  const totalAmount = useMemo(() => {
51
65
  if (!event) return 0;
52
66
  return event.tickets.reduce((sum, t) => sum + t.price * (selectedTickets[t.id] ?? 0), 0);
@@ -124,9 +138,19 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
124
138
  firstName,
125
139
  lastName,
126
140
  questionnaire,
141
+ couponCode: coupon.submittedCode,
127
142
  attributionRefId: readAttributionRef() ?? undefined,
128
143
  ...(readLanding() ?? {}),
129
144
  });
145
+ // What the server actually charged and why — including any AUTOMATIC
146
+ // discount the buyer never asked for, which nothing else would reveal.
147
+ coupon.record({
148
+ subTotal: data.subTotal ?? data.totalAmount ?? totalAmount,
149
+ totalAmount: data.totalAmount ?? totalAmount,
150
+ discountAmount: data.discountAmount ?? 0,
151
+ couponId: data.couponId ?? null,
152
+ appliedCoupons: data.appliedCoupons ?? [],
153
+ });
130
154
  const origin = typeof window !== "undefined" ? window.location.origin : "";
131
155
  const ru = `${origin}${finalisePath(slug, data.orderId)}`;
132
156
  setReturnUrl(ru);
@@ -141,10 +165,29 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
141
165
  if (result.provider && result.provider !== "stripe") return; // Paystack redirected
142
166
  setStep("payment");
143
167
  } catch (e) {
144
- setError(errMessage(e));
168
+ // A refused code fails the whole request, so with one entered the message
169
+ // belongs next to the field the buyer just used. The message is the API's
170
+ // own — "This coupon has expired", "…does not reach this coupon's minimum
171
+ // spend" — never a house "invalid code" that hides which it was.
172
+ if (coupon.submittedCode) coupon.fail(e);
173
+ else setError(errMessage(e));
145
174
  }
146
175
  };
147
176
 
177
+ /**
178
+ * Drop the code and go back to re-enter details. There is nothing to un-apply
179
+ * server-side: the order was created WITH the discount and is simply left
180
+ * behind, exactly as pressing "Back" already does. The next
181
+ * `continueToPayment` creates a fresh, undiscounted order.
182
+ */
183
+ const clearCoupon = () => {
184
+ coupon.reset();
185
+ // The started payment described the DISCOUNTED order; leaving it would keep
186
+ // the reduced total on screen after the discount is gone.
187
+ flow.reset();
188
+ setStep("details");
189
+ };
190
+
148
191
  return {
149
192
  event,
150
193
  isLoading,
@@ -153,7 +196,15 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
153
196
  selectedTickets,
154
197
  setTicketQty,
155
198
  ticketCount,
156
- totalAmount: flow.result?.totalAmount ?? totalAmount,
199
+ // The server's figure wins the moment there is one: start-payment first,
200
+ // then the order response (which is already net of any discount), and only
201
+ // the locally summed ticket prices before either exists.
202
+ totalAmount: flow.result?.totalAmount ?? coupon.quote?.totalAmount ?? totalAmount,
203
+ /** GROSS ticket total, before any discount. */
204
+ subTotal: coupon.quote?.subTotal ?? totalAmount,
205
+ /** Discount code state + the server's quote. See `useCouponField`. */
206
+ coupon,
207
+ clearCoupon,
157
208
  /** Authoritative sales-tax quote from start-payment (display only). */
158
209
  taxQuote: flow.result?.taxQuote ?? null,
159
210
  firstName,
@@ -23,6 +23,13 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
23
23
  // Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
24
24
  // each composed from the data hooks so a creator can rebuild any page.
25
25
  export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
26
+ export {
27
+ useCouponField,
28
+ apiErrorMessage,
29
+ couponFailureMessage,
30
+ type CouponField,
31
+ type CouponQuoteFn,
32
+ } from "./coupon/useCouponField";
26
33
  export { useCourseCheckout, type UseCourseCheckoutOptions, type CourseCheckoutStep } from "./course/useCourseCheckout";
27
34
  export {
28
35
  useCoachingBooking,
@@ -43,6 +50,14 @@ export { useMessageThread, type UseMessageThreadResult } from "./chat/useMessage
43
50
  export { useChatAttachmentUpload, type PendingAttachment } from "./chat/useChatAttachmentUpload";
44
51
  export { attachmentKind, formatBytes, type AttachmentKind } from "./chat/attachmentKind";
45
52
  export { useCookieConsent, type UseCookieConsentResult } from "./consent/useCookieConsent";
53
+ export {
54
+ useAddToCalendar,
55
+ buildAddToCalendarLinks,
56
+ toUtcBasic,
57
+ stripHtml,
58
+ type AddToCalendarInput,
59
+ type AddToCalendarLinks,
60
+ } from "./calendar/useAddToCalendar";
46
61
 
47
62
  export { usePodcastPlayer, type UsePodcastPlayerOptions, type PodcastPlayerState } from "./podcast/usePodcastPlayer";
48
63
 
package/src/ui/index.ts CHANGED
@@ -45,6 +45,12 @@ export {
45
45
  export { DonationButton, type DonationButtonProps } from "./styled/DonationButton";
46
46
  export { OfferButton, type OfferButtonProps } from "./styled/OfferButton";
47
47
  export { Checkout, type CheckoutProps } from "./styled/Checkout";
48
+ export {
49
+ DiscountCodeField,
50
+ type DiscountCodeFieldProps,
51
+ DiscountSummaryLines,
52
+ type DiscountSummaryLinesProps,
53
+ } from "./styled/DiscountCode";
48
54
  export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
49
55
  export { ForgeStripePayment } from "./payment/ForgeStripePayment";
50
56
  export { Cart, type CartProps } from "./styled/Cart";
@@ -67,6 +73,7 @@ export { EventsList, type EventsListProps } from "./styled/EventsList";
67
73
  export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
68
74
  export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
69
75
  export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
76
+ export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
70
77
  export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
71
78
  export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
72
79
  export { CourseCheckout, type CourseCheckoutProps } from "./styled/CourseCheckout";
@@ -0,0 +1,60 @@
1
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
2
+
3
+ /** Where the badge points. utm params so the referral traffic is attributable. */
4
+ const HREF = "https://tribenest.co/?utm_source=powered_by&utm_medium=badge&utm_campaign=creator_site";
5
+
6
+ export interface PoweredByProps {
7
+ /** Live released build only — see `shellPoweredByEnabled`. */
8
+ enabled?: boolean;
9
+ }
10
+
11
+ /**
12
+ * The "Powered by TribeNest" line every released site and app carries.
13
+ *
14
+ * Rendered by `TribeNestApp` after the page tree, so it lands BELOW the tenant's
15
+ * own footer and needs no edit to the tenant-owned `__root.tsx` — a Forge bump
16
+ * distributes it to every existing site. Both starters (`apps/site-starter` and
17
+ * `apps/app-starter`) wrap in `TribeNestApp`, so websites and mini-apps are
18
+ * covered by the same component.
19
+ *
20
+ * Deliberately NOT a prop on `TribeNestApp`: the tenant owns `__root.tsx`, so an
21
+ * opt-out prop is an opt-out. Removal is a server-side decision instead —
22
+ * `siteConfig.hideTribeNestBadge`, which is how a white-label plan entitlement
23
+ * will switch it off without waiting for a Forge release to propagate.
24
+ *
25
+ * Styled from theme tokens and kept in the normal document flow (not fixed), so
26
+ * it inherits the site's palette and never collides with the pinned AudioPlayer.
27
+ */
28
+ export function PoweredBy({ enabled = true }: PoweredByProps) {
29
+ const t = useThemeTokens();
30
+ if (!enabled) return null;
31
+ return (
32
+ <div
33
+ style={{
34
+ display: "flex",
35
+ justifyContent: "center",
36
+ padding: "14px 16px",
37
+ borderTop: `1px solid ${t.border}`,
38
+ background: t.background,
39
+ }}
40
+ >
41
+ <a
42
+ href={HREF}
43
+ target="_blank"
44
+ // nofollow: the same link on every site we host is exactly the pattern
45
+ // Google reads as a link scheme. Drop it if the SEO value is judged
46
+ // worth that risk — it is a one-word change.
47
+ rel="noopener noreferrer nofollow"
48
+ style={{
49
+ fontSize: 12,
50
+ lineHeight: 1,
51
+ color: t.muted,
52
+ textDecoration: "none",
53
+ letterSpacing: 0.2,
54
+ }}
55
+ >
56
+ Powered by <span style={{ fontWeight: 600 }}>TribeNest</span>
57
+ </a>
58
+ </div>
59
+ );
60
+ }
@@ -6,7 +6,9 @@ import { PwaRegistration } from "../styled/PwaRegistration";
6
6
  import { InstallBanner } from "../styled/InstallBanner";
7
7
  import { CookieConsent } from "../styled/CookieConsent";
8
8
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
9
- import { shellPwaEnabled } from "./shellGating";
9
+ import { useInitialSiteConfig } from "../../provider/SiteConfigProvider";
10
+ import { PoweredBy } from "./PoweredBy";
11
+ import { shellPoweredByEnabled, shellPwaEnabled } from "./shellGating";
10
12
  import { captureAttributionRefFromUrl, readAttributionRef } from "../../utils/attribution";
11
13
  import { captureLandingFromUrl, postLandingBeacon } from "../../utils/landing";
12
14
 
@@ -65,6 +67,15 @@ function PwaHeadClient({ enabled, manifestHref }: { enabled: boolean; manifestHr
65
67
  return null;
66
68
  }
67
69
 
70
+ // Reads the server-supplied site config, so the white-label entitlement can turn
71
+ // the badge off without a Forge release. Must live INSIDE ForgeProvider (that is
72
+ // what mounts SiteConfigProvider), hence a component rather than a call in the
73
+ // TribeNestApp body.
74
+ function PoweredByGate({ editable, state }: { editable?: boolean; state?: "draft" | "published" }) {
75
+ const siteConfig = useInitialSiteConfig();
76
+ return <PoweredBy enabled={shellPoweredByEnabled({ editable, state, hideBadge: siteConfig?.hideTribeNestBadge })} />;
77
+ }
78
+
68
79
  /**
69
80
  * The single root component for a TribeNest code website. Wrap the page tree once
70
81
  * — it owns the whole provider tree (via `ForgeProvider`) PLUS the global shell
@@ -100,6 +111,9 @@ export function TribeNestApp({
100
111
  return (
101
112
  <ForgeProvider editable={editable} {...forgeProps}>
102
113
  {children}
114
+ {/* "Powered by TribeNest" — last in the page flow, so it sits below the
115
+ tenant's own footer. Released builds only (live + published). */}
116
+ <PoweredByGate editable={editable} state={state} />
103
117
  {/* Global playback bar — self-pins to the viewport bottom, renders nothing
104
118
  until a track loads. */}
105
119
  <AudioPlayer />