@solumflow-app/crm-client 0.1.0 → 0.3.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 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
- /** A category id. Passing something that is not a uuid is refused, loudly. */
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.