Blocks / Application
Detail page
One record in full: a header with trail, status and actions, key facts, and tabs for lines, activity, comments.
Order #2041 to Neal's Yard Dairy
| Item | Quantity | Unit price | Total |
|---|---|---|---|
| Montgomery's CheddarCow, unpasteurised, Somerset | 3 kg | £26.50 a kg | £79.50 |
| SticheltonCow, unpasteurised, Nottinghamshire | 1.5 kg | £31.00 a kg | £46.50 |
| Coolea, aged 18 monthsCow, pasteurised, County Cork | 2 kg | £28.20 a kg | £56.40 |
| OgleshieldJersey cow, washed rind, Somerset | 1 kg | £24.80 a kg | £24.80 |
| Cultured butter, unsalted250 g blocks | 6 blocks | £4.10 each | £24.60 |
| Double cream1 litre tubs | 8 tubs | £5.20 each | £41.60 |
| Crème fraîche500 ml tubs | 4 tubs | £3.90 each | £15.60 |
| Subtotal | £289.00 | ||
| Delivery, free over £150 | £0.00 | ||
| VAT, zero rated food | £0.00 | ||
| Total | £289.00 | ||
- Delivery booked for Friday, 07:00 to 08:00Larder
- Confirmed by Neal's Yard Dairy, all 7 lines in stockOrders desk, Bermondsey
- Sent to Neal's Yard Dairy by emailInes Duarte
- Edited 2 linesInes Duarte
Show the changes
Montgomery's Cheddar from 2 kg to 3 kg for the Friday rarebit. Added Ogleshield, 1 kg. - Drafted from par levels after the Wednesday countLarderDouble cream and cultured butter were below par, with 1.5 and 0.6 days left.
Install
Writes the block to components/blocks/detail-page.tsx and its .css, and every part it uses to components/equip/, with the parts those are built from.
npx https://equip.studio311.app/equip.tgz add block:detail-pageBuilt from 12 parts
Notes
- Tabs wraps the whole page so the TabList can sit in the PageHeader's tabs slot while the panels sit below it.
- Order lines keep prices in pence and the subtotal and total are summed from them, so the figures always agree; the totals use a native tfoot because the table part has no footer piece.
- Reactions and new comments are local state; post them to your API in post() and react().
- The facts panel comes after the panels in the source, so on narrow screens the tabs stay next to the content they switch.
Source
components/blocks/detail-page.tsx359 lines
"use client";
import { useId, useState } from "react";
import { Badge } from "@/components/equip/badge";
import { Breadcrumb } from "@/components/equip/breadcrumb";
import { Button } from "@/components/equip/button";
import { CommentItem, CommentThread } from "@/components/equip/comment-thread";
import { Icon } from "@/components/equip/icon";
import { KeyValue, KeyValueItem } from "@/components/equip/key-value";
import { PageHeader } from "@/components/equip/page-header";
import { Tab, TabList, TabPanel, Tabs } from "@/components/equip/tabs";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFoot,
TableHead,
TableHeader,
TableRow,
} from "@/components/equip/table";
import { Text } from "@/components/equip/text";
import { Textarea } from "@/components/equip/textarea";
import { Timeline, TimelineGroup, TimelineItem } from "@/components/equip/timeline";
import "./detail-page.css";
/* Prices are kept in pence so the lines and the total always agree. */
type Line = { name: string; detail: string; quantity: number; unit: string; price: number };
const LINES: Line[] = [
{ name: "Montgomery's Cheddar", detail: "Cow, unpasteurised, Somerset", quantity: 3, unit: "kg", price: 2650 },
{ name: "Stichelton", detail: "Cow, unpasteurised, Nottinghamshire", quantity: 1.5, unit: "kg", price: 3100 },
{ name: "Coolea, aged 18 months", detail: "Cow, pasteurised, County Cork", quantity: 2, unit: "kg", price: 2820 },
{ name: "Ogleshield", detail: "Jersey cow, washed rind, Somerset", quantity: 1, unit: "kg", price: 2480 },
{ name: "Cultured butter, unsalted", detail: "250 g blocks", quantity: 6, unit: "blocks", price: 410 },
{ name: "Double cream", detail: "1 litre tubs", quantity: 8, unit: "tubs", price: 520 },
{ name: "Crème fraîche", detail: "500 ml tubs", quantity: 4, unit: "tubs", price: 390 },
];
const money = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
const amount = new Intl.NumberFormat("en-GB", { maximumFractionDigits: 2 });
const gbp = (pence: number) => money.format(pence / 100);
const lineTotal = (line: Line) => Math.round(line.quantity * line.price);
const SUBTOTAL = LINES.reduce((sum, line) => sum + lineTotal(line), 0);
const DELIVERY = 0;
const VAT = 0;
const TOTAL = SUBTOTAL + DELIVERY + VAT;
type Reaction = { count: number; pressed: boolean };
type Posted = { id: number; text: string };
/*
* One supplier order: a header with its trail, status and actions, the facts that matter
* on the morning it arrives, and three tabs for the lines, what happened and the talk
* around it.
*/
export default function DetailPage() {
const id = useId();
const [reactions, setReactions] = useState<Record<string, Reaction>>({
"ines-up": { count: 2, pressed: false },
"sam-up": { count: 2, pressed: true },
"sam-heart": { count: 1, pressed: false },
"priya-up": { count: 1, pressed: false },
});
const [draft, setDraft] = useState("");
const [posted, setPosted] = useState<Posted[]>([]);
function react(key: string) {
setReactions((all) => {
const r = all[key];
return { ...all, [key]: { count: r.count + (r.pressed ? -1 : 1), pressed: !r.pressed } };
});
}
function reaction(key: string, label: string, icon: "thumbs-up" | "heart") {
return { id: key, label, icon, count: reactions[key].count, pressed: reactions[key].pressed };
}
function post() {
const text = draft.trim();
if (!text) return;
setPosted((list) => [...list, { id: list.length + 1, text }]);
setDraft("");
}
return (
<div className="blk-detail-page">
<Tabs defaultValue="lines" className="blk-detail-page-inner">
<PageHeader
className="blk-detail-page-header"
size="l"
breadcrumb={
<Breadcrumb
items={[
{ label: "Orders", href: "#orders" },
{ label: "Neal's Yard Dairy", href: "#supplier-neals-yard-dairy" },
{ label: "#2041" },
]}
/>
}
title={
<>
Order #2041 <span className="blk-detail-page-to">to Neal's Yard Dairy</span>
</>
}
meta={[
<Badge key="status" tone="ok" dot>
Confirmed
</Badge>,
<span key="by">Placed by Ines Duarte on 11 November</span>,
<span key="lines">{LINES.length} lines</span>,
]}
secondaryActions={
<>
<Button variant="outline" lead={<Icon name="copy" />}>
Duplicate
</Button>
<Button variant="ghost" lead={<Icon name="download" />}>
Download PDF
</Button>
</>
}
primaryAction={
<Button variant="accent" lead={<Icon name="truck" />}>
Check in delivery
</Button>
}
tabs={
<TabList aria-label="Order #2041">
<Tab value="lines">
Lines <span className="blk-detail-page-count">{LINES.length}</span>
</Tab>
<Tab value="activity">Activity</Tab>
<Tab value="comments">
Comments <span className="blk-detail-page-count">{3 + posted.length}</span>
</Tab>
</TabList>
}
/>
<div className="blk-detail-page-body">
<div className="blk-detail-page-panels">
<TabPanel value="lines">
<Table label="Order lines">
<TableCaption className="blk-detail-page-caption">
Prices as quoted by Neal's Yard Dairy on 11 November
</TableCaption>
<TableHead>
<TableRow>
<TableHeader>Item</TableHeader>
<TableHeader numeric>Quantity</TableHeader>
<TableHeader numeric>Unit price</TableHeader>
<TableHeader numeric>Total</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{LINES.map((line) => (
<TableRow key={line.name}>
<TableCell className="blk-detail-page-item-cell">
<span className="blk-detail-page-item">{line.name}</span>
<span className="blk-detail-page-item-detail">{line.detail}</span>
</TableCell>
<TableCell numeric className="blk-detail-page-figure">
{amount.format(line.quantity)} {line.unit}
</TableCell>
<TableCell numeric className="blk-detail-page-figure">
{gbp(line.price)}
<span className="blk-detail-page-per">{line.unit === "kg" ? " a kg" : " each"}</span>
</TableCell>
<TableCell numeric className="blk-detail-page-figure">
{gbp(lineTotal(line))}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFoot>
<TableRow>
<TableHeader scope="row" colSpan={3} numeric>
Subtotal
</TableHeader>
<TableCell numeric className="blk-detail-page-figure">
{gbp(SUBTOTAL)}
</TableCell>
</TableRow>
<TableRow>
<TableHeader scope="row" colSpan={3} numeric>
Delivery, free over £150
</TableHeader>
<TableCell numeric className="blk-detail-page-figure">
{gbp(DELIVERY)}
</TableCell>
</TableRow>
<TableRow>
<TableHeader scope="row" colSpan={3} numeric>
VAT, zero rated food
</TableHeader>
<TableCell numeric className="blk-detail-page-figure">
{gbp(VAT)}
</TableCell>
</TableRow>
<TableRow>
<TableHeader scope="row" colSpan={3} numeric className="blk-detail-page-total">
Total
</TableHeader>
<TableCell numeric className="blk-detail-page-figure blk-detail-page-total">
{gbp(TOTAL)}
</TableCell>
</TableRow>
</TableFoot>
</Table>
</TabPanel>
<TabPanel value="activity">
<Timeline label="Activity on order #2041">
<TimelineGroup label="Thursday 12 November">
<TimelineItem
title="Delivery booked for Friday, 07:00 to 08:00"
time="09:15"
dateTime="2026-11-12T09:15"
actor="Larder"
icon={<Icon name="calendar-check" />}
/>
<TimelineItem
title="Confirmed by Neal's Yard Dairy, all 7 lines in stock"
time="09:14"
dateTime="2026-11-12T09:14"
actor="Orders desk, Bermondsey"
tone="ok"
/>
</TimelineGroup>
<TimelineGroup label="Wednesday 11 November">
<TimelineItem
title="Sent to Neal's Yard Dairy by email"
time="16:05"
dateTime="2026-11-11T16:05"
actor="Ines Duarte"
icon={<Icon name="send" />}
/>
<TimelineItem
title="Edited 2 lines"
time="15:58"
dateTime="2026-11-11T15:58"
actor="Ines Duarte"
summary="Show the changes"
>
Montgomery's Cheddar from 2 kg to 3 kg for the Friday rarebit. Added Ogleshield, 1 kg.
</TimelineItem>
<TimelineItem
title="Drafted from par levels after the Wednesday count"
time="15:40"
dateTime="2026-11-11T15:40"
actor="Larder"
tone="warn"
>
Double cream and cultured butter were below par, with 1.5 and 0.6 days left.
</TimelineItem>
</TimelineGroup>
</Timeline>
</TabPanel>
<TabPanel value="comments">
<CommentThread
label="Comments on order #2041"
composer={
<div className="blk-detail-page-composer">
<Textarea
id={`${id}-comment`}
label="Add a comment"
hint="Ines, Sam and Priya are following this order."
rows={2}
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<div className="blk-detail-page-composer-actions">
<Button onClick={post} disabled={!draft.trim()}>
Comment
</Button>
</div>
</div>
}
>
<CommentItem
author="Ines Duarte"
time="Wed 16:12"
dateTime="2026-11-11T16:12"
reactions={[reaction("ines-up", "Thumbs up", "thumbs-up")]}
onReact={react}
replies={
<CommentItem author="Priya Raman" time="Wed 16:40" dateTime="2026-11-11T16:40">
Fine by me. Keep it under £300 this week, the walk-in is already full of cream.
</CommentItem>
}
>
If they cannot do the full 2 kg of Coolea, take Hegarty's instead. Not more Montgomery's, we
have plenty.
</CommentItem>
<CommentItem
author="Sam Whitlock"
time="Thu 07:48"
dateTime="2026-11-12T07:48"
edited
reactions={[reaction("sam-up", "Thumbs up", "thumbs-up"), reaction("sam-heart", "Heart", "heart")]}
onReact={react}
replies={
<CommentItem author="Ines Duarte" time="Thu 08:02" dateTime="2026-11-12T08:02">
Thanks Sam. Anything over 5°C goes straight back on the van.
</CommentItem>
}
>
I'm on the door Friday from 06:45. Their driver rings the side bell, so I'll probe the cream
before I sign.
</CommentItem>
<CommentItem
author="Priya Raman"
time="Thu 11:20"
dateTime="2026-11-12T11:20"
reactions={[reaction("priya-up", "Thumbs up", "thumbs-up")]}
onReact={react}
>
Can the Ogleshield move to the bakery order next week? Kasia wants it for the Saturday toasties.
</CommentItem>
{posted.map((comment) => (
<CommentItem key={comment.id} author="Ines Duarte" time="Just now">
{comment.text}
</CommentItem>
))}
</CommentThread>
</TabPanel>
</div>
<aside className="blk-detail-page-facts" aria-label="Order facts">
<div className="blk-detail-page-arrival">
<Text size="s" tone="quiet">
Arrives
</Text>
<p className="blk-detail-page-arrival-day">
<time dateTime="2026-11-13T07:00">Friday 13 November</time>
</p>
<Text size="s">Between 07:00 and 08:00, side door. Sam Whitlock signs.</Text>
</div>
<KeyValue label="Order #2041" layout="grid" className="blk-detail-page-kv">
<KeyValueItem term="Supplier">Neal's Yard Dairy</KeyValueItem>
<KeyValueItem term="Deliver to">
<span>Marrow, 41 Wilton Way, E8 1BG</span>
</KeyValueItem>
<KeyValueItem term="Order total">{gbp(TOTAL)}</KeyValueItem>
<KeyValueItem term="Payment">30 days, by invoice</KeyValueItem>
<KeyValueItem term="Account" mono copy>
NYD-MARROW-0417
</KeyValueItem>
<KeyValueItem term="Supplier reference" mono copy>
BMY-88213
</KeyValueItem>
</KeyValue>
</aside>
</div>
</Tabs>
</div>
);
}components/blocks/detail-page.css143 lines
@layer blocks {
.blk-detail-page {
container: blk-detail-page / inline-size;
background: var(--eq-bg);
color: var(--eq-ink);
}
.blk-detail-page-inner {
gap: var(--eq-space-5);
max-width: 76rem;
margin-inline: auto;
padding: var(--eq-space-5) var(--eq-space-4) calc(var(--eq-space-6) * 1.5);
}
/* The order number carries the title; the supplier is said more quietly after it. */
.blk-detail-page-to {
color: var(--eq-ink-2);
font-weight: var(--eq-weight);
}
.blk-detail-page-count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
padding-inline: var(--eq-space-1);
box-sizing: border-box;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
}
.blk-detail-page-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: calc(var(--eq-space-6) * 1.25);
}
.blk-detail-page-panels {
min-width: 0;
}
.blk-detail-page-caption {
color: var(--eq-ink-2);
font-weight: var(--eq-weight);
}
.blk-detail-page-item,
.blk-detail-page-item-detail {
display: block;
}
/* Below its natural width the table scrolls inside its own area rather than crushing the names. */
.blk-detail-page-item-cell {
min-width: 11rem;
}
.blk-detail-page-figure {
white-space: nowrap;
}
.blk-detail-page-item {
font-weight: var(--eq-weight-strong);
}
.blk-detail-page-item-detail {
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
}
.blk-detail-page-per {
color: var(--eq-ink-3);
}
/* The table foot sets quiet sums and a strong last row; here the total is ruled in ink, like the bottom of a delivery note. */
.blk-detail-page-total {
border-top-color: var(--eq-ink);
font-size: var(--eq-text-m);
}
.blk-detail-page-composer {
display: grid;
gap: var(--eq-space-2);
}
.blk-detail-page-composer-actions {
display: flex;
justify-content: flex-end;
}
/* The facts panel: the arrival first, set large, then the reference details. */
.blk-detail-page-facts {
display: grid;
align-content: start;
gap: var(--eq-space-4);
padding: var(--eq-space-4);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-surface);
}
.blk-detail-page-arrival {
display: grid;
gap: var(--eq-space-1);
padding-bottom: var(--eq-space-4);
border-bottom: 1px solid var(--eq-line);
}
.blk-detail-page-arrival > * {
margin: 0;
}
.blk-detail-page-arrival-day {
font-size: clamp(1.375rem, 4cqi, 1.75rem);
font-weight: var(--eq-weight-strong);
line-height: 1.15;
letter-spacing: -0.02em;
}
@container blk-detail-page (width >= 40rem) {
.blk-detail-page-inner {
padding-inline: var(--eq-space-6);
}
}
@container blk-detail-page (width >= 60rem) {
.blk-detail-page-body {
grid-template-columns: minmax(0, 1fr) 19rem;
align-items: start;
}
.blk-detail-page-facts {
position: sticky;
top: var(--eq-space-4);
}
.blk-detail-page-arrival-day {
font-size: 1.75rem;
}
}
}
Ines Duarte
Priya Raman
Sam WhitlockEdited
Ines Duarte
Priya Raman
Ines, Sam and Priya are following this order.