@businessdash/sdk 0.9.60 → 0.9.70
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 +1012 -186
- package/assets/biab-sw.js +92 -0
- package/dist/account-core/index.d.ts +126 -0
- package/dist/account-core/index.d.ts.map +1 -0
- package/dist/account-core/index.js +191 -0
- package/dist/account-core/index.js.map +1 -0
- package/dist/account.d.ts +11 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +11 -0
- package/dist/account.js.map +1 -0
- package/dist/auth-handler.d.ts +1 -1
- package/dist/auth-links.d.ts +33 -0
- package/dist/auth-links.d.ts.map +1 -0
- package/dist/auth-links.js +64 -0
- package/dist/auth-links.js.map +1 -0
- package/dist/blog-core/index.d.ts +112 -0
- package/dist/blog-core/index.d.ts.map +1 -0
- package/dist/blog-core/index.js +330 -0
- package/dist/blog-core/index.js.map +1 -0
- package/dist/blog.d.ts +11 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +11 -0
- package/dist/blog.js.map +1 -0
- package/dist/chat-core/index.d.ts +97 -0
- package/dist/chat-core/index.d.ts.map +1 -0
- package/dist/chat-core/index.js +237 -0
- package/dist/chat-core/index.js.map +1 -0
- package/dist/chat.d.ts +11 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +11 -0
- package/dist/chat.js.map +1 -0
- package/dist/checkout-core/index.d.ts +65 -0
- package/dist/checkout-core/index.d.ts.map +1 -0
- package/dist/checkout-core/index.js +159 -0
- package/dist/checkout-core/index.js.map +1 -0
- package/dist/checkout.d.ts +10 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +10 -0
- package/dist/checkout.js.map +1 -0
- package/dist/cli.js +133 -25
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +567 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +807 -1
- package/dist/client.js.map +1 -1
- package/dist/contracts.d.ts +3309 -761
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +541 -4
- package/dist/contracts.js.map +1 -1
- package/dist/data-model-schema.d.ts +1 -1
- package/dist/data-model-schema.d.ts.map +1 -1
- package/dist/element.d.ts +30 -0
- package/dist/element.d.ts.map +1 -1
- package/dist/element.js +56 -0
- package/dist/element.js.map +1 -1
- package/dist/embed-protocol.d.ts +86 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +60 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/framework-support.d.ts +122 -0
- package/dist/framework-support.d.ts.map +1 -0
- package/dist/framework-support.js +323 -0
- package/dist/framework-support.js.map +1 -0
- package/dist/index.cjs +1244 -5
- package/dist/legal-core/index.cjs +115 -0
- package/dist/legal-core/index.d.ts +160 -0
- package/dist/legal-core/index.d.ts.map +1 -0
- package/dist/legal-core/index.js +220 -0
- package/dist/legal-core/index.js.map +1 -0
- package/dist/marketing-core/index.d.ts +66 -0
- package/dist/marketing-core/index.d.ts.map +1 -0
- package/dist/marketing-core/index.js +154 -0
- package/dist/marketing-core/index.js.map +1 -0
- package/dist/marketing.d.ts +7 -0
- package/dist/marketing.d.ts.map +1 -0
- package/dist/marketing.js +7 -0
- package/dist/marketing.js.map +1 -0
- package/dist/mcp-adapters/index.d.ts +135 -0
- package/dist/mcp-adapters/index.d.ts.map +1 -0
- package/dist/mcp-adapters/index.js +187 -0
- package/dist/mcp-adapters/index.js.map +1 -0
- package/dist/mcp-adapters.d.ts +7 -0
- package/dist/mcp-adapters.d.ts.map +1 -0
- package/dist/mcp-adapters.js +7 -0
- package/dist/mcp-adapters.js.map +1 -0
- package/dist/model3d-core/index.cjs +95 -0
- package/dist/model3d-core/index.d.ts +167 -0
- package/dist/model3d-core/index.d.ts.map +1 -0
- package/dist/model3d-core/index.js +158 -0
- package/dist/model3d-core/index.js.map +1 -0
- package/dist/notifications-core/index.cjs +315 -0
- package/dist/notifications-core/index.d.ts +229 -0
- package/dist/notifications-core/index.d.ts.map +1 -0
- package/dist/notifications-core/index.js +445 -0
- package/dist/notifications-core/index.js.map +1 -0
- package/dist/portal-core/index.d.ts +451 -0
- package/dist/portal-core/index.d.ts.map +1 -0
- package/dist/portal-core/index.js +819 -0
- package/dist/portal-core/index.js.map +1 -0
- package/dist/portal.d.ts +7 -0
- package/dist/portal.d.ts.map +1 -0
- package/dist/portal.js +7 -0
- package/dist/portal.js.map +1 -0
- package/dist/push-core/index.cjs +171 -0
- package/dist/push-core/index.d.ts +149 -0
- package/dist/push-core/index.d.ts.map +1 -0
- package/dist/push-core/index.js +231 -0
- package/dist/push-core/index.js.map +1 -0
- package/dist/react-marketing.d.ts +1 -17
- package/dist/react-marketing.d.ts.map +1 -1
- package/dist/react-marketing.js +4 -0
- package/dist/react-marketing.js.map +1 -1
- package/dist/react.d.ts +294 -101
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +302 -246
- package/dist/react.js.map +1 -1
- package/dist/seed-core/index.d.ts +354 -0
- package/dist/seed-core/index.d.ts.map +1 -0
- package/dist/seed-core/index.js +420 -0
- package/dist/seed-core/index.js.map +1 -0
- package/dist/seed.d.ts +7 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +7 -0
- package/dist/seed.js.map +1 -0
- package/dist/seo-core/blog.d.ts +94 -0
- package/dist/seo-core/blog.d.ts.map +1 -0
- package/dist/seo-core/blog.js +149 -0
- package/dist/seo-core/blog.js.map +1 -0
- package/dist/seo-core/commerce.d.ts +64 -0
- package/dist/seo-core/commerce.d.ts.map +1 -0
- package/dist/seo-core/commerce.js +87 -0
- package/dist/seo-core/commerce.js.map +1 -0
- package/dist/seo-core/index.cjs +751 -0
- package/dist/seo-core/index.d.ts +226 -0
- package/dist/seo-core/index.d.ts.map +1 -0
- package/dist/seo-core/index.js +455 -0
- package/dist/seo-core/index.js.map +1 -0
- package/dist/seo-core/local.d.ts +181 -0
- package/dist/seo-core/local.d.ts.map +1 -0
- package/dist/seo-core/local.js +193 -0
- package/dist/seo-core/local.js.map +1 -0
- package/dist/sitemap-core/index.cjs +167 -0
- package/dist/sitemap-core/index.d.ts +155 -0
- package/dist/sitemap-core/index.d.ts.map +1 -0
- package/dist/sitemap-core/index.js +195 -0
- package/dist/sitemap-core/index.js.map +1 -0
- package/dist/social-core/index.d.ts +74 -0
- package/dist/social-core/index.d.ts.map +1 -0
- package/dist/social-core/index.js +72 -0
- package/dist/social-core/index.js.map +1 -0
- package/dist/social.d.ts +7 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +7 -0
- package/dist/social.js.map +1 -0
- package/dist/solid-compat.d.ts +204 -0
- package/dist/solid-compat.d.ts.map +1 -0
- package/dist/solid-compat.js +221 -0
- package/dist/solid-compat.js.map +1 -0
- package/dist/store-core/index.d.ts +141 -0
- package/dist/store-core/index.d.ts.map +1 -0
- package/dist/store-core/index.js +277 -0
- package/dist/store-core/index.js.map +1 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +12 -0
- package/dist/store.js.map +1 -0
- package/dist/vanilla.d.ts +111 -0
- package/dist/vanilla.d.ts.map +1 -1
- package/dist/vanilla.js +206 -0
- package/dist/vanilla.js.map +1 -1
- package/dist/verify-core/index.d.ts +98 -0
- package/dist/verify-core/index.d.ts.map +1 -0
- package/dist/verify-core/index.js +186 -0
- package/dist/verify-core/index.js.map +1 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +8 -0
- package/dist/verify.js.map +1 -0
- package/openapi.json +4316 -1313
- package/package.json +121 -9
- package/src/account-core/index.ts +309 -0
- package/src/account.ts +11 -0
- package/src/angular/headless.ts +519 -0
- package/src/angular/index.ts +3 -0
- package/src/auth-links.ts +76 -0
- package/src/blog-core/index.ts +423 -0
- package/src/blog.ts +11 -0
- package/src/chat-core/index.ts +335 -0
- package/src/chat.ts +11 -0
- package/src/checkout-core/index.ts +225 -0
- package/src/checkout.ts +10 -0
- package/src/cli.ts +184 -28
- package/src/client.ts +998 -0
- package/src/contracts.ts +730 -11
- package/src/data-model-schema.ts +1 -1
- package/src/element.ts +102 -0
- package/src/embed-protocol.ts +100 -0
- package/src/framework-support.ts +440 -0
- package/src/legal-core/index.ts +276 -0
- package/src/marketing-core/index.ts +209 -0
- package/src/marketing.ts +7 -0
- package/src/mcp-adapters/index.ts +264 -0
- package/src/mcp-adapters.ts +7 -0
- package/src/model3d-core/index.ts +248 -0
- package/src/notifications-core/index.ts +650 -0
- package/src/portal-core/index.ts +1564 -0
- package/src/portal.ts +7 -0
- package/src/push-core/index.ts +335 -0
- package/src/qwik.tsx +602 -2
- package/src/react-marketing.tsx +5 -0
- package/src/react.tsx +434 -319
- package/src/seed-core/index.ts +735 -0
- package/src/seed.ts +7 -0
- package/src/seo-core/blog.ts +216 -0
- package/src/seo-core/commerce.ts +138 -0
- package/src/seo-core/index.ts +605 -0
- package/src/seo-core/local.ts +333 -0
- package/src/sitemap-core/index.ts +296 -0
- package/src/social-core/index.ts +103 -0
- package/src/social.ts +7 -0
- package/src/solid-compat.tsx +347 -0
- package/src/solid-surfaces.tsx +1311 -0
- package/src/solid.tsx +82 -60
- package/src/store-core/index.ts +426 -0
- package/src/store.ts +12 -0
- package/src/svelte/SocialLinks.svelte +84 -0
- package/src/svelte/headless.ts +435 -0
- package/src/svelte/index.ts +9 -0
- package/src/vanilla.ts +367 -0
- package/src/verify-core/index.ts +288 -0
- package/src/verify.ts +8 -0
- package/src/vue/headless.ts +527 -0
- package/src/vue/index.ts +3 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@businessdash/sdk/notifications` — one hook, every notification.
|
|
3
|
+
*
|
|
4
|
+
* ## The problem this solves
|
|
5
|
+
*
|
|
6
|
+
* BusinessDash raises a lot of notifications: an order ships, an invoice is
|
|
7
|
+
* paid, a technician is dispatched, staff reply to a message, a quote expires.
|
|
8
|
+
* Before this, a site that wanted to surface any of them had to know each one
|
|
9
|
+
* existed, find the call that returns it, poll it, diff it, and write a toast —
|
|
10
|
+
* per event type. Sites did two of them and missed the rest, and every new
|
|
11
|
+
* platform event was a change every consumer had to make.
|
|
12
|
+
*
|
|
13
|
+
* So the SDK inverts it. Attach ONE handler and receive everything, already
|
|
14
|
+
* normalised to a single shape:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const feed = createNotificationFeed({ portal });
|
|
18
|
+
* feed.subscribe((n) => toast(n.title, { description: n.body }));
|
|
19
|
+
* feed.start();
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* That is the entire integration. New platform events arrive through the same
|
|
23
|
+
* handler with no code change, because nothing in the consumer's code names an
|
|
24
|
+
* event type.
|
|
25
|
+
*
|
|
26
|
+
* ## We do not ship a toast
|
|
27
|
+
*
|
|
28
|
+
* Deliberately. A notification component is one of the most branded things on a
|
|
29
|
+
* site — position, motion, stacking, dismiss behaviour, dark mode — and an SDK
|
|
30
|
+
* that renders its own is one a designer has to fight. This produces normalised
|
|
31
|
+
* DATA and stops there; the consumer keeps their own component and their own
|
|
32
|
+
* design. `toToast()` flattens to the shape sonner, react-hot-toast, Vue Toast
|
|
33
|
+
* and friends all accept, for the common case where a straight mapping is all
|
|
34
|
+
* anyone wants.
|
|
35
|
+
*
|
|
36
|
+
* ## Where notifications actually come from
|
|
37
|
+
*
|
|
38
|
+
* Two sources, and they behave differently enough to be worth knowing:
|
|
39
|
+
*
|
|
40
|
+
* - **Push** (`pushSource`) — the service worker relays what it received to any
|
|
41
|
+
* open tab. Instant, but only while a tab is open; when the site is closed
|
|
42
|
+
* the OS notification IS the delivery and no toast is possible or wanted.
|
|
43
|
+
* - **Polling** (`portalSource`) — watches the signed-in customer's portal and
|
|
44
|
+
* raises a notification when something it tracks changes. Works without push
|
|
45
|
+
* permission, which matters because most visitors never grant it.
|
|
46
|
+
*
|
|
47
|
+
* Both feed one stream, and **the same event routinely arrives twice** — push
|
|
48
|
+
* is instant, the poll notices the same change seconds later. Toasting it twice
|
|
49
|
+
* is the obvious bug, so the feed deduplicates on a stable id before any
|
|
50
|
+
* handler sees it. That deduplication is the main reason this is a shared
|
|
51
|
+
* component rather than a snippet in a README.
|
|
52
|
+
*
|
|
53
|
+
* @module
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import type { PortalController } from "../portal-core/index.js";
|
|
57
|
+
|
|
58
|
+
/** The six the platform sends. Open, because the list grows. */
|
|
59
|
+
export type NotificationCategory =
|
|
60
|
+
| "transactional"
|
|
61
|
+
| "account"
|
|
62
|
+
| "messages"
|
|
63
|
+
| "product_updates"
|
|
64
|
+
| "marketing"
|
|
65
|
+
| "digest"
|
|
66
|
+
| (string & {});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One notification, whatever it came from.
|
|
70
|
+
*
|
|
71
|
+
* The shape is deliberately small and toast-shaped. Anything specific to an
|
|
72
|
+
* event type lives in `data` rather than growing this — a consumer that never
|
|
73
|
+
* looks at `data` still renders every notification correctly, which is the
|
|
74
|
+
* whole promise.
|
|
75
|
+
*/
|
|
76
|
+
export type BusinessDashNotification = {
|
|
77
|
+
/**
|
|
78
|
+
* Stable across sources. Two deliveries of one event share it, which is
|
|
79
|
+
* what makes deduplication work — see `dedupeKey` below for how it is
|
|
80
|
+
* derived when the source does not supply one.
|
|
81
|
+
*/
|
|
82
|
+
id: string;
|
|
83
|
+
/** Where this delivery came from. Rarely interesting; occasionally is. */
|
|
84
|
+
source: "push" | "poll" | "local";
|
|
85
|
+
category: NotificationCategory;
|
|
86
|
+
/**
|
|
87
|
+
* A dotted event name — `order.shipped`, `invoice.paid`, `message.received`.
|
|
88
|
+
*
|
|
89
|
+
* Provided so a consumer CAN special-case one event. Nobody has to: the
|
|
90
|
+
* title and body are always populated.
|
|
91
|
+
*/
|
|
92
|
+
kind: string;
|
|
93
|
+
title: string;
|
|
94
|
+
body: string | null;
|
|
95
|
+
/** Where the notification points. Render as the toast's action. */
|
|
96
|
+
url: string | null;
|
|
97
|
+
/**
|
|
98
|
+
* Maps to whatever a toast library calls its variants.
|
|
99
|
+
*
|
|
100
|
+
* `success` is used sparingly — a shipped order is `info`, not a
|
|
101
|
+
* celebration. `error` means something needs the customer to act.
|
|
102
|
+
*/
|
|
103
|
+
severity: "info" | "success" | "warning" | "error";
|
|
104
|
+
/** ISO 8601. When the event happened, not when this tab noticed it. */
|
|
105
|
+
occurredAt: string;
|
|
106
|
+
/** Which company it came from. Null for platform-level notices. */
|
|
107
|
+
orgId: string | null;
|
|
108
|
+
/** Everything event-specific: order id, invoice number, ETA, and so on. */
|
|
109
|
+
data: Record<string, unknown>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Everything except what the feed fills in. */
|
|
113
|
+
export type NotificationInput = Partial<BusinessDashNotification> &
|
|
114
|
+
Pick<BusinessDashNotification, "title">;
|
|
115
|
+
|
|
116
|
+
export type NotificationHandler = (
|
|
117
|
+
notification: BusinessDashNotification,
|
|
118
|
+
) => void;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A place notifications come from.
|
|
122
|
+
*
|
|
123
|
+
* Called with an `emit` and returns its own teardown. Consumers can write one
|
|
124
|
+
* to bring in a channel the SDK does not know about — a websocket, an SSE
|
|
125
|
+
* stream, their own backend — and it joins the same deduplicated feed.
|
|
126
|
+
*/
|
|
127
|
+
export type NotificationSource = (
|
|
128
|
+
emit: (input: NotificationInput) => void,
|
|
129
|
+
) => () => void;
|
|
130
|
+
|
|
131
|
+
export type NotificationFeedOptions = {
|
|
132
|
+
/**
|
|
133
|
+
* A portal controller to watch. Shorthand for adding `portalSource`.
|
|
134
|
+
*
|
|
135
|
+
* Optional: a marketing site with no signed-in customer still receives push
|
|
136
|
+
* notifications, and still wants them toasted.
|
|
137
|
+
*/
|
|
138
|
+
portal?: PortalController;
|
|
139
|
+
/** How often to poll the portal. Default 60s; floor 15s. */
|
|
140
|
+
pollIntervalMs?: number;
|
|
141
|
+
/** Extra sources, including your own. */
|
|
142
|
+
sources?: NotificationSource[];
|
|
143
|
+
/**
|
|
144
|
+
* How long an id is remembered for deduplication. Default 5 minutes.
|
|
145
|
+
*
|
|
146
|
+
* Long enough to cover push and the next poll seeing the same event; short
|
|
147
|
+
* enough that a genuinely repeated notification hours later still shows.
|
|
148
|
+
*/
|
|
149
|
+
dedupeWindowMs?: number;
|
|
150
|
+
/** How many to keep for `recent()`. Default 50. */
|
|
151
|
+
historyLimit?: number;
|
|
152
|
+
/**
|
|
153
|
+
* Suppress the OS notification when a tab is already focused.
|
|
154
|
+
*
|
|
155
|
+
* Off by default. A push MUST show a system notification — browsers revoke
|
|
156
|
+
* permission from sites that receive pushes silently — so this closes it
|
|
157
|
+
* immediately after, which is a different thing from never showing it and
|
|
158
|
+
* is visible as a brief flash on some platforms. Worth it when a toast has
|
|
159
|
+
* already said the same thing; not worth it otherwise.
|
|
160
|
+
*/
|
|
161
|
+
closeOsNotificationWhenFocused?: boolean;
|
|
162
|
+
onError?: (error: Error) => void;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export type NotificationFeed = {
|
|
166
|
+
/**
|
|
167
|
+
* Attach a handler. Returns its own unsubscribe.
|
|
168
|
+
*
|
|
169
|
+
* **Past notifications are NOT replayed.** A toast is a moment, and
|
|
170
|
+
* replaying a morning's worth on every mount would fire a stack of stale
|
|
171
|
+
* toasts each time someone navigates. Use `recent()` to render a bell or an
|
|
172
|
+
* inbox, where showing history is the point.
|
|
173
|
+
*/
|
|
174
|
+
subscribe(handler: NotificationHandler): () => void;
|
|
175
|
+
/**
|
|
176
|
+
* Raise a notification from your own code.
|
|
177
|
+
*
|
|
178
|
+
* It goes through the same deduplication and the same handlers, so an app
|
|
179
|
+
* can route its own messages through one component instead of two.
|
|
180
|
+
*/
|
|
181
|
+
emit(input: NotificationInput): void;
|
|
182
|
+
/** Begin listening and polling. Idempotent. */
|
|
183
|
+
start(): void;
|
|
184
|
+
/** Stop, without dropping handlers. `start()` resumes. */
|
|
185
|
+
stop(): void;
|
|
186
|
+
/** Newest first, capped at `historyLimit`. */
|
|
187
|
+
recent(): BusinessDashNotification[];
|
|
188
|
+
/** Stop everything and drop handlers. */
|
|
189
|
+
destroy(): void;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The id used for deduplication when a source does not supply one.
|
|
194
|
+
*
|
|
195
|
+
* Push carries a server-assigned id and this is not needed. Polling has no id
|
|
196
|
+
* to carry — it inferred the event by noticing a value changed — so the key is
|
|
197
|
+
* built from what identifies the event rather than the delivery: kind, org, and
|
|
198
|
+
* whatever the source put in `data.entityId`.
|
|
199
|
+
*
|
|
200
|
+
* `occurredAt` is deliberately NOT part of it. A poll timestamps at discovery,
|
|
201
|
+
* push at send; including either would make two deliveries of one event look
|
|
202
|
+
* like two events, which is exactly the bug this exists to prevent.
|
|
203
|
+
*/
|
|
204
|
+
function dedupeKey(input: NotificationInput): string {
|
|
205
|
+
if (input.id) return input.id;
|
|
206
|
+
const entity =
|
|
207
|
+
typeof input.data?.entityId === "string" ? input.data.entityId : "";
|
|
208
|
+
return [input.kind ?? "unknown", input.orgId ?? "", entity, input.title].join(
|
|
209
|
+
"|",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build the feed.
|
|
215
|
+
*
|
|
216
|
+
* Nothing runs until `start()`, so a controller can be constructed during
|
|
217
|
+
* render without a component mount kicking off a poll.
|
|
218
|
+
*/
|
|
219
|
+
export function createNotificationFeed(
|
|
220
|
+
options: NotificationFeedOptions = {},
|
|
221
|
+
): NotificationFeed {
|
|
222
|
+
const handlers = new Set<NotificationHandler>();
|
|
223
|
+
const history: BusinessDashNotification[] = [];
|
|
224
|
+
/** id → when it was seen, so the window can be pruned. */
|
|
225
|
+
const seen = new Map<string, number>();
|
|
226
|
+
const teardowns: Array<() => void> = [];
|
|
227
|
+
|
|
228
|
+
const dedupeWindowMs = options.dedupeWindowMs ?? 5 * 60 * 1000;
|
|
229
|
+
const historyLimit = options.historyLimit ?? 50;
|
|
230
|
+
let running = false;
|
|
231
|
+
let destroyed = false;
|
|
232
|
+
|
|
233
|
+
function fail(cause: unknown): void {
|
|
234
|
+
options.onError?.(
|
|
235
|
+
cause instanceof Error ? cause : new Error(String(cause)),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function deliver(input: NotificationInput): void {
|
|
240
|
+
if (destroyed) return;
|
|
241
|
+
|
|
242
|
+
const key = dedupeKey(input);
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
|
|
245
|
+
// Prune first, so a long-lived page does not accumulate keys forever.
|
|
246
|
+
for (const [id, at] of seen) {
|
|
247
|
+
if (now - at > dedupeWindowMs) seen.delete(id);
|
|
248
|
+
}
|
|
249
|
+
if (seen.has(key)) return;
|
|
250
|
+
seen.set(key, now);
|
|
251
|
+
|
|
252
|
+
const notification: BusinessDashNotification = {
|
|
253
|
+
id: input.id ?? key,
|
|
254
|
+
source: input.source ?? "local",
|
|
255
|
+
category: input.category ?? "transactional",
|
|
256
|
+
kind: input.kind ?? "notification",
|
|
257
|
+
title: input.title,
|
|
258
|
+
body: input.body ?? null,
|
|
259
|
+
url: input.url ?? null,
|
|
260
|
+
severity: input.severity ?? "info",
|
|
261
|
+
occurredAt: input.occurredAt ?? new Date(now).toISOString(),
|
|
262
|
+
orgId: input.orgId ?? null,
|
|
263
|
+
data: input.data ?? {},
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
history.unshift(notification);
|
|
267
|
+
if (history.length > historyLimit) history.length = historyLimit;
|
|
268
|
+
|
|
269
|
+
for (const handler of handlers) {
|
|
270
|
+
// One handler throwing must not stop the others, or a broken toast
|
|
271
|
+
// in one component silences every notification on the page.
|
|
272
|
+
try {
|
|
273
|
+
handler(notification);
|
|
274
|
+
} catch (cause) {
|
|
275
|
+
fail(cause);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
subscribe(handler) {
|
|
282
|
+
handlers.add(handler);
|
|
283
|
+
return () => {
|
|
284
|
+
handlers.delete(handler);
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
emit(input) {
|
|
289
|
+
deliver({ ...input, source: input.source ?? "local" });
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
start() {
|
|
293
|
+
if (running || destroyed) return;
|
|
294
|
+
running = true;
|
|
295
|
+
|
|
296
|
+
const sources: NotificationSource[] = [
|
|
297
|
+
pushSource({
|
|
298
|
+
...(options.closeOsNotificationWhenFocused === undefined
|
|
299
|
+
? {}
|
|
300
|
+
: {
|
|
301
|
+
closeOsNotificationWhenFocused:
|
|
302
|
+
options.closeOsNotificationWhenFocused,
|
|
303
|
+
}),
|
|
304
|
+
onError: fail,
|
|
305
|
+
}),
|
|
306
|
+
...(options.portal
|
|
307
|
+
? [
|
|
308
|
+
portalSource({
|
|
309
|
+
portal: options.portal,
|
|
310
|
+
...(options.pollIntervalMs === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { intervalMs: options.pollIntervalMs }),
|
|
313
|
+
}),
|
|
314
|
+
]
|
|
315
|
+
: []),
|
|
316
|
+
...(options.sources ?? []),
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
for (const source of sources) {
|
|
320
|
+
try {
|
|
321
|
+
teardowns.push(source(deliver));
|
|
322
|
+
} catch (cause) {
|
|
323
|
+
fail(cause);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
stop() {
|
|
329
|
+
running = false;
|
|
330
|
+
while (teardowns.length) {
|
|
331
|
+
try {
|
|
332
|
+
teardowns.pop()?.();
|
|
333
|
+
} catch (cause) {
|
|
334
|
+
fail(cause);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
recent() {
|
|
340
|
+
return [...history];
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
destroy() {
|
|
344
|
+
this.stop();
|
|
345
|
+
destroyed = true;
|
|
346
|
+
handlers.clear();
|
|
347
|
+
history.length = 0;
|
|
348
|
+
seen.clear();
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Push notifications relayed by the service worker, for tabs that are open.
|
|
355
|
+
*
|
|
356
|
+
* The worker shipped at `@businessdash/sdk/biab-sw.js` posts every push to its
|
|
357
|
+
* clients. A worker written by hand has to do the same — post
|
|
358
|
+
* `{ type: "businessdash:push", payload }` — or this source hears nothing.
|
|
359
|
+
*/
|
|
360
|
+
export function pushSource(
|
|
361
|
+
options: {
|
|
362
|
+
closeOsNotificationWhenFocused?: boolean;
|
|
363
|
+
onError?: (error: Error) => void;
|
|
364
|
+
} = {},
|
|
365
|
+
): NotificationSource {
|
|
366
|
+
return (emit) => {
|
|
367
|
+
if (
|
|
368
|
+
typeof navigator === "undefined" ||
|
|
369
|
+
!("serviceWorker" in navigator) ||
|
|
370
|
+
typeof window === "undefined"
|
|
371
|
+
) {
|
|
372
|
+
return () => undefined;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const listener = (event: MessageEvent) => {
|
|
376
|
+
const message = event.data as
|
|
377
|
+
| { type?: string; payload?: Record<string, unknown> }
|
|
378
|
+
| undefined;
|
|
379
|
+
if (message?.type !== "businessdash:push" || !message.payload) return;
|
|
380
|
+
|
|
381
|
+
const payload = message.payload;
|
|
382
|
+
const data =
|
|
383
|
+
typeof payload.data === "object" && payload.data !== null
|
|
384
|
+
? (payload.data as Record<string, unknown>)
|
|
385
|
+
: {};
|
|
386
|
+
|
|
387
|
+
emit({
|
|
388
|
+
...(typeof payload.id === "string" ? { id: payload.id } : {}),
|
|
389
|
+
source: "push",
|
|
390
|
+
...(typeof payload.category === "string"
|
|
391
|
+
? { category: payload.category }
|
|
392
|
+
: {}),
|
|
393
|
+
...(typeof payload.kind === "string" ? { kind: payload.kind } : {}),
|
|
394
|
+
title:
|
|
395
|
+
typeof payload.title === "string" ? payload.title : "Notification",
|
|
396
|
+
body: typeof payload.body === "string" ? payload.body : null,
|
|
397
|
+
url: typeof payload.url === "string" ? payload.url : null,
|
|
398
|
+
...(typeof payload.orgId === "string" ? { orgId: payload.orgId } : {}),
|
|
399
|
+
...(typeof payload.occurredAt === "string"
|
|
400
|
+
? { occurredAt: payload.occurredAt }
|
|
401
|
+
: {}),
|
|
402
|
+
data,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
if (
|
|
406
|
+
options.closeOsNotificationWhenFocused &&
|
|
407
|
+
document.visibilityState === "visible"
|
|
408
|
+
) {
|
|
409
|
+
// The system notification has already been shown — required, and
|
|
410
|
+
// not optional. Closing it here trades a brief flash for not
|
|
411
|
+
// saying the same thing twice to someone who is looking at the
|
|
412
|
+
// page. See `closeOsNotificationWhenFocused` for why it is off
|
|
413
|
+
// by default.
|
|
414
|
+
navigator.serviceWorker.ready
|
|
415
|
+
.then((registration) =>
|
|
416
|
+
registration.getNotifications(
|
|
417
|
+
typeof payload.tag === "string" ? { tag: payload.tag } : {},
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
.then((notifications) => {
|
|
421
|
+
for (const notification of notifications) notification.close();
|
|
422
|
+
})
|
|
423
|
+
.catch((cause) => options.onError?.(cause as Error));
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
navigator.serviceWorker.addEventListener("message", listener);
|
|
428
|
+
return () => {
|
|
429
|
+
navigator.serviceWorker.removeEventListener("message", listener);
|
|
430
|
+
};
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** What the poll remembers between ticks, to notice a change. */
|
|
435
|
+
type PortalWatermark = {
|
|
436
|
+
unreadMessages: number;
|
|
437
|
+
orderStatuses: Map<string, string>;
|
|
438
|
+
invoiceStatuses: Map<string, string>;
|
|
439
|
+
shipmentStatuses: Map<string, string>;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Notifications inferred from the signed-in customer's portal.
|
|
444
|
+
*
|
|
445
|
+
* ## Why polling, and why this is not a downgrade
|
|
446
|
+
*
|
|
447
|
+
* Push reaches maybe a third of visitors — the rest never grant permission, or
|
|
448
|
+
* are on an iPhone that has not been added to the Home Screen. A portal that
|
|
449
|
+
* only notified push subscribers would be silent for most customers. Polling
|
|
450
|
+
* covers everyone who has the page open, which is exactly the audience a toast
|
|
451
|
+
* can reach anyway.
|
|
452
|
+
*
|
|
453
|
+
* ## The first tick never notifies
|
|
454
|
+
*
|
|
455
|
+
* It records a baseline instead. Otherwise opening the portal would toast every
|
|
456
|
+
* order that ever shipped, which reads as a flood of stale news and trains
|
|
457
|
+
* people to dismiss without reading.
|
|
458
|
+
*/
|
|
459
|
+
export function portalSource(options: {
|
|
460
|
+
portal: PortalController;
|
|
461
|
+
intervalMs?: number;
|
|
462
|
+
}): NotificationSource {
|
|
463
|
+
// Floored rather than trusted: this runs against a rate-limited API, and a
|
|
464
|
+
// consumer passing 1000 because it "feels responsive" would spend an org's
|
|
465
|
+
// quota on a page nobody is looking at.
|
|
466
|
+
const intervalMs = Math.max(15_000, options.intervalMs ?? 60_000);
|
|
467
|
+
|
|
468
|
+
return (emit) => {
|
|
469
|
+
let stopped = false;
|
|
470
|
+
let baseline: PortalWatermark | null = null;
|
|
471
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
472
|
+
|
|
473
|
+
function readWatermark(): PortalWatermark {
|
|
474
|
+
const snapshot = options.portal.snapshot();
|
|
475
|
+
return {
|
|
476
|
+
unreadMessages: snapshot.unreadMessageCount,
|
|
477
|
+
orderStatuses: new Map(
|
|
478
|
+
(snapshot.orders?.items ?? []).map((o) => [o.id, String(o.status)]),
|
|
479
|
+
),
|
|
480
|
+
invoiceStatuses: new Map(
|
|
481
|
+
(snapshot.invoices?.items ?? []).map((i) => [i.id, String(i.status)]),
|
|
482
|
+
),
|
|
483
|
+
shipmentStatuses: new Map(
|
|
484
|
+
(snapshot.shipments?.items ?? []).map((sh) => [
|
|
485
|
+
sh.id,
|
|
486
|
+
String(sh.status ?? "unknown"),
|
|
487
|
+
]),
|
|
488
|
+
),
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function diff(previous: PortalWatermark, next: PortalWatermark): void {
|
|
493
|
+
const orgId = options.portal.snapshot().context?.organization?.id ?? null;
|
|
494
|
+
|
|
495
|
+
if (next.unreadMessages > previous.unreadMessages) {
|
|
496
|
+
const delta = next.unreadMessages - previous.unreadMessages;
|
|
497
|
+
emit({
|
|
498
|
+
source: "poll",
|
|
499
|
+
category: "messages",
|
|
500
|
+
kind: "message.received",
|
|
501
|
+
title: delta === 1 ? "New message" : `${delta} new messages`,
|
|
502
|
+
body: "You have a new message from the team.",
|
|
503
|
+
severity: "info",
|
|
504
|
+
orgId,
|
|
505
|
+
// Keyed on the resulting count rather than the delta, so two
|
|
506
|
+
// tabs polling at different moments agree on the id.
|
|
507
|
+
data: { entityId: `unread:${next.unreadMessages}` },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const [id, status] of next.orderStatuses) {
|
|
512
|
+
const before = previous.orderStatuses.get(id);
|
|
513
|
+
// A newly appearing order is not a status CHANGE — it is an order
|
|
514
|
+
// this tab had not loaded yet, and announcing it would toast the
|
|
515
|
+
// backlog on first pagination.
|
|
516
|
+
if (before === undefined || before === status) continue;
|
|
517
|
+
emit({
|
|
518
|
+
source: "poll",
|
|
519
|
+
category: "transactional",
|
|
520
|
+
kind: `order.${status}`,
|
|
521
|
+
title: orderTitle(status),
|
|
522
|
+
body: null,
|
|
523
|
+
severity: status === "cancelled" ? "warning" : "info",
|
|
524
|
+
orgId,
|
|
525
|
+
data: { entityId: `order:${id}:${status}`, orderId: id, status },
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
for (const [id, status] of next.invoiceStatuses) {
|
|
530
|
+
const before = previous.invoiceStatuses.get(id);
|
|
531
|
+
if (before === undefined || before === status) continue;
|
|
532
|
+
emit({
|
|
533
|
+
source: "poll",
|
|
534
|
+
category: "transactional",
|
|
535
|
+
kind: `invoice.${status}`,
|
|
536
|
+
title:
|
|
537
|
+
status === "paid"
|
|
538
|
+
? "Invoice paid"
|
|
539
|
+
: status === "overdue"
|
|
540
|
+
? "Invoice overdue"
|
|
541
|
+
: "Invoice updated",
|
|
542
|
+
body: null,
|
|
543
|
+
severity: status === "overdue" ? "error" : "info",
|
|
544
|
+
orgId,
|
|
545
|
+
data: { entityId: `invoice:${id}:${status}`, invoiceId: id, status },
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
for (const [id, status] of next.shipmentStatuses) {
|
|
550
|
+
const before = previous.shipmentStatuses.get(id);
|
|
551
|
+
if (before === undefined || before === status) continue;
|
|
552
|
+
emit({
|
|
553
|
+
source: "poll",
|
|
554
|
+
category: "transactional",
|
|
555
|
+
kind: `shipment.${status}`,
|
|
556
|
+
title:
|
|
557
|
+
status === "delivered"
|
|
558
|
+
? "Your order was delivered"
|
|
559
|
+
: status === "out_for_delivery"
|
|
560
|
+
? "Out for delivery"
|
|
561
|
+
: "Shipping update",
|
|
562
|
+
body: null,
|
|
563
|
+
severity: "info",
|
|
564
|
+
orgId,
|
|
565
|
+
data: {
|
|
566
|
+
entityId: `shipment:${id}:${status}`,
|
|
567
|
+
shipmentId: id,
|
|
568
|
+
status,
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function tick(): Promise<void> {
|
|
575
|
+
if (stopped) return;
|
|
576
|
+
// Refresh only the slices being watched, and settle rather than
|
|
577
|
+
// race: one failing call must not stop the other three from
|
|
578
|
+
// noticing their own changes.
|
|
579
|
+
await Promise.allSettled([
|
|
580
|
+
options.portal.loadOrders(),
|
|
581
|
+
options.portal.loadInvoices(),
|
|
582
|
+
options.portal.loadShipments(),
|
|
583
|
+
options.portal.loadMessages(),
|
|
584
|
+
]);
|
|
585
|
+
if (stopped) return;
|
|
586
|
+
|
|
587
|
+
const next = readWatermark();
|
|
588
|
+
if (baseline) diff(baseline, next);
|
|
589
|
+
baseline = next;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Baseline immediately from whatever is already loaded, so a change that
|
|
593
|
+
// happens before the first interval is still caught.
|
|
594
|
+
baseline = readWatermark();
|
|
595
|
+
timer = setInterval(() => void tick(), intervalMs);
|
|
596
|
+
|
|
597
|
+
return () => {
|
|
598
|
+
stopped = true;
|
|
599
|
+
if (timer) clearInterval(timer);
|
|
600
|
+
};
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function orderTitle(status: string): string {
|
|
605
|
+
switch (status) {
|
|
606
|
+
case "shipped":
|
|
607
|
+
return "Your order shipped";
|
|
608
|
+
case "delivered":
|
|
609
|
+
return "Your order was delivered";
|
|
610
|
+
case "cancelled":
|
|
611
|
+
return "Your order was cancelled";
|
|
612
|
+
case "refunded":
|
|
613
|
+
return "Your refund was issued";
|
|
614
|
+
default:
|
|
615
|
+
return "Order updated";
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Flatten to the shape every toast library accepts.
|
|
621
|
+
*
|
|
622
|
+
* `sonner`, `react-hot-toast`, `vue-toastification`, `svelte-french-toast` and
|
|
623
|
+
* shadcn's `useToast` all take some arrangement of title, description, variant
|
|
624
|
+
* and an action — so this is the mapping most consumers would write by hand,
|
|
625
|
+
* offered once so they do not have to.
|
|
626
|
+
*
|
|
627
|
+
* Rendering stays entirely theirs. This returns data.
|
|
628
|
+
*/
|
|
629
|
+
export function toToast(notification: BusinessDashNotification): {
|
|
630
|
+
title: string;
|
|
631
|
+
description: string | undefined;
|
|
632
|
+
variant: "default" | "success" | "warning" | "destructive";
|
|
633
|
+
action: { label: string; url: string } | undefined;
|
|
634
|
+
} {
|
|
635
|
+
return {
|
|
636
|
+
title: notification.title,
|
|
637
|
+
description: notification.body ?? undefined,
|
|
638
|
+
variant:
|
|
639
|
+
notification.severity === "error"
|
|
640
|
+
? "destructive"
|
|
641
|
+
: notification.severity === "warning"
|
|
642
|
+
? "warning"
|
|
643
|
+
: notification.severity === "success"
|
|
644
|
+
? "success"
|
|
645
|
+
: "default",
|
|
646
|
+
action: notification.url
|
|
647
|
+
? { label: "View", url: notification.url }
|
|
648
|
+
: undefined,
|
|
649
|
+
};
|
|
650
|
+
}
|