Wocart

Checkout and OTP

The order form is a normal HTML form. It posts to an Astro action, the action calls the server, and the browser lands on a thank-you page. The server prices the order; the browser only describes the choice.

The theme starter has all of it working: the product form and the basket checkout (src/components/checkout/OrderForm.astro, src/scripts/order-form.ts and its parts in src/scripts/checkout/), the action (src/actions/index.ts) and the thank-you redirect. This page explains every piece: the order, the basket, OTP, bot protection and conversions.

Code
form → src/actions/index.ts → executePlaceOrder() → cod-server ↓ /thank-you?order=…&total=…&oid=…

Keep the flow post/redirect/get. A direct render of the success page behind a POST makes a refresh re-attempt the order.

The action

Astro's defineAction is the thin framework wrapper; the SDK owns the input whitelist and the submission.

TypeScriptCode
// src/actions/index.ts import { ActionError, defineAction } from "astro:actions"; import { executePlaceOrder, placeOrderInput } from "@wocart/storefront/astro"; import { createStore } from "../lib/store"; export const server = { placeOrder: defineAction({ accept: "form", input: placeOrderInput, handler: async (input, context) => { const result = await executePlaceOrder( input, context.request, createStore(), ); if (!result.success) { if (result.errorKind === "shape") { throw new ActionError({ code: "BAD_REQUEST", message: result.error }); } if (result.couponRefusal) return { couponRefusal: result.couponRefusal }; throw new ActionError({ code: "UNPROCESSABLE_CONTENT", message: result.error, }); } return result.data; }, }), };

executePlaceOrder enforces one rule: an order is either a basket (items) or one product. Anything else is a shape error. It also forwards the shopper's User-Agent, IP and Referer so attribution is recorded for the visitor, not for your Worker.

result.data is the accepted order:

TypeScriptCode
type CreatedOrder = { orderId: string; orderNumber: string; price: number; deliveryFee: number; total: number; couponDiscount: number; // 0 without a coupon };

Those totals are authoritative only after the server accepts the order.

The form fields

placeOrderInput is a whitelist. A field it does not name is stripped before the action runs, so use its names exactly.

FieldNotes
customerNameRequired, 2–100 characters
phoneAlgerian mobile; accepted in local or international form
wilayaIdRequired, 1–58
communeIdRequired
addressUp to 300 characters
deliveryTypehome or stop_desk
notesUp to 500 characters
emailOptional unless the store's policy requires it
couponCodeUp to 64 characters
otpTokenSet after phone verification
turnstileTokenSet by the bot-protection widget
checkoutKeyAdded by bindCheckoutKey; places the checkout's order once
customFieldResponsesJSON array for merchant-defined fields
itemsJSON array for a basket order
productId, productName, pricePerUnit, quantity, variantId, offerIdThe single-product form; pricePerUnit is required by the shape check
variantSelectionsJSON array for per-unit variant choices
landingPageSlugAttribution for a landing-page order
fbc, fbpMeta click and browser cookies

For a basket, serialize the basket into one hidden input:

TypeScriptCode
const itemsInput = document.querySelector<HTMLInputElement>("#cart-items"); itemsInput.value = JSON.stringify(basket.toOrderItems());

For custom fields, one JSON field keeps the form independent of the action's schema:

TypeScriptCode
const answers = config.checkoutForm.customFields.map((field) => ({ id: field.id, value: formData.get(`custom:${field.id}`)?.toString() ?? "", })); customInput.value = JSON.stringify(answers);

pricePerUnit is display-only, but a single-product order must include it: executePlaceOrder rejects a form that has neither items nor a product with a price. The server re-prices the line anyway, so it is never the charge.

Show the server's refusal

The action can refuse for a field, a business rule, or a coupon.

  • Shape — neither a basket nor a product. BAD_REQUEST; this is a bug in the form, not a shopper error.
  • Validation — the server's domainError names the field and the rule. Render the error beside that field.
  • Coupon — couponRefusal is returned without throwing. The code box shows the reason and the shopper can change it without re-entering the order.
  • Business — an unavailable product, a closed store, an undeliverable destination. Keep the form filled and show the reason.

Do not replace these with a generic “something went wrong”. A shopper who chose a sold-out size needs to know which choice to change.

One checkout, one order

A shopper who double-clicks, refreshes the page their order was posted from, or retries after a dropped connection sends the same checkout again. Bind a key to the form once and the server places that checkout's order once:

TypeScriptCode
import { bindCheckoutKey } from "@wocart/storefront"; bindCheckoutKey(document.querySelector<HTMLFormElement>("form.order-form")!);

Every submission then carries checkoutKey. The same key again answers with the order already placed, exactly as the first time: no second order, no second stock deduction, no second notification. The key stays the same while the shopper changes nothing and is drawn anew once they change any field, so a different order is never answered with the first. It is drawn in the browser at the first submission, never rendered into the page, because a storefront page is cached for every shopper. For another phone the key fails with CHECKOUT_KEY_IN_USE.

Thank-you and tracking

After success, redirect to thankYouHref(order, { locale, routing }): /thank-you?order=<number>&total=<total>&oid=<orderId>, in the shopper's language, with nothing about the shopper in it. The thank-you page reads it back with readThankYou(Astro.url) ({ orderNumber, orderId, total }, each empty or null when missing), and an editor previews the page by building the same address with a sample order. In an editable theme the thank-you page is a Studio surface like any other page type, with the thank-you-main section in the starter.

The page asks the server which pixel and event this order belongs to:

TypeScriptCode
const tracking = await Astro.locals.store.client.orders.tracking(orderId);

tracking is null when the lookup fails. In that case fire nothing:

TypeScriptCode
if (!tracking?.pixelId || !tracking.event) return;

The event is already resolved. If the merchant's conversion fires later in the funnel, event is null; do not fall back to Purchase.

JavascriptCode
fbq( "trackSingle", pixelId, tracking.event, { value: total, currency: "DZD" }, { eventID: orderId }, );

Use trackSingle, not track: the sale belongs to one pixel, the one the server's Conversions API mirrors. eventID: orderId is what deduplicates the browser event against the server event. A different ID double-counts.

When tracking is unavailable, skip the browser event rather than guessing a pixel. The server-side event still exists.

WhatsApp OTP

OTP is optional per store (config.otpEnabled). When enabled, the checkout phone must be verified before the order is accepted.

The browser flow is a state machine from the SDK:

TypeScriptCode
import { createOtpMachine, createOtpTransport, mapOtpResponse, otpTransportFailure, STOREFRONT_OPERATIONS, type OtpSendResult, type OtpVerifyResult, } from "@wocart/storefront"; async function postOtp<T>(path: string, body: object, operation: string) { try { const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return await mapOtpResponse<T>(response, { operation, fallbackError: "" }); } catch (error) { return otpTransportFailure<T>(error, operation, ""); } } const transport = createOtpTransport({ send: (phone) => postOtp<OtpSendResult>("/api/otp/send", { phone }, STOREFRONT_OPERATIONS.ordersOtpSend), verify: (phone, requestId, code) => postOtp<OtpVerifyResult>( "/api/otp/verify", { phone, requestId, code }, STOREFRONT_OPERATIONS.ordersOtpVerify, ), }); const otp = createOtpMachine({ transport, initialPhone: phoneInput.value });

The same-origin routes are created by the SDK:

TypeScriptCode
// src/pages/api/otp/send.ts import { routes } from "../../../lib/api-routes"; export const POST = routes.otpSend;
TypeScriptCode
// src/pages/api/otp/verify.ts import { routes } from "../../../lib/api-routes"; export const POST = routes.otpVerify;

What the machine owns

StateWhat to render
idleNothing; waiting for a phone
sendingDisable the send button
code_sentCode field, resend countdown, attempt count
verifyingDisable verify and submit
verifiedPut otpToken in the hidden input
bypassedProvider unavailable; put bypassToken in the same input
errorThe reason; resend may be available

send() starts a 60-second cooldown (or the server's longer rate-limit window). verify() accepts six digits, counts attempts, and moves to a terminal state when the code expires. setPhone() resets the flow when the phone changes; the token must never outlive the number it was issued for.

TypeScriptCode
otp.subscribe((state) => { tokenInput.value = state.otpToken ?? state.bypassToken ?? ""; renderOtp(state); }); phoneInput.addEventListener("input", () => otp.setPhone(phoneInput.value));

When the phone verifies, submit the form once. The proof travels as otpToken, not as a second field.

bypassed is not an error to hide: the server failed open on purpose, and the order must still be attested. It carries a signed bypass token and the reason.

Without JavaScript

The form still posts. The server answers OTP_VERIFICATION_REQUIRED if the phone is not verified, and the page shows that error. The OTP step is an enhancement, not the only path.

Turnstile

When the store enables bot protection, the widget writes a token. The action expects it as turnstileToken; if the widget's field is named differently, copy it into that field before submit.

A missing or expired Turnstile token is a validation refusal. Keep the rest of the form; do not clear it.

Abandoned checkout

An optional tracker records a checkout the shopper left, once a name and a valid mobile are present. In a browser, createBrowserAbandonedCheckoutTracker wires it to the page — the checkout's id in sessionStorage, the timers, the Meta _fbc and _fbp cookies, a keepalive POST to your route, and sendBeacon when the shopper leaves:

TypeScriptCode
import { createBrowserAbandonedCheckoutTracker } from "@wocart/storefront"; const tracker = createBrowserAbandonedCheckoutTracker({ endpoint: "/api/abandoned" }); tracker.schedule(snapshot); tracker.update(snapshot); tracker.captureOnExit(snapshot);

schedule waits three seconds after typing stops before sending; update only sends again after the first record exists. A page that blocks storage still captures, and nothing the tracker does can fail the order. createAbandonedCheckoutTracker(ports) is the same tracker with every port passed in, for a host that is not a browser page.

The thank-you page marks the checkout converted, so the merchant no longer sees the order it became as abandoned:

TypeScriptCode
import { convertAbandonedCheckout } from "@wocart/storefront"; convertAbandonedCheckout({ endpoint: "/api/abandoned/convert", orderId, orderNumber });

It is fire-and-forget: it reads the id the tracker kept (ABANDONED_CHECKOUT_SESSION_KEY), uses it once, and never throws or holds the thank-you page.

Track an order

A shopper has no account: the order's number and the phone it was placed with prove an order is theirs. The platform's page for it is trackOrder (/track-order); a link with ?order=<number> — from the thank-you page, say — fills the number in.

lookupOrder makes that page a plain form posted to an Astro Action: no script, and the answer, rendered from the same POST, is never cached.

TypeScriptCode
// src/actions/index.ts import { defineAction } from "astro:actions"; import { lookupOrder, orderLookupInput } from "@wocart/storefront/astro"; export const server = { orderStatus: defineAction({ accept: "form", input: orderLookupInput, handler: async (input, context) => ({ asked: input, lookup: await lookupOrder(input, context.request, context.locals.store.client), }), }), };

lookup.status is found (with lookup.order), invalid-number (not an order number — isOrderNumber decides, and the server is not asked), invalid-phone, not-found (a wrong number or a wrong phone, never saying which), rate-limited (with retryAfterSeconds) or failed. Return what was asked beside it, so a refused form keeps what the shopper typed.

A found order carries its stage in the shopper's words, its lines, the total to pay, the delivery type and, once the carrier has the parcel, the tracking number. SHOPPER_ORDER_JOURNEY is the path from placed to delivered a progress bar shows; returned and cancelled end an order off it, and a stage a newer server adds reads your general words.

A checkout checklist

  • The form posts to placeOrder; the basket is one JSON items field.

  • pricePerUnit is present for display and never treated as the charge.

  • Custom fields are serialized by field id.

  • OTP is rendered only when config.otpEnabled is true.

  • The OTP token is in otpToken; a changed phone resets it.

  • Turnstile's token is in turnstileToken.

  • bindCheckoutKey is bound to the checkout form.

  • The page that rendered the form redirects to the thank-you page on success (Astro.getActionResult); the middleware never redirects.

  • The thank-you page fires only the pixel and event orders.tracking returns.

  • eventID is the order id.

  • The abandoned session is marked converted after success.

  • The thank-you page calls convertAbandonedCheckout.

  • The track-order page lives at storefrontPath("trackOrder") and words every lookupOrder outcome.