@tribe-nest/forge 3.65.0 → 3.69.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/useCheckoutLink.ts +70 -0
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +42 -0
- package/src/i18n/en.json +42 -0
- package/src/index.ts +1 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/runtime/pages.ts +1 -0
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/headless/checkoutLink/useCheckoutLinkPayment.ts +78 -0
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/CheckoutLinkPayment.tsx +303 -0
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +231 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { useForgeT } from "../../i18n";
|
|
3
|
+
import { useCheckoutLinkPayment } from "../headless/checkoutLink/useCheckoutLinkPayment";
|
|
4
|
+
import { useInventoryHold } from "../headless/checkout/useInventoryHold";
|
|
5
|
+
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
6
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
7
|
+
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
8
|
+
import type { CheckoutLinkView } from "../../data/queries/useCheckoutLink";
|
|
9
|
+
import { Button } from "./Button";
|
|
10
|
+
import { HoldNotice } from "./HoldNotice";
|
|
11
|
+
import { Loading } from "./Loading";
|
|
12
|
+
import { PaystackPayButton } from "./PaystackPayButton";
|
|
13
|
+
|
|
14
|
+
export interface CheckoutLinkPaymentProps {
|
|
15
|
+
/** The token from the link (`/i/checkout/pay/<token>`). */
|
|
16
|
+
token?: string | null;
|
|
17
|
+
/**
|
|
18
|
+
* True when the payment provider has just sent the buyer back (`?finalise=1`
|
|
19
|
+
* or Stripe's `redirect_status`): the page settles the link instead of
|
|
20
|
+
* offering to pay.
|
|
21
|
+
*/
|
|
22
|
+
returning?: boolean;
|
|
23
|
+
/** Where "browse the store" goes when the link can no longer be paid. Default `/i/store`. */
|
|
24
|
+
storePath?: string;
|
|
25
|
+
/** Format a numeric amount for display. */
|
|
26
|
+
formatAmount?: (amount: number, currencyCode?: string) => string;
|
|
27
|
+
className?: string;
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The hosted pay page for a checkout link (`/i/checkout/pay/<token>`).
|
|
33
|
+
*
|
|
34
|
+
* A business (or its agent) held an item for this person and sent them here.
|
|
35
|
+
* The page shows exactly what is held, at what price and until when, and
|
|
36
|
+
* continues to the normal payment step. It does not build a checkout: the
|
|
37
|
+
* checkout already exists on the server, and the `<Checkout>` drop-in always
|
|
38
|
+
* makes a new one, which is why this is its own component.
|
|
39
|
+
*
|
|
40
|
+
* Pressing Pay asks the server first (the link still open, the hold still
|
|
41
|
+
* there or taken again); a refusal is shown in the server's own words, before
|
|
42
|
+
* any card form exists. A paid, expired or cancelled link says which, with a
|
|
43
|
+
* way to the store.
|
|
44
|
+
*/
|
|
45
|
+
export function CheckoutLinkPayment({
|
|
46
|
+
token,
|
|
47
|
+
returning = false,
|
|
48
|
+
storePath = "/i/store",
|
|
49
|
+
formatAmount,
|
|
50
|
+
className,
|
|
51
|
+
style,
|
|
52
|
+
}: CheckoutLinkPaymentProps) {
|
|
53
|
+
const tr = useForgeT();
|
|
54
|
+
const t = useThemeTokens();
|
|
55
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
56
|
+
const renderPayment = usePaymentRenderer();
|
|
57
|
+
const page = useCheckoutLinkPayment(token, { returning });
|
|
58
|
+
const hold = useInventoryHold(page.holdExpiresAt);
|
|
59
|
+
|
|
60
|
+
const shell: CSSProperties = {
|
|
61
|
+
maxWidth: 560,
|
|
62
|
+
margin: "0 auto",
|
|
63
|
+
color: t.text,
|
|
64
|
+
fontFamily: t.fontFamily,
|
|
65
|
+
...style,
|
|
66
|
+
};
|
|
67
|
+
const heading: CSSProperties = { fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily, marginBottom: 8 };
|
|
68
|
+
const muted: CSSProperties = { color: t.muted, fontSize: 15, lineHeight: 1.6 };
|
|
69
|
+
|
|
70
|
+
const toStore = (
|
|
71
|
+
<div style={{ marginTop: 24 }}>
|
|
72
|
+
<Button onClick={() => go(storePath)} data-testid="checkout-link-store">
|
|
73
|
+
{tr("forge.checkout_link_payment.browse_store")}
|
|
74
|
+
</Button>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
// A booking whose time is gone: the way on is another time of the same service.
|
|
78
|
+
const otherTimes = (path: string | null | undefined) =>
|
|
79
|
+
path ? (
|
|
80
|
+
<div style={{ marginTop: 24 }}>
|
|
81
|
+
<Button onClick={() => go(path)} data-testid="checkout-link-other-times">
|
|
82
|
+
{tr("forge.checkout_link_payment.other_times")}
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
) : (
|
|
86
|
+
toStore
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// ── The return leg: settle, then say what is true ────────────────────────
|
|
90
|
+
if (returning) {
|
|
91
|
+
if (page.finalize.isLoading) return <Loading fullPage />;
|
|
92
|
+
const outcome = page.finalize.data?.outcome;
|
|
93
|
+
if (page.finalize.error || !page.finalize.data) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="not-found">
|
|
96
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.not_found_title")}</h1>
|
|
97
|
+
<p style={muted}>{tr("forge.checkout_link_payment.not_found_body")}</p>
|
|
98
|
+
{toStore}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (outcome === "needs_refund" || page.finalize.data.link.needsRefund) {
|
|
103
|
+
return (
|
|
104
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="needs-refund">
|
|
105
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.too_late_title")}</h1>
|
|
106
|
+
<p style={muted}>{tr("forge.checkout_link_payment.too_late_body")}</p>
|
|
107
|
+
{toStore}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (outcome === "fulfilled" || outcome === "paid") {
|
|
112
|
+
return (
|
|
113
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="paid">
|
|
114
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.paid_title")}</h1>
|
|
115
|
+
<Summary view={page.finalize.data.link} fmt={fmt} />
|
|
116
|
+
<p style={{ ...muted, marginTop: 16 }}>{tr("forge.checkout_link_payment.paid_body")}</p>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
// Not paid (a declined card, a closed window): the link is still open, so
|
|
121
|
+
// they can try again from the same address.
|
|
122
|
+
return (
|
|
123
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="unpaid">
|
|
124
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.unpaid_title")}</h1>
|
|
125
|
+
<p style={muted}>{tr("forge.checkout_link_payment.unpaid_body")}</p>
|
|
126
|
+
<div style={{ marginTop: 24 }}>
|
|
127
|
+
<Button onClick={() => go(typeof window !== "undefined" ? window.location.pathname : "")}>
|
|
128
|
+
{tr("forge.checkout_link_payment.try_again")}
|
|
129
|
+
</Button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── The page as the email opens it ───────────────────────────────────────
|
|
136
|
+
if (page.link.isLoading) return <Loading fullPage />;
|
|
137
|
+
const view = page.link.data;
|
|
138
|
+
if (page.link.error || !view) {
|
|
139
|
+
return (
|
|
140
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="not-found">
|
|
141
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.not_found_title")}</h1>
|
|
142
|
+
<p style={muted}>{tr("forge.checkout_link_payment.not_found_body")}</p>
|
|
143
|
+
{toStore}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (view.status !== "open") {
|
|
149
|
+
const closed = view.needsRefund
|
|
150
|
+
? { title: tr("forge.checkout_link_payment.too_late_title"), body: tr("forge.checkout_link_payment.too_late_body") }
|
|
151
|
+
: view.status === "paid"
|
|
152
|
+
? { title: tr("forge.checkout_link_payment.paid_title"), body: tr("forge.checkout_link_payment.paid_body") }
|
|
153
|
+
: view.status === "cancelled"
|
|
154
|
+
? {
|
|
155
|
+
title: tr("forge.checkout_link_payment.cancelled_title"),
|
|
156
|
+
body: tr("forge.checkout_link_payment.cancelled_body"),
|
|
157
|
+
}
|
|
158
|
+
: { title: tr("forge.checkout_link_payment.ended_title"), body: tr("forge.checkout_link_payment.ended_body") };
|
|
159
|
+
return (
|
|
160
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state={view.status}>
|
|
161
|
+
<h1 style={heading}>{closed.title}</h1>
|
|
162
|
+
<p style={muted}>{closed.body}</p>
|
|
163
|
+
{view.status !== "paid" && (view.booking ? otherTimes(view.booking.servicePath) : toStore)}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const total = fmt(view.totalCents / 100, view.currency);
|
|
169
|
+
const refused = page.refusal;
|
|
170
|
+
const timeTaken = !!refused && refused.code === "INVENTORY_HOLD_EXPIRED" && !!view.booking;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className={className} style={shell} data-testid="checkout-link-page" data-state="open">
|
|
174
|
+
<h1 style={heading}>{tr("forge.checkout_link_payment.title")}</h1>
|
|
175
|
+
<Summary view={view} fmt={fmt} />
|
|
176
|
+
|
|
177
|
+
<div style={{ marginTop: 16 }}>
|
|
178
|
+
<HoldNotice
|
|
179
|
+
hold={hold}
|
|
180
|
+
noun={tr(view.booking ? "forge.checkout_link_payment.hold_noun_booking" : "forge.checkout_link_payment.hold_noun")}
|
|
181
|
+
/>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{refused && (
|
|
185
|
+
<p role="alert" data-testid="checkout-link-refusal" style={{ color: t.secondary, fontSize: 14, marginTop: 8 }}>
|
|
186
|
+
{refused.message || tr("forge.checkout_link_payment.payment_unavailable")}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
{timeTaken && otherTimes(view.booking?.servicePath)}
|
|
190
|
+
|
|
191
|
+
<div style={{ marginTop: 16 }}>
|
|
192
|
+
{page.isPaystack ? (
|
|
193
|
+
page.canOpenPaystack ? (
|
|
194
|
+
<PaystackPayButton
|
|
195
|
+
onPay={page.openPaystackCheckout}
|
|
196
|
+
label={tr("forge.checkout_link_payment.pay_amount", { amount: total })}
|
|
197
|
+
/>
|
|
198
|
+
) : (
|
|
199
|
+
<Loading label={tr("forge.checkout_link_payment.preparing_payment")} size={22} />
|
|
200
|
+
)
|
|
201
|
+
) : page.clientSecret ? (
|
|
202
|
+
renderPayment?.({
|
|
203
|
+
clientSecret: page.clientSecret,
|
|
204
|
+
returnUrl: page.returnUrl,
|
|
205
|
+
amount: view.totalCents / 100,
|
|
206
|
+
currency: view.currency,
|
|
207
|
+
mode: "redirect",
|
|
208
|
+
})
|
|
209
|
+
) : timeTaken ? null : (
|
|
210
|
+
<Button
|
|
211
|
+
fullWidth
|
|
212
|
+
loading={page.isStarting}
|
|
213
|
+
disabled={hold.hasLapsed && !refused}
|
|
214
|
+
onClick={() => void page.pay()}
|
|
215
|
+
data-testid="checkout-link-pay"
|
|
216
|
+
>
|
|
217
|
+
{tr("forge.checkout_link_payment.pay_amount", { amount: total })}
|
|
218
|
+
</Button>
|
|
219
|
+
)}
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function Summary({
|
|
226
|
+
view,
|
|
227
|
+
fmt,
|
|
228
|
+
}: {
|
|
229
|
+
view: CheckoutLinkView;
|
|
230
|
+
fmt: (amount: number, currencyCode?: string) => string;
|
|
231
|
+
}) {
|
|
232
|
+
const tr = useForgeT();
|
|
233
|
+
const t = useThemeTokens();
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
data-testid="checkout-link-summary"
|
|
237
|
+
style={{
|
|
238
|
+
border: `1px solid ${t.border}`,
|
|
239
|
+
borderRadius: t.cornerRadius,
|
|
240
|
+
padding: 20,
|
|
241
|
+
background: t.surface,
|
|
242
|
+
}}
|
|
243
|
+
>
|
|
244
|
+
<div style={{ fontSize: 18, fontWeight: 700 }} data-testid="checkout-link-item">
|
|
245
|
+
{view.item?.title}
|
|
246
|
+
</div>
|
|
247
|
+
{view.booking ? (
|
|
248
|
+
<div style={{ marginTop: 12, color: t.muted }}>
|
|
249
|
+
{view.booking.sessions.map((session) => (
|
|
250
|
+
// The buyer's own clock: the time is shown in the zone of the
|
|
251
|
+
// device reading it, with the zone named.
|
|
252
|
+
<div key={session.startsAt} data-testid="checkout-link-session" suppressHydrationWarning>
|
|
253
|
+
{formatLocalTime(session.startsAt)}
|
|
254
|
+
</div>
|
|
255
|
+
))}
|
|
256
|
+
{view.status === "open" && (
|
|
257
|
+
<div style={{ marginTop: 8, fontSize: 14 }} data-testid="checkout-link-held-until" suppressHydrationWarning>
|
|
258
|
+
{tr("forge.checkout_link_payment.held_until", { time: formatLocalTime(view.expiresAt) })}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 12, color: t.muted }}>
|
|
264
|
+
<span data-testid="checkout-link-quantity">
|
|
265
|
+
{tr("forge.checkout_link_payment.quantity", { quantity: view.quantity })}
|
|
266
|
+
</span>
|
|
267
|
+
{view.unitPriceCents !== null && (
|
|
268
|
+
<span>{tr("forge.checkout_link_payment.each", { price: fmt(view.unitPriceCents / 100, view.currency) })}</span>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
<div
|
|
273
|
+
style={{ display: "flex", justifyContent: "space-between", marginTop: 12, fontSize: 20, fontWeight: 800 }}
|
|
274
|
+
>
|
|
275
|
+
<span>{tr("forge.checkout_link_payment.total")}</span>
|
|
276
|
+
<span data-testid="checkout-link-total" style={{ color: t.primary }}>
|
|
277
|
+
{fmt(view.totalCents / 100, view.currency)}
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function go(path: string) {
|
|
285
|
+
if (typeof window !== "undefined") window.location.href = path;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A moment in the reader's own time zone, with the zone named ("Tuesday 14 October, 10:00 CEST"). */
|
|
289
|
+
function formatLocalTime(iso: string, timeZone?: string): string {
|
|
290
|
+
try {
|
|
291
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
292
|
+
weekday: "long",
|
|
293
|
+
day: "numeric",
|
|
294
|
+
month: "long",
|
|
295
|
+
hour: "2-digit",
|
|
296
|
+
minute: "2-digit",
|
|
297
|
+
timeZoneName: "short",
|
|
298
|
+
...(timeZone ? { timeZone } : {}),
|
|
299
|
+
}).format(new Date(iso));
|
|
300
|
+
} catch {
|
|
301
|
+
return new Date(iso).toISOString();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useForgeT } from "../../i18n";
|
|
|
4
4
|
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
5
5
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { readableTextOn } from "../theme/contrast";
|
|
7
|
+
import { useBrandLegalUrls } from "../../data/queries/useWebsite";
|
|
7
8
|
|
|
8
9
|
export interface CookieConsentProps {
|
|
9
10
|
/** Heading text. Defaults to Forge's own translated heading. */
|
|
@@ -17,19 +18,20 @@ export interface CookieConsentProps {
|
|
|
17
18
|
/** Link to the site's own privacy policy (rendered after the description). */
|
|
18
19
|
privacyPolicyHref?: string;
|
|
19
20
|
/**
|
|
20
|
-
* Fall back to
|
|
21
|
-
*
|
|
21
|
+
* Fall back to the brand's hosted privacy policy when the site doesn't have
|
|
22
|
+
* its own: TribeNest's, or Mandatera's for a law firm, from `site-config`
|
|
23
|
+
* (https://www.tribenest.co/privacy when the backend sends none).
|
|
22
24
|
* Ignored if `privacyPolicyHref` is set. Opens in a new tab.
|
|
23
25
|
*/
|
|
24
26
|
useTribeNestPrivacy?: boolean;
|
|
25
27
|
/**
|
|
26
28
|
* Controlled visibility. Leave undefined for the default behaviour (show once
|
|
27
29
|
* until the visitor decides). Set `true` to force it open again so a visitor
|
|
28
|
-
* can change their choice
|
|
30
|
+
* can change their choice: wire a "Cookie settings" link to a boolean state
|
|
29
31
|
* and pass it here (see the site-starter footer for the pattern).
|
|
30
32
|
*/
|
|
31
33
|
open?: boolean;
|
|
32
|
-
/** Called after the visitor makes a choice or dismisses
|
|
34
|
+
/** Called after the visitor makes a choice or dismisses. Use it to reset your
|
|
33
35
|
* controlled `open` state back to false. */
|
|
34
36
|
onClose?: () => void;
|
|
35
37
|
/** Extra class on the outer wrapper. */
|
|
@@ -42,8 +44,8 @@ const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
|
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* Drop-in cookie-consent banner. Shows once until the visitor decides, writes to
|
|
45
|
-
* the shared consent store, and
|
|
46
|
-
* store
|
|
47
|
+
* the shared consent store, and (because `<ForgeAnalytics />` reads the same
|
|
48
|
+
* store) actually gates first-party analytics (performance) and the Meta Pixel
|
|
47
49
|
* (marketing). Themed via Forge tokens. Drop it into the site root:
|
|
48
50
|
* `<CookieConsent privacyPolicyHref="/privacy" />`.
|
|
49
51
|
*/
|
|
@@ -59,8 +61,10 @@ export function CookieConsent({
|
|
|
59
61
|
}: CookieConsentProps) {
|
|
60
62
|
const t = useThemeTokens();
|
|
61
63
|
const tr = useForgeT();
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
+
const brandLegal = useBrandLegalUrls();
|
|
65
|
+
const privacyHref =
|
|
66
|
+
privacyPolicyHref ?? (useTribeNestPrivacy ? (brandLegal?.privacy ?? TRIBENEST_PRIVACY_URL) : undefined);
|
|
67
|
+
// The brand's hosted policy opens in a new tab; a same-origin custom page stays.
|
|
64
68
|
const privacyExternal = !privacyPolicyHref && useTribeNestPrivacy;
|
|
65
69
|
const { ready, decided, consent, acceptAll, rejectAll, save } = useCookieConsent();
|
|
66
70
|
const [draft, setDraft] = useState<Draft>({
|
|
@@ -121,7 +125,7 @@ export function CookieConsent({
|
|
|
121
125
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 800, fontFamily: t.headingFontFamily }}>
|
|
122
126
|
{title ?? tr("forge.cookie_consent.title")}
|
|
123
127
|
</h2>
|
|
124
|
-
{/* Close is only meaningful once a choice exists (reopened panel)
|
|
128
|
+
{/* Close is only meaningful once a choice exists (reopened panel);
|
|
125
129
|
on first run the visitor must pick an option. */}
|
|
126
130
|
{decided && (
|
|
127
131
|
<button
|
|
@@ -9,6 +9,7 @@ import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
|
9
9
|
import { PageActions } from "./PageActions";
|
|
10
10
|
import { DEFAULT_MUSIC_LINK_STYLE, MusicLinkLayout, type LayoutParts, type MusicLinkStyle } from "./musicLinkStyles";
|
|
11
11
|
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
12
|
+
import { useBrandLegalUrls } from "../../data/queries/useWebsite";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* How each streaming service is presented.
|
|
@@ -57,7 +58,7 @@ const SERVICES: Record<string, { label: string; color: string; action?: string }
|
|
|
57
58
|
*/
|
|
58
59
|
const OVERLAY_Z_INDEX = 60;
|
|
59
60
|
|
|
60
|
-
/** TribeNest's hosted policy, matching
|
|
61
|
+
/** TribeNest's hosted policy, the last fallback, matching `CookieConsent`. */
|
|
61
62
|
const TRIBENEST_PRIVACY_URL = "https://www.tribenest.co/privacy";
|
|
62
63
|
|
|
63
64
|
const serviceMeta = (platform: string) => SERVICES[platform] ?? { label: platform, color: "#666666", action: "Open" };
|
|
@@ -69,7 +70,8 @@ export interface MusicLinkPageProps {
|
|
|
69
70
|
initialLink?: IMusicLink | null;
|
|
70
71
|
/** Heading above the service list. */
|
|
71
72
|
listHeading?: string;
|
|
72
|
-
/** The site's own privacy policy. Falls back to
|
|
73
|
+
/** The site's own privacy policy. Falls back to the brand's hosted one
|
|
74
|
+
* (TribeNest's, or Mandatera's for a law firm) from `site-config`. */
|
|
73
75
|
privacyHref?: string;
|
|
74
76
|
/** The "Powered by TribeNest" credit. */
|
|
75
77
|
showPoweredBy?: boolean;
|
|
@@ -375,7 +377,8 @@ function SmartLinkFooter({
|
|
|
375
377
|
showPoweredBy: boolean;
|
|
376
378
|
}) {
|
|
377
379
|
const { reopen } = useCookieConsent();
|
|
378
|
-
const
|
|
380
|
+
const brandLegal = useBrandLegalUrls();
|
|
381
|
+
const href = privacyHref ?? brandLegal?.privacy ?? TRIBENEST_PRIVACY_URL;
|
|
379
382
|
const external = !privacyHref;
|
|
380
383
|
|
|
381
384
|
const linkStyle: CSSProperties = {
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import type { CheckoutLinkView } from "../../../data/queries/useCheckoutLink";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The hosted pay page for a checkout link (`/i/checkout/pay/<token>`).
|
|
11
|
+
*
|
|
12
|
+
* `useCheckoutLinkPayment` is stubbed at the module boundary: the server
|
|
13
|
+
* decides everything (open, closed, paid too late); what can only go wrong
|
|
14
|
+
* here is the page drawing the wrong state. Each state is a different fact
|
|
15
|
+
* the buyer acts on: pay now, it is already paid, the hold ended, it was
|
|
16
|
+
* withdrawn, or a refund is coming.
|
|
17
|
+
*
|
|
18
|
+
* Rendered through `react-dom/server`, so no DOM and no effects.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type Page = ReturnType<typeof import("../../headless/checkoutLink/useCheckoutLinkPayment").useCheckoutLinkPayment>;
|
|
22
|
+
const state = vi.hoisted(() => ({ current: null as unknown }));
|
|
23
|
+
|
|
24
|
+
vi.mock("../../headless/checkoutLink/useCheckoutLinkPayment", () => ({
|
|
25
|
+
useCheckoutLinkPayment: () => state.current,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { CheckoutLinkPayment } = await import("../CheckoutLinkPayment");
|
|
29
|
+
|
|
30
|
+
const view = (overrides: Partial<CheckoutLinkView> = {}): CheckoutLinkView => ({
|
|
31
|
+
status: "open",
|
|
32
|
+
needsRefund: false,
|
|
33
|
+
item: { type: "event_ticket", title: "Spring Show: General" },
|
|
34
|
+
quantity: 2,
|
|
35
|
+
unitPriceCents: 2500,
|
|
36
|
+
totalCents: 5000,
|
|
37
|
+
currency: "USD",
|
|
38
|
+
expiresAt: new Date(Date.now() + 20 * 60_000).toISOString(),
|
|
39
|
+
...overrides,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const page = (overrides: Record<string, unknown> = {}) =>
|
|
43
|
+
({
|
|
44
|
+
link: { isLoading: false, error: null, data: view() },
|
|
45
|
+
finalize: { isLoading: false, error: null, data: undefined },
|
|
46
|
+
returnUrl: "https://shop.test/i/checkout/pay/tok?finalise=1",
|
|
47
|
+
pay: async () => {},
|
|
48
|
+
isStarting: false,
|
|
49
|
+
clientSecret: undefined,
|
|
50
|
+
isPaystack: false,
|
|
51
|
+
canOpenPaystack: false,
|
|
52
|
+
openPaystackCheckout: async () => "unavailable",
|
|
53
|
+
refusal: null,
|
|
54
|
+
holdExpiresAt: view().expiresAt,
|
|
55
|
+
...overrides,
|
|
56
|
+
}) as unknown as Page;
|
|
57
|
+
|
|
58
|
+
function render(current: Page, props: { returning?: boolean } = {}): string {
|
|
59
|
+
state.current = current;
|
|
60
|
+
return renderToStaticMarkup(
|
|
61
|
+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
|
62
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
|
|
63
|
+
<PublicAuthContext.Provider value={{ currencies: null, userSelectedCurrency: "USD" } as never}>
|
|
64
|
+
<ForgeThemeProvider
|
|
65
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
66
|
+
>
|
|
67
|
+
<CheckoutLinkPayment token="tok" formatAmount={(n) => `$${n.toFixed(2)}`} {...props} />
|
|
68
|
+
</ForgeThemeProvider>
|
|
69
|
+
</PublicAuthContext.Provider>
|
|
70
|
+
</ForgeClientProvider>
|
|
71
|
+
</QueryClientProvider>,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("CheckoutLinkPayment", () => {
|
|
76
|
+
it("an open link shows the item, the quantity, the price and a Pay button for the total", () => {
|
|
77
|
+
const html = render(page());
|
|
78
|
+
expect(html).toContain('data-state="open"');
|
|
79
|
+
expect(html).toContain("Held for you");
|
|
80
|
+
expect(html).toContain("Spring Show: General");
|
|
81
|
+
expect(html).toContain("Quantity: 2");
|
|
82
|
+
expect(html).toContain("$25.00 each");
|
|
83
|
+
expect(html).toContain("$50.00");
|
|
84
|
+
expect(html).toContain('data-testid="checkout-link-pay"');
|
|
85
|
+
expect(html).toContain("Pay $50.00");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("a refused Pay shows the server's own words", () => {
|
|
89
|
+
const html = render(
|
|
90
|
+
page({ refusal: { message: "These tickets are no longer held for you.", code: "INVENTORY_HOLD_EXPIRED", status: null } }),
|
|
91
|
+
);
|
|
92
|
+
expect(html).toContain('data-testid="checkout-link-refusal"');
|
|
93
|
+
expect(html).toContain("These tickets are no longer held for you.");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
[{ status: "paid" as const }, "Payment received", false],
|
|
98
|
+
[{ status: "expired" as const }, "This reservation has ended", true],
|
|
99
|
+
[{ status: "cancelled" as const }, "This link is no longer available", true],
|
|
100
|
+
[{ status: "expired" as const, needsRefund: true }, "Your payment will be refunded", true],
|
|
101
|
+
])("a closed link (%o) says so, and offers the store unless it was paid", (overrides, title, offersStore) => {
|
|
102
|
+
const html = render(page({ link: { isLoading: false, error: null, data: view(overrides) } }));
|
|
103
|
+
expect(html).toContain(title);
|
|
104
|
+
expect(html).not.toContain('data-testid="checkout-link-pay"');
|
|
105
|
+
expect(html.includes('data-testid="checkout-link-store"')).toBe(offersStore);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("a booking", () => {
|
|
109
|
+
const START = "2026-11-03T15:00:00.000Z";
|
|
110
|
+
const booking = (overrides: Partial<CheckoutLinkView> = {}) =>
|
|
111
|
+
view({
|
|
112
|
+
item: { type: "booking", title: "Initial consultation" },
|
|
113
|
+
quantity: 1,
|
|
114
|
+
unitPriceCents: 15000,
|
|
115
|
+
totalCents: 15000,
|
|
116
|
+
expiresAt: "2026-11-01T12:30:00.000Z",
|
|
117
|
+
booking: {
|
|
118
|
+
sessions: [{ startsAt: START, endsAt: "2026-11-03T16:00:00.000Z" }],
|
|
119
|
+
timezone: "America/New_York",
|
|
120
|
+
servicePath: "/i/coaching/initial-consultation",
|
|
121
|
+
},
|
|
122
|
+
...overrides,
|
|
123
|
+
});
|
|
124
|
+
// What the page must print: the buyer's own clock, the zone named.
|
|
125
|
+
const local = (iso: string) =>
|
|
126
|
+
new Intl.DateTimeFormat(undefined, {
|
|
127
|
+
weekday: "long",
|
|
128
|
+
day: "numeric",
|
|
129
|
+
month: "long",
|
|
130
|
+
hour: "2-digit",
|
|
131
|
+
minute: "2-digit",
|
|
132
|
+
timeZoneName: "short",
|
|
133
|
+
}).format(new Date(iso));
|
|
134
|
+
|
|
135
|
+
it("shows the service, the session in the reader's own zone, until when it is held, and the price, with no quantity", () => {
|
|
136
|
+
const html = render(page({ link: { isLoading: false, error: null, data: booking() } }));
|
|
137
|
+
expect(html).toContain('data-state="open"');
|
|
138
|
+
expect(html).toContain("Initial consultation");
|
|
139
|
+
expect(html).toContain('data-testid="checkout-link-session"');
|
|
140
|
+
expect(html).toContain(local(START));
|
|
141
|
+
expect(html).toContain(`Held for you until ${local("2026-11-01T12:30:00.000Z")}`);
|
|
142
|
+
expect(html).toContain("Pay $150.00");
|
|
143
|
+
expect(html).not.toContain('data-testid="checkout-link-quantity"');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("lists every session of a package", () => {
|
|
147
|
+
const second = "2026-11-10T15:00:00.000Z";
|
|
148
|
+
const html = render(
|
|
149
|
+
page({
|
|
150
|
+
link: {
|
|
151
|
+
isLoading: false,
|
|
152
|
+
error: null,
|
|
153
|
+
data: booking({
|
|
154
|
+
booking: {
|
|
155
|
+
sessions: [
|
|
156
|
+
{ startsAt: START, endsAt: "2026-11-03T16:00:00.000Z" },
|
|
157
|
+
{ startsAt: second, endsAt: "2026-11-10T16:00:00.000Z" },
|
|
158
|
+
],
|
|
159
|
+
timezone: "UTC",
|
|
160
|
+
servicePath: null,
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
expect(html.split('data-testid="checkout-link-session"')).toHaveLength(3);
|
|
167
|
+
expect(html).toContain(local(second));
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("a time that was taken while unpaid: the refusal, no Pay button, and a way to pick another time", () => {
|
|
171
|
+
const html = render(
|
|
172
|
+
page({
|
|
173
|
+
link: { isLoading: false, error: null, data: booking() },
|
|
174
|
+
refusal: {
|
|
175
|
+
message: "Your hold on this time expired and it is no longer available. Please pick another time",
|
|
176
|
+
code: "INVENTORY_HOLD_EXPIRED",
|
|
177
|
+
status: null,
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
expect(html).toContain('data-testid="checkout-link-refusal"');
|
|
182
|
+
expect(html).toContain('data-testid="checkout-link-other-times"');
|
|
183
|
+
expect(html).toContain("Pick another time");
|
|
184
|
+
expect(html).not.toContain('data-testid="checkout-link-pay"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("an ended booking link offers another time instead of the store", () => {
|
|
188
|
+
const html = render(page({ link: { isLoading: false, error: null, data: booking({ status: "expired" }) } }));
|
|
189
|
+
expect(html).toContain("This reservation has ended");
|
|
190
|
+
expect(html).toContain('data-testid="checkout-link-other-times"');
|
|
191
|
+
expect(html).not.toContain('data-testid="checkout-link-store"');
|
|
192
|
+
expect(html).not.toContain('data-testid="checkout-link-held-until"');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("an unknown link says it is not valid", () => {
|
|
197
|
+
const html = render(page({ link: { isLoading: false, error: new Error("404"), data: undefined } }));
|
|
198
|
+
expect(html).toContain('data-state="not-found"');
|
|
199
|
+
expect(html).toContain("Link not found");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("the return leg: paid shows the confirmation, unpaid offers another try, too late says a refund is coming", () => {
|
|
203
|
+
const paid = render(
|
|
204
|
+
page({ finalize: { isLoading: false, error: null, data: { outcome: "fulfilled", link: view({ status: "paid" }) } } }),
|
|
205
|
+
{ returning: true },
|
|
206
|
+
);
|
|
207
|
+
expect(paid).toContain('data-state="paid"');
|
|
208
|
+
expect(paid).toContain("Payment received");
|
|
209
|
+
expect(paid).toContain("Spring Show: General");
|
|
210
|
+
|
|
211
|
+
const unpaid = render(
|
|
212
|
+
page({ finalize: { isLoading: false, error: null, data: { outcome: "unpaid", link: view() } } }),
|
|
213
|
+
{ returning: true },
|
|
214
|
+
);
|
|
215
|
+
expect(unpaid).toContain('data-state="unpaid"');
|
|
216
|
+
expect(unpaid).toContain("Try again");
|
|
217
|
+
|
|
218
|
+
const late = render(
|
|
219
|
+
page({
|
|
220
|
+
finalize: {
|
|
221
|
+
isLoading: false,
|
|
222
|
+
error: null,
|
|
223
|
+
data: { outcome: "needs_refund", link: view({ status: "expired", needsRefund: true }) },
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
226
|
+
{ returning: true },
|
|
227
|
+
);
|
|
228
|
+
expect(late).toContain('data-state="needs-refund"');
|
|
229
|
+
expect(late).toContain("Your payment will be refunded");
|
|
230
|
+
});
|
|
231
|
+
});
|