form-graph

Shipping quote

Rung two: a real-world shape. Watch the computed chain — dimensions → dimensional weight → billable weight → price — and the two different reactions to a bad combination: switching a freight-ocean quote to parcel silently projects the service (with a note), while explosives on an air service refuses at submit. Hazmat demands an emergency contact (validated at output), and an international quote over $5000 flips insurance on for you — and the signature question appears whenever insurance is on, forced or chosen. Persisted to sessionStorage: reload keeps the quote, closing the tab discards it.

shipmentType
service
destination

The computed chain

dim weight
4.8 kg
billable = max(actual, dim)
10 kg
price (+0 surcharges)
$11.00
transit
5 days
Try: freight + ocean, then switch to parcel. Hazmat + air + Class 1.4, then submit. Or international with declaredValue at $5000.
view source — shipping-form.ts (154 lines)
import { z } from 'zod';
import { defineGraph } from '$lib/index.js';
import { boolOf, enumOf, slider, textOf } from '$lib/defs/index.js';

// Demo ladder, rung 2: a real-world shape. Chained computed fields
// (dimensional weight → billable weight → price), a gated service (disabled
// AND corrected, one declaration), a refusal in zod's own vocabulary, and a
// field that corrects ITSELF (forced insurance) — every condition computed in
// the field's own definition.

export type ShipmentType = 'parcel' | 'freight' | 'hazmat';
export type Service = 'ground' | 'air' | 'ocean';

const RATE: Record<Service, number> = { ground: 1.1, air: 4.2, ocean: 0.4 };

const HAZMAT = enumOf({
  options: [
    { value: '3', label: 'Class 3 — Flammable liquid' },
    { value: '8', label: 'Class 8 — Corrosive' },
    { value: '1.4', label: 'Class 1.4 — Explosives' },
  ],
  default: '3',
});

const cm = (max: number, dflt: number) => slider({ min: 1, max, default: dflt });

export const shippingForm = defineGraph()
  .field('shipmentType', enumOf({
    options: [
      { value: 'parcel', label: 'Parcel' },
      { value: 'freight', label: 'Freight' },
      { value: 'hazmat', label: 'Hazmat' },
    ],
    default: 'parcel',
  }))
  .field('service', (c) =>
    // Ocean is freight-only: gated — disabled in the options AND corrected
    // (with the note the page shows) when the type changes under you.
    enumOf<Service>({
      options: [
        { value: 'ground', label: 'ground' },
        { value: 'air', label: 'air' },
        { value: 'ocean', label: 'ocean' },
      ],
      default: 'ground',
      gate: { ocean: c.shipmentType !== 'freight' && 'service_unavailable_for_type' },
    })
  )
  .field('destination', enumOf({
    options: [
      { value: 'domestic', label: 'Domestic' },
      { value: 'international', label: 'International' },
    ],
    default: 'domestic',
  }))
  .field('emergencyContact', (c) =>
    c.shipmentType === 'hazmat'
      ? {
          input: z.string().optional(),
          output: z
            .string()
            .regex(/^\+?[\d\s()-]{7,}$/, 'Hazmat requires a 24h emergency phone number'),
          default: '',
        }
      : null
  )
  .field('hazmatClass', (c) =>
    c.shipmentType === 'hazmat'
      ? {
          ...HAZMAT,
          // Explosives exist as a legal choice — but not on a plane. REFUSES
          // at submit: the output contract, narrowed in zod's own vocabulary.
          refine: (output) =>
            output.refine((value) => !(value === '1.4' && c.service === 'air'), {
              message: 'Class 1.4 explosives cannot ship by air',
              params: { kind: 'hazmat_air_forbidden' },
            }),
        }
      : null
  )
  .field('residential', (c) =>
    c.shipmentType === 'freight' && c.service === 'ground' ? boolOf() : null
  )
  .field('lengthCm', cm(200, 40))
  .field('widthCm', cm(200, 30))
  .field('heightCm', cm(200, 20))
  .field('actualKg', slider({ min: 1, max: 500, default: 10 }))
  .field('contents', (c) =>
    c.destination === 'international'
      ? {
          input: z.string().optional(),
          output: z.string().min(1, 'Customs needs a contents description'),
          default: '',
        }
      : null
  )
  .field('declaredValue', (c) =>
    c.destination === 'international' ? slider({ min: 0, max: 10000, step: 50, default: 100 }) : null
  )
  .field('incoterms', (c) =>
    c.destination === 'international'
      ? enumOf({
          options: [
            { value: 'DAP', label: 'DAP — Delivered at place' },
            { value: 'DDP', label: 'DDP — Duty paid' },
            { value: 'EXW', label: 'EXW — Ex works' },
          ],
          default: 'DAP',
        })
      : null
  )
  .field('insurance', (c) =>
    c.destination === 'international'
      ? {
          ...boolOf(),
          // High-value shipments MUST be insured: the field corrects ITSELF,
          // with the note telling the page why the box flipped on.
          correct: (value: boolean) =>
            (c.declaredValue ?? 0) >= 5000 && !value
              ? {
                  value: true,
                  reason: 'high_value_requires_insurance',
                  detail: { declaredValue: c.declaredValue },
                }
              : undefined,
        }
      : null
  )
  .field('signatureRequired', (c) =>
    c.destination === 'international' && c.insurance === true ? boolOf() : null
  )
  .computed('dimKg', (c) =>
    Math.round(((c.lengthCm * c.widthCm * c.heightCm) / 5000) * 10) / 10
  )
  .computed('billableKg', (c) => Math.max(c.actualKg, c.dimKg))
  .computed('surcharges', (c) =>
    (c.shipmentType === 'hazmat' ? 45 : 0) +
    (c.residential === true ? 28 : 0) +
    (c.destination === 'international' ? 15 : 0) +
    (c.insurance === true ? Math.max(5, Math.round((c.declaredValue ?? 0) * 0.01)) : 0) +
    (c.signatureRequired === true ? 6 : 0)
  )
  .computed('price', (c) =>
    Math.round((c.billableKg * RATE[c.service] + c.surcharges) * 100) / 100
  )
  .computed('transitDays', (c) =>
    c.service === 'air'
      ? c.destination === 'international'
        ? 3
        : 1
      : c.service === 'ocean'
        ? 28
        : 5
  );