@wocart/storefront 1.0.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.
@@ -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
+ }
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.0.0",
3
+ "version": "1.2.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",