@wocart/storefront 1.1.0 → 1.2.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 +51 -0
- package/README.md +1 -1
- package/USAGE.md +105 -49
- package/dist/actions.d.ts +5 -4
- package/dist/actions.js +14 -9
- package/dist/astro.d.ts +3 -1
- package/dist/astro.js +2 -1
- package/dist/client/core.d.ts +8 -0
- package/dist/client/core.js +12 -2
- package/dist/client/orders.d.ts +5 -2
- package/dist/client/orders.js +11 -5
- package/dist/client/page-data.js +3 -2
- package/dist/client/pages.js +5 -3
- package/dist/client/products.js +3 -2
- package/dist/endpoints/proxy.d.ts +5 -3
- package/dist/endpoints/proxy.js +21 -4
- package/dist/endpoints/storefront-routes.d.ts +50 -0
- package/dist/endpoints/storefront-routes.js +133 -0
- package/dist/middleware.d.ts +29 -35
- package/dist/middleware.js +32 -42
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,57 @@ 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.2.0 — 2026-10-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`createStorefrontRoutes`** (`@wocart/storefront/astro`): the eight
|
|
13
|
+
same-origin API routes every storefront needs — OTP send and verify, cart
|
|
14
|
+
validation, abandoned-checkout tracking and conversion, communes, and
|
|
15
|
+
reviews (list and submit) — ready to export, so a theme's route file is one
|
|
16
|
+
line. Every POST refuses cross-origin requests, the OTP and abandoned
|
|
17
|
+
routes forward the shopper's address, and input is bounded before it is
|
|
18
|
+
sent. The theme passes only the request's own client.
|
|
19
|
+
- **`runFormAction`** (`@wocart/storefront/astro`) runs an action posted from
|
|
20
|
+
a form and stores its result for the page that POST renders.
|
|
21
|
+
- `orders.otp.send` and `orders.otp.verify` take the shopper's forwarded
|
|
22
|
+
headers as a last argument. The server limits sends and attempts per
|
|
23
|
+
shopper; without them every shopper of a store shared the theme worker's
|
|
24
|
+
one limit.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **A proxy route's failure status follows the server's category** when
|
|
29
|
+
`errorStatus` is unset: 400 for validation, 422 for a business refusal, 429
|
|
30
|
+
when the server named a wait, 504 for a timeout, and 502 for anything the
|
|
31
|
+
shopper cannot fix (and for a failure that carries no `domainError`, as
|
|
32
|
+
before). An explicit `errorStatus` still wins.
|
|
33
|
+
- A client function that throws answers 502 with
|
|
34
|
+
`code: "PROXY_UPSTREAM_FAILED"` and a generic message; the exception's own
|
|
35
|
+
text no longer reaches the browser.
|
|
36
|
+
- `placeOrderInput` refusals name their field without a sentence. The Arabic
|
|
37
|
+
messages it carried reached French and English stores as-is; the theme
|
|
38
|
+
answers each field with its own copy.
|
|
39
|
+
- `placeOrderInput` and `executePlaceOrder` accept a `pricePerUnit` of 0. The
|
|
40
|
+
price is display-only, and a product the merchant prices at 0 was not
|
|
41
|
+
orderable from its product page.
|
|
42
|
+
|
|
43
|
+
### Deprecated
|
|
44
|
+
|
|
45
|
+
- `resolveActionRedirectDecision`: use `runFormAction` and call `next()`. It
|
|
46
|
+
now always answers `{ kind: "next" }`. Its 303 back to the form on an
|
|
47
|
+
error lost the result in Astro 7 — the shopper returned to the form with no
|
|
48
|
+
message — so an errored post now renders in place. Its `referer` and
|
|
49
|
+
`pathname` inputs are unused.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- A server error's `context` can no longer overwrite the result's own
|
|
54
|
+
`success`, `error`, `code` or `domainError` fields.
|
|
55
|
+
- A `*_NOT_FOUND` answer from `products.get`, `pages.get`, `pages.landing`,
|
|
56
|
+
`pageData.get` or `orders.tracking` is absence, not a failure: it no longer
|
|
57
|
+
reaches `onError` as a degraded read.
|
|
58
|
+
|
|
8
59
|
## 1.1.0 — 2026-10-14
|
|
9
60
|
|
|
10
61
|
### 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`, `
|
|
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
|
@@ -915,6 +915,12 @@ verify returns a verified status with otpToken on success. Failure context can
|
|
|
915
915
|
include attemptsRemaining and a terminal state. Use it for the countdown,
|
|
916
916
|
retry, and lockout UI.
|
|
917
917
|
|
|
918
|
+
The server limits sends and attempts per shopper. Both calls take the
|
|
919
|
+
shopper's forwarded headers as their last argument
|
|
920
|
+
(`send(phone, collectForwardedHeaders(request))`); without them every shopper
|
|
921
|
+
of the store shares the one limit of the theme's worker. The `otpSend` and
|
|
922
|
+
`otpVerify` routes of `createStorefrontRoutes` pass them for you.
|
|
923
|
+
|
|
918
924
|
### Using the SDK's OtpMachine
|
|
919
925
|
|
|
920
926
|
The SDK exports `createOtpMachine` to manage the complete interactive WhatsApp
|
|
@@ -1017,6 +1023,8 @@ wrapper while the SDK owns reusable validation and execution logic.
|
|
|
1017
1023
|
placeOrderInput is the shared form schema. It:
|
|
1018
1024
|
|
|
1019
1025
|
- whitelists supported fields and strips unknown fields;
|
|
1026
|
+
- names the failing field on a refusal, with no shopper-facing sentence — the
|
|
1027
|
+
theme answers each field with its own copy in the store's language;
|
|
1020
1028
|
- coerces form number strings;
|
|
1021
1029
|
- turns empty optional values into undefined;
|
|
1022
1030
|
- canonicalizes phone input;
|
|
@@ -1046,7 +1054,7 @@ export const server = {
|
|
|
1046
1054
|
|
|
1047
1055
|
if (!result.success) {
|
|
1048
1056
|
throw new ActionError({
|
|
1049
|
-
code: "BAD_REQUEST",
|
|
1057
|
+
code: result.errorKind === "shape" ? "BAD_REQUEST" : "UNPROCESSABLE_CONTENT",
|
|
1050
1058
|
message: result.error,
|
|
1051
1059
|
});
|
|
1052
1060
|
}
|
|
@@ -1076,71 +1084,113 @@ order request:
|
|
|
1076
1084
|
Use it when the server route is the trusted boundary that forwards the order.
|
|
1077
1085
|
Do not let arbitrary browser code choose identity headers.
|
|
1078
1086
|
|
|
1079
|
-
###
|
|
1087
|
+
### Running the action from middleware
|
|
1080
1088
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1089
|
+
runFormAction runs an action posted from an HTML form and stores its result
|
|
1090
|
+
for the page that POST renders:
|
|
1083
1091
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1092
|
+
~~~ts
|
|
1093
|
+
// src/middleware.ts
|
|
1094
|
+
import { defineMiddleware } from "astro:middleware";
|
|
1095
|
+
import { getActionContext } from "astro:actions";
|
|
1096
|
+
import { runFormAction } from "@wocart/storefront/astro";
|
|
1097
|
+
|
|
1098
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
1099
|
+
const { action, setActionResult, serializeActionResult } = getActionContext(context);
|
|
1100
|
+
await runFormAction({
|
|
1101
|
+
action: action && {
|
|
1102
|
+
calledFrom: action.calledFrom,
|
|
1103
|
+
name: action.name,
|
|
1104
|
+
handler: action.handler,
|
|
1105
|
+
},
|
|
1106
|
+
setActionResult,
|
|
1107
|
+
serializeActionResult,
|
|
1108
|
+
});
|
|
1109
|
+
return next();
|
|
1110
|
+
});
|
|
1111
|
+
~~~
|
|
1089
1112
|
|
|
1090
|
-
The
|
|
1091
|
-
|
|
1092
|
-
the
|
|
1113
|
+
The page reads the result with `Astro.getActionResult()`: on success it
|
|
1114
|
+
redirects to the thank-you page, and on a refusal it renders the form again
|
|
1115
|
+
with the error in place.
|
|
1116
|
+
|
|
1117
|
+
Never redirect from the middleware, on success or on error. Astro 7 keeps an
|
|
1118
|
+
action result for the response being rendered only, so a redirect arrives at
|
|
1119
|
+
the next page with no result: the thank-you redirect never fires, and a
|
|
1120
|
+
refused order shows no message. resolveActionRedirectDecision, the earlier
|
|
1121
|
+
name, is deprecated and now always answers next.
|
|
1093
1122
|
|
|
1094
1123
|
## Browser-facing proxy routes
|
|
1095
1124
|
|
|
1096
|
-
Browser code
|
|
1097
|
-
|
|
1125
|
+
Browser code calls same-origin theme routes, which call the platform with the
|
|
1126
|
+
server-only API key. The key never reaches the browser.
|
|
1098
1127
|
|
|
1099
|
-
|
|
1100
|
-
validation, parameter parsing, attribution forwarding, and error shape.
|
|
1128
|
+
### The storefront routes, ready to export
|
|
1101
1129
|
|
|
1102
|
-
|
|
1130
|
+
createStorefrontRoutes returns the eight routes every storefront needs. Bind
|
|
1131
|
+
them once to the request's own client:
|
|
1103
1132
|
|
|
1104
1133
|
~~~ts
|
|
1105
|
-
|
|
1106
|
-
import {
|
|
1107
|
-
|
|
1108
|
-
import { BasketLineInputSchema, MAX_BASKET_LINES } from "@wocart/storefront";
|
|
1134
|
+
// src/theme/api-routes.ts
|
|
1135
|
+
import type { APIContext } from "astro";
|
|
1136
|
+
import { createStorefrontRoutes } from "@wocart/storefront/astro";
|
|
1109
1137
|
|
|
1110
|
-
const
|
|
1111
|
-
|
|
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 }),
|
|
1138
|
+
export const routes = createStorefrontRoutes<APIContext>({
|
|
1139
|
+
client: (context) => context.locals.store.client,
|
|
1119
1140
|
});
|
|
1120
1141
|
~~~
|
|
1121
1142
|
|
|
1122
|
-
|
|
1123
|
-
directly and never needs the API key.
|
|
1124
|
-
|
|
1125
|
-
### Order route with attribution
|
|
1143
|
+
Each route file then exports one of them:
|
|
1126
1144
|
|
|
1127
1145
|
~~~ts
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
forwardAttribution: true,
|
|
1132
|
-
successStatus: 201,
|
|
1133
|
-
errorStatus: 422,
|
|
1134
|
-
});
|
|
1146
|
+
// src/pages/api/otp/send.ts
|
|
1147
|
+
import { routes } from "@/theme/api-routes";
|
|
1148
|
+
export const POST = routes.otpSend;
|
|
1135
1149
|
~~~
|
|
1136
1150
|
|
|
1137
|
-
|
|
1151
|
+
| Route | Method | Browser sends | Answers |
|
|
1152
|
+
| --- | --- | --- | --- |
|
|
1153
|
+
| `otpSend` | POST | `{ phone }` | `{ data: OtpSendResult }` |
|
|
1154
|
+
| `otpVerify` | POST | `{ phone, requestId, code }` | `{ data: OtpVerifyResult }` |
|
|
1155
|
+
| `cartValidate` | POST | `{ items }` | `{ success: true, data: CartValidation }` |
|
|
1156
|
+
| `abandonedUpsert` | POST | `AbandonedCheckoutInput` | 204 |
|
|
1157
|
+
| `abandonedConvert` | POST | `{ sessionId, orderId, orderNumber }` | 204 |
|
|
1158
|
+
| `communes` | GET | path param `wilayaId` | `{ data: Commune[] }` |
|
|
1159
|
+
| `reviewsList` | GET | path param `productId`, query `limit` (1–50, default 20) and `offset` | `{ data: Review[], total }` |
|
|
1160
|
+
| `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id } }` |
|
|
1161
|
+
|
|
1162
|
+
What every route gets right:
|
|
1163
|
+
|
|
1164
|
+
- every POST refuses a cross-origin request with 403;
|
|
1165
|
+
- the OTP routes and abandoned tracking forward the shopper's address and
|
|
1166
|
+
user agent, so the server limits and attributes per shopper;
|
|
1167
|
+
- input is bounded before it is sent, and a malformed body is a 400 that never
|
|
1168
|
+
reaches the server;
|
|
1169
|
+
- a refusal answers with the status the server's category implies — 400 for
|
|
1170
|
+
validation, 422 for a business refusal, 429 when the server named a wait —
|
|
1171
|
+
and carries the server's `code` and context (`windowSeconds`,
|
|
1172
|
+
`attemptsRemaining`, `terminal`), which is what the browser branches on;
|
|
1173
|
+
- a failure the shopper cannot fix (a wrong store key, a server fault, a
|
|
1174
|
+
dropped connection) is a 502, and a timeout a 504.
|
|
1175
|
+
|
|
1176
|
+
The client resolver runs once per request. Return the request's own client,
|
|
1177
|
+
never one kept across requests.
|
|
1178
|
+
|
|
1179
|
+
### A custom route
|
|
1180
|
+
|
|
1181
|
+
createProxyEndpoint is the factory behind those routes, for a route of the
|
|
1182
|
+
theme's own. Its client function returns a result (`{ success, data }` or a
|
|
1183
|
+
failure), so wrap a read that returns a plain value:
|
|
1138
1184
|
|
|
1139
1185
|
~~~ts
|
|
1186
|
+
import { z } from "zod";
|
|
1187
|
+
import { createProxyEndpoint } from "@wocart/storefront/astro";
|
|
1188
|
+
|
|
1140
1189
|
export const GET = createProxyEndpoint({
|
|
1141
1190
|
method: "GET",
|
|
1142
|
-
|
|
1143
|
-
|
|
1191
|
+
pathParams: { slug: "string" },
|
|
1192
|
+
inputSchema: z.object({ slug: z.string().min(1).max(200) }),
|
|
1193
|
+
client: async ({ slug }) => ({ success: true, data: await store.products.get(slug) }),
|
|
1144
1194
|
});
|
|
1145
1195
|
~~~
|
|
1146
1196
|
|
|
@@ -1153,12 +1203,18 @@ Proxy configuration supports:
|
|
|
1153
1203
|
- numeric or string path parameter parsing;
|
|
1154
1204
|
- CSRF protection, enabled by default for POST and disabled by default for GET;
|
|
1155
1205
|
- attribution forwarding;
|
|
1156
|
-
- custom success
|
|
1157
|
-
-
|
|
1158
|
-
|
|
1206
|
+
- a custom success status, successBody and noContent;
|
|
1207
|
+
- errorStatus, which overrides the status derived from the failure. Leave it
|
|
1208
|
+
unset unless the browser contract needs one fixed status.
|
|
1209
|
+
|
|
1210
|
+
A thrown client function answers 502 with `code: "PROXY_UPSTREAM_FAILED"`
|
|
1211
|
+
and a generic message: an exception's own text never reaches the browser.
|
|
1212
|
+
|
|
1213
|
+
Orders do not go through a proxy route: they go through the placeOrder action
|
|
1214
|
+
(see above), which validates the form and forwards attribution itself.
|
|
1159
1215
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1216
|
+
Do not solve a proxy problem by moving the API key into public environment
|
|
1217
|
+
variables.
|
|
1162
1218
|
|
|
1163
1219
|
## Money, images, and presentation utilities
|
|
1164
1220
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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 ||
|
|
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";
|
package/dist/client/core.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/client/core.js
CHANGED
|
@@ -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
|
*
|
package/dist/client/orders.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/client/orders.js
CHANGED
|
@@ -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
|
-
|
|
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 }, {
|
|
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
|
};
|
package/dist/client/page-data.js
CHANGED
|
@@ -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
|
-
|
|
31
|
+
if (!isNotFoundAnswer(error))
|
|
32
|
+
reportDegradedRead(core.config, error, "pageData.get");
|
|
32
33
|
return null;
|
|
33
34
|
}
|
|
34
35
|
},
|
package/dist/client/pages.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
40
|
+
if (!isNotFoundAnswer(error))
|
|
41
|
+
reportDegradedRead(core.config, error, "pages.landing");
|
|
40
42
|
return null;
|
|
41
43
|
}
|
|
42
44
|
},
|
package/dist/client/products.js
CHANGED
|
@@ -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
|
-
|
|
30
|
+
if (!isNotFoundAnswer(error))
|
|
31
|
+
reportDegradedRead(core.config, error, "products.get");
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
34
|
},
|
|
@@ -69,9 +69,11 @@ export interface ProxyEndpointConfig<TInput, TOutput> {
|
|
|
69
69
|
*/
|
|
70
70
|
successStatus?: number;
|
|
71
71
|
/**
|
|
72
|
-
* Status code for upstream
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
/**
|
package/dist/endpoints/proxy.js
CHANGED
|
@@ -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
|
|
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
|
|
72
|
-
return jsonResponse({ error:
|
|
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,50 @@
|
|
|
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
|
+
/** POST `{ items }` → `{ success: true, data: CartValidation }`. */
|
|
36
|
+
readonly cartValidate: StorefrontApiRoute<C>;
|
|
37
|
+
/** POST an `AbandonedCheckoutInput` → 204. */
|
|
38
|
+
readonly abandonedUpsert: StorefrontApiRoute<C>;
|
|
39
|
+
/** POST `{ sessionId, orderId, orderNumber }` → 204. */
|
|
40
|
+
readonly abandonedConvert: StorefrontApiRoute<C>;
|
|
41
|
+
/** GET, path param `wilayaId` → `{ data: Commune[] }`. */
|
|
42
|
+
readonly communes: StorefrontApiRoute<C>;
|
|
43
|
+
/** GET, path param `productId`, query `limit`/`offset` → `{ data: Review[], total }`. */
|
|
44
|
+
readonly reviewsList: StorefrontApiRoute<C>;
|
|
45
|
+
/** POST a `ReviewInput` → 201 `{ success: true, data: { id } }`. */
|
|
46
|
+
readonly reviewSubmit: StorefrontApiRoute<C>;
|
|
47
|
+
}
|
|
48
|
+
export declare const REVIEWS_PAGE_DEFAULT = 20;
|
|
49
|
+
export declare const REVIEWS_PAGE_MAX = 50;
|
|
50
|
+
export declare function createStorefrontRoutes<C extends AstroAPIContext = AstroAPIContext>(options: StorefrontRoutesOptions<C>): StorefrontRoutes<C>;
|
|
@@ -0,0 +1,133 @@
|
|
|
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, 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
|
+
});
|
|
37
|
+
const abandonedConvertInput = z.object({
|
|
38
|
+
sessionId: z.string().uuid(),
|
|
39
|
+
orderId: z.string().min(1).max(200),
|
|
40
|
+
orderNumber: z.string().min(1).max(100),
|
|
41
|
+
});
|
|
42
|
+
const communesInput = z.object({ wilayaId: WilayaIdSchema });
|
|
43
|
+
const reviewsListInput = z.object({
|
|
44
|
+
productId: z.string().min(1).max(200),
|
|
45
|
+
limit: z.coerce
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.min(1)
|
|
49
|
+
.catch(REVIEWS_PAGE_DEFAULT)
|
|
50
|
+
.transform((n) => Math.min(n, REVIEWS_PAGE_MAX)),
|
|
51
|
+
offset: z.coerce.number().int().min(0).catch(0),
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* The shape of a review, bounded so the body stays small. The rules — the
|
|
55
|
+
* order-number format, the rating range, the body's length — are the
|
|
56
|
+
* server's, and its refusal carries the code the form answers by.
|
|
57
|
+
*/
|
|
58
|
+
const reviewSubmitInput = z.object({
|
|
59
|
+
orderNumber: z.string().min(1).max(64),
|
|
60
|
+
productId: z.string().min(1).max(200),
|
|
61
|
+
rating: z.number(),
|
|
62
|
+
title: z.string().max(500).optional(),
|
|
63
|
+
body: z.string().max(5000),
|
|
64
|
+
});
|
|
65
|
+
export function createStorefrontRoutes(options) {
|
|
66
|
+
const route = (config) => async (context) => {
|
|
67
|
+
const { call, ...endpoint } = config;
|
|
68
|
+
return createProxyEndpoint({
|
|
69
|
+
...endpoint,
|
|
70
|
+
client: (input, headers) => call(options.client(context), input, headers),
|
|
71
|
+
})(context);
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
otpSend: route({
|
|
75
|
+
method: "POST",
|
|
76
|
+
inputSchema: otpSendInput,
|
|
77
|
+
forwardAttribution: true,
|
|
78
|
+
call: (client, { phone }, headers) => client.orders.otp.send(phone, headers),
|
|
79
|
+
}),
|
|
80
|
+
otpVerify: route({
|
|
81
|
+
method: "POST",
|
|
82
|
+
inputSchema: otpVerifyInput,
|
|
83
|
+
forwardAttribution: true,
|
|
84
|
+
call: (client, { phone, requestId, code }, headers) => client.orders.otp.verify(phone, requestId, code, headers),
|
|
85
|
+
}),
|
|
86
|
+
cartValidate: route({
|
|
87
|
+
method: "POST",
|
|
88
|
+
inputSchema: cartValidateInput,
|
|
89
|
+
successBody: (data) => ({ success: true, data }),
|
|
90
|
+
call: (client, { items }) => client.cart.validate(items),
|
|
91
|
+
}),
|
|
92
|
+
abandonedUpsert: route({
|
|
93
|
+
method: "POST",
|
|
94
|
+
inputSchema: AbandonedCheckoutInputSchema,
|
|
95
|
+
forwardAttribution: true,
|
|
96
|
+
noContent: true,
|
|
97
|
+
call: (client, input, headers) => client.orders.abandoned.upsert(input, headers),
|
|
98
|
+
}),
|
|
99
|
+
abandonedConvert: route({
|
|
100
|
+
method: "POST",
|
|
101
|
+
inputSchema: abandonedConvertInput,
|
|
102
|
+
noContent: true,
|
|
103
|
+
call: (client, { sessionId, orderId, orderNumber }) => client.orders.abandoned.convert(sessionId, orderId, orderNumber),
|
|
104
|
+
}),
|
|
105
|
+
communes: route({
|
|
106
|
+
method: "GET",
|
|
107
|
+
pathParams: { wilayaId: "number" },
|
|
108
|
+
inputSchema: communesInput,
|
|
109
|
+
call: async (client, { wilayaId }) => ({
|
|
110
|
+
success: true,
|
|
111
|
+
data: await client.geo.communes(wilayaId),
|
|
112
|
+
}),
|
|
113
|
+
}),
|
|
114
|
+
reviewsList: route({
|
|
115
|
+
method: "GET",
|
|
116
|
+
pathParams: { productId: "string" },
|
|
117
|
+
queryParams: ["limit", "offset"],
|
|
118
|
+
inputSchema: reviewsListInput,
|
|
119
|
+
successBody: ({ rows, total }) => ({ data: rows, total }),
|
|
120
|
+
call: async (client, { productId, limit, offset }) => ({
|
|
121
|
+
success: true,
|
|
122
|
+
data: await client.products.reviews.list(productId, { limit, offset }),
|
|
123
|
+
}),
|
|
124
|
+
}),
|
|
125
|
+
reviewSubmit: route({
|
|
126
|
+
method: "POST",
|
|
127
|
+
inputSchema: reviewSubmitInput,
|
|
128
|
+
successStatus: 201,
|
|
129
|
+
successBody: (data) => ({ success: true, data }),
|
|
130
|
+
call: (client, { title, ...review }) => client.products.reviews.submit(title === undefined ? review : { ...review, title }),
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
133
|
+
}
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Middleware engine
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* -
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
/**
|
|
67
|
+
/** @deprecated Unused since 1.2.0: nothing redirects. */
|
|
77
68
|
referer?: string | null;
|
|
78
|
-
/**
|
|
79
|
-
pathname
|
|
69
|
+
/** @deprecated Unused since 1.2.0: nothing redirects. */
|
|
70
|
+
pathname?: string;
|
|
80
71
|
}
|
|
81
72
|
export type PrgDecision =
|
|
82
|
-
/**
|
|
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
|
|
79
|
+
/** Render the page from this POST. */
|
|
89
80
|
| {
|
|
90
81
|
kind: "next";
|
|
91
82
|
};
|
|
92
83
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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>;
|
package/dist/middleware.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Middleware engine
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* -
|
|
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
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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/package.json
CHANGED