@tribe-nest/forge 3.11.0 → 3.17.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 +2 -2
- package/src/contexts/CartContext.tsx +38 -2
- package/src/data/queries/useCheckouts.ts +14 -0
- package/src/data/queries/useEvents.ts +6 -0
- package/src/data/queries/useProducts.ts +104 -10
- package/src/index.ts +8 -0
- package/src/types/models.ts +87 -5
- package/src/ui/format/_tests/pwyw.spec.ts +157 -0
- package/src/ui/format/pwyw.ts +95 -0
- package/src/ui/headless/event/useEventCheckout.ts +96 -6
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/headless/useVariantSelection.ts +138 -0
- package/src/ui/index.ts +17 -0
- package/src/ui/styled/AccountDashboard.tsx +7 -0
- package/src/ui/styled/Cart.tsx +11 -8
- package/src/ui/styled/CartLineOptions.tsx +107 -0
- package/src/ui/styled/Checkout.tsx +5 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +4 -6
- package/src/ui/styled/EventTickets.tsx +114 -0
- package/src/ui/styled/ProductBrowseNav.tsx +212 -0
- package/src/ui/styled/ProductDetail.tsx +158 -91
- package/src/ui/styled/ProductGrid.tsx +16 -2
|
@@ -5,6 +5,7 @@ import { useEventCheckout } from "../headless/event/useEventCheckout";
|
|
|
5
5
|
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
6
6
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
7
7
|
import { PriceDisplay, summarizeTaxQuote } from "../format/PriceDisplay";
|
|
8
|
+
import { clampChosenAmount, isPayWhatYouWant, pwywDefaultAmount, pwywMaximum } from "../format/pwyw";
|
|
8
9
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
9
10
|
import { Loading } from "./Loading";
|
|
10
11
|
import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePaymentProvider";
|
|
@@ -282,6 +283,12 @@ function TicketStep({
|
|
|
282
283
|
{fmt(Number(ticket.compareAtPrice))}
|
|
283
284
|
</span>
|
|
284
285
|
)}
|
|
286
|
+
{/* On a PWYW tier `price` is the FLOOR, so it is labelled as
|
|
287
|
+
one — showing it bare would read as a fixed price the
|
|
288
|
+
buyer is about to be charged. */}
|
|
289
|
+
{isPayWhatYouWant(ticket) && (
|
|
290
|
+
<span style={{ fontSize: 14, fontWeight: 500, opacity: 0.7, marginRight: 6 }}>from</span>
|
|
291
|
+
)}
|
|
285
292
|
<PriceDisplay amount={Number(ticket.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={theme.colors.text} captionStyle={{ opacity: 0.65 }} />
|
|
286
293
|
</div>
|
|
287
294
|
</div>
|
|
@@ -305,6 +312,20 @@ function TicketStep({
|
|
|
305
312
|
</button>
|
|
306
313
|
</div>
|
|
307
314
|
</div>
|
|
315
|
+
{/* The amount box appears only once a seat is actually selected —
|
|
316
|
+
before that there is nothing to price, and a row of empty
|
|
317
|
+
inputs down the tier list reads as a form to fill in rather
|
|
318
|
+
than a choice to make. */}
|
|
319
|
+
{isPayWhatYouWant(ticket) && selectedQty > 0 && !isSoldOut && !isExpired && (
|
|
320
|
+
<PwywAmountField
|
|
321
|
+
ticket={ticket}
|
|
322
|
+
quantity={selectedQty}
|
|
323
|
+
value={c.pwywAmounts[ticket.id]}
|
|
324
|
+
onChange={(amount) => c.setTicketAmount(ticket.id, amount)}
|
|
325
|
+
fmt={fmt}
|
|
326
|
+
theme={theme}
|
|
327
|
+
/>
|
|
328
|
+
)}
|
|
308
329
|
{ticket.description && (
|
|
309
330
|
<p dangerouslySetInnerHTML={{ __html: ticket.description }} style={{ marginTop: 12, fontSize: 14 }} />
|
|
310
331
|
)}
|
|
@@ -669,6 +690,99 @@ const closeButtonStyle = (t: ForgeTheme): CSSProperties => ({
|
|
|
669
690
|
opacity: 0.7,
|
|
670
691
|
});
|
|
671
692
|
|
|
693
|
+
/**
|
|
694
|
+
* The buyer's "what will you pay?" box for one pay-what-you-want tier.
|
|
695
|
+
*
|
|
696
|
+
* Holds its own STRING state rather than binding the number straight through.
|
|
697
|
+
* Two reasons, both of which are broken inputs if you skip them: an empty box
|
|
698
|
+
* has to stay empty while the buyer retypes (a number-bound input snaps it back
|
|
699
|
+
* to the floor on every keystroke), and "1" on the way to "15" must not be
|
|
700
|
+
* clamped up to the minimum the instant it is typed.
|
|
701
|
+
*
|
|
702
|
+
* Correction happens on BLUR, when the buyer has finished — and the server
|
|
703
|
+
* clamps again regardless, so nothing here is load-bearing for money.
|
|
704
|
+
*/
|
|
705
|
+
function PwywAmountField({
|
|
706
|
+
ticket,
|
|
707
|
+
quantity,
|
|
708
|
+
value,
|
|
709
|
+
onChange,
|
|
710
|
+
fmt,
|
|
711
|
+
theme,
|
|
712
|
+
}: {
|
|
713
|
+
ticket: ITicket;
|
|
714
|
+
quantity: number;
|
|
715
|
+
value: number | undefined;
|
|
716
|
+
onChange: (amount: number | null) => void;
|
|
717
|
+
fmt: (amount: number) => string;
|
|
718
|
+
theme: ForgeTheme;
|
|
719
|
+
}) {
|
|
720
|
+
const floor = Number(ticket.price);
|
|
721
|
+
const maximum = pwywMaximum(ticket);
|
|
722
|
+
const suggested = pwywDefaultAmount(ticket);
|
|
723
|
+
const [draft, setDraft] = useState<string>(String(value ?? suggested));
|
|
724
|
+
|
|
725
|
+
// Follow the tier's own default when the buyer hasn't typed anything — an
|
|
726
|
+
// operator's suggested amount arriving late (or the selection being cleared
|
|
727
|
+
// and remade) should re-seed the box rather than leave a stale figure.
|
|
728
|
+
useEffect(() => {
|
|
729
|
+
if (value == null) setDraft(String(suggested));
|
|
730
|
+
}, [value, suggested]);
|
|
731
|
+
|
|
732
|
+
const parsed = Number(draft);
|
|
733
|
+
const effective = Number.isFinite(parsed) ? clampChosenAmount(ticket, parsed) : floor;
|
|
734
|
+
const belowFloor = draft.trim() !== "" && Number.isFinite(parsed) && parsed < floor;
|
|
735
|
+
const aboveMax = maximum != null && Number.isFinite(parsed) && parsed > maximum;
|
|
736
|
+
|
|
737
|
+
const commit = () => {
|
|
738
|
+
if (draft.trim() === "" || !Number.isFinite(parsed)) {
|
|
739
|
+
onChange(null);
|
|
740
|
+
setDraft(String(suggested));
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
const clamped = clampChosenAmount(ticket, parsed);
|
|
744
|
+
onChange(clamped);
|
|
745
|
+
setDraft(String(clamped));
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
return (
|
|
749
|
+
<div style={{ marginTop: 12 }}>
|
|
750
|
+
<label
|
|
751
|
+
htmlFor={`pwyw-${ticket.id}`}
|
|
752
|
+
style={{ display: "block", fontSize: 13, fontWeight: 600, marginBottom: 6 }}
|
|
753
|
+
>
|
|
754
|
+
Name your price
|
|
755
|
+
<span style={{ fontWeight: 400, opacity: 0.7 }}>
|
|
756
|
+
{" "}
|
|
757
|
+
— minimum {fmt(floor)}
|
|
758
|
+
{maximum != null ? `, up to ${fmt(maximum)}` : ""}
|
|
759
|
+
</span>
|
|
760
|
+
</label>
|
|
761
|
+
<input
|
|
762
|
+
id={`pwyw-${ticket.id}`}
|
|
763
|
+
type="number"
|
|
764
|
+
inputMode="decimal"
|
|
765
|
+
min={floor}
|
|
766
|
+
{...(maximum != null ? { max: maximum } : {})}
|
|
767
|
+
step="0.01"
|
|
768
|
+
value={draft}
|
|
769
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
770
|
+
onBlur={commit}
|
|
771
|
+
style={{ ...inputStyle(theme), maxWidth: 200 }}
|
|
772
|
+
/>
|
|
773
|
+
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 6 }}>
|
|
774
|
+
{belowFloor
|
|
775
|
+
? `The minimum is ${fmt(floor)} — we'll use that.`
|
|
776
|
+
: aboveMax
|
|
777
|
+
? `The most you can pay is ${fmt(maximum!)} — we'll use that.`
|
|
778
|
+
: quantity > 1
|
|
779
|
+
? `${fmt(effective)} each · ${fmt(effective * quantity)} for ${quantity}`
|
|
780
|
+
: "Per ticket."}
|
|
781
|
+
</p>
|
|
782
|
+
</div>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
672
786
|
const stepperStyle = (t: ForgeTheme, filled: boolean): CSSProperties => ({
|
|
673
787
|
width: 40,
|
|
674
788
|
height: 40,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useProductCategories,
|
|
4
|
+
useProductCollections,
|
|
5
|
+
type IPublicProductCategory,
|
|
6
|
+
} from "../../data/queries/useProducts";
|
|
7
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
8
|
+
import { Loading } from "./Loading";
|
|
9
|
+
|
|
10
|
+
export interface ProductBrowseNavProps {
|
|
11
|
+
/** The currently browsed category, so the nav can mark it. */
|
|
12
|
+
activeCategoryId?: string;
|
|
13
|
+
activeCollectionId?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Where a category leads. Router-agnostic on purpose: Forge has no router,
|
|
16
|
+
* so the host decides the path.
|
|
17
|
+
*/
|
|
18
|
+
hrefForCategory?: (category: IPublicProductCategory) => string;
|
|
19
|
+
hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
|
|
20
|
+
onSelectCategory?: (category: IPublicProductCategory) => void;
|
|
21
|
+
onSelectCollection?: (collection: { id: string; slug: string; title: string }) => void;
|
|
22
|
+
/** Hide collections and show only the category tree, or vice versa. */
|
|
23
|
+
show?: "all" | "categories" | "collections";
|
|
24
|
+
/** Show only collections the creator marked as featured. */
|
|
25
|
+
featuredCollectionsOnly?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
style?: React.CSSProperties;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Storefront navigation over the creator's OWN taxonomy.
|
|
32
|
+
*
|
|
33
|
+
* Two things it deliberately does not do:
|
|
34
|
+
*
|
|
35
|
+
* - **It does not show product types.** Music / Merch / Digital / Service is
|
|
36
|
+
* what a product IS; this is how the creator chose to shelve their catalogue.
|
|
37
|
+
* Mixing them into one list is the conflation the taxonomy split undid.
|
|
38
|
+
* - **It does not hide empty branches by counting children.** The counts are
|
|
39
|
+
* descendant-inclusive server-side, so a parent with products only in its
|
|
40
|
+
* grandchildren still reads as non-empty — which is correct, because browsing
|
|
41
|
+
* it returns those products.
|
|
42
|
+
*/
|
|
43
|
+
export function ProductBrowseNav({
|
|
44
|
+
activeCategoryId,
|
|
45
|
+
activeCollectionId,
|
|
46
|
+
hrefForCategory,
|
|
47
|
+
hrefForCollection,
|
|
48
|
+
onSelectCategory,
|
|
49
|
+
onSelectCollection,
|
|
50
|
+
show = "all",
|
|
51
|
+
featuredCollectionsOnly = false,
|
|
52
|
+
className,
|
|
53
|
+
style,
|
|
54
|
+
}: ProductBrowseNavProps) {
|
|
55
|
+
const t = useThemeTokens();
|
|
56
|
+
const categoriesQuery = useProductCategories();
|
|
57
|
+
const collectionsQuery = useProductCollections();
|
|
58
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
59
|
+
|
|
60
|
+
const showCategories = show === "all" || show === "categories";
|
|
61
|
+
const showCollections = show === "all" || show === "collections";
|
|
62
|
+
|
|
63
|
+
if (categoriesQuery.isLoading || collectionsQuery.isLoading) return <Loading />;
|
|
64
|
+
|
|
65
|
+
const categories = categoriesQuery.data ?? [];
|
|
66
|
+
const collections = (collectionsQuery.data ?? []).filter((c) =>
|
|
67
|
+
featuredCollectionsOnly ? c.isFeatured : true,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// A creator who has authored no taxonomy gets no nav at all rather than an
|
|
71
|
+
// empty heading — the storefront should look unchanged until they use it.
|
|
72
|
+
if (!categories.length && !collections.length) return null;
|
|
73
|
+
|
|
74
|
+
const toggle = (id: string) =>
|
|
75
|
+
setExpanded((prev) => {
|
|
76
|
+
const next = new Set(prev);
|
|
77
|
+
if (next.has(id)) next.delete(id);
|
|
78
|
+
else next.add(id);
|
|
79
|
+
return next;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const renderCategory = (category: IPublicProductCategory, depth: number): React.ReactNode => {
|
|
83
|
+
const children = category.children ?? [];
|
|
84
|
+
const isActive = category.id === activeCategoryId;
|
|
85
|
+
// A branch containing the active category opens so the reader can see
|
|
86
|
+
// where they are, without needing to remember the path.
|
|
87
|
+
const containsActive = (node: IPublicProductCategory): boolean =>
|
|
88
|
+
node.id === activeCategoryId || (node.children ?? []).some(containsActive);
|
|
89
|
+
const isOpen = expanded.has(category.id) || containsActive(category);
|
|
90
|
+
|
|
91
|
+
const label = (
|
|
92
|
+
<>
|
|
93
|
+
<span>{category.title}</span>
|
|
94
|
+
<span style={{ color: t.muted, fontSize: 12, marginLeft: 8 }}>{category.productCount}</span>
|
|
95
|
+
</>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const linkStyle: React.CSSProperties = {
|
|
99
|
+
display: "inline-flex",
|
|
100
|
+
alignItems: "baseline",
|
|
101
|
+
color: isActive ? t.primary : t.text,
|
|
102
|
+
fontWeight: isActive ? 600 : 400,
|
|
103
|
+
textDecoration: "none",
|
|
104
|
+
background: "none",
|
|
105
|
+
border: "none",
|
|
106
|
+
padding: 0,
|
|
107
|
+
cursor: "pointer",
|
|
108
|
+
font: "inherit",
|
|
109
|
+
textAlign: "left",
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const href = hrefForCategory?.(category);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<li key={category.id} style={{ paddingLeft: depth * 14, listStyle: "none", marginBottom: 6 }}>
|
|
116
|
+
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
117
|
+
{children.length > 0 && (
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={() => toggle(category.id)}
|
|
121
|
+
aria-expanded={isOpen}
|
|
122
|
+
aria-label={isOpen ? `Collapse ${category.title}` : `Expand ${category.title}`}
|
|
123
|
+
style={{
|
|
124
|
+
background: "none",
|
|
125
|
+
border: "none",
|
|
126
|
+
cursor: "pointer",
|
|
127
|
+
color: t.muted,
|
|
128
|
+
padding: 0,
|
|
129
|
+
lineHeight: 1,
|
|
130
|
+
width: 12,
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
{isOpen ? "−" : "+"}
|
|
134
|
+
</button>
|
|
135
|
+
)}
|
|
136
|
+
{children.length === 0 && <span style={{ width: 12 }} />}
|
|
137
|
+
|
|
138
|
+
{href ? (
|
|
139
|
+
<a href={href} style={linkStyle} aria-current={isActive ? "page" : undefined}>
|
|
140
|
+
{label}
|
|
141
|
+
</a>
|
|
142
|
+
) : (
|
|
143
|
+
<button type="button" style={linkStyle} onClick={() => onSelectCategory?.(category)}>
|
|
144
|
+
{label}
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{isOpen && children.length > 0 && (
|
|
150
|
+
<ul style={{ margin: "6px 0 0", padding: 0 }}>
|
|
151
|
+
{children.map((child) => renderCategory(child, depth + 1))}
|
|
152
|
+
</ul>
|
|
153
|
+
)}
|
|
154
|
+
</li>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const headingStyle: React.CSSProperties = {
|
|
159
|
+
color: t.muted,
|
|
160
|
+
fontSize: 12,
|
|
161
|
+
letterSpacing: "0.08em",
|
|
162
|
+
textTransform: "uppercase",
|
|
163
|
+
margin: "0 0 10px",
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<nav className={className} style={{ display: "grid", gap: 24, ...style }} aria-label="Browse the store">
|
|
168
|
+
{showCategories && categories.length > 0 && (
|
|
169
|
+
<div>
|
|
170
|
+
<h2 style={headingStyle}>Categories</h2>
|
|
171
|
+
<ul style={{ margin: 0, padding: 0 }}>{categories.map((c) => renderCategory(c, 0))}</ul>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{showCollections && collections.length > 0 && (
|
|
176
|
+
<div>
|
|
177
|
+
<h2 style={headingStyle}>Collections</h2>
|
|
178
|
+
<ul style={{ margin: 0, padding: 0 }}>
|
|
179
|
+
{collections.map((collection) => {
|
|
180
|
+
const isActive = collection.id === activeCollectionId;
|
|
181
|
+
const href = hrefForCollection?.(collection);
|
|
182
|
+
const linkStyle: React.CSSProperties = {
|
|
183
|
+
color: isActive ? t.primary : t.text,
|
|
184
|
+
fontWeight: isActive ? 600 : 400,
|
|
185
|
+
textDecoration: "none",
|
|
186
|
+
background: "none",
|
|
187
|
+
border: "none",
|
|
188
|
+
padding: 0,
|
|
189
|
+
cursor: "pointer",
|
|
190
|
+
font: "inherit",
|
|
191
|
+
};
|
|
192
|
+
return (
|
|
193
|
+
<li key={collection.id} style={{ listStyle: "none", marginBottom: 6 }}>
|
|
194
|
+
{href ? (
|
|
195
|
+
<a href={href} style={linkStyle} aria-current={isActive ? "page" : undefined}>
|
|
196
|
+
{collection.title}
|
|
197
|
+
</a>
|
|
198
|
+
) : (
|
|
199
|
+
<button type="button" style={linkStyle} onClick={() => onSelectCollection?.(collection)}>
|
|
200
|
+
{collection.title}
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
<span style={{ color: t.muted, fontSize: 12, marginLeft: 8 }}>{collection.productCount}</span>
|
|
204
|
+
</li>
|
|
205
|
+
);
|
|
206
|
+
})}
|
|
207
|
+
</ul>
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
</nav>
|
|
211
|
+
);
|
|
212
|
+
}
|