Subscription Manager
A manage-plan block — the running plan with its renewal countdown and seat overage, usage against the plan's own limits, prorated plan changes, and a cancellation that states what is lost and when access actually ends.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/subscription-manager.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SubscriptionManager" block with zod and
lucide-react, on top of shadcn/ui alert-dialog, badge and button; cn() (clsx +
tailwind-merge) for classes, no other dependency. This is the screen a customer
opens to answer four questions — what am I on, when does it renew, how much of it
have I used, and how do I leave. Its whole job is to make the last one honest:
cancelling SCHEDULES an ending, it never performs one, and the block says what is
lost and on which day before anyone confirms anything.
Contract
- A zod schema (`subscriptionManagerSchema` in a sibling contract file) is the
single source of truth and props are z.infer of it — never a parallel
interface: { status; asOf; subscription | null; usage[]; changeOptions?;
invoicesLink?; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" — the block's own render
state, unrelated to the health of the subscription. The subscription's own
lifecycle lives in subscription.state.
- Money rule: every amount is an INTEGER in the currency's minor unit (4900 =
$49.00, 128000 = ¥128,000 — JPY has no minor unit, KWD has three digits).
Never floats, and never a hard-coded /100: how many digits a minor unit has is
decided by the ISO 4217 code through Intl.
- Time rule: every instant is absolute ISO 8601 with an offset, and `asOf` is the
block's ONLY clock. Never call Date.now(): a renewal countdown read from the
visitor's machine renders differently on the server and in the browser, which
is a hydration mismatch on the one screen that is about to move money.
- SubscriptionPlan = { id; name; tagline?; priceMinorUnits; currency; interval:
"month"|"year"; includes?: string[] }. `includes` is not decoration — the
cancel dialog reads it to answer "what do I lose".
- Subscription = { id; plan; state; currentPeriodStart; currentPeriodEnd;
trialEndsAt?; accessEndsAt?; seats?; cancellation? }.
state = "trialing" | "active" | "past_due" | "canceling" | "canceled".
`canceling` is the state the block exists for: scheduled, nothing taken away
yet, access running to the end of the period already paid for. Which state
applies is the billing system's call (it owns grace periods and dunning), so
it arrives decided rather than derived in the browser.
seats = { used; included: number|null (null = unlimited, 0 = every seat is
priced); extraSeatPriceMinorUnits? }.
cancellation = { losing?: string[]; dataRetentionNote? } — the two questions a
cancel dialog has to answer before it is honest.
- UsageLimit = { id; label; used; limit: number|null; unit: "count"|"bytes"|
"seconds"; unitLabel?; overLimitNote? }. limit null = UNLIMITED (print the
word, never a bar at 0%); limit 0 = "this plan includes none of it", which is a
different statement and stays distinct through the arithmetic.
- PlanChangeOption = { plan; direction: "upgrade"|"downgrade"|"lateral";
effective: "immediate"|"period_end"; amountDueMinorUnits?; losing?; note? }.
Direction is REPORTED, never inferred from price: monthly to annual raises the
invoice while lowering nothing, and a cheaper regional plan can carry the same
features.
- invoicesLink = { label; href } with "" and "#" rejected by the schema — a dead
link styled as a real one is the defect that refinement exists to prevent.
- Component props = the schema type plus: heading? ("Subscription", pass null for
a bare card), locale? ("en-US"), timeZone? ("UTC"), skeletonRows? (3, clamped
1-8), showUsage? (true), actionLockMs? (1500), and the callbacks onChangePlan,
onCancelSubscription, onResumeSubscription, onUpdatePaymentMethod,
onBrowsePlans, onRetry. forwardRef<HTMLElement>, extends
Omit<React.HTMLAttributes<HTMLElement>, "children">, rest spread on the root
<section>. Every callback is the consumer's: omit one and its control is not
painted at all — a dead button is worse than no button.
- Export the arithmetic as summarizeSubscription({ asOf, subscription, usage })
-> { state; focusInstant; deltaMs; stale; extraSeats; extraSeatCostMinorUnits;
nextInvoiceMinorUnits; nextInvoicePartial; breached[]; nearLimit[] }, and
estimateProration({ asOf, current, option, periodStart, periodEnd }), so a
header badge, a dunning email or a nav dot reuses them instead of growing a
second opinion about when the plan renews.
Behavior — four branches, not one plus three afterthoughts
- loading: the heading and the invoice link still render (both are known before
the billing API answers); below them an aria-hidden skeleton of the plan card
plus skeletonRows meter rows, with aria-busy on the root.
- empty: one centred panel — no plan, so nothing to renew, meter or cancel — and
a note that billing history is unaffected, because "I cancelled, did my
invoices disappear" is the next support ticket.
- error: a destructive-bordered panel printing errorMessage, falling back to a
sentence that admits the plan, the renewal date and the usage are UNKNOWN and
that nothing was charged or changed. The retry button exists only with onRetry.
- ready: plan card + state box + seats/next-invoice pair + usage card + change
plan card + the cancel strip.
- status "ready" with subscription null renders the EMPTY branch: a plan card
with no plan is a shell, and the schema rejects the combination anyway.
Behavior — the state box, one sentence per lifecycle state
- The instant that matters differs per state: trialEndsAt while trialing,
accessEndsAt (falling back to currentPeriodEnd) while canceling/canceled,
currentPeriodEnd otherwise. Everything else — the countdown, the cancel
dialog's date, the "no further invoice" line — reads that one instant.
- trialing: "Trial ends <date> · in N days", then what it will cost.
- active: "Renews <date> · in N days", billed monthly/annually, period start.
- past_due: the retry deadline WITH what happens at it, and an "Update payment
method" action when the host passes one. Nothing is removed while retries run.
- canceling: "Cancels <date>", plus the promise in full — nothing has been
removed, the plan stays exactly as it is until then, resuming costs nothing —
and a "Resume subscription" button.
- canceled: "Access ended <date>" (or "ends", if that instant is still ahead)
and the retention note, because "what happened to my data" is the only
question left.
- If the countdown has already run out and the state is not canceled, print one
extra line: the payload predates the renewal it describes. A date that has
passed rendered as if it had not is worse than admitting the feed is stale.
Behavior — seats, next invoice and usage
- extraSeats = max(0, used - included), 0 when included is null (unlimited).
Cost = extraSeats × extraSeatPriceMinorUnits, but only when that price is in
the payload: otherwise report the overage in seats and refuse to invent its
price. The next invoice is then rendered "from $X" and says so — a floor, not
a total.
- nextInvoice = plan price + priced seat overage, and null while canceling or
canceled: "$0.00" reads as a charge of nothing rather than the absence of one.
- Usage ratio = used / limit; limit null yields null (unlimited), limit 0 with
usage yields Infinity (over, but with no percentage that could describe it).
- Percentages floor below the limit, so a meter at 99.6% never claims an
allowance it has not spent, and anything above 0 but below 1% prints "<1%"
rather than "0%" — consumed is consumed.
- Rows at or over their limit, and rows above 80%, are called out with the
consequence (throttled / blocked / billed), never with colour alone.
- Quantities format per unit: counts through Intl.NumberFormat with the caller's
noun, bytes on the binary ladder (B, KiB … PiB, base 1024, one decimal above
bytes), seconds as "45s" / "29m" / "2h 29m" / "3d 4h". One switch, so a byte
figure can never be printed as a count.
Behavior — what a plan change costs, decided in one order that never guesses
1. option.amountDueMinorUnits (the provider's own figure) wins outright and is
stated as fact: charged today, or credited to the account when negative.
2. effective "period_end" charges nothing today; say when the new price starts.
3. effective "immediate", same currency AND same interval: remainingFraction =
clamp01((periodEnd - asOf) / (periodEnd - periodStart)); charge =
round(newPrice × fraction), credit = round(oldPrice × fraction), and the net
is labelled an estimate with both sides shown and tax called out. Round each
side separately, the way a provider rounds each invoice line.
4. Anything else — another currency, an interval switch that restarts the
period, a period the payload got wrong (end <= start, unparseable) — yields
NO number: "settled at checkout". A plausible wrong figure beside a Switch
button is the expensive failure.
- option.note replaces the generated sentence entirely, for tax rules a
component cannot know.
- Drop any option whose plan.id equals the running plan: the plan already
running is not a place to move to.
Behavior — the cancel flow (the reason this block exists)
- The trigger sits in its own strip that already says the plan keeps running
until <date>, so the promise is visible before the dialog opens.
- The dialog leads with the exact instant access runs to (date AND time, plus the
zone, plus "in N days"), then the list of what stops working on that day
(cancellation.losing, falling back to plan.includes), then the data-retention
note. The confirm button is labelled with the date — "Cancel on 20 Aug 2026",
never "Yes" — and the dismiss button is labelled "Keep <plan>".
- Radix AlertDialog focuses the cancel button by default; leave that alone.
- After confirming, the host flips state to "canceling" and the block keeps
rendering the entire plan under a "Cancels …" headline with Resume in reach.
- A plan change is confirmed the same way, with the cost sentence as the
description and either what the downgrade gives up or what the new plan
includes underneath.
Behavior — one-shot, focus and cleanup
- One global lock: these actions move money and two of them in flight is never
what anyone meant. The guard is a ref read AND written synchronously inside the
handler, because a state flag is only visible after a re-render and the second
half of a double click lands before that; separate state paints the pending
spinner. The lock re-arms on a timer (actionLockMs) — a considered second press
is real intent, and a button that never re-arms is worse than a duplicated
request. Clear that timer on unmount and before re-arming it.
- The confirm dialog closes ONLY if the action actually fired; a locked second
press does nothing at all rather than closing on a no-op.
- Use aria-disabled plus the handler guard while locked, never the native
disabled attribute: the browser blurs a focused disabled control to <body> and
the keyboard user loses their place.
- Focus handoff, twice over. (a) The dialog is controlled and has no
AlertDialogTrigger, so take over onCloseAutoFocus: preventDefault, focus the
opener if it is still connected, otherwise a deliberate successor. (b) A
pressed control frequently unmounts as a result of its own action ("Resume"
once the state flips back to active), so arm a handoff on every press and, in
an effect after the commit, if the actor is gone and focus landed on <body>,
move it to the successor. The successor is the state box, carrying tabIndex
-1: it outlives every control inside the ready branch. Fall back to the root
<section>, also tabIndex -1.
- One persistent live region: <span role="status" aria-atomic class="sr-only">
that speaks only when the PHASE changes (branch, lifecycle state, plan id, or
the count of breached limits). The first paint is the baseline and is never
announced.
- Tab / Shift+Tab move between the buttons and the invoice link — this is a list
of controls, not a composite widget, so no roving tabindex. Enter / Space
activate natively; Escape closes the dialog (Radix owns it).
Behavior — degenerate data (all branches, none of them crash)
- A currency with no minor unit must print no decimals; that falls out of Intl,
never out of a /100.
- A zero-length or inverted period disables the proration estimate instead of
dividing by zero.
- A renewal instant already behind asOf renders as a past date plus the
stale-payload line.
- Seats used against an allowance of 0, with no per-seat price, produce a "from"
invoice figure and an explicit sentence about why.
- An unparseable instant prints verbatim and every figure that needed it is
omitted — no NaN, no "Invalid Date".
- An unknown IANA zone or malformed locale makes Intl throw a RangeError at
construction: catch it, retry without the zone, then with "en-US"/UTC, so one
bad config string cannot blank the page.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
rounded-xl cards, bg-muted meter tracks with bg-primary fills (bg-destructive
over the limit), text-muted-foreground for secondary text, border-destructive/30
+ bg-destructive/5 for the cancel strip, ring-ring focus-visible rings. The
state box's tint comes from one map (border-primary/30 bg-primary/5 for a
trial, bg-muted/40 when active, border-destructive/40 bg-destructive/5 when
past due) and always pairs an icon with a word.
- Merge the consumer className with cn() on the root; every name, label and
identifier gets wrap-anywhere, so a 54-character plan id wraps instead of
overflowing the card. Money and counts are tabular-nums.
- Meters are aria-hidden: everything they encode is in the visible sentence
beside them, and a row of decorative bars is not worth a focus stop.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and the
pending spinners, motion-reduce:transition-none on the meter width transition.
Nothing functional depends on any of it.
Customization levers
- Sub-blocks: showUsage={false} drops the usage card for a seat-only plan; pass
changeOptions: [] and the change-plan card disappears entirely rather than
rendering an empty shell; omit onCancelSubscription and the cancel strip is
never painted (self-serve cancellation off); omit invoicesLink and the header
is just the heading; heading={null} gives a bare card for an existing settings
section.
- Thresholds: NEAR_LIMIT_RATIO (0.8) is the whole "approaching the limit"
policy — raise it for a chatty plan, lower it to warn earlier.
- Lock: actionLockMs (1500) is how long a pressed action stays one-shot. Raise it
when the host's mutation is slow; never set it to Infinity.
- Proration voice: the four branches of prorationSentence are where an exact
quote, an estimate and a refusal are worded. Swap the estimate wording for your
provider's, or pass option.note per row to override it outright.
- Money and time: locale and timeZone drive every Intl formatter, and the zone is
printed in the cancel dialog so nobody has to guess whose midnight it is.
- Palette: STATE_TONE / STATE_TEXT / STATE_BADGE are the only place colour is
decided; the default is monochrome (primary + destructive + muted). Swap the
meter fills for var(--chart-1..5) if usage should read as a data viz.
- Sections are plain cards in a flex column, so reordering them, or lifting the
seats/next-invoice pair into a host dashboard, is a move, not a rewrite.Concepts
- Cancel at period end — the destructive path never removes anything on the spot. The dialog names the instant access runs to, lists what stops working on that day and how long the data survives; afterwards the block keeps rendering the entire plan under a "Cancels …" headline with Resume one press away. "Are you sure?" is not a cancellation flow.
- Injected clock —
asOfis a prop, notDate.now(). Every countdown, the proration fraction, the live-or-lapsed decision and the stale-payload warning derive from it, which is what makes the block renderable on the server, screenshot-stable and identical between SSR and hydration. - Estimate, quote, or silence — a figure from the billing system is stated as fact; a same-currency, same-interval switch is estimated from the unused remainder and labelled an estimate with both sides shown; a currency change, an interval change or a broken period gets no number at all. A plausible wrong figure beside a Switch button costs more than an honest "settled at checkout".
- Unlimited is not zero —
limit: nullprints the word and draws no bar,limit: 0with usage is over-limit with no percentage that could describe it, and a meter under one percent reads<1%rather than0%. Consumed is consumed, and an allowance that does not exist is not an allowance of nothing. - One-shot destructive steps — the guard is a ref read and written synchronously inside the handler, because a state flag only becomes visible after a re-render and the second half of a double click lands before that. The dialog closes only if the action actually fired, and the lock re-arms on a timer instead of latching forever.
- Focus handoff — controls here unmount as a result of their own press: confirm a cancellation and the trigger is gone. The dialog's close and every action both hand focus to a deliberate successor — the opener if it survived, otherwise the state box, which outlives everything inside the ready branch — so focus never lands on
<body>.