@tribe-nest/forge 1.20.2 → 2.2.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 +26 -0
- package/src/contexts/CartContext.tsx +132 -8
- package/src/data/queries/useCheckouts.ts +101 -0
- package/src/data/queries/useCollections.ts +24 -4
- package/src/data/queries/useFinalize.ts +41 -0
- package/src/data/queries/usePageActions.ts +2 -0
- package/src/index.ts +6 -1
- package/src/server/_tests/appUserPermissions.spec.ts +197 -0
- package/src/server/appAuth.ts +37 -2
- package/src/server/appUsers.ts +133 -0
- package/src/server/index.ts +17 -0
- package/src/server/jobs.ts +141 -6
- package/src/server/platform.ts +208 -0
- package/src/types/models.ts +26 -2
- package/src/ui/headless/checkout/useCheckout.ts +56 -9
- package/src/ui/headless/event/useEventCheckout.ts +36 -0
- package/src/ui/headless/funnel/Funnel.tsx +159 -0
- package/src/ui/headless/funnel/funnelSession.spec.ts +108 -0
- package/src/ui/headless/funnel/funnelSession.ts +88 -0
- package/src/ui/headless/funnel/index.ts +3 -0
- package/src/ui/headless/funnel/useFunnelStep.ts +70 -0
- package/src/ui/headless/index.ts +3 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/styled/Addons.tsx +77 -0
- package/src/ui/styled/BundleConfirmation.tsx +161 -0
- package/src/ui/styled/Cart.tsx +66 -9
- package/src/ui/styled/CheckoutConfirmation.tsx +25 -1
- package/src/ui/styled/EventTickets.tsx +52 -7
- package/src/ui/styled/PageActions.tsx +34 -3
- package/src/ui/styled/ProductGrid.tsx +45 -9
- package/src/utils/formatDateTime.ts +25 -0
- package/src/utils/headMeta.ts +50 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { getFunnelSessionId, markOnce, resetFunnelSession } from "./funnelSession";
|
|
3
|
+
|
|
4
|
+
// A tiny in-memory `sessionStorage`. The real one isn't available in a node
|
|
5
|
+
// environment, and the behaviour under test is precisely what happens when it
|
|
6
|
+
// is and isn't there.
|
|
7
|
+
function fakeStorage(): Storage {
|
|
8
|
+
const map = new Map<string, string>();
|
|
9
|
+
return {
|
|
10
|
+
get length() {
|
|
11
|
+
return map.size;
|
|
12
|
+
},
|
|
13
|
+
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
14
|
+
getItem: (k: string) => map.get(k) ?? null,
|
|
15
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
16
|
+
removeItem: (k: string) => void map.delete(k),
|
|
17
|
+
clear: () => map.clear(),
|
|
18
|
+
} as Storage;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const g = globalThis as unknown as { window?: unknown };
|
|
22
|
+
|
|
23
|
+
function withStorage(storage: Storage | null) {
|
|
24
|
+
g.window = storage
|
|
25
|
+
? { sessionStorage: storage }
|
|
26
|
+
: {
|
|
27
|
+
get sessionStorage(): Storage {
|
|
28
|
+
// What a browser does when storage is blocked (private mode, or a
|
|
29
|
+
// cookie policy that denies it) — a throwing getter, not undefined.
|
|
30
|
+
throw new Error("access denied");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("funnel session", () => {
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
delete g.window;
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("with storage", () => {
|
|
42
|
+
beforeEach(() => withStorage(fakeStorage()));
|
|
43
|
+
|
|
44
|
+
it("mints one id per funnel and keeps returning it", () => {
|
|
45
|
+
const first = getFunnelSessionId("launch");
|
|
46
|
+
expect(first).toBeTruthy();
|
|
47
|
+
// Stability is the whole point: a new id per call would make every step
|
|
48
|
+
// look like a different visitor and report total drop-off everywhere.
|
|
49
|
+
expect(getFunnelSessionId("launch")).toBe(first);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps separate funnels separate", () => {
|
|
53
|
+
expect(getFunnelSessionId("a")).not.toBe(getFunnelSessionId("b"));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("marks a thing once, then reports it already seen", () => {
|
|
57
|
+
expect(markOnce("launch", "view_offer")).toBe(true);
|
|
58
|
+
expect(markOnce("launch", "view_offer")).toBe(false);
|
|
59
|
+
// Different step, same funnel — unaffected.
|
|
60
|
+
expect(markOnce("launch", "view_checkout")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("restarting clears the id and every mark for that funnel only", () => {
|
|
64
|
+
const before = getFunnelSessionId("launch");
|
|
65
|
+
markOnce("launch", "view_offer");
|
|
66
|
+
const otherId = getFunnelSessionId("other");
|
|
67
|
+
markOnce("other", "view_offer");
|
|
68
|
+
|
|
69
|
+
resetFunnelSession("launch");
|
|
70
|
+
|
|
71
|
+
expect(getFunnelSessionId("launch")).not.toBe(before);
|
|
72
|
+
expect(markOnce("launch", "view_offer")).toBe(true);
|
|
73
|
+
// The neighbouring funnel is untouched.
|
|
74
|
+
expect(getFunnelSessionId("other")).toBe(otherId);
|
|
75
|
+
expect(markOnce("other", "view_offer")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("without storage", () => {
|
|
80
|
+
beforeEach(() => withStorage(null));
|
|
81
|
+
|
|
82
|
+
it("returns no session id rather than inventing one", () => {
|
|
83
|
+
// Null is the honest answer: the report degrades to per-step totals it
|
|
84
|
+
// can't stitch, which is better than a fabricated per-call id that would
|
|
85
|
+
// read as a fresh visitor at every step.
|
|
86
|
+
expect(getFunnelSessionId("launch")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("treats every mark as first-time, so events still fire", () => {
|
|
90
|
+
// Erring towards emitting: a duplicate event is noise, a missing one is a
|
|
91
|
+
// hole in the funnel.
|
|
92
|
+
expect(markOnce("launch", "view_offer")).toBe(true);
|
|
93
|
+
expect(markOnce("launch", "view_offer")).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("restarting is a no-op rather than a crash", () => {
|
|
97
|
+
expect(() => resetFunnelSession("launch")).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("on the server", () => {
|
|
102
|
+
it("has no session and never touches storage", () => {
|
|
103
|
+
delete g.window;
|
|
104
|
+
expect(getFunnelSessionId("launch")).toBeNull();
|
|
105
|
+
expect(markOnce("launch", "entered")).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Funnel session identity + per-session de-duplication.
|
|
2
|
+
//
|
|
3
|
+
// A funnel usually spans several PAGES, not several components, so the session
|
|
4
|
+
// has to survive a full navigation. `sessionStorage` does exactly that (same
|
|
5
|
+
// tab, same origin) and dies with the tab, which is the lifetime we want: one
|
|
6
|
+
// visitor attempt at one funnel.
|
|
7
|
+
|
|
8
|
+
const SESSION_PREFIX = "tribe_nest_funnel_";
|
|
9
|
+
|
|
10
|
+
function safeStorage(): Storage | null {
|
|
11
|
+
if (typeof window === "undefined") return null;
|
|
12
|
+
try {
|
|
13
|
+
return window.sessionStorage;
|
|
14
|
+
} catch {
|
|
15
|
+
// Private mode / storage disabled.
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newId(): string {
|
|
21
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
22
|
+
return `fs_${Date.now().toString(36)}_${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The visitor's id for THIS funnel, minted on first use.
|
|
27
|
+
*
|
|
28
|
+
* Returns `null` when storage is unavailable. That is not a failure: step
|
|
29
|
+
* counts still work without it (they're plain event totals), only per-session
|
|
30
|
+
* stitching — and therefore drop-off — degrades. Callers must handle null
|
|
31
|
+
* rather than inventing a per-call id, which would make every step look like a
|
|
32
|
+
* different visitor and report 100% drop-off everywhere.
|
|
33
|
+
*/
|
|
34
|
+
export function getFunnelSessionId(funnelId: string): string | null {
|
|
35
|
+
const storage = safeStorage();
|
|
36
|
+
if (!storage) return null;
|
|
37
|
+
const key = `${SESSION_PREFIX}${funnelId}_sid`;
|
|
38
|
+
try {
|
|
39
|
+
let id = storage.getItem(key);
|
|
40
|
+
if (!id) {
|
|
41
|
+
id = newId();
|
|
42
|
+
storage.setItem(key, id);
|
|
43
|
+
}
|
|
44
|
+
return id;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Record that `marker` already fired for this funnel session, returning whether
|
|
52
|
+
* it was the FIRST time. Keeps back-navigation and remounts from re-emitting a
|
|
53
|
+
* step view. Without storage everything reads as first-time, which is the safe
|
|
54
|
+
* direction: an extra event is noise, a missing one is a hole in the report.
|
|
55
|
+
*/
|
|
56
|
+
export function markOnce(funnelId: string, marker: string): boolean {
|
|
57
|
+
const storage = safeStorage();
|
|
58
|
+
if (!storage) return true;
|
|
59
|
+
const key = `${SESSION_PREFIX}${funnelId}_seen_${marker}`;
|
|
60
|
+
try {
|
|
61
|
+
if (storage.getItem(key)) return false;
|
|
62
|
+
storage.setItem(key, "1");
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Forget this funnel session — the next event starts a fresh one. Use when a
|
|
71
|
+
* visitor deliberately restarts (a "start over" control), not on completion:
|
|
72
|
+
* a converted session must stay identifiable for the rest of the tab.
|
|
73
|
+
*/
|
|
74
|
+
export function resetFunnelSession(funnelId: string): void {
|
|
75
|
+
const storage = safeStorage();
|
|
76
|
+
if (!storage) return;
|
|
77
|
+
try {
|
|
78
|
+
const prefix = `${SESSION_PREFIX}${funnelId}_`;
|
|
79
|
+
const doomed: string[] = [];
|
|
80
|
+
for (let i = 0; i < storage.length; i++) {
|
|
81
|
+
const key = storage.key(i);
|
|
82
|
+
if (key?.startsWith(prefix)) doomed.push(key);
|
|
83
|
+
}
|
|
84
|
+
for (const key of doomed) storage.removeItem(key);
|
|
85
|
+
} catch {
|
|
86
|
+
// Nothing to clear.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { Funnel, useFunnel, FUNNEL_EVENTS, type FunnelProps, type FunnelContextValue } from "./Funnel";
|
|
2
|
+
export { useFunnelStep, type FunnelStepApi, type UseFunnelStepOptions } from "./useFunnelStep";
|
|
3
|
+
export { getFunnelSessionId, resetFunnelSession } from "./funnelSession";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { useFunnel } from "./Funnel";
|
|
3
|
+
|
|
4
|
+
export interface FunnelStepApi {
|
|
5
|
+
stepId: string;
|
|
6
|
+
/** Position in the funnel's declared order, or -1 when the step wasn't declared. */
|
|
7
|
+
index: number;
|
|
8
|
+
/** Whether this step is inside a `<Funnel>` at all. False makes every call a no-op. */
|
|
9
|
+
tracked: boolean;
|
|
10
|
+
/** Mark this step's goal as met — call it on submit/purchase, not on render. */
|
|
11
|
+
complete(data?: Record<string, unknown>): void;
|
|
12
|
+
/** Terminal success for the whole funnel. Usually called alongside the last step's `complete()`. */
|
|
13
|
+
convert(data?: Record<string, unknown>): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseFunnelStepOptions {
|
|
17
|
+
/**
|
|
18
|
+
* Emit the "reached this step" event on mount. Default `true`.
|
|
19
|
+
*
|
|
20
|
+
* Set `false` when a nested component calls this hook only to get
|
|
21
|
+
* `complete()` — though it rarely matters, since a view is recorded once per
|
|
22
|
+
* visitor per step regardless of how many callers ask.
|
|
23
|
+
*/
|
|
24
|
+
trackView?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Declare that the calling component IS a funnel step.
|
|
29
|
+
*
|
|
30
|
+
* On mount it records that the visitor reached this step; the returned
|
|
31
|
+
* `complete()` records that the step's goal was actually met. Those are
|
|
32
|
+
* deliberately different events — a step few people reach is an upstream
|
|
33
|
+
* problem, a step many reach and few complete is a problem with the step — so
|
|
34
|
+
* never call `complete()` during render.
|
|
35
|
+
*
|
|
36
|
+
* const step = useFunnelStep("optin");
|
|
37
|
+
* return <EmailListForm onSuccess={() => step.complete()} />;
|
|
38
|
+
*
|
|
39
|
+
* Outside a `<Funnel>` it returns a working no-op (`tracked: false`), so a step
|
|
40
|
+
* page can be rendered standalone — or lifted out of its funnel — without any
|
|
41
|
+
* null-checking at the call site.
|
|
42
|
+
*/
|
|
43
|
+
export function useFunnelStep(stepId: string, options?: UseFunnelStepOptions): FunnelStepApi {
|
|
44
|
+
const funnel = useFunnel();
|
|
45
|
+
const trackView = options?.trackView ?? true;
|
|
46
|
+
const index = funnel ? funnel.indexOf(stepId) : -1;
|
|
47
|
+
|
|
48
|
+
// The context value is rebuilt whenever the funnel's emitter identity changes.
|
|
49
|
+
// Reading it through a ref keeps the view effect keyed on what actually
|
|
50
|
+
// identifies the step, so a re-render can't re-fire it.
|
|
51
|
+
const funnelRef = useRef(funnel);
|
|
52
|
+
funnelRef.current = funnel;
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!trackView) return;
|
|
56
|
+
funnelRef.current?.viewStep(stepId);
|
|
57
|
+
}, [stepId, trackView, funnel?.funnelId]);
|
|
58
|
+
|
|
59
|
+
const complete = useCallback(
|
|
60
|
+
(data?: Record<string, unknown>) => funnelRef.current?.completeStep(stepId, data),
|
|
61
|
+
[stepId],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const convert = useCallback((data?: Record<string, unknown>) => funnelRef.current?.convert(data), []);
|
|
65
|
+
|
|
66
|
+
return useMemo<FunnelStepApi>(
|
|
67
|
+
() => ({ stepId, index, tracked: !!funnel, complete, convert }),
|
|
68
|
+
[stepId, index, funnel, complete, convert],
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -58,3 +58,6 @@ export * from "./reviews";
|
|
|
58
58
|
export * from "./document";
|
|
59
59
|
// Work (project management) client-portal headless primitives.
|
|
60
60
|
export * from "./work";
|
|
61
|
+
// Funnel instrumentation — wrap a multi-step flow to record step-through and
|
|
62
|
+
// drop-off on the first-party analytics feed.
|
|
63
|
+
export * from "./funnel";
|
package/src/ui/index.ts
CHANGED
|
@@ -59,6 +59,8 @@ export { ContactForm, type ContactFormProps } from "./styled/ContactForm";
|
|
|
59
59
|
export { Paywall, type PaywallProps } from "./styled/Paywall";
|
|
60
60
|
export { ReactionBar, type ReactionBarProps } from "./styled/ReactionBar";
|
|
61
61
|
export { ProductGrid, type ProductGridProps } from "./styled/ProductGrid";
|
|
62
|
+
export { Addons, type AddonsProps } from "./styled/Addons";
|
|
63
|
+
export { BundleConfirmation, type BundleConfirmationProps } from "./styled/BundleConfirmation";
|
|
62
64
|
export { ProductDetail, type ProductDetailProps } from "./styled/ProductDetail";
|
|
63
65
|
export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipTiers";
|
|
64
66
|
export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
2
|
+
import { useCart } from "../../contexts/CartContext";
|
|
3
|
+
import { ProductGrid } from "./ProductGrid";
|
|
4
|
+
|
|
5
|
+
export interface AddonsProps {
|
|
6
|
+
/** The entity these products attach to — the event on an event page. */
|
|
7
|
+
entityId?: string;
|
|
8
|
+
productIds: string[];
|
|
9
|
+
title?: string;
|
|
10
|
+
columns?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Where product detail lives on this site. Code sites mount `ProductDetail`
|
|
13
|
+
* at `/i/store/:slug`; Craft sites resolve `/products/:slug` through the
|
|
14
|
+
* catch-all. Forge has no router and no opinion, so the host says.
|
|
15
|
+
*/
|
|
16
|
+
productBasePath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Products sold as add-ons to whatever the page is about — merch you can take
|
|
21
|
+
* with your ticket, and only with your ticket.
|
|
22
|
+
*
|
|
23
|
+
* The card links to the ORDINARY product detail page, carrying `?addonFor=`.
|
|
24
|
+
* There is no modal and no bespoke buying UI: the buyer gets the same page,
|
|
25
|
+
* variants, quantity and all, that they'd reach from the shop. `CartProvider`
|
|
26
|
+
* reads that query param when the item is added and binds the line to the
|
|
27
|
+
* event, so the product page itself needs no changes and doesn't know add-ons
|
|
28
|
+
* exist.
|
|
29
|
+
*
|
|
30
|
+
* With no ticket in the cart the cards render inert under a label. The rule is
|
|
31
|
+
* enforced twice more below this: the cart won't check out with an orphaned
|
|
32
|
+
* add-on, and `POST /public/checkouts` rejects one outright.
|
|
33
|
+
*/
|
|
34
|
+
export function Addons({ entityId, productIds, title, columns = 3, productBasePath = "/i/store" }: AddonsProps) {
|
|
35
|
+
const t = useThemeTokens();
|
|
36
|
+
const { hasTicketsFor } = useCart();
|
|
37
|
+
|
|
38
|
+
// No entity means nothing to attach to — render the products as plain cards
|
|
39
|
+
// rather than silently gating forever.
|
|
40
|
+
const unlocked = !entityId || hasTicketsFor(entityId);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div data-testid="addons-block" data-addons-locked={unlocked ? "false" : "true"}>
|
|
44
|
+
{title && <h3 style={{ fontWeight: 600, marginBottom: 12, color: t.text }}>{title}</h3>}
|
|
45
|
+
|
|
46
|
+
{!unlocked && (
|
|
47
|
+
<p
|
|
48
|
+
data-testid="addons-locked-label"
|
|
49
|
+
style={{
|
|
50
|
+
color: t.text,
|
|
51
|
+
opacity: 0.7,
|
|
52
|
+
fontSize: 14,
|
|
53
|
+
margin: "0 0 12px",
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
Select tickets first
|
|
57
|
+
</p>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
<ProductGrid
|
|
61
|
+
columns={columns}
|
|
62
|
+
productIds={productIds}
|
|
63
|
+
emptyLabel="No add-ons available."
|
|
64
|
+
// Locked: no href, so the cards render as inert, non-clickable tiles.
|
|
65
|
+
hrefFor={
|
|
66
|
+
unlocked
|
|
67
|
+
? (p) =>
|
|
68
|
+
`${productBasePath.replace(/\/$/, "")}/${p.slug ?? p.id}${
|
|
69
|
+
entityId ? `?addonFor=${encodeURIComponent(entityId)}` : ""
|
|
70
|
+
}`
|
|
71
|
+
: undefined
|
|
72
|
+
}
|
|
73
|
+
style={!unlocked ? { opacity: 0.55 } : undefined}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Clock, Loader2, Ticket, XCircle } from "lucide-react";
|
|
3
|
+
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
|
+
import { readableTextOn } from "../theme/contrast";
|
|
5
|
+
import { ConfirmationStage, ConfirmationCard, CheckSeal, WarnSeal, alpha } from "./Confirmation";
|
|
6
|
+
import { useCheckoutFinalize } from "../../data/queries/useFinalize";
|
|
7
|
+
import { useCart } from "../../contexts/CartContext";
|
|
8
|
+
|
|
9
|
+
export interface BundleConfirmationProps {
|
|
10
|
+
checkoutId: string;
|
|
11
|
+
/** Stripe's `redirect_status` — lets a failed payment render without polling. */
|
|
12
|
+
redirectStatus?: string;
|
|
13
|
+
explorePath?: string;
|
|
14
|
+
accountPath?: string;
|
|
15
|
+
checkoutPath?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The return page for a bundle: one payment that bought tickets AND products.
|
|
20
|
+
*
|
|
21
|
+
* Both surfaces fulfil server-side off a single finalize call, so there is one
|
|
22
|
+
* confirmation rather than one per surface — but each surface still sends its
|
|
23
|
+
* own email (the ticket PDF, the order receipt), because each owns its own
|
|
24
|
+
* delivery.
|
|
25
|
+
*/
|
|
26
|
+
export function BundleConfirmation({
|
|
27
|
+
checkoutId,
|
|
28
|
+
redirectStatus,
|
|
29
|
+
explorePath = "/i/store",
|
|
30
|
+
accountPath = "/i/account",
|
|
31
|
+
checkoutPath = "/i/checkout",
|
|
32
|
+
}: BundleConfirmationProps) {
|
|
33
|
+
const theme = useForgeTheme();
|
|
34
|
+
const { clearCart } = useCart();
|
|
35
|
+
const failedRedirect = redirectStatus === "failed";
|
|
36
|
+
|
|
37
|
+
const { data, isLoading, isError } = useCheckoutFinalize(
|
|
38
|
+
{ checkoutId: failedRedirect ? undefined : checkoutId },
|
|
39
|
+
{ pollWhilePending: true },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const isComplete = data?.fulfillmentStatus === "complete";
|
|
43
|
+
const isPartial = data?.fulfillmentStatus === "partial";
|
|
44
|
+
const isUnpaid = data?.status === "unpaid";
|
|
45
|
+
|
|
46
|
+
// Only empty the cart once the money is actually in — a failed or still-
|
|
47
|
+
// settling payment leaves it intact so the buyer can retry without rebuilding.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (isComplete || isPartial) clearCart();
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
}, [isComplete, isPartial]);
|
|
52
|
+
|
|
53
|
+
const onPrimary = theme.colors.textPrimary ?? readableTextOn(theme.colors.primary);
|
|
54
|
+
const link = (href: string, label: string, primary = false) => (
|
|
55
|
+
<a
|
|
56
|
+
href={href}
|
|
57
|
+
style={{
|
|
58
|
+
display: "inline-block",
|
|
59
|
+
padding: "10px 20px",
|
|
60
|
+
borderRadius: theme.cornerRadius,
|
|
61
|
+
fontWeight: 600,
|
|
62
|
+
textDecoration: "none",
|
|
63
|
+
...(primary
|
|
64
|
+
? { background: theme.colors.primary, color: onPrimary }
|
|
65
|
+
: { border: `1px solid ${alpha(theme.colors.primary, 0.4)}`, color: theme.colors.text }),
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{label}
|
|
69
|
+
</a>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (failedRedirect || isUnpaid) {
|
|
73
|
+
return (
|
|
74
|
+
<ConfirmationStage>
|
|
75
|
+
<ConfirmationCard>
|
|
76
|
+
<WarnSeal icon={<XCircle size={28} />} />
|
|
77
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment didn't go through</h1>
|
|
78
|
+
<p style={{ opacity: 0.75, marginBottom: 20 }}>
|
|
79
|
+
Nothing was charged and your cart is still here. You can try again.
|
|
80
|
+
</p>
|
|
81
|
+
{link(checkoutPath, "Return to checkout", true)}
|
|
82
|
+
</ConfirmationCard>
|
|
83
|
+
</ConfirmationStage>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (isLoading || (!data && !isError)) {
|
|
88
|
+
return (
|
|
89
|
+
<ConfirmationStage>
|
|
90
|
+
<ConfirmationCard>
|
|
91
|
+
<Loader2 size={28} style={{ animation: "spin 1s linear infinite" }} />
|
|
92
|
+
<h1 style={{ fontSize: 20, fontWeight: 700, marginTop: 12 }}>Confirming your order…</h1>
|
|
93
|
+
</ConfirmationCard>
|
|
94
|
+
</ConfirmationStage>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Charged, but a surface hasn't finished. The buyer owes nothing more and the
|
|
99
|
+
// reconciliation sweep finishes the rest, so say so plainly rather than
|
|
100
|
+
// showing an error they might act on.
|
|
101
|
+
if (isPartial) {
|
|
102
|
+
return (
|
|
103
|
+
<ConfirmationStage>
|
|
104
|
+
<ConfirmationCard>
|
|
105
|
+
<WarnSeal icon={<Clock size={28} />} />
|
|
106
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>Payment received</h1>
|
|
107
|
+
<p style={{ opacity: 0.75, marginBottom: 20 }}>
|
|
108
|
+
Your payment went through and part of your order is confirmed. We're still finishing the rest — it
|
|
109
|
+
will arrive by email shortly, with nothing more to pay.
|
|
110
|
+
</p>
|
|
111
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
112
|
+
{link(accountPath, "View your orders", true)}
|
|
113
|
+
{link(explorePath, "Continue shopping")}
|
|
114
|
+
</div>
|
|
115
|
+
</ConfirmationCard>
|
|
116
|
+
</ConfirmationStage>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ticketChildren = (data?.children ?? []).filter((c) => c.sourceType === "event_ticket_order").length;
|
|
121
|
+
const orderChildren = (data?.children ?? []).filter((c) => c.sourceType === "order").length;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<ConfirmationStage>
|
|
125
|
+
<ConfirmationCard>
|
|
126
|
+
<CheckSeal />
|
|
127
|
+
<h1 style={{ fontSize: 22, fontWeight: 800, margin: "12px 0 6px" }}>You're all set</h1>
|
|
128
|
+
<p style={{ opacity: 0.75, marginBottom: 16 }}>
|
|
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
|
|
136
|
+
style={{
|
|
137
|
+
display: "inline-flex",
|
|
138
|
+
alignItems: "center",
|
|
139
|
+
gap: 8,
|
|
140
|
+
padding: "8px 14px",
|
|
141
|
+
borderRadius: theme.cornerRadius,
|
|
142
|
+
background: alpha(theme.colors.primary, 0.08),
|
|
143
|
+
color: theme.colors.primary,
|
|
144
|
+
fontSize: 14,
|
|
145
|
+
fontWeight: 600,
|
|
146
|
+
marginBottom: 20,
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<Ticket size={16} />
|
|
150
|
+
Tickets emailed to you
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
<div style={{ display: "flex", gap: 8, justifyContent: "center", flexWrap: "wrap" }}>
|
|
155
|
+
{link(accountPath, "View your orders", true)}
|
|
156
|
+
{link(explorePath, "Continue shopping")}
|
|
157
|
+
</div>
|
|
158
|
+
</ConfirmationCard>
|
|
159
|
+
</ConfirmationStage>
|
|
160
|
+
);
|
|
161
|
+
}
|
package/src/ui/styled/Cart.tsx
CHANGED
|
@@ -45,10 +45,15 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
45
45
|
// Inclusive stores show a muted "incl. tax" caption on the cart total; the
|
|
46
46
|
// exact tax figure only exists once checkout quotes it (display-only).
|
|
47
47
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
48
|
-
const { cartItems, removeFromCart, isCartOpen, setCartOpen } = useCart();
|
|
48
|
+
const { cartItems, removeFromCart, ticketItems, removeTickets, isCartOpen, setCartOpen } = useCart();
|
|
49
49
|
|
|
50
|
-
const
|
|
51
|
-
|
|
50
|
+
const ticketTotal = (t: (typeof ticketItems)[number]) =>
|
|
51
|
+
Object.entries(t.tickets).reduce((sum, [id, qty]) => sum + (t.ticketMeta[id]?.price ?? 0) * qty, 0);
|
|
52
|
+
|
|
53
|
+
const total =
|
|
54
|
+
cartItems.reduce((sum, i) => sum + i.price * i.quantity, 0) +
|
|
55
|
+
ticketItems.reduce((sum, t) => sum + ticketTotal(t), 0);
|
|
56
|
+
const count = cartItems.length + ticketItems.length;
|
|
52
57
|
const onPrimary = t.textPrimary || readableTextOn(t.primary);
|
|
53
58
|
const linkFor = (productId: string) => (productHref ? productHref(productId) : `/i/store/${productId}`);
|
|
54
59
|
|
|
@@ -72,6 +77,7 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
72
77
|
type="button"
|
|
73
78
|
onClick={() => setCartOpen((p) => !p)}
|
|
74
79
|
aria-label="Open cart"
|
|
80
|
+
data-testid="cart-button"
|
|
75
81
|
className={className}
|
|
76
82
|
style={{
|
|
77
83
|
position: "relative",
|
|
@@ -159,10 +165,59 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
159
165
|
</div>
|
|
160
166
|
|
|
161
167
|
<div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 16 }}>
|
|
162
|
-
{count === 0
|
|
163
|
-
|
|
164
|
-
)
|
|
165
|
-
|
|
168
|
+
{count === 0 && <p style={{ textAlign: "center", opacity: 0.7 }}>Your cart is empty.</p>}
|
|
169
|
+
|
|
170
|
+
{ticketItems.map((ticket) => (
|
|
171
|
+
<div
|
|
172
|
+
key={ticket.eventId}
|
|
173
|
+
data-testid="cart-ticket-line"
|
|
174
|
+
style={{
|
|
175
|
+
display: "flex",
|
|
176
|
+
gap: 12,
|
|
177
|
+
alignItems: "flex-start",
|
|
178
|
+
borderBottom: `1px solid ${t.border}`,
|
|
179
|
+
paddingBottom: 16,
|
|
180
|
+
position: "relative",
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
<img
|
|
184
|
+
src={ticket.coverImage || undefined}
|
|
185
|
+
alt={ticket.eventTitle}
|
|
186
|
+
style={{
|
|
187
|
+
width: 64,
|
|
188
|
+
height: 64,
|
|
189
|
+
objectFit: "cover",
|
|
190
|
+
borderRadius: t.cornerRadius,
|
|
191
|
+
background: `${t.primary}10`,
|
|
192
|
+
flexShrink: 0,
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
<div style={{ flex: 1, minWidth: 0, paddingRight: 28 }}>
|
|
196
|
+
<div style={{ fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
197
|
+
{ticket.eventTitle}
|
|
198
|
+
</div>
|
|
199
|
+
{Object.entries(ticket.tickets).map(([id, qty]) => (
|
|
200
|
+
<div key={id} style={{ fontSize: 12, marginTop: 4, opacity: 0.75 }}>
|
|
201
|
+
{ticket.ticketMeta[id]?.title ?? "Ticket"} × {qty}
|
|
202
|
+
</div>
|
|
203
|
+
))}
|
|
204
|
+
<div style={{ fontSize: 14, marginTop: 4, opacity: 0.8 }}>{fmt(ticketTotal(ticket))}</div>
|
|
205
|
+
</div>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
// Removing the ticket takes its add-ons with it — they
|
|
209
|
+
// cannot be bought on their own, and leaving them would
|
|
210
|
+
// produce a checkout the server refuses.
|
|
211
|
+
onClick={() => removeTickets(ticket.eventId)}
|
|
212
|
+
aria-label="Remove tickets"
|
|
213
|
+
style={{ position: "absolute", top: 0, right: 0, background: "transparent", border: "none", cursor: "pointer", color: t.primary, padding: 4 }}
|
|
214
|
+
>
|
|
215
|
+
<Trash2 size={16} />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
|
|
220
|
+
{cartItems.map((item) => (
|
|
166
221
|
<div
|
|
167
222
|
key={item.productId + item.productVariantId + String(item.isGift) + (item.recipientEmail || "")}
|
|
168
223
|
style={{
|
|
@@ -216,6 +271,9 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
216
271
|
</div>
|
|
217
272
|
)}
|
|
218
273
|
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>Qty: {item.quantity}</div>
|
|
274
|
+
{item.attachedTo && (
|
|
275
|
+
<div style={{ fontSize: 12, marginTop: 4, color: t.primary }}>Added with your tickets</div>
|
|
276
|
+
)}
|
|
219
277
|
</div>
|
|
220
278
|
<button
|
|
221
279
|
type="button"
|
|
@@ -226,8 +284,7 @@ export function Cart({ icon, checkoutPath = "/i/checkout", productHref, formatAm
|
|
|
226
284
|
<Trash2 size={16} />
|
|
227
285
|
</button>
|
|
228
286
|
</div>
|
|
229
|
-
))
|
|
230
|
-
)}
|
|
287
|
+
))}
|
|
231
288
|
</div>
|
|
232
289
|
|
|
233
290
|
{count > 0 && (
|