@skuio/storefront 0.6.1 → 0.7.1

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @skuio/storefront
2
2
 
3
- Typed catalogue, checkout and order primitives for storefronts on the SKU.io
3
+ Typed catalog, checkout and order primitives for storefronts on the SKU.io
4
4
  storefront kit. Framework-agnostic: takes and returns plain data.
5
5
 
6
6
  ```ts
@@ -17,7 +17,7 @@ import { createStripeCheckout, parseStripeWebhook } from '@skuio/storefront/stri
17
17
  Storefront API: live availability, checkout created by SKU.io on the
18
18
  tenant\'s processor, orders born in SKU.io).
19
19
  - Stripe helpers behind a separate entry point so non-Stripe stores never
20
- bundle it. Prices are only ever read from the catalogue on the server.
20
+ bundle it. Prices are only ever read from the catalog on the server.
21
21
  - Cart functions — pure, browser-agnostic, never trusted for money.
22
22
 
23
23
  Start a store with `npx @skuio/cli create my-store`. Kit and docs:
@@ -0,0 +1,59 @@
1
+ -- Quotes (request for quote): a buyer — signed in or a guest who never makes an account — asks for a price on a
2
+ -- list of products; the operations system prices it through the Store API; the buyer accepts it, which places an
3
+ -- order at exactly the quoted prices, or declines it. Money is integer minor units; the priced columns stay NULL
4
+ -- until an offer exists.
5
+ CREATE TABLE quotes (
6
+ id TEXT PRIMARY KEY,
7
+ number INTEGER NOT NULL UNIQUE,
8
+ status TEXT NOT NULL, -- requested | quoted | accepted | declined | expired | cancelled
9
+ customer_id TEXT NOT NULL REFERENCES customers (id),
10
+ email TEXT NOT NULL,
11
+ company TEXT NULL,
12
+ message TEXT NULL,
13
+ shipping_address_json TEXT NULL,
14
+ currency TEXT NOT NULL,
15
+ subtotal INTEGER NULL,
16
+ shipping INTEGER NULL,
17
+ tax INTEGER NULL,
18
+ total INTEGER NULL,
19
+ offer_note TEXT NULL,
20
+ expires_at TEXT NULL,
21
+ external_reference TEXT NULL,
22
+ order_id TEXT NULL REFERENCES orders (id),
23
+ decline_reason TEXT NULL,
24
+ metadata_json TEXT NOT NULL DEFAULT '{}',
25
+ access_key TEXT NOT NULL,
26
+ created_at TEXT NOT NULL,
27
+ updated_at TEXT NOT NULL,
28
+ quoted_at TEXT NULL,
29
+ accepted_at TEXT NULL,
30
+ declined_at TEXT NULL,
31
+ cancelled_at TEXT NULL
32
+ );
33
+ CREATE INDEX quotes_updated ON quotes (updated_at, id);
34
+ CREATE INDEX quotes_customer ON quotes (customer_id, created_at);
35
+ CREATE INDEX quotes_email ON quotes (email, created_at);
36
+ CREATE INDEX quotes_status ON quotes (status, expires_at);
37
+
38
+ CREATE TABLE quote_lines (
39
+ id TEXT PRIMARY KEY,
40
+ quote_id TEXT NOT NULL REFERENCES quotes (id) ON DELETE CASCADE,
41
+ sku TEXT NOT NULL,
42
+ name TEXT NOT NULL,
43
+ quantity INTEGER NOT NULL,
44
+ list_amount INTEGER NULL, -- the catalog price when the buyer asked; for reference only
45
+ unit_amount INTEGER NULL, -- the offered price; NULL until quoted
46
+ total_amount INTEGER NULL,
47
+ image_url TEXT NULL,
48
+ position INTEGER NOT NULL DEFAULT 0
49
+ );
50
+ CREATE INDEX quote_lines_quote ON quote_lines (quote_id, position);
51
+
52
+ CREATE TABLE quote_events (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ quote_id TEXT NOT NULL REFERENCES quotes (id) ON DELETE CASCADE,
55
+ type TEXT NOT NULL,
56
+ data_json TEXT NULL,
57
+ created_at TEXT NOT NULL
58
+ );
59
+ CREATE INDEX quote_events_quote ON quote_events (quote_id, id);
package/dist/api.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CategoryRecord, ProductRecord, Connection, Store } from './store.js';
2
- import { O as Order, F as Fulfillment } from './config-C4YCi7m4.js';
2
+ import { O as Order, Q as QuoteStatus, F as Fulfillment, S as StoreQuote } from './config-CCxTka_z.js';
3
3
  import './driver-Cr4l6GgG.js';
4
4
  import './types-CWreCyxc.js';
5
5
 
@@ -79,6 +79,48 @@ interface ApiOrder {
79
79
  paid_at: string | null;
80
80
  cancelled_at: string | null;
81
81
  }
82
+ interface ApiQuote {
83
+ id: string;
84
+ /** With the store's quote prefix: "Q1001". */
85
+ number: string;
86
+ status: QuoteStatus;
87
+ currency: string;
88
+ customer: {
89
+ id: string;
90
+ email: string;
91
+ name: string | null;
92
+ phone: string | null;
93
+ };
94
+ company: string | null;
95
+ message: string | null;
96
+ shipping_address: ApiAddress | null;
97
+ lines: {
98
+ id: string;
99
+ sku: string;
100
+ name: string;
101
+ quantity: number;
102
+ list_amount: ApiMoney | null;
103
+ unit_amount: ApiMoney | null;
104
+ total_amount: ApiMoney | null;
105
+ }[];
106
+ subtotal: ApiMoney | null;
107
+ shipping: ApiMoney | null;
108
+ tax: ApiMoney | null;
109
+ total: ApiMoney | null;
110
+ note: string | null;
111
+ expires_at: string | null;
112
+ external_reference: string | null;
113
+ /** The order the buyer's acceptance placed; it also arrives through GET /orders with `metadata.quote_id`. */
114
+ order_id: string | null;
115
+ decline_reason: string | null;
116
+ metadata: Record<string, string>;
117
+ created_at: string;
118
+ updated_at: string;
119
+ quoted_at: string | null;
120
+ accepted_at: string | null;
121
+ declined_at: string | null;
122
+ cancelled_at: string | null;
123
+ }
82
124
  interface ApiProduct {
83
125
  sku: string;
84
126
  name: string;
@@ -126,8 +168,31 @@ declare function serializeFulfillment(f: Fulfillment): ApiFulfillment;
126
168
  * sees, with the store's prefix (VLD4200), so the consumer shows the same number.
127
169
  */
128
170
  declare function serializeOrder(o: Order, numberPrefix?: string): ApiOrder;
129
- /** The Store API's view of a catalogue product — the stored row, not the shopper projection. */
130
- declare function serializeProduct(p: ProductRecord): ApiProduct;
171
+ /** The Store API's view of a quote. `access_key` never leaves the store. */
172
+ declare function serializeQuote(q: StoreQuote, numberPrefix?: string): ApiQuote;
173
+ /**
174
+ * Maps a stored image src to the public URL a consumer can load. Return null
175
+ * (or undefined) to fall back to the default: an absolute URL as-is, anything
176
+ * else resolved against the request origin.
177
+ */
178
+ type ImageUrlResolver = (src: string) => string | null | undefined;
179
+ interface SerializeProductOptions {
180
+ /** The store's own image resolver; see StoreApiOptions.imageUrl. */
181
+ imageUrl?: ImageUrlResolver;
182
+ /** The origin relative srcs resolve against when the resolver has no answer ("https://store.example"). */
183
+ origin?: string;
184
+ }
185
+ /**
186
+ * The absolute URL the Store API reports for an image. The store's resolver
187
+ * wins; without one (or when it returns null) an absolute URL passes through
188
+ * unchanged and a relative path or bare filename is resolved against the
189
+ * request origin. Resolving a bare filename only produces a loadable URL when
190
+ * the store serves the file at that path — stores that fingerprint their
191
+ * assets must pass a resolver.
192
+ */
193
+ declare function absoluteImageUrl(src: string, options?: SerializeProductOptions): string;
194
+ /** The Store API's view of a catalog product — the stored row, not the shopper projection. */
195
+ declare function serializeProduct(p: ProductRecord, options?: SerializeProductOptions): ApiProduct;
131
196
  declare function serializeCategory(c: CategoryRecord): ApiCategory;
132
197
 
133
198
  declare class ValidationError extends Error {
@@ -145,6 +210,15 @@ interface StoreApiOptions {
145
210
  apiKey: string | undefined;
146
211
  /** Where the API is mounted in the request URL. */
147
212
  basePath?: string;
213
+ /**
214
+ * Maps a stored product image src (a bare filename, a path, or an absolute
215
+ * URL) to the absolute URL the store actually serves it at — e.g. the
216
+ * fingerprinted `/_astro/<name>.<hash>.jpg` a template's `resolveImage()`
217
+ * finds, made absolute against the site URL. Return null to fall back to the
218
+ * default: an absolute src as-is, a relative one resolved against the
219
+ * request origin. `images[].url` in every response is always absolute.
220
+ */
221
+ imageUrl?: ImageUrlResolver;
148
222
  }
149
223
  /** Who a request authenticated as: the environment key, or an approved connection. */
150
224
  type ApiCaller = {
@@ -171,7 +245,7 @@ declare function serializeConnection(c: Connection): {
171
245
  id: string;
172
246
  name: string;
173
247
  tenant: string | null;
174
- scopes: ("orders" | "payments" | "fulfillments" | "inventory" | "catalog")[];
248
+ scopes: ("orders" | "payments" | "fulfillments" | "inventory" | "catalog" | "quotes")[];
175
249
  key_prefix: string | null;
176
250
  created_at: string;
177
251
  issued_at: string | null;
@@ -179,4 +253,4 @@ declare function serializeConnection(c: Connection): {
179
253
  revoked_at: string | null;
180
254
  };
181
255
 
182
- export { type ApiCaller, type ApiCategory, type ApiFulfillment, type ApiOrder, type ApiProduct, STORE_API_BASE, STORE_API_BASES, type StoreApiOptions, ValidationError, handleStoreApi, serializeCategory, serializeConnection, serializeFulfillment, serializeOrder, serializeProduct };
256
+ export { type ApiCaller, type ApiCategory, type ApiFulfillment, type ApiOrder, type ApiProduct, type ApiQuote, type ImageUrlResolver, STORE_API_BASE, STORE_API_BASES, type SerializeProductOptions, type StoreApiOptions, ValidationError, absoluteImageUrl, handleStoreApi, serializeCategory, serializeConnection, serializeFulfillment, serializeOrder, serializeProduct, serializeQuote };
package/dist/api.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  API_KEY_PREFIX,
3
3
  StoreError,
4
4
  secretsEqual
5
- } from "./chunk-X7VXO4QU.js";
5
+ } from "./chunk-RUUUZ2EL.js";
6
6
 
7
7
  // src/api/serialize.ts
8
8
  var money = (m) => ({ amount: m.amount, currency: m.currency.toUpperCase() });
@@ -64,7 +64,55 @@ function serializeOrder(o, numberPrefix = "") {
64
64
  cancelled_at: o.cancelledAt
65
65
  };
66
66
  }
67
- function serializeProduct(p) {
67
+ var maybeMoney = (m) => m ? money(m) : null;
68
+ function serializeQuote(q, numberPrefix = "") {
69
+ return {
70
+ id: q.id,
71
+ number: `${numberPrefix}${q.number}`,
72
+ status: q.status,
73
+ currency: q.currency.toUpperCase(),
74
+ customer: { id: q.customer.id, email: q.customer.email, name: q.customer.name, phone: q.customer.phone },
75
+ company: q.company,
76
+ message: q.message,
77
+ shipping_address: address(q.shippingAddress),
78
+ lines: q.lines.map((l) => ({
79
+ id: l.id,
80
+ sku: l.sku,
81
+ name: l.name,
82
+ quantity: l.quantity,
83
+ list_amount: maybeMoney(l.listAmount),
84
+ unit_amount: maybeMoney(l.unitAmount),
85
+ total_amount: maybeMoney(l.totalAmount)
86
+ })),
87
+ subtotal: maybeMoney(q.subtotal),
88
+ shipping: maybeMoney(q.shipping),
89
+ tax: maybeMoney(q.tax),
90
+ total: maybeMoney(q.total),
91
+ note: q.offerNote,
92
+ expires_at: q.expiresAt,
93
+ external_reference: q.externalReference,
94
+ order_id: q.orderId,
95
+ decline_reason: q.declineReason,
96
+ metadata: q.metadata,
97
+ created_at: q.createdAt,
98
+ updated_at: q.updatedAt,
99
+ quoted_at: q.quotedAt,
100
+ accepted_at: q.acceptedAt,
101
+ declined_at: q.declinedAt,
102
+ cancelled_at: q.cancelledAt
103
+ };
104
+ }
105
+ var ABSOLUTE = /^[a-z][a-z0-9+.-]*:/i;
106
+ function absoluteImageUrl(src, options = {}) {
107
+ const url = options.imageUrl?.(src) || src;
108
+ if (ABSOLUTE.test(url) || !options.origin) return url;
109
+ try {
110
+ return new URL(url, `${options.origin.replace(/\/+$/, "")}/`).href;
111
+ } catch {
112
+ return url;
113
+ }
114
+ }
115
+ function serializeProduct(p, options = {}) {
68
116
  return {
69
117
  sku: p.sku,
70
118
  name: p.name,
@@ -78,7 +126,7 @@ function serializeProduct(p) {
78
126
  position: p.position,
79
127
  legacy_path: p.legacyPath,
80
128
  categories: p.categories.map((c) => ({ id: c.id, slug: c.slug, name: c.name })),
81
- images: p.images.map((i) => ({ url: i.url, alt: i.alt, position: i.position })),
129
+ images: p.images.map((i) => ({ url: absoluteImageUrl(i.url, options), alt: i.alt, position: i.position })),
82
130
  inventory: p.inventory ? { available: p.inventory.available, allow_backorder: p.inventory.allowBackorder, updated_at: p.inventory.updatedAt } : null,
83
131
  created_at: p.createdAt,
84
132
  updated_at: p.updatedAt
@@ -370,6 +418,55 @@ function validateOrderPatch(body) {
370
418
  finish(details, "Invalid order patch");
371
419
  return out;
372
420
  }
421
+ function validateQuotePatch(body) {
422
+ const details = {};
423
+ const out = {};
424
+ if ("external_reference" in body) {
425
+ const ref = str(details, body, "external_reference", "external_reference", { nullable: true, max: 64 });
426
+ if (ref !== void 0) out.external_reference = ref;
427
+ }
428
+ if (body.status !== void 0) {
429
+ if (body.status !== "cancelled") details.status = 'can only be set to "cancelled"';
430
+ else out.status = "cancelled";
431
+ }
432
+ if (body.offer !== void 0) {
433
+ if (!isObject(body.offer)) details.offer = "must be an object";
434
+ else {
435
+ const o = body.offer;
436
+ const lines = [];
437
+ if (!Array.isArray(o.lines) || o.lines.length === 0) details["offer.lines"] = "must be a non-empty array";
438
+ else
439
+ o.lines.forEach((raw, i) => {
440
+ if (!isObject(raw)) {
441
+ details[`offer.lines.${i}`] = "must be an object";
442
+ return;
443
+ }
444
+ const sku = str(details, raw, "sku", `offer.lines.${i}.sku`, { required: true, max: 100 });
445
+ const name = str(details, raw, "name", `offer.lines.${i}.name`, { nullable: true, max: 300 });
446
+ if (!isInt(raw.quantity) || raw.quantity < 1) details[`offer.lines.${i}.quantity`] = "must be an integer of at least 1";
447
+ const unit = moneyField(details, raw.unit_amount, `offer.lines.${i}.unit_amount`, { required: true });
448
+ if (sku && isInt(raw.quantity) && raw.quantity >= 1 && unit) lines.push({ sku: sku.trim(), name: name ?? null, quantity: raw.quantity, unit_amount: unit });
449
+ });
450
+ const shipping = moneyField(details, o.shipping, "offer.shipping", { nullable: true });
451
+ const tax = moneyField(details, o.tax, "offer.tax", { nullable: true });
452
+ const expiresAt = isoDate(details, o.expires_at, "offer.expires_at");
453
+ const note = str(details, o, "note", "offer.note", { nullable: true, max: 4e3 });
454
+ if (o.notify_customer !== void 0 && typeof o.notify_customer !== "boolean") details["offer.notify_customer"] = "must be a boolean";
455
+ out.offer = {
456
+ lines,
457
+ shipping: shipping ?? null,
458
+ tax: tax ?? null,
459
+ expires_at: expiresAt ?? null,
460
+ note: note ?? null,
461
+ notify_customer: o.notify_customer === void 0 ? true : o.notify_customer
462
+ };
463
+ }
464
+ }
465
+ if (out.status && out.offer) details.status = "cannot be combined with an offer";
466
+ if (!("external_reference" in body) && body.status === void 0 && body.offer === void 0) details.body = "must include external_reference, status or offer";
467
+ finish(details, "Invalid quote patch");
468
+ return out;
469
+ }
373
470
 
374
471
  // src/api/index.ts
375
472
  var STORE_API_BASES = ["/api/store/v1", "/api/skuio/v1"];
@@ -395,7 +492,8 @@ async function handleStoreApi(request, options) {
395
492
  if (scope && caller.kind === "connection" && !caller.connection.scopes.includes(scope)) {
396
493
  return error(403, "forbidden", `This connection was not approved for "${scope}"; reconnect and approve it at /admin/connect`);
397
494
  }
398
- return await route(method, path, url, request, options.store, caller);
495
+ const images = { imageUrl: options.imageUrl, origin: url.origin };
496
+ return await route(method, path, url, request, options.store, caller, images);
399
497
  } catch (e) {
400
498
  if (e instanceof ValidationError) return error(422, "validation_failed", e.message, e.details);
401
499
  if (e instanceof StoreError) return error(e.status, e.code, e.message, e.details);
@@ -448,9 +546,10 @@ function scopeFor(path) {
448
546
  if (first === "orders") return /^\/orders\/[^/]+\/fulfillments$/.test(path) ? "fulfillments" : "orders";
449
547
  if (first === "inventory") return "inventory";
450
548
  if (first === "products" || first === "categories") return "catalog";
549
+ if (first === "quotes") return "quotes";
451
550
  return null;
452
551
  }
453
- async function route(method, path, url, request, store, caller) {
552
+ async function route(method, path, url, request, store, caller, images) {
454
553
  const segments = path.split("/").filter(Boolean);
455
554
  if (path === "/health" && method === "GET") {
456
555
  const health = await store.health();
@@ -498,6 +597,27 @@ async function route(method, path, url, request, store, caller) {
498
597
  return json({ data: serializeFulfillment(result.fulfillment) }, result.created ? 201 : 200);
499
598
  }
500
599
  }
600
+ if (segments[0] === "quotes") {
601
+ const prefix = store.config.quotes.numberPrefix;
602
+ if (segments.length === 1 && method === "GET") {
603
+ const query = validateListQuery(url.searchParams, { defaultLimit: 50, maxLimit: 200, allowUpdatedSince: true });
604
+ await store.quotes.expireStale();
605
+ const page = await store.quotes.changedSince(query.updatedSince, query.cursor, query.limit);
606
+ return json({ data: page.quotes.map((q) => serializeQuote(q, prefix)), next_cursor: page.nextCursor });
607
+ }
608
+ const id = segments[1] ? decodeURIComponent(segments[1]) : "";
609
+ if (segments.length === 2 && id) {
610
+ if (method === "GET") return json({ data: serializeQuote(await store.quotes.require(id), prefix) });
611
+ if (method === "PATCH") {
612
+ const patch = validateQuotePatch(await readJson(request));
613
+ let quote = await store.quotes.require(id);
614
+ if (patch.external_reference !== void 0) quote = await store.quotes.setExternalReference(id, patch.external_reference);
615
+ if (patch.offer) quote = await store.quotes.offer(id, patch.offer);
616
+ if (patch.status === "cancelled") quote = await store.quotes.cancel(id, "Cancelled by the operations system");
617
+ return json({ data: serializeQuote(quote, prefix) });
618
+ }
619
+ }
620
+ }
501
621
  if (path === "/inventory" && method === "PUT") {
502
622
  const items = validateInventory(await readJson(request));
503
623
  return json({ data: await store.inventory.set(items) });
@@ -509,11 +629,11 @@ async function route(method, path, url, request, store, caller) {
509
629
  if (path === "/products" && method === "GET") {
510
630
  const query = validateListQuery(url.searchParams, { defaultLimit: 50, maxLimit: 200, allowUpdatedSince: true, allowActive: true });
511
631
  const page = await store.catalog.listProducts(query);
512
- return json({ data: page.products.map(serializeProduct), next_cursor: page.nextCursor });
632
+ return json({ data: page.products.map((p) => serializeProduct(p, images)), next_cursor: page.nextCursor });
513
633
  }
514
634
  if (segments[0] === "products" && segments.length === 2) {
515
635
  const sku = decodeURIComponent(segments[1]);
516
- if (method === "GET") return json({ data: serializeProduct(await store.catalog.requireProduct(sku)) });
636
+ if (method === "GET") return json({ data: serializeProduct(await store.catalog.requireProduct(sku), images) });
517
637
  if (method === "DELETE") {
518
638
  const done = await store.catalog.deactivateProduct(sku);
519
639
  if (!done) return error(404, "not_found", `Product ${sku} not found`);
@@ -535,11 +655,13 @@ export {
535
655
  STORE_API_BASE,
536
656
  STORE_API_BASES,
537
657
  ValidationError,
658
+ absoluteImageUrl,
538
659
  handleStoreApi,
539
660
  serializeCategory,
540
661
  serializeConnection,
541
662
  serializeFulfillment,
542
663
  serializeOrder,
543
- serializeProduct
664
+ serializeProduct,
665
+ serializeQuote
544
666
  };
545
667
  //# sourceMappingURL=api.js.map