@tribe-nest/forge 3.21.0 → 3.23.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 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { KeyRound } from "lucide-react";
|
|
3
|
+
import type { PresaleCodeField } from "../headless/event/usePresaleCode";
|
|
4
|
+
import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
|
+
import { buttonStyle } from "./Button";
|
|
6
|
+
|
|
7
|
+
/** Append an alpha to a 6-digit hex color (mirrors frontend-shared addAlphaToHexCode). */
|
|
8
|
+
const a = (hex: string, alpha: number) => hex + Math.round(alpha * 255).toString(16).padStart(2, "0");
|
|
9
|
+
|
|
10
|
+
export interface PresaleCodeFieldProps {
|
|
11
|
+
presale: PresaleCodeField;
|
|
12
|
+
/** Collapsed-state prompt. */
|
|
13
|
+
promptText?: string;
|
|
14
|
+
/** Extra class(es) on the root. */
|
|
15
|
+
className?: string;
|
|
16
|
+
/** Inline style merged LAST (callers can override). */
|
|
17
|
+
style?: CSSProperties;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The box a buyer types a presale code into (Events 1.2).
|
|
22
|
+
*
|
|
23
|
+
* **Renders nothing at all unless the event actually has a coded tier.** That
|
|
24
|
+
* is the single most important thing about it: a code box on an ordinary event
|
|
25
|
+
* tells the buyer there is a secret they are missing, and they have no way to
|
|
26
|
+
* find out there isn't. `presale.visible` comes from the server, because a
|
|
27
|
+
* hidden tier is filtered out of the response and no client can tell a presale
|
|
28
|
+
* apart from an event that has not put tickets on sale yet.
|
|
29
|
+
*
|
|
30
|
+
* Three states, three different jobs:
|
|
31
|
+
*
|
|
32
|
+
* - **Idle** — an inline prompt. Small, because most visitors have no code and
|
|
33
|
+
* should not feel gated.
|
|
34
|
+
* - **Accepted** — confirms it worked, with a way out. Without this, a code for
|
|
35
|
+
* a tier that was already VISIBLE (code-gated but listed) would appear to do
|
|
36
|
+
* nothing at all.
|
|
37
|
+
* - **Rejected** — says the code is not valid for this event and stops. It
|
|
38
|
+
* names no tier and confirms no partial guess; a box that said "that isn't
|
|
39
|
+
* the VIP code" would be a way to enumerate them.
|
|
40
|
+
*/
|
|
41
|
+
export function PresaleCodeField({
|
|
42
|
+
presale,
|
|
43
|
+
promptText = "Have a presale code?",
|
|
44
|
+
className,
|
|
45
|
+
style,
|
|
46
|
+
}: PresaleCodeFieldProps) {
|
|
47
|
+
const theme = useForgeTheme();
|
|
48
|
+
if (!presale.visible) return null;
|
|
49
|
+
|
|
50
|
+
const isChecking = presale.status === "checking";
|
|
51
|
+
const isRejected = presale.status === "rejected";
|
|
52
|
+
|
|
53
|
+
if (presale.isUnlocked) {
|
|
54
|
+
return (
|
|
55
|
+
<div
|
|
56
|
+
data-testid="presale-code-applied"
|
|
57
|
+
className={className}
|
|
58
|
+
style={{
|
|
59
|
+
display: "flex",
|
|
60
|
+
justifyContent: "space-between",
|
|
61
|
+
alignItems: "center",
|
|
62
|
+
gap: 8,
|
|
63
|
+
padding: 12,
|
|
64
|
+
borderRadius: theme.cornerRadius,
|
|
65
|
+
background: a(theme.colors.primary, 0.1),
|
|
66
|
+
fontFamily: theme.fontFamily,
|
|
67
|
+
...style,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<span
|
|
71
|
+
style={{
|
|
72
|
+
display: "inline-flex",
|
|
73
|
+
alignItems: "center",
|
|
74
|
+
gap: 6,
|
|
75
|
+
fontSize: 14,
|
|
76
|
+
fontWeight: 600,
|
|
77
|
+
color: theme.colors.primary,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<KeyRound size={14} />
|
|
81
|
+
Presale unlocked: {presale.code}
|
|
82
|
+
</span>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
data-testid="presale-code-remove"
|
|
86
|
+
onClick={presale.clear}
|
|
87
|
+
style={{
|
|
88
|
+
background: "transparent",
|
|
89
|
+
border: "none",
|
|
90
|
+
padding: 0,
|
|
91
|
+
fontSize: 13,
|
|
92
|
+
textDecoration: "underline",
|
|
93
|
+
cursor: "pointer",
|
|
94
|
+
color: theme.colors.text,
|
|
95
|
+
fontFamily: theme.fontFamily,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
Remove
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
data-testid="presale-code"
|
|
107
|
+
className={className}
|
|
108
|
+
style={{ display: "flex", flexDirection: "column", gap: 8, fontFamily: theme.fontFamily, ...style }}
|
|
109
|
+
>
|
|
110
|
+
<label
|
|
111
|
+
htmlFor="presale-code-input"
|
|
112
|
+
style={{ fontSize: 14, fontWeight: 600, display: "inline-flex", alignItems: "center", gap: 6 }}
|
|
113
|
+
>
|
|
114
|
+
<KeyRound size={14} />
|
|
115
|
+
{promptText}
|
|
116
|
+
</label>
|
|
117
|
+
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
|
|
118
|
+
<input
|
|
119
|
+
id="presale-code-input"
|
|
120
|
+
data-testid="presale-code-input"
|
|
121
|
+
placeholder="Enter code"
|
|
122
|
+
value={presale.input}
|
|
123
|
+
onChange={(e) => presale.setInput(e.target.value.toUpperCase())}
|
|
124
|
+
// Enter is what a buyer does after typing a code into a one-field
|
|
125
|
+
// form; without this it either does nothing or submits the checkout
|
|
126
|
+
// form this box sits inside.
|
|
127
|
+
onKeyDown={(e) => {
|
|
128
|
+
if (e.key !== "Enter") return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
presale.submit();
|
|
131
|
+
}}
|
|
132
|
+
aria-invalid={isRejected || undefined}
|
|
133
|
+
aria-describedby={presale.message ? "presale-code-message" : undefined}
|
|
134
|
+
style={{ ...inputStyle(theme), flex: 1 }}
|
|
135
|
+
/>
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
data-testid="presale-code-apply"
|
|
139
|
+
onClick={presale.submit}
|
|
140
|
+
disabled={isChecking || !presale.input.trim()}
|
|
141
|
+
style={{
|
|
142
|
+
...buttonStyle(theme, { variant: "primary" }),
|
|
143
|
+
opacity: isChecking || !presale.input.trim() ? 0.6 : 1,
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
{isChecking ? "Checking…" : "Unlock"}
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
{presale.message && (
|
|
150
|
+
<p
|
|
151
|
+
id="presale-code-message"
|
|
152
|
+
data-testid="presale-code-message"
|
|
153
|
+
role={isRejected ? "alert" : undefined}
|
|
154
|
+
// Refusal is the one place a non-theme colour is right: red is the
|
|
155
|
+
// convention for "this input is wrong", and the artist's primary
|
|
156
|
+
// could be any hue including a green.
|
|
157
|
+
style={{ color: isRejected ? "#ef4444" : theme.colors.primary, fontSize: 13, margin: 0 }}
|
|
158
|
+
>
|
|
159
|
+
{presale.message}
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const inputStyle = (t: ForgeTheme): CSSProperties => ({
|
|
167
|
+
width: "100%",
|
|
168
|
+
padding: 10,
|
|
169
|
+
border: `1px solid ${a(t.colors.primary, 0.25)}`,
|
|
170
|
+
borderRadius: t.cornerRadius,
|
|
171
|
+
background: t.colors.background,
|
|
172
|
+
color: t.colors.text,
|
|
173
|
+
fontFamily: t.fontFamily,
|
|
174
|
+
});
|
|
@@ -6,6 +6,7 @@ import { useVariantSelection } from "../headless/useVariantSelection";
|
|
|
6
6
|
import { useGetProduct } from "../../data/queries/useProducts";
|
|
7
7
|
import { useCart } from "../../contexts/CartContext";
|
|
8
8
|
import { useAudioPlayer } from "../../contexts/AudioPlayerContext";
|
|
9
|
+
import { MembershipGateNotice } from "./MembershipGateNotice";
|
|
9
10
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
10
11
|
import { readableTextOn } from "../theme/contrast";
|
|
11
12
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
@@ -35,6 +36,10 @@ export interface ProductDetailProps {
|
|
|
35
36
|
*/
|
|
36
37
|
hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
|
|
37
38
|
hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
|
|
39
|
+
/** Where a members-only product sends a signed-OUT visitor. Default `/login`. */
|
|
40
|
+
loginPath?: string;
|
|
41
|
+
/** Where a members-only product sends a signed-in NON-member. Default `/membership`. */
|
|
42
|
+
membershipPath?: string;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
const a = (hex: string, al: number) => hex + Math.round(al * 255).toString(16).padStart(2, "0");
|
|
@@ -55,6 +60,8 @@ export function ProductDetail({
|
|
|
55
60
|
initialProduct,
|
|
56
61
|
hrefForCategory,
|
|
57
62
|
hrefForCollection,
|
|
63
|
+
loginPath,
|
|
64
|
+
membershipPath,
|
|
58
65
|
}: ProductDetailProps) {
|
|
59
66
|
const t = useThemeTokens();
|
|
60
67
|
const { data: product, isLoading } = useGetProduct(slug, { initialData: initialProduct });
|
|
@@ -68,7 +75,15 @@ export function ProductDetail({
|
|
|
68
75
|
// music (audio player over a PDF, etc.).
|
|
69
76
|
const isMusic = product.productType === ProductType.Music;
|
|
70
77
|
return isMusic ? (
|
|
71
|
-
<MusicDetail
|
|
78
|
+
<MusicDetail
|
|
79
|
+
product={product}
|
|
80
|
+
fmt={fmt}
|
|
81
|
+
checkoutPath={checkoutPath}
|
|
82
|
+
className={className}
|
|
83
|
+
style={style}
|
|
84
|
+
loginPath={loginPath}
|
|
85
|
+
membershipPath={membershipPath}
|
|
86
|
+
/>
|
|
72
87
|
) : (
|
|
73
88
|
<StandardDetail
|
|
74
89
|
product={product}
|
|
@@ -78,6 +93,8 @@ export function ProductDetail({
|
|
|
78
93
|
style={style}
|
|
79
94
|
hrefForCategory={hrefForCategory}
|
|
80
95
|
hrefForCollection={hrefForCollection}
|
|
96
|
+
loginPath={loginPath}
|
|
97
|
+
membershipPath={membershipPath}
|
|
81
98
|
/>
|
|
82
99
|
);
|
|
83
100
|
}
|
|
@@ -141,6 +158,8 @@ function StandardDetail({
|
|
|
141
158
|
style,
|
|
142
159
|
hrefForCategory,
|
|
143
160
|
hrefForCollection,
|
|
161
|
+
loginPath,
|
|
162
|
+
membershipPath,
|
|
144
163
|
}: {
|
|
145
164
|
product: IPublicProduct;
|
|
146
165
|
fmt: (n: number) => string;
|
|
@@ -149,6 +168,8 @@ function StandardDetail({
|
|
|
149
168
|
style?: React.CSSProperties;
|
|
150
169
|
hrefForCategory?: (category: { id: string; slug: string; title: string }) => string;
|
|
151
170
|
hrefForCollection?: (collection: { id: string; slug: string; title: string }) => string;
|
|
171
|
+
loginPath?: string;
|
|
172
|
+
membershipPath?: string;
|
|
152
173
|
}) {
|
|
153
174
|
const t = useThemeTokens();
|
|
154
175
|
// Inclusive stores caption the price as tax-inclusive (display only).
|
|
@@ -187,8 +208,24 @@ function StandardDetail({
|
|
|
187
208
|
}, []);
|
|
188
209
|
|
|
189
210
|
const outOfStock = !!selectedVariant && selectedVariant.availabilityStatus !== "active";
|
|
190
|
-
|
|
191
|
-
|
|
211
|
+
/**
|
|
212
|
+
* A members-only product the buyer cannot have (S.4).
|
|
213
|
+
*
|
|
214
|
+
* The product is LISTED rather than hidden — "Gold members get first refusal
|
|
215
|
+
* on the vinyl" only works as an offer if non-members can see it — so the page
|
|
216
|
+
* has to name the restriction and offer the way through. Without this the
|
|
217
|
+
* buyer adds it to a cart, fills in checkout, and `createOrder` refuses them
|
|
218
|
+
* at the end.
|
|
219
|
+
*/
|
|
220
|
+
const isGated = !!product.membershipGate && !product.membershipGate.allowed;
|
|
221
|
+
const canAdd = !!selectedVariant && !outOfStock && !isGated;
|
|
222
|
+
const label = isGated
|
|
223
|
+
? "Members only"
|
|
224
|
+
: !selectedVariant
|
|
225
|
+
? "Select options"
|
|
226
|
+
: outOfStock
|
|
227
|
+
? "Out of stock"
|
|
228
|
+
: "Add to cart";
|
|
192
229
|
const addToCart = () => {
|
|
193
230
|
if (!canAdd || !selectedVariant) return;
|
|
194
231
|
add(selectedVariant, { quantity, coverImage: images[imageIndex]?.url ?? images[0]?.url });
|
|
@@ -338,6 +375,17 @@ function StandardDetail({
|
|
|
338
375
|
<AddButton innerRef={addRef} />
|
|
339
376
|
</div>
|
|
340
377
|
|
|
378
|
+
{/* Directly under the disabled button, because "Members only" on a
|
|
379
|
+
dead button is a locked door; this is the key beside it. */}
|
|
380
|
+
<MembershipGateNotice
|
|
381
|
+
gate={product.membershipGate}
|
|
382
|
+
variant="card"
|
|
383
|
+
itemLabel="This product"
|
|
384
|
+
loginPath={loginPath}
|
|
385
|
+
membershipPath={membershipPath}
|
|
386
|
+
style={{ marginTop: 12 }}
|
|
387
|
+
/>
|
|
388
|
+
|
|
341
389
|
<div style={{ margin: "16px 0", opacity: 0.9 }}>
|
|
342
390
|
<RichText html={product.description || ""} />
|
|
343
391
|
</div>
|
|
@@ -453,12 +501,16 @@ function MusicDetail({
|
|
|
453
501
|
checkoutPath,
|
|
454
502
|
className,
|
|
455
503
|
style,
|
|
504
|
+
loginPath,
|
|
505
|
+
membershipPath,
|
|
456
506
|
}: {
|
|
457
507
|
product: IPublicProduct;
|
|
458
508
|
fmt: (n: number) => string;
|
|
459
509
|
checkoutPath: string;
|
|
460
510
|
className?: string;
|
|
461
511
|
style?: React.CSSProperties;
|
|
512
|
+
loginPath?: string;
|
|
513
|
+
membershipPath?: string;
|
|
462
514
|
}) {
|
|
463
515
|
const t = useThemeTokens();
|
|
464
516
|
// Inclusive stores caption the price as tax-inclusive (display only).
|
|
@@ -470,6 +522,8 @@ function MusicDetail({
|
|
|
470
522
|
// exactly what this used to: the default variant.
|
|
471
523
|
const { axes, selection, select, selectedVariant } = useVariantSelection(product.variants);
|
|
472
524
|
const defaultVariant = selectedVariant ?? product.variants.find((v) => v.isDefault) ?? product.variants[0];
|
|
525
|
+
/** Members-only release (S.4) — announced, so the buy buttons must say so. */
|
|
526
|
+
const isGated = !!product.membershipGate && !product.membershipGate.allowed;
|
|
473
527
|
// The TRACKLIST is the release's contents and does not depend on which
|
|
474
528
|
// version is selected — every version of an album has the same songs.
|
|
475
529
|
const tracks = defaultVariant?.tracks ?? [];
|
|
@@ -604,11 +658,27 @@ function MusicDetail({
|
|
|
604
658
|
))}
|
|
605
659
|
|
|
606
660
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 4 }}>
|
|
607
|
-
<Button onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>
|
|
608
|
-
|
|
661
|
+
<Button disabled={isGated} onClick={() => buyNow(defaultVariant, { canIncreaseQuantity: false })}>
|
|
662
|
+
Buy now
|
|
663
|
+
</Button>
|
|
664
|
+
<Button
|
|
665
|
+
variant="outline"
|
|
666
|
+
disabled={isGated}
|
|
667
|
+
onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}
|
|
668
|
+
>
|
|
609
669
|
Add to cart
|
|
610
670
|
</Button>
|
|
611
671
|
</div>
|
|
672
|
+
{/* A gated release is still listed and still PLAYABLE — the
|
|
673
|
+
preview is the upsell. What changes is that the buy buttons say
|
|
674
|
+
why they are dead and where to go. */}
|
|
675
|
+
<MembershipGateNotice
|
|
676
|
+
gate={product.membershipGate}
|
|
677
|
+
variant="badge"
|
|
678
|
+
itemLabel="This release"
|
|
679
|
+
loginPath={loginPath}
|
|
680
|
+
membershipPath={membershipPath}
|
|
681
|
+
/>
|
|
612
682
|
</div>
|
|
613
683
|
</div>
|
|
614
684
|
</div>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Lock } from "lucide-react";
|
|
1
2
|
import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
|
|
2
3
|
import type { IPublicProduct, ProductType } from "../../types/models";
|
|
3
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
@@ -120,6 +121,31 @@ export function ProductGrid({
|
|
|
120
121
|
</div>
|
|
121
122
|
<div style={{ padding: 10 }}>
|
|
122
123
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 2 }}>{p.title}</div>
|
|
124
|
+
{/* The listing announces the gate rather than hiding the row —
|
|
125
|
+
hiding it would kill the very upsell a members-only product
|
|
126
|
+
exists for. A card is too small for the full prompt, so it
|
|
127
|
+
carries the label and the detail page carries the action. */}
|
|
128
|
+
{p.membershipGate && !p.membershipGate.allowed && (
|
|
129
|
+
<div
|
|
130
|
+
data-testid="membership-gate-card-badge"
|
|
131
|
+
style={{
|
|
132
|
+
display: "inline-flex",
|
|
133
|
+
alignItems: "center",
|
|
134
|
+
gap: 4,
|
|
135
|
+
padding: "1px 6px",
|
|
136
|
+
marginBottom: 4,
|
|
137
|
+
borderRadius: 6,
|
|
138
|
+
fontSize: 11,
|
|
139
|
+
fontWeight: 700,
|
|
140
|
+
background: `${t.primary}1a`,
|
|
141
|
+
color: t.primary,
|
|
142
|
+
border: `1px solid ${t.primary}4d`,
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<Lock size={10} aria-hidden />
|
|
146
|
+
Members only
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
123
149
|
<div style={{ color: t.primary, fontWeight: 600, fontSize: 14 }}>
|
|
124
150
|
<PriceDisplay amount={minPrice(p)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
125
151
|
</div>
|
|
@@ -97,7 +97,7 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
|
|
|
97
97
|
<StarRow rating={afterSubmit.rating} size={20} />
|
|
98
98
|
{held ? (
|
|
99
99
|
<p style={{ opacity: 0.75, lineHeight: 1.6 }}>
|
|
100
|
-
Your review has been received and is awaiting approval
|
|
100
|
+
Your review has been received and is awaiting approval. It will appear once it's been checked.
|
|
101
101
|
</p>
|
|
102
102
|
) : (
|
|
103
103
|
<p style={{ opacity: 0.75, lineHeight: 1.6 }}>
|
|
@@ -120,7 +120,7 @@ export function ReviewForm({ token, className, style }: ReviewFormProps) {
|
|
|
120
120
|
{existing.title && <p style={{ fontWeight: 700, margin: 0 }}>{existing.title}</p>}
|
|
121
121
|
{existing.body && <p style={{ opacity: 0.85, lineHeight: 1.6, margin: 0 }}>{existing.body}</p>}
|
|
122
122
|
<p style={{ opacity: 0.6, fontSize: 13 }}>
|
|
123
|
-
Reviews can be edited for 30 days after they're written
|
|
123
|
+
Reviews can be edited for 30 days after they're written. This one can no longer be changed.
|
|
124
124
|
</p>
|
|
125
125
|
</Panel>
|
|
126
126
|
</div>
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
useCancelPassTransfer,
|
|
5
5
|
useClaimPassTransfer,
|
|
6
6
|
usePendingPassTransfers,
|
|
7
|
+
mergePendingTransfer,
|
|
7
8
|
type PassTransfer,
|
|
8
9
|
} from "../../data/queries/usePassTransfers";
|
|
10
|
+
import type { MyTicketPass, MyTicketPendingTransfer } from "../../data/queries/useMyTickets";
|
|
9
11
|
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
10
12
|
|
|
11
13
|
/**
|
|
@@ -89,8 +91,24 @@ const formatWhen = (value: string | null | undefined): string | null => {
|
|
|
89
91
|
// ── The holder's side ───────────────────────────────────────────────────────
|
|
90
92
|
|
|
91
93
|
export interface TicketTransferPanelProps {
|
|
92
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* The `TN-…` passes to draw a control for.
|
|
96
|
+
*
|
|
97
|
+
* Pass `myTicketHeldPassIds(ticket)`, not `myTicketPassIds(ticket)`: every
|
|
98
|
+
* pass-scoped endpoint resolves the current HOLDER, so a row drawn on a
|
|
99
|
+
* named guest's seat or on a ticket already claimed by somebody else could
|
|
100
|
+
* only ever answer 404.
|
|
101
|
+
*/
|
|
93
102
|
passIds: string[];
|
|
103
|
+
/**
|
|
104
|
+
* The pass OBJECTS, when the caller has them — `myTicketPasses(ticket)`.
|
|
105
|
+
*
|
|
106
|
+
* Only used to read each pass's `pendingTransfer`, which is how a sender sees
|
|
107
|
+
* and withdraws a transfer they started on ANOTHER DEVICE. Optional so a host
|
|
108
|
+
* on an older API build still gets the panel, falling back to this browser's
|
|
109
|
+
* own local record.
|
|
110
|
+
*/
|
|
111
|
+
passes?: MyTicketPass[];
|
|
94
112
|
/** Extra class(es) on the root element. */
|
|
95
113
|
className?: string;
|
|
96
114
|
/** Inline style merged LAST into the root element. */
|
|
@@ -105,19 +123,21 @@ export interface TicketTransferPanelProps {
|
|
|
105
123
|
* does not emit them cannot address any pass-scoped endpoint, so an affordance
|
|
106
124
|
* would only ever 404.
|
|
107
125
|
*
|
|
108
|
-
* The pending
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
126
|
+
* The pending transfer comes from the SERVER — `pendingTransfer` on each pass,
|
|
127
|
+
* narrowed by the API to a row this reader sent and still `pending`. That is
|
|
128
|
+
* what makes "withdraw" work for a sender who has since signed in somewhere
|
|
129
|
+
* else; the transfer HISTORY endpoint stays operator-only by design, because it
|
|
130
|
+
* names every previous holder. This browser's local record is kept only as a
|
|
131
|
+
* bridge across the refetch that follows a send.
|
|
113
132
|
*/
|
|
114
|
-
export function TicketTransferPanel({ passIds, className, style }: TicketTransferPanelProps) {
|
|
133
|
+
export function TicketTransferPanel({ passIds, passes, className, style }: TicketTransferPanelProps) {
|
|
115
134
|
const t = useThemeTokens();
|
|
116
135
|
const [openFor, setOpenFor] = useState<string | null>(null);
|
|
117
136
|
|
|
118
137
|
if (passIds.length === 0) return null;
|
|
119
138
|
|
|
120
139
|
const single = passIds.length === 1;
|
|
140
|
+
const byId = new Map((passes ?? []).map((pass) => [pass.id, pass]));
|
|
121
141
|
|
|
122
142
|
return (
|
|
123
143
|
<div
|
|
@@ -129,6 +149,7 @@ export function TicketTransferPanel({ passIds, className, style }: TicketTransfe
|
|
|
129
149
|
<TicketTransferRow
|
|
130
150
|
key={passId}
|
|
131
151
|
passId={passId}
|
|
152
|
+
serverTransfer={byId.get(passId)?.pendingTransfer ?? null}
|
|
132
153
|
t={t}
|
|
133
154
|
showPassId={!single}
|
|
134
155
|
isOpen={openFor === passId}
|
|
@@ -141,12 +162,14 @@ export function TicketTransferPanel({ passIds, className, style }: TicketTransfe
|
|
|
141
162
|
|
|
142
163
|
function TicketTransferRow({
|
|
143
164
|
passId,
|
|
165
|
+
serverTransfer,
|
|
144
166
|
t,
|
|
145
167
|
showPassId,
|
|
146
168
|
isOpen,
|
|
147
169
|
onToggle,
|
|
148
170
|
}: {
|
|
149
171
|
passId: string;
|
|
172
|
+
serverTransfer: MyTicketPendingTransfer | null;
|
|
150
173
|
t: ResolvedThemeTokens;
|
|
151
174
|
showPassId: boolean;
|
|
152
175
|
isOpen: boolean;
|
|
@@ -154,7 +177,10 @@ function TicketTransferRow({
|
|
|
154
177
|
}) {
|
|
155
178
|
const send = useSendPassTransfer();
|
|
156
179
|
const cancel = useCancelPassTransfer();
|
|
157
|
-
const
|
|
180
|
+
const remembered = usePendingPassTransfers(passId);
|
|
181
|
+
// One live transfer per pass is a DATABASE guarantee, so exactly one row may
|
|
182
|
+
// ever be drawn. The server's copy wins whenever it has one.
|
|
183
|
+
const pending = mergePendingTransfer(serverTransfer, remembered);
|
|
158
184
|
|
|
159
185
|
const [toEmail, setToEmail] = useState("");
|
|
160
186
|
const [toName, setToName] = useState("");
|
|
@@ -207,39 +233,42 @@ function TicketTransferRow({
|
|
|
207
233
|
</button>
|
|
208
234
|
</div>
|
|
209
235
|
|
|
210
|
-
{/*
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
Your QR code keeps working until they claim it.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
disabled={cancel.isPending}
|
|
236
|
-
style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
|
|
237
|
-
>
|
|
238
|
-
{cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
|
|
239
|
-
</button>
|
|
236
|
+
{/* The live transfer out on this pass, from whichever device sent it. */}
|
|
237
|
+
{pending && (
|
|
238
|
+
<div
|
|
239
|
+
data-testid="pending-pass-transfer"
|
|
240
|
+
style={{
|
|
241
|
+
marginTop: 12,
|
|
242
|
+
padding: 12,
|
|
243
|
+
borderRadius: t.cornerRadius,
|
|
244
|
+
border: `1px solid ${t.primary}4d`,
|
|
245
|
+
fontSize: 14,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<div>
|
|
249
|
+
Sent to <strong>{pending.toEmail}</strong>
|
|
250
|
+
{formatWhen(pending.expiresAt) && pending.open
|
|
251
|
+
? `. The link works until ${formatWhen(pending.expiresAt)}.`
|
|
252
|
+
: "."}
|
|
253
|
+
</div>
|
|
254
|
+
<div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
|
|
255
|
+
{pending.open
|
|
256
|
+
? "Your QR code keeps working until they claim it."
|
|
257
|
+
: /* Lapsed but not yet swept. Still `pending`, still withdrawable —
|
|
258
|
+
hiding it would leave the sender with a live row and no way to
|
|
259
|
+
address it. */
|
|
260
|
+
"That link has expired. Withdraw it to send this ticket to someone else."}
|
|
240
261
|
</div>
|
|
241
|
-
|
|
242
|
-
|
|
262
|
+
<button
|
|
263
|
+
type="button"
|
|
264
|
+
onClick={() => void onCancel(pending.transferId)}
|
|
265
|
+
disabled={cancel.isPending}
|
|
266
|
+
style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
|
|
267
|
+
>
|
|
268
|
+
{cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
243
272
|
|
|
244
273
|
{sent && (
|
|
245
274
|
<p style={{ fontSize: 13, marginTop: 10 }}>
|
|
@@ -341,7 +370,7 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
|
|
|
341
370
|
<h1 style={{ fontSize: 24, fontWeight: 700, fontFamily: t.headingFontFamily }}>The ticket is yours</h1>
|
|
342
371
|
<p style={{ marginTop: 8 }}>
|
|
343
372
|
Ticket <strong>{claimed.eventPassId}</strong> is now in your name. We have emailed it to{" "}
|
|
344
|
-
<strong>{claimed.toEmail}</strong
|
|
373
|
+
<strong>{claimed.toEmail}</strong>. Bring that QR code to the door.
|
|
345
374
|
</p>
|
|
346
375
|
</div>
|
|
347
376
|
);
|
|
@@ -367,7 +396,7 @@ export function ClaimTicketTransfer({ token, onClaimed, className, style }: Clai
|
|
|
367
396
|
Claim your ticket
|
|
368
397
|
</h1>
|
|
369
398
|
<p style={{ color: t.muted, marginBottom: 24 }}>
|
|
370
|
-
Someone sent you their ticket. Tell us what to put on it
|
|
399
|
+
Someone sent you their ticket. Tell us what to put on it. You do not need an account.
|
|
371
400
|
</p>
|
|
372
401
|
<form onSubmit={onSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
373
402
|
<input
|