@wocart/storefront 2.0.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,24 @@ 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
+
8
26
  ## 2.0.0 — 2026-10-30
9
27
 
10
28
  A store's images can live on any media host. The server answers every image
package/USAGE.md CHANGED
@@ -998,7 +998,19 @@ const landing = await store.pages.landing("summer-sale");
998
998
 
999
999
  A landing page can contain its own name, SEO metadata, image gap, images,
1000
1000
  product, publishedAt, and tracking information. The product may be null.
1001
- 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
+ ~~~
1002
1014
 
1003
1015
  Landing tracking is already resolved by the server:
1004
1016
 
@@ -1010,6 +1022,21 @@ if (landing?.tracking.pixelId) {
1010
1022
 
1011
1023
  Do not invent tracking events from a landing-page slug.
1012
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
+
1013
1040
  ### The saved page document (theme composition)
1014
1041
 
1015
1042
  ~~~ts
@@ -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
@@ -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
@@ -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";
package/dist/schemas.d.ts CHANGED
@@ -746,6 +746,11 @@ export declare const LandingPageSchema: z.ZodObject<{
746
746
  rewardUnitPrice: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
747
747
  }, z.core.$strip>>;
748
748
  }, z.core.$strip>>;
749
+ document: z.ZodDefault<z.ZodCatch<z.ZodNullable<z.ZodObject<{
750
+ version: z.ZodNumber;
751
+ surface: z.ZodString;
752
+ sections: z.ZodArray<z.ZodUnknown>;
753
+ }, z.core.$loose>>>>;
749
754
  tracking: z.ZodObject<{
750
755
  pixelId: z.ZodNullable<z.ZodString>;
751
756
  conversionEvent: z.ZodEnum<{
package/dist/schemas.js CHANGED
@@ -341,6 +341,7 @@ export const LandingPageSchema = z.object({
341
341
  publishedAt: z.string().nullable(),
342
342
  images: z.array(LandingPageImageSchema),
343
343
  product: ProductSchema.nullable(),
344
+ document: PageDocumentPayloadSchema.nullable().catch(null).default(null),
344
345
  tracking: z.object({
345
346
  pixelId: z.string().nullable(),
346
347
  conversionEvent: z.enum([
package/dist/types.d.ts CHANGED
@@ -296,6 +296,12 @@ export interface LandingPage {
296
296
  pixelId: string | null;
297
297
  conversionEvent: "Lead" | "Purchase" | "Purchase_Confirmed" | "Purchase_Delivered";
298
298
  };
299
+ /**
300
+ * 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.
303
+ */
304
+ document: PageDocumentPayload | null;
299
305
  }
300
306
  export interface Wilaya {
301
307
  id: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "2.0.0",
3
+ "version": "2.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",