@startsimpli/billing 0.4.32 → 0.4.34

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.
Files changed (36) hide show
  1. package/package.json +16 -3
  2. package/src/components/PricingDetailPage.tsx +2 -1
  3. package/src/components/PricingPage.tsx +2 -2
  4. package/src/components/PricingSection.tsx +2 -2
  5. package/src/components/SubscriptionManager.tsx +5 -4
  6. package/src/components/UpgradeModal.tsx +2 -1
  7. package/src/components/UsageMeters.test.tsx +27 -0
  8. package/src/components/UsageMeters.tsx +85 -0
  9. package/src/components/index.ts +2 -0
  10. package/src/credits/CreditBalanceBadge.tsx +13 -0
  11. package/src/credits/CreditHistoryList.tsx +32 -0
  12. package/src/credits/CreditPackCard.tsx +51 -0
  13. package/src/credits/CreditsPanel.tsx +60 -0
  14. package/src/credits/CreditsReturn.tsx +89 -0
  15. package/src/credits/UseCreditPrompt.tsx +46 -0
  16. package/src/credits/copy.ts +96 -0
  17. package/src/credits/credits.test.tsx +75 -0
  18. package/src/credits/index.ts +13 -0
  19. package/src/credits/types.ts +17 -0
  20. package/src/hooks/index.ts +4 -0
  21. package/src/hooks/useAutoCheckout.test.tsx +34 -0
  22. package/src/hooks/useAutoCheckout.ts +31 -0
  23. package/src/hooks/useOfferCheckout.test.tsx +73 -0
  24. package/src/hooks/useOfferCheckout.ts +82 -0
  25. package/src/index.ts +24 -2
  26. package/src/types/index.ts +2 -0
  27. package/src/utils/checkout-flow.test.ts +56 -0
  28. package/src/utils/checkout-flow.ts +55 -0
  29. package/src/utils/credit-math.test.ts +18 -0
  30. package/src/utils/credit-math.ts +32 -0
  31. package/src/utils/plan.test.ts +13 -1
  32. package/src/utils/plan.ts +22 -0
  33. package/src/utils/return-urls.test.ts +38 -0
  34. package/src/utils/return-urls.ts +74 -0
  35. package/src/utils/usage-meters.test.ts +20 -0
  36. 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.32",
3
+ "version": "0.4.34",
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.175"
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.175"
53
66
  },
54
67
  "keywords": [
55
68
  "billing",
@@ -20,6 +20,7 @@ import {
20
20
  planDirection,
21
21
  resolveCurrentOffer,
22
22
  trialApplies,
23
+ trialLabel,
23
24
  type CurrentPlanState,
24
25
  } from "../utils/plan";
25
26
  import { usePlanChange } from "../hooks/usePlanChange";
@@ -330,7 +331,7 @@ function DetailCard({
330
331
  <div className="mb-5 min-h-8">
331
332
  {trial ? (
332
333
  <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-sm font-semibold px-3 py-1 rounded-lg w-fit">
333
- {offer.trialDays}-day free trial
334
+ {trialLabel(offer)}
334
335
  </span>
335
336
  ) : yourPlanNote ? (
336
337
  <span data-testid={`your-plan-note-${offer.slug}`} className="inline-block bg-gray-100 text-gray-700 text-sm font-semibold px-3 py-1 rounded-lg w-fit">
@@ -13,7 +13,7 @@ import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
14
  import { FeatureIcon } from "./FeatureIcon";
15
15
  import { formatFeatureLabel } from "../utils/format";
16
- import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
16
+ import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
17
17
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
18
18
 
19
19
  export interface PricingPageProps {
@@ -136,7 +136,7 @@ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
136
136
  data-testid="trial-badge"
137
137
  className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit"
138
138
  >
139
- {offer.trialDays}-day free trial
139
+ {trialLabel(offer)}
140
140
  </span>
141
141
  )}
142
142
 
@@ -14,7 +14,7 @@ import type { OfferFeature, ProductOffer } from "../types";
14
14
  import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
15
  import { FeatureIcon } from "./FeatureIcon";
16
16
  import { formatFeatureLabel } from "../utils/format";
17
- import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
17
+ import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
18
18
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
19
19
 
20
20
  export interface PricingSectionProps {
@@ -152,7 +152,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurr
152
152
  data-testid="trial-badge"
153
153
  className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-xs font-semibold px-2 py-0.5 rounded-md w-fit"
154
154
  >
155
- {offer.trialDays}-day free trial
155
+ {trialLabel(offer)}
156
156
  </span>
157
157
  )}
158
158
  </div>
@@ -29,6 +29,7 @@ import {
29
29
  planCtaLabel,
30
30
  planDirection,
31
31
  trialApplies,
32
+ trialLabel,
32
33
  TRIAL_NOTE_FEATURE_KEY,
33
34
  type PlanDirection,
34
35
  } from "../utils/plan";
@@ -95,11 +96,11 @@ function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFe
95
96
  * startsim-pih — so users know "Start Free Trial" actually IS a free trial.
96
97
  * No "no card required": checkout collects a card up front and the offer
97
98
  * carries nothing that says otherwise (crochet_patterns-34n7). */
98
- function TrialCopy({ trialDays }: { trialDays: number }) {
99
- if (!trialDays || trialDays <= 0) return null;
99
+ function TrialCopy({ offer }: { offer: ProductOffer }) {
100
+ if (!offer.trialDays || offer.trialDays <= 0) return null;
100
101
  return (
101
102
  <p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
102
- {trialDays}-day free trial
103
+ {trialLabel(offer)}
103
104
  </p>
104
105
  );
105
106
  }
@@ -566,7 +567,7 @@ export function SubscriptionManager({
566
567
  {/* Trial copy is for prospective trialers only — once the
567
568
  user is on this offer or moving downward, drop the
568
569
  "14-day free trial" sales pitch. */}
569
- {showTrial && <TrialCopy trialDays={offer.trialDays} />}
570
+ {showTrial && <TrialCopy offer={offer} />}
570
571
  <PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
571
572
  <button
572
573
  data-testid={`upgrade-cta-${offer.slug}`}
@@ -21,6 +21,7 @@ import {
21
21
  planDirection,
22
22
  resolveCurrentOffer,
23
23
  trialApplies,
24
+ trialLabel,
24
25
  TRIAL_NOTE_FEATURE_KEY,
25
26
  type CurrentPlanState,
26
27
  } from "../utils/plan";
@@ -201,7 +202,7 @@ export function UpgradeModal({
201
202
 
202
203
  {trial && (
203
204
  <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2.5 py-0.5 rounded-md mb-3 w-fit">
204
- {offer.trialDays}-day free trial
205
+ {trialLabel(offer)}
205
206
  </span>
206
207
  )}
207
208
 
@@ -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
+ }
@@ -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
+ }