Blocks / Auth and flows

Sign up

A create-account form with live password rules and a strength meter, beside what the free plan includes.

Width

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

Built 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);
    }
  }
}

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