Blocks / Auth and flows
Sign up
A create-account form with live password rules and a strength meter, beside what the free plan includes.
Larder
Set up your kitchen
Five fields, then your first count. You can bring the team in afterwards.
Install
Writes the block to components/blocks/sign-up.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:sign-upBuilt from 13 parts
Notes
- The password rules tick off as you type, and validate checks them again on submit, since the list under the field is guidance only.
- The email is held in state so the "Not your email" rule can compare against it.
- After a successful submit the form gives way to a Result and focus moves to it, so the outcome is read out; the h1 stays.
- Wire the action to your sign-up endpoint and return { errors } by field name for server checks such as an email already in use.
Source
components/blocks/sign-up.tsx203 lines
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Badge } from "@/components/equip/badge";
import { Button } from "@/components/equip/button";
import { Checkbox } from "@/components/equip/checkbox";
import { Field } from "@/components/equip/field";
import { Form, FormActions, FormSection } from "@/components/equip/form";
import { Heading } from "@/components/equip/heading";
import { Icon } from "@/components/equip/icon";
import { Link } from "@/components/equip/link";
import { List, ListItem } from "@/components/equip/list";
import { PasswordField } from "@/components/equip/password-field";
import { Result, ResultActions } from "@/components/equip/result";
import { Select } from "@/components/equip/select";
import { Text } from "@/components/equip/text";
import "./sign-up.css";
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** The part of an email before the @, lower case, or "" when there is too little to compare. */
function localPart(email: string) {
const local = email.trim().split("@")[0]?.toLowerCase() ?? "";
return local.length > 2 ? local : "";
}
function rulesFor(email: string) {
const local = localPart(email);
return [
{ label: "At least 12 characters", test: (v: string) => v.length >= 12 },
{ label: "A number", test: (v: string) => /\d/.test(v) },
/* Fails while empty, so the meter never counts a rule nobody has met yet. */
{ label: "Not your email", test: (v: string) => v.length > 0 && (local === "" || !v.toLowerCase().includes(local)) },
];
}
const pantry = [
{ icon: "store" as const, title: "One kitchen", description: "Your stock, suppliers and counts in one place." },
{ icon: "list-checks" as const, title: "Up to 150 stock items", description: "Enough for a tight menu and the dry store." },
{ icon: "barcode" as const, title: "Count by scan or by hand", description: "Use a phone camera, or a scanner if you have one." },
{ icon: "bell" as const, title: "Low stock before service", description: "A note at 16:00 with what will not last the night." },
];
/*
* Create a kitchen account. The form checks itself through the Form part; on success the
* form gives way to a result, and the page keeps its one h1.
*/
export default function SignUp() {
const id = useId();
const titleId = `${id}-title`;
const [email, setEmail] = useState("");
const [done, setDone] = useState<{ kitchen: string } | null>(null);
const resultRef = useRef<HTMLDivElement>(null);
const rules = rulesFor(email);
// The form has gone, so focus moves to what replaced it and the outcome is read out.
useEffect(() => {
if (done) resultRef.current?.focus();
}, [done]);
return (
<section className="blk-sign-up" aria-labelledby={titleId}>
<div className="blk-sign-up-inner">
<div className="blk-sign-up-pane">
<header className="blk-sign-up-head">
<div className="blk-sign-up-brand">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10" /><path d="M8 4v2.5a3 3 0 0 1-1.5 2.6A3 3 0 0 0 5 11.7V18a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6.3a3 3 0 0 0-1.5-2.6A3 3 0 0 1 16 6.5V4" /><path d="M5 13h14" /></svg>
<span>Larder</span>
</div>
<Heading level={1} id={titleId} className="blk-sign-up-title">
Set up your kitchen
</Heading>
<Text tone="quiet">Five fields, then your first count. You can bring the team in afterwards.</Text>
</header>
{done ? (
<div className="blk-sign-up-done" ref={resultRef} tabIndex={-1}>
<Result status="success" title={`${done.kitchen} is ready for its first count`}>
<p>We sent a link to {email || "your inbox"} to confirm the address. Your Pantry plan is free for as long as you stay on it.</p>
<ResultActions>
<Button variant="accent">Start the first count</Button>
<Button variant="outline" lead={<Icon name="user-plus" />}>
Invite the team
</Button>
</ResultActions>
</Result>
</div>
) : (
<Form
className="blk-sign-up-form"
successMessage="Kitchen created."
validate={(values) => {
const errors: Record<string, string> = {};
const password = String(values.password ?? "");
const checks = rulesFor(String(values.email ?? ""));
if (password && !checks[0].test(password)) errors.password = "Use at least 12 characters.";
else if (password && !checks[1].test(password)) errors.password = "Add a number somewhere in it.";
else if (password && !checks[2].test(password)) errors.password = "Leave your email out of the password.";
if (String(values.kitchen ?? "").trim().toLowerCase() === "larder") errors.kitchen = "Pick your kitchen's own name.";
return errors;
}}
action={async (data) => {
await wait(900);
setDone({ kitchen: String(data.get("kitchen") ?? "").trim() || "Your kitchen" });
}}
>
{({ errors, pending }) => (
<>
<FormSection title="You">
<Field id={`${id}-name`} name="name" label="Your name" placeholder="Priya Raman" autoComplete="name" required error={errors.name} />
<Field
id={`${id}-email`}
name="email"
type="email"
label="Work email"
placeholder="priya@marrow.co.uk"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
error={errors.email}
/>
</FormSection>
<FormSection title="Your kitchen" description="Shown on supplier orders and to anyone you invite.">
<div className="blk-sign-up-pair">
<Field id={`${id}-kitchen`} name="kitchen" label="Kitchen name" placeholder="Marrow" autoComplete="organization" required error={errors.kitchen} />
<Select id={`${id}-type`} name="type" label="Kitchen type" defaultValue="" required error={errors.type}>
<option value="" disabled>
Choose one
</option>
<option value="restaurant">Restaurant</option>
<option value="bakery">Bakery</option>
<option value="cafe">Cafe</option>
<option value="pub">Pub</option>
</Select>
</div>
<PasswordField
id={`${id}-password`}
name="password"
label="Password"
autoComplete="new-password"
required
requirements={rules}
showStrength
error={errors.password}
/>
</FormSection>
<Checkbox
name="terms"
required
label={
<>
I agree to the <Link href="#terms">terms</Link> and the <Link href="#privacy">privacy notice</Link>
</>
}
/>
<FormActions className="blk-sign-up-actions">
<Button type="submit" variant="accent" size="l" loading={pending}>
Create kitchen account
</Button>
</FormActions>
<Text size="s" tone="quiet" className="blk-sign-up-foot">
Already counting with Larder? <Link href="#sign-in">Sign in</Link>
</Text>
</>
)}
</Form>
)}
</div>
<aside className="blk-sign-up-aside" aria-labelledby={`${id}-plan`}>
<div className="blk-sign-up-ticket">
<div className="blk-sign-up-ticket-top">
<Badge tone="ok" dot>
Free
</Badge>
<Heading level={2} size="s" id={`${id}-plan`}>
What you get on Pantry
</Heading>
<p className="blk-sign-up-price">
<span className="blk-sign-up-price-figure">£0</span>
<span className="blk-sign-up-price-unit">a month, no card</span>
</p>
</div>
<List className="blk-sign-up-list">
{pantry.map((item) => (
<ListItem
key={item.title}
lead={<Icon name={item.icon} />}
title={item.title}
description={item.description}
/>
))}
</List>
<Text size="s" tone="quiet" className="blk-sign-up-ticket-foot">
Outgrow it and Kitchen is £39 a month, with supplier ordering and waste reports. Your counts come with you.
</Text>
</div>
</aside>
</div>
</section>
);
}components/blocks/sign-up.css185 lines
@layer blocks {
.blk-sign-up {
container: blk-sign-up / inline-size;
background: var(--eq-bg);
color: var(--eq-ink);
}
.blk-sign-up-inner {
display: grid;
gap: calc(var(--eq-space-6) * 1.5);
max-width: 72rem;
margin-inline: auto;
padding: calc(var(--eq-space-6) * 1.25) var(--eq-space-4);
}
.blk-sign-up-pane {
display: grid;
align-content: start;
gap: var(--eq-space-5);
width: 100%;
max-width: 30rem;
margin-inline: auto;
}
.blk-sign-up-head {
display: grid;
gap: var(--eq-space-2);
}
.blk-sign-up-head > * {
margin: 0;
}
.blk-sign-up-brand {
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
margin-bottom: var(--eq-space-3);
font-weight: var(--eq-weight-strong);
}
.blk-sign-up-title {
font-size: clamp(1.75rem, 5cqi, 2.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
text-wrap: balance;
}
/* Name and type share a row once there is room for both. */
.blk-sign-up-pair {
display: grid;
gap: var(--eq-space-3);
}
.blk-sign-up-actions > * {
flex: 1 1 100%;
}
.blk-sign-up-foot {
margin: 0;
}
.blk-sign-up-done {
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-surface);
}
.blk-sign-up-done:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.blk-sign-up-done p {
margin: 0;
}
/* ---- the plan, set like a shelf label with a tear line ---- */
.blk-sign-up-aside {
display: grid;
align-content: center;
padding: var(--eq-space-5) var(--eq-space-3);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background:
radial-gradient(circle at 1px 1px, var(--eq-line-strong) 1px, transparent 0) 0 0 / 18px 18px,
color-mix(in oklab, var(--eq-surface) 70%, var(--eq-bg));
}
.blk-sign-up-ticket {
display: grid;
width: 100%;
max-width: 26rem;
margin-inline: auto;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
}
.blk-sign-up-ticket-top {
position: relative;
display: grid;
justify-items: start;
gap: var(--eq-space-2);
padding: var(--eq-space-5) var(--eq-space-4) var(--eq-space-4);
border-bottom: 1px dashed var(--eq-line-strong);
}
.blk-sign-up-ticket-top > * {
margin: 0;
}
/* Two notches where the tear line meets the edges. */
.blk-sign-up-ticket-top::before,
.blk-sign-up-ticket-top::after {
content: "";
box-sizing: border-box;
position: absolute;
bottom: -7px;
width: 12px;
height: 12px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-full);
background: color-mix(in oklab, var(--eq-surface) 70%, var(--eq-bg));
}
.blk-sign-up-ticket-top::before {
left: -7px;
clip-path: inset(0 0 0 50%);
}
.blk-sign-up-ticket-top::after {
right: -7px;
clip-path: inset(0 50% 0 0);
}
.blk-sign-up-price {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--eq-space-2);
}
.blk-sign-up-price-figure {
font-size: clamp(2.25rem, 8cqi, 3rem);
font-weight: var(--eq-weight-strong);
line-height: 1;
letter-spacing: -0.03em;
}
.blk-sign-up-price-unit {
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
}
.blk-sign-up-list {
padding: var(--eq-space-3) var(--eq-space-2);
}
.blk-sign-up-ticket-foot {
margin: 0;
padding: var(--eq-space-3) var(--eq-space-4) var(--eq-space-4);
border-top: 1px solid var(--eq-line);
}
@container blk-sign-up (width >= 30rem) {
.blk-sign-up-pair {
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
align-items: start;
}
}
@container blk-sign-up (width >= 56rem) {
.blk-sign-up-inner {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
align-items: stretch;
padding-block: calc(var(--eq-space-6) * 2);
}
.blk-sign-up-aside {
padding: calc(var(--eq-space-6) * 1.25) var(--eq-space-6);
}
}
}