Blocks / Application

Detail page

One record in full: a header with trail, status and actions, key facts, and tabs for lines, activity, comments.

Width

Order #2041 to Neal's Yard Dairy

  • Confirmed
  • Placed by Ines Duarte on 11 November
  • 7 lines
Prices as quoted by Neal's Yard Dairy on 11 November
ItemQuantityUnit priceTotal
Montgomery's CheddarCow, unpasteurised, Somerset3 kg£26.50 a kg£79.50
SticheltonCow, unpasteurised, Nottinghamshire1.5 kg£31.00 a kg£46.50
Coolea, aged 18 monthsCow, pasteurised, County Cork2 kg£28.20 a kg£56.40
OgleshieldJersey cow, washed rind, Somerset1 kg£24.80 a kg£24.80
Cultured butter, unsalted250 g blocks6 blocks£4.10 each£24.60
Double cream1 litre tubs8 tubs£5.20 each£41.60
Crème fraîche500 ml tubs4 tubs£3.90 each£15.60
Subtotal£289.00
Delivery, free over £150£0.00
VAT, zero rated food£0.00
Total£289.00

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-page

Built 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&apos;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&apos;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&apos;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&apos;s instead. Not more Montgomery&apos;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&apos;m on the door Friday from 06:45. Their driver rings the side bell, so I&apos;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&apos;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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index