@wocart/storefront 1.23.0 → 2.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,65 @@ 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
+ ## 2.1.0 — 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - `LandingPage.document`: the page's own composition for the client's
13
+ `theme`, or null — a landing page and its document in one request. Null
14
+ from a server that predates it, and a malformed document reads as null.
15
+ - `pages.landing(slug, { preview: true })` answers a draft page too and
16
+ counts no visit, for an editor's preview.
17
+ - `countdownTarget`, `countdownEnd` and `timeLeft`: an offer countdown's end
18
+ — every midnight, or a day the merchant names, at its end or at a time —
19
+ in Algiers time, the same in the server's render and the browser.
20
+
21
+ ### Fixed
22
+
23
+ - `pages.landing` sends the client's language, like every read that carries
24
+ merchant text: the page's product reaches a shopper in their language.
25
+
26
+ ## 2.0.0 — 2026-10-30
27
+
28
+ A store's images can live on any media host. The server answers every image
29
+ as a `MediaImage` and the store's config says how its images are delivered;
30
+ the image helpers take that delivery instead of a media domain. Requires a
31
+ store server that answers this shape.
32
+
33
+ ### Changed (breaking)
34
+
35
+ - Every image is a `MediaImage`, `{ key, url, width, height }`: a product
36
+ image (`ProductImageSchema`) and a landing image carry `key` and `url`
37
+ (`src`, `srcSm`, `srcMd`, `srcLg` are gone); a category has `image` (was
38
+ `imageUrl`); `StoreConfig` has `logo` (was `logoUrl`) and `ogImage` as
39
+ images.
40
+ - `StoreConfig.media` (`MediaDelivery`, `{ kind, origin }`): where every key is
41
+ served from, and how it is resized — `imagekit`, `cloudflare`, or `direct`.
42
+ - `mediaUrl(key, delivery)`, `mediaImageUrl(url, params, delivery)`,
43
+ `mediaImageSrcset(url, widths, { delivery })` and `pictureOf(image,
44
+ delivery)` take the store's delivery (was a media domain). `mediaImageUrl`
45
+ builds ImageKit's `?tr=` or Cloudflare's `/cdn-cgi/image/`, and answers
46
+ `null` for a `direct` delivery.
47
+ - `productImageSource` reads `url`.
48
+
49
+ ### Removed
50
+
51
+ - `optimizeImage`, `generateSrcSet`, `IMAGE_SIZES`, `getCardImage`,
52
+ `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`,
53
+ `getLogoImage`, `createImageHelpers` and their types: each took a media
54
+ domain, which no longer exists. Use `mediaImageUrl` and `mediaImageSrcset`
55
+ with the size a page shows. They are removed without a deprecating minor
56
+ because no media domain is left for them to take.
57
+
58
+ ### Added
59
+
60
+ - `isMediaKey`, `MediaImage`, `MediaDelivery`, `MediaDeliveryKind`,
61
+ `MediaImageSchema` and `MediaDeliverySchema`.
62
+
63
+ ### Changed
64
+
65
+ - The README is short and points to the documentation at docs.wocart.store.
66
+
8
67
  ## 1.23.0 — 2026-10-25
9
68
 
10
69
  One checkout, one order: a double click, a refreshed submission or a retry
package/README.md CHANGED
@@ -1,20 +1,13 @@
1
1
  # @wocart/storefront
2
2
 
3
3
  The storefront SDK for [Wocart](https://github.com/bighadj22/codflow), the
4
- cash-on-delivery commerce platform for Algeria. It gives a storefront theme
5
- typed, validated access to a store's catalog, delivery prices, basket,
6
- checkout, OTP and order tracking — and keeps prices, rules and secrets on the
7
- server.
4
+ cash-on-delivery commerce platform for Algeria. It gives a storefront typed,
5
+ validated access to a store's catalog, delivery prices, basket, checkout, OTP,
6
+ order tracking and images — and keeps prices, rules and secrets on the server.
8
7
 
9
- - **Typed and validated.** Every server response is parsed against a schema
10
- before your code sees it.
11
- - **Predictable failures.** Reads never throw: they resolve to `null`, `[]` or
12
- `{}` and report through `onError`. Writes return a result whose failure always
13
- carries a structured `DomainError`.
14
- - **Browser-safe by construction.** The root entry exports no client and no
15
- secret; the store API key lives behind `@wocart/storefront/server`.
16
- - **Framework-neutral core, Astro adapters.** Proxy routes, form actions and
17
- the POST/Redirect/GET middleware decision for Astro, with no Astro import.
8
+ **Documentation: [docs.wocart.store/sdk/storefront](https://docs.wocart.store/sdk/storefront)**
9
+ — also in [French](https://docs.wocart.store/fr/sdk/storefront) and
10
+ [Arabic](https://docs.wocart.store/ar/sdk/storefront).
18
11
 
19
12
  ## Install
20
13
 
@@ -22,123 +15,35 @@ server.
22
15
  npm install @wocart/storefront zod
23
16
  ```
24
17
 
25
- `zod` (4.x) is a peer dependency: the SDK's schemas are part of its API — you
26
- can validate with them, and you pass your own schemas to `createProxyEndpoint` —
27
- so your app and the SDK share one copy. npm 7 and later install it
28
- automatically. ESM only. Runs in browsers, Cloudflare Workers, Node and Astro server
29
- runtimes; tooling needs Node 22.12 or newer.
18
+ `zod` 4 is a peer dependency. ESM only; Node 22.12 or newer for tooling.
30
19
 
31
20
  ## Quick start
32
21
 
33
22
  ```ts
34
23
  import { createStoreClient } from "@wocart/storefront/server";
24
+ import { mediaImageUrl } from "@wocart/storefront";
35
25
 
36
- // Server-side only: a page frontmatter, endpoint, action or middleware.
37
- const store = createStoreClient({
38
- serverUrl: env.COD_SERVER_URL, // your store's API origin
39
- apiKey: env.STORE_API_KEY, // server-only secret
40
- onError: (error) => console.warn(error.operation, error.code, error.requestId),
41
- });
26
+ const store = createStoreClient({ serverUrl: env.COD_SERVER_URL, apiKey: env.STORE_API_KEY });
42
27
 
43
- // Reads: typed, validated, safe to render straight away.
44
- const [config, products] = await Promise.all([
45
- store.config.get(), // StoreConfig | null
46
- store.products.list({ featured: true, limit: 8 }), // ProductList[]
47
- ]);
48
- const product = await store.products.get("blue-t-shirt"); // Product | null
49
-
50
- // Writes: a discriminated result, never a thrown HTTP error.
51
- const result = await store.orders.create({
52
- customerName: "أمين",
53
- phone: "0555123456",
54
- wilayaId: 16,
55
- communeId: "c-16-001",
56
- items: [{ productId: "p-1", productName: "Blue T-Shirt", quantity: 1 }],
57
- });
58
-
59
- if (result.success) {
60
- console.log(result.data.orderNumber, result.data.total);
61
- } else {
62
- console.error(result.domainError.category, result.domainError.message);
63
- }
64
- ```
65
-
66
- ## Entry points
67
-
68
- | Import | Runs in | What it holds |
69
- | --- | --- | --- |
70
- | `@wocart/storefront` | browser and server | read-model types and schemas, reference data (58 wilayas, 1,551 communes), money and image helpers, the basket and OTP state machines, language routing, Meta pixel payloads |
71
- | `@wocart/storefront/server` | server only | `createStoreClient`, `StoreClient`, `ClientResult`, `StorefrontError` — the API key lives here — the preview checks (`readPreviewRequest`, `verifyPreviewToken`, `previewResponseHeaders`, `previewConfig`) and revalidation (`readRevalidateRequest`) |
72
- | `@wocart/storefront/astro` | server only | `createStorefrontRoutes`, `createProxyEndpoint`, `placeOrderInput`, `executePlaceOrder`, `collectForwardedHeaders`, `runFormAction` |
73
-
74
- Browser code never receives the key. It calls your own same-origin routes
75
- (for example `src/pages/api/cart.ts`), which call the SDK on the server:
76
-
77
- ```ts
78
- // In the browser: no client, no key.
79
- import { createBasketStore, formatMoney } from "@wocart/storefront";
28
+ const config = await store.config.get();
29
+ const product = await store.products.get("blue-t-shirt");
30
+ const photo = product?.images[0];
31
+ const src = photo && config ? mediaImageUrl(photo.url, { width: 480, fit: "cover", format: "auto" }, config.media) ?? photo.url : null;
80
32
  ```
81
33
 
82
- ## What the client covers
83
-
84
- | Namespace | Use it for |
85
- | --- | --- |
86
- | `config` | store identity, branding, languages, checkout policy, feature flags |
87
- | `products` | catalog lists, product detail, reviews |
88
- | `categories` | catalog navigation |
89
- | `pages` | legal and CMS pages, landing pages |
90
- | `pageData` | the store's saved page document for a surface (render it with [`@wocart/theme`](https://www.npmjs.com/package/@wocart/theme)) |
91
- | `shipping` | home and stop-desk delivery prices per wilaya |
92
- | `geo` | wilayas and communes |
93
- | `cart` | server re-pricing and validation of a browser basket |
94
- | `orders` | order creation, OTP, abandoned checkout, conversion tracking |
95
-
96
- ## How errors work
34
+ Reads never throw: they resolve to `null`, `[]` or `{}` and report through
35
+ `onError`. Writes return `{ success, data }` or a structured `domainError`.
97
36
 
98
- - **Reads** resolve to the empty value (`null`, `[]`, `{}`) so a page still
99
- renders, and report the cause to `onError` as a `DomainError` with
100
- `category`, `code`, `retryable`, `sideEffect`, `operation` and `requestId`.
101
- - **Writes** return `ClientResult<T>`: `{ success: true, data }` or
102
- `{ success: false, error, domainError }`. Branch on
103
- `result.domainError.category` — `validation`, `authentication`, `business`,
104
- `system`, `transport` — instead of status codes.
105
- - **Setup errors are loud.** An empty `serverUrl` or `apiKey` throws
106
- `MISSING_CONFIGURATION` when the client is created, instead of quietly
107
- turning every read into an empty store.
108
-
109
- ## Configuration
37
+ ## Entry points
110
38
 
111
- | Option | Required | Notes |
39
+ | Import | Runs in | Holds |
112
40
  | --- | --- | --- |
113
- | `serverUrl` | yes | your store's API origin |
114
- | `apiKey` | yes | the store API key, sent as `X-Store-API-Key`; server-only |
115
- | `locale` | no | read merchant text in a published store language (`"fr"`); one client per language |
116
- | `theme` | no | your theme's key (`defineTheme({ key })`), to receive the merchant's edits to your theme's text |
117
- | `onError` | no | observability hook for degraded reads |
118
- | `readTimeoutMs` | no | how long one read may take, retries included, before it degrades (default 10 000; `0` for no limit) |
119
-
120
- A storefront deployed by Wocart receives its origin and key as the
121
- `COD_SERVER_URL` and `STORE_API_KEY` environment variables. The SDK reads no
122
- environment variables itself; pass the values in. In Astro, declare the key as
123
- a server secret (`envField.string({ context: "server", access: "secret" })`)
124
- and never expose it as a public variable.
125
-
126
- ## Documentation
127
-
128
- - [USAGE.md](./USAGE.md) — the full guide: every namespace and the UI decisions
129
- it implies, checkout and OTP, Astro actions and proxy routes, languages,
130
- images, and a definition of done for a new theme.
131
- - [CHANGELOG.md](./CHANGELOG.md) — what changed in each release.
132
-
133
- ## Compatibility
41
+ | `@wocart/storefront` | browser and server | types and schemas, reference data, money and image helpers, basket and OTP state |
42
+ | `@wocart/storefront/server` | server only | `createStoreClient` — the store API key lives here — preview and revalidation checks |
43
+ | `@wocart/storefront/astro` | server only | proxy routes, form actions, `placeOrderInput` |
134
44
 
135
- - Follows [semantic versioning](https://semver.org/). Breaking changes ship
136
- only in a new major version and are listed in the changelog.
137
- - Response schemas ignore fields they do not know, so a newer server never
138
- breaks an older SDK; a response missing a required field degrades the read
139
- and reports it.
140
- - Astro 7 is verified. The Astro adapters are structural and import nothing
141
- from Astro.
45
+ [USAGE.md](./USAGE.md) ships with the package for offline reading;
46
+ [CHANGELOG.md](./CHANGELOG.md) lists every release.
142
47
 
143
48
  ## License
144
49
 
package/USAGE.md CHANGED
@@ -214,10 +214,11 @@ server directly.
214
214
 
215
215
  | Export | Use it for |
216
216
  | --- | --- |
217
- | `formatMoney`, `optimizeImage`, `generateSrcSet` | Money text and image URLs |
218
- | `mediaImageUrl` | The Cloudflare resizing URL of a media-domain image, served by the media domain itself (any storefront host); `null` for any other image — the builder behind every image helper and `@wocart/theme/image-service` |
219
- | `mediaUrl(key, mediaDomain)` | The public URL of a stored media key — what a theme's `image` field holds (`{ key: "products/…" }`): `mediaUrl(image.key, MEDIA_DOMAIN)`; `null` with no media domain or a key that cannot name an object. The key is the identity; the URL is derived at render |
220
- | `createImageHelpers(mediaDomain)`, `getCardImage`, `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`, `getLogoImage` | The bound image toolkit (`IMAGE_SIZES` holds the presets) |
217
+ | `formatMoney` | Money text |
218
+ | `mediaImageUrl(url, params, config.media)` | A store image at the size and format a page shows it, from the store's delivery (ImageKit `?tr=` or Cloudflare `/cdn-cgi/image/`); `null` for an image the delivery does not serve, or a `direct` delivery — the builder behind `@wocart/theme/image-service` |
219
+ | `mediaImageSrcset(url, widths, { delivery })` | The same image at several widths, for `srcset` |
220
+ | `mediaUrl(key, config.media)`, `isMediaKey` | The URL of a Media Key — what a theme's `image` field holds (`{ key: "products/…" }`); `null` without a delivery or for a value that is not a key |
221
+ | `pictureOf(image, config.media)` | A picked image ready for an `<img>`: URL, alt, size, focal point |
221
222
  | `WILAYAS`, `ALGERIA_COMMUNES`, `communesForWilaya` | Reference data: 58 wilayas, 1,551 communes, one wilaya's rows |
222
223
  | `isWilayaId` | Whether a value is a wilaya id — the range `WilayaIdSchema` checks on the server, without a schema in the browser |
223
224
  | `readShippingRates`, `readCouponOutcome` | Read in the browser what the server handed it — the rates a page carries, a quote's coupon outcome — accepting exactly what `ShippingRatesSchema` / `CouponOutcomeSchema` accept, without Zod |
@@ -290,7 +291,6 @@ storefront deployed by Wocart receives these:
290
291
  | --- | --- | --- |
291
292
  | `COD_SERVER_URL` | server variable | the store's API origin, e.g. `https://api.your-store.com` |
292
293
  | `STORE_API_KEY` | server secret | the store API key |
293
- | `MEDIA_DOMAIN` | server variable (optional) | media domain for `createImageHelpers(mediaDomain)`; unset → image URLs pass through unchanged |
294
294
 
295
295
  Never put `STORE_API_KEY` in a browser-exposed variable (no `PUBLIC_*`, no
296
296
  `import.meta.env` in client code, no HTML attribute). In Astro, declare it in
@@ -518,11 +518,13 @@ const currencySymbol = config?.currencySymbol ?? "دج";
518
518
  StoreConfig contains the current store identity and presentation fields,
519
519
  including:
520
520
 
521
- - name, domain, logoUrl and themeId (the colors, fontFamily and fontUrl
521
+ - name, domain, logo (a MediaImage) and themeId (the colors, fontFamily and fontUrl
522
522
  fields are deprecated: colors and fonts are theme settings, read from
523
523
  themeSettings);
524
524
  - lang (the primary language), locales, contentOverrides, themeSettings,
525
- currency, currencySymbol, metaTitle, metaDescription, and ogImage;
525
+ currency, currencySymbol, metaTitle, metaDescription, and ogImage (a
526
+ MediaImage);
527
+ - media, the store's MediaDelivery, which every image helper takes;
526
528
  - reviewsEnabled (announcementBar is deprecated and always null: an
527
529
  announcement is a theme's own setting);
528
530
  - cartEnabled, freeShippingThreshold, and cartShippingMode;
@@ -996,7 +998,19 @@ const landing = await store.pages.landing("summer-sale");
996
998
 
997
999
  A landing page can contain its own name, SEO metadata, image gap, images,
998
1000
  product, publishedAt, and tracking information. The product may be null.
999
- Draft, archived, or unknown landing pages resolve to null.
1001
+ Draft, archived, or unknown landing pages resolve to null. Its product
1002
+ arrives in the client's language.
1003
+
1004
+ A client created with `theme` also receives the page's own composition,
1005
+ `landing.document` (the page document `lp:<id>`), or null when it has none:
1006
+ render it with `@wocart/theme` against the theme's `landing` surface, which
1007
+ gives every landing page its starting document. An editor's preview reads a
1008
+ draft page too, and counts no visit, with `{ preview: true }` — never on a
1009
+ shopper's page:
1010
+
1011
+ ~~~ts
1012
+ const landing = await store.pages.landing(slug, { preview: true });
1013
+ ~~~
1000
1014
 
1001
1015
  Landing tracking is already resolved by the server:
1002
1016
 
@@ -1008,6 +1022,21 @@ if (landing?.tracking.pixelId) {
1008
1022
 
1009
1023
  Do not invent tracking events from a landing-page slug.
1010
1024
 
1025
+ ### An offer's countdown
1026
+
1027
+ ~~~ts
1028
+ import { countdownEnd, countdownTarget, timeLeft } from "@wocart/storefront";
1029
+
1030
+ const ends = countdownTarget(settings.mode, settings.endsOn, settings.endsAt); // "midnight", "2026-11-30", "2026-11-30T18:30"
1031
+ const end = countdownEnd(ends); // null: ended, or no real moment — show nothing
1032
+ const left = end === null ? null : timeLeft(end);
1033
+ ~~~
1034
+
1035
+ A countdown ends every day at midnight, or on a day the merchant names, at
1036
+ its end or at the time they give, in Algiers time. The server writes the time
1037
+ left; the browser keeps it running with the same functions. A countdown that
1038
+ has ended shows nothing, never zeros.
1039
+
1011
1040
  ### The saved page document (theme composition)
1012
1041
 
1013
1042
  ~~~ts
@@ -1827,53 +1856,46 @@ price presentation does not drift between cards, product pages, and checkout.
1827
1856
 
1828
1857
  ### Images
1829
1858
 
1859
+ Every image the store's server answers is a `MediaImage` — `{ key, url,
1860
+ width, height }` — and the store's config carries `media`, its
1861
+ `MediaDelivery`: the origin every key is served from and how that origin
1862
+ resizes (`imagekit`, `cloudflare`, or `direct` for files served as stored).
1863
+ Pass `config.media` to the image helpers; one theme then works on every host a
1864
+ store can use.
1865
+
1830
1866
  ~~~ts
1831
- import {
1832
- generateSrcSet,
1833
- getCardImage,
1834
- optimizeImage,
1835
- } from "@wocart/storefront";
1867
+ import { mediaImageUrl } from "@wocart/storefront";
1836
1868
 
1837
- const mediaDomain = env.MEDIA_DOMAIN;
1838
- const originalImage = product.images[0]?.src ?? "";
1839
- const cardImage = getCardImage(originalImage, mediaDomain);
1840
- const src = optimizeImage(originalImage, {
1841
- width: 480,
1842
- height: 480,
1843
- quality: 82,
1844
- format: "auto",
1845
- fit: "cover",
1846
- }, mediaDomain);
1847
- const srcSet = generateSrcSet(originalImage, 480, 480, mediaDomain);
1869
+ const photo = product.images[0];
1870
+ const card = photo ? mediaImageUrl(photo.url, { width: 480, height: 480, fit: "cover", format: "auto", quality: 82 }, config.media) ?? photo.url : null;
1848
1871
  ~~~
1849
1872
 
1850
- The image utility accepts the original media URL and can target a media
1851
- domain. Use it to produce appropriately sized URLs rather than downloading
1852
- original assets into every card.
1873
+ `mediaImageUrl` answers `null` for an image its delivery does not serve and
1874
+ for a `direct` delivery, which does not resize: show the original then.
1853
1875
 
1854
1876
  For an image whose width changes with the screen, give the browser widths and
1855
1877
  let `sizes` choose — a phone then downloads a phone-sized file.
1856
- `mediaImageSrcset(src, widths, { mediaDomain, ratio, quality })` gives one
1857
- size per width with width descriptors, cropped to `ratio` (height ÷ width)
1858
- when given; null off the media domain:
1878
+ `mediaImageSrcset(src, widths, { delivery, ratio, quality })` gives one size
1879
+ per width with width descriptors, cropped to `ratio` (height ÷ width) when
1880
+ given and never enlarged past the original otherwise:
1859
1881
 
1860
1882
  ~~~astro
1861
1883
  <img
1862
- src={mediaImageUrl(src, { width: 720, format: "auto" }, mediaDomain) ?? src}
1863
- srcset={mediaImageSrcset(src, [360, 540, 720, 1080], { mediaDomain, ratio: 1.25 }) ?? undefined}
1884
+ src={mediaImageUrl(src, { width: 720, fit: "scale-down", format: "auto" }, config.media) ?? src}
1885
+ srcset={mediaImageSrcset(src, [360, 540, 720, 1080], { delivery: config.media, ratio: 1.25 }) ?? undefined}
1864
1886
  sizes="(min-width: 1024px) 25vw, 50vw"
1865
1887
  alt="" loading="lazy" />
1866
1888
  ~~~
1867
1889
 
1868
- A photo a merchant picks for a section is stored as a media key with its alt
1869
- text, size and focal point. `pictureOf(image, mediaDomain)` turns it into what
1890
+ A photo a merchant picks for a section is stored as a Media Key with its alt
1891
+ text, size and focal point. `pictureOf(image, config.media)` turns it into what
1870
1892
  an `<img>` needs — `src`, `alt` (empty when none was written), `width`,
1871
1893
  `height` and `position`, the focal point as CSS `object-position` — or null
1872
1894
  when none is picked:
1873
1895
 
1874
1896
  ~~~astro
1875
1897
  ---
1876
- const picture = pictureOf(props.image, mediaDomain);
1898
+ const picture = pictureOf(props.image, config.media);
1877
1899
  ---
1878
1900
  {picture && <img src={picture.src} alt={picture.alt} width={picture.width} height={picture.height} style={{ objectPosition: picture.position }} />}
1879
1901
  ~~~
@@ -1886,10 +1908,10 @@ thumbnail. `productImageSource` answers which image represents the product, so
1886
1908
  a cart drawer, a checkout summary and a product card cannot disagree:
1887
1909
 
1888
1910
  ~~~ts
1889
- import { getThumbnailImage, productImageSource } from "@wocart/storefront";
1911
+ import { mediaImageUrl, productImageSource } from "@wocart/storefront";
1890
1912
 
1891
1913
  const src = productImageSource(product); // Product | ProductList
1892
- const thumbnail = src ? getThumbnailImage(src) : null;
1914
+ const thumbnail = src ? mediaImageUrl(src, { width: 160, height: 160, fit: "cover", format: "auto" }, config.media) ?? src : null;
1893
1915
  ~~~
1894
1916
 
1895
1917
  It prefers the merchant's cover and falls back to the first usable gallery
@@ -1897,40 +1919,6 @@ image; `null` means the product genuinely has no photograph, so that is the
1897
1919
  moment to render a placeholder. The list model carries no gallery at all, so a
1898
1920
  card gets the cover or `null`.
1899
1921
 
1900
- Available semantic helpers include:
1901
-
1902
- - getCardImage;
1903
- - getGalleryImage;
1904
- - getThumbnailImage;
1905
- - getHeroImage;
1906
- - getCategoryImage;
1907
- - getLogoImage.
1908
-
1909
- The helper chooses the appropriate image role. The theme still owns layout,
1910
- aspect ratio, alt text, lazy loading, placeholders, and art direction.
1911
-
1912
- #### Bind the media domain once
1913
-
1914
- Do not thread the media domain through every call site — a missed argument
1915
- silently ships an unoptimized image. Bind it once and use the URL-only helper
1916
- set:
1917
-
1918
- ~~~ts
1919
- import { createImageHelpers } from "@wocart/storefront";
1920
-
1921
- const images = createImageHelpers(env.MEDIA_DOMAIN);
1922
-
1923
- const thumbnail = images.getThumbnailImage(product.images[0].src);
1924
- const preview = images.getCardImage(product.images[0].src);
1925
- const srcSet = images.generateSrcSet(product.images[0].src, 480, 480);
1926
- ~~~
1927
-
1928
- The returned toolkit exposes the same helpers plus `optimizeImage`,
1929
- `generateSrcSet`, and `IMAGE_SIZES`. With no domain bound, every helper returns
1930
- the original URL unchanged, so a store without a media domain needs no branch —
1931
- and `optimizeImage` / `generateSrcSet` still take a per-call domain that
1932
- overrides the bound one.
1933
-
1934
1922
  ## WhatsApp prefill messages
1935
1923
 
1936
1924
  The storefront's WhatsApp launcher hands the shopper to WhatsApp with a message
@@ -9,7 +9,8 @@
9
9
  * - `get(slug)` → null means "not published" — the theme 404s, never a
10
10
  * soft 200 with empty content.
11
11
  * - `landing(slug)` → null means draft/archived/unknown — the theme
12
- * redirects to the store home instead of a dead end.
12
+ * redirects to the store home instead of a dead end. `{ preview: true }`
13
+ * also answers a draft page and counts no visit: an editor's preview.
13
14
  */
14
15
  import { type ClientCore } from "./core.js";
15
16
  import type { LandingPage, StorePagePublic } from "../types.js";
@@ -22,10 +23,15 @@ export interface PagesNamespace {
22
23
  get(slug: string): Promise<StorePagePublic | null>;
23
24
  /**
24
25
  * Get a published landing page by its public slug: the ordered image
25
- * stack, spacing settings, and the linked product in full store-product
26
- * shape. Resolves to `null` for draft/archived/unknown slugs or failure.
26
+ * stack, spacing settings, the linked product in full store-product
27
+ * shape, and the page's own document for the client's `theme`. Resolves to
28
+ * `null` for draft/archived/unknown slugs or failure. With
29
+ * `{ preview: true }` a draft page answers too and no visit is counted —
30
+ * for an editor's preview only, never a shopper's page.
27
31
  */
28
- landing(slug: string): Promise<LandingPage | null>;
32
+ landing(slug: string, options?: {
33
+ preview?: boolean;
34
+ }): Promise<LandingPage | null>;
29
35
  }
30
36
  /**
31
37
  * Create pages namespace
@@ -9,7 +9,8 @@
9
9
  * - `get(slug)` → null means "not published" — the theme 404s, never a
10
10
  * soft 200 with empty content.
11
11
  * - `landing(slug)` → null means draft/archived/unknown — the theme
12
- * redirects to the store home instead of a dead end.
12
+ * redirects to the store home instead of a dead end. `{ preview: true }`
13
+ * also answers a draft page and counts no visit: an editor's preview.
13
14
  */
14
15
  import { LandingPageSchema, StorePagePublicSchema } from "../schemas.js";
15
16
  import { STOREFRONT_OPERATIONS } from "../operations.js";
@@ -31,9 +32,12 @@ export function createPagesNamespace(core) {
31
32
  return null;
32
33
  }
33
34
  },
34
- async landing(slug) {
35
+ async landing(slug, options = {}) {
35
36
  try {
36
- const landingPage = await core.get(`/store/landing-pages/${encodeURIComponent(slug)}`, LandingPageSchema.nullable(), { operation: STOREFRONT_OPERATIONS.pagesLanding });
37
+ const landingPage = await core.get(localizedPath(core.config, `/store/landing-pages/${encodeURIComponent(slug)}`, {
38
+ theme: core.config.theme,
39
+ preview: options.preview === true ? "1" : undefined,
40
+ }), LandingPageSchema.nullable(), { operation: STOREFRONT_OPERATIONS.pagesLanding });
37
41
  return landingPage ?? null;
38
42
  }
39
43
  catch (error) {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * An offer countdown's end, as the store sets it: every day at midnight, or
3
+ * a day the merchant names — at its end or at the time they give — in
4
+ * Algiers time (UTC+1 all year). An end already past, or a day or time that
5
+ * does not exist, is no countdown at all: a shopper never sees zeros. Runs
6
+ * the same on the server, which writes the time left, and in the browser,
7
+ * which keeps it running.
8
+ */
9
+ /** What a countdown counts to: `midnight`, a day (`YYYY-MM-DD`) or a day at a time (`YYYY-MM-DDTHH:MM`). */
10
+ export type CountdownEnd = "midnight" | string;
11
+ /** The time left, in whole units. */
12
+ export interface TimeLeft {
13
+ days: number;
14
+ hours: number;
15
+ minutes: number;
16
+ seconds: number;
17
+ }
18
+ /**
19
+ * What a countdown's settings count to: `mode` `midnight` or `date`, the
20
+ * day the merchant typed (`YYYY-MM-DD`) and, optionally, the time
21
+ * (`HH:MM`). Undefined when they count to nothing — no day for `date`, or
22
+ * any other mode.
23
+ */
24
+ export declare function countdownTarget(mode: string | undefined, endsOn: string | undefined, endsAt: string | undefined): CountdownEnd | undefined;
25
+ /** When a countdown ends, as an instant (ms since the epoch); null when it has ended or names no real moment. */
26
+ export declare function countdownEnd(ends: CountdownEnd | undefined, now?: number): number | null;
27
+ /** The time left until `end`, in whole days, hours, minutes and seconds; zero once it has passed. */
28
+ export declare function timeLeft(end: number, now?: number): TimeLeft;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * An offer countdown's end, as the store sets it: every day at midnight, or
3
+ * a day the merchant names — at its end or at the time they give — in
4
+ * Algiers time (UTC+1 all year). An end already past, or a day or time that
5
+ * does not exist, is no countdown at all: a shopper never sees zeros. Runs
6
+ * the same on the server, which writes the time left, and in the browser,
7
+ * which keeps it running.
8
+ */
9
+ const ALGERIA_OFFSET_MS = 60 * 60 * 1000;
10
+ const MOMENT = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/;
11
+ const TIME = /^(\d{1,2}):(\d{2})$/;
12
+ /**
13
+ * What a countdown's settings count to: `mode` `midnight` or `date`, the
14
+ * day the merchant typed (`YYYY-MM-DD`) and, optionally, the time
15
+ * (`HH:MM`). Undefined when they count to nothing — no day for `date`, or
16
+ * any other mode.
17
+ */
18
+ export function countdownTarget(mode, endsOn, endsAt) {
19
+ if (mode === "midnight")
20
+ return "midnight";
21
+ const day = endsOn?.trim() ?? "";
22
+ if (mode !== "date" || day === "")
23
+ return undefined;
24
+ const time = TIME.exec(endsAt?.trim() ?? "");
25
+ return time ? `${day}T${time[1].padStart(2, "0")}:${time[2]}` : day;
26
+ }
27
+ /** When a countdown ends, as an instant (ms since the epoch); null when it has ended or names no real moment. */
28
+ export function countdownEnd(ends, now = Date.now()) {
29
+ if (ends === undefined || ends === "")
30
+ return null;
31
+ if (ends === "midnight") {
32
+ const local = new Date(now + ALGERIA_OFFSET_MS);
33
+ return Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate() + 1) - ALGERIA_OFFSET_MS;
34
+ }
35
+ const match = MOMENT.exec(ends.trim());
36
+ if (!match)
37
+ return null;
38
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
39
+ const check = new Date(Date.UTC(year, month - 1, day));
40
+ if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day)
41
+ return null;
42
+ let end = Date.UTC(year, month - 1, day + 1) - ALGERIA_OFFSET_MS;
43
+ if (match[4] !== undefined) {
44
+ const [hours, minutes] = [Number(match[4]), Number(match[5])];
45
+ if (hours > 23 || minutes > 59)
46
+ return null;
47
+ end = Date.UTC(year, month - 1, day, hours, minutes) - ALGERIA_OFFSET_MS;
48
+ }
49
+ return end > now ? end : null;
50
+ }
51
+ /** The time left until `end`, in whole days, hours, minutes and seconds; zero once it has passed. */
52
+ export function timeLeft(end, now = Date.now()) {
53
+ const total = Math.max(0, Math.floor((end - now) / 1000));
54
+ return { days: Math.floor(total / 86400), hours: Math.floor((total % 86400) / 3600), minutes: Math.floor((total % 3600) / 60), seconds: total % 60 };
55
+ }
package/dist/index.d.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
  export { formatMoney } from "./utils/money.js";
13
13
  export type { MoneyFormatOptions } from "./utils/money.js";
14
- export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaImageSrcset, mediaUrl, pictureOf, } from "./utils/image.js";
15
- export type { ImageHelpers, ImageResizeOptions, MediaImageParams, MediaImageSrcsetOptions, PickedImage, Picture } from "./utils/image.js";
14
+ export { isMediaKey, mediaImageSrcset, mediaImageUrl, mediaUrl, pictureOf } from "./utils/image.js";
15
+ export type { MediaDelivery, MediaDeliveryKind, MediaImage, MediaImageParams, MediaImageSrcsetOptions, PickedImage, Picture } from "./utils/image.js";
16
16
  export { productImageSource } from "./utils/product-image.js";
17
17
  export type { ProductImageSourceInput } from "./utils/product-image.js";
18
18
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, } from "./whatsapp.js";
@@ -49,6 +49,8 @@ export * from "./domain.js";
49
49
  export * from "./operations.js";
50
50
  export * from "./product-view.js";
51
51
  export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
52
+ export { countdownEnd, countdownTarget, timeLeft } from "./countdown.js";
53
+ export type { CountdownEnd, TimeLeft } from "./countdown.js";
52
54
  export { COUPON_HINT_REASONS, COUPON_NOT_APPLICABLE, COUPON_REFUSAL_REASONS, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf, couponRefusalTone, } from "./coupon.js";
53
55
  export { initialVariant, optionStates, pickOption, variantFor } from "./variant-picker.js";
54
56
  export type { OptionSelection, OptionState, PickerVariant } from "./variant-picker.js";
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * Internal modules stay reachable only through their intentional entry points.
11
11
  */
12
12
  export { formatMoney } from "./utils/money.js";
13
- export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaImageSrcset, mediaUrl, pictureOf, } from "./utils/image.js";
13
+ export { isMediaKey, mediaImageSrcset, mediaImageUrl, mediaUrl, pictureOf } from "./utils/image.js";
14
14
  export { productImageSource } from "./utils/product-image.js";
15
15
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, } from "./whatsapp.js";
16
16
  export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
@@ -36,6 +36,7 @@ export * from "./domain.js";
36
36
  export * from "./operations.js";
37
37
  export * from "./product-view.js";
38
38
  export { getOfferTierView, getQuantityTierViews } from "./offer-view.js";
39
+ export { countdownEnd, countdownTarget, timeLeft } from "./countdown.js";
39
40
  export { COUPON_HINT_REASONS, COUPON_NOT_APPLICABLE, COUPON_REFUSAL_REASONS, COUPON_URL_PARAM, couponFromUrl, couponRefusalOf, couponRefusalTone, } from "./coupon.js";
40
41
  export { initialVariant, optionStates, pickOption, variantFor } from "./variant-picker.js";
41
42
  export { ABANDONED_CHECKOUT_SESSION_KEY, convertAbandonedCheckout, createAbandonedCheckoutTracker, createBrowserAbandonedCheckoutTracker, } from "./abandoned-checkout.js";