@wocart/storefront 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +143 -2
  5. package/USAGE.md +1465 -0
  6. package/dist/abandoned-checkout.d.ts +22 -0
  7. package/dist/abandoned-checkout.js +89 -0
  8. package/dist/actions.d.ts +117 -0
  9. package/dist/actions.js +196 -0
  10. package/dist/astro.d.ts +6 -0
  11. package/dist/astro.js +3 -0
  12. package/dist/basket.d.ts +87 -0
  13. package/dist/basket.js +258 -0
  14. package/dist/client/cart.d.ts +30 -0
  15. package/dist/client/cart.js +26 -0
  16. package/dist/client/categories.d.ts +25 -0
  17. package/dist/client/categories.js +37 -0
  18. package/dist/client/config.d.ts +29 -0
  19. package/dist/client/config.js +41 -0
  20. package/dist/client/core.d.ts +86 -0
  21. package/dist/client/core.js +582 -0
  22. package/dist/client/errors.d.ts +19 -0
  23. package/dist/client/errors.js +31 -0
  24. package/dist/client/geo.d.ts +31 -0
  25. package/dist/client/geo.js +39 -0
  26. package/dist/client/index.d.ts +39 -0
  27. package/dist/client/index.js +80 -0
  28. package/dist/client/orders.d.ts +68 -0
  29. package/dist/client/orders.js +76 -0
  30. package/dist/client/page-data.d.ts +31 -0
  31. package/dist/client/page-data.js +37 -0
  32. package/dist/client/pages.d.ts +33 -0
  33. package/dist/client/pages.js +44 -0
  34. package/dist/client/paths.d.ts +8 -0
  35. package/dist/client/paths.js +10 -0
  36. package/dist/client/products.d.ts +56 -0
  37. package/dist/client/products.js +78 -0
  38. package/dist/client/shipping.d.ts +37 -0
  39. package/dist/client/shipping.js +48 -0
  40. package/dist/data/communes.d.ts +25 -0
  41. package/dist/data/communes.js +1690 -0
  42. package/dist/data/wilayas.d.ts +6 -0
  43. package/dist/data/wilayas.js +64 -0
  44. package/dist/domain.d.ts +332 -0
  45. package/dist/domain.js +214 -0
  46. package/dist/endpoints/proxy.d.ts +111 -0
  47. package/dist/endpoints/proxy.js +161 -0
  48. package/dist/index.d.ts +42 -0
  49. package/dist/index.js +30 -0
  50. package/dist/locale-routing.d.ts +59 -0
  51. package/dist/locale-routing.js +62 -0
  52. package/dist/locales.d.ts +15 -0
  53. package/dist/locales.js +8 -0
  54. package/dist/meta-tracking.d.ts +34 -0
  55. package/dist/meta-tracking.js +72 -0
  56. package/dist/middleware.d.ts +100 -0
  57. package/dist/middleware.js +72 -0
  58. package/dist/offer-view.d.ts +10 -0
  59. package/dist/offer-view.js +13 -0
  60. package/dist/otp.d.ts +80 -0
  61. package/dist/otp.js +295 -0
  62. package/dist/phone.d.ts +2 -0
  63. package/dist/phone.js +34 -0
  64. package/dist/product-view.d.ts +12 -0
  65. package/dist/product-view.js +42 -0
  66. package/dist/routes.d.ts +45 -0
  67. package/dist/routes.js +59 -0
  68. package/dist/schemas.d.ts +753 -0
  69. package/dist/schemas.js +377 -0
  70. package/dist/server.d.ts +4 -0
  71. package/dist/server.js +2 -0
  72. package/dist/types.d.ts +418 -0
  73. package/dist/types.js +1 -0
  74. package/dist/utils/image.d.ts +140 -0
  75. package/dist/utils/image.js +131 -0
  76. package/dist/utils/money.d.ts +39 -0
  77. package/dist/utils/money.js +43 -0
  78. package/dist/utils/product-image.d.ts +37 -0
  79. package/dist/utils/product-image.js +38 -0
  80. package/dist/whatsapp.d.ts +55 -0
  81. package/dist/whatsapp.js +89 -0
  82. package/package.json +76 -4
@@ -0,0 +1,22 @@
1
+ import type { AbandonedCheckoutInput } from "./domain.js";
2
+ export type AbandonedCheckoutSnapshot = Omit<AbandonedCheckoutInput, "sessionId" | "fbc" | "fbp">;
3
+ export interface AbandonedCheckoutStorage {
4
+ getItem(key: string): string | null;
5
+ setItem(key: string, value: string): void;
6
+ }
7
+ export interface AbandonedCheckoutPorts {
8
+ readonly storage: AbandonedCheckoutStorage;
9
+ readonly createId: () => string;
10
+ readonly schedule: (callback: () => void, delayMs: number) => unknown;
11
+ readonly cancel: (handle: unknown) => void;
12
+ readonly readCookie: (name: string) => string | undefined;
13
+ readonly send: (payload: AbandonedCheckoutInput) => Promise<void>;
14
+ readonly beacon: (payload: AbandonedCheckoutInput) => void;
15
+ }
16
+ export interface AbandonedCheckoutTracker {
17
+ schedule(snapshot: AbandonedCheckoutSnapshot): void;
18
+ update(snapshot: AbandonedCheckoutSnapshot): void;
19
+ captureOnExit(snapshot: AbandonedCheckoutSnapshot): void;
20
+ isCapturable(snapshot: AbandonedCheckoutSnapshot): boolean;
21
+ }
22
+ export declare function createAbandonedCheckoutTracker(ports: AbandonedCheckoutPorts): AbandonedCheckoutTracker;
@@ -0,0 +1,89 @@
1
+ import { toLocalAlgerianMobile } from "./phone.js";
2
+ const SESSION_KEY = "cod_session_id";
3
+ const DEBOUNCE_MS = 3000;
4
+ export function createAbandonedCheckoutTracker(ports) {
5
+ let sessionId = readSessionId(ports);
6
+ let timer = null;
7
+ let hasSentInitial = false;
8
+ const payloadFor = (snapshot) => {
9
+ const fbc = readCookie(ports, "_fbc");
10
+ const fbp = readCookie(ports, "_fbp");
11
+ return {
12
+ ...snapshot,
13
+ sessionId,
14
+ ...(fbc === undefined ? {} : { fbc }),
15
+ ...(fbp === undefined ? {} : { fbp }),
16
+ };
17
+ };
18
+ const send = (snapshot) => {
19
+ try {
20
+ if (!isCapturable(snapshot))
21
+ return;
22
+ const payload = payloadFor(snapshot);
23
+ void Promise.resolve(ports.send(payload)).then(() => {
24
+ hasSentInitial = true;
25
+ }, () => undefined);
26
+ }
27
+ catch {
28
+ return;
29
+ }
30
+ };
31
+ const isCapturable = (snapshot) => snapshot.customerName.trim().length >= 2 &&
32
+ toLocalAlgerianMobile(snapshot.phone) !== null;
33
+ return {
34
+ schedule(snapshot) {
35
+ try {
36
+ if (timer !== null)
37
+ ports.cancel(timer);
38
+ timer = ports.schedule(() => {
39
+ timer = null;
40
+ send(snapshot);
41
+ }, DEBOUNCE_MS);
42
+ }
43
+ catch {
44
+ timer = null;
45
+ }
46
+ },
47
+ update(snapshot) {
48
+ try {
49
+ if (hasSentInitial)
50
+ send(snapshot);
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ },
56
+ captureOnExit(snapshot) {
57
+ try {
58
+ if (!isCapturable(snapshot))
59
+ return;
60
+ ports.beacon(payloadFor(snapshot));
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ },
66
+ isCapturable,
67
+ };
68
+ }
69
+ function readCookie(ports, name) {
70
+ try {
71
+ return ports.readCookie(name);
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ function readSessionId(ports) {
78
+ try {
79
+ const existing = ports.storage.getItem(SESSION_KEY);
80
+ if (existing)
81
+ return existing;
82
+ const created = ports.createId();
83
+ ports.storage.setItem(SESSION_KEY, created);
84
+ return created;
85
+ }
86
+ catch {
87
+ return ports.createId();
88
+ }
89
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Storefront Actions — the placeOrder action's engine, framework-free.
3
+ *
4
+ * Astro's `defineAction`/`ActionError` live in `astro:actions`, a virtual
5
+ * module that only exists inside an Astro project — so the engine moves here
6
+ * and each theme keeps a thin wrapper:
7
+ *
8
+ * ```ts
9
+ * // src/actions/index.ts
10
+ * import { ActionError, defineAction } from "astro:actions";
11
+ * import { executePlaceOrder, placeOrderInput } from "@wocart/storefront/astro";
12
+ *
13
+ * export const server = {
14
+ * placeOrder: defineAction({
15
+ * accept: "form",
16
+ * input: placeOrderInput,
17
+ * handler: async (input, context) => {
18
+ * const result = await executePlaceOrder(input, context.request, client);
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);
24
+ * }
25
+ * return result.data;
26
+ * },
27
+ * }),
28
+ * };
29
+ * ```
30
+ *
31
+ * Everything decision-bearing — the input whitelist, the basket-or-product
32
+ * rule, attribution forwarding, the exact wire body — lives here, tested
33
+ * without Astro.
34
+ */
35
+ import { z } from "zod";
36
+ import type { StoreClient } from "./client/index.js";
37
+ import type { DomainError } from "./domain.js";
38
+ import type { CreatedOrder } from "./types.js";
39
+ /**
40
+ * The order form's input whitelist.
41
+ *
42
+ * zod STRIPS every key this schema does not name, which is why a field the
43
+ * storefront renders but this schema forgets silently never reaches
44
+ * the server. Form parsing needs a plain object schema
45
+ * (no .superRefine), so the basket-or-product rule lives in
46
+ * executePlaceOrder — failing there turns a bare 400 into a field error the
47
+ * form can actually render.
48
+ */
49
+ export declare const placeOrderInput: z.ZodObject<{
50
+ productId: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
51
+ productName: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
52
+ variantId: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
53
+ variantLabel: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
54
+ pricePerUnit: z.ZodPreprocess<z.ZodOptional<z.ZodCoercedNumber<unknown>>, unknown>;
55
+ quantity: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
56
+ offerId: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
57
+ variantSelections: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
58
+ variantId: z.ZodString;
59
+ variantLabel: z.ZodOptional<z.ZodString>;
60
+ }, z.core.$strip>>>, unknown>;
61
+ customerName: z.ZodString;
62
+ phone: z.ZodPreprocess<z.ZodString, unknown>;
63
+ wilayaId: z.ZodCoercedNumber<unknown>;
64
+ communeId: z.ZodString;
65
+ address: z.ZodOptional<z.ZodString>;
66
+ deliveryType: z.ZodDefault<z.ZodEnum<{
67
+ home: "home";
68
+ stop_desk: "stop_desk";
69
+ }>>;
70
+ notes: z.ZodOptional<z.ZodString>;
71
+ email: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
72
+ customFieldResponses: z.ZodPreprocess<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodUnknown>]>>, unknown>;
73
+ fbc: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
74
+ fbp: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
75
+ otpToken: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
76
+ turnstileToken: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
77
+ items: z.ZodPreprocess<z.ZodOptional<z.ZodArray<z.ZodObject<{
78
+ productId: z.ZodString;
79
+ productName: z.ZodString;
80
+ variantId: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
81
+ variantLabel: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
82
+ quantity: z.ZodCoercedNumber<unknown>;
83
+ pricePerUnit: z.ZodOptional<z.ZodCoercedNumber<unknown>>;
84
+ offerId: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
85
+ }, z.core.$strip>>>, unknown>;
86
+ landingPageSlug: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
87
+ }, z.core.$strip>;
88
+ export type PlaceOrderInput = z.infer<typeof placeOrderInput>;
89
+ /**
90
+ * Collect the shopper's attribution headers so the server records the
91
+ * visitor, not the theme's worker: User-Agent verbatim, the client IP from
92
+ * Cloudflare's CF-Connecting-IP (falling back to the first X-Forwarded-For
93
+ * entry), and the Referer when present.
94
+ */
95
+ export declare function collectForwardedHeaders(request: Request): Record<string, string>;
96
+ export type PlaceOrderResult = {
97
+ success: true;
98
+ data: CreatedOrder;
99
+ } | {
100
+ success: false;
101
+ error: string;
102
+ errorKind: "shape";
103
+ } | {
104
+ success: false;
105
+ error: string;
106
+ errorKind: "upstream";
107
+ domainError: DomainError;
108
+ };
109
+ /**
110
+ * Execute the placeOrder action's handler body: enforce the
111
+ * basket-or-product rule, forward attribution headers, and submit the order.
112
+ *
113
+ * The parsed `input` IS the wire body: placeOrderInput is a whitelist whose
114
+ * keys match the server's create-order body 1:1, so a second hand-written
115
+ * field list would be one more thing to drift.
116
+ */
117
+ export declare function executePlaceOrder(input: PlaceOrderInput, request: Request, client: StoreClient): Promise<PlaceOrderResult>;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Storefront Actions — the placeOrder action's engine, framework-free.
3
+ *
4
+ * Astro's `defineAction`/`ActionError` live in `astro:actions`, a virtual
5
+ * module that only exists inside an Astro project — so the engine moves here
6
+ * and each theme keeps a thin wrapper:
7
+ *
8
+ * ```ts
9
+ * // src/actions/index.ts
10
+ * import { ActionError, defineAction } from "astro:actions";
11
+ * import { executePlaceOrder, placeOrderInput } from "@wocart/storefront/astro";
12
+ *
13
+ * export const server = {
14
+ * placeOrder: defineAction({
15
+ * accept: "form",
16
+ * input: placeOrderInput,
17
+ * handler: async (input, context) => {
18
+ * const result = await executePlaceOrder(input, context.request, client);
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);
24
+ * }
25
+ * return result.data;
26
+ * },
27
+ * }),
28
+ * };
29
+ * ```
30
+ *
31
+ * Everything decision-bearing — the input whitelist, the basket-or-product
32
+ * rule, attribution forwarding, the exact wire body — lives here, tested
33
+ * without Astro.
34
+ */
35
+ import { z } from "zod";
36
+ import { toLocalAlgerianMobile } from "./phone.js";
37
+ function parseOptionalArrayInput(value) {
38
+ if (!value || value === "[]")
39
+ return undefined;
40
+ if (Array.isArray(value))
41
+ return value.length > 0 ? value : undefined;
42
+ if (typeof value !== "string")
43
+ return undefined;
44
+ try {
45
+ const parsed = JSON.parse(value);
46
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ function parseVariantSelectionsInput(value) {
53
+ if (!value || value === "[]")
54
+ return undefined;
55
+ if (Array.isArray(value))
56
+ return value.length > 0 ? value : undefined;
57
+ if (typeof value !== "string")
58
+ return undefined;
59
+ try {
60
+ return JSON.parse(value);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ const optionalVariantId = z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).max(200).optional());
67
+ const optionalVariantLabel = z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().max(100).optional());
68
+ const cartItemInput = z.object({
69
+ productId: z.string().min(1).max(200),
70
+ productName: z.string().min(1).max(200),
71
+ variantId: optionalVariantId,
72
+ variantLabel: optionalVariantLabel,
73
+ quantity: z.coerce.number().int().min(1).max(100),
74
+ pricePerUnit: z.coerce.number().nonnegative().optional(),
75
+ offerId: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().optional()),
76
+ });
77
+ /**
78
+ * The order form's input whitelist.
79
+ *
80
+ * zod STRIPS every key this schema does not name, which is why a field the
81
+ * storefront renders but this schema forgets silently never reaches
82
+ * the server. Form parsing needs a plain object schema
83
+ * (no .superRefine), so the basket-or-product rule lives in
84
+ * executePlaceOrder — failing there turns a bare 400 into a field error the
85
+ * form can actually render.
86
+ */
87
+ export const placeOrderInput = z.object({
88
+ // Optional because a basket order has no single representative product;
89
+ // executePlaceOrder requires them whenever `items` is absent.
90
+ productId: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).max(200).optional()),
91
+ productName: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).max(200).optional()),
92
+ // Forms always submit all hidden inputs — empty string must become undefined
93
+ variantId: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).optional()),
94
+ 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
+ quantity: z.coerce.number().int().min(1).max(100).default(1),
97
+ // Explicit offer tier selected by the user
98
+ offerId: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().optional()),
99
+ // Per-unit variant selections — JSON string from hidden form input
100
+ variantSelections: z.preprocess(parseVariantSelectionsInput, z.array(z.object({
101
+ variantId: z.string().min(1),
102
+ variantLabel: z.string().optional(),
103
+ })).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}$/, "رقم الهاتف غير صحيح")),
106
+ wilayaId: z.coerce.number().int().min(1).max(58),
107
+ communeId: z.string().min(1, "يرجى اختيار البلدية"),
108
+ address: z.string().max(300).optional(),
109
+ deliveryType: z.enum(["home", "stop_desk"]).default("home"),
110
+ notes: z.string().max(500).optional(),
111
+ // Checkout Form Policy fields. This schema is a whitelist — anything not
112
+ // named here is stripped before the order reaches the server — so both
113
+ // keys must exist even though whether they are asked for, and whether
114
+ // they may be empty, is the merchant's policy and the server's ruling.
115
+ // Validated permissively on purpose: a stricter rule here would reject
116
+ // with this worker's message instead of the store-language one the server
117
+ // returns, and would be a second definition of the same rule.
118
+ email: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().max(254).optional()),
119
+ // One JSON field rather than one input per custom field, precisely
120
+ // because of this whitelist: a merchant adding a field must never require
121
+ // a theme deploy.
122
+ customFieldResponses: z.preprocess((v) => {
123
+ if (!v || v === "[]")
124
+ return undefined;
125
+ if (Array.isArray(v))
126
+ return v.length > 0 ? v : undefined;
127
+ if (typeof v !== "string" || v.length > 4000)
128
+ return undefined;
129
+ return v;
130
+ }, z.union([z.string().max(4000), z.array(z.unknown())]).optional()),
131
+ fbc: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().optional()),
132
+ fbp: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().optional()),
133
+ // WhatsApp OTP verification proof — set after the OTP step verifies.
134
+ // Absent when the store has verification disabled (schema stays additive).
135
+ otpToken: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(10).max(1024).optional()),
136
+ // Cloudflare Turnstile proof — auto-injected by the widget's hidden input
137
+ // when the store enables bot protection. Absent when disabled (additive).
138
+ // Tokens are single-use, max 2048 chars, and expire after 300 seconds.
139
+ turnstileToken: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).max(2048).optional()),
140
+ // Cart basket. Present only when checkout started from the drawer; an
141
+ // empty or malformed value falls through to the flat single-product
142
+ // fields above, so the direct order form is untouched by this.
143
+ items: z.preprocess(parseOptionalArrayInput, z.array(cartItemInput).min(1).max(20).optional()),
144
+ // Landing page attribution — best-effort: unknown/draft slug leaves
145
+ // the order unattributed, never blocked (platform extension, LP feature).
146
+ landingPageSlug: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).max(60).optional()),
147
+ });
148
+ /**
149
+ * Collect the shopper's attribution headers so the server records the
150
+ * visitor, not the theme's worker: User-Agent verbatim, the client IP from
151
+ * Cloudflare's CF-Connecting-IP (falling back to the first X-Forwarded-For
152
+ * entry), and the Referer when present.
153
+ */
154
+ export function collectForwardedHeaders(request) {
155
+ const forwardedHeaders = {};
156
+ const userAgent = request.headers.get("User-Agent");
157
+ if (userAgent)
158
+ forwardedHeaders["User-Agent"] = userAgent;
159
+ const clientIp = request.headers.get("CF-Connecting-IP") ??
160
+ request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
161
+ if (clientIp)
162
+ forwardedHeaders["X-Forwarded-For"] = clientIp;
163
+ const referer = request.headers.get("Referer");
164
+ if (referer)
165
+ forwardedHeaders["Referer"] = referer;
166
+ return forwardedHeaders;
167
+ }
168
+ /**
169
+ * Execute the placeOrder action's handler body: enforce the
170
+ * basket-or-product rule, forward attribution headers, and submit the order.
171
+ *
172
+ * The parsed `input` IS the wire body: placeOrderInput is a whitelist whose
173
+ * keys match the server's create-order body 1:1, so a second hand-written
174
+ * field list would be one more thing to drift.
175
+ */
176
+ export async function executePlaceOrder(input, request, client) {
177
+ // An order is either a basket or a single product, never neither.
178
+ if (!input.items?.length &&
179
+ (!input.productId || !input.productName || !input.pricePerUnit)) {
180
+ return {
181
+ success: false,
182
+ error: "An order needs either a basket or a product.",
183
+ errorKind: "shape",
184
+ };
185
+ }
186
+ const result = await client.orders.create(input, collectForwardedHeaders(request));
187
+ if (!result.success) {
188
+ return {
189
+ success: false,
190
+ error: result.error,
191
+ errorKind: "upstream",
192
+ domainError: result.domainError,
193
+ };
194
+ }
195
+ return { success: true, data: result.data };
196
+ }
@@ -0,0 +1,6 @@
1
+ export { createProxyEndpoint } from "./endpoints/proxy.js";
2
+ export type { ProxyClientFn, ProxyEndpointConfig, AstroAPIContext, AstroAPIRoute, } from "./endpoints/proxy.js";
3
+ export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./actions.js";
4
+ export type { PlaceOrderInput, PlaceOrderResult } from "./actions.js";
5
+ export { resolveActionRedirectDecision } from "./middleware.js";
6
+ export type { PrgDecision, ActionMiddlewareDeps, ActionDescriptor } from "./middleware.js";
package/dist/astro.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createProxyEndpoint } from "./endpoints/proxy.js";
2
+ export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./actions.js";
3
+ export { resolveActionRedirectDecision } from "./middleware.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Browser-side shopping cart / basket engine.
3
+ *
4
+ * Deep module: concentrates state, persistence, event notification,
5
+ * cross-tab synchronization, duplicate merging, and bounds enforcement behind
6
+ * a minimal, testable interface.
7
+ *
8
+ * The basket is intent, not trusted money. It lives in the client environment
9
+ * (or memory fake in server/tests). The server always validates and re-prices
10
+ * every line at checkout.
11
+ */
12
+ import { type BasketLine, type BasketLineInput } from "./domain.js";
13
+ /** Default key used in browser storage, versioned to prevent schema drift crashes. */
14
+ export declare const DEFAULT_BASKET_STORAGE_KEY = "cod_cart_v1";
15
+ /**
16
+ * Storage adapter abstraction.
17
+ *
18
+ * Allows zero-browser operation (in-memory for unit tests / SSR) and safe
19
+ * degradation when localStorage is blocked (cookies disabled, private mode).
20
+ */
21
+ export interface BasketStorageAdapter {
22
+ getItem(key: string): string | null;
23
+ setItem(key: string, value: string): void;
24
+ removeItem(key: string): void;
25
+ isAvailable(): boolean;
26
+ subscribe?(onExternalChange: () => void): () => void;
27
+ }
28
+ /**
29
+ * Options for creating a basket store.
30
+ */
31
+ export interface BasketStoreOptions {
32
+ storageKey?: string;
33
+ storage?: BasketStorageAdapter;
34
+ maxLines?: number;
35
+ maxLineQuantity?: number;
36
+ }
37
+ export type BasketListener = (lines: readonly BasketLine[]) => void;
38
+ /**
39
+ * Basket Store Interface.
40
+ *
41
+ * Deep module interface: 9 simple, focused methods that hide all storage
42
+ * failures, cross-tab events, line reconciliation, and normalization.
43
+ */
44
+ export interface BasketStore {
45
+ /** Get current snapshot of lines. */
46
+ getLines(): readonly BasketLine[];
47
+ /** Total quantity of items in the basket. */
48
+ getCount(): number;
49
+ /** Optimistic display subtotal. Not trusted for money. */
50
+ getSubtotal(): number;
51
+ /** Add a line. Merges with existing product+variant, caps quantities. */
52
+ addLine(line: BasketLine): readonly BasketLine[];
53
+ /** Update quantity of an existing line. Removes if <= 0. */
54
+ setQuantity(key: string, quantity: number): readonly BasketLine[];
55
+ /** Remove a line by its composite key. */
56
+ removeLine(key: string): readonly BasketLine[];
57
+ /** Empty the basket. */
58
+ clear(): readonly BasketLine[];
59
+ /** Convert lines to server-ready wire items for POST /store/cart/validate or order creation. */
60
+ toOrderItems(): BasketLineInput[];
61
+ /** Subscribe to state changes (local and cross-tab). Returns unsubscribe. */
62
+ subscribe(listener: BasketListener): () => void;
63
+ /** Check if the underlying storage can persist across reloads. */
64
+ isPersistent(): boolean;
65
+ }
66
+ /**
67
+ * Create a memory storage adapter (ideal for node, tests, and SSR).
68
+ */
69
+ export declare function createMemoryStorageAdapter(): BasketStorageAdapter;
70
+ /**
71
+ * Create a safe browser localStorage adapter with storage event subscription.
72
+ */
73
+ export declare function createLocalStorageAdapter(storageKey?: string): BasketStorageAdapter;
74
+ /**
75
+ * The server-facing projection of basket lines — the ONE place a browser
76
+ * basket turns into `items[]` for POST /store/cart/validate or an order.
77
+ *
78
+ * Pure and store-free so a caller holding a line snapshot (a theme's
79
+ * debounced validator, a checkout draft) projects it without re-deriving the
80
+ * wire shape; `BasketStore.toOrderItems()` delegates here, and the order
81
+ * action's schema is what finally accepts or rejects it.
82
+ */
83
+ export declare function toOrderItems(lines: readonly BasketLine[]): BasketLineInput[];
84
+ /**
85
+ * Create a deep Basket Store.
86
+ */
87
+ export declare function createBasketStore(options?: BasketStoreOptions): BasketStore;