@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- 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,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
|
|
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>
|