@tribe-nest/forge 3.20.0 → 3.22.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/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -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/server/index.ts +52 -0
- package/src/types/models.ts +151 -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/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -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/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -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/TicketTransfer.tsx +69 -40
- 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/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
|
@@ -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>
|
|
@@ -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 }}>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { AddToCalendar } from "../AddToCalendar";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Rendered through `react-dom/server`, which needs no DOM — same approach as
|
|
8
|
+
* `EventConfirmation.spec.tsx`.
|
|
9
|
+
*
|
|
10
|
+
* What this guards is the anchor ATTRIBUTES, because they are what actually
|
|
11
|
+
* decides whether an iPhone opens Calendar. A `webcal:` link with
|
|
12
|
+
* `target="_blank"` leaves a blank tab behind and, on several browsers, never
|
|
13
|
+
* reaches the OS handler at all; one with `download` asks to save a file from a
|
|
14
|
+
* scheme that has no file. Both are easy to add back by reflex, and neither
|
|
15
|
+
* failure is visible from a desktop.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const THEME = {
|
|
19
|
+
colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
|
|
20
|
+
cornerRadius: 8,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const render = (node: React.ReactNode) =>
|
|
24
|
+
renderToStaticMarkup(<ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>);
|
|
25
|
+
|
|
26
|
+
const ICS = "https://api.tribenest.co/public/calendar/events/abc/tok.ics";
|
|
27
|
+
const WEBCAL = "webcal://api.tribenest.co/public/calendar/events/abc/tok.ics";
|
|
28
|
+
|
|
29
|
+
const base = { title: "Night Show", start: "2026-03-15T20:00:00.000Z" };
|
|
30
|
+
|
|
31
|
+
describe("<AddToCalendar>", () => {
|
|
32
|
+
it("draws only the three template providers when no .ics is supplied", () => {
|
|
33
|
+
const html = render(<AddToCalendar {...base} />);
|
|
34
|
+
|
|
35
|
+
expect(html).toContain("add-to-calendar-google");
|
|
36
|
+
expect(html).toContain("add-to-calendar-outlook");
|
|
37
|
+
expect(html).toContain("add-to-calendar-office365");
|
|
38
|
+
// The pre-existing behaviour, preserved for a site on an older API build:
|
|
39
|
+
// no Apple chip rather than a dead one.
|
|
40
|
+
expect(html).not.toContain("add-to-calendar-apple");
|
|
41
|
+
expect(html).not.toContain("add-to-calendar-ics");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("adds Apple Calendar and a download once the host has the address", () => {
|
|
45
|
+
const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
|
|
46
|
+
|
|
47
|
+
expect(html).toContain("add-to-calendar-apple");
|
|
48
|
+
expect(html).toContain(`href="${WEBCAL}"`);
|
|
49
|
+
expect(html).toContain("Apple Calendar");
|
|
50
|
+
|
|
51
|
+
expect(html).toContain("add-to-calendar-ics");
|
|
52
|
+
expect(html).toContain("Download .ics");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("the Apple anchor is a plain same-window navigation", () => {
|
|
56
|
+
const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
|
|
57
|
+
|
|
58
|
+
// Isolate the Apple anchor so the assertions cannot be satisfied by the
|
|
59
|
+
// Google one sitting next to it.
|
|
60
|
+
const apple = html.slice(html.indexOf('data-testid="add-to-calendar-apple"'));
|
|
61
|
+
const anchor = apple.slice(0, apple.indexOf(">"));
|
|
62
|
+
|
|
63
|
+
expect(anchor).not.toContain('target="_blank"');
|
|
64
|
+
expect(anchor).not.toContain("download");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("the .ics anchor DOES ask to download", () => {
|
|
68
|
+
const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
|
|
69
|
+
const ics = html.slice(html.indexOf('data-testid="add-to-calendar-ics"'));
|
|
70
|
+
expect(ics.slice(0, ics.indexOf(">"))).toContain("download");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("still offers Apple when only the https form is passed", () => {
|
|
74
|
+
// The webcal address is derived; a host that only forwards `calendarUrl`
|
|
75
|
+
// must not lose the one option iOS can use.
|
|
76
|
+
const html = render(<AddToCalendar {...base} icsUrl={ICS} />);
|
|
77
|
+
expect(html).toContain(`href="${WEBCAL}"`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("renders nothing at all for an unusable start time", () => {
|
|
81
|
+
// The theme provider still emits its `:root` variables, so the assertion is
|
|
82
|
+
// that no ANCHOR was drawn — a chip pointing at a garbage prefill is worse
|
|
83
|
+
// than no chip.
|
|
84
|
+
const html = render(<AddToCalendar title="X" start="not-a-date" icsUrl={ICS} webcalUrl={WEBCAL} />);
|
|
85
|
+
expect(html).not.toContain("<a ");
|
|
86
|
+
expect(html).not.toContain("add-to-calendar");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -38,7 +38,11 @@ const baseOrder: ITicketOrder = {
|
|
|
38
38
|
function render(order: ITicketOrder) {
|
|
39
39
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
40
40
|
queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
|
|
41
|
-
|
|
41
|
+
// Four-element key: `useEvent` gained a presale `accessCode` segment, which
|
|
42
|
+
// is `null` for an ordinary read. Seeding the old three-element key left
|
|
43
|
+
// `event` undefined, which silently dropped the add-to-calendar block — the
|
|
44
|
+
// assertion below is what caught it.
|
|
45
|
+
queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID, null], {
|
|
42
46
|
id: EVENT_ID,
|
|
43
47
|
title: "Midnight Set",
|
|
44
48
|
description: "A set",
|