Warranty Coverage Panel
A verdict for this serial number today, with exclusions given equal weight to cover and an extension offered only while it can still be bought.
Refund, exchange or credit decided per line, with live stock on the variants and the price difference shown before the goods are posted rather than after.
components/return-exchange-picker.tsxnpx hoverlab add return-exchange-picker
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Order ORD-88410. Each line is decided on its own — one can be refunded while another is swapped.
Graphite mesh, standard height · £420.00 · 1 in the order
Swap for
Brushed steel · £145.00 · 2 in the order
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <ReturnExchangePicker> — refund, exchange, or credit, decided per line.
*
* The fork every returns flow has to offer and most of them get wrong by
* asking too early: a single radio at the top — "Refund or exchange?" — before
* the customer has said what they are sending back. Then the exchange path
* discovers the replacement is out of stock and the whole flow restarts.
*
* THE ORDER OF THE QUESTIONS IS THE DESIGN
*
* 1. Which line, and how many.
* 2. What is the outcome for *that* line.
* 3. If exchange: what for — with live stock, in this step, not at checkout.
*
* Doing it per line matters because the answer genuinely differs: the chair
* that arrived scratched is a refund, the shirt in the wrong size is an
* exchange, and forcing one answer onto both is what makes people give up and
* email.
*
* EXCHANGE IS PRICED HONESTLY AND IMMEDIATELY
*
* Swapping for a more expensive variant shows the difference to pay before the
* customer commits, and a cheaper one shows the amount coming back. A flow
* that reveals "£40 to pay" after the goods have been posted is a flow with a
* complaint at the end of it.
*
* STORE CREDIT IS OFFERED, NOT PRE-SELECTED. It is usually the retailer's
* preferred outcome and often carries a bonus, so it gets a badge saying what
* the bonus is — but the default stays refund, because a pre-ticked option
* that favours the seller is a dark pattern with a business case.
*
* OUT-OF-STOCK VARIANTS ARE SHOWN DISABLED, NOT HIDDEN. Same argument as
* `search-facet-panel` makes about zero counts: a list that reshapes itself is
* disorienting, and "my size is not there" is a different feeling from "my
* size is out of stock until the 30th".
*
* ACCESSIBILITY: the outcome is a real radio group per line, named by the
* product; variant buttons carry `aria-pressed` and their stock state in text;
* the running summary is a polite live region.
*/
import * as React from 'react'
import { ArrowLeftRight, BadgePercent, Banknote, Check } from 'lucide-react'
export type Outcome = 'refund' | 'exchange' | 'credit'
export interface ExchangeVariant {
id: string
label: string
priceDelta: number
/** Zero renders the variant disabled with the restock note. */
stock: number
restock?: string
}
export interface ReturnLine {
id: string
name: string
variant: string
price: number
quantity: number
variants: ExchangeVariant[]
}
export interface ReturnExchangePickerProps {
orderRef?: string
lines?: ReturnLine[]
/** Percentage bonus added when store credit is chosen. */
creditBonusPct?: number
currency?: string
className?: string
}
const DEFAULT_LINES: ReturnLine[] = [
{
id: 'l1',
name: 'Meridian task chair',
variant: 'Graphite mesh, standard height',
price: 420,
quantity: 1,
variants: [
{ id: 'v1', label: 'Graphite mesh, tall', priceDelta: 0, stock: 6 },
{ id: 'v2', label: 'Slate mesh, standard', priceDelta: 0, stock: 0, restock: 'back on 30 September' },
{ id: 'v3', label: 'Graphite mesh, headrest', priceDelta: 65, stock: 3 },
{ id: 'v4', label: 'Graphite fabric, standard', priceDelta: -40, stock: 11 },
],
},
{
id: 'l2',
name: 'Halo task lamp',
variant: 'Brushed steel',
price: 145,
quantity: 2,
variants: [
{ id: 'v5', label: 'Matte black', priceDelta: 0, stock: 14 },
{ id: 'v6', label: 'Brass', priceDelta: 25, stock: 0, restock: 'back on 12 October' },
],
},
]
const OUTCOMES: { id: Outcome; label: string; note: string; icon: typeof Banknote }[] = [
{ id: 'refund', label: 'Refund', note: 'Back to the original payment method', icon: Banknote },
{ id: 'exchange', label: 'Exchange', note: 'Swap for another variant', icon: ArrowLeftRight },
{ id: 'credit', label: 'Store credit', note: 'Issued instantly, never expires', icon: BadgePercent },
]
interface LineChoice {
on: boolean
quantity: number
outcome: Outcome
variantId: string
}
export function ReturnExchangePicker({
orderRef = 'ORD-88410',
lines = DEFAULT_LINES,
creditBonusPct = 10,
currency = '£',
className = '',
}: ReturnExchangePickerProps) {
const uid = React.useId()
const [choices, setChoices] = React.useState<Record<string, LineChoice>>(() =>
Object.fromEntries(
lines.map((line, i) => [
line.id,
{ on: i === 0, quantity: 1, outcome: i === 0 ? 'exchange' : 'refund', variantId: '' },
]),
),
)
function money(value: number) {
const sign = value < 0 ? '−' : ''
return `${sign}${currency}${Math.abs(value).toFixed(2)}`
}
const active = lines.filter((line) => choices[line.id]?.on)
const refundTotal = active.reduce((sum, line) => {
const c = choices[line.id]!
if (c.outcome === 'refund') return sum + line.price * c.quantity
if (c.outcome === 'credit') {
return sum + line.price * c.quantity * (1 + creditBonusPct / 100)
}
return sum
}, 0)
const exchangeDelta = active.reduce((sum, line) => {
const c = choices[line.id]!
if (c.outcome !== 'exchange') return sum
const variant = line.variants.find((v) => v.id === c.variantId)
return sum + (variant?.priceDelta ?? 0) * c.quantity
}, 0)
function update(lineId: string, patch: Partial<LineChoice>) {
setChoices((c) => ({ ...c, [lineId]: { ...c[lineId]!, ...patch } }))
}
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<div className="mx-auto max-w-3xl">
<h2 className="text-xl font-bold tracking-tight">Return or exchange</h2>
<p className="mt-1 text-sm text-muted-foreground">
Order {orderRef}. Each line is decided on its own — one can be
refunded while another is swapped.
</p>
<div className="mt-6 space-y-4">
{lines.map((line) => {
const c = choices[line.id]!
const chosenVariant = line.variants.find((v) => v.id === c.variantId)
return (
<div
key={line.id}
className={`rounded-xl border p-4 transition-colors ${
c.on ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<div className="flex items-start gap-3">
<input
type="checkbox"
id={`${uid}-${line.id}`}
checked={c.on}
onChange={(e) => update(line.id, { on: e.target.checked })}
className="mt-1 h-4 w-4 shrink-0 rounded border-border accent-primary"
/>
<div className="min-w-0 flex-1">
<label htmlFor={`${uid}-${line.id}`} className="text-sm font-medium">
{line.name}
</label>
<p className="text-xs text-muted-foreground">
{line.variant} · {money(line.price)} · {line.quantity} in the order
</p>
{c.on ? (
<>
<fieldset className="mt-3 border-0 p-0">
<legend className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Outcome for {line.name}
</legend>
<div className="mt-2 grid gap-2 md:grid-cols-3">
{OUTCOMES.map((outcome) => {
const Icon = outcome.icon
const id = `${uid}-${line.id}-${outcome.id}`
const on = c.outcome === outcome.id
return (
<div key={outcome.id} className="min-w-0">
<input
type="radio"
id={id}
name={`${uid}-${line.id}-outcome`}
checked={on}
onChange={() => update(line.id, { outcome: outcome.id })}
className="peer sr-only"
/>
<label
htmlFor={id}
className={`block cursor-pointer rounded-lg border p-2.5 text-xs transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring ${
on ? 'border-primary bg-card' : 'border-border hover:bg-muted/60'
}`}
>
<span className="flex items-center gap-1.5 font-semibold">
<Icon aria-hidden className="h-3.5 w-3.5 shrink-0" />
{outcome.label}
{outcome.id === 'credit' ? (
<span className="rounded bg-emerald-500/10 px-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">
+{creditBonusPct}%
</span>
) : null}
</span>
<span className="mt-0.5 block text-muted-foreground">
{outcome.note}
</span>
</label>
</div>
)
})}
</div>
</fieldset>
{c.outcome === 'exchange' ? (
<div className="mt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Swap for
</p>
<ul className="mt-2 flex flex-wrap gap-2">
{line.variants.map((variant) => {
const out = variant.stock === 0
const on = c.variantId === variant.id
return (
<li key={variant.id}>
<button
type="button"
disabled={out}
aria-pressed={on}
onClick={() => update(line.id, { variantId: variant.id })}
className={`rounded-lg border px-2.5 py-1.5 text-start text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
on ? 'border-primary bg-card font-semibold' : 'border-border hover:bg-muted/60'
}`}
>
<span className="flex items-center gap-1.5">
{on ? <Check aria-hidden className="h-3 w-3" /> : null}
{variant.label}
</span>
<span className="mt-0.5 block text-muted-foreground">
{out
? `Out of stock — ${variant.restock}`
: variant.priceDelta === 0
? 'Same price'
: `${money(variant.priceDelta)} difference`}
</span>
</button>
</li>
)
})}
</ul>
{chosenVariant && chosenVariant.priceDelta !== 0 ? (
<p className="mt-2 text-xs font-medium">
{chosenVariant.priceDelta > 0
? `${money(chosenVariant.priceDelta * c.quantity)} to pay before it ships.`
: `${money(Math.abs(chosenVariant.priceDelta) * c.quantity)} comes back to you.`}
</p>
) : null}
</div>
) : null}
</>
) : null}
</div>
</div>
</div>
)
})}
</div>
<div
aria-live="polite"
className="mt-6 rounded-xl border border-border bg-card p-4"
>
<h3 data-stress-ignore className="text-sm font-semibold">Summary</h3>
<dl className="mt-3 space-y-2 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Lines selected</dt>
<dd>{active.length}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Coming back to you</dt>
<dd className="font-medium">{money(refundTotal)}</dd>
</div>
{exchangeDelta !== 0 ? (
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">
{exchangeDelta > 0 ? 'To pay for the exchange' : 'Exchange credit'}
</dt>
<dd className="font-medium">{money(Math.abs(exchangeDelta))}</dd>
</div>
) : null}
</dl>
<button
type="button"
disabled={active.length === 0}
className="mt-4 w-full rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
Confirm and get a return label
</button>
</div>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/return-exchange-picker.tsx and import it where you need the section:
import { ReturnExchangePicker } from '@/components/return-exchange-picker'3 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
orderRef | string | 'ORD-88410' |
lines | ReturnLine[] | DEFAULT_LINES |
creditBonusPctPercentage bonus added when store credit is chosen. | number | 10 |
currency | string | '£' |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Return or Exchange Picker — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="bg-background px-4 py-10 sm:px-6 ">
<div class="mx-auto max-w-3xl">
<h2 class="text-xl font-bold tracking-tight">Return or exchange</h2>
<p class="mt-1 text-sm text-muted-foreground">Order ORD-88410. Each line is decided on its own — one can be refunded while another is swapped.</p>
<div class="mt-6 space-y-4">
<div class="rounded-xl border p-4 transition-colors border-primary bg-primary/5">
<div class="flex items-start gap-3">
<input type="checkbox" id="_R_0_-l1" class="mt-1 h-4 w-4 shrink-0 rounded border-border accent-primary" checked="" />
<div class="min-w-0 flex-1">
<label for="_R_0_-l1" class="text-sm font-medium">Meridian task chair</label>
<p class="text-xs text-muted-foreground">Graphite mesh, standard height · £420.00 · 1 in the order</p>
<fieldset class="mt-3 border-0 p-0">
<legend class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Outcome for Meridian task chair</legend>
<div class="mt-2 grid gap-2 md:grid-cols-3">
<div class="min-w-0">
<input type="radio" id="_R_0_-l1-refund" class="peer sr-only" name="_R_0_-l1-outcome" />
<label for="_R_0_-l1-refund" class="block cursor-pointer rounded-lg border p-2.5 text-xs transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted/60">
<span class="flex items-center gap-1.5 font-semibold"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-banknote h-3.5 w-3.5 shrink-0" aria-hidden="true"><rect width="20" height="12" x="2" y="6" rx="2"></rect><circle cx="12" cy="12" r="2"></circle><path d="M6 12h.01M18 12h.01"></path></svg>Refund</span>
<span class="mt-0.5 block text-muted-foreground">Back to the original payment method</span>
</label>
</div>
<div class="min-w-0">
<input type="radio" id="_R_0_-l1-exchange" class="peer sr-only" name="_R_0_-l1-outcome" checked="" />
<label for="_R_0_-l1-exchange" class="block cursor-pointer rounded-lg border p-2.5 text-xs transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-primary bg-card">
<span class="flex items-center gap-1.5 font-semibold"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-right h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="M8 3 4 7l4 4"></path><path d="M4 7h16"></path><path d="m16 21 4-4-4-4"></path><path d="M20 17H4"></path></svg>Exchange</span>
<span class="mt-0.5 block text-muted-foreground">Swap for another variant</span>
</label>
</div>
<div class="min-w-0">
<input type="radio" id="_R_0_-l1-credit" class="peer sr-only" name="_R_0_-l1-outcome" />
<label for="_R_0_-l1-credit" class="block cursor-pointer rounded-lg border p-2.5 text-xs transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-ring border-border hover:bg-muted/60">
<span class="flex items-center gap-1.5 font-semibold"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-percent h-3.5 w-3.5 shrink-0" aria-hidden="true"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"></path><path d="m15 9-6 6"></path><path d="M9 9h.01"></path><path d="M15 15h.01"></path></svg>Store credit<span class="rounded bg-emerald-500/10 px-1 text-[10px] font-bold text-emerald-700 dark:text-emerald-400">+10%</span></span>
<span class="mt-0.5 block text-muted-foreground">Issued instantly, never expires</span>
</label>
</div>
</div>
</fieldset>
<div class="mt-3">
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Swap for</p>
<ul class="mt-2 flex flex-wrap gap-2">
<li>
<button type="button" aria-pressed="false" class="rounded-lg border px-2.5 py-1.5 text-start text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted/60">
<span class="flex items-center gap-1.5">Graphite mesh, tall</span>
<span class="mt-0.5 block text-muted-foreground">Same price</span>
</button>
</li>
<li>
<button type="button" disabled="" aria-pressed="false" class="rounded-lg border px-2.5 py-1.5 text-start text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted/60">
<span class="flex items-center gap-1.5">Slate mesh, standard</span>
<span class="mt-0.5 block text-muted-foreground">Out of stock — back on 30 September</span>
</button>
</li>
<li>
<button type="button" aria-pressed="false" class="rounded-lg border px-2.5 py-1.5 text-start text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted/60">
<span class="flex items-center gap-1.5">Graphite mesh, headrest</span>
<span class="mt-0.5 block text-muted-foreground">£65.00 difference</span>
</button>
</li>
<li>
<button type="button" aria-pressed="false" class="rounded-lg border px-2.5 py-1.5 text-start text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring border-border hover:bg-muted/60">
<span class="flex items-center gap-1.5">Graphite fabric, standard</span>
<span class="mt-0.5 block text-muted-foreground">−£40.00 difference</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="rounded-xl border p-4 transition-colors border-border">
<div class="flex items-start gap-3">
<input type="checkbox" id="_R_0_-l2" class="mt-1 h-4 w-4 shrink-0 rounded border-border accent-primary" />
<div class="min-w-0 flex-1">
<label for="_R_0_-l2" class="text-sm font-medium">Halo task lamp</label>
<p class="text-xs text-muted-foreground">Brushed steel · £145.00 · 2 in the order</p>
</div>
</div>
</div>
</div>
<div aria-live="polite" class="mt-6 rounded-xl border border-border bg-card p-4">
<h3 data-stress-ignore="true" class="text-sm font-semibold">Summary</h3>
<dl class="mt-3 space-y-2 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-muted-foreground">Lines selected</dt>
<dd>1</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted-foreground">Coming back to you</dt>
<dd class="font-medium">£0.00</dd>
</div>
</dl>
<button type="button" class="mt-4 w-full rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card">Confirm and get a return label</button>
</div>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse After-Sale ServiceCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
A verdict for this serial number today, with exclusions given equal weight to cover and an extension offered only while it can still be bought.
Refunds are per line, not per order: a reason on each line that drives what is asked for next, and a running total itemising the restocking fee and the delivery rule.
Where the money is and who is holding it - four stages, each naming the responsible party, including the bank stage every where-is-my-refund contact comes from.
The operator queue, sorted oldest-open-first because recency is the wrong default, with blocked as a first-class status that names its blocker.
The screen between approved and posted: three drop-off methods with the thing that actually blocks each one, the packing rules that get returns refused, and a date rather than a duration.
A repair is not a return - a symptom picker that filters the service routes, warranty resolved inside the form, and an estimate with its uncertainty stated.
The reviews you wrote and the ones you owe, with editing that is honest about being marked as edited and moderation that names the rule it applied.
A balance checker built for the two ways it goes wrong - an empty card and an expired one - answering with a transaction history rather than a bare zero.