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