@wocart/storefront 0.0.0-stage → 1.1.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 +95 -0
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +144 -2
- package/USAGE.md +1470 -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 +88 -0
- package/dist/client/core.js +566 -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 +40 -0
- package/dist/client/index.js +81 -0
- package/dist/client/orders.d.ts +68 -0
- package/dist/client/orders.js +76 -0
- package/dist/client/page-data.d.ts +30 -0
- package/dist/client/page-data.js +36 -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 +36 -0
- package/dist/client/shipping.js +47 -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 +341 -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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware engine — the POST/Redirect/GET pattern for Astro Actions,
|
|
3
|
+
* framework-free.
|
|
4
|
+
*
|
|
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:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // src/middleware.ts
|
|
11
|
+
* import { defineMiddleware } from "astro:middleware";
|
|
12
|
+
* import { getActionContext } from "astro:actions";
|
|
13
|
+
* import { resolveActionRedirectDecision } from "@wocart/storefront/astro";
|
|
14
|
+
*
|
|
15
|
+
* export const onRequest = defineMiddleware(async (context, next) => {
|
|
16
|
+
* const { action, setActionResult, serializeActionResult } =
|
|
17
|
+
* getActionContext(context);
|
|
18
|
+
* const decision = await resolveActionRedirectDecision({
|
|
19
|
+
* action: action && {
|
|
20
|
+
* calledFrom: action.calledFrom,
|
|
21
|
+
* name: action.name,
|
|
22
|
+
* handler: action.handler,
|
|
23
|
+
* },
|
|
24
|
+
* setActionResult,
|
|
25
|
+
* serializeActionResult,
|
|
26
|
+
* referer: context.request.headers.get("Referer"),
|
|
27
|
+
* pathname: context.url.pathname,
|
|
28
|
+
* });
|
|
29
|
+
* if (decision.kind === "redirect") {
|
|
30
|
+
* return context.redirect(decision.location, decision.status);
|
|
31
|
+
* }
|
|
32
|
+
* return next();
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
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.
|
|
44
|
+
*
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
51
|
+
* The slice of an action result the decision reads: whether it errored.
|
|
52
|
+
* Astro's own `SafeResult` is assignable to this.
|
|
53
|
+
*/
|
|
54
|
+
export interface ActionHandlerResult {
|
|
55
|
+
error?: unknown;
|
|
56
|
+
data?: unknown;
|
|
57
|
+
}
|
|
58
|
+
export interface ActionDescriptor<TResult extends ActionHandlerResult = ActionHandlerResult> {
|
|
59
|
+
/** Astro sets this to "form" for HTML form submissions, "json" otherwise. */
|
|
60
|
+
calledFrom: string;
|
|
61
|
+
name: string;
|
|
62
|
+
handler: () => Promise<TResult>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `TSerialized` is the framework's result-cookie token, opaque to this SDK:
|
|
66
|
+
* Astro 5 used a string, Astro 7 an object (`SerializedActionResult`). The
|
|
67
|
+
* generics thread whatever `getActionContext()` hands the theme straight
|
|
68
|
+
* through, so no theme ever casts.
|
|
69
|
+
*/
|
|
70
|
+
export interface ActionMiddlewareDeps<TResult extends ActionHandlerResult = ActionHandlerResult, TSerialized = string> {
|
|
71
|
+
action?: ActionDescriptor<TResult>;
|
|
72
|
+
/** Stores the serialized action result cookie (Astro's setActionResult). */
|
|
73
|
+
setActionResult: (name: string, serialized: TSerialized) => void;
|
|
74
|
+
/** Serializes an ActionResult into the cookie value (Astro's serializeActionResult). */
|
|
75
|
+
serializeActionResult: (result: TResult) => TSerialized;
|
|
76
|
+
/** The Referer request header, if the shopper's browser sent one. */
|
|
77
|
+
referer?: string | null;
|
|
78
|
+
/** Path of the page the form posted to — the fallback redirect target. */
|
|
79
|
+
pathname: string;
|
|
80
|
+
}
|
|
81
|
+
export type PrgDecision =
|
|
82
|
+
/** Redirect back to the form page (error path) — 303 See Other. */
|
|
83
|
+
{
|
|
84
|
+
kind: "redirect";
|
|
85
|
+
location: string;
|
|
86
|
+
status: 303;
|
|
87
|
+
}
|
|
88
|
+
/** Render the page from this POST (success / not an action / JSON action). */
|
|
89
|
+
| {
|
|
90
|
+
kind: "next";
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
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.
|
|
99
|
+
*/
|
|
100
|
+
export declare function resolveActionRedirectDecision<TResult extends ActionHandlerResult, TSerialized>(deps: ActionMiddlewareDeps<TResult, TSerialized>): Promise<PrgDecision>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware engine — the POST/Redirect/GET pattern for Astro Actions,
|
|
3
|
+
* framework-free.
|
|
4
|
+
*
|
|
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:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* // src/middleware.ts
|
|
11
|
+
* import { defineMiddleware } from "astro:middleware";
|
|
12
|
+
* import { getActionContext } from "astro:actions";
|
|
13
|
+
* import { resolveActionRedirectDecision } from "@wocart/storefront/astro";
|
|
14
|
+
*
|
|
15
|
+
* export const onRequest = defineMiddleware(async (context, next) => {
|
|
16
|
+
* const { action, setActionResult, serializeActionResult } =
|
|
17
|
+
* getActionContext(context);
|
|
18
|
+
* const decision = await resolveActionRedirectDecision({
|
|
19
|
+
* action: action && {
|
|
20
|
+
* calledFrom: action.calledFrom,
|
|
21
|
+
* name: action.name,
|
|
22
|
+
* handler: action.handler,
|
|
23
|
+
* },
|
|
24
|
+
* setActionResult,
|
|
25
|
+
* serializeActionResult,
|
|
26
|
+
* referer: context.request.headers.get("Referer"),
|
|
27
|
+
* pathname: context.url.pathname,
|
|
28
|
+
* });
|
|
29
|
+
* if (decision.kind === "redirect") {
|
|
30
|
+
* return context.redirect(decision.location, decision.status);
|
|
31
|
+
* }
|
|
32
|
+
* return next();
|
|
33
|
+
* });
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
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.
|
|
44
|
+
*
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
/**
|
|
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.
|
|
57
|
+
*/
|
|
58
|
+
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
|
+
}
|
|
71
|
+
return { kind: "next" };
|
|
72
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Offer } from "./types.js";
|
|
2
|
+
export interface OfferTierView {
|
|
3
|
+
isFreeShipping: boolean;
|
|
4
|
+
savePct: number;
|
|
5
|
+
savingsAmount: number;
|
|
6
|
+
price: number;
|
|
7
|
+
comparePrice: number | null;
|
|
8
|
+
}
|
|
9
|
+
export type OfferTierInput = Pick<Offer, "discountType" | "triggerQuantity" | "rewardQuantity">;
|
|
10
|
+
export declare function getOfferTierView(offer: OfferTierInput, basePrice: number, comparePrice: number | null): OfferTierView;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function getOfferTierView(offer, basePrice, comparePrice) {
|
|
2
|
+
const isFreeShipping = offer.discountType === "free_shipping";
|
|
3
|
+
const quantityTotal = offer.triggerQuantity + offer.rewardQuantity;
|
|
4
|
+
return {
|
|
5
|
+
isFreeShipping,
|
|
6
|
+
savePct: isFreeShipping || quantityTotal <= 0
|
|
7
|
+
? 0
|
|
8
|
+
: Math.round((offer.rewardQuantity / quantityTotal) * 100),
|
|
9
|
+
savingsAmount: isFreeShipping ? 0 : offer.rewardQuantity * basePrice,
|
|
10
|
+
price: basePrice * offer.triggerQuantity,
|
|
11
|
+
comparePrice: comparePrice === null ? null : comparePrice * offer.triggerQuantity,
|
|
12
|
+
};
|
|
13
|
+
}
|
package/dist/otp.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side checkout WhatsApp OTP state machine.
|
|
3
|
+
*
|
|
4
|
+
* Deep module: concentrates send/verify flows, cooldown countdowns,
|
|
5
|
+
* attempt counting, terminal expiry handling, fail-open bypass management,
|
|
6
|
+
* and state notifications behind a simple, predictable interface.
|
|
7
|
+
*
|
|
8
|
+
* Can run in pure memory (tests / node) or drive browser UI via subscription.
|
|
9
|
+
*/
|
|
10
|
+
import type { ClientResult } from "./domain.js";
|
|
11
|
+
import type { OtpSendResult, OtpVerifyResult } from "./types.js";
|
|
12
|
+
import type { z } from "zod";
|
|
13
|
+
/**
|
|
14
|
+
* Visual / operational step of the OTP process.
|
|
15
|
+
*/
|
|
16
|
+
export type OtpStatus = "idle" | "sending" | "code_sent" | "verifying" | "verified" | "bypassed" | "error";
|
|
17
|
+
/**
|
|
18
|
+
* Snapshot of OTP state machine at any instant.
|
|
19
|
+
*/
|
|
20
|
+
export interface OtpState {
|
|
21
|
+
readonly status: OtpStatus;
|
|
22
|
+
readonly phone: string;
|
|
23
|
+
readonly normalizedPhone: string | null;
|
|
24
|
+
readonly requestId: string | null;
|
|
25
|
+
readonly otpToken: string | null;
|
|
26
|
+
readonly bypassToken: string | null;
|
|
27
|
+
readonly error: string | null;
|
|
28
|
+
readonly errorCode: string | null;
|
|
29
|
+
readonly cooldownRemaining: number;
|
|
30
|
+
readonly attemptsRemaining: number | null;
|
|
31
|
+
readonly isTerminal: boolean;
|
|
32
|
+
}
|
|
33
|
+
export type OtpStateListener = (state: OtpState) => void;
|
|
34
|
+
export interface OtpFailureContext {
|
|
35
|
+
readonly windowSeconds?: number;
|
|
36
|
+
readonly attemptsRemaining?: number;
|
|
37
|
+
readonly terminal?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export type OtpTransportResult<T> = ClientResult<T> & OtpFailureContext;
|
|
40
|
+
export interface OtpTransport {
|
|
41
|
+
send(phone: string): Promise<OtpTransportResult<OtpSendResult>>;
|
|
42
|
+
verify(phone: string, requestId: string, code: string): Promise<OtpTransportResult<OtpVerifyResult>>;
|
|
43
|
+
}
|
|
44
|
+
export declare function createOtpTransport(options: {
|
|
45
|
+
send: OtpTransport["send"];
|
|
46
|
+
verify: OtpTransport["verify"];
|
|
47
|
+
}): OtpTransport;
|
|
48
|
+
export interface OtpResponseOptions<T> {
|
|
49
|
+
schema: z.Schema<T>;
|
|
50
|
+
operation: string;
|
|
51
|
+
fallbackError: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function otpTransportFailure<T>(error: unknown, operation: string, fallbackError: string): OtpTransportResult<T>;
|
|
54
|
+
export declare function mapOtpResponse<T>(response: Response, options: OtpResponseOptions<T>): Promise<OtpTransportResult<T>>;
|
|
55
|
+
export interface OtpMachineOptions {
|
|
56
|
+
transport: OtpTransport;
|
|
57
|
+
initialPhone?: string;
|
|
58
|
+
onStateChange?: OtpStateListener;
|
|
59
|
+
timerIntervalMs?: number;
|
|
60
|
+
}
|
|
61
|
+
export interface OtpMachine {
|
|
62
|
+
/** Get current snapshot of the machine. */
|
|
63
|
+
getState(): OtpState;
|
|
64
|
+
/** Set or change phone number. Resets verification if phone changed. */
|
|
65
|
+
setPhone(phone: string): void;
|
|
66
|
+
/** Request / send an OTP code via WhatsApp. */
|
|
67
|
+
send(): Promise<boolean>;
|
|
68
|
+
/** Verify a 6-digit code. */
|
|
69
|
+
verify(code: string): Promise<boolean>;
|
|
70
|
+
/** Reset to idle state. */
|
|
71
|
+
reset(): void;
|
|
72
|
+
/** Subscribe to state changes. Returns unsubscribe callback. */
|
|
73
|
+
subscribe(listener: OtpStateListener): () => void;
|
|
74
|
+
/** Dispose internal timers. */
|
|
75
|
+
dispose(): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Create an OtpMachine.
|
|
79
|
+
*/
|
|
80
|
+
export declare function createOtpMachine(options: OtpMachineOptions): OtpMachine;
|
package/dist/otp.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side checkout WhatsApp OTP state machine.
|
|
3
|
+
*
|
|
4
|
+
* Deep module: concentrates send/verify flows, cooldown countdowns,
|
|
5
|
+
* attempt counting, terminal expiry handling, fail-open bypass management,
|
|
6
|
+
* and state notifications behind a simple, predictable interface.
|
|
7
|
+
*
|
|
8
|
+
* Can run in pure memory (tests / node) or drive browser UI via subscription.
|
|
9
|
+
*/
|
|
10
|
+
import { toLocalAlgerianMobile } from "./phone.js";
|
|
11
|
+
export function createOtpTransport(options) {
|
|
12
|
+
return { send: options.send, verify: options.verify };
|
|
13
|
+
}
|
|
14
|
+
function isRecord(value) {
|
|
15
|
+
return typeof value === "object" && value !== null;
|
|
16
|
+
}
|
|
17
|
+
function isDomainError(value) {
|
|
18
|
+
if (!isRecord(value))
|
|
19
|
+
return false;
|
|
20
|
+
return (typeof value.category === "string" &&
|
|
21
|
+
typeof value.code === "string" &&
|
|
22
|
+
typeof value.message === "string" &&
|
|
23
|
+
typeof value.retryable === "boolean" &&
|
|
24
|
+
typeof value.operation === "string" &&
|
|
25
|
+
typeof value.sideEffect === "string");
|
|
26
|
+
}
|
|
27
|
+
function fallbackDomainError(operation, code, message) {
|
|
28
|
+
return {
|
|
29
|
+
category: "unknown",
|
|
30
|
+
code,
|
|
31
|
+
message,
|
|
32
|
+
retryable: false,
|
|
33
|
+
operation,
|
|
34
|
+
sideEffect: "unknown",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function mapFailure(body, options, fallbackCode) {
|
|
38
|
+
const error = typeof body.error === "string" ? body.error : options.fallbackError;
|
|
39
|
+
const domainError = isDomainError(body.domainError)
|
|
40
|
+
? body.domainError
|
|
41
|
+
: fallbackDomainError(options.operation, typeof body.code === "string" ? body.code : fallbackCode, error);
|
|
42
|
+
return {
|
|
43
|
+
success: false,
|
|
44
|
+
error,
|
|
45
|
+
...(typeof body.code === "string" ? { code: body.code } : {}),
|
|
46
|
+
...(typeof body.windowSeconds === "number" ? { windowSeconds: body.windowSeconds } : {}),
|
|
47
|
+
...(typeof body.attemptsRemaining === "number"
|
|
48
|
+
? { attemptsRemaining: body.attemptsRemaining }
|
|
49
|
+
: {}),
|
|
50
|
+
...(typeof body.terminal === "boolean" ? { terminal: body.terminal } : {}),
|
|
51
|
+
domainError,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function otpTransportFailure(error, operation, fallbackError) {
|
|
55
|
+
const message = error instanceof Error ? error.message : fallbackError;
|
|
56
|
+
return {
|
|
57
|
+
success: false,
|
|
58
|
+
error: message,
|
|
59
|
+
domainError: fallbackDomainError(operation, "NETWORK_ERROR", message),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export async function mapOtpResponse(response, options) {
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = await response.json();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: options.fallbackError,
|
|
71
|
+
domainError: fallbackDomainError(options.operation, "MALFORMED_RESPONSE", options.fallbackError),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (!isRecord(body)) {
|
|
75
|
+
return mapFailure({}, options, "MALFORMED_RESPONSE");
|
|
76
|
+
}
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
return mapFailure(body, options, `HTTP_${response.status}`);
|
|
79
|
+
}
|
|
80
|
+
const parsed = options.schema.safeParse(body.data);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
return mapFailure(body, options, "SCHEMA_VALIDATION_FAILED");
|
|
83
|
+
}
|
|
84
|
+
return { success: true, data: parsed.data };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Create an OtpMachine.
|
|
88
|
+
*/
|
|
89
|
+
export function createOtpMachine(options) {
|
|
90
|
+
const { transport, timerIntervalMs = 1000 } = options;
|
|
91
|
+
const listeners = new Set();
|
|
92
|
+
if (options.onStateChange)
|
|
93
|
+
listeners.add(options.onStateChange);
|
|
94
|
+
let state = {
|
|
95
|
+
status: "idle",
|
|
96
|
+
phone: options.initialPhone ?? "",
|
|
97
|
+
normalizedPhone: options.initialPhone ? toLocalAlgerianMobile(options.initialPhone) : null,
|
|
98
|
+
requestId: null,
|
|
99
|
+
otpToken: null,
|
|
100
|
+
bypassToken: null,
|
|
101
|
+
error: null,
|
|
102
|
+
errorCode: null,
|
|
103
|
+
cooldownRemaining: 0,
|
|
104
|
+
attemptsRemaining: null,
|
|
105
|
+
isTerminal: false,
|
|
106
|
+
};
|
|
107
|
+
let timer = null;
|
|
108
|
+
function updateState(next) {
|
|
109
|
+
state = { ...state, ...next };
|
|
110
|
+
for (const listener of listeners) {
|
|
111
|
+
try {
|
|
112
|
+
listener(state);
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function startCooldown(seconds) {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearInterval(timer);
|
|
120
|
+
let remaining = seconds;
|
|
121
|
+
updateState({ cooldownRemaining: remaining });
|
|
122
|
+
timer = setInterval(() => {
|
|
123
|
+
remaining -= 1;
|
|
124
|
+
if (remaining <= 0) {
|
|
125
|
+
if (timer)
|
|
126
|
+
clearInterval(timer);
|
|
127
|
+
timer = null;
|
|
128
|
+
updateState({ cooldownRemaining: 0 });
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
updateState({ cooldownRemaining: remaining });
|
|
132
|
+
}
|
|
133
|
+
}, timerIntervalMs);
|
|
134
|
+
}
|
|
135
|
+
function stopCooldown() {
|
|
136
|
+
if (timer) {
|
|
137
|
+
clearInterval(timer);
|
|
138
|
+
timer = null;
|
|
139
|
+
}
|
|
140
|
+
updateState({ cooldownRemaining: 0 });
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
getState() {
|
|
144
|
+
return state;
|
|
145
|
+
},
|
|
146
|
+
setPhone(rawPhone) {
|
|
147
|
+
const normalized = toLocalAlgerianMobile(rawPhone);
|
|
148
|
+
if (state.phone === rawPhone && state.normalizedPhone === normalized)
|
|
149
|
+
return;
|
|
150
|
+
stopCooldown();
|
|
151
|
+
updateState({
|
|
152
|
+
status: "idle",
|
|
153
|
+
phone: rawPhone,
|
|
154
|
+
normalizedPhone: normalized,
|
|
155
|
+
requestId: null,
|
|
156
|
+
otpToken: null,
|
|
157
|
+
bypassToken: null,
|
|
158
|
+
error: null,
|
|
159
|
+
errorCode: null,
|
|
160
|
+
attemptsRemaining: null,
|
|
161
|
+
isTerminal: false,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
async send() {
|
|
165
|
+
if (state.cooldownRemaining > 0 || state.status === "sending" || state.status === "verifying") {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
const phoneToSend = state.normalizedPhone ?? state.phone.trim();
|
|
169
|
+
if (!phoneToSend) {
|
|
170
|
+
updateState({ status: "error", error: "Phone number is required" });
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
updateState({ status: "sending", error: null, errorCode: null });
|
|
174
|
+
try {
|
|
175
|
+
const res = await transport.send(phoneToSend);
|
|
176
|
+
if (!res.success) {
|
|
177
|
+
const cooldown = res.windowSeconds ?? 60;
|
|
178
|
+
startCooldown(cooldown);
|
|
179
|
+
updateState({
|
|
180
|
+
status: "error",
|
|
181
|
+
error: res.error,
|
|
182
|
+
errorCode: res.domainError.code,
|
|
183
|
+
});
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (res.data.status === "unavailable") {
|
|
187
|
+
stopCooldown();
|
|
188
|
+
updateState({
|
|
189
|
+
status: "bypassed",
|
|
190
|
+
bypassToken: res.data.bypassToken,
|
|
191
|
+
error: null,
|
|
192
|
+
errorCode: null,
|
|
193
|
+
});
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
startCooldown(60);
|
|
197
|
+
updateState({
|
|
198
|
+
status: "code_sent",
|
|
199
|
+
requestId: res.data.requestId,
|
|
200
|
+
error: null,
|
|
201
|
+
errorCode: null,
|
|
202
|
+
attemptsRemaining: null,
|
|
203
|
+
isTerminal: false,
|
|
204
|
+
});
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
updateState({
|
|
209
|
+
status: "error",
|
|
210
|
+
error: err instanceof Error ? err.message : "Failed to send verification code",
|
|
211
|
+
errorCode: "NETWORK_ERROR",
|
|
212
|
+
});
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
async verify(code) {
|
|
217
|
+
const cleanCode = code.replace(/\D/g, "");
|
|
218
|
+
if (cleanCode.length !== 6) {
|
|
219
|
+
updateState({
|
|
220
|
+
error: "Verification code must be 6 digits",
|
|
221
|
+
errorCode: "INVALID_FORMAT",
|
|
222
|
+
});
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (!state.requestId) {
|
|
226
|
+
updateState({
|
|
227
|
+
status: "error",
|
|
228
|
+
error: "No active verification request. Please request a new code.",
|
|
229
|
+
errorCode: "NO_REQUEST",
|
|
230
|
+
});
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (state.status === "verifying")
|
|
234
|
+
return false;
|
|
235
|
+
const phoneToVerify = state.normalizedPhone ?? state.phone.trim();
|
|
236
|
+
updateState({ status: "verifying", error: null, errorCode: null });
|
|
237
|
+
try {
|
|
238
|
+
const res = await transport.verify(phoneToVerify, state.requestId, cleanCode);
|
|
239
|
+
if (!res.success) {
|
|
240
|
+
const isTerminal = res.terminal === true;
|
|
241
|
+
updateState({
|
|
242
|
+
status: isTerminal ? "error" : "code_sent",
|
|
243
|
+
error: res.error,
|
|
244
|
+
errorCode: res.domainError.code,
|
|
245
|
+
attemptsRemaining: res.attemptsRemaining ?? null,
|
|
246
|
+
isTerminal,
|
|
247
|
+
requestId: isTerminal ? null : state.requestId,
|
|
248
|
+
});
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
stopCooldown();
|
|
252
|
+
updateState({
|
|
253
|
+
status: "verified",
|
|
254
|
+
otpToken: res.data.otpToken,
|
|
255
|
+
error: null,
|
|
256
|
+
errorCode: null,
|
|
257
|
+
attemptsRemaining: null,
|
|
258
|
+
isTerminal: false,
|
|
259
|
+
});
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
updateState({
|
|
264
|
+
status: "code_sent",
|
|
265
|
+
error: err instanceof Error ? err.message : "Verification failed",
|
|
266
|
+
errorCode: "NETWORK_ERROR",
|
|
267
|
+
});
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
reset() {
|
|
272
|
+
stopCooldown();
|
|
273
|
+
updateState({
|
|
274
|
+
status: "idle",
|
|
275
|
+
requestId: null,
|
|
276
|
+
otpToken: null,
|
|
277
|
+
bypassToken: null,
|
|
278
|
+
error: null,
|
|
279
|
+
errorCode: null,
|
|
280
|
+
attemptsRemaining: null,
|
|
281
|
+
isTerminal: false,
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
subscribe(listener) {
|
|
285
|
+
listeners.add(listener);
|
|
286
|
+
return () => {
|
|
287
|
+
listeners.delete(listener);
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
dispose() {
|
|
291
|
+
stopCooldown();
|
|
292
|
+
listeners.clear();
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
package/dist/phone.d.ts
ADDED
package/dist/phone.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const ALGERIAN_COUNTRY_CODE = "213";
|
|
2
|
+
const ALGERIAN_MOBILE_PREFIX = /^[567]/;
|
|
3
|
+
function clean(raw) {
|
|
4
|
+
return raw.replace(/[\s\-().]/g, "");
|
|
5
|
+
}
|
|
6
|
+
export function normalizeAlgerianPhone(raw) {
|
|
7
|
+
if (typeof raw !== "string")
|
|
8
|
+
return null;
|
|
9
|
+
let phone = clean(raw.trim());
|
|
10
|
+
if (phone.length < 6 || phone.length > 20)
|
|
11
|
+
return null;
|
|
12
|
+
if (phone.startsWith("00")) {
|
|
13
|
+
phone = `+${phone.slice(2)}`;
|
|
14
|
+
}
|
|
15
|
+
if (phone.startsWith("+")) {
|
|
16
|
+
return /^\+\d{7,15}$/.test(phone) ? phone : null;
|
|
17
|
+
}
|
|
18
|
+
if (phone.startsWith(ALGERIAN_COUNTRY_CODE)) {
|
|
19
|
+
const rest = phone.slice(ALGERIAN_COUNTRY_CODE.length);
|
|
20
|
+
return ALGERIAN_MOBILE_PREFIX.test(rest) ? `+${phone}` : null;
|
|
21
|
+
}
|
|
22
|
+
const local = phone.startsWith("0") ? phone.slice(1) : phone;
|
|
23
|
+
if (local.length === 9 && ALGERIAN_MOBILE_PREFIX.test(local)) {
|
|
24
|
+
return `+${ALGERIAN_COUNTRY_CODE}${local}`;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export function toLocalAlgerianMobile(raw) {
|
|
29
|
+
const e164 = normalizeAlgerianPhone(raw);
|
|
30
|
+
if (!e164 || !e164.startsWith("+213"))
|
|
31
|
+
return null;
|
|
32
|
+
const local = e164.slice(4);
|
|
33
|
+
return /^[567]\d{8}$/.test(local) ? `0${local}` : null;
|
|
34
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProductDetail, ProductList, ProductVariant } from "./types.js";
|
|
2
|
+
export interface ProductView {
|
|
3
|
+
readonly defaultVariant: ProductVariant | null;
|
|
4
|
+
readonly basePrice: number;
|
|
5
|
+
readonly comparePrice: number | null;
|
|
6
|
+
readonly hasDiscount: boolean;
|
|
7
|
+
readonly discountPct: number;
|
|
8
|
+
readonly isOutOfStock: boolean;
|
|
9
|
+
readonly isLowStock: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function getProductView(product: ProductDetail): ProductView;
|
|
12
|
+
export declare function getProductListView(product: ProductList): ProductView;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function discountOf(basePrice, comparePrice) {
|
|
2
|
+
if (comparePrice == null || comparePrice <= basePrice) {
|
|
3
|
+
return { hasDiscount: false, discountPct: 0 };
|
|
4
|
+
}
|
|
5
|
+
return {
|
|
6
|
+
hasDiscount: true,
|
|
7
|
+
discountPct: Math.round((1 - basePrice / comparePrice) * 100),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function stockOf(product) {
|
|
11
|
+
if (!product.trackInventory) {
|
|
12
|
+
return { isOutOfStock: false, isLowStock: false };
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
isOutOfStock: product.inventory === 0,
|
|
16
|
+
isLowStock: product.inventory > 0 && product.inventory <= product.lowStockThreshold,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function getProductView(product) {
|
|
20
|
+
const defaultVariant = product.hasVariants
|
|
21
|
+
? (product.variants.find((variant) => variant.isDefault) ?? product.variants[0] ?? null)
|
|
22
|
+
: null;
|
|
23
|
+
const basePrice = defaultVariant?.price ?? product.price;
|
|
24
|
+
const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ?? null;
|
|
25
|
+
return {
|
|
26
|
+
defaultVariant,
|
|
27
|
+
basePrice,
|
|
28
|
+
comparePrice,
|
|
29
|
+
...discountOf(basePrice, comparePrice),
|
|
30
|
+
...stockOf(product),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function getProductListView(product) {
|
|
34
|
+
const comparePrice = product.compareAtPrice ?? null;
|
|
35
|
+
return {
|
|
36
|
+
defaultVariant: null,
|
|
37
|
+
basePrice: product.price,
|
|
38
|
+
comparePrice,
|
|
39
|
+
...discountOf(product.price, comparePrice),
|
|
40
|
+
...stockOf(product),
|
|
41
|
+
};
|
|
42
|
+
}
|