@wocart/storefront 1.10.0 → 1.12.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 +43 -0
- package/USAGE.md +67 -1
- package/dist/actions.d.ts +4 -1
- package/dist/actions.js +5 -0
- package/dist/client/cart.d.ts +16 -7
- package/dist/client/cart.js +8 -3
- package/dist/coupon.d.ts +18 -0
- package/dist/coupon.js +19 -0
- package/dist/domain.d.ts +3 -0
- package/dist/domain.js +1 -0
- package/dist/endpoints/storefront-routes.d.ts +4 -3
- package/dist/endpoints/storefront-routes.js +15 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/product-view.js +2 -3
- package/dist/schemas.d.ts +33 -0
- package/dist/schemas.js +17 -1
- package/dist/types.d.ts +33 -3
- package/dist/variant-picker.d.ts +52 -0
- package/dist/variant-picker.js +46 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ follows [semantic versioning](https://semver.org/): breaking changes ship only
|
|
|
5
5
|
in a new major version, and an export is marked `@deprecated` for at least one
|
|
6
6
|
minor release before a major removes it.
|
|
7
7
|
|
|
8
|
+
## 1.12.0 — 2026-10-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Variant picker.** `initialVariant(product)`, `pickOption(product,
|
|
13
|
+
selection, option, value)`, `optionStates(product, selection)` and
|
|
14
|
+
`variantFor(variants, selection)` (`OptionSelection`, `OptionState`,
|
|
15
|
+
`PickerVariant`): the variant a page opens on, the variant a picked value
|
|
16
|
+
means — always one that exists — and each value's state.
|
|
17
|
+
- `CouponRefusalReason` names `offer_applied`: every line of the basket is
|
|
18
|
+
already priced by an offer, and a code covers only lines no offer holds.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- `getProductView(product).defaultVariant` is the merchant's default only
|
|
23
|
+
when it can be sold, else the first variant that can — never a sold-out or
|
|
24
|
+
switched-off one while another is in stock.
|
|
25
|
+
|
|
26
|
+
## 1.11.0 — 2026-10-17
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Coupons.** `cart.quote` / `cart.validate` take `couponCode` and `phone`
|
|
31
|
+
(`CartQuoteOptions`) and answer `coupon` (`CouponOutcome`,
|
|
32
|
+
`CouponOutcomeSchema`): applied — its `discount` already out of `subtotal`
|
|
33
|
+
and of each line's `lineTotal`, `CartValidationLine.discount` being the
|
|
34
|
+
line's share, `freeShipping` making the delivery fee 0 — or refused with a
|
|
35
|
+
`reason` (`CouponRefusalReason`, open), `shortBy` or `startsAt` when it has
|
|
36
|
+
one. Null without a code; absent from an older server.
|
|
37
|
+
- `CreateOrderInput.couponCode` and `placeOrderInput.couponCode` (an empty
|
|
38
|
+
box sends none); `CreatedOrder.couponDiscount`. An order whose code does not
|
|
39
|
+
apply is refused whole with `COUPON_NOT_APPLICABLE`: `couponRefusalOf(result)`
|
|
40
|
+
reads the refusal, and `executePlaceOrder`'s upstream failure carries it as
|
|
41
|
+
`couponRefusal`.
|
|
42
|
+
- Shareable coupon links: `COUPON_URL_PARAM` (`?coupon=CODE`) and
|
|
43
|
+
`couponFromUrl(url)`.
|
|
44
|
+
- `CheckoutDraft.couponCode`.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- `CartValidationLine.offerId` names the offer applied to the line, whatever
|
|
49
|
+
its kind — a coupon never covers that line.
|
|
50
|
+
|
|
8
51
|
## 1.10.0 — 2026-10-17
|
|
9
52
|
|
|
10
53
|
### Added
|
package/USAGE.md
CHANGED
|
@@ -223,6 +223,7 @@ server directly.
|
|
|
223
223
|
| `productImageSource` | Which photo represents a product (cover → gallery fallback) |
|
|
224
224
|
| `getProductView`, `getProductListView` | Pure price, discount, default-variant, and stock values for detail and list projections |
|
|
225
225
|
| `getOfferTierView` | Pure offer-tier price, compare-price, savings, and free-shipping projection; display-only, never order authority |
|
|
226
|
+
| `initialVariant`, `pickOption`, `optionStates`, `variantFor` | Option buttons: the variant a page opens on, the variant a picked value means, each value's state (available, sold out, not made) |
|
|
226
227
|
| `getQuantityTierViews` | A product's quantity-pricing tiers as tiles show them — price, per-unit price, saving against the unit price, free delivery; display-only, the total is the server's cart quote |
|
|
227
228
|
| `META_CURRENCY`, `buildMetaProductPayload`, `buildMetaViewContentPayload`, `buildMetaAddToCartPayload`, `buildMetaInitiateCheckoutPayload` | Browser-safe Meta event payloads with the platform currency and catalog shape |
|
|
228
229
|
| `createMetaPixelTracker` | Injected browser-pixel dispatch; the theme supplies the vendor global and keeps bootstrap/timing |
|
|
@@ -647,6 +648,33 @@ Show the tiers as choices; never compute the total yourself. The cart quote
|
|
|
647
648
|
delivery — and the order charges exactly that. A tier locked to one variant
|
|
648
649
|
(`triggerVariantId`) counts and prices only that variant.
|
|
649
650
|
|
|
651
|
+
`product.offers` lists only offers the shopper can earn today: an offer whose
|
|
652
|
+
reward is out of stock, or a tier that costs what the catalog does, is left
|
|
653
|
+
out until it can give something.
|
|
654
|
+
|
|
655
|
+
**Option buttons.** Never derive a variant from each option's first value: a
|
|
656
|
+
shirt made in white M and blue L has no "white L". `getProductView(product)
|
|
657
|
+
.defaultVariant` (the same as `initialVariant(product)`) is the variant a page
|
|
658
|
+
opens on — the merchant's default when it can be sold, else the first that
|
|
659
|
+
can. When the shopper picks a value, `pickOption` names the variant they mean
|
|
660
|
+
and the selection to show, keeping most of their other choices; the buttons
|
|
661
|
+
then always show a variant that exists, the one the order is placed for.
|
|
662
|
+
|
|
663
|
+
~~~ts
|
|
664
|
+
let selection = getProductView(product).defaultVariant?.variations ?? {};
|
|
665
|
+
function choose(option: string, value: string) {
|
|
666
|
+
const picked = pickOption(product, selection, option, value);
|
|
667
|
+
if (!picked.variant) return;
|
|
668
|
+
selection = picked.selection;
|
|
669
|
+
render(picked.variant, optionStates(product, selection));
|
|
670
|
+
// optionStates: { Colour: { White: "available", Black: "sold_out", Blue: "unavailable" }, … }
|
|
671
|
+
}
|
|
672
|
+
~~~
|
|
673
|
+
|
|
674
|
+
The picker reads only `variations`, `active`, `inventory`, `position` and
|
|
675
|
+
`isDefault` of each variant (`PickerVariant`), so a page may hand it a
|
|
676
|
+
lighter copy.
|
|
677
|
+
|
|
650
678
|
### Product lists
|
|
651
679
|
|
|
652
680
|
~~~ts
|
|
@@ -1059,7 +1087,45 @@ variant details. The displayed price can be included for the request shape,
|
|
|
1059
1087
|
but the server is authoritative and recalculates the actual amount.
|
|
1060
1088
|
|
|
1061
1089
|
CreatedOrder contains the order ID, customer-facing order number, product
|
|
1062
|
-
price, delivery fee, and total, all in whole DZD
|
|
1090
|
+
price, delivery fee, and total, all in whole DZD — and `couponDiscount`, what
|
|
1091
|
+
the order's coupon took off.
|
|
1092
|
+
|
|
1093
|
+
### Coupons
|
|
1094
|
+
|
|
1095
|
+
A coupon is a code the shopper types. It applies after offers, only to the
|
|
1096
|
+
products no offer applied to, and the server alone decides whether it applies
|
|
1097
|
+
and what it takes off. A theme asks, then shows the answer.
|
|
1098
|
+
|
|
1099
|
+
Ask the cart quote with the code — and the phone once the shopper has typed
|
|
1100
|
+
one, so a coupon for selected customers or one use per customer is judged
|
|
1101
|
+
before the order:
|
|
1102
|
+
|
|
1103
|
+
~~~ts
|
|
1104
|
+
const quoted = await store.cart.quote(form, { destination, couponCode, phone });
|
|
1105
|
+
if (quoted.success && quoted.data.coupon) {
|
|
1106
|
+
const coupon = quoted.data.coupon;
|
|
1107
|
+
// applied: coupon.discount is already out of quoted.data.subtotal and of each
|
|
1108
|
+
// line's lineTotal (line.discount is its share); coupon.freeShipping
|
|
1109
|
+
// means delivery.fee is 0.
|
|
1110
|
+
// refused: coupon.reason — not_found, not_started (startsAt), expired,
|
|
1111
|
+
// used_up, identify_first, not_eligible, already_used,
|
|
1112
|
+
// no_eligible_products, offer_applied (an offer already prices
|
|
1113
|
+
// every line), below_minimum_subtotal / _quantity (shortBy),
|
|
1114
|
+
// delivery_already_free. An unknown reason gets the theme's general sentence.
|
|
1115
|
+
}
|
|
1116
|
+
~~~
|
|
1117
|
+
|
|
1118
|
+
`CartQuoteOptions` names what a quote may be asked besides its lines.
|
|
1119
|
+
|
|
1120
|
+
Send the code with the order as `couponCode` (`placeOrderInput` keeps it; an
|
|
1121
|
+
empty box sends none). An order whose code does not apply is refused whole —
|
|
1122
|
+
never placed without the discount the shopper expected — with the code
|
|
1123
|
+
`COUPON_NOT_APPLICABLE`; `couponRefusalOf(result)` reads its refusal, and
|
|
1124
|
+
`executePlaceOrder` hands it back as `couponRefusal` so the form can show it on
|
|
1125
|
+
the code's field.
|
|
1126
|
+
|
|
1127
|
+
A shareable link carries a code in the query (`COUPON_URL_PARAM`, `?coupon=RAMADAN10`);
|
|
1128
|
+
`couponFromUrl(url)` reads it so the theme can fill the code box.
|
|
1063
1129
|
|
|
1064
1130
|
### OTP
|
|
1065
1131
|
|
package/dist/actions.d.ts
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
import { z } from "zod";
|
|
37
37
|
import type { StoreClient } from "./client/index.js";
|
|
38
38
|
import type { DomainError } from "./domain.js";
|
|
39
|
-
import type { CreatedOrder } from "./types.js";
|
|
39
|
+
import type { CouponRefusal, CreatedOrder } from "./types.js";
|
|
40
40
|
/**
|
|
41
41
|
* The order form's input whitelist.
|
|
42
42
|
*
|
|
@@ -85,6 +85,7 @@ export declare const placeOrderInput: z.ZodObject<{
|
|
|
85
85
|
offerId: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
86
86
|
}, z.core.$strip>>>, unknown>;
|
|
87
87
|
landingPageSlug: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
88
|
+
couponCode: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
|
|
88
89
|
}, z.core.$strip>;
|
|
89
90
|
export type PlaceOrderInput = z.infer<typeof placeOrderInput>;
|
|
90
91
|
/**
|
|
@@ -106,6 +107,8 @@ export type PlaceOrderResult = {
|
|
|
106
107
|
error: string;
|
|
107
108
|
errorKind: "upstream";
|
|
108
109
|
domainError: DomainError;
|
|
110
|
+
/** Set when the order was refused for its code: the form shows it on the code's field. */
|
|
111
|
+
couponRefusal: CouponRefusal | null;
|
|
109
112
|
};
|
|
110
113
|
/**
|
|
111
114
|
* Execute the placeOrder action's handler body: enforce the
|
package/dist/actions.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* without Astro.
|
|
35
35
|
*/
|
|
36
36
|
import { z } from "zod";
|
|
37
|
+
import { couponRefusalOf } from "./coupon.js";
|
|
37
38
|
import { toLocalAlgerianMobile } from "./phone.js";
|
|
38
39
|
function parseOptionalArrayInput(value) {
|
|
39
40
|
if (!value || value === "[]")
|
|
@@ -149,6 +150,9 @@ export const placeOrderInput = z.object({
|
|
|
149
150
|
// Landing page attribution — best-effort: unknown/draft slug leaves
|
|
150
151
|
// the order unattributed, never blocked (platform extension, LP feature).
|
|
151
152
|
landingPageSlug: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().min(1).max(60).optional()),
|
|
153
|
+
// The code the shopper typed; an empty box sends none. Whether it applies
|
|
154
|
+
// is the server's to say — a refusal comes back as `couponRefusal`.
|
|
155
|
+
couponCode: z.preprocess((v) => (typeof v === "string" && v.trim() === "") || v == null ? undefined : v, z.string().max(64).optional()),
|
|
152
156
|
});
|
|
153
157
|
/**
|
|
154
158
|
* Collect the shopper's attribution headers so the server records the
|
|
@@ -195,6 +199,7 @@ export async function executePlaceOrder(input, request, client) {
|
|
|
195
199
|
error: result.error,
|
|
196
200
|
errorKind: "upstream",
|
|
197
201
|
domainError: result.domainError,
|
|
202
|
+
couponRefusal: couponRefusalOf(result),
|
|
198
203
|
};
|
|
199
204
|
}
|
|
200
205
|
return { success: true, data: result.data };
|
package/dist/client/cart.d.ts
CHANGED
|
@@ -24,19 +24,28 @@ export interface CartNamespace {
|
|
|
24
24
|
*
|
|
25
25
|
* With a `destination`, the answer also carries `delivery`: the fee and
|
|
26
26
|
* total the order would charge there. A theme shows that, never a fee or
|
|
27
|
-
* total of its own making.
|
|
27
|
+
* total of its own making. With a `couponCode`, it carries `coupon`: applied,
|
|
28
|
+
* or refused with the reason the order would be refused for.
|
|
28
29
|
*/
|
|
29
|
-
validate(items: readonly BasketLineInput[], options?:
|
|
30
|
-
destination?: DeliveryDestination;
|
|
31
|
-
}): Promise<ClientResult<CartValidation>>;
|
|
30
|
+
validate(items: readonly BasketLineInput[], options?: CartQuoteOptions): Promise<ClientResult<CartValidation>>;
|
|
32
31
|
/**
|
|
33
32
|
* Quote what an order would cost, for a basket or for one product exactly as
|
|
34
33
|
* its order form posts it (quantity, variants per unit, offer tier). The lines
|
|
35
34
|
* are built the way the order builds them, so the quote and the order agree.
|
|
36
35
|
*/
|
|
37
|
-
quote(lines: CartQuoteLines, options?:
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
quote(lines: CartQuoteLines, options?: CartQuoteOptions): Promise<ClientResult<CartValidation>>;
|
|
37
|
+
}
|
|
38
|
+
/** What a quote is asked besides its lines. */
|
|
39
|
+
export interface CartQuoteOptions {
|
|
40
|
+
/** Where it would be delivered: the answer then carries `delivery`. */
|
|
41
|
+
destination?: DeliveryDestination;
|
|
42
|
+
/** The code the shopper typed: the answer then carries `coupon`. */
|
|
43
|
+
couponCode?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The phone the shopper typed, once there is one: a coupon for selected
|
|
46
|
+
* customers, or one use per customer, is then judged before the order.
|
|
47
|
+
*/
|
|
48
|
+
phone?: string;
|
|
40
49
|
}
|
|
41
50
|
/**
|
|
42
51
|
* Create cart namespace
|
package/dist/client/cart.js
CHANGED
|
@@ -19,9 +19,14 @@ import { localizedPath } from "./paths.js";
|
|
|
19
19
|
*/
|
|
20
20
|
export function createCartNamespace(core) {
|
|
21
21
|
// A closure rather than `this`: a theme may destructure `store.cart`.
|
|
22
|
-
const quote = async (lines, options) => {
|
|
23
|
-
const destination = options
|
|
24
|
-
return core.post(localizedPath(core.config, "/store/cart/validate"),
|
|
22
|
+
const quote = async (lines, options = {}) => {
|
|
23
|
+
const { destination, couponCode, phone } = options;
|
|
24
|
+
return core.post(localizedPath(core.config, "/store/cart/validate"), {
|
|
25
|
+
...lines,
|
|
26
|
+
...(destination ? { destination } : {}),
|
|
27
|
+
...(couponCode?.trim() ? { couponCode } : {}),
|
|
28
|
+
...(phone?.trim() ? { phone } : {}),
|
|
29
|
+
}, { schema: CartValidationSchema, operation: STOREFRONT_OPERATIONS.cartValidate });
|
|
25
30
|
};
|
|
26
31
|
return {
|
|
27
32
|
validate: (items, options) => quote({ items: [...items] }, options),
|
package/dist/coupon.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coupons on the storefront: the code a shareable link brings, and the
|
|
3
|
+
* refusal an order comes back with.
|
|
4
|
+
*
|
|
5
|
+
* What a code does is the server's to say — the cart quote answers `coupon`
|
|
6
|
+
* (applied, or refused with a reason) and the order is refused whole when its
|
|
7
|
+
* code does not apply. A theme shows those answers; it never decides one.
|
|
8
|
+
*/
|
|
9
|
+
import type { ClientResult } from "./domain.js";
|
|
10
|
+
import type { CouponRefusal } from "./types.js";
|
|
11
|
+
/** The error code of an order refused for its code. */
|
|
12
|
+
export declare const COUPON_NOT_APPLICABLE = "COUPON_NOT_APPLICABLE";
|
|
13
|
+
/** The query parameter a shareable coupon link carries: `/products/robe?coupon=RAMADAN10`. */
|
|
14
|
+
export declare const COUPON_URL_PARAM = "coupon";
|
|
15
|
+
/** The code a URL brings, trimmed; null when it brings none. */
|
|
16
|
+
export declare function couponFromUrl(url: URL): string | null;
|
|
17
|
+
/** Why an order was refused for its code, or null when it was refused — or placed — for anything else. */
|
|
18
|
+
export declare function couponRefusalOf(result: ClientResult<unknown>): CouponRefusal | null;
|
package/dist/coupon.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** The error code of an order refused for its code. */
|
|
2
|
+
export const COUPON_NOT_APPLICABLE = "COUPON_NOT_APPLICABLE";
|
|
3
|
+
/** The query parameter a shareable coupon link carries: `/products/robe?coupon=RAMADAN10`. */
|
|
4
|
+
export const COUPON_URL_PARAM = "coupon";
|
|
5
|
+
/** The code a URL brings, trimmed; null when it brings none. */
|
|
6
|
+
export function couponFromUrl(url) {
|
|
7
|
+
const code = url.searchParams.get(COUPON_URL_PARAM)?.trim();
|
|
8
|
+
return code ? code.slice(0, 64) : null;
|
|
9
|
+
}
|
|
10
|
+
/** Why an order was refused for its code, or null when it was refused — or placed — for anything else. */
|
|
11
|
+
export function couponRefusalOf(result) {
|
|
12
|
+
if (result.success || result.domainError.code !== COUPON_NOT_APPLICABLE || typeof result.reason !== "string")
|
|
13
|
+
return null;
|
|
14
|
+
return {
|
|
15
|
+
reason: result.reason,
|
|
16
|
+
...(typeof result.shortBy === "number" ? { shortBy: result.shortBy } : {}),
|
|
17
|
+
...(typeof result.startsAt === "string" ? { startsAt: result.startsAt } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/domain.d.ts
CHANGED
|
@@ -272,6 +272,8 @@ export interface CheckoutDraft {
|
|
|
272
272
|
readonly customFieldResponses: readonly CheckoutFieldAnswer[];
|
|
273
273
|
readonly otpProof: OtpProof | null;
|
|
274
274
|
readonly attribution: CheckoutAttribution;
|
|
275
|
+
/** The code the shopper typed, if any; the server decides whether it applies. */
|
|
276
|
+
readonly couponCode?: string | null;
|
|
275
277
|
}
|
|
276
278
|
/** Proof that the checkout phone was verified or that the provider failed open. */
|
|
277
279
|
export declare const OtpProofSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -292,6 +294,7 @@ export declare const OrderSchema: z.ZodObject<{
|
|
|
292
294
|
price: z.ZodNumber;
|
|
293
295
|
deliveryFee: z.ZodNumber;
|
|
294
296
|
total: z.ZodNumber;
|
|
297
|
+
couponDiscount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
295
298
|
}, z.core.$strip>;
|
|
296
299
|
export type Order = z.infer<typeof OrderSchema>;
|
|
297
300
|
/** Server-owned decision for the thank-you page's conversion event. */
|
package/dist/domain.js
CHANGED
|
@@ -189,6 +189,7 @@ export const OrderSchema = z.object({
|
|
|
189
189
|
price: z.number().finite().nonnegative(),
|
|
190
190
|
deliveryFee: z.number().finite().nonnegative(),
|
|
191
191
|
total: z.number().finite().nonnegative(),
|
|
192
|
+
couponDiscount: z.number().finite().nonnegative().nullish(),
|
|
192
193
|
});
|
|
193
194
|
/** Server-owned decision for the thank-you page's conversion event. */
|
|
194
195
|
export const TrackingDecisionSchema = z.object({
|
|
@@ -35,9 +35,10 @@ export interface StorefrontRoutes<C extends AstroAPIContext> {
|
|
|
35
35
|
readonly otpVerify: StorefrontApiRoute<C>;
|
|
36
36
|
/**
|
|
37
37
|
* POST a basket `{ items }` or one product `{ productId, productName, quantity,
|
|
38
|
-
* variantSelections, offerId, … }`, each with an optional `destination
|
|
39
|
-
* `{ success: true, data: CartValidation }`.
|
|
40
|
-
* With a destination, `data.delivery` is the fee and total the order will
|
|
38
|
+
* variantSelections, offerId, … }`, each with an optional `destination`,
|
|
39
|
+
* `couponCode` and `phone` → `{ success: true, data: CartValidation }`.
|
|
40
|
+
* With a destination, `data.delivery` is the fee and total the order will
|
|
41
|
+
* charge; with a code, `data.coupon` is what it does.
|
|
41
42
|
*/
|
|
42
43
|
readonly cartValidate: StorefrontApiRoute<C>;
|
|
43
44
|
/** POST an `AbandonedCheckoutInput` → 204. */
|
|
@@ -32,16 +32,23 @@ const otpVerifyInput = z.object({
|
|
|
32
32
|
requestId: z.string().min(1).max(200),
|
|
33
33
|
code: z.string().min(1).max(16),
|
|
34
34
|
});
|
|
35
|
+
/** What a quote is asked besides its lines (`CartQuoteOptions`). */
|
|
36
|
+
const quoteAsks = {
|
|
37
|
+
destination: DeliveryDestinationSchema.optional(),
|
|
38
|
+
couponCode: z.string().max(64).optional(),
|
|
39
|
+
phone: z.string().max(32).optional(),
|
|
40
|
+
};
|
|
35
41
|
/**
|
|
36
42
|
* A basket, or one product as its order form posts it — the same two shapes the
|
|
37
|
-
* server's quote takes — each with an optional destination to quote to
|
|
43
|
+
* server's quote takes — each with an optional destination to quote to, the
|
|
44
|
+
* code typed and the phone typed.
|
|
38
45
|
*/
|
|
39
46
|
const cartValidateInput = z.union([
|
|
40
47
|
z.object({
|
|
41
48
|
items: z.array(BasketLineInputSchema).min(1).max(MAX_BASKET_LINES),
|
|
42
|
-
|
|
49
|
+
...quoteAsks,
|
|
43
50
|
}),
|
|
44
|
-
ProductQuoteInputSchema.extend(
|
|
51
|
+
ProductQuoteInputSchema.extend(quoteAsks),
|
|
45
52
|
]);
|
|
46
53
|
const abandonedConvertInput = z.object({
|
|
47
54
|
sessionId: z.string().uuid(),
|
|
@@ -125,7 +132,11 @@ export function createStorefrontRoutes(options) {
|
|
|
125
132
|
method: "POST",
|
|
126
133
|
inputSchema: cartValidateInput,
|
|
127
134
|
successBody: (data) => ({ success: true, data }),
|
|
128
|
-
call: (client, { destination, ...lines }) => client.cart.quote(lines,
|
|
135
|
+
call: (client, { destination, couponCode, phone, ...lines }) => client.cart.quote(lines, {
|
|
136
|
+
...(destination ? { destination } : {}),
|
|
137
|
+
...(couponCode !== undefined ? { couponCode } : {}),
|
|
138
|
+
...(phone !== undefined ? { phone } : {}),
|
|
139
|
+
}),
|
|
129
140
|
}),
|
|
130
141
|
abandonedUpsert: route({
|
|
131
142
|
method: "POST",
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,10 @@ export * from "./schemas.js";
|
|
|
35
35
|
export * from "./domain.js";
|
|
36
36
|
export * from "./product-view.js";
|
|
37
37
|
export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
|
|
38
|
+
export { COUPON_NOT_APPLICABLE, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf } from "./coupon.js";
|
|
39
|
+
export { initialVariant, optionStates, pickOption, variantFor } from "./variant-picker.js";
|
|
40
|
+
export type { OptionSelection, OptionState, PickerVariant } from "./variant-picker.js";
|
|
41
|
+
export type { CartQuoteOptions } from "./client/cart.js";
|
|
38
42
|
export type { OfferTierInput, OfferTierView, QuantityTierView } from "./offer-view.js";
|
|
39
43
|
export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
|
|
40
44
|
export type { AbandonedCheckoutPorts, AbandonedCheckoutSnapshot, AbandonedCheckoutStorage, AbandonedCheckoutTracker, } from "./abandoned-checkout.js";
|
package/dist/index.js
CHANGED
|
@@ -26,5 +26,7 @@ export * from "./schemas.js";
|
|
|
26
26
|
export * from "./domain.js";
|
|
27
27
|
export * from "./product-view.js";
|
|
28
28
|
export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
|
|
29
|
+
export { COUPON_NOT_APPLICABLE, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf } from "./coupon.js";
|
|
30
|
+
export { initialVariant, optionStates, pickOption, variantFor } from "./variant-picker.js";
|
|
29
31
|
export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
|
|
30
32
|
export { META_CURRENCY, buildMetaAddToCartPayload, buildMetaInitiateCheckoutPayload, buildMetaProductPayload, buildMetaViewContentPayload, createMetaPixelTracker, } from "./meta-tracking.js";
|
package/dist/product-view.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { initialVariant } from "./variant-picker.js";
|
|
1
2
|
function discountOf(basePrice, comparePrice) {
|
|
2
3
|
if (comparePrice == null || comparePrice <= basePrice) {
|
|
3
4
|
return { hasDiscount: false, discountPct: 0 };
|
|
@@ -17,9 +18,7 @@ function stockOf(product) {
|
|
|
17
18
|
};
|
|
18
19
|
}
|
|
19
20
|
export function getProductView(product) {
|
|
20
|
-
const defaultVariant = product.hasVariants
|
|
21
|
-
? (product.variants.find((variant) => variant.isDefault) ?? product.variants[0] ?? null)
|
|
22
|
-
: null;
|
|
21
|
+
const defaultVariant = product.hasVariants ? initialVariant(product) : null;
|
|
23
22
|
const basePrice = defaultVariant?.price ?? product.price;
|
|
24
23
|
const comparePrice = defaultVariant?.compareAtPrice ?? product.compareAtPrice ?? null;
|
|
25
24
|
return {
|
package/dist/schemas.d.ts
CHANGED
|
@@ -713,6 +713,7 @@ export declare const CartValidationLineSchema: z.ZodObject<{
|
|
|
713
713
|
unitPrice: z.ZodNumber;
|
|
714
714
|
lineTotal: z.ZodNumber;
|
|
715
715
|
offerId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
716
|
+
discount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
716
717
|
maxQuantity: z.ZodNullable<z.ZodNumber>;
|
|
717
718
|
blocker: z.ZodNullable<z.ZodEnum<{
|
|
718
719
|
missing: "missing";
|
|
@@ -726,6 +727,24 @@ export declare const CartRewardSchema: z.ZodObject<{
|
|
|
726
727
|
productName: z.ZodNullable<z.ZodString>;
|
|
727
728
|
quantity: z.ZodNumber;
|
|
728
729
|
}, z.core.$strip>;
|
|
730
|
+
/**
|
|
731
|
+
* What the code the quote was asked with did: applied, or refused with the
|
|
732
|
+
* reason the order would be refused for. `reason` is an open string — a reason
|
|
733
|
+
* this SDK has not seen yet still validates — and `shortBy` / `startsAt` come
|
|
734
|
+
* with the reasons that have them.
|
|
735
|
+
*/
|
|
736
|
+
export declare const CouponOutcomeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
737
|
+
code: z.ZodString;
|
|
738
|
+
applied: z.ZodLiteral<true>;
|
|
739
|
+
discount: z.ZodNumber;
|
|
740
|
+
freeShipping: z.ZodBoolean;
|
|
741
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
742
|
+
code: z.ZodString;
|
|
743
|
+
applied: z.ZodLiteral<false>;
|
|
744
|
+
reason: z.ZodString;
|
|
745
|
+
shortBy: z.ZodOptional<z.ZodNumber>;
|
|
746
|
+
startsAt: z.ZodOptional<z.ZodString>;
|
|
747
|
+
}, z.core.$strip>], "applied">;
|
|
729
748
|
export declare const CartValidationSchema: z.ZodObject<{
|
|
730
749
|
lines: z.ZodArray<z.ZodObject<{
|
|
731
750
|
productId: z.ZodString;
|
|
@@ -736,6 +755,7 @@ export declare const CartValidationSchema: z.ZodObject<{
|
|
|
736
755
|
unitPrice: z.ZodNumber;
|
|
737
756
|
lineTotal: z.ZodNumber;
|
|
738
757
|
offerId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
758
|
+
discount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
739
759
|
maxQuantity: z.ZodNullable<z.ZodNumber>;
|
|
740
760
|
blocker: z.ZodNullable<z.ZodEnum<{
|
|
741
761
|
missing: "missing";
|
|
@@ -765,6 +785,18 @@ export declare const CartValidationSchema: z.ZodObject<{
|
|
|
765
785
|
fee: z.ZodNull;
|
|
766
786
|
total: z.ZodNull;
|
|
767
787
|
}, z.core.$strip>], "available">>;
|
|
788
|
+
coupon: z.ZodOptional<z.ZodNullable<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
789
|
+
code: z.ZodString;
|
|
790
|
+
applied: z.ZodLiteral<true>;
|
|
791
|
+
discount: z.ZodNumber;
|
|
792
|
+
freeShipping: z.ZodBoolean;
|
|
793
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
794
|
+
code: z.ZodString;
|
|
795
|
+
applied: z.ZodLiteral<false>;
|
|
796
|
+
reason: z.ZodString;
|
|
797
|
+
shortBy: z.ZodOptional<z.ZodNumber>;
|
|
798
|
+
startsAt: z.ZodOptional<z.ZodString>;
|
|
799
|
+
}, z.core.$strip>], "applied">>>;
|
|
768
800
|
}, z.core.$strip>;
|
|
769
801
|
export declare const CreatedOrderSchema: z.ZodObject<{
|
|
770
802
|
orderId: z.ZodString;
|
|
@@ -772,6 +804,7 @@ export declare const CreatedOrderSchema: z.ZodObject<{
|
|
|
772
804
|
price: z.ZodNumber;
|
|
773
805
|
deliveryFee: z.ZodNumber;
|
|
774
806
|
total: z.ZodNumber;
|
|
807
|
+
couponDiscount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
775
808
|
}, z.core.$strip>;
|
|
776
809
|
export declare const OrderTrackingSchema: z.ZodObject<{
|
|
777
810
|
pixelId: z.ZodNullable<z.ZodString>;
|
package/dist/schemas.js
CHANGED
|
@@ -351,8 +351,10 @@ export const CartValidationLineSchema = z.object({
|
|
|
351
351
|
quantity: z.number().int(),
|
|
352
352
|
unitPrice: z.number(),
|
|
353
353
|
lineTotal: z.number(),
|
|
354
|
-
/** The
|
|
354
|
+
/** The offer applied to the line; absent from a server that predates offers on lines. */
|
|
355
355
|
offerId: z.string().nullish(),
|
|
356
|
+
/** The line's share of the coupon's discount, already out of `lineTotal`; absent from a server that predates coupons. */
|
|
357
|
+
discount: z.number().nullish(),
|
|
356
358
|
maxQuantity: z.number().nullable(),
|
|
357
359
|
blocker: z.enum(["missing", "unlisted", "out_of_stock"]).nullable(),
|
|
358
360
|
});
|
|
@@ -362,6 +364,16 @@ export const CartRewardSchema = z.object({
|
|
|
362
364
|
productName: z.string().nullable(),
|
|
363
365
|
quantity: z.number().int(),
|
|
364
366
|
});
|
|
367
|
+
/**
|
|
368
|
+
* What the code the quote was asked with did: applied, or refused with the
|
|
369
|
+
* reason the order would be refused for. `reason` is an open string — a reason
|
|
370
|
+
* this SDK has not seen yet still validates — and `shortBy` / `startsAt` come
|
|
371
|
+
* with the reasons that have them.
|
|
372
|
+
*/
|
|
373
|
+
export const CouponOutcomeSchema = z.discriminatedUnion("applied", [
|
|
374
|
+
z.object({ code: z.string(), applied: z.literal(true), discount: z.number(), freeShipping: z.boolean() }),
|
|
375
|
+
z.object({ code: z.string(), applied: z.literal(false), reason: z.string(), shortBy: z.number().optional(), startsAt: z.string().optional() }),
|
|
376
|
+
]);
|
|
365
377
|
export const CartValidationSchema = z.object({
|
|
366
378
|
lines: z.array(CartValidationLineSchema),
|
|
367
379
|
subtotal: z.number(),
|
|
@@ -378,6 +390,8 @@ export const CartValidationSchema = z.object({
|
|
|
378
390
|
z.object({ available: z.literal(false), fee: z.null(), total: z.null() }),
|
|
379
391
|
])
|
|
380
392
|
.optional(),
|
|
393
|
+
/** The code's outcome: null without one; absent from a server that predates coupons. */
|
|
394
|
+
coupon: CouponOutcomeSchema.nullish(),
|
|
381
395
|
});
|
|
382
396
|
export const CreatedOrderSchema = z.object({
|
|
383
397
|
orderId: z.string(),
|
|
@@ -385,6 +399,8 @@ export const CreatedOrderSchema = z.object({
|
|
|
385
399
|
price: z.number(),
|
|
386
400
|
deliveryFee: z.number(),
|
|
387
401
|
total: z.number(),
|
|
402
|
+
/** What the coupon took off; absent from a server that predates coupons. */
|
|
403
|
+
couponDiscount: z.number().nullish(),
|
|
388
404
|
});
|
|
389
405
|
export const OrderTrackingSchema = z.object({
|
|
390
406
|
pixelId: z.string().nullable(),
|
package/dist/types.d.ts
CHANGED
|
@@ -310,10 +310,12 @@ export interface CartValidationLine {
|
|
|
310
310
|
quantity: number;
|
|
311
311
|
/** CURRENT catalog price — the cart's optimistic price is display-only. */
|
|
312
312
|
unitPrice: number;
|
|
313
|
-
/** What the line costs
|
|
313
|
+
/** What the line costs, offers and the coupon applied. */
|
|
314
314
|
lineTotal: number;
|
|
315
|
-
/** The
|
|
315
|
+
/** The offer applied to the line; null or absent when none. */
|
|
316
316
|
offerId?: string | null | undefined;
|
|
317
|
+
/** The line's share of the coupon's discount, already out of `lineTotal`; null or absent without one. */
|
|
318
|
+
discount?: number | null | undefined;
|
|
317
319
|
/** Units still available; null when the product is not stock-tracked. */
|
|
318
320
|
maxQuantity: number | null;
|
|
319
321
|
/**
|
|
@@ -349,7 +351,31 @@ export interface CartValidation {
|
|
|
349
351
|
* deliver there, and an order to it is refused.
|
|
350
352
|
*/
|
|
351
353
|
delivery?: CartDeliveryQuote | undefined;
|
|
354
|
+
/** What the code the quote was asked with did; null without one, absent from a server that predates coupons. */
|
|
355
|
+
coupon?: CouponOutcome | null | undefined;
|
|
352
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Why a code does not apply. Open: a server may name a reason this SDK has not
|
|
359
|
+
* seen yet, and a theme then shows its general sentence.
|
|
360
|
+
*/
|
|
361
|
+
export type CouponRefusalReason = "not_found" | "not_started" | "expired" | "used_up" | "identify_first" | "not_eligible" | "already_used" | "no_eligible_products" | "offer_applied" | "below_minimum_subtotal" | "below_minimum_quantity" | "delivery_already_free" | (string & {});
|
|
362
|
+
export interface CouponRefusal {
|
|
363
|
+
reason: CouponRefusalReason;
|
|
364
|
+
/** The dinars (`below_minimum_subtotal`) or items (`below_minimum_quantity`) still missing. */
|
|
365
|
+
shortBy?: number | undefined;
|
|
366
|
+
/** When a `not_started` code begins. */
|
|
367
|
+
startsAt?: string | undefined;
|
|
368
|
+
}
|
|
369
|
+
/** A code applied — `discount` already out of the lines, delivery free with `freeShipping` — or refused. */
|
|
370
|
+
export type CouponOutcome = {
|
|
371
|
+
code: string;
|
|
372
|
+
applied: true;
|
|
373
|
+
discount: number;
|
|
374
|
+
freeShipping: boolean;
|
|
375
|
+
} | ({
|
|
376
|
+
code: string;
|
|
377
|
+
applied: false;
|
|
378
|
+
} & CouponRefusal);
|
|
353
379
|
export type CartDeliveryQuote = {
|
|
354
380
|
available: true;
|
|
355
381
|
fee: number;
|
|
@@ -365,8 +391,10 @@ export interface CreatedOrder {
|
|
|
365
391
|
orderNumber: string;
|
|
366
392
|
price: number;
|
|
367
393
|
deliveryFee: number;
|
|
368
|
-
/** price + deliveryFee,
|
|
394
|
+
/** price + deliveryFee, offers and the coupon applied. */
|
|
369
395
|
total: number;
|
|
396
|
+
/** What the coupon took off; null or absent without one. */
|
|
397
|
+
couponDiscount?: number | null | undefined;
|
|
370
398
|
}
|
|
371
399
|
/** One basket line in a create-order body. */
|
|
372
400
|
export interface CreateOrderItemInput {
|
|
@@ -431,6 +459,8 @@ export interface CreateOrderInput {
|
|
|
431
459
|
items?: CreateOrderItemInput[] | undefined;
|
|
432
460
|
/** Landing-page attribution — best-effort; an unknown slug never blocks. */
|
|
433
461
|
landingPageSlug?: string | undefined;
|
|
462
|
+
/** The code the shopper typed. An order whose code does not apply is refused whole (`couponRefusalOf`). */
|
|
463
|
+
couponCode?: string | undefined;
|
|
434
464
|
}
|
|
435
465
|
/**
|
|
436
466
|
* The order's tracking destination — which Meta pixel it belongs to and
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Choosing a variant with option buttons — the rule every product page needs,
|
|
3
|
+
* so no theme guesses it.
|
|
4
|
+
*
|
|
5
|
+
* A page opens on a variant that can be sold, its buttons showing that
|
|
6
|
+
* variant's values. A value picked that does not combine with the other
|
|
7
|
+
* choices moves the selection to the variant that has it and keeps most of
|
|
8
|
+
* them, so what the buttons show is always a variant that exists — the one
|
|
9
|
+
* the order is placed for. Each value can be told apart: available, sold out,
|
|
10
|
+
* or not made with the other choices.
|
|
11
|
+
*/
|
|
12
|
+
import type { ProductVariant } from "./types.js";
|
|
13
|
+
/** What the picker reads of a variant — a page may hand it a lighter copy than `ProductVariant`. */
|
|
14
|
+
export type PickerVariant = Pick<ProductVariant, "variations" | "active" | "inventory" | "position" | "isDefault">;
|
|
15
|
+
/** Option name → chosen value, as a product's `variantOptions` name them. */
|
|
16
|
+
export type OptionSelection = Readonly<Record<string, string>>;
|
|
17
|
+
export type OptionState = "available" | "sold_out" | "unavailable";
|
|
18
|
+
interface Catalog<V extends PickerVariant> {
|
|
19
|
+
readonly variants: readonly V[];
|
|
20
|
+
readonly trackInventory: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** The variant whose every option has the value `selection` gives it, or null. */
|
|
23
|
+
export declare function variantFor<V extends PickerVariant>(variants: readonly V[], selection: OptionSelection): V | null;
|
|
24
|
+
/**
|
|
25
|
+
* The variant a product page opens on: the merchant's default when it can be
|
|
26
|
+
* sold, else the first that can, else the default (or first) shown sold out.
|
|
27
|
+
*/
|
|
28
|
+
export declare function initialVariant<V extends PickerVariant>(product: Catalog<V>): V | null;
|
|
29
|
+
/**
|
|
30
|
+
* The shopper picks `value` for `option`. The variant they mean is the one
|
|
31
|
+
* with that value that keeps most of their other choices — one that can be
|
|
32
|
+
* sold before one that cannot, then the catalog's order. Null, with the
|
|
33
|
+
* selection unchanged, when no variant has the value.
|
|
34
|
+
*/
|
|
35
|
+
export declare function pickOption<V extends PickerVariant>(product: Catalog<V>, selection: OptionSelection, option: string, value: string): {
|
|
36
|
+
variant: V | null;
|
|
37
|
+
selection: OptionSelection;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Each value of each option, given the other choices: `available` when a
|
|
41
|
+
* variant with it and the other choices can be sold, `sold_out` when that
|
|
42
|
+
* variant exists without stock, `unavailable` when it is not made.
|
|
43
|
+
*/
|
|
44
|
+
export declare function optionStates<V extends PickerVariant>(product: Catalog<V> & {
|
|
45
|
+
readonly variantOptions?: readonly {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly values: readonly {
|
|
48
|
+
readonly value: string;
|
|
49
|
+
}[];
|
|
50
|
+
}[] | null;
|
|
51
|
+
}, selection: OptionSelection): Record<string, Record<string, OptionState>>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const live = (variant) => variant.active;
|
|
2
|
+
const sellable = (product, variant) => live(variant) && (!product.trackInventory || variant.inventory > 0);
|
|
3
|
+
const byPosition = (a, b) => a.position - b.position;
|
|
4
|
+
/** The variant whose every option has the value `selection` gives it, or null. */
|
|
5
|
+
export function variantFor(variants, selection) {
|
|
6
|
+
return variants.find((variant) => live(variant) && Object.entries(variant.variations).every(([option, value]) => selection[option] === value)) ?? null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The variant a product page opens on: the merchant's default when it can be
|
|
10
|
+
* sold, else the first that can, else the default (or first) shown sold out.
|
|
11
|
+
*/
|
|
12
|
+
export function initialVariant(product) {
|
|
13
|
+
const ordered = product.variants.filter(live).sort(byPosition);
|
|
14
|
+
const preferred = ordered.find((variant) => variant.isDefault) ?? ordered[0] ?? null;
|
|
15
|
+
if (preferred && sellable(product, preferred))
|
|
16
|
+
return preferred;
|
|
17
|
+
return ordered.find((variant) => sellable(product, variant)) ?? preferred;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The shopper picks `value` for `option`. The variant they mean is the one
|
|
21
|
+
* with that value that keeps most of their other choices — one that can be
|
|
22
|
+
* sold before one that cannot, then the catalog's order. Null, with the
|
|
23
|
+
* selection unchanged, when no variant has the value.
|
|
24
|
+
*/
|
|
25
|
+
export function pickOption(product, selection, option, value) {
|
|
26
|
+
const kept = (variant) => Object.entries(selection).filter(([name, chosen]) => variant.variations[name] === chosen).length;
|
|
27
|
+
const candidates = product.variants.filter((variant) => live(variant) && variant.variations[option] === value);
|
|
28
|
+
const best = candidates.sort((a, b) => kept(b) - kept(a) || Number(sellable(product, b)) - Number(sellable(product, a)) || byPosition(a, b))[0];
|
|
29
|
+
return best ? { variant: best, selection: { ...best.variations } } : { variant: null, selection };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Each value of each option, given the other choices: `available` when a
|
|
33
|
+
* variant with it and the other choices can be sold, `sold_out` when that
|
|
34
|
+
* variant exists without stock, `unavailable` when it is not made.
|
|
35
|
+
*/
|
|
36
|
+
export function optionStates(product, selection) {
|
|
37
|
+
const states = {};
|
|
38
|
+
for (const option of product.variantOptions ?? []) {
|
|
39
|
+
states[option.name] = {};
|
|
40
|
+
for (const { value } of option.values) {
|
|
41
|
+
const match = variantFor(product.variants, { ...selection, [option.name]: value });
|
|
42
|
+
states[option.name][value] = match === null ? "unavailable" : sellable(product, match) ? "available" : "sold_out";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return states;
|
|
46
|
+
}
|
package/package.json
CHANGED