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.
Interactive demo
## 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>
);
}