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.
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.
components/gift-card-balance.tsxnpx hoverlab add gift-card-balance
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.
The number and PIN are on the back of the card, or in the email if it was sent digitally.
Card ending 5566
£62.50
Issued 4 December 2025 · valid until 4 December 2027
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <GiftCardBalance> — checking a card, and the two ways that goes wrong.
*
* A balance checker is four lines of markup and is almost always shipped
* broken, because it is treated as a lookup rather than as a screen someone
* reaches while holding a card that is not working. The two failures it has to
* handle are both real and both common:
*
* the card is empty spent, and the customer wants to know on what
* the card is expired which in some jurisdictions is not the end of it
*
* SO THE RESULT IS A HISTORY, NOT A NUMBER
*
* "£0.00" is technically an answer and practically an accusation. The
* transaction list — where it was spent, when, against which order — is what
* turns a disputed balance into a resolved one, and it costs one table.
*
* THE PIN FIELD EXISTS AND IS EXPLAINED
*
* Balance lookups are scraped: a card number alone is guessable at scale, and
* a checker with no second factor is a free oracle for anyone brute-forcing
* them. The PIN is why, and saying so is better than an unexplained extra
* field people assume is optional.
*
* THE CARD NUMBER IS FORMATTED AS IT IS TYPED, AND STORED UNFORMATTED. Spaces
* in the input, none in the value. Rejecting a number because someone typed
* the spaces printed on the card is the pettiest possible validation failure.
*
* ACCESSIBILITY: the result is a `role="status"` region so it is announced
* without moving focus; the failure is `role="alert"`; the input has
* `inputMode="numeric"` and an `autoComplete` value of `off`, because a gift
* card is not a payment card and offering saved cards here is wrong.
*/
import * as React from 'react'
import { CreditCard, Gift, Info, TriangleAlert } from 'lucide-react'
export interface CardTransaction {
id: string
what: string
when: string
amount: number
}
export interface KnownCard {
/** Unformatted digits. */
number: string
pin: string
balance: number
issued: string
expires: string
expired?: boolean
transactions: CardTransaction[]
}
export interface GiftCardBalanceProps {
cards?: KnownCard[]
currency?: string
className?: string
}
const DEFAULT_CARDS: KnownCard[] = [
{
number: '4471908220145566',
pin: '8841',
balance: 62.5,
issued: '4 December 2025',
expires: '4 December 2027',
transactions: [
{ id: 't1', what: 'Order ORD-88410 — Halo task lamp', when: '2 Sep 2026', amount: -145 },
{ id: 't2', what: 'Top-up at checkout', when: '12 Aug 2026', amount: 100 },
{ id: 't3', what: 'Order ORD-87204 — Cable spine ×4', when: '3 Jun 2026', amount: -42.5 },
{ id: 't4', what: 'Issued', when: '4 Dec 2025', amount: 150 },
],
},
{
number: '4471908220149999',
pin: '1020',
balance: 0,
issued: '1 February 2023',
expires: '1 February 2025',
expired: true,
transactions: [
{ id: 't5', what: 'Expired — unspent balance written off', when: '1 Feb 2025', amount: -18 },
{ id: 't6', what: 'Issued', when: '1 Feb 2023', amount: 18 },
],
},
]
function formatNumber(digits: string) {
return digits.replace(/(.{4})/g, '$1 ').trim()
}
export function GiftCardBalance({
cards = DEFAULT_CARDS,
currency = '£',
className = '',
}: GiftCardBalanceProps) {
const uid = React.useId()
const [digits, setDigits] = React.useState('4471908220145566')
const [pin, setPin] = React.useState('8841')
const [result, setResult] = React.useState<KnownCard | 'unknown' | null>(cards[0] ?? null)
function money(value: number) {
const sign = value < 0 ? '−' : ''
return `${sign}${currency}${Math.abs(value).toFixed(2)}`
}
function check(event: React.FormEvent) {
event.preventDefault()
const match = cards.find((card) => card.number === digits && card.pin === pin)
setResult(match ?? 'unknown')
}
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<div className="mx-auto max-w-xl">
<h2 className="flex items-center gap-2 text-xl font-bold tracking-tight">
<Gift aria-hidden className="h-5 w-5 text-primary" />
Check a gift card
</h2>
<p className="mt-1 text-sm text-muted-foreground">
The number and PIN are on the back of the card, or in the email if it
was sent digitally.
</p>
<form onSubmit={check} className="mt-6 rounded-2xl border border-border bg-card p-5">
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_7rem]">
<div>
<label htmlFor={`${uid}-number`} className="block text-sm font-medium">
Card number
</label>
<input
id={`${uid}-number`}
inputMode="numeric"
// Not a payment card: offering saved cards here is wrong.
autoComplete="off"
// Formatted for the eye, stored bare — see the docblock.
value={formatNumber(digits)}
onChange={(e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 16))}
placeholder="4471 9082 2014 5566"
className="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm tracking-wide outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div>
<label htmlFor={`${uid}-pin`} className="block text-sm font-medium">
PIN
</label>
<input
id={`${uid}-pin`}
inputMode="numeric"
autoComplete="off"
value={pin}
onChange={(e) => setPin(e.target.value.replace(/\D/g, '').slice(0, 4))}
aria-describedby={`${uid}-pin-why`}
placeholder="8841"
className="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
{/* Explained, not left as an unexplained extra field. */}
<p id={`${uid}-pin-why`} className="mt-2 flex gap-1.5 text-xs text-muted-foreground">
<Info aria-hidden className="mt-0.5 h-3 w-3 shrink-0" />
The PIN is asked for because a card number on its own is guessable
at scale, and a checker without it is a free oracle for anyone
trying.
</p>
<button
type="submit"
disabled={digits.length < 16 || pin.length < 4}
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"
>
Check the balance
</button>
</form>
{result === 'unknown' ? (
<div
role="alert"
className="mt-5 rounded-xl border border-destructive/40 bg-destructive/5 p-4"
>
<p className="flex items-center gap-2 text-sm font-semibold text-destructive">
<TriangleAlert aria-hidden className="h-4 w-4" />
No card matches that number and PIN
</p>
<p className="mt-1 text-sm text-muted-foreground">
Check the PIN — it is the four digits under the scratch panel, not
the last four of the card number. Three wrong attempts locks the
card for an hour.
</p>
</div>
) : null}
{result && result !== 'unknown' ? (
<div role="status" className="mt-5 rounded-2xl border border-border bg-card p-5">
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<CreditCard aria-hidden className="h-3.5 w-3.5" />
Card ending {result.number.slice(-4)}
</p>
<p className="mt-1 text-3xl font-bold tracking-tight">
{money(result.balance)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Issued {result.issued} ·{' '}
{result.expired ? (
<span className="font-medium text-destructive">
expired {result.expires}
</span>
) : (
`valid until ${result.expires}`
)}
</p>
{result.expired ? (
<p className="mt-3 rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 text-xs text-muted-foreground">
An expired card is not always a lost balance — in several
jurisdictions gift-card funds must be honoured for longer than
the printed date. Contact us with the number before writing it
off.
</p>
) : null}
{/* A number alone is an accusation; the history is the answer. */}
<h3 data-stress-ignore className="mt-5 text-sm font-semibold">
Where it went
</h3>
<ul className="mt-2 divide-y divide-border/70">
{result.transactions.map((tx) => (
<li key={tx.id} className="flex items-baseline justify-between gap-3 py-2 text-sm">
<span className="min-w-0 flex-1">
<span className="block truncate">{tx.what}</span>
<span className="block text-xs text-muted-foreground">{tx.when}</span>
</span>
<span
className={`shrink-0 font-medium ${
tx.amount < 0 ? 'text-muted-foreground' : 'text-emerald-700 dark:text-emerald-400'
}`}
>
{money(tx.amount)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/gift-card-balance.tsx and import it where you need the section:
import { GiftCardBalance } from '@/components/gift-card-balance'1 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 |
|---|---|---|
cards | KnownCard[] | DEFAULT_CARDS |
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.
<!--
Gift Card Balance Check — 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-xl">
<h2 class="flex items-center gap-2 text-xl font-bold tracking-tight"><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-gift h-5 w-5 text-primary" aria-hidden="true"><rect x="3" y="8" width="18" height="4" rx="1"></rect><path d="M12 8v13"></path><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"></path><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"></path></svg>Check a gift card</h2>
<p class="mt-1 text-sm text-muted-foreground">The number and PIN are on the back of the card, or in the email if it was sent digitally.</p>
<form class="mt-6 rounded-2xl border border-border bg-card p-5">
<div class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_7rem]">
<div>
<label for="_R_0_-number" class="block text-sm font-medium">Card number</label>
<input id="_R_0_-number" inputMode="numeric" autoComplete="off" placeholder="4471 9082 2014 5566" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm tracking-wide outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="4471 9082 2014 5566" />
</div>
<div>
<label for="_R_0_-pin" class="block text-sm font-medium">PIN</label>
<input id="_R_0_-pin" inputMode="numeric" autoComplete="off" aria-describedby="_R_0_-pin-why" placeholder="8841" class="mt-1.5 w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="8841" />
</div>
</div>
<p id="_R_0_-pin-why" class="mt-2 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>The PIN is asked for because a card number on its own is guessable at scale, and a checker without it is a free oracle for anyone trying.</p>
<button type="submit" 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">Check the balance</button>
</form>
<div role="status" class="mt-5 rounded-2xl border border-border bg-card p-5">
<p class="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide 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-credit-card h-3.5 w-3.5" aria-hidden="true"><rect width="20" height="14" x="2" y="5" rx="2"></rect><line x1="2" x2="22" y1="10" y2="10"></line></svg>Card ending 5566</p>
<p class="mt-1 text-3xl font-bold tracking-tight">£62.50</p>
<p class="mt-1 text-sm text-muted-foreground">Issued 4 December 2025 · valid until 4 December 2027</p>
<h3 data-stress-ignore="true" class="mt-5 text-sm font-semibold">Where it went</h3>
<ul class="mt-2 divide-y divide-border/70">
<li class="flex items-baseline justify-between gap-3 py-2 text-sm">
<span class="min-w-0 flex-1">
<span class="block truncate">Order ORD-88410 — Halo task lamp</span>
<span class="block text-xs text-muted-foreground">2 Sep 2026</span>
</span>
<span class="shrink-0 font-medium text-muted-foreground">−£145.00</span>
</li>
<li class="flex items-baseline justify-between gap-3 py-2 text-sm">
<span class="min-w-0 flex-1">
<span class="block truncate">Top-up at checkout</span>
<span class="block text-xs text-muted-foreground">12 Aug 2026</span>
</span>
<span class="shrink-0 font-medium text-emerald-700 dark:text-emerald-400">£100.00</span>
</li>
<li class="flex items-baseline justify-between gap-3 py-2 text-sm">
<span class="min-w-0 flex-1">
<span class="block truncate">Order ORD-87204 — Cable spine ×4</span>
<span class="block text-xs text-muted-foreground">3 Jun 2026</span>
</span>
<span class="shrink-0 font-medium text-muted-foreground">−£42.50</span>
</li>
<li class="flex items-baseline justify-between gap-3 py-2 text-sm">
<span class="min-w-0 flex-1">
<span class="block truncate">Issued</span>
<span class="block text-xs text-muted-foreground">4 Dec 2025</span>
</span>
<span class="shrink-0 font-medium text-emerald-700 dark:text-emerald-400">£150.00</span>
</li>
</ul>
</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.
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.