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