@hanzo/rooms 0.1.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.
Files changed (235) hide show
  1. package/LICENSE-APACHE +205 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +23 -0
  4. package/dist/Account.d.ts +8 -0
  5. package/dist/Account.js +58 -0
  6. package/dist/Beluga.d.ts +12 -0
  7. package/dist/Beluga.js +152 -0
  8. package/dist/Billing.d.ts +1 -0
  9. package/dist/Billing.js +31 -0
  10. package/dist/Board.d.ts +9 -0
  11. package/dist/Board.js +447 -0
  12. package/dist/Boxes.d.ts +1 -0
  13. package/dist/Boxes.js +173 -0
  14. package/dist/CalScheduler.d.ts +15 -0
  15. package/dist/CalScheduler.js +196 -0
  16. package/dist/Chat.d.ts +35 -0
  17. package/dist/Chat.js +1049 -0
  18. package/dist/Column.d.ts +15 -0
  19. package/dist/Column.js +88 -0
  20. package/dist/Contacts.d.ts +1 -0
  21. package/dist/Contacts.js +301 -0
  22. package/dist/Directory.d.ts +24 -0
  23. package/dist/Directory.js +356 -0
  24. package/dist/Drive.d.ts +2 -0
  25. package/dist/Drive.js +466 -0
  26. package/dist/Find.d.ts +7 -0
  27. package/dist/Find.js +82 -0
  28. package/dist/Guide.d.ts +6 -0
  29. package/dist/Guide.js +272 -0
  30. package/dist/Home.d.ts +39 -0
  31. package/dist/Home.js +306 -0
  32. package/dist/Inbox.d.ts +9 -0
  33. package/dist/Inbox.js +193 -0
  34. package/dist/Market.d.ts +3 -0
  35. package/dist/Market.js +149 -0
  36. package/dist/Me.d.ts +27 -0
  37. package/dist/Me.js +251 -0
  38. package/dist/Meet.d.ts +9 -0
  39. package/dist/Meet.js +370 -0
  40. package/dist/ModeMark.d.ts +8 -0
  41. package/dist/ModeMark.js +18 -0
  42. package/dist/OrgSwitcher.d.ts +31 -0
  43. package/dist/OrgSwitcher.js +120 -0
  44. package/dist/Orgs.d.ts +10 -0
  45. package/dist/Orgs.js +582 -0
  46. package/dist/Profile.d.ts +4 -0
  47. package/dist/Profile.js +67 -0
  48. package/dist/Prose.d.ts +5 -0
  49. package/dist/Prose.js +111 -0
  50. package/dist/ProviderMark.d.ts +17 -0
  51. package/dist/ProviderMark.js +277 -0
  52. package/dist/Recents.d.ts +10 -0
  53. package/dist/Recents.js +91 -0
  54. package/dist/RightPane.d.ts +13 -0
  55. package/dist/RightPane.js +426 -0
  56. package/dist/Room.d.ts +8 -0
  57. package/dist/Room.js +50 -0
  58. package/dist/Scene.d.ts +12 -0
  59. package/dist/Scene.js +105 -0
  60. package/dist/Settings.d.ts +46 -0
  61. package/dist/Settings.js +321 -0
  62. package/dist/Setup.d.ts +1 -0
  63. package/dist/Setup.js +36 -0
  64. package/dist/Share.d.ts +3 -0
  65. package/dist/Share.js +130 -0
  66. package/dist/Shared.d.ts +1 -0
  67. package/dist/Shared.js +113 -0
  68. package/dist/Shell.d.ts +63 -0
  69. package/dist/Shell.js +1225 -0
  70. package/dist/Start.d.ts +1 -0
  71. package/dist/Start.js +96 -0
  72. package/dist/Support.d.ts +14 -0
  73. package/dist/Support.js +122 -0
  74. package/dist/Talk.d.ts +4 -0
  75. package/dist/Talk.js +107 -0
  76. package/dist/Upgrade.d.ts +11 -0
  77. package/dist/Upgrade.js +75 -0
  78. package/dist/Usage.d.ts +1 -0
  79. package/dist/Usage.js +21 -0
  80. package/dist/Vibe.d.ts +2 -0
  81. package/dist/Vibe.js +188 -0
  82. package/dist/Work.d.ts +12 -0
  83. package/dist/Work.js +256 -0
  84. package/dist/bots/Bots.d.ts +2 -0
  85. package/dist/bots/Bots.js +456 -0
  86. package/dist/bots/Lights.d.ts +8 -0
  87. package/dist/bots/Lights.js +13 -0
  88. package/dist/bots/api.d.ts +120 -0
  89. package/dist/bots/api.js +75 -0
  90. package/dist/bots/face.d.ts +5 -0
  91. package/dist/bots/face.js +13 -0
  92. package/dist/bots/ink.d.ts +7 -0
  93. package/dist/bots/ink.js +29 -0
  94. package/dist/bots/kit.d.ts +113 -0
  95. package/dist/bots/kit.js +43 -0
  96. package/dist/bots/runtime.d.ts +4 -0
  97. package/dist/bots/runtime.js +326 -0
  98. package/dist/card.d.ts +52 -0
  99. package/dist/card.js +52 -0
  100. package/dist/cast.d.ts +143 -0
  101. package/dist/cast.js +251 -0
  102. package/dist/connect.d.ts +29 -0
  103. package/dist/connect.js +64 -0
  104. package/dist/contacts-store.d.ts +111 -0
  105. package/dist/contacts-store.js +276 -0
  106. package/dist/conversations.d.ts +54 -0
  107. package/dist/conversations.js +98 -0
  108. package/dist/copy.d.ts +23 -0
  109. package/dist/copy.js +39 -0
  110. package/dist/crew.d.ts +36 -0
  111. package/dist/crew.js +304 -0
  112. package/dist/dev.d.ts +3 -0
  113. package/dist/dev.js +102 -0
  114. package/dist/enso.d.ts +70 -0
  115. package/dist/enso.js +112 -0
  116. package/dist/failure.d.ts +57 -0
  117. package/dist/failure.js +143 -0
  118. package/dist/ground.d.ts +32 -0
  119. package/dist/ground.js +32 -0
  120. package/dist/host.d.ts +76 -0
  121. package/dist/host.js +76 -0
  122. package/dist/index.d.ts +14 -0
  123. package/dist/index.js +12 -0
  124. package/dist/kept.d.ts +5 -0
  125. package/dist/kept.js +27 -0
  126. package/dist/lib/account.d.ts +49 -0
  127. package/dist/lib/account.js +229 -0
  128. package/dist/lib/ai.d.ts +46 -0
  129. package/dist/lib/ai.js +140 -0
  130. package/dist/lib/api.d.ts +43 -0
  131. package/dist/lib/api.js +62 -0
  132. package/dist/lib/bare.d.ts +9 -0
  133. package/dist/lib/bare.js +7 -0
  134. package/dist/lib/coding.d.ts +105 -0
  135. package/dist/lib/coding.js +87 -0
  136. package/dist/lib/destination.d.ts +102 -0
  137. package/dist/lib/destination.js +260 -0
  138. package/dist/lib/durable.d.ts +66 -0
  139. package/dist/lib/durable.js +107 -0
  140. package/dist/lib/first.d.ts +11 -0
  141. package/dist/lib/first.js +23 -0
  142. package/dist/lib/home.d.ts +61 -0
  143. package/dist/lib/home.js +54 -0
  144. package/dist/lib/host.d.ts +104 -0
  145. package/dist/lib/host.js +132 -0
  146. package/dist/lib/hostname.d.ts +1 -0
  147. package/dist/lib/hostname.js +14 -0
  148. package/dist/lib/hydrated.d.ts +1 -0
  149. package/dist/lib/hydrated.js +20 -0
  150. package/dist/lib/integrations.d.ts +21 -0
  151. package/dist/lib/integrations.js +1 -0
  152. package/dist/lib/intent.d.ts +6 -0
  153. package/dist/lib/intent.js +71 -0
  154. package/dist/lib/invite.d.ts +155 -0
  155. package/dist/lib/invite.js +245 -0
  156. package/dist/lib/limits.d.ts +55 -0
  157. package/dist/lib/limits.js +119 -0
  158. package/dist/lib/list.d.ts +9 -0
  159. package/dist/lib/list.js +11 -0
  160. package/dist/lib/mark.d.ts +13 -0
  161. package/dist/lib/mark.js +85 -0
  162. package/dist/lib/mix.d.ts +22 -0
  163. package/dist/lib/mix.js +22 -0
  164. package/dist/lib/models.d.ts +27 -0
  165. package/dist/lib/models.js +106 -0
  166. package/dist/lib/org.d.ts +109 -0
  167. package/dist/lib/org.js +140 -0
  168. package/dist/lib/path.d.ts +9 -0
  169. package/dist/lib/path.js +29 -0
  170. package/dist/lib/plans.d.ts +193 -0
  171. package/dist/lib/plans.js +310 -0
  172. package/dist/lib/prose.d.ts +13 -0
  173. package/dist/lib/prose.js +62 -0
  174. package/dist/lib/reach.d.ts +17 -0
  175. package/dist/lib/reach.js +70 -0
  176. package/dist/lib/rooms.json +23 -0
  177. package/dist/lib/served.d.ts +20 -0
  178. package/dist/lib/served.js +92 -0
  179. package/dist/lib/session.d.ts +211 -0
  180. package/dist/lib/session.js +493 -0
  181. package/dist/lib/share.d.ts +131 -0
  182. package/dist/lib/share.js +216 -0
  183. package/dist/lib/signout.d.ts +14 -0
  184. package/dist/lib/signout.js +40 -0
  185. package/dist/lib/tags.d.ts +27 -0
  186. package/dist/lib/tags.js +46 -0
  187. package/dist/lib/tap.d.ts +17 -0
  188. package/dist/lib/tap.js +9 -0
  189. package/dist/lib/tier.d.ts +115 -0
  190. package/dist/lib/tier.js +207 -0
  191. package/dist/lib/tiers.d.ts +9 -0
  192. package/dist/lib/tiers.js +141 -0
  193. package/dist/lib/todo.d.ts +125 -0
  194. package/dist/lib/todo.js +379 -0
  195. package/dist/look.d.ts +1 -0
  196. package/dist/look.js +21 -0
  197. package/dist/meters.d.ts +8 -0
  198. package/dist/meters.js +31 -0
  199. package/dist/model.d.ts +4 -0
  200. package/dist/model.js +44 -0
  201. package/dist/network.d.ts +64 -0
  202. package/dist/network.js +216 -0
  203. package/dist/open.d.ts +81 -0
  204. package/dist/open.js +149 -0
  205. package/dist/pane.d.ts +92 -0
  206. package/dist/pane.js +231 -0
  207. package/dist/progress.d.ts +46 -0
  208. package/dist/progress.js +51 -0
  209. package/dist/recent.d.ts +67 -0
  210. package/dist/recent.js +161 -0
  211. package/dist/rooms.d.ts +131 -0
  212. package/dist/rooms.js +195 -0
  213. package/dist/roster.d.ts +28 -0
  214. package/dist/roster.js +70 -0
  215. package/dist/span.d.ts +14 -0
  216. package/dist/span.js +50 -0
  217. package/dist/speech.d.ts +44 -0
  218. package/dist/speech.js +229 -0
  219. package/dist/stars.d.ts +16 -0
  220. package/dist/stars.js +75 -0
  221. package/dist/tabs.d.ts +26 -0
  222. package/dist/tabs.js +25 -0
  223. package/dist/team.d.ts +11 -0
  224. package/dist/team.gen.d.ts +10 -0
  225. package/dist/team.gen.js +11 -0
  226. package/dist/team.js +30 -0
  227. package/dist/tools.d.ts +35 -0
  228. package/dist/tools.js +143 -0
  229. package/dist/tours.d.ts +24 -0
  230. package/dist/tours.js +311 -0
  231. package/dist/waiting.d.ts +15 -0
  232. package/dist/waiting.js +17 -0
  233. package/dist/where.d.ts +54 -0
  234. package/dist/where.js +14 -0
  235. package/package.json +108 -0
@@ -0,0 +1,310 @@
1
+ /**
2
+ * The subscription catalog, in one place, for every pricing surface.
3
+ *
4
+ * THERE ARE TWO CATALOGS AND THEY ARE DIFFERENT PRODUCTS. Confusing them is
5
+ * what this module exists to prevent:
6
+ *
7
+ * GET /v1/billing/plans THIS ONE. commerce's subscription authority — the
8
+ * same rows that charge. 17 plans across categories
9
+ * personal/team/enterprise/world/social/dns, each
10
+ * with `features`. Bare ARRAY, `slug`, `price` in
11
+ * CENTS.
12
+ * GET /v1/plans Cloud VM tiers — vcpus, memoryGB, diskGB. 11 rows,
13
+ * NO `features` on any of them. Shaped `{plans:[…]}`.
14
+ *
15
+ * They collide on the words "pro" and "enterprise" and on nothing else: `pro`
16
+ * the subscription is $20/mo, `pro` the VM tier is $25/mo for 2 vCPU. Reading
17
+ * the VM catalog to render subscriptions is not a wrong number, it is a wrong
18
+ * product — and it crashed this page. The team/enterprise view filtered /v1/plans
19
+ * for `category === "enterprise"`, matched the $429 VM row, replaced the real
20
+ * Team/Enterprise cards with it, and handed a card `features: undefined`. The
21
+ * `.map()` over it took the whole pricing page down to "Something went wrong."
22
+ * for anyone who clicked the tab.
23
+ *
24
+ * So the catalog is named once, mapped once, and guarded once, here.
25
+ *
26
+ * A SuperAdmin edits these rows at admin.hanzo.ai (/v1/commerce/plans/entries),
27
+ * and every reader here follows: the build bakes the authority's answer into the
28
+ * pricing snapshot for the first paint, and the page replaces it with the live
29
+ * read. No price in this repo is typed; @hanzo/plans is read only for what the
30
+ * wire does not carry — a plan's name for a slug, and the Free row's models.
31
+ */
32
+ import { useEffect, useMemo, useState } from "react";
33
+ import { subscriptionPlans } from "@hanzo/plans";
34
+ import { where } from "../where.js";
35
+ import { api } from "./api.js";
36
+ import { org } from "./session.js";
37
+ /**
38
+ * Where money changes hands: the checkout, mounted at hanzo.ai/pay so a buyer
39
+ * never leaves the site. It is hanzo-inc/pay, served from the same export
40
+ * pay.hanzo.ai was; that host now redirects here.
41
+ *
42
+ * billing.hanzo.ai has no route at the edge and answers 404, and /billing on
43
+ * this site is the Billing PRODUCT's page — a reader sent there to buy a plan
44
+ * lands on marketing. /pay takes `?plan=`, `?interval=`, `?returnUrl=` and
45
+ * `?org=`, and comes back. Every "add credit" and "billing" link reads this one
46
+ * address. Absolute, because this export also serves cloud.hanzo.ai and
47
+ * hanzo.bot, and the checkout lives on hanzo.ai.
48
+ */
49
+ const BILLING_URL = "https://hanzo.ai/pay";
50
+ /**
51
+ * The checkout address, opened on a plan when one is named, and returning to
52
+ * `back` once the buyer has paid. Without `back` a paid buyer lands on the pay
53
+ * site's receipt with no way into what they bought.
54
+ *
55
+ * ONE writer for this address. It was spelled out in three places — the
56
+ * personal ladder, the business strip and the /account hand-off — and a plan id
57
+ * only selects the card a reader clicked if every writer spells the query the
58
+ * same way.
59
+ */
60
+ export function checkoutUrl(id, back) {
61
+ return payUrl(id ? "/cart" : "/", { plan: id, returnUrl: back });
62
+ }
63
+ /**
64
+ * A page on the pay site, for the organization this browser works in.
65
+ *
66
+ * pay charges and reads the ledger it is named, and it can only offer the ones
67
+ * the token lists; which of them this workspace is in is a fact only this site
68
+ * holds. `?org=` carries it across, and pay honours it only while the token
69
+ * offers it. Call it at the moment of leaving: the organization lives in this
70
+ * browser and moves with the switcher.
71
+ */
72
+ export function payPage(path = "/") {
73
+ return payUrl(path, { org: org() });
74
+ }
75
+ /** The pay address with its query, written in one place for both callers above. */
76
+ function payUrl(path, query) {
77
+ const q = new URLSearchParams();
78
+ for (const [k, v] of Object.entries(query))
79
+ if (v)
80
+ q.set(k, v);
81
+ const s = q.toString();
82
+ if (path === "/" && !s)
83
+ return BILLING_URL;
84
+ return `${BILLING_URL}${path}${s ? `?${s}` : ""}`;
85
+ }
86
+ /**
87
+ * The checkout a plan's CTA opens.
88
+ *
89
+ * A catalog row may carry its own link, and then that link wins; otherwise the
90
+ * id is the whole address. The id is also what `plan_clicked` reports, so the
91
+ * plan the event names and the plan the checkout opens cannot disagree.
92
+ */
93
+ export function planCheckoutUrl(plan, back) {
94
+ return plan.checkoutUrl || checkoutUrl(plan.checkoutId || plan.id, back);
95
+ }
96
+ /**
97
+ * A checkout address for a term: a year adds `interval=year`, a month leaves the
98
+ * address as it is. pay reads the parameter; until it sells a year, its cart
99
+ * states the monthly charge, and the buyer reads that before paying. A relative
100
+ * address is read against the pay site, where every checkout is.
101
+ */
102
+ export function term(href, interval) {
103
+ if (interval === "month")
104
+ return href;
105
+ const url = new URL(href, BILLING_URL);
106
+ url.searchParams.set("interval", "year");
107
+ return url.toString();
108
+ }
109
+ /**
110
+ * The cart for `seats` of a per-seat plan, for a term, charged to `org` and
111
+ * returning to `back` once paid. `quantity` is the seat count checkout sells.
112
+ */
113
+ export function cartUrl(plan, seats, interval, org, back) {
114
+ return term(payUrl("/cart", { plan, quantity: String(seats), org, returnUrl: back }), interval);
115
+ }
116
+ /**
117
+ * Billing speaks CENTS; these pages render dollars. Converting here, once, at
118
+ * the boundary — a price that is 100x wrong on a checkout page is the worst
119
+ * possible rounding bug, so it converts in exactly one place and nowhere else.
120
+ *
121
+ * `features` defaults to [] because a card maps over it. A plan that arrives
122
+ * without features should render as a plan with no features listed, never as a
123
+ * blank page.
124
+ */
125
+ function fromBillingPlan(p) {
126
+ return {
127
+ id: p.slug,
128
+ name: p.name,
129
+ description: p.description,
130
+ priceMonthly: p.price == null ? null : p.price / 100,
131
+ priceAnnual: p.priceAnnual == null ? null : p.priceAnnual / 100,
132
+ annualTotal: p.annualTotal ? p.annualTotal / 100 : null,
133
+ category: p.category,
134
+ popular: p.popular,
135
+ contactSales: p.contactSales,
136
+ pricePerUser: p.perSeat,
137
+ features: p.features ?? [],
138
+ limits: p.limits,
139
+ checkoutUrl: p.checkoutUrl,
140
+ checkoutId: p.checkoutId,
141
+ };
142
+ }
143
+ /**
144
+ * The authority's rows as the build last read them — scripts/sync-pricing.mjs
145
+ * takes GET /v1/billing/plans into lib/data/plans.json on every build. It is the
146
+ * first paint, and the whole answer wherever the live read fails: a preview host
147
+ * the API does not admit, a reader offline, an outage. The SAME rows as the live
148
+ * read, so the two can differ only by what a SuperAdmin changed since the build.
149
+ */
150
+ const baked = () => where().plans ?? [];
151
+ /**
152
+ * The catalog, live — or null when commerce did not answer.
153
+ *
154
+ * The difference is the whole point. Commerce is where a plan is listed or
155
+ * archived, so "this category sells nothing right now" is a real answer an admin
156
+ * can produce, and it has to reach the page: archiving the last rung in a
157
+ * category should empty it, not leave the build-time copy standing in as if
158
+ * nothing had changed. A transport error or a non-2xx is null, and a caller keeps
159
+ * the snapshot for it — a commerce outage must not blank the pricing page.
160
+ *
161
+ * ONE READ PER PAGE. Every hook below asks for the whole catalog and filters it,
162
+ * so a page drawing four categories costs one request, not four.
163
+ */
164
+ let reading = null;
165
+ function live() {
166
+ reading ??= fetch(`${api()}/v1/billing/plans`)
167
+ .then(async (res) => {
168
+ if (!res.ok)
169
+ return null;
170
+ const body = await res.json();
171
+ // The catalog answers with a bare array. Tolerate {plans:[…]} so a proxy
172
+ // that wraps it cannot silently yield zero rows. Anything else is not a
173
+ // catalog, and reading it as an empty one would blank the page on a shape
174
+ // change rather than on a decision.
175
+ return Array.isArray(body) ? body : Array.isArray(body?.plans) ? body.plans : null;
176
+ })
177
+ .catch(() => null);
178
+ return reading;
179
+ }
180
+ /** The plans in one category, live — or null when commerce did not answer. */
181
+ export async function loadPlans(category) {
182
+ const rows = await live();
183
+ return rows === null ? null : rows.filter((p) => p?.category === category).map(fromBillingPlan);
184
+ }
185
+ /** The plans in one category as the build last read them. */
186
+ export function fallbackPlans(category) {
187
+ return baked().filter((p) => p.category === category).map(fromBillingPlan);
188
+ }
189
+ /**
190
+ * Every row the authority serves, in catalog order: painted from the snapshot,
191
+ * replaced by the live read. This is how an edit made at admin.hanzo.ai reaches
192
+ * a reader with no build in between.
193
+ */
194
+ export function useCatalog() {
195
+ const [rows, setRows] = useState(() => baked().map(fromBillingPlan));
196
+ useEffect(() => {
197
+ let open = true;
198
+ live().then((got) => {
199
+ if (open && got)
200
+ setRows(got.map(fromBillingPlan));
201
+ });
202
+ return () => {
203
+ open = false;
204
+ };
205
+ }, []);
206
+ return rows;
207
+ }
208
+ /** Dollars as a price is written: two places, always, so a card and a button agree. */
209
+ const dollars = (n) => `$${n.toFixed(2)}`;
210
+ /**
211
+ * Which plan an organization runs on, and what it costs. A plan is a
212
+ * subscription with usage limits; it mints no credit, so nothing here says it does.
213
+ *
214
+ * ONE ANSWER FOR TWO SURFACES. /pricing states the offer and the workspace gate
215
+ * asks a member to buy it; those were two readings of one row, each with its own
216
+ * copy of "the first personal row that charges" and its own money formatter. A
217
+ * marketing page and a subscribe screen quoting different numbers for the same
218
+ * plan is the failure that costs a sale, and it needs only for one of the two
219
+ * copies to be edited.
220
+ *
221
+ * It is the FIRST ROW THAT CHARGES in a category: `personal` is the plan a
222
+ * personal organization runs on, `team` the per-seat plan a team organization
223
+ * runs on, whatever commerce currently prices each at. Neither surface holds the
224
+ * figure, so neither can be wrong on its own.
225
+ *
226
+ * Painted from the snapshot and replaced by the live catalog, which is the
227
+ * contract every reader in this module keeps.
228
+ */
229
+ export function usePlan(category = "personal") {
230
+ return priced(usePlans(category).find((p) => (p.priceMonthly ?? 0) > 0) ?? null);
231
+ }
232
+ /**
233
+ * A category's rows a reader can buy on their own, in catalog order: the
234
+ * personal ladder from Free up, or the Team plan. Rows sold through a
235
+ * conversation are left out. Painted from the snapshot, replaced by the catalog.
236
+ */
237
+ export function usePlans(category) {
238
+ const all = useCatalog();
239
+ return useMemo(() => all.filter((p) => p.category === category && !p.contactSales), [all, category]);
240
+ }
241
+ /** A row priced the way every surface writes it. */
242
+ export function priced(plan) {
243
+ return { plan, price: plan ? dollars(plan.priceMonthly ?? 0) : "—" };
244
+ }
245
+ /** Money as a sentence says it: whole dollars bare, anything else to the cent — $20, $16.67. */
246
+ export const money = (n) => (Number.isInteger(n) ? `$${n.toLocaleString("en-US")}` : dollars(n));
247
+ /**
248
+ * What one term of a plan charges, in dollars, or null where the catalog sells
249
+ * no such term. A month is `priceMonthly`. A year is the total the catalog
250
+ * states, or twelve of `priceAnnual` where it states only the monthly figure.
251
+ */
252
+ export function charge(plan, interval) {
253
+ const n = interval === "month"
254
+ ? plan.priceMonthly
255
+ : (plan.annualTotal ?? (plan.priceAnnual ? Math.round(plan.priceAnnual * 1200) / 100 : null));
256
+ return n && n > 0 ? n : null;
257
+ }
258
+ /**
259
+ * A term's price as a button quotes it — "$20/month", "$200/year" — or null.
260
+ * A year is quoted as the total where the catalog states one, and otherwise as
261
+ * the monthly figure the catalog does state, never as a total derived here.
262
+ */
263
+ export function quote(plan, interval) {
264
+ if (interval === "month")
265
+ return plan.priceMonthly ? `${money(plan.priceMonthly)}/month` : null;
266
+ if (plan.annualTotal)
267
+ return `${money(plan.annualTotal)}/year`;
268
+ return plan.priceAnnual ? `${money(plan.priceAnnual)}/month, billed yearly` : null;
269
+ }
270
+ /** What a year saves against twelve months, as a whole percent: 1 − year ÷ (12 × month). Null where it saves nothing. */
271
+ export function saving(plan) {
272
+ const month = charge(plan, "month");
273
+ const year = charge(plan, "year");
274
+ if (!month || !year || year >= 12 * month)
275
+ return null;
276
+ return Math.round((1 - year / (12 * month)) * 100);
277
+ }
278
+ /** The models the Free plan runs, as @hanzo/plans states them: `ai.models` on the `free` row. */
279
+ const FREE_MODELS = new Set(subscriptionPlans.find((p) => p.id === "free")?.entitlements?.["ai.models"] ?? []);
280
+ /** Whether the Free plan runs a model: one its row names, or any `:free` route. */
281
+ export function onFree(model) {
282
+ return FREE_MODELS.has(model) || model.endsWith(":free");
283
+ }
284
+ /**
285
+ * The catalog's own name for a plan slug — "max-20x" reads as "Max 20x" —
286
+ * or null where the slug is empty or the catalog does not carry it.
287
+ *
288
+ * Read off the published package: a plan's name does not move between a build and the next release the way
289
+ * its price can, so the bundled copy is not a stale answer here the way it
290
+ * would be for money. A reader of `GET /v1/billing/tier`'s `plan` field
291
+ * needs a name and nothing else, so no live fetch is spent proving what the
292
+ * package already states.
293
+ */
294
+ export function planName(slug) {
295
+ if (!slug)
296
+ return null;
297
+ const row = subscriptionPlans.find((p) => p.id === slug);
298
+ return row?.name ?? null;
299
+ }
300
+ /**
301
+ * A usage rate as money. A sub-dollar rate needs at least two decimals to read as
302
+ * a price at all — 0.1 is $0.10, not $0.1 — while keeping the precision the
303
+ * catalog states, so $0.015/1K vectors does not round away to a cent.
304
+ */
305
+ export function formatUsageRate(v) {
306
+ if (v >= 1)
307
+ return `$${v.toLocaleString("en-US")}`;
308
+ const decimals = (String(v).split(".")[1] ?? "").length;
309
+ return `$${v.toFixed(Math.max(2, decimals))}`;
310
+ }
@@ -0,0 +1,13 @@
1
+ import { type Token, type Tokens } from 'marked';
2
+ export type { Token, Tokens };
3
+ /** One message's text as marked's block tokens. Empty in, empty out — a caller
4
+ * rendering a streaming turn asks this on every token and the first is nothing. */
5
+ export declare function read(text: string): Token[];
6
+ /** The href a link may carry, or null when it may carry none. Parsed with URL,
7
+ * so the scheme judged is the scheme a browser would follow, and a link is
8
+ * away whenever it would leave the page's origin — `//host`, `/\host` and
9
+ * `https:host` included, however the address is spelled. */
10
+ export declare function destination(href: string): {
11
+ href: string;
12
+ away: boolean;
13
+ } | null;
@@ -0,0 +1,62 @@
1
+ // What a message says, as it was written.
2
+ //
3
+ // A model answers in markdown — fences, lists, emphasis, tables — and this app
4
+ // drew that as literal characters: an answer arrived reading "```ts" and three
5
+ // backticks stayed on screen. Three surfaces then each grew their own half of a
6
+ // renderer, and all three were the same regex pair (fences, newlines) with no
7
+ // escaping inside the fence, so a code block containing a tag lost the tag.
8
+ //
9
+ // One reader, and `marked` is the one the site already parses its legal
10
+ // documents with — same engine, same dialect, so a fence means the same thing
11
+ // wherever it is written. This file READS: it turns a message into marked's
12
+ // tokens. `components/workspace/Prose.tsx` draws them in gui elements.
13
+ //
14
+ // SAFE BY CONSTRUCTION RATHER THAN BY CLEANUP. Nothing here or in the drawing
15
+ // produces markup from the text: every leaf is a string React escapes, raw HTML
16
+ // is shown as the text somebody typed, and a link keeps only a scheme a message
17
+ // is allowed to carry. The same tokens come out on the server and in the
18
+ // browser, which is also what keeps hydration quiet.
19
+ import { Lexer } from 'marked';
20
+ /** GitHub's dialect, and a single newline is a line break: a model breaks a
21
+ * line where it means one. */
22
+ const DIALECT = { gfm: true, breaks: true };
23
+ /** One message's text as marked's block tokens. Empty in, empty out — a caller
24
+ * rendering a streaming turn asks this on every token and the first is nothing. */
25
+ export function read(text) {
26
+ if (!text)
27
+ return [];
28
+ return Lexer.lex(text, DIALECT);
29
+ }
30
+ /** Schemes a message may link to. Anything else — `javascript:`, `data:`,
31
+ * `vbscript:` — is a script delivered as a destination, so the text stays and
32
+ * the link does not. A relative or fragment href resolves to this page's own
33
+ * scheme and is kept. */
34
+ const SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);
35
+ /** An href read the way a browser reads it: every tab, newline and carriage
36
+ * return removed wherever it stands, and control characters and spaces removed
37
+ * from both ends (WHATWG URL parsing). `\x01javascript:` is `javascript:` to a
38
+ * browser, so it has to be `javascript:` to the allowlist too. */
39
+ const TAB_OR_NEWLINE = /[\t\n\r]/g;
40
+ const EDGES = /^[\u0000- ]+|[\u0000- ]+$/g;
41
+ /** The page a relative href is resolved against. Its origin is no site's, so an
42
+ * href that stays on it is one that stays on whatever site renders it. */
43
+ const HERE = new URL('https://here.invalid/');
44
+ /** The href a link may carry, or null when it may carry none. Parsed with URL,
45
+ * so the scheme judged is the scheme a browser would follow, and a link is
46
+ * away whenever it would leave the page's origin — `//host`, `/\host` and
47
+ * `https:host` included, however the address is spelled. */
48
+ export function destination(href) {
49
+ const bare = href.replace(TAB_OR_NEWLINE, '').replace(EDGES, '');
50
+ if (/[\u0000-\u001f\u007f]/.test(bare))
51
+ return null;
52
+ let url;
53
+ try {
54
+ url = new URL(bare, HERE);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ if (!SCHEMES.has(url.protocol))
60
+ return null;
61
+ return { href: bare, away: url.origin !== HERE.origin };
62
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * What the server said about a failed call, or undefined when it said nothing.
3
+ *
4
+ * The platform refuses in RFC 9457 problem documents — `{title, status,
5
+ * detail}` — and `@hanzo/ai` 0.6.16 reads only `msg` and `error`, so a refusal
6
+ * with a reason reaches the page as `HTTP 400`. The reason is in the body the
7
+ * error carries, and this reads it: `detail`, then `message`, then `title`.
8
+ */
9
+ export declare function told(error: unknown): string | undefined;
10
+ /**
11
+ * The sentence to show for a failed call.
12
+ *
13
+ * Anything the SERVER said is passed through untouched — a refusal in the
14
+ * platform's own words is better than any rewording of it. Only the silence
15
+ * gets replaced, because silence is the case with nothing in it to show.
16
+ */
17
+ export declare function reach(error: unknown): string;
@@ -0,0 +1,70 @@
1
+ // What went wrong, in words that name something you can act on.
2
+ //
3
+ // A browser reports a request that never got an answer as `TypeError: Load
4
+ // failed` (Safari) or `Failed to fetch` (Chrome). Both are the SAME event and
5
+ // neither says which address failed or why — so a form that shows the message
6
+ // verbatim reads as "the app is broken" for a cause that is usually one line of
7
+ // configuration. There is no status to report because no response arrived.
8
+ //
9
+ // The cause is nearly always one of two, and both are about WHERE the page is
10
+ // open rather than about the request: the API is not running at the address the
11
+ // page was built with, or it is running and does not allow this page's origin,
12
+ // which the browser refuses BEFORE the request and reports identically.
13
+ // `localhost` and `127.0.0.1` are different origins to a browser and the same
14
+ // machine to a person, which is why this is worth a sentence rather than a
15
+ // shrug.
16
+ import { api } from './api.js';
17
+ /** A browser's word for "no answer arrived", per engine. */
18
+ const silent = (message) => /load failed|failed to fetch|networkerror|network request failed/i.test(message);
19
+ /**
20
+ * What the server said about a failed call, or undefined when it said nothing.
21
+ *
22
+ * The platform refuses in RFC 9457 problem documents — `{title, status,
23
+ * detail}` — and `@hanzo/ai` 0.6.16 reads only `msg` and `error`, so a refusal
24
+ * with a reason reaches the page as `HTTP 400`. The reason is in the body the
25
+ * error carries, and this reads it: `detail`, then `message`, then `title`.
26
+ */
27
+ export function told(error) {
28
+ const body = error?.body;
29
+ if (body && typeof body === 'object') {
30
+ const b = body;
31
+ for (const key of ['detail', 'message', 'msg', 'error', 'title']) {
32
+ const v = b[key];
33
+ if (typeof v === 'string' && v.trim())
34
+ return v.trim();
35
+ if (key === 'error' && v && typeof v === 'object') {
36
+ const m = v.message;
37
+ if (typeof m === 'string' && m.trim())
38
+ return m.trim();
39
+ }
40
+ }
41
+ }
42
+ return undefined;
43
+ }
44
+ /**
45
+ * The sentence to show for a failed call.
46
+ *
47
+ * Anything the SERVER said is passed through untouched — a refusal in the
48
+ * platform's own words is better than any rewording of it. Only the silence
49
+ * gets replaced, because silence is the case with nothing in it to show.
50
+ */
51
+ export function reach(error) {
52
+ const message = told(error) ?? (error instanceof Error ? error.message : String(error ?? ''));
53
+ // Named at call time, so it is the address the page in front of the reader
54
+ // actually used rather than the one the build was made with.
55
+ const where = api();
56
+ if (!message)
57
+ return `No answer from ${where}.`;
58
+ if (!silent(message))
59
+ return message;
60
+ const here = typeof window === 'undefined' ? '' : window.location.origin;
61
+ // SAME ORIGIN IS NOT A CORS REFUSAL. On the dev server the platform's address
62
+ // IS this page's, because `/v1` is proxied there — so silence means whatever
63
+ // the proxy points at is not up, and the second half below would send the
64
+ // reader after an allowlist that was never in the way.
65
+ if (where === here)
66
+ return `No answer from ${here}/v1 — the address it proxies to is not running.`;
67
+ return (`No answer from ${where}. It is either not running, or it is not open to ` +
68
+ `pages served from ${here || 'this address'} ` +
69
+ `— a browser treats localhost and 127.0.0.1 as different places.`);
70
+ }
@@ -0,0 +1,23 @@
1
+ [
2
+ "/beluga",
3
+ "/board",
4
+ "/bots",
5
+ "/cal",
6
+ "/chat",
7
+ "/chat/shared",
8
+ "/contacts",
9
+ "/dashboard",
10
+ "/dev",
11
+ "/drive",
12
+ "/home",
13
+ "/inbox",
14
+ "/meet",
15
+ "/settings",
16
+ "/settings/organization",
17
+ "/settings/organization/billing",
18
+ "/settings/organization/keys",
19
+ "/settings/organization/members",
20
+ "/settings/organization/usage",
21
+ "/vibe",
22
+ "/work"
23
+ ]
@@ -0,0 +1,20 @@
1
+ /**
2
+ * WHICH MODEL ANSWERED A TURN, as the gateway says it.
3
+ *
4
+ * The gateway may answer a pinned model from another when a provider refuses,
5
+ * and it names the one that answered twice: the `X-Hanzo-Served` header and
6
+ * the `model` field of the reply's own frames. `@hanzo/ai` 0.6.16 reads
7
+ * neither, so the site's client is built on `observed(fetch)`, which reads
8
+ * them off each completion as it passes and tells the listeners — the header
9
+ * when the gateway exposes it, else the first frame that names a model. Every
10
+ * byte is passed through untouched.
11
+ */
12
+ /** A completion's asked and answering models. */
13
+ export interface Served {
14
+ asked: string;
15
+ served: string;
16
+ }
17
+ /** Hears every served completion from now on; answers the unsubscribe. */
18
+ export declare function onServed(listener: (heard: Served) => void): () => void;
19
+ /** A fetch that tells `onServed` listeners which model answered each completion. */
20
+ export declare function observed(base: typeof fetch): typeof fetch;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * WHICH MODEL ANSWERED A TURN, as the gateway says it.
3
+ *
4
+ * The gateway may answer a pinned model from another when a provider refuses,
5
+ * and it names the one that answered twice: the `X-Hanzo-Served` header and
6
+ * the `model` field of the reply's own frames. `@hanzo/ai` 0.6.16 reads
7
+ * neither, so the site's client is built on `observed(fetch)`, which reads
8
+ * them off each completion as it passes and tells the listeners — the header
9
+ * when the gateway exposes it, else the first frame that names a model. Every
10
+ * byte is passed through untouched.
11
+ */
12
+ const listeners = new Set();
13
+ /** Hears every served completion from now on; answers the unsubscribe. */
14
+ export function onServed(listener) {
15
+ listeners.add(listener);
16
+ return () => {
17
+ listeners.delete(listener);
18
+ };
19
+ }
20
+ const tell = (heard) => {
21
+ for (const listener of listeners)
22
+ listener(heard);
23
+ };
24
+ /** The completion route, whatever host the client was built for. */
25
+ const COMPLETION = /\/v1\/chat\/completions(?:[?#]|$)/;
26
+ /** A fetch that tells `onServed` listeners which model answered each completion. */
27
+ export function observed(base) {
28
+ return async (input, init) => {
29
+ const res = await base(input, init);
30
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
31
+ if (!COMPLETION.test(url) || !res.ok)
32
+ return res;
33
+ let asked = '';
34
+ try {
35
+ const body = typeof init?.body === 'string' ? JSON.parse(init.body) : null;
36
+ if (typeof body?.model === 'string')
37
+ asked = body.model;
38
+ }
39
+ catch {
40
+ /* a body that is not JSON names no model */
41
+ }
42
+ const header = res.headers.get('x-hanzo-served');
43
+ if (header) {
44
+ tell({ asked, served: header });
45
+ return res;
46
+ }
47
+ if (!res.body || !(res.headers.get('content-type') ?? '').includes('text/event-stream')) {
48
+ res
49
+ .clone()
50
+ .json()
51
+ .then((d) => {
52
+ if (typeof d?.model === 'string' && d.model)
53
+ tell({ asked, served: d.model });
54
+ })
55
+ .catch(() => { });
56
+ return res;
57
+ }
58
+ const decoder = new TextDecoder();
59
+ let heard = false;
60
+ let carry = '';
61
+ const watch = new TransformStream({
62
+ transform(chunk, out) {
63
+ out.enqueue(chunk);
64
+ if (heard)
65
+ return;
66
+ carry += decoder.decode(chunk, { stream: true });
67
+ let at = carry.indexOf('\n');
68
+ while (!heard && at >= 0) {
69
+ const line = carry.slice(0, at).trim();
70
+ carry = carry.slice(at + 1);
71
+ at = carry.indexOf('\n');
72
+ if (!line.startsWith('data:'))
73
+ continue;
74
+ const data = line.slice(5).trim();
75
+ if (!data || data === '[DONE]')
76
+ continue;
77
+ try {
78
+ const frame = JSON.parse(data);
79
+ if (typeof frame.model === 'string' && frame.model) {
80
+ heard = true;
81
+ tell({ asked, served: frame.model });
82
+ }
83
+ }
84
+ catch {
85
+ /* a frame split across chunks is read whole on the next line */
86
+ }
87
+ }
88
+ },
89
+ });
90
+ return new Response(res.body.pipeThrough(watch), { status: res.status, statusText: res.statusText, headers: res.headers });
91
+ };
92
+ }