@wocart/storefront 1.3.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,36 @@ 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
+
23
+ ## 1.4.0 — 2026-10-15
24
+
25
+ ### Added
26
+
27
+ - **`cart.quote(lines, { destination })`** quotes what an order would cost for
28
+ a basket, or for one product exactly as its order form posts it: a quantity,
29
+ a variant per unit (`variantSelections`), an offer tier (`offerId`). The
30
+ server builds the lines the way the order does, so a product page's delivery
31
+ and total are the order's own. `validate(items, options)` is now its basket
32
+ form.
33
+ - `ProductQuoteInputSchema`, `CartQuoteLinesSchema` and the `ProductQuoteInput`
34
+ and `CartQuoteLines` types.
35
+ - The `cartValidate` route accepts a product form as well as a basket, each
36
+ with an optional `destination`.
37
+
8
38
  ## 1.3.0 — 2026-10-15
9
39
 
10
40
  ### Added
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ClientCore, ClientResult } from "./core.js";
15
15
  import type { CartValidation } from "../types.js";
16
- import type { BasketLineInput, DeliveryDestination } from "../domain.js";
16
+ import type { BasketLineInput, CartQuoteLines, DeliveryDestination } from "../domain.js";
17
17
  export interface CartNamespace {
18
18
  /**
19
19
  * Re-price and re-check a basket against the live catalog.
@@ -29,6 +29,14 @@ export interface CartNamespace {
29
29
  validate(items: readonly BasketLineInput[], options?: {
30
30
  destination?: DeliveryDestination;
31
31
  }): Promise<ClientResult<CartValidation>>;
32
+ /**
33
+ * Quote what an order would cost, for a basket or for one product exactly as
34
+ * its order form posts it (quantity, variants per unit, offer tier). The lines
35
+ * are built the way the order builds them, so the quote and the order agree.
36
+ */
37
+ quote(lines: CartQuoteLines, options?: {
38
+ destination?: DeliveryDestination;
39
+ }): Promise<ClientResult<CartValidation>>;
32
40
  }
33
41
  /**
34
42
  * Create cart namespace
@@ -18,10 +18,13 @@ import { localizedPath } from "./paths.js";
18
18
  * Create cart namespace
19
19
  */
20
20
  export function createCartNamespace(core) {
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 });
25
+ };
21
26
  return {
22
- async validate(items, options) {
23
- const destination = options?.destination;
24
- return core.post(localizedPath(core.config, "/store/cart/validate"), destination ? { items, destination } : { items }, { schema: CartValidationSchema, operation: STOREFRONT_OPERATIONS.cartValidate });
25
- },
27
+ validate: (items, options) => quote({ items: [...items] }, options),
28
+ quote,
26
29
  };
27
30
  }
@@ -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) {
package/dist/domain.d.ts CHANGED
@@ -175,6 +175,48 @@ export declare const AbandonedCheckoutInputSchema: z.ZodObject<{
175
175
  * `items[]` line: absent — not null — is the common wire shape.
176
176
  */
177
177
  export type AbandonedCheckoutInput = z.infer<typeof AbandonedCheckoutInputSchema>;
178
+ /**
179
+ * One product as a product page's order form posts it: a quantity, or a
180
+ * variant chosen per unit, and an offer tier. The quote reads these the way the
181
+ * order does, so the page asks with exactly what its form will submit.
182
+ */
183
+ export declare const ProductQuoteInputSchema: z.ZodObject<{
184
+ productId: z.ZodString;
185
+ productName: z.ZodString;
186
+ quantity: z.ZodOptional<z.ZodNumber>;
187
+ variantId: z.ZodOptional<z.ZodString>;
188
+ variantLabel: z.ZodOptional<z.ZodString>;
189
+ offerId: z.ZodOptional<z.ZodString>;
190
+ variantSelections: z.ZodOptional<z.ZodArray<z.ZodObject<{
191
+ variantId: z.ZodString;
192
+ variantLabel: z.ZodOptional<z.ZodString>;
193
+ }, z.core.$strip>>>;
194
+ }, z.core.$strip>;
195
+ export type ProductQuoteInput = z.infer<typeof ProductQuoteInputSchema>;
196
+ /** What a quote is asked about: a basket, or one product as its form posts it. */
197
+ export declare const CartQuoteLinesSchema: z.ZodUnion<readonly [z.ZodObject<{
198
+ items: z.ZodArray<z.ZodObject<{
199
+ productId: z.ZodString;
200
+ productName: z.ZodString;
201
+ variantId: z.ZodOptional<z.ZodNullable<z.ZodString>>;
202
+ variantLabel: z.ZodOptional<z.ZodNullable<z.ZodString>>;
203
+ quantity: z.ZodNumber;
204
+ pricePerUnit: z.ZodOptional<z.ZodNumber>;
205
+ offerId: z.ZodOptional<z.ZodString>;
206
+ }, z.core.$strip>>;
207
+ }, z.core.$strip>, z.ZodObject<{
208
+ productId: z.ZodString;
209
+ productName: z.ZodString;
210
+ quantity: z.ZodOptional<z.ZodNumber>;
211
+ variantId: z.ZodOptional<z.ZodString>;
212
+ variantLabel: z.ZodOptional<z.ZodString>;
213
+ offerId: z.ZodOptional<z.ZodString>;
214
+ variantSelections: z.ZodOptional<z.ZodArray<z.ZodObject<{
215
+ variantId: z.ZodString;
216
+ variantLabel: z.ZodOptional<z.ZodString>;
217
+ }, z.core.$strip>>>;
218
+ }, z.core.$strip>]>;
219
+ export type CartQuoteLines = z.infer<typeof CartQuoteLinesSchema>;
178
220
  /**
179
221
  * Where a basket would be delivered, for a delivery quote. The commune is
180
222
  * optional — without it the wilaya's rate applies; with it, the commune's own
package/dist/domain.js CHANGED
@@ -126,6 +126,31 @@ export const AbandonedCheckoutInputSchema = z.object({
126
126
  fbc: z.string().max(500).optional(),
127
127
  fbp: z.string().max(500).optional(),
128
128
  });
129
+ /**
130
+ * One product as a product page's order form posts it: a quantity, or a
131
+ * variant chosen per unit, and an offer tier. The quote reads these the way the
132
+ * order does, so the page asks with exactly what its form will submit.
133
+ */
134
+ export const ProductQuoteInputSchema = z.object({
135
+ productId: z.string().min(1).max(200),
136
+ productName: z.string().min(1).max(200),
137
+ quantity: z.number().int().min(1).max(MAX_BASKET_LINE_QUANTITY).optional(),
138
+ variantId: z.string().min(1).max(200).optional(),
139
+ variantLabel: z.string().max(200).optional(),
140
+ offerId: z.string().min(1).max(200).optional(),
141
+ variantSelections: z
142
+ .array(z.object({
143
+ variantId: z.string().min(1).max(200),
144
+ variantLabel: z.string().max(200).optional(),
145
+ }))
146
+ .max(MAX_BASKET_LINE_QUANTITY)
147
+ .optional(),
148
+ });
149
+ /** What a quote is asked about: a basket, or one product as its form posts it. */
150
+ export const CartQuoteLinesSchema = z.union([
151
+ z.object({ items: z.array(BasketLineInputSchema).min(1).max(MAX_BASKET_LINES) }),
152
+ ProductQuoteInputSchema,
153
+ ]);
129
154
  /**
130
155
  * Where a basket would be delivered, for a delivery quote. The commune is
131
156
  * optional — without it the wilaya's rate applies; with it, the commune's own
@@ -33,7 +33,9 @@ export interface StorefrontRoutes<C extends AstroAPIContext> {
33
33
  /** POST `{ phone, requestId, code }` → `{ data: OtpVerifyResult }`. */
34
34
  readonly otpVerify: StorefrontApiRoute<C>;
35
35
  /**
36
- * POST `{ items, destination? }` → `{ success: true, data: CartValidation }`.
36
+ * POST a basket `{ items }` or one product `{ productId, productName, quantity,
37
+ * variantSelections, offerId, … }`, each with an optional `destination` →
38
+ * `{ success: true, data: CartValidation }`.
37
39
  * With a destination, `data.delivery` is the fee and total the order will charge.
38
40
  */
39
41
  readonly cartValidate: StorefrontApiRoute<C>;
@@ -21,7 +21,7 @@
21
21
  * (see `createProxyEndpoint`), and the server's diagnostic fields ride along.
22
22
  */
23
23
  import { z } from "zod";
24
- import { AbandonedCheckoutInputSchema, BasketLineInputSchema, DeliveryDestinationSchema, MAX_BASKET_LINES, WilayaIdSchema, } from "../domain.js";
24
+ import { AbandonedCheckoutInputSchema, BasketLineInputSchema, DeliveryDestinationSchema, ProductQuoteInputSchema, MAX_BASKET_LINES, WilayaIdSchema, } from "../domain.js";
25
25
  import { createProxyEndpoint, } from "./proxy.js";
26
26
  export const REVIEWS_PAGE_DEFAULT = 20;
27
27
  export const REVIEWS_PAGE_MAX = 50;
@@ -31,10 +31,17 @@ const otpVerifyInput = z.object({
31
31
  requestId: z.string().min(1).max(200),
32
32
  code: z.string().min(1).max(16),
33
33
  });
34
- const cartValidateInput = z.object({
35
- items: z.array(BasketLineInputSchema).min(1).max(MAX_BASKET_LINES),
36
- destination: DeliveryDestinationSchema.optional(),
37
- });
34
+ /**
35
+ * A basket, or one product as its order form posts it — the same two shapes the
36
+ * server's quote takes — each with an optional destination to quote to.
37
+ */
38
+ const cartValidateInput = z.union([
39
+ z.object({
40
+ items: z.array(BasketLineInputSchema).min(1).max(MAX_BASKET_LINES),
41
+ destination: DeliveryDestinationSchema.optional(),
42
+ }),
43
+ ProductQuoteInputSchema.extend({ destination: DeliveryDestinationSchema.optional() }),
44
+ ]);
38
45
  const abandonedConvertInput = z.object({
39
46
  sessionId: z.string().uuid(),
40
47
  orderId: z.string().min(1).max(200),
@@ -88,7 +95,7 @@ export function createStorefrontRoutes(options) {
88
95
  method: "POST",
89
96
  inputSchema: cartValidateInput,
90
97
  successBody: (data) => ({ success: true, data }),
91
- call: (client, { items, destination }) => destination ? client.cart.validate(items, { destination }) : client.cart.validate(items),
98
+ call: (client, { destination, ...lines }) => client.cart.quote(lines, destination ? { destination } : undefined),
92
99
  }),
93
100
  abandonedUpsert: route({
94
101
  method: "POST",
@@ -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.3.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",