@solumflow-app/crm-client 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -59,6 +59,8 @@ function codeForStatus(status) {
59
59
  return "forbidden";
60
60
  case 404:
61
61
  return "not_found";
62
+ case 422:
63
+ return "invalid_request";
62
64
  case 409:
63
65
  return "request_in_progress";
64
66
  case 429:
@@ -76,6 +78,12 @@ function productsTag() {
76
78
  function productTag(slugOrId) {
77
79
  return `${PREFIX}:product:${slugOrId}`;
78
80
  }
81
+ function categoriesTag() {
82
+ return `${PREFIX}:categories`;
83
+ }
84
+ function categoryTag(slugOrId) {
85
+ return `${PREFIX}:category:${slugOrId}`;
86
+ }
79
87
  function eventsTag() {
80
88
  return `${PREFIX}:events`;
81
89
  }
@@ -83,15 +91,21 @@ function eventTag(idOrSlug) {
83
91
  return `${PREFIX}:event:${idOrSlug}`;
84
92
  }
85
93
  function tagsForDelivery(input) {
86
- const collection = input.type.startsWith("product.") ? productsTag() : input.type.startsWith("event.") ? eventsTag() : null;
87
- if (collection === null) {
94
+ const collections = input.type.startsWith("product.") ? [productsTag()] : input.type.startsWith("category.") ? (
95
+ // Both, and the second one is the point: a category page is a list of
96
+ // products, and `getProducts({ category })` is filed under the products
97
+ // collection. Clearing only the category tag would leave every shop
98
+ // showing yesterday's shelf under today's heading.
99
+ [categoriesTag(), productsTag()]
100
+ ) : input.type.startsWith("event.") ? [eventsTag()] : [];
101
+ if (collections.length === 0) {
88
102
  return [];
89
103
  }
90
104
  if (input.truncated) {
91
- return [collection];
105
+ return collections;
92
106
  }
93
- const item = input.type.startsWith("product.") ? productTag : eventTag;
94
- return [collection, ...input.ids.map((id) => item(id))];
107
+ const item = input.type.startsWith("product.") ? productTag : input.type.startsWith("category.") ? categoryTag : eventTag;
108
+ return [...collections, ...input.ids.map((id) => item(id))];
95
109
  }
96
110
 
97
111
  // src/client.ts
@@ -152,9 +166,9 @@ function createClient(options) {
152
166
  return { method: "GET", next: { tags, revalidate } };
153
167
  }
154
168
  const live = { method: "GET", cache: "no-store" };
155
- function write(body, options2) {
169
+ function write(body, options2, method = "POST") {
156
170
  return {
157
- method: "POST",
171
+ method,
158
172
  cache: "no-store",
159
173
  headers: {
160
174
  "content-type": "application/json",
@@ -175,6 +189,9 @@ function createClient(options) {
175
189
  if (listOptions?.category) {
176
190
  query.set("category", listOptions.category);
177
191
  }
192
+ if (listOptions?.object) {
193
+ query.set("object", listOptions.object);
194
+ }
178
195
  const result = await send(
179
196
  `/products${suffix(query)}`,
180
197
  cached([productsTag()]),
@@ -191,6 +208,46 @@ function createClient(options) {
191
208
  true
192
209
  );
193
210
  },
211
+ async getCategories(listOptions) {
212
+ const query = new URLSearchParams();
213
+ if (listOptions?.limit !== void 0) {
214
+ query.set("limit", `${listOptions.limit}`);
215
+ }
216
+ if (listOptions?.cursor) {
217
+ query.set("cursor", listOptions.cursor);
218
+ }
219
+ if (listOptions?.parent) {
220
+ query.set("parent", listOptions.parent);
221
+ }
222
+ const result = await send(
223
+ `/categories${suffix(query)}`,
224
+ cached([categoriesTag()]),
225
+ (body) => listOrNothing(body, "data"),
226
+ false
227
+ );
228
+ return result;
229
+ },
230
+ /*
231
+ * Filed under the products collection as well as its own two tags, because
232
+ * what this answers is mostly a page of products: a product published into
233
+ * this category has to reach it, and a `product.changed` delivery clears
234
+ * only the product tags.
235
+ */
236
+ getCategory(slugOrId, listOptions) {
237
+ const query = new URLSearchParams();
238
+ if (listOptions?.limit !== void 0) {
239
+ query.set("limit", `${listOptions.limit}`);
240
+ }
241
+ if (listOptions?.cursor) {
242
+ query.set("cursor", listOptions.cursor);
243
+ }
244
+ return send(
245
+ `/categories/${encodeURIComponent(slugOrId)}${suffix(query)}`,
246
+ cached([categoriesTag(), categoryTag(slugOrId), productsTag()]),
247
+ (body) => body,
248
+ true
249
+ );
250
+ },
194
251
  getAvailability(slugOrId) {
195
252
  return send(
196
253
  `/products/${encodeURIComponent(slugOrId)}/availability`,
@@ -255,6 +312,111 @@ function createClient(options) {
255
312
  );
256
313
  return result;
257
314
  },
315
+ async upsertProduct(externalRef, input, writeOptions) {
316
+ const result = await send(
317
+ `/products/by-ref/${encodeURIComponent(externalRef)}`,
318
+ write(input, writeOptions, "PUT"),
319
+ (body) => dataOf(body),
320
+ false
321
+ );
322
+ return result;
323
+ },
324
+ async archiveProduct(externalRef, source, writeOptions) {
325
+ const result = await send(
326
+ `/products/by-ref/${encodeURIComponent(externalRef)}?source=${encodeURIComponent(source)}`,
327
+ write({}, writeOptions, "DELETE"),
328
+ (body) => dataOf(body),
329
+ false
330
+ );
331
+ return result;
332
+ },
333
+ async upsertCategory(slug, input, writeOptions) {
334
+ const result = await send(
335
+ `/categories/by-slug/${encodeURIComponent(slug)}`,
336
+ write(input, writeOptions, "PUT"),
337
+ (body) => dataOf(body),
338
+ false
339
+ );
340
+ return result;
341
+ },
342
+ async upsertField(slug, input, writeOptions) {
343
+ const result = await send(
344
+ `/attributes/by-slug/${encodeURIComponent(slug)}`,
345
+ write(input, writeOptions, "PUT"),
346
+ (body) => dataOf(body),
347
+ false
348
+ );
349
+ return result;
350
+ },
351
+ async getFields() {
352
+ const result = await send(
353
+ "/attributes",
354
+ cached([productsTag()]),
355
+ (body) => {
356
+ const data = dataOf(body);
357
+ return Array.isArray(data) ? data : void 0;
358
+ },
359
+ false
360
+ );
361
+ return result;
362
+ },
363
+ async getForms(listOptions) {
364
+ const result = await send(
365
+ `/forms${suffix(pageQuery(listOptions))}`,
366
+ live,
367
+ (body) => listOrNothing(body, "data"),
368
+ false
369
+ );
370
+ return result;
371
+ },
372
+ async getChatWidgets(listOptions) {
373
+ const result = await send(
374
+ `/chat-widgets${suffix(pageQuery(listOptions))}`,
375
+ live,
376
+ (body) => listOrNothing(body, "data"),
377
+ false
378
+ );
379
+ return result;
380
+ },
381
+ async upsertChatWidget(slug, input, writeOptions) {
382
+ const result = await send(
383
+ `/chat-widgets/by-slug/${encodeURIComponent(slug)}`,
384
+ write(input, writeOptions, "PUT"),
385
+ (body) => dataOf(body),
386
+ false
387
+ );
388
+ return result;
389
+ },
390
+ async getSiteTracking() {
391
+ const result = await send(
392
+ "/site-tracking",
393
+ live,
394
+ (body) => body !== null && typeof body === "object" && "data" in body ? { site: dataOf(body) } : void 0,
395
+ false
396
+ );
397
+ return result?.site ?? null;
398
+ },
399
+ async updateSiteTracking(input, writeOptions) {
400
+ const result = await send(
401
+ "/site-tracking",
402
+ write(input, writeOptions, "PUT"),
403
+ (body) => dataOf(body),
404
+ false
405
+ );
406
+ return result;
407
+ },
408
+ async getBookingPages() {
409
+ const result = await send(
410
+ "/booking-pages",
411
+ live,
412
+ (body) => {
413
+ const data = dataOf(body);
414
+ return Array.isArray(data) ? data : void 0;
415
+ },
416
+ false
417
+ );
418
+ return result;
419
+ },
258
420
  async submitRequest(input, writeOptions) {
259
421
  const result = await send(
260
422
  "/requests",
@@ -279,6 +441,16 @@ function suffix(query) {
279
441
  const rendered = query.toString();
280
442
  return rendered ? `?${rendered}` : "";
281
443
  }
444
+ function pageQuery(listOptions) {
445
+ const query = new URLSearchParams();
446
+ if (listOptions?.limit !== void 0) {
447
+ query.set("limit", `${listOptions.limit}`);
448
+ }
449
+ if (listOptions?.cursor) {
450
+ query.set("cursor", listOptions.cursor);
451
+ }
452
+ return query;
453
+ }
282
454
  function dataOf(body) {
283
455
  return body?.data;
284
456
  }
@@ -308,14 +480,17 @@ function inSomethingTheUserCanRead() {
308
480
  // src/generated/api-types.ts
309
481
  var ALL_API_SCOPES = [
310
482
  "catalog:read",
483
+ "catalog:write",
311
484
  "events:read",
312
485
  "orders:write",
313
486
  "contacts:write",
314
- "forms:write"
487
+ "forms:write",
488
+ "website:manage"
315
489
  ];
316
490
  var ALL_API_WEBHOOK_EVENTS = [
317
491
  "product.changed",
318
492
  "product.deleted",
493
+ "category.changed",
319
494
  "event.changed",
320
495
  "order.status_changed"
321
496
  ];
@@ -325,6 +500,7 @@ var ALL_API_ERROR_CODES = [
325
500
  "feature_unavailable",
326
501
  "not_found",
327
502
  "invalid_request",
503
+ "conflict",
328
504
  "idempotency_key_reused",
329
505
  "request_in_progress",
330
506
  "rate_limited",
@@ -335,6 +511,8 @@ export {
335
511
  ALL_API_SCOPES,
336
512
  ALL_API_WEBHOOK_EVENTS,
337
513
  CrmApiError,
514
+ categoriesTag,
515
+ categoryTag,
338
516
  createClient,
339
517
  eventTag,
340
518
  eventsTag,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/refusal.ts","../src/tags.ts","../src/client.ts","../src/generated/api-types.ts"],"sourcesContent":["import type { ApiErrorCode } from './generated/api-types';\n\n/**\n * A refusal from the API, with the machine-readable reason kept.\n *\n * Match on `code`, never on `message`. The code is a closed set that this\n * package generates from the server's own list; the message is written for\n * somebody reading a log and gets reworded.\n *\n * Three of the codes are deliberately vaguer than they could be, and knowing\n * that saves an afternoon:\n *\n * - `unauthorized` means the key was not accepted, and says nothing about\n * which of \"missing\", \"mistyped\", \"revoked\" or \"expired\" applies. That is on\n * purpose — the alternative is an endpoint that tells a stranger whether a\n * key they guessed exists.\n * - `forbidden` covers both \"this key does not carry that permission\" and \"this\n * is a browser-safe key and you asked it to write\", for the same reason.\n * - `not_found` is also the answer for something that exists but belongs to\n * somebody else. A shop cannot tell the two apart, and should not be able to.\n */\nexport class CrmApiError extends Error {\n readonly code: ApiErrorCode;\n readonly status: number;\n readonly fields: { path: string; message: string }[];\n /** Present when the API refused before the request reached a handler. */\n readonly requestUrl: string;\n\n constructor(input: {\n code: ApiErrorCode;\n message: string;\n status: number;\n fields?: { path: string; message: string }[];\n requestUrl: string;\n }) {\n super(input.message);\n\n this.name = 'CrmApiError';\n this.code = input.code;\n this.status = input.status;\n this.fields = input.fields ?? [];\n this.requestUrl = input.requestUrl;\n }\n\n /**\n * Whether trying the same request again could plausibly work.\n *\n * A rate limit clears and a server error may be a blip; a rejected key and a\n * malformed body will be refused just as firmly the second time. Write\n * requests should be retried with the *same* idempotency key, which this\n * client fills in for you, so a retry after a timeout cannot become a second\n * order.\n */\n get retryable() {\n return this.code === 'rate_limited' || this.code === 'internal_error';\n }\n}\n","import { CrmApiError } from './errors';\nimport type { ApiErrorCode } from './generated/api-types';\n\nexport async function readJson(response: Response) {\n try {\n return (await response.json()) as unknown;\n } catch {\n return null;\n }\n}\n\n/**\n * A refusal, whatever shape it arrived in.\n *\n * Almost everything answers `{ error: { code, message } }`, and then the code\n * is taken at its word — it is a closed set this package generates from the\n * server's own list, so a code that does not appear here means the two have\n * drifted and a build somewhere should already have gone red.\n *\n * The events listing is the exception: it answers `{ error: \"...\" }`, because\n * it predates the shared error helper and is read by script tags on sites\n * nobody here can redeploy. For that one the status code is all there is, and\n * the one place it cannot be precise is 403, where \"your key lacks this scope\"\n * and \"that module is switched off for this account\" share a number. It is\n * reported as `forbidden`, the one of the two a developer can act on.\n */\nexport function refusalOf(status: number, body: unknown, requestUrl: string) {\n const structured = (\n body as { error?: { code?: string; message?: string; fields?: unknown } }\n )?.error;\n\n if (structured && typeof structured === 'object' && structured.code) {\n return new CrmApiError({\n code: structured.code as ApiErrorCode,\n message: structured.message ?? structured.code,\n status,\n fields: (structured.fields ?? []) as { path: string; message: string }[],\n requestUrl,\n });\n }\n\n const plain = (body as { error?: unknown } | null)?.error;\n\n return new CrmApiError({\n code: codeForStatus(status),\n message:\n typeof plain === 'string' ? plain : `The request failed with ${status}.`,\n status,\n requestUrl,\n });\n}\n\nfunction codeForStatus(status: number): ApiErrorCode {\n switch (status) {\n case 400:\n return 'invalid_request';\n case 401:\n return 'unauthorized';\n case 403:\n return 'forbidden';\n case 404:\n return 'not_found';\n case 409:\n return 'request_in_progress';\n case 429:\n return 'rate_limited';\n default:\n return 'internal_error';\n }\n}\n","/**\n * The names a cached answer is filed under, so a webhook can throw it away.\n *\n * This is the part of the package that actually does something. A `getProducts`\n * that only fetches is three lines anybody writes themselves; a `getProducts`\n * whose answer sits under a name the shipped revalidation route already knows\n * how to clear is the piece that makes the connection work at all.\n *\n * **Every read carries the collection tag, detail reads included**, and that is\n * not sloppiness. A delivery names ids and nothing else — on purpose, so a\n * late message still leads to the right state and no message can hand out data\n * the receiving key could not have read. But a product page is usually fetched\n * by *slug*, and nothing on this side can turn an id into the slug somebody\n * asked for. So the per-item tag is a narrower handle for a site that fetches\n * by id and for its own manual invalidations, and the collection tag is the one\n * that guarantees a slug-fetched page ever updates.\n */\nconst PREFIX = 'crm';\n\n/** Everything that lists products. Cleared by every catalogue delivery. */\nexport function productsTag() {\n return `${PREFIX}:products`;\n}\n\n/** One product, by whichever key it was fetched with. */\nexport function productTag(slugOrId: string) {\n return `${PREFIX}:product:${slugOrId}`;\n}\n\nexport function eventsTag() {\n return `${PREFIX}:events`;\n}\n\nexport function eventTag(idOrSlug: string) {\n return `${PREFIX}:event:${idOrSlug}`;\n}\n\n/**\n * What a delivery of this type should clear.\n *\n * `truncated` means the batch stopped collecting and `ids` is incomplete, so\n * the only honest answer is the whole collection — which the collection tag\n * already is. The per-item tags are added on top when the list can be trusted.\n */\nexport function tagsForDelivery(input: {\n type: string;\n ids: readonly string[];\n truncated: boolean;\n}) {\n const collection = input.type.startsWith('product.')\n ? productsTag()\n : input.type.startsWith('event.')\n ? eventsTag()\n : null;\n\n if (collection === null) {\n return [];\n }\n\n if (input.truncated) {\n return [collection];\n }\n\n const item = input.type.startsWith('product.') ? productTag : eventTag;\n\n return [collection, ...input.ids.map((id) => item(id))];\n}\n","import { CrmApiError } from './errors';\nimport { readJson, refusalOf } from './refusal';\nimport { eventTag, eventsTag, productTag, productsTag } from './tags';\nimport type {\n ContactInput,\n ContactResult,\n EventAvailability,\n EventDetail,\n EventList,\n FormSubmissionInput,\n FormSubmissionResult,\n OrderInput,\n OrderResult,\n ProductDetail,\n ProductList,\n RequestInput,\n RequestResult,\n StockStatus,\n} from './types';\n\nconst KEY_FORM = /^crm[ps]_[A-Za-z0-9_-]{43}$/;\nconst SECRET_PREFIX = 'crms_';\nconst BASE_PATH = '/api/public/v1';\nconst DEFAULT_REVALIDATE = 60;\n\n/**\n * The fetch options this package sets that are not in the web standard.\n *\n * `next` is read by Next.js and ignored by every other runtime, which is\n * exactly the behaviour wanted: the tags do nothing outside a framework that\n * caches, and nothing breaks in one that does not.\n */\ntype CachedInit = RequestInit & {\n next?: { tags?: string[]; revalidate?: number | false };\n};\n\nexport type FetchLike = (input: string, init?: CachedInit) => Promise<Response>;\n\nexport interface CrmClientOptions {\n /**\n * The key, whole.\n *\n * A `crmp_` key may be read by anyone who views source and may only read; a\n * `crms_` key may write and must never leave a server. This client refuses a\n * `crms_` key outright when it finds itself in a browser — see the note on\n * `createClient`.\n */\n apiKey: string;\n /**\n * Where the CRM lives: an origin, with no path on it.\n *\n * `https://app.example.com`, not `https://app.example.com/api/public/v1`.\n * The version lives in this package so that a shop upgrading the package is\n * the thing that moves it, rather than a string in someone's environment\n * file that nobody remembers to change.\n */\n baseUrl: string;\n /**\n * How long a cached answer may be served before it is refetched, in seconds.\n *\n * Defaults to a minute. `false` means never on a timer — only when a webhook\n * clears the tag. That is the right setting for a shop whose revalidation\n * route is wired up and reachable, and the wrong one for a shop where it is\n * not, because then nothing ever expires at all.\n */\n revalidate?: number | false;\n /** For tests and for runtimes with their own instrumented fetch. */\n fetch?: FetchLike;\n}\n\nexport interface ListOptions {\n limit?: number;\n cursor?: string | null;\n}\n\nexport interface ProductListOptions extends ListOptions {\n /** A category id. Passing something that is not a uuid is refused, loudly. */\n category?: string;\n}\n\nexport interface EventListOptions extends ListOptions {\n /** ISO timestamps. Both ends are optional and both are inclusive. */\n from?: string;\n to?: string;\n}\n\nexport interface WriteOptions {\n /**\n * The key that makes a retry safe.\n *\n * Generated per call when you leave it out, which is right for a first\n * attempt and wrong for a retry: a website that timed out does not know\n * whether the order arrived, and sending it again under a *new* key is how\n * one customer gets charged twice. Keep the key you used and resend with it.\n */\n idempotencyKey?: string;\n}\n\nexport interface CrmClient {\n getProducts(options?: ProductListOptions): Promise<ProductList>;\n getProduct(slugOrId: string): Promise<ProductDetail | null>;\n /** Live. Never cached, at any layer. */\n getAvailability(slugOrId: string): Promise<StockStatus | null>;\n getEvents(options?: EventListOptions): Promise<EventList>;\n getEvent(idOrSlug: string): Promise<EventDetail | null>;\n /** Live. Never cached, at any layer. */\n getEventAvailability(eventId: string): Promise<EventAvailability | null>;\n submitOrder(input: OrderInput, options?: WriteOptions): Promise<OrderResult>;\n upsertContact(\n input: ContactInput,\n options?: WriteOptions,\n ): Promise<ContactResult>;\n submitRequest(\n input: RequestInput,\n options?: WriteOptions,\n ): Promise<RequestResult>;\n submitForm(\n formId: string,\n input: FormSubmissionInput,\n options?: WriteOptions,\n ): Promise<FormSubmissionResult>;\n}\n\n/**\n * A client for one account's public API.\n *\n * Two layers, and the names are the whole warning. `getProducts` and\n * `getProduct` are cached at the edge and filed under tags the shipped\n * revalidation route knows how to clear. `getAvailability` is not cached\n * anywhere and never will be — a stock figure in a cached answer is the number\n * that lies first and lies worst, and a developer who reaches for `getProduct`\n * to show \"in stock\" gets no warning from the type system, so the split has to\n * be in the name.\n *\n * The browser check is not decoration either. A `crms_` key can create orders\n * and read contacts; pasted into a client component it ends up in a JavaScript\n * bundle that anybody can open. Throwing at construction turns that into a\n * build-time failure on the first render instead of a quiet leak.\n */\nexport function createClient(options: CrmClientOptions): CrmClient {\n const apiKey = options.apiKey?.trim() ?? '';\n\n if (!KEY_FORM.test(apiKey)) {\n throw new Error(\n 'crm-client: the API key is not in the expected form. It starts with ' +\n '`crmp_` or `crms_` and is 48 characters long — a truncated paste ' +\n 'looks exactly like a revoked key once it reaches the server.',\n );\n }\n\n if (apiKey.startsWith(SECRET_PREFIX) && inSomethingTheUserCanRead()) {\n throw new Error(\n 'crm-client: a `crms_` key may not be used in a browser. It can write ' +\n 'orders and read contacts, and anything a browser holds is public — ' +\n 'including a service worker or a web worker, which is why this is ' +\n 'refused there too. Read from a server component or a route handler, ' +\n 'or issue a `crmp_` key for the parts of the catalogue a page reads ' +\n 'directly.',\n );\n }\n\n const base = options.baseUrl?.replace(/\\/+$/, '') ?? '';\n\n if (!/^https?:\\/\\/[^/]+$/.test(base)) {\n throw new Error(\n `crm-client: baseUrl should be an origin and nothing more, such as ` +\n `\"https://app.example.com\". Received \"${options.baseUrl}\".`,\n );\n }\n\n const doFetch: FetchLike =\n options.fetch ?? ((input, init) => fetch(input, init));\n const revalidate = options.revalidate ?? DEFAULT_REVALIDATE;\n\n async function send<T>(\n path: string,\n init: CachedInit,\n unwrap: (body: unknown) => T,\n notFoundIsNull: boolean,\n ): Promise<T | null> {\n const url = `${base}${BASE_PATH}${path}`;\n\n const response = await doFetch(url, {\n ...init,\n headers: {\n accept: 'application/json',\n authorization: `Bearer ${apiKey}`,\n ...init.headers,\n },\n });\n\n const body = await readJson(response);\n\n if (!response.ok) {\n const refusal = refusalOf(response.status, body, url);\n\n if (notFoundIsNull && refusal.code === 'not_found') {\n return null;\n }\n\n throw refusal;\n }\n\n const answer = unwrap(body);\n\n /*\n * A 2xx whose body is not the shape this method promised. It happens: a\n * proxy that answers its own page, a CDN error dressed as a 200, a version\n * of this package older than the route it is talking to.\n *\n * Without this the value is cast and handed back typed as an answer, and\n * the failure surfaces as `TypeError: cannot read properties of undefined`\n * three frames away in somebody's shop -- the one kind of error this\n * package's whole design is meant to avoid producing.\n */\n if (answer === undefined || answer === null) {\n throw new CrmApiError({\n code: 'internal_error',\n message:\n 'The API answered successfully with a body this client did not ' +\n 'recognise. Either something between here and it replaced the ' +\n 'answer, or this package is older than the route it called.',\n status: response.status,\n requestUrl: url,\n });\n }\n\n return answer;\n }\n\n function cached(tags: string[]): CachedInit {\n return { method: 'GET', next: { tags, revalidate } };\n }\n\n const live: CachedInit = { method: 'GET', cache: 'no-store' };\n\n function write(body: unknown, options?: WriteOptions): CachedInit {\n return {\n method: 'POST',\n cache: 'no-store',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': options?.idempotencyKey ?? newIdempotencyKey(),\n },\n body: JSON.stringify(body),\n };\n }\n\n return {\n async getProducts(listOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n if (listOptions?.category) {\n query.set('category', listOptions.category);\n }\n\n const result = await send(\n `/products${suffix(query)}`,\n cached([productsTag()]),\n (body) => listOrNothing(body, 'data') as ProductList | undefined,\n false,\n );\n\n return result as ProductList;\n },\n\n getProduct(slugOrId) {\n return send(\n `/products/${encodeURIComponent(slugOrId)}`,\n cached([productsTag(), productTag(slugOrId)]),\n (body) => dataOf(body) as ProductDetail,\n true,\n );\n },\n\n getAvailability(slugOrId) {\n return send(\n `/products/${encodeURIComponent(slugOrId)}/availability`,\n live,\n (body) => dataOf(body) as StockStatus,\n true,\n );\n },\n\n async getEvents(listOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n if (listOptions?.from) {\n query.set('from', listOptions.from);\n }\n\n if (listOptions?.to) {\n query.set('to', listOptions.to);\n }\n\n const result = await send(\n `/events${suffix(query)}`,\n cached([eventsTag()]),\n (body) => listOrNothing(body, 'events') as EventList | undefined,\n false,\n );\n\n return result as EventList;\n },\n\n getEvent(idOrSlug) {\n return send(\n `/events/${encodeURIComponent(idOrSlug)}`,\n cached([eventsTag(), eventTag(idOrSlug)]),\n (body) => dataOf(body) as EventDetail,\n true,\n );\n },\n\n getEventAvailability(eventId) {\n return send(\n `/events/${encodeURIComponent(eventId)}/availability`,\n live,\n (body) => body as EventAvailability,\n true,\n );\n },\n\n async submitOrder(input, writeOptions) {\n const result = await send(\n '/orders',\n write(input, writeOptions),\n (body) => dataOf(body) as OrderResult,\n false,\n );\n\n return result as OrderResult;\n },\n\n async upsertContact(input, writeOptions) {\n const result = await send(\n '/contacts',\n write(input, writeOptions),\n (body) => dataOf(body) as ContactResult,\n false,\n );\n\n return result as ContactResult;\n },\n\n async submitRequest(input, writeOptions) {\n const result = await send(\n '/requests',\n write(input, writeOptions),\n (body) => dataOf(body) as RequestResult,\n false,\n );\n\n return result as RequestResult;\n },\n\n async submitForm(formId, input, writeOptions) {\n const result = await send(\n `/forms/${encodeURIComponent(formId)}/submissions`,\n write(input, writeOptions),\n (body) => dataOf(body) as FormSubmissionResult,\n false,\n );\n\n return result as FormSubmissionResult;\n },\n };\n}\n\nfunction suffix(query: URLSearchParams) {\n const rendered = query.toString();\n\n return rendered ? `?${rendered}` : '';\n}\n\nfunction dataOf(body: unknown) {\n return (body as { data: unknown } | null)?.data;\n}\n\n/**\n * A listing, or nothing at all when the body is not one.\n *\n * `undefined` is the signal `send` turns into a legible error. Checking for the\n * array rather than merely for the key matters: a proxy answering its own page,\n * or a CDN dressing an error as a 200, produces a body with neither — and\n * without this the caller receives it typed as a page of products and finds out\n * when `.map` is not a function, somewhere else entirely.\n *\n * The key differs per listing because the events one answers `events` rather\n * than `data`, which it has done since before this convention existed.\n */\nfunction listOrNothing(body: unknown, key: 'data' | 'events') {\n const listing = body as Record<string, unknown> | null;\n\n return Array.isArray(listing?.[key]) ? listing : undefined;\n}\n\n/**\n * A fresh key for a first attempt.\n *\n * Deliberately not derived from the body. Two identical orders placed a minute\n * apart by the same customer are two orders, and a fingerprint would quietly\n * collapse them into one.\n *\n * `crypto.randomUUID` is not everywhere it looks like it is: browsers expose it\n * only in a secure context, so a shop still served over plain http has\n * `crypto` and not that method. Reaching for it unguarded turns every write\n * into `TypeError: crypto.randomUUID is not a function`, thrown while building\n * the headers, before any request — which tells the shop's developer nothing\n * about what is actually missing.\n *\n * The weakest fallback is acceptable *here specifically*, and nowhere else in\n * this package: an idempotency key needs to be unique within one account for a\n * day. It is not a credential, it guesses nothing and guards nothing; a caller\n * who wants a stronger one passes their own.\n */\nfunction newIdempotencyKey() {\n const source = globalThis.crypto;\n\n if (typeof source?.randomUUID === 'function') {\n return source.randomUUID();\n }\n\n if (typeof source?.getRandomValues === 'function') {\n const bytes = source.getRandomValues(new Uint8Array(16));\n\n return [...bytes]\n .map((byte) => byte.toString(16).padStart(2, '0'))\n .join('');\n }\n\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;\n}\n\n/**\n * Whether this is somewhere an end user could read what the code holds.\n *\n * `typeof window !== 'undefined'` is the usual test and it is not enough: a\n * service worker and a web worker both have no `window` and are both JavaScript\n * delivered to a browser, openable in devtools like any other script. Treating\n * \"no window\" as \"a trusted server\" is exactly the assumption that lets a\n * writing key into an offline bundle.\n */\nfunction inSomethingTheUserCanRead() {\n if (typeof window !== 'undefined') {\n return true;\n }\n\n const scope = globalThis as { importScripts?: unknown; self?: unknown };\n\n return typeof scope.importScripts === 'function';\n}\n","// GENERATED FILE -- edit the projections, not this.\n//\n// Every declaration below was lifted, comments and all, out of the services\n// that build the public API's answers. Run `pnpm --filter @solumflow-app/crm-client\n// generate:types` after changing one of them; a test in this package fails\n// when this file and those sources disagree.\n//\n// Sources:\n// packages/features/crm-invoicing/src/shared/public-product-item.ts\n// packages/features/crm-events/src/shared/public-event-list-item.ts\n// packages/features/crm-events/src/shared/public-event-detail-item.ts\n// packages/api-keys/src/webhook-events.ts\n// packages/api-keys/src/scopes.ts\n// apps/web/app/api/public/_lib/errors.ts\n\n/** What a key is allowed to reach. A key carries one or more of these. */\nexport type ApiScope =\n | 'catalog:read'\n | 'events:read'\n | 'orders:write'\n | 'contacts:write'\n | 'forms:write';\n\nexport const ALL_API_SCOPES: readonly ApiScope[] = [\n 'catalog:read',\n 'events:read',\n 'orders:write',\n 'contacts:write',\n 'forms:write',\n];\n\n/** Everything an endpoint can be told about. A delivery names one. */\nexport type ApiWebhookEvent =\n | 'product.changed'\n | 'product.deleted'\n | 'event.changed'\n | 'order.status_changed';\n\nexport const ALL_API_WEBHOOK_EVENTS: readonly ApiWebhookEvent[] = [\n 'product.changed',\n 'product.deleted',\n 'event.changed',\n 'order.status_changed',\n];\n\n/** The `error.code` of a refusal. Match on this, never on the message -- the message is written for a person reading a log and may be reworded. */\nexport type ApiErrorCode =\n | 'unauthorized'\n | 'forbidden'\n | 'feature_unavailable'\n | 'not_found'\n | 'invalid_request'\n | 'idempotency_key_reused'\n | 'request_in_progress'\n | 'rate_limited'\n | 'internal_error';\n\nexport const ALL_API_ERROR_CODES: readonly ApiErrorCode[] = [\n 'unauthorized',\n 'forbidden',\n 'feature_unavailable',\n 'not_found',\n 'invalid_request',\n 'idempotency_key_reused',\n 'request_in_progress',\n 'rate_limited',\n 'internal_error',\n];\n\n/**\n * One product as a stranger's website sees it.\n *\n * These interfaces are the public API's contract, not internal convenience\n * types. They are served cross-origin to pages nobody here controls, and a\n * customer's shop reads these exact keys — so a field removed or renamed breaks\n * a site that will not be redeployed. Add fields; do not repurpose them.\n *\n * What is deliberately absent is as much the contract as what is here.\n *\n * - **`sku`** is the number this business uses to find the thing in its own\n * stockroom. It says how many suppliers there are, which one a line came\n * from, and often what was paid. `gtin` is the number printed on the box and\n * is offered instead, in the detail: that one is meant to be public, and a\n * shop needs it for a product feed.\n * - **The stock count** never leaves. `inStock` answers the only question a\n * visitor has, and the number itself is a business fact a competitor would\n * like and a cached answer would get wrong within the minute.\n * - **`unit_price_cents` raw** is not it either; see `priceFromCents`.\n * - **Non-public custom fields.** `fields` carries only attributes a member\n * marked `is_public`, which defaults to off. A field called \"purchase price\"\n * stays home unless somebody says otherwise, once, per field.\n *\n * And one field that an event has and a product does not: **`url`**. An event\n * is sold on a page this system hosts, so its listing can hand out a link. A\n * product is sold on the customer's own site — that is the entire reason this\n * API exists — and this system does not know what they called their product\n * page. A guessed link is worse than none.\n */\nexport interface PublicProductListItem {\n id: string;\n /** Always present: a product without an address cannot be published. */\n slug: string;\n name: string;\n productType: 'digital' | 'physical';\n /**\n * The storage path, not a URL. The route turns it into a public URL, because\n * only the route knows the origin its own storage is served from.\n */\n imagePath: string | null;\n /**\n * The cheapest way in, over the active price options — not the default one.\n * A card that says \"from €12\" next to a product whose entry price is €12 and\n * whose default is the €40 yearly plan is answering the question the visitor\n * actually asked.\n *\n * Never null, unlike the same field on an event. An event without a ticket\n * tier on sale genuinely has no price; a product always has `unit_price_cents`\n * on its own row, so when no separate price option is active that column is\n * the answer — including when it is `0`, which is a real price for something\n * given away.\n */\n priceFromCents: number;\n /** The struck-through price beside it, when the seller set one. */\n compareAtCents: number | null;\n currency: string;\n /**\n * Whether a visitor can buy it right now — never how many are left.\n *\n * True whenever the seller does not track stock at all, which is the common\n * case and the honest answer for anything made to order. When they do track\n * it, this counts what is on the shelf minus what other people already hold\n * in a checkout, and it stays true for a seller who accepts backorders.\n */\n inStock: boolean;\n /**\n * The custom fields this account invented, keyed by the slug they chose, and\n * only the ones marked public. An empty object when none are.\n */\n fields: Record<string, unknown>;\n}\n\n/**\n * The same product on its own page, where more may be shown.\n *\n * The detail answers for things the listing hides — an unlisted product, one\n * reachable by anyone holding the link — for the same reason the events detail\n * answers for a cancelled evening: somebody was sent here on purpose, and a 404\n * would be a lie. What it still refuses is anything unpublished.\n */\nexport interface PublicProductDetail extends PublicProductListItem {\n /** The one-line text that lands on a quote or an invoice row. */\n description: string | null;\n /** The long story, as sanitised HTML. Written by the seller, for this page. */\n longDescription: string | null;\n /** The barcode number: printed on the box, wanted by every product feed. */\n gtin: string | null;\n metaTitle: string | null;\n metaDescription: string | null;\n images: PublicProductImage[];\n prices: PublicProductPrice[];\n categories: PublicProductCategory[];\n /** What the seller linked this to — a companion, a refill, a bigger model. */\n related: PublicProductSummary[];\n}\n\nexport interface PublicProductImage {\n /** A path, turned into a URL by the route. Same reason as `imagePath`. */\n path: string;\n alt: string | null;\n}\n\n/**\n * One way to buy the thing.\n *\n * `stripe_price_id` and `stripe_account_id` are absent and must stay absent:\n * they name objects in the seller's payment account, and a checkout is started\n * through this system's own order route, never by a stranger quoting an id.\n */\nexport interface PublicProductPrice {\n id: string;\n kind: 'one_off' | 'recurring' | 'installment';\n label: string | null;\n amountCents: number;\n /** What the whole thing costs when paid in parts; null for the other kinds. */\n totalCents: number | null;\n installmentCount: number | null;\n recurringInterval: 'day' | 'week' | 'month' | 'year' | null;\n recurringIntervalCount: number | null;\n compareAtCents: number | null;\n trialPeriodDays: number | null;\n isDefault: boolean;\n}\n\nexport interface PublicProductCategory {\n id: string;\n name: string;\n}\n\nexport interface PublicProductSummary {\n id: string;\n slug: string;\n name: string;\n}\n\n/**\n * One event as a stranger's website sees it.\n *\n * This interface is the public API's contract, not an internal convenience\n * type. It is served cross-origin to pages nobody here controls, and a widget\n * on a customer's homepage reads these exact keys — so a field removed or\n * renamed breaks a site that will not be redeployed. Add fields; do not\n * repurpose them.\n *\n * What is deliberately absent is as much the contract as what is here: no\n * description (a paragraph no card renders), no joining link (that is\n * admission), no counts of who bought what, and nothing an organiser uses to\n * manage the event.\n */\nexport interface PublicEventListItem {\n id: string;\n slug: string;\n title: string;\n subtitle: string | null;\n startsAt: string;\n endsAt: string | null;\n timezone: string;\n locationName: string | null;\n /**\n * The storage path, not a URL. The route turns it into a public URL, because\n * only the route knows the origin its own storage is served from.\n */\n coverImagePath: string | null;\n /** Null when nothing is on sale — never 0, which is a real price. */\n priceFromCents: number | null;\n currency: string | null;\n soldOut: boolean;\n /** Null when the event has no ceiling to count down from. */\n seatsLeft: number | null;\n /** Path on the hosted site, so a widget can link straight to checkout. */\n url: string;\n /**\n * The custom fields this account invented, keyed by the slug they chose, and\n * only the ones marked public. An empty object when none are, which is every\n * account until somebody turns one on.\n *\n * Added rather than repurposed, which is the rule this interface states at\n * the top: a widget written before this field existed keeps working, because\n * it simply never reads the key.\n */\n fields: Record<string, unknown>;\n}\n\n/**\n * One event as the public API hands it out, on its own page.\n *\n * Not the same thing as `PublicEventView`, which feeds the detail page this\n * system hosts — and the difference is the reason this type exists rather than\n * the other one being reused. That view carries `online_url`: the link you join\n * the evening by. On a page we host, behind a ticket, that is correct. Through\n * an API that answers any holder of a read key, it is a free seat.\n *\n * It also carries `account_id`, which is ours and not the caller's business.\n *\n * So this is a separate, narrower projection, and everything in\n * `PublicEventListItem` about adding rather than repurposing fields applies\n * here too.\n */\nexport interface PublicEventDetailItem extends PublicEventListItem {\n /** The organiser's own text for the evening. */\n description: string | null;\n /**\n * `on_sale`, `closed` or `cancelled` — never `draft`, which does not answer\n * at all. A ticket holder has to be able to read that the evening is off, so\n * this route replies for the last two where the listing withholds them.\n */\n status: 'on_sale' | 'closed' | 'cancelled';\n doorsOpenAt: string | null;\n salesStartAt: string | null;\n salesEndAt: string | null;\n /** The postal address, as the organiser entered it. */\n address: unknown;\n metaTitle: string | null;\n metaDescription: string | null;\n ticketTypes: PublicEventTicketType[];\n /** Custom fields the account marked public, keyed by their own slug. */\n fields: Record<string, unknown>;\n}\n\n/**\n * One way in, as a stranger's page may show it.\n *\n * `reserved_count` and `sold_count` are absent: how many were sold is the\n * organiser's figure, and `seatsLeft` already answers the only question a\n * visitor has. The capacity behind it stays home for the same reason a stock\n * count does.\n */\nexport interface PublicEventTicketType {\n id: string;\n name: string;\n description: string | null;\n priceCents: number;\n currency: string;\n taxPercentage: number | null;\n minPerOrder: number | null;\n maxPerOrder: number | null;\n salesStartAt: string | null;\n salesEndAt: string | null;\n /** Null when this tier has no ceiling to count down from. */\n seatsLeft: number | null;\n soldOut: boolean;\n onSale: boolean;\n}\n\n/**\n * The body of one delivery.\n *\n * IDS AND NOTHING ELSE, and the three reasons are worth keeping together. A\n * message that arrives late still leads to the right state, because the\n * receiver fetches the current row rather than trusting a snapshot from twenty\n * minutes ago. A message can never hand out something the receiving key was\n * not allowed to read, because it hands out nothing. And five hundred changes\n * fit in a body of a few kilobytes instead of a few megabytes.\n *\n * `truncated` means the batch stopped collecting at its ceiling and `ids` is\n * therefore incomplete: the receiver should refetch the whole collection. It\n * is always present rather than only when true, so a receiver that reads it\n * cannot mistake \"absent\" for \"false\" in the one direction that matters.\n */\nexport interface ApiWebhookPayload {\n type: ApiWebhookEvent;\n occurredAt: string;\n ids: string[];\n truncated: boolean;\n}\n"],"mappings":"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","names":["options"]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/refusal.ts","../src/tags.ts","../src/client.ts","../src/generated/api-types.ts"],"sourcesContent":["import type { ApiErrorCode } from './generated/api-types';\n\n/**\n * A refusal from the API, with the machine-readable reason kept.\n *\n * Match on `code`, never on `message`. The code is a closed set that this\n * package generates from the server's own list; the message is written for\n * somebody reading a log and gets reworded.\n *\n * Three of the codes are deliberately vaguer than they could be, and knowing\n * that saves an afternoon:\n *\n * - `unauthorized` means the key was not accepted, and says nothing about\n * which of \"missing\", \"mistyped\", \"revoked\" or \"expired\" applies. That is on\n * purpose — the alternative is an endpoint that tells a stranger whether a\n * key they guessed exists.\n * - `forbidden` covers both \"this key does not carry that permission\" and \"this\n * is a browser-safe key and you asked it to write\", for the same reason.\n * - `not_found` is also the answer for something that exists but belongs to\n * somebody else. A shop cannot tell the two apart, and should not be able to.\n */\nexport class CrmApiError extends Error {\n readonly code: ApiErrorCode;\n readonly status: number;\n readonly fields: { path: string; message: string }[];\n /** Present when the API refused before the request reached a handler. */\n readonly requestUrl: string;\n\n constructor(input: {\n code: ApiErrorCode;\n message: string;\n status: number;\n fields?: { path: string; message: string }[];\n requestUrl: string;\n }) {\n super(input.message);\n\n this.name = 'CrmApiError';\n this.code = input.code;\n this.status = input.status;\n this.fields = input.fields ?? [];\n this.requestUrl = input.requestUrl;\n }\n\n /**\n * Whether trying the same request again could plausibly work.\n *\n * A rate limit clears and a server error may be a blip; a rejected key and a\n * malformed body will be refused just as firmly the second time. Write\n * requests should be retried with the *same* idempotency key, which this\n * client fills in for you, so a retry after a timeout cannot become a second\n * order.\n */\n get retryable() {\n return this.code === 'rate_limited' || this.code === 'internal_error';\n }\n}\n","import { CrmApiError } from './errors';\nimport type { ApiErrorCode } from './generated/api-types';\n\nexport async function readJson(response: Response) {\n try {\n return (await response.json()) as unknown;\n } catch {\n return null;\n }\n}\n\n/**\n * A refusal, whatever shape it arrived in.\n *\n * Almost everything answers `{ error: { code, message } }`, and then the code\n * is taken at its word — it is a closed set this package generates from the\n * server's own list, so a code that does not appear here means the two have\n * drifted and a build somewhere should already have gone red.\n *\n * The events listing is the exception: it answers `{ error: \"...\" }`, because\n * it predates the shared error helper and is read by script tags on sites\n * nobody here can redeploy. For that one the status code is all there is, and\n * the one place it cannot be precise is 403, where \"your key lacks this scope\"\n * and \"that module is switched off for this account\" share a number. It is\n * reported as `forbidden`, the one of the two a developer can act on.\n */\nexport function refusalOf(status: number, body: unknown, requestUrl: string) {\n const structured = (\n body as { error?: { code?: string; message?: string; fields?: unknown } }\n )?.error;\n\n if (structured && typeof structured === 'object' && structured.code) {\n return new CrmApiError({\n code: structured.code as ApiErrorCode,\n message: structured.message ?? structured.code,\n status,\n fields: (structured.fields ?? []) as { path: string; message: string }[],\n requestUrl,\n });\n }\n\n const plain = (body as { error?: unknown } | null)?.error;\n\n return new CrmApiError({\n code: codeForStatus(status),\n message:\n typeof plain === 'string' ? plain : `The request failed with ${status}.`,\n status,\n requestUrl,\n });\n}\n\nfunction codeForStatus(status: number): ApiErrorCode {\n switch (status) {\n case 400:\n return 'invalid_request';\n case 401:\n return 'unauthorized';\n case 403:\n return 'forbidden';\n case 404:\n return 'not_found';\n case 422:\n return 'invalid_request';\n case 409:\n return 'request_in_progress';\n case 429:\n return 'rate_limited';\n default:\n return 'internal_error';\n }\n}\n","/**\n * The names a cached answer is filed under, so a webhook can throw it away.\n *\n * This is the part of the package that actually does something. A `getProducts`\n * that only fetches is three lines anybody writes themselves; a `getProducts`\n * whose answer sits under a name the shipped revalidation route already knows\n * how to clear is the piece that makes the connection work at all.\n *\n * **Every read carries the collection tag, detail reads included**, and that is\n * not sloppiness. A delivery names ids and nothing else — on purpose, so a\n * late message still leads to the right state and no message can hand out data\n * the receiving key could not have read. But a product page is usually fetched\n * by *slug*, and nothing on this side can turn an id into the slug somebody\n * asked for. So the per-item tag is a narrower handle for a site that fetches\n * by id and for its own manual invalidations, and the collection tag is the one\n * that guarantees a slug-fetched page ever updates.\n */\nconst PREFIX = 'crm';\n\n/** Everything that lists products. Cleared by every catalogue delivery. */\nexport function productsTag() {\n return `${PREFIX}:products`;\n}\n\n/** One product, by whichever key it was fetched with. */\nexport function productTag(slugOrId: string) {\n return `${PREFIX}:product:${slugOrId}`;\n}\n\n/**\n * Everything that lists categories, and every category page.\n *\n * A category delivery clears both this and the products collection, because a\n * category page *is* a list of products: renaming a category, publishing one,\n * or moving a product into one all change what a product listing filtered by\n * that category answers.\n */\nexport function categoriesTag() {\n return `${PREFIX}:categories`;\n}\n\n/** One category, by whichever key it was fetched with. */\nexport function categoryTag(slugOrId: string) {\n return `${PREFIX}:category:${slugOrId}`;\n}\n\nexport function eventsTag() {\n return `${PREFIX}:events`;\n}\n\nexport function eventTag(idOrSlug: string) {\n return `${PREFIX}:event:${idOrSlug}`;\n}\n\n/**\n * What a delivery of this type should clear.\n *\n * `truncated` means the batch stopped collecting and `ids` is incomplete, so\n * the only honest answer is the whole collection — which the collection tag\n * already is. The per-item tags are added on top when the list can be trusted.\n */\nexport function tagsForDelivery(input: {\n type: string;\n ids: readonly string[];\n truncated: boolean;\n}) {\n const collections = input.type.startsWith('product.')\n ? [productsTag()]\n : input.type.startsWith('category.')\n ? // Both, and the second one is the point: a category page is a list of\n // products, and `getProducts({ category })` is filed under the products\n // collection. Clearing only the category tag would leave every shop\n // showing yesterday's shelf under today's heading.\n [categoriesTag(), productsTag()]\n : input.type.startsWith('event.')\n ? [eventsTag()]\n : [];\n\n if (collections.length === 0) {\n return [];\n }\n\n if (input.truncated) {\n return collections;\n }\n\n const item = input.type.startsWith('product.')\n ? productTag\n : input.type.startsWith('category.')\n ? categoryTag\n : eventTag;\n\n return [...collections, ...input.ids.map((id) => item(id))];\n}\n","import { CrmApiError } from './errors';\nimport { readJson, refusalOf } from './refusal';\nimport {\n categoriesTag,\n categoryTag,\n eventTag,\n eventsTag,\n productTag,\n productsTag,\n} from './tags';\nimport type {\n CatalogArchiveResult,\n CatalogCategoryInput,\n CatalogCategoryResult,\n CatalogField,\n CatalogFieldInput,\n CatalogFieldResult,\n CatalogProductInput,\n CatalogProductResult,\n CategoryList,\n ChatWidgetInput,\n ChatWidgetList,\n ChatWidgetResult,\n CategoryPage,\n ContactInput,\n ContactResult,\n EventAvailability,\n EventDetail,\n EventList,\n FormSubmissionInput,\n FormList,\n FormSubmissionResult,\n OrderInput,\n OrderResult,\n ProductDetail,\n ProductList,\n RequestInput,\n PlacementBookingPage,\n PlacementSiteTracking,\n RequestResult,\n SiteTrackingInput,\n SiteTrackingResult,\n StockStatus,\n} from './types';\n\nconst KEY_FORM = /^crm[ps]_[A-Za-z0-9_-]{43}$/;\nconst SECRET_PREFIX = 'crms_';\nconst BASE_PATH = '/api/public/v1';\nconst DEFAULT_REVALIDATE = 60;\n\n/**\n * The fetch options this package sets that are not in the web standard.\n *\n * `next` is read by Next.js and ignored by every other runtime, which is\n * exactly the behaviour wanted: the tags do nothing outside a framework that\n * caches, and nothing breaks in one that does not.\n */\ntype CachedInit = RequestInit & {\n next?: { tags?: string[]; revalidate?: number | false };\n};\n\nexport type FetchLike = (input: string, init?: CachedInit) => Promise<Response>;\n\nexport interface CrmClientOptions {\n /**\n * The key, whole.\n *\n * A `crmp_` key may be read by anyone who views source and may only read; a\n * `crms_` key may write and must never leave a server. This client refuses a\n * `crms_` key outright when it finds itself in a browser — see the note on\n * `createClient`.\n */\n apiKey: string;\n /**\n * Where the CRM lives: an origin, with no path on it.\n *\n * `https://app.example.com`, not `https://app.example.com/api/public/v1`.\n * The version lives in this package so that a shop upgrading the package is\n * the thing that moves it, rather than a string in someone's environment\n * file that nobody remembers to change.\n */\n baseUrl: string;\n /**\n * How long a cached answer may be served before it is refetched, in seconds.\n *\n * Defaults to a minute. `false` means never on a timer — only when a webhook\n * clears the tag. That is the right setting for a shop whose revalidation\n * route is wired up and reachable, and the wrong one for a shop where it is\n * not, because then nothing ever expires at all.\n */\n revalidate?: number | false;\n /** For tests and for runtimes with their own instrumented fetch. */\n fetch?: FetchLike;\n}\n\nexport interface ListOptions {\n limit?: number;\n cursor?: string | null;\n}\n\nexport interface ProductListOptions extends ListOptions {\n /**\n * A category id, and it means that category's whole branch.\n *\n * Passing something that is not a uuid is refused, loudly.\n */\n category?: string;\n /**\n * One catalogue, by the slug of its product object: `product`, or one the\n * seller made. Left out, every catalogue is listed; each product says which\n * it is in (`object`).\n */\n object?: string;\n}\n\nexport interface CategoryListOptions extends ListOptions {\n /**\n * One level of the tree instead of all of it.\n *\n * `'root'` asks for the top level; a category id asks for its children.\n * Leaving it out returns every published category, which is what a shop\n * building a menu in one go wants.\n */\n parent?: string | 'root';\n}\n\nexport interface EventListOptions extends ListOptions {\n /** ISO timestamps. Both ends are optional and both are inclusive. */\n from?: string;\n to?: string;\n}\n\nexport interface WriteOptions {\n /**\n * The key that makes a retry safe.\n *\n * Generated per call when you leave it out, which is right for a first\n * attempt and wrong for a retry: a website that timed out does not know\n * whether the order arrived, and sending it again under a *new* key is how\n * one customer gets charged twice. Keep the key you used and resend with it.\n */\n idempotencyKey?: string;\n}\n\nexport interface CrmClient {\n getProducts(options?: ProductListOptions): Promise<ProductList>;\n getProduct(slugOrId: string): Promise<ProductDetail | null>;\n getCategories(options?: CategoryListOptions): Promise<CategoryList>;\n /** The category, its breadcrumb, and the first page of its products. */\n getCategory(\n slugOrId: string,\n options?: ListOptions,\n ): Promise<CategoryPage | null>;\n /** Live. Never cached, at any layer. */\n getAvailability(slugOrId: string): Promise<StockStatus | null>;\n getEvents(options?: EventListOptions): Promise<EventList>;\n getEvent(idOrSlug: string): Promise<EventDetail | null>;\n /** Live. Never cached, at any layer. */\n getEventAvailability(eventId: string): Promise<EventAvailability | null>;\n submitOrder(input: OrderInput, options?: WriteOptions): Promise<OrderResult>;\n upsertContact(\n input: ContactInput,\n options?: WriteOptions,\n ): Promise<ContactResult>;\n submitRequest(\n input: RequestInput,\n options?: WriteOptions,\n ): Promise<RequestResult>;\n submitForm(\n formId: string,\n input: FormSubmissionInput,\n options?: WriteOptions,\n ): Promise<FormSubmissionResult>;\n /**\n * Make or change one product, found by the id it has in your own system.\n * Needs a `crms_` key with `catalog:write`. Sending the same body again\n * changes nothing and answers `created: false`.\n */\n upsertProduct(\n externalRef: string,\n input: CatalogProductInput,\n options?: WriteOptions,\n ): Promise<CatalogProductResult>;\n /** Take a product and its variants out of the catalogue. Archived, never deleted. */\n archiveProduct(\n externalRef: string,\n source: string,\n options?: WriteOptions,\n ): Promise<CatalogArchiveResult>;\n /** Make or change one category, by its address. */\n upsertCategory(\n slug: string,\n input: CatalogCategoryInput,\n options?: WriteOptions,\n ): Promise<CatalogCategoryResult>;\n /** Make or change one field of the catalogue, by its slug. */\n upsertField(\n slug: string,\n input: CatalogFieldInput,\n options?: WriteOptions,\n ): Promise<CatalogFieldResult>;\n /** The fields the catalogue publishes, with their choices: what a filter is built from. */\n getFields(): Promise<CatalogField[]>;\n /**\n * The account's published forms, each with its link and the snippet that\n * places it on a page. Needs a `crms_` key with `website:manage`. Forms are\n * made in the app's builder; a draft is not listed. Live, never cached.\n */\n getForms(options?: ListOptions): Promise<FormList>;\n /** The account's chat widgets, with the sites each may run on. Live. */\n getChatWidgets(options?: ListOptions): Promise<ChatWidgetList>;\n /** Make or change one chat widget, by its address. */\n upsertChatWidget(\n slug: string,\n input: ChatWidgetInput,\n options?: WriteOptions,\n ): Promise<ChatWidgetResult>;\n /** The website tracking script, or null when tracking was never set up. Live. */\n getSiteTracking(): Promise<PlacementSiteTracking | null>;\n /** Set tracking up when needed, and change its hosts or consent signal. */\n updateSiteTracking(\n input: SiteTrackingInput,\n options?: WriteOptions,\n ): Promise<SiteTrackingResult>;\n /** Every page a visitor can book on, with its link and, where there is one, its widget. Live. */\n getBookingPages(): Promise<PlacementBookingPage[]>;\n}\n\n/**\n * A client for one account's public API.\n *\n * Two layers, and the names are the whole warning. `getProducts` and\n * `getProduct` are cached at the edge and filed under tags the shipped\n * revalidation route knows how to clear. `getAvailability` is not cached\n * anywhere and never will be — a stock figure in a cached answer is the number\n * that lies first and lies worst, and a developer who reaches for `getProduct`\n * to show \"in stock\" gets no warning from the type system, so the split has to\n * be in the name.\n *\n * The browser check is not decoration either. A `crms_` key can create orders\n * and read contacts; pasted into a client component it ends up in a JavaScript\n * bundle that anybody can open. Throwing at construction turns that into a\n * build-time failure on the first render instead of a quiet leak.\n */\nexport function createClient(options: CrmClientOptions): CrmClient {\n const apiKey = options.apiKey?.trim() ?? '';\n\n if (!KEY_FORM.test(apiKey)) {\n throw new Error(\n 'crm-client: the API key is not in the expected form. It starts with ' +\n '`crmp_` or `crms_` and is 48 characters long — a truncated paste ' +\n 'looks exactly like a revoked key once it reaches the server.',\n );\n }\n\n if (apiKey.startsWith(SECRET_PREFIX) && inSomethingTheUserCanRead()) {\n throw new Error(\n 'crm-client: a `crms_` key may not be used in a browser. It can write ' +\n 'orders and read contacts, and anything a browser holds is public — ' +\n 'including a service worker or a web worker, which is why this is ' +\n 'refused there too. Read from a server component or a route handler, ' +\n 'or issue a `crmp_` key for the parts of the catalogue a page reads ' +\n 'directly.',\n );\n }\n\n const base = options.baseUrl?.replace(/\\/+$/, '') ?? '';\n\n if (!/^https?:\\/\\/[^/]+$/.test(base)) {\n throw new Error(\n `crm-client: baseUrl should be an origin and nothing more, such as ` +\n `\"https://app.example.com\". Received \"${options.baseUrl}\".`,\n );\n }\n\n const doFetch: FetchLike =\n options.fetch ?? ((input, init) => fetch(input, init));\n const revalidate = options.revalidate ?? DEFAULT_REVALIDATE;\n\n async function send<T>(\n path: string,\n init: CachedInit,\n unwrap: (body: unknown) => T,\n notFoundIsNull: boolean,\n ): Promise<T | null> {\n const url = `${base}${BASE_PATH}${path}`;\n\n const response = await doFetch(url, {\n ...init,\n headers: {\n accept: 'application/json',\n authorization: `Bearer ${apiKey}`,\n ...init.headers,\n },\n });\n\n const body = await readJson(response);\n\n if (!response.ok) {\n const refusal = refusalOf(response.status, body, url);\n\n if (notFoundIsNull && refusal.code === 'not_found') {\n return null;\n }\n\n throw refusal;\n }\n\n const answer = unwrap(body);\n\n /*\n * A 2xx whose body is not the shape this method promised. It happens: a\n * proxy that answers its own page, a CDN error dressed as a 200, a version\n * of this package older than the route it is talking to.\n *\n * Without this the value is cast and handed back typed as an answer, and\n * the failure surfaces as `TypeError: cannot read properties of undefined`\n * three frames away in somebody's shop -- the one kind of error this\n * package's whole design is meant to avoid producing.\n */\n if (answer === undefined || answer === null) {\n throw new CrmApiError({\n code: 'internal_error',\n message:\n 'The API answered successfully with a body this client did not ' +\n 'recognise. Either something between here and it replaced the ' +\n 'answer, or this package is older than the route it called.',\n status: response.status,\n requestUrl: url,\n });\n }\n\n return answer;\n }\n\n function cached(tags: string[]): CachedInit {\n return { method: 'GET', next: { tags, revalidate } };\n }\n\n const live: CachedInit = { method: 'GET', cache: 'no-store' };\n\n function write(\n body: unknown,\n options?: WriteOptions,\n method: 'POST' | 'PUT' | 'DELETE' = 'POST',\n ): CachedInit {\n return {\n method,\n cache: 'no-store',\n headers: {\n 'content-type': 'application/json',\n 'idempotency-key': options?.idempotencyKey ?? newIdempotencyKey(),\n },\n body: JSON.stringify(body),\n };\n }\n\n return {\n async getProducts(listOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n if (listOptions?.category) {\n query.set('category', listOptions.category);\n }\n\n if (listOptions?.object) {\n query.set('object', listOptions.object);\n }\n\n const result = await send(\n `/products${suffix(query)}`,\n cached([productsTag()]),\n (body) => listOrNothing(body, 'data') as ProductList | undefined,\n false,\n );\n\n return result as ProductList;\n },\n\n getProduct(slugOrId) {\n return send(\n `/products/${encodeURIComponent(slugOrId)}`,\n cached([productsTag(), productTag(slugOrId)]),\n (body) => dataOf(body) as ProductDetail,\n true,\n );\n },\n\n async getCategories(listOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n if (listOptions?.parent) {\n query.set('parent', listOptions.parent);\n }\n\n const result = await send(\n `/categories${suffix(query)}`,\n cached([categoriesTag()]),\n (body) => listOrNothing(body, 'data') as CategoryList | undefined,\n false,\n );\n\n return result as CategoryList;\n },\n\n /*\n * Filed under the products collection as well as its own two tags, because\n * what this answers is mostly a page of products: a product published into\n * this category has to reach it, and a `product.changed` delivery clears\n * only the product tags.\n */\n getCategory(slugOrId, listOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n return send(\n `/categories/${encodeURIComponent(slugOrId)}${suffix(query)}`,\n cached([categoriesTag(), categoryTag(slugOrId), productsTag()]),\n (body) => body as CategoryPage,\n true,\n );\n },\n\n getAvailability(slugOrId) {\n return send(\n `/products/${encodeURIComponent(slugOrId)}/availability`,\n live,\n (body) => dataOf(body) as StockStatus,\n true,\n );\n },\n\n async getEvents(listOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n if (listOptions?.from) {\n query.set('from', listOptions.from);\n }\n\n if (listOptions?.to) {\n query.set('to', listOptions.to);\n }\n\n const result = await send(\n `/events${suffix(query)}`,\n cached([eventsTag()]),\n (body) => listOrNothing(body, 'events') as EventList | undefined,\n false,\n );\n\n return result as EventList;\n },\n\n getEvent(idOrSlug) {\n return send(\n `/events/${encodeURIComponent(idOrSlug)}`,\n cached([eventsTag(), eventTag(idOrSlug)]),\n (body) => dataOf(body) as EventDetail,\n true,\n );\n },\n\n getEventAvailability(eventId) {\n return send(\n `/events/${encodeURIComponent(eventId)}/availability`,\n live,\n (body) => body as EventAvailability,\n true,\n );\n },\n\n async submitOrder(input, writeOptions) {\n const result = await send(\n '/orders',\n write(input, writeOptions),\n (body) => dataOf(body) as OrderResult,\n false,\n );\n\n return result as OrderResult;\n },\n\n async upsertContact(input, writeOptions) {\n const result = await send(\n '/contacts',\n write(input, writeOptions),\n (body) => dataOf(body) as ContactResult,\n false,\n );\n\n return result as ContactResult;\n },\n\n async upsertProduct(externalRef, input, writeOptions) {\n const result = await send(\n `/products/by-ref/${encodeURIComponent(externalRef)}`,\n write(input, writeOptions, 'PUT'),\n (body) => dataOf(body) as CatalogProductResult,\n false,\n );\n\n return result as CatalogProductResult;\n },\n\n async archiveProduct(externalRef, source, writeOptions) {\n const result = await send(\n `/products/by-ref/${encodeURIComponent(externalRef)}?source=${encodeURIComponent(source)}`,\n write({}, writeOptions, 'DELETE'),\n (body) => dataOf(body) as CatalogArchiveResult,\n false,\n );\n\n return result as CatalogArchiveResult;\n },\n\n async upsertCategory(slug, input, writeOptions) {\n const result = await send(\n `/categories/by-slug/${encodeURIComponent(slug)}`,\n write(input, writeOptions, 'PUT'),\n (body) => dataOf(body) as CatalogCategoryResult,\n false,\n );\n\n return result as CatalogCategoryResult;\n },\n\n async upsertField(slug, input, writeOptions) {\n const result = await send(\n `/attributes/by-slug/${encodeURIComponent(slug)}`,\n write(input, writeOptions, 'PUT'),\n (body) => dataOf(body) as CatalogFieldResult,\n false,\n );\n\n return result as CatalogFieldResult;\n },\n\n async getFields() {\n const result = await send(\n '/attributes',\n cached([productsTag()]),\n (body) => {\n const data = dataOf(body);\n\n return Array.isArray(data) ? (data as CatalogField[]) : undefined;\n },\n false,\n );\n\n return result as CatalogField[];\n },\n\n async getForms(listOptions) {\n const result = await send(\n `/forms${suffix(pageQuery(listOptions))}`,\n live,\n (body) => listOrNothing(body, 'data') as FormList | undefined,\n false,\n );\n\n return result as FormList;\n },\n\n async getChatWidgets(listOptions) {\n const result = await send(\n `/chat-widgets${suffix(pageQuery(listOptions))}`,\n live,\n (body) => listOrNothing(body, 'data') as ChatWidgetList | undefined,\n false,\n );\n\n return result as ChatWidgetList;\n },\n\n async upsertChatWidget(slug, input, writeOptions) {\n const result = await send(\n `/chat-widgets/by-slug/${encodeURIComponent(slug)}`,\n write(input, writeOptions, 'PUT'),\n (body) => dataOf(body) as ChatWidgetResult,\n false,\n );\n\n return result as ChatWidgetResult;\n },\n\n async getSiteTracking() {\n // `{ data: null }` is an answer here -- tracking not set up -- so the\n // body is unwrapped into a holder rather than handed to `send` as null,\n // which `send` rightly reads as a body it did not recognise.\n const result = await send(\n '/site-tracking',\n live,\n (body) =>\n body !== null && typeof body === 'object' && 'data' in body\n ? { site: dataOf(body) as PlacementSiteTracking | null }\n : undefined,\n false,\n );\n\n return result?.site ?? null;\n },\n\n async updateSiteTracking(input, writeOptions) {\n const result = await send(\n '/site-tracking',\n write(input, writeOptions, 'PUT'),\n (body) => dataOf(body) as SiteTrackingResult,\n false,\n );\n\n return result as SiteTrackingResult;\n },\n\n async getBookingPages() {\n const result = await send(\n '/booking-pages',\n live,\n (body) => {\n const data = dataOf(body);\n\n return Array.isArray(data)\n ? (data as PlacementBookingPage[])\n : undefined;\n },\n false,\n );\n\n return result as PlacementBookingPage[];\n },\n\n async submitRequest(input, writeOptions) {\n const result = await send(\n '/requests',\n write(input, writeOptions),\n (body) => dataOf(body) as RequestResult,\n false,\n );\n\n return result as RequestResult;\n },\n\n async submitForm(formId, input, writeOptions) {\n const result = await send(\n `/forms/${encodeURIComponent(formId)}/submissions`,\n write(input, writeOptions),\n (body) => dataOf(body) as FormSubmissionResult,\n false,\n );\n\n return result as FormSubmissionResult;\n },\n };\n}\n\nfunction suffix(query: URLSearchParams) {\n const rendered = query.toString();\n\n return rendered ? `?${rendered}` : '';\n}\n\nfunction pageQuery(listOptions?: ListOptions) {\n const query = new URLSearchParams();\n\n if (listOptions?.limit !== undefined) {\n query.set('limit', `${listOptions.limit}`);\n }\n\n if (listOptions?.cursor) {\n query.set('cursor', listOptions.cursor);\n }\n\n return query;\n}\n\nfunction dataOf(body: unknown) {\n return (body as { data: unknown } | null)?.data;\n}\n\n/**\n * A listing, or nothing at all when the body is not one.\n *\n * `undefined` is the signal `send` turns into a legible error. Checking for the\n * array rather than merely for the key matters: a proxy answering its own page,\n * or a CDN dressing an error as a 200, produces a body with neither — and\n * without this the caller receives it typed as a page of products and finds out\n * when `.map` is not a function, somewhere else entirely.\n *\n * The key differs per listing because the events one answers `events` rather\n * than `data`, which it has done since before this convention existed.\n */\nfunction listOrNothing(body: unknown, key: 'data' | 'events') {\n const listing = body as Record<string, unknown> | null;\n\n return Array.isArray(listing?.[key]) ? listing : undefined;\n}\n\n/**\n * A fresh key for a first attempt.\n *\n * Deliberately not derived from the body. Two identical orders placed a minute\n * apart by the same customer are two orders, and a fingerprint would quietly\n * collapse them into one.\n *\n * `crypto.randomUUID` is not everywhere it looks like it is: browsers expose it\n * only in a secure context, so a shop still served over plain http has\n * `crypto` and not that method. Reaching for it unguarded turns every write\n * into `TypeError: crypto.randomUUID is not a function`, thrown while building\n * the headers, before any request — which tells the shop's developer nothing\n * about what is actually missing.\n *\n * The weakest fallback is acceptable *here specifically*, and nowhere else in\n * this package: an idempotency key needs to be unique within one account for a\n * day. It is not a credential, it guesses nothing and guards nothing; a caller\n * who wants a stronger one passes their own.\n */\nfunction newIdempotencyKey() {\n const source = globalThis.crypto;\n\n if (typeof source?.randomUUID === 'function') {\n return source.randomUUID();\n }\n\n if (typeof source?.getRandomValues === 'function') {\n const bytes = source.getRandomValues(new Uint8Array(16));\n\n return [...bytes]\n .map((byte) => byte.toString(16).padStart(2, '0'))\n .join('');\n }\n\n return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;\n}\n\n/**\n * Whether this is somewhere an end user could read what the code holds.\n *\n * `typeof window !== 'undefined'` is the usual test and it is not enough: a\n * service worker and a web worker both have no `window` and are both JavaScript\n * delivered to a browser, openable in devtools like any other script. Treating\n * \"no window\" as \"a trusted server\" is exactly the assumption that lets a\n * writing key into an offline bundle.\n */\nfunction inSomethingTheUserCanRead() {\n if (typeof window !== 'undefined') {\n return true;\n }\n\n const scope = globalThis as { importScripts?: unknown; self?: unknown };\n\n return typeof scope.importScripts === 'function';\n}\n","// GENERATED FILE -- edit the projections, not this.\n//\n// Every declaration below was lifted, comments and all, out of the services\n// that build the public API's answers. Run `pnpm --filter @solumflow-app/crm-client\n// generate:types` after changing one of them; a test in this package fails\n// when this file and those sources disagree.\n//\n// Sources:\n// packages/features/crm-invoicing/src/shared/public-product-item.ts\n// packages/features/crm-invoicing/src/shared/public-category-item.ts\n// packages/features/crm-events/src/shared/public-event-list-item.ts\n// packages/features/crm-events/src/shared/public-event-detail-item.ts\n// packages/api-keys/src/webhook-events.ts\n// apps/web/app/api/public/v1/_lib/website-placement-items.ts\n// packages/api-keys/src/scopes.ts\n// apps/web/app/api/public/_lib/errors.ts\n// packages/features/crm-invoicing/src/schema/product.schema.ts\n\n/** What a key is allowed to reach. A key carries one or more of these. */\nexport type ApiScope =\n | 'catalog:read'\n | 'catalog:write'\n | 'events:read'\n | 'orders:write'\n | 'contacts:write'\n | 'forms:write'\n | 'website:manage';\n\nexport const ALL_API_SCOPES: readonly ApiScope[] = [\n 'catalog:read',\n 'catalog:write',\n 'events:read',\n 'orders:write',\n 'contacts:write',\n 'forms:write',\n 'website:manage',\n];\n\n/** Everything an endpoint can be told about. A delivery names one. */\nexport type ApiWebhookEvent =\n | 'product.changed'\n | 'product.deleted'\n | 'category.changed'\n | 'event.changed'\n | 'order.status_changed';\n\nexport const ALL_API_WEBHOOK_EVENTS: readonly ApiWebhookEvent[] = [\n 'product.changed',\n 'product.deleted',\n 'category.changed',\n 'event.changed',\n 'order.status_changed',\n];\n\n/** The `error.code` of a refusal. Match on this, never on the message -- the message is written for a person reading a log and may be reworded. */\nexport type ApiErrorCode =\n | 'unauthorized'\n | 'forbidden'\n | 'feature_unavailable'\n | 'not_found'\n | 'invalid_request'\n | 'conflict'\n | 'idempotency_key_reused'\n | 'request_in_progress'\n | 'rate_limited'\n | 'internal_error';\n\nexport const ALL_API_ERROR_CODES: readonly ApiErrorCode[] = [\n 'unauthorized',\n 'forbidden',\n 'feature_unavailable',\n 'not_found',\n 'invalid_request',\n 'conflict',\n 'idempotency_key_reused',\n 'request_in_progress',\n 'rate_limited',\n 'internal_error',\n];\n\n/** What a product is, for the one question the answer changes: does anything get shipped. Whether it repeats is a property of its price, not of this. */\nexport type ProductType = 'digital' | 'physical' | 'service';\n\nexport const ALL_PRODUCT_TYPES: readonly ProductType[] = [\n 'digital',\n 'physical',\n 'service',\n];\n\nexport interface PublicProductListItem {\n id: string;\n /**\n * Which catalogue the product is in, by the slug the seller gave it:\n * `product` for the catalogue every account has, something like `courses`\n * for one of its own. The listing takes the same word as `?object=`.\n */\n object: string;\n /** Always present: a product without an address cannot be published. */\n slug: string;\n name: string;\n productType: ProductType;\n /**\n * The storage path, not a URL. The route turns it into a public URL, because\n * only the route knows the origin its own storage is served from.\n */\n imagePath: string | null;\n /**\n * The cheapest way in, over the active price options — not the default one.\n * A card that says \"from €12\" next to a product whose entry price is €12 and\n * whose default is the €40 yearly plan is answering the question the visitor\n * actually asked.\n *\n * Never null, unlike the same field on an event. An event without a ticket\n * tier on sale genuinely has no price; a product always has `unit_price_cents`\n * on its own row, so when no separate price option is active that column is\n * the answer — including when it is `0`, which is a real price for something\n * given away.\n */\n priceFromCents: number;\n /** The struck-through price beside it, when the seller set one. */\n compareAtCents: number | null;\n currency: string;\n /**\n * Whether a visitor can buy it right now — never how many are left.\n *\n * True whenever the seller does not track stock at all, which is the common\n * case and the honest answer for anything made to order. When they do track\n * it, this counts what is on the shelf minus what other people already hold\n * in a checkout, and it stays true for a seller who accepts backorders.\n */\n inStock: boolean;\n /**\n * The custom fields this account invented for this product's catalogue,\n * keyed by the slug they chose, and only the ones marked public there. An\n * empty object when none are.\n */\n fields: Record<string, unknown>;\n}\n\n/**\n * The same product on its own page, where more may be shown.\n *\n * The detail answers for things the listing hides — an unlisted product, one\n * reachable by anyone holding the link — for the same reason the events detail\n * answers for a cancelled evening: somebody was sent here on purpose, and a 404\n * would be a lie. What it still refuses is anything unpublished.\n */\nexport interface PublicProductDetail extends PublicProductListItem {\n /** The one-line text that lands on a quote or an invoice row. */\n description: string | null;\n /** The long story, as sanitised HTML. Written by the seller, for this page. */\n longDescription: string | null;\n /** The barcode number: printed on the box, wanted by every product feed. */\n gtin: string | null;\n metaTitle: string | null;\n metaDescription: string | null;\n /**\n * What a link to this product should show when somebody shares it, with\n * every fallback already applied.\n *\n * Resolved here rather than left to the site, and `metaTitle` beside it is\n * why: a site given both would have to decide which wins when one is empty,\n * and every site would decide slightly differently. The screen that edits\n * these shows a preview, and that preview and this field are the same\n * function -- so what a seller saw is what a stranger gets.\n */\n social: {\n title: string;\n description: string | null;\n /** A path, turned into a URL by the route. Same reason as `imagePath`. */\n imagePath: string | null;\n };\n images: PublicProductImage[];\n prices: PublicProductPrice[];\n categories: PublicProductCategory[];\n /** What the seller linked this to — a companion, a refill, a bigger model. */\n related: PublicProductSummary[];\n /**\n * The fields this product's variants differ along — a width, a colour —\n * each with the values somebody can choose, in the field's own order.\n * Empty for a product sold as one thing.\n */\n variantAxes: PublicProductVariantAxis[];\n /**\n * One entry per variant still on sale, in the seller's order. A shop\n * orders a variant by its `id` or by the `id` of one of its prices.\n * Pictures are the variant's own; a variant without any shows the\n * product's.\n */\n variants: PublicProductVariant[];\n}\n\nexport interface PublicProductImage {\n /** A path, turned into a URL by the route. Same reason as `imagePath`. */\n path: string;\n alt: string | null;\n}\n\n/**\n * One way to buy the thing.\n *\n * `stripe_price_id` and `stripe_account_id` are absent and must stay absent:\n * they name objects in the seller's payment account, and a checkout is started\n * through this system's own order route, never by a stranger quoting an id.\n */\nexport interface PublicProductPrice {\n id: string;\n kind: 'one_off' | 'recurring' | 'installment';\n label: string | null;\n amountCents: number;\n /** What the whole thing costs when paid in parts; null for the other kinds. */\n totalCents: number | null;\n installmentCount: number | null;\n recurringInterval: 'day' | 'week' | 'month' | 'year' | null;\n recurringIntervalCount: number | null;\n compareAtCents: number | null;\n trialPeriodDays: number | null;\n isDefault: boolean;\n}\n\n/**\n * One category this product sits in, as a page a visitor could land on.\n *\n * It used to be a record of an object the account invented, linked through\n * `crm_record_links`, and it carried a name and nothing else. It is now a real\n * category: a page with an address, a place in a tree, and one of them marked\n * as the one this product belongs to most.\n *\n * `slug` is nullable even though only published categories are listed here,\n * and that is not belt-and-braces: it keeps the type honest against the day a\n * caller asks for the categories of something that is not on sale yet.\n *\n * `path` runs from the top down and **ends with this category itself**, so a\n * breadcrumb is the array as given, with no element to append. A step whose\n * `slug` is null is a step to print rather than to link -- an ancestor that is\n * still a draft is not a page anybody can open.\n */\nexport interface PublicProductCategory {\n id: string;\n name: string;\n slug: string | null;\n /** Root first, this category last. Never empty. */\n path: PublicCategoryStep[];\n /** The one that carries the breadcrumb and the structured data. */\n isPrimary: boolean;\n}\n\n/** One step on the way down to a category. */\nexport interface PublicCategoryStep {\n id: string;\n name: string;\n /** Null when this step has no page of its own yet: print it, do not link it. */\n slug: string | null;\n}\n\nexport interface PublicProductSummary {\n id: string;\n /** The catalogue it is in, the same word a listing item carries. */\n object: string;\n slug: string;\n name: string;\n}\n\nexport interface PublicProductVariantAxis {\n slug: string;\n label: string;\n values: string[];\n}\n\nexport interface PublicProductVariant {\n id: string;\n /** The value per axis, keyed by the axis slug. */\n options: Record<string, string>;\n /** The cheapest active price of this variant, as on the product. */\n priceFromCents: number;\n inStock: boolean;\n prices: PublicProductPrice[];\n images: PublicProductImage[];\n}\n\n/**\n * One category as a stranger's website sees it.\n *\n * The same contract rules as `public-product-item.ts`: these keys are read by\n * shops nobody here redeploys, so fields may be added and must not be renamed\n * or repurposed.\n *\n * What is deliberately absent:\n *\n * - **`active`.** It says whether a member still files products under this\n * category, which is a fact about their catalogue rather than about the\n * page. A shop that read it would hide a page that is plainly published.\n * - **`sortOrder`.** The order is the order of the array. A number would\n * invite a shop to sort by it across pages, where it does not mean what it\n * looks like -- it is unique only among siblings.\n * - **Anything that counts drafts.** `productCount` is what a visitor would\n * find, counted the same way the product list selects. A number larger than\n * the page it heads is a promise the page then breaks.\n */\nexport interface PublicCategoryListItem {\n id: string;\n /** Always present: a category without an address is not published. */\n slug: string;\n name: string;\n /** Where it hangs, so a shop can build the tree from a flat page. */\n parentId: string | null;\n description: string | null;\n /**\n * The storage path, not a URL. The route turns it into a public URL, because\n * only the route knows the origin its own storage is served from.\n */\n imagePath: string | null;\n /**\n * The way down from the top, ending with this category.\n *\n * Here as well as on the detail, and not left to a shop to assemble from\n * `parentId`. `?parent=<id>` answers one level, so a menu that unfolds has\n * no ancestors to build from -- and a shop that stitched a breadcrumb\n * together itself here while reading `path` on the detail page would have\n * two sources for one road. They agree until somebody moves a category, and\n * then the visible trail and the `BreadcrumbList` beside it say different\n * things, which is the one contradiction a search engine reads as a fault.\n *\n * A step whose `slug` is null is an ancestor with no page of its own yet --\n * print it, do not link it.\n */\n path: PublicCategoryStep[];\n /** Public products on this category's page, its whole branch included. */\n productCount: number;\n /**\n * When this category last changed, for `lastmod` in a shop's sitemap.\n *\n * It is the moment the *category* was edited: its text, its address, its\n * place in the tree. A product moving onto its shelf does not move it, so a\n * shop that wants its category pages recrawled when the contents change has\n * to take the products into account as well.\n *\n * **Not the same kind of fact as `publishedAt`, which is deliberately absent\n * here.** A publication moment may lie in the future, and then it is a plan:\n * it tells a stranger when a shop intends to launch something. This one is\n * always in the past and says only that the page they can already see was\n * touched -- which is exactly what a sitemap publishes anyway.\n */\n updatedAt: string;\n}\n\nexport interface PublicCategoryDetail {\n id: string;\n slug: string;\n name: string;\n parentId: string | null;\n description: string | null;\n imagePath: string | null;\n metaTitle: string | null;\n metaDescription: string | null;\n /**\n * What a link to this page looks like when somebody shares it.\n *\n * Filled in by the same function the seller previewed it with, so what they\n * saw is what a stranger gets.\n */\n social: {\n title: string;\n description: string | null;\n /** A path, turned into a URL by the route. Same reason as `imagePath`. */\n imagePath: string | null;\n };\n /**\n * The way down from the top, ending with this category.\n *\n * The breadcrumb, as given. A step whose `slug` is null is an ancestor with\n * no page of its own yet -- print it, do not link it.\n */\n path: PublicCategoryStep[];\n productCount: number;\n /** When the category itself last changed. Same meaning as on the listing. */\n updatedAt: string;\n /** The first page of them; `nextCursor` beside the answer carries the rest. */\n products: PublicProductListItem[];\n}\n\n/**\n * One event as a stranger's website sees it.\n *\n * This interface is the public API's contract, not an internal convenience\n * type. It is served cross-origin to pages nobody here controls, and a widget\n * on a customer's homepage reads these exact keys — so a field removed or\n * renamed breaks a site that will not be redeployed. Add fields; do not\n * repurpose them.\n *\n * What is deliberately absent is as much the contract as what is here: no\n * description (a paragraph no card renders), no joining link (that is\n * admission), no counts of who bought what, and nothing an organiser uses to\n * manage the event.\n */\nexport interface PublicEventListItem {\n id: string;\n slug: string;\n title: string;\n subtitle: string | null;\n startsAt: string;\n endsAt: string | null;\n timezone: string;\n locationName: string | null;\n /**\n * The storage path, not a URL. The route turns it into a public URL, because\n * only the route knows the origin its own storage is served from.\n */\n coverImagePath: string | null;\n /** Null when nothing is on sale — never 0, which is a real price. */\n priceFromCents: number | null;\n currency: string | null;\n soldOut: boolean;\n /** Null when the event has no ceiling to count down from. */\n seatsLeft: number | null;\n /** Path on the hosted site, so a widget can link straight to checkout. */\n url: string;\n /**\n * The custom fields this account invented, keyed by the slug they chose, and\n * only the ones marked public. An empty object when none are, which is every\n * account until somebody turns one on.\n *\n * Added rather than repurposed, which is the rule this interface states at\n * the top: a widget written before this field existed keeps working, because\n * it simply never reads the key.\n */\n fields: Record<string, unknown>;\n}\n\n/**\n * One event as the public API hands it out, on its own page.\n *\n * Not the same thing as `PublicEventView`, which feeds the detail page this\n * system hosts — and the difference is the reason this type exists rather than\n * the other one being reused. That view carries `online_url`: the link you join\n * the evening by. On a page we host, behind a ticket, that is correct. Through\n * an API that answers any holder of a read key, it is a free seat.\n *\n * It also carries `account_id`, which is ours and not the caller's business.\n *\n * So this is a separate, narrower projection, and everything in\n * `PublicEventListItem` about adding rather than repurposing fields applies\n * here too.\n */\nexport interface PublicEventDetailItem extends PublicEventListItem {\n /** The organiser's own text for the evening. */\n description: string | null;\n /**\n * `on_sale`, `closed` or `cancelled` — never `draft`, which does not answer\n * at all. A ticket holder has to be able to read that the evening is off, so\n * this route replies for the last two where the listing withholds them.\n */\n status: 'on_sale' | 'closed' | 'cancelled';\n doorsOpenAt: string | null;\n salesStartAt: string | null;\n salesEndAt: string | null;\n /** The postal address, as the organiser entered it. */\n address: unknown;\n metaTitle: string | null;\n metaDescription: string | null;\n ticketTypes: PublicEventTicketType[];\n /** Custom fields the account marked public, keyed by their own slug. */\n fields: Record<string, unknown>;\n}\n\n/**\n * One way in, as a stranger's page may show it.\n *\n * `reserved_count` and `sold_count` are absent: how many were sold is the\n * organiser's figure, and `seatsLeft` already answers the only question a\n * visitor has. The capacity behind it stays home for the same reason a stock\n * count does.\n */\nexport interface PublicEventTicketType {\n id: string;\n name: string;\n description: string | null;\n priceCents: number;\n currency: string;\n taxPercentage: number | null;\n minPerOrder: number | null;\n maxPerOrder: number | null;\n salesStartAt: string | null;\n salesEndAt: string | null;\n /** Null when this tier has no ceiling to count down from. */\n seatsLeft: number | null;\n soldOut: boolean;\n onSale: boolean;\n}\n\n/**\n * The body of one delivery.\n *\n * IDS AND NOTHING ELSE, and the three reasons are worth keeping together. A\n * message that arrives late still leads to the right state, because the\n * receiver fetches the current row rather than trusting a snapshot from twenty\n * minutes ago. A message can never hand out something the receiving key was\n * not allowed to read, because it hands out nothing. And five hundred changes\n * fit in a body of a few kilobytes instead of a few megabytes.\n *\n * `truncated` means the batch stopped collecting at its ceiling and `ids` is\n * therefore incomplete: the receiver should refetch the whole collection. It\n * is always present rather than only when true, so a receiver that reads it\n * cannot mistake \"absent\" for \"false\" in the one direction that matters.\n */\nexport interface ApiWebhookPayload {\n type: ApiWebhookEvent;\n occurredAt: string;\n ids: string[];\n truncated: boolean;\n}\n\n/** A published form, ready to be placed on a page. */\nexport interface PlacementForm {\n id: string;\n slug: string;\n name: string;\n /** The form on a page of its own, for a plain link. */\n url: string;\n /** An empty `div` naming the form and the script that fills it. */\n embed: string;\n}\n\n/** A chat widget and what it takes to put it on a site. */\nexport interface PlacementChatWidget {\n id: string;\n slug: string;\n name: string;\n status: 'draft' | 'published' | 'archived';\n /**\n * The sites the widget may run on. A site not listed here gets an empty\n * corner, with the reason only in the browser's console.\n */\n allowedOrigins: string[];\n /** The script tag, or null while the widget is not published. */\n embed: string | null;\n}\n\n/** The website tracking script of the account. */\nexport interface PlacementSiteTracking {\n /** Public by design: it is in the page source of every tracked site. */\n publicKey: string;\n /** The hosts the script reports from; visits from any other are dropped. */\n allowedHosts: string[];\n /**\n * The consent the script waits for before it does anything:\n * `ad_storage`, `analytics_storage`, `personalization_storage`, or\n * `api_only` (it waits until the page says so itself).\n */\n consentSignal:\n | 'ad_storage'\n | 'analytics_storage'\n | 'personalization_storage'\n | 'api_only';\n lastReceivedAt: string | null;\n embed: string;\n}\n\n/** A page a visitor can book on. */\nexport interface PlacementBookingPage {\n /**\n * `services`: every service the account offers online. `category` and\n * `service`: narrowed to one. `calendar`: an appointment calendar of its own.\n */\n kind: 'services' | 'category' | 'service' | 'calendar';\n /** The category, service or calendar; null for `services`. */\n id: string | null;\n name: string;\n /** The page's address. Built on the id where there is one, so a rename does not break it. */\n url: string;\n /**\n * An iframe plus the script that sizes it, or null for a `calendar`, which\n * has no embeddable widget yet: link to `url` instead.\n */\n embed: string | null;\n}\n"],"mappings":"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","names":["options"]}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A booking page, embedded: the iframe the "Share booking" dialog hands out,
3
+ * plus the script that makes it as tall as what is inside it.
4
+ *
5
+ * `url` is a booking page's address as the API lists it
6
+ * (`getBookingPages()`), and it must be one of the app's own: the script only
7
+ * listens to frames on that origin. React 19 loads the script once per page,
8
+ * however many of these are on it.
9
+ */
10
+ export declare function CrmBooking(props: {
11
+ url: string;
12
+ /** The frame's accessible name, such as the service's name. */
13
+ title: string;
14
+ appOrigin?: string;
15
+ className?: string;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The chat widget of the account, in the corner of every page it is rendered
3
+ * on. Put it in the layout, once: the chat script reads its settings off its
4
+ * own tag and runs once per page.
5
+ *
6
+ * Only a published widget answers, and only on a site in its list of allowed
7
+ * origins (an empty list allows any site). `hideOnPaths` and
8
+ * `loadOnInteraction` are the two switches the Embed tab puts on the tag; pass
9
+ * what `getChatWidgets()` shows in its snippet, or leave them out.
10
+ */
11
+ export declare function CrmChatWidget(props: {
12
+ widgetId: string;
13
+ appOrigin?: string;
14
+ hideOnPaths?: readonly string[];
15
+ loadOnInteraction?: boolean;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A published form, drawn in place.
3
+ *
4
+ * The iframe is drawn here rather than left to `embed/v1/embed.js`, because
5
+ * that loader looks for forms once, when it loads: a form that appears after a
6
+ * client-side navigation would stay an empty box. What the loader does besides
7
+ * drawing the frame is honoured here, on the same contract:
8
+ *
9
+ * - `crm-form:resize` sets the frame's height, so it never scrolls inside;
10
+ * - `crm-form:submit` with an `https://` `redirectUrl` sends the whole page
11
+ * there, and nothing else ever navigates it.
12
+ *
13
+ * Both are trusted only from the app's own origin *and* from this frame's own
14
+ * window: another frame on the page can send the same shape.
15
+ */
16
+ export declare function CrmForm(props: {
17
+ formId: string;
18
+ appOrigin?: string;
19
+ /**
20
+ * The frame's accessible name, in your site's own language: what a screen
21
+ * reader announces before the form. Required, because this package has no
22
+ * language of its own to fall back on.
23
+ */
24
+ title: string;
25
+ className?: string;
26
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The website tracking script. Put it in the layout, once.
3
+ *
4
+ * It does nothing until the visitor has given the consent the account waits
5
+ * for, and visits only count from hosts on the account's list -- an empty list
6
+ * counts nothing. `publicKey` is public by design; it is in the page source of
7
+ * every tracked site.
8
+ */
9
+ export declare function CrmSiteTracking(props: {
10
+ publicKey: string;
11
+ appOrigin?: string;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * The app's address, once for every component below it, so a site does not
4
+ * repeat it on each form, booking page and chat. The same origin as the
5
+ * client's `baseUrl`: `https://app.example.com`, without a path.
6
+ */
7
+ export declare function CrmEmbedProvider(props: {
8
+ appOrigin: string;
9
+ children: ReactNode;
10
+ }): import("react").JSX.Element;
11
+ /** The origin from the prop, else from the provider; refuses to guess one. */
12
+ export declare function useEmbedOrigin(appOrigin: string | undefined): string;