@asstio-dev/storefront 0.6.0 → 0.9.2

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.
@@ -0,0 +1,68 @@
1
+ import { localProblem, retryAfterSeconds } from "./types.js";
2
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3
+ /** `POST /v1/checkout`: creates the cart's attempt or converges on the live one (spec §5.3). Call it from a Server Action or route handler, never during render. */
4
+ export async function startCheckout(client, cartToken, opts = {}) {
5
+ const { data, error, response } = await client.POST("/v1/checkout", {
6
+ body: { option: opts.option ?? null, returnOrigin: opts.returnOrigin ?? null },
7
+ headers: { "X-Cart-Token": cartToken },
8
+ });
9
+ const retryAfter = retryAfterSeconds(response);
10
+ if (error || !data)
11
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter };
12
+ return { ok: true, data, retryAfter };
13
+ }
14
+ /** The same `POST /v1/checkout`, retrying `CHECKOUT_IN_PROGRESS` per `Retry-After`, twice (spec §10.3) — or
15
+ * `retries` times; `{ retries: 0 }` is the single-attempt call to put behind `mountCheckout` *(plan review 3 #5)*.
16
+ * Never repeats a cart mutation: it only syncs. */
17
+ export async function syncCheckout(client, cartToken, opts = {}, retry = {}) {
18
+ const sleep = retry.sleep ?? defaultSleep;
19
+ const retries = retry.retries ?? 2; // *(plan review 3 #5)*
20
+ let result = await startCheckout(client, cartToken, opts);
21
+ for (let attempt = 0; attempt < retries && !result.ok && result.problem.code === "CHECKOUT_IN_PROGRESS"; attempt++) {
22
+ await sleep((result.retryAfter ?? 1) * 1000);
23
+ result = await startCheckout(client, cartToken, opts);
24
+ }
25
+ return result;
26
+ }
27
+ /** `POST /v1/checkout/{attemptId}/complete`: the confirmation page's call; with the cart token the answer carries the order's access token (spec §8.3). */
28
+ export async function completeCheckout(client, attemptId, cartToken) {
29
+ const { data, error, response } = await client.POST("/v1/checkout/{attemptId}/complete", {
30
+ params: { path: { attemptId } },
31
+ headers: cartToken ? { "X-Cart-Token": cartToken } : {},
32
+ });
33
+ if (error || !data)
34
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter: retryAfterSeconds(response) };
35
+ return { ok: true, data };
36
+ }
37
+ /** `GET /v1/checkout/{attemptId}`: the non-mutating status read the browser module polls with. */
38
+ export async function getCheckoutStatus(client, attemptId, cartToken) {
39
+ const { data, error, response } = await client.GET("/v1/checkout/{attemptId}", { params: { path: { attemptId } }, headers: { "X-Cart-Token": cartToken } });
40
+ if (error || !data)
41
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")), retryAfter: retryAfterSeconds(response) };
42
+ return { ok: true, data };
43
+ }
44
+ /** `GET /v1/orders/{orderId}` with `X-Order-Token` — a header only, never a query string (spec §8.3). */
45
+ export async function getOrder(client, orderId, token) {
46
+ const { data, error, response } = await client.GET("/v1/orders/{orderId}", { params: { path: { orderId } }, headers: { "X-Order-Token": token } });
47
+ if (error || !data)
48
+ return { ok: false, status: response.status, problem: (error ?? localProblem("SDK_EMPTY_ANSWER", "no body")) };
49
+ return { ok: true, data };
50
+ }
51
+ const FINAL = new Set(["completed", "pending", "declined", "expired"]);
52
+ /** Polls `complete` with doubling backoff until the attempt has an order or is closed, or the timeout passes (the last answer is returned either way). */
53
+ export async function awaitCompletion(complete, opts = {}) {
54
+ const first = opts.firstDelayMs ?? 1000;
55
+ const max = opts.maxDelayMs ?? 10_000;
56
+ const timeout = opts.timeoutMs ?? 120_000;
57
+ const sleep = opts.sleep ?? defaultSleep;
58
+ let delay = first;
59
+ let waited = 0;
60
+ let last = await complete();
61
+ while (last.ok && !FINAL.has(last.data.status) && waited + delay <= timeout) {
62
+ await sleep(delay);
63
+ waited += delay;
64
+ delay = Math.min(delay * 2, max);
65
+ last = await complete();
66
+ }
67
+ return last;
68
+ }
@@ -0,0 +1,42 @@
1
+ import type { components } from "../schema";
2
+ export type CheckoutDto = components["schemas"]["CheckoutDto"];
3
+ export type CheckoutStatusDto = components["schemas"]["CheckoutStatusDto"];
4
+ export type CheckoutOrderRefDto = components["schemas"]["CheckoutOrderRefDto"];
5
+ export type CheckoutWidgetDto = components["schemas"]["CheckoutWidgetDto"];
6
+ export type OrderDto = components["schemas"]["OrderDto"];
7
+ export type StartCheckoutRequest = components["schemas"]["StartCheckoutRequest"];
8
+ /** The problem body; `cart` is the corrected cart on 409 CHECKOUT_CART_CHANGED (an extension, so it is typed loosely here). */
9
+ export type StorefrontProblem = components["schemas"]["StorefrontProblem"] & {
10
+ cart?: unknown;
11
+ };
12
+ export type CheckoutResult = {
13
+ ok: true;
14
+ data: CheckoutDto;
15
+ retryAfter?: number;
16
+ } | {
17
+ ok: false;
18
+ status: number;
19
+ problem: StorefrontProblem;
20
+ retryAfter?: number;
21
+ };
22
+ export type StatusResult = {
23
+ ok: true;
24
+ data: CheckoutStatusDto;
25
+ } | {
26
+ ok: false;
27
+ status: number;
28
+ problem: StorefrontProblem;
29
+ retryAfter?: number;
30
+ };
31
+ export type OrderResult = {
32
+ ok: true;
33
+ data: OrderDto;
34
+ } | {
35
+ ok: false;
36
+ status: number;
37
+ problem: StorefrontProblem;
38
+ };
39
+ /** `Retry-After` in seconds: a delay, or an HTTP-date turned into one; garbage is 1 s, absence is undefined. */
40
+ export declare function retryAfterSeconds(response: Response, now?: () => number): number | undefined;
41
+ /** A problem shaped like the API's, for failures that never reached it (a thrown fetch, a timeout). */
42
+ export declare function localProblem(code: string, detail: string): StorefrontProblem;
@@ -0,0 +1,17 @@
1
+ /** `Retry-After` in seconds: a delay, or an HTTP-date turned into one; garbage is 1 s, absence is undefined. */
2
+ export function retryAfterSeconds(response, now = Date.now) {
3
+ const raw = response.headers.get("Retry-After");
4
+ if (raw === null || raw === "")
5
+ return undefined;
6
+ const seconds = Number(raw);
7
+ if (Number.isFinite(seconds))
8
+ return Math.max(0, Math.ceil(seconds));
9
+ const date = Date.parse(raw);
10
+ if (Number.isFinite(date))
11
+ return Math.max(0, Math.ceil((date - now()) / 1000));
12
+ return 1;
13
+ }
14
+ /** A problem shaped like the API's, for failures that never reached it (a thrown fetch, a timeout). */
15
+ export function localProblem(code, detail) {
16
+ return { type: "https://docs.asstio.com/storefront/errors#" + code, title: code, status: 0, detail, code };
17
+ }
package/dist/index.d.ts CHANGED
@@ -46,4 +46,8 @@ export type FilterSelection = NonNullable<NonNullable<paths["/v1/products"]["get
46
46
  */
47
47
  export declare function filterQuery(selection: Record<string, string | string[] | undefined>): FilterSelection;
48
48
  export { decodeFilterValues, encodeFilterValues } from "./filters.js";
49
- export { fileUrl, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, srcSet, videoSources, type MediaConfig, type MediaDescriptor, type PictureSources, type VideoSource, } from "./media.js";
49
+ export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, type FormatFit, type FormattedImage, type MediaConfig, type MediaDescriptor, type MediaFormat, type PictureSources, type SlotSize, type VideoSource, } from "./media.js";
50
+ export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, startCheckout, syncCheckout, type CheckoutClient, type StartCheckoutOptions, type SyncRetryOptions, type AwaitCompletionOptions } from "./checkout/server.js";
51
+ export { retryAfterSeconds, type CheckoutDto, type CheckoutOrderRefDto, type CheckoutResult, type CheckoutStatusDto, type CheckoutWidgetDto, type OrderDto, type OrderResult, type StatusResult, type StorefrontProblem } from "./checkout/types.js";
52
+ export { mountCheckout, type CheckoutHandle, type MountOptions, type MinimalDocument, type MinimalElement, type MinimalScript } from "./checkout/browser.js";
53
+ export { controllers, type CheckoutController, type ProviderWindow } from "./checkout/controllers.js";
package/dist/index.js CHANGED
@@ -35,4 +35,8 @@ export function filterQuery(selection) {
35
35
  return out;
36
36
  }
37
37
  export { decodeFilterValues, encodeFilterValues } from "./filters.js";
38
- export { fileUrl, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, srcSet, videoSources, } from "./media.js";
38
+ export { coverOffset, fileUrl, fillFactor, formatFactor, formatImage, imageRect, mediaHash, mediaUrl, MediaUrlError, pickWidth, pictureSources, posterUrl, scaledSizes, srcSet, styleToCss, videoSources, } from "./media.js";
39
+ export { awaitCompletion, completeCheckout, getCheckoutStatus, getOrder, startCheckout, syncCheckout } from "./checkout/server.js"; // SyncRetryOptions *(plan review 3 #5)*
40
+ export { retryAfterSeconds } from "./checkout/types.js";
41
+ export { mountCheckout } from "./checkout/browser.js";
42
+ export { controllers } from "./checkout/controllers.js";
package/dist/media.d.ts CHANGED
@@ -49,3 +49,83 @@ export declare function posterUrl(site: MediaConfig, d: MediaDescriptor, width:
49
49
  export declare function fileUrl(site: MediaConfig, d: MediaDescriptor): string;
50
50
  /** Every encoded rendition of a video, for `<source>` elements. Empty for anything that is not a video. */
51
51
  export declare function videoSources(site: MediaConfig, d: MediaDescriptor): VideoSource[];
52
+ export type FormatFit = "contain" | "cover" | "natural";
53
+ /** One slot size of the frontend's layout (F6): `{ media: "(min-width: 1080px)", size: "25vw" }`; the last entry
54
+ * has no media condition. The SDK turns these into the `sizes` attribute. */
55
+ export type SlotSize = {
56
+ media?: string;
57
+ size: string;
58
+ };
59
+ /**
60
+ * The image's drawn width as a fraction of the slot (spec §6 rule 4). `rBox` = format W/H, `rImg` = image w/h,
61
+ * `padding` a fraction of the box's width (contain only).
62
+ */
63
+ export declare function fillFactor(fit: FormatFit, rBox: number, rImg: number, padding: number): number;
64
+ /**
65
+ * Where a cover image sits (spec §3.3): the fractions `ox`/`oy` of the overflow placed before the box, so the
66
+ * focal point is as centred as possible without showing an edge. Visible fraction `v = min(rBox/rImg, rImg/rBox)`;
67
+ * along the overflowing axis `clamp((f − v/2) / (1 − v), 0, 1)`, the other axis 0.5. Unset focal ⇒ centre.
68
+ */
69
+ export declare function coverOffset(focal: {
70
+ x: number | string;
71
+ y: number | string;
72
+ } | null | undefined, rBox: number, rImg: number): {
73
+ ox: number;
74
+ oy: number;
75
+ };
76
+ /**
77
+ * The rectangle the image occupies, in percent of the box (spec §6 rule 3): `width = fill·100`,
78
+ * `height = fill·rBox/rImg·100`; contain centred (the padding inset is inside `fill`), cover placed by
79
+ * `coverOffset` (negative offsets; the box clips), natural the whole box (its box has the image's aspect).
80
+ * The `<img>` and the placeholder both use it.
81
+ */
82
+ export declare function imageRect(fit: FormatFit, rBox: number, rImg: number, fill: number, offset: {
83
+ ox: number;
84
+ oy: number;
85
+ }): {
86
+ left: number;
87
+ top: number;
88
+ width: number;
89
+ height: number;
90
+ };
91
+ /** `fill` with 4 significant digits: a positive factor never rounds to 0 (review 2 #12). */
92
+ export declare function formatFactor(fill: number): string;
93
+ /** The `sizes` attribute: every slot's size scaled by `fill` with `calc()`, unchanged when `fill` is 1 (F10). */
94
+ export declare function scaledSizes(slots: SlotSize[], fill: number): string;
95
+ export type MediaFormat = components["schemas"]["MediaFormatDto"];
96
+ export type FormattedImage = {
97
+ /** The format key actually used, after fallback ("default" when the site has no formats). */
98
+ format: string;
99
+ /** The effective fit, after `showWhole`. */
100
+ fit: FormatFit;
101
+ /** The aspect frame's style (camelCase CSS): the slot's only child in every fit. For natural it takes the
102
+ * image's own aspect and has no colour. */
103
+ box: Record<string, string>;
104
+ /** The `<img>` style: the image's rectangle in the box. */
105
+ img: Record<string, string>;
106
+ /** The placeholder layer's style (e.g. a blurhash canvas): the same rectangle as `img`. */
107
+ placeholder: Record<string, string>;
108
+ sources: {
109
+ type: string;
110
+ srcSet: string;
111
+ sizes: string;
112
+ }[];
113
+ fallback: {
114
+ src: string;
115
+ srcSet: string;
116
+ sizes: string;
117
+ width: number;
118
+ height: number;
119
+ };
120
+ };
121
+ /**
122
+ * A complete, standards-conforming `<picture>` for one descriptor in one of the site's formats (spec 2026-09-23 §6).
123
+ * Render: your **slot** element (your className, may be a flex/grid item) → `box` (the slot's only child, in every
124
+ * fit) → the placeholder layer and `<picture>` with `sources` and an `<img>` carrying `fallback` and `img`.
125
+ * `slots` describes your layout's widths for the slot (F6); the SDK scales them into `sizes`.
126
+ */
127
+ export declare function formatImage(site: MediaConfig, d: MediaDescriptor, formatKey: string, slots: SlotSize[]): FormattedImage;
128
+ /** A camelCase style object as a `style` attribute value, for plain HTML and templating engines. Meant for
129
+ * `formatImage` output, whose values are SDK-generated or a server-validated `#rrggbb`: it does not escape, so
130
+ * do not pass it values of your own that could hold `"`, `;` or `<`. */
131
+ export declare function styleToCss(style: Record<string, string>): string;
package/dist/media.js CHANGED
@@ -112,3 +112,145 @@ export function videoSources(site, d) {
112
112
  name: v.name,
113
113
  }));
114
114
  }
115
+ /**
116
+ * The image's drawn width as a fraction of the slot (spec §6 rule 4). `rBox` = format W/H, `rImg` = image w/h,
117
+ * `padding` a fraction of the box's width (contain only).
118
+ */
119
+ export function fillFactor(fit, rBox, rImg, padding) {
120
+ if (fit === "natural")
121
+ return 1;
122
+ if (fit === "cover")
123
+ return Math.max(1, rImg / rBox);
124
+ return Math.min(1 - 2 * padding, (1 / rBox - 2 * padding) * rImg);
125
+ }
126
+ /**
127
+ * Where a cover image sits (spec §3.3): the fractions `ox`/`oy` of the overflow placed before the box, so the
128
+ * focal point is as centred as possible without showing an edge. Visible fraction `v = min(rBox/rImg, rImg/rBox)`;
129
+ * along the overflowing axis `clamp((f − v/2) / (1 − v), 0, 1)`, the other axis 0.5. Unset focal ⇒ centre.
130
+ */
131
+ export function coverOffset(focal, rBox, rImg) {
132
+ const v = Math.min(rBox / rImg, rImg / rBox);
133
+ if (v >= 1)
134
+ return { ox: 0.5, oy: 0.5 };
135
+ const pos = (f) => Math.min(1, Math.max(0, (f - v / 2) / (1 - v)));
136
+ const fx = focal == null ? 0.5 : toNumber(focal.x);
137
+ const fy = focal == null ? 0.5 : toNumber(focal.y);
138
+ return rImg > rBox ? { ox: pos(fx), oy: 0.5 } : { ox: 0.5, oy: pos(fy) };
139
+ }
140
+ /**
141
+ * The rectangle the image occupies, in percent of the box (spec §6 rule 3): `width = fill·100`,
142
+ * `height = fill·rBox/rImg·100`; contain centred (the padding inset is inside `fill`), cover placed by
143
+ * `coverOffset` (negative offsets; the box clips), natural the whole box (its box has the image's aspect).
144
+ * The `<img>` and the placeholder both use it.
145
+ */
146
+ export function imageRect(fit, rBox, rImg, fill, offset) {
147
+ if (fit === "natural")
148
+ return { left: 0, top: 0, width: 100, height: 100 };
149
+ const width = fill * 100;
150
+ const height = ((fill * rBox) / rImg) * 100;
151
+ if (fit === "contain")
152
+ return { left: (100 - width) / 2, top: (100 - height) / 2, width, height };
153
+ return { left: offset.ox * (100 - width), top: offset.oy * (100 - height), width, height };
154
+ }
155
+ /** `fill` with 4 significant digits: a positive factor never rounds to 0 (review 2 #12). */
156
+ export function formatFactor(fill) {
157
+ return String(Number(fill.toPrecision(4)));
158
+ }
159
+ /** The `sizes` attribute: every slot's size scaled by `fill` with `calc()`, unchanged when `fill` is 1 (F10). */
160
+ export function scaledSizes(slots, fill) {
161
+ if (slots.length === 0)
162
+ throw new MediaUrlError("formatImage: slots must not be empty");
163
+ return slots
164
+ .map((s) => {
165
+ const size = fill === 1 ? s.size : `calc(${s.size} * ${formatFactor(fill)})`;
166
+ return s.media ? `${s.media} ${size}` : size;
167
+ })
168
+ .join(", ");
169
+ }
170
+ const NATURAL = { aspect: null, fit: "natural", padding: 0, background: null };
171
+ const pct = (v) => `${Number(v.toFixed(4))}%`;
172
+ /** Rule 1: `formats[key] ?? formats.default ?? natural`. Own-property lookups: a key like "constructor" must fall
173
+ * through to `default`/natural, never resolve on `Object.prototype`. */
174
+ function resolveFormat(site, key) {
175
+ const formats = site.formats ?? null;
176
+ if (formats && Object.hasOwn(formats, key))
177
+ return { key, format: formats[key] };
178
+ if (formats && Object.hasOwn(formats, "default"))
179
+ return { key: "default", format: formats.default };
180
+ return { key: "default", format: NATURAL };
181
+ }
182
+ function parseAspect(aspect, key) {
183
+ const m = aspect == null ? null : /^(\d{1,3})\/(\d{1,3})$/.exec(aspect);
184
+ if (!m)
185
+ throw new MediaUrlError(`formatImage: format '${key}' aspect '${aspect}' is not "W/H"`);
186
+ return { w: Number(m[1]), h: Number(m[2]) };
187
+ }
188
+ /**
189
+ * A complete, standards-conforming `<picture>` for one descriptor in one of the site's formats (spec 2026-09-23 §6).
190
+ * Render: your **slot** element (your className, may be a flex/grid item) → `box` (the slot's only child, in every
191
+ * fit) → the placeholder layer and `<picture>` with `sources` and an `<img>` carrying `fallback` and `img`.
192
+ * `slots` describes your layout's widths for the slot (F6); the SDK scales them into `sizes`.
193
+ */
194
+ export function formatImage(site, d, formatKey, slots) {
195
+ if (d.kind !== "image")
196
+ throw new MediaUrlError(`formatImage: kind '${d.kind}' is not an image`);
197
+ requireImage(d, "formatImage");
198
+ if (d.w == null || d.h == null)
199
+ throw new MediaUrlError(`formatImage: descriptor ${d.id} has no w/h`);
200
+ const w = toNumber(d.w);
201
+ const h = toNumber(d.h);
202
+ const { key, format } = resolveFormat(site, formatKey);
203
+ const rawFit = format.fit;
204
+ // An unknown fit falls back to natural (spec §8), so `FormattedImage.fit` is always a real FormatFit.
205
+ let fit = rawFit === "contain" || rawFit === "cover" ? rawFit : "natural";
206
+ if (fit === "cover" && d.showWhole)
207
+ fit = "contain";
208
+ // Natural gets a box too (spec §6 rule 3): the descriptor's own aspect, no colour, the image fills it.
209
+ const a = fit === "natural" ? { w, h } : parseAspect(format.aspect, key);
210
+ const rBox = a.w / a.h;
211
+ const rImg = w / h;
212
+ const padding = fit === "contain" ? toNumber(format.padding) : 0;
213
+ const fill = fillFactor(fit, rBox, rImg, padding);
214
+ const rect = imageRect(fit, rBox, rImg, fill, coverOffset(d.focal, rBox, rImg));
215
+ const bgClass = d.bg ?? "default";
216
+ const bg = fit !== "natural" && format.background
217
+ ? Object.hasOwn(format.background, bgClass)
218
+ ? format.background[bgClass]
219
+ : Object.hasOwn(format.background, "default")
220
+ ? format.background.default
221
+ : null
222
+ : null;
223
+ const box = {
224
+ display: "block", position: "relative", overflow: "hidden", width: "100%", height: "auto", aspectRatio: `${a.w} / ${a.h}`,
225
+ minHeight: "0", maxHeight: "none", padding: "0", border: "0", boxSizing: "content-box",
226
+ alignSelf: "flex-start", justifySelf: "stretch", flex: "none",
227
+ ...(bg ? { backgroundColor: bg } : {}),
228
+ };
229
+ const layer = {
230
+ position: "absolute", left: pct(rect.left), top: pct(rect.top), width: pct(rect.width), height: pct(rect.height),
231
+ display: "block", objectFit: "fill",
232
+ // A global `img { max-width: 100%; height: auto }` reset must not shrink a cover rectangle wider than the box.
233
+ maxWidth: "none", maxHeight: "none", minWidth: "0", minHeight: "0", margin: "0", padding: "0", border: "0",
234
+ };
235
+ const sizes = scaledSizes(slots, fill);
236
+ // Same rendition logic as pictureSources (sources except the last format, middle-width fallback src): delegate
237
+ // rather than duplicate it; formatImage keeps the real w/h it already validated for the fallback dimensions.
238
+ const p = pictureSources(site, d, sizes);
239
+ return {
240
+ format: key,
241
+ fit,
242
+ box,
243
+ img: layer,
244
+ placeholder: { ...layer },
245
+ sources: p.sources,
246
+ fallback: { src: p.img.src, srcSet: p.img.srcSet, sizes, width: w, height: h },
247
+ };
248
+ }
249
+ /** A camelCase style object as a `style` attribute value, for plain HTML and templating engines. Meant for
250
+ * `formatImage` output, whose values are SDK-generated or a server-validated `#rrggbb`: it does not escape, so
251
+ * do not pass it values of your own that could hold `"`, `;` or `<`. */
252
+ export function styleToCss(style) {
253
+ return Object.entries(style)
254
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase())}: ${v}`)
255
+ .join("; ");
256
+ }