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
The form component owns submission, through submitFeeefOrder, the supported path
today:
Browser form → submitFeeefOrder(body, { store, formLoadTime })
→ POST /api/orders/create → server SDK ff.orders.send(data)submitFeeefOrder runs the storefront checks, sends the checkout idempotency key and posts to
/api/orders/create, which forwards the shopper's IP, user agent and visitor geo (from Traefik,
X-GeoIP2-*).
A direct browser POST to the feeef API (api.feeef.org) is coming, but it is not recommended until backend enforcement for it ships. Do not build forms on it yet. 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 |
503 | ORDER_RETRY: not sent, retry with the same idempotency key |
502 | ORDER_STATUS_UNKNOWN: may already exist, don't resubmit with a new key |
A submit the storefront does not accept still gets an answer that looks exactly like a
real success: same fields, same kind of id, similar timing. Don't try to tell them apart
from the id or the body. Use submitFeeefOrder (or the native form); it applies the
shared checks and the store's own settings, so don't add your own time check, honeypot
handling or raw fetch fallback. Every successful response is then handled the same way:
FeeefPixels.fireIdentify + FeeefPixels.fireConversion, clear the cart, and redirect to
/thanks?order=<id> with feeefThanksUrl(res, { customerName, total }). Never branch on
the response.
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 define extra order fields (contract v2). The list a form renders is:
- the store list,
store.publicIntegrations.customFields.fields(nullwhen inactive), unless - a product in the cart has
integrationsData.customFieldsData.useProductFields: true— then that product'sfieldsreplace the store list (several products → union byid).
Answers are kept by field id and sent as customFields: { [fieldId]: value }. The field
shape and limits are listed in Integrations → customFields.
Types and values
type | Input | Value sent |
|---|---|---|
text, phone, email, textarea | text input / textarea | string (honor minLength, maxLength, regexPattern) |
number | number input | number (min, max, integer) |
select | select / chips from options | one options[].value (never the label) |
checkbox | checkbox | boolean — required means it must be true ("I agree") |
image, file | file picker | the url returned by the upload endpoint |
date, time, datetime | native pickers | YYYY-MM-DD, HH:mm, ISO-8601 |
daterange, timerange, datetimerange | two pickers | { start, end } |
multiple: true turns the value into an array (between minCount and maxCount, at most 20).
Empty values ("", null, []) count as not provided — don't send them.
Visibility rules
A field with visibility: { match: "all" | "any", conditions } is shown only when its
conditions pass. Conditions look at an earlier field (equals, notEquals, in,
notIn, filled, empty, gt, lt, checked, unchecked) or at the products in the cart
(source: "product", in / notIn product ids). A hidden field is not rendered, not
required, and its value is not sent; a field with active: false is never shown. Re-evaluate
on every change, in order. Don't re-implement this — use the SDK helpers:
import { customFields } from 'feeef'
const productIds = cart.items.map((i) => i.productId)
const shown = customFields.visibleCustomFields(fields, values, productIds) // sorted, visible only
const { cleaned, issues } = customFields.validateCustomFieldValues(fields, values, productIds)
// issues: [{ fieldId, message }] → show under the field; send `cleaned` as customFields
<input type="file" accept={customFields.customFieldAcceptAttribute(field)} />Uploads
image / file fields upload first, then send the returned URL:
// POST /v1/actions/uploadCustomFieldFile (multipart: file, storeId, fieldId, productId?)
const { url } = await ff.actions.uploadCustomFieldFile({
file,
storeId: store.id,
fieldId: field.id,
productId, // only when the field comes from a product override
})
values[field.id] = urlThe server checks the real file type (not the name), limits the allowed kinds to the
field's accept (image type = images only), and rate-limits uploads. Checkout only
accepts URLs from this endpoint for this store — never send data URLs or other hosts. Only
render http(s) URLs as links or images.
Errors
The server is the authority: it re-checks visibility, drops hidden values and rejects
unknown keys. Errors come back on 422 with field path customFields.<fieldId> — map them
under the matching field, the same way as the SDK's issues.
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
The native v1 and v2 forms and submitFeeefOrder (the scope binding custom forms should
use) share one order-submit guard. It runs on pending submits; drafts keep only the
in-flight lock:
| Check | Behavior |
|---|---|
| Honeypot | Render the hidden honeypot field (off-screen, not display:none) and pass its value. Bots that fill it get a normal-looking answer and no order is placed |
| Time on form | Capture formLoadTime on mount and pass it. Implausibly fast submits get a normal-looking answer. The storefront decides; don't hard-code your own rule |
| Repeat orders | A repeat of the same order from the same browser shortly after a success may be held back (a short message or a normal-looking answer) |
| In-flight lock | useRef boolean; ignore double submits. submitFeeefOrder also refuses an overlapping submit |
| Fingerprint | Optional FingerprintJS visitorId in metadata.deviceFingerprint |
| Pixels | Call FeeefPixels.fireConversion on every successful response: GA4, plus Meta / TikTok / Snap Purchase or Lead for each platform the server can't send (see pixels); don't re-send Purchase on pending if the draft already sent it |
Send an idempotency key with every pending submit (submitFeeefOrder does).
Idempotency and retries
- One key per checkout submission, not per HTTP attempt: a lowercase UUID in the
x-feeef-idempotency-keyrequest header. Keep it across retries of the same cart; make a new one only when the cart changes or after a success. Drafts carry no key. A malformed key is dropped, not forwarded. Coming soon: the feeef API deduping repeats of the same key into one order; send the key now so retries are covered when it ships. 503withcode: "ORDER_RETRY"(andRetry-After): the order was not sent. Retry with the same key.502withcode: "ORDER_STATUS_UNKNOWN": the order may already exist. Don't resubmit with a new key; tell the shopper and keep the key.- The storefront server adds the shopper's location and signs the call to the feeef API. Templates never set the country, IP or any signing header.
Templates submit through submitFeeefOrder (it posts to /api/orders/create) and must
never set the shopper's country or IP themselves. Visitor location comes from the edge
(Traefik geo), not from the template.
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 every pending success, decoys included and with no branch on the response: call
FeeefPixels.fireIdentify and FeeefPixels.fireConversion once (GA4, plus the per-platform
browser fallback described in pixels), clear the cart, then show a
success dialog or redirect with router.push(feeefThanksUrl(res, { customerName, total }))
to /thanks?order={id} (see standard pages). Never put the
shopper's name or phone in that URL. Order tracking lives at
https://api.feeef.org/track/{orderId}.
Pitfalls
| Bad | Good |
|---|---|
Raw fetch("/api/orders/create", ...), ff.orders.send, or a direct browser POST to the feeef API | submitFeeefOrder(body, { store, formLoadTime }), the supported path today, and only from the form. A direct POST is coming but not recommended until backend enforcement ships |
| Draft on every keypress | Exact digit length + a sending ref |
One item with variantPath: "A | B" | Expand into two items |
fbq('track', 'Purchase') / ttq.track('Purchase') / snaptr('track', 'PURCHASE') in a theme | FeeefPixels.fireConversion: GA4, plus the browser fallback where the server can't send |
Detecting bot answers from the order id, or skipping cart clear / redirect for some responses | Impossible by design; treat every success the same |
Name, phone or total in the /thanks URL | feeefThanksUrl(res, details) → /thanks?order=<id> |
| 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.