@tribe-nest/forge 3.9.0 → 3.11.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/useCoachingAvailability.ts +14 -3
- package/src/data/queries/useEventSeries.ts +42 -0
- package/src/data/queries/useEvents.ts +10 -1
- package/src/data/queries/useSubscriptions.ts +53 -0
- package/src/index.ts +4 -0
- package/src/server/index.ts +20 -0
- package/src/types/models.ts +146 -0
- package/src/ui/format/bookingFee.ts +98 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +19 -0
- package/src/ui/headless/event/useEventCheckout.ts +61 -2
- package/src/ui/headless/membership/MembershipGate.tsx +7 -2
- package/src/ui/index.ts +10 -0
- package/src/ui/styled/AccountDashboard.tsx +45 -4
- package/src/ui/styled/CancellationTerms.tsx +70 -0
- package/src/ui/styled/CoachingBooking.tsx +10 -0
- package/src/ui/styled/CoachingDetail.tsx +4 -0
- package/src/ui/styled/EventDetail.tsx +12 -0
- package/src/ui/styled/EventSeriesDetail.tsx +222 -0
- package/src/ui/styled/EventTickets.tsx +58 -0
- package/src/utils/membershipAccess.ts +182 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE answer to "does this membership grant access right now", and the
|
|
3
|
+
* separate answer to "what should we tell the member".
|
|
4
|
+
*
|
|
5
|
+
* ## Why this file exists
|
|
6
|
+
*
|
|
7
|
+
* The backend has always kept a member's benefits alive through `past_due` and
|
|
8
|
+
* the grace window — that is a locked product decision (retention beats a few
|
|
9
|
+
* days of free access; an abrupt cut-off during a bank hiccup is the top
|
|
10
|
+
* cancellation trigger). Three fan-facing components disagreed with it, each
|
|
11
|
+
* with its own `status === "active"` string compare:
|
|
12
|
+
*
|
|
13
|
+
* - Forge `MembershipGate` → locked past_due members out of paid content
|
|
14
|
+
* - Forge `AccountDashboard` → badge read "Cancelled"
|
|
15
|
+
* - client `MembershipTab` → badge read "Cancelled", cancel button hidden
|
|
16
|
+
*
|
|
17
|
+
* So a member whose card bounced lost access instantly AND was told they had
|
|
18
|
+
* cancelled. Both wrong, from the same overloaded boolean.
|
|
19
|
+
*
|
|
20
|
+
* ## How it stays fixed
|
|
21
|
+
*
|
|
22
|
+
* The server is the source of truth. `describeMembershipAccess` (backend,
|
|
23
|
+
* `services/_core/membershipAccess.ts`) runs the SAME `grantsAccess` predicate
|
|
24
|
+
* the membership guard, community tier resolution and the notification workers
|
|
25
|
+
* use, and ships the result as `membership.access` on every membership a fan
|
|
26
|
+
* surface receives. `getMembershipAccess` below prefers that block. It never
|
|
27
|
+
* re-decides; a UI gate and a server gate cannot disagree.
|
|
28
|
+
*
|
|
29
|
+
* The status fallback exists only for a client running against a server that
|
|
30
|
+
* predates the `access` block. Keep it in lockstep with
|
|
31
|
+
* `ACCESS_GRANTING_STATUSES` on the backend — and prefer adding to the server.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const MEMBERSHIP_BILLING_STATE = {
|
|
35
|
+
active: "active",
|
|
36
|
+
ending: "ending",
|
|
37
|
+
pastDue: "past_due",
|
|
38
|
+
grace: "grace",
|
|
39
|
+
pending: "pending",
|
|
40
|
+
ended: "ended",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type MembershipBillingState = (typeof MEMBERSHIP_BILLING_STATE)[keyof typeof MEMBERSHIP_BILLING_STATE];
|
|
44
|
+
|
|
45
|
+
/** Mirror of the backend's `ACCESS_GRANTING_STATUSES` — fallback only. */
|
|
46
|
+
const ACCESS_GRANTING_STATUSES = ["active", "past_due", "grace"];
|
|
47
|
+
const RECOVERABLE_STATUSES = ["past_due", "grace"];
|
|
48
|
+
|
|
49
|
+
export type MembershipAccessSummary = {
|
|
50
|
+
/** May this member use their benefits right now. */
|
|
51
|
+
hasAccess: boolean;
|
|
52
|
+
billingState: MembershipBillingState;
|
|
53
|
+
/** A charge failed and recovery is still possible — show the fix-my-card path. */
|
|
54
|
+
paymentFailed: boolean;
|
|
55
|
+
/** ISO date access is cut if the card is not fixed. */
|
|
56
|
+
graceUntil: string | null;
|
|
57
|
+
pastDueAt: string | null;
|
|
58
|
+
cancelAtPeriodEnd: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type MembershipAccessInput =
|
|
62
|
+
| {
|
|
63
|
+
status?: string | null;
|
|
64
|
+
cancelAtPeriodEnd?: boolean | null;
|
|
65
|
+
graceUntil?: string | null;
|
|
66
|
+
pastDueAt?: string | null;
|
|
67
|
+
access?: Partial<MembershipAccessSummary> | null;
|
|
68
|
+
}
|
|
69
|
+
| null
|
|
70
|
+
| undefined;
|
|
71
|
+
|
|
72
|
+
const NO_MEMBERSHIP: MembershipAccessSummary = {
|
|
73
|
+
hasAccess: false,
|
|
74
|
+
billingState: MEMBERSHIP_BILLING_STATE.ended,
|
|
75
|
+
paymentFailed: false,
|
|
76
|
+
graceUntil: null,
|
|
77
|
+
pastDueAt: null,
|
|
78
|
+
cancelAtPeriodEnd: false,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const billingStateFromStatus = (status: string | null | undefined, cancelAtPeriodEnd: boolean) => {
|
|
82
|
+
switch (status) {
|
|
83
|
+
case "active":
|
|
84
|
+
return cancelAtPeriodEnd ? MEMBERSHIP_BILLING_STATE.ending : MEMBERSHIP_BILLING_STATE.active;
|
|
85
|
+
case "past_due":
|
|
86
|
+
return MEMBERSHIP_BILLING_STATE.pastDue;
|
|
87
|
+
case "grace":
|
|
88
|
+
return MEMBERSHIP_BILLING_STATE.grace;
|
|
89
|
+
case "pending":
|
|
90
|
+
return MEMBERSHIP_BILLING_STATE.pending;
|
|
91
|
+
default:
|
|
92
|
+
return MEMBERSHIP_BILLING_STATE.ended;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function getMembershipAccess(membership: MembershipAccessInput): MembershipAccessSummary {
|
|
97
|
+
if (!membership) return NO_MEMBERSHIP;
|
|
98
|
+
|
|
99
|
+
const server = membership.access;
|
|
100
|
+
if (server && typeof server.hasAccess === "boolean" && server.billingState) {
|
|
101
|
+
return {
|
|
102
|
+
hasAccess: server.hasAccess,
|
|
103
|
+
billingState: server.billingState,
|
|
104
|
+
paymentFailed: !!server.paymentFailed,
|
|
105
|
+
graceUntil: server.graceUntil ?? null,
|
|
106
|
+
pastDueAt: server.pastDueAt ?? null,
|
|
107
|
+
cancelAtPeriodEnd: !!server.cancelAtPeriodEnd,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const status = membership.status ?? null;
|
|
112
|
+
const cancelAtPeriodEnd = !!membership.cancelAtPeriodEnd;
|
|
113
|
+
return {
|
|
114
|
+
hasAccess: !!status && ACCESS_GRANTING_STATUSES.includes(status),
|
|
115
|
+
billingState: billingStateFromStatus(status, cancelAtPeriodEnd),
|
|
116
|
+
paymentFailed: !!status && RECOVERABLE_STATUSES.includes(status),
|
|
117
|
+
graceUntil: membership.graceUntil ?? null,
|
|
118
|
+
pastDueAt: membership.pastDueAt ?? null,
|
|
119
|
+
cancelAtPeriodEnd,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The label + explanation a member should see. Deliberately separate from
|
|
125
|
+
* access: a `past_due` member keeps their benefits AND must be told their
|
|
126
|
+
* payment failed. Showing "Active" to someone whose card bounced is its own
|
|
127
|
+
* failure — it is why they never fix it.
|
|
128
|
+
*/
|
|
129
|
+
export type MembershipStatusMessage = {
|
|
130
|
+
label: string;
|
|
131
|
+
/** Longer explanation, or null when there is nothing to explain. */
|
|
132
|
+
detail: string | null;
|
|
133
|
+
/** Show the "update payment method" affordance. */
|
|
134
|
+
showUpdatePayment: boolean;
|
|
135
|
+
/** Severity for styling — the caller maps this onto its own theme colors. */
|
|
136
|
+
tone: "positive" | "warning" | "neutral";
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const formatDay = (iso: string | null): string | null => {
|
|
140
|
+
if (!iso) return null;
|
|
141
|
+
const date = new Date(iso);
|
|
142
|
+
return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export function getMembershipStatusMessage(access: MembershipAccessSummary): MembershipStatusMessage {
|
|
146
|
+
switch (access.billingState) {
|
|
147
|
+
case MEMBERSHIP_BILLING_STATE.active:
|
|
148
|
+
return { label: "Active", detail: null, showUpdatePayment: false, tone: "positive" };
|
|
149
|
+
|
|
150
|
+
case MEMBERSHIP_BILLING_STATE.ending:
|
|
151
|
+
return {
|
|
152
|
+
label: "Ending",
|
|
153
|
+
detail: "Your membership is cancelled and will not renew. You keep access until the end of this period.",
|
|
154
|
+
showUpdatePayment: false,
|
|
155
|
+
tone: "neutral",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
case MEMBERSHIP_BILLING_STATE.pastDue:
|
|
159
|
+
case MEMBERSHIP_BILLING_STATE.grace: {
|
|
160
|
+
const until = formatDay(access.graceUntil);
|
|
161
|
+
return {
|
|
162
|
+
label: "Payment failed",
|
|
163
|
+
detail: until
|
|
164
|
+
? `We couldn't charge your payment method. You still have full access until ${until} — update your card to keep it.`
|
|
165
|
+
: "We couldn't charge your payment method. You still have full access — update your card to keep it.",
|
|
166
|
+
showUpdatePayment: true,
|
|
167
|
+
tone: "warning",
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case MEMBERSHIP_BILLING_STATE.pending:
|
|
172
|
+
return {
|
|
173
|
+
label: "Pending",
|
|
174
|
+
detail: "Your membership isn't active yet — finish checkout to unlock it.",
|
|
175
|
+
showUpdatePayment: false,
|
|
176
|
+
tone: "neutral",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
default:
|
|
180
|
+
return { label: "Ended", detail: null, showUpdatePayment: false, tone: "neutral" };
|
|
181
|
+
}
|
|
182
|
+
}
|