@tribe-nest/forge 3.25.0 → 3.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/activeFunnel.ts +35 -0
- package/src/client/createForgeClient.ts +9 -0
- package/src/contexts/CartContext.tsx +8 -1
- package/src/data/queries/useForms.ts +48 -3
- package/src/data/queries/usePageActions.ts +2 -2
- package/src/ui/headless/checkout/useCheckout.ts +50 -12
- package/src/ui/headless/event/useEventCheckout.ts +3 -0
- package/src/ui/headless/funnel/Funnel.tsx +13 -0
- package/src/ui/styled/BundleConfirmation.tsx +77 -46
- package/src/ui/styled/Checkout.tsx +41 -2
- package/src/ui/styled/EventTickets.tsx +84 -22
- package/src/ui/styled/PageActions.tsx +13 -9
- package/src/ui/styled/ProductGrid.tsx +17 -4
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Which funnel the visitor is currently inside, held outside React so the
|
|
2
|
+
// shared axios client can read it.
|
|
3
|
+
//
|
|
4
|
+
// Why a module singleton rather than a prop or a hook argument: a funnel's UI
|
|
5
|
+
// is written per site, increasingly by an agent, and the pages that CONVERT (a
|
|
6
|
+
// booking, a checkout, an email-list join) are ordinary Forge components that
|
|
7
|
+
// know nothing about funnels. Any design where each capture call has to pass
|
|
8
|
+
// the funnel produces attribution that is missing on whichever call someone
|
|
9
|
+
// forgot, which reads as complete and undercounts in silence. Put it in the
|
|
10
|
+
// transport and no call site can omit it.
|
|
11
|
+
//
|
|
12
|
+
// Scope: one active funnel at a time. Funnels are page-level flows and nesting
|
|
13
|
+
// them has no meaning, so a nested <Funnel> replacing its parent is the honest
|
|
14
|
+
// behaviour rather than a merge.
|
|
15
|
+
|
|
16
|
+
let activeFunnelId: string | null = null;
|
|
17
|
+
|
|
18
|
+
/** Mark this funnel as the one the visitor is currently in. */
|
|
19
|
+
export function setActiveFunnel(funnelId: string | null): void {
|
|
20
|
+
activeFunnelId = funnelId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The active funnel id, or null outside a funnel.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT the funnel session id. Attribution answers "which funnel
|
|
27
|
+
* brought in this lead", and the funnel id alone answers it. The session id is
|
|
28
|
+
* a behavioural key, so attaching it to a named person is the thing consent
|
|
29
|
+
* regimes exist for, and it is also the one value that vanishes in private mode
|
|
30
|
+
* (`getFunnelSessionId` returns null), which would make attribution degrade
|
|
31
|
+
* exactly where the funnel id does not.
|
|
32
|
+
*/
|
|
33
|
+
export function getActiveFunnelId(): string | null {
|
|
34
|
+
return activeFunnelId;
|
|
35
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import axios, { type AxiosError, type AxiosInstance } from "axios";
|
|
2
|
+
import { getActiveFunnelId } from "./activeFunnel";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
|
|
@@ -60,6 +61,14 @@ export const createForgeClient = ({
|
|
|
60
61
|
config.headers["x-forge-key"] = publishableKey;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// First-touch funnel attribution. Rides every request rather than every
|
|
65
|
+
// capture call, so a lead that arrives through a booking or a checkout is
|
|
66
|
+
// attributed the same as one that arrives through a form.
|
|
67
|
+
const funnelId = getActiveFunnelId();
|
|
68
|
+
if (funnelId) {
|
|
69
|
+
config.headers["x-funnel-id"] = funnelId;
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
const token = getToken?.();
|
|
64
73
|
if (token) {
|
|
65
74
|
config.headers["authorization"] = `Bearer ${token}`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import type { ProductDeliveryType } from "../types/models";
|
|
2
|
+
import type { IBookingFee, ProductDeliveryType } from "../types/models";
|
|
3
3
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { useEffect } from "react";
|
|
@@ -92,6 +92,13 @@ export type TicketCartItem = {
|
|
|
92
92
|
* chosen amount has to arrive in a field the server actually reads.
|
|
93
93
|
*/
|
|
94
94
|
ticketMeta: Record<string, { title: string; price: number; pwywAmount?: number }>;
|
|
95
|
+
/**
|
|
96
|
+
* The event's RESOLVED booking fee, captured when the modal put the tickets
|
|
97
|
+
* here, so the cart checkout can show the fee line the buyer will be charged
|
|
98
|
+
* (an estimate until start-payment answers; the server figure then wins).
|
|
99
|
+
* Absent on carts saved before the field existed, which renders as no line.
|
|
100
|
+
*/
|
|
101
|
+
bookingFee?: IBookingFee | null;
|
|
95
102
|
};
|
|
96
103
|
|
|
97
104
|
interface CartContextType {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
1
2
|
import type { FormData, FormAnswerEntry } from "../../types/models";
|
|
2
3
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
@@ -18,14 +19,58 @@ export function usePublicForm(formId?: string) {
|
|
|
18
19
|
});
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
export interface SubmitPublicFormInput {
|
|
23
|
+
answers: FormAnswerEntry[];
|
|
24
|
+
/**
|
|
25
|
+
* Send this whenever the form collects an email.
|
|
26
|
+
*
|
|
27
|
+
* It is what resolves the submission to a CRM contact, and therefore what
|
|
28
|
+
* makes the lead attributable to the funnel at all. A submission without it
|
|
29
|
+
* is stored, but anonymous: no contact, no attribution, and no automation,
|
|
30
|
+
* because the form's automations are contact-scoped.
|
|
31
|
+
*/
|
|
32
|
+
respondentEmail?: string;
|
|
33
|
+
respondentName?: string;
|
|
34
|
+
metadata?: Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function newIdempotencyKey(): string {
|
|
38
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
39
|
+
return `fk_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Submit a public form.
|
|
44
|
+
*
|
|
45
|
+
* Accepts either a bare answers array or the full input. The full form is what
|
|
46
|
+
* a funnel step wants, because `respondentEmail` is what turns a submission
|
|
47
|
+
* into a lead.
|
|
48
|
+
*
|
|
49
|
+
* **Idempotency.** A key is minted per mount and rotated after each successful
|
|
50
|
+
* submit. A double-click or a network retry therefore reuses it and replays the
|
|
51
|
+
* first submission instead of writing a second row or re-firing the form's
|
|
52
|
+
* automations. Going back and deliberately submitting again is a new mount, so
|
|
53
|
+
* a new key and a genuine second submission. Nothing is ever overwritten: a
|
|
54
|
+
* submission can be the record of what somebody agreed to.
|
|
55
|
+
*/
|
|
22
56
|
export function useSubmitPublicForm(formId?: string) {
|
|
23
57
|
const { client } = useForge();
|
|
58
|
+
const keyRef = useRef<string>(newIdempotencyKey());
|
|
24
59
|
|
|
25
60
|
return useMutation({
|
|
26
|
-
mutationFn: async (
|
|
27
|
-
const
|
|
61
|
+
mutationFn: async (input: FormAnswerEntry[] | SubmitPublicFormInput) => {
|
|
62
|
+
const payload = Array.isArray(input) ? { answers: input } : input;
|
|
63
|
+
const res = await client.post(`/public/forms/${formId}/submit`, {
|
|
64
|
+
...payload,
|
|
65
|
+
idempotencyKey: keyRef.current,
|
|
66
|
+
});
|
|
28
67
|
return res.data;
|
|
29
68
|
},
|
|
69
|
+
// Rotate only once the write has landed. A failed submit keeps the key so
|
|
70
|
+
// the retry is recognised as the same act; a successful one starts a fresh
|
|
71
|
+
// act, so a form left mounted for corrections records each as its own.
|
|
72
|
+
onSuccess: () => {
|
|
73
|
+
keyRef.current = newIdempotencyKey();
|
|
74
|
+
},
|
|
30
75
|
});
|
|
31
76
|
}
|
|
@@ -5,9 +5,9 @@ export type PageActionType =
|
|
|
5
5
|
| "newsletter"
|
|
6
6
|
| "lead_magnet"
|
|
7
7
|
| "offer"
|
|
8
|
+
// Products on a page. `config.isAddon` decides whether they can be bought on
|
|
9
|
+
// their own or only alongside what the page is selling.
|
|
8
10
|
| "product_cards"
|
|
9
|
-
// Specific products sold as attachments to the page's own entity.
|
|
10
|
-
| "addons"
|
|
11
11
|
| "donation"
|
|
12
12
|
| "membership"
|
|
13
13
|
| "button";
|
|
@@ -23,6 +23,7 @@ import { holdExpiredMessage, isHoldExpiredError } from "./inventoryHold";
|
|
|
23
23
|
import { useInventoryHold } from "./useInventoryHold";
|
|
24
24
|
import { readAttributionRef } from "../../../utils/attribution";
|
|
25
25
|
import { readLanding } from "../../../utils/landing";
|
|
26
|
+
import { computeBookingFeeAmount } from "../../format/bookingFee";
|
|
26
27
|
import { ProductDeliveryType, PaymentProviderName, type ApiError, type PublicTaxQuote } from "../../../types/models";
|
|
27
28
|
|
|
28
29
|
/** The four checkout stages (guest details are skipped for a logged-in buyer;
|
|
@@ -114,7 +115,15 @@ function messageOf(e: unknown): string | undefined {
|
|
|
114
115
|
export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
115
116
|
const finalisePath = opts.finalisePath ?? "/checkout/finalise";
|
|
116
117
|
const { user } = usePublicAuth();
|
|
117
|
-
const { cartItems, ticketItems,
|
|
118
|
+
const { cartItems, ticketItems, isReady: isCartReady } = useCart();
|
|
119
|
+
/**
|
|
120
|
+
* Any cart holding tickets settles through the checkout (bundle) engine,
|
|
121
|
+
* even with NO products beside them. Tickets have no single-surface order
|
|
122
|
+
* path from this page: `createOrder` only carries `cartItems`, so routing a
|
|
123
|
+
* tickets-only cart there minted an order with a total and no lines. (The
|
|
124
|
+
* event modal's own direct flow is unaffected; it never comes through here.)
|
|
125
|
+
*/
|
|
126
|
+
const usesCheckoutEngine = ticketItems.length > 0;
|
|
118
127
|
const { data: countries = [] } = useShippingCountries();
|
|
119
128
|
const shippingRates = useShippingRates();
|
|
120
129
|
const createOrder = useCreateOrder();
|
|
@@ -145,6 +154,27 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
145
154
|
[cartItems, ticketItems],
|
|
146
155
|
);
|
|
147
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The booking fee the ticket lines will be charged, summed per event from the
|
|
159
|
+
* resolved fee each selection carried out of the modal. An ESTIMATE with the
|
|
160
|
+
* same honesty contract as the modal's own line: it keeps the running total
|
|
161
|
+
* explained while no charge exists, and the server's charged total supersedes
|
|
162
|
+
* it the moment start-payment answers.
|
|
163
|
+
*/
|
|
164
|
+
const bookingFeeCost = useMemo(
|
|
165
|
+
() =>
|
|
166
|
+
round2(
|
|
167
|
+
ticketItems.reduce((sum, t) => {
|
|
168
|
+
const ticketSubtotal = Object.entries(t.tickets).reduce(
|
|
169
|
+
(acc, [id, qty]) => acc + (t.ticketMeta[id]?.price ?? 0) * qty,
|
|
170
|
+
0,
|
|
171
|
+
);
|
|
172
|
+
return sum + computeBookingFeeAmount({ subtotal: ticketSubtotal, fee: t.bookingFee });
|
|
173
|
+
}, 0),
|
|
174
|
+
),
|
|
175
|
+
[ticketItems],
|
|
176
|
+
);
|
|
177
|
+
|
|
148
178
|
const [currentStage, setCurrentStage] = useState<CheckoutStage>("userDetails");
|
|
149
179
|
const [guestUserData, setGuestUserData] = useState<GuestUserData | null>(null);
|
|
150
180
|
const [shippingData, setShippingData] = useState<CheckoutShippingData>();
|
|
@@ -211,10 +241,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
211
241
|
if (currentStage !== "payment" || !isPaidCheckout || startedRef.current) return;
|
|
212
242
|
startedRef.current = true;
|
|
213
243
|
|
|
214
|
-
// A cart
|
|
215
|
-
//
|
|
216
|
-
// has always had, below.
|
|
217
|
-
if (
|
|
244
|
+
// A cart holding tickets (with or without products) goes through the
|
|
245
|
+
// bundle engine so everything settles on one payment. A products-only
|
|
246
|
+
// cart keeps the flow it has always had, below.
|
|
247
|
+
if (usesCheckoutEngine) {
|
|
218
248
|
const enteredCode = couponCode.trim() || undefined;
|
|
219
249
|
createCheckout
|
|
220
250
|
.mutateAsync({
|
|
@@ -457,7 +487,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
457
487
|
* the checkout was created — which for a signed-in buyer with a digital-only
|
|
458
488
|
* cart was on mount, so the code could never be typed at all.
|
|
459
489
|
*/
|
|
460
|
-
const isCouponEditable =
|
|
490
|
+
const isCouponEditable = usesCheckoutEngine ? !settledCheckoutId : !!orderId;
|
|
461
491
|
|
|
462
492
|
/** Fold an apply/remove answer for a bundle back into the summary + payment. */
|
|
463
493
|
const absorbBundleCoupon = useCallback((data: ApplyCheckoutCouponResult) => {
|
|
@@ -479,7 +509,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
479
509
|
}, []);
|
|
480
510
|
|
|
481
511
|
const applyCoupon = useCallback(async () => {
|
|
482
|
-
if (
|
|
512
|
+
if (usesCheckoutEngine) {
|
|
483
513
|
if (!couponCode.trim()) return;
|
|
484
514
|
setCouponError("");
|
|
485
515
|
// Not created yet (a guest, or a cart with a shipping stage still ahead):
|
|
@@ -534,10 +564,10 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
534
564
|
} finally {
|
|
535
565
|
setIsApplyingCoupon(false);
|
|
536
566
|
}
|
|
537
|
-
}, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon,
|
|
567
|
+
}, [couponCode, orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
|
|
538
568
|
|
|
539
569
|
const removeCoupon = useCallback(async () => {
|
|
540
|
-
if (
|
|
570
|
+
if (usesCheckoutEngine) {
|
|
541
571
|
setCouponError("");
|
|
542
572
|
// Nothing created yet — the staged code simply never travels.
|
|
543
573
|
if (!checkoutId) {
|
|
@@ -592,7 +622,7 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
592
622
|
} finally {
|
|
593
623
|
setIsApplyingCoupon(false);
|
|
594
624
|
}
|
|
595
|
-
}, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon,
|
|
625
|
+
}, [orderId, checkoutId, finalisePath, applyCouponMutation, applyCheckoutCouponMutation, absorbBundleCoupon, usesCheckoutEngine]);
|
|
596
626
|
|
|
597
627
|
// ── Free checkout (nothing to charge) ───────────────────────────────────────
|
|
598
628
|
/** Create the (free) order and return its id so the caller can navigate to the
|
|
@@ -660,6 +690,8 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
660
690
|
isCartReady,
|
|
661
691
|
subTotal,
|
|
662
692
|
shippingCost,
|
|
693
|
+
/** Estimated booking fee across the cart's ticket lines; 0 with no tickets. */
|
|
694
|
+
bookingFeeCost,
|
|
663
695
|
discountAmount,
|
|
664
696
|
hasPhysicalProduct,
|
|
665
697
|
isPaidCheckout,
|
|
@@ -695,7 +727,9 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
695
727
|
* page instead of waiting for a payment that will never be asked for.
|
|
696
728
|
*/
|
|
697
729
|
settledCheckoutId,
|
|
698
|
-
|
|
730
|
+
/** True when this checkout settles through the bundle engine (any cart
|
|
731
|
+
holding tickets), which is what the coupon field and summary key off. */
|
|
732
|
+
isBundle: usesCheckoutEngine,
|
|
699
733
|
ticketItems,
|
|
700
734
|
isCreatingOrder: createOrder.isPending || flow.isStarting,
|
|
701
735
|
startError,
|
|
@@ -731,7 +765,11 @@ export function useCheckout(opts: UseCheckoutOptions = {}) {
|
|
|
731
765
|
paymentId: effectivePayment?.paymentId,
|
|
732
766
|
chargedAmount: effectivePayment?.chargedAmount ?? 0,
|
|
733
767
|
chargedCurrency: effectivePayment?.chargedCurrency ?? "",
|
|
734
|
-
|
|
768
|
+
// The SAME bundle-aware URL handed to start-payment. This was previously
|
|
769
|
+
// rebuilt here from `orderId` alone, so a bundle (which has a checkoutId
|
|
770
|
+
// and no order) confirmed its Stripe payment with `?orderId=` empty and
|
|
771
|
+
// landed on a finalise page that could not resolve anything.
|
|
772
|
+
returnUrl,
|
|
735
773
|
// ── Free checkout ──────────────────────────────────────────────────────────
|
|
736
774
|
isFreeCheckoutLoading,
|
|
737
775
|
freeCheckout,
|
|
@@ -264,6 +264,9 @@ export function useEventCheckout(slug?: string, opts: UseEventCheckoutOptions =
|
|
|
264
264
|
eventSlug: slug ?? event.id,
|
|
265
265
|
eventTitle: event.title,
|
|
266
266
|
coverImage: event.media?.find((m: { type: string }) => m.type === "image")?.url,
|
|
267
|
+
// The resolved fee travels with the selection so the CART checkout can
|
|
268
|
+
// show the booking-fee line this modal already shows.
|
|
269
|
+
bookingFee: event.bookingFee ?? null,
|
|
267
270
|
tickets: { ...selectedTickets },
|
|
268
271
|
ticketMeta: Object.fromEntries(
|
|
269
272
|
event.tickets
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
|
|
2
|
+
import { setActiveFunnel } from "../../../client/activeFunnel";
|
|
2
3
|
import { useTrackEvent } from "../../../data/queries/useAnalytics";
|
|
3
4
|
import { useCookieConsent } from "../consent/useCookieConsent";
|
|
4
5
|
import { getFunnelSessionId, markOnce, resetFunnelSession } from "./funnelSession";
|
|
@@ -114,6 +115,18 @@ export function Funnel({ id, steps, name, enabled = true, requireConsent = true,
|
|
|
114
115
|
const emitRef = useRef(emit);
|
|
115
116
|
emitRef.current = emit;
|
|
116
117
|
|
|
118
|
+
// Attribution is registered on `enabled`, NOT on `on`. Analytics is what
|
|
119
|
+
// cookie consent governs; "this lead came from this funnel" is first-party
|
|
120
|
+
// business data about a conversion the visitor deliberately started, and
|
|
121
|
+
// gating it behind performance consent would silently drop the attribution of
|
|
122
|
+
// every lead who declined cookies. Nothing behavioural is attached to the
|
|
123
|
+
// person: the funnel id travels, the funnel SESSION id never does.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!enabled) return;
|
|
126
|
+
setActiveFunnel(id);
|
|
127
|
+
return () => setActiveFunnel(null);
|
|
128
|
+
}, [enabled, id]);
|
|
129
|
+
|
|
117
130
|
useEffect(() => {
|
|
118
131
|
if (!on) return;
|
|
119
132
|
if (!markOnce(id, "entered")) return;
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
2
|
import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
|
|
3
3
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
4
|
import { readableTextOn } from "../theme/contrast";
|
|
5
5
|
import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from "./Confirmation";
|
|
6
6
|
import { useCheckoutFinalize } from "../../data/queries/useFinalize";
|
|
7
7
|
import { useCart } from "../../contexts/CartContext";
|
|
8
|
+
import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
9
|
+
|
|
10
|
+
/** The card's padded, centered interior — the shell itself has neither, and
|
|
11
|
+
content dropped in bare sits left-aligned against the card edge. */
|
|
12
|
+
function Body({ children }: { children: ReactNode }) {
|
|
13
|
+
return <div style={{ padding: "40px 32px", textAlign: "center" }}>{children}</div>;
|
|
14
|
+
}
|
|
8
15
|
|
|
9
16
|
export interface BundleConfirmationProps {
|
|
10
17
|
checkoutId: string;
|
|
@@ -32,6 +39,9 @@ export function BundleConfirmation({
|
|
|
32
39
|
}: BundleConfirmationProps) {
|
|
33
40
|
const theme = useForgeTheme();
|
|
34
41
|
const { clearCart } = useCart();
|
|
42
|
+
// A guest has no account screen behind "View your orders": the link would
|
|
43
|
+
// land them on a login wall for an order their EMAIL is the receipt for.
|
|
44
|
+
const { user } = usePublicAuth();
|
|
35
45
|
const failedRedirect = redirectStatus === "failed";
|
|
36
46
|
|
|
37
47
|
const { data, isLoading, isError } = useCheckoutFinalize(
|
|
@@ -73,12 +83,14 @@ export function BundleConfirmation({
|
|
|
73
83
|
return (
|
|
74
84
|
<ConfirmationStage>
|
|
75
85
|
<ConfirmationCard>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
<Body>
|
|
87
|
+
<WarnSeal icon={<XCircle size={28} />} />
|
|
88
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment didn't go through</h1>
|
|
89
|
+
<p style={{ opacity: 0.75, marginBottom: 20 }}>
|
|
90
|
+
Nothing was charged and your cart is still here. You can try again.
|
|
91
|
+
</p>
|
|
92
|
+
{link(checkoutPath, "Return to checkout", true)}
|
|
93
|
+
</Body>
|
|
82
94
|
</ConfirmationCard>
|
|
83
95
|
</ConfirmationStage>
|
|
84
96
|
);
|
|
@@ -88,8 +100,11 @@ export function BundleConfirmation({
|
|
|
88
100
|
return (
|
|
89
101
|
<ConfirmationStage>
|
|
90
102
|
<ConfirmationCard>
|
|
91
|
-
<
|
|
92
|
-
|
|
103
|
+
<Body>
|
|
104
|
+
<Loader2 size={28} style={{ animation: "cc-spin 1s linear infinite" }} />
|
|
105
|
+
<style>{"@keyframes cc-spin{to{transform:rotate(360deg)}}"}</style>
|
|
106
|
+
<h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>Confirming your order…</h1>
|
|
107
|
+
</Body>
|
|
93
108
|
</ConfirmationCard>
|
|
94
109
|
</ConfirmationStage>
|
|
95
110
|
);
|
|
@@ -102,16 +117,18 @@ export function BundleConfirmation({
|
|
|
102
117
|
return (
|
|
103
118
|
<ConfirmationStage>
|
|
104
119
|
<ConfirmationCard>
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
<Body>
|
|
121
|
+
<WarnSeal icon={<Clock size={28} />} />
|
|
122
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
|
|
123
|
+
<p style={{ opacity: 0.75, marginBottom: 20 }}>
|
|
124
|
+
Your payment went through and part of your order is confirmed. We're still finishing the rest. It
|
|
125
|
+
will arrive by email shortly, with nothing more to pay.
|
|
126
|
+
</p>
|
|
127
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
128
|
+
{user && link(accountPath, "View your orders", true)}
|
|
129
|
+
{link(explorePath, "Continue shopping", !user)}
|
|
130
|
+
</div>
|
|
131
|
+
</Body>
|
|
115
132
|
</ConfirmationCard>
|
|
116
133
|
</ConfirmationStage>
|
|
117
134
|
);
|
|
@@ -123,38 +140,52 @@ export function BundleConfirmation({
|
|
|
123
140
|
return (
|
|
124
141
|
<ConfirmationStage>
|
|
125
142
|
<ConfirmationCard>
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
|
|
130
|
-
{ticketChildren > 0 && orderChildren > 0 && ", and "}
|
|
131
|
-
{orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
|
|
132
|
-
</p>
|
|
133
|
-
|
|
134
|
-
{ticketChildren > 0 && (
|
|
135
|
-
<div
|
|
143
|
+
<Body>
|
|
144
|
+
<CheckSeal />
|
|
145
|
+
<p
|
|
136
146
|
style={{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
background: alpha(theme.colors.primary, 0.08),
|
|
147
|
+
margin: "22px 0 6px",
|
|
148
|
+
fontSize: 12,
|
|
149
|
+
fontWeight: 700,
|
|
150
|
+
letterSpacing: "0.22em",
|
|
151
|
+
textTransform: "uppercase",
|
|
143
152
|
color: theme.colors.primary,
|
|
144
|
-
fontSize: 14,
|
|
145
|
-
fontWeight: 600,
|
|
146
|
-
marginBottom: 20,
|
|
147
153
|
}}
|
|
148
154
|
>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
</
|
|
152
|
-
|
|
155
|
+
Order confirmed
|
|
156
|
+
</p>
|
|
157
|
+
<h1 style={{ fontSize: 24, fontWeight: 800, margin: "0 0 8px" }}>You're all set</h1>
|
|
158
|
+
<p style={{ opacity: 0.75, margin: "0 auto 16px", maxWidth: 360, lineHeight: 1.55 }}>
|
|
159
|
+
Paid in one go. {ticketChildren > 0 && "Your tickets are on their way by email"}
|
|
160
|
+
{ticketChildren > 0 && orderChildren > 0 && ", and "}
|
|
161
|
+
{orderChildren > 0 && `${ticketChildren > 0 ? "your order is confirmed" : "Your order is confirmed"}`}.
|
|
162
|
+
</p>
|
|
153
163
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
{ticketChildren > 0 && (
|
|
165
|
+
<div
|
|
166
|
+
style={{
|
|
167
|
+
display: "inline-flex",
|
|
168
|
+
alignItems: "center",
|
|
169
|
+
gap: 8,
|
|
170
|
+
padding: "8px 14px",
|
|
171
|
+
borderRadius: theme.cornerRadius,
|
|
172
|
+
background: alpha(theme.colors.primary, 0.08),
|
|
173
|
+
color: theme.colors.primary,
|
|
174
|
+
fontSize: 14,
|
|
175
|
+
fontWeight: 600,
|
|
176
|
+
marginBottom: 20,
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<Ticket size={16} />
|
|
180
|
+
Tickets emailed to you
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
185
|
+
{user && link(accountPath, "View your orders", true)}
|
|
186
|
+
{link(explorePath, "Continue shopping", !user)}
|
|
187
|
+
</div>
|
|
188
|
+
</Body>
|
|
158
189
|
</ConfirmationCard>
|
|
159
190
|
</ConfirmationStage>
|
|
160
191
|
);
|
|
@@ -177,7 +177,9 @@ export function Checkout({
|
|
|
177
177
|
return <Loading fullPage />;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
// Tickets count: a cart holding only tickets (buyer skipped the add-ons)
|
|
181
|
+
// is NOT empty, and this page is the only checkout it has.
|
|
182
|
+
if (c.cartItems.length === 0 && c.ticketItems.length === 0) {
|
|
181
183
|
return (
|
|
182
184
|
<div
|
|
183
185
|
className={["w-full max-w-4xl mx-auto p-6", className].filter(Boolean).join(" ")}
|
|
@@ -622,6 +624,34 @@ export function Checkout({
|
|
|
622
624
|
Items
|
|
623
625
|
</h4>
|
|
624
626
|
<div className="space-y-2">
|
|
627
|
+
{/* Ticket lines first: they are what the bundle is FOR, and the
|
|
628
|
+
subtotal already includes them, so a summary without them
|
|
629
|
+
reads as a wrong total. */}
|
|
630
|
+
{c.ticketItems.flatMap((t) =>
|
|
631
|
+
Object.entries(t.tickets)
|
|
632
|
+
.filter(([, qty]) => qty > 0)
|
|
633
|
+
.map(([ticketId, qty]) => {
|
|
634
|
+
const meta = t.ticketMeta[ticketId];
|
|
635
|
+
return (
|
|
636
|
+
<div key={`${t.eventId}-${ticketId}`} className="flex justify-between text-sm" data-testid="summary-ticket-line">
|
|
637
|
+
<div className="flex-1">
|
|
638
|
+
<p className="font-medium" style={{ color: theme.colors.text }}>
|
|
639
|
+
{meta?.title ?? "Ticket"}
|
|
640
|
+
</p>
|
|
641
|
+
<p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
642
|
+
{t.eventTitle}
|
|
643
|
+
</p>
|
|
644
|
+
<p className="text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
645
|
+
Qty: {qty}
|
|
646
|
+
</p>
|
|
647
|
+
</div>
|
|
648
|
+
<p className="font-medium" style={{ color: theme.colors.text }}>
|
|
649
|
+
{formatCurrency((meta?.price ?? 0) * qty)}
|
|
650
|
+
</p>
|
|
651
|
+
</div>
|
|
652
|
+
);
|
|
653
|
+
}),
|
|
654
|
+
)}
|
|
625
655
|
{c.cartItems.map((item) => (
|
|
626
656
|
<div
|
|
627
657
|
key={`${item.productId}-${item.productVariantId}-${item.isGift}-${item.recipientEmail}`}
|
|
@@ -756,6 +786,15 @@ export function Checkout({
|
|
|
756
786
|
<span style={{ color: theme.colors.text }}>{formatCurrency(c.shippingCost)}</span>
|
|
757
787
|
</div>
|
|
758
788
|
)}
|
|
789
|
+
{/* The ticket booking fee, named BEFORE the buyer commits. It is
|
|
790
|
+
charged either way; omitting the line here left a total that
|
|
791
|
+
jumped past the subtotal with no explanation. */}
|
|
792
|
+
{c.bookingFeeCost > 0 && (
|
|
793
|
+
<div className="flex justify-between" data-testid="summary-booking-fee">
|
|
794
|
+
<span style={{ color: theme.colors.text }}>Booking fee</span>
|
|
795
|
+
<span style={{ color: theme.colors.text }}>{formatCurrency(c.bookingFeeCost)}</span>
|
|
796
|
+
</div>
|
|
797
|
+
)}
|
|
759
798
|
{/* Exclusive mode: the authoritative tax line, once the checkout
|
|
760
799
|
quote is known (added on top of the total). */}
|
|
761
800
|
{taxSummary?.mode === "exclusive" && (
|
|
@@ -772,7 +811,7 @@ export function Checkout({
|
|
|
772
811
|
<span>
|
|
773
812
|
{c.chargedTotal
|
|
774
813
|
? formatCurrency(c.chargedTotal.amount, c.chargedTotal.currency)
|
|
775
|
-
: formatCurrency(c.subTotal + c.shippingCost)}
|
|
814
|
+
: formatCurrency(c.subTotal + c.shippingCost + c.bookingFeeCost)}
|
|
776
815
|
</span>
|
|
777
816
|
</div>
|
|
778
817
|
{/* Inclusive mode: the total already contains tax — break out the
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
ticketSeatState,
|
|
17
17
|
} from "../format/ticketAvailability";
|
|
18
18
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
19
|
+
import { usePageActions } from "../../data/queries/usePageActions";
|
|
19
20
|
import { Loading } from "./Loading";
|
|
20
21
|
import { PaystackPayButton } from "./PaystackPayButton";
|
|
21
22
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
@@ -80,6 +81,20 @@ export function EventTickets({
|
|
|
80
81
|
const c = useEventCheckout(slug, { finalisePath, onComplete });
|
|
81
82
|
const renderPay = renderPayment ?? registered ?? undefined;
|
|
82
83
|
|
|
84
|
+
// Does this page sell add-ons for this event? Same query key as the page's
|
|
85
|
+
// own <PageActions> mount, so this is a cache read, not a second request.
|
|
86
|
+
// When add-ons exist, the PRIMARY exit from ticket selection is the cart
|
|
87
|
+
// (that's what unlocks the add-on grid); direct payment stays the happy
|
|
88
|
+
// path only on pages without add-ons.
|
|
89
|
+
const { data: pageActionsData } = usePageActions("event_detail", c.event?.id);
|
|
90
|
+
const hasAddons = useMemo(
|
|
91
|
+
() =>
|
|
92
|
+
(pageActionsData?.actions ?? []).some(
|
|
93
|
+
(action) => action.type === "product_cards" && Boolean((action.config as Record<string, unknown>)?.isAddon),
|
|
94
|
+
),
|
|
95
|
+
[pageActionsData],
|
|
96
|
+
);
|
|
97
|
+
|
|
83
98
|
const [open, setOpen] = useState(false);
|
|
84
99
|
|
|
85
100
|
const tryClose = (next: boolean) => {
|
|
@@ -133,9 +148,20 @@ export function EventTickets({
|
|
|
133
148
|
renderPayment={renderPay}
|
|
134
149
|
loginPath={loginPath}
|
|
135
150
|
membershipPath={membershipPath}
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
151
|
+
hasAddons={hasAddons}
|
|
152
|
+
// Selection is safe in the cart now: close, and bring the
|
|
153
|
+
// freshly unlocked add-on grid into view so the buyer lands on
|
|
154
|
+
// the next step instead of wherever they left the page.
|
|
155
|
+
onAddedToCart={() => {
|
|
156
|
+
setOpen(false);
|
|
157
|
+
if (typeof document !== "undefined") {
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
document
|
|
160
|
+
.querySelector('[data-testid="addons-block"]')
|
|
161
|
+
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
162
|
+
}, 100);
|
|
163
|
+
}
|
|
164
|
+
}}
|
|
139
165
|
/>
|
|
140
166
|
)}
|
|
141
167
|
</DialogContent>
|
|
@@ -152,6 +178,7 @@ function EventTicketsBody({
|
|
|
152
178
|
fmt,
|
|
153
179
|
renderPayment,
|
|
154
180
|
onAddedToCart,
|
|
181
|
+
hasAddons,
|
|
155
182
|
loginPath,
|
|
156
183
|
membershipPath,
|
|
157
184
|
}: {
|
|
@@ -160,6 +187,7 @@ function EventTicketsBody({
|
|
|
160
187
|
fmt: (n: number) => string;
|
|
161
188
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
162
189
|
onAddedToCart?: () => void;
|
|
190
|
+
hasAddons?: boolean;
|
|
163
191
|
loginPath?: string;
|
|
164
192
|
membershipPath?: string;
|
|
165
193
|
}) {
|
|
@@ -173,6 +201,7 @@ function EventTicketsBody({
|
|
|
173
201
|
event={event}
|
|
174
202
|
fmt={fmt}
|
|
175
203
|
onAddedToCart={onAddedToCart}
|
|
204
|
+
hasAddons={hasAddons}
|
|
176
205
|
loginPath={loginPath}
|
|
177
206
|
membershipPath={membershipPath}
|
|
178
207
|
/>
|
|
@@ -232,6 +261,7 @@ function TicketStep({
|
|
|
232
261
|
event,
|
|
233
262
|
fmt,
|
|
234
263
|
onAddedToCart,
|
|
264
|
+
hasAddons,
|
|
235
265
|
loginPath,
|
|
236
266
|
membershipPath,
|
|
237
267
|
}: {
|
|
@@ -239,6 +269,7 @@ function TicketStep({
|
|
|
239
269
|
event: IEvent;
|
|
240
270
|
fmt: (n: number) => string;
|
|
241
271
|
onAddedToCart?: () => void;
|
|
272
|
+
hasAddons?: boolean;
|
|
242
273
|
loginPath?: string;
|
|
243
274
|
membershipPath?: string;
|
|
244
275
|
}) {
|
|
@@ -533,21 +564,37 @@ function TicketStep({
|
|
|
533
564
|
</div>
|
|
534
565
|
|
|
535
566
|
{c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
onClick: () => {
|
|
567
|
+
{/* Two shapes of this bar. With add-ons on the page, the PRIMARY action
|
|
568
|
+
is the cart: that is the only path that unlocks the add-on grid, so
|
|
569
|
+
it has to be the happy path, with a direct tickets-only checkout as
|
|
570
|
+
the secondary exit. Without add-ons the original flow is untouched:
|
|
571
|
+
Continue goes straight to details, the cart is the secondary exit. */}
|
|
572
|
+
{hasAddons ? (
|
|
573
|
+
<ActionBar
|
|
574
|
+
c={c}
|
|
575
|
+
fmt={fmt}
|
|
576
|
+
onNext={() => {
|
|
547
577
|
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
|
|
578
|
+
}}
|
|
579
|
+
nextLabel={c.ticketsInCart ? "Update cart & choose add-ons" : "Continue to add-ons"}
|
|
580
|
+
nextTestId="tickets-add-to-cart"
|
|
581
|
+
secondary={{ label: "Checkout tickets only", onClick: c.goToDetails, testId: "tickets-checkout-only" }}
|
|
582
|
+
/>
|
|
583
|
+
) : (
|
|
584
|
+
<ActionBar
|
|
585
|
+
c={c}
|
|
586
|
+
fmt={fmt}
|
|
587
|
+
onNext={c.goToDetails}
|
|
588
|
+
nextLabel="Continue"
|
|
589
|
+
secondary={{
|
|
590
|
+
label: c.ticketsInCart ? "Update cart" : "Add to cart",
|
|
591
|
+
onClick: () => {
|
|
592
|
+
if (c.addTicketsToCart()) onAddedToCart?.();
|
|
593
|
+
},
|
|
594
|
+
testId: "tickets-add-to-cart",
|
|
595
|
+
}}
|
|
596
|
+
/>
|
|
597
|
+
)}
|
|
551
598
|
</div>
|
|
552
599
|
);
|
|
553
600
|
}
|
|
@@ -692,7 +739,13 @@ function DetailsStep({
|
|
|
692
739
|
fmt={fmt}
|
|
693
740
|
onBack={() => c.setStep("tickets")}
|
|
694
741
|
onNext={onContinue}
|
|
695
|
-
|
|
742
|
+
// Nothing to pay means no payment step follows, so promising one is a
|
|
743
|
+
// lie the next screen immediately contradicts. Read off `totalAmount`
|
|
744
|
+
// rather than the ticket prices because it already carries the booking
|
|
745
|
+
// fee and any discount: a free ticket with a FLAT booking fee still
|
|
746
|
+
// costs money and still goes to payment, and a 100%-off code on a paid
|
|
747
|
+
// ticket does not.
|
|
748
|
+
nextLabel={c.isProcessing ? "Processing…" : c.totalAmount > 0 ? "Continue to payment" : "Confirm"}
|
|
696
749
|
nextDisabled={c.isProcessing}
|
|
697
750
|
/>
|
|
698
751
|
</div>
|
|
@@ -865,6 +918,7 @@ function ActionBar({
|
|
|
865
918
|
onNext,
|
|
866
919
|
nextLabel,
|
|
867
920
|
nextDisabled,
|
|
921
|
+
nextTestId,
|
|
868
922
|
secondary,
|
|
869
923
|
}: {
|
|
870
924
|
c: Checkout;
|
|
@@ -873,8 +927,11 @@ function ActionBar({
|
|
|
873
927
|
onNext: () => void;
|
|
874
928
|
nextLabel: string;
|
|
875
929
|
nextDisabled?: boolean;
|
|
876
|
-
/**
|
|
877
|
-
|
|
930
|
+
/** Test id for the primary button; the caller says which button is which
|
|
931
|
+
action, because add-to-cart moves between slots depending on add-ons. */
|
|
932
|
+
nextTestId?: string;
|
|
933
|
+
/** Extra exit shown next to Back on the selection step. */
|
|
934
|
+
secondary?: { label: string; onClick: () => void; testId?: string };
|
|
878
935
|
}) {
|
|
879
936
|
const theme = useForgeTheme();
|
|
880
937
|
return (
|
|
@@ -904,7 +961,7 @@ function ActionBar({
|
|
|
904
961
|
</div>
|
|
905
962
|
<div style={{ display: "flex", gap: 8, justifyContent: "space-between" }}>
|
|
906
963
|
{secondary ? (
|
|
907
|
-
<button data-testid=
|
|
964
|
+
<button data-testid={secondary.testId} onClick={secondary.onClick} style={secondaryButtonStyle(theme)}>
|
|
908
965
|
{secondary.label}
|
|
909
966
|
</button>
|
|
910
967
|
) : (
|
|
@@ -912,7 +969,12 @@ function ActionBar({
|
|
|
912
969
|
Back
|
|
913
970
|
</button>
|
|
914
971
|
)}
|
|
915
|
-
<button
|
|
972
|
+
<button
|
|
973
|
+
data-testid={nextTestId}
|
|
974
|
+
onClick={onNext}
|
|
975
|
+
disabled={nextDisabled}
|
|
976
|
+
style={{ ...primaryButtonStyle(theme), opacity: nextDisabled ? 0.6 : 1 }}
|
|
977
|
+
>
|
|
916
978
|
{nextLabel}
|
|
917
979
|
</button>
|
|
918
980
|
</div>
|
|
@@ -15,7 +15,7 @@ export interface PageActionsProps {
|
|
|
15
15
|
placement?: string;
|
|
16
16
|
/**
|
|
17
17
|
* Where product detail lives on this site — `/i/store` on code sites,
|
|
18
|
-
* `/products` on Craft ones. Only
|
|
18
|
+
* `/products` on Craft ones. Only add-on product cards use it.
|
|
19
19
|
*/
|
|
20
20
|
productBasePath?: string;
|
|
21
21
|
className?: string;
|
|
@@ -63,14 +63,11 @@ function ActionRenderer({
|
|
|
63
63
|
case "offer":
|
|
64
64
|
return <OfferButton productId={c.productId} text={c.text} />;
|
|
65
65
|
case "product_cards":
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
);
|
|
72
|
-
case "addons":
|
|
73
|
-
return (
|
|
66
|
+
// One config, two buying rules. `isAddon` gates the products behind the
|
|
67
|
+
// page's own purchase (a ticket on an event page); without it they are
|
|
68
|
+
// ordinary cards anyone can buy on their own. Both draw the same grid,
|
|
69
|
+
// which is why this is one action type rather than two.
|
|
70
|
+
return c.isAddon ? (
|
|
74
71
|
<Addons
|
|
75
72
|
entityId={entityId}
|
|
76
73
|
productIds={c.productIds ?? []}
|
|
@@ -78,6 +75,13 @@ function ActionRenderer({
|
|
|
78
75
|
columns={c.columns}
|
|
79
76
|
productBasePath={productBasePath}
|
|
80
77
|
/>
|
|
78
|
+
) : (
|
|
79
|
+
<div>
|
|
80
|
+
{c.title && <h3 style={{ fontWeight: 600, marginBottom: 12 }}>{c.title}</h3>}
|
|
81
|
+
{/* Chosen products win; with none chosen the grid falls back to the
|
|
82
|
+
shop listing capped at `limit`. */}
|
|
83
|
+
<ProductGrid columns={c.columns} limit={c.limit} productIds={c.productIds} />
|
|
84
|
+
</div>
|
|
81
85
|
);
|
|
82
86
|
case "donation":
|
|
83
87
|
return <DonationButton donationId={c.donationId} text={c.text} />;
|
|
@@ -78,11 +78,24 @@ export function ProductGrid({
|
|
|
78
78
|
if (isLoading) return <Loading />;
|
|
79
79
|
if (!products.length) return <p style={{ color: t.text, opacity: 0.7 }}>{emptyLabel ?? "No products yet."}</p>;
|
|
80
80
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
81
|
+
// `columns` is the DESKTOP count. Each card is also given a minimum width, so
|
|
82
|
+
// the grid drops to fewer columns as the container narrows and reaches a
|
|
83
|
+
// single column on a phone.
|
|
84
|
+
//
|
|
85
|
+
// No media query, because the column count is per-instance data: a static
|
|
86
|
+
// stylesheet would need a generated class for every distinct `columns` value
|
|
87
|
+
// on the page. `auto-fill` expresses the same intent with no injected CSS,
|
|
88
|
+
// and it responds to the CONTAINER rather than the viewport, so a grid in a
|
|
89
|
+
// narrow column behaves correctly on a wide screen.
|
|
90
|
+
//
|
|
91
|
+
// The minimum is what sets the phone breakpoint. At 260px a 390px-wide phone
|
|
92
|
+
// fits one card ((390 + 16) / (260 + 16) = 1.47), and anything under ~552px
|
|
93
|
+
// stays single-column; a 3-column desktop is unaffected because
|
|
94
|
+
// (1200 - 32) / 3 = 389px already exceeds it. Raised from 150px, which fitted
|
|
95
|
+
// two cramped cards side by side on every phone.
|
|
84
96
|
const gap = 16;
|
|
85
|
-
const
|
|
97
|
+
const minCardWidth = 260;
|
|
98
|
+
const track = `minmax(max(${minCardWidth}px, calc((100% - ${(columns - 1) * gap}px) / ${columns})), 1fr)`;
|
|
86
99
|
|
|
87
100
|
return (
|
|
88
101
|
<div
|