
import { Callout } from "zudoku/ui/Callout";

# 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](/sdk/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.

```text
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.

```ts
// 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:

```ts
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.

| Field | Notes |
| --- | --- |
| `customerName` | Required, 2–100 characters |
| `phone` | Algerian mobile; accepted in local or international form |
| `wilayaId` | Required, 1–58 |
| `communeId` | Required |
| `address` | Up to 300 characters |
| `deliveryType` | `home` or `stop_desk` |
| `notes` | Up to 500 characters |
| `email` | Optional unless the store's policy requires it |
| `couponCode` | Up to 64 characters |
| `otpToken` | Set after phone verification |
| `turnstileToken` | Set by the bot-protection widget |
| `checkoutKey` | Added by `bindCheckoutKey`; places the checkout's order once |
| `customFieldResponses` | JSON array for merchant-defined fields |
| `items` | JSON array for a basket order |
| `productId`, `productName`, `pricePerUnit`, `quantity`, `variantId`, `offerId` | The single-product form; `pricePerUnit` is required by the shape check |
| `variantSelections` | JSON array for per-unit variant choices |
| `landingPageSlug` | Attribution for a landing-page order |
| `fbc`, `fbp` | Meta click and browser cookies |

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

```ts
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:

```ts
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:

```ts
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:

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

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

```ts
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`.

```js
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:

```ts
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:

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

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

### What the machine owns

| State | What to render |
| --- | --- |
| `idle` | Nothing; waiting for a phone |
| `sending` | Disable the send button |
| `code_sent` | Code field, resend countdown, attempt count |
| `verifying` | Disable verify and submit |
| `verified` | Put `otpToken` in the hidden input |
| `bypassed` | Provider unavailable; put `bypassToken` in the same input |
| `error` | The 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.

```ts
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:

```ts
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:

```ts
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.

```ts
// 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.
