@wocart/storefront 1.1.0 → 1.3.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 CHANGED
@@ -5,6 +5,71 @@ follows [semantic versioning](https://semver.org/): breaking changes ship only
5
5
  in a new major version, and an export is marked `@deprecated` for at least one
6
6
  minor release before a major removes it.
7
7
 
8
+ ## 1.3.0 — 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - **Delivery quotes.** `cart.validate(items, { destination })` answers
13
+ `delivery`: the fee and total the order will charge at that wilaya, commune
14
+ and delivery type — product shipping profiles, commune overrides, the
15
+ free-delivery threshold and free-shipping offers included — or
16
+ `available: false` where the store does not deliver. The server computes the
17
+ quote with the same function it charges orders with. A platform server that
18
+ predates the quote answers without `delivery`.
19
+ - `DeliveryDestinationSchema`, `DeliveryDestination` and `CartDeliveryQuote`.
20
+ - The `cartValidate` route passes a `destination` through.
21
+
22
+ ## 1.2.0 — 2026-10-15
23
+
24
+ ### Added
25
+
26
+ - **`createStorefrontRoutes`** (`@wocart/storefront/astro`): the eight
27
+ same-origin API routes every storefront needs — OTP send and verify, cart
28
+ validation, abandoned-checkout tracking and conversion, communes, and
29
+ reviews (list and submit) — ready to export, so a theme's route file is one
30
+ line. Every POST refuses cross-origin requests, the OTP and abandoned
31
+ routes forward the shopper's address, and input is bounded before it is
32
+ sent. The theme passes only the request's own client.
33
+ - **`runFormAction`** (`@wocart/storefront/astro`) runs an action posted from
34
+ a form and stores its result for the page that POST renders.
35
+ - `orders.otp.send` and `orders.otp.verify` take the shopper's forwarded
36
+ headers as a last argument. The server limits sends and attempts per
37
+ shopper; without them every shopper of a store shared the theme worker's
38
+ one limit.
39
+
40
+ ### Changed
41
+
42
+ - **A proxy route's failure status follows the server's category** when
43
+ `errorStatus` is unset: 400 for validation, 422 for a business refusal, 429
44
+ when the server named a wait, 504 for a timeout, and 502 for anything the
45
+ shopper cannot fix (and for a failure that carries no `domainError`, as
46
+ before). An explicit `errorStatus` still wins.
47
+ - A client function that throws answers 502 with
48
+ `code: "PROXY_UPSTREAM_FAILED"` and a generic message; the exception's own
49
+ text no longer reaches the browser.
50
+ - `placeOrderInput` refusals name their field without a sentence. The Arabic
51
+ messages it carried reached French and English stores as-is; the theme
52
+ answers each field with its own copy.
53
+ - `placeOrderInput` and `executePlaceOrder` accept a `pricePerUnit` of 0. The
54
+ price is display-only, and a product the merchant prices at 0 was not
55
+ orderable from its product page.
56
+
57
+ ### Deprecated
58
+
59
+ - `resolveActionRedirectDecision`: use `runFormAction` and call `next()`. It
60
+ now always answers `{ kind: "next" }`. Its 303 back to the form on an
61
+ error lost the result in Astro 7 — the shopper returned to the form with no
62
+ message — so an errored post now renders in place. Its `referer` and
63
+ `pathname` inputs are unused.
64
+
65
+ ### Fixed
66
+
67
+ - A server error's `context` can no longer overwrite the result's own
68
+ `success`, `error`, `code` or `domainError` fields.
69
+ - A `*_NOT_FOUND` answer from `products.get`, `pages.get`, `pages.landing`,
70
+ `pageData.get` or `orders.tracking` is absence, not a failure: it no longer
71
+ reaches `onError` as a degraded read.
72
+
8
73
  ## 1.1.0 — 2026-10-14
9
74
 
10
75
  ### Fixed
package/README.md CHANGED
@@ -69,7 +69,7 @@ if (result.success) {
69
69
  | --- | --- | --- |
70
70
  | `@wocart/storefront` | browser and server | read-model types and schemas, reference data (58 wilayas, 1,551 communes), money and image helpers, the basket and OTP state machines, language routing, Meta pixel payloads |
71
71
  | `@wocart/storefront/server` | server only | `createStoreClient`, `StoreClient`, `ClientResult`, `StorefrontError` — the API key lives here |
72
- | `@wocart/storefront/astro` | server only | `createProxyEndpoint`, `placeOrderInput`, `executePlaceOrder`, `collectForwardedHeaders`, `resolveActionRedirectDecision` |
72
+ | `@wocart/storefront/astro` | server only | `createStorefrontRoutes`, `createProxyEndpoint`, `placeOrderInput`, `executePlaceOrder`, `collectForwardedHeaders`, `runFormAction` |
73
73
 
74
74
  Browser code never receives the key. It calls your own same-origin routes
75
75
  (for example `src/pages/api/cart.ts`), which call the SDK on the server:
package/USAGE.md CHANGED
@@ -745,6 +745,28 @@ type ShippingRate = {
745
745
  If the map is empty or one rate is null, show a useful unavailable state. Do
746
746
  not silently display zero shipping.
747
747
 
748
+ These are the store default profile's wilaya rates — a guide for "delivery
749
+ from X DZD", never the fee an order is charged. The fee also depends on each
750
+ product's own shipping profile, the commune, the free-delivery threshold and
751
+ free-shipping offers. To show what the shopper will pay, quote it:
752
+
753
+ ~~~ts
754
+ const result = await store.cart.validate(items, {
755
+ destination: { wilayaId: 16, communeId: "c-16-001", deliveryType: "home" },
756
+ });
757
+
758
+ if (result.success && result.data.delivery) {
759
+ const quote = result.data.delivery;
760
+ // quote.available → quote.fee and quote.total are what the order charges.
761
+ // !quote.available → the store does not deliver there; an order is refused.
762
+ }
763
+ ~~~
764
+
765
+ The quote comes from the same function the order uses, so the fee and total
766
+ it names are the ones charged. A single-product order form quotes the same
767
+ way, with its one product as the only line. From the browser, post
768
+ `{ items, destination }` to the `cartValidate` route.
769
+
748
770
  ### Wilayas and communes
749
771
 
750
772
  ~~~ts
@@ -915,6 +937,12 @@ verify returns a verified status with otpToken on success. Failure context can
915
937
  include attemptsRemaining and a terminal state. Use it for the countdown,
916
938
  retry, and lockout UI.
917
939
 
940
+ The server limits sends and attempts per shopper. Both calls take the
941
+ shopper's forwarded headers as their last argument
942
+ (`send(phone, collectForwardedHeaders(request))`); without them every shopper
943
+ of the store shares the one limit of the theme's worker. The `otpSend` and
944
+ `otpVerify` routes of `createStorefrontRoutes` pass them for you.
945
+
918
946
  ### Using the SDK's OtpMachine
919
947
 
920
948
  The SDK exports `createOtpMachine` to manage the complete interactive WhatsApp
@@ -1017,6 +1045,8 @@ wrapper while the SDK owns reusable validation and execution logic.
1017
1045
  placeOrderInput is the shared form schema. It:
1018
1046
 
1019
1047
  - whitelists supported fields and strips unknown fields;
1048
+ - names the failing field on a refusal, with no shopper-facing sentence — the
1049
+ theme answers each field with its own copy in the store's language;
1020
1050
  - coerces form number strings;
1021
1051
  - turns empty optional values into undefined;
1022
1052
  - canonicalizes phone input;
@@ -1046,7 +1076,7 @@ export const server = {
1046
1076
 
1047
1077
  if (!result.success) {
1048
1078
  throw new ActionError({
1049
- code: "BAD_REQUEST",
1079
+ code: result.errorKind === "shape" ? "BAD_REQUEST" : "UNPROCESSABLE_CONTENT",
1050
1080
  message: result.error,
1051
1081
  });
1052
1082
  }
@@ -1076,71 +1106,113 @@ order request:
1076
1106
  Use it when the server route is the trusted boundary that forwards the order.
1077
1107
  Do not let arbitrary browser code choose identity headers.
1078
1108
 
1079
- ### Redirect behavior
1109
+ ### Running the action from middleware
1110
+
1111
+ runFormAction runs an action posted from an HTML form and stores its result
1112
+ for the page that POST renders:
1080
1113
 
1081
- The middleware helper resolveActionRedirectDecision handles the form POST
1082
- flow:
1114
+ ~~~ts
1115
+ // src/middleware.ts
1116
+ import { defineMiddleware } from "astro:middleware";
1117
+ import { getActionContext } from "astro:actions";
1118
+ import { runFormAction } from "@wocart/storefront/astro";
1119
+
1120
+ export const onRequest = defineMiddleware(async (context, next) => {
1121
+ const { action, setActionResult, serializeActionResult } = getActionContext(context);
1122
+ await runFormAction({
1123
+ action: action && {
1124
+ calledFrom: action.calledFrom,
1125
+ name: action.name,
1126
+ handler: action.handler,
1127
+ },
1128
+ setActionResult,
1129
+ serializeActionResult,
1130
+ });
1131
+ return next();
1132
+ });
1133
+ ~~~
1083
1134
 
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.
1135
+ The page reads the result with `Astro.getActionResult()`: on success it
1136
+ redirects to the thank-you page, and on a refusal it renders the form again
1137
+ with the error in place.
1089
1138
 
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.
1139
+ Never redirect from the middleware, on success or on error. Astro 7 keeps an
1140
+ action result for the response being rendered only, so a redirect arrives at
1141
+ the next page with no result: the thank-you redirect never fires, and a
1142
+ refused order shows no message. resolveActionRedirectDecision, the earlier
1143
+ name, is deprecated and now always answers next.
1093
1144
 
1094
1145
  ## Browser-facing proxy routes
1095
1146
 
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.
1147
+ Browser code calls same-origin theme routes, which call the platform with the
1148
+ server-only API key. The key never reaches the browser.
1098
1149
 
1099
- This keeps the key out of the browser and centralizes CSRF checks, input
1100
- validation, parameter parsing, attribution forwarding, and error shape.
1150
+ ### The storefront routes, ready to export
1101
1151
 
1102
- ### Cart validation route
1152
+ createStorefrontRoutes returns the eight routes every storefront needs. Bind
1153
+ them once to the request's own client:
1103
1154
 
1104
1155
  ~~~ts
1105
- import { z } from "zod";
1106
- import { createProxyEndpoint } from "@wocart/storefront/astro";
1107
-
1108
- import { BasketLineInputSchema, MAX_BASKET_LINES } from "@wocart/storefront";
1156
+ // src/theme/api-routes.ts
1157
+ import type { APIContext } from "astro";
1158
+ import { createStorefrontRoutes } from "@wocart/storefront/astro";
1109
1159
 
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 }),
1160
+ export const routes = createStorefrontRoutes<APIContext>({
1161
+ client: (context) => context.locals.store.client,
1119
1162
  });
1120
1163
  ~~~
1121
1164
 
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
1165
+ Each route file then exports one of them:
1126
1166
 
1127
1167
  ~~~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
- });
1168
+ // src/pages/api/otp/send.ts
1169
+ import { routes } from "@/theme/api-routes";
1170
+ export const POST = routes.otpSend;
1135
1171
  ~~~
1136
1172
 
1137
- ### GET route with a numeric path parameter
1173
+ | Route | Method | Browser sends | Answers |
1174
+ | --- | --- | --- | --- |
1175
+ | `otpSend` | POST | `{ phone }` | `{ data: OtpSendResult }` |
1176
+ | `otpVerify` | POST | `{ phone, requestId, code }` | `{ data: OtpVerifyResult }` |
1177
+ | `cartValidate` | POST | `{ items }` | `{ success: true, data: CartValidation }` |
1178
+ | `abandonedUpsert` | POST | `AbandonedCheckoutInput` | 204 |
1179
+ | `abandonedConvert` | POST | `{ sessionId, orderId, orderNumber }` | 204 |
1180
+ | `communes` | GET | path param `wilayaId` | `{ data: Commune[] }` |
1181
+ | `reviewsList` | GET | path param `productId`, query `limit` (1–50, default 20) and `offset` | `{ data: Review[], total }` |
1182
+ | `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id } }` |
1183
+
1184
+ What every route gets right:
1185
+
1186
+ - every POST refuses a cross-origin request with 403;
1187
+ - the OTP routes and abandoned tracking forward the shopper's address and
1188
+ user agent, so the server limits and attributes per shopper;
1189
+ - input is bounded before it is sent, and a malformed body is a 400 that never
1190
+ reaches the server;
1191
+ - a refusal answers with the status the server's category implies — 400 for
1192
+ validation, 422 for a business refusal, 429 when the server named a wait —
1193
+ and carries the server's `code` and context (`windowSeconds`,
1194
+ `attemptsRemaining`, `terminal`), which is what the browser branches on;
1195
+ - a failure the shopper cannot fix (a wrong store key, a server fault, a
1196
+ dropped connection) is a 502, and a timeout a 504.
1197
+
1198
+ The client resolver runs once per request. Return the request's own client,
1199
+ never one kept across requests.
1200
+
1201
+ ### A custom route
1202
+
1203
+ createProxyEndpoint is the factory behind those routes, for a route of the
1204
+ theme's own. Its client function returns a result (`{ success, data }` or a
1205
+ failure), so wrap a read that returns a plain value:
1138
1206
 
1139
1207
  ~~~ts
1208
+ import { z } from "zod";
1209
+ import { createProxyEndpoint } from "@wocart/storefront/astro";
1210
+
1140
1211
  export const GET = createProxyEndpoint({
1141
1212
  method: "GET",
1142
- client: (input) => store.geo.communes(input.wilayaId),
1143
- pathParams: { wilayaId: "number" },
1213
+ pathParams: { slug: "string" },
1214
+ inputSchema: z.object({ slug: z.string().min(1).max(200) }),
1215
+ client: async ({ slug }) => ({ success: true, data: await store.products.get(slug) }),
1144
1216
  });
1145
1217
  ~~~
1146
1218
 
@@ -1153,12 +1225,18 @@ Proxy configuration supports:
1153
1225
  - numeric or string path parameter parsing;
1154
1226
  - CSRF protection, enabled by default for POST and disabled by default for GET;
1155
1227
  - attribution forwarding;
1156
- - custom success and error status codes;
1157
- - custom successBody;
1158
- - noContent responses.
1228
+ - a custom success status, successBody and noContent;
1229
+ - errorStatus, which overrides the status derived from the failure. Leave it
1230
+ unset unless the browser contract needs one fixed status.
1231
+
1232
+ A thrown client function answers 502 with `code: "PROXY_UPSTREAM_FAILED"`
1233
+ and a generic message: an exception's own text never reaches the browser.
1234
+
1235
+ Orders do not go through a proxy route: they go through the placeOrder action
1236
+ (see above), which validates the form and forwards attribution itself.
1159
1237
 
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.
1238
+ Do not solve a proxy problem by moving the API key into public environment
1239
+ variables.
1162
1240
 
1163
1241
  ## Money, images, and presentation utilities
1164
1242
 
@@ -1374,8 +1452,9 @@ render null as not found.
1374
1452
  ### The cart total is different after checkout
1375
1453
 
1376
1454
  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.
1455
+ was stale, or when the theme computed a fee or total itself. Show the totals
1456
+ cart.validate answers — with a destination for the delivery fee — and treat
1457
+ order creation as the final server decision.
1379
1458
 
1380
1459
  ### OTP says unavailable
1381
1460
 
package/dist/actions.d.ts CHANGED
@@ -17,10 +17,11 @@
17
17
  * handler: async (input, context) => {
18
18
  * const result = await executePlaceOrder(input, context.request, client);
19
19
  * if (!result.success) {
20
- * if (result.errorKind === "shape") {
21
- * throw new ActionError({ code: "BAD_REQUEST", message: result.error });
22
- * }
23
- * throw new Error(result.error);
20
+ * // A refusal renders on the form, in place (see middleware.ts).
21
+ * throw new ActionError({
22
+ * code: result.errorKind === "shape" ? "BAD_REQUEST" : "UNPROCESSABLE_CONTENT",
23
+ * message: result.error,
24
+ * });
24
25
  * }
25
26
  * return result.data;
26
27
  * },
package/dist/actions.js CHANGED
@@ -17,10 +17,11 @@
17
17
  * handler: async (input, context) => {
18
18
  * const result = await executePlaceOrder(input, context.request, client);
19
19
  * if (!result.success) {
20
- * if (result.errorKind === "shape") {
21
- * throw new ActionError({ code: "BAD_REQUEST", message: result.error });
22
- * }
23
- * throw new Error(result.error);
20
+ * // A refusal renders on the form, in place (see middleware.ts).
21
+ * throw new ActionError({
22
+ * code: result.errorKind === "shape" ? "BAD_REQUEST" : "UNPROCESSABLE_CONTENT",
23
+ * message: result.error,
24
+ * });
24
25
  * }
25
26
  * return result.data;
26
27
  * },
@@ -92,7 +93,9 @@ export const placeOrderInput = z.object({
92
93
  // Forms always submit all hidden inputs — empty string must become undefined
93
94
  variantId: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).optional()),
94
95
  variantLabel: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().max(100).optional()),
95
- pricePerUnit: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.coerce.number().positive().optional()),
96
+ // Display-only — the server prices every order from the catalog — so a
97
+ // product the merchant prices at 0 is as orderable as any other.
98
+ pricePerUnit: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.coerce.number().nonnegative().optional()),
96
99
  quantity: z.coerce.number().int().min(1).max(100).default(1),
97
100
  // Explicit offer tier selected by the user
98
101
  offerId: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().optional()),
@@ -101,10 +104,12 @@ export const placeOrderInput = z.object({
101
104
  variantId: z.string().min(1),
102
105
  variantLabel: z.string().optional(),
103
106
  })).optional()),
104
- customerName: z.string().min(2, "الاسم مطلوب").max(100),
105
- phone: z.preprocess((v) => (typeof v === "string" ? toLocalAlgerianMobile(v) ?? v : v), z.string().regex(/^0[567]\d{8}$/, "رقم الهاتف غير صحيح")),
107
+ // No custom messages: a failure names its field, and the theme answers it
108
+ // with its own copy in the store's language.
109
+ customerName: z.string().min(2).max(100),
110
+ phone: z.preprocess((v) => (typeof v === "string" ? toLocalAlgerianMobile(v) ?? v : v), z.string().regex(/^0[567]\d{8}$/)),
106
111
  wilayaId: z.coerce.number().int().min(1).max(58),
107
- communeId: z.string().min(1, "يرجى اختيار البلدية"),
112
+ communeId: z.string().min(1),
108
113
  address: z.string().max(300).optional(),
109
114
  deliveryType: z.enum(["home", "stop_desk"]).default("home"),
110
115
  notes: z.string().max(500).optional(),
@@ -176,7 +181,7 @@ export function collectForwardedHeaders(request) {
176
181
  export async function executePlaceOrder(input, request, client) {
177
182
  // An order is either a basket or a single product, never neither.
178
183
  if (!input.items?.length &&
179
- (!input.productId || !input.productName || !input.pricePerUnit)) {
184
+ (!input.productId || !input.productName || input.pricePerUnit === undefined)) {
180
185
  return {
181
186
  success: false,
182
187
  error: "An order needs either a basket or a product.",
package/dist/astro.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { createProxyEndpoint } from "./endpoints/proxy.js";
2
2
  export type { ProxyClientFn, ProxyEndpointConfig, AstroAPIContext, AstroAPIRoute, } from "./endpoints/proxy.js";
3
+ export { createStorefrontRoutes, REVIEWS_PAGE_DEFAULT, REVIEWS_PAGE_MAX, } from "./endpoints/storefront-routes.js";
4
+ export type { StorefrontApiRoute, StorefrontRoutes, StorefrontRoutesOptions, } from "./endpoints/storefront-routes.js";
3
5
  export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./actions.js";
4
6
  export type { PlaceOrderInput, PlaceOrderResult } from "./actions.js";
5
- export { resolveActionRedirectDecision } from "./middleware.js";
7
+ export { runFormAction, resolveActionRedirectDecision } from "./middleware.js";
6
8
  export type { PrgDecision, ActionMiddlewareDeps, ActionDescriptor } from "./middleware.js";
package/dist/astro.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { createProxyEndpoint } from "./endpoints/proxy.js";
2
+ export { createStorefrontRoutes, REVIEWS_PAGE_DEFAULT, REVIEWS_PAGE_MAX, } from "./endpoints/storefront-routes.js";
2
3
  export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./actions.js";
3
- export { resolveActionRedirectDecision } from "./middleware.js";
4
+ export { runFormAction, resolveActionRedirectDecision } from "./middleware.js";
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ClientCore, ClientResult } from "./core.js";
15
15
  import type { CartValidation } from "../types.js";
16
- import type { BasketLineInput } from "../domain.js";
16
+ import type { BasketLineInput, DeliveryDestination } from "../domain.js";
17
17
  export interface CartNamespace {
18
18
  /**
19
19
  * Re-price and re-check a basket against the live catalog.
@@ -21,8 +21,14 @@ export interface CartNamespace {
21
21
  * optional variant fields); pricing from their side is never trusted, and
22
22
  * each line's product name and variant label come back from the catalog in
23
23
  * the client's language.
24
+ *
25
+ * With a `destination`, the answer also carries `delivery`: the fee and
26
+ * total the order would charge there. A theme shows that, never a fee or
27
+ * total of its own making.
24
28
  */
25
- validate(items: readonly BasketLineInput[]): Promise<ClientResult<CartValidation>>;
29
+ validate(items: readonly BasketLineInput[], options?: {
30
+ destination?: DeliveryDestination;
31
+ }): Promise<ClientResult<CartValidation>>;
26
32
  }
27
33
  /**
28
34
  * Create cart namespace
@@ -19,8 +19,9 @@ import { localizedPath } from "./paths.js";
19
19
  */
20
20
  export function createCartNamespace(core) {
21
21
  return {
22
- async validate(items) {
23
- return core.post(localizedPath(core.config, "/store/cart/validate"), { items }, { schema: CartValidationSchema, operation: STOREFRONT_OPERATIONS.cartValidate });
22
+ async validate(items, options) {
23
+ const destination = options?.destination;
24
+ return core.post(localizedPath(core.config, "/store/cart/validate"), destination ? { items, destination } : { items }, { schema: CartValidationSchema, operation: STOREFRONT_OPERATIONS.cartValidate });
24
25
  },
25
26
  };
26
27
  }
@@ -70,6 +70,14 @@ export declare const DEFAULT_READ_TIMEOUT_MS = 10000;
70
70
  * @returns Core infrastructure for namespace implementations
71
71
  */
72
72
  export declare function createClientCore(config: ClientConfig): ClientCore;
73
+ /**
74
+ * The server's own answer that a looked-up record does not exist
75
+ * (`PRODUCT_NOT_FOUND`, `STORE_PAGE_NOT_FOUND`, …). A lookup returns `null` for
76
+ * it without reporting a failure: a stale link is not an outage. A 404 that
77
+ * does not carry the server's code — a wrong `serverUrl`, a route the platform
78
+ * does not have — is not absence, and is still reported.
79
+ */
80
+ export declare function isNotFoundAnswer(error: unknown): boolean;
73
81
  /**
74
82
  * Report a read that a namespace swallowed.
75
83
  *
@@ -430,11 +430,11 @@ export function createClientCore(config) {
430
430
  if (!response.ok) {
431
431
  const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
432
432
  return {
433
+ ...failure.flatContext,
433
434
  success: false,
434
435
  error: failure.message,
435
436
  ...(failure.code !== undefined ? { code: failure.code } : {}),
436
437
  domainError: failure.domainError,
437
- ...failure.flatContext,
438
438
  };
439
439
  }
440
440
  if (!read.ok || !isRecord(read.value)) {
@@ -488,11 +488,11 @@ export function createClientCore(config) {
488
488
  if (!response.ok) {
489
489
  const failure = serverFailure(response.status, read.ok ? read.value : undefined, operation, false);
490
490
  return {
491
+ ...failure.flatContext,
491
492
  success: false,
492
493
  error: failure.message,
493
494
  ...(failure.code !== undefined ? { code: failure.code } : {}),
494
495
  domainError: failure.domainError,
495
- ...failure.flatContext,
496
496
  };
497
497
  }
498
498
  if (!read.ok || !isRecord(read.value)) {
@@ -526,6 +526,16 @@ export function createClientCore(config) {
526
526
  patch,
527
527
  };
528
528
  }
529
+ /**
530
+ * The server's own answer that a looked-up record does not exist
531
+ * (`PRODUCT_NOT_FOUND`, `STORE_PAGE_NOT_FOUND`, …). A lookup returns `null` for
532
+ * it without reporting a failure: a stale link is not an outage. A 404 that
533
+ * does not carry the server's code — a wrong `serverUrl`, a route the platform
534
+ * does not have — is not absence, and is still reported.
535
+ */
536
+ export function isNotFoundAnswer(error) {
537
+ return isStorefrontError(error) && /_NOT_FOUND$/.test(error.domainError.code);
538
+ }
529
539
  /**
530
540
  * Report a read that a namespace swallowed.
531
541
  *
@@ -34,14 +34,17 @@ export interface OtpNamespace {
34
34
  * Send a WhatsApp verification code. On "unavailable" the server returns
35
35
  * a signed bypassToken — checkout proceeds unverified (fail-open).
36
36
  * Rate-limit errors carry `code: "OTP_RATE_LIMITED"` and `windowSeconds`.
37
+ * `forwardedHeaders` carries the shopper's address: the server's send and
38
+ * verify limits count per shopper, and without it every shopper of the
39
+ * store shares the one limit of this worker.
37
40
  */
38
- send(phone: string): Promise<ClientResult<OtpSendResult>>;
41
+ send(phone: string, forwardedHeaders?: Record<string, string>): Promise<ClientResult<OtpSendResult>>;
39
42
  /**
40
43
  * Verify the code the customer typed. Wrong-but-retryable codes return
41
44
  * `code`/`attemptsRemaining`/`terminal` on the error result; a success
42
45
  * returns the signed `otpToken` that travels with the order.
43
46
  */
44
- verify(phone: string, requestId: string, code: string): Promise<ClientResult<OtpVerifyResult>>;
47
+ verify(phone: string, requestId: string, code: string, forwardedHeaders?: Record<string, string>): Promise<ClientResult<OtpVerifyResult>>;
45
48
  }
46
49
  export interface OrdersNamespace {
47
50
  /**
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { CreatedOrderSchema, IdResponseSchema, OrderTrackingSchema, OtpSendResultSchema, OtpVerifyResultSchema, } from "../schemas.js";
15
15
  import { STOREFRONT_OPERATIONS } from "../domain.js";
16
- import { reportDegradedRead } from "./core.js";
16
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
17
17
  /**
18
18
  * Create orders namespace
19
19
  */
@@ -34,7 +34,8 @@ export function createOrdersNamespace(core) {
34
34
  return tracking ?? null;
35
35
  }
36
36
  catch (error) {
37
- reportDegradedRead(core.config, error, "orders.tracking");
37
+ if (!isNotFoundAnswer(error))
38
+ reportDegradedRead(core.config, error, "orders.tracking");
38
39
  return null;
39
40
  }
40
41
  },
@@ -60,16 +61,21 @@ export function createOrdersNamespace(core) {
60
61
  },
61
62
  },
62
63
  otp: {
63
- async send(phone) {
64
+ async send(phone, forwardedHeaders) {
64
65
  // core.post flattens the server's `context` (windowSeconds) and
65
66
  // `code` into the error result — exactly what the OTP step reads.
66
67
  return core.post("/store/otp/send", { phone }, {
68
+ ...(forwardedHeaders ? { headers: forwardedHeaders } : {}),
67
69
  schema: OtpSendResultSchema,
68
70
  operation: STOREFRONT_OPERATIONS.ordersOtpSend,
69
71
  });
70
72
  },
71
- async verify(phone, requestId, code) {
72
- return core.post("/store/otp/verify", { phone, requestId, code }, { schema: OtpVerifyResultSchema, operation: STOREFRONT_OPERATIONS.ordersOtpVerify });
73
+ async verify(phone, requestId, code, forwardedHeaders) {
74
+ return core.post("/store/otp/verify", { phone, requestId, code }, {
75
+ ...(forwardedHeaders ? { headers: forwardedHeaders } : {}),
76
+ schema: OtpVerifyResultSchema,
77
+ operation: STOREFRONT_OPERATIONS.ordersOtpVerify,
78
+ });
73
79
  },
74
80
  },
75
81
  };
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { PageDocumentPayloadSchema } from "../schemas.js";
18
18
  import { STOREFRONT_OPERATIONS } from "../domain.js";
19
- import { reportDegradedRead } from "./core.js";
19
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
20
20
  /**
21
21
  * Create the page-data namespace.
22
22
  */
@@ -28,7 +28,8 @@ export function createPageDataNamespace(core) {
28
28
  return document ?? null;
29
29
  }
30
30
  catch (error) {
31
- reportDegradedRead(core.config, error, "pageData.get");
31
+ if (!isNotFoundAnswer(error))
32
+ reportDegradedRead(core.config, error, "pageData.get");
32
33
  return null;
33
34
  }
34
35
  },
@@ -14,7 +14,7 @@
14
14
  import { LandingPageSchema, StorePagePublicSchema } from "../schemas.js";
15
15
  import { STOREFRONT_OPERATIONS } from "../domain.js";
16
16
  import { localizedPath } from "./paths.js";
17
- import { reportDegradedRead } from "./core.js";
17
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
18
18
  /**
19
19
  * Create pages namespace
20
20
  */
@@ -26,7 +26,8 @@ export function createPagesNamespace(core) {
26
26
  return page ?? null;
27
27
  }
28
28
  catch (error) {
29
- reportDegradedRead(core.config, error, "pages.get");
29
+ if (!isNotFoundAnswer(error))
30
+ reportDegradedRead(core.config, error, "pages.get");
30
31
  return null;
31
32
  }
32
33
  },
@@ -36,7 +37,8 @@ export function createPagesNamespace(core) {
36
37
  return landingPage ?? null;
37
38
  }
38
39
  catch (error) {
39
- reportDegradedRead(core.config, error, "pages.landing");
40
+ if (!isNotFoundAnswer(error))
41
+ reportDegradedRead(core.config, error, "pages.landing");
40
42
  return null;
41
43
  }
42
44
  },
@@ -15,7 +15,7 @@ import { z } from "zod";
15
15
  import { IdResponseSchema, ProductListSchema, ProductSchema, ReviewListEnvelopeSchema } from "../schemas.js";
16
16
  import { STOREFRONT_OPERATIONS } from "../domain.js";
17
17
  import { localizedPath } from "./paths.js";
18
- import { reportDegradedRead } from "./core.js";
18
+ import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
19
19
  /**
20
20
  * Create products namespace
21
21
  */
@@ -27,7 +27,8 @@ export function createProductsNamespace(core) {
27
27
  return product ?? null;
28
28
  }
29
29
  catch (error) {
30
- reportDegradedRead(core.config, error, "products.get");
30
+ if (!isNotFoundAnswer(error))
31
+ reportDegradedRead(core.config, error, "products.get");
31
32
  return null;
32
33
  }
33
34
  },
package/dist/domain.d.ts CHANGED
@@ -175,6 +175,20 @@ export declare const AbandonedCheckoutInputSchema: z.ZodObject<{
175
175
  * `items[]` line: absent — not null — is the common wire shape.
176
176
  */
177
177
  export type AbandonedCheckoutInput = z.infer<typeof AbandonedCheckoutInputSchema>;
178
+ /**
179
+ * Where a basket would be delivered, for a delivery quote. The commune is
180
+ * optional — without it the wilaya's rate applies; with it, the commune's own
181
+ * override when the merchant set one.
182
+ */
183
+ export declare const DeliveryDestinationSchema: z.ZodObject<{
184
+ wilayaId: z.ZodNumber;
185
+ communeId: z.ZodOptional<z.ZodString>;
186
+ deliveryType: z.ZodEnum<{
187
+ home: "home";
188
+ stop_desk: "stop_desk";
189
+ }>;
190
+ }, z.core.$strip>;
191
+ export type DeliveryDestination = z.infer<typeof DeliveryDestinationSchema>;
178
192
  /** Shopper's selected delivery mode and destination. Policy decides whether address is required. */
179
193
  export declare const DeliverySelectionSchema: z.ZodObject<{
180
194
  wilayaId: z.ZodNumber;
package/dist/domain.js CHANGED
@@ -126,6 +126,16 @@ export const AbandonedCheckoutInputSchema = z.object({
126
126
  fbc: z.string().max(500).optional(),
127
127
  fbp: z.string().max(500).optional(),
128
128
  });
129
+ /**
130
+ * Where a basket would be delivered, for a delivery quote. The commune is
131
+ * optional — without it the wilaya's rate applies; with it, the commune's own
132
+ * override when the merchant set one.
133
+ */
134
+ export const DeliveryDestinationSchema = z.object({
135
+ wilayaId: WilayaIdSchema,
136
+ communeId: z.string().min(1).max(200).optional(),
137
+ deliveryType: z.enum(["home", "stop_desk"]),
138
+ });
129
139
  /** Shopper's selected delivery mode and destination. Policy decides whether address is required. */
130
140
  export const DeliverySelectionSchema = z.object({
131
141
  wilayaId: z.number().int().min(1).max(58),
@@ -69,9 +69,11 @@ export interface ProxyEndpointConfig<TInput, TOutput> {
69
69
  */
70
70
  successStatus?: number;
71
71
  /**
72
- * Status code for upstream failures (default: 502).
73
- * Typical choices: 502 for cart/abandoned/otp-send, 422 for otp-verify,
74
- * 400 for submit-review.
72
+ * Status code for every upstream failure. Leave it unset: the status then
73
+ * follows the failure's `domainError` — 400 for a validation refusal, 422 for
74
+ * a business refusal, 429 when the server named a wait, 504 for a timeout and
75
+ * 502 for anything the shopper cannot fix (a bad store key, a server fault,
76
+ * a dropped connection, or a failure that carries no `domainError`).
75
77
  */
76
78
  errorStatus?: number;
77
79
  /**
@@ -31,7 +31,7 @@ import { collectForwardedHeaders } from "../actions.js";
31
31
  * ```
32
32
  */
33
33
  export function createProxyEndpoint(config) {
34
- const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method === "POST", forwardAttribution = false, successStatus = 200, errorStatus = 502, successBody, noContent = false, } = config;
34
+ const { method, client, inputSchema, queryParams = [], pathParams = {}, csrfProtection = method === "POST", forwardAttribution = false, successStatus = 200, errorStatus, successBody, noContent = false, } = config;
35
35
  return async (context) => {
36
36
  const { request, params } = context;
37
37
  // CSRF Protection: reject cross-origin requests
@@ -68,8 +68,8 @@ export function createProxyEndpoint(config) {
68
68
  try {
69
69
  result = await client(input, headers);
70
70
  }
71
- catch (error) {
72
- return jsonResponse({ error: error instanceof Error ? error.message : "Upstream request failed" }, errorStatus);
71
+ catch {
72
+ return jsonResponse({ error: "The request could not be completed", code: "PROXY_UPSTREAM_FAILED" }, errorStatus ?? 502);
73
73
  }
74
74
  // Handle client errors — forward every diagnostic field the client
75
75
  // attached (code, windowSeconds, attemptsRemaining, terminal, ...)
@@ -78,7 +78,7 @@ export function createProxyEndpoint(config) {
78
78
  const extra = { ...result };
79
79
  delete extra.success;
80
80
  delete extra.error;
81
- return jsonResponse({ error: result.error, ...extra }, errorStatus);
81
+ return jsonResponse({ error: result.error, ...extra }, errorStatus ?? failureStatus(result.domainError));
82
82
  }
83
83
  // Return success response
84
84
  if (noContent) {
@@ -88,6 +88,23 @@ export function createProxyEndpoint(config) {
88
88
  return jsonResponse(body, successStatus);
89
89
  };
90
90
  }
91
+ /**
92
+ * The status a failure answers the browser with. The server's category decides:
93
+ * a refusal the shopper can act on stays a 4xx, and everything else is the
94
+ * gateway's failure, never the shopper's.
95
+ */
96
+ function failureStatus(domainError) {
97
+ if (typeof domainError !== "object" || domainError === null)
98
+ return 502;
99
+ const { category, code, retryAfterSeconds } = domainError;
100
+ if (category === "validation")
101
+ return 400;
102
+ if (category === "business")
103
+ return typeof retryAfterSeconds === "number" ? 429 : 422;
104
+ if (category === "transport" && code === "REQUEST_TIMEOUT")
105
+ return 504;
106
+ return 502;
107
+ }
91
108
  /**
92
109
  * Parse GET request input from query params and path params
93
110
  */
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The storefront's same-origin API routes, ready to export.
3
+ *
4
+ * Every theme needs the same eight routes between the shopper's browser and
5
+ * the platform: OTP send and verify, cart validation, abandoned-checkout
6
+ * tracking and its conversion, communes, and reviews (list and submit). What
7
+ * each one must get right is decided here once — the cross-origin check on
8
+ * every POST, the shopper's address forwarded wherever the server counts or
9
+ * attributes per shopper, the input bounds, and the response shape the
10
+ * browser reads — so a theme's route file is one line:
11
+ *
12
+ * ```ts
13
+ * // src/pages/api/otp/send.ts
14
+ * import { routes } from "@/theme/api-routes";
15
+ * export const POST = routes.otpSend;
16
+ * ```
17
+ *
18
+ * The theme decides only which client serves the request — its own,
19
+ * per-request client, so the language and the request lifetime are the
20
+ * page's. A failure answers with the status the server's category implies
21
+ * (see `createProxyEndpoint`), and the server's diagnostic fields ride along.
22
+ */
23
+ import type { StoreClient } from "../client/index.js";
24
+ import { type AstroAPIContext } from "./proxy.js";
25
+ export interface StorefrontRoutesOptions<C extends AstroAPIContext> {
26
+ /** The store client that serves this request — the request's own, never a shared one. */
27
+ client: (context: C) => StoreClient;
28
+ }
29
+ export type StorefrontApiRoute<C extends AstroAPIContext> = (context: C) => Promise<Response>;
30
+ export interface StorefrontRoutes<C extends AstroAPIContext> {
31
+ /** POST `{ phone }` → `{ data: OtpSendResult }`. */
32
+ readonly otpSend: StorefrontApiRoute<C>;
33
+ /** POST `{ phone, requestId, code }` → `{ data: OtpVerifyResult }`. */
34
+ readonly otpVerify: StorefrontApiRoute<C>;
35
+ /**
36
+ * POST `{ items, destination? }` → `{ success: true, data: CartValidation }`.
37
+ * With a destination, `data.delivery` is the fee and total the order will charge.
38
+ */
39
+ readonly cartValidate: StorefrontApiRoute<C>;
40
+ /** POST an `AbandonedCheckoutInput` → 204. */
41
+ readonly abandonedUpsert: StorefrontApiRoute<C>;
42
+ /** POST `{ sessionId, orderId, orderNumber }` → 204. */
43
+ readonly abandonedConvert: StorefrontApiRoute<C>;
44
+ /** GET, path param `wilayaId` → `{ data: Commune[] }`. */
45
+ readonly communes: StorefrontApiRoute<C>;
46
+ /** GET, path param `productId`, query `limit`/`offset` → `{ data: Review[], total }`. */
47
+ readonly reviewsList: StorefrontApiRoute<C>;
48
+ /** POST a `ReviewInput` → 201 `{ success: true, data: { id } }`. */
49
+ readonly reviewSubmit: StorefrontApiRoute<C>;
50
+ }
51
+ export declare const REVIEWS_PAGE_DEFAULT = 20;
52
+ export declare const REVIEWS_PAGE_MAX = 50;
53
+ export declare function createStorefrontRoutes<C extends AstroAPIContext = AstroAPIContext>(options: StorefrontRoutesOptions<C>): StorefrontRoutes<C>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The storefront's same-origin API routes, ready to export.
3
+ *
4
+ * Every theme needs the same eight routes between the shopper's browser and
5
+ * the platform: OTP send and verify, cart validation, abandoned-checkout
6
+ * tracking and its conversion, communes, and reviews (list and submit). What
7
+ * each one must get right is decided here once — the cross-origin check on
8
+ * every POST, the shopper's address forwarded wherever the server counts or
9
+ * attributes per shopper, the input bounds, and the response shape the
10
+ * browser reads — so a theme's route file is one line:
11
+ *
12
+ * ```ts
13
+ * // src/pages/api/otp/send.ts
14
+ * import { routes } from "@/theme/api-routes";
15
+ * export const POST = routes.otpSend;
16
+ * ```
17
+ *
18
+ * The theme decides only which client serves the request — its own,
19
+ * per-request client, so the language and the request lifetime are the
20
+ * page's. A failure answers with the status the server's category implies
21
+ * (see `createProxyEndpoint`), and the server's diagnostic fields ride along.
22
+ */
23
+ import { z } from "zod";
24
+ import { AbandonedCheckoutInputSchema, BasketLineInputSchema, DeliveryDestinationSchema, MAX_BASKET_LINES, WilayaIdSchema, } from "../domain.js";
25
+ import { createProxyEndpoint, } from "./proxy.js";
26
+ export const REVIEWS_PAGE_DEFAULT = 20;
27
+ export const REVIEWS_PAGE_MAX = 50;
28
+ const otpSendInput = z.object({ phone: z.string().min(1).max(32) });
29
+ const otpVerifyInput = z.object({
30
+ phone: z.string().min(1).max(32),
31
+ requestId: z.string().min(1).max(200),
32
+ code: z.string().min(1).max(16),
33
+ });
34
+ const cartValidateInput = z.object({
35
+ items: z.array(BasketLineInputSchema).min(1).max(MAX_BASKET_LINES),
36
+ destination: DeliveryDestinationSchema.optional(),
37
+ });
38
+ const abandonedConvertInput = z.object({
39
+ sessionId: z.string().uuid(),
40
+ orderId: z.string().min(1).max(200),
41
+ orderNumber: z.string().min(1).max(100),
42
+ });
43
+ const communesInput = z.object({ wilayaId: WilayaIdSchema });
44
+ const reviewsListInput = z.object({
45
+ productId: z.string().min(1).max(200),
46
+ limit: z.coerce
47
+ .number()
48
+ .int()
49
+ .min(1)
50
+ .catch(REVIEWS_PAGE_DEFAULT)
51
+ .transform((n) => Math.min(n, REVIEWS_PAGE_MAX)),
52
+ offset: z.coerce.number().int().min(0).catch(0),
53
+ });
54
+ /**
55
+ * The shape of a review, bounded so the body stays small. The rules — the
56
+ * order-number format, the rating range, the body's length — are the
57
+ * server's, and its refusal carries the code the form answers by.
58
+ */
59
+ const reviewSubmitInput = z.object({
60
+ orderNumber: z.string().min(1).max(64),
61
+ productId: z.string().min(1).max(200),
62
+ rating: z.number(),
63
+ title: z.string().max(500).optional(),
64
+ body: z.string().max(5000),
65
+ });
66
+ export function createStorefrontRoutes(options) {
67
+ const route = (config) => async (context) => {
68
+ const { call, ...endpoint } = config;
69
+ return createProxyEndpoint({
70
+ ...endpoint,
71
+ client: (input, headers) => call(options.client(context), input, headers),
72
+ })(context);
73
+ };
74
+ return {
75
+ otpSend: route({
76
+ method: "POST",
77
+ inputSchema: otpSendInput,
78
+ forwardAttribution: true,
79
+ call: (client, { phone }, headers) => client.orders.otp.send(phone, headers),
80
+ }),
81
+ otpVerify: route({
82
+ method: "POST",
83
+ inputSchema: otpVerifyInput,
84
+ forwardAttribution: true,
85
+ call: (client, { phone, requestId, code }, headers) => client.orders.otp.verify(phone, requestId, code, headers),
86
+ }),
87
+ cartValidate: route({
88
+ method: "POST",
89
+ inputSchema: cartValidateInput,
90
+ successBody: (data) => ({ success: true, data }),
91
+ call: (client, { items, destination }) => destination ? client.cart.validate(items, { destination }) : client.cart.validate(items),
92
+ }),
93
+ abandonedUpsert: route({
94
+ method: "POST",
95
+ inputSchema: AbandonedCheckoutInputSchema,
96
+ forwardAttribution: true,
97
+ noContent: true,
98
+ call: (client, input, headers) => client.orders.abandoned.upsert(input, headers),
99
+ }),
100
+ abandonedConvert: route({
101
+ method: "POST",
102
+ inputSchema: abandonedConvertInput,
103
+ noContent: true,
104
+ call: (client, { sessionId, orderId, orderNumber }) => client.orders.abandoned.convert(sessionId, orderId, orderNumber),
105
+ }),
106
+ communes: route({
107
+ method: "GET",
108
+ pathParams: { wilayaId: "number" },
109
+ inputSchema: communesInput,
110
+ call: async (client, { wilayaId }) => ({
111
+ success: true,
112
+ data: await client.geo.communes(wilayaId),
113
+ }),
114
+ }),
115
+ reviewsList: route({
116
+ method: "GET",
117
+ pathParams: { productId: "string" },
118
+ queryParams: ["limit", "offset"],
119
+ inputSchema: reviewsListInput,
120
+ successBody: ({ rows, total }) => ({ data: rows, total }),
121
+ call: async (client, { productId, limit, offset }) => ({
122
+ success: true,
123
+ data: await client.products.reviews.list(productId, { limit, offset }),
124
+ }),
125
+ }),
126
+ reviewSubmit: route({
127
+ method: "POST",
128
+ inputSchema: reviewSubmitInput,
129
+ successStatus: 201,
130
+ successBody: (data) => ({ success: true, data }),
131
+ call: (client, { title, ...review }) => client.products.reviews.submit(title === undefined ? review : { ...review, title }),
132
+ }),
133
+ };
134
+ }
@@ -1,21 +1,20 @@
1
1
  /**
2
- * Middleware engine — the POST/Redirect/GET pattern for Astro Actions,
3
- * framework-free.
2
+ * Middleware engine for Astro Actions called from an HTML form, framework-free.
4
3
  *
5
- * `astro:middleware` and `astro:actions` are virtual modules that only
6
- * exist inside an Astro project, so this file computes the DECISION and each
7
- * theme's middleware executes it:
4
+ * `astro:middleware` and `astro:actions` are virtual modules that only exist
5
+ * inside an Astro project, so this file runs the action and each theme's
6
+ * middleware wires it:
8
7
  *
9
8
  * ```ts
10
9
  * // src/middleware.ts
11
10
  * import { defineMiddleware } from "astro:middleware";
12
11
  * import { getActionContext } from "astro:actions";
13
- * import { resolveActionRedirectDecision } from "@wocart/storefront/astro";
12
+ * import { runFormAction } from "@wocart/storefront/astro";
14
13
  *
15
14
  * export const onRequest = defineMiddleware(async (context, next) => {
16
15
  * const { action, setActionResult, serializeActionResult } =
17
16
  * getActionContext(context);
18
- * const decision = await resolveActionRedirectDecision({
17
+ * await runFormAction({
19
18
  * action: action && {
20
19
  * calledFrom: action.calledFrom,
21
20
  * name: action.name,
@@ -23,29 +22,21 @@
23
22
  * },
24
23
  * setActionResult,
25
24
  * serializeActionResult,
26
- * referer: context.request.headers.get("Referer"),
27
- * pathname: context.url.pathname,
28
25
  * });
29
- * if (decision.kind === "redirect") {
30
- * return context.redirect(decision.location, decision.status);
31
- * }
32
26
  * return next();
33
27
  * });
34
28
  * ```
35
29
  *
36
- * The pattern (Astro docs):
37
- * - On ERROR → store the result cookie + 303 redirect back to the form page
38
- * → the page re-renders, getActionResult() returns the error,
39
- * field errors are displayed to the user.
40
- * - On SUCCESS → store the result cookie + let next() render the page from
41
- * the same POST → getActionResult() returns the success data,
42
- * the page issues Astro.redirect('/thank-you?...') which the
43
- * browser follows.
30
+ * The page then renders from the same POST and reads the result with
31
+ * `getActionResult()`:
32
+ * - on SUCCESS it issues `Astro.redirect('/thank-you?…')`, which the browser
33
+ * follows;
34
+ * - on ERROR it renders the form again with the error in place.
44
35
  *
45
- * On success the caller MUST call next(), not context.redirect(): a redirect
46
- * drops the setActionResult cookie, getActionResult() sees undefined on the
47
- * next page, and the thank-you redirect never fires. That invariant is what
48
- * the "next on success" decision encodes — the tests pin it.
36
+ * Neither path may redirect from the middleware. Astro 7 holds an action
37
+ * result for the response being rendered only — a redirect arrives at the next
38
+ * page with no result, the thank-you redirect never fires and a refused order
39
+ * shows no message.
49
40
  */
50
41
  /**
51
42
  * The slice of an action result the decision reads: whether it errored.
@@ -73,28 +64,31 @@ export interface ActionMiddlewareDeps<TResult extends ActionHandlerResult = Acti
73
64
  setActionResult: (name: string, serialized: TSerialized) => void;
74
65
  /** Serializes an ActionResult into the cookie value (Astro's serializeActionResult). */
75
66
  serializeActionResult: (result: TResult) => TSerialized;
76
- /** The Referer request header, if the shopper's browser sent one. */
67
+ /** @deprecated Unused since 1.2.0: nothing redirects. */
77
68
  referer?: string | null;
78
- /** Path of the page the form posted to — the fallback redirect target. */
79
- pathname: string;
69
+ /** @deprecated Unused since 1.2.0: nothing redirects. */
70
+ pathname?: string;
80
71
  }
81
72
  export type PrgDecision =
82
- /** Redirect back to the form page (error path) — 303 See Other. */
73
+ /** Never produced since 1.2.0 — kept so existing `decision.kind` checks compile. */
83
74
  {
84
75
  kind: "redirect";
85
76
  location: string;
86
77
  status: 303;
87
78
  }
88
- /** Render the page from this POST (success / not an action / JSON action). */
79
+ /** Render the page from this POST. */
89
80
  | {
90
81
  kind: "next";
91
82
  };
92
83
  /**
93
- * Decide what the middleware should do for this request.
94
- *
95
- * Runs the action handler exactly when it was called from a form, stores the
96
- * serialized result cookie either way, and redirects back to the form page
97
- * on error (Referer first, the posted path as fallback). Success falls
98
- * through to "next" — never a redirect — so the result cookie survives.
84
+ * Run an action called from an HTML form and store its result for the page
85
+ * this POST renders. Resolves to that result, or `undefined` when the request
86
+ * is not a form-called action (a JSON call or a plain request is Astro's).
87
+ */
88
+ export declare function runFormAction<TResult extends ActionHandlerResult, TSerialized>(deps: Pick<ActionMiddlewareDeps<TResult, TSerialized>, "action" | "setActionResult" | "serializeActionResult">): Promise<TResult | undefined>;
89
+ /**
90
+ * @deprecated Use `runFormAction` and call `next()`. Runs the action the same
91
+ * way and always answers `{ kind: "next" }`: an errored post renders in place,
92
+ * because a redirect drops the result (see the note at the top of this file).
99
93
  */
100
94
  export declare function resolveActionRedirectDecision<TResult extends ActionHandlerResult, TSerialized>(deps: ActionMiddlewareDeps<TResult, TSerialized>): Promise<PrgDecision>;
@@ -1,21 +1,20 @@
1
1
  /**
2
- * Middleware engine — the POST/Redirect/GET pattern for Astro Actions,
3
- * framework-free.
2
+ * Middleware engine for Astro Actions called from an HTML form, framework-free.
4
3
  *
5
- * `astro:middleware` and `astro:actions` are virtual modules that only
6
- * exist inside an Astro project, so this file computes the DECISION and each
7
- * theme's middleware executes it:
4
+ * `astro:middleware` and `astro:actions` are virtual modules that only exist
5
+ * inside an Astro project, so this file runs the action and each theme's
6
+ * middleware wires it:
8
7
  *
9
8
  * ```ts
10
9
  * // src/middleware.ts
11
10
  * import { defineMiddleware } from "astro:middleware";
12
11
  * import { getActionContext } from "astro:actions";
13
- * import { resolveActionRedirectDecision } from "@wocart/storefront/astro";
12
+ * import { runFormAction } from "@wocart/storefront/astro";
14
13
  *
15
14
  * export const onRequest = defineMiddleware(async (context, next) => {
16
15
  * const { action, setActionResult, serializeActionResult } =
17
16
  * getActionContext(context);
18
- * const decision = await resolveActionRedirectDecision({
17
+ * await runFormAction({
19
18
  * action: action && {
20
19
  * calledFrom: action.calledFrom,
21
20
  * name: action.name,
@@ -23,50 +22,41 @@
23
22
  * },
24
23
  * setActionResult,
25
24
  * serializeActionResult,
26
- * referer: context.request.headers.get("Referer"),
27
- * pathname: context.url.pathname,
28
25
  * });
29
- * if (decision.kind === "redirect") {
30
- * return context.redirect(decision.location, decision.status);
31
- * }
32
26
  * return next();
33
27
  * });
34
28
  * ```
35
29
  *
36
- * The pattern (Astro docs):
37
- * - On ERROR → store the result cookie + 303 redirect back to the form page
38
- * → the page re-renders, getActionResult() returns the error,
39
- * field errors are displayed to the user.
40
- * - On SUCCESS → store the result cookie + let next() render the page from
41
- * the same POST → getActionResult() returns the success data,
42
- * the page issues Astro.redirect('/thank-you?...') which the
43
- * browser follows.
30
+ * The page then renders from the same POST and reads the result with
31
+ * `getActionResult()`:
32
+ * - on SUCCESS it issues `Astro.redirect('/thank-you?…')`, which the browser
33
+ * follows;
34
+ * - on ERROR it renders the form again with the error in place.
44
35
  *
45
- * On success the caller MUST call next(), not context.redirect(): a redirect
46
- * drops the setActionResult cookie, getActionResult() sees undefined on the
47
- * next page, and the thank-you redirect never fires. That invariant is what
48
- * the "next on success" decision encodes — the tests pin it.
36
+ * Neither path may redirect from the middleware. Astro 7 holds an action
37
+ * result for the response being rendered only — a redirect arrives at the next
38
+ * page with no result, the thank-you redirect never fires and a refused order
39
+ * shows no message.
49
40
  */
50
41
  /**
51
- * Decide what the middleware should do for this request.
52
- *
53
- * Runs the action handler exactly when it was called from a form, stores the
54
- * serialized result cookie either way, and redirects back to the form page
55
- * on error (Referer first, the posted path as fallback). Success falls
56
- * through to "next" — never a redirect — so the result cookie survives.
42
+ * Run an action called from an HTML form and store its result for the page
43
+ * this POST renders. Resolves to that result, or `undefined` when the request
44
+ * is not a form-called action (a JSON call or a plain request is Astro's).
45
+ */
46
+ export async function runFormAction(deps) {
47
+ const { action, setActionResult, serializeActionResult } = deps;
48
+ if (action?.calledFrom !== "form")
49
+ return undefined;
50
+ const result = await action.handler();
51
+ setActionResult(action.name, serializeActionResult(result));
52
+ return result;
53
+ }
54
+ /**
55
+ * @deprecated Use `runFormAction` and call `next()`. Runs the action the same
56
+ * way and always answers `{ kind: "next" }`: an errored post renders in place,
57
+ * because a redirect drops the result (see the note at the top of this file).
57
58
  */
58
59
  export async function resolveActionRedirectDecision(deps) {
59
- const { action, setActionResult, serializeActionResult, referer, pathname } = deps;
60
- if (action?.calledFrom === "form") {
61
- const result = await action.handler();
62
- setActionResult(action.name, serializeActionResult(result));
63
- if (result.error) {
64
- // Redirect back to the form page so getActionResult() can surface
65
- // field errors.
66
- return { kind: "redirect", location: referer ?? pathname, status: 303 };
67
- }
68
- // Success: let next() render the page; the page will redirect to
69
- // /thank-you. Calling a redirect here would drop the result cookie.
70
- }
60
+ await runFormAction(deps);
71
61
  return { kind: "next" };
72
62
  }
package/dist/schemas.d.ts CHANGED
@@ -692,6 +692,15 @@ export declare const CartValidationSchema: z.ZodObject<{
692
692
  qualified: z.ZodBoolean;
693
693
  remaining: z.ZodNumber;
694
694
  }, z.core.$strip>;
695
+ delivery: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
696
+ available: z.ZodLiteral<true>;
697
+ fee: z.ZodNumber;
698
+ total: z.ZodNumber;
699
+ }, z.core.$strip>, z.ZodObject<{
700
+ available: z.ZodLiteral<false>;
701
+ fee: z.ZodNull;
702
+ total: z.ZodNull;
703
+ }, z.core.$strip>], "available">>;
695
704
  }, z.core.$strip>;
696
705
  export declare const CreatedOrderSchema: z.ZodObject<{
697
706
  orderId: z.ZodString;
package/dist/schemas.js CHANGED
@@ -314,6 +314,12 @@ export const CartValidationSchema = z.object({
314
314
  qualified: z.boolean(),
315
315
  remaining: z.number(),
316
316
  }),
317
+ delivery: z
318
+ .discriminatedUnion("available", [
319
+ z.object({ available: z.literal(true), fee: z.number(), total: z.number() }),
320
+ z.object({ available: z.literal(false), fee: z.null(), total: z.null() }),
321
+ ])
322
+ .optional(),
317
323
  });
318
324
  export const CreatedOrderSchema = z.object({
319
325
  orderId: z.string(),
package/dist/types.d.ts CHANGED
@@ -275,7 +275,24 @@ export interface CartValidation {
275
275
  qualified: boolean;
276
276
  remaining: number;
277
277
  };
278
+ /**
279
+ * Present when `validate` was given a destination: what the order would
280
+ * charge there, from the same quote the order itself uses (shipping
281
+ * profiles, commune overrides, the free-delivery threshold and offers).
282
+ * `available: false` — fee and total null — means the store does not
283
+ * deliver there, and an order to it is refused.
284
+ */
285
+ delivery?: CartDeliveryQuote | undefined;
278
286
  }
287
+ export type CartDeliveryQuote = {
288
+ available: true;
289
+ fee: number;
290
+ total: number;
291
+ } | {
292
+ available: false;
293
+ fee: null;
294
+ total: null;
295
+ };
279
296
  /** What POST /store/orders returns on 201. */
280
297
  export interface CreatedOrder {
281
298
  orderId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",