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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/referral-program.jsonPrompt
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 clock —
asOfis a prop, notDate.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-disabledwith 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.
Help Center
A support landing block whose search ranks titles, keywords and summaries, whose topic cards report honest counts, and whose zero result offers a topic or a human instead of a dead end.
App Download
A get-the-app block that promotes the store matching the visitor's device, encodes the smart link into a scannable QR for desktop visitors, and renders the store badges you supply instead of redrawing brand marks.