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.
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.
components/refund-request-form.tsxnpx hoverlab add refund-request-form
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.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <RefundRequestForm> — asking for money back, line by line.
*
* The mistake almost every refund form makes is treating the order as the
* unit. Customers refund *items*, and usually not all of them: two of the
* four arrived damaged, the third was the wrong size, the fourth is fine. A
* form with one reason box and one Submit forces that into a paragraph
* someone in support then has to parse by hand.
*
* SO THE UNIT IS THE LINE ITEM
*
* Each line has its own checkbox, its own quantity, and its own reason,
* because the reason drives the outcome: "arrived damaged" is refunded with
* no return, "changed my mind" needs the item back, and the two cannot share
* a code path. Selecting a reason therefore changes what the form asks for
* next — a photo for damage, nothing for a change of mind.
*
* THE TOTAL IS COMPUTED IN FRONT OF THE CUSTOMER
*
* Refundable amount, delivery, and the restocking fee where one applies, all
* itemised and updating as lines are ticked. A refund form that submits into
* silence and mails a figure four days later generates a second contact every
* time the figure surprises anyone.
*
* DELIVERY IS REFUNDED ONLY WHEN THE WHOLE ORDER GOES BACK. That is the rule
* in most jurisdictions and every retailer, and it is the single most common
* source of "you refunded me the wrong amount". It is stated in the summary as
* a line, not in a footnote.
*
* ACCESSIBILITY: each line is a `<fieldset>` whose `<legend>` is the product
* name, so a screen reader hears which item each reason select belongs to
* rather than four identical "Reason" comboboxes. The running total is a
* polite live region. The photo requirement is announced when it appears,
* because a required field that materialises silently is a submit failure
* waiting to happen.
*/
import * as React from 'react'
import { Camera, Info, ShieldQuestion } from 'lucide-react'
export interface RefundLine {
id: string
name: string
variant: string
unitPrice: number
quantity: number
/** Restocking fee percentage applied to a change-of-mind return. */
restockPct?: number
}
export interface RefundRequestFormProps {
orderRef?: string
lines?: RefundLine[]
deliveryPaid?: number
currency?: string
className?: string
}
const REASONS = [
{ id: 'damaged', label: 'Arrived damaged', needsPhoto: true, needsReturn: false },
{ id: 'faulty', label: 'Faulty in use', needsPhoto: true, needsReturn: true },
{ id: 'wrong', label: 'Wrong item sent', needsPhoto: false, needsReturn: true },
{ id: 'mind', label: 'Changed my mind', needsPhoto: false, needsReturn: true },
{ id: 'late', label: 'Arrived too late to be useful', needsPhoto: false, needsReturn: true },
] as const
type ReasonId = (typeof REASONS)[number]['id']
const DEFAULT_LINES: RefundLine[] = [
{ id: 'l1', name: 'Meridian task chair', variant: 'Graphite mesh', unitPrice: 420, quantity: 2 },
{ id: 'l2', name: 'Halo task lamp', variant: 'Brushed steel', unitPrice: 145, quantity: 1, restockPct: 10 },
{ id: 'l3', name: 'Cable spine', variant: 'Black, 1.2 m', unitPrice: 38, quantity: 4, restockPct: 10 },
]
const INPUT_CLASS =
'rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring'
interface LineState {
on: boolean
quantity: number
reason: ReasonId | ''
}
export function RefundRequestForm({
orderRef = 'ORD-88410',
lines = DEFAULT_LINES,
deliveryPaid = 12,
currency = '£',
className = '',
}: RefundRequestFormProps) {
const uid = React.useId()
const [state, setState] = React.useState<Record<string, LineState>>(() =>
Object.fromEntries(
lines.map((line, i) => [
line.id,
{ on: i === 0, quantity: i === 0 ? 1 : line.quantity, reason: i === 0 ? 'damaged' : '' },
]),
),
)
const chosen = lines.filter((line) => state[line.id]?.on)
const goods = chosen.reduce(
(sum, line) => sum + line.unitPrice * (state[line.id]?.quantity ?? 0),
0,
)
const restocking = chosen.reduce((sum, line) => {
const s = state[line.id]
if (!s || s.reason !== 'mind' || !line.restockPct) return sum
return sum + (line.unitPrice * s.quantity * line.restockPct) / 100
}, 0)
// Delivery comes back only when nothing is kept — the single most common
// source of "you refunded me the wrong amount".
const everythingReturned =
chosen.length === lines.length &&
lines.every((line) => (state[line.id]?.quantity ?? 0) === line.quantity)
const delivery = everythingReturned ? deliveryPaid : 0
const total = goods - restocking + delivery
const needsPhoto = chosen.some(
(line) => REASONS.find((r) => r.id === state[line.id]?.reason)?.needsPhoto,
)
const needsReturn = chosen.some(
(line) => REASONS.find((r) => r.id === state[line.id]?.reason)?.needsReturn,
)
function money(value: number) {
return `${currency}${value.toFixed(2)}`
}
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<form className="mx-auto max-w-3xl" onSubmit={(e) => e.preventDefault()}>
<h2 className="text-xl font-bold tracking-tight">Request a refund</h2>
<p className="mt-1 text-sm text-muted-foreground">
Order {orderRef} · delivered 2 September 2026. Pick the lines you want
back — you do not have to return the whole order.
</p>
<div className="mt-6 space-y-3">
{lines.map((line) => {
const s = state[line.id] ?? { on: false, quantity: line.quantity, reason: '' as const }
const reason = REASONS.find((r) => r.id === s.reason)
return (
<fieldset
key={line.id}
className={`rounded-xl border p-4 transition-colors ${
s.on ? 'border-primary bg-primary/5' : 'border-border'
}`}
>
<legend className="sr-only">{line.name}</legend>
<div className="flex flex-wrap items-start gap-3">
<input
type="checkbox"
id={`${uid}-${line.id}-on`}
checked={s.on}
onChange={(e) =>
setState((v) => ({ ...v, [line.id]: { ...s, 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}-on`} className="text-sm font-medium">
{line.name}
</label>
<p className="text-xs text-muted-foreground">
{line.variant} · {money(line.unitPrice)} each · {line.quantity} ordered
</p>
{s.on ? (
<div className="mt-3 flex flex-wrap items-end gap-3">
<div>
<label
htmlFor={`${uid}-${line.id}-qty`}
className="block text-xs font-medium"
>
Quantity
</label>
<select
id={`${uid}-${line.id}-qty`}
value={s.quantity}
onChange={(e) =>
setState((v) => ({
...v,
[line.id]: { ...s, quantity: Number(e.target.value) },
}))
}
className={`mt-1 ${INPUT_CLASS}`}
>
{Array.from({ length: line.quantity }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
<div className="min-w-[12rem] flex-1">
<label
htmlFor={`${uid}-${line.id}-reason`}
className="block text-xs font-medium"
>
Reason for {line.name}
</label>
<select
id={`${uid}-${line.id}-reason`}
required
value={s.reason}
onChange={(e) =>
setState((v) => ({
...v,
[line.id]: { ...s, reason: e.target.value as ReasonId },
}))
}
className={`mt-1 w-full ${INPUT_CLASS}`}
>
<option value="">Choose a reason…</option>
{REASONS.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
</div>
</div>
) : null}
{s.on && reason?.needsReturn && line.restockPct && s.reason === 'mind' ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-400">
A {line.restockPct}% restocking fee applies to a change of
mind on this line.
</p>
) : null}
</div>
</div>
</fieldset>
)
})}
</div>
{/* Announced when it appears — a required field that materialises
silently is a submit failure waiting to happen. */}
{needsPhoto ? (
<div
role="status"
className="mt-4 rounded-xl border border-border bg-card p-4"
>
<label
htmlFor={`${uid}-photos`}
className="flex items-center gap-2 text-sm font-medium"
>
<Camera aria-hidden className="h-4 w-4 text-primary" />
Photos of the damage — required
</label>
<p className="mt-1 text-xs text-muted-foreground">
One clear shot of the fault and one of the packaging. Claims
without them are held until we ask for them, which adds days.
</p>
<input
id={`${uid}-photos`}
type="file"
multiple
accept="image/*"
required
className="mt-2 block w-full text-xs file:me-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-xs file:font-medium"
/>
</div>
) : null}
<div className="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div className="min-w-0">
<label htmlFor={`${uid}-notes`} className="block text-sm font-medium">
Anything else we should know
<span className="ms-2 text-xs font-normal text-muted-foreground">
Optional
</span>
</label>
<textarea
id={`${uid}-notes`}
rows={4}
placeholder="The second chair had a cracked base plate out of the box."
className={`mt-1.5 w-full ${INPUT_CLASS}`}
/>
</div>
<aside
aria-live="polite"
className="rounded-xl border border-border bg-card p-4 text-sm"
>
<h3 data-stress-ignore className="text-sm font-semibold">Estimated refund</h3>
<dl className="mt-3 space-y-2">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Goods</dt>
<dd>{money(goods)}</dd>
</div>
{restocking > 0 ? (
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Restocking fee</dt>
<dd className="text-destructive">−{money(restocking)}</dd>
</div>
) : null}
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Delivery</dt>
<dd>
{delivery > 0 ? (
money(delivery)
) : (
<span className="text-xs text-muted-foreground">
Not refunded — part of the order is kept
</span>
)}
</dd>
</div>
<div className="flex justify-between gap-3 border-t border-border pt-2 font-semibold">
<dt>Total</dt>
<dd>{money(total)}</dd>
</div>
</dl>
<p className="mt-3 flex gap-1.5 text-xs text-muted-foreground">
<Info aria-hidden className="mt-0.5 h-3 w-3 shrink-0" />
{needsReturn
? 'Some lines must come back before the refund is released. A prepaid label follows by email.'
: 'Nothing needs returning. The refund is released once the photos are checked.'}
</p>
</aside>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldQuestion aria-hidden className="h-3.5 w-3.5" />
Refunds reach the original payment method in 3–5 working days once
approved.
</p>
<button
type="submit"
disabled={chosen.length === 0 || chosen.some((l) => !state[l.id]?.reason)}
className="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-background"
>
Request {money(total)} back
</button>
</div>
</form>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/refund-request-form.tsx and import it where you need the section:
import { RefundRequestForm } from '@/components/refund-request-form'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 | RefundLine[] | DEFAULT_LINES |
deliveryPaid | number | 12 |
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.
<!--
Refund Request Form — 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 ">
<form class="mx-auto max-w-3xl">
<h2 class="text-xl font-bold tracking-tight">Request a refund</h2>
<p class="mt-1 text-sm text-muted-foreground">Order ORD-88410 · delivered 2 September 2026. Pick the lines you want back — you do not have to return the whole order.</p>
<div class="mt-6 space-y-3">
<fieldset class="rounded-xl border p-4 transition-colors border-primary bg-primary/5">
<legend class="sr-only">Meridian task chair</legend>
<div class="flex flex-wrap items-start gap-3">
<input type="checkbox" id="_R_0_-l1-on" 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-on" class="text-sm font-medium">Meridian task chair</label>
<p class="text-xs text-muted-foreground">Graphite mesh · £420.00 each · 2 ordered</p>
<div class="mt-3 flex flex-wrap items-end gap-3">
<div>
<label for="_R_0_-l1-qty" class="block text-xs font-medium">Quantity</label>
<select id="_R_0_-l1-qty" class="mt-1 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option value="1" selected="">1</option>
<option value="2">2</option>
</select>
</div>
<div class="min-w-[12rem] flex-1">
<label for="_R_0_-l1-reason" class="block text-xs font-medium">Reason for Meridian task chair</label>
<select id="_R_0_-l1-reason" required="" class="mt-1 w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring">
<option value="">Choose a reason…</option>
<option value="damaged" selected="">Arrived damaged</option>
<option value="faulty">Faulty in use</option>
<option value="wrong">Wrong item sent</option>
<option value="mind">Changed my mind</option>
<option value="late">Arrived too late to be useful</option>
</select>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset class="rounded-xl border p-4 transition-colors border-border">
<legend class="sr-only">Halo task lamp</legend>
<div class="flex flex-wrap items-start gap-3">
<input type="checkbox" id="_R_0_-l2-on" 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-on" class="text-sm font-medium">Halo task lamp</label>
<p class="text-xs text-muted-foreground">Brushed steel · £145.00 each · 1 ordered</p>
</div>
</div>
</fieldset>
<fieldset class="rounded-xl border p-4 transition-colors border-border">
<legend class="sr-only">Cable spine</legend>
<div class="flex flex-wrap items-start gap-3">
<input type="checkbox" id="_R_0_-l3-on" 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_-l3-on" class="text-sm font-medium">Cable spine</label>
<p class="text-xs text-muted-foreground">Black, 1.2 m · £38.00 each · 4 ordered</p>
</div>
</div>
</fieldset>
</div>
<div role="status" class="mt-4 rounded-xl border border-border bg-card p-4">
<label for="_R_0_-photos" class="flex items-center gap-2 text-sm font-medium"><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-camera h-4 w-4 text-primary" aria-hidden="true"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"></path><circle cx="12" cy="13" r="3"></circle></svg>Photos of the damage — required</label>
<p class="mt-1 text-xs text-muted-foreground">One clear shot of the fault and one of the packaging. Claims without them are held until we ask for them, which adds days.</p>
<input id="_R_0_-photos" type="file" multiple="" accept="image/*" required="" class="mt-2 block w-full text-xs file:me-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-xs file:font-medium" />
</div>
<div class="mt-6 grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="min-w-0">
<label for="_R_0_-notes" class="block text-sm font-medium">Anything else we should know<span class="ms-2 text-xs font-normal text-muted-foreground">Optional</span></label>
<textarea id="_R_0_-notes" rows="4" placeholder="The second chair had a cracked base plate out of the box." class="mt-1.5 w-full rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"></textarea>
</div>
<aside aria-live="polite" class="rounded-xl border border-border bg-card p-4 text-sm">
<h3 data-stress-ignore="true" class="text-sm font-semibold">Estimated refund</h3>
<dl class="mt-3 space-y-2">
<div class="flex justify-between gap-3">
<dt class="text-muted-foreground">Goods</dt>
<dd>£420.00</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted-foreground">Delivery</dt>
<dd>
<span class="text-xs text-muted-foreground">Not refunded — part of the order is kept</span>
</dd>
</div>
<div class="flex justify-between gap-3 border-t border-border pt-2 font-semibold">
<dt>Total</dt>
<dd>£420.00</dd>
</div>
</dl>
<p class="mt-3 flex gap-1.5 text-xs text-muted-foreground"><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-info mt-0.5 h-3 w-3 shrink-0" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path></svg>Nothing needs returning. The refund is released once the photos are checked.</p>
</aside>
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
<p class="flex items-center gap-1.5 text-xs text-muted-foreground"><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-shield-question-mark h-3.5 w-3.5" aria-hidden="true"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"></path><path d="M9.1 9a3 3 0 0 1 5.82 1c0 2-3 3-3 3"></path><path d="M12 17h.01"></path></svg>Refunds reach the original payment method in 3–5 working days once approved.</p>
<button type="submit" class="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-background">Request £420.00 back</button>
</div>
</form>
</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.
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.
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.
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.