@startsimpli/billing 0.4.33 → 0.4.35
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 +16 -3
- package/src/components/UsageMeters.test.tsx +27 -0
- package/src/components/UsageMeters.tsx +85 -0
- package/src/components/index.ts +2 -0
- package/src/credits/CreditBalanceBadge.tsx +13 -0
- package/src/credits/CreditHistoryList.tsx +32 -0
- package/src/credits/CreditPackCard.tsx +51 -0
- package/src/credits/CreditsPanel.tsx +60 -0
- package/src/credits/CreditsReturn.tsx +89 -0
- package/src/credits/UseCreditPrompt.tsx +46 -0
- package/src/credits/copy.ts +96 -0
- package/src/credits/credits.test.tsx +75 -0
- package/src/credits/index.ts +13 -0
- package/src/credits/types.ts +17 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/useAutoCheckout.test.tsx +34 -0
- package/src/hooks/useAutoCheckout.ts +31 -0
- package/src/hooks/useOfferCheckout.test.tsx +73 -0
- package/src/hooks/useOfferCheckout.ts +82 -0
- package/src/index.ts +23 -2
- package/src/utils/checkout-flow.test.ts +56 -0
- package/src/utils/checkout-flow.ts +55 -0
- package/src/utils/credit-math.test.ts +18 -0
- package/src/utils/credit-math.ts +32 -0
- package/src/utils/return-urls.test.ts +38 -0
- package/src/utils/return-urls.ts +74 -0
- package/src/utils/usage-meters.test.ts +20 -0
- package/src/utils/usage-meters.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.35",
|
|
4
4
|
"description": "Universal billing integration for StartSimpli Next.js apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://startsimpli.com",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"./client": "./src/index.ts",
|
|
20
20
|
"./server": "./src/server/index.ts",
|
|
21
21
|
"./plan": "./src/utils/plan.ts",
|
|
22
|
+
"./credits": "./src/credits/index.ts",
|
|
22
23
|
"./types": "./src/types/index.ts",
|
|
23
24
|
"./mock": "./src/mock/index.ts"
|
|
24
25
|
},
|
|
@@ -33,7 +34,17 @@
|
|
|
33
34
|
"peerDependencies": {
|
|
34
35
|
"next": ">=14.0.0",
|
|
35
36
|
"react": "^18.0.0 || ^19.0.0",
|
|
36
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
37
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
38
|
+
"@startsimpli/api": "^0.5.73",
|
|
39
|
+
"@startsimpli/ui": "^0.4.176"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@startsimpli/api": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@startsimpli/ui": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
37
48
|
},
|
|
38
49
|
"devDependencies": {
|
|
39
50
|
"@testing-library/jest-dom": "^6.9.1",
|
|
@@ -49,7 +60,9 @@
|
|
|
49
60
|
"react-dom": "^19.2.5",
|
|
50
61
|
"tsup": "^8.5.1",
|
|
51
62
|
"typescript": "^6.0.3",
|
|
52
|
-
"vitest": "^4.1.5"
|
|
63
|
+
"vitest": "^4.1.5",
|
|
64
|
+
"@startsimpli/api": "0.5.73",
|
|
65
|
+
"@startsimpli/ui": "0.4.176"
|
|
53
66
|
},
|
|
54
67
|
"keywords": [
|
|
55
68
|
"billing",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { UsageMeters } from "./UsageMeters";
|
|
5
|
+
|
|
6
|
+
const state = vi.hoisted(() => ({ usage: {} as Record<string, { used: number; limit: number | null }> }));
|
|
7
|
+
vi.mock("../hooks/useUsage", () => ({ useUsage: () => ({ usage: state.usage, refetch: vi.fn() }) }));
|
|
8
|
+
vi.mock("../hooks/useSubscription", () => ({
|
|
9
|
+
useSubscription: () => ({ subscription: { offer: { features: [{ key: "designs", name: "Designs" }] } } }),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("UsageMeters", () => {
|
|
13
|
+
it("renders nothing without capped features", () => {
|
|
14
|
+
state.usage = { x: { used: 1, limit: null } };
|
|
15
|
+
const { container } = render(<UsageMeters productSlug="p" />);
|
|
16
|
+
expect(container.innerHTML).toBe("");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders a meter per capped feature with the plan's label and a state note", () => {
|
|
20
|
+
state.usage = { designs: { used: 9, limit: 10 }, extra: { used: 3, limit: 3 } };
|
|
21
|
+
render(<UsageMeters productSlug="p" copy={{ atCap: "Maxed" }} />);
|
|
22
|
+
expect(screen.getByTestId("meter-designs").getAttribute("data-state")).toBe("approaching");
|
|
23
|
+
expect(screen.getByText("Designs")).toBeTruthy();
|
|
24
|
+
expect(screen.getByText("1 of 10 left")).toBeTruthy();
|
|
25
|
+
expect(screen.getByText("Maxed")).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
import { useSubscription } from "../hooks/useSubscription";
|
|
5
|
+
import { useUsage } from "../hooks/useUsage";
|
|
6
|
+
import { meterRows, type MeterState } from "../utils/usage-meters";
|
|
7
|
+
|
|
8
|
+
export interface UsageMetersCopy {
|
|
9
|
+
/** Shown on a meter at 80%+ of its cap. */
|
|
10
|
+
approaching: string;
|
|
11
|
+
/** Shown on a meter at its cap. */
|
|
12
|
+
atCap: string;
|
|
13
|
+
/** "{left} of {limit} left" line. */
|
|
14
|
+
left: (left: number, limit: number) => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_COPY: UsageMetersCopy = {
|
|
18
|
+
approaching: "Almost used up",
|
|
19
|
+
atCap: "Limit reached",
|
|
20
|
+
left: (left, limit) => `${left} of ${limit} left`,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TONE: Record<MeterState, { box: string; bar: string }> = {
|
|
24
|
+
ok: { box: "border-border bg-card", bar: "bg-primary" },
|
|
25
|
+
approaching: { box: "border-amber-300 bg-amber-50", bar: "bg-amber-500" },
|
|
26
|
+
"at-cap": { box: "border-red-300 bg-red-50", bar: "bg-red-500" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Re-reads after checkout: the first read can predate the new plan. */
|
|
30
|
+
const AFTER_CHECKOUT_REFETCH_MS = 2000;
|
|
31
|
+
const AFTER_CHECKOUT_REFETCH_TRIES = 5;
|
|
32
|
+
|
|
33
|
+
export interface UsageMetersProps {
|
|
34
|
+
productSlug: string;
|
|
35
|
+
/** Back from checkout: ask again a few times while usage still reads empty. */
|
|
36
|
+
afterCheckout?: boolean;
|
|
37
|
+
copy?: Partial<UsageMetersCopy>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One meter per capped feature of the person's plan; nothing for unlimited ones. */
|
|
41
|
+
export function UsageMeters({ productSlug, afterCheckout = false, copy }: UsageMetersProps) {
|
|
42
|
+
const text = { ...DEFAULT_COPY, ...copy };
|
|
43
|
+
const { usage, refetch } = useUsage(productSlug);
|
|
44
|
+
const { subscription } = useSubscription(productSlug);
|
|
45
|
+
|
|
46
|
+
const left = useRef(AFTER_CHECKOUT_REFETCH_TRIES);
|
|
47
|
+
const stillEmpty = Object.keys(usage).length === 0;
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!afterCheckout || !stillEmpty || left.current <= 0) return;
|
|
50
|
+
const t = setTimeout(() => {
|
|
51
|
+
left.current -= 1;
|
|
52
|
+
refetch();
|
|
53
|
+
}, AFTER_CHECKOUT_REFETCH_MS);
|
|
54
|
+
return () => clearTimeout(t);
|
|
55
|
+
}, [afterCheckout, stillEmpty, refetch]);
|
|
56
|
+
|
|
57
|
+
const labels = useMemo(() => {
|
|
58
|
+
const map: Record<string, string> = {};
|
|
59
|
+
for (const f of subscription?.offer?.features ?? []) map[f.key] = f.name;
|
|
60
|
+
return map;
|
|
61
|
+
}, [subscription]);
|
|
62
|
+
const rows = meterRows(usage, labels);
|
|
63
|
+
if (rows.length === 0) return null;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="mb-6 space-y-3" data-testid="usage-meters">
|
|
67
|
+
{rows.map((r) => {
|
|
68
|
+
const tone = TONE[r.state];
|
|
69
|
+
const note = r.state === "at-cap" ? text.atCap : r.state === "approaching" ? text.approaching : null;
|
|
70
|
+
return (
|
|
71
|
+
<div key={r.key} data-testid={`meter-${r.key}`} data-state={r.state} className={`rounded-lg border p-4 ${tone.box}`}>
|
|
72
|
+
<div className="mb-1.5 flex items-center justify-between gap-3">
|
|
73
|
+
<h3 className="text-sm font-semibold">{r.label}</h3>
|
|
74
|
+
{note && <span className="text-xs font-medium">{note}</span>}
|
|
75
|
+
</div>
|
|
76
|
+
<p className="mb-2 text-sm text-muted-foreground tabular-nums">{text.left(r.left, r.limit)}</p>
|
|
77
|
+
<div className="h-2 w-full rounded-full bg-muted" aria-hidden="true">
|
|
78
|
+
<div className={`h-2 rounded-full transition-all ${tone.bar}`} style={{ width: `${Math.max(r.pct, 3)}%` }} />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -18,3 +18,5 @@ export { LimitNudge } from "./LimitNudge";
|
|
|
18
18
|
export type { LimitNudgeProps } from "./LimitNudge";
|
|
19
19
|
export { PlanChangeConfirm, planChangeCopy } from "./PlanChangeConfirm";
|
|
20
20
|
export type { PlanChangeConfirmProps } from "./PlanChangeConfirm";
|
|
21
|
+
export { UsageMeters } from "./UsageMeters";
|
|
22
|
+
export type { UsageMetersProps, UsageMetersCopy } from "./UsageMeters";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CreditsCopy } from "./copy";
|
|
2
|
+
|
|
3
|
+
/** The balance, read out in full by screen readers. */
|
|
4
|
+
export function CreditBalanceBadge({ balance, copy, className = "" }: { balance: number; copy: CreditsCopy; className?: string }) {
|
|
5
|
+
return (
|
|
6
|
+
<span
|
|
7
|
+
aria-label={copy.badgeLabel(balance)}
|
|
8
|
+
className={`inline-flex items-center rounded-full bg-green-100 px-3 py-1 text-sm font-medium text-green-800 ${className}`}
|
|
9
|
+
>
|
|
10
|
+
<span aria-hidden="true">{copy.badge(balance)}</span>
|
|
11
|
+
</span>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CreditsCopy } from "./copy";
|
|
2
|
+
import type { PatternCreditHistoryEntry } from "./types";
|
|
3
|
+
|
|
4
|
+
const when = (iso: string) => {
|
|
5
|
+
const d = new Date(iso);
|
|
6
|
+
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Purchases and use, in the order given (newest first from the server). */
|
|
10
|
+
export function CreditHistoryList({ history, copy }: { history: PatternCreditHistoryEntry[]; copy: CreditsCopy }) {
|
|
11
|
+
if (history.length === 0) return <p className="text-sm text-muted-foreground">{copy.historyEmpty}</p>;
|
|
12
|
+
const label = (e: PatternCreditHistoryEntry) =>
|
|
13
|
+
e.entryType === "grant" && e.reason ? e.reason : copy.entry[e.entryType] ?? e.entryType;
|
|
14
|
+
return (
|
|
15
|
+
<ul aria-label={copy.historyTitle} className="divide-y divide-border rounded-lg border border-border bg-card">
|
|
16
|
+
{history.map((e) => (
|
|
17
|
+
<li key={e.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
|
18
|
+
<span className="min-w-0">
|
|
19
|
+
<span className="block truncate text-foreground">{label(e)}</span>
|
|
20
|
+
<span className="block text-xs text-muted-foreground">{when(e.createdAt)}</span>
|
|
21
|
+
</span>
|
|
22
|
+
<span
|
|
23
|
+
className={`shrink-0 font-semibold ${e.quantity > 0 ? "text-green-700" : "text-muted-foreground"}`}
|
|
24
|
+
aria-label={`${e.quantity > 0 ? "plus" : "minus"} ${Math.abs(e.quantity)}`}
|
|
25
|
+
>
|
|
26
|
+
{e.quantity > 0 ? `+${e.quantity}` : e.quantity}
|
|
27
|
+
</span>
|
|
28
|
+
</li>
|
|
29
|
+
))}
|
|
30
|
+
</ul>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Button } from "@startsimpli/ui";
|
|
2
|
+
import { formatPackPrice, pricePerCredit, savingsPercent } from "../utils/credit-math";
|
|
3
|
+
import type { CreditsCopy } from "./copy";
|
|
4
|
+
import type { PatternCreditOffer } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface CreditPackCardProps {
|
|
7
|
+
offer: PatternCreditOffer;
|
|
8
|
+
copy: CreditsCopy;
|
|
9
|
+
/** Offer id being opened in checkout, if any. */
|
|
10
|
+
buying: string | null;
|
|
11
|
+
onBuy: (offerId: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One pack: its price, the struck-through price and saving when the offer has one, and a Buy button. */
|
|
15
|
+
export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardProps) {
|
|
16
|
+
const saving = savingsPercent(offer);
|
|
17
|
+
const name = copy.packName(offer.credits);
|
|
18
|
+
const opening = buying === offer.id;
|
|
19
|
+
return (
|
|
20
|
+
<li className={`flex flex-col rounded-xl border p-4 ${offer.featured ? "border-primary bg-primary/5" : "border-border bg-card"}`}>
|
|
21
|
+
<div className="flex items-start justify-between gap-2">
|
|
22
|
+
<p className="text-base font-semibold text-foreground">{name}</p>
|
|
23
|
+
{offer.featured && (
|
|
24
|
+
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">{copy.featured}</span>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
27
|
+
<p className="mt-3 flex flex-wrap items-baseline gap-x-2">
|
|
28
|
+
<span className="text-2xl font-bold text-foreground">{formatPackPrice(offer.price, offer.currency)}</span>
|
|
29
|
+
{saving !== null && (
|
|
30
|
+
<s className="text-sm text-muted-foreground" style={{ textDecoration: "line-through" }}>
|
|
31
|
+
{formatPackPrice(offer.compareAt, offer.currency)}
|
|
32
|
+
</s>
|
|
33
|
+
)}
|
|
34
|
+
</p>
|
|
35
|
+
<p className="mt-1 min-h-[1.25rem] text-sm text-muted-foreground">
|
|
36
|
+
{saving !== null && <span className="font-medium text-green-700">{copy.save(saving)}</span>}
|
|
37
|
+
{saving !== null && offer.credits > 1 && " · "}
|
|
38
|
+
{offer.credits > 1 && copy.perCredit(formatPackPrice(pricePerCredit(offer), offer.currency))}
|
|
39
|
+
</p>
|
|
40
|
+
<Button
|
|
41
|
+
className="mt-4 w-full"
|
|
42
|
+
variant={offer.featured ? "default" : "outline"}
|
|
43
|
+
disabled={buying !== null}
|
|
44
|
+
onClick={() => onBuy(offer.id)}
|
|
45
|
+
aria-label={opening ? undefined : copy.buy(name)}
|
|
46
|
+
>
|
|
47
|
+
{opening ? copy.buying : copy.buy(name)}
|
|
48
|
+
</Button>
|
|
49
|
+
</li>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@startsimpli/ui";
|
|
4
|
+
import { CreditBalanceBadge } from "./CreditBalanceBadge";
|
|
5
|
+
import { CreditHistoryList } from "./CreditHistoryList";
|
|
6
|
+
import { CreditPackCard } from "./CreditPackCard";
|
|
7
|
+
import { withCreditsCopy, type CreditsCopy } from "./copy";
|
|
8
|
+
import type { CreditsView } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface CreditsPanelProps {
|
|
11
|
+
credits: CreditsView;
|
|
12
|
+
copy?: Partial<CreditsCopy>;
|
|
13
|
+
headingId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Buy credits, see the balance and the history. Renders nothing while the sale
|
|
18
|
+
* is off (flag off, signed out, or the server's 409), so the page is exactly
|
|
19
|
+
* what it was.
|
|
20
|
+
*/
|
|
21
|
+
export function CreditsPanel({ credits, copy: copyIn, headingId = "credits-heading" }: CreditsPanelProps) {
|
|
22
|
+
const copy = withCreditsCopy(copyIn);
|
|
23
|
+
if (credits.status === "disabled") return null;
|
|
24
|
+
return (
|
|
25
|
+
<section aria-labelledby={headingId} className="mb-8 rounded-xl border border-border bg-muted/40 p-4 sm:p-6">
|
|
26
|
+
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
|
27
|
+
<h3 id={headingId} className="text-lg font-semibold text-foreground">{copy.sectionTitle}</h3>
|
|
28
|
+
{credits.status === "ready" && <CreditBalanceBadge balance={credits.balance} copy={copy} />}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{credits.status === "loading" && (
|
|
32
|
+
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">{copy.loading}</p>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{credits.status === "error" && (
|
|
36
|
+
<div role="alert" className="flex flex-wrap items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
|
37
|
+
<span>{copy.loadError}</span>
|
|
38
|
+
<Button size="sm" variant="outline" onClick={() => void credits.refresh()}>{copy.retry}</Button>
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
|
|
42
|
+
{credits.status === "ready" && (
|
|
43
|
+
<>
|
|
44
|
+
<p className="mb-4 text-sm text-muted-foreground">{copy.sectionIntro}</p>
|
|
45
|
+
{credits.error && (
|
|
46
|
+
<p role="alert" className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">{credits.error}</p>
|
|
47
|
+
)}
|
|
48
|
+
<ul className="mb-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
49
|
+
{credits.offers.map((offer) => (
|
|
50
|
+
<CreditPackCard key={offer.id} offer={offer} copy={copy} buying={credits.buying} onBuy={(id) => void credits.buy(id)} />
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
<p className="mb-6 text-xs text-muted-foreground">{copy.secure}</p>
|
|
54
|
+
<h4 className="mb-2 text-sm font-semibold text-foreground">{copy.historyTitle}</h4>
|
|
55
|
+
<CreditHistoryList history={credits.history} copy={copy} />
|
|
56
|
+
</>
|
|
57
|
+
)}
|
|
58
|
+
</section>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { Button } from "@startsimpli/ui";
|
|
5
|
+
import { withCreditsCopy, type CreditsCopy } from "./copy";
|
|
6
|
+
import type { CreditsView } from "./types";
|
|
7
|
+
|
|
8
|
+
type Phase = "pending" | "done" | "waiting" | "error";
|
|
9
|
+
|
|
10
|
+
const LINK = "rounded-lg px-4 py-2 text-sm font-semibold";
|
|
11
|
+
|
|
12
|
+
export interface CreditsReturnLinks {
|
|
13
|
+
/** Where "continue" goes (e.g. the builder). */
|
|
14
|
+
continueHref: string;
|
|
15
|
+
billingHref: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CreditsSuccessProps extends CreditsReturnLinks {
|
|
19
|
+
credits: Pick<CreditsView, "status" | "sync">;
|
|
20
|
+
/** Stripe session from the return URL (`creditSessionFrom`). */
|
|
21
|
+
sessionId: string | null;
|
|
22
|
+
copy?: Partial<CreditsCopy>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Back from Stripe: confirm the paid session (the webhook may not have arrived) and show the balance. */
|
|
26
|
+
export function CreditsSuccess({ credits, sessionId, continueHref, billingHref, copy: copyIn }: CreditsSuccessProps) {
|
|
27
|
+
const copy = withCreditsCopy(copyIn);
|
|
28
|
+
const [phase, setPhase] = useState<Phase>("pending");
|
|
29
|
+
const [balance, setBalance] = useState(0);
|
|
30
|
+
const started = useRef(false);
|
|
31
|
+
const { sync } = credits;
|
|
32
|
+
|
|
33
|
+
const run = useCallback(async () => {
|
|
34
|
+
if (!sessionId) return;
|
|
35
|
+
setPhase("pending");
|
|
36
|
+
try {
|
|
37
|
+
const r = await sync(sessionId);
|
|
38
|
+
setBalance(r.balance);
|
|
39
|
+
setPhase(r.granted ? "done" : "waiting");
|
|
40
|
+
} catch {
|
|
41
|
+
setPhase("error");
|
|
42
|
+
}
|
|
43
|
+
}, [sessionId, sync]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!sessionId || started.current || credits.status === "disabled") return;
|
|
47
|
+
started.current = true;
|
|
48
|
+
void run();
|
|
49
|
+
}, [sessionId, credits.status, run]);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="mx-auto max-w-lg py-10 text-center">
|
|
53
|
+
<h3 className="mb-4 text-xl font-semibold text-foreground">{copy.successTitle}</h3>
|
|
54
|
+
{!sessionId && <p className="text-sm text-muted-foreground">{copy.successMissingSession}</p>}
|
|
55
|
+
{sessionId && phase === "pending" && <p role="status" className="text-sm text-muted-foreground">{copy.successPending}</p>}
|
|
56
|
+
{sessionId && phase === "done" && <p role="status" className="text-sm text-foreground">{copy.successDone(1, balance)}</p>}
|
|
57
|
+
{sessionId && phase === "waiting" && (
|
|
58
|
+
<div role="status" className="space-y-3 text-sm text-muted-foreground">
|
|
59
|
+
<p>{copy.successStillPending}</p>
|
|
60
|
+
<Button variant="outline" onClick={() => void run()}>{copy.successCheckAgain}</Button>
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
{sessionId && phase === "error" && (
|
|
64
|
+
<div role="alert" className="space-y-3 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
65
|
+
<p>{copy.successSyncError}</p>
|
|
66
|
+
<Button variant="outline" onClick={() => void run()}>{copy.successCheckAgain}</Button>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
|
70
|
+
<a href={continueHref} className={`${LINK} bg-primary text-primary-foreground hover:opacity-90`}>{copy.continueLabel}</a>
|
|
71
|
+
<a href={billingHref} className={`${LINK} border border-border text-foreground hover:bg-muted`}>{copy.seeBilling}</a>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Back from Stripe after cancelling. */
|
|
78
|
+
export function CreditsCancelled({ billingHref, copy: copyIn }: { billingHref: string; copy?: Partial<CreditsCopy> }) {
|
|
79
|
+
const copy = withCreditsCopy(copyIn);
|
|
80
|
+
return (
|
|
81
|
+
<div className="mx-auto max-w-lg py-10 text-center">
|
|
82
|
+
<h3 className="mb-4 text-xl font-semibold text-foreground">{copy.cancelledTitle}</h3>
|
|
83
|
+
<p className="text-sm text-muted-foreground">{copy.cancelledBody}</p>
|
|
84
|
+
<div className="mt-6">
|
|
85
|
+
<a href={billingHref} className={`${LINK} bg-primary text-primary-foreground hover:opacity-90`}>{copy.tryAgain}</a>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@startsimpli/ui";
|
|
4
|
+
import { CreditPackCard } from "./CreditPackCard";
|
|
5
|
+
import { withCreditsCopy, type CreditsCopy } from "./copy";
|
|
6
|
+
import type { PatternCreditOffer } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface UseCreditPromptProps {
|
|
9
|
+
balance: number;
|
|
10
|
+
offers: PatternCreditOffer[];
|
|
11
|
+
buying: string | null;
|
|
12
|
+
error: string | null;
|
|
13
|
+
onUse: () => void;
|
|
14
|
+
onBuy: (offerId: string) => void;
|
|
15
|
+
onCancel: () => void;
|
|
16
|
+
/** True when the plan's monthly allowance is used up (vs. no plan at all). */
|
|
17
|
+
planAllowanceUsed?: boolean;
|
|
18
|
+
copy?: Partial<CreditsCopy>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** At the action button, when the plan cannot cover it: confirm using a credit, or (with none left) buy a pack. */
|
|
22
|
+
export function UseCreditPrompt({ balance, offers, buying, error, onUse, onBuy, onCancel, planAllowanceUsed, copy: copyIn }: UseCreditPromptProps) {
|
|
23
|
+
const copy = withCreditsCopy(copyIn);
|
|
24
|
+
const title = balance > 0 ? copy.confirmTitle : copy.outTitle;
|
|
25
|
+
return (
|
|
26
|
+
<div role="dialog" aria-label={title} className="w-full rounded-xl border border-border bg-muted/40 p-4 text-left">
|
|
27
|
+
<h4 className="text-base font-semibold text-foreground">{title}</h4>
|
|
28
|
+
<p className="mt-1 text-sm text-muted-foreground">{balance > 0 ? copy.confirmBody(balance) : copy.outBody}</p>
|
|
29
|
+
{planAllowanceUsed && balance > 0 && <p className="mt-1 text-xs text-muted-foreground">{copy.planAllowanceUsed}</p>}
|
|
30
|
+
{error && <p role="alert" className="mt-2 text-sm text-red-700">{error}</p>}
|
|
31
|
+
{balance > 0 ? (
|
|
32
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
33
|
+
<Button onClick={onUse}>{copy.confirmUse}</Button>
|
|
34
|
+
<Button variant="outline" onClick={onCancel}>{copy.confirmCancel}</Button>
|
|
35
|
+
</div>
|
|
36
|
+
) : (
|
|
37
|
+
<>
|
|
38
|
+
<ul className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
39
|
+
{offers.map((o) => <CreditPackCard key={o.id} offer={o} copy={copy} buying={buying} onBuy={onBuy} />)}
|
|
40
|
+
</ul>
|
|
41
|
+
<div className="mt-3"><Button variant="outline" size="sm" onClick={onCancel}>{copy.confirmCancel}</Button></div>
|
|
42
|
+
</>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every customer-facing string of the credit-pack UI. The app owns wording
|
|
3
|
+
* (what a credit is called, its brand voice); these defaults are neutral.
|
|
4
|
+
* Prices and pack sizes are NOT here: they come from the offers.
|
|
5
|
+
*/
|
|
6
|
+
export interface CreditsCopy {
|
|
7
|
+
badge: (n: number) => string;
|
|
8
|
+
badgeLabel: (n: number) => string;
|
|
9
|
+
sectionTitle: string;
|
|
10
|
+
sectionIntro: string;
|
|
11
|
+
loading: string;
|
|
12
|
+
loadError: string;
|
|
13
|
+
retry: string;
|
|
14
|
+
packName: (credits: number) => string;
|
|
15
|
+
perCredit: (price: string) => string;
|
|
16
|
+
save: (pct: number) => string;
|
|
17
|
+
featured: string;
|
|
18
|
+
buy: (name: string) => string;
|
|
19
|
+
buying: string;
|
|
20
|
+
secure: string;
|
|
21
|
+
historyTitle: string;
|
|
22
|
+
historyEmpty: string;
|
|
23
|
+
/** Label per history entry type (grant, reserve, commit, release, refund, expire, adjust). */
|
|
24
|
+
entry: Record<string, string>;
|
|
25
|
+
successTitle: string;
|
|
26
|
+
successPending: string;
|
|
27
|
+
successDone: (granted: number | null, balance: number) => string;
|
|
28
|
+
successStillPending: string;
|
|
29
|
+
successCheckAgain: string;
|
|
30
|
+
successMissingSession: string;
|
|
31
|
+
successSyncError: string;
|
|
32
|
+
/** Primary action on the success page (e.g. "Make a design"). */
|
|
33
|
+
continueLabel: string;
|
|
34
|
+
seeBilling: string;
|
|
35
|
+
cancelledTitle: string;
|
|
36
|
+
cancelledBody: string;
|
|
37
|
+
tryAgain: string;
|
|
38
|
+
confirmTitle: string;
|
|
39
|
+
confirmBody: (balance: number) => string;
|
|
40
|
+
confirmUse: string;
|
|
41
|
+
confirmCancel: string;
|
|
42
|
+
outTitle: string;
|
|
43
|
+
outBody: string;
|
|
44
|
+
planAllowanceUsed: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const plural = (n: number, one: string, many: string) => (n === 1 ? one : many);
|
|
48
|
+
|
|
49
|
+
export const defaultCreditsCopy: CreditsCopy = {
|
|
50
|
+
badge: (n) => `${n} ${plural(n, "credit", "credits")}`,
|
|
51
|
+
badgeLabel: (n) => `You have ${n} ${plural(n, "credit", "credits")}`,
|
|
52
|
+
sectionTitle: "Credits",
|
|
53
|
+
sectionIntro: "Buy credits to use when your plan's allowance runs out. Credits never expire.",
|
|
54
|
+
loading: "Loading your credits…",
|
|
55
|
+
loadError: "We couldn’t load your credits.",
|
|
56
|
+
retry: "Try again",
|
|
57
|
+
packName: (c) => `${c} ${plural(c, "credit", "credits")}`,
|
|
58
|
+
perCredit: (p) => `${p} each`,
|
|
59
|
+
save: (pct) => `Save ${pct}%`,
|
|
60
|
+
featured: "Best value",
|
|
61
|
+
buy: (name) => `Buy ${name}`,
|
|
62
|
+
buying: "Opening checkout…",
|
|
63
|
+
secure: "Secure checkout by Stripe.",
|
|
64
|
+
historyTitle: "Purchases and use",
|
|
65
|
+
historyEmpty: "Nothing yet. When you buy or use a credit it shows up here.",
|
|
66
|
+
entry: {
|
|
67
|
+
grant: "Bought",
|
|
68
|
+
reserve: "Used",
|
|
69
|
+
commit: "Used",
|
|
70
|
+
release: "Returned",
|
|
71
|
+
refund: "Refunded",
|
|
72
|
+
expire: "Expired",
|
|
73
|
+
adjust: "Adjustment",
|
|
74
|
+
},
|
|
75
|
+
successTitle: "Thank you",
|
|
76
|
+
successPending: "Adding your credits…",
|
|
77
|
+
successDone: (_g, balance) => `Your credits are ready. You now have ${balance}.`,
|
|
78
|
+
successStillPending: "Your payment went through, and your credits are still being added. This can take a minute.",
|
|
79
|
+
successCheckAgain: "Check again",
|
|
80
|
+
successMissingSession: "We couldn’t find a checkout to confirm. If you just paid, your credits will appear on the billing page shortly.",
|
|
81
|
+
successSyncError: "We couldn’t confirm your payment just yet. Your card was not charged twice; check again in a moment.",
|
|
82
|
+
continueLabel: "Continue",
|
|
83
|
+
seeBilling: "Back to billing",
|
|
84
|
+
cancelledTitle: "Checkout cancelled",
|
|
85
|
+
cancelledBody: "No charge was made. Your credits are unchanged.",
|
|
86
|
+
tryAgain: "Choose a pack",
|
|
87
|
+
confirmTitle: "Use a credit?",
|
|
88
|
+
confirmBody: (b) => `This uses 1 credit. You have ${b}.`,
|
|
89
|
+
confirmUse: "Use a credit",
|
|
90
|
+
confirmCancel: "Not now",
|
|
91
|
+
outTitle: "You’re out of credits",
|
|
92
|
+
outBody: "Buy a pack to continue.",
|
|
93
|
+
planAllowanceUsed: "Your plan’s allowance for this month is used. You can use a credit instead.",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const withCreditsCopy = (copy?: Partial<CreditsCopy>): CreditsCopy => ({ ...defaultCreditsCopy, ...copy });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { CreditsCancelled, CreditsPanel, CreditsSuccess, UseCreditPrompt, type CreditsView } from "./index";
|
|
5
|
+
|
|
6
|
+
const offer = (over = {}) => ({ id: "o1", credits: 5, price: "8.00", compareAt: "10.00", currency: "usd", featured: true, ...over }) as never;
|
|
7
|
+
const view = (over: Partial<CreditsView> = {}): CreditsView => ({
|
|
8
|
+
status: "ready", balance: 3, offers: [offer()], history: [], error: null, buying: null,
|
|
9
|
+
refresh: vi.fn(async () => {}), buy: vi.fn(async () => {}), sync: vi.fn(async () => ({ granted: true, balance: 7 })), ...over,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("CreditsPanel", () => {
|
|
13
|
+
it("renders nothing while disabled", () => {
|
|
14
|
+
const { container } = render(<CreditsPanel credits={view({ status: "disabled" })} />);
|
|
15
|
+
expect(container.innerHTML).toBe("");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("shows balance, price, saving and buys the clicked pack with injected copy", () => {
|
|
19
|
+
const credits = view();
|
|
20
|
+
render(<CreditsPanel credits={credits} copy={{ packName: (n) => `${n} pattern credits`, badge: (n) => `${n} pattern credits` }} />);
|
|
21
|
+
expect(screen.getByText("3 pattern credits")).toBeTruthy();
|
|
22
|
+
expect(screen.getByText("$8.00")).toBeTruthy();
|
|
23
|
+
expect(screen.getByText("Save 20%")).toBeTruthy();
|
|
24
|
+
fireEvent.click(screen.getByRole("button", { name: "Buy 5 pattern credits" }));
|
|
25
|
+
expect(credits.buy).toHaveBeenCalledWith("o1");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("offers a retry on error", () => {
|
|
29
|
+
const credits = view({ status: "error" });
|
|
30
|
+
render(<CreditsPanel credits={credits} />);
|
|
31
|
+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
32
|
+
expect(credits.refresh).toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("UseCreditPrompt", () => {
|
|
37
|
+
it("confirms with a balance, sells packs without one", () => {
|
|
38
|
+
const onUse = vi.fn();
|
|
39
|
+
const { rerender } = render(<UseCreditPrompt balance={2} offers={[offer()]} buying={null} error={null} onUse={onUse} onBuy={vi.fn()} onCancel={vi.fn()} />);
|
|
40
|
+
fireEvent.click(screen.getByRole("button", { name: "Use a credit" }));
|
|
41
|
+
expect(onUse).toHaveBeenCalled();
|
|
42
|
+
rerender(<UseCreditPrompt balance={0} offers={[offer()]} buying={null} error={null} onUse={onUse} onBuy={vi.fn()} onCancel={vi.fn()} />);
|
|
43
|
+
expect(screen.getByText("You’re out of credits")).toBeTruthy();
|
|
44
|
+
expect(screen.getByRole("button", { name: "Buy 5 credits" })).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("CreditsSuccess / CreditsCancelled", () => {
|
|
49
|
+
it("syncs the session once and reports the balance", async () => {
|
|
50
|
+
const credits = view();
|
|
51
|
+
render(<CreditsSuccess credits={credits} sessionId="cs_1" continueHref="/go" billingHref="/b" />);
|
|
52
|
+
await waitFor(() => expect(screen.getByText("Your credits are ready. You now have 7.")).toBeTruthy());
|
|
53
|
+
expect(credits.sync).toHaveBeenCalledTimes(1);
|
|
54
|
+
expect(screen.getByText("Continue").getAttribute("href")).toBe("/go");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("says so when there is no session and never syncs", () => {
|
|
58
|
+
const credits = view();
|
|
59
|
+
render(<CreditsSuccess credits={credits} sessionId={null} continueHref="/go" billingHref="/b" />);
|
|
60
|
+
expect(screen.getByText(/couldn’t find a checkout/)).toBeTruthy();
|
|
61
|
+
expect(credits.sync).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("waits and allows re-check when not granted yet", async () => {
|
|
65
|
+
const sync = vi.fn(async () => ({ granted: false, balance: 1 }));
|
|
66
|
+
render(<CreditsSuccess credits={view({ sync })} sessionId="cs_1" continueHref="/go" billingHref="/b" />);
|
|
67
|
+
fireEvent.click(await screen.findByRole("button", { name: "Check again" }));
|
|
68
|
+
await waitFor(() => expect(sync).toHaveBeenCalledTimes(2));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cancelled links back to billing", () => {
|
|
72
|
+
render(<CreditsCancelled billingHref="/b" />);
|
|
73
|
+
expect(screen.getByText("Choose a pack").getAttribute("href")).toBe("/b");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { CreditsPanel } from "./CreditsPanel";
|
|
2
|
+
export type { CreditsPanelProps } from "./CreditsPanel";
|
|
3
|
+
export { CreditPackCard } from "./CreditPackCard";
|
|
4
|
+
export type { CreditPackCardProps } from "./CreditPackCard";
|
|
5
|
+
export { CreditBalanceBadge } from "./CreditBalanceBadge";
|
|
6
|
+
export { CreditHistoryList } from "./CreditHistoryList";
|
|
7
|
+
export { UseCreditPrompt } from "./UseCreditPrompt";
|
|
8
|
+
export type { UseCreditPromptProps } from "./UseCreditPrompt";
|
|
9
|
+
export { CreditsSuccess, CreditsCancelled } from "./CreditsReturn";
|
|
10
|
+
export type { CreditsSuccessProps, CreditsReturnLinks } from "./CreditsReturn";
|
|
11
|
+
export { defaultCreditsCopy, withCreditsCopy } from "./copy";
|
|
12
|
+
export type { CreditsCopy } from "./copy";
|
|
13
|
+
export type { CreditsView } from "./types";
|