@tribe-nest/forge 3.52.0 → 3.54.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/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useEvents.ts +14 -0
- package/src/data/queries/useMyCourses.ts +42 -0
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useWebsiteAgent.ts +2 -0
- package/src/i18n/de.json +47 -0
- package/src/i18n/en.json +47 -0
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +23 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +92 -0
- package/src/types/models.ts +58 -0
- package/src/ui/headless/agent/_tests/pathExclusion.spec.ts +52 -0
- package/src/ui/headless/agent/_tests/useCurrentPath.spec.tsx +54 -0
- package/src/ui/headless/agent/pathExclusion.ts +53 -0
- package/src/ui/headless/agent/useAiAgent.ts +10 -1
- package/src/ui/headless/agent/useCurrentPath.ts +49 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +11 -0
- package/src/ui/index.ts +18 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +15 -0
- package/src/ui/payment/ForgeStripePayment.tsx +37 -3
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +181 -14
- package/src/ui/styled/AiAgentWidget.tsx +3 -2
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AlertTriangle, ArrowRight, Calendar, MapPin, RotateCcw, Ticket } from "lucide-react";
|
|
1
|
+
import { AlertTriangle, ArrowRight, Ban, Calendar, Hourglass, MapPin, RotateCcw, Ticket, TimerOff } from "lucide-react";
|
|
2
2
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
3
3
|
import { readableTextOn } from "../theme/contrast";
|
|
4
4
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
@@ -8,7 +8,11 @@ import { useForgeT } from "../../i18n";
|
|
|
8
8
|
import { useEventOrderFinalize } from "../../data/queries/useFinalize";
|
|
9
9
|
import { useEvent } from "../../data/queries/useEvents";
|
|
10
10
|
import { AddToCalendar } from "./AddToCalendar";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getTicketOrderOutcome,
|
|
13
|
+
ticketRequestFailureKind,
|
|
14
|
+
TICKET_ORDER_REFUNDED_COPY,
|
|
15
|
+
} from "../../utils/ticketOrderOutcome";
|
|
12
16
|
|
|
13
17
|
export interface EventConfirmationProps {
|
|
14
18
|
slug: string;
|
|
@@ -20,6 +24,12 @@ export interface EventConfirmationProps {
|
|
|
20
24
|
formatAmount?: (n: number, currency?: string) => string;
|
|
21
25
|
/** Where the "browse more events" action links. */
|
|
22
26
|
explorePath?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Where a fan whose ticket REQUEST lapsed or could not be settled is sent to
|
|
29
|
+
* try again: the event itself, which may still have seats. Default
|
|
30
|
+
* `/i/events/:slug`.
|
|
31
|
+
*/
|
|
32
|
+
eventPath?: string;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/** Locale date-time formatting, replicating the site-starter `formatDateTime`. */
|
|
@@ -42,6 +52,7 @@ export function EventConfirmation({
|
|
|
42
52
|
orderId,
|
|
43
53
|
formatAmount,
|
|
44
54
|
explorePath = "/i/events",
|
|
55
|
+
eventPath,
|
|
45
56
|
}: EventConfirmationProps) {
|
|
46
57
|
const theme = useForgeTheme();
|
|
47
58
|
const t = useForgeT();
|
|
@@ -75,6 +86,15 @@ export function EventConfirmation({
|
|
|
75
86
|
const outcome = getTicketOrderOutcome(order);
|
|
76
87
|
const paid = outcome === "paid";
|
|
77
88
|
const refunded = outcome === "refunded";
|
|
89
|
+
/**
|
|
90
|
+
* Ticket requests (curated access, feature 3). `awaiting` is a request the
|
|
91
|
+
* organiser has not decided; `requestEnded` names how one ended without a
|
|
92
|
+
* sale (declined, expired, or approved but the card could not be settled),
|
|
93
|
+
* and is `null` for an ordinary abandoned checkout, whose copy is unchanged.
|
|
94
|
+
*/
|
|
95
|
+
const awaiting = outcome === "awaiting_approval";
|
|
96
|
+
const requestEnded = ticketRequestFailureKind(order);
|
|
97
|
+
const tryAgainPath = eventPath ?? `/i/events/${slug}`;
|
|
78
98
|
const fmt = (n: number) => formatAmt(n, order.currency || undefined);
|
|
79
99
|
const location =
|
|
80
100
|
event && event.type !== "virtual" && event.address
|
|
@@ -91,6 +111,14 @@ export function EventConfirmation({
|
|
|
91
111
|
) : refunded ? (
|
|
92
112
|
// Neutral, not alarmed: a refund is a completed outcome, not a fault.
|
|
93
113
|
<NeutralSeal icon={<RotateCcw size={32} color={alpha(text, 0.65)} />} text={text} />
|
|
114
|
+
) : awaiting ? (
|
|
115
|
+
// Waiting is the intended state of a request, so it gets the
|
|
116
|
+
// theme's own colour, not warning amber.
|
|
117
|
+
<NeutralSeal icon={<Hourglass size={32} color={primary} />} text={text} />
|
|
118
|
+
) : requestEnded === "declined" ? (
|
|
119
|
+
<NeutralSeal icon={<Ban size={32} color={alpha(text, 0.65)} />} text={text} />
|
|
120
|
+
) : requestEnded === "expired" ? (
|
|
121
|
+
<NeutralSeal icon={<TimerOff size={32} color={alpha(text, 0.65)} />} text={text} />
|
|
94
122
|
) : (
|
|
95
123
|
<WarnSeal icon={<AlertTriangle size={34} color="#f59e0b" />} />
|
|
96
124
|
)}
|
|
@@ -103,7 +131,12 @@ export function EventConfirmation({
|
|
|
103
131
|
fontWeight: 700,
|
|
104
132
|
letterSpacing: "0.22em",
|
|
105
133
|
textTransform: "uppercase",
|
|
106
|
-
color:
|
|
134
|
+
color:
|
|
135
|
+
paid || awaiting
|
|
136
|
+
? primary
|
|
137
|
+
: refunded || requestEnded === "declined" || requestEnded === "expired"
|
|
138
|
+
? alpha(text, 0.65)
|
|
139
|
+
: "#f59e0b",
|
|
107
140
|
animationDelay: "0.15s",
|
|
108
141
|
}}
|
|
109
142
|
>
|
|
@@ -111,22 +144,76 @@ export function EventConfirmation({
|
|
|
111
144
|
? t("forge.event_confirmation.eyebrow_paid")
|
|
112
145
|
: refunded
|
|
113
146
|
? TICKET_ORDER_REFUNDED_COPY.eyebrow
|
|
114
|
-
:
|
|
147
|
+
: awaiting
|
|
148
|
+
? t("forge.event_confirmation.eyebrow_awaiting")
|
|
149
|
+
: requestEnded === "declined"
|
|
150
|
+
? t("forge.event_confirmation.eyebrow_declined")
|
|
151
|
+
: requestEnded === "expired"
|
|
152
|
+
? t("forge.event_confirmation.eyebrow_expired")
|
|
153
|
+
: requestEnded === "payment_failed"
|
|
154
|
+
? t("forge.event_confirmation.eyebrow_request_payment_failed")
|
|
155
|
+
: t("forge.event_confirmation.eyebrow_status", { status: order.status.replace(/_/g, " ") })}
|
|
115
156
|
</p>
|
|
116
157
|
<h1 className="cf-rise" style={{ fontSize: 29, fontWeight: 800, lineHeight: 1.12, margin: "0 0 8px", animationDelay: "0.22s" }}>
|
|
117
158
|
{paid
|
|
118
159
|
? t("forge.event_confirmation.title_paid")
|
|
119
160
|
: refunded
|
|
120
161
|
? TICKET_ORDER_REFUNDED_COPY.heading
|
|
121
|
-
:
|
|
162
|
+
: awaiting
|
|
163
|
+
? t("forge.event_confirmation.title_awaiting")
|
|
164
|
+
: requestEnded === "declined"
|
|
165
|
+
? t("forge.event_confirmation.title_declined")
|
|
166
|
+
: requestEnded === "expired"
|
|
167
|
+
? t("forge.event_confirmation.title_expired")
|
|
168
|
+
: requestEnded === "payment_failed"
|
|
169
|
+
? t("forge.event_confirmation.title_request_payment_failed")
|
|
170
|
+
: t("forge.event_confirmation.title_incomplete")}
|
|
122
171
|
</h1>
|
|
123
172
|
<p className="cf-rise" style={{ fontSize: 15, color: alpha(text, 0.7), margin: 0, animationDelay: "0.3s" }}>
|
|
124
173
|
{paid
|
|
125
174
|
? t("forge.event_confirmation.body_paid", { email: order.email })
|
|
126
175
|
: refunded
|
|
127
176
|
? TICKET_ORDER_REFUNDED_COPY.body
|
|
128
|
-
:
|
|
177
|
+
: awaiting
|
|
178
|
+
? t("forge.event_confirmation.body_awaiting_no_date")
|
|
179
|
+
: requestEnded === "declined"
|
|
180
|
+
? t("forge.event_confirmation.body_declined")
|
|
181
|
+
: requestEnded === "expired"
|
|
182
|
+
? t("forge.event_confirmation.body_expired")
|
|
183
|
+
: requestEnded === "payment_failed"
|
|
184
|
+
? t("forge.event_confirmation.body_request_payment_failed")
|
|
185
|
+
: t("forge.event_confirmation.body_incomplete", { status: order.status.replace(/_/g, " ") })}
|
|
129
186
|
</p>
|
|
187
|
+
{/* "Nothing is charged until they approve" is the promise the whole
|
|
188
|
+
request rests on. Content, not help: a fan who has just committed
|
|
189
|
+
a card reads it every time. */}
|
|
190
|
+
{awaiting && (
|
|
191
|
+
<p
|
|
192
|
+
className="cf-rise"
|
|
193
|
+
data-testid="request-nothing-charged"
|
|
194
|
+
style={{ fontSize: 14, fontWeight: 600, color: primary, margin: "10px 0 0", animationDelay: "0.34s" }}
|
|
195
|
+
>
|
|
196
|
+
{t("forge.event_confirmation.awaiting_nothing_charged")}
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
{/* The organiser's own words, when they gave any. Theirs, so quoted
|
|
200
|
+
rather than paraphrased. */}
|
|
201
|
+
{requestEnded === "declined" && order.approvalDecisionNote && (
|
|
202
|
+
<blockquote
|
|
203
|
+
data-testid="request-decision-note"
|
|
204
|
+
style={{
|
|
205
|
+
margin: "14px auto 0",
|
|
206
|
+
padding: "10px 14px",
|
|
207
|
+
maxWidth: 400,
|
|
208
|
+
fontSize: 14,
|
|
209
|
+
color: alpha(text, 0.8),
|
|
210
|
+
borderLeft: `3px solid ${alpha(text, 0.18)}`,
|
|
211
|
+
textAlign: "left",
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
{order.approvalDecisionNote}
|
|
215
|
+
</blockquote>
|
|
216
|
+
)}
|
|
130
217
|
</div>
|
|
131
218
|
|
|
132
219
|
{/* ── Event summary ── */}
|
|
@@ -209,7 +296,9 @@ export function EventConfirmation({
|
|
|
209
296
|
? t("forge.event_confirmation.total_paid")
|
|
210
297
|
: refunded
|
|
211
298
|
? TICKET_ORDER_REFUNDED_COPY.totalLabel
|
|
212
|
-
:
|
|
299
|
+
: awaiting
|
|
300
|
+
? t("forge.event_confirmation.total_on_approval")
|
|
301
|
+
: t("forge.event_confirmation.total")}
|
|
213
302
|
</span>
|
|
214
303
|
{/* The exact charged total (charged currency) when present — never a re-converted catalog amount. */}
|
|
215
304
|
<span style={{ fontSize: 18, fontWeight: 800, color: primary }}>
|
|
@@ -242,7 +331,10 @@ export function EventConfirmation({
|
|
|
242
331
|
{/* ── Action ── */}
|
|
243
332
|
<div className="cf-rise" style={{ padding: "20px 30px 30px", animationDelay: "0.52s" }}>
|
|
244
333
|
<a
|
|
245
|
-
|
|
334
|
+
// A request that lapsed or could not be settled sends the fan back
|
|
335
|
+
// to the event, which may still have seats; everything else browses.
|
|
336
|
+
href={requestEnded === "expired" || requestEnded === "payment_failed" ? tryAgainPath : explorePath}
|
|
337
|
+
data-testid="confirmation-action"
|
|
246
338
|
style={{
|
|
247
339
|
display: "inline-flex",
|
|
248
340
|
alignItems: "center",
|
|
@@ -257,7 +349,12 @@ export function EventConfirmation({
|
|
|
257
349
|
textDecoration: "none",
|
|
258
350
|
}}
|
|
259
351
|
>
|
|
260
|
-
{
|
|
352
|
+
{requestEnded === "payment_failed"
|
|
353
|
+
? t("forge.event_confirmation.buy_again")
|
|
354
|
+
: requestEnded === "expired"
|
|
355
|
+
? t("forge.event_confirmation.view_event")
|
|
356
|
+
: t("forge.event_confirmation.explore")}{" "}
|
|
357
|
+
<ArrowRight size={16} />
|
|
261
358
|
</a>
|
|
262
359
|
</div>
|
|
263
360
|
</ConfirmationCard>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import { AlertCircle, CalendarClock, Clock, KeyRound, X } from "lucide-react";
|
|
2
|
+
import { AlertCircle, CalendarClock, Clock, KeyRound, ShieldCheck, X } from "lucide-react";
|
|
3
3
|
import type { IEvent, ITicket } from "../../types/models";
|
|
4
4
|
import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
5
5
|
import { useForgeT } from "../../i18n";
|
|
@@ -26,6 +26,8 @@ import { MembershipGateNotice } from "./MembershipGateNotice";
|
|
|
26
26
|
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
27
27
|
import { PresaleCodeField } from "./PresaleCode";
|
|
28
28
|
import { HoldNotice } from "./HoldNotice";
|
|
29
|
+
import { CallHelpHint } from "../media/CallHelpHint";
|
|
30
|
+
import { isDeferredSettlement, deferredChargeNotice } from "../payment/stripeConfirmOutcome";
|
|
29
31
|
import {
|
|
30
32
|
DialogRoot,
|
|
31
33
|
DialogTrigger,
|
|
@@ -401,6 +403,16 @@ function TicketStep({
|
|
|
401
403
|
const gate =
|
|
402
404
|
event.membershipGate && !event.membershipGate.allowed ? event.membershipGate : ticket.membershipGate;
|
|
403
405
|
const isGated = !!gate && !gate.allowed;
|
|
406
|
+
/**
|
|
407
|
+
* Ticket requests (curated access, feature 3). The badge says the
|
|
408
|
+
* tier is requested, not bought; the `?` beside it says what that
|
|
409
|
+
* means for the fan's card. `isMixBlocked` is the one rule the
|
|
410
|
+
* picker enforces itself: one order is either a request or a
|
|
411
|
+
* purchase, so once the selection is one kind the other kind's `+`
|
|
412
|
+
* goes dark, and the sentence under the row says why.
|
|
413
|
+
*/
|
|
414
|
+
const needsApproval = ticket.requiresApproval === true;
|
|
415
|
+
const isMixBlocked = c.isTierBlockedByApprovalMix(ticket.id);
|
|
404
416
|
const borderColor = isExpired ? "#ef4444" : u && !u.expired ? a(u.color, 0.5) : a(theme.colors.primary, 0.25);
|
|
405
417
|
return (
|
|
406
418
|
<div
|
|
@@ -471,6 +483,31 @@ function TicketStep({
|
|
|
471
483
|
{onSaleBadge(saleWindow.startsAt, new Date(now))}
|
|
472
484
|
</div>
|
|
473
485
|
)}
|
|
486
|
+
{needsApproval && (
|
|
487
|
+
<div
|
|
488
|
+
data-testid="ticket-approval-badge"
|
|
489
|
+
style={{
|
|
490
|
+
display: "inline-flex",
|
|
491
|
+
alignItems: "center",
|
|
492
|
+
gap: 4,
|
|
493
|
+
padding: "2px 8px",
|
|
494
|
+
borderRadius: 6,
|
|
495
|
+
fontSize: 12,
|
|
496
|
+
fontWeight: 700,
|
|
497
|
+
marginBottom: 6,
|
|
498
|
+
marginRight: 6,
|
|
499
|
+
background: a(theme.colors.primary, 0.1),
|
|
500
|
+
color: theme.colors.primary,
|
|
501
|
+
border: `1px solid ${a(theme.colors.primary, 0.3)}`,
|
|
502
|
+
}}
|
|
503
|
+
>
|
|
504
|
+
<ShieldCheck size={12} />
|
|
505
|
+
{t("forge.event_tickets.approval_badge")}
|
|
506
|
+
<CallHelpHint label={t("forge.event_tickets.approval_help_label")}>
|
|
507
|
+
{t("forge.event_tickets.approval_help_body")}
|
|
508
|
+
</CallHelpHint>
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
474
511
|
{u && !u.expired && (
|
|
475
512
|
<div
|
|
476
513
|
style={{
|
|
@@ -546,7 +583,9 @@ function TicketStep({
|
|
|
546
583
|
// but because letting a buyer build a cart the server will
|
|
547
584
|
// not sell is precisely the "discoverable only by failing"
|
|
548
585
|
// journey this change exists to end.
|
|
549
|
-
disabled={
|
|
586
|
+
disabled={
|
|
587
|
+
selectedQty >= maxAllowed || isSoldOut || isExpired || isGated || isScheduled || isMixBlocked
|
|
588
|
+
}
|
|
550
589
|
style={stepperStyle(theme, true)}
|
|
551
590
|
>
|
|
552
591
|
+
|
|
@@ -603,6 +642,18 @@ function TicketStep({
|
|
|
603
642
|
{seatNotice}
|
|
604
643
|
</div>
|
|
605
644
|
)}
|
|
645
|
+
{/* The `+` above went dark for this tier. That is state the fan
|
|
646
|
+
has to act on (remove the others, or leave this one), so it
|
|
647
|
+
stays visible; the reason lives behind the `?`. */}
|
|
648
|
+
{isMixBlocked && !isSoldOut && !isExpired && (
|
|
649
|
+
<div data-testid="ticket-approval-mix-notice" style={infoNoticeStyle(theme.colors.primary, theme)}>
|
|
650
|
+
<AlertCircle size={16} />
|
|
651
|
+
<span>{t("forge.event_tickets.approval_mix_blocked")}</span>
|
|
652
|
+
<CallHelpHint label={t("forge.event_tickets.approval_mix_help_label")}>
|
|
653
|
+
{t("forge.event_tickets.approval_mix_help_body")}
|
|
654
|
+
</CallHelpHint>
|
|
655
|
+
</div>
|
|
656
|
+
)}
|
|
606
657
|
{isExpired && (
|
|
607
658
|
<div
|
|
608
659
|
style={{
|
|
@@ -634,7 +685,12 @@ function TicketStep({
|
|
|
634
685
|
it has to be the happy path, with a direct tickets-only checkout as
|
|
635
686
|
the secondary exit. Without add-ons the original flow is untouched:
|
|
636
687
|
Continue goes straight to details, the cart is the secondary exit. */}
|
|
637
|
-
{
|
|
688
|
+
{/* A request cannot go to the cart: a bundle forces payment and the API
|
|
689
|
+
refuses approval tiers in one. So a request selection gets the plain
|
|
690
|
+
Continue with no cart exit, whether or not the page sells add-ons. */}
|
|
691
|
+
{c.awaitingApproval ? (
|
|
692
|
+
<ActionBar c={c} fmt={fmt} onNext={c.goToDetails} nextLabel={t("forge.event_tickets.continue")} />
|
|
693
|
+
) : hasAddons ? (
|
|
638
694
|
<ActionBar
|
|
639
695
|
c={c}
|
|
640
696
|
fmt={fmt}
|
|
@@ -851,12 +907,18 @@ function DetailsStep({
|
|
|
851
907
|
// fee and any discount: a free ticket with a FLAT booking fee still
|
|
852
908
|
// costs money and still goes to payment, and a 100%-off code on a paid
|
|
853
909
|
// ticket does not.
|
|
910
|
+
// A request says so on the button, priced or not: with a price the
|
|
911
|
+
// next screen authorises the card rather than charging it, and with
|
|
912
|
+
// none there is no next screen at all. Either way "Continue to
|
|
913
|
+
// payment" would promise a sale that is not happening.
|
|
854
914
|
nextLabel={
|
|
855
915
|
c.isProcessing
|
|
856
916
|
? t("forge.event_tickets.processing")
|
|
857
|
-
: c.
|
|
858
|
-
? t("forge.event_tickets.
|
|
859
|
-
:
|
|
917
|
+
: c.awaitingApproval
|
|
918
|
+
? t("forge.event_tickets.request_tickets")
|
|
919
|
+
: c.totalAmount > 0
|
|
920
|
+
? t("forge.event_tickets.continue_payment")
|
|
921
|
+
: t("forge.event_tickets.confirm")
|
|
860
922
|
}
|
|
861
923
|
nextDisabled={c.isProcessing}
|
|
862
924
|
/>
|
|
@@ -880,10 +942,17 @@ function PaymentStep({
|
|
|
880
942
|
// row (already contained in the charged total below); inclusive stores get a
|
|
881
943
|
// muted "Includes X tax" caption. Display only.
|
|
882
944
|
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
945
|
+
// A priced ticket request: start-payment said the charge is deferred, so
|
|
946
|
+
// this screen authorises the card and the wording must not say "purchase".
|
|
947
|
+
const deferred = isDeferredSettlement(c.settlement);
|
|
883
948
|
return (
|
|
884
949
|
<div>
|
|
885
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
|
886
|
-
|
|
950
|
+
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>
|
|
951
|
+
{deferred ? t("forge.event_tickets.authorize_title") : t("forge.event_tickets.payment_title")}
|
|
952
|
+
</h2>
|
|
953
|
+
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 16 }}>
|
|
954
|
+
{deferred ? t("forge.event_tickets.authorize_subtitle") : t("forge.event_tickets.payment_subtitle")}
|
|
955
|
+
</p>
|
|
887
956
|
{/* The reservation clock. Event ticket holds are taken unconditionally
|
|
888
957
|
(no feature switch), so this is the one checkout where the countdown
|
|
889
958
|
fires for every profile, and it was the one that did not render it. */}
|
|
@@ -954,17 +1023,42 @@ function PaymentStep({
|
|
|
954
1023
|
so it is checked BEFORE the "not configured" branch. */}
|
|
955
1024
|
{c.isPaystack ? (
|
|
956
1025
|
c.canOpenPaystack ? (
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1026
|
+
<>
|
|
1027
|
+
<PaystackPayButton
|
|
1028
|
+
onPay={c.openPaystackCheckout}
|
|
1029
|
+
label={
|
|
1030
|
+
deferred
|
|
1031
|
+
? t("forge.event_tickets.authorize", { amount: fmt(c.totalAmount) })
|
|
1032
|
+
: t("forge.event_tickets.pay", { amount: fmt(c.totalAmount) })
|
|
1033
|
+
}
|
|
1034
|
+
/>
|
|
1035
|
+
{/* Content, not help: the fan has just been told "Authorize" and
|
|
1036
|
+
must be told when the money moves. The built-in Stripe renderer
|
|
1037
|
+
draws its own copy of this line under its own button. */}
|
|
1038
|
+
{deferred && (
|
|
1039
|
+
<p data-testid="deferred-charge-notice" style={{ fontSize: 13, opacity: 0.8, marginTop: 8 }}>
|
|
1040
|
+
{t("forge.event_tickets.deferred_charged_on_approval")}
|
|
1041
|
+
{deferredChargeNotice(c.deferredStrategy) === "authorization" && (
|
|
1042
|
+
<> {t("forge.event_tickets.deferred_hold_on_statement")}</>
|
|
1043
|
+
)}
|
|
1044
|
+
</p>
|
|
1045
|
+
)}
|
|
1046
|
+
</>
|
|
961
1047
|
) : (
|
|
962
1048
|
<p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
|
|
963
1049
|
)
|
|
964
1050
|
) : !renderPayment ? (
|
|
965
1051
|
<p style={{ color: "#ef4444", fontSize: 14 }}>{t("forge.event_tickets.payment_not_configured")}</p>
|
|
966
1052
|
) : c.clientSecret ? (
|
|
967
|
-
renderPayment({
|
|
1053
|
+
renderPayment({
|
|
1054
|
+
clientSecret: c.clientSecret,
|
|
1055
|
+
returnUrl: c.returnUrl,
|
|
1056
|
+
amount: c.totalAmount,
|
|
1057
|
+
mode: "redirect",
|
|
1058
|
+
// Additive: a renderer that ignores these renders the sale it always did.
|
|
1059
|
+
settlement: c.settlement,
|
|
1060
|
+
deferredStrategy: c.deferredStrategy,
|
|
1061
|
+
})
|
|
968
1062
|
) : (
|
|
969
1063
|
<p style={{ opacity: 0.7 }}>{t("forge.event_tickets.preparing_payment")}</p>
|
|
970
1064
|
)}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a "how did we do?" email lands on.
|
|
3
|
+
*
|
|
4
|
+
* It lives in Forge rather than in either storefront because BOTH need it: the
|
|
5
|
+
* hosted `/i/reviews/$token` page renders it, and every site also keeps a
|
|
6
|
+
* root-level `/reviews/$token` route for links in emails that were sent before
|
|
7
|
+
* the move. Two copies of a page that must agree is the failure this avoids.
|
|
8
|
+
*/
|
|
9
|
+
// The emailed review-link form (`/reviews/:token`) built from the headless
|
|
10
|
+
// `useReviewForm` primitive + Tailwind theme tokens. Lives under `/i/`
|
|
11
|
+
// (centrally managed) — the top-level `/reviews/$token` route is a thin shell
|
|
12
|
+
// so emailed links keep working while the page stays updatable.
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { CheckCircle2, Clock, Star } from "lucide-react";
|
|
15
|
+
import { Loading } from "./Loading";
|
|
16
|
+
import { useReviewForm } from "../headless";
|
|
17
|
+
|
|
18
|
+
export function ReviewRequestPage({ token }: { token: string }) {
|
|
19
|
+
const form = useReviewForm(token);
|
|
20
|
+
const [hoverRating, setHoverRating] = useState(0);
|
|
21
|
+
|
|
22
|
+
if (form.status === "loading") return <Loading fullPage />;
|
|
23
|
+
|
|
24
|
+
if (form.status === "invalid") {
|
|
25
|
+
return (
|
|
26
|
+
<Panel>
|
|
27
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">This review link isn't valid</h1>
|
|
28
|
+
<p className="fg:text-muted">
|
|
29
|
+
The link may have been mistyped or is no longer active. If you received it by email, try opening it again
|
|
30
|
+
from the original message.
|
|
31
|
+
</p>
|
|
32
|
+
</Panel>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (form.status === "expired") {
|
|
37
|
+
return (
|
|
38
|
+
<Panel>
|
|
39
|
+
<Clock size={32} className="fg:text-primary" />
|
|
40
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">This review link has expired</h1>
|
|
41
|
+
<p className="fg:text-muted">
|
|
42
|
+
Review links are only valid for a limited time after your purchase
|
|
43
|
+
{form.entityTitle ? ` of “${form.entityTitle}”` : ""}. Thanks anyway for your support!
|
|
44
|
+
</p>
|
|
45
|
+
</Panel>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (form.status === "submitted" && form.submitted) {
|
|
50
|
+
return (
|
|
51
|
+
<Panel>
|
|
52
|
+
<CheckCircle2 size={36} className="fg:text-primary" />
|
|
53
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">Thank you for your review!</h1>
|
|
54
|
+
<StarRow rating={form.submitted.rating} size={20} />
|
|
55
|
+
<p className="fg:text-muted">
|
|
56
|
+
{form.isHeld
|
|
57
|
+
? "Your review has been received and is awaiting approval. It will appear once it's been checked."
|
|
58
|
+
: `Your review is now live${form.entityTitle ? ` on “${form.entityTitle}”` : ""}.`}
|
|
59
|
+
</p>
|
|
60
|
+
</Panel>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (form.status === "locked" && form.existingReview) {
|
|
65
|
+
return (
|
|
66
|
+
<Panel>
|
|
67
|
+
<CheckCircle2 size={32} className="fg:text-primary" />
|
|
68
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">You've already reviewed this</h1>
|
|
69
|
+
{form.entityTitle && <p className="fg:font-semibold fg:text-text">{form.entityTitle}</p>}
|
|
70
|
+
<StarRow rating={form.existingReview.rating} size={20} />
|
|
71
|
+
{form.existingReview.title && <p className="fg:font-bold fg:text-text">{form.existingReview.title}</p>}
|
|
72
|
+
{form.existingReview.body && <p className="fg:text-text/85">{form.existingReview.body}</p>}
|
|
73
|
+
<p className="fg:text-sm fg:text-muted">
|
|
74
|
+
Reviews can be edited for 30 days after they're written. This one can no longer be changed.
|
|
75
|
+
</p>
|
|
76
|
+
</Panel>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const displayStars = hoverRating || form.rating;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="fg:mx-auto fg:w-full fg:max-w-xl fg:px-4 fg:py-6">
|
|
84
|
+
<form
|
|
85
|
+
className="fg:flex fg:flex-col fg:gap-5 fg:rounded-xl fg:border fg:border-border fg:bg-surface fg:p-6"
|
|
86
|
+
onSubmit={(e) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
form.submit();
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<div>
|
|
92
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">
|
|
93
|
+
{form.existingReview ? "Edit your review" : "Leave a review"}
|
|
94
|
+
</h1>
|
|
95
|
+
{form.entityTitle && (
|
|
96
|
+
<p className="fg:mt-1 fg:text-muted">
|
|
97
|
+
How was <span className="fg:font-bold fg:text-text">{form.entityTitle}</span>?
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
{form.existingReview && (
|
|
101
|
+
<p className="fg:mt-1.5 fg:text-sm fg:text-muted">
|
|
102
|
+
You can edit your review within 30 days of writing it. Edits are re-checked before publishing.
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Star picker */}
|
|
108
|
+
<div>
|
|
109
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">Your rating *</span>
|
|
110
|
+
<div className="fg:flex fg:gap-1.5" role="radiogroup" aria-label="Rating" onMouseLeave={() => setHoverRating(0)}>
|
|
111
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
112
|
+
<button
|
|
113
|
+
key={star}
|
|
114
|
+
type="button"
|
|
115
|
+
role="radio"
|
|
116
|
+
aria-checked={form.rating === star}
|
|
117
|
+
aria-label={`${star} star${star === 1 ? "" : "s"}`}
|
|
118
|
+
onClick={() => form.setRating(star)}
|
|
119
|
+
onMouseEnter={() => setHoverRating(star)}
|
|
120
|
+
className="fg:p-0.5 fg:leading-none"
|
|
121
|
+
>
|
|
122
|
+
<Star
|
|
123
|
+
size={30}
|
|
124
|
+
className={star <= displayStars ? "fg:text-primary" : "fg:text-muted/50"}
|
|
125
|
+
fill={star <= displayStars ? "currentColor" : "transparent"}
|
|
126
|
+
/>
|
|
127
|
+
</button>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<label className="fg:block">
|
|
133
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">
|
|
134
|
+
Title <span className="fg:font-normal fg:text-muted">(optional)</span>
|
|
135
|
+
</span>
|
|
136
|
+
<input
|
|
137
|
+
type="text"
|
|
138
|
+
maxLength={200}
|
|
139
|
+
value={form.title}
|
|
140
|
+
onChange={(e) => form.setTitle(e.target.value)}
|
|
141
|
+
placeholder="Sum it up in a few words"
|
|
142
|
+
className="fg:w-full fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-text fg:outline-none fg:focus:border-primary"
|
|
143
|
+
/>
|
|
144
|
+
</label>
|
|
145
|
+
|
|
146
|
+
<label className="fg:block">
|
|
147
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">
|
|
148
|
+
Your review <span className="fg:font-normal fg:text-muted">(optional)</span>
|
|
149
|
+
</span>
|
|
150
|
+
<textarea
|
|
151
|
+
rows={5}
|
|
152
|
+
maxLength={5000}
|
|
153
|
+
value={form.body}
|
|
154
|
+
onChange={(e) => form.setBody(e.target.value)}
|
|
155
|
+
placeholder="What did you like or dislike?"
|
|
156
|
+
className="fg:w-full fg:resize-y fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-text fg:outline-none fg:focus:border-primary"
|
|
157
|
+
/>
|
|
158
|
+
</label>
|
|
159
|
+
|
|
160
|
+
<label className="fg:block">
|
|
161
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">Display name</span>
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
maxLength={100}
|
|
165
|
+
value={form.reviewerName}
|
|
166
|
+
onChange={(e) => form.setReviewerName(e.target.value)}
|
|
167
|
+
className="fg:w-full fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-text fg:outline-none fg:focus:border-primary"
|
|
168
|
+
/>
|
|
169
|
+
<span className="fg:mt-1 fg:block fg:text-xs fg:text-muted">Shown publicly next to your review.</span>
|
|
170
|
+
</label>
|
|
171
|
+
|
|
172
|
+
{form.errorMessage && (
|
|
173
|
+
<p className="fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-sm fg:text-text">
|
|
174
|
+
{form.errorMessage}
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
<button
|
|
179
|
+
type="submit"
|
|
180
|
+
disabled={!form.canSubmit}
|
|
181
|
+
className="fg:w-full fg:rounded-lg fg:bg-primary fg:py-2.5 fg:font-semibold fg:disabled:opacity-50"
|
|
182
|
+
style={{ color: "var(--forge-text-primary, #fff)" }}
|
|
183
|
+
>
|
|
184
|
+
{form.isSubmitting ? "Submitting…" : form.existingReview ? "Update review" : "Submit review"}
|
|
185
|
+
</button>
|
|
186
|
+
</form>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function StarRow({ rating, size = 16 }: { rating: number; size?: number }) {
|
|
192
|
+
return (
|
|
193
|
+
<span className="fg:inline-flex fg:gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
|
194
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
195
|
+
<Star
|
|
196
|
+
key={star}
|
|
197
|
+
size={size}
|
|
198
|
+
className={star <= Math.round(rating) ? "fg:text-primary" : "fg:text-muted/50"}
|
|
199
|
+
fill={star <= Math.round(rating) ? "currentColor" : "transparent"}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</span>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Panel({ children }: { children: React.ReactNode }) {
|
|
207
|
+
return (
|
|
208
|
+
<div className="fg:mx-auto fg:w-full fg:max-w-xl fg:px-4 fg:py-6">
|
|
209
|
+
<div className="fg:flex fg:flex-col fg:items-center fg:gap-3 fg:rounded-xl fg:border fg:border-border fg:bg-surface fg:p-8 fg:text-center">
|
|
210
|
+
{children}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|