Skip to content
~sami-ullah

Concept project · runs in your browserTypeScript · React.js · Next.js

WooCommerce Store Customization Demo

A demo WooCommerce shop with the changes clients ask for most: a product configurator that changes the price, gift wrap as a per-line fee, a free-delivery bar, a minimum order, and a reworked checkout with a delivery date that is validated on the server. The hooks inspector lists every WooCommerce action and filter as it fires, with the PHP behind it.

Read the case study →

Interactive demo

samiullah.tech/lab/woocommerce-customization
demo shop · no payments

## How it works

Each click is treated as one PHP request: a fresh hook registry is created, the plugin registers its callbacks with add_filter and add_action, and the WooCommerce code path runs. Every apply_filters and do_action call is recorded, including hooks nobody listens to, so you see the real order of events.

Option prices are attached to the cart line in woocommerce_add_cart_item_data and applied with set_price() in woocommerce_before_calculate_totals, starting from the stored price so the calculation can run twice without doubling. Gift wrap is a fee added once per line, not per unit.

The checkout form is rendered from the result of the woocommerce_checkout_fields filter. Turn the plugin off and the default WooCommerce fields come back. The delivery date picker only hides Sundays; the rule itself lives in woocommerce_after_checkout_validation, so bypassing the picker still fails.

## View source

The code running in the window above, exactly as deployed.

app/lab/woocommerce-customization/hooks.ts70 lines
/**
 * A small copy of the WordPress Plugin API (add_filter / apply_filters / add_action / do_action) with a trace,
 * so the demo can show exactly which hooks fire, in which order, and what each callback changed.
 * Priorities and "lower number runs first, ties in registration order" match WP_Hook.
 */

export type HookKind = "filter" | "action";

export type TraceEntry = {
  seq: number;
  request: string; // the HTTP request that triggered it, e.g. "POST /?wc-ajax=add_to_cart"
  kind: HookKind;
  hook: string;
  callback: string | null; // null when no callback is attached (core still fires the hook)
  note: string;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Callback = (...args: any[]) => any;
type Registered = { fn: Callback; priority: number; name: string; order: number };

export class Hooks {
  private map = new Map<string, Registered[]>();
  private order = 0;
  private seq = 0;
  private notes: string[] = [];
  trace: TraceEntry[] = [];
  request = "";

  add_filter(hook: string, name: string, fn: Callback, priority = 10) {
    const list = this.map.get(hook) ?? [];
    list.push({ fn, priority, name, order: this.order++ });
    list.sort((a, b) => a.priority - b.priority || a.order - b.order);
    this.map.set(hook, list);
  }

  add_action(hook: string, name: string, fn: Callback, priority = 10) {
    this.add_filter(hook, name, fn, priority);
  }

  /** Callbacks call this to explain what they did; it is attached to the trace line. */
  note(text: string) {
    this.notes.push(text);
  }

  apply_filters<T>(hook: string, value: T, ...args: unknown[]): T {
    return this.run("filter", hook, value, args) as T;
  }

  do_action(hook: string, ...args: unknown[]): void {
    this.run("action", hook, undefined, args);
  }

  private run(kind: HookKind, hook: string, value: unknown, args: unknown[]) {
    const list = this.map.get(hook) ?? [];
    if (!list.length) {
      this.trace.push({ seq: ++this.seq, request: this.request, kind, hook, callback: null, note: "no callbacks attached (core default)" });
      return value;
    }
    let v = value;
    for (const cb of list) {
      this.notes = [];
      const out = kind === "filter" ? cb.fn(v, ...args) : cb.fn(...args);
      if (kind === "filter") v = out;
      this.trace.push({ seq: ++this.seq, request: this.request, kind, hook, callback: `${cb.name}${cb.priority !== 10 ? ` (priority ${cb.priority})` : ""}`, note: this.notes.join("; ") });
    }
    return v;
  }
}
app/lab/woocommerce-customization/woo.ts297 lines
/**
 * The parts of WooCommerce core the demo needs: catalogue, cart, totals, checkout and orders.
 * Each function fires the same hooks, in the same order, as the matching WooCommerce code path
 * (WC_Cart::add_to_cart, WC_Cart_Totals, WC_Checkout::process_checkout...). Customisations live in acme-tweaks.ts.
 */

import type { Hooks } from "./hooks";

export type Product = {
  id: number;
  name: string;
  sku: string;
  price: number; // rupees, integer
  configurable: boolean;
  giftWrap: boolean;
  hue: number;
  blurb: string;
};

export const PRODUCTS: Product[] = [
  { id: 71, name: "Hand-thrown Pour-Over Set", sku: "AR-POUR-01", price: 3200, configurable: true, giftWrap: true, hue: 24, blurb: "Stoneware dripper and cup, glazed by hand. Choose the size and glaze, add an engraving." },
  { id: 72, name: "House Blend Coffee, 250 g", sku: "AR-BLEND-250", price: 1100, configurable: false, giftWrap: true, hue: 30, blurb: "Medium roast, chocolate and orange notes. Roasted the week it ships." },
  { id: 73, name: "Paper Filters (100)", sku: "AR-FILT-100", price: 450, configurable: false, giftWrap: false, hue: 45, blurb: "Unbleached filters that fit the pour-over set." },
];

export type CartOptions = { size?: string; glaze?: string; engraving?: string; gift_wrap?: boolean };
export type CartItem = { key: string; productId: number; qty: number; data: CartOptions };

export type Notice = { type: "error" | "notice" | "success"; text: string };

/** What callbacks can call back into: wc_add_notice(), echo, and the store clock. */
export type WcApi = {
  add_notice: (text: string, type?: Notice["type"]) => void;
  echo: (text: string) => void;
  now: () => { date: string; hour: number };
};

export function createWc(clock: () => { date: string; hour: number }) {
  const notices: Notice[] = [];
  let buffer: string[] = [];
  const api: WcApi = {
    add_notice: (text, type = "success") => notices.push({ type, text }),
    echo: (t) => buffer.push(t),
    now: clock,
  };
  /** ob_start() / ob_get_clean() around an action, to capture what callbacks print. */
  const capture = (fn: () => void) => {
    buffer = [];
    fn();
    const out = buffer;
    buffer = [];
    return out;
  };
  return { api, notices, capture };
}

export const money = (n: number) => `Rs ${n.toLocaleString("en-US")}`;
const product = (id: number) => PRODUCTS.find((p) => p.id === id)!;

/* ------------------------------------------------------------------ */
/* Product page                                                        */
/* ------------------------------------------------------------------ */

export function viewProduct(h: Hooks, wc: ReturnType<typeof createWc>, p: Product) {
  h.request = `GET /product/${p.sku.toLowerCase()}/`;
  const priceHtml = h.apply_filters("woocommerce_get_price_html", money(p.price), p);
  const fields = wc.capture(() => h.do_action("woocommerce_before_add_to_cart_button", p));
  return { priceHtml, fields };
}

/* ------------------------------------------------------------------ */
/* Add to cart (WC_Cart::add_to_cart)                                  */
/* ------------------------------------------------------------------ */

export function addToCart(h: Hooks, cart: CartItem[], productId: number, qty: number, posted: Record<string, string>) {
  h.request = "POST /?wc-ajax=add_to_cart";
  const passed = h.apply_filters("woocommerce_add_to_cart_validation", true, productId, qty, posted);
  if (!passed) return { ok: false as const, cart };

  const data = h.apply_filters<CartOptions>("woocommerce_add_cart_item_data", {}, productId, posted);
  // WC_Cart::generate_cart_id: md5 of product id + serialised cart item data, so different options = different lines.
  const key = cartId(productId, data);
  const existing = cart.find((c) => c.key === key);
  const next = existing ? cart.map((c) => (c.key === key ? { ...c, qty: c.qty + qty } : c)) : [...cart, { key, productId, qty, data }];
  h.do_action("woocommerce_add_to_cart", key, productId, qty, data);
  return { ok: true as const, cart: next, key };
}

function cartId(productId: number, data: CartOptions) {
  const s = `${productId}|${JSON.stringify(data)}`;
  let hash = 2166136261;
  for (let i = 0; i < s.length; i++) hash = Math.imul(hash ^ s.charCodeAt(i), 16777619);
  return (hash >>> 0).toString(16).padStart(8, "0");
}

/* ------------------------------------------------------------------ */
/* Totals (WC_Cart_Totals)                                             */
/* ------------------------------------------------------------------ */

export type CartLine = CartItem & { name: string; unit: number; subtotal: number; meta: { name: string; value: string }[] };
export type ShippingRate = { id: string; label: string; cost: number };
export type Totals = {
  lines: CartLine[];
  subtotal: number;
  fees: { name: string; amount: number }[];
  rates: ShippingRate[];
  shipping: ShippingRate | null;
  total: number;
  beforeTotals: string[];
};

/** The cart object handed to woocommerce_before_calculate_totals and woocommerce_cart_calculate_fees. */
export type WcCart = {
  lines: (CartItem & { price: number; set_price: (n: number) => void })[];
  fees: { name: string; amount: number }[];
  add_fee: (name: string, amount: number) => void;
  subtotal: number;
};

export const FREE_SHIPPING_MIN = 5000;
export const FLAT_RATE = 250;

export function calculateTotals(h: Hooks, wc: ReturnType<typeof createWc>, cart: CartItem[], request = "GET /cart/"): Totals {
  h.request = request;
  const wcCart: WcCart = {
    lines: cart.map((c) => {
      const line = { ...c, price: product(c.productId).price, set_price: (n: number) => (line.price = n) };
      return line;
    }),
    fees: [],
    add_fee: (name, amount) => wcCart.fees.push({ name, amount }),
    subtotal: 0,
  };

  h.do_action("woocommerce_before_calculate_totals", wcCart);
  wcCart.subtotal = wcCart.lines.reduce((s, l) => s + l.price * l.qty, 0);
  h.do_action("woocommerce_cart_calculate_fees", wcCart);

  // Shipping: core offers every enabled method whose conditions match; free_shipping has a minimum amount.
  const coreRates: ShippingRate[] = [{ id: "flat_rate:1", label: "Delivery", cost: FLAT_RATE }];
  if (wcCart.subtotal >= FREE_SHIPPING_MIN) coreRates.push({ id: "free_shipping:2", label: "Free delivery", cost: 0 });
  const rates = cart.length ? h.apply_filters("woocommerce_package_rates", coreRates, { contents_cost: wcCart.subtotal }) : [];
  const shipping = rates[0] ?? null;

  const lines: CartLine[] = wcCart.lines.map((l) => ({
    key: l.key,
    productId: l.productId,
    qty: l.qty,
    data: l.data,
    name: product(l.productId).name,
    unit: l.price,
    subtotal: l.price * l.qty,
    meta: h.apply_filters<{ name: string; value: string }[]>("woocommerce_get_item_data", [], l),
  }));

  const beforeTotals = wc.capture(() => h.do_action("woocommerce_before_cart_totals", wcCart.subtotal));
  h.do_action("woocommerce_check_cart_items", wcCart.subtotal);

  const feeTotal = wcCart.fees.reduce((s, f) => s + f.amount, 0);
  return {
    lines,
    subtotal: wcCart.subtotal,
    fees: wcCart.fees,
    rates,
    shipping,
    total: wcCart.subtotal + feeTotal + (shipping?.cost ?? 0),
    beforeTotals,
  };
}

/* ------------------------------------------------------------------ */
/* Checkout (WC_Checkout)                                              */
/* ------------------------------------------------------------------ */

export type Field = {
  label: string;
  required?: boolean;
  type?: "text" | "email" | "tel" | "select" | "textarea" | "delivery_date";
  priority: number;
  options?: Record<string, string>;
  maxlength?: number;
  description?: string;
};
export type FieldSet = { billing: Record<string, Field>; order: Record<string, Field> };

/** WooCommerce's default checkout fields (WC_Countries::get_default_address_fields + billing extras). */
export function defaultCheckoutFields(): FieldSet {
  return {
    billing: {
      billing_first_name: { label: "First name", required: true, priority: 10 },
      billing_last_name: { label: "Last name", required: true, priority: 20 },
      billing_company: { label: "Company name", priority: 30 },
      billing_country: { label: "Country / Region", required: true, priority: 40, type: "select", options: { PK: "Pakistan" } },
      billing_address_1: { label: "Street address", required: true, priority: 50 },
      billing_address_2: { label: "Apartment, suite, unit, etc.", priority: 60 },
      billing_city: { label: "Town / City", required: true, priority: 70 },
      billing_state: { label: "Province", required: true, priority: 80 },
      billing_postcode: { label: "Postcode / ZIP", required: true, priority: 90 },
      billing_phone: { label: "Phone", required: true, type: "tel", priority: 100 },
      billing_email: { label: "Email address", required: true, type: "email", priority: 110 },
    },
    order: {
      order_comments: { label: "Order notes", type: "textarea", priority: 10 },
    },
  };
}

export function checkoutFields(h: Hooks): FieldSet {
  return h.apply_filters("woocommerce_checkout_fields", defaultCheckoutFields());
}

export const sorted = (fs: Record<string, Field>) => Object.entries(fs).sort((a, b) => a[1].priority - b[1].priority);

export type Order = {
  id: number;
  created: string;
  status: "processing";
  billing: Record<string, string>;
  lines: CartLine[];
  totals: Omit<Totals, "lines" | "rates" | "beforeTotals">;
  meta: Record<string, string>;
  itemMeta: Record<string, Record<string, string>>;
};

export type CheckoutResult = { ok: true; order: Order } | { ok: false; errors: { field: string | null; text: string }[] };

/** WC_Checkout::process_checkout, reduced to the steps that matter for the customisations. */
export function processCheckout(
  h: Hooks,
  wc: ReturnType<typeof createWc>,
  cart: CartItem[],
  posted: Record<string, string>,
  nextId: number,
  createdIso: string,
): CheckoutResult {
  h.request = "POST /?wc-ajax=checkout";
  const errors: { field: string | null; text: string }[] = [];
  const fields = checkoutFields(h);

  // Core validation: required fields, email format, phone characters (WC_Checkout::validate_posted_data).
  for (const [key, f] of [...sorted(fields.billing), ...sorted(fields.order)]) {
    const v = (posted[key] ?? "").trim();
    if (f.required && !v) errors.push({ field: key, text: `${f.label} is a required field.` });
    if (key === "billing_email" && v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) errors.push({ field: key, text: "Email address is not a valid email address." });
  }
  if (!cart.length) errors.push({ field: null, text: "Your cart is empty." });

  const totals = calculateTotals(h, wc, cart, h.request);
  wc.notices.filter((n) => n.type === "error").forEach((n) => errors.push({ field: null, text: n.text }));

  // Extension point for custom rules: callbacks add to $errors (a WP_Error in PHP).
  const wpError = { add: (field: string | null, text: string) => errors.push({ field, text }) };
  h.do_action("woocommerce_after_checkout_validation", posted, wpError);
  if (errors.length) return { ok: false, errors: dedupe(errors) };

  // Create the order (WC_Checkout::create_order). With HPOS, meta must go through the order object, not post meta.
  const order: Order = {
    id: nextId,
    created: createdIso,
    status: "processing",
    billing: Object.fromEntries(Object.keys(fields.billing).map((k) => [k, (posted[k] ?? "").trim()])),
    lines: totals.lines,
    totals: { subtotal: totals.subtotal, fees: totals.fees, shipping: totals.shipping, total: totals.total },
    meta: {},
    itemMeta: {},
  };
  const orderObj = { update_meta_data: (k: string, v: string) => (order.meta[k] = v), get_id: () => order.id };
  for (const line of totals.lines) {
    const item = { add_meta_data: (k: string, v: string) => ((order.itemMeta[line.key] ??= {})[k] = v) };
    h.do_action("woocommerce_checkout_create_order_line_item", item, line.key, line, orderObj);
  }
  h.do_action("woocommerce_checkout_create_order", orderObj, posted);
  h.do_action("woocommerce_checkout_order_processed", order.id, posted, orderObj);
  h.request = `GET /checkout/order-received/${order.id}/`;
  h.do_action("woocommerce_thankyou", order.id);
  return { ok: true, order };
}

function dedupe(e: { field: string | null; text: string }[]) {
  const seen = new Set<string>();
  return e.filter((x) => (seen.has(x.text) ? false : (seen.add(x.text), true)));
}

/* ------------------------------------------------------------------ */
/* Admin and email                                                     */
/* ------------------------------------------------------------------ */

export function adminOrderPanel(h: Hooks, wc: ReturnType<typeof createWc>, order: Order) {
  h.request = `GET /wp-admin/admin.php?page=wc-orders&action=edit&id=${order.id}`;
  return wc.capture(() => h.do_action("woocommerce_admin_order_data_after_billing_address", order));
}

export function emailMetaFields(h: Hooks, order: Order, sentToAdmin: boolean) {
  h.request = `wp_mail (WC_Email_${sentToAdmin ? "New_Order" : "Customer_Processing_Order"})`;
  return h.apply_filters<Record<string, { label: string; value: string }>>("woocommerce_email_order_meta_fields", {}, sentToAdmin, order);
}
app/lab/woocommerce-customization/acme-tweaks.ts225 lines
/**
 * The site plugin: every customisation, attached through hooks. This is a line-by-line TypeScript twin of
 * php/acme-store-tweaks.php, so the hooks inspector can run it in the browser. The callback names match the PHP functions.
 */

import type { Hooks } from "./hooks";
import { FREE_SHIPPING_MIN, PRODUCTS, money, type CartOptions, type FieldSet, type Order, type ShippingRate, type WcApi, type WcCart } from "./woo";

export const SIZES = { single: { label: "Single cup", add: 0 }, double: { label: "Two cups", add: 900 } } as const;
export const GLAZES = {
  "matte-black": { label: "Matte black", add: 0 },
  "speckled-white": { label: "Speckled white", add: 0 },
  "ocean-blue": { label: "Ocean blue", add: 300 },
} as const;
export const ENGRAVING_FEE = 450;
export const GIFT_WRAP_FEE = 250;
export const MIN_ORDER = 1500;
export const CUTOFF_HOUR = 14;
const ENGRAVING_RE = /^[A-Za-z0-9 .'-]{0,20}$/;

/** acme_configured_price(): one formula used by the product page preview and by the cart. */
export function configuredPrice(base: number, o: CartOptions): number {
  let price = base;
  if (o.size && o.size in SIZES) price += SIZES[o.size as keyof typeof SIZES].add;
  if (o.glaze && o.glaze in GLAZES) price += GLAZES[o.glaze as keyof typeof GLAZES].add;
  if (o.engraving) price += ENGRAVING_FEE;
  return price;
}

/** Store dates are YYYY-MM-DD in the shop timezone (Asia/Karachi in production). */
export function isSunday(ymd: string) {
  const [y, m, d] = ymd.split("-").map(Number);
  return new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay() === 0;
}

export function addDays(ymd: string, n: number) {
  const [y, m, d] = ymd.split("-").map(Number);
  return new Date(Date.UTC(y!, m! - 1, d! + n)).toISOString().slice(0, 10);
}

/** acme_delivery_date_error(): the server-side rule. The date picker only hides bad dates; this rejects them. */
export function deliveryDateError(raw: string, now: { date: string; hour: number }): string | null {
  const v = raw.trim();
  if (!v) return "Delivery date is a required field.";
  if (!/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v + "T00:00:00Z")) || new Date(v + "T00:00:00Z").toISOString().slice(0, 10) !== v) {
    return "Delivery date must be a valid date (YYYY-MM-DD).";
  }
  if (v < now.date) return "Delivery date cannot be in the past.";
  if (v === now.date && now.hour >= CUTOFF_HOUR) return "Same-day delivery closes at 2 pm. Please pick tomorrow or later.";
  if (isSunday(v)) return "We do not deliver on Sundays.";
  if (v > addDays(now.date, 30)) return "Delivery can be booked up to 30 days ahead.";
  return null;
}

export function normalisePhone(raw: string): string | null {
  const digits = raw.replace(/[\s-]/g, "");
  if (/^03\d{9}$/.test(digits)) return "+92" + digits.slice(1);
  if (/^\+923\d{9}$/.test(digits)) return digits;
  return null;
}

/** sanitize_text_field(): strip tags, line breaks and extra whitespace. */
export const sanitizeText = (s: string) => s.replace(/<[^>]*>/g, "").replace(/[\r\n\t]+/g, " ").replace(/\s{2,}/g, " ").trim();

export function registerAcmeTweaks(h: Hooks, wc: WcApi) {
  /* ---------- Product page ---------- */

  h.add_filter("woocommerce_get_price_html", "acme_from_price_html", (html: string, p: { configurable: boolean }) => {
    if (!p.configurable) return html;
    h.note("prefixed 'From' because options change the price");
    return `From ${html}`;
  });

  h.add_action("woocommerce_before_add_to_cart_button", "acme_render_configurator", (p: { configurable: boolean; giftWrap: boolean }) => {
    if (p.configurable) {
      wc.echo("size");
      wc.echo("glaze");
      wc.echo("engraving");
    }
    if (p.giftWrap) wc.echo("gift_wrap");
    h.note(p.configurable || p.giftWrap ? "printed the option fields inside the add-to-cart form" : "nothing to add for this product");
  });

  /* ---------- Add to cart ---------- */

  h.add_filter("woocommerce_add_to_cart_validation", "acme_validate_options", (passed: boolean, productId: number, _qty: number, posted: Record<string, string>) => {
    const p = PRODUCTS.find((x) => x.id === productId);
    if (!p?.configurable) return passed;
    if (!(posted.acme_size! in SIZES) || !(posted.acme_glaze! in GLAZES)) {
      wc.add_notice("Please choose a size and a glaze.", "error");
      h.note("rejected: missing option");
      return false;
    }
    const engraving = sanitizeText(posted.acme_engraving ?? "");
    if (!ENGRAVING_RE.test(engraving)) {
      wc.add_notice("Engraving: up to 20 letters, numbers, spaces, dots, dashes or apostrophes.", "error");
      h.note(`rejected engraving "${engraving}"`);
      return false;
    }
    h.note("options valid");
    return passed;
  });

  h.add_filter("woocommerce_add_cart_item_data", "acme_add_cart_item_data", (data: CartOptions, productId: number, posted: Record<string, string>) => {
    const p = PRODUCTS.find((x) => x.id === productId);
    const out: CartOptions = { ...data };
    if (p?.configurable) {
      out.size = posted.acme_size;
      out.glaze = posted.acme_glaze;
      const e = sanitizeText(posted.acme_engraving ?? "");
      if (e) out.engraving = e;
    }
    if (p?.giftWrap && posted.acme_gift_wrap === "1") out.gift_wrap = true;
    h.note(`stored ${JSON.stringify(out)} on the cart line`);
    return out;
  });

  /* ---------- Cart totals ---------- */

  h.add_action("woocommerce_before_calculate_totals", "acme_apply_option_prices", (cart: WcCart) => {
    const changed: string[] = [];
    for (const line of cart.lines) {
      const p = PRODUCTS.find((x) => x.id === line.productId);
      if (!p?.configurable) continue;
      const price = configuredPrice(p.price, line.data);
      line.set_price(price);
      changed.push(`${line.key.slice(0, 6)} → ${money(price)}`);
    }
    h.note(changed.length ? `set_price(): ${changed.join(", ")}` : "no configurable lines");
  }, 20);

  h.add_action("woocommerce_cart_calculate_fees", "acme_gift_wrap_fee", (cart: WcCart) => {
    const wrapped = cart.lines.filter((l) => l.data.gift_wrap).length;
    if (wrapped) cart.add_fee(`Gift wrap × ${wrapped}`, wrapped * GIFT_WRAP_FEE);
    h.note(wrapped ? `add_fee(${money(wrapped * GIFT_WRAP_FEE)}): once per line, not per quantity` : "no gift-wrapped lines");
  });

  h.add_filter("woocommerce_get_item_data", "acme_show_item_data", (meta: { name: string; value: string }[], line: { data: CartOptions }) => {
    const out = [...meta];
    const d = line.data;
    if (d.size) out.push({ name: "Size", value: SIZES[d.size as keyof typeof SIZES]?.label ?? d.size });
    if (d.glaze) out.push({ name: "Glaze", value: GLAZES[d.glaze as keyof typeof GLAZES]?.label ?? d.glaze });
    if (d.engraving) out.push({ name: "Engraving", value: d.engraving });
    if (d.gift_wrap) out.push({ name: "Gift wrap", value: "Yes" });
    if (out.length) h.note(`${out.length} option rows shown under the item`);
    return out;
  });

  h.add_filter("woocommerce_package_rates", "acme_hide_paid_when_free", (rates: ShippingRate[]) => {
    const free = rates.filter((r) => r.id.startsWith("free_shipping"));
    h.note(free.length ? "free shipping available: removed the paid rate" : "below the free-shipping minimum: kept flat rate");
    return free.length ? free : rates;
  }, 100);

  h.add_action("woocommerce_before_cart_totals", "acme_free_shipping_bar", (subtotal: number) => {
    const left = FREE_SHIPPING_MIN - subtotal;
    wc.echo(left > 0 ? `${money(left)} away from free delivery` : "You have free delivery");
    h.note(`progress ${Math.min(100, Math.round((subtotal / FREE_SHIPPING_MIN) * 100))}%`);
  });

  h.add_action("woocommerce_check_cart_items", "acme_minimum_order", (subtotal: number) => {
    if (subtotal > 0 && subtotal < MIN_ORDER) {
      wc.add_notice(`The minimum order for delivery is ${money(MIN_ORDER)}. Your cart is ${money(subtotal)}.`, "error");
      h.note("below minimum: error notice blocks checkout");
    } else h.note("minimum met");
  });

  /* ---------- Checkout ---------- */

  h.add_filter("woocommerce_checkout_fields", "acme_checkout_fields", (f: FieldSet) => {
    const b = { ...f.billing };
    delete b.billing_company;
    delete b.billing_address_2;
    delete b.billing_postcode;
    delete b.billing_state;
    delete b.billing_country; // single-country shop: set to PK in acme_checkout_create_order
    b.billing_phone = { ...b.billing_phone!, priority: 25, description: "03xx xxxxxxx. The rider calls before delivery." };
    b.billing_city = { ...b.billing_city!, type: "select", options: { "": "Choose a city", lahore: "Lahore", islamabad: "Islamabad", karachi: "Karachi" } };
    const o = {
      acme_delivery_date: { label: "Delivery date", required: true, type: "delivery_date" as const, priority: 5, description: "No Sunday deliveries. Same-day orders close at 2 pm." },
      acme_gift_message: { label: "Gift message (printed on a card)", type: "textarea" as const, priority: 8, maxlength: 140 },
      ...f.order,
    };
    h.note("removed 5 fields, moved phone up, city → select, added delivery date and gift message");
    return { billing: b, order: o };
  });

  h.add_action("woocommerce_after_checkout_validation", "acme_validate_checkout", (data: Record<string, string>, errors: { add: (f: string | null, t: string) => void }) => {
    const dateErr = deliveryDateError(data.acme_delivery_date ?? "", wc.now());
    if (dateErr) errors.add("acme_delivery_date", dateErr);
    if (data.billing_phone && !normalisePhone(data.billing_phone)) errors.add("billing_phone", "Phone must be a Pakistani mobile number, e.g. 0300 1234567.");
    if ((data.acme_gift_message ?? "").length > 140) errors.add("acme_gift_message", "Gift message can be up to 140 characters.");
    h.note(dateErr ? `server rejected date: ${dateErr}` : "date, phone and message accepted");
  });

  h.add_action("woocommerce_checkout_create_order_line_item", "acme_line_item_meta", (item: { add_meta_data: (k: string, v: string) => void }, _key: string, line: { meta: { name: string; value: string }[] }) => {
    for (const m of line.meta) item.add_meta_data(m.name, m.value);
    h.note(line.meta.length ? `copied ${line.meta.length} option rows to the order item` : "plain line");
  });

  h.add_action("woocommerce_checkout_create_order", "acme_save_order_meta", (order: { update_meta_data: (k: string, v: string) => void }, data: Record<string, string>) => {
    order.update_meta_data("_acme_delivery_date", (data.acme_delivery_date ?? "").trim());
    const msg = sanitizeText(data.acme_gift_message ?? "");
    if (msg) order.update_meta_data("_acme_gift_message", msg);
    order.update_meta_data("_billing_phone_e164", normalisePhone(data.billing_phone ?? "") ?? "");
    h.note("$order->update_meta_data(): HPOS-safe, no update_post_meta()");
  });

  /* ---------- Admin + email ---------- */

  h.add_action("woocommerce_admin_order_data_after_billing_address", "acme_admin_order_fields", (order: Order) => {
    wc.echo(`Delivery date: ${order.meta._acme_delivery_date ?? "—"}`);
    if (order.meta._acme_gift_message) wc.echo(`Gift message: ${order.meta._acme_gift_message}`);
    wc.echo(`Phone (E.164): ${order.meta._billing_phone_e164 ?? "—"}`);
    h.note("printed escaped meta under the billing address");
  });

  h.add_filter("woocommerce_email_order_meta_fields", "acme_email_fields", (fields: Record<string, { label: string; value: string }>, _admin: boolean, order: Order) => {
    const out = { ...fields, delivery: { label: "Delivery date", value: order.meta._acme_delivery_date ?? "" } };
    if (order.meta._acme_gift_message) Object.assign(out, { gift: { label: "Gift message", value: order.meta._acme_gift_message } });
    h.note(`${Object.keys(out).length} rows added to the email`);
    return out;
  });
}
app/lab/woocommerce-customization/php/acme-store-tweaks.php318 lines
<?php
/**
 * Plugin Name: Acme Store Tweaks
 * Description: Product options, delivery dates and checkout rules for the Acme Roasters demo shop.
 * Requires Plugins: woocommerce
 * Version: 1.0.0
 *
 * Production design for the WooCommerce demo. Everything is a hook: no template overrides, no edits to WooCommerce.
 * The demo's hooks inspector shows the matching block below for each hook that fires.
 * Lines starting with "// @hook" are section markers the demo uses to find each snippet.
 */

defined( 'ABSPATH' ) || exit;

const ACME_SIZES          = array( 'single' => 0, 'double' => 900 );
const ACME_GLAZES         = array( 'matte-black' => 0, 'speckled-white' => 0, 'ocean-blue' => 300 );
const ACME_ENGRAVING_FEE  = 450;
const ACME_GIFT_WRAP_FEE  = 250;
const ACME_MIN_ORDER      = 1500;
const ACME_FREE_SHIPPING  = 5000;
const ACME_CUTOFF_HOUR    = 14;

// Declare HPOS compatibility: all order data goes through WC_Order, never post meta.
add_action(
	'before_woocommerce_init',
	static function () {
		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true );
		}
	}
);

function acme_is_configurable( WC_Product $product ): bool {
	return 'yes' === $product->get_meta( '_acme_configurable' );
}

function acme_configured_price( float $base, array $o ): float {
	$price  = $base;
	$price += ACME_SIZES[ $o['size'] ?? '' ] ?? 0;
	$price += ACME_GLAZES[ $o['glaze'] ?? '' ] ?? 0;
	if ( ! empty( $o['engraving'] ) ) {
		$price += ACME_ENGRAVING_FEE;
	}
	return $price;
}

// @hook woocommerce_get_price_html
add_filter( 'woocommerce_get_price_html', 'acme_from_price_html', 10, 2 );
function acme_from_price_html( string $html, WC_Product $product ): string {
	return acme_is_configurable( $product ) ? sprintf( /* translators: %s: price */ __( 'From %s', 'acme' ), $html ) : $html;
}

// @hook woocommerce_before_add_to_cart_button
add_action( 'woocommerce_before_add_to_cart_button', 'acme_render_configurator' );
function acme_render_configurator(): void {
	global $product;
	if ( acme_is_configurable( $product ) ) {
		wc_get_template( 'configurator.php', array( 'sizes' => ACME_SIZES, 'glazes' => ACME_GLAZES ), '', plugin_dir_path( __FILE__ ) . 'templates/' );
	}
	if ( 'yes' === $product->get_meta( '_acme_gift_wrap' ) ) {
		printf(
			'<p class="form-row"><label><input type="checkbox" name="acme_gift_wrap" value="1"> %s</label></p>',
			esc_html( sprintf( __( 'Gift wrap (+%s)', 'acme' ), wp_strip_all_tags( wc_price( ACME_GIFT_WRAP_FEE ) ) ) )
		);
	}
	wp_nonce_field( 'acme_add_options', 'acme_options_nonce' );
}

// @hook woocommerce_add_to_cart_validation
add_filter( 'woocommerce_add_to_cart_validation', 'acme_validate_options', 10, 3 );
function acme_validate_options( bool $passed, int $product_id, int $qty ): bool {
	$product = wc_get_product( $product_id );
	if ( ! $product || ! acme_is_configurable( $product ) ) {
		return $passed;
	}
	if ( ! isset( $_POST['acme_options_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['acme_options_nonce'] ), 'acme_add_options' ) ) {
		wc_add_notice( __( 'Your session expired. Please try again.', 'acme' ), 'error' );
		return false;
	}
	$size  = sanitize_key( wp_unslash( $_POST['acme_size'] ?? '' ) );
	$glaze = sanitize_key( wp_unslash( $_POST['acme_glaze'] ?? '' ) );
	if ( ! isset( ACME_SIZES[ $size ], ACME_GLAZES[ $glaze ] ) ) {
		wc_add_notice( __( 'Please choose a size and a glaze.', 'acme' ), 'error' );
		return false;
	}
	$engraving = sanitize_text_field( wp_unslash( $_POST['acme_engraving'] ?? '' ) );
	if ( ! preg_match( "/^[A-Za-z0-9 .'-]{0,20}$/", $engraving ) ) {
		wc_add_notice( __( 'Engraving: up to 20 letters, numbers, spaces, dots, dashes or apostrophes.', 'acme' ), 'error' );
		return false;
	}
	return $passed;
}

// @hook woocommerce_add_cart_item_data
add_filter( 'woocommerce_add_cart_item_data', 'acme_add_cart_item_data', 10, 2 );
function acme_add_cart_item_data( array $data, int $product_id ): array {
	// Nonce already verified in acme_validate_options, which runs first in WC_Cart::add_to_cart.
	// phpcs:disable WordPress.Security.NonceVerification.Missing
	$product = wc_get_product( $product_id );
	if ( acme_is_configurable( $product ) ) {
		$data['acme'] = array(
			'size'      => sanitize_key( wp_unslash( $_POST['acme_size'] ?? '' ) ),
			'glaze'     => sanitize_key( wp_unslash( $_POST['acme_glaze'] ?? '' ) ),
			'engraving' => sanitize_text_field( wp_unslash( $_POST['acme_engraving'] ?? '' ) ),
		);
	}
	if ( ! empty( $_POST['acme_gift_wrap'] ) && 'yes' === $product->get_meta( '_acme_gift_wrap' ) ) {
		$data['acme']['gift_wrap'] = true;
	}
	// phpcs:enable
	return $data; // Different options => different cart_item_key => separate cart lines.
}

// @hook woocommerce_before_calculate_totals
add_action( 'woocommerce_before_calculate_totals', 'acme_apply_option_prices', 20 );
function acme_apply_option_prices( WC_Cart $cart ): void {
	if ( is_admin() && ! wp_doing_ajax() ) {
		return;
	}
	foreach ( $cart->get_cart() as $item ) {
		if ( empty( $item['acme'] ) || ! acme_is_configurable( $item['data'] ) ) {
			continue;
		}
		// Start from the stored regular price so running twice per request never adds the options twice.
		$base = (float) wc_get_product( $item['product_id'] )->get_price( 'edit' );
		$item['data']->set_price( acme_configured_price( $base, $item['acme'] ) );
	}
}

// @hook woocommerce_cart_calculate_fees
add_action( 'woocommerce_cart_calculate_fees', 'acme_gift_wrap_fee' );
function acme_gift_wrap_fee( WC_Cart $cart ): void {
	$lines = count( array_filter( $cart->get_cart(), static fn( $i ) => ! empty( $i['acme']['gift_wrap'] ) ) );
	if ( $lines ) {
		/* translators: %d: number of wrapped lines */
		$cart->add_fee( sprintf( __( 'Gift wrap × %d', 'acme' ), $lines ), $lines * ACME_GIFT_WRAP_FEE, true );
	}
}

// @hook woocommerce_get_item_data
add_filter( 'woocommerce_get_item_data', 'acme_show_item_data', 10, 2 );
function acme_show_item_data( array $meta, array $item ): array {
	$o = $item['acme'] ?? array();
	if ( ! empty( $o['size'] ) ) {
		$meta[] = array( 'name' => __( 'Size', 'acme' ), 'value' => 'double' === $o['size'] ? __( 'Two cups', 'acme' ) : __( 'Single cup', 'acme' ) );
	}
	if ( ! empty( $o['glaze'] ) ) {
		$meta[] = array( 'name' => __( 'Glaze', 'acme' ), 'value' => ucwords( str_replace( '-', ' ', $o['glaze'] ) ) );
	}
	if ( ! empty( $o['engraving'] ) ) {
		$meta[] = array( 'name' => __( 'Engraving', 'acme' ), 'value' => $o['engraving'] ); // escaped by wc_get_formatted_cart_item_data()
	}
	if ( ! empty( $o['gift_wrap'] ) ) {
		$meta[] = array( 'name' => __( 'Gift wrap', 'acme' ), 'value' => __( 'Yes', 'acme' ) );
	}
	return $meta;
}

// @hook woocommerce_package_rates
add_filter( 'woocommerce_package_rates', 'acme_hide_paid_when_free', 100 );
function acme_hide_paid_when_free( array $rates ): array {
	$free = array_filter( $rates, static fn( WC_Shipping_Rate $r ) => 'free_shipping' === $r->get_method_id() );
	return $free ? $free : $rates;
}

// @hook woocommerce_before_cart_totals
add_action( 'woocommerce_before_cart_totals', 'acme_free_shipping_bar' );
add_action( 'woocommerce_before_mini_cart', 'acme_free_shipping_bar' );
function acme_free_shipping_bar(): void {
	$subtotal = (float) WC()->cart->get_displayed_subtotal();
	$left     = ACME_FREE_SHIPPING - $subtotal;
	$pct      = min( 100, (int) round( $subtotal / ACME_FREE_SHIPPING * 100 ) );
	printf(
		'<div class="acme-ship-bar" role="status"><progress max="100" value="%1$d"></progress> %2$s</div>',
		absint( $pct ),
		$left > 0
			? wp_kses_post( sprintf( __( '%s away from free delivery', 'acme' ), wc_price( $left ) ) )
			: esc_html__( 'You have free delivery', 'acme' )
	);
}

// @hook woocommerce_check_cart_items
add_action( 'woocommerce_check_cart_items', 'acme_minimum_order' );
function acme_minimum_order(): void {
	$subtotal = (float) WC()->cart->get_subtotal();
	if ( $subtotal > 0 && $subtotal < ACME_MIN_ORDER ) {
		wc_add_notice(
			sprintf( __( 'The minimum order for delivery is %1$s. Your cart is %2$s.', 'acme' ), wc_price( ACME_MIN_ORDER ), wc_price( $subtotal ) ),
			'error' // An error notice on the cart blocks proceeding to payment.
		);
	}
}

// @hook woocommerce_checkout_fields
add_filter( 'woocommerce_checkout_fields', 'acme_checkout_fields' );
function acme_checkout_fields( array $fields ): array {
	unset(
		$fields['billing']['billing_company'],
		$fields['billing']['billing_address_2'],
		$fields['billing']['billing_postcode'],
		$fields['billing']['billing_state'],
		$fields['billing']['billing_country']
	);
	$fields['billing']['billing_phone']['priority']    = 25;
	$fields['billing']['billing_phone']['description'] = __( '03xx xxxxxxx. The rider calls before delivery.', 'acme' );
	$fields['billing']['billing_city']['type']         = 'select';
	$fields['billing']['billing_city']['options']      = array( '' => __( 'Choose a city', 'acme' ), 'lahore' => 'Lahore', 'islamabad' => 'Islamabad', 'karachi' => 'Karachi' );

	$fields['order'] = array(
		'acme_delivery_date' => array(
			'label'             => __( 'Delivery date', 'acme' ),
			'type'              => 'date',
			'required'          => true,
			'priority'          => 5,
			'description'       => __( 'No Sunday deliveries. Same-day orders close at 2 pm.', 'acme' ),
			'custom_attributes' => array( 'min' => wp_date( 'Y-m-d' ), 'max' => wp_date( 'Y-m-d', strtotime( '+30 days' ) ) ),
		),
		'acme_gift_message'  => array(
			'label'             => __( 'Gift message (printed on a card)', 'acme' ),
			'type'              => 'textarea',
			'priority'          => 8,
			'custom_attributes' => array( 'maxlength' => 140 ),
		),
	) + $fields['order'];
	return $fields;
}

function acme_delivery_date_error( string $raw ): ?string {
	$tz   = wp_timezone(); // Asia/Karachi
	$date = DateTimeImmutable::createFromFormat( '!Y-m-d', $raw, $tz );
	if ( ! $date || $date->format( 'Y-m-d' ) !== $raw ) {
		return __( 'Delivery date must be a valid date (YYYY-MM-DD).', 'acme' );
	}
	$now   = new DateTimeImmutable( 'now', $tz );
	$today = $now->setTime( 0, 0 );
	if ( $date < $today ) {
		return __( 'Delivery date cannot be in the past.', 'acme' );
	}
	if ( $date->format( 'Y-m-d' ) === $today->format( 'Y-m-d' ) && (int) $now->format( 'G' ) >= ACME_CUTOFF_HOUR ) {
		return __( 'Same-day delivery closes at 2 pm. Please pick tomorrow or later.', 'acme' );
	}
	if ( '7' === $date->format( 'N' ) ) {
		return __( 'We do not deliver on Sundays.', 'acme' );
	}
	if ( $date > $today->modify( '+30 days' ) ) {
		return __( 'Delivery can be booked up to 30 days ahead.', 'acme' );
	}
	return null;
}

// @hook woocommerce_after_checkout_validation
add_action( 'woocommerce_after_checkout_validation', 'acme_validate_checkout', 10, 2 );
function acme_validate_checkout( array $data, WP_Error $errors ): void {
	// The date picker hides Sundays, but anyone can post any value: the rule lives here.
	$date = sanitize_text_field( $data['acme_delivery_date'] ?? '' );
	$err  = '' === $date ? __( 'Delivery date is a required field.', 'acme' ) : acme_delivery_date_error( $date );
	if ( $err ) {
		$errors->add( 'acme_delivery_date', $err, array( 'id' => 'acme_delivery_date' ) );
	}
	if ( ! empty( $data['billing_phone'] ) && ! acme_normalise_phone( $data['billing_phone'] ) ) {
		$errors->add( 'billing_phone', __( 'Phone must be a Pakistani mobile number, e.g. 0300 1234567.', 'acme' ), array( 'id' => 'billing_phone' ) );
	}
	if ( mb_strlen( $data['acme_gift_message'] ?? '' ) > 140 ) {
		$errors->add( 'acme_gift_message', __( 'Gift message can be up to 140 characters.', 'acme' ) );
	}
}

function acme_normalise_phone( string $raw ): ?string {
	$d = preg_replace( '/[\s-]/', '', $raw );
	if ( preg_match( '/^03\d{9}$/', $d ) ) {
		return '+92' . substr( $d, 1 );
	}
	return preg_match( '/^\+923\d{9}$/', $d ) ? $d : null;
}

// @hook woocommerce_checkout_create_order_line_item
add_action( 'woocommerce_checkout_create_order_line_item', 'acme_line_item_meta', 10, 3 );
function acme_line_item_meta( WC_Order_Item_Product $item, string $key, array $values ): void {
	foreach ( acme_show_item_data( array(), $values ) as $row ) {
		$item->add_meta_data( $row['name'], $row['value'] );
	}
}

// @hook woocommerce_checkout_create_order
add_action( 'woocommerce_checkout_create_order', 'acme_save_order_meta', 10, 2 );
function acme_save_order_meta( WC_Order $order, array $data ): void {
	// HPOS-safe: meta goes on the order object and is saved with it. No update_post_meta().
	$order->update_meta_data( '_acme_delivery_date', sanitize_text_field( $data['acme_delivery_date'] ) );
	$message = sanitize_text_field( $data['acme_gift_message'] ?? '' );
	if ( '' !== $message ) {
		$order->update_meta_data( '_acme_gift_message', $message );
	}
	$order->update_meta_data( '_billing_phone_e164', (string) acme_normalise_phone( $data['billing_phone'] ?? '' ) );
	$order->set_billing_country( 'PK' );
}

// @hook woocommerce_admin_order_data_after_billing_address
add_action( 'woocommerce_admin_order_data_after_billing_address', 'acme_admin_order_fields' );
function acme_admin_order_fields( WC_Order $order ): void {
	printf( '<p><strong>%s</strong> %s</p>', esc_html__( 'Delivery date:', 'acme' ), esc_html( $order->get_meta( '_acme_delivery_date' ) ) );
	$message = $order->get_meta( '_acme_gift_message' );
	if ( $message ) {
		printf( '<p><strong>%s</strong> %s</p>', esc_html__( 'Gift message:', 'acme' ), esc_html( $message ) );
	}
	printf( '<p><strong>%s</strong> %s</p>', esc_html__( 'Phone (E.164):', 'acme' ), esc_html( $order->get_meta( '_billing_phone_e164' ) ) );
}

// @hook woocommerce_email_order_meta_fields
add_filter( 'woocommerce_email_order_meta_fields', 'acme_email_fields', 10, 3 );
function acme_email_fields( array $fields, bool $sent_to_admin, WC_Order $order ): array {
	$fields['delivery'] = array( 'label' => __( 'Delivery date', 'acme' ), 'value' => $order->get_meta( '_acme_delivery_date' ) );
	$message            = $order->get_meta( '_acme_gift_message' );
	if ( $message ) {
		$fields['gift'] = array( 'label' => __( 'Gift message', 'acme' ), 'value' => $message );
	}
	return $fields;
}
app/lab/woocommerce-customization/Demo.tsx784 lines
"use client";

import { useEffect, useRef, useState } from "react";
import { CodePanel } from "@/components/lab/wp/LogPanel";
import { usePersistentState } from "@/components/lab/wp/usePersistentState";
import { Hooks, type TraceEntry } from "./hooks";
import { CUTOFF_HOUR, ENGRAVING_FEE, GIFT_WRAP_FEE, GLAZES, MIN_ORDER, SIZES, addDays, configuredPrice, isSunday, registerAcmeTweaks } from "./acme-tweaks";
import {
  FREE_SHIPPING_MIN,
  PRODUCTS,
  addToCart,
  adminOrderPanel,
  calculateTotals,
  checkoutFields,
  createWc,
  emailMetaFields,
  money,
  processCheckout,
  sorted,
  viewProduct,
  type CartItem,
  type FieldSet,
  type Notice,
  type Order,
  type Product,
  type Totals,
} from "./woo";

const KEY = "lab:woocommerce-customization:v1";

type Persisted = { cart: CartItem[]; orders: Order[]; nextOrderId: number; hour: number; pluginOn: boolean };
const seed = (): Persisted => ({ cart: [], orders: [], nextOrderId: 1042, hour: 11, pluginOn: true });

type Page = { kind: "shop" } | { kind: "product"; id: number } | { kind: "cart" } | { kind: "checkout" } | { kind: "order"; id: number };
type Engine = { h: Hooks; wc: ReturnType<typeof createWc> };

const HOURS = [
  { v: 9, label: "09:00" },
  { v: 11, label: "11:00" },
  { v: 13.5, label: "13:30" },
  { v: 15, label: "15:00 (after cut-off)" },
];

const TEST_DATA: Record<string, string> = {
  billing_first_name: "Ayesha",
  billing_last_name: "Khan",
  billing_phone: "0300 1234567",
  billing_email: "ayesha@example.com",
  billing_address_1: "House 12, Street 4",
  billing_city: "lahore",
  acme_gift_message: "Happy birthday!",
  // Only used when the plugin is inactive and core asks for these:
  billing_country: "PK",
  billing_state: "Punjab",
  billing_postcode: "54000",
};

const input = "w-full rounded border border-border bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-accent";
const btn = "rounded-md border border-border px-3 py-1.5 text-sm hover:border-accent hover:text-accent";
const btnPrimary = "rounded-md bg-accent px-4 py-2 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40";

export default function Demo({ snippets }: { snippets: Record<string, string> }) {
  const [state, setState, resetState, loaded] = usePersistentState<Persisted>(KEY, seed);
  const [today, setToday] = useState<string | null>(null);
  const [page, setPage] = useState<Page>({ kind: "shop" });
  const [notices, setNotices] = useState<Notice[]>([]);
  const [trace, setTrace] = useState<TraceEntry[]>([]);
  const [selected, setSelected] = useState<string | null>(null);
  const [pluginOnly, setPluginOnly] = useState(false);
  const seq = useRef(0);

  // product page state
  const [productFields, setProductFields] = useState<string[]>([]);
  const [priceHtml, setPriceHtml] = useState("");
  const [opt, setOpt] = useState({ size: "single", glaze: "matte-black", engraving: "", gift: false, qty: 1 });

  // cart / checkout state
  const [totals, setTotals] = useState<Totals | null>(null);
  const [fields, setFields] = useState<FieldSet | null>(null);
  const [form, setForm] = useState<Record<string, string>>({});
  const [errors, setErrors] = useState<{ field: string | null; text: string }[]>([]);
  const [bypass, setBypass] = useState(false);
  const [orderTab, setOrderTab] = useState<"customer" | "admin" | "email">("customer");
  const [adminLines, setAdminLines] = useState<string[]>([]);
  const [emailRows, setEmailRows] = useState<Record<string, { label: string; value: string }>>({});

  useEffect(() => {
    const d = new Date();
    setToday(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`);
  }, []);

  /** One simulated PHP request: fresh hook registry, plugin loaded (if active), run, collect the trace and notices. */
  function request<T>(fn: (e: Engine) => T, s: Persisted = state): T {
    const h = new Hooks();
    const wc = createWc(() => ({ date: today ?? "2026-01-01", hour: s.hour }));
    if (s.pluginOn) registerAcmeTweaks(h, wc.api);
    const out = fn({ h, wc });
    const entries = h.trace.map((t) => ({ ...t, seq: ++seq.current }));
    setTrace((tr) => [...tr, ...entries].slice(-250));
    setNotices(wc.notices);
    return out;
  }

  const recalc = (cart: CartItem[], s: Persisted = state, req?: string) => setTotals(request((e) => calculateTotals(e.h, e.wc, cart, req), s));

  // Initial cart totals once storage is loaded.
  useEffect(() => {
    if (loaded) setTotals(request((e) => calculateTotals(e.h, e.wc, state.cart, "GET /")));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [loaded]);

  function go(p: Page, s: Persisted = state) {
    setErrors([]);
    setPage(p);
    if (p.kind === "product") {
      const prod = PRODUCTS.find((x) => x.id === p.id)!;
      const r = request((e) => viewProduct(e.h, e.wc, prod), s);
      setProductFields(r.fields);
      setPriceHtml(r.priceHtml);
      setOpt({ size: "single", glaze: "matte-black", engraving: "", gift: false, qty: 1 });
    } else if (p.kind === "cart") {
      recalc(s.cart, s);
    } else if (p.kind === "checkout") {
      setFields(request((e) => checkoutFields(e.h), s));
      recalc(s.cart, s, "GET /checkout/");
    } else if (p.kind === "order") {
      const o = s.orders.find((x) => x.id === p.id);
      if (o) {
        setAdminLines(request((e) => adminOrderPanel(e.h, e.wc, o), s));
        setEmailRows(request((e) => emailMetaFields(e.h, o, true), s));
      }
    } else {
      setNotices([]);
    }
  }

  function onAdd(e: React.FormEvent, p: Product) {
    e.preventDefault();
    const posted: Record<string, string> = { quantity: String(opt.qty) };
    if (productFields.includes("size")) {
      posted.acme_size = opt.size;
      posted.acme_glaze = opt.glaze;
      posted.acme_engraving = opt.engraving;
    }
    if (productFields.includes("gift_wrap") && opt.gift) posted.acme_gift_wrap = "1";
    const r = request((en) => {
      const res = addToCart(en.h, state.cart, p.id, opt.qty, posted);
      if (res.ok) en.wc.api.add_notice(`“${p.name}” has been added to your cart.`, "success");
      return res;
    });
    if (r.ok) {
      const s = { ...state, cart: r.cart };
      setState(s);
      setTotals(request((en) => calculateTotals(en.h, en.wc, r.cart, "GET /?wc-ajax=get_refreshed_fragments"), s));
      setNotices([{ type: "success", text: `“${p.name}” has been added to your cart.` }]);
    }
  }

  function setQty(key: string, qty: number) {
    const cart = qty <= 0 ? state.cart.filter((c) => c.key !== key) : state.cart.map((c) => (c.key === key ? { ...c, qty } : c));
    const s = { ...state, cart };
    setState(s);
    recalc(cart, s, "POST /cart/ (update_cart)");
  }

  function placeOrder(e: React.FormEvent) {
    e.preventDefault();
    const r = request((en) => processCheckout(en.h, en.wc, state.cart, form, state.nextOrderId, new Date().toISOString()));
    if (!r.ok) {
      setErrors(r.errors);
      return;
    }
    const s = { ...state, cart: [], orders: [r.order, ...state.orders], nextOrderId: state.nextOrderId + 1 };
    setState(s);
    setTotals(null);
    setForm({});
    setOrderTab("customer");
    go({ kind: "order", id: r.order.id }, s);
  }

  function togglePlugin() {
    const s = { ...state, pluginOn: !state.pluginOn };
    setState(s);
    // Re-run the current page with the plugin (de)activated so the difference is visible straight away.
    go(page, s);
  }

  function reset() {
    resetState();
    setTrace([]);
    setNotices([]);
    setErrors([]);
    setForm({});
    setSelected(null);
    setTotals(null);
    setPage({ kind: "shop" });
  }

  const cartCount = state.cart.reduce((n, c) => n + c.qty, 0);
  const blocking = notices.some((n) => n.type === "error");
  const product = page.kind === "product" ? PRODUCTS.find((p) => p.id === page.id)! : null;
  const order = page.kind === "order" ? state.orders.find((o) => o.id === page.id) : undefined;
  const fieldErr = (k: string) => errors.find((x) => x.field === k)?.text;

  // Group the trace by request, newest request first.
  const groups: { request: string; items: TraceEntry[] }[] = [];
  for (const t of trace.filter((x) => !pluginOnly || x.callback)) {
    const last = groups[groups.length - 1];
    if (last && last.request === t.request && last.items.at(-1)!.seq === t.seq - 1) last.items.push(t);
    else groups.push({ request: t.request, items: [t] });
  }
  groups.reverse();

  return (
    <div className="grid gap-0 lg:grid-cols-[1.35fr_1fr]">
      {/* ---------------- Store ---------------- */}
      <div className="min-w-0 p-3 sm:p-5">
        <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
          <label className="flex items-center gap-1.5">
            <input type="checkbox" checked={state.pluginOn} onChange={togglePlugin} />
            <span>
              Plugin <code className="font-mono">acme-store-tweaks</code> active
            </span>
          </label>
          <label className="flex items-center gap-1.5">
            Store time
            <select
              value={state.hour}
              onChange={(e) => setState({ ...state, hour: Number(e.target.value) })}
              className="rounded border border-border bg-bg-elev px-1.5 py-0.5"
            >
              {HOURS.map((h) => (
                <option key={h.v} value={h.v}>
                  {h.label}
                </option>
              ))}
            </select>
          </label>
          <button type="button" onClick={reset} className="rounded border border-border px-2 py-0.5 font-mono hover:border-accent">
            reset demo
          </button>
        </div>

        <div className="overflow-hidden rounded-md border border-border bg-bg-elev">
          <header className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-2.5">
            <button type="button" onClick={() => go({ kind: "shop" })} className="font-semibold tracking-tight">
              Acme <span className="text-accent-3">Roasters</span>
            </button>
            <span className="rounded bg-warn-bg px-1.5 text-[10px] text-warn-fg">demo shop · no payments</span>
            <nav aria-label="Shop" className="ml-auto flex gap-1 text-sm">
              <button type="button" onClick={() => go({ kind: "shop" })} className="rounded px-2 py-1 hover:text-accent" aria-current={page.kind === "shop" ? "page" : undefined}>
                Shop
              </button>
              <button type="button" onClick={() => go({ kind: "cart" })} className="rounded px-2 py-1 hover:text-accent" aria-current={page.kind === "cart" ? "page" : undefined}>
                Cart <span className="rounded-full bg-accent px-1.5 font-mono text-[11px] text-accent-fg">{cartCount}</span>
              </button>
            </nav>
          </header>

          <div className="min-h-[30rem] p-4">
            <div aria-live="polite">
              {notices.map((n, i) => (
                <p
                  key={i}
                  role={n.type === "error" ? "alert" : undefined}
                  className={`mb-3 rounded border-l-4 px-3 py-2 text-sm ${n.type === "error" ? "border-danger bg-danger/10 text-danger" : "border-accent bg-accent-soft"}`}
                >
                  {n.text}
                  {n.type === "success" && page.kind === "product" && (
                    <button type="button" onClick={() => go({ kind: "cart" })} className="ml-2 underline">
                      View cart
                    </button>
                  )}
                </p>
              ))}
            </div>

            {page.kind === "shop" && (
              <ul className="grid gap-3 sm:grid-cols-3">
                {PRODUCTS.map((p) => (
                  <li key={p.id}>
                    <button type="button" onClick={() => go({ kind: "product", id: p.id })} className="group block w-full text-left">
                      <ProductArt hue={p.hue} className="aspect-square w-full" />
                      <span className="mt-2 block text-sm font-medium group-hover:text-accent">{p.name}</span>
                      <span className="text-sm text-fg-muted">
                        {p.configurable && state.pluginOn ? "From " : ""}
                        {money(p.price)}
                      </span>
                    </button>
                  </li>
                ))}
              </ul>
            )}

            {product && (
              <div className="grid gap-4 sm:grid-cols-[12rem_1fr]">
                <ProductArt hue={product.hue} className="aspect-square w-full max-w-[12rem]" />
                <form onSubmit={(e) => onAdd(e, product)} className="space-y-3">
                  <div>
                    <h4 className="text-xl font-semibold">{product.name}</h4>
                    <p className="text-sm text-fg-muted">
                      SKU {product.sku} · <span>{priceHtml}</span>
                    </p>
                    <p className="mt-1 text-sm">{product.blurb}</p>
                  </div>
                  {productFields.includes("size") && (
                    <>
                      <fieldset>
                        <legend className="text-sm font-medium">Size</legend>
                        <div className="mt-1 flex flex-wrap gap-2">
                          {Object.entries(SIZES).map(([k, v]) => (
                            <label key={k} className={`cursor-pointer rounded border px-3 py-1.5 text-sm has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-accent ${opt.size === k ? "border-accent bg-accent-soft" : "border-border"}`}>
                              <input type="radio" name="acme_size" value={k} checked={opt.size === k} onChange={() => setOpt({ ...opt, size: k })} className="sr-only" />
                              {v.label} {v.add ? <span className="text-fg-muted">+{money(v.add)}</span> : null}
                            </label>
                          ))}
                        </div>
                      </fieldset>
                      <label className="block">
                        <span className="text-sm font-medium">Glaze</span>
                        <select value={opt.glaze} onChange={(e) => setOpt({ ...opt, glaze: e.target.value })} className={`${input} mt-1`}>
                          {Object.entries(GLAZES).map(([k, v]) => (
                            <option key={k} value={k}>
                              {v.label}
                              {v.add ? ` (+${money(v.add)})` : ""}
                            </option>
                          ))}
                        </select>
                      </label>
                      <label className="block">
                        <span className="text-sm font-medium">Engraving (optional, +{money(ENGRAVING_FEE)})</span>
                        <input value={opt.engraving} onChange={(e) => setOpt({ ...opt, engraving: e.target.value })} placeholder="e.g. Morning ritual" className={`${input} mt-1`} aria-describedby="eng-help" />
                        <span id="eng-help" className="text-xs text-fg-muted">
                          Up to 20 letters, numbers, spaces, dots, dashes. Try an emoji or 25 characters to see server validation.
                        </span>
                      </label>
                    </>
                  )}
                  {productFields.includes("gift_wrap") && (
                    <label className="flex items-center gap-2 text-sm">
                      <input type="checkbox" checked={opt.gift} onChange={(e) => setOpt({ ...opt, gift: e.target.checked })} />
                      Gift wrap (+{money(GIFT_WRAP_FEE)} per line)
                    </label>
                  )}
                  {!state.pluginOn && product.configurable && <p className="text-sm text-warn-fg">Plugin inactive: WooCommerce shows no options for this product.</p>}
                  <div className="flex flex-wrap items-end gap-3">
                    <label className="block">
                      <span className="text-sm font-medium">Qty</span>
                      <input type="number" min={1} max={9} value={opt.qty} onChange={(e) => setOpt({ ...opt, qty: Math.max(1, Math.min(9, Number(e.target.value) || 1)) })} className={`${input} mt-1 w-20`} />
                    </label>
                    <p className="pb-1.5 text-lg font-semibold" aria-live="polite">
                      {money((productFields.includes("size") ? configuredPrice(product.price, { size: opt.size, glaze: opt.glaze, engraving: opt.engraving.trim() }) : product.price) * opt.qty)}
                    </p>
                    <button type="submit" className={btnPrimary}>
                      Add to cart
                    </button>
                  </div>
                </form>
              </div>
            )}

            {page.kind === "cart" && (
              <div>
                {!state.cart.length ? (
                  <div className="py-10 text-center text-sm text-fg-muted">
                    Your cart is currently empty.{" "}
                    <button type="button" onClick={() => go({ kind: "shop" })} className="text-accent underline">
                      Return to shop
                    </button>
                  </div>
                ) : (
                  totals && (
                    <div className="space-y-4">
                      <ul className="divide-y divide-border">
                        {totals.lines.map((l) => (
                          <li key={l.key} className="flex flex-wrap items-start gap-3 py-3">
                            <ProductArt hue={PRODUCTS.find((p) => p.id === l.productId)!.hue} className="size-12 shrink-0" />
                            <div className="min-w-0 flex-1">
                              <p className="text-sm font-medium">{l.name}</p>
                              {l.meta.length > 0 && (
                                <dl className="mt-0.5 grid grid-cols-[auto_1fr] gap-x-2 text-xs text-fg-muted">
                                  {l.meta.map((m) => (
                                    <div key={m.name} className="contents">
                                      <dt>{m.name}:</dt>
                                      <dd>{m.value}</dd>
                                    </div>
                                  ))}
                                </dl>
                              )}
                              <p className="text-xs text-fg-muted">{money(l.unit)} each</p>
                            </div>
                            <div className="flex items-center gap-1">
                              <button type="button" onClick={() => setQty(l.key, l.qty - 1)} className="size-7 rounded border border-border hover:border-accent" aria-label={`Decrease quantity of ${l.name}`}>
                                −
                              </button>
                              <span className="w-6 text-center font-mono text-sm">{l.qty}</span>
                              <button type="button" onClick={() => setQty(l.key, l.qty + 1)} className="size-7 rounded border border-border hover:border-accent" aria-label={`Increase quantity of ${l.name}`}>
                                +
                              </button>
                              <button type="button" onClick={() => setQty(l.key, 0)} className="ml-1 size-7 rounded border border-border hover:border-danger hover:text-danger" aria-label={`Remove ${l.name}`}>
                                ×
                              </button>
                            </div>
                            <p className="w-24 text-right font-mono text-sm">{money(l.subtotal)}</p>
                          </li>
                        ))}
                      </ul>
                      {totals.beforeTotals.length > 0 && (
                        <div role="status" className="rounded border border-border p-3 text-sm">
                          <div className="mb-1 h-2 overflow-hidden rounded bg-bg-muted">
                            <div className="h-full bg-accent transition-all" style={{ width: `${Math.min(100, (totals.subtotal / FREE_SHIPPING_MIN) * 100)}%` }} />
                          </div>
                          {totals.beforeTotals.join(" ")}
                        </div>
                      )}
                      <TotalsTable totals={totals} />
                      <button type="button" disabled={blocking} onClick={() => go({ kind: "checkout" })} className={`${btnPrimary} w-full`}>
                        Proceed to checkout
                      </button>
                      {state.pluginOn && <p className="text-xs text-fg-muted">Minimum order {money(MIN_ORDER)} · free delivery from {money(FREE_SHIPPING_MIN)}.</p>}
                    </div>
                  )
                )}
              </div>
            )}

            {page.kind === "checkout" && fields && (
              <form onSubmit={placeOrder} noValidate className="space-y-4">
                {errors.length > 0 && (
                  <div role="alert" className="rounded border-l-4 border-danger bg-danger/10 px-3 py-2 text-sm text-danger">
                    <ul className="list-inside list-disc">
                      {errors.map((er) => (
                        <li key={er.text}>{er.field ? <a href={`#f-${er.field}`} className="underline">{er.text}</a> : er.text}</li>
                      ))}
                    </ul>
                  </div>
                )}
                <div className="flex flex-wrap gap-2">
                  <button type="button" className={btn} onClick={() => setForm({ ...TEST_DATA, ...form })}>
                    Fill test details
                  </button>
                  <label className="flex items-center gap-1.5 text-xs">
                    <input type="checkbox" checked={bypass} onChange={(e) => setBypass(e.target.checked)} />
                    Bypass the date picker (as if edited in DevTools)
                  </label>
                </div>
                <div className="grid gap-4 md:grid-cols-2">
                  <fieldset className="space-y-2.5">
                    <legend className="mb-1 font-semibold">Billing details</legend>
                    {sorted(fields.billing).map(([k, f]) => (
                      <FieldRow key={k} id={k} f={f} value={form[k] ?? ""} error={fieldErr(k)} onChange={(v) => setForm({ ...form, [k]: v })} />
                    ))}
                  </fieldset>
                  <fieldset className="space-y-2.5">
                    <legend className="mb-1 font-semibold">Delivery and notes</legend>
                    {sorted(fields.order).map(([k, f]) =>
                      f.type === "delivery_date" ? (
                        <DatePicker
                          key={k}
                          id={k}
                          label={f.label}
                          help={f.description}
                          value={form[k] ?? ""}
                          error={fieldErr(k)}
                          today={today}
                          hour={state.hour}
                          bypass={bypass}
                          onChange={(v) => setForm({ ...form, [k]: v })}
                        />
                      ) : (
                        <FieldRow key={k} id={k} f={f} value={form[k] ?? ""} error={fieldErr(k)} onChange={(v) => setForm({ ...form, [k]: v })} />
                      ),
                    )}
                    {totals && <TotalsTable totals={totals} />}
                    <p className="text-xs text-fg-muted">Payment: Cash on delivery (demo).</p>
                  </fieldset>
                </div>
                <button type="submit" className={btnPrimary}>
                  Place order
                </button>
              </form>
            )}

            {page.kind === "order" && order && (
              <div>
                <div role="tablist" aria-label="Order views" className="mb-3 flex gap-1 border-b border-border">
                  {(
                    [
                      ["customer", "Order received"],
                      ["admin", "wp-admin › Edit order"],
                      ["email", "New order email"],
                    ] as const
                  ).map(([id, label]) => (
                    <button key={id} type="button" role="tab" aria-selected={orderTab === id} onClick={() => setOrderTab(id)} className={`-mb-px border-b-2 px-3 py-1.5 text-xs ${orderTab === id ? "border-accent text-accent" : "border-transparent text-fg-muted"}`}>
                      {label}
                    </button>
                  ))}
                </div>
                {orderTab === "customer" && (
                  <div className="space-y-3">
                    <p className="rounded border-l-4 border-accent bg-accent-soft px-3 py-2 text-sm">Thank you. Your order #{order.id} has been received.</p>
                    <p className="text-sm">
                      Delivery on <strong>{order.meta._acme_delivery_date ?? "—"}</strong> to {order.billing.billing_address_1}, {order.billing.billing_city}.
                    </p>
                    <TotalsTable totals={{ ...order.totals, lines: order.lines }} showLines />
                  </div>
                )}
                {orderTab === "admin" && (
                  <div className="rounded border border-border bg-bg p-3 text-sm">
                    <p className="font-semibold">
                      Order #{order.id} details <span className="ml-2 rounded bg-accent-soft px-1.5 text-xs text-accent">Processing</span>
                    </p>
                    <div className="mt-2 grid gap-3 sm:grid-cols-2">
                      <div>
                        <p className="text-xs text-fg-muted">Billing</p>
                        <p>
                          {order.billing.billing_first_name} {order.billing.billing_last_name}
                          <br />
                          {order.billing.billing_address_1}
                          <br />
                          {order.billing.billing_email}
                        </p>
                        {adminLines.map((l) => (
                          <p key={l} className="mt-1">
                            <strong>{l.split(":")[0]}:</strong>
                            {l.slice(l.indexOf(":") + 1)}
                          </p>
                        ))}
                        {!adminLines.length && <p className="mt-1 text-xs text-fg-muted">(no custom fields: plugin inactive)</p>}
                      </div>
                      <div>
                        <p className="text-xs text-fg-muted">Items (with item meta)</p>
                        {order.lines.map((l) => (
                          <div key={l.key} className="mt-1">
                            {l.name} × {l.qty}
                            {Object.entries(order.itemMeta[l.key] ?? {}).map(([k, v]) => (
                              <div key={k} className="text-xs text-fg-muted">
                                {k}: {v}
                              </div>
                            ))}
                          </div>
                        ))}
                      </div>
                    </div>
                  </div>
                )}
                {orderTab === "email" && (
                  <div className="mx-auto max-w-md rounded border border-border bg-bg p-4 text-sm">
                    <p className="bg-accent-3/80 -m-4 mb-3 px-4 py-3 font-semibold text-white">New order: #{order.id}</p>
                    <p>You have received an order from {order.billing.billing_first_name} {order.billing.billing_last_name}.</p>
                    <TotalsTable totals={{ ...order.totals, lines: order.lines }} showLines />
                    {Object.values(emailRows).map((r) => (
                      <p key={r.label} className="mt-1">
                        <strong>{r.label}:</strong> {r.value}
                      </p>
                    ))}
                  </div>
                )}
              </div>
            )}

            {state.orders.length > 0 && page.kind !== "checkout" && (
              <div className="mt-6 border-t border-border pt-3">
                <p className="text-xs text-fg-muted">Recent orders</p>
                <ul className="mt-1 flex flex-wrap gap-2">
                  {state.orders.slice(0, 6).map((o) => (
                    <li key={o.id}>
                      <button type="button" onClick={() => go({ kind: "order", id: o.id })} className="rounded border border-border px-2 py-0.5 font-mono text-xs hover:border-accent">
                        #{o.id} · {money(o.totals.total)}
                      </button>
                    </li>
                  ))}
                </ul>
              </div>
            )}
          </div>
        </div>
      </div>

      {/* ---------------- Hooks inspector ---------------- */}
      <aside aria-label="Hooks inspector" className="min-w-0 space-y-3 border-t border-border bg-bg-elev p-3 sm:p-5 lg:border-t-0 lg:border-l">
        <div className="flex flex-wrap items-center justify-between gap-2">
          <h3 className="font-mono text-sm font-semibold">hooks inspector</h3>
          <label className="flex items-center gap-1.5 text-xs">
            <input type="checkbox" checked={pluginOnly} onChange={(e) => setPluginOnly(e.target.checked)} />
            only hooks with plugin callbacks
          </label>
        </div>
        <p className="text-xs text-fg-muted">Every simulated request, newest first. Select a hook to see the PHP that handles it.</p>
        <ol aria-live="polite" className="h-[22rem] space-y-2 overflow-y-auto rounded-md border border-border bg-[#0b0b0d] p-2 font-mono text-[11.5px]">
          {groups.length === 0 && <li className="p-1 text-[#71717a]">$ open a product to start…</li>}
          {groups.map((g, gi) => (
            <li key={gi + g.request + g.items[0]!.seq}>
              <p className="px-1 text-[#d4d4d8]">{g.request}</p>
              <ul>
                {g.items.map((t) => (
                  <li key={t.seq}>
                    <button
                      type="button"
                      onClick={() => setSelected(t.hook)}
                      className={`w-full rounded px-1 py-0.5 text-left hover:bg-[#27272a] ${selected === t.hook ? "bg-[#1e293b]" : ""}`}
                    >
                      <span className={t.kind === "filter" ? "text-[#c4b5fd]" : "text-[#93c5fd]"}>{t.kind === "filter" ? "apply_filters" : "do_action"}</span>{" "}
                      <span className={t.callback ? "text-[#4ade80]" : "text-[#71717a]"}>{t.hook}</span>
                      {t.callback && <span className="text-[#a1a1aa]"> → {t.callback}</span>}
                      {t.note && <span className="block pl-3 text-[#71717a]">{t.note}</span>}
                    </button>
                  </li>
                ))}
              </ul>
            </li>
          ))}
        </ol>
        {selected ? (
          snippets[selected] ? (
            <CodePanel code={snippets[selected]!} label={`php/acme-store-tweaks.php · ${selected}`} className="max-h-96" />
          ) : (
            <p className="rounded border border-border p-3 text-xs text-fg-muted">
              <code>{selected}</code> is fired by WooCommerce core. The plugin does not attach to it, so core behaviour applies.
            </p>
          )
        ) : (
          <p className="text-xs text-fg-muted">No hook selected.</p>
        )}
        <p className="text-xs text-fg-muted">
          Same-day cut-off {CUTOFF_HOUR}:00 · today {today ?? "…"}
          {today && isSunday(today) ? " (Sunday)" : ""}
        </p>
      </aside>
    </div>
  );
}

function ProductArt({ hue, className }: { hue: number; className: string }) {
  return (
    <div aria-hidden="true" className={`grid place-items-center rounded-md ${className}`} style={{ background: `linear-gradient(160deg, hsl(${hue} 45% 62%), hsl(${hue + 15} 35% 32%))` }}>
      <div className="h-2/5 w-2/5 rounded-b-full border-4 border-white/70" />
    </div>
  );
}

function TotalsTable({ totals, showLines }: { totals: Pick<Totals, "lines" | "subtotal" | "fees" | "shipping" | "total">; showLines?: boolean }) {
  return (
    <table className="w-full text-sm">
      <tbody>
        {showLines &&
          totals.lines.map((l) => (
            <tr key={l.key} className="border-b border-border">
              <td className="py-1">
                {l.name} × {l.qty}
              </td>
              <td className="py-1 text-right font-mono">{money(l.subtotal)}</td>
            </tr>
          ))}
        <tr className="border-b border-border">
          <th scope="row" className="py-1 text-left font-normal text-fg-muted">
            Subtotal
          </th>
          <td className="py-1 text-right font-mono">{money(totals.subtotal)}</td>
        </tr>
        {totals.fees.map((f) => (
          <tr key={f.name} className="border-b border-border">
            <th scope="row" className="py-1 text-left font-normal text-fg-muted">
              {f.name}
            </th>
            <td className="py-1 text-right font-mono">{money(f.amount)}</td>
          </tr>
        ))}
        {totals.shipping && (
          <tr className="border-b border-border">
            <th scope="row" className="py-1 text-left font-normal text-fg-muted">
              {totals.shipping.label}
            </th>
            <td className="py-1 text-right font-mono">{totals.shipping.cost ? money(totals.shipping.cost) : "Free"}</td>
          </tr>
        )}
        <tr>
          <th scope="row" className="py-1 text-left">
            Total
          </th>
          <td className="py-1 text-right font-mono font-semibold">{money(totals.total)}</td>
        </tr>
      </tbody>
    </table>
  );
}

function FieldRow({ id, f, value, error, onChange }: { id: string; f: import("./woo").Field; value: string; error?: string; onChange: (v: string) => void }) {
  const describedBy = [f.description ? `${id}-help` : "", error ? `${id}-err` : ""].filter(Boolean).join(" ") || undefined;
  const common = { id: `f-${id}`, name: id, value, "aria-invalid": !!error, "aria-describedby": describedBy, className: `${input} mt-1 ${error ? "border-danger" : ""}` };
  return (
    <div>
      <label htmlFor={`f-${id}`} className="text-sm">
        {f.label} {f.required && <span className="text-danger" aria-hidden="true">*</span>}
        {f.required && <span className="sr-only">(required)</span>}
      </label>
      {f.type === "select" ? (
        <select {...common} onChange={(e) => onChange(e.target.value)}>
          {Object.entries(f.options ?? {}).map(([k, v]) => (
            <option key={k} value={k}>
              {v}
            </option>
          ))}
        </select>
      ) : f.type === "textarea" ? (
        <textarea {...common} rows={2} onChange={(e) => onChange(e.target.value)} />
      ) : (
        <input {...common} type={f.type === "email" ? "email" : f.type === "tel" ? "tel" : "text"} onChange={(e) => onChange(e.target.value)} />
      )}
      {f.description && (
        <p id={`${id}-help`} className="text-xs text-fg-muted">
          {f.description}
        </p>
      )}
      {error && (
        <p id={`${id}-err`} className="text-xs text-danger">
          {error}
        </p>
      )}
    </div>
  );
}

function DatePicker(props: {
  id: string;
  label: string;
  help?: string;
  value: string;
  error?: string;
  today: string | null;
  hour: number;
  bypass: boolean;
  onChange: (v: string) => void;
}) {
  const { id, label, help, value, error, today, hour, bypass, onChange } = props;
  const days = today ? Array.from({ length: 10 }, (_, i) => addDays(today, i)) : [];
  const fmt = (d: string) => new Date(d + "T12:00:00Z").toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", timeZone: "UTC" });
  return (
    <div>
      {bypass ? (
        <>
          <label htmlFor={`f-${id}`} className="text-sm">
            {label} <span className="text-danger">*</span>
          </label>
          <input id={`f-${id}`} value={value} onChange={(e) => onChange(e.target.value)} placeholder="YYYY-MM-DD, try a Sunday" aria-invalid={!!error} aria-describedby={error ? `${id}-err` : undefined} className={`${input} mt-1 font-mono ${error ? "border-danger" : ""}`} />
        </>
      ) : (
        <fieldset id={`f-${id}`} aria-describedby={error ? `${id}-err` : `${id}-help`}>
          <legend className="text-sm">
            {label} <span className="text-danger">*</span>
          </legend>
          <div className="mt-1 grid grid-cols-5 gap-1">
            {days.map((d, i) => {
              const disabled = isSunday(d) || (i === 0 && hour >= CUTOFF_HOUR);
              return (
                <button
                  key={d}
                  type="button"
                  disabled={disabled}
                  aria-pressed={value === d}
                  onClick={() => onChange(d)}
                  className={`rounded border px-1 py-1 text-[11px] leading-tight disabled:cursor-not-allowed disabled:opacity-35 disabled:line-through ${value === d ? "border-accent bg-accent-soft" : "border-border hover:border-accent"}`}
                >
                  {fmt(d)}
                </button>
              );
            })}
          </div>
        </fieldset>
      )}
      {help && (
        <p id={`${id}-help`} className="text-xs text-fg-muted">
          {help}
        </p>
      )}
      {error && (
        <p id={`${id}-err`} className="text-xs text-danger">
          {error}
        </p>
      )}
    </div>
  );
}