Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowDownRight,
  ArrowRightLeft,
  ArrowUpRight,
  Ban,
  CalendarClock,
  CircleAlert,
  CircleCheck,
  CircleDashed,
  CreditCard,
  ExternalLink,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/subscription-manager.json

Prompt

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 clockasOf is a prop, not Date.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 zerolimit: null prints the word and draws no bar, limit: 0 with usage is over-limit with no percentage that could describe it, and a meter under one percent reads <1% rather than 0%. 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>.

On This Page