@tribe-nest/forge 3.21.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useEvents.ts +66 -4
  4. package/src/data/queries/useMembership.ts +8 -2
  5. package/src/data/queries/useMyBookings.ts +12 -0
  6. package/src/data/queries/useMyTickets.ts +112 -0
  7. package/src/data/queries/useOrders.ts +10 -0
  8. package/src/data/queries/usePassTransfers.ts +73 -13
  9. package/src/server/index.ts +52 -0
  10. package/src/types/models.ts +151 -0
  11. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  12. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  13. package/src/ui/format/attendees.ts +187 -0
  14. package/src/ui/format/membershipGate.ts +209 -0
  15. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  16. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  17. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  18. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  19. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  20. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  22. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  23. package/src/ui/headless/index.ts +25 -0
  24. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  25. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  26. package/src/ui/index.ts +42 -0
  27. package/src/ui/styled/AccountDashboard.tsx +70 -8
  28. package/src/ui/styled/AddToCalendar.tsx +34 -10
  29. package/src/ui/styled/Checkout.tsx +18 -1
  30. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  31. package/src/ui/styled/CourseDetail.tsx +30 -1
  32. package/src/ui/styled/EventConfirmation.tsx +2 -0
  33. package/src/ui/styled/EventDetail.tsx +53 -22
  34. package/src/ui/styled/EventTickets.tsx +156 -5
  35. package/src/ui/styled/HoldNotice.tsx +192 -0
  36. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  37. package/src/ui/styled/OfferButton.tsx +23 -0
  38. package/src/ui/styled/PresaleCode.tsx +174 -0
  39. package/src/ui/styled/ProductDetail.tsx +75 -5
  40. package/src/ui/styled/ProductGrid.tsx +26 -0
  41. package/src/ui/styled/TicketTransfer.tsx +69 -40
  42. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  43. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  44. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  45. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  46. package/src/utils/_tests/structuredData.spec.ts +275 -0
  47. package/src/utils/presaleCode.ts +96 -0
  48. package/src/utils/structuredData.ts +361 -27
@@ -0,0 +1,159 @@
1
+ import type { CSSProperties } from "react";
2
+ import { Lock } from "lucide-react";
3
+ import type { PublicMembershipGate } from "../../types/models";
4
+ import type { MembershipGateRefusal } from "../format/membershipGate";
5
+ import { useMembershipGateNotice } from "../headless/membership/useMembershipGateNotice";
6
+ import { useForgeTheme, type ForgeTheme } from "../theme/ForgeThemeProvider";
7
+ import { readableTextOn } from "../theme/contrast";
8
+
9
+ export interface MembershipGateNoticeProps {
10
+ /** The announced gate from the read. */
11
+ gate?: PublicMembershipGate | null;
12
+ /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
13
+ refusal?: MembershipGateRefusal | null;
14
+ /** What the thing is, in the buyer's words. Default "This". */
15
+ itemLabel?: string;
16
+ /**
17
+ * `badge` is the one-line form for a row in a list of things (a ticket tier);
18
+ * `card` is the block form for a page about ONE thing (a course, a product).
19
+ */
20
+ variant?: "badge" | "card";
21
+ loginPath?: string;
22
+ membershipPath?: string;
23
+ style?: CSSProperties;
24
+ }
25
+
26
+ /**
27
+ * "Members only" — said BEFORE the buyer pays, with the action that resolves it.
28
+ *
29
+ * ## What was wrong
30
+ *
31
+ * Members-only tickets were enforced at checkout and announced on no surface.
32
+ * A logged-out buyer picked a seat, filled in a two-step form and was refused on
33
+ * the last screen with a 400 — the restriction was discoverable only by failing
34
+ * it, and the "members save X, sign in" funnel the feature was sold on existed
35
+ * nowhere. Products and courses published the same gate and drew it just as
36
+ * little.
37
+ *
38
+ * ## Why the button is the point, not the badge
39
+ *
40
+ * A badge alone converts a silent failure into a visible dead end. The two
41
+ * reasons need genuinely different exits — `sign_in_required` gets a sign-in
42
+ * that RETURNS here, `membership_required` gets the tier — and offering the
43
+ * wrong one is worse than offering none: a signed-in member sent to sign in
44
+ * again learns only that the site is broken. `buildMembershipGateNotice` owns
45
+ * that choice so both rendering stacks make it identically.
46
+ *
47
+ * Renders nothing when nothing is gated, which is also every storefront with the
48
+ * gates switch off.
49
+ */
50
+ export function MembershipGateNotice({
51
+ gate,
52
+ refusal,
53
+ itemLabel,
54
+ variant = "card",
55
+ loginPath,
56
+ membershipPath,
57
+ style,
58
+ }: MembershipGateNoticeProps) {
59
+ const theme = useForgeTheme();
60
+ const notice = useMembershipGateNotice({ gate, refusal, itemLabel, loginPath, membershipPath });
61
+ if (!notice) return null;
62
+
63
+ if (variant === "badge") {
64
+ return (
65
+ <div
66
+ data-testid="membership-gate-badge"
67
+ data-gate-reason={notice.reason}
68
+ style={{
69
+ display: "flex",
70
+ flexWrap: "wrap",
71
+ alignItems: "center",
72
+ gap: 8,
73
+ marginTop: 8,
74
+ fontFamily: theme.fontFamily,
75
+ ...style,
76
+ }}
77
+ >
78
+ <span style={pillStyle(theme)}>
79
+ <Lock size={12} aria-hidden />
80
+ {notice.badge}
81
+ </span>
82
+ <span style={{ fontSize: 13, opacity: 0.8, color: theme.colors.text }}>{notice.body}</span>
83
+ {notice.actionHref && (
84
+ <a data-testid="membership-gate-action" href={notice.actionHref} style={linkStyle(theme)}>
85
+ {notice.actionLabel}
86
+ </a>
87
+ )}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <div
94
+ data-testid="membership-gate-card"
95
+ data-gate-reason={notice.reason}
96
+ style={{
97
+ border: `1px solid ${alpha(theme.colors.primary, 0.3)}`,
98
+ borderRadius: theme.cornerRadius,
99
+ padding: 20,
100
+ color: theme.colors.text,
101
+ background: theme.colors.background,
102
+ fontFamily: theme.fontFamily,
103
+ ...style,
104
+ }}
105
+ >
106
+ <span style={pillStyle(theme)}>
107
+ <Lock size={12} aria-hidden />
108
+ {notice.badge}
109
+ </span>
110
+ <h3 style={{ fontWeight: 600, fontSize: 17, margin: "12px 0 6px" }}>{notice.title}</h3>
111
+ <p style={{ opacity: 0.8, fontSize: 14, marginBottom: notice.actionHref ? 16 : 0 }}>{notice.body}</p>
112
+ {notice.actionHref && (
113
+ <a
114
+ data-testid="membership-gate-action"
115
+ href={notice.actionHref}
116
+ style={{
117
+ display: "inline-block",
118
+ background: theme.colors.primary,
119
+ color: readableTextOn(theme.colors.primary),
120
+ borderRadius: theme.cornerRadius,
121
+ padding: "10px 18px",
122
+ fontWeight: 600,
123
+ fontSize: 14,
124
+ textDecoration: "none",
125
+ }}
126
+ >
127
+ {notice.actionLabel}
128
+ </a>
129
+ )}
130
+ </div>
131
+ );
132
+ }
133
+
134
+ /** Append an alpha to a 6-digit hex colour (mirrors frontend-shared addAlphaToHexCode). */
135
+ const alpha = (hex: string, value: number) => hex + Math.round(value * 255).toString(16).padStart(2, "0");
136
+
137
+ // Everything is drawn from the artist's own palette. A members-only badge in a
138
+ // hardcoded gold or indigo is a brand bug on every site that is not that colour.
139
+ const pillStyle = (t: ForgeTheme): CSSProperties => ({
140
+ display: "inline-flex",
141
+ alignItems: "center",
142
+ gap: 4,
143
+ padding: "2px 8px",
144
+ borderRadius: 6,
145
+ fontSize: 12,
146
+ fontWeight: 700,
147
+ background: alpha(t.colors.primary, 0.1),
148
+ color: t.colors.primary,
149
+ border: `1px solid ${alpha(t.colors.primary, 0.3)}`,
150
+ whiteSpace: "nowrap",
151
+ });
152
+
153
+ const linkStyle = (t: ForgeTheme): CSSProperties => ({
154
+ fontSize: 13,
155
+ fontWeight: 600,
156
+ color: t.colors.primary,
157
+ textDecoration: "underline",
158
+ whiteSpace: "nowrap",
159
+ });
@@ -5,6 +5,7 @@ import { Loading } from "./Loading";
5
5
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
6
  import { readableTextOn } from "../theme/contrast";
7
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
8
+ import { HoldNotice } from "./HoldNotice";
8
9
 
9
10
  export interface OfferButtonProps {
10
11
  productId: string;
@@ -97,6 +98,15 @@ function OfferBody({
97
98
  <span>Total</span>
98
99
  <strong style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</strong>
99
100
  </div>
101
+ {/* The reservation, counted down while the buyer pays. Nothing is drawn
102
+ for a digital item or a profile with holds off. */}
103
+ <HoldNotice
104
+ hold={c.hold}
105
+ expiredMessage={c.holdExpiredError ?? undefined}
106
+ onRetry={c.retryHold}
107
+ isRetrying={c.isRetryingHold}
108
+ noun="item"
109
+ />
100
110
  {renderPayment ? (
101
111
  <Offer.PaymentStep>{(p) => renderPayment(p)}</Offer.PaymentStep>
102
112
  ) : (
@@ -157,6 +167,19 @@ function OfferBody({
157
167
  <span style={{ color: theme.colors.primary }}>{fmt(c.totalPrice)}</span>
158
168
  </div>
159
169
 
170
+ {/* A reservation that lapsed on the way to the card form never reached
171
+ the payment step, so it is answered here — distinctly from the error
172
+ line, because "your hold ran out" is not "this is gone". */}
173
+ {c.holdExpiredError && (
174
+ <HoldNotice
175
+ hold={c.hold}
176
+ expiredMessage={c.holdExpiredError}
177
+ onRetry={c.retryHold}
178
+ isRetrying={c.isRetryingHold}
179
+ noun="item"
180
+ />
181
+ )}
182
+
160
183
  {c.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{c.error}</p>}
161
184
 
162
185
  <button
@@ -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 product={product} fmt={fmt} checkoutPath={checkoutPath} className={className} style={style} />
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
- const canAdd = !!selectedVariant && !outOfStock;
191
- const label = !selectedVariant ? "Select options" : outOfStock ? "Out of stock" : "Add to cart";
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 })}>Buy now</Button>
608
- <Button variant="outline" onClick={() => add(defaultVariant, { canIncreaseQuantity: false })}>
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>