Blocks

Referral Program

A four-state referral panel: the personal invite link with copy and templated share targets, tier progress counted from the referral list itself, every referred person with their stage and reward, and the program terms behind a disclosure.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronDown,
  Copy,
  Gift,
  Link2,
  Lock,
  OctagonAlert,
  RotateCw,
  Share2,
  Sparkles,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/referral-program.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ReferralProgram" block with zod and
lucide-react, on top of the shadcn Button, Badge and Input primitives; cn()
(clsx + tailwind-merge) for classes, no other dependency. This is the panel an
account page shows under "Refer a friend": the link, how far along the reward
ladder the account is, who took the link up and what each of them earned, and
the small print. Its one job is that the bar, the list and the wording can never
tell three different stories.

Contract
- A zod schema (referralProgramSchema in a sibling contract file) is the single
  source of truth and props are z.infer of it — never a parallel interface:
  { status; title; asOf; link; shareMessage?; shareTargets[]; tiers[];
    referrals[]; earned; terms[]; termsUrl?; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready": the panel's own render
  state, unrelated to how the program is doing.
- link is { url; code?; expiresAt: string | null } or NULL — an account with no
  link yet is a real state, not an error.
- ReferralShareTarget = { id; label; href }. href is a TEMPLATE: "{url}" and
  "{text}" are replaced with the URL-encoded link and share message, and a
  template with neither placeholder is used verbatim, so mailto:, sms: and any
  web intent all work through one field. No platform is hardcoded anywhere in
  the component.
- ReferralTier = { id; label; threshold; reward; note? }. threshold is the
  number of QUALIFIED referrals that unlock the rung; tiers arrive in any order.
- ReferralPerson = { id; name; contact?; avatarUrl?; stage; invitedAt;
  updatedAt: string | null; reward: Reward | null }. stage is
  "invited" | "signed_up" | "qualified" and only "qualified" pays; what
  qualifies is the program's rule, never something this panel infers. contact is
  already masked BY THE HOST — the panel cannot know which half of an address is
  safe to print. reward null means "nothing yet", which is not zero.
- Reward = { amount: number; currency: string | null; unit?: string }. A
  currency makes it money; currency null makes it a COUNT of units (credits,
  months free) that must never be printed with a currency symbol. unit is
  printed verbatim, so the host writes the noun that reads right for that amount
  ("1 month free", "3 months free") — never guess an English plural.
- earned = { credited; pending; note? } is REPORTED by billing and deliberately
  NOT derived from referrals[]: the list is a window, the balance counts
  everything the program ever paid. Print them side by side with their
  provenance instead of pretending one is the other.
- asOf is an ISO instant and is the ONLY clock. Never call Date.now(): every
  "3 days ago", every "expires in …" and the expired/live decision derives from
  it, so the same payload always renders the same panel and SSR matches
  hydration.
- Component props = the schema type plus: locale? ("en-US"), timeZone? ("UTC"),
  skeletonRows? (3, clamped 1-8), initialVisibleReferrals? (6, min 1),
  staleInviteDays? (14, 0 disables), enableSystemShare? (true), shareIcons?
  (Record of target id to ReactNode — the ONLY way a third-party brand mark
  enters this component), description?, onCopyLink?, onShare?, onGenerateLink?,
  onRetry?. forwardRef<HTMLElement>, extends
  Omit<React.HTMLAttributes<HTMLElement>, "title">, rest spread on the root
  <section>. Every callback is the consumer's: omit onGenerateLink and the
  link-less state explains itself instead of painting a dead button; omit
  onRetry and the error branch has no retry.
- Export summarizeReferralProgram({ referrals, tiers }) ->
  { counts; total; qualified; currentTier; nextTier; remaining; ratio;
    ladderMax; placed[]; unplaced[] } so a nav badge, a toast or an email reuses
  the ladder arithmetic instead of growing a second opinion. Export sumRewards,
  createRewardFormatter and buildShareHref too.

Behavior — four branches, not one plus three afterthoughts
- loading: the heading still renders (it is known before the feed answers);
  below it a skeleton link row and skeletonRows people rows, aria-hidden, with
  aria-busy on the root.
- error: a destructive-bordered panel printing errorMessage, falling back to a
  sentence that says the link and balance are SAFE and merely unreadable —
  never "you have no rewards". The retry button exists only when onRetry does.
- empty: the earnings tiles, the LINK ROW and the tier ladder still render, and
  only the people list is replaced by one sentence. Nobody having used the link
  yet is exactly when the link matters most; hiding it behind an empty
  illustration would remove the one control the panel exists for.
- ready: the same, with the people list.
- status "ready" with zero referrals renders the EMPTY branch, so a list header
  never sits over nothing.

Behavior — the maths
- qualified = count of referrals whose stage is "qualified". Tier position is
  DERIVED from it and never read from a separate reported number, because two
  figures that can drift apart eventually do.
- Normalise thresholds: a non-finite threshold cannot be placed on the ladder
  and is listed apart rather than dropped (a rung nobody can reach is still
  information); the rest clamp to max(0, round(threshold)) and sort ascending,
  stably, so two rungs at the same threshold keep the order the program wrote.
- currentTier = the LAST rung whose threshold <= qualified (null before the
  first). nextTier = the FIRST rung above it. remaining = nextTier.threshold -
  qualified. ladderMax = the highest threshold. ratio = qualified / ladderMax
  clamped to 0-1; with tiers present but ladderMax 0 the ladder is complete, and
  with no tiers at all the whole section disappears rather than drawing an empty
  bar.
- Rewards only add up inside one currency (or one unit). Bucket by a composite
  key of currency-or-unit and return a LIST of totals in first-seen order:
  merging dollars, euros and "months free" into one figure is arithmetic nobody
  can spend. Build that key with an ESCAPE SEQUENCE separator ("\u001f"), never
  a literal control byte typed into the file — a raw byte makes the source read
  as binary to grep and ships into the buyer's repository.
- Amounts that are not finite numbers are counted as "could not be added up"
  and reported next to the total, instead of poisoning a sum into NaN.
- The list total is computed over the WHOLE filtered set, not the visible slice:
  collapsing the list must not change what it says the list is worth.
- Money is formatted with Intl.NumberFormat style "currency"; an ISO code Intl
  rejects (a RangeError at construction, which is how "US$" arrives) falls back
  to the number with the code beside it. Counts are formatted as plain numbers
  plus the unit noun. A non-finite amount is an em dash, never "NaN".
- Relative labels print the largest unit only ("12 days ago", "in 2 months") and
  handle BOTH directions, because clocks skew and an invitedAt can sit slightly
  ahead of asOf.

Behavior — the link row
- The URL renders in a readOnly input whose focus handler selects the whole
  value, so Control/Command + C still works where the clipboard API does not.
- Copy: navigator.clipboard.writeText guarded by a feature test inside a
  try/catch. Insecure context, denied permission and Safari's user-gesture rules
  all end in the same honest "Copy failed" state that tells the user to press
  Control or Command + C, never in a silent tick. The result resets after 2s.
  The write is async and can land after unmount: check a mounted ref before
  touching state or arming that timer.
- Expiry is derived from asOf: parse(expiresAt) <= asOf means expired. An
  expired link makes Copy and every share target REFUSE (they announce why) but
  keeps them mounted and focusable — aria-disabled plus a prevented default,
  never the native disabled attribute and never a removed href, because the
  browser blurs a disabled control to the page body and the keyboard user loses
  their place. An expiresAt that will not parse prints verbatim and never
  expires the link on a guess.
- Share targets render as real anchors (target="_blank", rel="noopener
  noreferrer") built from the template, with the brand mark taken from
  shareIcons[target.id] and a neutral fallback icon.
- The Web Share button is capability-detected through useSyncExternalStore with
  a server snapshot of false — never a navigator read during render, which would
  make the first client paint disagree with the markup React hydrates. A
  dismissed share sheet rejects with AbortError: a cancel is a choice, not a
  failure, and announcing it would be noise.
- "Create my link" (link null + onGenerateLink) is one-shot: 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 click of a double click lands
  before that. It re-arms on a 1200ms timer — a considered second attempt is
  real, and a permanently dead button is worse than a duplicated request.

Behavior — the people list
- Sort by last activity (updatedAt ?? invitedAt) newest first; rows whose
  timestamp will not parse sink to the bottom in their original order rather
  than reordering the ones that did, and print the raw string.
- Filter chips for All / Invited / Signed up / Qualified, each carrying its
  count from the same derived summary. Chips stay mounted at a count of zero, so
  a filter can never unmount the control the user just pressed; an empty result
  says "Nobody is at this stage yet" inside the list.
- The first initialVisibleReferrals rows show and the rest hide behind a
  "Show all N" disclosure. Rows carry no focusable controls, so collapsing can
  never strand focus.
- A person still at "invited" after staleInviteDays gets a quiet "No response
  in N days" line — the injected clock earning its keep, not a new stage.
- Avatars fall back to initials on error, and a ref callback probes
  img.complete && naturalWidth === 0 as well: on a prerendered page a cached
  image can fail before hydration attaches onError, and the broken glyph would
  stay forever.

Behavior — keyboard, ARIA and cleanup
- Root is <section aria-labelledby> pointing at the h2, aria-busy while loading.
  The h2 carries tabIndex -1 as the last-resort focus successor.
- Tab / Shift+Tab move between the link field, Copy, each share target, each
  filter chip, the disclosure and the terms toggle — a list of buttons, not a
  composite widget, so no roving tabindex. Enter / Space activate natively.
- The filter chips are toggle buttons with aria-pressed inside a labelled
  role="group": deliberately a filter, not a tablist, so nothing steals the
  arrow keys from the page.
- The tier bar is a single role="progressbar" with aria-valuemin/max/now and an
  aria-valuetext spelling out the sentence ("4 qualified referrals of 12. 2 more
  to unlock Squad, worth $120."). The milestone pips are decoration and stay
  aria-hidden.
- Disclosures toggle with the `hidden` attribute, not a collapsed 0fr grid
  track: a 0fr track still holds its tab stops, which turns the panel into an
  invisible keyboard trap.
- One persistent live region: a span with role="status" aria-atomic and sr-only.
  It speaks copy results, refusals, filter changes and phase changes only — the
  first paint is the baseline and is never announced. Announcements self-clear
  after 6s so that the NEXT identical message is spoken again.
- Any control that unmounts under the user hands focus to a deliberate
  successor: pressing Try again or Create my link records whether it held focus,
  and once the branch swaps (or the link arrives) focus goes to Copy link, or to
  the heading if there is none — but only if focus really was orphaned onto the
  page body, because a user who moved on outranks this rule.
- Cleanup: clear the announcement timer, the copy-reset timer and both one-shot
  re-arm timers on unmount and before re-arming any of them.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
  rounded-xl cards, bg-muted tracks, bg-primary fill and pips, text-primary for
  reached rungs, text-destructive plus border-destructive/40 for the error panel
  and the expired chip, text-muted-foreground for secondary text, ring-ring
  focus-visible rings inherited from the Button and Input primitives. Swap the
  fill for var(--chart-1) if the panel should read as part of a dashboard.
- Stage is carried by the WORD in the badge, never by colour alone: Qualified is
  the solid badge, Signed up the secondary, Invited the outline.
- Merge the consumer className with cn() on the root; names, URLs, notes and
  ids get wrap-anywhere so a 128-character link wraps instead of pushing the
  card sideways. Figures are tabular-nums so columns line up.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and the retry
  spinner, motion-reduce:transition-none on the bar fill and the chevrons.
  Nothing functional depends on any of it.
- "use client" is required: clipboard, share, focus management and disclosure
  state all need the browser.

Customization levers
- Sub-blocks: pass tiers: [] and the whole ladder disappears; terms: [] drops
  the disclosure; shareTargets: [] with enableSystemShare={false} leaves a bare
  copy row; earned with all-null fields and no note hides the tiles. Each block
  is absent, never an empty shell.
- Density: initialVisibleReferrals decides how much list shows before the
  disclosure (set it above your list length to always show everything);
  skeletonRows matches the loading skeleton to the number of rows you expect.
- Nudges: staleInviteDays sets when a silent invite grows its "no response"
  line; 0 turns the nudge off entirely.
- Ladder shape: thresholds are counts of qualified referrals, so a two-rung
  ladder and a ten-rung one need no code change. Feed rewards with currency null
  to pay in credits or months instead of money.
- Sharing: shareTargets is data, so a buyer adds or removes platforms without
  touching the component, and shareIcons injects the brand marks. Wire onShare
  for analytics, onCopyLink to count copies, onGenerateLink to your provisioning
  call and onRetry to your fetcher.
- Palette: STAGE_BADGE, the bar fill and the tier row tints are the only places
  colour is decided; a branded palette is a handful of edits in one object.
- Locale / zone: locale drives both Intl.DateTimeFormat and every amount;
  timeZone only affects the absolute expiry date.

Concepts

  • Derived ladder — tier position is counted from the referrals themselves, so the bar, the "2 more to unlock" sentence and the rows are three views of one number. A separately reported progress figure is the classic way a rewards panel ends up telling a customer they are one referral short of a tier they already passed.
  • The empty state still hands over the link — nobody having used the link yet is precisely when the link matters most, so the empty branch keeps the copy row and the ladder and replaces only the list. An empty illustration in its place would remove the one control the panel exists for.
  • Injected clockasOf is a prop, not Date.now(). Every "12 days ago", every "expires in", the expired decision and the silent-invite nudge derive from it, which is what makes the panel renderable on the server, screenshot-stable and identical between SSR and hydration.
  • Refusal beats disappearance — an expired link keeps its Copy and share controls mounted and focusable, marked aria-disabled with the click prevented and the reason announced. Unmounting or natively disabling the control someone is standing on drops their focus to the page body, and they have to find their place again.
  • Provenance, not reconciliation — the balance comes from billing and the list total is summed from the rows on screen; they are printed as two labelled figures instead of one confident number, because the list is a window and the balance is the whole history. Amounts in different currencies are totalled apart for the same reason.
  • One-shot with a ref, not with state — creating a link and retrying a failed load both lock on a ref that is read and written inside the handler, because a state flag only becomes visible after a re-render and the second click of a double click lands before that. The lock re-arms on a timer, so a considered second attempt is still possible.

On This Page