@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.
- package/package.json +1 -1
- package/src/client/createForgeClient.ts +85 -2
- package/src/client/tokenStorage.ts +31 -0
- package/src/contexts/AppAuthContext.tsx +100 -15
- package/src/contexts/PublicAuthContext.tsx +46 -9
- package/src/data/queries/useCheckouts.ts +84 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -3
- package/src/data/queries/useCourseAccess.ts +1 -1
- package/src/data/queries/useCourses.ts +42 -1
- package/src/data/queries/useEvents.ts +15 -1
- package/src/data/queries/usePaymentFlow.ts +12 -0
- package/src/data/queries/useWebsite.ts +6 -0
- package/src/index.ts +19 -2
- package/src/provider/ForgeProvider.tsx +30 -1
- package/src/server/_tests/platformEvents.spec.ts +315 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +41 -10
- package/src/server/platform.ts +234 -9
- package/src/server/platformEvents.generated.ts +422 -0
- package/src/types/models.ts +110 -3
- package/src/ui/headless/auth/useSignupForm.ts +69 -4
- package/src/ui/headless/calendar/useAddToCalendar.ts +194 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +169 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +96 -0
- package/src/ui/headless/checkout/useCheckout.ts +156 -8
- package/src/ui/headless/coaching/useCoachingBooking.ts +53 -3
- package/src/ui/headless/coupon/_tests/couponFailureMessage.spec.ts +84 -0
- package/src/ui/headless/coupon/useCouponField.ts +164 -0
- package/src/ui/headless/course/useCourseCheckout.ts +113 -18
- package/src/ui/headless/event/useEventCheckout.ts +53 -2
- package/src/ui/headless/index.ts +15 -0
- package/src/ui/headless/work/useWorkPortal.ts +24 -21
- package/src/ui/index.ts +7 -0
- package/src/ui/shell/PoweredBy.tsx +60 -0
- package/src/ui/shell/TribeNestApp.tsx +15 -1
- package/src/ui/shell/shellGating.spec.ts +21 -1
- package/src/ui/shell/shellGating.ts +14 -0
- package/src/ui/styled/AddToCalendar.tsx +104 -0
- package/src/ui/styled/Checkout.tsx +45 -14
- package/src/ui/styled/CoachingBooking.tsx +28 -8
- package/src/ui/styled/CoachingConfirmation.tsx +12 -0
- package/src/ui/styled/CourseCheckout.tsx +49 -18
- package/src/ui/styled/DiscountCode.tsx +206 -0
- package/src/ui/styled/EventConfirmation.tsx +68 -22
- package/src/ui/styled/EventDetail.tsx +18 -5
- package/src/ui/styled/EventTickets.tsx +49 -5
- package/src/ui/styled/SignupForm.tsx +86 -35
- package/src/ui/styled/_tests/DiscountCode.spec.tsx +272 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +154 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +54 -5
- package/src/utils/_tests/safeRedirect.spec.ts +117 -0
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +126 -0
- package/src/utils/safeRedirect.ts +41 -0
- package/src/utils/ticketOrderOutcome.ts +125 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Add to calendar" link building — the whole of it. Styled components render
|
|
5
|
+
* anchors; they never assemble a URL themselves, so a fix to (say) Google's
|
|
6
|
+
* `dates` format lands in exactly one place.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is framework-free and dependency-free: Forge ships no date
|
|
9
|
+
* library, so the two formats the providers want are derived from the platform
|
|
10
|
+
* `Date` alone.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type AddToCalendarInput = {
|
|
14
|
+
/** Event/session name — becomes the calendar entry's title. */
|
|
15
|
+
title: string;
|
|
16
|
+
/** Absolute instant the thing starts (ISO-8601 with an offset or `Z`). */
|
|
17
|
+
start: string | Date;
|
|
18
|
+
/** Absolute instant it ends. Nullable — falls back to `start + defaultDurationMinutes`. */
|
|
19
|
+
end?: string | Date | null;
|
|
20
|
+
/** Fallback length when `end` is absent. Defaults to 120 minutes. */
|
|
21
|
+
defaultDurationMinutes?: number;
|
|
22
|
+
/** Long description. HTML is stripped and truncated before it goes in a URL. */
|
|
23
|
+
description?: string | null;
|
|
24
|
+
/** Human-readable place (a street address, or "Virtual"). */
|
|
25
|
+
location?: string | null;
|
|
26
|
+
/** Canonical page for the event; appended to the description body when present. */
|
|
27
|
+
url?: string | null;
|
|
28
|
+
/**
|
|
29
|
+
* Absolute URL to a downloadable `.ics` (what Apple Calendar / Outlook desktop
|
|
30
|
+
* want). Forge does not synthesize this — the host passes the endpoint it has.
|
|
31
|
+
* When absent, `ics` comes back `null` and no Apple option is offered.
|
|
32
|
+
*/
|
|
33
|
+
icsUrl?: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type AddToCalendarLinks = {
|
|
37
|
+
/** Google Calendar "TEMPLATE" prefill. */
|
|
38
|
+
google: string;
|
|
39
|
+
/** Outlook.com (personal accounts). */
|
|
40
|
+
outlook: string;
|
|
41
|
+
/** Outlook on the web for work/school accounts (Microsoft 365). */
|
|
42
|
+
office365: string;
|
|
43
|
+
/** Yahoo Calendar. */
|
|
44
|
+
yahoo: string;
|
|
45
|
+
/** Pass-through of `icsUrl` — the Apple/desktop route. `null` when not supplied. */
|
|
46
|
+
ics: string | null;
|
|
47
|
+
startsAt: Date;
|
|
48
|
+
endsAt: Date;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Length of a calendar entry when the caller has no end time. */
|
|
52
|
+
const DEFAULT_DURATION_MINUTES = 120;
|
|
53
|
+
|
|
54
|
+
/** Calendar providers put the description in a URL; keep it well under limits. */
|
|
55
|
+
const MAX_DESCRIPTION_CHARS = 800;
|
|
56
|
+
|
|
57
|
+
/** Parse anything the caller has to a valid `Date`, or `null` if it isn't one. */
|
|
58
|
+
function toDate(value: string | Date | null | undefined): Date | null {
|
|
59
|
+
if (value === null || value === undefined || value === "") return null;
|
|
60
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
61
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* UTC "basic format" (RFC 5545 / what Google and Yahoo want):
|
|
66
|
+
* `2026-03-15T06:30:00.000Z` → `20260315T063000Z`.
|
|
67
|
+
*/
|
|
68
|
+
export function toUtcBasic(d: Date): string {
|
|
69
|
+
return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Flatten an HTML description to plain text. Event/session descriptions in this
|
|
74
|
+
* codebase are rich text rendered with `dangerouslySetInnerHTML`, so the raw
|
|
75
|
+
* value is markup — pasting it into a calendar body would show tags.
|
|
76
|
+
*
|
|
77
|
+
* `&` is decoded LAST so an encoded entity (`<`) doesn't turn into a
|
|
78
|
+
* live one.
|
|
79
|
+
*/
|
|
80
|
+
export function stripHtml(html: string, maxLength: number = MAX_DESCRIPTION_CHARS): string {
|
|
81
|
+
const text = html
|
|
82
|
+
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
|
83
|
+
.replace(/<\s*\/\s*(p|div|li|tr|h[1-6])\s*>/gi, "\n")
|
|
84
|
+
.replace(/<[^>]*>/g, "")
|
|
85
|
+
.replace(/ /gi, " ")
|
|
86
|
+
.replace(/'/g, "'")
|
|
87
|
+
.replace(/"/gi, '"')
|
|
88
|
+
.replace(/</gi, "<")
|
|
89
|
+
.replace(/>/gi, ">")
|
|
90
|
+
.replace(/&/gi, "&")
|
|
91
|
+
.replace(/[ \t]+/g, " ")
|
|
92
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
93
|
+
.trim();
|
|
94
|
+
|
|
95
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1).trimEnd()}…` : text;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const enc = encodeURIComponent;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build every provider URL for one calendar entry. Returns `null` when the
|
|
102
|
+
* start is missing or unparseable — callers render nothing rather than a link
|
|
103
|
+
* that would prefill garbage.
|
|
104
|
+
*/
|
|
105
|
+
export function buildAddToCalendarLinks(input: AddToCalendarInput): AddToCalendarLinks | null {
|
|
106
|
+
const startsAt = toDate(input.start);
|
|
107
|
+
if (!startsAt) return null;
|
|
108
|
+
|
|
109
|
+
const durationMinutes =
|
|
110
|
+
input.defaultDurationMinutes && input.defaultDurationMinutes > 0
|
|
111
|
+
? input.defaultDurationMinutes
|
|
112
|
+
: DEFAULT_DURATION_MINUTES;
|
|
113
|
+
|
|
114
|
+
const parsedEnd = toDate(input.end);
|
|
115
|
+
// An end at-or-before the start is as useless as no end at all.
|
|
116
|
+
const endsAt =
|
|
117
|
+
parsedEnd && parsedEnd.getTime() > startsAt.getTime()
|
|
118
|
+
? parsedEnd
|
|
119
|
+
: new Date(startsAt.getTime() + durationMinutes * 60_000);
|
|
120
|
+
|
|
121
|
+
const title = (input.title || "").trim();
|
|
122
|
+
const location = (input.location || "").trim();
|
|
123
|
+
|
|
124
|
+
const body = [input.description ? stripHtml(input.description) : "", (input.url || "").trim()]
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join("\n\n");
|
|
127
|
+
|
|
128
|
+
const startBasic = toUtcBasic(startsAt);
|
|
129
|
+
const endBasic = toUtcBasic(endsAt);
|
|
130
|
+
const startIso = startsAt.toISOString();
|
|
131
|
+
const endIso = endsAt.toISOString();
|
|
132
|
+
|
|
133
|
+
const outlookQuery =
|
|
134
|
+
`path=${enc("/calendar/action/compose")}` +
|
|
135
|
+
`&rru=addevent` +
|
|
136
|
+
`&subject=${enc(title)}` +
|
|
137
|
+
`&startdt=${enc(startIso)}` +
|
|
138
|
+
`&enddt=${enc(endIso)}` +
|
|
139
|
+
`&body=${enc(body)}` +
|
|
140
|
+
`&location=${enc(location)}`;
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
google:
|
|
144
|
+
`https://calendar.google.com/calendar/render?action=TEMPLATE` +
|
|
145
|
+
`&text=${enc(title)}` +
|
|
146
|
+
`&dates=${startBasic}/${endBasic}` +
|
|
147
|
+
`&details=${enc(body)}` +
|
|
148
|
+
`&location=${enc(location)}`,
|
|
149
|
+
outlook: `https://outlook.live.com/calendar/0/deeplink/compose?${outlookQuery}`,
|
|
150
|
+
office365: `https://outlook.office.com/calendar/0/deeplink/compose?${outlookQuery}`,
|
|
151
|
+
yahoo:
|
|
152
|
+
`https://calendar.yahoo.com/?v=60` +
|
|
153
|
+
`&title=${enc(title)}` +
|
|
154
|
+
`&st=${startBasic}` +
|
|
155
|
+
`&et=${endBasic}` +
|
|
156
|
+
`&desc=${enc(body)}` +
|
|
157
|
+
`&in_loc=${enc(location)}`,
|
|
158
|
+
ics: input.icsUrl || null,
|
|
159
|
+
startsAt,
|
|
160
|
+
endsAt,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* React wrapper over {@link buildAddToCalendarLinks}. Memoized on the primitive
|
|
166
|
+
* inputs so an anchor's `href` is referentially stable between renders.
|
|
167
|
+
*
|
|
168
|
+
* Returns `null` for an unusable start time — safe to call unconditionally with
|
|
169
|
+
* data that may still be loading.
|
|
170
|
+
*/
|
|
171
|
+
export function useAddToCalendar(input: AddToCalendarInput | null | undefined): AddToCalendarLinks | null {
|
|
172
|
+
const title = input?.title;
|
|
173
|
+
const start = input?.start;
|
|
174
|
+
const end = input?.end;
|
|
175
|
+
const defaultDurationMinutes = input?.defaultDurationMinutes;
|
|
176
|
+
const description = input?.description;
|
|
177
|
+
const location = input?.location;
|
|
178
|
+
const url = input?.url;
|
|
179
|
+
const icsUrl = input?.icsUrl;
|
|
180
|
+
|
|
181
|
+
return useMemo(() => {
|
|
182
|
+
if (!start) return null;
|
|
183
|
+
return buildAddToCalendarLinks({
|
|
184
|
+
title: title ?? "",
|
|
185
|
+
start,
|
|
186
|
+
end,
|
|
187
|
+
defaultDurationMinutes,
|
|
188
|
+
description,
|
|
189
|
+
location,
|
|
190
|
+
url,
|
|
191
|
+
icsUrl,
|
|
192
|
+
});
|
|
193
|
+
}, [title, start, end, defaultDurationMinutes, description, location, url, icsUrl]);
|
|
194
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { bundleCouponSummary, bundleCouponRequestBody, bundleReturnUrl } from "../bundleCoupon";
|
|
3
|
+
import type { ApplyCheckoutCouponResult } from "../../../../data/queries/useCheckouts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A bundle's discount code, as `useCheckout` handles it.
|
|
7
|
+
*
|
|
8
|
+
* These are the two decisions that used to be impossible to make at all: before
|
|
9
|
+
* `/public/checkouts/apply-coupon` existed, a bundle's code was consumed by the
|
|
10
|
+
* call that CREATED the checkout, so for a signed-in buyer with a digital-only
|
|
11
|
+
* cart — who enters the payment stage on mount — the field froze before it could
|
|
12
|
+
* be typed into.
|
|
13
|
+
*
|
|
14
|
+
* They live outside the hook so they can be asserted without a provider tree,
|
|
15
|
+
* the same reason `couponFailureMessage` does.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The endpoint's answer, defaulted to "APPLYME20 took $2 off a $60 bundle". */
|
|
19
|
+
const answer = (overrides: Partial<ApplyCheckoutCouponResult> = {}): ApplyCheckoutCouponResult => ({
|
|
20
|
+
checkoutId: "checkout-1",
|
|
21
|
+
currency: "USD",
|
|
22
|
+
subtotalCents: 6000,
|
|
23
|
+
discountCents: 200,
|
|
24
|
+
couponId: "coupon-1",
|
|
25
|
+
totalCents: 5800,
|
|
26
|
+
appliedCoupons: [{ code: "APPLYME20", discountKind: "simple", discountAmount: 2 }],
|
|
27
|
+
paymentSecret: "pi_2_secret",
|
|
28
|
+
paymentId: "pi_2",
|
|
29
|
+
chargedAmount: 58,
|
|
30
|
+
chargedCurrency: "USD",
|
|
31
|
+
isFreeCheckout: false,
|
|
32
|
+
...overrides,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("bundleCouponSummary — applying", () => {
|
|
36
|
+
it("converts the minor-unit discount and names the coupon", () => {
|
|
37
|
+
const next = bundleCouponSummary(answer());
|
|
38
|
+
|
|
39
|
+
expect(next.discountAmount).toBe(2);
|
|
40
|
+
expect(next.appliedCoupon).toEqual({ code: "APPLYME20", discountAmount: 2 });
|
|
41
|
+
expect(next.settledCheckoutId).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("labels an AUTOMATIC discount the buyer never typed", () => {
|
|
45
|
+
// The whole point of the response carrying `appliedCoupons`: without it a
|
|
46
|
+
// no-code discount could only be drawn as an unexplained amount, because
|
|
47
|
+
// there is no public `couponId` → code lookup.
|
|
48
|
+
const next = bundleCouponSummary(
|
|
49
|
+
answer({ appliedCoupons: [{ code: "SUMMER10", discountKind: "simple", discountAmount: 6 }], discountCents: 600 }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(next.appliedCoupon).toEqual({ code: "SUMMER10", discountAmount: 6 });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("moves the payment element onto the re-minted intent", () => {
|
|
56
|
+
// Applying re-prices AND re-mints. Leaving the old secret in place would
|
|
57
|
+
// have the buyer confirm a payment quoting the PRE-coupon amount.
|
|
58
|
+
const next = bundleCouponSummary(answer());
|
|
59
|
+
|
|
60
|
+
expect(next.paymentUpdate).toEqual({
|
|
61
|
+
paymentSecret: "pi_2_secret",
|
|
62
|
+
paymentId: "pi_2",
|
|
63
|
+
chargedAmount: 58,
|
|
64
|
+
chargedCurrency: "USD",
|
|
65
|
+
});
|
|
66
|
+
expect(next.chargedTotal).toEqual({ amount: 58, currency: "USD" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("claims no payment when the bundle had no intent to replace", () => {
|
|
70
|
+
// A bundle that has not reached start-payment yet: the client's own
|
|
71
|
+
// start-payment will pick the new figure up, so nothing may supersede it.
|
|
72
|
+
const next = bundleCouponSummary(answer({ paymentSecret: "", paymentId: "", chargedAmount: 0, chargedCurrency: "" }));
|
|
73
|
+
|
|
74
|
+
expect(next.paymentUpdate).toBeNull();
|
|
75
|
+
expect(next.chargedTotal).toBeNull();
|
|
76
|
+
// The discount is still real and still shown.
|
|
77
|
+
expect(next.discountAmount).toBe(2);
|
|
78
|
+
expect(next.appliedCoupon).toEqual({ code: "APPLYME20", discountAmount: 2 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not claim a coupon it cannot name", () => {
|
|
82
|
+
// A reduction with no coupon behind it is reported as an amount and nothing
|
|
83
|
+
// more — inventing a label is how a buyer ends up chasing a code that
|
|
84
|
+
// does not exist.
|
|
85
|
+
const next = bundleCouponSummary(answer({ appliedCoupons: [] }));
|
|
86
|
+
|
|
87
|
+
expect(next.discountAmount).toBe(2);
|
|
88
|
+
expect(next.appliedCoupon).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("bundleCouponSummary — removing", () => {
|
|
93
|
+
it("restores the undiscounted figure and drops the applied coupon", () => {
|
|
94
|
+
const next = bundleCouponSummary(
|
|
95
|
+
answer({
|
|
96
|
+
discountCents: 0,
|
|
97
|
+
couponId: null,
|
|
98
|
+
totalCents: 6000,
|
|
99
|
+
appliedCoupons: [],
|
|
100
|
+
paymentSecret: "pi_3_secret",
|
|
101
|
+
paymentId: "pi_3",
|
|
102
|
+
chargedAmount: 60,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(next.discountAmount).toBe(0);
|
|
107
|
+
expect(next.appliedCoupon).toBeNull();
|
|
108
|
+
// The full price is what the buyer is now charged, on a fresh intent.
|
|
109
|
+
expect(next.chargedTotal).toEqual({ amount: 60, currency: "USD" });
|
|
110
|
+
expect(next.paymentUpdate?.paymentId).toBe("pi_3");
|
|
111
|
+
expect(next.settledCheckoutId).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("bundleCouponSummary — a bundle taken to zero", () => {
|
|
116
|
+
it("reports the settled checkout so the buyer is sent to finalise", () => {
|
|
117
|
+
// There is no intent to confirm: the server settled and fulfilled it. Left
|
|
118
|
+
// unhandled, the buyer waits in front of a payment form that will never be
|
|
119
|
+
// asked to do anything.
|
|
120
|
+
const next = bundleCouponSummary(
|
|
121
|
+
answer({
|
|
122
|
+
discountCents: 6000,
|
|
123
|
+
totalCents: 0,
|
|
124
|
+
appliedCoupons: [{ code: "ALLOFIT", discountKind: "simple", discountAmount: 60 }],
|
|
125
|
+
paymentSecret: "",
|
|
126
|
+
paymentId: "",
|
|
127
|
+
chargedAmount: 0,
|
|
128
|
+
chargedCurrency: "",
|
|
129
|
+
isFreeCheckout: true,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(next.settledCheckoutId).toBe("checkout-1");
|
|
134
|
+
expect(next.paymentUpdate).toBeNull();
|
|
135
|
+
expect(next.chargedTotal).toBeNull();
|
|
136
|
+
expect(next.discountAmount).toBe(60);
|
|
137
|
+
expect(next.appliedCoupon).toEqual({ code: "ALLOFIT", discountAmount: 60 });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("bundleCouponRequestBody", () => {
|
|
142
|
+
const base = { profileId: "p1", checkoutId: "c1", returnUrl: "https://x.test/finalise?checkoutId=c1" };
|
|
143
|
+
|
|
144
|
+
it("sends the trimmed code when applying", () => {
|
|
145
|
+
expect(bundleCouponRequestBody({ ...base, couponCode: " applyme20 " })).toEqual({
|
|
146
|
+
...base,
|
|
147
|
+
couponCode: "applyme20",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("omits the field entirely when removing", () => {
|
|
152
|
+
// The endpoint validates `couponCode` as `min(1)`, so an empty string is a
|
|
153
|
+
// 400 — "take my code off" would fail and the discount would stay on.
|
|
154
|
+
expect(bundleCouponRequestBody(base)).toEqual(base);
|
|
155
|
+
expect(bundleCouponRequestBody({ ...base, couponCode: "" })).toEqual(base);
|
|
156
|
+
expect(bundleCouponRequestBody({ ...base, couponCode: " " })).toEqual(base);
|
|
157
|
+
expect("couponCode" in bundleCouponRequestBody(base)).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("bundleReturnUrl", () => {
|
|
162
|
+
it("returns the buyer to the bundle's own finalise page", () => {
|
|
163
|
+
// `?checkoutId=` and not `?orderId=`: the finalise page resolves a bundle
|
|
164
|
+
// through the checkout, and a bundle's children are several orders.
|
|
165
|
+
expect(bundleReturnUrl("https://artist.test", "/checkout/finalise", "c1")).toBe(
|
|
166
|
+
"https://artist.test/checkout/finalise?checkoutId=c1",
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ApplyCheckoutCouponResult, AppliedBundleCoupon } from "../../../data/queries/useCheckouts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two decisions a bundle's discount code turns on, kept out of the hook so
|
|
5
|
+
* they can be asserted without a provider tree.
|
|
6
|
+
*
|
|
7
|
+
* Both exist because a bundle is the odd one out: it is the only checkout that
|
|
8
|
+
* answers in MINOR units, the only one whose "remove" is expressed by the
|
|
9
|
+
* ABSENCE of a field rather than an empty one, and the only one that can settle
|
|
10
|
+
* itself — a code that takes it to zero leaves nothing to pay for.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What the summary and the payment element read after an apply or a remove. */
|
|
14
|
+
export type BundleCouponSummary = {
|
|
15
|
+
/** MAJOR units, converted from the endpoint's minor-unit answer. */
|
|
16
|
+
discountAmount: number;
|
|
17
|
+
/**
|
|
18
|
+
* The discount as the buyer sees it, or null when nothing came off.
|
|
19
|
+
*
|
|
20
|
+
* Named from the server's `appliedCoupons`, so an AUTOMATIC discount the buyer
|
|
21
|
+
* never typed is labelled with its real code instead of appearing as an
|
|
22
|
+
* unexplained reduction.
|
|
23
|
+
*/
|
|
24
|
+
appliedCoupon: { code: string; discountAmount: number } | null;
|
|
25
|
+
/**
|
|
26
|
+
* The re-minted intent. Applying re-prices AND re-mints, so the payment
|
|
27
|
+
* element must move onto this secret — the previous one still quotes the
|
|
28
|
+
* pre-coupon amount. Null when there was no intent to replace (the bundle had
|
|
29
|
+
* not started payment) or when the bundle settled for free.
|
|
30
|
+
*/
|
|
31
|
+
paymentUpdate: {
|
|
32
|
+
paymentSecret: string;
|
|
33
|
+
paymentId: string;
|
|
34
|
+
chargedAmount: number;
|
|
35
|
+
chargedCurrency: string;
|
|
36
|
+
} | null;
|
|
37
|
+
/** The charged total for the summary, or null when there is nothing to charge. */
|
|
38
|
+
chargedTotal: { amount: number; currency: string } | null;
|
|
39
|
+
/**
|
|
40
|
+
* Set when the re-price took the bundle to zero. The server has already
|
|
41
|
+
* settled and fulfilled it, so the buyer belongs on the finalise page rather
|
|
42
|
+
* than in front of a payment form that will never be asked to do anything.
|
|
43
|
+
*/
|
|
44
|
+
settledCheckoutId: string | null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function bundleCouponSummary(data: ApplyCheckoutCouponResult): BundleCouponSummary {
|
|
48
|
+
const discountAmount = (data.discountCents ?? 0) / 100;
|
|
49
|
+
const named: AppliedBundleCoupon | undefined = data.appliedCoupons?.[0];
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
discountAmount,
|
|
53
|
+
// A discount with no coupon behind it cannot be labelled honestly, so it is
|
|
54
|
+
// not claimed as one — `discountAmount` still reports the reduction.
|
|
55
|
+
appliedCoupon: discountAmount > 0 && named ? { code: named.code, discountAmount } : null,
|
|
56
|
+
paymentUpdate:
|
|
57
|
+
data.paymentSecret && data.paymentId
|
|
58
|
+
? {
|
|
59
|
+
paymentSecret: data.paymentSecret,
|
|
60
|
+
paymentId: data.paymentId,
|
|
61
|
+
chargedAmount: data.chargedAmount,
|
|
62
|
+
chargedCurrency: data.chargedCurrency,
|
|
63
|
+
}
|
|
64
|
+
: null,
|
|
65
|
+
chargedTotal: data.chargedAmount > 0 ? { amount: data.chargedAmount, currency: data.chargedCurrency } : null,
|
|
66
|
+
settledCheckoutId: data.isFreeCheckout ? data.checkoutId : null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The body for `POST /public/checkouts/apply-coupon`.
|
|
72
|
+
*
|
|
73
|
+
* Removing sends NO `couponCode` at all. An empty string is a validation error
|
|
74
|
+
* on that endpoint (`min(1)`), so sending one would turn "take my code off" into
|
|
75
|
+
* a 400 and leave the discount in place.
|
|
76
|
+
*/
|
|
77
|
+
export function bundleCouponRequestBody(input: {
|
|
78
|
+
/** Optional only because `useForge()` types it so before the provider resolves. */
|
|
79
|
+
profileId?: string;
|
|
80
|
+
checkoutId: string;
|
|
81
|
+
returnUrl: string;
|
|
82
|
+
couponCode?: string;
|
|
83
|
+
}): Record<string, unknown> {
|
|
84
|
+
const code = input.couponCode?.trim();
|
|
85
|
+
return {
|
|
86
|
+
profileId: input.profileId,
|
|
87
|
+
checkoutId: input.checkoutId,
|
|
88
|
+
returnUrl: input.returnUrl,
|
|
89
|
+
...(code ? { couponCode: code } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The return URL a bundle's re-minted intent comes back to. */
|
|
94
|
+
export function bundleReturnUrl(origin: string, finalisePath: string, checkoutId: string): string {
|
|
95
|
+
return `${origin}${finalisePath}?checkoutId=${checkoutId}`;
|
|
96
|
+
}
|