@tribe-nest/forge 3.53.0 → 3.57.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/contexts/AppAuthContext.tsx +3 -2
- package/src/contexts/AudioPlayerContext.tsx +3 -2
- package/src/contexts/CartContext.tsx +3 -2
- package/src/contexts/PublicAuthContext.tsx +3 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +26 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyTickets.ts +49 -0
- package/src/data/queries/usePaymentFlow.ts +8 -0
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +116 -1
- package/src/i18n/en.json +116 -1
- package/src/i18n/index.ts +3 -2
- package/src/index.ts +22 -0
- package/src/provider/ForgeAppProvider.tsx +8 -1
- package/src/provider/ForgeProvider.tsx +3 -2
- package/src/provider/SiteConfigProvider.tsx +3 -2
- package/src/runtime/RemotePage.tsx +110 -0
- package/src/runtime/hostRuntime.ts +84 -0
- package/src/runtime/pages.spec.ts +41 -0
- package/src/runtime/pages.ts +183 -0
- package/src/runtime/pagesClient.ts +127 -0
- package/src/runtime/registry.spec.ts +45 -0
- package/src/runtime/registry.ts +102 -0
- package/src/server/index.ts +74 -0
- package/src/server/jobs.ts +4 -1
- package/src/server/platformEvents.generated.ts +152 -0
- package/src/types/models.ts +185 -0
- package/src/ui/headless/event/_tests/ticketApproval.spec.ts +137 -0
- package/src/ui/headless/event/_tests/useEventCheckoutApproval.spec.tsx +250 -0
- package/src/ui/headless/event/ticketApproval.ts +109 -0
- package/src/ui/headless/event/useEventCheckout.ts +74 -4
- package/src/ui/headless/index.ts +34 -0
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +27 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +29 -0
- package/src/ui/payment/ForgeStripePayment.tsx +110 -5
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeConfirmOutcome.spec.ts +61 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeConfirmOutcome.ts +50 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +6 -0
- package/src/ui/styled/AccountDashboard.tsx +239 -15
- package/src/ui/styled/DocumentSigningPage.tsx +516 -0
- package/src/ui/styled/EventConfirmation.tsx +106 -9
- package/src/ui/styled/EventTickets.tsx +107 -13
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/ReviewRequestPage.tsx +214 -0
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +310 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +3 -2
- package/src/utils/_tests/ticketOrderOutcome.spec.ts +82 -0
- package/src/utils/ticketOrderOutcome.ts +57 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CSSProperties } from "react";
|
|
2
|
-
import { useGetMembershipTiers } from "../../data/queries/useMembership";
|
|
2
|
+
import { useGetMembershipTiers, useMembershipInviteInfo } from "../../data/queries/useMembership";
|
|
3
3
|
import { useForgeT } from "../../i18n";
|
|
4
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
@@ -10,6 +10,13 @@ export interface MembershipTierCalloutProps {
|
|
|
10
10
|
membershipTierId?: string;
|
|
11
11
|
/** How many benefits to list before summarising the rest. Default 3. */
|
|
12
12
|
maxBenefits?: number;
|
|
13
|
+
/**
|
|
14
|
+
* The membership invite the visitor is holding (read off the same redirect as
|
|
15
|
+
* the tier id). An invite-only tier is only listed with it, and the callout
|
|
16
|
+
* then also names the email the invite was sent to, so the fan signs in with
|
|
17
|
+
* the right account.
|
|
18
|
+
*/
|
|
19
|
+
inviteToken?: string;
|
|
13
20
|
className?: string;
|
|
14
21
|
style?: CSSProperties;
|
|
15
22
|
}
|
|
@@ -31,6 +38,7 @@ export interface MembershipTierCalloutProps {
|
|
|
31
38
|
export function MembershipTierCallout({
|
|
32
39
|
membershipTierId,
|
|
33
40
|
maxBenefits = 3,
|
|
41
|
+
inviteToken,
|
|
34
42
|
className,
|
|
35
43
|
style,
|
|
36
44
|
}: MembershipTierCalloutProps) {
|
|
@@ -39,7 +47,8 @@ export function MembershipTierCallout({
|
|
|
39
47
|
const fmt = useAmountFormatter();
|
|
40
48
|
// The tier list is one cached query per profile, so this shares the fetch the
|
|
41
49
|
// membership page and the checkout already make rather than adding a request.
|
|
42
|
-
const { data: tiers } = useGetMembershipTiers();
|
|
50
|
+
const { data: tiers } = useGetMembershipTiers(inviteToken ? { inviteToken } : {});
|
|
51
|
+
const { data: invite } = useMembershipInviteInfo(inviteToken);
|
|
43
52
|
const tier = membershipTierId ? tiers?.find((candidate) => candidate.id === membershipTierId) : undefined;
|
|
44
53
|
|
|
45
54
|
if (!tier) return null;
|
|
@@ -67,7 +76,12 @@ export function MembershipTierCallout({
|
|
|
67
76
|
<h2 style={{ fontSize: 18, fontWeight: 700, marginBottom: 8, fontFamily: tokens.headingFontFamily }}>
|
|
68
77
|
{t("forge.membership_tier_callout.join_title", { tier_name: tier.name })}
|
|
69
78
|
</h2>
|
|
70
|
-
<p style={{ fontSize: 14, marginBottom: shown.length ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
|
|
79
|
+
<p style={{ fontSize: 14, marginBottom: shown.length || invite ? 12 : 0 }}>{membershipPriceLabel(tier, t, fmt)}</p>
|
|
80
|
+
{invite && invite.membershipTierId === tier.id && (
|
|
81
|
+
<p style={{ fontSize: 14, fontWeight: 600, marginBottom: shown.length ? 12 : 0 }}>
|
|
82
|
+
{t("forge.membership_tier_callout.invite_for_email", { email: invite.email })}
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
71
85
|
{!!shown.length && (
|
|
72
86
|
<div style={{ fontSize: 14 }}>
|
|
73
87
|
<p style={{ fontWeight: 500, marginBottom: 4 }}>{t("forge.membership_tier_callout.benefits_label")}</p>
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { MembershipTier } from "../../types/models";
|
|
2
|
-
import {
|
|
2
|
+
import { useContext, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
readMembershipInviteFromUrl,
|
|
5
|
+
useGetMembershipTiers,
|
|
6
|
+
useWithdrawMembershipApplication,
|
|
7
|
+
} from "../../data/queries/useMembership";
|
|
8
|
+
import { tierCardAction } from "../headless/membership/membershipApplication";
|
|
9
|
+
import { tierTrialDays } from "../headless/membership/membershipTrial";
|
|
3
10
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
4
11
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
5
12
|
import { priceTaxCaption } from "../format/PriceDisplay";
|
|
6
13
|
import { usePricesIncludeTax } from "../../data/queries/useWebsite";
|
|
7
14
|
import { useForgeT } from "../../i18n";
|
|
15
|
+
import { PublicAuthContext } from "../../contexts/PublicAuthContext";
|
|
8
16
|
import { membershipPriceLabel } from "../format/membershipPriceLabel";
|
|
9
17
|
import { Button } from "./Button";
|
|
10
18
|
import { Loading } from "./Loading";
|
|
@@ -17,7 +25,12 @@ export interface MembershipTiersProps {
|
|
|
17
25
|
* `membershipTierId` query param. Default `/i/membership/checkout`.
|
|
18
26
|
*/
|
|
19
27
|
checkoutPath?: string;
|
|
20
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Optional override for tier selection (e.g. router navigation). Also what
|
|
30
|
+
* "Complete your membership" calls on an approved application whose card was
|
|
31
|
+
* declined. A host that overrides it should carry an `invite` search param
|
|
32
|
+
* through to the checkout.
|
|
33
|
+
*/
|
|
21
34
|
onSelect?: (tier: MembershipTier) => void;
|
|
22
35
|
/** Optional page heading. Pass `null` to omit. Default `"Membership"`. */
|
|
23
36
|
heading?: string | null;
|
|
@@ -30,6 +43,15 @@ export interface MembershipTiersProps {
|
|
|
30
43
|
* responsive card row (a single tier sits at a natural card width rather than
|
|
31
44
|
* stretching full-screen). "Select" routes to `checkoutPath` with the tier id,
|
|
32
45
|
* or calls `onSelect` when provided.
|
|
46
|
+
*
|
|
47
|
+
* Curated access: an invite-only tier is only returned when the page was opened
|
|
48
|
+
* with its invite (`?invite=`), and reads "Accept invite"; an application tier
|
|
49
|
+
* reads "Apply to join". When the signed-in fan already applied, the card shows
|
|
50
|
+
* where that application stands instead of the button.
|
|
51
|
+
*
|
|
52
|
+
* Free trials: a paid tier with a trial the fan is still eligible for carries a
|
|
53
|
+
* "{days}-day free trial" badge, and an open tier's button reads "Start free
|
|
54
|
+
* trial". An application tier keeps "Apply to join" (approval starts the trial).
|
|
33
55
|
*/
|
|
34
56
|
export function MembershipTiers({
|
|
35
57
|
formatAmount,
|
|
@@ -46,6 +68,8 @@ export function MembershipTiers({
|
|
|
46
68
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
47
69
|
const taxCaption = priceTaxCaption({ pricesIncludeTax });
|
|
48
70
|
const { data: tiers, isLoading } = useGetMembershipTiers();
|
|
71
|
+
const withdraw = useWithdrawMembershipApplication();
|
|
72
|
+
const [withdrawError, setWithdrawError] = useState<string | null>(null);
|
|
49
73
|
// `null` still means "no heading"; anything else falls back to the default.
|
|
50
74
|
const resolvedHeading = heading === null ? null : (heading ?? t("forge.membership_tiers.heading"));
|
|
51
75
|
const freeLabel = t("forge.membership_tiers.price_free");
|
|
@@ -62,7 +86,10 @@ export function MembershipTiers({
|
|
|
62
86
|
if (onSelect) return onSelect(tier);
|
|
63
87
|
if (typeof window !== "undefined") {
|
|
64
88
|
const sep = checkoutPath.includes("?") ? "&" : "?";
|
|
65
|
-
|
|
89
|
+
const invite = readMembershipInviteFromUrl();
|
|
90
|
+
window.location.href = `${checkoutPath}${sep}membershipTierId=${encodeURIComponent(tier.id)}${
|
|
91
|
+
invite ? `&invite=${encodeURIComponent(invite)}` : ""
|
|
92
|
+
}`;
|
|
66
93
|
}
|
|
67
94
|
};
|
|
68
95
|
|
|
@@ -99,6 +126,7 @@ export function MembershipTiers({
|
|
|
99
126
|
</span>
|
|
100
127
|
)}
|
|
101
128
|
</div>
|
|
129
|
+
<TierTrialBadge tier={tier} />
|
|
102
130
|
{tier.description && <p style={{ opacity: 0.75 }}>{tier.description}</p>}
|
|
103
131
|
{!!tier.benefits?.length && (
|
|
104
132
|
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 6 }}>
|
|
@@ -110,12 +138,126 @@ export function MembershipTiers({
|
|
|
110
138
|
))}
|
|
111
139
|
</ul>
|
|
112
140
|
)}
|
|
113
|
-
<
|
|
114
|
-
{
|
|
115
|
-
|
|
141
|
+
<TierAction
|
|
142
|
+
tier={tier}
|
|
143
|
+
onSelect={() => select(tier)}
|
|
144
|
+
onWithdraw={async (applicationId) => {
|
|
145
|
+
setWithdrawError(null);
|
|
146
|
+
try {
|
|
147
|
+
await withdraw.mutateAsync({ applicationId });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
const detail = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
150
|
+
setWithdrawError(detail || t("forge.membership_tiers.withdraw_error"));
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
isWithdrawing={withdraw.isPending}
|
|
154
|
+
withdrawError={withdraw.variables?.applicationId === tier.myApplication?.id ? withdrawError : null}
|
|
155
|
+
/>
|
|
116
156
|
</div>
|
|
117
157
|
))}
|
|
118
158
|
</div>
|
|
119
159
|
</div>
|
|
120
160
|
);
|
|
121
161
|
}
|
|
162
|
+
|
|
163
|
+
/** "{days}-day free trial" on a tier that gives this fan a trial; nothing otherwise. Exported for the spec. */
|
|
164
|
+
export function TierTrialBadge({ tier }: { tier: MembershipTier }) {
|
|
165
|
+
const t = useForgeT();
|
|
166
|
+
const tokens = useThemeTokens();
|
|
167
|
+
// Read softly: the card also renders outside a signed-in tree.
|
|
168
|
+
const user = useContext(PublicAuthContext)?.user;
|
|
169
|
+
const days = tierTrialDays(tier, user?.membership);
|
|
170
|
+
if (days <= 0) return null;
|
|
171
|
+
return (
|
|
172
|
+
<span
|
|
173
|
+
data-testid="membership-tier-trial-badge"
|
|
174
|
+
style={{
|
|
175
|
+
alignSelf: "flex-start",
|
|
176
|
+
padding: "2px 10px",
|
|
177
|
+
borderRadius: 999,
|
|
178
|
+
fontSize: 12,
|
|
179
|
+
fontWeight: 700,
|
|
180
|
+
border: `1px solid ${tokens.primary}`,
|
|
181
|
+
color: tokens.primary,
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
{t("forge.membership_tiers.trial_badge", { days })}
|
|
185
|
+
</span>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The bottom of a tier card: the join button in the tier's own words, or where
|
|
191
|
+
* the fan's application stands. Exported for the card-state spec.
|
|
192
|
+
*/
|
|
193
|
+
export function TierAction({
|
|
194
|
+
tier,
|
|
195
|
+
onSelect,
|
|
196
|
+
onWithdraw,
|
|
197
|
+
isWithdrawing,
|
|
198
|
+
withdrawError,
|
|
199
|
+
}: {
|
|
200
|
+
tier: MembershipTier;
|
|
201
|
+
onSelect: () => void;
|
|
202
|
+
onWithdraw: (applicationId: string) => void;
|
|
203
|
+
isWithdrawing?: boolean;
|
|
204
|
+
withdrawError?: string | null;
|
|
205
|
+
}) {
|
|
206
|
+
const t = useForgeT();
|
|
207
|
+
const tokens = useThemeTokens();
|
|
208
|
+
// Read softly: the card also renders outside a signed-in tree.
|
|
209
|
+
const user = useContext(PublicAuthContext)?.user;
|
|
210
|
+
const action = tierCardAction(tier);
|
|
211
|
+
const stateLine = (text: string) => (
|
|
212
|
+
<p data-testid="membership-tier-application-state" style={{ fontWeight: 600, color: tokens.text }}>
|
|
213
|
+
{text}
|
|
214
|
+
</p>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
switch (action) {
|
|
218
|
+
case "pending":
|
|
219
|
+
return (
|
|
220
|
+
<div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
|
|
221
|
+
{stateLine(t("forge.membership_tiers.application_pending"))}
|
|
222
|
+
<Button
|
|
223
|
+
fullWidth
|
|
224
|
+
variant="outline"
|
|
225
|
+
disabled={isWithdrawing}
|
|
226
|
+
onClick={() => tier.myApplication && onWithdraw(tier.myApplication.id)}
|
|
227
|
+
>
|
|
228
|
+
{isWithdrawing ? t("forge.membership_tiers.withdrawing") : t("forge.membership_tiers.withdraw")}
|
|
229
|
+
</Button>
|
|
230
|
+
{withdrawError && (
|
|
231
|
+
<p role="alert" style={{ fontSize: 13 }}>
|
|
232
|
+
{withdrawError}
|
|
233
|
+
</p>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
case "approved":
|
|
238
|
+
return <div style={{ marginTop: "auto" }}>{stateLine(t("forge.membership_tiers.application_approved"))}</div>;
|
|
239
|
+
case "declined":
|
|
240
|
+
return <div style={{ marginTop: "auto" }}>{stateLine(t("forge.membership_tiers.application_declined"))}</div>;
|
|
241
|
+
case "complete":
|
|
242
|
+
return (
|
|
243
|
+
<div style={{ marginTop: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
|
|
244
|
+
{stateLine(t("forge.membership_tiers.application_payment_failed"))}
|
|
245
|
+
<Button fullWidth onClick={onSelect}>
|
|
246
|
+
{t("forge.membership_tiers.complete_membership")}
|
|
247
|
+
</Button>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
default:
|
|
251
|
+
return (
|
|
252
|
+
<Button fullWidth style={{ marginTop: "auto" }} onClick={onSelect}>
|
|
253
|
+
{action === "accept_invite"
|
|
254
|
+
? t("forge.membership_tiers.accept_invite")
|
|
255
|
+
: action === "apply"
|
|
256
|
+
? t("forge.membership_tiers.apply")
|
|
257
|
+
: tierTrialDays(tier, user?.membership) > 0
|
|
258
|
+
? t("forge.membership_tiers.start_free_trial")
|
|
259
|
+
: t("forge.membership_tiers.select")}
|
|
260
|
+
</Button>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Lock } from "lucide-react";
|
|
2
2
|
import { useGetProducts, useGetProductsByIds } from "../../data/queries/useProducts";
|
|
3
|
-
import type { IPublicProduct, ProductType } from "../../types/models";
|
|
3
|
+
import type { IPublicProduct, PaginatedData, ProductType } from "../../types/models";
|
|
4
4
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
5
|
import { useAmountFormatter } from "../format/useFormatCurrency";
|
|
6
6
|
import { PriceDisplay } from "../format/PriceDisplay";
|
|
@@ -9,6 +9,15 @@ import { Loading } from "./Loading";
|
|
|
9
9
|
|
|
10
10
|
export interface ProductGridProps {
|
|
11
11
|
columns?: number;
|
|
12
|
+
/**
|
|
13
|
+
* The first page of the shop listing, from a server render
|
|
14
|
+
* (`fetchProductsServer`).
|
|
15
|
+
*
|
|
16
|
+
* Seeds the query so the grid arrives with products in it instead of
|
|
17
|
+
* rendering a spinner into the document. Only applies to the plain listing:
|
|
18
|
+
* a grid showing `productIds` is a different query and ignores it.
|
|
19
|
+
*/
|
|
20
|
+
initialProducts?: PaginatedData<IPublicProduct>;
|
|
12
21
|
limit?: number;
|
|
13
22
|
/**
|
|
14
23
|
* Show these specific products, in this order, instead of the shop listing.
|
|
@@ -49,6 +58,7 @@ function minPrice(p: IPublicProduct): number {
|
|
|
49
58
|
/** Themed product grid, built on `useGetProducts`. */
|
|
50
59
|
export function ProductGrid({
|
|
51
60
|
columns = 3,
|
|
61
|
+
initialProducts,
|
|
52
62
|
limit,
|
|
53
63
|
productIds,
|
|
54
64
|
categoryId,
|
|
@@ -67,7 +77,9 @@ export function ProductGrid({
|
|
|
67
77
|
const byIds = !!productIds?.length;
|
|
68
78
|
// Both hooks are called unconditionally (rules of hooks); each disables
|
|
69
79
|
// itself when it isn't the one in use.
|
|
70
|
-
const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds
|
|
80
|
+
const listQuery = useGetProducts({ page: 1, categoryId, collectionId, productType }, !byIds, {
|
|
81
|
+
initialData: initialProducts,
|
|
82
|
+
});
|
|
71
83
|
const idsQuery = useGetProductsByIds(byIds ? productIds! : []);
|
|
72
84
|
|
|
73
85
|
const isLoading = byIds ? idsQuery.isLoading : listQuery.isLoading;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a "how did we do?" email lands on.
|
|
3
|
+
*
|
|
4
|
+
* It lives in Forge rather than in either storefront because BOTH need it: the
|
|
5
|
+
* hosted `/i/reviews/$token` page renders it, and every site also keeps a
|
|
6
|
+
* root-level `/reviews/$token` route for links in emails that were sent before
|
|
7
|
+
* the move. Two copies of a page that must agree is the failure this avoids.
|
|
8
|
+
*/
|
|
9
|
+
// The emailed review-link form (`/reviews/:token`) built from the headless
|
|
10
|
+
// `useReviewForm` primitive + Tailwind theme tokens. Lives under `/i/`
|
|
11
|
+
// (centrally managed) — the top-level `/reviews/$token` route is a thin shell
|
|
12
|
+
// so emailed links keep working while the page stays updatable.
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { CheckCircle2, Clock, Star } from "lucide-react";
|
|
15
|
+
import { Loading } from "./Loading";
|
|
16
|
+
import { useReviewForm } from "../headless";
|
|
17
|
+
|
|
18
|
+
export function ReviewRequestPage({ token }: { token: string }) {
|
|
19
|
+
const form = useReviewForm(token);
|
|
20
|
+
const [hoverRating, setHoverRating] = useState(0);
|
|
21
|
+
|
|
22
|
+
if (form.status === "loading") return <Loading fullPage />;
|
|
23
|
+
|
|
24
|
+
if (form.status === "invalid") {
|
|
25
|
+
return (
|
|
26
|
+
<Panel>
|
|
27
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">This review link isn't valid</h1>
|
|
28
|
+
<p className="fg:text-muted">
|
|
29
|
+
The link may have been mistyped or is no longer active. If you received it by email, try opening it again
|
|
30
|
+
from the original message.
|
|
31
|
+
</p>
|
|
32
|
+
</Panel>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (form.status === "expired") {
|
|
37
|
+
return (
|
|
38
|
+
<Panel>
|
|
39
|
+
<Clock size={32} className="fg:text-primary" />
|
|
40
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">This review link has expired</h1>
|
|
41
|
+
<p className="fg:text-muted">
|
|
42
|
+
Review links are only valid for a limited time after your purchase
|
|
43
|
+
{form.entityTitle ? ` of “${form.entityTitle}”` : ""}. Thanks anyway for your support!
|
|
44
|
+
</p>
|
|
45
|
+
</Panel>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (form.status === "submitted" && form.submitted) {
|
|
50
|
+
return (
|
|
51
|
+
<Panel>
|
|
52
|
+
<CheckCircle2 size={36} className="fg:text-primary" />
|
|
53
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">Thank you for your review!</h1>
|
|
54
|
+
<StarRow rating={form.submitted.rating} size={20} />
|
|
55
|
+
<p className="fg:text-muted">
|
|
56
|
+
{form.isHeld
|
|
57
|
+
? "Your review has been received and is awaiting approval. It will appear once it's been checked."
|
|
58
|
+
: `Your review is now live${form.entityTitle ? ` on “${form.entityTitle}”` : ""}.`}
|
|
59
|
+
</p>
|
|
60
|
+
</Panel>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (form.status === "locked" && form.existingReview) {
|
|
65
|
+
return (
|
|
66
|
+
<Panel>
|
|
67
|
+
<CheckCircle2 size={32} className="fg:text-primary" />
|
|
68
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">You've already reviewed this</h1>
|
|
69
|
+
{form.entityTitle && <p className="fg:font-semibold fg:text-text">{form.entityTitle}</p>}
|
|
70
|
+
<StarRow rating={form.existingReview.rating} size={20} />
|
|
71
|
+
{form.existingReview.title && <p className="fg:font-bold fg:text-text">{form.existingReview.title}</p>}
|
|
72
|
+
{form.existingReview.body && <p className="fg:text-text/85">{form.existingReview.body}</p>}
|
|
73
|
+
<p className="fg:text-sm fg:text-muted">
|
|
74
|
+
Reviews can be edited for 30 days after they're written. This one can no longer be changed.
|
|
75
|
+
</p>
|
|
76
|
+
</Panel>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const displayStars = hoverRating || form.rating;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="fg:mx-auto fg:w-full fg:max-w-xl fg:px-4 fg:py-6">
|
|
84
|
+
<form
|
|
85
|
+
className="fg:flex fg:flex-col fg:gap-5 fg:rounded-xl fg:border fg:border-border fg:bg-surface fg:p-6"
|
|
86
|
+
onSubmit={(e) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
form.submit();
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
<div>
|
|
92
|
+
<h1 className="fg:text-2xl fg:font-extrabold fg:text-text">
|
|
93
|
+
{form.existingReview ? "Edit your review" : "Leave a review"}
|
|
94
|
+
</h1>
|
|
95
|
+
{form.entityTitle && (
|
|
96
|
+
<p className="fg:mt-1 fg:text-muted">
|
|
97
|
+
How was <span className="fg:font-bold fg:text-text">{form.entityTitle}</span>?
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
{form.existingReview && (
|
|
101
|
+
<p className="fg:mt-1.5 fg:text-sm fg:text-muted">
|
|
102
|
+
You can edit your review within 30 days of writing it. Edits are re-checked before publishing.
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{/* Star picker */}
|
|
108
|
+
<div>
|
|
109
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">Your rating *</span>
|
|
110
|
+
<div className="fg:flex fg:gap-1.5" role="radiogroup" aria-label="Rating" onMouseLeave={() => setHoverRating(0)}>
|
|
111
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
112
|
+
<button
|
|
113
|
+
key={star}
|
|
114
|
+
type="button"
|
|
115
|
+
role="radio"
|
|
116
|
+
aria-checked={form.rating === star}
|
|
117
|
+
aria-label={`${star} star${star === 1 ? "" : "s"}`}
|
|
118
|
+
onClick={() => form.setRating(star)}
|
|
119
|
+
onMouseEnter={() => setHoverRating(star)}
|
|
120
|
+
className="fg:p-0.5 fg:leading-none"
|
|
121
|
+
>
|
|
122
|
+
<Star
|
|
123
|
+
size={30}
|
|
124
|
+
className={star <= displayStars ? "fg:text-primary" : "fg:text-muted/50"}
|
|
125
|
+
fill={star <= displayStars ? "currentColor" : "transparent"}
|
|
126
|
+
/>
|
|
127
|
+
</button>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<label className="fg:block">
|
|
133
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">
|
|
134
|
+
Title <span className="fg:font-normal fg:text-muted">(optional)</span>
|
|
135
|
+
</span>
|
|
136
|
+
<input
|
|
137
|
+
type="text"
|
|
138
|
+
maxLength={200}
|
|
139
|
+
value={form.title}
|
|
140
|
+
onChange={(e) => form.setTitle(e.target.value)}
|
|
141
|
+
placeholder="Sum it up in a few words"
|
|
142
|
+
className="fg:w-full fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-text fg:outline-none fg:focus:border-primary"
|
|
143
|
+
/>
|
|
144
|
+
</label>
|
|
145
|
+
|
|
146
|
+
<label className="fg:block">
|
|
147
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">
|
|
148
|
+
Your review <span className="fg:font-normal fg:text-muted">(optional)</span>
|
|
149
|
+
</span>
|
|
150
|
+
<textarea
|
|
151
|
+
rows={5}
|
|
152
|
+
maxLength={5000}
|
|
153
|
+
value={form.body}
|
|
154
|
+
onChange={(e) => form.setBody(e.target.value)}
|
|
155
|
+
placeholder="What did you like or dislike?"
|
|
156
|
+
className="fg:w-full fg:resize-y fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-text fg:outline-none fg:focus:border-primary"
|
|
157
|
+
/>
|
|
158
|
+
</label>
|
|
159
|
+
|
|
160
|
+
<label className="fg:block">
|
|
161
|
+
<span className="fg:mb-1.5 fg:block fg:text-sm fg:font-semibold fg:text-text">Display name</span>
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
maxLength={100}
|
|
165
|
+
value={form.reviewerName}
|
|
166
|
+
onChange={(e) => form.setReviewerName(e.target.value)}
|
|
167
|
+
className="fg:w-full fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-text fg:outline-none fg:focus:border-primary"
|
|
168
|
+
/>
|
|
169
|
+
<span className="fg:mt-1 fg:block fg:text-xs fg:text-muted">Shown publicly next to your review.</span>
|
|
170
|
+
</label>
|
|
171
|
+
|
|
172
|
+
{form.errorMessage && (
|
|
173
|
+
<p className="fg:rounded-lg fg:border fg:border-border fg:bg-background fg:px-3 fg:py-2 fg:text-sm fg:text-text">
|
|
174
|
+
{form.errorMessage}
|
|
175
|
+
</p>
|
|
176
|
+
)}
|
|
177
|
+
|
|
178
|
+
<button
|
|
179
|
+
type="submit"
|
|
180
|
+
disabled={!form.canSubmit}
|
|
181
|
+
className="fg:w-full fg:rounded-lg fg:bg-primary fg:py-2.5 fg:font-semibold fg:disabled:opacity-50"
|
|
182
|
+
style={{ color: "var(--forge-text-primary, #fff)" }}
|
|
183
|
+
>
|
|
184
|
+
{form.isSubmitting ? "Submitting…" : form.existingReview ? "Update review" : "Submit review"}
|
|
185
|
+
</button>
|
|
186
|
+
</form>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function StarRow({ rating, size = 16 }: { rating: number; size?: number }) {
|
|
192
|
+
return (
|
|
193
|
+
<span className="fg:inline-flex fg:gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
|
194
|
+
{[1, 2, 3, 4, 5].map((star) => (
|
|
195
|
+
<Star
|
|
196
|
+
key={star}
|
|
197
|
+
size={size}
|
|
198
|
+
className={star <= Math.round(rating) ? "fg:text-primary" : "fg:text-muted/50"}
|
|
199
|
+
fill={star <= Math.round(rating) ? "currentColor" : "transparent"}
|
|
200
|
+
/>
|
|
201
|
+
))}
|
|
202
|
+
</span>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function Panel({ children }: { children: React.ReactNode }) {
|
|
207
|
+
return (
|
|
208
|
+
<div className="fg:mx-auto fg:w-full fg:max-w-xl fg:px-4 fg:py-6">
|
|
209
|
+
<div className="fg:flex fg:flex-col fg:items-center fg:gap-3 fg:rounded-xl fg:border fg:border-border fg:bg-surface fg:p-8 fg:text-center">
|
|
210
|
+
{children}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -15,11 +15,13 @@ export interface SignupFormProps {
|
|
|
15
15
|
* create-account request, where a coupon can turn it into a membership.
|
|
16
16
|
*/
|
|
17
17
|
membershipTierId?: string;
|
|
18
|
+
/** The membership invite token riding on the same redirect, if any. See `MembershipTierCallout`. */
|
|
19
|
+
membershipInviteToken?: string;
|
|
18
20
|
couponCode?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/** Themed account signup form, built on `useSignupForm`. */
|
|
22
|
-
export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode }: SignupFormProps) {
|
|
24
|
+
export function SignupForm({ onSuccess, loginHref, membershipTierId, membershipInviteToken, couponCode }: SignupFormProps) {
|
|
23
25
|
const t = useForgeT();
|
|
24
26
|
const theme = useForgeTheme();
|
|
25
27
|
const form = useSignupForm({ onSuccess, membershipTierId, couponCode });
|
|
@@ -73,7 +75,7 @@ export function SignupForm({ onSuccess, loginHref, membershipTierId, couponCode
|
|
|
73
75
|
<div style={card}>
|
|
74
76
|
{/* What the fan was doing when they hit this wall, kept in view through
|
|
75
77
|
the emailed-code step as well. Nothing is drawn without a tier. */}
|
|
76
|
-
<MembershipTierCallout membershipTierId={membershipTierId} />
|
|
78
|
+
<MembershipTierCallout membershipTierId={membershipTierId} inviteToken={membershipInviteToken} />
|
|
77
79
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
|
|
78
80
|
{form.awaitingCode ? t("forge.signup_form.verify_title") : t("forge.signup_form.title")}
|
|
79
81
|
</h1>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
3
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
4
|
+
import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
|
|
5
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
6
|
+
import { ForgeClientProvider } from "../../../provider/ForgeProvider";
|
|
7
|
+
import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
|
|
8
|
+
import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
|
|
9
|
+
import { AccountDashboard } from "../AccountDashboard";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The account page during a free trial: the date the trial ends and what is
|
|
13
|
+
* charged after it replace "Renews on", and cancelling says access lasts to the
|
|
14
|
+
* trial end with nothing charged.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
18
|
+
const inDays = (n: number) => new Date(Date.now() + n * DAY).toISOString();
|
|
19
|
+
const fmt = (iso: string) =>
|
|
20
|
+
new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
|
21
|
+
|
|
22
|
+
const memberWith = (membership: Record<string, unknown>) => ({
|
|
23
|
+
id: "account-1",
|
|
24
|
+
email: "fan@example.com",
|
|
25
|
+
firstName: "Fan",
|
|
26
|
+
lastName: "Person",
|
|
27
|
+
kind: "fan",
|
|
28
|
+
status: "active",
|
|
29
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
30
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
31
|
+
membership: {
|
|
32
|
+
id: "membership-1",
|
|
33
|
+
membershipTierId: "tier-1",
|
|
34
|
+
status: "active",
|
|
35
|
+
startDate: "2026-09-17T00:00:00.000Z",
|
|
36
|
+
subscriptionAmount: 10,
|
|
37
|
+
subscriptionCurrency: "USD",
|
|
38
|
+
billingCycle: "month",
|
|
39
|
+
membershipTier: { id: "tier-1", name: "Inner Circle", benefits: [] },
|
|
40
|
+
...membership,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
let realAdapter: AxiosAdapter | undefined;
|
|
45
|
+
|
|
46
|
+
function mount(user: unknown) {
|
|
47
|
+
// Every request answers empty, so the tab's side queries never reach a network.
|
|
48
|
+
realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
|
|
49
|
+
axios.defaults.adapter = (async (config: InternalAxiosRequestConfig) =>
|
|
50
|
+
({ data: [], status: 200, statusText: "OK", headers: {}, config }) as never) as AxiosAdapter;
|
|
51
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
52
|
+
return render(
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
<ForgeClientProvider apiUrl="http://api.test" profileId="profile-1">
|
|
55
|
+
<PublicAuthContext.Provider
|
|
56
|
+
value={{ user, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
57
|
+
>
|
|
58
|
+
<ForgeThemeProvider theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}>
|
|
59
|
+
<AccountDashboard tab="membership" onNavigateMembership={() => {}} />
|
|
60
|
+
</ForgeThemeProvider>
|
|
61
|
+
</PublicAuthContext.Provider>
|
|
62
|
+
</ForgeClientProvider>
|
|
63
|
+
</QueryClientProvider>,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
cleanup();
|
|
69
|
+
if (realAdapter) axios.defaults.adapter = realAdapter;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("AccountDashboard: a membership in its free trial", () => {
|
|
73
|
+
it("shows the trial end and what is charged after it, instead of Renews on", () => {
|
|
74
|
+
const ends = inDays(10);
|
|
75
|
+
mount(memberWith({ trialEndsAt: ends, endDate: ends }));
|
|
76
|
+
|
|
77
|
+
const state = screen.getByTestId("membership-trial-state");
|
|
78
|
+
expect(state.textContent).toContain(`Free trial until ${fmt(ends)}`);
|
|
79
|
+
expect(state.textContent).toMatch(new RegExp(`Then .*10.* per month from ${fmt(ends)}\\.`));
|
|
80
|
+
expect(document.body.textContent).not.toContain("Renews on");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("says access lasts to the trial end with nothing charged when cancelling", () => {
|
|
84
|
+
const ends = inDays(10);
|
|
85
|
+
mount(memberWith({ trialEndsAt: ends, endDate: ends }));
|
|
86
|
+
|
|
87
|
+
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
|
88
|
+
const dialog = screen.getByRole("alertdialog");
|
|
89
|
+
expect(dialog.textContent).toContain(
|
|
90
|
+
`Your free trial will not turn into a paid membership. You keep your benefits until ${fmt(ends)}, and nothing is charged.`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("says nothing will be charged once a trial is cancelled", () => {
|
|
95
|
+
const ends = inDays(10);
|
|
96
|
+
mount(memberWith({ trialEndsAt: ends, endDate: ends, cancelAtPeriodEnd: true }));
|
|
97
|
+
expect(screen.getByTestId("membership-trial-state").textContent).toContain("Cancelled. Nothing will be charged.");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("shows no trial line once the trial has ended", () => {
|
|
101
|
+
mount(memberWith({ trialEndsAt: inDays(-5), endDate: inDays(25) }));
|
|
102
|
+
expect(screen.queryByTestId("membership-trial-state")).toBeNull();
|
|
103
|
+
expect(document.body.textContent).toContain("Renews on");
|
|
104
|
+
});
|
|
105
|
+
});
|