@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 +59 -0
- package/README.md +22 -117
- package/USAGE.md +59 -71
- package/dist/client/pages.d.ts +10 -4
- package/dist/client/pages.js +7 -3
- package/dist/countdown.d.ts +28 -0
- package/dist/countdown.js +55 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +89 -35
- package/dist/schemas.js +18 -12
- package/dist/seo.js +1 -1
- package/dist/types.d.ts +13 -7
- package/dist/utils/image.d.ts +50 -169
- package/dist/utils/image.js +72 -165
- package/dist/utils/product-image.d.ts +6 -6
- package/dist/utils/product-image.js +7 -7
- package/package.json +1 -1
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
39
|
+
| Import | Runs in | Holds |
|
|
112
40
|
| --- | --- | --- |
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
218
|
-
| `mediaImageUrl` |
|
|
219
|
-
| `
|
|
220
|
-
| `
|
|
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,
|
|
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
|
|
1838
|
-
const
|
|
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
|
-
|
|
1851
|
-
|
|
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, {
|
|
1857
|
-
|
|
1858
|
-
|
|
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" },
|
|
1863
|
-
srcset={mediaImageSrcset(src, [360, 540, 720, 1080], {
|
|
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
|
|
1869
|
-
text, size and focal point. `pictureOf(image,
|
|
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,
|
|
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 {
|
|
1911
|
+
import { mediaImageUrl, productImageSource } from "@wocart/storefront";
|
|
1890
1912
|
|
|
1891
1913
|
const src = productImageSource(product); // Product | ProductList
|
|
1892
|
-
const thumbnail = src ?
|
|
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
|
package/dist/client/pages.d.ts
CHANGED
|
@@ -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,
|
|
26
|
-
* shape
|
|
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
|
|
32
|
+
landing(slug: string, options?: {
|
|
33
|
+
preview?: boolean;
|
|
34
|
+
}): Promise<LandingPage | null>;
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
37
|
* Create pages namespace
|
package/dist/client/pages.js
CHANGED
|
@@ -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)}`,
|
|
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 {
|
|
15
|
-
export type {
|
|
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 {
|
|
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";
|