@wocart/storefront 2.2.0 → 3.1.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/USAGE.md +189 -95
  3. package/dist/answer-readers.js +16 -43
  4. package/dist/astro.d.ts +6 -2
  5. package/dist/astro.js +3 -1
  6. package/dist/client/index.d.ts +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client/products.d.ts +2 -3
  9. package/dist/client/products.js +2 -3
  10. package/dist/endpoints/revalidate-endpoint.d.ts +37 -0
  11. package/dist/endpoints/revalidate-endpoint.js +32 -0
  12. package/dist/endpoints/storefront-routes.js +1 -1
  13. package/dist/index.d.ts +8 -4
  14. package/dist/index.js +4 -2
  15. package/dist/locale-routing.d.ts +11 -20
  16. package/dist/locale-routing.js +28 -8
  17. package/dist/middleware.d.ts +6 -26
  18. package/dist/middleware.js +0 -9
  19. package/dist/product-view.d.ts +0 -8
  20. package/dist/product-view.js +0 -17
  21. package/dist/revalidate.d.ts +6 -0
  22. package/dist/revalidate.js +6 -0
  23. package/dist/routes.d.ts +44 -27
  24. package/dist/routes.js +54 -13
  25. package/dist/schemas.d.ts +144 -222
  26. package/dist/schemas.js +77 -137
  27. package/dist/seo.js +2 -2
  28. package/dist/server.d.ts +1 -1
  29. package/dist/server.js +1 -1
  30. package/dist/store-request.d.ts +5 -4
  31. package/dist/store-request.js +1 -1
  32. package/dist/storefront-middleware.d.ts +88 -0
  33. package/dist/storefront-middleware.js +37 -0
  34. package/dist/thank-you.d.ts +32 -0
  35. package/dist/thank-you.js +33 -0
  36. package/dist/types.d.ts +76 -111
  37. package/dist/unavailable-store.d.ts +23 -0
  38. package/dist/unavailable-store.js +58 -0
  39. package/dist/variant-picker.d.ts +6 -2
  40. package/dist/variant-picker.js +6 -9
  41. package/dist/whatsapp.d.ts +59 -6
  42. package/dist/whatsapp.js +28 -10
  43. package/package.json +1 -1
package/dist/types.d.ts CHANGED
@@ -64,38 +64,16 @@ export interface StorefrontLanguage {
64
64
  /** `<html dir>` for this language. */
65
65
  direction: "rtl" | "ltr";
66
66
  }
67
+ /**
68
+ * The storefront's view of the store, in one language — `config.get()`.
69
+ * Every merchant text is in `locale`; a field holds exactly what a theme
70
+ * reads, nothing the server keeps to itself.
71
+ */
67
72
  export interface StoreConfig {
68
73
  id: string;
69
74
  name: string;
70
75
  domain: string | null;
71
76
  logo: MediaImage | null;
72
- themeId: string;
73
- /**
74
- * @deprecated A store's colors and font are its theme's settings: declare
75
- * them in `defineTheme({ settings })` and read `themeSettings`. This is the
76
- * value saved before that, no longer editable; removed in 2.0.
77
- */
78
- primaryColor: string;
79
- /**
80
- * @deprecated A store's colors and font are its theme's settings: declare
81
- * them in `defineTheme({ settings })` and read `themeSettings`. This is the
82
- * value saved before that, no longer editable; removed in 2.0.
83
- */
84
- accentColor: string;
85
- /**
86
- * @deprecated A store's colors and font are its theme's settings: declare
87
- * them in `defineTheme({ settings })` and read `themeSettings`. This is the
88
- * value saved before that, no longer editable; removed in 2.0.
89
- */
90
- bgColor: string;
91
- /**
92
- * @deprecated A store's colors and font are its theme's settings: declare
93
- * them in `defineTheme({ settings })` and read `themeSettings`. This is the
94
- * value saved before that, no longer editable; removed in 2.0.
95
- */
96
- fontFamily: string;
97
- /** @deprecated Always null; removed in 2.0. */
98
- fontUrl: string | null;
99
77
  /** The store's primary language: a BCP 47 code such as `ar`. */
100
78
  lang: string;
101
79
  /**
@@ -105,23 +83,8 @@ export interface StoreConfig {
105
83
  locale: string;
106
84
  /** The published languages, primary first (always includes `lang`). */
107
85
  locales: StorefrontLanguage[];
108
- /**
109
- * The store's edits to this theme's text, one map per published language:
110
- * `{ ar: { heroTitle: "…" } }`. Sent only to a client created with `theme`.
111
- * A theme merges `contentOverrides[locale]` over its own pack for that
112
- * language — never another language's map.
113
- */
114
- contentOverrides: Record<string, Record<string, string>>;
115
- /**
116
- * The merchant's values for this theme's settings, by setting key, exactly
117
- * as saved — `{}` when there are none or the client was created without
118
- * `theme`, absent from a server that predates theme settings. Read them
119
- * against the theme's declaration with `resolveThemeSettings` from
120
- * `@wocart/theme` (which takes either): a value the theme no longer accepts
121
- * falls back to its default there.
122
- */
123
- themeSettings?: Record<string, unknown> | undefined;
124
86
  currency: string;
87
+ /** In `locale`: "دج" in Arabic reads "DA" in French. */
125
88
  currencySymbol: string;
126
89
  metaTitle: string | null;
127
90
  metaDescription: string | null;
@@ -129,47 +92,55 @@ export interface StoreConfig {
129
92
  ogImage: MediaImage | null;
130
93
  /** Where every image key is served from, and how it is resized: pass it to `mediaUrl`, `mediaImageUrl` and `pictureOf`. */
131
94
  media: MediaDelivery;
132
- /**
133
- * @deprecated Always null. An announcement is a theme's own content: declare
134
- * it as a theme setting and read `themeSettings`. Removed in 2.0.
135
- */
136
- announcementBar: string | null;
137
95
  reviewsEnabled: boolean;
138
96
  /** When true the storefront renders the cart alongside the direct order form. */
139
97
  cartEnabled: boolean;
140
98
  /** Subtotal (DZD) at or above which delivery is free. null = no threshold. */
141
99
  freeShippingThreshold: number | null;
142
- cartShippingMode: "highest" | "default_profile";
143
- otpEnabled: boolean;
144
- /** Cloudflare Turnstile — true only when a store_turnstile_config row exists AND is enabled. */
145
- turnstileEnabled: boolean;
146
- /** Public widget site key; null when Turnstile is disabled. The secret never leaves the server. */
147
- turnstileSiteKey: string | null;
148
- status: "active" | "inactive";
149
- createdAt: string;
150
- updatedAt: string;
100
+ /**
101
+ * The store's edits to this theme's text, one map per published language:
102
+ * `{ ar: { heroTitle: "…" } }`. Sent only to a client created with `theme`.
103
+ * A theme merges `contentOverrides[locale]` over its own pack for that
104
+ * language — never another language's map.
105
+ */
106
+ contentOverrides: Record<string, Record<string, string>>;
107
+ /**
108
+ * The merchant's values for this theme's settings, by setting key, exactly
109
+ * as saved — `{}` when there are none or the client was created without
110
+ * `theme`. Read them against the theme's declaration with
111
+ * `resolveThemeSettings` from `@wocart/theme`: a value the theme no longer
112
+ * accepts falls back to its default there.
113
+ */
114
+ themeSettings: Record<string, unknown>;
151
115
  checkoutForm: CheckoutFormPolicy;
116
+ /** The WhatsApp button, or null when the store has none. Read it through `whatsAppContact`. */
117
+ whatsapp: StoreWhatsAppWidget | null;
152
118
  pixelId: string | null;
153
119
  conversionEvent: "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered" | "Lead";
154
120
  /**
155
- * Published pages that opt into the footer, titled in the store's own
156
- * language, ordered for display. The checkout consent line resolves Terms/
157
- * Refund from here by `kind` — never a hardcoded slug, since a merchant may
158
- * rename any page's slug freely.
121
+ * Published pages that opt into the footer, titled in `locale`, ordered for
122
+ * display. The checkout consent line resolves Terms/Refund from here by
123
+ * `kind` — never a hardcoded slug, since a merchant may rename any page's
124
+ * slug freely.
159
125
  */
160
126
  pages: StorePageLink[];
161
- /**
162
- * The public subset of the store's legal profile. Null until the merchant
163
- * saves one from the dashboard (Settings → Store Pages) — RC/NIF are never
164
- * exposed here, only inside the legal documents themselves.
165
- */
166
- legalContact: StoreLegalContact | null;
167
- /** The WhatsApp contact widget, already resolved, or null when disabled. */
168
- whatsapp: StoreWhatsAppWidget | null;
127
+ /** How a shopper reaches the store, besides WhatsApp: each null until the merchant writes it. */
128
+ contact: StoreContact;
129
+ otpEnabled: boolean;
130
+ /** Cloudflare Turnstile — true only when the store switched it on. */
131
+ turnstileEnabled: boolean;
132
+ /** Public widget site key; null when Turnstile is off. The secret never leaves the server. */
133
+ turnstileSiteKey: string | null;
169
134
  }
170
135
  /** The merchant's per-surface switches for the WhatsApp contact widget. */
171
136
  export type WidgetSurfaces = StoreWhatsAppWidget["surfaces"];
137
+ /**
138
+ * The WhatsApp button App, as the server serves it: each text in the page's
139
+ * language, or null where the merchant wrote none in it — the theme's words
140
+ * then. A theme reads it through `whatsAppContact`, never field by field.
141
+ */
172
142
  export interface StoreWhatsAppWidget {
143
+ /** The chat link to the store's number, without a message. */
173
144
  href: string;
174
145
  agentName: string | null;
175
146
  caption: string | null;
@@ -203,11 +174,11 @@ export interface StorePageLink {
203
174
  title: string;
204
175
  position: number;
205
176
  }
206
- export interface StoreLegalContact {
207
- contactEmail: string | null;
208
- contactPhone: string | null;
209
- deliveryMinDays: number;
210
- deliveryMaxDays: number;
177
+ /** How a shopper reaches the store — `StoreConfig.contact`. */
178
+ export interface StoreContact {
179
+ phone: string | null;
180
+ email: string | null;
181
+ address: string | null;
211
182
  }
212
183
  /** A resolved store page — GET /store/pages/{slug}. */
213
184
  export interface StorePagePublic {
@@ -227,10 +198,10 @@ export interface StoreCollection {
227
198
  /** In the client's language when the store translated it, else the primary. */
228
199
  title: string;
229
200
  description: string | null;
230
- /** How many products a shopper sees in it; absent from a server that predates it. */
231
- productCount?: number | null | undefined;
232
- /** Its first listed product's first photo, until a merchant can pick one; absent from a server that predates it. */
233
- image?: ProductImage | null | undefined;
201
+ /** How many products a shopper sees in it. */
202
+ productCount: number;
203
+ /** Its first listed product's first photo, until a merchant can pick one; null while it lists none with one. */
204
+ image: ProductImage | null;
234
205
  }
235
206
  /** A navigation menu — GET /store/menus/{handle}. */
236
207
  export interface StoreMenu {
@@ -247,7 +218,7 @@ export interface StoreMenuItem {
247
218
  * it through `localizeHref` — or the absolute URL the merchant entered.
248
219
  */
249
220
  href: string;
250
- /** The page type it opens (`home`, `catalog`, `category`, `product`, `page`, `landing`), or `url`. */
221
+ /** What it opens: a page type (`home`, `catalog`, `search`, `trackOrder`, `contact`, `category`, `collection`, `product`, `page`, `landing`), or `url`. */
251
222
  type: string;
252
223
  children: StoreMenuItem[];
253
224
  }
@@ -298,8 +269,8 @@ export interface LandingPage {
298
269
  };
299
270
  /**
300
271
  * The page's own composition for the client's `theme` (its `lp:<id>`
301
- * document), or null when it has none, the client names no theme, or the
302
- * server predates it: the theme then renders its `landing` default.
272
+ * document), or null when it has none or the client names no theme: the
273
+ * theme then renders its `landing` default.
303
274
  */
304
275
  document: PageDocumentPayload | null;
305
276
  }
@@ -324,12 +295,12 @@ export interface CartValidationLine {
324
295
  unitPrice: number;
325
296
  /** What the line costs, offers and the coupon applied. */
326
297
  lineTotal: number;
327
- /** The offer applied to the line; null or absent when none. */
328
- offerId?: string | null | undefined;
329
- /** That offer's name, in the shopper's language; null or absent when none. */
330
- offerName?: string | null | undefined;
331
- /** The line's share of the coupon's discount, already out of `lineTotal`; null or absent without one. */
332
- discount?: number | null | undefined;
298
+ /** The offer applied to the line; null when none. */
299
+ offerId: string | null;
300
+ /** That offer's name, in the shopper's language; null when none. */
301
+ offerName: string | null;
302
+ /** The line's share of the coupon's discount, already out of `lineTotal`; 0 without one. */
303
+ discount: number;
333
304
  /** Units still available; null when the product is not stock-tracked. */
334
305
  maxQuantity: number | null;
335
306
  /**
@@ -338,17 +309,16 @@ export interface CartValidationLine {
338
309
  */
339
310
  blocker: "missing" | "unlisted" | "out_of_stock" | null;
340
311
  }
341
- /** A satisfied Buy-X-Get-Y reward, appended server-side at order time. */
342
312
  /** A gift the basket earns, as the order will give it: free, beside the lines. */
343
313
  export interface CartReward {
344
314
  offerId: string;
345
- /** The offer's name, in the shopper's language; absent from an older server. */
346
- offerName?: string | null | undefined;
315
+ /** The offer's name, in the shopper's language. */
316
+ offerName: string | null;
347
317
  productId: string | null;
348
318
  productName: string | null;
349
- /** The variant given and its label; null for a product without variants, absent from an older server. */
350
- variantId?: string | null | undefined;
351
- variantLabel?: string | null | undefined;
319
+ /** The variant given and its label; null for a product without variants. */
320
+ variantId: string | null;
321
+ variantLabel: string | null;
352
322
  quantity: number;
353
323
  }
354
324
  /** The catalog's truth about a basket, right now. Read-only — nothing is written. */
@@ -362,6 +332,8 @@ export interface CartValidation {
362
332
  threshold: number | null;
363
333
  qualified: boolean;
364
334
  remaining: number;
335
+ /** Why delivery is free: an offer, the coupon or the store's amount; null when it is not. */
336
+ by: "offer" | "coupon" | "threshold" | null;
365
337
  };
366
338
  /**
367
339
  * Present when `validate` was given a destination: what the order would
@@ -371,8 +343,8 @@ export interface CartValidation {
371
343
  * deliver there, and an order to it is refused.
372
344
  */
373
345
  delivery?: CartDeliveryQuote | undefined;
374
- /** What the code the quote was asked with did; null without one, absent from a server that predates coupons. */
375
- coupon?: CouponOutcome | null | undefined;
346
+ /** What the code the quote was asked with did; null without one. */
347
+ coupon: CouponOutcome | null;
376
348
  }
377
349
  /**
378
350
  * Why a code does not apply. Open: a server may name a reason this SDK has not
@@ -413,8 +385,8 @@ export interface CreatedOrder {
413
385
  deliveryFee: number;
414
386
  /** price + deliveryFee, offers and the coupon applied. */
415
387
  total: number;
416
- /** What the coupon took off; null or absent without one. */
417
- couponDiscount?: number | null | undefined;
388
+ /** What the coupon took off; 0 without one. */
389
+ couponDiscount: number;
418
390
  }
419
391
  /** One basket line in a create-order body. */
420
392
  export interface CreateOrderItemInput {
@@ -573,7 +545,7 @@ export interface Review {
573
545
  createdAt: string;
574
546
  /** Rests on an order that reached its customer: show it as a verified purchase. */
575
547
  verified: boolean;
576
- /** Photos first, then the voice note. Empty from servers before 1.6. */
548
+ /** Photos first, then the voice note. */
577
549
  media: ReviewMedia[];
578
550
  }
579
551
  /** One product of the shopper's order, as `products.reviews.eligibility()` answers it. */
@@ -613,8 +585,7 @@ export interface ReviewUpload {
613
585
  /** What a submitted review answers: published now, or after the store checks it. */
614
586
  export interface ReviewSubmitResult {
615
587
  id: string;
616
- /** Absent from servers before 1.6. */
617
- visibility?: "published" | "after_review" | undefined;
588
+ visibility: "published" | "after_review";
618
589
  }
619
590
  /**
620
591
  * Filters `products.list()` accepts — named so a theme can type its own
@@ -631,11 +602,9 @@ export interface ProductListFilters {
631
602
  collection?: string;
632
603
  /** Only products priced below their compare-at price. */
633
604
  onSale?: boolean;
634
- /** @deprecated Use `collection: "featured"`. */
635
- featured?: boolean;
636
605
  /**
637
606
  * The order. Absent: the listing's own — a collection's, a search's best
638
- * match first, else newest. A server that predates sorting ignores it.
607
+ * match first, else newest.
639
608
  */
640
609
  sort?: ProductSort;
641
610
  /** Server-side cap; the server clamps anything larger. */
@@ -650,8 +619,8 @@ export interface ProductPage {
650
619
  items: ProductList[];
651
620
  /** True when another page follows this one. */
652
621
  hasNext: boolean;
653
- /** How many products the whole listing holds, across its pages; null from a server that predates counts. */
654
- total: number | null;
622
+ /** How many products the whole listing holds, across its pages. */
623
+ total: number;
655
624
  }
656
625
  /** `products.reviews.list()` paging — named for the same reason. */
657
626
  export interface ReviewListPagination {
@@ -665,12 +634,8 @@ export interface ReviewListPagination {
665
634
  */
666
635
  export interface ReviewInput {
667
636
  orderNumber: string;
668
- /**
669
- * The phone the order was placed with. Without it (the 1.5 shape) a review
670
- * is text only and still needs a delivered order.
671
- * @deprecated Omitting it is deprecated; a later major requires it.
672
- */
673
- phone?: string;
637
+ /** The phone the order was placed with. */
638
+ phone: string;
674
639
  productId: string;
675
640
  /** 1–5, the server validates the range. */
676
641
  rating: number;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The store a page renders when its config cannot be read.
3
+ *
4
+ * Every theme needs one, and each used to write its own — with whatever
5
+ * fields the config had when it was written. It is the SDK's, so it always has
6
+ * the config's shape, and it decides once what a store we cannot read offers:
7
+ * nothing to buy. The cart, the checkout, the code by SMS, reviews and the
8
+ * WhatsApp button are off, so the page can never look like a working
9
+ * checkout; the theme renders its own words in its layout around it.
10
+ */
11
+ import type { StoreConfig } from "./types.js";
12
+ export interface UnavailableStoreOptions {
13
+ /** The store's server, as the theme's client is given it: a store that serves its own files serves them from its `/media`. */
14
+ serverUrl: string;
15
+ /** The page's language. Default: the platform's default language. */
16
+ locale?: string;
17
+ }
18
+ /**
19
+ * The config of a store that cannot be read, in `locale`, its only language —
20
+ * pass it as `resolveStoreRequest`'s `fallback`. Its `id` is empty: no store
21
+ * has that id, so a theme tells it from a real config by `config.id`.
22
+ */
23
+ export declare function unavailableStoreConfig({ serverUrl, locale }: UnavailableStoreOptions): StoreConfig;
@@ -0,0 +1,58 @@
1
+ /** The platform's default store language: a new store speaks it until the merchant picks another. */
2
+ const DEFAULT_LOCALE = "ar";
3
+ /**
4
+ * A language described by the runtime's own data: the platform's list of
5
+ * languages lives on the server, and an unreadable store has none to send.
6
+ */
7
+ function describedLanguage(code) {
8
+ const locale = new Intl.Locale(code);
9
+ const info = locale.getTextInfo?.() ?? locale.textInfo;
10
+ const name = new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code;
11
+ return {
12
+ code,
13
+ nativeName: name.charAt(0).toLocaleUpperCase(code) + name.slice(1),
14
+ direction: info?.direction === "rtl" ? "rtl" : "ltr",
15
+ };
16
+ }
17
+ /**
18
+ * The config of a store that cannot be read, in `locale`, its only language —
19
+ * pass it as `resolveStoreRequest`'s `fallback`. Its `id` is empty: no store
20
+ * has that id, so a theme tells it from a real config by `config.id`.
21
+ */
22
+ export function unavailableStoreConfig({ serverUrl, locale = DEFAULT_LOCALE }) {
23
+ return {
24
+ id: "",
25
+ name: "",
26
+ domain: null,
27
+ logo: null,
28
+ lang: locale,
29
+ locale,
30
+ locales: [describedLanguage(locale)],
31
+ currency: "DZD",
32
+ currencySymbol: "DZD",
33
+ metaTitle: null,
34
+ metaDescription: null,
35
+ ogImage: null,
36
+ media: { kind: "direct", origin: `${serverUrl.replace(/\/+$/, "")}/media` },
37
+ reviewsEnabled: false,
38
+ cartEnabled: false,
39
+ freeShippingThreshold: null,
40
+ contentOverrides: {},
41
+ themeSettings: {},
42
+ checkoutForm: {
43
+ address: "hidden",
44
+ notes: "hidden",
45
+ email: "hidden",
46
+ deliveryOptions: { home: false, stopDesk: false },
47
+ customFields: [],
48
+ },
49
+ whatsapp: null,
50
+ pixelId: null,
51
+ conversionEvent: "Purchase",
52
+ pages: [],
53
+ contact: { phone: null, email: null, address: null },
54
+ otpEnabled: false,
55
+ turnstileEnabled: false,
56
+ turnstileSiteKey: null,
57
+ };
58
+ }
@@ -10,8 +10,12 @@
10
10
  * or not made with the other choices.
11
11
  */
12
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">;
13
+ /**
14
+ * What the picker reads of a variant — a page may hand it a lighter copy than
15
+ * `ProductVariant`. A product's variants are its active ones, in the
16
+ * merchant's order: the picker takes them as they come.
17
+ */
18
+ export type PickerVariant = Pick<ProductVariant, "variations" | "inventory" | "isDefault">;
15
19
  /** Option name → chosen value, as a product's `variantOptions` name them. */
16
20
  export type OptionSelection = Readonly<Record<string, string>>;
17
21
  export type OptionState = "available" | "sold_out" | "unavailable";
@@ -1,20 +1,17 @@
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;
1
+ const sellable = (product, variant) => !product.trackInventory || variant.inventory > 0;
4
2
  /** The variant whose every option has the value `selection` gives it, or null. */
5
3
  export function variantFor(variants, selection) {
6
- return variants.find((variant) => live(variant) && Object.entries(variant.variations).every(([option, value]) => selection[option] === value)) ?? null;
4
+ return variants.find((variant) => Object.entries(variant.variations).every(([option, value]) => selection[option] === value)) ?? null;
7
5
  }
8
6
  /**
9
7
  * The variant a product page opens on: the merchant's default when it can be
10
8
  * sold, else the first that can, else the default (or first) shown sold out.
11
9
  */
12
10
  export function initialVariant(product) {
13
- const ordered = product.variants.filter(live).sort(byPosition);
14
- const preferred = ordered.find((variant) => variant.isDefault) ?? ordered[0] ?? null;
11
+ const preferred = product.variants.find((variant) => variant.isDefault) ?? product.variants[0] ?? null;
15
12
  if (preferred && sellable(product, preferred))
16
13
  return preferred;
17
- return ordered.find((variant) => sellable(product, variant)) ?? preferred;
14
+ return product.variants.find((variant) => sellable(product, variant)) ?? preferred;
18
15
  }
19
16
  /**
20
17
  * The shopper picks `value` for `option`. The variant they mean is the one
@@ -24,8 +21,8 @@ export function initialVariant(product) {
24
21
  */
25
22
  export function pickOption(product, selection, option, value) {
26
23
  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];
24
+ const candidates = product.variants.filter((variant) => variant.variations[option] === value);
25
+ const best = candidates.sort((a, b) => kept(b) - kept(a) || Number(sellable(product, b)) - Number(sellable(product, a)))[0];
29
26
  return best ? { variant: best, selection: { ...best.variations } } : { variant: null, selection };
30
27
  }
31
28
  /**
@@ -6,7 +6,9 @@
6
6
  * authors it within the limits below), and the page
7
7
  * resolves it: `{store}`, `{product}`, `{url}`, `{order}`.
8
8
  *
9
- * Three rules live here so no theme re-invents them:
9
+ * `whatsAppContact` is the one way a theme reads the store's WhatsApp: it
10
+ * picks the page's template, falls back to the theme's words, and applies
11
+ * three rules no theme re-invents:
10
12
  *
11
13
  * 1. **Ad-click parameters are stripped from `{url}`.** A shopper arriving from
12
14
  * a paid ad should not hand the merchant's WhatsApp a `fbclid`-laden link.
@@ -55,9 +57,60 @@ export declare function resolvePrefillText({ template, storeName, productName, u
55
57
  /** Appends the resolved message to a `wa.me` href, encoded and query-safe. */
56
58
  export declare function buildWhatsAppHref(baseHref: string, text: string): string;
57
59
  /**
58
- * The WhatsApp widget as a page in `config.locale` shows it. The merchant
59
- * writes its texts once, in the store's primary language (`config.lang`), so
60
- * a page in another language gets them as null and shows the theme's own
61
- * words in its language instead; the agent's name and every setting stay.
60
+ * A theme's own words for the WhatsApp button, in the page's language: what
61
+ * each text reads when the merchant left it empty. A theme has them in every
62
+ * language it speaks; the store's texts come translated or not at all. The
63
+ * panel's three optional texts take null when the theme shows nothing there.
62
64
  */
63
- export declare function localWhatsAppWidget(config: Pick<StoreConfig, "whatsapp" | "lang" | "locale">): StoreWhatsAppWidget | null;
65
+ export interface WhatsAppWords {
66
+ /** The floating launcher's text; null: the launcher shows its icon alone. */
67
+ launcher: string | null;
68
+ /** The line under the agent's name; null: none. */
69
+ caption: string | null;
70
+ /** The panel's greeting; null: none, and a launcher may open the chat straight away. */
71
+ welcome: string | null;
72
+ /** The button that opens WhatsApp from the panel, and any other WhatsApp link's text. */
73
+ cta: string;
74
+ /** The button on an order's confirmation or tracking page. */
75
+ orderButton: string;
76
+ /** The message each kind of page starts the chat with; each may carry `{store}`, `{product}`, `{url}` and `{order}`. */
77
+ messages: Record<WhatsAppPage["kind"], string>;
78
+ }
79
+ /** What a page is about, for the message the chat starts with. */
80
+ export interface WhatsAppPage {
81
+ /** A product or landing page names its product; an order's confirmation or tracking page its order. */
82
+ kind: "general" | "product" | "order";
83
+ /** The page's address, for `{url}`; its ad-click parameters are dropped. */
84
+ url: string;
85
+ product?: string | null | undefined;
86
+ order?: string | null | undefined;
87
+ }
88
+ /** The store's WhatsApp on one page, every text resolved — `whatsAppContact`. */
89
+ export interface WhatsAppContact {
90
+ /** The chat link, with this page's message in it. */
91
+ href: string;
92
+ launcherLabel: string | null;
93
+ caption: string | null;
94
+ welcomeMessage: string | null;
95
+ ctaLabel: string;
96
+ orderButtonLabel: string;
97
+ /** Who the shopper writes to: the merchant's agent, else the store. */
98
+ agentName: string;
99
+ avatarUrl: string | null;
100
+ accent: StoreWhatsAppWidget["accent"];
101
+ position: StoreWhatsAppWidget["position"];
102
+ attention: boolean;
103
+ /** The pages the floating launcher shows on (`showsWidgetOn`); a link elsewhere on the page does not depend on it. */
104
+ surfaces: StoreWhatsAppWidget["surfaces"];
105
+ }
106
+ /**
107
+ * The store's WhatsApp for one page, or null when it has none: every link a
108
+ * theme draws to it — the launcher, a footer or menu link, a contact card,
109
+ * the order confirmation's button — comes from here, so each carries the
110
+ * page's message and every text is in the page's language.
111
+ *
112
+ * Each text is the merchant's when they wrote one, else the theme's `words`.
113
+ * The message is the page's own template (a product's, an order's), else the
114
+ * merchant's general one, else the theme's for that kind of page.
115
+ */
116
+ export declare function whatsAppContact(config: Pick<StoreConfig, "whatsapp" | "name">, page: WhatsAppPage, words: WhatsAppWords): WhatsAppContact | null;
package/dist/whatsapp.js CHANGED
@@ -69,17 +69,35 @@ export function buildWhatsAppHref(baseHref, text) {
69
69
  const separator = baseHref.includes("?") ? "&" : "?";
70
70
  return `${baseHref}${separator}text=${encodeURIComponent(text)}`;
71
71
  }
72
- /** The widget's texts the merchant writes once, in the store's primary language. */
73
- const WIDGET_TEXTS = ["caption", "welcomeMessage", "launcherLabel", "ctaLabel", "prefillGeneral", "prefillProduct", "thankYouButtonLabel", "prefillThankYou"];
74
72
  /**
75
- * The WhatsApp widget as a page in `config.locale` shows it. The merchant
76
- * writes its texts once, in the store's primary language (`config.lang`), so
77
- * a page in another language gets them as null and shows the theme's own
78
- * words in its language instead; the agent's name and every setting stay.
73
+ * The store's WhatsApp for one page, or null when it has none: every link a
74
+ * theme draws to it — the launcher, a footer or menu link, a contact card,
75
+ * the order confirmation's button — comes from here, so each carries the
76
+ * page's message and every text is in the page's language.
77
+ *
78
+ * Each text is the merchant's when they wrote one, else the theme's `words`.
79
+ * The message is the page's own template (a product's, an order's), else the
80
+ * merchant's general one, else the theme's for that kind of page.
79
81
  */
80
- export function localWhatsAppWidget(config) {
82
+ export function whatsAppContact(config, page, words) {
81
83
  const widget = config.whatsapp;
82
- if (!widget || config.locale === config.lang)
83
- return widget;
84
- return { ...widget, ...Object.fromEntries(WIDGET_TEXTS.map((key) => [key, null])) };
84
+ if (!widget)
85
+ return null;
86
+ const own = page.kind === "product" ? widget.prefillProduct : page.kind === "order" ? widget.prefillThankYou : null;
87
+ const template = own ?? widget.prefillGeneral ?? words.messages[page.kind];
88
+ const message = resolvePrefillText({ template, storeName: config.name, productName: page.product ?? null, url: page.url, orderNumber: page.order ?? null });
89
+ return {
90
+ href: buildWhatsAppHref(widget.href, message),
91
+ launcherLabel: widget.launcherLabel ?? words.launcher,
92
+ caption: widget.caption ?? words.caption,
93
+ welcomeMessage: widget.welcomeMessage ?? words.welcome,
94
+ ctaLabel: widget.ctaLabel ?? words.cta,
95
+ orderButtonLabel: widget.thankYouButtonLabel ?? words.orderButton,
96
+ agentName: widget.agentName ?? config.name,
97
+ avatarUrl: widget.avatarUrl,
98
+ accent: widget.accent,
99
+ position: widget.position,
100
+ attention: widget.attention,
101
+ surfaces: widget.surfaces,
102
+ };
85
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "2.2.0",
3
+ "version": "3.1.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",