@wocart/storefront 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +144 -2
  5. package/USAGE.md +1470 -0
  6. package/dist/abandoned-checkout.d.ts +22 -0
  7. package/dist/abandoned-checkout.js +89 -0
  8. package/dist/actions.d.ts +117 -0
  9. package/dist/actions.js +196 -0
  10. package/dist/astro.d.ts +6 -0
  11. package/dist/astro.js +3 -0
  12. package/dist/basket.d.ts +87 -0
  13. package/dist/basket.js +258 -0
  14. package/dist/client/cart.d.ts +30 -0
  15. package/dist/client/cart.js +26 -0
  16. package/dist/client/categories.d.ts +25 -0
  17. package/dist/client/categories.js +37 -0
  18. package/dist/client/config.d.ts +29 -0
  19. package/dist/client/config.js +41 -0
  20. package/dist/client/core.d.ts +88 -0
  21. package/dist/client/core.js +566 -0
  22. package/dist/client/errors.d.ts +19 -0
  23. package/dist/client/errors.js +31 -0
  24. package/dist/client/geo.d.ts +31 -0
  25. package/dist/client/geo.js +39 -0
  26. package/dist/client/index.d.ts +40 -0
  27. package/dist/client/index.js +81 -0
  28. package/dist/client/orders.d.ts +68 -0
  29. package/dist/client/orders.js +76 -0
  30. package/dist/client/page-data.d.ts +30 -0
  31. package/dist/client/page-data.js +36 -0
  32. package/dist/client/pages.d.ts +33 -0
  33. package/dist/client/pages.js +44 -0
  34. package/dist/client/paths.d.ts +8 -0
  35. package/dist/client/paths.js +10 -0
  36. package/dist/client/products.d.ts +56 -0
  37. package/dist/client/products.js +78 -0
  38. package/dist/client/shipping.d.ts +36 -0
  39. package/dist/client/shipping.js +47 -0
  40. package/dist/data/communes.d.ts +25 -0
  41. package/dist/data/communes.js +1690 -0
  42. package/dist/data/wilayas.d.ts +6 -0
  43. package/dist/data/wilayas.js +64 -0
  44. package/dist/domain.d.ts +341 -0
  45. package/dist/domain.js +214 -0
  46. package/dist/endpoints/proxy.d.ts +111 -0
  47. package/dist/endpoints/proxy.js +161 -0
  48. package/dist/index.d.ts +42 -0
  49. package/dist/index.js +30 -0
  50. package/dist/locale-routing.d.ts +59 -0
  51. package/dist/locale-routing.js +62 -0
  52. package/dist/locales.d.ts +15 -0
  53. package/dist/locales.js +8 -0
  54. package/dist/meta-tracking.d.ts +34 -0
  55. package/dist/meta-tracking.js +72 -0
  56. package/dist/middleware.d.ts +100 -0
  57. package/dist/middleware.js +72 -0
  58. package/dist/offer-view.d.ts +10 -0
  59. package/dist/offer-view.js +13 -0
  60. package/dist/otp.d.ts +80 -0
  61. package/dist/otp.js +295 -0
  62. package/dist/phone.d.ts +2 -0
  63. package/dist/phone.js +34 -0
  64. package/dist/product-view.d.ts +12 -0
  65. package/dist/product-view.js +42 -0
  66. package/dist/routes.d.ts +45 -0
  67. package/dist/routes.js +59 -0
  68. package/dist/schemas.d.ts +753 -0
  69. package/dist/schemas.js +377 -0
  70. package/dist/server.d.ts +4 -0
  71. package/dist/server.js +2 -0
  72. package/dist/types.d.ts +418 -0
  73. package/dist/types.js +1 -0
  74. package/dist/utils/image.d.ts +140 -0
  75. package/dist/utils/image.js +131 -0
  76. package/dist/utils/money.d.ts +39 -0
  77. package/dist/utils/money.js +43 -0
  78. package/dist/utils/product-image.d.ts +37 -0
  79. package/dist/utils/product-image.js +38 -0
  80. package/dist/whatsapp.d.ts +55 -0
  81. package/dist/whatsapp.js +89 -0
  82. package/package.json +76 -4
package/USAGE.md ADDED
@@ -0,0 +1,1470 @@
1
+ # @wocart/storefront — the guide
2
+
3
+ ## What this guide is for
4
+
5
+ This guide is the working contract between a Wocart storefront theme and the
6
+ store's server.
7
+
8
+ It is written for:
9
+
10
+ - A first-time theme developer who wants to focus on layout, motion, copy,
11
+ photography, and conversion.
12
+ - A vibe coder using an AI agent to build or change a theme safely.
13
+ - An experienced developer who needs the exact data, failure, checkout, and
14
+ security boundaries.
15
+
16
+ The SDK is the stable seam between the creative theme layer and the Wocart
17
+ platform. Your theme should decide how the store feels and looks. The SDK and
18
+ the server decide what products, prices, delivery options, policies, and order
19
+ states are valid.
20
+
21
+ Read this document in order the first time. For a focused task, start with the
22
+ namespace table, then open the section for the namespace you need.
23
+
24
+ ## The mental model
25
+
26
+ There are three layers:
27
+
28
+ 1. **Theme UI** — Astro pages, components, CSS, client-side interactions,
29
+ content presentation, loading states, empty states, and error messages.
30
+ 2. **Storefront SDK** — typed client methods, form schemas, action helpers,
31
+ browser proxy helpers, money formatting, and image URL helpers.
32
+ 3. **The server** — the source of truth for catalog visibility, prices, stock,
33
+ offers, checkout policy, delivery rates, OTP, order creation, tracking, and
34
+ authorization.
35
+
36
+ A theme does not query the database. A theme does not reimplement pricing. A browser
37
+ bundle does not receive the store API key. A theme renders the result it gets
38
+ from the SDK and gives the shopper a useful next action when the result is
39
+ empty or unsuccessful.
40
+
41
+ The SDK is a theme boundary, not a second business-logic layer. If a feature
42
+ requires a new server rule, change the server contract first and then update
43
+ the SDK usage.
44
+
45
+ ## Canonical domain vocabulary
46
+
47
+ Use the SDK's names when describing storefront data in theme code, tickets, or
48
+ AI-agent prompts. These names prevent a UI concept from quietly becoming a
49
+ second business rule:
50
+
51
+ | SDK term | Meaning | Authority |
52
+ | --- | --- | --- |
53
+ | `ProductListItem` | Compact product projection for grids, cards, and search | server |
54
+ | `ProductDetail` | Complete product projection for a product page | server |
55
+ | `Basket` | Browser-owned shopper intent; it may be empty while persisted | browser, never the server |
56
+ | `BasketLine` | One product-plus-variant identity with an optimistic display price | browser intent |
57
+ | `BasketLineInput` | The wire projection of one basket line (`pricePerUnit` is display-only) — what `cart.validate()` and `/api/cart/validate` accept | SDK |
58
+ | `CheckoutDraft` | Mutable customer, delivery, basket, custom-field, attribution, and OTP input | theme/SDK until submitted |
59
+ | `DeliverySelection` | Wilaya, commune, delivery mode, and optional address | server policy decides validity |
60
+ | `OtpProof` | Verified proof or an explicit provider-unavailable bypass token | server |
61
+ | `Order` | Purchase accepted by the server with authoritative totals | server |
62
+ | `OrderTracking` | The pixel/event the thank-you page may fire (`event: null` → nothing fires) | server |
63
+ | `LandingPage` | A paid-traffic page (`/lp/:slug`) with its images and CTA | server |
64
+ | `AbandonedCheckoutInput` | The optional abandoned-checkout signal `orders.abandoned.upsert()` accepts | SDK schema, server bounds |
65
+ | `PlaceOrderInput` | The checkout form whitelist (`@wocart/storefront/astro`) — the body `executePlaceOrder()` submits | SDK |
66
+ | `TrackingDecision` | Server-selected pixel/event instruction after an accepted outcome | server |
67
+
68
+ Import browser-safe contracts from the package root:
69
+
70
+ ~~~ts
71
+ import {
72
+ BasketSchema,
73
+ type Basket,
74
+ type BasketLine,
75
+ type CheckoutDraft,
76
+ type DeliverySelection,
77
+ type OtpProof,
78
+ type ProductDetail,
79
+ type ProductListItem,
80
+ } from "@wocart/storefront";
81
+ ~~~
82
+
83
+ The canonical basket has at most 20 lines, at most 100 units per line, and no
84
+ duplicate product-plus-variant identities. An empty basket is valid storage
85
+ state; checkout is not. `BasketLine.unitPrice` is only a display hint. The
86
+ server re-prices it during both cart validation and order creation.
87
+
88
+ Do not make a theme responsible for required address fields, custom-field
89
+ allowlists, delivery availability, stock, discounts, final totals, OTP
90
+ acceptance, or tracking eligibility. Those rules are platform data or platform
91
+ decisions. The theme presents them and gives the shopper a clear next action.
92
+
93
+ ## The storefront route contract
94
+
95
+ A storefront's public paths are a platform contract, not a theme preference.
96
+ Three parts of the platform meet on them: the theme renders the pages, the server writes the
97
+ URL Meta is told about (`/lp/:slug` for a sale attributed to a landing page,
98
+ `/thank-you` otherwise), and the dashboard prints them as the store's public
99
+ links. These six patterns are frozen — restyle anything else, but change one of
100
+ these only as a platform change:
101
+
102
+ | Route | Renders |
103
+ | --- | --- |
104
+ | `/products/:handle` | product detail (public handle, never an internal id) |
105
+ | `/category/:slug` | a category listing |
106
+ | `/pages/:slug` | a legal or CMS page |
107
+ | `/lp/:slug` | a landing page |
108
+ | `/checkout` | the checkout |
109
+ | `/thank-you` | order confirmation |
110
+
111
+ The root (`/`) and the catalog index (`/products`) carry no parameter and are
112
+ the two further surfaces a theme implements.
113
+
114
+ The contract is exported, so a theme can read it instead of repeating it:
115
+ `STOREFRONT_ROUTES` is the table above, and `showsWidgetOn(surfaces, pathname)`
116
+ is the platform's half of "which surface is this page" — the merchant's
117
+ per-surface switches then mean the same thing as the dashboard's preview. The
118
+ gate never prefix-matches: `/productsomething`, `/checkout-success` and
119
+ `/thank-you-again` are no surface at all, never a widget on a page the merchant
120
+ switched off.
121
+
122
+ ## The five rules to remember
123
+
124
+ 1. **Create a client for one store scope.** Do not share a client between
125
+ unrelated stores or tenants.
126
+ 2. **Keep the API key on the server.** Browser code calls same-origin theme
127
+ routes created with the proxy helper.
128
+ 3. **Treat server data as authoritative.** The browser may suggest quantities
129
+ and display prices, but the server re-prices and validates before an order.
130
+ 4. **Handle null, empty arrays, and unsuccessful results deliberately.** They
131
+ are normal states, not exceptional UI bugs.
132
+ 5. **Use public handles and documented fields.** Do not guess endpoints, spread
133
+ raw server records into browser responses, or invent missing business rules.
134
+
135
+ ## Quick start
136
+
137
+ ### Create the client
138
+
139
+ The server-only package entry creates a client for one store scope:
140
+
141
+ ~~~ts
142
+ import { createStoreClient } from "@wocart/storefront/server";
143
+
144
+ // Use a server-only environment variable for the key.
145
+ const store = createStoreClient({
146
+ serverUrl: process.env.COD_SERVER_URL,
147
+ apiKey: process.env.STORE_API_KEY,
148
+ });
149
+ ~~~
150
+
151
+ The exact environment-variable mechanism depends on the theme runtime. The
152
+ important rule is that STORE_API_KEY must never be included in client-side
153
+ JavaScript, HTML, public Astro variables, or a browser request.
154
+
155
+ Create the client in a server loader, request handler, or server action. A
156
+ request-scoped client is the simplest safe default:
157
+
158
+ ~~~ts
159
+ const [config, featuredProducts] = await Promise.all([
160
+ store.config.get(),
161
+ store.products.list({ featured: true, limit: 8 }),
162
+ ]);
163
+ ~~~
164
+
165
+ Every call makes its own request; a call never waits on another call's
166
+ pending request, even an identical one. Within one render, ask once and share
167
+ the result (`Promise.all`, or a per-request memo) when several parts of a page
168
+ need the same data. A client never holds pending work across calls, so one
169
+ kept for several requests cannot leave a request waiting on another's read.
170
+
171
+ ### Available imports
172
+
173
+ The root package is browser-safe. It exports types, runtime schemas, money and
174
+ image utilities, and the static reference datasets (wilayas and the 1,551-row
175
+ commune bundle). It does not export the client factory or any function that
176
+ carries the store API key.
177
+
178
+ The two server-only entry points:
179
+
180
+ ~~~ts
181
+ @wocart/storefront/server
182
+ @wocart/storefront/astro
183
+ ~~~
184
+
185
+ Use `/server` only in server loaders, request handlers, or server actions. Use
186
+ `/astro` only in server-side Astro routes/actions/middleware. Browser bundles
187
+ import the root only, and they must never receive the API key or call the
188
+ server directly.
189
+
190
+ #### The names you will look up, and what they are for
191
+
192
+ | Export | Use it for |
193
+ | --- | --- |
194
+ | `formatMoney`, `optimizeImage`, `generateSrcSet` | Money text and image URLs |
195
+ | `createImageHelpers(mediaDomain)`, `getCardImage`, `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`, `getLogoImage` | The bound image toolkit (`IMAGE_SIZES` holds the presets) |
196
+ | `WILAYAS`, `ALGERIA_COMMUNES`, `communesForWilaya` | Reference data: 58 wilayas, 1,551 communes, one wilaya's rows |
197
+ | `normalizeAlgerianPhone`, `toLocalAlgerianMobile` | The one Algerian phone rule (accept/format) |
198
+ | `resolvePrefillText`, `buildWhatsAppHref`, `stripTrackingParams`, `truncateAtWordBoundary`, `PREFILL_TOKENS`, `MAX_RESOLVED_PREFILL_LENGTH` | WhatsApp prefill text and the `wa.me` link |
199
+ | `showsWidgetOn`, `STOREFRONT_ROUTES` | Which surface a pathname is (the frozen route contract) |
200
+ | `productImageSource` | Which photo represents a product (cover → gallery fallback) |
201
+ | `getProductView`, `getProductListView` | Pure price, discount, default-variant, and stock values for detail and list projections |
202
+ | `getOfferTierView` | Pure offer-tier price, compare-price, savings, and free-shipping projection; display-only, never order authority |
203
+ | `META_CURRENCY`, `buildMetaProductPayload`, `buildMetaViewContentPayload`, `buildMetaAddToCartPayload`, `buildMetaInitiateCheckoutPayload` | Browser-safe Meta event payloads with the platform currency and catalog shape |
204
+ | `createMetaPixelTracker` | Injected browser-pixel dispatch; the theme supplies the vendor global and keeps bootstrap/timing |
205
+ | `createBasketStore`, `createLocalStorageAdapter`, `createMemoryStorageAdapter`, `DEFAULT_BASKET_STORAGE_KEY` | The browser basket, its storage, and the key it persists under (`cod_cart_v1`) |
206
+ | `basketLineKey`, `toOrderItems`, `MAX_BASKET_LINES`, `MAX_BASKET_LINE_QUANTITY` | Basket identity, the wire projection, and the caps the server re-checks |
207
+ | `createOtpMachine` | The OTP step's state machine |
208
+ | `createOtpTransport`, `mapOtpResponse`, `otpTransportFailure` | Route-agnostic OTP result mapping; the theme supplies its same-origin route functions |
209
+ | `createAbandonedCheckoutTracker` | Port-injected session, capture eligibility, debounce, update, cookie, fetch, and exit-beacon policy |
210
+ | `STOREFRONT_OPERATIONS` | The exact operation labels used by namespace calls |
211
+ | `STOREFRONT_RETRY_POLICIES` | Which operations retry, by exact operation label |
212
+
213
+ ### Commerce projections
214
+
215
+ `getProductView`, `getProductListView`, and `getOfferTierView` are pure screen
216
+ projections. They make repeated storefront surfaces agree on what to display;
217
+ they do not authorize stock, discounts, offer eligibility, totals, or orders.
218
+ The server remains authoritative at cart validation and order creation.
219
+
220
+ Import these projections directly from `@wocart/storefront`. A theme should
221
+ not add a local product-view or offer-price module around them.
222
+
223
+ ## Configuration
224
+
225
+ ### The client's options
226
+
227
+ `createStoreClient` takes a `StoreScope` (`@wocart/storefront/server`):
228
+
229
+ | Field | Required | What it is |
230
+ | --- | --- | --- |
231
+ | `serverUrl` | yes | Your store's API origin. No default: a wrong origin must be a visible mistake, not a silent one. |
232
+ | `apiKey` | yes | The store's API key. Sent as `X-Store-API-Key` on every request. **Server-only.** |
233
+ | `locale` | no | The language to read merchant text in — see "Store languages". |
234
+ | `theme` | no | Your theme's key, to receive the merchant's edits to your theme's text — see "Store languages". |
235
+ | `onError` | no | Observability hook for degraded reads — see "When a read degrades: onError". |
236
+
237
+ `serverUrl` and `apiKey` are checked at construction: an empty or whitespace-only value throws a
238
+ `StorefrontError` with code `MISSING_CONFIGURATION` (category
239
+ `authentication`, operation `client.create`) naming the variable to set. That
240
+ is deliberate — an empty key would otherwise send unauthenticated requests and
241
+ turn every read into an empty store, which looks like "no products" instead of
242
+ "misconfigured". After construction, reads never throw.
243
+
244
+ ```ts
245
+ import { createStoreClient } from "@wocart/storefront/server";
246
+
247
+ const store = createStoreClient({
248
+ serverUrl: process.env.COD_SERVER_URL!,
249
+ apiKey: process.env.STORE_API_KEY!,
250
+ });
251
+ ```
252
+
253
+ ### Environment variables
254
+
255
+ The SDK reads **no** environment variables itself — you pass the values in. A
256
+ storefront deployed by Wocart receives these:
257
+
258
+ | Variable | Kind | Purpose |
259
+ | --- | --- | --- |
260
+ | `COD_SERVER_URL` | server variable | the store's API origin, e.g. `https://api.your-store.com` |
261
+ | `STORE_API_KEY` | server secret | the store API key |
262
+ | `MEDIA_DOMAIN` | server variable (optional) | media domain for `createImageHelpers(mediaDomain)`; unset → image URLs pass through unchanged |
263
+
264
+ Never put `STORE_API_KEY` in a browser-exposed variable (no `PUBLIC_*`, no
265
+ `import.meta.env` in client code, no HTML attribute). In Astro, declare it in
266
+ `astro.config.mjs` → `env.schema` with `context: "server"`, `access: "secret"`,
267
+ and read it with `astro:env/server`.
268
+
269
+ ### Where the API key comes from
270
+
271
+ Each store has its own API origin and store API key; the server stores only a
272
+ hash of the key. A storefront Wocart deploys receives both automatically. To
273
+ develop a theme against a store, ask the store's operator for its origin and a
274
+ key, and keep the key in a local, uncommitted secrets file. The SDK cannot mint
275
+ or rotate keys.
276
+
277
+ ### What may run where
278
+
279
+ | Surface | Import from | May hold the key? |
280
+ | --- | --- | --- |
281
+ | Server loaders, request handlers, actions, endpoints | `@wocart/storefront/server` | yes |
282
+ | Astro server routes/actions/middleware | `@wocart/storefront/astro` | yes |
283
+ | Browser scripts, islands, client components | `@wocart/storefront` (root) | **no** |
284
+
285
+ Browser code reaches the platform only through same-origin routes you own
286
+ (`src/pages/api/**`) that call the SDK server-side. The root entry is
287
+ browser-safe by construction: it exports no client factory and no function that
288
+ carries the key, which the package's own contract test asserts.
289
+
290
+ ### The namespaces
291
+
292
+ | Namespace | Use it for | Important result shapes |
293
+ | --- | --- | --- |
294
+ | config | Branding and store-wide checkout/display policy | StoreConfig or null |
295
+ | products | Product catalog and reviews | Product, ProductList, or empty/null |
296
+ | categories | Catalog navigation | Category array |
297
+ | pages | Legal and CMS pages, landing pages | StorePagePublic, LandingPage, or null |
298
+ | pageData | The store's saved page document | PageDocumentPayload or null |
299
+ | shipping | Delivery prices | ShippingRates or one rate/null |
300
+ | geo | Wilayas and communes | Static wilayas or Commune array |
301
+ | cart | Server validation of a browser basket | CartValidation result |
302
+ | orders | OTP, order creation, abandoned checkout, tracking | ClientResult or tracking/null |
303
+
304
+ The following sections explain each namespace and the UI decisions it
305
+ requires.
306
+
307
+ ## Failure and result contracts
308
+
309
+ ### Read methods
310
+
311
+ Read methods use safe empty results so a theme can still render its shell:
312
+
313
+ | Call | Empty or unavailable result |
314
+ | --- | --- |
315
+ | config.get() | null |
316
+ | products.get(handle) | null |
317
+ | products.list() | [] |
318
+ | products.reviews.list() | { rows: [], total: 0 } |
319
+ | categories.list() | [] |
320
+ | pages.get(slug) | null |
321
+ | pages.landing(slug) | null |
322
+ | pageData.get(surface) | null |
323
+ | shipping.rates() | {} |
324
+ | shipping.rate(wilayaId) | null |
325
+ | geo.communes(wilayaId) | [] |
326
+ | orders.tracking(orderId) | null |
327
+
328
+ These results do not mean the theme should crash. They mean the theme should
329
+ show a deliberate fallback: a default brand shell, an empty catalog state, a
330
+ not-found page, a delivery-rate fallback, or no conversion event.
331
+
332
+ ### When a read degrades: `onError`
333
+
334
+ An empty result is the right thing to *render*, but it is the wrong thing to
335
+ *observe*: "no products" and "the store's key is wrong" look identical on the
336
+ page. `onError` is the hook that tells them apart, and it is the only place a
337
+ degraded read is visible:
338
+
339
+ ```ts
340
+ const store = createStoreClient({
341
+ serverUrl: env.COD_SERVER_URL,
342
+ apiKey: env.STORE_API_KEY,
343
+ onError: (error) => {
344
+ // error: DomainError — same shape mutations return
345
+ logger.warn({ operation: error.operation, code: error.code, requestId: error.requestId });
346
+ },
347
+ });
348
+ ```
349
+
350
+ - **When it fires:** every read that could not complete — transport failure
351
+ (no connection, DNS, timeout), HTTP failure (401/403 misconfiguration, 5xx,
352
+ 429), a response the schema rejected, a malformed body, or an aborted
353
+ request. Never on success.
354
+ - **What it receives:** one `DomainError` with the same fields a mutation
355
+ failure carries — `operation` (`"products.list"`, `"geo.communes"`, …),
356
+ `code`, `category`, `retryable`, `sideEffect`, and `requestId` when the
357
+ server sent one. `requestId` is the safe line to log: it never contains
358
+ customer data.
359
+ - **Its exceptions:** a throwing `onError` cannot break anything. The hook is
360
+ wrapped, so a bug in your logger still resolves the read to `null`/`[]`/`{}`.
361
+ - **Where it runs:** server-side, next to the client. It is not serialized into
362
+ the page, and browser code has no client to attach it to.
363
+ - **What it is not:** a retry switch. It reports; it does not change SDK
364
+ behaviour (`retryable` tells you whether *you* may repeat the call — reads
365
+ already retry transport and 5xx failures internally).
366
+
367
+ `StorefrontError` is the thrown counterpart: an aborted or malformed **read**
368
+ throws it, and it always carries `domainError`. Inside a namespace read the SDK
369
+ catches it and reports through `onError`; `isStorefrontError(value)` recognises
370
+ it across a realm boundary (an Astro action, a worker), where `instanceof` fails.
371
+
372
+ ### Mutations
373
+
374
+ Mutations return ClientResult rather than making the theme parse arbitrary
375
+ HTTP responses:
376
+
377
+ ~~~ts
378
+ type ClientResult<T> =
379
+ | { success: true; data: T }
380
+ | {
381
+ success: false;
382
+ error: string;
383
+ code?: string;
384
+ domainError?: DomainError;
385
+ [key: string]: unknown;
386
+ };
387
+ ~~~
388
+
389
+ Every failure also carries `domainError`: the stable, serializable contract to
390
+ branch on instead of a status code or an error string.
391
+
392
+ ~~~ts
393
+ interface DomainError {
394
+ category:
395
+ | "validation"
396
+ | "authentication"
397
+ | "business"
398
+ | "system"
399
+ | "transport"
400
+ | "unknown";
401
+ code: string; // the server's code, or HTTP_<status>, NETWORK_ERROR,
402
+ // REQUEST_ABORTED, REQUEST_TIMEOUT,
403
+ // MALFORMED_RESPONSE, SCHEMA_VALIDATION_FAILED
404
+ message: string; // safe to show to the shopper
405
+ retryable: boolean; // whether this same call may be replayed
406
+ operation: string; // stable label, e.g. "orders.otp.send"
407
+ sideEffect: "not_started" | "rejected" | "committed" | "unknown";
408
+ fieldIssues?: { field: string; code: string; message: string }[];
409
+ retryAfterSeconds?: number;
410
+ requestId?: string; // the server log line this failure belongs to
411
+ }
412
+ ~~~
413
+
414
+ `category` is the server's own classification whenever the failure names one;
415
+ the SDK only falls back to the status code for gateway or proxy failures that
416
+ never reached the application. The server stays the authority on which context
417
+ is valid, so treat `fieldIssues`, `retryAfterSeconds`, and `requestId` as
418
+ server-authored data and read `requestId` only for logging or support.
419
+
420
+ Use the success branch before reading data:
421
+
422
+ ~~~ts
423
+ const result = await store.orders.create(orderBody);
424
+
425
+ if (!result.success) {
426
+ // Show result.error and use result.code or other context for field-level UI.
427
+ return;
428
+ }
429
+
430
+ // result.data is now CreatedOrder.
431
+ ~~~
432
+
433
+ Validation failures may include useful context such as field errors,
434
+ attemptsRemaining, windowSeconds, or a terminal OTP state. Preserve that
435
+ context when mapping an SDK result to UI.
436
+
437
+ Public namespace methods are designed to resolve to these documented results.
438
+ Do not turn a failed result into a successful-looking order, empty price, or
439
+ conversion event.
440
+
441
+ ### Caching and retries
442
+
443
+ - GET requests are fresh by default, and each call makes its own request.
444
+ - config.get(), categories.list(), and geo.communes() keep their finished
445
+ result for the client's lifetime; a call made while the first is still
446
+ pending makes its own request rather than waiting on it.
447
+ - Every read is bounded by `readTimeoutMs` (10 seconds by default), retries
448
+ included; a read that runs out of time degrades and reports
449
+ `REQUEST_TIMEOUT` to `onError`. Writes have no default limit.
450
+ - null results and failures are not cached, so a temporary failure can recover
451
+ on the next call.
452
+ - GET network failures use two short exponential backoff retries.
453
+ - POST and PATCH requests are not retried. This is intentional: retrying order
454
+ creation or another side effect can duplicate the shopper's action.
455
+
456
+ The cache belongs to a StoreClient instance. Do not put a multi-tenant client
457
+ in a process-global singleton unless the cache is explicitly isolated by
458
+ store.
459
+
460
+ ## Store configuration
461
+
462
+ Use config.get() near the root of every page. It is the theme's starting point
463
+ for branding and feature flags:
464
+
465
+ ~~~ts
466
+ const config = await store.config.get();
467
+
468
+ const cartEnabled = config?.cartEnabled === true;
469
+ const currencySymbol = config?.currencySymbol ?? "دج";
470
+ const announcement = config?.announcementBar ?? null;
471
+ ~~~
472
+
473
+ StoreConfig contains the current store identity and presentation fields,
474
+ including:
475
+
476
+ - name, domain, logoUrl, themeId, colors, fontFamily, and fontUrl;
477
+ - lang (the primary language), locales, contentOverrides, currency,
478
+ currencySymbol, metaTitle, metaDescription, and ogImage;
479
+ - announcementBar and reviewsEnabled;
480
+ - cartEnabled, freeShippingThreshold, and cartShippingMode;
481
+ - checkoutForm policy;
482
+ - OTP and Turnstile settings;
483
+ - pixelId and conversionEvent;
484
+ - public page links, legal contact information, and WhatsApp widget settings.
485
+
486
+ The checkout policy is not a suggestion. Use it to decide whether address,
487
+ email, notes, delivery options, and custom fields are required, optional, or
488
+ hidden. The policy includes:
489
+
490
+ - address: required, optional, or hidden;
491
+ - notes: optional or hidden;
492
+ - email: required, optional, or hidden;
493
+ - deliveryOptions.home and deliveryOptions.stopDesk;
494
+ - customFields with stable IDs, labels, types, required flags, and select
495
+ options.
496
+
497
+ When cartEnabled is false, do not render cart markup or basket controls. A
498
+ store can intentionally run a single-product checkout.
499
+
500
+ The config response also contains public integration settings such as the
501
+ tracking pixel choice and WhatsApp presentation. It must not be treated as a
502
+ place to expose the store API key. Render only documented public fields.
503
+
504
+ ### Store languages
505
+
506
+ A store has a primary language (`lang`) and publishes the languages in
507
+ `locales`, primary first; a single-language store is one entry. Each entry
508
+ carries what a theme needs to render it — `code` (BCP 47), `nativeName` and
509
+ `direction` — so a language the platform adds later reaches your theme without
510
+ an SDK release. `servedLanguage(config, code?)` finds one (the primary by
511
+ default), or `null` when the store does not serve it:
512
+
513
+ ~~~ts
514
+ import { servedLanguage } from "@wocart/storefront";
515
+
516
+ const dir = servedLanguage(config)?.direction ?? "ltr"; // "rtl" for Arabic
517
+ const switcher = config.locales.map((language) => language.nativeName);
518
+ const fromUrl = servedLanguage(config, segment)?.code ?? config.lang;
519
+ ~~~
520
+
521
+ `contentOverrides` holds the merchant's edits of **your theme's** text, one map
522
+ per published language. They belong to the theme that declares the keys, so
523
+ they arrive only when the client names the theme:
524
+
525
+ ~~~ts
526
+ const store = createStoreClient({ serverUrl, apiKey, theme: "my-theme" }); // defineTheme({ key })
527
+ ~~~
528
+
529
+ Merge `contentOverrides[locale]` over your own pack for that same language, and
530
+ never use another language's map: a store that switches language must not show
531
+ text left over from the old one.
532
+
533
+ To read the store's own data — products, categories, offers, legal pages, the
534
+ announcement bar — in another language, create the client with `locale`
535
+ (one client per language; each keeps its own caches):
536
+
537
+ ~~~ts
538
+ const store = createStoreClient({ serverUrl, apiKey, theme: "my-theme", locale: "fr" });
539
+ const config = await store.config.get(); // config.locale: "fr" if the store publishes French, else config.lang
540
+ ~~~
541
+
542
+ Untranslated fields fall back to the primary language one by one, so a page can
543
+ mix both; render merchant text with `dir="auto"` so an Arabic name inside a
544
+ French page still reads right-to-left. Landing pages are not translated.
545
+
546
+ ### Language URLs
547
+
548
+ The primary language keeps bare URLs; every other published language lives
549
+ under its code (`/fr/products/phone`), and the primary has one address
550
+ (`/ar/…` redirects). Read a request's language with `resolveLocalePath`, build
551
+ every internal link with `localizeHref`, and list a page's versions for
552
+ `<link rel="alternate" hreflang>` with `alternateLinks`:
553
+
554
+ ~~~ts
555
+ import { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "@wocart/storefront";
556
+
557
+ const routing = localeRouting(config); // from the primary config
558
+ const path = resolveLocalePath(url.pathname, routing);
559
+ if (path.kind === "redirect") return redirect(path.location, 301);
560
+ // path.locale: "fr", path.pathname: "/products/phone" — render that route
561
+ localizeHref("/checkout", path.locale, routing); // "/fr/checkout"
562
+ alternateLinks(path.pathname, routing, url.origin); // [{ hreflang: "ar", href }, …, x-default]
563
+ ~~~
564
+
565
+ `localizeHref` leaves external URLs, fragments, endpoints, files and already
566
+ prefixed links alone, so it is safe on any `href`. Never redirect a shopper by
567
+ browser language: offer the other languages as links and let them choose.
568
+ `products.list({ limit, offset })` pages through the whole catalog, for a
569
+ sitemap.
570
+
571
+ ## Products and catalog presentation
572
+
573
+ ### Product detail
574
+
575
+ Use a public product handle, not an internal product ID:
576
+
577
+ ~~~ts
578
+ const product = await store.products.get("linen-shirt");
579
+
580
+ if (!product) {
581
+ // Render the theme's product-not-found state or redirect to the catalog.
582
+ return;
583
+ }
584
+ ~~~
585
+
586
+ Product detail includes the product fields, parsed variant options and tags,
587
+ the category, variants, images, review statistics, and offers. The server
588
+ decides whether a product is publicly available.
589
+
590
+ ### Product lists
591
+
592
+ ~~~ts
593
+ const products = await store.products.list({
594
+ categoryId: selectedCategoryId,
595
+ featured: true,
596
+ limit: 12,
597
+ });
598
+ ~~~
599
+
600
+ Product lists return ProductList objects. ProductList and Product are
601
+ different shapes: a list item is not a detail object and must not be cast to
602
+ Product. ProductList carries list-oriented fields such as coverImage and
603
+ reviewStats, while variantOptions and tags remain their raw serialized form.
604
+
605
+ The limit is optional. An explicit limit of 0 is preserved, so do not replace
606
+ it with a truthiness fallback.
607
+
608
+ The server controls active, listed, visible, and stock-related catalog rules.
609
+ The theme owns the visual treatment of:
610
+
611
+ - product cards and comparison;
612
+ - badges, offer labels, and review summaries;
613
+ - image galleries and zoom;
614
+ - variant selection;
615
+ - loading, empty, and unavailable states.
616
+
617
+ ### Reviews
618
+
619
+ ~~~ts
620
+ const reviews = await store.products.reviews.list(productId, {
621
+ limit: 10,
622
+ offset: 0,
623
+ });
624
+ ~~~
625
+
626
+ The result is { rows: Review[], total: number }. The default page size is 20
627
+ and the default offset is 0. For submitting a review:
628
+
629
+ ~~~ts
630
+ const submitted = await store.products.reviews.submit({
631
+ orderNumber: customerVisibleOrderNumber,
632
+ productId,
633
+ rating: 5,
634
+ title: "Beautiful",
635
+ body: "Exactly as described.",
636
+ });
637
+ ~~~
638
+
639
+ orderNumber is the customer-visible order number, not the internal order UUID.
640
+
641
+ ## Categories, legal pages, and landing pages
642
+
643
+ ### Categories
644
+
645
+ categories.list() returns full category rows including name, slug,
646
+ description, parentId, imageUrl, SEO fields, position, and timestamps. The
647
+ result is cached after success.
648
+
649
+ Use category slugs and category IDs only where the SDK method requires them.
650
+ Do not recreate category hierarchy rules in the theme.
651
+
652
+ ### CMS and legal pages
653
+
654
+ ~~~ts
655
+ const page = await store.pages.get("privacy-policy");
656
+
657
+ if (page) {
658
+ // bodyHtml is sanitized by the server.
659
+ return page.bodyHtml;
660
+ }
661
+ ~~~
662
+
663
+ bodyHtml is server-sanitized rich HTML. In an Astro page, render it through
664
+ Astro's HTML rendering mechanism, for example:
665
+
666
+ ~~~astro
667
+ <article set:html={page.bodyHtml} />
668
+ ~~~
669
+
670
+ Do not hardcode the legal navigation list. Use config.pages so the store's
671
+ available public pages determine the links. If a page is null, render the
672
+ theme's not-found or fallback state.
673
+
674
+ ### Landing pages
675
+
676
+ ~~~ts
677
+ const landing = await store.pages.landing("summer-sale");
678
+ ~~~
679
+
680
+ A landing page can contain its own name, SEO metadata, image gap, images,
681
+ product, publishedAt, and tracking information. The product may be null.
682
+ Draft, archived, or unknown landing pages resolve to null.
683
+
684
+ Landing tracking is already resolved by the server:
685
+
686
+ ~~~ts
687
+ if (landing?.tracking.pixelId) {
688
+ // Fire only the configured event for the configured pixel.
689
+ }
690
+ ~~~
691
+
692
+ Do not invent tracking events from a landing-page slug.
693
+
694
+ ### The saved page document (theme composition)
695
+
696
+ ~~~ts
697
+ const saved = await store.pageData.get("home"); // null = never saved / failed
698
+ ~~~
699
+
700
+ One document per surface ("home" today), owned by the store and written by
701
+ the platform's page editor — a storefront never writes it. `null` means the store has never saved one (or the read
702
+ failed): render the theme's shipped default document. The payload is
703
+ **transport-checked only** (`version`, `surface`, `sections`) and fetched
704
+ fresh on every call, so a save reaches the next render.
705
+
706
+ What the sections *mean* is `@wocart/theme`'s job — validate and render with
707
+ the theme SDK:
708
+
709
+ ~~~ts
710
+ import { parsePageData } from "@wocart/theme";
711
+ import InstanceView from "@wocart/theme/astro/InstanceView.astro";
712
+ import { createReadCache } from "@wocart/theme/astro";
713
+
714
+ const { page } = parsePageData(saved ?? shippedDefault);
715
+ const reads = createReadCache(); // one per render — sections share reads
716
+
717
+ // {page?.sections.map((section) => (
718
+ // <InstanceView instance={section} theme={theme} context={ctx}
719
+ // storefront={store} reads={reads} />
720
+ // ))}
721
+ ~~~
722
+
723
+ An unparseable or unsupported-version document is the theme's decision to
724
+ degrade (its non-composed fallback), never an exception.
725
+
726
+ ## Shipping and geography
727
+
728
+ ### Delivery rates
729
+
730
+ ~~~ts
731
+ const rates = await store.shipping.rates();
732
+ const wilayaRate = await store.shipping.rate(String(wilayaId));
733
+ ~~~
734
+
735
+ ShippingRates is a map keyed by wilaya ID. Each value contains whole-DZD
736
+ home and stop-desk prices:
737
+
738
+ ~~~ts
739
+ type ShippingRate = {
740
+ home: number;
741
+ stopDesk: number;
742
+ };
743
+ ~~~
744
+
745
+ If the map is empty or one rate is null, show a useful unavailable state. Do
746
+ not silently display zero shipping.
747
+
748
+ ### Wilayas and communes
749
+
750
+ ~~~ts
751
+ const wilayas = await store.geo.wilayas(); // static 58-wilaya dataset, no network
752
+ const communes = await store.geo.communes(wilayaId); // server rows, cached
753
+
754
+ // zero-network rows for the checkout's locality field:
755
+ import { communesForWilaya } from "@wocart/storefront";
756
+ const rows = communesForWilaya(wilayaId); // Commune[] — [] if unknown
757
+ ~~~
758
+
759
+ Wilayas are local and do not use the network. `geo.communes()` reads the
760
+ server's reference rows and caches them after success; the wilaya argument may
761
+ be a number or a numeric string, and a failure is an empty list, never a throw.
762
+
763
+ The commune dataset is also bundled (1,551 rows across all 58 wilayas,
764
+ `ALGERIA_COMMUNES` plus `communesForWilaya`). Use it when the locality field
765
+ must fill with no round-trip to fail on — the bundled rows are the same rows
766
+ the server returns, checked against each other before every release (count,
767
+ id, wilaya, French name, Arabic name). One caveat carried by the data: `c-01-024` (Talmine)
768
+ has no Arabic spelling, so an RTL label is `nameAr || name`.
769
+
770
+ ## Cart and checkout: the money path
771
+
772
+ ### The basket lives in the browser
773
+
774
+ The shopper's basket is stored by the theme in browser storage or another
775
+ client-side mechanism. It is never stored on the server.
776
+
777
+ The browser's pricePerUnit is display-only. Never trust a browser subtotal,
778
+ delivery fee, total, stock value, or offer. Before checkout, send the selected
779
+ lines to cart.validate:
780
+
781
+ ~~~ts
782
+ const validation = await store.cart.validate(items);
783
+
784
+ if (!validation.success) {
785
+ // The request itself failed. Keep the basket and show validation.error.
786
+ return;
787
+ }
788
+
789
+ const { lines, subtotal, rewards, freeDelivery } = validation.data;
790
+ ~~~
791
+
792
+ The server re-prices catalog lines, merges duplicate lines, checks stock and
793
+ offers, and calculates free-delivery eligibility. CartValidation contains:
794
+
795
+ - lines with productId, variantId, variantLabel, productName, quantity,
796
+ unitPrice, lineTotal, maxQuantity, and blocker;
797
+ - subtotal and rewards;
798
+ - freeDelivery with fromOffer, threshold, qualified, and remaining.
799
+
800
+ Line blockers include missing, unlisted, and out_of_stock. The creative
801
+ experience is yours: inline warnings, a highlighted invalid line, a drawer,
802
+ or a guided correction step. The server result is not optional.
803
+
804
+ ### Using the SDK's BasketStore
805
+
806
+ The SDK provides an out-of-the-box, deep `createBasketStore` so themes do not
807
+ have to implement duplicate merging, storage validation, private-mode
808
+ fallbacks, or cross-tab synchronization manually:
809
+
810
+ ~~~ts
811
+ import { createBasketStore } from "@wocart/storefront";
812
+
813
+ const basket = createBasketStore();
814
+
815
+ // Add or merge a line
816
+ basket.addLine({
817
+ productId: "p1",
818
+ productName: "T-Shirt",
819
+ variantId: "red-m",
820
+ variantLabel: "Red / M",
821
+ quantity: 1,
822
+ unitPrice: 2500,
823
+ });
824
+
825
+ // Reactive subscription (updates on local and cross-tab storage events)
826
+ const unsubscribe = basket.subscribe((lines) => {
827
+ renderCartBadge(basket.getCount());
828
+ renderSubtotal(basket.getSubtotal());
829
+ });
830
+
831
+ // Ready-to-validate server payload
832
+ const items = basket.toOrderItems();
833
+ const validation = await store.cart.validate(items);
834
+ ~~~
835
+
836
+ The basket persists under `DEFAULT_BASKET_STORAGE_KEY` and holds at most
837
+ `MAX_BASKET_LINES` lines of at most `MAX_BASKET_LINE_QUANTITY` units each —
838
+ the same caps the server re-checks.
839
+
840
+ ### Single-product order versus basket order
841
+
842
+ An order is either:
843
+
844
+ - a basket order with a non-empty items array; or
845
+ - a single-product order with productId, productName, pricePerUnit, and
846
+ quantity.
847
+
848
+ The basket shape supersedes the flat product fields. Do not send both shapes
849
+ as competing sources of truth. The server re-prices all lines again during
850
+ order creation.
851
+
852
+ ### Create an order
853
+
854
+ ~~~ts
855
+ const placed = await store.orders.create({
856
+ customerName,
857
+ phone,
858
+ wilayaId,
859
+ communeId,
860
+ address,
861
+ deliveryType,
862
+ productId,
863
+ productName,
864
+ quantity,
865
+ pricePerUnit,
866
+ otpToken,
867
+ });
868
+
869
+ if (!placed.success) {
870
+ // Keep the checkout state and map placed.error/context to the UI.
871
+ return;
872
+ }
873
+
874
+ const order = placed.data;
875
+ // order.orderId, order.orderNumber, order.price, order.deliveryFee, order.total
876
+ ~~~
877
+
878
+ For a basket, send a non-empty items array with the selected product and
879
+ variant details. The displayed price can be included for the request shape,
880
+ but the server is authoritative and recalculates the actual amount.
881
+
882
+ CreatedOrder contains the order ID, customer-facing order number, product
883
+ price, delivery fee, and total, all in whole DZD.
884
+
885
+ ### OTP
886
+
887
+ Use OTP only when config.otpEnabled says the store requires or offers it.
888
+
889
+ ~~~ts
890
+ const sent = await store.orders.otp.send(phone);
891
+
892
+ if (sent.success && sent.data.status === "sent") {
893
+ const verified = await store.orders.otp.verify(
894
+ phone,
895
+ sent.data.requestId,
896
+ code,
897
+ );
898
+
899
+ if (verified.success && verified.data.status === "verified") {
900
+ // Send verified.data.otpToken with the order.
901
+ }
902
+ }
903
+ ~~~
904
+
905
+ send can return:
906
+
907
+ - sent with requestId, expiresAt, and maxAttempts;
908
+ - unavailable with reason and bypassToken.
909
+
910
+ If OTP is unavailable, the checkout may continue through the documented
911
+ fail-open path by sending bypassToken as otpToken. Do not treat unavailable as
912
+ verified, and do not make the shopper wait for a code that was never sent.
913
+
914
+ verify returns a verified status with otpToken on success. Failure context can
915
+ include attemptsRemaining and a terminal state. Use it for the countdown,
916
+ retry, and lockout UI.
917
+
918
+ ### Using the SDK's OtpMachine
919
+
920
+ The SDK exports `createOtpMachine` to manage the complete interactive WhatsApp
921
+ verification lifecycle without managing timers and attempts manually. It talks
922
+ to your own same-origin OTP routes through a transport; `mapOtpResponse` turns
923
+ each route's response into the SDK's typed result, and `otpTransportFailure`
924
+ covers a request that never completed:
925
+
926
+ ~~~ts
927
+ import {
928
+ OtpSendResultSchema,
929
+ OtpVerifyResultSchema,
930
+ STOREFRONT_OPERATIONS,
931
+ createOtpMachine,
932
+ createOtpTransport,
933
+ mapOtpResponse,
934
+ otpTransportFailure,
935
+ } from "@wocart/storefront";
936
+
937
+ async function post(path, body, schema, operation) {
938
+ try {
939
+ const response = await fetch(path, {
940
+ method: "POST",
941
+ headers: { "Content-Type": "application/json" },
942
+ body: JSON.stringify(body),
943
+ });
944
+ return await mapOtpResponse(response, { schema, operation, fallbackError: "Please try again" });
945
+ } catch (error) {
946
+ return otpTransportFailure(error, operation, "Please try again");
947
+ }
948
+ }
949
+
950
+ const otp = createOtpMachine({
951
+ transport: createOtpTransport({
952
+ send: (phone) =>
953
+ post("/api/otp/send", { phone }, OtpSendResultSchema, STOREFRONT_OPERATIONS.ordersOtpSend),
954
+ verify: (phone, requestId, code) =>
955
+ post("/api/otp/verify", { phone, requestId, code }, OtpVerifyResultSchema, STOREFRONT_OPERATIONS.ordersOtpVerify),
956
+ }),
957
+ initialPhone: "0551234567",
958
+ });
959
+
960
+ otp.subscribe((state) => {
961
+ // state.status: "idle" | "sending" | "code_sent" | "verifying" | "verified" | "bypassed" | "error"
962
+ // state.cooldownRemaining: seconds until resend allowed
963
+ // state.attemptsRemaining: attempts left before terminal lockout
964
+ // state.otpToken: ready to place order
965
+ });
966
+
967
+ await otp.send();
968
+ await otp.verify("123456");
969
+ ~~~
970
+
971
+ ### Order tracking and conversion events
972
+
973
+ ~~~ts
974
+ const tracking = await store.orders.tracking(order.orderId);
975
+
976
+ if (tracking) {
977
+ // tracking.pixelId and tracking.event are the server-selected values.
978
+ }
979
+ ~~~
980
+
981
+ tracking returns pixelId and an event of Purchase or Lead, or null. If it is
982
+ null, fire no conversion event. Do not infer an event from a successful HTTP
983
+ response alone.
984
+
985
+ ### Meta browser primitives
986
+
987
+ The payload builders are pure and browser-safe. Use the server-validated value
988
+ for `buildMetaInitiateCheckoutPayload`; the add-to-cart builder derives the
989
+ value from the lines that were added. `createMetaPixelTracker` takes an
990
+ injected `fbq`-compatible function and stays silent when it is unavailable.
991
+
992
+ The theme owns pixel bootstrap, DOM collection, and first-interaction timing.
993
+ Pixel-specific events use `trackSingle`; basket events retain their existing
994
+ `track` dispatch. The thank-you page uses the server-selected `pixelId` and
995
+ event, and passes the order id as `eventID` to the browser event. The server
996
+ uses that same order id for the mirrored Lead event; do not generate a second
997
+ browser id or infer a conversion when `orders.tracking()` returns `event: null`.
998
+
999
+ ### Abandoned checkout
1000
+
1001
+ The SDK also exposes abandoned-order methods:
1002
+
1003
+ - abandoned.upsert(body, forwardedHeaders?) saves the current checkout state;
1004
+ - abandoned.convert(sessionId, orderId, orderNumber) marks it converted.
1005
+
1006
+ Use these only when the theme has a real abandoned-checkout flow. Keep the
1007
+ normal mutation result handling and never block order creation on optional
1008
+ analytics.
1009
+
1010
+ ## Astro actions and form handling
1011
+
1012
+ The SDK cannot import Astro virtual modules, so a theme owns the thin Astro
1013
+ wrapper while the SDK owns reusable validation and execution logic.
1014
+
1015
+ ### Validate and execute place-order forms
1016
+
1017
+ placeOrderInput is the shared form schema. It:
1018
+
1019
+ - whitelists supported fields and strips unknown fields;
1020
+ - coerces form number strings;
1021
+ - turns empty optional values into undefined;
1022
+ - canonicalizes phone input;
1023
+ - accepts items, variantSelections, and customFieldResponses as JSON strings
1024
+ or arrays;
1025
+ - preserves item offerId values.
1026
+
1027
+ Use executePlaceOrder to keep form behavior consistent:
1028
+
1029
+ ~~~ts
1030
+ import { ActionError, defineAction } from "astro:actions";
1031
+ import {
1032
+ executePlaceOrder,
1033
+ placeOrderInput,
1034
+ } from "@wocart/storefront/astro";
1035
+
1036
+ export const server = {
1037
+ placeOrder: defineAction({
1038
+ accept: "form",
1039
+ input: placeOrderInput,
1040
+ handler: async (input, context) => {
1041
+ const result = await executePlaceOrder(
1042
+ input,
1043
+ context.request,
1044
+ store,
1045
+ );
1046
+
1047
+ if (!result.success) {
1048
+ throw new ActionError({
1049
+ code: "BAD_REQUEST",
1050
+ message: result.error,
1051
+ });
1052
+ }
1053
+
1054
+ return result.data;
1055
+ },
1056
+ }),
1057
+ };
1058
+ ~~~
1059
+
1060
+ executePlaceOrder distinguishes:
1061
+
1062
+ - shape failures, when there is neither a non-empty basket nor a complete flat
1063
+ product order;
1064
+ - upstream failures, when the server rejects or cannot complete the request.
1065
+
1066
+ The action should preserve the distinction when the UI needs different
1067
+ messages.
1068
+
1069
+ collectForwardedHeaders(request) extracts attribution headers for an upstream
1070
+ order request:
1071
+
1072
+ - User-Agent;
1073
+ - client IP from CF-Connecting-IP, then the first X-Forwarded-For value;
1074
+ - Referer.
1075
+
1076
+ Use it when the server route is the trusted boundary that forwards the order.
1077
+ Do not let arbitrary browser code choose identity headers.
1078
+
1079
+ ### Redirect behavior
1080
+
1081
+ The middleware helper resolveActionRedirectDecision handles the form POST
1082
+ flow:
1083
+
1084
+ - form actions execute and serialize their result;
1085
+ - form errors redirect to the referer, or the current pathname, with status
1086
+ 303;
1087
+ - successful form actions return next so the result cookie survives;
1088
+ - non-form and JSON requests continue without a redirect.
1089
+
1090
+ The common mistake is redirecting successful actions too early. Let success
1091
+ continue through next, then redirect from the successful page/action flow to
1092
+ the thank-you experience.
1093
+
1094
+ ## Browser-facing proxy routes
1095
+
1096
+ Browser code should call a same-origin theme route. That route uses
1097
+ createProxyEndpoint to call the SDK with the server-only API key.
1098
+
1099
+ This keeps the key out of the browser and centralizes CSRF checks, input
1100
+ validation, parameter parsing, attribution forwarding, and error shape.
1101
+
1102
+ ### Cart validation route
1103
+
1104
+ ~~~ts
1105
+ import { z } from "zod";
1106
+ import { createProxyEndpoint } from "@wocart/storefront/astro";
1107
+
1108
+ import { BasketLineInputSchema, MAX_BASKET_LINES } from "@wocart/storefront";
1109
+
1110
+ const cartInputSchema = z.object({
1111
+ items: z.array(BasketLineInputSchema).min(1).max(MAX_BASKET_LINES),
1112
+ });
1113
+
1114
+ export const POST = createProxyEndpoint({
1115
+ method: "POST",
1116
+ client: (input) => store.cart.validate(input.items),
1117
+ inputSchema: cartInputSchema,
1118
+ successBody: (data) => ({ success: true, data }),
1119
+ });
1120
+ ~~~
1121
+
1122
+ The browser can now call the theme's own route. It never calls the server
1123
+ directly and never needs the API key.
1124
+
1125
+ ### Order route with attribution
1126
+
1127
+ ~~~ts
1128
+ export const POST = createProxyEndpoint({
1129
+ method: "POST",
1130
+ client: (input, headers) => store.orders.create(input, headers),
1131
+ forwardAttribution: true,
1132
+ successStatus: 201,
1133
+ errorStatus: 422,
1134
+ });
1135
+ ~~~
1136
+
1137
+ ### GET route with a numeric path parameter
1138
+
1139
+ ~~~ts
1140
+ export const GET = createProxyEndpoint({
1141
+ method: "GET",
1142
+ client: (input) => store.geo.communes(input.wilayaId),
1143
+ pathParams: { wilayaId: "number" },
1144
+ });
1145
+ ~~~
1146
+
1147
+ Proxy configuration supports:
1148
+
1149
+ - GET and POST methods;
1150
+ - a client function receiving parsed input and optional forwarded headers;
1151
+ - inputSchema for body validation;
1152
+ - queryParams and pathParams allowlists;
1153
+ - numeric or string path parameter parsing;
1154
+ - CSRF protection, enabled by default for POST and disabled by default for GET;
1155
+ - attribution forwarding;
1156
+ - custom success and error status codes;
1157
+ - custom successBody;
1158
+ - noContent responses.
1159
+
1160
+ Use same-origin proxy routes for browser operations. Do not solve a proxy
1161
+ problem by moving the API key into public environment variables.
1162
+
1163
+ ## Money, images, and presentation utilities
1164
+
1165
+ ### Money
1166
+
1167
+ ~~~ts
1168
+ import { formatMoney } from "@wocart/storefront";
1169
+
1170
+ const priceLabel = formatMoney(45000, {
1171
+ symbol: "دج",
1172
+ locale: "ar-DZ",
1173
+ });
1174
+ ~~~
1175
+
1176
+ Amounts are whole DZD values. formatMoney supports a symbol, locale, and
1177
+ optional decimal count. Arabic locales place the symbol as a suffix; other
1178
+ locales use the appropriate prefix behavior. Use this utility consistently so
1179
+ price presentation does not drift between cards, product pages, and checkout.
1180
+
1181
+ ### Images
1182
+
1183
+ ~~~ts
1184
+ import {
1185
+ generateSrcSet,
1186
+ getCardImage,
1187
+ optimizeImage,
1188
+ } from "@wocart/storefront";
1189
+
1190
+ const mediaDomain = env.MEDIA_DOMAIN;
1191
+ const originalImage = product.images[0]?.src ?? "";
1192
+ const cardImage = getCardImage(originalImage, mediaDomain);
1193
+ const src = optimizeImage(originalImage, {
1194
+ width: 480,
1195
+ height: 480,
1196
+ quality: 82,
1197
+ format: "auto",
1198
+ fit: "cover",
1199
+ }, mediaDomain);
1200
+ const srcSet = generateSrcSet(originalImage, 480, 480, mediaDomain);
1201
+ ~~~
1202
+
1203
+ The image utility accepts the original media URL and can target a media
1204
+ domain. Use it to produce appropriately sized URLs rather than downloading
1205
+ original assets into every card.
1206
+
1207
+ ### Which photo a product is
1208
+
1209
+ `coverImage` is nullable and a merchant who uploads a gallery may never pick
1210
+ one — reading that field alone renders a product with four photos and no
1211
+ thumbnail. `productImageSource` answers which image represents the product, so
1212
+ a cart drawer, a checkout summary and a product card cannot disagree:
1213
+
1214
+ ~~~ts
1215
+ import { getThumbnailImage, productImageSource } from "@wocart/storefront";
1216
+
1217
+ const src = productImageSource(product); // Product | ProductList
1218
+ const thumbnail = src ? getThumbnailImage(src) : null;
1219
+ ~~~
1220
+
1221
+ It prefers the merchant's cover and falls back to the first usable gallery
1222
+ image; `null` means the product genuinely has no photograph, so that is the
1223
+ moment to render a placeholder. The list model carries no gallery at all, so a
1224
+ card gets the cover or `null`.
1225
+
1226
+ Available semantic helpers include:
1227
+
1228
+ - getCardImage;
1229
+ - getGalleryImage;
1230
+ - getThumbnailImage;
1231
+ - getHeroImage;
1232
+ - getCategoryImage;
1233
+ - getLogoImage.
1234
+
1235
+ The helper chooses the appropriate image role. The theme still owns layout,
1236
+ aspect ratio, alt text, lazy loading, placeholders, and art direction.
1237
+
1238
+ #### Bind the media domain once
1239
+
1240
+ Do not thread the media domain through every call site — a missed argument
1241
+ silently ships an unoptimized image. Bind it once and use the URL-only helper
1242
+ set:
1243
+
1244
+ ~~~ts
1245
+ import { createImageHelpers } from "@wocart/storefront";
1246
+
1247
+ const images = createImageHelpers(env.MEDIA_DOMAIN);
1248
+
1249
+ const thumbnail = images.getThumbnailImage(product.images[0].src);
1250
+ const preview = images.getCardImage(product.images[0].src);
1251
+ const srcSet = images.generateSrcSet(product.images[0].src, 480, 480);
1252
+ ~~~
1253
+
1254
+ The returned toolkit exposes the same helpers plus `optimizeImage`,
1255
+ `generateSrcSet`, and `IMAGE_SIZES`. With no domain bound, every helper returns
1256
+ the original URL unchanged, so a store without a media domain needs no branch —
1257
+ and `optimizeImage` / `generateSrcSet` still take a per-call domain that
1258
+ overrides the bound one.
1259
+
1260
+ ## WhatsApp prefill messages
1261
+
1262
+ The storefront's WhatsApp launcher hands the shopper to WhatsApp with a message
1263
+ already written, and the thank-you page offers the same handoff for an order.
1264
+ The template belongs to the merchant — the dashboard authors and previews it
1265
+ within the platform's limits — while the page resolves it:
1266
+
1267
+ ~~~ts
1268
+ import {
1269
+ buildWhatsAppHref,
1270
+ resolvePrefillText,
1271
+ } from "@wocart/storefront";
1272
+
1273
+ const text = resolvePrefillText({
1274
+ template: config.whatsapp.prefillProduct ?? "Hello {store}, about {product}: {url}",
1275
+ storeName: config.name,
1276
+ productName: product.name, // falls back to the store name when absent
1277
+ url: Astro.url.href, // ad-click parameters (`utm_*`, `fbclid`) stripped
1278
+ orderNumber: null, // resolves to nothing when absent
1279
+ });
1280
+
1281
+ const href = buildWhatsAppHref(config.whatsapp.href, text);
1282
+ ~~~
1283
+
1284
+ Three rules come with it, and a theme must not re-implement them:
1285
+
1286
+ - the resolved message is capped at `MAX_RESOLVED_PREFILL_LENGTH` characters and
1287
+ cut at a word boundary, because browsers and WhatsApp both truncate silently
1288
+ past a point;
1289
+ - `{url}` never carries ad-click parameters;
1290
+ - the encoded `text=` is appended to the base href, preserving a query string
1291
+ the href already carries.
1292
+
1293
+ `PREFILL_TOKENS` is the token vocabulary the dashboard offers (`product`, `url`,
1294
+ `order`, `store`); an unknown placeholder is left verbatim so a template typo is
1295
+ visible rather than silently swallowed. The cap and the token list are the
1296
+ same ones the dashboard enforces.
1297
+
1298
+ ## A recommended theme structure
1299
+
1300
+ Keep the theme visually expressive while making the platform boundary obvious:
1301
+
1302
+ ~~~text
1303
+ src/
1304
+ lib/
1305
+ store.ts server-only client factory
1306
+ loaders/ page data loaders
1307
+ components/ cards, gallery, cart, checkout, navigation
1308
+ content/ copy and visual configuration owned by the theme
1309
+ layouts/
1310
+ pages/
1311
+ api/ same-origin proxy endpoints
1312
+ actions/
1313
+ styles/
1314
+ ~~~
1315
+
1316
+ The exact structure can change. The responsibilities should not:
1317
+
1318
+ - store.ts owns client construction and environment access;
1319
+ - loaders compose SDK calls into page data;
1320
+ - components render data and states without calling the server directly;
1321
+ - actions validate and execute mutations;
1322
+ - API routes are the browser-to-server seam;
1323
+ - CSS and content remain theme-owned and swappable.
1324
+
1325
+ Keep loaders thin. They should compose platform data, not calculate new
1326
+ prices, decide catalog visibility, or duplicate server policy.
1327
+
1328
+ ## A safe workflow for AI agents
1329
+
1330
+ When an AI agent changes a theme, give it this sequence:
1331
+
1332
+ 1. Identify the user-visible task: page, section, component, checkout step,
1333
+ or server route.
1334
+ 2. Find the relevant SDK namespace in this document.
1335
+ 3. Read the exported type and implementation before guessing a field or
1336
+ endpoint.
1337
+ 4. If behavior is unclear, read the method's type and doc comment, then the
1338
+ result it actually returns: the server is the source of truth.
1339
+ 5. Keep the API key and all server-only environment values out of browser
1340
+ code.
1341
+ 6. Reuse the SDK's result shape, validation, caching, and retry behavior.
1342
+ 7. Add the UI states for loading, empty, unavailable, validation failure, and
1343
+ success.
1344
+ 8. For checkout changes, test both direct-product and basket orders, including
1345
+ re-pricing and invalid-stock cases.
1346
+ 9. Run typecheck and the relevant tests.
1347
+ 10. Report any contract ambiguity instead of inventing a fallback that could
1348
+ change money, stock, authorization, or tracking behavior.
1349
+
1350
+ An AI agent should prefer an existing namespace method over a new fetch call.
1351
+ It should prefer a same-origin proxy route over exposing a secret. It should
1352
+ prefer a small loader or component change over moving business logic into the
1353
+ theme.
1354
+
1355
+ ## Common mistakes and their fixes
1356
+
1357
+ ### The page has no config
1358
+
1359
+ config.get() returned null. Render safe theme defaults and keep the page
1360
+ usable, but investigate the server URL, API key, store scope, and server
1361
+ availability.
1362
+
1363
+ ### The catalog is empty
1364
+
1365
+ products.list() returned an empty array. Check whether the products are
1366
+ publicly active, visible, listed, and assigned to the expected store. Do not
1367
+ replace the empty result with hardcoded products.
1368
+
1369
+ ### A product route works for an ID but not a slug
1370
+
1371
+ products.get() accepts a public handle. Use the handle from the route and
1372
+ render null as not found.
1373
+
1374
+ ### The cart total is different after checkout
1375
+
1376
+ This is expected when a browser price, offer, stock state, or delivery value
1377
+ was stale. Render cart.validate results and treat order creation as the final
1378
+ server decision.
1379
+
1380
+ ### OTP says unavailable
1381
+
1382
+ Use the documented bypassToken path when the server supplies one. Do not
1383
+ pretend that a code was sent or repeatedly poll for a code.
1384
+
1385
+ ### A legal page looks like escaped HTML
1386
+
1387
+ Render page.bodyHtml through Astro's HTML rendering mechanism. The server has
1388
+ already sanitized it; do not print it as ordinary text or apply a second
1389
+ sanitizer in the theme.
1390
+
1391
+ ### A browser request gets a CSRF or validation error
1392
+
1393
+ Call the same-origin proxy route with the expected method and body shape.
1394
+ Check inputSchema, queryParams, pathParams, and the route's CSRF policy before
1395
+ changing the server.
1396
+
1397
+ ### Images are slow
1398
+
1399
+ Use the image helpers and request dimensions appropriate to the component.
1400
+ Do not use a full original asset for every thumbnail.
1401
+
1402
+ ## Definition of done for a new theme
1403
+
1404
+ Before calling a theme ready, confirm:
1405
+
1406
+ - A server-only StoreClient is created with the correct server URL and API key.
1407
+ - No browser bundle, public variable, HTML page, or client route exposes the
1408
+ API key.
1409
+ - Config flags control cart, reviews, OTP, checkout fields, delivery options,
1410
+ and tracking.
1411
+ - Product lists use ProductList and product pages use Product.
1412
+ - Product routes use public handles.
1413
+ - Null, empty, loading, validation, and upstream failure states are designed.
1414
+ - All displayed and submitted prices are treated as whole-DZD server values.
1415
+ - Browser baskets are validated before checkout.
1416
+ - Direct-product and basket order shapes are both handled when the store
1417
+ supports them.
1418
+ - OTP sent, verified, unavailable, retry, and terminal states are covered.
1419
+ - Tracking null means no conversion event.
1420
+ - Legal pages render sanitized bodyHtml safely and use config.pages for links.
1421
+ - Browser mutations go through same-origin proxy routes.
1422
+ - Money and image utilities are used consistently.
1423
+ - The theme typechecks against the SDK's declarations.
1424
+ - Relevant tests pass.
1425
+
1426
+ ## Compatibility and versioning
1427
+
1428
+ What this SDK promises, and what it needs from the platform:
1429
+
1430
+ | Dimension | Status |
1431
+ | --- | --- |
1432
+ | Versioning | [Semantic versioning](https://semver.org/). A breaking change ships only in a new major version; an export is marked `@deprecated` for at least one minor release before a major removes it |
1433
+ | Server contract | The store's public `/store/*` API. Every path the SDK calls, and every constant it shares with the server (basket caps, WhatsApp prefill cap and tokens, the 1,551-row commune bundle), is checked against the server before each release |
1434
+ | zod | A peer dependency (`^4.4.3`): the SDK's schemas are part of its API, so your app and the SDK share one copy. Astro 7 depends on zod 4 as well, so `astro/zod` resolves to that same copy |
1435
+ | Unknown response fields | Tolerated: schemas strip fields they do not know, so a server that adds a field before this SDK documents it cannot break a theme |
1436
+ | Missing response fields | Rejected for required fields and normalised for optional ones (`null` → the documented empty value) — a drifted response degrades rather than crashing a page, and reports through `onError` |
1437
+ | Node / runtime | ESM-only, `node >= 22.12` for tooling. The code targets ES2022 + DOM and runs in browsers, workers, and Astro server runtimes; there is no CJS build and no Node built-in in the browser-safe entries |
1438
+ | Astro | Framework-neutral core. `@wocart/storefront/astro` has **no Astro import** — its adapter types are structural mirrors of `APIRoute`, `defineAction` and `getActionContext`, so it works with the Astro version your theme pins (7.x verified) without a peer dependency |
1439
+
1440
+ Depend on a major range (`^1.0.0`) to receive fixes and additions.
1441
+
1442
+ ## Testing a theme
1443
+
1444
+ Test your theme against a running store: a server origin and a store key in a
1445
+ local, uncommitted secrets file, then your theme's dev server. In unit tests,
1446
+ stand in for the server with your own `StoreClient` double — every namespace
1447
+ method resolves to the documented result shapes, so a double only needs the
1448
+ methods a component calls.
1449
+
1450
+ ## Where to look when this guide is not enough
1451
+
1452
+ 1. The declarations shipped with the package (`dist/*.d.ts`): every export is
1453
+ typed and documented in place.
1454
+ 2. The browser-safe surface is `@wocart/storefront`; the client is
1455
+ `@wocart/storefront/server`; the Astro adapters are
1456
+ `@wocart/storefront/astro`. Nothing else is public.
1457
+ 3. The changelog, for what changed and when.
1458
+
1459
+ Do not work around a server contract by duplicating it in a theme.
1460
+
1461
+ ## What the SDK does not do
1462
+
1463
+ - It does not provide database access, merchant permissions, or merchant authentication.
1464
+ - It does not replace Astro pages, layouts, virtual modules, or UI state.
1465
+ - It does not make browser prices trustworthy.
1466
+ - It does not store the browser basket on the server.
1467
+ - It does not decide a theme's visual language, copy, layout, motion, or
1468
+ accessibility design.
1469
+ - It does not make an unavailable resource, empty catalog, invalid order, or
1470
+ missing tracking configuration look successful.