@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.
- package/LICENSE-APACHE +205 -0
- package/LICENSE-MIT +21 -0
- package/README.md +23 -0
- package/dist/Account.d.ts +8 -0
- package/dist/Account.js +58 -0
- package/dist/Beluga.d.ts +12 -0
- package/dist/Beluga.js +152 -0
- package/dist/Billing.d.ts +1 -0
- package/dist/Billing.js +31 -0
- package/dist/Board.d.ts +9 -0
- package/dist/Board.js +447 -0
- package/dist/Boxes.d.ts +1 -0
- package/dist/Boxes.js +173 -0
- package/dist/CalScheduler.d.ts +15 -0
- package/dist/CalScheduler.js +196 -0
- package/dist/Chat.d.ts +35 -0
- package/dist/Chat.js +1049 -0
- package/dist/Column.d.ts +15 -0
- package/dist/Column.js +88 -0
- package/dist/Contacts.d.ts +1 -0
- package/dist/Contacts.js +301 -0
- package/dist/Directory.d.ts +24 -0
- package/dist/Directory.js +356 -0
- package/dist/Drive.d.ts +2 -0
- package/dist/Drive.js +466 -0
- package/dist/Find.d.ts +7 -0
- package/dist/Find.js +82 -0
- package/dist/Guide.d.ts +6 -0
- package/dist/Guide.js +272 -0
- package/dist/Home.d.ts +39 -0
- package/dist/Home.js +306 -0
- package/dist/Inbox.d.ts +9 -0
- package/dist/Inbox.js +193 -0
- package/dist/Market.d.ts +3 -0
- package/dist/Market.js +149 -0
- package/dist/Me.d.ts +27 -0
- package/dist/Me.js +251 -0
- package/dist/Meet.d.ts +9 -0
- package/dist/Meet.js +370 -0
- package/dist/ModeMark.d.ts +8 -0
- package/dist/ModeMark.js +18 -0
- package/dist/OrgSwitcher.d.ts +31 -0
- package/dist/OrgSwitcher.js +120 -0
- package/dist/Orgs.d.ts +10 -0
- package/dist/Orgs.js +582 -0
- package/dist/Profile.d.ts +4 -0
- package/dist/Profile.js +67 -0
- package/dist/Prose.d.ts +5 -0
- package/dist/Prose.js +111 -0
- package/dist/ProviderMark.d.ts +17 -0
- package/dist/ProviderMark.js +277 -0
- package/dist/Recents.d.ts +10 -0
- package/dist/Recents.js +91 -0
- package/dist/RightPane.d.ts +13 -0
- package/dist/RightPane.js +426 -0
- package/dist/Room.d.ts +8 -0
- package/dist/Room.js +50 -0
- package/dist/Scene.d.ts +12 -0
- package/dist/Scene.js +105 -0
- package/dist/Settings.d.ts +46 -0
- package/dist/Settings.js +321 -0
- package/dist/Setup.d.ts +1 -0
- package/dist/Setup.js +36 -0
- package/dist/Share.d.ts +3 -0
- package/dist/Share.js +130 -0
- package/dist/Shared.d.ts +1 -0
- package/dist/Shared.js +113 -0
- package/dist/Shell.d.ts +63 -0
- package/dist/Shell.js +1225 -0
- package/dist/Start.d.ts +1 -0
- package/dist/Start.js +96 -0
- package/dist/Support.d.ts +14 -0
- package/dist/Support.js +122 -0
- package/dist/Talk.d.ts +4 -0
- package/dist/Talk.js +107 -0
- package/dist/Upgrade.d.ts +11 -0
- package/dist/Upgrade.js +75 -0
- package/dist/Usage.d.ts +1 -0
- package/dist/Usage.js +21 -0
- package/dist/Vibe.d.ts +2 -0
- package/dist/Vibe.js +188 -0
- package/dist/Work.d.ts +12 -0
- package/dist/Work.js +256 -0
- package/dist/bots/Bots.d.ts +2 -0
- package/dist/bots/Bots.js +456 -0
- package/dist/bots/Lights.d.ts +8 -0
- package/dist/bots/Lights.js +13 -0
- package/dist/bots/api.d.ts +120 -0
- package/dist/bots/api.js +75 -0
- package/dist/bots/face.d.ts +5 -0
- package/dist/bots/face.js +13 -0
- package/dist/bots/ink.d.ts +7 -0
- package/dist/bots/ink.js +29 -0
- package/dist/bots/kit.d.ts +113 -0
- package/dist/bots/kit.js +43 -0
- package/dist/bots/runtime.d.ts +4 -0
- package/dist/bots/runtime.js +326 -0
- package/dist/card.d.ts +52 -0
- package/dist/card.js +52 -0
- package/dist/cast.d.ts +143 -0
- package/dist/cast.js +251 -0
- package/dist/connect.d.ts +29 -0
- package/dist/connect.js +64 -0
- package/dist/contacts-store.d.ts +111 -0
- package/dist/contacts-store.js +276 -0
- package/dist/conversations.d.ts +54 -0
- package/dist/conversations.js +98 -0
- package/dist/copy.d.ts +23 -0
- package/dist/copy.js +39 -0
- package/dist/crew.d.ts +36 -0
- package/dist/crew.js +304 -0
- package/dist/dev.d.ts +3 -0
- package/dist/dev.js +102 -0
- package/dist/enso.d.ts +70 -0
- package/dist/enso.js +112 -0
- package/dist/failure.d.ts +57 -0
- package/dist/failure.js +143 -0
- package/dist/ground.d.ts +32 -0
- package/dist/ground.js +32 -0
- package/dist/host.d.ts +76 -0
- package/dist/host.js +76 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/kept.d.ts +5 -0
- package/dist/kept.js +27 -0
- package/dist/lib/account.d.ts +49 -0
- package/dist/lib/account.js +229 -0
- package/dist/lib/ai.d.ts +46 -0
- package/dist/lib/ai.js +140 -0
- package/dist/lib/api.d.ts +43 -0
- package/dist/lib/api.js +62 -0
- package/dist/lib/bare.d.ts +9 -0
- package/dist/lib/bare.js +7 -0
- package/dist/lib/coding.d.ts +105 -0
- package/dist/lib/coding.js +87 -0
- package/dist/lib/destination.d.ts +102 -0
- package/dist/lib/destination.js +260 -0
- package/dist/lib/durable.d.ts +66 -0
- package/dist/lib/durable.js +107 -0
- package/dist/lib/first.d.ts +11 -0
- package/dist/lib/first.js +23 -0
- package/dist/lib/home.d.ts +61 -0
- package/dist/lib/home.js +54 -0
- package/dist/lib/host.d.ts +104 -0
- package/dist/lib/host.js +132 -0
- package/dist/lib/hostname.d.ts +1 -0
- package/dist/lib/hostname.js +14 -0
- package/dist/lib/hydrated.d.ts +1 -0
- package/dist/lib/hydrated.js +20 -0
- package/dist/lib/integrations.d.ts +21 -0
- package/dist/lib/integrations.js +1 -0
- package/dist/lib/intent.d.ts +6 -0
- package/dist/lib/intent.js +71 -0
- package/dist/lib/invite.d.ts +155 -0
- package/dist/lib/invite.js +245 -0
- package/dist/lib/limits.d.ts +55 -0
- package/dist/lib/limits.js +119 -0
- package/dist/lib/list.d.ts +9 -0
- package/dist/lib/list.js +11 -0
- package/dist/lib/mark.d.ts +13 -0
- package/dist/lib/mark.js +85 -0
- package/dist/lib/mix.d.ts +22 -0
- package/dist/lib/mix.js +22 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +106 -0
- package/dist/lib/org.d.ts +109 -0
- package/dist/lib/org.js +140 -0
- package/dist/lib/path.d.ts +9 -0
- package/dist/lib/path.js +29 -0
- package/dist/lib/plans.d.ts +193 -0
- package/dist/lib/plans.js +310 -0
- package/dist/lib/prose.d.ts +13 -0
- package/dist/lib/prose.js +62 -0
- package/dist/lib/reach.d.ts +17 -0
- package/dist/lib/reach.js +70 -0
- package/dist/lib/rooms.json +23 -0
- package/dist/lib/served.d.ts +20 -0
- package/dist/lib/served.js +92 -0
- package/dist/lib/session.d.ts +211 -0
- package/dist/lib/session.js +493 -0
- package/dist/lib/share.d.ts +131 -0
- package/dist/lib/share.js +216 -0
- package/dist/lib/signout.d.ts +14 -0
- package/dist/lib/signout.js +40 -0
- package/dist/lib/tags.d.ts +27 -0
- package/dist/lib/tags.js +46 -0
- package/dist/lib/tap.d.ts +17 -0
- package/dist/lib/tap.js +9 -0
- package/dist/lib/tier.d.ts +115 -0
- package/dist/lib/tier.js +207 -0
- package/dist/lib/tiers.d.ts +9 -0
- package/dist/lib/tiers.js +141 -0
- package/dist/lib/todo.d.ts +125 -0
- package/dist/lib/todo.js +379 -0
- package/dist/look.d.ts +1 -0
- package/dist/look.js +21 -0
- package/dist/meters.d.ts +8 -0
- package/dist/meters.js +31 -0
- package/dist/model.d.ts +4 -0
- package/dist/model.js +44 -0
- package/dist/network.d.ts +64 -0
- package/dist/network.js +216 -0
- package/dist/open.d.ts +81 -0
- package/dist/open.js +149 -0
- package/dist/pane.d.ts +92 -0
- package/dist/pane.js +231 -0
- package/dist/progress.d.ts +46 -0
- package/dist/progress.js +51 -0
- package/dist/recent.d.ts +67 -0
- package/dist/recent.js +161 -0
- package/dist/rooms.d.ts +131 -0
- package/dist/rooms.js +195 -0
- package/dist/roster.d.ts +28 -0
- package/dist/roster.js +70 -0
- package/dist/span.d.ts +14 -0
- package/dist/span.js +50 -0
- package/dist/speech.d.ts +44 -0
- package/dist/speech.js +229 -0
- package/dist/stars.d.ts +16 -0
- package/dist/stars.js +75 -0
- package/dist/tabs.d.ts +26 -0
- package/dist/tabs.js +25 -0
- package/dist/team.d.ts +11 -0
- package/dist/team.gen.d.ts +10 -0
- package/dist/team.gen.js +11 -0
- package/dist/team.js +30 -0
- package/dist/tools.d.ts +35 -0
- package/dist/tools.js +143 -0
- package/dist/tours.d.ts +24 -0
- package/dist/tours.js +311 -0
- package/dist/waiting.d.ts +15 -0
- package/dist/waiting.js +17 -0
- package/dist/where.d.ts +54 -0
- package/dist/where.js +14 -0
- 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
|
+
}
|