.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

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 === 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

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:

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 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 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

All of these run on pending submits (drafts skip bot checks but keep in-flight locks):

CheckBehavior
HoneypotHidden input name="website" (off-screen, not display:none). If filled → fake FuHe3nf… success, no API call
TimingSubmit within 3 seconds of mount → fake success
In-flight lockuseRef boolean; ignore double submits
Double-sendCooldown per shopper when the store's security integration is active
FingerprintOptional FingerprintJS visitorId in metadata.deviceFingerprint
Pixel suppressionNo 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:

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 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

BadGood
ff.orders.send(...) from the browserfetch("/api/orders/create", ...) — and only from the form
Draft on every keypressExact digit length + a sending ref
One item with variantPath: "A | B"Expand into two items
Firing Purchase for a FuHe3nf idSuppress pixels for fake orders
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