@tribe-nest/forge 3.21.0 → 3.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- package/src/utils/ticketOrderOutcome.ts +1 -1
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -9,12 +9,19 @@ import {
|
|
|
9
9
|
DialogTitle,
|
|
10
10
|
} from "../dialog";
|
|
11
11
|
import { OfferProvider, useOfferContext, type OfferProviderProps } from "./OfferContext";
|
|
12
|
+
import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
|
|
12
13
|
|
|
13
14
|
/** Render-prop payload for `<Offer.PaymentStep>` (redirect-mode payment). */
|
|
14
15
|
export interface OfferPaymentRenderProps {
|
|
16
|
+
/** STRIPE: the PaymentIntent client secret. Undefined on Paystack. */
|
|
15
17
|
clientSecret?: string;
|
|
16
18
|
returnUrl: string;
|
|
17
19
|
amount: number;
|
|
20
|
+
/** PAYSTACK: render a pay button instead of a card form. */
|
|
21
|
+
isPaystack: boolean;
|
|
22
|
+
canOpenPaystack: boolean;
|
|
23
|
+
/** Opens (or re-opens, after a dismissal) the Paystack modal. */
|
|
24
|
+
openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
type OfferComponent = ((props: OfferProviderProps) => React.ReactElement) & {
|
|
@@ -82,7 +89,18 @@ Offer.PaymentStep = function OfferPaymentStep({ children }) {
|
|
|
82
89
|
const ctx = useOfferContext();
|
|
83
90
|
if (ctx.step !== "payment") return null;
|
|
84
91
|
if (typeof children === "function") {
|
|
85
|
-
return
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{children({
|
|
95
|
+
clientSecret: ctx.clientSecret,
|
|
96
|
+
returnUrl: ctx.returnUrl,
|
|
97
|
+
amount: ctx.totalPrice,
|
|
98
|
+
isPaystack: ctx.isPaystack,
|
|
99
|
+
canOpenPaystack: ctx.canOpenPaystack,
|
|
100
|
+
openPaystackCheckout: ctx.openPaystackCheckout,
|
|
101
|
+
})}
|
|
102
|
+
</>
|
|
103
|
+
);
|
|
86
104
|
}
|
|
87
105
|
return <>{children}</>;
|
|
88
106
|
};
|
|
@@ -3,6 +3,9 @@ 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";
|
|
8
|
+
import type { PaystackCheckoutOutcome } from "../../../utils/paystackCheckout";
|
|
6
9
|
|
|
7
10
|
export type OfferStep = "details" | "payment";
|
|
8
11
|
|
|
@@ -34,10 +37,26 @@ export interface OfferContextValue {
|
|
|
34
37
|
|
|
35
38
|
isProcessing: boolean;
|
|
36
39
|
error: string | null;
|
|
37
|
-
/** Payment intent once the order is created (
|
|
40
|
+
/** Payment intent once the order is created (STRIPE only). */
|
|
38
41
|
clientSecret?: string;
|
|
42
|
+
/** True once the charge started on Paystack rather than Stripe. */
|
|
43
|
+
isPaystack: boolean;
|
|
44
|
+
/** True once there is a Paystack session a pay button can (re-)open. */
|
|
45
|
+
canOpenPaystack: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Re-open the Paystack modal on the SAME charge. A buyer who closed it has
|
|
48
|
+
* not failed, and the order is still sitting there waiting to be paid.
|
|
49
|
+
*/
|
|
50
|
+
openPaystackCheckout: () => Promise<PaystackCheckoutOutcome>;
|
|
39
51
|
returnUrl: string;
|
|
40
52
|
continueToPayment: () => Promise<void>;
|
|
53
|
+
/** The stock reservation held while the buyer pays. All-null when none was taken. */
|
|
54
|
+
hold: InventoryHoldState;
|
|
55
|
+
/** The server's 409 `INVENTORY_HOLD_EXPIRED` message — never sold-out. */
|
|
56
|
+
holdExpiredError: string | null;
|
|
57
|
+
/** Re-take the reservation on the same order. */
|
|
58
|
+
retryHold: () => Promise<void>;
|
|
59
|
+
isRetryingHold: boolean;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
const OfferContext = createContext<OfferContextValue | null>(null);
|
|
@@ -72,6 +91,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
72
91
|
const [confirmEmail, setConfirmEmail] = useState("");
|
|
73
92
|
const [error, setError] = useState<string | null>(null);
|
|
74
93
|
const [returnUrl, setReturnUrl] = useState("");
|
|
94
|
+
const [orderId, setOrderId] = useState<string | null>(null);
|
|
95
|
+
/** The window the ORDER reported, until start-payment restarts it. */
|
|
96
|
+
const [orderHoldExpiresAt, setOrderHoldExpiresAt] = useState<string | null>(null);
|
|
97
|
+
const [holdExpiredError, setHoldExpiredError] = useState<string | null>(null);
|
|
75
98
|
|
|
76
99
|
const selectedVariant = useMemo(
|
|
77
100
|
() => variants.find((v) => v.id === selectedVariantId) ?? variants[0],
|
|
@@ -82,6 +105,7 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
82
105
|
|
|
83
106
|
const continueToPayment = async () => {
|
|
84
107
|
setError(null);
|
|
108
|
+
setHoldExpiredError(null);
|
|
85
109
|
if (!product || !selectedVariant) return;
|
|
86
110
|
if (!firstName.trim() || !lastName.trim()) {
|
|
87
111
|
setError("Please enter your name.");
|
|
@@ -114,6 +138,10 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
114
138
|
});
|
|
115
139
|
const ru = `${typeof window !== "undefined" ? window.location.origin : ""}${finalisePath ?? "/checkout/finalise"}?orderId=${created.orderId}`;
|
|
116
140
|
setReturnUrl(ru);
|
|
141
|
+
setOrderId(created.orderId);
|
|
142
|
+
// Absent/null for a digital item, a free order, or a profile with holds
|
|
143
|
+
// switched off — every one of which must render no countdown at all.
|
|
144
|
+
setOrderHoldExpiresAt(created.holdExpiresAt ?? null);
|
|
117
145
|
// Free checkout — skip payment.
|
|
118
146
|
if (created.subTotal === 0 && typeof window !== "undefined") {
|
|
119
147
|
window.location.href = ru;
|
|
@@ -124,11 +152,44 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
124
152
|
if (result.provider && result.provider !== "stripe") return;
|
|
125
153
|
setStep("payment");
|
|
126
154
|
} catch (e) {
|
|
155
|
+
// A lapsed reservation gets its own state so the buyer is offered the
|
|
156
|
+
// retry that re-acquires the same unit, rather than a bare error that
|
|
157
|
+
// reads like the item is gone.
|
|
158
|
+
if (isHoldExpiredError(e)) {
|
|
159
|
+
setHoldExpiredError(
|
|
160
|
+
holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
127
164
|
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
128
165
|
setError(message || "Something went wrong.");
|
|
129
166
|
}
|
|
130
167
|
};
|
|
131
168
|
|
|
169
|
+
/** Take the same unit again on the same order. */
|
|
170
|
+
const retryHold = async () => {
|
|
171
|
+
if (!orderId || !returnUrl) return;
|
|
172
|
+
setHoldExpiredError(null);
|
|
173
|
+
try {
|
|
174
|
+
const result = await flow.start({ orderId, returnUrl });
|
|
175
|
+
if (result.provider && result.provider !== "stripe") return; // Paystack redirected
|
|
176
|
+
setStep("payment");
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (isHoldExpiredError(e)) {
|
|
179
|
+
setHoldExpiredError(
|
|
180
|
+
holdExpiredMessage(e, "Your reservation expired before payment finished. Please try again."),
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const message = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
185
|
+
setError(message || "Something went wrong.");
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// start-payment restarts the clock and reports the newer instant, so it wins
|
|
190
|
+
// over the create-time one.
|
|
191
|
+
const hold = useInventoryHold(flow.result?.holdExpiresAt ?? orderHoldExpiresAt);
|
|
192
|
+
|
|
132
193
|
const value: OfferContextValue = {
|
|
133
194
|
product,
|
|
134
195
|
isLoading,
|
|
@@ -154,8 +215,15 @@ export function OfferProvider({ productId, children, finalisePath }: OfferProvid
|
|
|
154
215
|
isProcessing: createOrder.isPending || flow.isStarting,
|
|
155
216
|
error,
|
|
156
217
|
clientSecret: flow.result?.paymentSecret,
|
|
218
|
+
isPaystack: flow.isPaystack,
|
|
219
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
220
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
157
221
|
returnUrl,
|
|
158
222
|
continueToPayment,
|
|
223
|
+
hold,
|
|
224
|
+
holdExpiredError,
|
|
225
|
+
retryHold,
|
|
226
|
+
isRetryingHold: flow.isStarting,
|
|
159
227
|
};
|
|
160
228
|
|
|
161
229
|
return <OfferContext.Provider value={value}>{children}</OfferContext.Provider>;
|
|
@@ -11,7 +11,9 @@ export interface UsePaymentLinkPaymentOptions {
|
|
|
11
11
|
/**
|
|
12
12
|
* Headless payment-link checkout: loads the link and starts its payment.
|
|
13
13
|
* Composes `usePaymentLink` + `usePaymentFlow`. Stripe UI renders from
|
|
14
|
-
* `clientSecret`;
|
|
14
|
+
* `clientSecret`; Paystack opens its inline modal (and `openPaystackCheckout`
|
|
15
|
+
* re-opens it after a dismissal). Finalize on the return page with
|
|
16
|
+
* `usePaymentLinkFinalize`.
|
|
15
17
|
*/
|
|
16
18
|
export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLinkPaymentOptions = {}) {
|
|
17
19
|
const link = usePaymentLink(paymentLinkId);
|
|
@@ -34,6 +36,10 @@ export function usePaymentLinkPayment(paymentLinkId?: string, opts: UsePaymentLi
|
|
|
34
36
|
clientSecret: flow.clientSecret,
|
|
35
37
|
paymentId: flow.paymentId,
|
|
36
38
|
provider: flow.provider,
|
|
39
|
+
/** Paystack: the started inline-checkout session and the way back into it. */
|
|
40
|
+
isPaystack: flow.isPaystack,
|
|
41
|
+
canOpenPaystack: flow.canOpenPaystack,
|
|
42
|
+
openPaystackCheckout: flow.openPaystackCheckout,
|
|
37
43
|
/** Authoritative sales-tax quote from start-payment (display only). */
|
|
38
44
|
taxQuote: flow.result?.taxQuote ?? null,
|
|
39
45
|
isStarting: flow.isStarting,
|
|
@@ -343,10 +343,10 @@ export function formatWorkMoney(amount: number, currency = "USD"): string {
|
|
|
343
343
|
return `${symbol}${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
-
/** ISO date → short human date, or "
|
|
346
|
+
/** ISO date → short human date, or "Not set" when absent. */
|
|
347
347
|
export function formatWorkDate(value: string | null | undefined): string {
|
|
348
|
-
if (!value) return "
|
|
348
|
+
if (!value) return "Not set";
|
|
349
349
|
const d = new Date(value);
|
|
350
|
-
if (Number.isNaN(d.getTime())) return "
|
|
350
|
+
if (Number.isNaN(d.getTime())) return "Not set";
|
|
351
351
|
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
|
352
352
|
}
|
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,9 @@ 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";
|
|
115
|
+
export { PaystackPayButton, type PaystackPayButtonProps } from "./styled/PaystackPayButton";
|
|
112
116
|
export {
|
|
113
117
|
ConfirmationStage,
|
|
114
118
|
ConfirmationCard,
|
|
@@ -170,6 +174,66 @@ export {
|
|
|
170
174
|
resolveUnitPrice,
|
|
171
175
|
ticketSubtotals,
|
|
172
176
|
} from "./format/pwyw";
|
|
177
|
+
// When a tier may be BOUGHT, for a site rendering its own ticket UI. Both halves
|
|
178
|
+
// close a "discoverable only by failing" journey: `ticketSeatsAvailable`
|
|
179
|
+
// subtracts `held` the way the server does, so a fan is never shown seats that
|
|
180
|
+
// are already in someone else's checkout; `ticketSaleWindow` surfaces a
|
|
181
|
+
// scheduled on-sale, which shipped enforced and drawn nowhere. Presentation
|
|
182
|
+
// only — `createOrder` re-checks both.
|
|
183
|
+
export {
|
|
184
|
+
ticketSeatsAvailable,
|
|
185
|
+
ticketSeatsUnsold,
|
|
186
|
+
ticketSeatState,
|
|
187
|
+
ticketSeatLabel,
|
|
188
|
+
ticketSeatNotice,
|
|
189
|
+
ticketSaleWindow,
|
|
190
|
+
isTicketScheduled,
|
|
191
|
+
formatOnSaleAt,
|
|
192
|
+
onSaleBadge,
|
|
193
|
+
onSaleNotice,
|
|
194
|
+
maxAddableTickets,
|
|
195
|
+
type TicketSeatState,
|
|
196
|
+
type TicketSaleWindow,
|
|
197
|
+
} from "./format/ticketAvailability";
|
|
198
|
+
// Per-attendee names at checkout. Exported for a site rendering its own ticket
|
|
199
|
+
// UI — and used by `apps/client`, which has its own event checkout: the request
|
|
200
|
+
// shape is positional per tier and a length mismatch is refused server-side, so
|
|
201
|
+
// neither stack derives it for itself.
|
|
202
|
+
export {
|
|
203
|
+
MAX_ATTENDEE_NAME_LENGTH,
|
|
204
|
+
normalizeAttendeeName,
|
|
205
|
+
collectsAttendees,
|
|
206
|
+
buyerFullName,
|
|
207
|
+
buildAttendeeSlots,
|
|
208
|
+
setAttendeeName,
|
|
209
|
+
validateAttendeeNames,
|
|
210
|
+
attendeesPayload,
|
|
211
|
+
type AttendeeNames,
|
|
212
|
+
type AttendeeSlot,
|
|
213
|
+
type AttendeeCheck,
|
|
214
|
+
type AttendeeProblem,
|
|
215
|
+
} from "./format/attendees";
|
|
216
|
+
// Members-only gates (S.4). The wording and the resolving ACTION are decided
|
|
217
|
+
// purely, in one place, because BOTH rendering stacks draw this — `apps/client`
|
|
218
|
+
// imports these directly, exactly as it does the PWYW arithmetic. A members-only
|
|
219
|
+
// badge that says one thing on an artist's PWA and another on their website is
|
|
220
|
+
// the same bug twice.
|
|
221
|
+
export {
|
|
222
|
+
MEMBERSHIP_GATE_CODE,
|
|
223
|
+
MEMBERSHIP_GATE_LOGIN_PATH,
|
|
224
|
+
MEMBERSHIP_GATE_MEMBERSHIP_PATH,
|
|
225
|
+
parseMembershipGateError,
|
|
226
|
+
isMembershipGateLocked,
|
|
227
|
+
buildMembershipGateNotice,
|
|
228
|
+
joinTierNames,
|
|
229
|
+
membershipSignInHref,
|
|
230
|
+
type MembershipGateNotice as MembershipGateNoticeModel,
|
|
231
|
+
type MembershipGateRefusal,
|
|
232
|
+
type MembershipGateReason,
|
|
233
|
+
type PublicMembershipGate,
|
|
234
|
+
type BuildMembershipGateNoticeInput,
|
|
235
|
+
} from "./format/membershipGate";
|
|
236
|
+
export { MembershipGateNotice, type MembershipGateNoticeProps } from "./styled/MembershipGateNotice";
|
|
173
237
|
export { usePricesIncludeTax } from "../data/queries/useWebsite";
|
|
174
238
|
|
|
175
239
|
// PWA (installable code sites) — SW registration + install/push UX (Tier 2).
|
|
@@ -65,13 +65,13 @@ const WHAT_LABEL: Record<string, string> = {
|
|
|
65
65
|
|
|
66
66
|
/** Human copy for the banner. Pure — no React, no formatting concerns. */
|
|
67
67
|
export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagnosticsMessage {
|
|
68
|
-
const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what}
|
|
68
|
+
const details = d.failures.map((f) => `Couldn't load the ${WHAT_LABEL[f.what] ?? f.what}: ${f.error}.`);
|
|
69
69
|
|
|
70
70
|
if (d.probe) {
|
|
71
71
|
details.push(
|
|
72
72
|
d.probe.ok
|
|
73
73
|
? `The API answered ${d.probe.url} in ${d.probe.ms}ms, so this looks like a per-route failure rather than a network one.`
|
|
74
|
-
: `Couldn't reach ${d.probe.url} either
|
|
74
|
+
: `Couldn't reach ${d.probe.url} either: ${d.probe.error ?? `HTTP ${d.probe.status}`} after ${d.probe.ms}ms. This preview has no route to the API.`,
|
|
75
75
|
);
|
|
76
76
|
}
|
|
77
77
|
if (d.apiUrl) details.push(`API: ${d.apiUrl}`);
|
|
@@ -84,7 +84,7 @@ export function previewDiagnosticsMessage(d: ForgeSsrDiagnostics): PreviewDiagno
|
|
|
84
84
|
// The sentence that would have saved the debugging session this was built
|
|
85
85
|
// after: the defaults on screen are a symptom, not the setting.
|
|
86
86
|
consequence:
|
|
87
|
-
"You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived
|
|
87
|
+
"You're seeing Forge's default theme (white background, purple accent) and empty content because the data never arrived, not because of how your site is configured. Your published site is unaffected.",
|
|
88
88
|
details,
|
|
89
89
|
};
|
|
90
90
|
}
|
|
@@ -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";
|
|
@@ -225,6 +227,7 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
225
227
|
const { formatAmount, formatDate, currency, onNavigateMembership } = ctx;
|
|
226
228
|
const cancelMembership = useCancelMembership();
|
|
227
229
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
230
|
+
const [cancelError, setCancelError] = useState<string | null>(null);
|
|
228
231
|
|
|
229
232
|
const membership = user?.membership;
|
|
230
233
|
// Two separate questions, deliberately answered separately:
|
|
@@ -236,12 +239,20 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
236
239
|
const openBillingPortal = useOpenBillingPortal();
|
|
237
240
|
const [billingError, setBillingError] = useState<string | null>(null);
|
|
238
241
|
|
|
242
|
+
// A failure here used to be swallowed whole: the button said "Cancelling…",
|
|
243
|
+
// then said "Cancel" again, and the member was never told anything. That is
|
|
244
|
+
// how a free-tier member — whose cancel the server rejected outright — could
|
|
245
|
+
// press this forever with no effect and no explanation.
|
|
239
246
|
const onCancel = async () => {
|
|
240
247
|
if (!membership?.id) return;
|
|
248
|
+
setCancelError(null);
|
|
241
249
|
setIsCancelling(true);
|
|
242
250
|
try {
|
|
243
251
|
await cancelMembership.mutateAsync({ membershipId: membership.id });
|
|
244
252
|
window.location.reload();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
255
|
+
setCancelError(detail || "We couldn't cancel your membership. Please try again or contact us.");
|
|
245
256
|
} finally {
|
|
246
257
|
setIsCancelling(false);
|
|
247
258
|
}
|
|
@@ -300,6 +311,11 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
300
311
|
</p>
|
|
301
312
|
)}
|
|
302
313
|
{billingError && <p style={{ fontSize: 13, opacity: 0.9 }}>{billingError}</p>}
|
|
314
|
+
{cancelError && (
|
|
315
|
+
<p role="alert" style={{ fontSize: 13, opacity: 0.9, color: t.primary }}>
|
|
316
|
+
{cancelError}
|
|
317
|
+
</p>
|
|
318
|
+
)}
|
|
303
319
|
</div>
|
|
304
320
|
) : (
|
|
305
321
|
<p style={{ opacity: 0.8 }}>You don't have an active membership.</p>
|
|
@@ -313,8 +329,9 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
313
329
|
)}
|
|
314
330
|
{/* Cancelling stays available for anyone who still HAS access — a
|
|
315
331
|
past_due member must not be trapped in a subscription they can see
|
|
316
|
-
but not leave.
|
|
317
|
-
|
|
332
|
+
but not leave. Hidden once it is already scheduled ("Ending"), where
|
|
333
|
+
the only honest label would be "Cancel again". */}
|
|
334
|
+
{access.hasAccess && !access.cancelAtPeriodEnd && (
|
|
318
335
|
<button onClick={onCancel} disabled={isCancelling} style={ghostButton}>
|
|
319
336
|
{isCancelling ? "Cancelling…" : "Cancel"}
|
|
320
337
|
</button>
|
|
@@ -340,6 +357,14 @@ function MembershipTab({ ctx }: { ctx: TabContext }) {
|
|
|
340
357
|
* come from the server, which reads the snapshot on the order; mirroring the
|
|
341
358
|
* date arithmetic in the UI would give two answers to the same question and the
|
|
342
359
|
* wrong one would be the one the buyer sees.
|
|
360
|
+
*
|
|
361
|
+
* ## Two kinds of row
|
|
362
|
+
*
|
|
363
|
+
* `role` says whether the caller PAID for this order or merely HOLDS a pass out
|
|
364
|
+
* of it — a ticket somebody transferred to them, which until now reached them
|
|
365
|
+
* nowhere at all. A holder's row is scrubbed server-side (no totals, no line
|
|
366
|
+
* prices, no other seats, no cancel), and this renders that difference rather
|
|
367
|
+
* than printing "$0.00" over somebody else's purchase.
|
|
343
368
|
*/
|
|
344
369
|
function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
345
370
|
const { user } = usePublicAuth();
|
|
@@ -381,6 +406,8 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
381
406
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
|
382
407
|
{tickets.map((ticket) => {
|
|
383
408
|
const cancelled = !!ticket.selfCancelledAt;
|
|
409
|
+
// Absent on an older API build, where every row WAS a purchase.
|
|
410
|
+
const gifted = ticket.role === "holder";
|
|
384
411
|
return (
|
|
385
412
|
<div
|
|
386
413
|
key={ticket.id}
|
|
@@ -392,6 +419,12 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
392
419
|
<div style={{ fontSize: 13, opacity: 0.7 }}>{formatDate(ticket.eventDateTime)}</div>
|
|
393
420
|
</div>
|
|
394
421
|
{cancelled && <span style={{ fontSize: 13, color: t.primary }}>Cancelled</span>}
|
|
422
|
+
{/* Says WHY this is in the list at all. Without it a
|
|
423
|
+
transferred ticket looks like a purchase the buyer cannot
|
|
424
|
+
remember making, with no prices on it. */}
|
|
425
|
+
{!cancelled && gifted && (
|
|
426
|
+
<span style={{ fontSize: 13, color: t.primary }}>Sent to you</span>
|
|
427
|
+
)}
|
|
395
428
|
</div>
|
|
396
429
|
|
|
397
430
|
{ticket.items.map((item) => (
|
|
@@ -399,14 +432,34 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
399
432
|
<span>
|
|
400
433
|
{item.ticketTitle ?? "Ticket"} × {item.quantity}
|
|
401
434
|
</span>
|
|
402
|
-
|
|
435
|
+
{/* A holder is not shown a price, because the server did
|
|
436
|
+
not send one — `Number(null)` is 0, and a confident
|
|
437
|
+
"$0.00" on somebody else's purchase is a lie. */}
|
|
438
|
+
{!gifted && (
|
|
439
|
+
<span>{formatAmount(Number(item.price) * item.quantity, ticket.currency || currency)}</span>
|
|
440
|
+
)}
|
|
403
441
|
</div>
|
|
404
442
|
))}
|
|
405
443
|
|
|
406
|
-
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
444
|
+
{!gifted && (
|
|
445
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontWeight: 700, marginTop: 12, paddingTop: 12, borderTop: `1px solid ${t.primary}20` }}>
|
|
446
|
+
<span>Total</span>
|
|
447
|
+
<span style={{ color: t.primary }}>{formatAmount(Number(ticket.totalAmount), ticket.currency || currency)}</span>
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{/* Diarise it — including the Apple option, which needs the
|
|
452
|
+
platform's own `.ics` and so could not exist before. Null
|
|
453
|
+
for a called-off show, and the component draws nothing. */}
|
|
454
|
+
<AddToCalendar
|
|
455
|
+
variant="compact"
|
|
456
|
+
title={ticket.eventTitle}
|
|
457
|
+
start={ticket.eventDateTime}
|
|
458
|
+
end={ticket.eventEndDateTime}
|
|
459
|
+
icsUrl={ticket.calendarUrl}
|
|
460
|
+
webcalUrl={ticket.calendarWebcalUrl}
|
|
461
|
+
style={{ marginTop: 12 }}
|
|
462
|
+
/>
|
|
410
463
|
|
|
411
464
|
{/*
|
|
412
465
|
Wallet passes (2.3). Renders NOTHING unless the API offers a
|
|
@@ -419,7 +472,7 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
419
472
|
Eligibility (cancelled order, rotating QR) is the server's
|
|
420
473
|
call, exactly as it is for cancellation above.
|
|
421
474
|
*/}
|
|
422
|
-
<WalletPassButtons passIds={
|
|
475
|
+
<WalletPassButtons passIds={myTicketHeldPassIds(ticket)} variant="compact" style={{ marginTop: 12 }} />
|
|
423
476
|
|
|
424
477
|
{/*
|
|
425
478
|
Transfer (2.2). The email a transfer sends points at
|
|
@@ -432,8 +485,19 @@ function TicketsTab({ ctx }: { ctx: TabContext }) {
|
|
|
432
485
|
404. Whether THIS pass may be handed on is the server's call,
|
|
433
486
|
answered on submit in its own words — not hidden behind a
|
|
434
487
|
guess here.
|
|
488
|
+
|
|
489
|
+
HELD passes only: the endpoints resolve the current holder, so
|
|
490
|
+
a control on a named guest's seat or on a ticket already
|
|
491
|
+
claimed by somebody else could answer nothing but 404. The
|
|
492
|
+
pass objects go through too, because each carries the caller's
|
|
493
|
+
own `pendingTransfer` — which is what lets a sender withdraw
|
|
494
|
+
from a device that is not the one they sent from.
|
|
435
495
|
*/}
|
|
436
|
-
<TicketTransferPanel
|
|
496
|
+
<TicketTransferPanel
|
|
497
|
+
passIds={myTicketHeldPassIds(ticket)}
|
|
498
|
+
passes={myTicketPasses(ticket)}
|
|
499
|
+
style={{ marginTop: 12 }}
|
|
500
|
+
/>
|
|
437
501
|
|
|
438
502
|
{/* The terms they agreed to, in the artist's own words where given. */}
|
|
439
503
|
{ticket.cancellation.description && (
|
|
@@ -576,7 +640,7 @@ function WaitlistPlaceRow({
|
|
|
576
640
|
fontSize: 14,
|
|
577
641
|
}}
|
|
578
642
|
>
|
|
579
|
-
<strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}
|
|
643
|
+
<strong>Buy within {formatCountdown(place.offer.secondsRemaining ?? 0)}.</strong> Your offer closes{" "}
|
|
580
644
|
{deadline.toLocaleString(undefined, {
|
|
581
645
|
weekday: "short",
|
|
582
646
|
day: "numeric",
|
|
@@ -672,7 +736,60 @@ function formatSessionWindow(booking: MyBooking): string {
|
|
|
672
736
|
});
|
|
673
737
|
const from = start.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
|
|
674
738
|
const to = end.toLocaleString(undefined, { hour: "numeric", minute: "2-digit", timeZone: zone });
|
|
675
|
-
return `${day}, ${from}
|
|
739
|
+
return `${day}, ${from} to ${to}${zone ? ` (${zone})` : ""}`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* WHERE the session is — the line this portal existed without.
|
|
744
|
+
*
|
|
745
|
+
* A client who bought a coaching call could see the time, the price and an
|
|
746
|
+
* add-to-calendar button, and nothing at all about how to attend; the answer
|
|
747
|
+
* arrived, if it arrived, in a separate email from the coach.
|
|
748
|
+
*
|
|
749
|
+
* The two kinds are not interchangeable, so they do not render the same. An
|
|
750
|
+
* online session's link is an anchor — at 9:59 for a 10:00 call it is the only
|
|
751
|
+
* thing on this row anyone wants — shown in full rather than as the word "Join",
|
|
752
|
+
* because the client may be reading this on a phone and about to type it into
|
|
753
|
+
* a laptop. An in-person address is plain text: it is the coach's own words,
|
|
754
|
+
* often "Studio B, ring the buzzer" rather than anything a maps provider could
|
|
755
|
+
* resolve, and linking it would guess at something they did not say.
|
|
756
|
+
*
|
|
757
|
+
* `location` is already resolved server-side (booking override over product
|
|
758
|
+
* default) and is null unless the session is confirmed, so there is no rule to
|
|
759
|
+
* apply here — which is the same discipline `canReschedule` follows two blocks
|
|
760
|
+
* down.
|
|
761
|
+
*/
|
|
762
|
+
function BookingLocationLine({
|
|
763
|
+
location,
|
|
764
|
+
primary,
|
|
765
|
+
}: {
|
|
766
|
+
location: MyBooking["location"];
|
|
767
|
+
primary: string;
|
|
768
|
+
}) {
|
|
769
|
+
if (!location) return null;
|
|
770
|
+
|
|
771
|
+
if (location.type === "online") {
|
|
772
|
+
return (
|
|
773
|
+
<div style={{ fontSize: 13, marginTop: 4 }}>
|
|
774
|
+
<span style={{ opacity: 0.7 }}>Join: </span>
|
|
775
|
+
<a
|
|
776
|
+
href={location.url}
|
|
777
|
+
target="_blank"
|
|
778
|
+
rel="noreferrer"
|
|
779
|
+
style={{ color: primary, textDecoration: "underline", wordBreak: "break-all" }}
|
|
780
|
+
>
|
|
781
|
+
{location.url}
|
|
782
|
+
</a>
|
|
783
|
+
</div>
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return (
|
|
788
|
+
<div style={{ fontSize: 13, opacity: 0.7, marginTop: 4 }}>
|
|
789
|
+
<span style={{ fontWeight: 600 }}>Where: </span>
|
|
790
|
+
{location.address}
|
|
791
|
+
</div>
|
|
792
|
+
);
|
|
676
793
|
}
|
|
677
794
|
|
|
678
795
|
function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
@@ -724,6 +841,7 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
724
841
|
{booking.coachingProductDurationMinutes != null && (
|
|
725
842
|
<div style={{ fontSize: 13, opacity: 0.7 }}>{booking.coachingProductDurationMinutes} minutes</div>
|
|
726
843
|
)}
|
|
844
|
+
<BookingLocationLine location={booking.location} primary={t.primary} />
|
|
727
845
|
</div>
|
|
728
846
|
<div style={{ textAlign: "right" }}>
|
|
729
847
|
{cancelled && <div style={{ fontSize: 13, color: t.primary }}>Cancelled</div>}
|
|
@@ -735,6 +853,19 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
|
|
|
735
853
|
</div>
|
|
736
854
|
</div>
|
|
737
855
|
|
|
856
|
+
{/* Diarise it — including Apple, which needs the platform's own `.ics`.
|
|
857
|
+
Null unless the session is confirmed, and the component draws nothing. */}
|
|
858
|
+
<AddToCalendar
|
|
859
|
+
variant="compact"
|
|
860
|
+
title={booking.coachingProductTitle}
|
|
861
|
+
start={booking.sessionStartTime}
|
|
862
|
+
end={booking.sessionEndTime}
|
|
863
|
+
defaultDurationMinutes={booking.coachingProductDurationMinutes ?? undefined}
|
|
864
|
+
icsUrl={booking.calendarUrl}
|
|
865
|
+
webcalUrl={booking.calendarWebcalUrl}
|
|
866
|
+
style={{ marginTop: 12 }}
|
|
867
|
+
/>
|
|
868
|
+
|
|
738
869
|
{/* The terms they booked under, in the artist's own words where given. */}
|
|
739
870
|
{booking.cancellation.description && (
|
|
740
871
|
<div style={{ fontSize: 13, opacity: 0.75, marginTop: 12 }}>{booking.cancellation.description}</div>
|