@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/README.md +146 -0
- package/dist/client.d.ts +55 -2
- package/dist/generated/api-types.d.ts +247 -35
- package/dist/index.cjs +188 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +186 -8
- package/dist/index.js.map +1 -1
- package/dist/react/crm-booking.d.ts +16 -0
- package/dist/react/crm-chat-widget.d.ts +16 -0
- package/dist/react/crm-form.d.ts +26 -0
- package/dist/react/crm-site-tracking.d.ts +12 -0
- package/dist/react/embed-origin.d.ts +12 -0
- package/dist/react/index.d.ts +20 -0
- package/dist/react.cjs +163 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +133 -0
- package/dist/react.js.map +1 -0
- package/dist/tags.d.ts +11 -0
- package/dist/types.d.ts +232 -5
- package/dist/webhooks.cjs +17 -5
- package/dist/webhooks.cjs.map +1 -1
- package/dist/webhooks.js +17 -5
- package/dist/webhooks.js.map +1 -1
- package/package.json +23 -7
package/README.md
CHANGED
|
@@ -27,6 +27,8 @@ the parts that bite.
|
|
|
27
27
|
```ts
|
|
28
28
|
const { data } = await crm.getProducts(); // cached, may be a minute old
|
|
29
29
|
const product = await crm.getProduct('eiken-tafel'); // cached
|
|
30
|
+
const { data: tree } = await crm.getCategories(); // cached
|
|
31
|
+
const shelf = await crm.getCategory('eettafels'); // cached
|
|
30
32
|
const stock = await crm.getAvailability(product.id); // live, never cached
|
|
31
33
|
```
|
|
32
34
|
|
|
@@ -39,6 +41,16 @@ the example: a cached "in stock" is the first thing to go stale and the most
|
|
|
39
41
|
expensive when it does. Nothing in the type system will warn you — the split is
|
|
40
42
|
in the name because that is the only place it could be.
|
|
41
43
|
|
|
44
|
+
`getCategories` answers flat rows with a `parentId` on each, because a tree is
|
|
45
|
+
the one shape a cursor cannot page through. Build the tree yourself, or ask for
|
|
46
|
+
one level at a time with `{ parent: 'root' }` and `{ parent: id }`.
|
|
47
|
+
|
|
48
|
+
`getCategory` answers the category, its breadcrumb and the first page of its
|
|
49
|
+
products — the products of its **whole branch**, so a top-level category is not
|
|
50
|
+
an empty page above everything that hangs under it. Page them with `limit` and
|
|
51
|
+
`cursor` on the same call; `nextCursor` sits beside the category, because it
|
|
52
|
+
pages the products and not the category.
|
|
53
|
+
|
|
42
54
|
Detail lookups answer `null` when there is no such thing, so a slug somebody
|
|
43
55
|
typed wrong is an ordinary outcome:
|
|
44
56
|
|
|
@@ -52,6 +64,84 @@ Everything else — a rejected key, a missing permission, a rate limit — throw
|
|
|
52
64
|
`CrmApiError`. That difference is deliberate: if a bad key also produced `null`,
|
|
53
65
|
a misconfigured site would render "not found" on every page for ever.
|
|
54
66
|
|
|
67
|
+
## Addresses
|
|
68
|
+
|
|
69
|
+
Your site owns its URLs; this package has no say in them and no way to check
|
|
70
|
+
them. So this section is the one piece of advice in this file that you have to
|
|
71
|
+
apply yourself, and it is here because the moment to read it is while you are
|
|
72
|
+
laying out your routes.
|
|
73
|
+
|
|
74
|
+
**One product, one address.** Put products at `/products/<slug>` — one flat
|
|
75
|
+
segment — and not at `/<category>/<product>`.
|
|
76
|
+
|
|
77
|
+
The reason is not tidiness. A product may sit in more than one category; that
|
|
78
|
+
is the whole point of the tree. Nest the product under its categories and a gas
|
|
79
|
+
fire filed under both "Fires" and "Gas" has two addresses serving identical
|
|
80
|
+
content, three if somebody adds a third shelf. A search engine then has to pick
|
|
81
|
+
which of them is the real one, and it will not ask you: whichever it picks is
|
|
82
|
+
the one that collects the rankings, and any links you or anybody else built to
|
|
83
|
+
the other two point at a page it has decided is a copy. Then somebody re-files
|
|
84
|
+
the product, and every address it had 404s at once. This is the failure mode
|
|
85
|
+
WordPress shops with nested product permalinks spend years undoing, and it is
|
|
86
|
+
cheap to avoid and expensive to reverse.
|
|
87
|
+
|
|
88
|
+
**Categories do get their own path**, because a category here is a page rather
|
|
89
|
+
than a filter — its own text, its own share card, its own place to land from a
|
|
90
|
+
search. `/<category-path>/` or `/categories/<slug>`, whichever suits your site;
|
|
91
|
+
what matters is that the address belongs to the category and never changes when
|
|
92
|
+
a product moves.
|
|
93
|
+
|
|
94
|
+
**Canonical every page to itself**, including page two of a category. A paged
|
|
95
|
+
listing that canonicals back to page one tells a crawler that the products on
|
|
96
|
+
page two live on a page they are not on.
|
|
97
|
+
|
|
98
|
+
### `path`, and using one breadcrumb
|
|
99
|
+
|
|
100
|
+
Every category read carries `path`: the road from the top down, **ending with
|
|
101
|
+
the category itself**, so there is nothing to append. It is on `getCategories`
|
|
102
|
+
as well as on `getCategory` precisely so that you never build a second one —
|
|
103
|
+
render the visible trail and your `BreadcrumbList` structured data from this
|
|
104
|
+
one array. Two sources agree until somebody moves a category in the CRM, and
|
|
105
|
+
then the trail a visitor reads and the one a crawler reads say different
|
|
106
|
+
things, which is read as a fault rather than as a difference.
|
|
107
|
+
|
|
108
|
+
A step whose `slug` is `null` is an ancestor that has no page of its own yet.
|
|
109
|
+
Print its name; do not link it.
|
|
110
|
+
|
|
111
|
+
### `isPrimary`, and products in more than one place
|
|
112
|
+
|
|
113
|
+
`getProduct` answers a `categories` array, each entry carrying its own full
|
|
114
|
+
`path`, and **at most one of them has `isPrimary: true`**. That is the one whose
|
|
115
|
+
path the product's breadcrumb should follow — the seller chose it, in the CRM,
|
|
116
|
+
for exactly this.
|
|
117
|
+
|
|
118
|
+
The array may also be **empty**, and that is an ordinary product rather than a
|
|
119
|
+
broken one: not everything a shop sells is filed somewhere. Draw no breadcrumb
|
|
120
|
+
at all in that case, rather than an empty bar. The canonical is unaffected
|
|
121
|
+
either way — under the rule above it is the product's own flat address, which is
|
|
122
|
+
where it lived all along.
|
|
123
|
+
|
|
124
|
+
The array may also carry **no** primary, and that happens for a reason worth
|
|
125
|
+
knowing: the seller did mark one, but that category is not published — still a
|
|
126
|
+
draft, or scheduled for a date that has not arrived — so it is not in your
|
|
127
|
+
answer at all. Nothing is wrong and nothing is being hidden from you; the shelf
|
|
128
|
+
simply has no page yet.
|
|
129
|
+
|
|
130
|
+
The list arrives sorted, the marked one first and the rest alphabetically by
|
|
131
|
+
their whole path, so `categories[0]` is a stable choice when there is no primary
|
|
132
|
+
and the same on every render. What you must not do is pick a different one each
|
|
133
|
+
time.
|
|
134
|
+
|
|
135
|
+
### `updatedAt`, and your sitemap
|
|
136
|
+
|
|
137
|
+
Every category carries `updatedAt`, which is what `lastmod` wants. It moves when
|
|
138
|
+
the *category* changes: its name, text, address, or where it hangs. It does not
|
|
139
|
+
move when a product is published onto its shelf — so if you want a category page
|
|
140
|
+
recrawled when its contents change, take the products on it into account too.
|
|
141
|
+
|
|
142
|
+
Categories that are not published are not in the answers at all, so a sitemap
|
|
143
|
+
built from `getCategories` cannot accidentally list a page that is not there.
|
|
144
|
+
|
|
55
145
|
## Two kinds of key
|
|
56
146
|
|
|
57
147
|
| Prefix | Where it may live | What it may do |
|
|
@@ -302,6 +392,8 @@ revalidation hooks.
|
|
|
302
392
|
| --- | --- |
|
|
303
393
|
| `crm:products` | every product read, listing and detail alike |
|
|
304
394
|
| `crm:product:<id or slug>` | `getProduct`, under the key you asked with |
|
|
395
|
+
| `crm:categories` | every category read |
|
|
396
|
+
| `crm:category:<id or slug>` | `getCategory`, under the key you asked with |
|
|
305
397
|
| `crm:events` | every event read |
|
|
306
398
|
| `crm:event:<id or slug>` | `getEvent` |
|
|
307
399
|
|
|
@@ -311,6 +403,11 @@ nothing on this side can turn one into the other. Without the collection tag a
|
|
|
311
403
|
slug-fetched page would never update — and would keep working while showing the
|
|
312
404
|
old price, which is exactly the failure this package exists to prevent.
|
|
313
405
|
|
|
406
|
+
`getCategory` carries `crm:products` too, and a `category.changed` delivery
|
|
407
|
+
clears both collections. A category page is mostly a list of products: a product
|
|
408
|
+
published into a shelf has to reach it, and a `product.changed` delivery names
|
|
409
|
+
only the product.
|
|
410
|
+
|
|
314
411
|
Exported as functions, so you can clear them yourself:
|
|
315
412
|
|
|
316
413
|
```ts
|
|
@@ -338,6 +435,53 @@ which expires the entry outright so the next visitor waits and never sees the
|
|
|
338
435
|
old answer. Pass `'max'` for stale-while-revalidate if you have a large
|
|
339
436
|
catalogue and have decided that showing one stale price per page is acceptable.
|
|
340
437
|
|
|
438
|
+
## Placing forms, booking pages, chat and tracking on your site
|
|
439
|
+
|
|
440
|
+
With a `crms_` key carrying `website:manage`, the client lists what an account
|
|
441
|
+
has set up for a website, each with its link and the HTML snippet the app's own
|
|
442
|
+
screens hand out:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
const { data: forms } = await crm.getForms();
|
|
446
|
+
const pages = await crm.getBookingPages();
|
|
447
|
+
const { data: widgets } = await crm.getChatWidgets();
|
|
448
|
+
const tracking = await crm.getSiteTracking(); // null until it is set up
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Two writes make a site work: `upsertChatWidget(slug, { name, status,
|
|
452
|
+
addAllowedOrigins })` makes or publishes a chat widget and lets it run on a
|
|
453
|
+
site, and `updateSiteTracking({ addAllowedHosts })` sets tracking up and
|
|
454
|
+
counts visits from a host. Both add and remove sites rather than replace the
|
|
455
|
+
list. Mind the difference: a chat widget with no sites runs on any site, while
|
|
456
|
+
tracking with no hosts counts nothing. Forms are made in the app's form
|
|
457
|
+
builder, never through the API.
|
|
458
|
+
|
|
459
|
+
On a React 19 site, `@solumflow-app/crm-client/react` draws the same things
|
|
460
|
+
without pasting HTML:
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
import {
|
|
464
|
+
CrmBooking,
|
|
465
|
+
CrmChatWidget,
|
|
466
|
+
CrmEmbedProvider,
|
|
467
|
+
CrmForm,
|
|
468
|
+
CrmSiteTracking,
|
|
469
|
+
} from '@solumflow-app/crm-client/react';
|
|
470
|
+
|
|
471
|
+
<CrmEmbedProvider appOrigin="https://app.example.com">
|
|
472
|
+
<CrmForm formId={form.id} title={form.name} />
|
|
473
|
+
<CrmBooking url={page.url} title={page.name} />
|
|
474
|
+
<CrmChatWidget widgetId={widget.id} />
|
|
475
|
+
<CrmSiteTracking publicKey={tracking.publicKey} />
|
|
476
|
+
</CrmEmbedProvider>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
`CrmForm` draws its own frame rather than leaving it to the embed script, which
|
|
480
|
+
looks for forms only once when it loads: in an app that navigates without
|
|
481
|
+
reloading, a form shown later would otherwise stay empty. Put `CrmChatWidget`
|
|
482
|
+
and `CrmSiteTracking` in the layout, once. React loads each script once per
|
|
483
|
+
page, however many components ask for it.
|
|
484
|
+
|
|
341
485
|
## What is deliberately not here
|
|
342
486
|
|
|
343
487
|
- **No product URL.** An event is sold on a page the CRM hosts and carries a
|
|
@@ -353,6 +497,8 @@ catalogue and have decided that showing one stale price per page is acceptable.
|
|
|
353
497
|
|
|
354
498
|
Node 20 or newer. `next` is an optional peer dependency, needed only by
|
|
355
499
|
`createRevalidateRoute` when you let it reach for `revalidateTag` itself.
|
|
500
|
+
`react` 19 or newer is an optional peer dependency, needed only by
|
|
501
|
+
`@solumflow-app/crm-client/react`.
|
|
356
502
|
|
|
357
503
|
## Licence
|
|
358
504
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContactInput, ContactResult, EventAvailability, EventDetail, EventList, FormSubmissionInput, FormSubmissionResult, OrderInput, OrderResult, ProductDetail, ProductList, RequestInput, RequestResult, StockStatus } from './types';
|
|
1
|
+
import type { CatalogArchiveResult, CatalogCategoryInput, CatalogCategoryResult, CatalogField, CatalogFieldInput, CatalogFieldResult, CatalogProductInput, CatalogProductResult, CategoryList, ChatWidgetInput, ChatWidgetList, ChatWidgetResult, CategoryPage, ContactInput, ContactResult, EventAvailability, EventDetail, EventList, FormSubmissionInput, FormList, FormSubmissionResult, OrderInput, OrderResult, ProductDetail, ProductList, RequestInput, PlacementBookingPage, PlacementSiteTracking, RequestResult, SiteTrackingInput, SiteTrackingResult, StockStatus } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* The fetch options this package sets that are not in the web standard.
|
|
4
4
|
*
|
|
@@ -49,8 +49,28 @@ export interface ListOptions {
|
|
|
49
49
|
cursor?: string | null;
|
|
50
50
|
}
|
|
51
51
|
export interface ProductListOptions extends ListOptions {
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* A category id, and it means that category's whole branch.
|
|
54
|
+
*
|
|
55
|
+
* Passing something that is not a uuid is refused, loudly.
|
|
56
|
+
*/
|
|
53
57
|
category?: string;
|
|
58
|
+
/**
|
|
59
|
+
* One catalogue, by the slug of its product object: `product`, or one the
|
|
60
|
+
* seller made. Left out, every catalogue is listed; each product says which
|
|
61
|
+
* it is in (`object`).
|
|
62
|
+
*/
|
|
63
|
+
object?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface CategoryListOptions extends ListOptions {
|
|
66
|
+
/**
|
|
67
|
+
* One level of the tree instead of all of it.
|
|
68
|
+
*
|
|
69
|
+
* `'root'` asks for the top level; a category id asks for its children.
|
|
70
|
+
* Leaving it out returns every published category, which is what a shop
|
|
71
|
+
* building a menu in one go wants.
|
|
72
|
+
*/
|
|
73
|
+
parent?: string | 'root';
|
|
54
74
|
}
|
|
55
75
|
export interface EventListOptions extends ListOptions {
|
|
56
76
|
/** ISO timestamps. Both ends are optional and both are inclusive. */
|
|
@@ -71,6 +91,9 @@ export interface WriteOptions {
|
|
|
71
91
|
export interface CrmClient {
|
|
72
92
|
getProducts(options?: ProductListOptions): Promise<ProductList>;
|
|
73
93
|
getProduct(slugOrId: string): Promise<ProductDetail | null>;
|
|
94
|
+
getCategories(options?: CategoryListOptions): Promise<CategoryList>;
|
|
95
|
+
/** The category, its breadcrumb, and the first page of its products. */
|
|
96
|
+
getCategory(slugOrId: string, options?: ListOptions): Promise<CategoryPage | null>;
|
|
74
97
|
/** Live. Never cached, at any layer. */
|
|
75
98
|
getAvailability(slugOrId: string): Promise<StockStatus | null>;
|
|
76
99
|
getEvents(options?: EventListOptions): Promise<EventList>;
|
|
@@ -81,6 +104,36 @@ export interface CrmClient {
|
|
|
81
104
|
upsertContact(input: ContactInput, options?: WriteOptions): Promise<ContactResult>;
|
|
82
105
|
submitRequest(input: RequestInput, options?: WriteOptions): Promise<RequestResult>;
|
|
83
106
|
submitForm(formId: string, input: FormSubmissionInput, options?: WriteOptions): Promise<FormSubmissionResult>;
|
|
107
|
+
/**
|
|
108
|
+
* Make or change one product, found by the id it has in your own system.
|
|
109
|
+
* Needs a `crms_` key with `catalog:write`. Sending the same body again
|
|
110
|
+
* changes nothing and answers `created: false`.
|
|
111
|
+
*/
|
|
112
|
+
upsertProduct(externalRef: string, input: CatalogProductInput, options?: WriteOptions): Promise<CatalogProductResult>;
|
|
113
|
+
/** Take a product and its variants out of the catalogue. Archived, never deleted. */
|
|
114
|
+
archiveProduct(externalRef: string, source: string, options?: WriteOptions): Promise<CatalogArchiveResult>;
|
|
115
|
+
/** Make or change one category, by its address. */
|
|
116
|
+
upsertCategory(slug: string, input: CatalogCategoryInput, options?: WriteOptions): Promise<CatalogCategoryResult>;
|
|
117
|
+
/** Make or change one field of the catalogue, by its slug. */
|
|
118
|
+
upsertField(slug: string, input: CatalogFieldInput, options?: WriteOptions): Promise<CatalogFieldResult>;
|
|
119
|
+
/** The fields the catalogue publishes, with their choices: what a filter is built from. */
|
|
120
|
+
getFields(): Promise<CatalogField[]>;
|
|
121
|
+
/**
|
|
122
|
+
* The account's published forms, each with its link and the snippet that
|
|
123
|
+
* places it on a page. Needs a `crms_` key with `website:manage`. Forms are
|
|
124
|
+
* made in the app's builder; a draft is not listed. Live, never cached.
|
|
125
|
+
*/
|
|
126
|
+
getForms(options?: ListOptions): Promise<FormList>;
|
|
127
|
+
/** The account's chat widgets, with the sites each may run on. Live. */
|
|
128
|
+
getChatWidgets(options?: ListOptions): Promise<ChatWidgetList>;
|
|
129
|
+
/** Make or change one chat widget, by its address. */
|
|
130
|
+
upsertChatWidget(slug: string, input: ChatWidgetInput, options?: WriteOptions): Promise<ChatWidgetResult>;
|
|
131
|
+
/** The website tracking script, or null when tracking was never set up. Live. */
|
|
132
|
+
getSiteTracking(): Promise<PlacementSiteTracking | null>;
|
|
133
|
+
/** Set tracking up when needed, and change its hosts or consent signal. */
|
|
134
|
+
updateSiteTracking(input: SiteTrackingInput, options?: WriteOptions): Promise<SiteTrackingResult>;
|
|
135
|
+
/** Every page a visitor can book on, with its link and, where there is one, its widget. Live. */
|
|
136
|
+
getBookingPages(): Promise<PlacementBookingPage[]>;
|
|
84
137
|
}
|
|
85
138
|
/**
|
|
86
139
|
* A client for one account's public API.
|
|
@@ -1,47 +1,27 @@
|
|
|
1
1
|
/** What a key is allowed to reach. A key carries one or more of these. */
|
|
2
|
-
export type ApiScope = 'catalog:read' | 'events:read' | 'orders:write' | 'contacts:write' | 'forms:write';
|
|
2
|
+
export type ApiScope = 'catalog:read' | 'catalog:write' | 'events:read' | 'orders:write' | 'contacts:write' | 'forms:write' | 'website:manage';
|
|
3
3
|
export declare const ALL_API_SCOPES: readonly ApiScope[];
|
|
4
4
|
/** Everything an endpoint can be told about. A delivery names one. */
|
|
5
|
-
export type ApiWebhookEvent = 'product.changed' | 'product.deleted' | 'event.changed' | 'order.status_changed';
|
|
5
|
+
export type ApiWebhookEvent = 'product.changed' | 'product.deleted' | 'category.changed' | 'event.changed' | 'order.status_changed';
|
|
6
6
|
export declare const ALL_API_WEBHOOK_EVENTS: readonly ApiWebhookEvent[];
|
|
7
7
|
/** 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. */
|
|
8
|
-
export type ApiErrorCode = 'unauthorized' | 'forbidden' | 'feature_unavailable' | 'not_found' | 'invalid_request' | 'idempotency_key_reused' | 'request_in_progress' | 'rate_limited' | 'internal_error';
|
|
8
|
+
export type ApiErrorCode = 'unauthorized' | 'forbidden' | 'feature_unavailable' | 'not_found' | 'invalid_request' | 'conflict' | 'idempotency_key_reused' | 'request_in_progress' | 'rate_limited' | 'internal_error';
|
|
9
9
|
export declare const ALL_API_ERROR_CODES: readonly ApiErrorCode[];
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* These interfaces are the public API's contract, not internal convenience
|
|
14
|
-
* types. They are served cross-origin to pages nobody here controls, and a
|
|
15
|
-
* customer's shop reads these exact keys — so a field removed or renamed breaks
|
|
16
|
-
* a site that will not be redeployed. Add fields; do not repurpose them.
|
|
17
|
-
*
|
|
18
|
-
* What is deliberately absent is as much the contract as what is here.
|
|
19
|
-
*
|
|
20
|
-
* - **`sku`** is the number this business uses to find the thing in its own
|
|
21
|
-
* stockroom. It says how many suppliers there are, which one a line came
|
|
22
|
-
* from, and often what was paid. `gtin` is the number printed on the box and
|
|
23
|
-
* is offered instead, in the detail: that one is meant to be public, and a
|
|
24
|
-
* shop needs it for a product feed.
|
|
25
|
-
* - **The stock count** never leaves. `inStock` answers the only question a
|
|
26
|
-
* visitor has, and the number itself is a business fact a competitor would
|
|
27
|
-
* like and a cached answer would get wrong within the minute.
|
|
28
|
-
* - **`unit_price_cents` raw** is not it either; see `priceFromCents`.
|
|
29
|
-
* - **Non-public custom fields.** `fields` carries only attributes a member
|
|
30
|
-
* marked `is_public`, which defaults to off. A field called "purchase price"
|
|
31
|
-
* stays home unless somebody says otherwise, once, per field.
|
|
32
|
-
*
|
|
33
|
-
* And one field that an event has and a product does not: **`url`**. An event
|
|
34
|
-
* is sold on a page this system hosts, so its listing can hand out a link. A
|
|
35
|
-
* product is sold on the customer's own site — that is the entire reason this
|
|
36
|
-
* API exists — and this system does not know what they called their product
|
|
37
|
-
* page. A guessed link is worse than none.
|
|
38
|
-
*/
|
|
10
|
+
/** 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. */
|
|
11
|
+
export type ProductType = 'digital' | 'physical' | 'service';
|
|
12
|
+
export declare const ALL_PRODUCT_TYPES: readonly ProductType[];
|
|
39
13
|
export interface PublicProductListItem {
|
|
40
14
|
id: string;
|
|
15
|
+
/**
|
|
16
|
+
* Which catalogue the product is in, by the slug the seller gave it:
|
|
17
|
+
* `product` for the catalogue every account has, something like `courses`
|
|
18
|
+
* for one of its own. The listing takes the same word as `?object=`.
|
|
19
|
+
*/
|
|
20
|
+
object: string;
|
|
41
21
|
/** Always present: a product without an address cannot be published. */
|
|
42
22
|
slug: string;
|
|
43
23
|
name: string;
|
|
44
|
-
productType:
|
|
24
|
+
productType: ProductType;
|
|
45
25
|
/**
|
|
46
26
|
* The storage path, not a URL. The route turns it into a public URL, because
|
|
47
27
|
* only the route knows the origin its own storage is served from.
|
|
@@ -73,8 +53,9 @@ export interface PublicProductListItem {
|
|
|
73
53
|
*/
|
|
74
54
|
inStock: boolean;
|
|
75
55
|
/**
|
|
76
|
-
* The custom fields this account invented
|
|
77
|
-
* only the ones marked public. An
|
|
56
|
+
* The custom fields this account invented for this product's catalogue,
|
|
57
|
+
* keyed by the slug they chose, and only the ones marked public there. An
|
|
58
|
+
* empty object when none are.
|
|
78
59
|
*/
|
|
79
60
|
fields: Record<string, unknown>;
|
|
80
61
|
}
|
|
@@ -95,11 +76,40 @@ export interface PublicProductDetail extends PublicProductListItem {
|
|
|
95
76
|
gtin: string | null;
|
|
96
77
|
metaTitle: string | null;
|
|
97
78
|
metaDescription: string | null;
|
|
79
|
+
/**
|
|
80
|
+
* What a link to this product should show when somebody shares it, with
|
|
81
|
+
* every fallback already applied.
|
|
82
|
+
*
|
|
83
|
+
* Resolved here rather than left to the site, and `metaTitle` beside it is
|
|
84
|
+
* why: a site given both would have to decide which wins when one is empty,
|
|
85
|
+
* and every site would decide slightly differently. The screen that edits
|
|
86
|
+
* these shows a preview, and that preview and this field are the same
|
|
87
|
+
* function -- so what a seller saw is what a stranger gets.
|
|
88
|
+
*/
|
|
89
|
+
social: {
|
|
90
|
+
title: string;
|
|
91
|
+
description: string | null;
|
|
92
|
+
/** A path, turned into a URL by the route. Same reason as `imagePath`. */
|
|
93
|
+
imagePath: string | null;
|
|
94
|
+
};
|
|
98
95
|
images: PublicProductImage[];
|
|
99
96
|
prices: PublicProductPrice[];
|
|
100
97
|
categories: PublicProductCategory[];
|
|
101
98
|
/** What the seller linked this to — a companion, a refill, a bigger model. */
|
|
102
99
|
related: PublicProductSummary[];
|
|
100
|
+
/**
|
|
101
|
+
* The fields this product's variants differ along — a width, a colour —
|
|
102
|
+
* each with the values somebody can choose, in the field's own order.
|
|
103
|
+
* Empty for a product sold as one thing.
|
|
104
|
+
*/
|
|
105
|
+
variantAxes: PublicProductVariantAxis[];
|
|
106
|
+
/**
|
|
107
|
+
* One entry per variant still on sale, in the seller's order. A shop
|
|
108
|
+
* orders a variant by its `id` or by the `id` of one of its prices.
|
|
109
|
+
* Pictures are the variant's own; a variant without any shows the
|
|
110
|
+
* product's.
|
|
111
|
+
*/
|
|
112
|
+
variants: PublicProductVariant[];
|
|
103
113
|
}
|
|
104
114
|
export interface PublicProductImage {
|
|
105
115
|
/** A path, turned into a URL by the route. Same reason as `imagePath`. */
|
|
@@ -127,14 +137,159 @@ export interface PublicProductPrice {
|
|
|
127
137
|
trialPeriodDays: number | null;
|
|
128
138
|
isDefault: boolean;
|
|
129
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* One category this product sits in, as a page a visitor could land on.
|
|
142
|
+
*
|
|
143
|
+
* It used to be a record of an object the account invented, linked through
|
|
144
|
+
* `crm_record_links`, and it carried a name and nothing else. It is now a real
|
|
145
|
+
* category: a page with an address, a place in a tree, and one of them marked
|
|
146
|
+
* as the one this product belongs to most.
|
|
147
|
+
*
|
|
148
|
+
* `slug` is nullable even though only published categories are listed here,
|
|
149
|
+
* and that is not belt-and-braces: it keeps the type honest against the day a
|
|
150
|
+
* caller asks for the categories of something that is not on sale yet.
|
|
151
|
+
*
|
|
152
|
+
* `path` runs from the top down and **ends with this category itself**, so a
|
|
153
|
+
* breadcrumb is the array as given, with no element to append. A step whose
|
|
154
|
+
* `slug` is null is a step to print rather than to link -- an ancestor that is
|
|
155
|
+
* still a draft is not a page anybody can open.
|
|
156
|
+
*/
|
|
130
157
|
export interface PublicProductCategory {
|
|
131
158
|
id: string;
|
|
132
159
|
name: string;
|
|
160
|
+
slug: string | null;
|
|
161
|
+
/** Root first, this category last. Never empty. */
|
|
162
|
+
path: PublicCategoryStep[];
|
|
163
|
+
/** The one that carries the breadcrumb and the structured data. */
|
|
164
|
+
isPrimary: boolean;
|
|
165
|
+
}
|
|
166
|
+
/** One step on the way down to a category. */
|
|
167
|
+
export interface PublicCategoryStep {
|
|
168
|
+
id: string;
|
|
169
|
+
name: string;
|
|
170
|
+
/** Null when this step has no page of its own yet: print it, do not link it. */
|
|
171
|
+
slug: string | null;
|
|
133
172
|
}
|
|
134
173
|
export interface PublicProductSummary {
|
|
174
|
+
id: string;
|
|
175
|
+
/** The catalogue it is in, the same word a listing item carries. */
|
|
176
|
+
object: string;
|
|
177
|
+
slug: string;
|
|
178
|
+
name: string;
|
|
179
|
+
}
|
|
180
|
+
export interface PublicProductVariantAxis {
|
|
181
|
+
slug: string;
|
|
182
|
+
label: string;
|
|
183
|
+
values: string[];
|
|
184
|
+
}
|
|
185
|
+
export interface PublicProductVariant {
|
|
186
|
+
id: string;
|
|
187
|
+
/** The value per axis, keyed by the axis slug. */
|
|
188
|
+
options: Record<string, string>;
|
|
189
|
+
/** The cheapest active price of this variant, as on the product. */
|
|
190
|
+
priceFromCents: number;
|
|
191
|
+
inStock: boolean;
|
|
192
|
+
prices: PublicProductPrice[];
|
|
193
|
+
images: PublicProductImage[];
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* One category as a stranger's website sees it.
|
|
197
|
+
*
|
|
198
|
+
* The same contract rules as `public-product-item.ts`: these keys are read by
|
|
199
|
+
* shops nobody here redeploys, so fields may be added and must not be renamed
|
|
200
|
+
* or repurposed.
|
|
201
|
+
*
|
|
202
|
+
* What is deliberately absent:
|
|
203
|
+
*
|
|
204
|
+
* - **`active`.** It says whether a member still files products under this
|
|
205
|
+
* category, which is a fact about their catalogue rather than about the
|
|
206
|
+
* page. A shop that read it would hide a page that is plainly published.
|
|
207
|
+
* - **`sortOrder`.** The order is the order of the array. A number would
|
|
208
|
+
* invite a shop to sort by it across pages, where it does not mean what it
|
|
209
|
+
* looks like -- it is unique only among siblings.
|
|
210
|
+
* - **Anything that counts drafts.** `productCount` is what a visitor would
|
|
211
|
+
* find, counted the same way the product list selects. A number larger than
|
|
212
|
+
* the page it heads is a promise the page then breaks.
|
|
213
|
+
*/
|
|
214
|
+
export interface PublicCategoryListItem {
|
|
215
|
+
id: string;
|
|
216
|
+
/** Always present: a category without an address is not published. */
|
|
217
|
+
slug: string;
|
|
218
|
+
name: string;
|
|
219
|
+
/** Where it hangs, so a shop can build the tree from a flat page. */
|
|
220
|
+
parentId: string | null;
|
|
221
|
+
description: string | null;
|
|
222
|
+
/**
|
|
223
|
+
* The storage path, not a URL. The route turns it into a public URL, because
|
|
224
|
+
* only the route knows the origin its own storage is served from.
|
|
225
|
+
*/
|
|
226
|
+
imagePath: string | null;
|
|
227
|
+
/**
|
|
228
|
+
* The way down from the top, ending with this category.
|
|
229
|
+
*
|
|
230
|
+
* Here as well as on the detail, and not left to a shop to assemble from
|
|
231
|
+
* `parentId`. `?parent=<id>` answers one level, so a menu that unfolds has
|
|
232
|
+
* no ancestors to build from -- and a shop that stitched a breadcrumb
|
|
233
|
+
* together itself here while reading `path` on the detail page would have
|
|
234
|
+
* two sources for one road. They agree until somebody moves a category, and
|
|
235
|
+
* then the visible trail and the `BreadcrumbList` beside it say different
|
|
236
|
+
* things, which is the one contradiction a search engine reads as a fault.
|
|
237
|
+
*
|
|
238
|
+
* A step whose `slug` is null is an ancestor with no page of its own yet --
|
|
239
|
+
* print it, do not link it.
|
|
240
|
+
*/
|
|
241
|
+
path: PublicCategoryStep[];
|
|
242
|
+
/** Public products on this category's page, its whole branch included. */
|
|
243
|
+
productCount: number;
|
|
244
|
+
/**
|
|
245
|
+
* When this category last changed, for `lastmod` in a shop's sitemap.
|
|
246
|
+
*
|
|
247
|
+
* It is the moment the *category* was edited: its text, its address, its
|
|
248
|
+
* place in the tree. A product moving onto its shelf does not move it, so a
|
|
249
|
+
* shop that wants its category pages recrawled when the contents change has
|
|
250
|
+
* to take the products into account as well.
|
|
251
|
+
*
|
|
252
|
+
* **Not the same kind of fact as `publishedAt`, which is deliberately absent
|
|
253
|
+
* here.** A publication moment may lie in the future, and then it is a plan:
|
|
254
|
+
* it tells a stranger when a shop intends to launch something. This one is
|
|
255
|
+
* always in the past and says only that the page they can already see was
|
|
256
|
+
* touched -- which is exactly what a sitemap publishes anyway.
|
|
257
|
+
*/
|
|
258
|
+
updatedAt: string;
|
|
259
|
+
}
|
|
260
|
+
export interface PublicCategoryDetail {
|
|
135
261
|
id: string;
|
|
136
262
|
slug: string;
|
|
137
263
|
name: string;
|
|
264
|
+
parentId: string | null;
|
|
265
|
+
description: string | null;
|
|
266
|
+
imagePath: string | null;
|
|
267
|
+
metaTitle: string | null;
|
|
268
|
+
metaDescription: string | null;
|
|
269
|
+
/**
|
|
270
|
+
* What a link to this page looks like when somebody shares it.
|
|
271
|
+
*
|
|
272
|
+
* Filled in by the same function the seller previewed it with, so what they
|
|
273
|
+
* saw is what a stranger gets.
|
|
274
|
+
*/
|
|
275
|
+
social: {
|
|
276
|
+
title: string;
|
|
277
|
+
description: string | null;
|
|
278
|
+
/** A path, turned into a URL by the route. Same reason as `imagePath`. */
|
|
279
|
+
imagePath: string | null;
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* The way down from the top, ending with this category.
|
|
283
|
+
*
|
|
284
|
+
* The breadcrumb, as given. A step whose `slug` is null is an ancestor with
|
|
285
|
+
* no page of its own yet -- print it, do not link it.
|
|
286
|
+
*/
|
|
287
|
+
path: PublicCategoryStep[];
|
|
288
|
+
productCount: number;
|
|
289
|
+
/** When the category itself last changed. Same meaning as on the listing. */
|
|
290
|
+
updatedAt: string;
|
|
291
|
+
/** The first page of them; `nextCursor` beside the answer carries the rest. */
|
|
292
|
+
products: PublicProductListItem[];
|
|
138
293
|
}
|
|
139
294
|
/**
|
|
140
295
|
* One event as a stranger's website sees it.
|
|
@@ -263,3 +418,60 @@ export interface ApiWebhookPayload {
|
|
|
263
418
|
ids: string[];
|
|
264
419
|
truncated: boolean;
|
|
265
420
|
}
|
|
421
|
+
/** A published form, ready to be placed on a page. */
|
|
422
|
+
export interface PlacementForm {
|
|
423
|
+
id: string;
|
|
424
|
+
slug: string;
|
|
425
|
+
name: string;
|
|
426
|
+
/** The form on a page of its own, for a plain link. */
|
|
427
|
+
url: string;
|
|
428
|
+
/** An empty `div` naming the form and the script that fills it. */
|
|
429
|
+
embed: string;
|
|
430
|
+
}
|
|
431
|
+
/** A chat widget and what it takes to put it on a site. */
|
|
432
|
+
export interface PlacementChatWidget {
|
|
433
|
+
id: string;
|
|
434
|
+
slug: string;
|
|
435
|
+
name: string;
|
|
436
|
+
status: 'draft' | 'published' | 'archived';
|
|
437
|
+
/**
|
|
438
|
+
* The sites the widget may run on. A site not listed here gets an empty
|
|
439
|
+
* corner, with the reason only in the browser's console.
|
|
440
|
+
*/
|
|
441
|
+
allowedOrigins: string[];
|
|
442
|
+
/** The script tag, or null while the widget is not published. */
|
|
443
|
+
embed: string | null;
|
|
444
|
+
}
|
|
445
|
+
/** The website tracking script of the account. */
|
|
446
|
+
export interface PlacementSiteTracking {
|
|
447
|
+
/** Public by design: it is in the page source of every tracked site. */
|
|
448
|
+
publicKey: string;
|
|
449
|
+
/** The hosts the script reports from; visits from any other are dropped. */
|
|
450
|
+
allowedHosts: string[];
|
|
451
|
+
/**
|
|
452
|
+
* The consent the script waits for before it does anything:
|
|
453
|
+
* `ad_storage`, `analytics_storage`, `personalization_storage`, or
|
|
454
|
+
* `api_only` (it waits until the page says so itself).
|
|
455
|
+
*/
|
|
456
|
+
consentSignal: 'ad_storage' | 'analytics_storage' | 'personalization_storage' | 'api_only';
|
|
457
|
+
lastReceivedAt: string | null;
|
|
458
|
+
embed: string;
|
|
459
|
+
}
|
|
460
|
+
/** A page a visitor can book on. */
|
|
461
|
+
export interface PlacementBookingPage {
|
|
462
|
+
/**
|
|
463
|
+
* `services`: every service the account offers online. `category` and
|
|
464
|
+
* `service`: narrowed to one. `calendar`: an appointment calendar of its own.
|
|
465
|
+
*/
|
|
466
|
+
kind: 'services' | 'category' | 'service' | 'calendar';
|
|
467
|
+
/** The category, service or calendar; null for `services`. */
|
|
468
|
+
id: string | null;
|
|
469
|
+
name: string;
|
|
470
|
+
/** The page's address. Built on the id where there is one, so a rename does not break it. */
|
|
471
|
+
url: string;
|
|
472
|
+
/**
|
|
473
|
+
* An iframe plus the script that sizes it, or null for a `calendar`, which
|
|
474
|
+
* has no embeddable widget yet: link to `url` instead.
|
|
475
|
+
*/
|
|
476
|
+
embed: string | null;
|
|
477
|
+
}
|