@wocart/storefront 1.9.0 → 1.11.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 CHANGED
@@ -5,6 +5,43 @@ 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.11.0 — 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - **Coupons.** `cart.quote` / `cart.validate` take `couponCode` and `phone`
13
+ (`CartQuoteOptions`) and answer `coupon` (`CouponOutcome`,
14
+ `CouponOutcomeSchema`): applied — its `discount` already out of `subtotal`
15
+ and of each line's `lineTotal`, `CartValidationLine.discount` being the
16
+ line's share, `freeShipping` making the delivery fee 0 — or refused with a
17
+ `reason` (`CouponRefusalReason`, open), `shortBy` or `startsAt` when it has
18
+ one. Null without a code; absent from an older server.
19
+ - `CreateOrderInput.couponCode` and `placeOrderInput.couponCode` (an empty
20
+ box sends none); `CreatedOrder.couponDiscount`. An order whose code does not
21
+ apply is refused whole with `COUPON_NOT_APPLICABLE`: `couponRefusalOf(result)`
22
+ reads the refusal, and `executePlaceOrder`'s upstream failure carries it as
23
+ `couponRefusal`.
24
+ - Shareable coupon links: `COUPON_URL_PARAM` (`?coupon=CODE`) and
25
+ `couponFromUrl(url)`.
26
+ - `CheckoutDraft.couponCode`.
27
+
28
+ ### Changed
29
+
30
+ - `CartValidationLine.offerId` names the offer applied to the line, whatever
31
+ its kind — a coupon never covers that line.
32
+
33
+ ## 1.10.0 — 2026-10-17
34
+
35
+ ### Added
36
+
37
+ - **Quantity pricing.** `ProductDetail.quantityPricing` (`QuantityPricing`,
38
+ `QuantityPricingSchema`): a product's price tiers by quantity, each with an
39
+ optional free delivery — null or absent when it has none.
40
+ `getQuantityTierViews(pricing, unitPrice)` projects them for tiles (price,
41
+ per-unit price, saving); the total is the server's cart quote.
42
+ - `CartValidationLine.offerId`: the quantity-pricing offer that priced the
43
+ line, whose `lineTotal` is then the tier's price. Optional.
44
+
8
45
  ## 1.9.0 — 2026-10-17
9
46
 
10
47
  ### 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
+ | `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 |
226
227
  | `META_CURRENCY`, `buildMetaProductPayload`, `buildMetaViewContentPayload`, `buildMetaAddToCartPayload`, `buildMetaInitiateCheckoutPayload` | Browser-safe Meta event payloads with the platform currency and catalog shape |
227
228
  | `createMetaPixelTracker` | Injected browser-pixel dispatch; the theme supplies the vendor global and keeps bootstrap/timing |
228
229
  | `createBasketStore`, `createLocalStorageAdapter`, `createMemoryStorageAdapter`, `DEFAULT_BASKET_STORAGE_KEY` | The browser basket, its storage, and the key it persists under (`cod_cart_v1`) |
@@ -625,8 +626,26 @@ if (!product) {
625
626
  ~~~
626
627
 
627
628
  Product detail includes the product fields, parsed variant options and tags,
628
- the category, variants, images, review statistics, and offers. The server
629
- decides whether a product is publicly available.
629
+ the category, variants, images, review statistics, quantity pricing, and
630
+ offers. The server decides whether a product is publicly available.
631
+
632
+ **Quantity pricing** (`product.quantityPricing`, null when the product has
633
+ none) prices a product by how many are bought — "1 for 2,000 DA, 2 for 3,500
634
+ DA, 3 for 4,500 DA". The highest tier the quantity reaches prices every unit
635
+ at its per-unit price, and a tier may include free delivery for a basket of
636
+ that product alone. A product has at most one offer per order, so it has
637
+ either quantity pricing or offers in `offers`, never both at once.
638
+
639
+ ~~~ts
640
+ const tiles = product.quantityPricing ? getQuantityTierViews(product.quantityPricing, product.price) : [];
641
+ // tiles: [{ quantity, price, perUnit, savingsAmount, savePct, freeShipping }]
642
+ ~~~
643
+
644
+ Show the tiers as choices; never compute the total yourself. The cart quote
645
+ (`POST /api/cart/validate`) answers what the shopper pays — each line's
646
+ `lineTotal` already priced by its tier (`offerId` names the offer), and the
647
+ delivery — and the order charges exactly that. A tier locked to one variant
648
+ (`triggerVariantId`) counts and prices only that variant.
630
649
 
631
650
  ### Product lists
632
651
 
@@ -1040,7 +1059,44 @@ variant details. The displayed price can be included for the request shape,
1040
1059
  but the server is authoritative and recalculates the actual amount.
1041
1060
 
1042
1061
  CreatedOrder contains the order ID, customer-facing order number, product
1043
- price, delivery fee, and total, all in whole DZD.
1062
+ price, delivery fee, and total, all in whole DZD — and `couponDiscount`, what
1063
+ the order's coupon took off.
1064
+
1065
+ ### Coupons
1066
+
1067
+ A coupon is a code the shopper types. It applies after offers, only to the
1068
+ products no offer applied to, and the server alone decides whether it applies
1069
+ and what it takes off. A theme asks, then shows the answer.
1070
+
1071
+ Ask the cart quote with the code — and the phone once the shopper has typed
1072
+ one, so a coupon for selected customers or one use per customer is judged
1073
+ before the order:
1074
+
1075
+ ~~~ts
1076
+ const quoted = await store.cart.quote(form, { destination, couponCode, phone });
1077
+ if (quoted.success && quoted.data.coupon) {
1078
+ const coupon = quoted.data.coupon;
1079
+ // applied: coupon.discount is already out of quoted.data.subtotal and of each
1080
+ // line's lineTotal (line.discount is its share); coupon.freeShipping
1081
+ // means delivery.fee is 0.
1082
+ // refused: coupon.reason — not_found, not_started (startsAt), expired,
1083
+ // used_up, identify_first, not_eligible, already_used,
1084
+ // no_eligible_products, below_minimum_subtotal / _quantity (shortBy),
1085
+ // delivery_already_free. An unknown reason gets the theme's general sentence.
1086
+ }
1087
+ ~~~
1088
+
1089
+ `CartQuoteOptions` names what a quote may be asked besides its lines.
1090
+
1091
+ Send the code with the order as `couponCode` (`placeOrderInput` keeps it; an
1092
+ empty box sends none). An order whose code does not apply is refused whole —
1093
+ never placed without the discount the shopper expected — with the code
1094
+ `COUPON_NOT_APPLICABLE`; `couponRefusalOf(result)` reads its refusal, and
1095
+ `executePlaceOrder` hands it back as `couponRefusal` so the form can show it on
1096
+ the code's field.
1097
+
1098
+ A shareable link carries a code in the query (`COUPON_URL_PARAM`, `?coupon=RAMADAN10`);
1099
+ `couponFromUrl(url)` reads it so the theme can fill the code box.
1044
1100
 
1045
1101
  ### OTP
1046
1102
 
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 };
@@ -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
- destination?: DeliveryDestination;
39
- }): Promise<ClientResult<CartValidation>>;
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
@@ -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?.destination;
24
- return core.post(localizedPath(core.config, "/store/cart/validate"), destination ? { ...lines, destination } : lines, { schema: CartValidationSchema, operation: STOREFRONT_OPERATIONS.cartValidate });
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),
@@ -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 charge.
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
- destination: DeliveryDestinationSchema.optional(),
49
+ ...quoteAsks,
43
50
  }),
44
- ProductQuoteInputSchema.extend({ destination: DeliveryDestinationSchema.optional() }),
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, destination ? { destination } : undefined),
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
@@ -34,8 +34,10 @@ export * from "./types.js";
34
34
  export * from "./schemas.js";
35
35
  export * from "./domain.js";
36
36
  export * from "./product-view.js";
37
- export { getOfferTierView } from "./offer-view.js";
38
- export type { OfferTierInput, OfferTierView } from "./offer-view.js";
37
+ export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
38
+ export { COUPON_NOT_APPLICABLE, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf } from "./coupon.js";
39
+ export type { CartQuoteOptions } from "./client/cart.js";
40
+ export type { OfferTierInput, OfferTierView, QuantityTierView } from "./offer-view.js";
39
41
  export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
40
42
  export type { AbandonedCheckoutPorts, AbandonedCheckoutSnapshot, AbandonedCheckoutStorage, AbandonedCheckoutTracker, } from "./abandoned-checkout.js";
41
43
  export { META_CURRENCY, buildMetaAddToCartPayload, buildMetaInitiateCheckoutPayload, buildMetaProductPayload, buildMetaViewContentPayload, createMetaPixelTracker, } from "./meta-tracking.js";
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ export * from "./types.js";
25
25
  export * from "./schemas.js";
26
26
  export * from "./domain.js";
27
27
  export * from "./product-view.js";
28
- export { getOfferTierView } from "./offer-view.js";
28
+ export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
29
+ export { COUPON_NOT_APPLICABLE, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf } from "./coupon.js";
29
30
  export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
30
31
  export { META_CURRENCY, buildMetaAddToCartPayload, buildMetaInitiateCheckoutPayload, buildMetaProductPayload, buildMetaViewContentPayload, createMetaPixelTracker, } from "./meta-tracking.js";
@@ -1,4 +1,4 @@
1
- import type { Offer } from "./types.js";
1
+ import type { Offer, QuantityPricing } from "./types.js";
2
2
  export interface OfferTierView {
3
3
  isFreeShipping: boolean;
4
4
  savePct: number;
@@ -8,3 +8,20 @@ export interface OfferTierView {
8
8
  }
9
9
  export type OfferTierInput = Pick<Offer, "discountType" | "triggerQuantity" | "rewardQuantity" | "rewardUnitPrice">;
10
10
  export declare function getOfferTierView(offer: OfferTierInput, basePrice: number, comparePrice: number | null): OfferTierView;
11
+ export interface QuantityTierView {
12
+ quantity: number;
13
+ /** What that many units cost. */
14
+ price: number;
15
+ /** What one unit costs within the tier, rounded to the dinar. */
16
+ perUnit: number;
17
+ /** What the tier saves against buying that many at `unitPrice`; 0 when it saves nothing. */
18
+ savingsAmount: number;
19
+ savePct: number;
20
+ freeShipping: boolean;
21
+ }
22
+ /**
23
+ * The tiers of a product's quantity pricing as tiles show them: price, per-unit
24
+ * price and saving against the product's unit price. Presentation only — the
25
+ * total for a quantity is the server's cart quote.
26
+ */
27
+ export declare function getQuantityTierViews(pricing: Pick<QuantityPricing, "tiers">, unitPrice: number): QuantityTierView[];
@@ -15,3 +15,24 @@ export function getOfferTierView(offer, basePrice, comparePrice) {
15
15
  comparePrice: comparePrice === null ? null : comparePrice * offer.triggerQuantity,
16
16
  };
17
17
  }
18
+ /**
19
+ * The tiers of a product's quantity pricing as tiles show them: price, per-unit
20
+ * price and saving against the product's unit price. Presentation only — the
21
+ * total for a quantity is the server's cart quote.
22
+ */
23
+ export function getQuantityTierViews(pricing, unitPrice) {
24
+ return [...pricing.tiers]
25
+ .sort((a, b) => a.quantity - b.quantity)
26
+ .map((tier) => {
27
+ const listValue = unitPrice * tier.quantity;
28
+ const savingsAmount = Math.max(0, listValue - tier.price);
29
+ return {
30
+ quantity: tier.quantity,
31
+ price: tier.price,
32
+ perUnit: Math.round(tier.price / tier.quantity),
33
+ savingsAmount,
34
+ savePct: listValue > 0 ? Math.round((savingsAmount / listValue) * 100) : 0,
35
+ freeShipping: tier.freeShipping,
36
+ };
37
+ });
38
+ }
package/dist/schemas.d.ts CHANGED
@@ -43,6 +43,21 @@ export declare const OfferSchema: z.ZodObject<{
43
43
  rewardVariantLabel: z.ZodNullable<z.ZodString>;
44
44
  rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
45
45
  }, z.core.$strip>;
46
+ /**
47
+ * Quantity pricing — price tiers by quantity ("2 for 3,500 DA"). The highest
48
+ * tier the quantity reaches prices every unit at its per-unit price; the
49
+ * server's cart quote is the total.
50
+ */
51
+ export declare const QuantityPricingSchema: z.ZodObject<{
52
+ offerId: z.ZodString;
53
+ name: z.ZodString;
54
+ triggerVariantId: z.ZodNullable<z.ZodString>;
55
+ tiers: z.ZodArray<z.ZodObject<{
56
+ quantity: z.ZodNumber;
57
+ price: z.ZodNumber;
58
+ freeShipping: z.ZodBoolean;
59
+ }, z.core.$strip>>;
60
+ }, z.core.$strip>;
46
61
  export declare const CategorySchema: z.ZodObject<{
47
62
  id: z.ZodString;
48
63
  name: z.ZodString;
@@ -201,6 +216,16 @@ export declare const ProductSchema: z.ZodObject<{
201
216
  avgRating: z.ZodNumber;
202
217
  reviewCount: z.ZodNumber;
203
218
  }, z.core.$strip>>;
219
+ quantityPricing: z.ZodOptional<z.ZodNullable<z.ZodObject<{
220
+ offerId: z.ZodString;
221
+ name: z.ZodString;
222
+ triggerVariantId: z.ZodNullable<z.ZodString>;
223
+ tiers: z.ZodArray<z.ZodObject<{
224
+ quantity: z.ZodNumber;
225
+ price: z.ZodNumber;
226
+ freeShipping: z.ZodBoolean;
227
+ }, z.core.$strip>>;
228
+ }, z.core.$strip>>>;
204
229
  offers: z.ZodArray<z.ZodObject<{
205
230
  id: z.ZodString;
206
231
  name: z.ZodString;
@@ -629,6 +654,16 @@ export declare const LandingPageSchema: z.ZodObject<{
629
654
  avgRating: z.ZodNumber;
630
655
  reviewCount: z.ZodNumber;
631
656
  }, z.core.$strip>>;
657
+ quantityPricing: z.ZodOptional<z.ZodNullable<z.ZodObject<{
658
+ offerId: z.ZodString;
659
+ name: z.ZodString;
660
+ triggerVariantId: z.ZodNullable<z.ZodString>;
661
+ tiers: z.ZodArray<z.ZodObject<{
662
+ quantity: z.ZodNumber;
663
+ price: z.ZodNumber;
664
+ freeShipping: z.ZodBoolean;
665
+ }, z.core.$strip>>;
666
+ }, z.core.$strip>>>;
632
667
  offers: z.ZodArray<z.ZodObject<{
633
668
  id: z.ZodString;
634
669
  name: z.ZodString;
@@ -677,6 +712,8 @@ export declare const CartValidationLineSchema: z.ZodObject<{
677
712
  quantity: z.ZodNumber;
678
713
  unitPrice: z.ZodNumber;
679
714
  lineTotal: z.ZodNumber;
715
+ offerId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
716
+ discount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
680
717
  maxQuantity: z.ZodNullable<z.ZodNumber>;
681
718
  blocker: z.ZodNullable<z.ZodEnum<{
682
719
  missing: "missing";
@@ -690,6 +727,24 @@ export declare const CartRewardSchema: z.ZodObject<{
690
727
  productName: z.ZodNullable<z.ZodString>;
691
728
  quantity: z.ZodNumber;
692
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">;
693
748
  export declare const CartValidationSchema: z.ZodObject<{
694
749
  lines: z.ZodArray<z.ZodObject<{
695
750
  productId: z.ZodString;
@@ -699,6 +754,8 @@ export declare const CartValidationSchema: z.ZodObject<{
699
754
  quantity: z.ZodNumber;
700
755
  unitPrice: z.ZodNumber;
701
756
  lineTotal: z.ZodNumber;
757
+ offerId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
758
+ discount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
702
759
  maxQuantity: z.ZodNullable<z.ZodNumber>;
703
760
  blocker: z.ZodNullable<z.ZodEnum<{
704
761
  missing: "missing";
@@ -728,6 +785,18 @@ export declare const CartValidationSchema: z.ZodObject<{
728
785
  fee: z.ZodNull;
729
786
  total: z.ZodNull;
730
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">>>;
731
800
  }, z.core.$strip>;
732
801
  export declare const CreatedOrderSchema: z.ZodObject<{
733
802
  orderId: z.ZodString;
@@ -735,6 +804,7 @@ export declare const CreatedOrderSchema: z.ZodObject<{
735
804
  price: z.ZodNumber;
736
805
  deliveryFee: z.ZodNumber;
737
806
  total: z.ZodNumber;
807
+ couponDiscount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
738
808
  }, z.core.$strip>;
739
809
  export declare const OrderTrackingSchema: z.ZodObject<{
740
810
  pixelId: z.ZodNullable<z.ZodString>;
package/dist/schemas.js CHANGED
@@ -42,6 +42,21 @@ export const OfferSchema = z.object({
42
42
  // it omits the field, and the product's own price stands in for the gift.
43
43
  rewardUnitPrice: z.number().nullish(),
44
44
  });
45
+ /**
46
+ * Quantity pricing — price tiers by quantity ("2 for 3,500 DA"). The highest
47
+ * tier the quantity reaches prices every unit at its per-unit price; the
48
+ * server's cart quote is the total.
49
+ */
50
+ export const QuantityPricingSchema = z.object({
51
+ offerId: z.string(),
52
+ name: z.string(),
53
+ triggerVariantId: z.string().nullable(),
54
+ tiers: z.array(z.object({
55
+ quantity: z.number().int(),
56
+ price: z.number().int(),
57
+ freeShipping: z.boolean(),
58
+ })),
59
+ });
45
60
  const productCategorySchema = z.object({
46
61
  id: z.string(),
47
62
  name: z.string(),
@@ -122,6 +137,11 @@ export const ProductSchema = z.object({
122
137
  variants: z.array(ProductVariantSchema),
123
138
  images: z.array(ProductImageSchema),
124
139
  reviewStats: reviewStatsSchema.nullable(),
140
+ /**
141
+ * The product's live quantity pricing; null or absent when it has none
142
+ * (absent from a server that predates it).
143
+ */
144
+ quantityPricing: QuantityPricingSchema.nullish(),
125
145
  offers: z.array(OfferSchema),
126
146
  });
127
147
  export const StorePagePublicSchema = z.object({
@@ -331,6 +351,10 @@ export const CartValidationLineSchema = z.object({
331
351
  quantity: z.number().int(),
332
352
  unitPrice: z.number(),
333
353
  lineTotal: z.number(),
354
+ /** The offer applied to the line; absent from a server that predates offers on lines. */
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(),
334
358
  maxQuantity: z.number().nullable(),
335
359
  blocker: z.enum(["missing", "unlisted", "out_of_stock"]).nullable(),
336
360
  });
@@ -340,6 +364,16 @@ export const CartRewardSchema = z.object({
340
364
  productName: z.string().nullable(),
341
365
  quantity: z.number().int(),
342
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
+ ]);
343
377
  export const CartValidationSchema = z.object({
344
378
  lines: z.array(CartValidationLineSchema),
345
379
  subtotal: z.number(),
@@ -356,6 +390,8 @@ export const CartValidationSchema = z.object({
356
390
  z.object({ available: z.literal(false), fee: z.null(), total: z.null() }),
357
391
  ])
358
392
  .optional(),
393
+ /** The code's outcome: null without one; absent from a server that predates coupons. */
394
+ coupon: CouponOutcomeSchema.nullish(),
359
395
  });
360
396
  export const CreatedOrderSchema = z.object({
361
397
  orderId: z.string(),
@@ -363,6 +399,8 @@ export const CreatedOrderSchema = z.object({
363
399
  price: z.number(),
364
400
  deliveryFee: z.number(),
365
401
  total: z.number(),
402
+ /** What the coupon took off; absent from a server that predates coupons. */
403
+ couponDiscount: z.number().nullish(),
366
404
  });
367
405
  export const OrderTrackingSchema = z.object({
368
406
  pixelId: z.string().nullable(),
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { ProductSchema, ProductListSchema, CategorySchema, ProductImageSchema, ProductVariantSchema, OfferSchema, PageDocumentPayloadSchema } from "./schemas.js";
2
+ import { ProductSchema, ProductListSchema, CategorySchema, ProductImageSchema, ProductVariantSchema, OfferSchema, QuantityPricingSchema, PageDocumentPayloadSchema } from "./schemas.js";
3
3
  export type Product = z.infer<typeof ProductSchema>;
4
4
  export type ProductList = z.infer<typeof ProductListSchema>;
5
5
  /** Compact catalog projection used in grids, cards and search results. */
@@ -10,6 +10,9 @@ export type Category = z.infer<typeof CategorySchema>;
10
10
  export type ProductImage = z.infer<typeof ProductImageSchema>;
11
11
  export type ProductVariant = z.infer<typeof ProductVariantSchema>;
12
12
  export type Offer = z.infer<typeof OfferSchema>;
13
+ /** A product's quantity pricing — `ProductDetail.quantityPricing`. */
14
+ export type QuantityPricing = z.infer<typeof QuantityPricingSchema>;
15
+ export type QuantityPricingTier = QuantityPricing["tiers"][number];
13
16
  /**
14
17
  * One store's saved composition document for one surface. Transport shape
15
18
  * only — `sections` items are `unknown`; the theme
@@ -307,7 +310,12 @@ export interface CartValidationLine {
307
310
  quantity: number;
308
311
  /** CURRENT catalog price — the cart's optimistic price is display-only. */
309
312
  unitPrice: number;
313
+ /** What the line costs, offers and the coupon applied. */
310
314
  lineTotal: number;
315
+ /** The offer applied to the line; null or absent when none. */
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;
311
319
  /** Units still available; null when the product is not stock-tracked. */
312
320
  maxQuantity: number | null;
313
321
  /**
@@ -343,7 +351,31 @@ export interface CartValidation {
343
351
  * deliver there, and an order to it is refused.
344
352
  */
345
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;
346
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" | "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);
347
379
  export type CartDeliveryQuote = {
348
380
  available: true;
349
381
  fee: number;
@@ -359,8 +391,10 @@ export interface CreatedOrder {
359
391
  orderNumber: string;
360
392
  price: number;
361
393
  deliveryFee: number;
362
- /** price + deliveryFee, after any offer. */
394
+ /** price + deliveryFee, offers and the coupon applied. */
363
395
  total: number;
396
+ /** What the coupon took off; null or absent without one. */
397
+ couponDiscount?: number | null | undefined;
364
398
  }
365
399
  /** One basket line in a create-order body. */
366
400
  export interface CreateOrderItemInput {
@@ -425,6 +459,8 @@ export interface CreateOrderInput {
425
459
  items?: CreateOrderItemInput[] | undefined;
426
460
  /** Landing-page attribution — best-effort; an unknown slug never blocks. */
427
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;
428
464
  }
429
465
  /**
430
466
  * The order's tracking destination — which Meta pixel it belongs to and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",