Order form
The COD order form contract — composition with slots, field visibility, draft orders, geo, validation, security, and the floating CTA.
Most Feeef stores sell cash-on-delivery: one form on the product page collects the
customer's phone, name and address, and creates the order. The native registry component
product_order_form implements the entire contract — drafts, geo cascades, validation,
bot protection, pixels — so the first rule of theme authoring is: use it, or compose
around it, before reimplementing it.
| Approach | When |
|---|---|
Registry product_order_form | Default. Fully merchant-configurable via propsSchema |
| Native blocks + custom chrome in slots | You want custom layout but stock behavior |
| Fully custom slot-composed form | The UI must diverge completely — you now own the whole contract below |
Built-in blocks
| Type | Role |
|---|---|
product_order_form | The full form |
product_order_form_variants | Variant picker only |
product_order_form_offers | Offers selector only (requireOffer prop) |
product_order_form_addons | Addons only |
order_floating_button | Floating CTA that scrolls to the buy button |
Who submits orders
Theme code never talks to the platform API directly to create orders — the form component owns submission, and it submits through the storefront's own server route:
Browser form → POST /api/orders/create → server SDK ff.orders.send(data)The proxy route forwards the shopper's real IP (x-feeef-real-ip) and user agent
(x-feeef-client-user-agent) — signals the anti-fraud layer needs. Calling
ff.orders.send() from the browser bypasses them and is forbidden in production themes.
Other theme blocks (floating CTAs, buy buttons in heroes) never post orders either: they
scroll to or trigger the form.
Lifecycle: draft, then pending
Every form sends two requests per successful order:
Draft — the moment the phone number reaches the exact digit length for the store's
country, the form silently creates a status: "draft" order. Abandoned carts still become
leads the merchant can call back.
Pending — on submit, after validation and security checks pass, the form sends
status: "pending" reusing the draft's id, upgrading it instead of duplicating it.
const COUNTRY_PHONE_LENGTHS = { DZ: 10, TN: 10, IQ: 11, LY: 10 }; // default 10
// Draft fires when the digits match exactly — not on blur:
phone.replace(/\D/g, "").length === requiredLengthGuard the draft with refs, not state — rapid keypresses will double-send otherwise
(draftOrderRef + draftOrderSendingRef in the native implementation). Drafts are
mandatory behavior: never expose a merchant-facing prop that can turn them off in a
custom form.
Request body
{
id?: string, // draft id when upgrading draft → pending
storeId: string,
customerPhone: string, // required
customerName?: string,
customerEmail?: string,
shippingCountry?: string, // UPPERCASE ISO, e.g. "DZ"
shippingState?: string, // legacy: "1".."58" | new: state code
shippingCity?: string, // legacy: 1-based index | new: city name
shippingAddress?: string,
shippingType: "home" | "pickup" | "store",
customerNote?: string,
status: "draft" | "pending",
source?: string,
references?: string[], // session attribution tokens
customFields?: Record<string, any>,
items: Array<{
productId: string,
quantity: number,
variantPath?: string, // "Color/Red" — expand "A | B" into two items of qty 1
offerCode?: string,
addons?: Record<string, number>, // addon title → qty
}>,
metadata: {
metaPixel?: { fbclid?, fbc?, fbp?, eventSourceUrl? },
deviceFingerprint?: string,
formLoadTime: number, // ms epoch captured on mount
platform: "web;lithium v1.0",
}
}Responses
| Status | Meaning |
|---|---|
200 | Order entity (id, totals, shipping) — success |
422 | Field errors { errors: [{ field, message }] } — custom fields come back as customFields.<id> |
403 | Security / ads-only rejection — show a friendly message |
429 | Rate limited |
Any order id starting with FuHe3nf is a synthetic bot response: show the normal
success UI but never fire pixels, never redirect to tracking, never count it as a sale.
Hiding and requiring fields
The native form exposes a fields object prop — each field is independently
configurable by the merchant from the editor:
{
"fields": {
"name": { "visible": true, "required": true, "placeholder": "الاسم الكامل" },
"phone": { "visible": true, "required": true, "validate": true },
"state": { "visible": true, "required": true, "inputType": "select" },
"city": { "visible": true, "required": true, "inputType": "select" },
"address": { "visible": false, "required": false },
"email": { "visible": false, "required": false },
"notes": { "visible": false, "required": false }
}
}Defaults: name, phone, state, city are visible and required; address, email,
notes are hidden and optional. Each field also takes a label object
(visible, text); state and city accept inputType: "select" | "datalist";
phone.validate toggles Algerian number validation. Custom forms must honor the same
semantics: every field block exposes hide, and every validated field exposes required.
Other notable form props: submitButton (style simple | magic | glowing, labels,
emojis, discount badge), whatsapp (an alternative order-by-WhatsApp button), shipping
(visible, layout: "cards" | "list", label), successPopup (title, message, track /
browse / feedback buttons, autoRedirect), plus showVariants, showOffers,
requireOffer, showAddons, showQuantity, showAddToCart and sticky.
Slot composition
A theme that ships its own COD form must be slot-composed — a shell/manager component with real child blocks the merchant can reorder or remove in the editor, not one monolithic JSX blob:
| Block | Merchant props (minimum) |
|---|---|
| Offers selector | hide, requireOffer, heading, allowDeselect |
| Variant picker | hide, stock flags; respect variant.required |
| Custom fields | hide; per-field required from the field definition |
| Name / phone / email / geo / address / note / qty inputs | hide, required, label |
| Shipping type cards | hide; render nothing until a state is selected |
| Buy actions | hide, showQty, showCart, showTotal, labels |
| Success | its own success slot |
Offers, addons, quantity
Selected offers go through the cart (cart.updateCurrentItemOffer(offer)) and are sent as
offerCode per item; clamp quantity to the offer's minQuantity/maxQuantity. Two
product-level policies stack with the template's requireOffer: product.defaultOfferCode
preselects an offer, and product.forceOffer prevents deselecting one. When the selected
offer's unit price differs from the catalog price, the PDP price tag must strike through
the catalog amount and show the offer price. Addons travel as a title-to-quantity map.
Image variants and gallery sync
When a variant option has an image (type: "image", an image value, or mediaIndex),
text pills are not enough:
- Render a thumbnail for the option.
- On select, highlight it and scroll or swap the matching gallery image.
- Keep picker and gallery decoupled with an event:
window.dispatchEvent(
new CustomEvent("feeef:variant-image", { detail: { url, mediaIndex } })
);
// The gallery listens and scrolls the matching image into view.Bundle multi-selects like "Red | Blue" must be expanded into separate items of
quantity 1 before submitting. If the store has the inventory integration active,
out-of-stock options render with mild opacity plus a red diagonal slash and — when
backorders are disabled — must be blocked at selection and again at submit.
Custom fields
Merchants can define extra fields (from store.publicIntegrations.customFields.fields).
The form renders them when the integration is active, keeps answers keyed by field id,
and sends them as customFields: Record<string, any>. Validation errors for them come
back on 422 as customFields.<id>.
Geo: states and cities
The single most error-prone part. Branch on one flag:
const isLegacy = !store?.configs?.selectedCountry;| Legacy Algeria | Multi-country | |
|---|---|---|
| States | Wilaya labels from shipping rates | feeef.states.list({ countryCode }), filtered by shipping prices |
| Cities | Commune list per wilaya | feeef.cities.list({ countryCode, stateCode }) on state change |
shippingState sent | 1-based index string "1"…"58" | State code |
shippingCity sent | 1-based index string of the commune list | City name (localized) |
Wrong encoding breaks shipping prices and backend validation. Before reading shipping
types or prices, bootstrap the cart once per product: set the current item, the shipping
method, and the address — and never re-run that bootstrap on every cart update, or the
form will wipe the selected state/city on each keystroke. Shipping types are home,
pickup (stopdesk) and store (collect at merchant); hide the selector when only one
type is available.
Validation
Client validation always runs before a pending submit — it is never optional:
- Phone digits must equal the country length exactly.
- Required fields (per the
fieldsconfig) must be filled; email checked against a simple regex when required. - A required variant (
product.variant.required) or required offer blocks submission. - Scroll and focus the first invalid field; do not call the API.
Security
All of these run on pending submits (drafts skip bot checks but keep in-flight locks):
| Check | Behavior |
|---|---|
| Honeypot | Hidden input name="website" (off-screen, not display:none). If filled → fake FuHe3nf… success, no API call |
| Timing | Submit within 3 seconds of mount → fake success |
| In-flight lock | useRef boolean; ignore double submits |
| Double-send | Cooldown per shopper when the store's security integration is active |
| Fingerprint | Optional FingerprintJS visitorId in metadata.deviceFingerprint |
| Pixel suppression | No Meta/TikTok events for fake ids or flagged treatments; don't re-fire Purchase on pending if the draft already sent it |
Floating CTA
Every theme ships a configurable floating order button that appears when the buy control scrolls out of view. Registry version:
| Prop | Type | Default |
|---|---|---|
hide | boolean | false |
action | scroll | popup | scroll |
label | string | translation of "order now" |
showTotal | boolean | true |
The float targets the buy button, not the form: it observes and scrolls to
#feeef-order-buy / [data-feeef-order-buy], so every theme's primary buy control must
set that id. Themes may ship their own float instead — same behavior, and never two
floats on one page.
Success UX
On a real (non-fake) pending success: clear the cart, fire pixels once, then show a
success dialog or redirect to /thanks?order={id} (see
standard pages). Order tracking lives at
https://api.feeef.org/track/{orderId}.
Pitfalls
| Bad | Good |
|---|---|
ff.orders.send(...) from the browser | fetch("/api/orders/create", ...) — and only from the form |
| Draft on every keypress | Exact digit length + a sending ref |
One item with variantPath: "A | B" | Expand into two items |
Firing Purchase for a FuHe3nf id | Suppress pixels for fake orders |
| Free-text state/city | Cascading selects with correct legacy indices / new codes |
| Re-bootstrapping cart on every cart change | Bootstrap once per product, preserve the address |
| Monolithic funnel component | Shell + slots, every field with hide/required |
| Float scrolling to the whole form | Scroll to #feeef-order-buy |
For the underlying node contract see the data model; for
scope hooks like useFeeefCart see custom components.