Blocks / Application

Settings page

A settings screen with split sections of real controls, a side nav and an autosave status in the title bar.

Width

Marrow, Wilton Way

Kitchen settings

Changes save as you make them

The kitchen

How Marrow appears on supplier orders and on the delivery notes your team signs.

Drivers see this. Mention the side door if they should use it.

Service times

Larder warns you about low stock an hour before each service starts, and holds deliveries out of these windows.

No warnings, orders or counts are due on this day.

Money and measures

Used for prices, order totals and every quantity on a count sheet.

Units

Stock count

The full count everyone does together. Spot counts in between can happen any time.

Count day

Pick the quietest morning, before the first delivery lands.

Notifications

Texts go to Ines Duarte on 07700 900 418. Everyone else gets these by email.

Danger zone

For when the kitchen closes for good, not for a holiday.

Close this kitchen

Stops counts, orders and texts. Your stock history stays readable for 12 months, and Priya Raman can reopen Marrow in that time.

Install

Writes the block to components/blocks/settings-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:settings-page

Built from 13 parts

Notes

  • Every control saves itself: one onChange on the content wrapper, plus each TimeField's onValueChange, restarts a 700ms save; replace the timer with your request and set failed on error.
  • The six links are a SideNav when the block is 62rem wide and an underlined SegmentedNav above the content below that; only one is ever displayed, so assistive tech meets one nav.
  • InlineStatus holds the only live region, so Saving and Saved are announced without moving focus.
  • Sections use fixed ids prefixed with settings-page so the nav's Kitchen link can jump to them; form controls take their ids from useId.

Source

components/blocks/settings-page.tsx266 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/equip/button";
import { Field } from "@/components/equip/field";
import { Heading } from "@/components/equip/heading";
import { InlineStatus, type InlineStatusState } from "@/components/equip/inline-status";
import { Radio, RadioGroup } from "@/components/equip/radio";
import { Section } from "@/components/equip/section";
import { SegmentedControl } from "@/components/equip/segmented-control";
import { SegmentedNav } from "@/components/equip/segmented-nav";
import { Select } from "@/components/equip/select";
import { SideNav, SideNavItem, SideNavSection } from "@/components/equip/side-nav";
import { Switch } from "@/components/equip/switch";
import { Text } from "@/components/equip/text";
import { TimeField } from "@/components/equip/time-field";
import "./settings-page.css";

const SAVE_MS = 700;

/*
 * The Marrow kitchen's settings. Every control saves itself: a change starts a short
 * save, and the status beside the title says Saving, then Saved. The same six links
 * are a side nav when there is room and an underlined strip above the content when not.
 */
export default function SettingsPage() {
  const id = useId();
  const [state, setState] = useState<InlineStatusState>("idle");
  const timer = useRef<number | undefined>(undefined);

  useEffect(() => () => window.clearTimeout(timer.current), []);

  // Any change restarts the save, so typing a name saves once, after the last key.
  function save() {
    window.clearTimeout(timer.current);
    setState("saving");
    timer.current = window.setTimeout(() => setState("saved"), SAVE_MS);
  }

  return (
    <div className="blk-settings-page">
      <div className="blk-settings-page-inner">
        <header className="blk-settings-page-top">
          <div className="blk-settings-page-heading">
            <Text size="s" tone="faint">
              Marrow, Wilton Way
            </Text>
            <Heading level={1} className="blk-settings-page-title">
              Kitchen settings
            </Heading>
          </div>
          <InlineStatus
            className="blk-settings-page-status"
            state={state}
            idleText="Changes save as you make them"
            savingText="Saving…"
            savedText="Saved just now"
          />
        </header>

        <div className="blk-settings-page-layout">
          <SideNav label="Settings" className="blk-settings-page-side">
            <SideNavSection label="Marrow">
              <SideNavItem href="#settings-page-kitchen" icon="utensils" current>
                Kitchen
              </SideNavItem>
              <SideNavItem href="#settings-team" icon="users" badge="6">
                Team
              </SideNavItem>
              <SideNavItem href="#settings-suppliers" icon="truck" badge="8">
                Suppliers
              </SideNavItem>
              <SideNavItem href="#settings-notifications" icon="bell">
                Notifications
              </SideNavItem>
              <SideNavItem href="#settings-billing" icon="credit-card">
                Billing
              </SideNavItem>
              <SideNavItem href="#settings-integrations" icon="plug">
                Integrations
              </SideNavItem>
            </SideNavSection>
          </SideNav>

          <SegmentedNav
            variant="underline"
            aria-label="Settings"
            className="blk-settings-page-strip"
            items={[
              { label: "Kitchen", href: "#settings-page-kitchen", current: true },
              { label: "Team", href: "#settings-team" },
              { label: "Suppliers", href: "#settings-suppliers" },
              { label: "Notifications", href: "#settings-notifications" },
              { label: "Billing", href: "#settings-billing" },
              { label: "Integrations", href: "#settings-integrations" },
            ]}
          />

          <div className="blk-settings-page-content" onChange={save}>
            <Section
              id="settings-page-kitchen"
              layout="split"
              title="The kitchen"
              description="How Marrow appears on supplier orders and on the delivery notes your team signs."
            >
              <div className="blk-settings-page-fields">
                <Field id={`${id}-name`} label="Kitchen name" defaultValue="Marrow" autoComplete="organization" />
                <Field
                  id={`${id}-street`}
                  label="Delivery address"
                  defaultValue="41 Wilton Way, Hackney"
                  hint="Drivers see this. Mention the side door if they should use it."
                  autoComplete="street-address"
                />
                <div className="blk-settings-page-pair">
                  <Field id={`${id}-city`} label="Town or city" defaultValue="London" autoComplete="address-level2" />
                  <Field id={`${id}-postcode`} label="Postcode" defaultValue="E8 1BG" autoComplete="postal-code" />
                </div>
              </div>
            </Section>

            <Section
              id="settings-page-service"
              layout="split"
              divider
              title="Service times"
              description="Larder warns you about low stock an hour before each service starts, and holds deliveries out of these windows."
            >
              <div className="blk-settings-page-services">
                <div className="blk-settings-page-service" data-service="lunch">
                  <TimeField label="Lunch opens" defaultValue="12:00" step={15} onValueChange={save} />
                  <TimeField label="Last lunch orders" defaultValue="14:30" step={15} onValueChange={save} />
                </div>
                <div className="blk-settings-page-service" data-service="dinner">
                  <TimeField label="Dinner opens" defaultValue="17:30" step={15} onValueChange={save} />
                  <TimeField label="Last dinner orders" defaultValue="22:00" step={15} onValueChange={save} />
                </div>
                <Select id={`${id}-closed`} label="Closed on" defaultValue="mon" hint="No warnings, orders or counts are due on this day.">
                  <option value="none">Open every day</option>
                  <option value="sun">Sundays</option>
                  <option value="mon">Mondays</option>
                  <option value="mon-tue">Mondays and Tuesdays</option>
                </Select>
              </div>
            </Section>

            <Section
              id="settings-page-measures"
              layout="split"
              divider
              title="Money and measures"
              description="Used for prices, order totals and every quantity on a count sheet."
            >
              <div className="blk-settings-page-fields">
                <Select id={`${id}-currency`} label="Currency" defaultValue="GBP">
                  <option value="GBP">Pound sterling (£)</option>
                  <option value="EUR">Euro (€)</option>
                  <option value="USD">US dollar ($)</option>
                </Select>
                <SegmentedControl
                  legend="Units"
                  name={`${id}-units`}
                  defaultValue="metric"
                  options={[
                    { value: "metric", label: "Metric", title: "Kilograms and litres" },
                    { value: "imperial", label: "Imperial", title: "Pounds and pints" },
                  ]}
                />
                <Select id={`${id}-vat`} label="Show prices" defaultValue="ex">
                  <option value="ex">Without VAT</option>
                  <option value="inc">With VAT</option>
                </Select>
              </div>
            </Section>

            <Section
              id="settings-page-count"
              layout="split"
              divider
              title="Stock count"
              description="The full count everyone does together. Spot counts in between can happen any time."
            >
              <RadioGroup legend="Count day" id={`${id}-count-day`} hint="Pick the quietest morning, before the first delivery lands.">
                <Radio
                  id={`${id}-count-sun`}
                  name={`${id}-count-day`}
                  value="sun"
                  label="Sunday night, after close"
                  description="Counts the week as it ended."
                />
                <Radio
                  id={`${id}-count-tue`}
                  name={`${id}-count-day`}
                  value="tue"
                  defaultChecked
                  label="Tuesday morning"
                  description="The first day back, before Natoora arrives at 07:30."
                />
                <Radio
                  id={`${id}-count-fri`}
                  name={`${id}-count-day`}
                  value="fri"
                  label="Friday afternoon"
                  description="Before the weekend, when the walk-in is fullest."
                />
              </RadioGroup>
            </Section>

            <Section
              id="settings-page-notify"
              layout="split"
              divider
              title="Notifications"
              description="Texts go to Ines Duarte on 07700 900 418. Everyone else gets these by email."
            >
              <ul className="blk-settings-page-switches">
                <li>
                  <Switch
                    id={`${id}-par`}
                    defaultChecked
                    label="Text me when an item drops below par"
                    description="One text per item, at most once a day."
                  />
                </li>
                <li>
                  <Switch
                    id={`${id}-summary`}
                    defaultChecked
                    label="Morning order summary at 7:00"
                    description="What is arriving today, from whom, and what it costs."
                  />
                </li>
                <li>
                  <Switch
                    id={`${id}-shorts`}
                    label="Delivery shorts"
                    description="When a checked-in delivery is missing lines or swaps a product."
                  />
                </li>
              </ul>
            </Section>

            <Section
              id="settings-page-danger"
              layout="split"
              divider
              title="Danger zone"
              description="For when the kitchen closes for good, not for a holiday."
            >
              <div className="blk-settings-page-danger">
                <div className="blk-settings-page-danger-copy">
                  <Text weight="strong">Close this kitchen</Text>
                  <Text size="s" tone="quiet">
                    Stops counts, orders and texts. Your stock history stays readable for 12 months, and Priya
                    Raman can reopen Marrow in that time.
                  </Text>
                </div>
                <Button variant="danger">Close this kitchen</Button>
              </div>
            </Section>
          </div>
        </div>
      </div>
    </div>
  );
}
components/blocks/settings-page.css197 lines
@layer blocks {
  .blk-settings-page {
    container: blk-settings-page / inline-size;
    background: var(--eq-bg);
    color: var(--eq-ink);
  }

  .blk-settings-page-inner {
    display: grid;
    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 title bar stays in view while the page scrolls, so the save status is always readable. */
  .blk-settings-page-top {
    position: sticky;
    top: 0;
    z-index: 1;
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--eq-space-2) var(--eq-space-4);
    margin-inline: calc(var(--eq-space-4) * -1);
    padding: var(--eq-space-3) var(--eq-space-4);
    border-bottom: 1px solid var(--eq-line);
    background: color-mix(in oklab, var(--eq-bg) 92%, transparent);
    backdrop-filter: var(--eq-blur);
    -webkit-backdrop-filter: var(--eq-blur);
  }

  .blk-settings-page-heading {
    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  .blk-settings-page-heading > * {
    margin: 0;
  }

  .blk-settings-page-title {
    font-size: clamp(1.75rem, 6cqi, 2.5rem);
    line-height: 1.05;
    letter-spacing: -0.025em;
  }

  .blk-settings-page-status {
    padding-block-end: var(--eq-space-1);
  }

  .blk-settings-page-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--eq-space-5);
  }

  /* Narrow: the underlined strip leads. Wide: the side nav takes over and the strip goes. */
  .blk-settings-page-side {
    display: none;
  }

  .blk-settings-page-strip {
    min-width: 0;
  }

  .blk-settings-page-content {
    display: grid;
    gap: calc(var(--eq-space-6) * 1.25);
    min-width: 0;
  }

  /* The Kitchen link lands its section below the sticky title bar, not under it. */
  .blk-settings-page-content > section {
    scroll-margin-top: calc(var(--eq-space-6) * 3.25);
  }

  .blk-settings-page-fields,
  .blk-settings-page-services {
    display: grid;
    gap: var(--eq-space-4);
    max-width: 34rem;
  }

  .blk-settings-page-pair {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--eq-space-4);
  }

  /* Each service is a ticket: a tinted slip with a rule down its edge. */
  .blk-settings-page-service {
    display: flex;
    flex-wrap: wrap;
    gap: var(--eq-space-3) var(--eq-space-5);
    padding: var(--eq-space-3) var(--eq-space-4);
    border: 1px solid var(--eq-line);
    border-inline-start: 3px solid var(--eq-ink-3);
    border-radius: var(--eq-radius-m);
    background: var(--eq-tint);
  }

  .blk-settings-page-service[data-service="dinner"] {
    border-inline-start-color: var(--eq-ink);
  }

  .blk-settings-page-switches {
    display: grid;
    margin: 0;
    padding: 0;
    list-style: none;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-l);
    background: var(--eq-surface);
  }

  .blk-settings-page-switches > li {
    margin: 0;
    padding: var(--eq-space-3) var(--eq-space-4);
  }

  .blk-settings-page-switches > li + li {
    border-top: 1px solid var(--eq-line);
  }

  /* The danger zone: a hazard stripe along the top, in the danger tone at a whisper. */
  .blk-settings-page-danger {
    display: grid;
    gap: var(--eq-space-4);
    justify-items: start;
    padding: var(--eq-space-5) var(--eq-space-4) var(--eq-space-4);
    border: 1px solid color-mix(in oklab, var(--eq-danger) 35%, var(--eq-line));
    border-radius: var(--eq-radius-l);
    background:
      repeating-linear-gradient(
          -45deg,
          color-mix(in oklab, var(--eq-danger) 30%, transparent) 0 6px,
          transparent 6px 12px
        )
        top / 100% 6px no-repeat,
      color-mix(in oklab, var(--eq-danger) 4%, var(--eq-surface));
  }

  .blk-settings-page-danger-copy {
    display: grid;
    gap: var(--eq-space-1);
    max-width: 44ch;
  }

  .blk-settings-page-danger-copy > * {
    margin: 0;
  }

  @container blk-settings-page (width >= 30rem) {
    .blk-settings-page-pair {
      grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    }
  }

  @container blk-settings-page (width >= 40rem) {
    .blk-settings-page-inner {
      padding-inline: var(--eq-space-6);
    }

    .blk-settings-page-top {
      margin-inline: calc(var(--eq-space-6) * -1);
      padding-inline: var(--eq-space-6);
    }

    .blk-settings-page-danger {
      grid-template-columns: minmax(0, 1fr) auto;
      align-items: center;
      padding-inline: var(--eq-space-5);
    }
  }

  /* Wide enough for a 13rem nav and a content column that can still split its sections. */
  @container blk-settings-page (width >= 62rem) {
    .blk-settings-page-layout {
      grid-template-columns: 13rem minmax(0, 1fr);
      gap: calc(var(--eq-space-6) * 1.5);
      align-items: start;
    }

    .blk-settings-page-side {
      display: flex;
      position: sticky;
      top: calc(var(--eq-space-6) * 3.25);
    }

    .blk-settings-page-strip {
      display: none;
    }
  }
}

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