@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.
- package/CHANGELOG.md +95 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +144 -2
- package/USAGE.md +1470 -0
- package/dist/abandoned-checkout.d.ts +22 -0
- package/dist/abandoned-checkout.js +89 -0
- package/dist/actions.d.ts +117 -0
- package/dist/actions.js +196 -0
- package/dist/astro.d.ts +6 -0
- package/dist/astro.js +3 -0
- package/dist/basket.d.ts +87 -0
- package/dist/basket.js +258 -0
- package/dist/client/cart.d.ts +30 -0
- package/dist/client/cart.js +26 -0
- package/dist/client/categories.d.ts +25 -0
- package/dist/client/categories.js +37 -0
- package/dist/client/config.d.ts +29 -0
- package/dist/client/config.js +41 -0
- package/dist/client/core.d.ts +88 -0
- package/dist/client/core.js +566 -0
- package/dist/client/errors.d.ts +19 -0
- package/dist/client/errors.js +31 -0
- package/dist/client/geo.d.ts +31 -0
- package/dist/client/geo.js +39 -0
- package/dist/client/index.d.ts +40 -0
- package/dist/client/index.js +81 -0
- package/dist/client/orders.d.ts +68 -0
- package/dist/client/orders.js +76 -0
- package/dist/client/page-data.d.ts +30 -0
- package/dist/client/page-data.js +36 -0
- package/dist/client/pages.d.ts +33 -0
- package/dist/client/pages.js +44 -0
- package/dist/client/paths.d.ts +8 -0
- package/dist/client/paths.js +10 -0
- package/dist/client/products.d.ts +56 -0
- package/dist/client/products.js +78 -0
- package/dist/client/shipping.d.ts +36 -0
- package/dist/client/shipping.js +47 -0
- package/dist/data/communes.d.ts +25 -0
- package/dist/data/communes.js +1690 -0
- package/dist/data/wilayas.d.ts +6 -0
- package/dist/data/wilayas.js +64 -0
- package/dist/domain.d.ts +341 -0
- package/dist/domain.js +214 -0
- package/dist/endpoints/proxy.d.ts +111 -0
- package/dist/endpoints/proxy.js +161 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +30 -0
- package/dist/locale-routing.d.ts +59 -0
- package/dist/locale-routing.js +62 -0
- package/dist/locales.d.ts +15 -0
- package/dist/locales.js +8 -0
- package/dist/meta-tracking.d.ts +34 -0
- package/dist/meta-tracking.js +72 -0
- package/dist/middleware.d.ts +100 -0
- package/dist/middleware.js +72 -0
- package/dist/offer-view.d.ts +10 -0
- package/dist/offer-view.js +13 -0
- package/dist/otp.d.ts +80 -0
- package/dist/otp.js +295 -0
- package/dist/phone.d.ts +2 -0
- package/dist/phone.js +34 -0
- package/dist/product-view.d.ts +12 -0
- package/dist/product-view.js +42 -0
- package/dist/routes.d.ts +45 -0
- package/dist/routes.js +59 -0
- package/dist/schemas.d.ts +753 -0
- package/dist/schemas.js +377 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +2 -0
- package/dist/types.d.ts +418 -0
- package/dist/types.js +1 -0
- package/dist/utils/image.d.ts +140 -0
- package/dist/utils/image.js +131 -0
- package/dist/utils/money.d.ts +39 -0
- package/dist/utils/money.js +43 -0
- package/dist/utils/product-image.d.ts +37 -0
- package/dist/utils/product-image.js +38 -0
- package/dist/whatsapp.d.ts +55 -0
- package/dist/whatsapp.js +89 -0
- 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.
|