@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
package/src/ui/headless/index.ts
CHANGED
|
@@ -4,6 +4,11 @@ export * from "./dialog";
|
|
|
4
4
|
export * from "./donation";
|
|
5
5
|
export * from "./offer";
|
|
6
6
|
export { MembershipGate, type MembershipGateProps, type MembershipGateState } from "./membership/MembershipGate";
|
|
7
|
+
// S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
|
|
8
|
+
export {
|
|
9
|
+
useMembershipGateNotice,
|
|
10
|
+
type UseMembershipGateNoticeInput,
|
|
11
|
+
} from "./membership/useMembershipGateNotice";
|
|
7
12
|
export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
|
|
8
13
|
export { useContactForm } from "./forms/useContactForm";
|
|
9
14
|
export { useSectionedForm } from "./forms/useSectionedForm";
|
|
@@ -17,12 +22,32 @@ export {
|
|
|
17
22
|
type CheckoutShippingData,
|
|
18
23
|
type SelectedShippingRate,
|
|
19
24
|
} from "./checkout/useCheckout";
|
|
25
|
+
// Inventory holds — the reservation a buyer is given while they pay, and the
|
|
26
|
+
// 409 they get when it lapses. Shared by both rendering stacks.
|
|
27
|
+
export {
|
|
28
|
+
useInventoryHold,
|
|
29
|
+
type InventoryHoldState,
|
|
30
|
+
type UseInventoryHoldOptions,
|
|
31
|
+
} from "./checkout/useInventoryHold";
|
|
32
|
+
export {
|
|
33
|
+
isHoldExpiredError,
|
|
34
|
+
holdExpiredMessage,
|
|
35
|
+
holdMsRemaining,
|
|
36
|
+
formatHoldRemaining,
|
|
37
|
+
HOLD_EXPIRED_CODE,
|
|
38
|
+
} from "./checkout/inventoryHold";
|
|
20
39
|
export { useLoginFlow, type LoginStep } from "./auth/useLoginFlow";
|
|
21
40
|
export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
22
41
|
|
|
23
42
|
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
24
43
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
25
44
|
export { useEventCheckout, type UseEventCheckoutOptions, type EventCheckoutStep } from "./event/useEventCheckout";
|
|
45
|
+
export {
|
|
46
|
+
usePresaleCode,
|
|
47
|
+
type PresaleCodeField,
|
|
48
|
+
type PresaleCodeStatus,
|
|
49
|
+
type UsePresaleCodeOptions,
|
|
50
|
+
} from "./event/usePresaleCode";
|
|
26
51
|
export {
|
|
27
52
|
useCouponField,
|
|
28
53
|
apiErrorMessage,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { PublicMembershipGate } from "../../../types/models";
|
|
3
|
+
import { useGetMembershipTiers } from "../../../data/queries/useMembership";
|
|
4
|
+
import {
|
|
5
|
+
buildMembershipGateNotice,
|
|
6
|
+
isMembershipGateLocked,
|
|
7
|
+
type MembershipGateNotice,
|
|
8
|
+
type MembershipGateRefusal,
|
|
9
|
+
} from "../../format/membershipGate";
|
|
10
|
+
|
|
11
|
+
export interface UseMembershipGateNoticeInput {
|
|
12
|
+
/** The gate announced by the read (`ticket.membershipGate`, `product.membershipGate`, …). */
|
|
13
|
+
gate?: PublicMembershipGate | null;
|
|
14
|
+
/** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`. */
|
|
15
|
+
refusal?: MembershipGateRefusal | null;
|
|
16
|
+
/** What the thing is, in the buyer's words — "This ticket", "This course". */
|
|
17
|
+
itemLabel?: string;
|
|
18
|
+
/** Where a signed-out visitor signs in. Code sites mount theirs under `/i`. */
|
|
19
|
+
loginPath?: string;
|
|
20
|
+
/** Where a signed-in non-member goes to join — the tier listing. */
|
|
21
|
+
membershipPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The members-only notice for one gated thing, tier names and all.
|
|
26
|
+
*
|
|
27
|
+
* ## Why the names are fetched here and not sent by the API
|
|
28
|
+
*
|
|
29
|
+
* A gate comes down as `requiredTierIds`, and "you need tier 8f3c-…" is not a
|
|
30
|
+
* sentence anyone can act on. The public tier list is already loaded by every
|
|
31
|
+
* storefront that sells memberships, is cached by React Query under one key per
|
|
32
|
+
* profile, and — unlike an id embedded in a ticket payload — stays correct when
|
|
33
|
+
* the artist renames a tier. So the ids are resolved against it here, once, for
|
|
34
|
+
* every surface.
|
|
35
|
+
*
|
|
36
|
+
* The names are best-effort by design: the query can be in flight, and a gating
|
|
37
|
+
* tier can be one the public list does not carry (an archived or unlisted tier
|
|
38
|
+
* still gates). `buildMembershipGateNotice` therefore degrades to "is for
|
|
39
|
+
* members" rather than showing an id or rendering nothing — a badge with slightly
|
|
40
|
+
* vaguer wording is a working funnel; a blank space is the bug this closes.
|
|
41
|
+
*
|
|
42
|
+
* Returns `null` when nothing is gated, which is also what every surface sees
|
|
43
|
+
* while the gates feature switch is off.
|
|
44
|
+
*/
|
|
45
|
+
export function useMembershipGateNotice(input: UseMembershipGateNoticeInput): MembershipGateNotice | null {
|
|
46
|
+
const locked = isMembershipGateLocked(input.gate) || !!input.refusal;
|
|
47
|
+
// Only pulled once something is actually locked — an ungated storefront must
|
|
48
|
+
// not acquire a request it never needed.
|
|
49
|
+
const { data: tiers } = useGetMembershipTiers({ enabled: locked });
|
|
50
|
+
|
|
51
|
+
const requiredTierIds = input.refusal?.requiredTierIds ?? input.gate?.requiredTierIds ?? [];
|
|
52
|
+
|
|
53
|
+
return useMemo(() => {
|
|
54
|
+
if (!locked) return null;
|
|
55
|
+
const byId = new Map((tiers ?? []).map((tier) => [tier.id, tier.name]));
|
|
56
|
+
return buildMembershipGateNotice({
|
|
57
|
+
gate: input.gate,
|
|
58
|
+
refusal: input.refusal,
|
|
59
|
+
tierNames: requiredTierIds.map((id) => byId.get(id) ?? "").filter(Boolean),
|
|
60
|
+
itemLabel: input.itemLabel,
|
|
61
|
+
loginPath: input.loginPath,
|
|
62
|
+
membershipPath: input.membershipPath,
|
|
63
|
+
currentPath: currentPath(),
|
|
64
|
+
});
|
|
65
|
+
// `requiredTierIds` is a fresh array each render; its CONTENTS are the input.
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [
|
|
68
|
+
locked,
|
|
69
|
+
tiers,
|
|
70
|
+
requiredTierIds.join(","),
|
|
71
|
+
input.gate?.reason,
|
|
72
|
+
input.refusal?.reason,
|
|
73
|
+
input.itemLabel,
|
|
74
|
+
input.loginPath,
|
|
75
|
+
input.membershipPath,
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where the buyer is standing, so signing in returns them to it. Empty during SSR. */
|
|
80
|
+
function currentPath(): string {
|
|
81
|
+
if (typeof window === "undefined") return "";
|
|
82
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
83
|
+
}
|
|
@@ -3,6 +3,8 @@ import type { IPublicProduct, IPublicProductVariant } from "../../../types/model
|
|
|
3
3
|
import { useGetProduct } from "../../../data/queries/useProducts";
|
|
4
4
|
import { useCreateOrder } from "../../../data/queries/useOrders";
|
|
5
5
|
import { usePaymentFlow } from "../../../data/queries/usePaymentFlow";
|
|
6
|
+
import { holdExpiredMessage, isHoldExpiredError } from "../checkout/inventoryHold";
|
|
7
|
+
import { useInventoryHold, type InventoryHoldState } from "../checkout/useInventoryHold";
|
|
6
8
|
|
|
7
9
|
export type OfferStep = "details" | "payment";
|
|
8
10
|
|
|
@@ -38,6 +40,13 @@ export interface OfferContextValue {
|
|
|
38
40
|
clientSecret?: string;
|
|
39
41
|
returnUrl: string;
|
|
40
42
|
continueToPayment: () => Promise<void>;
|
|
43
|
+
/** The stock reservation held while the buyer pays. All-null when none was taken. */
|
|
44
|
+
hold: InventoryHoldState;
|
|
45
|
+
/** The server's 409 `INVENTORY_HOLD_EXPIRED` message — never sold-out. */
|
|
46
|
+
holdExpiredError: string | null;
|
|
47
|
+
/** Re-take the reservation on the same order. */
|
|
48
|
+
retryHold: () => Promise<void>;
|
|
49
|
+
isRetryingHold: boolean;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
const OfferContext = createContext<OfferContextValue | null>(null);
|
|
@@ -72,6 +81,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
72
81
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
73
82
|
const [error, setError] = useState<string | null>(null);
|
|
74
83
|
const [returnUrl, setReturnUrl] = useState("");
|
|
84
|
+
const [orderId, setOrderId] = useState<string | null>(null);
|
|
85
|
+
/** The window the ORDER reported, until start-payment restarts it. */
|
|
86
|
+
const [orderHoldExpiresAt, setOrderHoldExpiresAt] = useState<string | null>(null);
|
|
87
|
+
const [holdExpiredError, setHoldExpiredError] = useState<string | null>(null);
|
|
75
88
|
|
|
76
89
|
const selectedVariant = useMemo(
|
|
77
90
|
() => variants.find((v) => v.id === selectedVariantId) ?? variants[0],
|
|
@@ -82,6 +95,7 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
82
95
|
|
|
83
96
|
const continueToPayment = async () => {
|
|
84
97
|
setError(null);
|
|
98
|
+
setHoldExpiredError(null);
|
|
85
99
|
if (!product || !selectedVariant) return;
|
|
86
100
|
if (!firstName.trim() || !lastName.trim()) {
|
|
87
101
|
setError("Please enter your name.");
|
|
@@ -114,6 +128,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
114
128
|
});
|
|
115
129
|
const ru = `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath ?? "/checkout/finalise"}?orderId=${created.orderId}`;
|
|
116
130
|
setReturnUrl(ru);
|
|
131
|
+
setOrderId(created.orderId);
|
|
132
|
+
// Absent/null for a digital item, a free order, or a profile with holds
|
|
133
|
+
// switched off — every one of which must render no countdown at all.
|
|
134
|
+
setOrderHoldExpiresAt(created.holdExpiresAt ?? null);
|
|
117
135
|
// Free checkout — skip payment.
|
|
118
136
|
if (created.subTotal === 0 && typeof window !== "undefined") {
|
|
119
137
|
window.location.href = ru;
|
|
@@ -124,11 +142,44 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
124
142
|
if (result.provider && result.provider !== "stripe") return;
|
|
125
143
|
setStep("payment");
|
|
126
144
|
} catch (e) {
|
|
145
|
+
// A lapsed reservation gets its own state so the buyer is offered the
|
|
146
|
+
// retry that re-acquires the same unit, rather than a bare error that
|
|
147
|
+
// reads like the item is gone.
|
|
148
|
+
if (isHoldExpiredError(e)) {
|
|
149
|
+
setHoldExpiredError(
|
|
150
|
+
holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
127
154
|
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
128
155
|
setError(message || "Something went wrong.");
|
|
129
156
|
}
|
|
130
157
|
};
|
|
131
158
|
|
|
159
|
+
/** Take the same unit again on the same order. */
|
|
160
|
+
const retryHold = async () => {
|
|
161
|
+
if (!orderId || !returnUrl) return;
|
|
162
|
+
setHoldExpiredError(null);
|
|
163
|
+
try {
|
|
164
|
+
const result = await flow.start({ orderId, returnUrl });
|
|
165
|
+
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
166
|
+
setStep("payment");
|
|
167
|
+
} catch (e) {
|
|
168
|
+
if (isHoldExpiredError(e)) {
|
|
169
|
+
setHoldExpiredError(
|
|
170
|
+
holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
|
|
171
|
+
);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
175
|
+
setError(message || "Something went wrong.");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// start-payment restarts the clock and reports the newer instant, so it wins
|
|
180
|
+
// over the create-time one.
|
|
181
|
+
const hold = useInventoryHold(flow.result?.holdExpiresAt ?? orderHoldExpiresAt);
|
|
182
|
+
|
|
132
183
|
const value: OfferContextValue = {
|
|
133
184
|
product,
|
|
134
185
|
isLoading,
|
|
@@ -156,6 +207,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
156
207
|
clientSecret: flow.result?.paymentSecret,
|
|
157
208
|
returnUrl,
|
|
158
209
|
continueToPayment,
|
|
210
|
+
hold,
|
|
211
|
+
holdExpiredError,
|
|
212
|
+
retryHold,
|
|
213
|
+
isRetryingHold: flow.isStarting,
|
|
159
214
|
};
|
|
160
215
|
|
|
161
216
|
return <OfferContext.Provider value={value}>{children}</OfferContext.Provider>;
|
package/src/ui/index.ts
CHANGED
|
@@ -51,6 +51,7 @@ export {
|
|
|
51
51
|
DiscountSummaryLines,
|
|
52
52
|
type DiscountSummaryLinesProps,
|
|
53
53
|
} from "./styled/DiscountCode";
|
|
54
|
+
export { PresaleCodeField, type PresaleCodeFieldProps } from "./styled/PresaleCode";
|
|
54
55
|
export { MembershipCheckout, type MembershipCheckoutProps } from "./styled/MembershipCheckout";
|
|
55
56
|
export { ForgeStripePayment } from "./payment/ForgeStripePayment";
|
|
56
57
|
export { Cart, type CartProps } from "./styled/Cart";
|
|
@@ -109,6 +110,8 @@ export { ForgeAnalytics, type ForgeAnalyticsProps } from "./analytics/ForgeAnaly
|
|
|
109
110
|
export { PageMetaPixel, usePageMetaPixel, type PageMetaPixelProps } from "./analytics/PageMetaPixel";
|
|
110
111
|
export { CookieConsent, type CookieConsentProps } from "./styled/CookieConsent";
|
|
111
112
|
export { Loading, type LoadingProps } from "./styled/Loading";
|
|
113
|
+
/** The inventory-hold countdown + its lapsed/409 state. See `useInventoryHold`. */
|
|
114
|
+
export { HoldNotice, type HoldNoticeProps } from "./styled/HoldNotice";
|
|
112
115
|
export {
|
|
113
116
|
ConfirmationStage,
|
|
114
117
|
ConfirmationCard,
|
|
@@ -170,6 +173,45 @@ export {
|
|
|
170
173
|
resolveUnitPrice,
|
|
171
174
|
ticketSubtotals,
|
|
172
175
|
} from "./format/pwyw";
|
|
176
|
+
// Per-attendee names at checkout. Exported for a site rendering its own ticket
|
|
177
|
+
// UI — and used by `apps/client`, which has its own event checkout: the request
|
|
178
|
+
// shape is positional per tier and a length mismatch is refused server-side, so
|
|
179
|
+
// neither stack derives it for itself.
|
|
180
|
+
export {
|
|
181
|
+
MAX_ATTENDEE_NAME_LENGTH,
|
|
182
|
+
normalizeAttendeeName,
|
|
183
|
+
collectsAttendees,
|
|
184
|
+
buyerFullName,
|
|
185
|
+
buildAttendeeSlots,
|
|
186
|
+
setAttendeeName,
|
|
187
|
+
validateAttendeeNames,
|
|
188
|
+
attendeesPayload,
|
|
189
|
+
type AttendeeNames,
|
|
190
|
+
type AttendeeSlot,
|
|
191
|
+
type AttendeeCheck,
|
|
192
|
+
type AttendeeProblem,
|
|
193
|
+
} from "./format/attendees";
|
|
194
|
+
// Members-only gates (S.4). The wording and the resolving ACTION are decided
|
|
195
|
+
// purely, in one place, because BOTH rendering stacks draw this — `apps/client`
|
|
196
|
+
// imports these directly, exactly as it does the PWYW arithmetic. A members-only
|
|
197
|
+
// badge that says one thing on an artist's PWA and another on their website is
|
|
198
|
+
// the same bug twice.
|
|
199
|
+
export {
|
|
200
|
+
MEMBERSHIP_GATE_CODE,
|
|
201
|
+
MEMBERSHIP_GATE_LOGIN_PATH,
|
|
202
|
+
MEMBERSHIP_GATE_MEMBERSHIP_PATH,
|
|
203
|
+
parseMembershipGateError,
|
|
204
|
+
isMembershipGateLocked,
|
|
205
|
+
buildMembershipGateNotice,
|
|
206
|
+
joinTierNames,
|
|
207
|
+
membershipSignInHref,
|
|
208
|
+
type MembershipGateNotice as MembershipGateNoticeModel,
|
|
209
|
+
type MembershipGateRefusal,
|
|
210
|
+
type MembershipGateReason,
|
|
211
|
+
type PublicMembershipGate,
|
|
212
|
+
type BuildMembershipGateNoticeInput,
|
|
213
|
+
} from "./format/membershipGate";
|
|
214
|
+
export { MembershipGateNotice, type MembershipGateNoticeProps } from "./styled/MembershipGateNotice";
|
|
173
215
|
export { usePricesIncludeTax } from "../data/queries/useWebsite";
|
|
174
216
|
|
|
175
217
|
// PWA (installable code sites) — SW registration + install/push UX (Tier 2).
|
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
useUserOrders,
|
|
5
5
|
useMyTickets,
|
|
6
6
|
useCancelMyTicket,
|
|
7
|
-
|
|
7
|
+
myTicketHeldPassIds,
|
|
8
|
+
myTicketPasses,
|
|
8
9
|
type MyTicket,
|
|
9
10
|
useMyWaitlistPlaces,
|
|
10
11
|
useLeaveEventWaitlist,
|
|
@@ -34,6 +35,7 @@ import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
|
34
35
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
35
36
|
import { Loading } from "./Loading";
|
|
36
37
|
import { formatCountdown } from "./EventWaitlist";
|
|
38
|
+
import { AddToCalendar } from "./AddToCalendar";
|
|
37
39
|
import { WalletPassButtons } from "./WalletPassButtons";
|
|
38
40
|
import { TicketTransferPanel } from "./TicketTransfer";
|
|
39
41
|
import { CartLineOptions } from "./CartLineOptions";
|
|
@@ -340,6 +342,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
340
342
|
* come from the server, which reads the snapshot on the order; mirroring the
|
|
341
343
|
* date arithmetic in the UI would give two answers to the same question and the
|
|
342
344
|
* wrong one would be the one the buyer sees.
|
|
345
|
+
*
|
|
346
|
+
* ## Two kinds of row
|
|
347
|
+
*
|
|
348
|
+
* `role` says whether the caller PAID for this order or merely HOLDS a pass out
|
|
349
|
+
* of it — a ticket somebody transferred to them, which until now reached them
|
|
350
|
+
* nowhere at all. A holder's row is scrubbed server-side (no totals, no line
|
|
351
|
+
* prices, no other seats, no cancel), and this renders that difference rather
|
|
352
|
+
* than printing "$0.00" over somebody else's purchase.
|
|
343
353
|
*/
|
|
344
354
|
function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
345
355
|
const { user } = usePublicAuth();
|
|
@@ -381,6 +391,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
381
391
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
382
392
|
{tickets.map((ticket) => {
|
|
383
393
|
const cancelled = !!ticket.selfCancelledAt;
|
|
394
|
+
// Absent on an older API build, where every row WAS a purchase.
|
|
395
|
+
const gifted = ticket.role === "holder";
|
|
384
396
|
return (
|
|
385
397
|
<div
|
|
386
398
|
key={ticket.id}
|
|
@@ -392,6 +404,12 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
392
404
|
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
|
|
393
405
|
</div>
|
|
394
406
|
{cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
|
|
407
|
+
{/* Says WHY this is in the list at all. Without it a
|
|
408
|
+
transferred ticket looks like a purchase the buyer cannot
|
|
409
|
+
remember making, with no prices on it. */}
|
|
410
|
+
{!cancelled && gifted && (
|
|
411
|
+
<span style={{ fontSize: 13, color: t.primary }}>Sent to you</span>
|
|
412
|
+
)}
|
|
395
413
|
</div>
|
|
396
414
|
|
|
397
415
|
{ticket.items.map((item) => (
|
|
@@ -399,14 +417,34 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
399
417
|
<span>
|
|
400
418
|
{item.ticketTitle ?? "Ticket"} × {item.quantity}
|
|
401
419
|
</span>
|
|
402
|
-
|
|
420
|
+
{/* A holder is not shown a price, because the server did
|
|
421
|
+
not send one — `Number(null)` is 0, and a confident
|
|
422
|
+
"$0.00" on somebody else's purchase is a lie. */}
|
|
423
|
+
{!gifted && (
|
|
424
|
+
<span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
|
|
425
|
+
)}
|
|
403
426
|
</div>
|
|
404
427
|
))}
|
|
405
428
|
|
|
406
|
-
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
429
|
+
{!gifted && (
|
|
430
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
|
|
431
|
+
<span>Total</span>
|
|
432
|
+
<span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
|
|
433
|
+
</div>
|
|
434
|
+
)}
|
|
435
|
+
|
|
436
|
+
{/* Diarise it — including the Apple option, which needs the
|
|
437
|
+
platform's own `.ics` and so could not exist before. Null
|
|
438
|
+
for a called-off show, and the component draws nothing. */}
|
|
439
|
+
<AddToCalendar
|
|
440
|
+
variant="compact"
|
|
441
|
+
title={ticket.eventTitle}
|
|
442
|
+
start={ticket.eventDateTime}
|
|
443
|
+
end={ticket.eventEndDateTime}
|
|
444
|
+
icsUrl={ticket.calendarUrl}
|
|
445
|
+
webcalUrl={ticket.calendarWebcalUrl}
|
|
446
|
+
style={{ marginTop: 12 }}
|
|
447
|
+
/>
|
|
410
448
|
|
|
411
449
|
{/*
|
|
412
450
|
Wallet passes (2.3). Renders NOTHING unless the API offers a
|
|
@@ -419,7 +457,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
419
457
|
Eligibility (cancelled order, rotating QR) is the server's
|
|
420
458
|
call, exactly as it is for cancellation above.
|
|
421
459
|
*/}
|
|
422
|
-
<WalletPassButtons passIds={
|
|
460
|
+
<WalletPassButtons passIds={myTicketHeldPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
|
|
423
461
|
|
|
424
462
|
{/*
|
|
425
463
|
Transfer (2.2). The email a transfer sends points at
|
|
@@ -432,8 +470,19 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
432
470
|
404. Whether THIS pass may be handed on is the server's call,
|
|
433
471
|
answered on submit in its own words — not hidden behind a
|
|
434
472
|
guess here.
|
|
473
|
+
|
|
474
|
+
HELD passes only: the endpoints resolve the current holder, so
|
|
475
|
+
a control on a named guest's seat or on a ticket already
|
|
476
|
+
claimed by somebody else could answer nothing but 404. The
|
|
477
|
+
pass objects go through too, because each carries the caller's
|
|
478
|
+
own `pendingTransfer` — which is what lets a sender withdraw
|
|
479
|
+
from a device that is not the one they sent from.
|
|
435
480
|
*/}
|
|
436
|
-
<TicketTransferPanel
|
|
481
|
+
<TicketTransferPanel
|
|
482
|
+
passIds={myTicketHeldPassIds(ticket)}
|
|
483
|
+
passes={myTicketPasses(ticket)}
|
|
484
|
+
style={{ marginTop: 12 }}
|
|
485
|
+
/>
|
|
437
486
|
|
|
438
487
|
{/* The terms they agreed to, in the artist's own words where given. */}
|
|
439
488
|
{ticket.cancellation.description && (
|
|
@@ -735,6 +784,19 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
735
784
|
</div>
|
|
736
785
|
</div>
|
|
737
786
|
|
|
787
|
+
{/* Diarise it — including Apple, which needs the platform's own `.ics`.
|
|
788
|
+
Null unless the session is confirmed, and the component draws nothing. */}
|
|
789
|
+
<AddToCalendar
|
|
790
|
+
variant="compact"
|
|
791
|
+
title={booking.coachingProductTitle}
|
|
792
|
+
start={booking.sessionStartTime}
|
|
793
|
+
end={booking.sessionEndTime}
|
|
794
|
+
defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
|
|
795
|
+
icsUrl={booking.calendarUrl}
|
|
796
|
+
webcalUrl={booking.calendarWebcalUrl}
|
|
797
|
+
style={{ marginTop: 12 }}
|
|
798
|
+
/>
|
|
799
|
+
|
|
738
800
|
{/* The terms they booked under, in the artist's own words where given. */}
|
|
739
801
|
{booking.cancellation.description && (
|
|
740
802
|
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{booking.cancellation.description}</div>
|
|
@@ -17,9 +17,30 @@ export interface AddToCalendarProps extends AddToCalendarInput {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* "Add to calendar" — deep links that prefill Google / Outlook / Microsoft 365,
|
|
21
|
+
* plus the two Apple-shaped options once the host supplies the platform's
|
|
22
|
+
* `.ics` address.
|
|
23
|
+
*
|
|
24
|
+
* ## Why Apple needs two entries and the others need none
|
|
25
|
+
*
|
|
26
|
+
* Google and Microsoft publish TEMPLATE URLs: the whole event travels in the
|
|
27
|
+
* query string and no server is involved. Apple publishes nothing of the kind —
|
|
28
|
+
* Calendar takes a FILE. So for as long as this component had no `.ics` to
|
|
29
|
+
* point at, an iPhone user had no option at all, which is precisely the state
|
|
30
|
+
* the product shipped in.
|
|
31
|
+
*
|
|
32
|
+
* - **Apple Calendar** → `webcal://`. The scheme is what iOS and macOS route
|
|
33
|
+
* to the Calendar app; it must be a plain same-window navigation, so it
|
|
34
|
+
* carries neither `target="_blank"` (the OS handler, not a tab) nor
|
|
35
|
+
* `download` (there is no file to save — the OS is opening an app).
|
|
36
|
+
* - **Download .ics** → the https address, with `download`. Outlook desktop,
|
|
37
|
+
* Thunderbird, Android calendar apps and anyone who would simply rather have
|
|
38
|
+
* the file.
|
|
39
|
+
*
|
|
40
|
+
* Both are offered because `webcal:` has no handler on much of the desktop web,
|
|
41
|
+
* and a lone Apple button on Windows is a dead link. Neither appears at all
|
|
42
|
+
* when the host passes no URL — a site pinned to an older API build degrades to
|
|
43
|
+
* exactly the three-provider row it had before.
|
|
23
44
|
*
|
|
24
45
|
* Purely presentational: every URL comes from `useAddToCalendar`. Renders
|
|
25
46
|
* nothing at all when the start time is missing or unparseable.
|
|
@@ -38,12 +59,14 @@ export function AddToCalendar({
|
|
|
38
59
|
|
|
39
60
|
const compact = variant === "compact";
|
|
40
61
|
|
|
41
|
-
const options: { key: string; text: string; href: string; download?: boolean }[] = [
|
|
42
|
-
{ key: "google", text: "Google", href: links.google },
|
|
43
|
-
{ key: "outlook", text: "Outlook", href: links.outlook },
|
|
44
|
-
{ key: "office365", text: "Microsoft 365", href: links.office365 },
|
|
62
|
+
const options: { key: string; text: string; href: string; download?: boolean; newTab?: boolean }[] = [
|
|
63
|
+
{ key: "google", text: "Google", href: links.google, newTab: true },
|
|
64
|
+
{ key: "outlook", text: "Outlook", href: links.outlook, newTab: true },
|
|
65
|
+
{ key: "office365", text: "Microsoft 365", href: links.office365, newTab: true },
|
|
45
66
|
];
|
|
46
|
-
|
|
67
|
+
// Apple first among the file-based options: it is the one a phone will use.
|
|
68
|
+
if (links.webcal) options.push({ key: "apple", text: "Apple Calendar", href: links.webcal });
|
|
69
|
+
if (links.ics) options.push({ key: "ics", text: "Download .ics", href: links.ics, download: true });
|
|
47
70
|
|
|
48
71
|
const chip: React.CSSProperties = {
|
|
49
72
|
display: "inline-flex",
|
|
@@ -89,8 +112,9 @@ export function AddToCalendar({
|
|
|
89
112
|
<a
|
|
90
113
|
key={o.key}
|
|
91
114
|
href={o.href}
|
|
92
|
-
|
|
93
|
-
rel
|
|
115
|
+
// `webcal:` hands off to the OS — a new tab would leave a blank one
|
|
116
|
+
// behind, and `rel` is meaningless for a non-http scheme.
|
|
117
|
+
{...(o.newTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
94
118
|
data-testid={`add-to-calendar-${o.key}`}
|
|
95
119
|
{...(o.download ? { download: "" } : {})}
|
|
96
120
|
style={chip}
|
|
@@ -12,6 +12,7 @@ import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
|
12
12
|
import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
|
|
13
13
|
import { Loading } from "./Loading";
|
|
14
14
|
import { CartLineOptions } from "./CartLineOptions";
|
|
15
|
+
import { HoldNotice } from "./HoldNotice";
|
|
15
16
|
|
|
16
17
|
export interface CheckoutProps {
|
|
17
18
|
/** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
|
|
@@ -814,6 +815,7 @@ function PaymentSection({
|
|
|
814
815
|
const { formatCurrency } = useFormatCurrency();
|
|
815
816
|
const renderPay = usePaymentRenderer();
|
|
816
817
|
const { paymentSecret, chargedAmount, chargedCurrency, returnUrl, isPaystack, isCreatingOrder, startError } = checkout;
|
|
818
|
+
const { hold, holdExpiredError, isHoldExpired, retryHold, isRetryingHold } = checkout;
|
|
817
819
|
|
|
818
820
|
// Paystack has no in-app form — send the buyer to the hosted checkout as soon
|
|
819
821
|
// as the redirect URL (paymentSecret) is ready. The button below is a manual
|
|
@@ -880,7 +882,22 @@ function PaymentSection({
|
|
|
880
882
|
</p>
|
|
881
883
|
)}
|
|
882
884
|
|
|
883
|
-
{
|
|
885
|
+
{/* The reservation — a countdown while it runs, and at zero (or on a 409)
|
|
886
|
+
the lapsed state with the retry that re-acquires the same units.
|
|
887
|
+
Renders nothing at all when no hold was taken, which is the ordinary
|
|
888
|
+
case for the profiles that have inventory holds switched off. */}
|
|
889
|
+
<HoldNotice
|
|
890
|
+
hold={hold}
|
|
891
|
+
expiredMessage={holdExpiredError || undefined}
|
|
892
|
+
onRetry={retryHold}
|
|
893
|
+
isRetrying={isRetryingHold}
|
|
894
|
+
secondaryAction={onBack ? { label: "Change your order", onClick: onBack } : undefined}
|
|
895
|
+
/>
|
|
896
|
+
|
|
897
|
+
{/* Suppressed once the server has refused: there is no intent coming, so a
|
|
898
|
+
"Preparing payment…" spinner would sit there forever under a notice
|
|
899
|
+
that has already explained what happened. */}
|
|
900
|
+
{isCreatingOrder && !paymentSecret && !isHoldExpired && <Loading label="Preparing payment…" size={22} />}
|
|
884
901
|
|
|
885
902
|
{isPaystack
|
|
886
903
|
? paymentSecret &&
|
|
@@ -173,6 +173,10 @@ export function CoachingConfirmation({
|
|
|
173
173
|
start={data.sessionStartTime}
|
|
174
174
|
end={data.sessionEndTime}
|
|
175
175
|
defaultDurationMinutes={data.durationMinutes ?? undefined}
|
|
176
|
+
// The finalise response carries these only for a CONFIRMED
|
|
177
|
+
// booking, which is the same condition this block renders under.
|
|
178
|
+
icsUrl={data.calendarUrl}
|
|
179
|
+
webcalUrl={data.calendarWebcalUrl}
|
|
176
180
|
style={{ marginTop: 18 }}
|
|
177
181
|
/>
|
|
178
182
|
)}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PublicCourse } from "../../types/models";
|
|
2
2
|
import { useGetCourse } from "../../data/queries/useCourses";
|
|
3
|
+
import { MembershipGateNotice } from "./MembershipGateNotice";
|
|
3
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
6
|
import { PriceDisplay } from "../format/PriceDisplay";
|
|
@@ -23,6 +24,10 @@ export interface CourseDetailProps {
|
|
|
23
24
|
finalisePath?: (slug: string, bookingId: string) => string;
|
|
24
25
|
/** Host-owned navigation on FREE completion (forwarded to the enroll flow). */
|
|
25
26
|
onComplete?: (info: { slug: string; bookingId: string }) => void;
|
|
27
|
+
/** Where a members-only course sends a signed-OUT visitor. Default `/login`. */
|
|
28
|
+
loginPath?: string;
|
|
29
|
+
/** Where a members-only course sends a signed-in NON-member. Default `/membership`. */
|
|
30
|
+
membershipPath?: string;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export function CourseDetail({
|
|
@@ -33,6 +38,8 @@ export function CourseDetail({
|
|
|
33
38
|
initialCourse,
|
|
34
39
|
finalisePath,
|
|
35
40
|
onComplete,
|
|
41
|
+
loginPath,
|
|
42
|
+
membershipPath,
|
|
36
43
|
}: CourseDetailProps) {
|
|
37
44
|
const t = useThemeTokens();
|
|
38
45
|
const fmt = useAmountFormatter(formatAmount);
|
|
@@ -42,6 +49,11 @@ export function CourseDetail({
|
|
|
42
49
|
if (isLoading) return <Loading fullPage />;
|
|
43
50
|
if (!course) return <p>Course not found.</p>;
|
|
44
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Members-only course (S.4). Announced by the detail read, which is why the
|
|
54
|
+
* page can say so BEFORE the buyer opens a checkout that would refuse them.
|
|
55
|
+
*/
|
|
56
|
+
const isGated = !!course.membershipGate && !course.membershipGate.allowed;
|
|
45
57
|
const cover = course.media?.find((m) => m.type === "image")?.url ?? course.media?.[0]?.url;
|
|
46
58
|
const introVideo = course.media?.find((m) => m.type === "video")?.url;
|
|
47
59
|
const modules = course.modules ?? [];
|
|
@@ -129,7 +141,24 @@ export function CourseDetail({
|
|
|
129
141
|
<PriceDisplay amount={Number(course.price)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={t.text} captionStyle={{ opacity: 0.65 }} />
|
|
130
142
|
)}
|
|
131
143
|
</span>
|
|
132
|
-
|
|
144
|
+
{/*
|
|
145
|
+
A live gate locks the course even for someone who already bought it —
|
|
146
|
+
"this course is part of Gold" is a CONDITION for access, not a
|
|
147
|
+
one-off unlock — so a lapsed member lands here too and needs the way
|
|
148
|
+
back in, not a purchase button that will be refused.
|
|
149
|
+
*/}
|
|
150
|
+
{isGated ? (
|
|
151
|
+
<MembershipGateNotice
|
|
152
|
+
gate={course.membershipGate}
|
|
153
|
+
variant="card"
|
|
154
|
+
itemLabel="This course"
|
|
155
|
+
loginPath={loginPath}
|
|
156
|
+
membershipPath={membershipPath}
|
|
157
|
+
style={{ padding: 0, border: "none", background: "transparent" }}
|
|
158
|
+
/>
|
|
159
|
+
) : (
|
|
160
|
+
<CourseCheckout slug={slug} formatAmount={fmt} finalisePath={finalisePath} onComplete={onComplete} />
|
|
161
|
+
)}
|
|
133
162
|
</aside>
|
|
134
163
|
</div>
|
|
135
164
|
|