@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
package/dist/Orgs.js
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The organization comes first.
|
|
4
|
+
//
|
|
5
|
+
// An organization owns every agent, credit and member. A signed-in reader who
|
|
6
|
+
// has none is shown one thing: make one. A reader with several picks the one
|
|
7
|
+
// they are working in when they arrive. And an organization whose plan is not
|
|
8
|
+
// live is asked for it before any room is drawn. Only past all three does the
|
|
9
|
+
// workspace render, and every request under it then carries that organization
|
|
10
|
+
// (lib/auth/session.ts `scope`, hooks/useAi.ts).
|
|
11
|
+
//
|
|
12
|
+
// FREE IS FOR ONE PERSON. A personal organization on the Free tier opens /chat
|
|
13
|
+
// and /dev on every host but hanzo.team; the workspace's own host asks every
|
|
14
|
+
// organization for a paid plan. A personal organization starts on Free and may
|
|
15
|
+
// upgrade at creation; a team organization is sold the per-seat Team plan, and
|
|
16
|
+
// that is the plan the subscribe ask offers. Plans are the catalog's, read live
|
|
17
|
+
// and seeded from @hanzo/plans for first paint. Checkout is hanzo.ai/pay in
|
|
18
|
+
// this tab; it returns here, and the tier is read again on return.
|
|
19
|
+
//
|
|
20
|
+
// THE FRONT DOOR. A workspace rooted at `/` (hanzo.team) draws its host's
|
|
21
|
+
// Landing for a stranger at Home (`front`) and its host's Signup at /start
|
|
22
|
+
// (`signup`). Behind either, a reader with no team names one, invites people
|
|
23
|
+
// and takes the per-seat plan (`Start`, `Plan`) instead of the create dialog
|
|
24
|
+
// and the subscribe ask; a personal organization on Free is offered a team,
|
|
25
|
+
// and an open one is sent on (`forward`). Without those props nothing here
|
|
26
|
+
// changes.
|
|
27
|
+
import { dev } from './lib/host.js';
|
|
28
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
29
|
+
import { useIam } from '@hanzo/iam/react';
|
|
30
|
+
import { Text, View, XStack, YStack } from '@hanzo/gui';
|
|
31
|
+
import { Button } from '@hanzo/ui';
|
|
32
|
+
import { Minus, Plus, X } from 'lucide-react';
|
|
33
|
+
import { Waiting } from './waiting.js';
|
|
34
|
+
import { LOGIN, enter } from './lib/destination.js';
|
|
35
|
+
import { useAi } from './lib/ai.js';
|
|
36
|
+
import { useHost } from './lib/hostname.js';
|
|
37
|
+
import { useHydrated } from './lib/hydrated.js';
|
|
38
|
+
import { api } from './lib/api.js';
|
|
39
|
+
import { usePersonal, useTier } from './lib/tier.js';
|
|
40
|
+
import { ORG, PICKED, assumed, hasSession, org, orgs as claimed, pick, renew, scope } from './lib/session.js';
|
|
41
|
+
import { team as workspace } from './lib/host.js';
|
|
42
|
+
import { cartUrl, charge, checkoutUrl, money, priced, usePlan, usePlans } from './lib/plans.js';
|
|
43
|
+
import { createInvitation, inviteLink } from './lib/invite.js';
|
|
44
|
+
import { countSignup } from './lib/intent.js';
|
|
45
|
+
import { BAD, mix } from './lib/mix.js';
|
|
46
|
+
import { useRooms } from './host.js';
|
|
47
|
+
import { site, where } from './where.js';
|
|
48
|
+
/** A template to set up once the organization is live. */
|
|
49
|
+
const SETUP = 'hanzo:org:template';
|
|
50
|
+
/** Checkout for a plan, coming back to this room. */
|
|
51
|
+
function checkout(plan) {
|
|
52
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://hanzo.ai';
|
|
53
|
+
const path = typeof window !== 'undefined' ? window.location.pathname : '/chat';
|
|
54
|
+
return checkoutUrl(plan.id, `${origin}${path}`);
|
|
55
|
+
}
|
|
56
|
+
export function Orgs({ children, front = false, forward, signup = false, }) {
|
|
57
|
+
const hydrated = useHydrated();
|
|
58
|
+
const { isAuthenticated, isLoading, accessToken } = useIam();
|
|
59
|
+
const { Landing, Signup } = where();
|
|
60
|
+
// The front door's own flow: naming a team, inviting it, the per-seat plan.
|
|
61
|
+
const door = front || signup;
|
|
62
|
+
const arriving = useArrival(door, hydrated, isLoading, isAuthenticated);
|
|
63
|
+
const members = useMemo(() => claimed(), [accessToken]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
64
|
+
const [current, setCurrent] = useState(() => org());
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setCurrent(org());
|
|
67
|
+
const on = () => setCurrent(org());
|
|
68
|
+
window.addEventListener(ORG, on);
|
|
69
|
+
window.addEventListener('storage', on);
|
|
70
|
+
return () => {
|
|
71
|
+
window.removeEventListener(ORG, on);
|
|
72
|
+
window.removeEventListener('storage', on);
|
|
73
|
+
};
|
|
74
|
+
}, [accessToken]);
|
|
75
|
+
const [picked, setPicked] = useState(false);
|
|
76
|
+
// The team being set up in this page. Once its name is taken the setup owns
|
|
77
|
+
// the screen until checkout, whatever `Orgs` reads of the new organization.
|
|
78
|
+
const [founding, setFounding] = useState(null);
|
|
79
|
+
const { tier, wrong, reload } = useTier(Boolean(isAuthenticated && current), current);
|
|
80
|
+
// A plan is live when billing READ one for this organization and it is not the free rung.
|
|
81
|
+
const live = Boolean(tier?.tier && tier.tier.name !== FREE_TIER);
|
|
82
|
+
// A Free organization off the workspace's host is asked what kind it is, and a
|
|
83
|
+
// personal one opens: Free is the plan a person uses the app on alone. Behind
|
|
84
|
+
// the front door every organization without a live plan is read for its kind:
|
|
85
|
+
// a team is offered the plan, a personal one is offered a team.
|
|
86
|
+
const onTeam = workspace(useHost());
|
|
87
|
+
const free = door ? Boolean(tier) && !live : !live && !onTeam && tier?.tier?.name === FREE_TIER;
|
|
88
|
+
const personal = usePersonal(current, free);
|
|
89
|
+
// A SuperAdmin IAM stepped into this organization sees its rooms whatever its
|
|
90
|
+
// plan: support is there to see what the organization has, and what it spends
|
|
91
|
+
// is billed to the operator's own books, never the organization's.
|
|
92
|
+
const supporting = Boolean(current) && assumed() === current;
|
|
93
|
+
const open = live || (!door && free && personal === true) || supporting;
|
|
94
|
+
const { client } = useAi();
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
try {
|
|
97
|
+
setPicked(Boolean(localStorage.getItem(PICKED)));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
setPicked(true);
|
|
101
|
+
}
|
|
102
|
+
}, [accessToken]);
|
|
103
|
+
// Back from checkout: the plan may be live now.
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const back = () => {
|
|
106
|
+
if (document.visibilityState === 'visible')
|
|
107
|
+
reload();
|
|
108
|
+
};
|
|
109
|
+
document.addEventListener('visibilitychange', back);
|
|
110
|
+
window.addEventListener('focus', back);
|
|
111
|
+
return () => {
|
|
112
|
+
document.removeEventListener('visibilitychange', back);
|
|
113
|
+
window.removeEventListener('focus', back);
|
|
114
|
+
};
|
|
115
|
+
}, [reload]);
|
|
116
|
+
const choose = useCallback((o) => {
|
|
117
|
+
pick(o);
|
|
118
|
+
setPicked(true);
|
|
119
|
+
}, []);
|
|
120
|
+
// The organization a link arrives for. hanzo.ai opens the workspace at
|
|
121
|
+
// `?signin&org=<the org it is working in>` (Me.tsx); once signed in, an org
|
|
122
|
+
// the token's `orgs` names is chosen and the parameter leaves the address, the
|
|
123
|
+
// way `?signin` does. An org the token does not name is ignored.
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!door || !hydrated || isLoading || !isAuthenticated)
|
|
126
|
+
return;
|
|
127
|
+
const url = new URL(window.location.href);
|
|
128
|
+
const asked = url.searchParams.get('org');
|
|
129
|
+
if (asked === null)
|
|
130
|
+
return;
|
|
131
|
+
url.searchParams.delete('org');
|
|
132
|
+
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
|
133
|
+
if (members.includes(asked))
|
|
134
|
+
choose(asked);
|
|
135
|
+
}, [door, hydrated, isLoading, isAuthenticated, members, choose]);
|
|
136
|
+
// One organization is not a choice.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (members.length === 1 && !picked)
|
|
139
|
+
choose(members[0]);
|
|
140
|
+
}, [members, picked, choose]);
|
|
141
|
+
// The template chosen at creation, set up once the organization opens.
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!open || !client)
|
|
144
|
+
return;
|
|
145
|
+
let slug;
|
|
146
|
+
try {
|
|
147
|
+
slug = localStorage.getItem(SETUP);
|
|
148
|
+
if (slug)
|
|
149
|
+
localStorage.removeItem(SETUP);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (!slug)
|
|
155
|
+
return;
|
|
156
|
+
client.http
|
|
157
|
+
.json({ method: 'POST', path: '/v1/projects/fork', body: { slug } })
|
|
158
|
+
.then((made) => {
|
|
159
|
+
if (made?.id)
|
|
160
|
+
window.location.assign(dev());
|
|
161
|
+
})
|
|
162
|
+
.catch(() => { });
|
|
163
|
+
}, [open, client]);
|
|
164
|
+
// NOTHING PRODUCT-SHAPED IS IN THE EXPORTED MARKUP. This branch is what the
|
|
165
|
+
// static file holds and what hydration's first render must agree with, so
|
|
166
|
+
// whatever it returns is what a reader sees before any answer exists. It used
|
|
167
|
+
// to return the room, which put the whole paid interface on disk: the browser
|
|
168
|
+
// painted it from the file, the SDK settled, billing answered, and only then
|
|
169
|
+
// did the ask appear over the top of a product the reader had already been
|
|
170
|
+
// shown. The veil is the same element in both renders, so there is no
|
|
171
|
+
// mismatch and there is nothing to see. Home at `/` ships the host's Landing
|
|
172
|
+
// instead, which is what a stranger there is shown and what a crawler reads.
|
|
173
|
+
if (!hydrated)
|
|
174
|
+
return front && Landing ? _jsx(Landing, {}) : _jsx(Veil, {});
|
|
175
|
+
// Past hydration the room keeps its place in the tree in every branch that
|
|
176
|
+
// draws it — the veil goes AFTER it — so a token refresh mid-session never
|
|
177
|
+
// remounts what the reader had open. The veil is unconditional now: it was
|
|
178
|
+
// hung only when a token was already in hand, which left a visitor arriving
|
|
179
|
+
// without one looking at the room while the SDK decided.
|
|
180
|
+
if (isLoading)
|
|
181
|
+
return front && Landing && !hasSession() && !arriving ? _jsx(Landing, {}) : _jsxs(_Fragment, { children: [children, _jsx(Veil, {})] });
|
|
182
|
+
if (arriving && !isAuthenticated)
|
|
183
|
+
return _jsx(SignIn, { to: LOGIN });
|
|
184
|
+
if (!isAuthenticated)
|
|
185
|
+
return front && Landing ? _jsx(Landing, {}) : signup && Signup ? _jsx(Signup, {}) : _jsx(SignIn, {});
|
|
186
|
+
if (founding)
|
|
187
|
+
return _jsx(Start, { founded: founding, onFounded: setFounding, first: members.length === 0 });
|
|
188
|
+
if (members.length === 0) {
|
|
189
|
+
if (front && Landing)
|
|
190
|
+
return _jsx(Landing, { member: true });
|
|
191
|
+
return door ? _jsx(Start, { onFounded: setFounding, first: true }) : _jsx(Create, {});
|
|
192
|
+
}
|
|
193
|
+
if (!current || (members.length > 1 && !picked))
|
|
194
|
+
return _jsx(Pick, { members: members, onPick: choose });
|
|
195
|
+
// NOTHING OPENS WITHOUT A LIVE PLAN. Every branch here used to hand the room
|
|
196
|
+
// over when the answer was anything but a plain "free": an error of any kind,
|
|
197
|
+
// a read still in flight, a document with no plan in it. Each was a way in
|
|
198
|
+
// without paying — a refusal, a slow network, or devtools blocking one request
|
|
199
|
+
// all read as permission. The default is now the ask, and the room is the
|
|
200
|
+
// exception rather than the fallback. The one exception to the plan is a
|
|
201
|
+
// personal organization on Free, off hanzo.team, and its kind is read first.
|
|
202
|
+
if (!tier && !wrong)
|
|
203
|
+
return _jsxs(_Fragment, { children: [children, _jsx(Veil, {})] });
|
|
204
|
+
if (free && personal === null)
|
|
205
|
+
return _jsxs(_Fragment, { children: [children, _jsx(Veil, {})] });
|
|
206
|
+
if (open)
|
|
207
|
+
return forward ? _jsx(Forward, { to: forward }) : _jsx(_Fragment, { children: children });
|
|
208
|
+
const swap = () => setPicked(false);
|
|
209
|
+
if (door) {
|
|
210
|
+
// A billing OUTAGE is ours; the plan is still the condition.
|
|
211
|
+
if (!tier)
|
|
212
|
+
return _jsx(Plan, { org: current, members: members, onSwitch: swap, outage: outage(wrong), onRetry: reload });
|
|
213
|
+
if (personal) {
|
|
214
|
+
if (front && Landing)
|
|
215
|
+
return _jsx(Landing, { member: true });
|
|
216
|
+
return _jsx(Start, { onFounded: setFounding, first: false, members: members, onSwitch: swap });
|
|
217
|
+
}
|
|
218
|
+
return _jsx(Plan, { org: current, members: members, onSwitch: swap });
|
|
219
|
+
}
|
|
220
|
+
// A billing OUTAGE is not a bill: it is ours, and saying "subscribe" to
|
|
221
|
+
// someone who already has would be a lie. It still does not open the room.
|
|
222
|
+
return _jsx(Subscribe, { org: current, members: members, onSwitch: swap, outage: outage(wrong), onRetry: reload });
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* A link that asks to arrive signed in.
|
|
226
|
+
*
|
|
227
|
+
* A workspace on its own origin keeps its own session, so a person signed in on
|
|
228
|
+
* hanzo.ai arrives there signed out and would meet the landing. hanzo.ai's
|
|
229
|
+
* Workspace row sends `?signin` (lib/host `ENTRY`), and behind the front door
|
|
230
|
+
* that draws the sign-in in place of the landing; the sign-in returns to this
|
|
231
|
+
* page, which opens the workspace. The flag leaves the address at once, so the
|
|
232
|
+
* page the sign-in returns to does not ask again.
|
|
233
|
+
*/
|
|
234
|
+
function useArrival(door, hydrated, isLoading, isAuthenticated) {
|
|
235
|
+
const [arriving, setArriving] = useState(false);
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!door || !hydrated)
|
|
238
|
+
return;
|
|
239
|
+
const url = new URL(window.location.href);
|
|
240
|
+
if (!url.searchParams.has('signin'))
|
|
241
|
+
return;
|
|
242
|
+
url.searchParams.delete('signin');
|
|
243
|
+
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
|
|
244
|
+
setArriving(true);
|
|
245
|
+
}, [door, hydrated]);
|
|
246
|
+
useEffect(() => {
|
|
247
|
+
if (arriving && !isLoading && isAuthenticated)
|
|
248
|
+
setArriving(false);
|
|
249
|
+
}, [arriving, isLoading, isAuthenticated]);
|
|
250
|
+
return arriving;
|
|
251
|
+
}
|
|
252
|
+
// ── the screens ────────────────────────────────────────────────────────────────
|
|
253
|
+
/** Every screen here is one sheet over the room, ten above its modals, and the create dialog ten above that.
|
|
254
|
+
* gui carries a `calc()` to the page unchanged; its types only name custom properties. */
|
|
255
|
+
const SHEET = 'calc(var(--z-modal) + 10)';
|
|
256
|
+
const DIALOG = 'calc(var(--z-modal) + 20)';
|
|
257
|
+
/** The sheet's top inset: a share of the window's height, held between two lengths. */
|
|
258
|
+
const TOP = 'clamp(32px, 12vh, 120px)';
|
|
259
|
+
const muted = 'var(--muted-foreground)';
|
|
260
|
+
/** The black that veils and shadows, at a share of itself. */
|
|
261
|
+
const shade = (share) => mix('var(--pure-black)', share, 'srgb');
|
|
262
|
+
/** A field in the create dialog. */
|
|
263
|
+
const FIELD = {
|
|
264
|
+
width: '100%',
|
|
265
|
+
fontSize: '$6',
|
|
266
|
+
px: 16,
|
|
267
|
+
py: 14,
|
|
268
|
+
rounded: 12,
|
|
269
|
+
borderWidth: 1,
|
|
270
|
+
borderColor: 'var(--border)',
|
|
271
|
+
bg: 'var(--background)',
|
|
272
|
+
color: 'var(--foreground)',
|
|
273
|
+
};
|
|
274
|
+
/** The sheet every screen stands on: the whole viewport, scrolling, its column centred. */
|
|
275
|
+
function Page({ label, children }) {
|
|
276
|
+
return (_jsx(YStack, { position: "fixed", inset: 0, z: SHEET, overflowY: "auto", bg: "var(--background)", pt: TOP, px: 20, pb: 48, role: "region", "aria-label": label, children: children }));
|
|
277
|
+
}
|
|
278
|
+
/** The column a screen's words sit in. */
|
|
279
|
+
function Column({ children }) {
|
|
280
|
+
return (_jsx(YStack, { width: "100%", maxW: 640, mx: "auto", children: children }));
|
|
281
|
+
}
|
|
282
|
+
/** A hairline between the parts of a screen. */
|
|
283
|
+
function Rule({ mt = 20, mb = 20 }) {
|
|
284
|
+
return _jsx(View, { height: 1, bg: "var(--border)", mt: mt, mb: mb });
|
|
285
|
+
}
|
|
286
|
+
/** A screen's heading and the line under it; `lead` is the line's leading where it states one. */
|
|
287
|
+
function Heading({ title, lede, lead }) {
|
|
288
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { render: "h1", fontSize: "$10", fontWeight: "500", letterSpacing: -0.32, color: "var(--text-primary)", children: title }), _jsx(Text, { render: "p", mt: 6, fontSize: "$4", lineHeight: lead, color: muted, children: lede })] }));
|
|
289
|
+
}
|
|
290
|
+
/** The room, held back until the organization is known. */
|
|
291
|
+
function Veil() {
|
|
292
|
+
return _jsx(View, { "aria-hidden": true, position: "fixed", inset: 0, z: SHEET, bg: "var(--background)" });
|
|
293
|
+
}
|
|
294
|
+
/** An open organization, sent on. */
|
|
295
|
+
function Forward({ to }) {
|
|
296
|
+
const { router } = useRooms();
|
|
297
|
+
useEffect(() => router.replace(to), [router, to]);
|
|
298
|
+
return _jsx(Veil, {});
|
|
299
|
+
}
|
|
300
|
+
/** Which step of the setup this is, of how many. */
|
|
301
|
+
function Step({ at, of }) {
|
|
302
|
+
return (_jsxs(Text, { render: "p", mb: 10, fontSize: "$2", fontFamily: "$mono", color: "$soft", children: ["Step ", at, " of ", of] }));
|
|
303
|
+
}
|
|
304
|
+
function Brand() {
|
|
305
|
+
return (_jsx(Text, { fontSize: "$6", fontWeight: "500", mb: 28, color: "var(--foreground)", children: "Hanzo" }));
|
|
306
|
+
}
|
|
307
|
+
/** The free rung's name in the billing catalog. */
|
|
308
|
+
const FREE_TIER = 'free';
|
|
309
|
+
/**
|
|
310
|
+
* Whether a failed tier read is OUR fault. A 5xx or a network error is an
|
|
311
|
+
* outage; a 401, 403 or 404 is an answer, and an answer that is not a paid plan
|
|
312
|
+
* is not a reason to open the room.
|
|
313
|
+
*/
|
|
314
|
+
function outage(wrong) {
|
|
315
|
+
const status = wrong?.status;
|
|
316
|
+
return status == null || status >= 500;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* SIGNED OUT IS A TRIP TO SIGN IN, and nothing else.
|
|
320
|
+
*
|
|
321
|
+
* IAM owns every credential, so this room draws no form of its own: it sends
|
|
322
|
+
* the reader to this site's sign-in (destination.ts `enter`), which asks
|
|
323
|
+
* hanzo.id for a session first and draws IAM's form only when there is none.
|
|
324
|
+
* `Room` wrote the address down on mount, so the sign-in returns to this room,
|
|
325
|
+
* `?q=` included.
|
|
326
|
+
*/
|
|
327
|
+
function SignIn({ to }) {
|
|
328
|
+
useEffect(() => enter(to), [to]);
|
|
329
|
+
return (_jsx(Page, { label: "Sign in", children: _jsx(Waiting, { title: "Signing you in", lede: "One moment." }) }));
|
|
330
|
+
}
|
|
331
|
+
/** No organization yet: the one thing to do. */
|
|
332
|
+
function Create() {
|
|
333
|
+
const [open, setOpen] = useState(false);
|
|
334
|
+
const [details, setDetails] = useState(false);
|
|
335
|
+
return (_jsxs(Page, { label: "Organizations", children: [_jsxs(Column, { children: [_jsx(Brand, {}), _jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 16, flexWrap: "wrap", children: [_jsxs(YStack, { children: [_jsx(Text, { render: "h1", fontSize: "$10", fontWeight: "500", letterSpacing: -0.32, color: "var(--text-primary)", children: "Organizations" }), _jsx(Text, { render: "p", mt: 6, fontSize: "$4", color: muted, children: "You do not belong to one yet." })] }), _jsxs(Button, { variant: "primary", size: "lg", onClick: () => setOpen(true), children: [_jsx(Plus, { size: 16, "aria-hidden": true }), "New organization"] })] }), _jsx(Rule, {}), _jsxs(YStack, { items: "center", py: 28, children: [_jsx(Text, { render: "p", fontSize: "$6", fontWeight: "500", color: "var(--foreground)", text: "center", children: "No organizations yet" }), _jsx(Button, { variant: "linkMuted", size: "sm", mt: 8, onClick: () => setDetails((d) => !d), children: details ? '- Details' : '+ Details' }), details ? (_jsx(Text, { render: "p", mt: 12, maxW: 440, fontSize: "$4", color: muted, lineHeight: 23.25, text: "center", children: "An organization owns every agent, credit and member. Create one and you are its owner." })) : null] }), _jsx(Rule, {})] }), open ? _jsx(CreateDialog, { onClose: () => setOpen(false) }) : null] }));
|
|
336
|
+
}
|
|
337
|
+
/** Name it, pick what it runs and its plan: a paid plan goes to checkout, Free goes straight in. */
|
|
338
|
+
function CreateDialog({ onClose }) {
|
|
339
|
+
const team = usePlan('team');
|
|
340
|
+
// The personal ladder, Free first. hanzo.team offers no personal organization:
|
|
341
|
+
// every room there runs on a paid plan.
|
|
342
|
+
const ladder = usePlans('personal');
|
|
343
|
+
const alone = !workspace(useHost());
|
|
344
|
+
const [rung, setRung] = useState(null);
|
|
345
|
+
const chosen = ladder.find((p) => p.id === rung) ?? ladder.find((p) => !p.priceMonthly) ?? ladder[0] ?? null;
|
|
346
|
+
const personal = priced(chosen);
|
|
347
|
+
const [name, setName] = useState('');
|
|
348
|
+
const [kind, setKind] = useState('org');
|
|
349
|
+
// A team organization runs on the per-seat Team plan; a personal one on the
|
|
350
|
+
// rung chosen below, Free unless another is. Checkout sets the seats and the term.
|
|
351
|
+
const { plan, price } = kind === 'org' ? team : personal;
|
|
352
|
+
const seat = plan?.pricePerUser ? ' / seat' : '';
|
|
353
|
+
const paid = (plan?.priceMonthly ?? 0) > 0;
|
|
354
|
+
// A row on the landing carries its template in the address, across sign-in.
|
|
355
|
+
const [template, setTemplate] = useState(() => {
|
|
356
|
+
if (typeof window === 'undefined')
|
|
357
|
+
return '';
|
|
358
|
+
return new URLSearchParams(window.location.search).get('template') ?? '';
|
|
359
|
+
});
|
|
360
|
+
const [templates, setTemplates] = useState([]);
|
|
361
|
+
const [busy, setBusy] = useState(false);
|
|
362
|
+
const [refused, setRefused] = useState(null);
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
fetch(`${api()}/v1/templates`)
|
|
365
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
366
|
+
.then((d) => {
|
|
367
|
+
if (Array.isArray(d?.data))
|
|
368
|
+
setTemplates(d.data.filter((t) => t?.slug));
|
|
369
|
+
})
|
|
370
|
+
.catch(() => { });
|
|
371
|
+
}, []);
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
const key = (e) => {
|
|
374
|
+
if (e.key === 'Escape')
|
|
375
|
+
onClose();
|
|
376
|
+
};
|
|
377
|
+
document.addEventListener('keydown', key);
|
|
378
|
+
return () => document.removeEventListener('keydown', key);
|
|
379
|
+
}, [onClose]);
|
|
380
|
+
const submit = async (e) => {
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
if (!plan || busy)
|
|
383
|
+
return;
|
|
384
|
+
if (kind === 'org' && !name.trim()) {
|
|
385
|
+
setRefused('Give it a name.');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
setBusy(true);
|
|
389
|
+
setRefused(null);
|
|
390
|
+
try {
|
|
391
|
+
const r = await fetch(`${api()}/v1/account/orgs`, {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers: { 'Content-Type': 'application/json', ...scope() },
|
|
394
|
+
body: JSON.stringify(kind === 'org' ? { name: name.trim() } : { personal: true }),
|
|
395
|
+
});
|
|
396
|
+
if (!r.ok) {
|
|
397
|
+
let why = `Could not create it (${r.status}).`;
|
|
398
|
+
try {
|
|
399
|
+
const body = (await r.json());
|
|
400
|
+
why = body.detail || body.error || body.message || why;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* no body worth showing */
|
|
404
|
+
}
|
|
405
|
+
setRefused(why);
|
|
406
|
+
setBusy(false);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const made = (await r.json());
|
|
410
|
+
try {
|
|
411
|
+
if (template)
|
|
412
|
+
localStorage.setItem(SETUP, template);
|
|
413
|
+
localStorage.setItem(PICKED, '1');
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
/* the next load re-derives both */
|
|
417
|
+
}
|
|
418
|
+
if (made?.org)
|
|
419
|
+
pick(made.org);
|
|
420
|
+
// Only a reader with no organization is offered this form, so its first
|
|
421
|
+
// success is the account becoming a customer: the ONE sign-up conversion,
|
|
422
|
+
// Free included — our signup_completed, GA4's sign_up, Meta's
|
|
423
|
+
// CompleteRegistration (lib/analytics/tags.ts).
|
|
424
|
+
countSignup();
|
|
425
|
+
// The token in hand predates the organization; the next load mints one
|
|
426
|
+
// that carries it. A paid plan is bought first, and checkout charges the
|
|
427
|
+
// organization it names; Free goes straight in.
|
|
428
|
+
renew();
|
|
429
|
+
if (paid)
|
|
430
|
+
window.location.assign(checkout(plan));
|
|
431
|
+
else
|
|
432
|
+
window.location.reload();
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
setRefused(err instanceof Error ? err.message : 'Could not create it.');
|
|
436
|
+
setBusy(false);
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
return (_jsx(YStack, { role: "dialog", "aria-modal": true, "aria-label": "Create an organization", position: "fixed", inset: 0, z: DIALOG, bg: shade(28), overflowY: "auto", py: "6vh", px: 16, onClick: onClose, children: _jsxs(YStack, { render: "form", onSubmit: submit, onClick: (e) => e.stopPropagation(), width: "100%", maxW: 640, mx: "auto", bg: "var(--background)", rounded: 16, boxShadow: `0 24px 64px ${shade(28)}`, pt: 28, px: 28, pb: 20, children: [_jsxs(XStack, { items: "flex-start", justify: "space-between", gap: 12, children: [_jsxs(YStack, { shrink: 1, minW: "auto", children: [_jsx(Text, { render: "h2", fontSize: "$8", fontWeight: "500", color: "var(--text-primary)", children: "Create an organization" }), _jsx(Text, { render: "p", mt: 8, fontSize: "$4", color: muted, lineHeight: 22.5, children: "You are its owner. Agents, credits and members all hang off it; you can switch between organizations from the rail." })] }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", mr: -8, onClick: onClose, "aria-label": "Close", children: _jsx(X, { size: 16, "aria-hidden": true }) })] }), _jsxs(View, { render: "fieldset", display: "block", borderWidth: 0, p: 0, m: 0, mt: 22, children: [_jsx(Text, { render: "legend", display: "block", fontSize: "$4", fontWeight: "500", mb: 10, color: "var(--foreground)", children: "Plan" }), _jsx(View, { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 10, children: [
|
|
440
|
+
['org', 'Organization', 'A team. Invite members, share agents and credit.', team],
|
|
441
|
+
['personal', 'Personal', 'Just you. Named after your account.', personal],
|
|
442
|
+
].filter(([k]) => alone || k === 'org').map(([k, label, hint, offer]) => (_jsxs(XStack, { render: "label", gap: 10, items: "flex-start", px: 14, py: 12, rounded: 12, borderWidth: 1, borderColor: kind === k ? 'var(--foreground)' : 'var(--border)', cursor: "pointer", children: [_jsx("input", { type: "radio", name: "kind", value: k, checked: kind === k, onChange: () => setKind(k) }), _jsxs(YStack, { shrink: 1, children: [_jsxs(Text, { fontSize: "$4", fontWeight: "500", color: "var(--foreground)", children: [label, offer.plan ? (_jsxs(Text, { color: muted, fontWeight: "400", children: [' ', "\u2014 ", offer.plan.priceMonthly ? `${offer.price}${offer.plan.pricePerUser ? ' / seat' : ''} / month` : offer.plan.name] })) : null] }), _jsx(Text, { fontSize: "$3", color: muted, mt: 2, children: hint })] })] }, k))) })] }), kind === 'personal' && ladder.length > 1 ? (_jsxs(View, { render: "fieldset", display: "block", borderWidth: 0, p: 0, m: 0, mt: 20, children: [_jsx(Text, { render: "legend", display: "block", fontSize: "$4", fontWeight: "500", mb: 10, color: "var(--foreground)", children: "Personal plan" }), _jsx(View, { display: "grid", gap: 8, children: ladder.map((p) => (_jsxs(XStack, { render: "label", gap: 10, items: "center", px: 14, py: 10, rounded: 12, borderWidth: 1, borderColor: chosen?.id === p.id ? 'var(--foreground)' : 'var(--border)', cursor: "pointer", children: [_jsx("input", { type: "radio", name: "rung", value: p.id, checked: chosen?.id === p.id, onChange: () => setRung(p.id) }), _jsx(Text, { fontSize: "$4", fontWeight: "500", color: "var(--foreground)", children: p.name }), p.priceMonthly ? (_jsxs(Text, { ml: "auto", fontSize: "$3", color: muted, children: [priced(p).price, " / month"] })) : null] }, p.id))) })] })) : null, kind === 'org' ? (_jsxs(View, { render: "label", display: "block", mt: 20, children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "var(--foreground)", children: "Name" }), _jsx(Text, { render: _jsx("input", { value: name, onChange: (e) => setName(e.target.value), placeholder: "Acme", autoFocus: true }), ...FIELD }), _jsx(Text, { display: "block", fontSize: "$3", color: muted, mt: 8, children: "What the rail and every invoice call it." })] })) : null, templates.length > 0 ? (_jsxs(View, { render: "label", display: "block", mt: 20, children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "var(--foreground)", children: "Template" }), _jsxs(Text, { render: _jsx("select", { value: template, onChange: (e) => setTemplate(e.target.value) }), ...FIELD, children: [_jsx("option", { value: "", children: "None \u2014 start empty" }), templates.map((t) => (_jsxs("option", { value: t.slug, children: [t.title || t.name || t.slug, t.category ? ` · ${t.category}` : ''] }, t.slug)))] }), _jsx(Text, { display: "block", fontSize: "$3", color: muted, mt: 8, children: "What it runs, set up as soon as the organization opens. Choose none to start empty." })] })) : null, plan && !paid ? (_jsxs(Text, { render: "p", mt: 22, fontSize: "$4", color: muted, lineHeight: 23.25, children: ["A personal organization starts on ", plan.name, " and opens as soon as it is made. Choose a paid plan above to go to checkout instead."] })) : plan ? (_jsxs(Text, { render: "p", mt: 22, fontSize: "$4", color: muted, lineHeight: 23.25, children: [plan.pricePerUser
|
|
443
|
+
? `A team organization runs on ${plan.name} at ${price} per seat a month, at least ${Number(plan.limits?.minSeats ?? 1)} seats.`
|
|
444
|
+
: `A personal organization runs on ${plan.name} at ${price} a month.`, ' ', "Nothing in it works until that plan is live, so creating one takes you straight to checkout, where you choose the seats and the term.", ' ', "Each plan includes session and daily usage limits; usage never draws your credit balance.", ' ', "Payment is on the provider's hosted page."] })) : null, refused ? (_jsx(Text, { render: "p", role: "alert", mt: 14, fontSize: "$3", color: "var(--foreground)", children: refused })) : null, _jsxs(XStack, { justify: "flex-end", gap: 10, mt: 24, flexWrap: "wrap", children: [_jsx(Button, { type: "button", size: "lg", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", size: "lg", shrink: 1, py: "$2", disabled: !plan || busy, children: _jsx(Text, { fontFamily: "$body", fontSize: "$3", color: "inherit", text: "center", children: busy ? 'Creating…' : paid ? `Create and subscribe — ${price}${seat} / month` : 'Create' }) })] }), _jsx(Rule, { mt: 18, mb: 8 }), _jsx(Text, { render: "p", text: "right", fontSize: "$2", color: muted, fontFamily: "$mono", children: "Esc to close" })] }) }));
|
|
445
|
+
}
|
|
446
|
+
/** Several organizations: which one, this time. */
|
|
447
|
+
function Pick({ members, onPick }) {
|
|
448
|
+
return (_jsx(Page, { label: "Choose an organization", children: _jsxs(Column, { children: [_jsx(Brand, {}), _jsx(Heading, { title: "Choose an organization", lede: _jsx(_Fragment, { children: "Everything you open \u2014 chats, agents, credit \u2014 is this one's." }) }), _jsx(Rule, {}), _jsx(View, { display: "grid", gap: 10, children: members.map((o) => (_jsx(View, { render: "button", onClick: () => onPick(o), items: "flex-start", px: 16, py: 14, rounded: 12, borderWidth: 1, borderColor: "var(--border)", bg: "transparent", cursor: "pointer", children: _jsx(Text, { fontSize: "$4", color: "var(--foreground)", text: "left", children: o }) }, o))) })] }) }));
|
|
449
|
+
}
|
|
450
|
+
/** The organization exists and its plan is not live: it is offered the per-seat Team plan. */
|
|
451
|
+
function Subscribe({ org, members, onSwitch, outage, onRetry, }) {
|
|
452
|
+
const { plan, price } = usePlan('team');
|
|
453
|
+
const seat = plan?.pricePerUser ? ' / seat' : '';
|
|
454
|
+
return (_jsx(Page, { label: "Subscribe", children: _jsxs(Column, { children: [_jsx(Brand, {}), _jsx(Heading, { title: org, lead: 22.5, lede: plan ? `This organization runs on the ${plan.name} plan at ${price}${seat} / month, and nothing in it works until that plan is live.` : 'Its plan is not live yet.' }), _jsx(Rule, {}), _jsxs(XStack, { gap: 10, flexWrap: "wrap", children: [plan ? (_jsx(Button, { asChild: true, variant: "primary", size: "lg", children: _jsx("a", { href: checkout(plan), children: `Subscribe — ${price}${seat} / month` }) })) : null, members.length > 1 ? (_jsx(Button, { size: "lg", onClick: onSwitch, children: "Switch organization" })) : null] })] }) }));
|
|
455
|
+
}
|
|
456
|
+
// ── the front door's setup ─────────────────────────────────────────────────────
|
|
457
|
+
/**
|
|
458
|
+
* CREATE YOUR TEAM: its name, then who else is in it, then its plan.
|
|
459
|
+
*
|
|
460
|
+
* The name makes the organization (`POST /v1/account/orgs`). The access token
|
|
461
|
+
* in hand predates it, so it is expired and refreshed at once, which mints one
|
|
462
|
+
* that carries the new organization, and the organization is selected: the
|
|
463
|
+
* invitations that follow are filed against it. Invitations are optional, one
|
|
464
|
+
* single-use link per address. The plan step is `Plan`, the same one a team on
|
|
465
|
+
* Free is shown.
|
|
466
|
+
*/
|
|
467
|
+
function Start({ founded = null, onFounded, first, members = [], onSwitch, }) {
|
|
468
|
+
const { sdk } = useIam();
|
|
469
|
+
const [step, setStep] = useState(founded ? 'invite' : 'name');
|
|
470
|
+
const [name, setName] = useState('');
|
|
471
|
+
const [busy, setBusy] = useState(false);
|
|
472
|
+
const [refused, setRefused] = useState(null);
|
|
473
|
+
const [emails, setEmails] = useState('');
|
|
474
|
+
const [sent, setSent] = useState([]);
|
|
475
|
+
const create = async (e) => {
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
if (busy)
|
|
478
|
+
return;
|
|
479
|
+
if (!name.trim()) {
|
|
480
|
+
setRefused('Give it a name.');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
setBusy(true);
|
|
484
|
+
setRefused(null);
|
|
485
|
+
try {
|
|
486
|
+
const r = await fetch(`${api()}/v1/account/orgs`, {
|
|
487
|
+
method: 'POST',
|
|
488
|
+
headers: { 'Content-Type': 'application/json', ...scope() },
|
|
489
|
+
body: JSON.stringify({ name: name.trim() }),
|
|
490
|
+
});
|
|
491
|
+
if (!r.ok) {
|
|
492
|
+
let why = `Could not create it (${r.status}).`;
|
|
493
|
+
try {
|
|
494
|
+
const body = (await r.json());
|
|
495
|
+
why = body.detail || body.error || body.message || why;
|
|
496
|
+
}
|
|
497
|
+
catch {
|
|
498
|
+
/* no body worth showing */
|
|
499
|
+
}
|
|
500
|
+
setRefused(why);
|
|
501
|
+
setBusy(false);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
const made = (await r.json());
|
|
505
|
+
if (!made?.org) {
|
|
506
|
+
setRefused('The organization was made but its name did not come back. Reload to find it.');
|
|
507
|
+
setBusy(false);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
// The first organization is the account becoming a customer: the ONE
|
|
511
|
+
// sign-up conversion, as the create dialog counts it.
|
|
512
|
+
if (first)
|
|
513
|
+
countSignup();
|
|
514
|
+
renew();
|
|
515
|
+
await sdk.refreshAccessToken().catch(() => null);
|
|
516
|
+
pick(made.org);
|
|
517
|
+
onFounded(made.org);
|
|
518
|
+
setBusy(false);
|
|
519
|
+
setStep('invite');
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
setRefused(err instanceof Error ? err.message : 'Could not create it.');
|
|
523
|
+
setBusy(false);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
const invite = async (e) => {
|
|
527
|
+
e.preventDefault();
|
|
528
|
+
const list = [...new Set(emails.split(/[\s,;]+/).map((s) => s.trim()).filter(Boolean))];
|
|
529
|
+
if (list.length === 0 || !founded) {
|
|
530
|
+
setStep('plan');
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
setBusy(true);
|
|
534
|
+
const out = await Promise.all(list.map(async (email) => {
|
|
535
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
|
|
536
|
+
return { email, refused: 'Not an email address.' };
|
|
537
|
+
try {
|
|
538
|
+
const made = await createInvitation(founded, email);
|
|
539
|
+
return { email, link: inviteLink(made.owner || founded, made.code) };
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
return { email, refused: err instanceof Error ? err.message : 'Could not invite.' };
|
|
543
|
+
}
|
|
544
|
+
}));
|
|
545
|
+
setSent(out);
|
|
546
|
+
setBusy(false);
|
|
547
|
+
};
|
|
548
|
+
if (step === 'plan' && founded)
|
|
549
|
+
return _jsx(Plan, { org: founded, members: [founded], seats: 1 + sent.filter((s) => s.link).length, setup: true });
|
|
550
|
+
return (_jsx(Page, { label: "Create your team", children: _jsxs(Column, { children: [_jsx(Brand, {}), step === 'name' ? (_jsxs(YStack, { render: "form", onSubmit: create, children: [_jsx(Step, { at: 1, of: 3 }), _jsx(Heading, { title: "Create your team", lede: "A team owns its agents, credit and members. You are its owner." }), _jsx(Rule, {}), _jsxs(View, { render: "label", display: "block", children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "$ink", children: "Team name" }), _jsx(Text, { render: _jsx("input", { value: name, onChange: (e) => setName(e.target.value), placeholder: "Acme", autoFocus: true }), ...FIELD }), _jsx(Text, { display: "block", fontSize: "$3", color: "$soft", mt: 8, children: "What the rail and every invoice call it." })] }), refused ? (_jsx(Text, { render: "p", role: "alert", mt: 14, fontSize: "$3", color: BAD, children: refused })) : null, _jsxs(XStack, { gap: 10, flexWrap: "wrap", mt: 24, children: [_jsx(Button, { type: "submit", variant: "primary", size: "lg", disabled: busy, children: busy ? 'Creating…' : 'Continue' }), members.length > 1 && onSwitch ? (_jsx(Button, { type: "button", size: "lg", onClick: onSwitch, children: "Switch organization" })) : null] }), _jsx(Rule, {}), _jsxs(Text, { render: "p", fontSize: "$3", color: "$soft", children: ["Just you? Hanzo Chat is free for personal use at", ' ', _jsx(Text, { render: _jsx("a", { href: site('/chat') }), color: "$ink", textDecorationLine: "underline", children: "hanzo.ai/chat" }), "."] })] })) : (_jsxs(YStack, { render: "form", onSubmit: invite, children: [_jsx(Step, { at: 2, of: 3 }), _jsx(Heading, { title: "Invite your teammates", lede: "Each address gets its own single-use link to join. You can skip this and invite people later." }), _jsx(Rule, {}), _jsxs(View, { render: "label", display: "block", children: [_jsx(Text, { display: "block", fontSize: "$4", fontWeight: "500", mb: 8, color: "$ink", children: "Email addresses" }), _jsx(Text, { render: _jsx("textarea", { value: emails, onChange: (e) => setEmails(e.target.value), placeholder: "ada@acme.com, alan@acme.com", rows: 3 }), ...FIELD }), _jsx(Text, { display: "block", fontSize: "$3", color: "$soft", mt: 8, children: "Separate them with commas, spaces or new lines." })] }), sent.length ? (_jsx(YStack, { render: "ul", gap: 8, m: 0, p: 0, mt: 18, "aria-label": "Invitations", children: sent.map((s) => (_jsx(YStack, { render: "li", children: _jsxs(Text, { fontSize: "$3", color: "$soft", wordWrap: "break-word", children: [_jsx(Text, { fontWeight: "500", color: "$ink", children: s.email }), ' ', s.link ? (_jsxs(_Fragment, { children: ["invited. Their link:", ' ', _jsx(Text, { render: _jsx("a", { href: s.link }), color: "$ink", textDecorationLine: "underline", children: s.link })] })) : (_jsx(Text, { role: "alert", color: BAD, children: s.refused }))] }) }, s.email))) })) : null, _jsx(XStack, { gap: 10, flexWrap: "wrap", mt: 24, children: sent.length ? (_jsx(Button, { type: "button", variant: "primary", size: "lg", onClick: () => setStep('plan'), children: "Continue" })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "submit", variant: "primary", size: "lg", disabled: busy, children: busy ? 'Inviting…' : emails.trim() ? 'Invite and continue' : 'Continue' }), _jsx(Button, { type: "button", size: "lg", onClick: () => setStep('plan'), children: "Skip for now" })] })) })] }))] }) }));
|
|
551
|
+
}
|
|
552
|
+
/** Where a checkout from the setup returns: the rooms' Home, on this origin. */
|
|
553
|
+
const home = () => `${window.location.origin}${where().home}`;
|
|
554
|
+
/**
|
|
555
|
+
* THE PLAN: how many seats, billed monthly or annually, then checkout.
|
|
556
|
+
*
|
|
557
|
+
* The prices are the catalog's Team row, never typed: its monthly price per
|
|
558
|
+
* seat, and a year as the catalog states it (`charge`). The seat floor is the
|
|
559
|
+
* row's `minSeats`. Checkout is hanzo.ai/pay's cart (`cartUrl`), charged to this
|
|
560
|
+
* organization and returning Home.
|
|
561
|
+
*/
|
|
562
|
+
function Plan({ org, members, onSwitch, outage, onRetry, seats: wanted = 1, setup = false, }) {
|
|
563
|
+
const { plan } = usePlan('team');
|
|
564
|
+
const floor = Math.max(1, Number(plan?.limits?.minSeats ?? 1));
|
|
565
|
+
// The count asked for, held above the floor: the floor arrives with the live
|
|
566
|
+
// catalog row and can rise after the first paint.
|
|
567
|
+
const [asked, setSeats] = useState(wanted);
|
|
568
|
+
const seats = Math.max(floor, asked);
|
|
569
|
+
const [term, setTerm] = useState('month');
|
|
570
|
+
const monthly = (plan && charge(plan, 'month')) ?? 0;
|
|
571
|
+
const yearly = plan ? charge(plan, 'year') : null;
|
|
572
|
+
const each = term === 'year' && yearly ? yearly : monthly;
|
|
573
|
+
const terms = [
|
|
574
|
+
['month', 'Monthly', `${money(monthly)} / seat / month`],
|
|
575
|
+
...(yearly ? [['year', 'Annual', `${money(yearly)} / seat, billed once a year`]] : []),
|
|
576
|
+
];
|
|
577
|
+
return (_jsx(Page, { label: "Choose your plan", children: _jsxs(Column, { children: [_jsx(Brand, {}), setup ? _jsx(Step, { at: 3, of: 3 }) : null, _jsx(Heading, { title: org, lead: 22.5, lede: outage
|
|
578
|
+
? 'Billing could not be read just now, so this team cannot open yet. That is ours to fix.'
|
|
579
|
+
: plan
|
|
580
|
+
? `A team runs on the ${plan.name} plan, and nothing in it works until that plan is live.`
|
|
581
|
+
: 'Its plan is not live yet.' }), outage && onRetry ? (_jsx(XStack, { mt: 16, children: _jsx(Button, { size: "lg", onClick: onRetry, children: "Try again" }) })) : null, _jsx(Rule, {}), plan ? (_jsxs(_Fragment, { children: [_jsxs(View, { render: "fieldset", display: "block", borderWidth: 0, p: 0, m: 0, children: [_jsx(Text, { render: "legend", display: "block", p: 0, fontSize: "$4", fontWeight: "500", mb: 10, color: "$ink", children: "Seats" }), _jsxs(XStack, { items: "center", gap: 10, children: [_jsx(Button, { type: "button", size: "icon", "aria-label": "One seat fewer", disabled: seats <= floor, onClick: () => setSeats(Math.max(floor, seats - 1)), children: _jsx(Minus, { size: 16, "aria-hidden": true }) }), _jsx(Text, { render: _jsx("input", { "aria-label": "Seats", type: "number", inputMode: "numeric", min: floor, value: seats, onChange: (e) => setSeats(Math.max(floor, Math.floor(Number(e.target.value) || floor))) }), ...FIELD, width: 80, height: 36, py: 0, fontSize: "$4", rounded: "$3", text: "center" }), _jsx(Button, { type: "button", size: "icon", "aria-label": "One seat more", onClick: () => setSeats(seats + 1), children: _jsx(Plus, { size: 16, "aria-hidden": true }) })] }), _jsxs(Text, { display: "block", fontSize: "$3", color: "$soft", mt: 8, children: ["At least ", floor, " ", floor === 1 ? 'seat' : 'seats', ". Each person in the team takes one."] })] }), _jsxs(View, { render: "fieldset", display: "block", borderWidth: 0, p: 0, m: 0, mt: 24, children: [_jsx(Text, { render: "legend", display: "block", p: 0, fontSize: "$4", fontWeight: "500", mb: 10, color: "$ink", children: "Billing" }), _jsx(View, { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 10, children: terms.map(([value, label, price]) => (_jsxs(XStack, { render: "label", gap: 10, items: "flex-start", px: 14, py: 12, rounded: "$3", borderWidth: 1, borderColor: term === value ? '$ink' : '$edge', cursor: "pointer", children: [_jsx("input", { type: "radio", name: "term", value: value, checked: term === value, onChange: () => setTerm(value) }), _jsxs(YStack, { shrink: 1, children: [_jsx(Text, { fontSize: "$4", fontWeight: "500", color: "$ink", children: label }), _jsx(Text, { fontSize: "$3", color: "$soft", mt: 2, children: price })] })] }, value))) })] }), _jsxs(Text, { render: "p", mt: 22, fontSize: "$4", color: "$soft", lineHeight: 23.25, children: [seats, " ", seats === 1 ? 'seat' : 'seats', " at ", money(each), " a ", term, " is", ' ', _jsxs(Text, { fontWeight: "500", color: "$ink", children: [money(seats * each), " a ", term] }), ". Payment is on the provider's hosted page."] })] })) : null, _jsxs(XStack, { gap: 10, flexWrap: "wrap", mt: 24, children: [plan ? (_jsx(Button, { asChild: true, variant: "primary", size: "lg", children: _jsx("a", { href: cartUrl(plan.id, seats, term, org, home()), children: "Continue to checkout" }) })) : null, members.length > 1 && onSwitch ? (_jsx(Button, { size: "lg", onClick: onSwitch, children: "Switch organization" })) : null] })] }) }));
|
|
582
|
+
}
|