.dev
Storefront templates

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.

ApproachWhen
Registry product_order_formDefault. Fully merchant-configurable via propsSchema
Native blocks + custom chrome in slotsYou want custom layout but stock behavior
Fully custom slot-composed formThe UI must diverge completely — you now own the whole contract below

Built-in blocks

TypeRole
product_order_formThe full form
product_order_form_variantsVariant picker only
product_order_form_offersOffers selector only (requireOffer prop)
product_order_form_addonsAddons only
order_floating_buttonFloating 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 === requiredLength

Guard 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

StatusMeaning
200Order entity (id, totals, shipping) — success
422Field errors { errors: [{ field, message }] } — custom fields come back as customFields.<id>
403Security / ads-only rejection — show a friendly message
429Rate limited
503ORDER_RETRY: not sent, retry with the same idempotency key
502ORDER_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:

BlockMerchant props (minimum)
Offers selectorhide, requireOffer, heading, allowDeselect
Variant pickerhide, stock flags; respect variant.required
Custom fieldshide; per-field required from the field definition
Name / phone / email / geo / address / note / qty inputshide, required, label
Shipping type cardshide; render nothing until a state is selected
Buy actionshide, showQty, showCart, showTotal, labels
Successits 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.

When a variant option has an image (type: "image", an image value, or mediaIndex), text pills are not enough:

  1. Render a thumbnail for the option.
  2. On select, highlight it and scroll or swap the matching gallery image.
  3. 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:

  1. the store list, store.publicIntegrations.customFields.fields (null when inactive), unless
  2. a product in the cart has integrationsData.customFieldsData.useProductFields: true — then that product's fields replace the store list (several products → union by id).

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

typeInputValue sent
text, phone, email, textareatext input / textareastring (honor minLength, maxLength, regexPattern)
numbernumber inputnumber (min, max, integer)
selectselect / chips from optionsone options[].value (never the label)
checkboxcheckboxboolean — required means it must be true ("I agree")
image, filefile pickerthe url returned by the upload endpoint
date, time, datetimenative pickersYYYY-MM-DD, HH:mm, ISO-8601
daterange, timerange, datetimerangetwo 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] = url

The 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 AlgeriaMulti-country
StatesWilaya labels from shipping ratesfeeef.states.list({ countryCode }), filtered by shipping prices
CitiesCommune list per wilayafeeef.cities.list({ countryCode, stateCode }) on state change
shippingState sent1-based index string "1"…"58"State code
shippingCity sent1-based index string of the commune listCity 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 fields config) 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:

CheckBehavior
HoneypotRender 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 formCapture 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 ordersA 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 lockuseRef boolean; ignore double submits. submitFeeefOrder also refuses an overlapping submit
FingerprintOptional FingerprintJS visitorId in metadata.deviceFingerprint
PixelsCall 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-key request 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.
  • 503 with code: "ORDER_RETRY" (and Retry-After): the order was not sent. Retry with the same key.
  • 502 with code: "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:

PropTypeDefault
hidebooleanfalse
actionscroll | popupscroll
labelstringtranslation of "order now"
showTotalbooleantrue

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

BadGood
Raw fetch("/api/orders/create", ...), ff.orders.send, or a direct browser POST to the feeef APIsubmitFeeefOrder(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 keypressExact 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 themeFeeefPixels.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 responsesImpossible by design; treat every success the same
Name, phone or total in the /thanks URLfeeefThanksUrl(res, details) → /thanks?order=<id>
Free-text state/cityCascading selects with correct legacy indices / new codes
Re-bootstrapping cart on every cart changeBootstrap once per product, preserve the address
Monolithic funnel componentShell + slots, every field with hide/required
Float scrolling to the whole formScroll to #feeef-order-buy

For the underlying node contract see the data model; for scope hooks like useFeeefCart see custom components.

On this page