@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 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 };
@@ -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
@@ -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";
@@ -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 quantity-pricing offer that set `lineTotal`; absent from a server that predates offers on lines. */
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: unit price × quantity, or its quantity-pricing tier's price. */
313
+ /** What the line costs, offers and the coupon applied. */
314
314
  lineTotal: number;
315
- /** The quantity-pricing offer that set `lineTotal`; null or absent at the catalog price. */
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, after any offer. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.10.0",
3
+ "version": "1.12.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",