@wocart/storefront 1.4.0 → 1.5.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,21 @@ 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.5.0 — 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - **`products.page(filters)`** returns one page of products and whether another
13
+ follows (`{ items, hasNext }`). It asks the server for one row more than the
14
+ page, so the answer is exact without a count query. A page is 24 by default
15
+ and at most 99 (one below the server's 100-row cap). A failed read is an empty
16
+ final page, as `list` degrades. Themes stop re-deriving paging from `list`.
17
+ - `ProductPage` type.
18
+ - **`rewardUnitPrice`** on an offer: what one gift unit is worth at list price
19
+ (the gift variant's price, or the gift product's). `getOfferTierView` states
20
+ the saving at that value and the share of the list value it is. Without it,
21
+ the product's own price stands in, as before.
22
+
8
23
  ## 1.4.0 — 2026-10-15
9
24
 
10
25
  ### Added
@@ -12,7 +12,11 @@
12
12
  * half-shaped product into a theme.
13
13
  */
14
14
  import { type ClientCore, type ClientResult } from "./core.js";
15
- import type { Product, ProductList, ProductListFilters, Review, ReviewInput, ReviewListPagination } from "../types.js";
15
+ import type { Product, ProductList, ProductPage, ProductListFilters, Review, ReviewInput, ReviewListPagination } from "../types.js";
16
+ /** Page size when a caller names none — the storefront's default grid. */
17
+ export declare const PRODUCT_PAGE_DEFAULT = 24;
18
+ /** One below the server's 100-row cap, so the extra row that proves a next page fits. */
19
+ export declare const PRODUCT_PAGE_MAX = 99;
16
20
  export interface ProductsNamespace {
17
21
  /**
18
22
  * Get a single product by its public URL handle
@@ -26,6 +30,12 @@ export interface ProductsNamespace {
26
30
  * @returns Array of products
27
31
  */
28
32
  list(filters?: ProductListFilters): Promise<ProductList[]>;
33
+ /**
34
+ * One page of products, and whether another page follows. The server is
35
+ * asked for one row more than the page, so the answer is exact without a
36
+ * count query. A failed read is an empty final page, as `list` degrades.
37
+ */
38
+ page(filters?: ProductListFilters): Promise<ProductPage>;
29
39
  /**
30
40
  * Reviews sub-namespace
31
41
  */
@@ -16,10 +16,29 @@ import { IdResponseSchema, ProductListSchema, ProductSchema, ReviewListEnvelopeS
16
16
  import { STOREFRONT_OPERATIONS } from "../domain.js";
17
17
  import { localizedPath } from "./paths.js";
18
18
  import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
19
+ /** Page size when a caller names none — the storefront's default grid. */
20
+ export const PRODUCT_PAGE_DEFAULT = 24;
21
+ /** One below the server's 100-row cap, so the extra row that proves a next page fits. */
22
+ export const PRODUCT_PAGE_MAX = 99;
19
23
  /**
20
24
  * Create products namespace
21
25
  */
22
26
  export function createProductsNamespace(core) {
27
+ const list = async (filters) => {
28
+ try {
29
+ const products = await core.get(localizedPath(core.config, "/store/products", {
30
+ categoryId: filters?.categoryId || undefined,
31
+ featured: filters?.featured ? "true" : undefined,
32
+ limit: filters?.limit === undefined ? undefined : String(filters.limit),
33
+ offset: filters?.offset ? String(filters.offset) : undefined,
34
+ }), z.array(ProductListSchema), { operation: STOREFRONT_OPERATIONS.productsList });
35
+ return products ?? [];
36
+ }
37
+ catch (error) {
38
+ reportDegradedRead(core.config, error, "products.list");
39
+ return [];
40
+ }
41
+ };
23
42
  return {
24
43
  async get(handle) {
25
44
  try {
@@ -32,20 +51,11 @@ export function createProductsNamespace(core) {
32
51
  return null;
33
52
  }
34
53
  },
35
- async list(filters) {
36
- try {
37
- const products = await core.get(localizedPath(core.config, "/store/products", {
38
- categoryId: filters?.categoryId || undefined,
39
- featured: filters?.featured ? "true" : undefined,
40
- limit: filters?.limit === undefined ? undefined : String(filters.limit),
41
- offset: filters?.offset ? String(filters.offset) : undefined,
42
- }), z.array(ProductListSchema), { operation: STOREFRONT_OPERATIONS.productsList });
43
- return products ?? [];
44
- }
45
- catch (error) {
46
- reportDegradedRead(core.config, error, "products.list");
47
- return [];
48
- }
54
+ list,
55
+ async page(filters) {
56
+ const size = Math.min(Math.max(1, filters?.limit ?? PRODUCT_PAGE_DEFAULT), PRODUCT_PAGE_MAX);
57
+ const rows = await list({ ...filters, limit: size + 1 });
58
+ return { items: rows.slice(0, size), hasNext: rows.length > size };
49
59
  },
50
60
  reviews: {
51
61
  async list(productId, pagination) {
@@ -6,5 +6,5 @@ export interface OfferTierView {
6
6
  price: number;
7
7
  comparePrice: number | null;
8
8
  }
9
- export type OfferTierInput = Pick<Offer, "discountType" | "triggerQuantity" | "rewardQuantity">;
9
+ export type OfferTierInput = Pick<Offer, "discountType" | "triggerQuantity" | "rewardQuantity" | "rewardUnitPrice">;
10
10
  export declare function getOfferTierView(offer: OfferTierInput, basePrice: number, comparePrice: number | null): OfferTierView;
@@ -1,13 +1,17 @@
1
1
  export function getOfferTierView(offer, basePrice, comparePrice) {
2
2
  const isFreeShipping = offer.discountType === "free_shipping";
3
- const quantityTotal = offer.triggerQuantity + offer.rewardQuantity;
3
+ // A gift that is another product (or a variant priced apart) is worth its own
4
+ // price; without one, the product's price stands in for it.
5
+ const giftPrice = offer.rewardUnitPrice ?? basePrice;
6
+ const savingsAmount = isFreeShipping ? 0 : offer.rewardQuantity * giftPrice;
7
+ const price = basePrice * offer.triggerQuantity;
8
+ // The share of what the shopper would have paid for everything, gifts included.
9
+ const listValue = price + savingsAmount;
4
10
  return {
5
11
  isFreeShipping,
6
- savePct: isFreeShipping || quantityTotal <= 0
7
- ? 0
8
- : Math.round((offer.rewardQuantity / quantityTotal) * 100),
9
- savingsAmount: isFreeShipping ? 0 : offer.rewardQuantity * basePrice,
10
- price: basePrice * offer.triggerQuantity,
12
+ savePct: isFreeShipping || listValue <= 0 ? 0 : Math.round((savingsAmount / listValue) * 100),
13
+ savingsAmount,
14
+ price,
11
15
  comparePrice: comparePrice === null ? null : comparePrice * offer.triggerQuantity,
12
16
  };
13
17
  }
package/dist/schemas.d.ts CHANGED
@@ -41,6 +41,7 @@ export declare const OfferSchema: z.ZodObject<{
41
41
  rewardProductName: z.ZodString;
42
42
  rewardVariantId: z.ZodNullable<z.ZodString>;
43
43
  rewardVariantLabel: z.ZodNullable<z.ZodString>;
44
+ rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
44
45
  }, z.core.$strip>;
45
46
  export declare const CategorySchema: z.ZodObject<{
46
47
  id: z.ZodString;
@@ -214,6 +215,7 @@ export declare const ProductSchema: z.ZodObject<{
214
215
  rewardProductName: z.ZodString;
215
216
  rewardVariantId: z.ZodNullable<z.ZodString>;
216
217
  rewardVariantLabel: z.ZodNullable<z.ZodString>;
218
+ rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
217
219
  }, z.core.$strip>>;
218
220
  }, z.core.$strip>;
219
221
  export declare const StorePagePublicSchema: z.ZodObject<{
@@ -617,6 +619,7 @@ export declare const LandingPageSchema: z.ZodObject<{
617
619
  rewardProductName: z.ZodString;
618
620
  rewardVariantId: z.ZodNullable<z.ZodString>;
619
621
  rewardVariantLabel: z.ZodNullable<z.ZodString>;
622
+ rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
620
623
  }, z.core.$strip>>;
621
624
  }, z.core.$strip>>;
622
625
  tracking: z.ZodObject<{
package/dist/schemas.js CHANGED
@@ -38,6 +38,9 @@ export const OfferSchema = z.object({
38
38
  rewardProductName: z.string(),
39
39
  rewardVariantId: z.string().nullable(),
40
40
  rewardVariantLabel: z.string().nullable(),
41
+ // What one gift unit is worth at list price. Optional: a server that predates
42
+ // it omits the field, and the product's own price stands in for the gift.
43
+ rewardUnitPrice: z.number().nullish(),
41
44
  });
42
45
  const productCategorySchema = z.object({
43
46
  id: z.string(),
package/dist/types.d.ts CHANGED
@@ -415,6 +415,12 @@ export interface ProductListFilters {
415
415
  /** Products to skip — with `limit`, pages through the whole catalog. */
416
416
  offset?: number;
417
417
  }
418
+ /** One page of products from `products.page()`. */
419
+ export interface ProductPage {
420
+ items: ProductList[];
421
+ /** True when another page follows this one. */
422
+ hasNext: boolean;
423
+ }
418
424
  /** `products.reviews.list()` paging — named for the same reason. */
419
425
  export interface ReviewListPagination {
420
426
  limit?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",