@tribe-nest/forge 3.54.0 → 3.58.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -4,6 +4,9 @@ import { CreditCard, MapPin, Package, User } from "lucide-react";
|
|
|
4
4
|
import { useCheckout, CHECKOUT_STAGE_ORDER, type CheckoutStage } from "../headless/checkout/useCheckout";
|
|
5
5
|
import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
|
|
6
6
|
import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
|
|
7
|
+
import type { CountryCode } from "libphonenumber-js";
|
|
8
|
+
import { usePhoneInput } from "../headless/forms/usePhoneInput";
|
|
9
|
+
import { PhoneInput } from "./PhoneInput";
|
|
7
10
|
import { useForgeT } from "../../i18n";
|
|
8
11
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
9
12
|
import { readableTextOn } from "../theme/contrast";
|
|
@@ -94,6 +97,26 @@ export function Checkout({
|
|
|
94
97
|
if (selectedCountryCode) shippingForm.setValue("stateCode", "");
|
|
95
98
|
}, [selectedCountryCode, shippingForm]);
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The delivery phone, in `shipping` mode.
|
|
102
|
+
*
|
|
103
|
+
* `shipping` rather than `messaging` because this number is for a courier to
|
|
104
|
+
* ring, so a landline is a perfectly good answer and the only bar is that the
|
|
105
|
+
* number is real. It also means no SMS warning appears on a field that has
|
|
106
|
+
* nothing to do with SMS.
|
|
107
|
+
*
|
|
108
|
+
* The country comes from the address the visitor is already filling in, which
|
|
109
|
+
* is the most confident source there is, and it arrives AFTER first render:
|
|
110
|
+
* `usePhoneInput` adopts a late-resolving default, but stops doing so once
|
|
111
|
+
* the visitor has touched the select themselves. A number kept in a form
|
|
112
|
+
* field is only ever the E.164 string, never what was typed.
|
|
113
|
+
*/
|
|
114
|
+
const shippingPhone = usePhoneInput({
|
|
115
|
+
mode: "shipping",
|
|
116
|
+
defaultCountry: (selectedCountryCode || undefined) as CountryCode | undefined,
|
|
117
|
+
onChange: (e164) => shippingForm.setValue("phone", e164 ?? "", { shouldValidate: true }),
|
|
118
|
+
});
|
|
119
|
+
|
|
97
120
|
const handleLogin = () => {
|
|
98
121
|
const here = typeof window !== "undefined" ? window.location.pathname : "/checkout";
|
|
99
122
|
go(`${loginPath}?redirect=${encodeURIComponent(here)}`);
|
|
@@ -155,7 +178,15 @@ export function Checkout({
|
|
|
155
178
|
</button>
|
|
156
179
|
);
|
|
157
180
|
|
|
158
|
-
const SecondaryButton = ({
|
|
181
|
+
const SecondaryButton = ({
|
|
182
|
+
text,
|
|
183
|
+
onClick,
|
|
184
|
+
fullWidth,
|
|
185
|
+
}: {
|
|
186
|
+
text: string;
|
|
187
|
+
onClick?: () => void;
|
|
188
|
+
fullWidth?: boolean;
|
|
189
|
+
}) => (
|
|
159
190
|
<button
|
|
160
191
|
type="button"
|
|
161
192
|
onClick={onClick}
|
|
@@ -186,7 +217,12 @@ export function Checkout({
|
|
|
186
217
|
return (
|
|
187
218
|
<div
|
|
188
219
|
className={["fg:w-full fg:max-w-4xl fg:mx-auto fg:p-6", className].filter(Boolean).join(" ")}
|
|
189
|
-
style={{
|
|
220
|
+
style={{
|
|
221
|
+
backgroundColor: theme.colors.background,
|
|
222
|
+
color: theme.colors.text,
|
|
223
|
+
fontFamily: theme.fontFamily,
|
|
224
|
+
...style,
|
|
225
|
+
}}
|
|
190
226
|
>
|
|
191
227
|
<div className="fg:text-center fg:py-12">
|
|
192
228
|
<h1 className="fg:text-2xl fg:font-bold fg:mb-4" style={{ color: theme.colors.text }}>
|
|
@@ -218,7 +254,12 @@ export function Checkout({
|
|
|
218
254
|
return (
|
|
219
255
|
<div
|
|
220
256
|
className={["fg:w-full fg:max-w-6xl fg:mx-auto fg:p-6", className].filter(Boolean).join(" ")}
|
|
221
|
-
style={{
|
|
257
|
+
style={{
|
|
258
|
+
backgroundColor: theme.colors.background,
|
|
259
|
+
color: theme.colors.text,
|
|
260
|
+
fontFamily: theme.fontFamily,
|
|
261
|
+
...style,
|
|
262
|
+
}}
|
|
222
263
|
>
|
|
223
264
|
{/* Progress Indicator */}
|
|
224
265
|
<div className="fg:mb-8">
|
|
@@ -227,7 +268,9 @@ export function Checkout({
|
|
|
227
268
|
const reached = currentIdx >= stageIdx(stage.key);
|
|
228
269
|
return (
|
|
229
270
|
<React.Fragment key={stage.key}>
|
|
230
|
-
{idx > 0 &&
|
|
271
|
+
{idx > 0 && (
|
|
272
|
+
<div className="fg:w-4 fg:h-0.5" style={{ backgroundColor: `${theme.colors.text}${alpha(0.3)}` }} />
|
|
273
|
+
)}
|
|
231
274
|
<div
|
|
232
275
|
className="fg:flex fg:items-center"
|
|
233
276
|
style={{ color: reached ? theme.colors.primary : `${theme.colors.text}${alpha(0.4)}` }}
|
|
@@ -263,6 +306,48 @@ export function Checkout({
|
|
|
263
306
|
</div>
|
|
264
307
|
)}
|
|
265
308
|
|
|
309
|
+
{/* Seats in the cart that another buyer took (seating wave 2). State
|
|
310
|
+
the buyer has to act on, so it is a visible alert, never a help
|
|
311
|
+
hint: the seats by name and the one way forward, which is back to
|
|
312
|
+
that event's picker. No seat is substituted here. */}
|
|
313
|
+
{c.seatsConflict && (
|
|
314
|
+
<div
|
|
315
|
+
role="alert"
|
|
316
|
+
data-testid="seats-conflict"
|
|
317
|
+
className="fg:mb-4 fg:p-4 fg:text-sm"
|
|
318
|
+
style={{
|
|
319
|
+
color: theme.colors.text,
|
|
320
|
+
border: `1px solid ${theme.colors.primary}`,
|
|
321
|
+
backgroundColor: `${theme.colors.primary}${alpha(0.08)}`,
|
|
322
|
+
borderRadius: `${theme.cornerRadius}px`,
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<p className="fg:font-semibold fg:mb-1">{tr("forge.checkout.seats_conflict_title")}</p>
|
|
326
|
+
<p className="fg:mb-3">
|
|
327
|
+
{tr("forge.checkout.seats_conflict_body", {
|
|
328
|
+
seats: c.seatsConflict.labels.join(", "),
|
|
329
|
+
event_title: c.seatsConflict.eventTitle,
|
|
330
|
+
})}
|
|
331
|
+
</p>
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
data-testid="seats-conflict-choose"
|
|
335
|
+
className="fg:px-4 fg:py-2 fg:font-semibold"
|
|
336
|
+
style={{
|
|
337
|
+
backgroundColor: theme.colors.primary,
|
|
338
|
+
color: readableTextOn(theme.colors.primary),
|
|
339
|
+
borderRadius: `${theme.cornerRadius}px`,
|
|
340
|
+
}}
|
|
341
|
+
onClick={() => {
|
|
342
|
+
const path = c.chooseOtherSeats();
|
|
343
|
+
if (path) go(path);
|
|
344
|
+
}}
|
|
345
|
+
>
|
|
346
|
+
{tr("forge.checkout.seats_conflict_action")}
|
|
347
|
+
</button>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
|
|
266
351
|
{/* Stage: User Details */}
|
|
267
352
|
{c.currentStage === "userDetails" && !c.user && (
|
|
268
353
|
<div
|
|
@@ -290,7 +375,10 @@ export function Checkout({
|
|
|
290
375
|
<form onSubmit={guestForm.handleSubmit(c.submitGuest)} className="fg:space-y-4">
|
|
291
376
|
<div className="fg:grid fg:grid-cols-1 fg:md:grid-cols-2 fg:gap-4">
|
|
292
377
|
<div>
|
|
293
|
-
<label
|
|
378
|
+
<label
|
|
379
|
+
className="fg:block fg:text-sm fg:font-medium fg:mb-2"
|
|
380
|
+
style={{ color: theme.colors.text }}
|
|
381
|
+
>
|
|
294
382
|
{tr("forge.checkout.first_name")}
|
|
295
383
|
</label>
|
|
296
384
|
<Controller
|
|
@@ -306,11 +394,16 @@ export function Checkout({
|
|
|
306
394
|
)}
|
|
307
395
|
/>
|
|
308
396
|
{guestForm.formState.errors.firstName && (
|
|
309
|
-
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
397
|
+
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
398
|
+
{guestForm.formState.errors.firstName.message}
|
|
399
|
+
</p>
|
|
310
400
|
)}
|
|
311
401
|
</div>
|
|
312
402
|
<div>
|
|
313
|
-
<label
|
|
403
|
+
<label
|
|
404
|
+
className="fg:block fg:text-sm fg:font-medium fg:mb-2"
|
|
405
|
+
style={{ color: theme.colors.text }}
|
|
406
|
+
>
|
|
314
407
|
{tr("forge.checkout.last_name")}
|
|
315
408
|
</label>
|
|
316
409
|
<Controller
|
|
@@ -326,7 +419,9 @@ export function Checkout({
|
|
|
326
419
|
)}
|
|
327
420
|
/>
|
|
328
421
|
{guestForm.formState.errors.lastName && (
|
|
329
|
-
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
422
|
+
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
423
|
+
{guestForm.formState.errors.lastName.message}
|
|
424
|
+
</p>
|
|
330
425
|
)}
|
|
331
426
|
</div>
|
|
332
427
|
</div>
|
|
@@ -403,7 +498,9 @@ export function Checkout({
|
|
|
403
498
|
)}
|
|
404
499
|
/>
|
|
405
500
|
{shippingForm.formState.errors.address1 && (
|
|
406
|
-
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
501
|
+
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
502
|
+
{shippingForm.formState.errors.address1.message}
|
|
503
|
+
</p>
|
|
407
504
|
)}
|
|
408
505
|
</div>
|
|
409
506
|
|
|
@@ -464,13 +561,18 @@ export function Checkout({
|
|
|
464
561
|
)}
|
|
465
562
|
/>
|
|
466
563
|
{shippingForm.formState.errors.countryCode && (
|
|
467
|
-
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
564
|
+
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
565
|
+
{shippingForm.formState.errors.countryCode.message}
|
|
566
|
+
</p>
|
|
468
567
|
)}
|
|
469
568
|
</div>
|
|
470
569
|
|
|
471
570
|
{selectedCountry?.states && selectedCountry.states.length > 0 && (
|
|
472
571
|
<div>
|
|
473
|
-
<label
|
|
572
|
+
<label
|
|
573
|
+
className="fg:block fg:text-sm fg:font-medium fg:mb-2"
|
|
574
|
+
style={{ color: theme.colors.text }}
|
|
575
|
+
>
|
|
474
576
|
{tr("forge.checkout.state_label")}
|
|
475
577
|
</label>
|
|
476
578
|
<Controller
|
|
@@ -489,7 +591,9 @@ export function Checkout({
|
|
|
489
591
|
)}
|
|
490
592
|
/>
|
|
491
593
|
{shippingForm.formState.errors.stateCode && (
|
|
492
|
-
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
594
|
+
<p className="fg:text-sm fg:text-red-500 fg:mt-1">
|
|
595
|
+
{shippingForm.formState.errors.stateCode.message}
|
|
596
|
+
</p>
|
|
493
597
|
)}
|
|
494
598
|
</div>
|
|
495
599
|
)}
|
|
@@ -503,11 +607,11 @@ export function Checkout({
|
|
|
503
607
|
control={shippingForm.control}
|
|
504
608
|
name="phone"
|
|
505
609
|
rules={{ required: tr("forge.checkout.phone_required") }}
|
|
506
|
-
render={(
|
|
507
|
-
<input {...field} placeholder={tr("forge.checkout.phone_placeholder")} style={inputStyle} />
|
|
508
|
-
)}
|
|
610
|
+
render={() => <PhoneInput controller={shippingPhone} />}
|
|
509
611
|
/>
|
|
510
|
-
{
|
|
612
|
+
{/* The field reports its own shape errors. This line is for the
|
|
613
|
+
form-level "we need one at all", which the field cannot know. */}
|
|
614
|
+
{shippingForm.formState.errors.phone && !shippingPhone.error && (
|
|
511
615
|
<p className="fg:text-sm fg:text-red-500 fg:mt-1">{shippingForm.formState.errors.phone.message}</p>
|
|
512
616
|
)}
|
|
513
617
|
</div>
|
|
@@ -652,7 +756,10 @@ export function Checkout({
|
|
|
652
756
|
|
|
653
757
|
{/* Shipping Info */}
|
|
654
758
|
{c.hasPhysicalProduct && c.shippingData && (
|
|
655
|
-
<div
|
|
759
|
+
<div
|
|
760
|
+
className="fg:mb-4 fg:pb-4"
|
|
761
|
+
style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}
|
|
762
|
+
>
|
|
656
763
|
<h4 className="fg:font-semibold fg:mb-2" style={{ color: theme.colors.text }}>
|
|
657
764
|
{tr("forge.checkout.summary_shipping_address")}
|
|
658
765
|
</h4>
|
|
@@ -683,7 +790,11 @@ export function Checkout({
|
|
|
683
790
|
.map(([ticketId, qty]) => {
|
|
684
791
|
const meta = t.ticketMeta[ticketId];
|
|
685
792
|
return (
|
|
686
|
-
<div
|
|
793
|
+
<div
|
|
794
|
+
key={`${t.eventId}-${ticketId}`}
|
|
795
|
+
className="fg:flex fg:justify-between fg:text-sm"
|
|
796
|
+
data-testid="summary-ticket-line"
|
|
797
|
+
>
|
|
687
798
|
<div className="fg:flex-1">
|
|
688
799
|
<p className="fg:font-medium" style={{ color: theme.colors.text }}>
|
|
689
800
|
{meta?.title ?? tr("forge.checkout.ticket_fallback")}
|
|
@@ -694,6 +805,19 @@ export function Checkout({
|
|
|
694
805
|
<p className="fg:text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
695
806
|
{tr("forge.checkout.qty", { count: qty })}
|
|
696
807
|
</p>
|
|
808
|
+
{/* The seats being paid for, by name: the one thing
|
|
809
|
+
on this page the buyer chose on a map. */}
|
|
810
|
+
{(meta?.seats?.length ?? 0) > 0 && (
|
|
811
|
+
<p
|
|
812
|
+
className="fg:text-xs"
|
|
813
|
+
data-testid="summary-ticket-seats"
|
|
814
|
+
style={{ color: `${theme.colors.text}${alpha(0.6)}` }}
|
|
815
|
+
>
|
|
816
|
+
{tr("forge.checkout.seats_label", {
|
|
817
|
+
seats: (meta?.seats ?? []).map((seat) => seat.label).join(", "),
|
|
818
|
+
})}
|
|
819
|
+
</p>
|
|
820
|
+
)}
|
|
697
821
|
</div>
|
|
698
822
|
<p className="fg:font-medium" style={{ color: theme.colors.text }}>
|
|
699
823
|
{formatCurrency((meta?.price ?? 0) * qty)}
|
|
@@ -740,7 +864,10 @@ export function Checkout({
|
|
|
740
864
|
that creates it, and one typed afterwards goes through
|
|
741
865
|
`/public/checkouts/apply-coupon`. Neither freezes the field. */}
|
|
742
866
|
{((c.currentStage === "payment" && c.orderId) || c.isBundle) && (
|
|
743
|
-
<div
|
|
867
|
+
<div
|
|
868
|
+
className="fg:mb-4 fg:pb-4"
|
|
869
|
+
style={{ borderBottom: `1px solid ${theme.colors.primary}${alpha(0.2)}` }}
|
|
870
|
+
>
|
|
744
871
|
{!c.showCouponInput && !c.appliedCoupon && c.isCouponEditable && (
|
|
745
872
|
<button
|
|
746
873
|
type="button"
|
|
@@ -873,13 +1000,19 @@ export function Checkout({
|
|
|
873
1000
|
{/* Inclusive mode: the total already contains tax — break out the
|
|
874
1001
|
contained amount once known, else a generic muted caption. */}
|
|
875
1002
|
{taxSummary?.mode === "inclusive" ? (
|
|
876
|
-
<p
|
|
1003
|
+
<p
|
|
1004
|
+
className="fg:text-xs fg:text-right"
|
|
1005
|
+
style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}
|
|
1006
|
+
>
|
|
877
1007
|
{taxSummary.label}
|
|
878
1008
|
</p>
|
|
879
1009
|
) : (
|
|
880
1010
|
!taxSummary &&
|
|
881
1011
|
pricesIncludeTax && (
|
|
882
|
-
<p
|
|
1012
|
+
<p
|
|
1013
|
+
className="fg:text-xs fg:text-right"
|
|
1014
|
+
style={{ color: `${theme.colors.text}${alpha(0.6)}`, margin: 0 }}
|
|
1015
|
+
>
|
|
883
1016
|
{tr("forge.checkout.incl_tax")}
|
|
884
1017
|
</p>
|
|
885
1018
|
)
|
|
@@ -905,19 +1038,14 @@ export function Checkout({
|
|
|
905
1038
|
* happens on mount: the modal is opened by the button, so a buyer still reading
|
|
906
1039
|
* the summary is not interrupted, and one who closes it can press it again.
|
|
907
1040
|
*/
|
|
908
|
-
function PaymentSection({
|
|
909
|
-
checkout,
|
|
910
|
-
onBack,
|
|
911
|
-
}: {
|
|
912
|
-
checkout: ReturnType<typeof useCheckout>;
|
|
913
|
-
onBack?: () => void;
|
|
914
|
-
}) {
|
|
1041
|
+
function PaymentSection({ checkout, onBack }: { checkout: ReturnType<typeof useCheckout>; onBack?: () => void }) {
|
|
915
1042
|
const theme = useForgeTheme();
|
|
916
1043
|
const tr = useForgeT();
|
|
917
1044
|
const renderPay = usePaymentRenderer();
|
|
918
|
-
const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } =
|
|
1045
|
+
const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } =
|
|
1046
|
+
checkout;
|
|
919
1047
|
const { canOpenPaystack, openPaystackCheckout } = checkout;
|
|
920
|
-
const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
|
|
1048
|
+
const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold, seatsConflict } = checkout;
|
|
921
1049
|
|
|
922
1050
|
// "There is something to pay with", whichever provider answered: Stripe's
|
|
923
1051
|
// secret or Paystack's session. Used only to decide whether the preparing
|
|
@@ -995,7 +1123,7 @@ function PaymentSection({
|
|
|
995
1123
|
{/* Suppressed once the server has refused: there is no intent coming, so a
|
|
996
1124
|
"Preparing payment…" spinner would sit there forever under a notice
|
|
997
1125
|
that has already explained what happened. */}
|
|
998
|
-
{isCreatingOrder && !hasCharge && !isHoldExpired && (
|
|
1126
|
+
{isCreatingOrder && !hasCharge && !isHoldExpired && !seatsConflict && (
|
|
999
1127
|
<Loading label={tr("forge.checkout.preparing_payment")} size={22} />
|
|
1000
1128
|
)}
|
|
1001
1129
|
|
|
@@ -1063,7 +1191,10 @@ function ShippingRateSelector({
|
|
|
1063
1191
|
// Flat / external — a single, auto-selected rate.
|
|
1064
1192
|
<div
|
|
1065
1193
|
className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md"
|
|
1066
|
-
style={{
|
|
1194
|
+
style={{
|
|
1195
|
+
backgroundColor: `${theme.colors.primary}${alpha(0.05)}`,
|
|
1196
|
+
borderRadius: `${theme.cornerRadius}px`,
|
|
1197
|
+
}}
|
|
1067
1198
|
>
|
|
1068
1199
|
<div>
|
|
1069
1200
|
<span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { CountryCode } from "libphonenumber-js";
|
|
1
2
|
import { useEmailListForm } from "../headless/forms/useEmailListForm";
|
|
3
|
+
import { PhoneInput } from "./PhoneInput";
|
|
2
4
|
import { useForgeT } from "../../i18n";
|
|
3
5
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
6
|
import { readableTextOn } from "../theme/contrast";
|
|
@@ -8,6 +10,8 @@ export interface EmailListFormProps {
|
|
|
8
10
|
leadMagnetId?: string;
|
|
9
11
|
includeName?: boolean;
|
|
10
12
|
includePhoneNumber?: boolean;
|
|
13
|
+
/** Seeds the phone country select. Undefined lets the visitor choose. */
|
|
14
|
+
defaultCountry?: CountryCode;
|
|
11
15
|
title?: string;
|
|
12
16
|
description?: string;
|
|
13
17
|
buttonText?: string;
|
|
@@ -20,6 +24,7 @@ export function EmailListForm({
|
|
|
20
24
|
leadMagnetId,
|
|
21
25
|
includeName,
|
|
22
26
|
includePhoneNumber,
|
|
27
|
+
defaultCountry,
|
|
23
28
|
title,
|
|
24
29
|
description,
|
|
25
30
|
buttonText,
|
|
@@ -27,7 +32,7 @@ export function EmailListForm({
|
|
|
27
32
|
}: EmailListFormProps) {
|
|
28
33
|
const t = useForgeT();
|
|
29
34
|
const theme = useForgeTheme();
|
|
30
|
-
const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber });
|
|
35
|
+
const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber, defaultCountry });
|
|
31
36
|
|
|
32
37
|
// Defaults resolve here rather than in the signature so they can be
|
|
33
38
|
// translated. `??` keeps a caller-supplied "" falsy exactly as before.
|
|
@@ -71,14 +76,7 @@ export function EmailListForm({
|
|
|
71
76
|
onChange={(e) => f.setEmail(e.target.value)}
|
|
72
77
|
style={inputStyle}
|
|
73
78
|
/>
|
|
74
|
-
{includePhoneNumber &&
|
|
75
|
-
<input
|
|
76
|
-
placeholder={t("forge.email_list_form.phone_placeholder")}
|
|
77
|
-
value={f.phoneNumber}
|
|
78
|
-
onChange={(e) => f.setPhoneNumber(e.target.value)}
|
|
79
|
-
style={inputStyle}
|
|
80
|
-
/>
|
|
81
|
-
)}
|
|
79
|
+
{includePhoneNumber && <PhoneInput controller={f.phone} />}
|
|
82
80
|
{f.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{f.error}</p>}
|
|
83
81
|
<button
|
|
84
82
|
onClick={f.submit}
|
|
@@ -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 { EventSeatPicker } from "./seating/EventSeatPicker";
|
|
30
|
+
import { tierColorMap } from "./seating/paint";
|
|
29
31
|
import { CallHelpHint } from "../media/CallHelpHint";
|
|
30
32
|
import { isDeferredSettlement, deferredChargeNotice } from "../payment/stripeConfirmOutcome";
|
|
31
33
|
import {
|
|
@@ -86,7 +88,11 @@ export function EventTickets({
|
|
|
86
88
|
const theme = useForgeTheme();
|
|
87
89
|
const registered = usePaymentRenderer();
|
|
88
90
|
const fmt = useAmountFormatter(formatAmount);
|
|
89
|
-
const
|
|
91
|
+
const [open, setOpen] = useState(false);
|
|
92
|
+
// `seatingActive`: the seating read is requested, and polled, only while
|
|
93
|
+
// this modal is open. Closed, an event page with a seat map costs the API
|
|
94
|
+
// nothing more than one without.
|
|
95
|
+
const c = useEventCheckout(slug, { finalisePath, onComplete, seatingActive: open });
|
|
90
96
|
const renderPay = renderPayment ?? registered ?? undefined;
|
|
91
97
|
|
|
92
98
|
// Does this page sell add-ons for this event? Same query key as the page's
|
|
@@ -103,8 +109,6 @@ export function EventTickets({
|
|
|
103
109
|
[pageActionsData],
|
|
104
110
|
);
|
|
105
111
|
|
|
106
|
-
const [open, setOpen] = useState(false);
|
|
107
|
-
|
|
108
112
|
const tryClose = (next: boolean) => {
|
|
109
113
|
if (!next && c.step !== "tickets" && typeof window !== "undefined") {
|
|
110
114
|
if (!window.confirm(t("forge.event_tickets.leave_confirm"))) return;
|
|
@@ -288,6 +292,9 @@ function TicketStep({
|
|
|
288
292
|
// Inclusive stores caption ticket prices as tax-inclusive (display only).
|
|
289
293
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
290
294
|
const [now, setNow] = useState(() => Date.now());
|
|
295
|
+
// The same palette the map and the legend use, so the swatch on a tier row
|
|
296
|
+
// is the colour of its seats by construction.
|
|
297
|
+
const tierColors = useMemo(() => tierColorMap(event.tickets.map((ticket) => ticket.id)), [event.tickets]);
|
|
291
298
|
|
|
292
299
|
useEffect(() => {
|
|
293
300
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
@@ -365,10 +372,38 @@ function TicketStep({
|
|
|
365
372
|
and from the broadcast-pass prompt) where there is no page-level box.
|
|
366
373
|
Both read the SAME stored code, so unlocking in one unlocks the other. */}
|
|
367
374
|
<PresaleCodeField presale={c.presale} style={{ marginBottom: 16 }} />
|
|
375
|
+
{/* Reserved seating (wave 2). The map sits above the tier list so the
|
|
376
|
+
buyer picks a seat first and reads the tier row as the receipt for
|
|
377
|
+
it; the row's own controls (chips, best available, the stepper) all
|
|
378
|
+
write the same selection. */}
|
|
379
|
+
{event.hasSeatMap && (
|
|
380
|
+
<div style={{ marginBottom: 16 }}>
|
|
381
|
+
<EventSeatPicker
|
|
382
|
+
event={event}
|
|
383
|
+
seating={c.seating}
|
|
384
|
+
selectedSeats={c.selectedSeats}
|
|
385
|
+
onToggleSeat={c.toggleSeat}
|
|
386
|
+
seatsTaken={c.seatsTaken}
|
|
387
|
+
onDismissSeatsTaken={c.dismissSeatsTaken}
|
|
388
|
+
formatAmount={fmt}
|
|
389
|
+
/>
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
368
392
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
369
393
|
{event.tickets.length === 0 && <p style={{ opacity: 0.7 }}>{t("forge.event_tickets.no_tickets")}</p>}
|
|
370
394
|
{event.tickets.map((ticket) => {
|
|
371
395
|
const selectedQty = c.selectedTickets[ticket.id] ?? 0;
|
|
396
|
+
/**
|
|
397
|
+
* A tier that sells reserved seats: its quantity IS the seat
|
|
398
|
+
* selection, so the row shows the chosen seats as removable chips
|
|
399
|
+
* and offers "Best available" beside the stepper. The stepper stays,
|
|
400
|
+
* picking or dropping best-available seats, for a buyer who does not
|
|
401
|
+
* care where they sit.
|
|
402
|
+
*/
|
|
403
|
+
const isSeated = event.hasSeatMap === true && c.isSeatedTier(ticket.id);
|
|
404
|
+
const tierColor = isSeated ? tierColors[ticket.id] : undefined;
|
|
405
|
+
const seatsLeft = c.seating.availableByTier[ticket.id] ?? 0;
|
|
406
|
+
const chosenSeats = c.seatSummary.find((s) => s.ticketId === ticket.id)?.seats ?? [];
|
|
372
407
|
/**
|
|
373
408
|
* `quantity - sold - HELD`, which is what the server sells on. The
|
|
374
409
|
* old `quantity - sold` showed seats sitting in other buyers' live
|
|
@@ -429,7 +464,25 @@ function TicketStep({
|
|
|
429
464
|
style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 8 }}
|
|
430
465
|
>
|
|
431
466
|
<div style={{ flex: 1 }}>
|
|
432
|
-
<h3
|
|
467
|
+
<h3
|
|
468
|
+
style={{
|
|
469
|
+
fontSize: 17,
|
|
470
|
+
fontWeight: 600,
|
|
471
|
+
marginBottom: 6,
|
|
472
|
+
display: "flex",
|
|
473
|
+
alignItems: "center",
|
|
474
|
+
gap: 8,
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
{tierColor && (
|
|
478
|
+
<span
|
|
479
|
+
aria-hidden
|
|
480
|
+
data-testid="ticket-tier-swatch"
|
|
481
|
+
style={{ width: 12, height: 12, borderRadius: 9999, background: tierColor, flexShrink: 0 }}
|
|
482
|
+
/>
|
|
483
|
+
)}
|
|
484
|
+
{ticket.title}
|
|
485
|
+
</h3>
|
|
433
486
|
{/* A hidden tier is only ever on screen because a code
|
|
434
487
|
revealed it, so say so. Without the badge, a code that
|
|
435
488
|
unlocked a tier the buyer had not seen listed looks like it
|
|
@@ -592,6 +645,56 @@ function TicketStep({
|
|
|
592
645
|
</button>
|
|
593
646
|
</div>
|
|
594
647
|
</div>
|
|
648
|
+
{isSeated && !isSoldOut && !isExpired && !isScheduled && !isGated && (
|
|
649
|
+
<div
|
|
650
|
+
data-testid="ticket-seat-controls"
|
|
651
|
+
style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, marginTop: 4 }}
|
|
652
|
+
>
|
|
653
|
+
{chosenSeats.map((seat) => (
|
|
654
|
+
<span
|
|
655
|
+
key={seat.id}
|
|
656
|
+
data-testid="ticket-seat-chip"
|
|
657
|
+
style={seatChipStyle(tierColor ?? theme.colors.primary, theme)}
|
|
658
|
+
>
|
|
659
|
+
{seat.label}
|
|
660
|
+
<button
|
|
661
|
+
type="button"
|
|
662
|
+
aria-label={t("forge.event_tickets.seat_remove_aria", { seat: seat.label })}
|
|
663
|
+
onClick={() => c.toggleSeat(ticket.id, seat.id)}
|
|
664
|
+
style={{
|
|
665
|
+
background: "transparent",
|
|
666
|
+
border: "none",
|
|
667
|
+
padding: 0,
|
|
668
|
+
cursor: "pointer",
|
|
669
|
+
color: "inherit",
|
|
670
|
+
display: "inline-flex",
|
|
671
|
+
}}
|
|
672
|
+
>
|
|
673
|
+
<X size={12} />
|
|
674
|
+
</button>
|
|
675
|
+
</span>
|
|
676
|
+
))}
|
|
677
|
+
<button
|
|
678
|
+
type="button"
|
|
679
|
+
data-testid="ticket-best-available"
|
|
680
|
+
onClick={() => c.pickBestAvailable(ticket.id, Math.max(1, selectedQty))}
|
|
681
|
+
disabled={seatsLeft === 0 || isMixBlocked}
|
|
682
|
+
style={{
|
|
683
|
+
...secondaryButtonStyle(theme),
|
|
684
|
+
padding: "6px 10px",
|
|
685
|
+
fontSize: 13,
|
|
686
|
+
opacity: seatsLeft === 0 || isMixBlocked ? 0.5 : 1,
|
|
687
|
+
}}
|
|
688
|
+
>
|
|
689
|
+
{t("forge.event_tickets.seat_best_available")}
|
|
690
|
+
</button>
|
|
691
|
+
<span style={{ fontSize: 13, opacity: 0.7 }}>
|
|
692
|
+
{seatsLeft === 1
|
|
693
|
+
? t("forge.event_tickets.seats_left_one", { count: seatsLeft })
|
|
694
|
+
: t("forge.event_tickets.seats_left_other", { count: seatsLeft })}
|
|
695
|
+
</span>
|
|
696
|
+
</div>
|
|
697
|
+
)}
|
|
595
698
|
{/* The amount box appears only once a seat is actually selected.
|
|
596
699
|
Before that there is nothing to price, and a row of empty
|
|
597
700
|
inputs down the tier list reads as a form to fill in rather
|
|
@@ -688,6 +791,9 @@ function TicketStep({
|
|
|
688
791
|
{/* A request cannot go to the cart: a bundle forces payment and the API
|
|
689
792
|
refuses approval tiers in one. So a request selection gets the plain
|
|
690
793
|
Continue with no cart exit, whether or not the page sells add-ons. */}
|
|
794
|
+
{/* Reserved seats ride in the bundle cart like any other line (the cart
|
|
795
|
+
line carries their ids and labels, and the bundle checkout re-checks
|
|
796
|
+
them before payment), so a seated selection keeps the cart exits. */}
|
|
691
797
|
{c.awaitingApproval ? (
|
|
692
798
|
<ActionBar c={c} fmt={fmt} onNext={c.goToDetails} nextLabel={t("forge.event_tickets.continue")} />
|
|
693
799
|
) : hasAddons ? (
|
|
@@ -965,6 +1071,24 @@ function PaymentStep({
|
|
|
965
1071
|
marginBottom: 16,
|
|
966
1072
|
}}
|
|
967
1073
|
>
|
|
1074
|
+
{/* The seats being paid for, by name. The one thing on this screen the
|
|
1075
|
+
buyer chose on a map rather than typed, so it is the one thing they
|
|
1076
|
+
will want to re-read before the card. */}
|
|
1077
|
+
{c.seatSummary.length > 0 && (
|
|
1078
|
+
<div data-testid="payment-seat-summary" style={{ marginBottom: 12, fontSize: 14 }}>
|
|
1079
|
+
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t("forge.event_tickets.seats_title")}</div>
|
|
1080
|
+
{c.seatSummary.map((tier) => (
|
|
1081
|
+
<div key={tier.ticketId} style={{ display: "flex", flexDirection: "column", gap: 2, marginBottom: 6 }}>
|
|
1082
|
+
<span style={{ opacity: 0.75 }}>{tier.title}</span>
|
|
1083
|
+
{tier.seats.map((seat) => (
|
|
1084
|
+
<span key={seat.id} style={{ paddingLeft: 12 }}>
|
|
1085
|
+
{seat.label}
|
|
1086
|
+
</span>
|
|
1087
|
+
))}
|
|
1088
|
+
</div>
|
|
1089
|
+
))}
|
|
1090
|
+
</div>
|
|
1091
|
+
)}
|
|
968
1092
|
{/* What the code actually did, from the order response, never a total
|
|
969
1093
|
that silently shrank. Automatic (no-code) discounts land here too. */}
|
|
970
1094
|
<DiscountSummaryLines
|
|
@@ -1328,6 +1452,21 @@ const infoNoticeStyle = (color: string, t: ForgeTheme): CSSProperties => ({
|
|
|
1328
1452
|
gap: 8,
|
|
1329
1453
|
});
|
|
1330
1454
|
|
|
1455
|
+
/** A chosen seat under its tier row, in the tier's colour, with its remove button. */
|
|
1456
|
+
const seatChipStyle = (color: string, t: ForgeTheme): CSSProperties => ({
|
|
1457
|
+
display: "inline-flex",
|
|
1458
|
+
alignItems: "center",
|
|
1459
|
+
gap: 6,
|
|
1460
|
+
padding: "4px 8px",
|
|
1461
|
+
borderRadius: t.cornerRadius,
|
|
1462
|
+
fontSize: 13,
|
|
1463
|
+
fontWeight: 600,
|
|
1464
|
+
background: a(color, 0.12),
|
|
1465
|
+
color: t.colors.text,
|
|
1466
|
+
border: `1px solid ${a(color, 0.5)}`,
|
|
1467
|
+
borderLeft: `4px solid ${color}`,
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1331
1470
|
const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
|
|
1332
1471
|
width: 40,
|
|
1333
1472
|
height: 40,
|
|
@@ -17,10 +17,12 @@ export interface LoginFormProps {
|
|
|
17
17
|
* form looks exactly as it did.
|
|
18
18
|
*/
|
|
19
19
|
membershipTierId?: string;
|
|
20
|
+
/** The membership invite token riding on the same redirect, if any. See `MembershipTierCallout`. */
|
|
21
|
+
membershipInviteToken?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/** Themed 2FA login form (credentials → emailed code), built on `useLoginFlow`. */
|
|
23
|
-
export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId }: LoginFormProps) {
|
|
25
|
+
export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId, membershipInviteToken }: LoginFormProps) {
|
|
24
26
|
const t = useForgeT();
|
|
25
27
|
const theme = useForgeTheme();
|
|
26
28
|
const flow = useLoginFlow({ onSuccess });
|
|
@@ -70,7 +72,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId
|
|
|
70
72
|
<div style={card}>
|
|
71
73
|
{/* Still on the way to the tier they picked, so it stays in view while
|
|
72
74
|
they fetch the code out of their inbox. */}
|
|
73
|
-
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
75
|
+
<MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
|
|
74
76
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>{t("forge.login_form.verify_title")}</h1>
|
|
75
77
|
<p style={{ fontSize: 14, opacity: 0.7, marginBottom: 24 }}>
|
|
76
78
|
{t("forge.login_form.code_sent_to")} <strong>{flow.email}</strong>
|
|
@@ -118,7 +120,7 @@ export function LoginForm({ onSuccess, signupHref, forgotHref, membershipTierId
|
|
|
118
120
|
<div style={card}>
|
|
119
121
|
{/* What the fan was doing when they hit this wall. Nothing is drawn when
|
|
120
122
|
they came here on their own. */}
|
|
121
|
-
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
123
|
+
<MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
|
|
122
124
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>{t("forge.login_form.title")}</h1>
|
|
123
125
|
{flow.error && <p style={{ color: "#ef4444", marginBottom: 16 }}>{flow.error}</p>}
|
|
124
126
|
<form
|