@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.
@@ -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
+ }