@startsimpli/billing 0.4.12 → 0.4.15
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/README.md +285 -87
- package/package.json +14 -13
- package/src/components/CurrentPlanCard.test.tsx +289 -0
- package/src/components/CurrentPlanCard.tsx +317 -0
- package/src/components/LimitNudge.test.tsx +132 -0
- package/src/components/LimitNudge.tsx +84 -0
- package/src/components/SubscriptionManager.test.tsx +772 -20
- package/src/components/SubscriptionManager.tsx +337 -81
- package/src/components/index.ts +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUsage.ts +94 -0
- package/src/index.ts +7 -1
- package/src/mock/__tests__/mock-backend.test.ts +128 -0
- package/src/mock/backend.ts +25 -0
- package/src/mock/index.ts +7 -0
- package/src/mock/mock-backend.ts +103 -0
- package/src/types/index.ts +44 -1
- package/src/utils/api.ts +72 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for <CurrentPlanCard /> — startsim-brx.
|
|
3
|
+
*
|
|
4
|
+
* Presentational component, so tests don't need a BillingProvider — they pass
|
|
5
|
+
* subscription + usage as props directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, vi } from "vitest";
|
|
9
|
+
import { render, screen } from "@testing-library/react";
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { CurrentPlanCard } from "./CurrentPlanCard";
|
|
12
|
+
import type { SubscriptionInfo, UsageMap } from "../types";
|
|
13
|
+
|
|
14
|
+
const freeSub: SubscriptionInfo = {
|
|
15
|
+
id: "sub-free",
|
|
16
|
+
offer: {
|
|
17
|
+
id: "o-free",
|
|
18
|
+
name: "Free",
|
|
19
|
+
slug: "vault-free",
|
|
20
|
+
unitPrice: "0.00",
|
|
21
|
+
currency: "USD",
|
|
22
|
+
pricingModel: "flat",
|
|
23
|
+
intervalUnit: "month",
|
|
24
|
+
intervalCount: 1,
|
|
25
|
+
intervalDisplay: "per month",
|
|
26
|
+
isRecurring: true,
|
|
27
|
+
isFree: true,
|
|
28
|
+
features: [
|
|
29
|
+
{ key: "environments", name: "Environments", value: 3, limit: 3 },
|
|
30
|
+
{ key: "secrets_per_env", name: "Secrets per environment", value: 25, limit: 25 },
|
|
31
|
+
{ key: "cli_exchange", name: "simpli CLI exchange", value: true },
|
|
32
|
+
{ key: "audit_log", name: "Audit log", value: true },
|
|
33
|
+
],
|
|
34
|
+
isActive: true,
|
|
35
|
+
isFeatured: false,
|
|
36
|
+
trialDays: 0,
|
|
37
|
+
sortOrder: 0,
|
|
38
|
+
ctaText: "Get Started",
|
|
39
|
+
},
|
|
40
|
+
status: "active",
|
|
41
|
+
currentPeriodStart: null,
|
|
42
|
+
currentPeriodEnd: null,
|
|
43
|
+
cancelAtPeriodEnd: false,
|
|
44
|
+
trialEnd: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const proSub: SubscriptionInfo = {
|
|
48
|
+
...freeSub,
|
|
49
|
+
id: "sub-pro",
|
|
50
|
+
offer: {
|
|
51
|
+
...freeSub.offer,
|
|
52
|
+
id: "o-pro",
|
|
53
|
+
name: "Pro",
|
|
54
|
+
slug: "vault-pro",
|
|
55
|
+
unitPrice: "12.00",
|
|
56
|
+
isFree: false,
|
|
57
|
+
isFeatured: true,
|
|
58
|
+
trialDays: 14,
|
|
59
|
+
features: [
|
|
60
|
+
{ key: "environments", name: "Environments", value: -1, limit: -1 },
|
|
61
|
+
{ key: "secrets_per_env", name: "Secrets per environment", value: -1, limit: -1 },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("<CurrentPlanCard />", () => {
|
|
67
|
+
it("renders the offer name and status badge", () => {
|
|
68
|
+
render(<CurrentPlanCard subscription={freeSub} usage={{}} />);
|
|
69
|
+
// "Free" appears twice (name in h2 + price block) — both expected.
|
|
70
|
+
expect(screen.getAllByText("Free").length).toBeGreaterThanOrEqual(1);
|
|
71
|
+
expect(screen.getByTestId("plan-status").textContent).toMatch(/active/i);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("renders 'Free' (not '$0') when offer.isFree", () => {
|
|
75
|
+
render(<CurrentPlanCard subscription={freeSub} usage={{}} />);
|
|
76
|
+
// Heading shows the offer name. The price block also shows "Free".
|
|
77
|
+
const freeNodes = screen.getAllByText(/^Free$/i);
|
|
78
|
+
expect(freeNodes.length).toBeGreaterThanOrEqual(1);
|
|
79
|
+
expect(screen.queryByText(/\$0/)).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders dollar price + interval for paid plans", () => {
|
|
83
|
+
render(<CurrentPlanCard subscription={proSub} usage={{}} />);
|
|
84
|
+
expect(screen.getByText("$12")).toBeDefined();
|
|
85
|
+
expect(screen.getByText(/per month/i)).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders the offer feature list", () => {
|
|
89
|
+
render(<CurrentPlanCard subscription={freeSub} usage={{}} />);
|
|
90
|
+
expect(screen.getByText(/up to 3 environments/i)).toBeDefined();
|
|
91
|
+
expect(screen.getByText(/up to 25 secrets per environment/i)).toBeDefined();
|
|
92
|
+
expect(screen.getByText(/simpli cli exchange/i)).toBeDefined();
|
|
93
|
+
expect(screen.getByText(/audit log/i)).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders 'Unlimited X' for limit -1 (Pro features)", () => {
|
|
97
|
+
render(<CurrentPlanCard subscription={proSub} usage={{}} />);
|
|
98
|
+
expect(screen.getByText(/unlimited environments/i)).toBeDefined();
|
|
99
|
+
expect(screen.getByText(/unlimited secrets per environment/i)).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("shows usage progress bar + 'used / limit' text when usage data exists for a feature", () => {
|
|
103
|
+
const usage: UsageMap = {
|
|
104
|
+
environments: { used: 2, limit: 3 },
|
|
105
|
+
secrets_per_env: { used: 12, limit: 25 },
|
|
106
|
+
};
|
|
107
|
+
render(<CurrentPlanCard subscription={freeSub} usage={usage} />);
|
|
108
|
+
expect(screen.getByTestId("usage-count-environments").textContent).toMatch(/2\s*\/\s*3/);
|
|
109
|
+
expect(screen.getByTestId("usage-count-secrets_per_env").textContent).toMatch(/12\s*\/\s*25/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("hides the progress bar for features without a numeric limit (cli_exchange = boolean)", () => {
|
|
113
|
+
const usage: UsageMap = { environments: { used: 1, limit: 3 } };
|
|
114
|
+
render(<CurrentPlanCard subscription={freeSub} usage={usage} />);
|
|
115
|
+
expect(screen.queryByTestId("usage-count-cli_exchange")).toBeNull();
|
|
116
|
+
expect(screen.queryByTestId("usage-count-audit_log")).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("hides the progress bar for unlimited (limit -1) features", () => {
|
|
120
|
+
const usage: UsageMap = { environments: { used: 99, limit: -1 } };
|
|
121
|
+
render(<CurrentPlanCard subscription={proSub} usage={usage} />);
|
|
122
|
+
expect(screen.queryByTestId("usage-count-environments")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("renders the upgradeCta slot when provided", () => {
|
|
126
|
+
render(
|
|
127
|
+
<CurrentPlanCard
|
|
128
|
+
subscription={freeSub}
|
|
129
|
+
usage={{}}
|
|
130
|
+
upgradeCta={<button data-testid="custom-upgrade">Upgrade Now</button>}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
expect(screen.getByTestId("custom-upgrade")).toBeDefined();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ───────────────────────────────────────────────────────────────
|
|
137
|
+
// State-driven banners — startsim-br2, -xe5, -dyg
|
|
138
|
+
// ───────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function withStatus(s: Partial<SubscriptionInfo>): SubscriptionInfo {
|
|
141
|
+
return { ...proSub, ...s };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it("trialing: shows countdown banner with days-remaining + 'Cancel trial' (br2)", () => {
|
|
145
|
+
// +7d 5min to avoid Math.floor drift on the exact-7-days boundary.
|
|
146
|
+
const trialEnd = new Date(Date.now() + 7 * 86_400_000 + 5 * 60_000).toISOString();
|
|
147
|
+
const onPortalClick = vi.fn();
|
|
148
|
+
render(
|
|
149
|
+
<CurrentPlanCard
|
|
150
|
+
subscription={withStatus({ status: "trialing", trialEnd })}
|
|
151
|
+
usage={{}}
|
|
152
|
+
onPortalClick={onPortalClick}
|
|
153
|
+
/>,
|
|
154
|
+
);
|
|
155
|
+
const banner = screen.getByTestId("banner-trialing");
|
|
156
|
+
expect(banner.textContent).toMatch(/trial ends in 7 days/i);
|
|
157
|
+
expect(banner.textContent).toMatch(/cancel trial/i);
|
|
158
|
+
(banner.querySelector("button") as HTMLButtonElement).click();
|
|
159
|
+
expect(onPortalClick).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("trialing-ends-tomorrow: 'ends tomorrow' phrasing (br2)", () => {
|
|
163
|
+
const trialEnd = new Date(Date.now() + 1 * 86_400_000 + 5 * 60_000).toISOString();
|
|
164
|
+
render(
|
|
165
|
+
<CurrentPlanCard
|
|
166
|
+
subscription={withStatus({ status: "trialing", trialEnd })}
|
|
167
|
+
usage={{}}
|
|
168
|
+
onPortalClick={() => {}}
|
|
169
|
+
/>,
|
|
170
|
+
);
|
|
171
|
+
expect(screen.getByTestId("banner-trialing").textContent).toMatch(/ends tomorrow/i);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("trialing-ends-today: 'ends today' phrasing (br2)", () => {
|
|
175
|
+
const trialEnd = new Date(Date.now() + 1 * 60_000).toISOString(); // just under 1 day
|
|
176
|
+
render(
|
|
177
|
+
<CurrentPlanCard
|
|
178
|
+
subscription={withStatus({ status: "trialing", trialEnd })}
|
|
179
|
+
usage={{}}
|
|
180
|
+
onPortalClick={() => {}}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
expect(screen.getByTestId("banner-trialing").textContent).toMatch(/ends today/i);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("past_due: red 'Payment failed' banner with portal CTA (xe5)", () => {
|
|
187
|
+
const onPortalClick = vi.fn();
|
|
188
|
+
render(
|
|
189
|
+
<CurrentPlanCard
|
|
190
|
+
subscription={withStatus({ status: "past_due", currentPeriodEnd: "2026-06-27T00:00:00Z" })}
|
|
191
|
+
usage={{}}
|
|
192
|
+
onPortalClick={onPortalClick}
|
|
193
|
+
/>,
|
|
194
|
+
);
|
|
195
|
+
const banner = screen.getByTestId("banner-past-due");
|
|
196
|
+
expect(banner.textContent).toMatch(/payment failed/i);
|
|
197
|
+
expect(banner.textContent).toMatch(/update.*card/i);
|
|
198
|
+
expect(banner.getAttribute("role")).toBe("alert");
|
|
199
|
+
(banner.querySelector("button") as HTMLButtonElement).click();
|
|
200
|
+
expect(onPortalClick).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("cancelled-in-period: shows period-end + 'Reactivate' CTA (dyg)", () => {
|
|
204
|
+
const onPortalClick = vi.fn();
|
|
205
|
+
render(
|
|
206
|
+
<CurrentPlanCard
|
|
207
|
+
subscription={withStatus({
|
|
208
|
+
status: "active",
|
|
209
|
+
cancelAtPeriodEnd: true,
|
|
210
|
+
currentPeriodEnd: "2026-06-27T00:00:00Z",
|
|
211
|
+
})}
|
|
212
|
+
usage={{}}
|
|
213
|
+
onPortalClick={onPortalClick}
|
|
214
|
+
/>,
|
|
215
|
+
);
|
|
216
|
+
const banner = screen.getByTestId("banner-cancelled-in-period");
|
|
217
|
+
expect(banner.textContent).toMatch(/cancelled/i);
|
|
218
|
+
expect(banner.textContent).toMatch(/Jun 27, 2026/);
|
|
219
|
+
expect(banner.textContent).toMatch(/reactivate/i);
|
|
220
|
+
(banner.querySelector("button") as HTMLButtonElement).click();
|
|
221
|
+
expect(onPortalClick).toHaveBeenCalledTimes(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("cancelled-status: same banner as cancel-at-period-end (dyg)", () => {
|
|
225
|
+
render(
|
|
226
|
+
<CurrentPlanCard
|
|
227
|
+
subscription={withStatus({ status: "cancelled", currentPeriodEnd: "2026-06-27T00:00:00Z" })}
|
|
228
|
+
usage={{}}
|
|
229
|
+
onPortalClick={() => {}}
|
|
230
|
+
/>,
|
|
231
|
+
);
|
|
232
|
+
expect(screen.getByTestId("banner-cancelled-in-period")).toBeDefined();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("incomplete: 'Finish checkout' banner (bis)", () => {
|
|
236
|
+
const onPortalClick = vi.fn();
|
|
237
|
+
render(
|
|
238
|
+
<CurrentPlanCard
|
|
239
|
+
subscription={withStatus({ status: "incomplete" })}
|
|
240
|
+
usage={{}}
|
|
241
|
+
onPortalClick={onPortalClick}
|
|
242
|
+
/>,
|
|
243
|
+
);
|
|
244
|
+
const banner = screen.getByTestId("banner-incomplete");
|
|
245
|
+
expect(banner.textContent).toMatch(/incomplete/i);
|
|
246
|
+
expect(banner.textContent).toMatch(/finish checkout/i);
|
|
247
|
+
(banner.querySelector("button") as HTMLButtonElement).click();
|
|
248
|
+
expect(onPortalClick).toHaveBeenCalledTimes(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("incomplete_expired: 'Start over' banner (bis)", () => {
|
|
252
|
+
const onPortalClick = vi.fn();
|
|
253
|
+
render(
|
|
254
|
+
<CurrentPlanCard
|
|
255
|
+
subscription={withStatus({ status: "incomplete_expired" })}
|
|
256
|
+
usage={{}}
|
|
257
|
+
onPortalClick={onPortalClick}
|
|
258
|
+
/>,
|
|
259
|
+
);
|
|
260
|
+
const banner = screen.getByTestId("banner-incomplete-expired");
|
|
261
|
+
expect(banner.textContent).toMatch(/expired/i);
|
|
262
|
+
expect(banner.textContent).toMatch(/start over/i);
|
|
263
|
+
(banner.querySelector("button") as HTMLButtonElement).click();
|
|
264
|
+
expect(onPortalClick).toHaveBeenCalledTimes(1);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("active paid: NO banner rendered", () => {
|
|
268
|
+
render(<CurrentPlanCard subscription={proSub} usage={{}} onPortalClick={() => {}} />);
|
|
269
|
+
expect(screen.queryByTestId("banner-trialing")).toBeNull();
|
|
270
|
+
expect(screen.queryByTestId("banner-past-due")).toBeNull();
|
|
271
|
+
expect(screen.queryByTestId("banner-cancelled-in-period")).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("renders nothing for boolean-false features (no bragging about what you don't have)", () => {
|
|
275
|
+
const subWithFalse: SubscriptionInfo = {
|
|
276
|
+
...freeSub,
|
|
277
|
+
offer: {
|
|
278
|
+
...freeSub.offer,
|
|
279
|
+
features: [
|
|
280
|
+
{ key: "sso", name: "SSO", value: false },
|
|
281
|
+
{ key: "audit_log", name: "Audit log", value: true },
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
render(<CurrentPlanCard subscription={subWithFalse} usage={{}} />);
|
|
286
|
+
expect(screen.queryByText(/^SSO$/i)).toBeNull();
|
|
287
|
+
expect(screen.getByText(/audit log/i)).toBeDefined();
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CurrentPlanCard /> — startsim-brx.
|
|
3
|
+
*
|
|
4
|
+
* The "you are here" widget on every app's billing page. Replaces the inline
|
|
5
|
+
* price-only block SubscriptionManager used to render and adds:
|
|
6
|
+
* - the offer's features as bullets,
|
|
7
|
+
* - usage progress bars for each numeric-limit feature
|
|
8
|
+
* (used via the `usage` prop, typically wired by useUsage),
|
|
9
|
+
* - a trial-ending banner when the sub is in `trialing` state.
|
|
10
|
+
*
|
|
11
|
+
* Stays presentational: takes the subscription + usage as props so it can be
|
|
12
|
+
* unit-tested without a BillingProvider context. Apps consume this through
|
|
13
|
+
* SubscriptionManager — no app-local copy of this card.
|
|
14
|
+
*/
|
|
15
|
+
"use client";
|
|
16
|
+
|
|
17
|
+
import React from "react";
|
|
18
|
+
import type { OfferFeature, SubscriptionInfo, UsageMap } from "../types";
|
|
19
|
+
|
|
20
|
+
export interface CurrentPlanCardProps {
|
|
21
|
+
subscription: SubscriptionInfo;
|
|
22
|
+
usage: UsageMap;
|
|
23
|
+
/** Optional Upgrade CTA slot — SubscriptionManager wires this when the
|
|
24
|
+
* subscription is on a free offer and there's a featured paid offer. */
|
|
25
|
+
upgradeCta?: React.ReactNode;
|
|
26
|
+
/** Called when the user clicks a state-banner CTA that should route
|
|
27
|
+
* through Stripe customer portal — past-due "Update card", cancelled
|
|
28
|
+
* "Reactivate", trial "Cancel trial". SubscriptionManager wires this to
|
|
29
|
+
* usePortal so apps don't reinvent it. startsim-br2 + startsim-xe5 + startsim-dyg. */
|
|
30
|
+
onPortalClick?: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function priceLabel(offer: SubscriptionInfo["offer"]): string {
|
|
34
|
+
if (offer.isFree) return "Free";
|
|
35
|
+
const n = Math.floor(parseFloat(offer.unitPrice) || 0);
|
|
36
|
+
return `$${n}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isProgressBarFeature(f: OfferFeature): boolean {
|
|
40
|
+
return typeof f.limit === "number" && f.limit > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function featureLabel(f: OfferFeature): string | null {
|
|
44
|
+
if (f.value === false) return null;
|
|
45
|
+
if (typeof f.value === "number" || typeof f.limit === "number") {
|
|
46
|
+
const limit = typeof f.limit === "number" ? f.limit
|
|
47
|
+
: typeof f.value === "number" ? (f.value as number) : null;
|
|
48
|
+
if (limit === -1) return `Unlimited ${f.name.toLowerCase()}`;
|
|
49
|
+
if (limit !== null && limit > 0) return `Up to ${limit} ${f.name.toLowerCase()}`;
|
|
50
|
+
}
|
|
51
|
+
return f.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
|
|
55
|
+
let cls = "inline-block text-xs font-semibold px-3 py-1 rounded-full ";
|
|
56
|
+
switch (status) {
|
|
57
|
+
case "active": cls += "bg-green-50 text-green-700"; break;
|
|
58
|
+
case "trialing": cls += "bg-blue-50 text-blue-700"; break;
|
|
59
|
+
case "past_due": cls += "bg-yellow-50 text-yellow-700 warning"; break;
|
|
60
|
+
case "cancelled": cls += "bg-gray-100 text-gray-600"; break;
|
|
61
|
+
case "incomplete": cls += "bg-yellow-50 text-yellow-700"; break;
|
|
62
|
+
case "incomplete_expired":cls += "bg-red-50 text-red-700"; break;
|
|
63
|
+
case "unpaid": cls += "bg-red-50 text-red-700"; break;
|
|
64
|
+
default: cls += "bg-gray-100 text-gray-600";
|
|
65
|
+
}
|
|
66
|
+
const labels: Record<string, string> = {
|
|
67
|
+
active: "Active",
|
|
68
|
+
trialing: "Trialing",
|
|
69
|
+
past_due: "Past due",
|
|
70
|
+
cancelled: "Cancelled",
|
|
71
|
+
incomplete: "Incomplete",
|
|
72
|
+
incomplete_expired: "Expired",
|
|
73
|
+
unpaid: "Unpaid",
|
|
74
|
+
};
|
|
75
|
+
const readable = labels[status] ?? status;
|
|
76
|
+
return (
|
|
77
|
+
<span
|
|
78
|
+
data-testid="plan-status"
|
|
79
|
+
role="status"
|
|
80
|
+
aria-label={`Subscription status: ${readable}`}
|
|
81
|
+
className={cls}
|
|
82
|
+
>
|
|
83
|
+
{readable}
|
|
84
|
+
{status === "trialing" && trialEnd && (
|
|
85
|
+
<span className="ml-1">until {formatDate(trialEnd)}</span>
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function formatDate(iso: string): string {
|
|
92
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ProgressBar({ used, limit }: { used: number; limit: number }) {
|
|
96
|
+
// Cap at 100% so a hard-overage display can't blow out the layout.
|
|
97
|
+
const pct = Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
|
|
98
|
+
const isFull = pct >= 100;
|
|
99
|
+
return (
|
|
100
|
+
<div className="w-full bg-gray-100 rounded-full h-1.5" aria-hidden="true">
|
|
101
|
+
<div
|
|
102
|
+
className={`h-1.5 rounded-full transition-all ${isFull ? "bg-red-500" : "bg-blue-600"}`}
|
|
103
|
+
style={{ width: `${pct}%` }}
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function FeatureRow({ feature, used }: { feature: OfferFeature; used: number | null }) {
|
|
110
|
+
const label = featureLabel(feature);
|
|
111
|
+
if (label === null) return null;
|
|
112
|
+
|
|
113
|
+
const showBar = isProgressBarFeature(feature) && used !== null;
|
|
114
|
+
const limit = feature.limit as number;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<li data-testid={`current-plan-feature-${feature.key}`} className="flex flex-col gap-1">
|
|
118
|
+
<div className="flex items-start gap-2 text-sm text-gray-700">
|
|
119
|
+
<svg className="w-4 h-4 text-green-600 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24"
|
|
120
|
+
stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
|
|
121
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
122
|
+
</svg>
|
|
123
|
+
<span className="flex-1">{label}</span>
|
|
124
|
+
{showBar && (
|
|
125
|
+
<span data-testid={`usage-count-${feature.key}`} className="text-xs text-gray-500 tabular-nums">
|
|
126
|
+
{used} / {limit}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
{showBar && <ProgressBar used={used as number} limit={limit} />}
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Days until ISO date, rounded down; negative if past. */
|
|
136
|
+
function daysUntil(iso: string): number {
|
|
137
|
+
const ms = new Date(iso).getTime() - Date.now();
|
|
138
|
+
return Math.floor(ms / 86_400_000);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Pretty "Jun 27, 2026" style — easier to scan than ISO yyyy-mm-dd in banners. */
|
|
142
|
+
function prettyDate(iso: string): string {
|
|
143
|
+
return new Date(iso).toLocaleDateString("en-US", {
|
|
144
|
+
year: "numeric", month: "short", day: "numeric", timeZone: "UTC",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface BannerProps {
|
|
149
|
+
testId: string;
|
|
150
|
+
variant: "info" | "warning" | "danger" | "neutral";
|
|
151
|
+
message: React.ReactNode;
|
|
152
|
+
action?: { label: string; onClick: () => void; variant?: "primary" | "secondary" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function StateBanner({ testId, variant, message, action }: BannerProps) {
|
|
156
|
+
const wrap =
|
|
157
|
+
variant === "danger" ? "bg-red-50 border-red-200 text-red-800"
|
|
158
|
+
: variant === "warning" ? "bg-yellow-50 border-yellow-200 text-yellow-900"
|
|
159
|
+
: variant === "info" ? "bg-blue-50 border-blue-200 text-blue-900"
|
|
160
|
+
: "bg-gray-50 border-gray-200 text-gray-800";
|
|
161
|
+
const btn =
|
|
162
|
+
variant === "danger" ? "bg-red-600 text-white hover:bg-red-700"
|
|
163
|
+
: variant === "warning" ? "bg-yellow-600 text-white hover:bg-yellow-700"
|
|
164
|
+
: variant === "info" ? "bg-blue-600 text-white hover:bg-blue-700"
|
|
165
|
+
: "bg-gray-900 text-white hover:bg-gray-800";
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
data-testid={testId}
|
|
169
|
+
className={`mb-4 rounded-lg border px-4 py-3 flex items-center justify-between gap-3 ${wrap}`}
|
|
170
|
+
role={variant === "danger" ? "alert" : "status"}
|
|
171
|
+
>
|
|
172
|
+
<p className="text-sm font-medium">{message}</p>
|
|
173
|
+
{action && (
|
|
174
|
+
<button
|
|
175
|
+
onClick={action.onClick}
|
|
176
|
+
className={`shrink-0 text-sm font-semibold px-3 py-1.5 rounded-lg cursor-pointer transition-colors ${btn}`}
|
|
177
|
+
>
|
|
178
|
+
{action.label}
|
|
179
|
+
</button>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick }: CurrentPlanCardProps) {
|
|
186
|
+
const offer = subscription.offer;
|
|
187
|
+
const features = offer.features ?? [];
|
|
188
|
+
|
|
189
|
+
// State-driven banner choice — at most one banner at the top of the card.
|
|
190
|
+
// Precedence: past_due > cancelled-in-period > trialing > none.
|
|
191
|
+
let stateBanner: React.ReactNode = null;
|
|
192
|
+
if (subscription.status === "past_due" && onPortalClick) {
|
|
193
|
+
// startsim-xe5: red banner with portal CTA so users fix the card fast.
|
|
194
|
+
stateBanner = (
|
|
195
|
+
<StateBanner
|
|
196
|
+
testId="banner-past-due"
|
|
197
|
+
variant="danger"
|
|
198
|
+
message={
|
|
199
|
+
<>
|
|
200
|
+
Payment failed{subscription.currentPeriodEnd ? ` on ${prettyDate(subscription.currentPeriodEnd)}` : ""}.
|
|
201
|
+
{" "}Update your card to keep your subscription active.
|
|
202
|
+
</>
|
|
203
|
+
}
|
|
204
|
+
action={{ label: "Update card", onClick: onPortalClick }}
|
|
205
|
+
/>
|
|
206
|
+
);
|
|
207
|
+
} else if (subscription.status === "cancelled" || subscription.cancelAtPeriodEnd) {
|
|
208
|
+
// startsim-dyg: cancelled-in-period — user keeps access until the end of
|
|
209
|
+
// the period, can reactivate via portal.
|
|
210
|
+
const endDate = subscription.currentPeriodEnd ? prettyDate(subscription.currentPeriodEnd) : "the end of your period";
|
|
211
|
+
stateBanner = (
|
|
212
|
+
<StateBanner
|
|
213
|
+
testId="banner-cancelled-in-period"
|
|
214
|
+
variant="neutral"
|
|
215
|
+
message={
|
|
216
|
+
<>
|
|
217
|
+
Your {offer.name} plan is cancelled. You'll keep access until {endDate}.
|
|
218
|
+
</>
|
|
219
|
+
}
|
|
220
|
+
action={onPortalClick ? { label: "Reactivate", onClick: onPortalClick } : undefined}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
} else if ((subscription.status === "incomplete" || subscription.status === "incomplete_expired") && onPortalClick) {
|
|
224
|
+
// startsim-bis — checkout was abandoned mid-flow (incomplete) or expired
|
|
225
|
+
// after 24h. Both states are unrecoverable without going back through
|
|
226
|
+
// Stripe; nudge the user toward picking up where they left off. Re-uses
|
|
227
|
+
// the portal CTA so apps don't reimplement the redirect.
|
|
228
|
+
const expired = subscription.status === "incomplete_expired";
|
|
229
|
+
stateBanner = (
|
|
230
|
+
<StateBanner
|
|
231
|
+
testId={`banner-${subscription.status.replace("_", "-")}`}
|
|
232
|
+
variant="warning"
|
|
233
|
+
message={
|
|
234
|
+
expired
|
|
235
|
+
? `Your ${offer.name} checkout expired. Start over to activate the plan.`
|
|
236
|
+
: `Your ${offer.name} plan setup is incomplete. Finish checkout to activate.`
|
|
237
|
+
}
|
|
238
|
+
action={{
|
|
239
|
+
label: expired ? "Start over" : "Finish checkout",
|
|
240
|
+
onClick: onPortalClick,
|
|
241
|
+
}}
|
|
242
|
+
/>
|
|
243
|
+
);
|
|
244
|
+
} else if (subscription.status === "trialing" && subscription.trialEnd) {
|
|
245
|
+
// startsim-br2: trial countdown — gentle info banner until ≤3 days, then
|
|
246
|
+
// upgrade to warning so users notice.
|
|
247
|
+
const days = daysUntil(subscription.trialEnd);
|
|
248
|
+
const variant: "info" | "warning" = days <= 3 ? "warning" : "info";
|
|
249
|
+
const label = days <= 0
|
|
250
|
+
? `Your trial ends today (${prettyDate(subscription.trialEnd)}).`
|
|
251
|
+
: days === 1
|
|
252
|
+
? `Your trial ends tomorrow (${prettyDate(subscription.trialEnd)}).`
|
|
253
|
+
: `Your trial ends in ${days} days (${prettyDate(subscription.trialEnd)}).`;
|
|
254
|
+
stateBanner = (
|
|
255
|
+
<StateBanner
|
|
256
|
+
testId="banner-trialing"
|
|
257
|
+
variant={variant}
|
|
258
|
+
message={label}
|
|
259
|
+
action={onPortalClick ? { label: "Cancel trial", onClick: onPortalClick } : undefined}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div data-testid="current-plan" className="rounded-2xl border border-gray-200 bg-white shadow-sm p-8">
|
|
266
|
+
{stateBanner}
|
|
267
|
+
<div className="flex items-center justify-between mb-4">
|
|
268
|
+
<h2 className="text-xl font-bold text-gray-900">{offer.name}</h2>
|
|
269
|
+
<StatusBadge status={subscription.status} trialEnd={subscription.trialEnd} />
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="mb-4">
|
|
273
|
+
{offer.isFree ? (
|
|
274
|
+
<span aria-label="Free">
|
|
275
|
+
<span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">Free</span>
|
|
276
|
+
</span>
|
|
277
|
+
) : (
|
|
278
|
+
<span aria-label={`${priceLabel(offer)} ${offer.intervalDisplay}`}>
|
|
279
|
+
<span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{priceLabel(offer)}</span>
|
|
280
|
+
<span aria-hidden="true" className="text-base text-gray-500 ml-1">{offer.intervalDisplay}</span>
|
|
281
|
+
</span>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Active paid subs surface "Next billing date" prominently — that's
|
|
286
|
+
what users actually want to know. Free + cancelled + no-period subs
|
|
287
|
+
fall back to the period range / nothing. startsim-7j4. */}
|
|
288
|
+
{subscription.currentPeriodEnd && !offer.isFree && subscription.status === "active" && !subscription.cancelAtPeriodEnd ? (
|
|
289
|
+
<p data-testid="next-billing-date" className="text-sm text-gray-600 mb-6">
|
|
290
|
+
Next billing date: {formatDate(subscription.currentPeriodEnd)}
|
|
291
|
+
</p>
|
|
292
|
+
) : subscription.currentPeriodStart && subscription.currentPeriodEnd ? (
|
|
293
|
+
<p className="text-sm text-gray-500 mb-6">
|
|
294
|
+
Billing period: {formatDate(subscription.currentPeriodStart)} to{" "}
|
|
295
|
+
{formatDate(subscription.currentPeriodEnd)}
|
|
296
|
+
</p>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
299
|
+
{features.length > 0 && (
|
|
300
|
+
<ul data-testid="current-plan-features" className="space-y-3 mb-6">
|
|
301
|
+
{features.map((f) => {
|
|
302
|
+
const u = usage[f.key];
|
|
303
|
+
return (
|
|
304
|
+
<FeatureRow
|
|
305
|
+
key={f.key}
|
|
306
|
+
feature={f}
|
|
307
|
+
used={u && typeof u.used === "number" ? u.used : null}
|
|
308
|
+
/>
|
|
309
|
+
);
|
|
310
|
+
})}
|
|
311
|
+
</ul>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{upgradeCta && <div className="pt-2">{upgradeCta}</div>}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|