@wocart/storefront 1.11.0 → 1.13.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,33 @@ 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.13.0 — 2026-10-17
9
+
10
+ ### Added
11
+
12
+ - A cart quote names its offers: `CartValidationLine.offerName`, and each
13
+ `CartReward` says what will be given — `variantId`, `variantLabel` and the
14
+ giving offer's `offerName` — in the shopper's language. All absent from an
15
+ older server.
16
+
17
+ ## 1.12.0 — 2026-10-17
18
+
19
+ ### Added
20
+
21
+ - **Variant picker.** `initialVariant(product)`, `pickOption(product,
22
+ selection, option, value)`, `optionStates(product, selection)` and
23
+ `variantFor(variants, selection)` (`OptionSelection`, `OptionState`,
24
+ `PickerVariant`): the variant a page opens on, the variant a picked value
25
+ means — always one that exists — and each value's state.
26
+ - `CouponRefusalReason` names `offer_applied`: every line of the basket is
27
+ already priced by an offer, and a code covers only lines no offer holds.
28
+
29
+ ### Fixed
30
+
31
+ - `getProductView(product).defaultVariant` is the merchant's default only
32
+ when it can be sold, else the first variant that can — never a sold-out or
33
+ switched-off one while another is in stock.
34
+
8
35
  ## 1.11.0 — 2026-10-17
9
36
 
10
37
  ### 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
@@ -972,10 +1000,17 @@ The server re-prices catalog lines, merges duplicate lines, checks stock and
972
1000
  offers, and calculates free-delivery eligibility. CartValidation contains:
973
1001
 
974
1002
  - lines with productId, variantId, variantLabel, productName, quantity,
975
- unitPrice, lineTotal, maxQuantity, and blocker;
976
- - subtotal and rewards;
1003
+ unitPrice, lineTotal (offers and the coupon applied), offerId and
1004
+ offerName (the offer that priced the line), discount, maxQuantity, and
1005
+ blocker;
1006
+ - subtotal and rewards — each gift as the order will give it: productName,
1007
+ variantLabel, quantity and the offerName that gives it, free;
977
1008
  - freeDelivery with fromOffer, threshold, qualified, and remaining.
978
1009
 
1010
+ Show a line at `lineTotal + discount` (the coupon has its own row) and its
1011
+ catalog price struck through when an offer brought it down; list each reward
1012
+ as its own free row, so the basket reads like the order it becomes.
1013
+
979
1014
  Line blockers include missing, unlisted, and out_of_stock. The creative
980
1015
  experience is yours: inline warnings, a highlighted invalid line, a drawer,
981
1016
  or a guided correction step. The server result is not optional.
@@ -1081,7 +1116,8 @@ if (quoted.success && quoted.data.coupon) {
1081
1116
  // means delivery.fee is 0.
1082
1117
  // refused: coupon.reason — not_found, not_started (startsAt), expired,
1083
1118
  // used_up, identify_first, not_eligible, already_used,
1084
- // no_eligible_products, below_minimum_subtotal / _quantity (shortBy),
1119
+ // no_eligible_products, offer_applied (an offer already prices
1120
+ // every line), below_minimum_subtotal / _quantity (shortBy),
1085
1121
  // delivery_already_free. An unknown reason gets the theme's general sentence.
1086
1122
  }
1087
1123
  ~~~
package/dist/index.d.ts CHANGED
@@ -36,6 +36,8 @@ export * from "./domain.js";
36
36
  export * from "./product-view.js";
37
37
  export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
38
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";
39
41
  export type { CartQuoteOptions } from "./client/cart.js";
40
42
  export type { OfferTierInput, OfferTierView, QuantityTierView } from "./offer-view.js";
41
43
  export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
package/dist/index.js CHANGED
@@ -27,5 +27,6 @@ export * from "./domain.js";
27
27
  export * from "./product-view.js";
28
28
  export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
29
29
  export { COUPON_NOT_APPLICABLE, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf } from "./coupon.js";
30
+ export { initialVariant, optionStates, pickOption, variantFor } from "./variant-picker.js";
30
31
  export { createAbandonedCheckoutTracker } from "./abandoned-checkout.js";
31
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
+ offerName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
716
717
  discount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
717
718
  maxQuantity: z.ZodNullable<z.ZodNumber>;
718
719
  blocker: z.ZodNullable<z.ZodEnum<{
@@ -723,8 +724,11 @@ export declare const CartValidationLineSchema: z.ZodObject<{
723
724
  }, z.core.$strip>;
724
725
  export declare const CartRewardSchema: z.ZodObject<{
725
726
  offerId: z.ZodString;
727
+ offerName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
726
728
  productId: z.ZodNullable<z.ZodString>;
727
729
  productName: z.ZodNullable<z.ZodString>;
730
+ variantId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
731
+ variantLabel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
728
732
  quantity: z.ZodNumber;
729
733
  }, z.core.$strip>;
730
734
  /**
@@ -755,6 +759,7 @@ export declare const CartValidationSchema: z.ZodObject<{
755
759
  unitPrice: z.ZodNumber;
756
760
  lineTotal: z.ZodNumber;
757
761
  offerId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
762
+ offerName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
758
763
  discount: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
759
764
  maxQuantity: z.ZodNullable<z.ZodNumber>;
760
765
  blocker: z.ZodNullable<z.ZodEnum<{
@@ -766,8 +771,11 @@ export declare const CartValidationSchema: z.ZodObject<{
766
771
  subtotal: z.ZodNumber;
767
772
  rewards: z.ZodArray<z.ZodObject<{
768
773
  offerId: z.ZodString;
774
+ offerName: z.ZodOptional<z.ZodNullable<z.ZodString>>;
769
775
  productId: z.ZodNullable<z.ZodString>;
770
776
  productName: z.ZodNullable<z.ZodString>;
777
+ variantId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
778
+ variantLabel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
771
779
  quantity: z.ZodNumber;
772
780
  }, z.core.$strip>>;
773
781
  freeDelivery: z.ZodObject<{
package/dist/schemas.js CHANGED
@@ -353,6 +353,8 @@ export const CartValidationLineSchema = z.object({
353
353
  lineTotal: z.number(),
354
354
  /** The offer applied to the line; absent from a server that predates offers on lines. */
355
355
  offerId: z.string().nullish(),
356
+ /** That offer's name, in the shopper's language; absent from an older server. */
357
+ offerName: z.string().nullish(),
356
358
  /** The line's share of the coupon's discount, already out of `lineTotal`; absent from a server that predates coupons. */
357
359
  discount: z.number().nullish(),
358
360
  maxQuantity: z.number().nullable(),
@@ -360,8 +362,13 @@ export const CartValidationLineSchema = z.object({
360
362
  });
361
363
  export const CartRewardSchema = z.object({
362
364
  offerId: z.string(),
365
+ /** The offer's name, in the shopper's language; absent from an older server. */
366
+ offerName: z.string().nullish(),
363
367
  productId: z.string().nullable(),
364
368
  productName: z.string().nullable(),
369
+ /** The variant given and its label; absent from an older server. */
370
+ variantId: z.string().nullish(),
371
+ variantLabel: z.string().nullish(),
365
372
  quantity: z.number().int(),
366
373
  });
367
374
  /**
package/dist/types.d.ts CHANGED
@@ -314,6 +314,8 @@ export interface CartValidationLine {
314
314
  lineTotal: number;
315
315
  /** The offer applied to the line; null or absent when none. */
316
316
  offerId?: string | null | undefined;
317
+ /** That offer's name, in the shopper's language; null or absent when none. */
318
+ offerName?: string | null | undefined;
317
319
  /** The line's share of the coupon's discount, already out of `lineTotal`; null or absent without one. */
318
320
  discount?: number | null | undefined;
319
321
  /** Units still available; null when the product is not stock-tracked. */
@@ -325,10 +327,16 @@ export interface CartValidationLine {
325
327
  blocker: "missing" | "unlisted" | "out_of_stock" | null;
326
328
  }
327
329
  /** A satisfied Buy-X-Get-Y reward, appended server-side at order time. */
330
+ /** A gift the basket earns, as the order will give it: free, beside the lines. */
328
331
  export interface CartReward {
329
332
  offerId: string;
333
+ /** The offer's name, in the shopper's language; absent from an older server. */
334
+ offerName?: string | null | undefined;
330
335
  productId: string | null;
331
336
  productName: string | null;
337
+ /** The variant given and its label; null for a product without variants, absent from an older server. */
338
+ variantId?: string | null | undefined;
339
+ variantLabel?: string | null | undefined;
332
340
  quantity: number;
333
341
  }
334
342
  /** The catalog's truth about a basket, right now. Read-only — nothing is written. */
@@ -358,7 +366,7 @@ export interface CartValidation {
358
366
  * Why a code does not apply. Open: a server may name a reason this SDK has not
359
367
  * seen yet, and a theme then shows its general sentence.
360
368
  */
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 & {});
369
+ 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
370
  export interface CouponRefusal {
363
371
  reason: CouponRefusalReason;
364
372
  /** The dinars (`below_minimum_subtotal`) or items (`below_minimum_quantity`) still missing. */
@@ -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.11.0",
3
+ "version": "1.13.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",