astryx-dracula 0.6.2 → 0.7.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/BRAND.md +54 -3
- package/README.md +11 -4
- package/USAGE.md +1 -1
- package/astryx-dracula.d.ts +2 -2
- package/astryx-dracula.js +26 -4
- package/astryx-dracula.variants.d.ts +2 -2
- package/package.json +11 -11
- package/shared/auth-chrome-config.ts +0 -4
- package/shared/auth-chrome.tsx +2 -3
- package/shared/chaptered-doc-config.ts +3 -2
- package/shared/order-desk.tsx +52 -54
- package/shared/revenue-chart.tsx +1 -1
- package/shared/scene-castle.tsx +21 -13
- package/templates/ai-chat-landing.tsx +6 -9
- package/templates/ai-chat.tsx +5 -1
- package/templates/centered-hero.tsx +2 -2
- package/templates/classic-gallery.tsx +5 -0
- package/templates/contact-form.tsx +9 -0
- package/templates/dashboard-portfolio.tsx +17 -4
- package/templates/dashboard.tsx +5 -5
- package/templates/detail-page.tsx +20 -28
- package/templates/documentation-design.tsx +3 -0
- package/templates/documentation-technical.tsx +8 -5
- package/templates/documentation.tsx +13 -6
- package/templates/form-two-column.tsx +11 -2
- package/templates/form-wizard.template.mjs +15 -0
- package/templates/form-wizard.tsx +878 -0
- package/templates/gallery-hero.tsx +10 -4
- package/templates/ide.tsx +177 -96
- package/templates/incident-console.tsx +15 -5
- package/templates/kanban-board.tsx +46 -4
- package/templates/library.tsx +12 -3
- package/templates/login-split.tsx +3 -3
- package/templates/login-sso.tsx +9 -17
- package/templates/messaging-shell.tsx +109 -46
- package/templates/mixed-gallery.tsx +6 -0
- package/templates/payment-form.tsx +29 -28
- package/templates/product-detail.tsx +12 -3
- package/templates/product-gallery.tsx +8 -3
- package/templates/product-tour.tsx +11 -7
- package/templates/settings-dialog.tsx +5 -0
- package/templates/settings-sidebar.tsx +603 -584
- package/templates/settings.tsx +5 -2
- package/templates/shell-nav.tsx +23 -3
- package/templates/shell-side-nav.tsx +94 -81
- package/templates/shell-top-nav.tsx +98 -86
- package/templates/table-grouped.tsx +146 -132
- package/templates/{table-page-shoe-store-heatmap.template.mjs → table-page-shoe-store.template.mjs} +1 -1
- package/templates/{table-page-shoe-store-heatmap.tsx → table-page-shoe-store.tsx} +1 -1
- package/templates/table-page.tsx +8 -0
- package/templates/table-tree.template.mjs +16 -0
- package/templates/table-tree.tsx +707 -0
- package/templates/tech-report.tsx +8 -5
- package/templates/theme-showcase.tsx +18 -11
- package/templates/work-item-detail.template.mjs +8 -0
- package/templates/work-item-detail.tsx +1049 -0
- package/theme.css +38 -3
- package/theme.css.d.ts +2 -2
- package/tokens.css +41 -4
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
// XLE (canonical structure of the tree below; the CLI had no XLE validator
|
|
3
|
+
// after `astryx layout` was removed in 0.7.0, so this is a reading aid and a
|
|
4
|
+
// human drift check — update it with the file, never alone):
|
|
5
|
+
// L[h=fill] > (LH > V[g=5] > (Hd"Create workspace"[level=1 t=display-2] + (Stepper[horizontal on-track] > (Step"Workspace"! + Step"Coven"[optional] + Step"Plan" + Step"Review")))) + (LC > ((V[g=5] > (V[g=1] > (Hd"Workspace details"[level=2] + Tx"Name your workspace and pick where its data lives"[t=body]) + (TI"Workspace name" + TI"Workspace URL" + SE"Data region" + (RL"Who can join" > RLI*3)))) | (V[g=5] > (V[g=1] > (Hd"Invite your coven"[level=2] + Tx"Coven-mates get an email when the workspace is created"[t=body]) + (TA"Email addresses" + (RL"Default role for invitees" > RLI*3) + D + Switch"Send invites as soon as the workspace is created" + TA"Add a note to the invite"))) | (V[g=5] > (V[g=1] > (Hd"Choose a plan"[level=2] + Tx"Billed monthly per seat"[t=body]) + (Fd"Plan" > G[c={min:200} g=3] > SC*3) + NI"Seats" + CheckboxList"Add-ons" + D + Switch"Weekly usage digest" + (C.muted[p=4] > H[g=3 a=center j=between] > (V[g=0.5] > (Tx"Estimated monthly cost"[t=label] + Tx[t=supporting])) + Tx[t=display-3]))) | (V[g=5] > (V[g=1] > (Hd"Review and create"[level=2] + Tx"Nothing is saved until you create the workspace"[t=body]) + Bn.success"Workspace created" + Bn.error[collapsible=false] + (ML > MLI*8) + D + CB"I agree to the Terms of Service and the Data Processing Addendum")))) + (LF[divider] > H[g=3 a=center] > (SI[fill] > B.ghost"Skip") + Tx"1 of 4"[t=supporting] + (SI[fill] > V[g=1 a=end] > (Tx[t=body] + H[g=3 j=end wrap] > (B.secondary"Back" + B.primary"Create workspace"))))
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create workspace — a four-step wizard that owns the page.
|
|
9
|
+
*
|
|
10
|
+
* The horizontal Stepper lives in the LayoutHeader, not the body: it answers
|
|
11
|
+
* "where am I and how much is left" at the moment a long step makes you ask,
|
|
12
|
+
* and it must never scroll away. The header slot inherits the Layout's
|
|
13
|
+
* contentWidth, so the stepper and the fields below it share one content line.
|
|
14
|
+
*
|
|
15
|
+
* Three pieces of state drive the flow: `step`, the field values, and
|
|
16
|
+
* `attempted` (which steps the person has tried to leave). `errorsByStep` is a
|
|
17
|
+
* pure function of the field values, so that one object colours the Stepper,
|
|
18
|
+
* decides which field gets focus when Next is blocked, and fills the review
|
|
19
|
+
* step's issue list — there is no second copy of the rules to drift.
|
|
20
|
+
*
|
|
21
|
+
* `attempted` is why an untouched field is never already red: a step joins the
|
|
22
|
+
* set when the person tries to leave it. A forward jump through the Stepper
|
|
23
|
+
* marks every step it passes, because jumping ahead asserts those steps are
|
|
24
|
+
* done; moving backward marks nothing, so nobody is punished for looking back.
|
|
25
|
+
*
|
|
26
|
+
* The last step's submit is the one place a button is disabled rather than
|
|
27
|
+
* explaining itself on press. The review panel lists every outstanding issue
|
|
28
|
+
* and the footer names the first blocker beside the button, so the disabled
|
|
29
|
+
* control always has its reason within reading distance. A valid submit records
|
|
30
|
+
* `isCreated`, which adds the confirmation Banner and parks the button until
|
|
31
|
+
* the review is re-entered.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { Banner } from "@astryxdesign/core/Banner";
|
|
35
|
+
import { Button } from "@astryxdesign/core/Button";
|
|
36
|
+
import { Card } from "@astryxdesign/core/Card";
|
|
37
|
+
import { CheckboxInput } from "@astryxdesign/core/CheckboxInput";
|
|
38
|
+
import { CheckboxList, CheckboxListItem } from "@astryxdesign/core/CheckboxList";
|
|
39
|
+
import { Divider } from "@astryxdesign/core/Divider";
|
|
40
|
+
import { Field } from "@astryxdesign/core/Field";
|
|
41
|
+
import { FormLayout } from "@astryxdesign/core/FormLayout";
|
|
42
|
+
import { Grid } from "@astryxdesign/core/Grid";
|
|
43
|
+
import { Layout, LayoutContent, LayoutFooter, LayoutHeader } from "@astryxdesign/core/Layout";
|
|
44
|
+
import { MetadataList, MetadataListItem } from "@astryxdesign/core/MetadataList";
|
|
45
|
+
import { NumberInput } from "@astryxdesign/core/NumberInput";
|
|
46
|
+
import { RadioList, RadioListItem } from "@astryxdesign/core/RadioList";
|
|
47
|
+
import { SelectableCard } from "@astryxdesign/core/SelectableCard";
|
|
48
|
+
import { Selector } from "@astryxdesign/core/Selector";
|
|
49
|
+
import { HStack, StackItem, VStack } from "@astryxdesign/core/Stack";
|
|
50
|
+
import { Step, Stepper } from "@astryxdesign/core/Stepper";
|
|
51
|
+
import { Switch } from "@astryxdesign/core/Switch";
|
|
52
|
+
import { Heading, Text } from "@astryxdesign/core/Text";
|
|
53
|
+
import { TextArea } from "@astryxdesign/core/TextArea";
|
|
54
|
+
import { TextInput } from "@astryxdesign/core/TextInput";
|
|
55
|
+
import { Token } from "@astryxdesign/core/Token";
|
|
56
|
+
import { type CSSProperties, useId, useMemo, useRef, useState } from "react";
|
|
57
|
+
|
|
58
|
+
// ── Data ──────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
// `isOptional` is read twice — by the Stepper to mark the step and by the
|
|
61
|
+
// footer to decide whether Skip exists — so it lives on the step rather than
|
|
62
|
+
// as an index check in two places that could disagree.
|
|
63
|
+
const STEPS = [
|
|
64
|
+
{ label: "Workspace" },
|
|
65
|
+
{ label: "Coven", isOptional: true },
|
|
66
|
+
{ label: "Plan" },
|
|
67
|
+
{ label: "Review" },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const REGIONS = [
|
|
71
|
+
{ value: "us-east", label: "US East (N. Virginia)" },
|
|
72
|
+
{ value: "us-west", label: "US West (Oregon)" },
|
|
73
|
+
{ value: "eu-central", label: "EU Central (Frankfurt)" },
|
|
74
|
+
{ value: "ap-southeast", label: "Asia Pacific (Singapore)" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const ROLE_LABELS: Record<string, string> = {
|
|
78
|
+
admin: "Admin",
|
|
79
|
+
member: "Member",
|
|
80
|
+
guest: "Guest",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const PLANS = [
|
|
84
|
+
{
|
|
85
|
+
id: "starter",
|
|
86
|
+
label: "Starter",
|
|
87
|
+
pricePerSeat: 0,
|
|
88
|
+
seatLimit: 5,
|
|
89
|
+
description: "Up to 5 members, 3 projects, community support.",
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "growth",
|
|
93
|
+
label: "Growth",
|
|
94
|
+
pricePerSeat: 24,
|
|
95
|
+
seatLimit: 250,
|
|
96
|
+
description: "Unlimited projects, integrations, and standard support.",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "scale",
|
|
100
|
+
label: "Scale",
|
|
101
|
+
pricePerSeat: 48,
|
|
102
|
+
seatLimit: 2000,
|
|
103
|
+
description: "Advanced controls, a 99.9% SLA, and a dedicated CSM.",
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const ADDONS = [
|
|
108
|
+
{
|
|
109
|
+
id: "sso",
|
|
110
|
+
label: "SSO / SAML",
|
|
111
|
+
price: 3,
|
|
112
|
+
description: "Single sign-on through Okta, Entra ID, or Google.",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "audit",
|
|
116
|
+
label: "Audit log export",
|
|
117
|
+
price: 2,
|
|
118
|
+
description: "Stream workspace audit events to your SIEM.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "support",
|
|
122
|
+
label: "Priority support",
|
|
123
|
+
price: 6,
|
|
124
|
+
description: "Four-hour response target, 24/5.",
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
// Reserved slugs the backend would reject; checked client-side so the person
|
|
129
|
+
// finds out while typing rather than after pressing Create.
|
|
130
|
+
const TAKEN_SLUGS = ["admin", "app", "api", "grimoire", "support"];
|
|
131
|
+
|
|
132
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
133
|
+
const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
134
|
+
|
|
135
|
+
const DEFAULT_INVITES = [
|
|
136
|
+
"amara.diallo@moonhollow.dev",
|
|
137
|
+
"felix.tran@moonhollow.dev",
|
|
138
|
+
"rosa.marchetti@moonhollow.dev",
|
|
139
|
+
].join("\n");
|
|
140
|
+
|
|
141
|
+
// ── Styles ────────────────────────────────────────────────────────────────────
|
|
142
|
+
// Plain inline styles over Astryx token CSS variables, so the template compiles
|
|
143
|
+
// in a project with no StyleX pipeline.
|
|
144
|
+
|
|
145
|
+
// A definite ancestor for `Layout height="fill"`. 100dvh, not minHeight:
|
|
146
|
+
// "100%" — a percentage min-height against an indefinite containing block
|
|
147
|
+
// computes to 0 (CSS 2.1 §10.5), so the soft failure would survive and the
|
|
148
|
+
// document would keep the scroll. LayoutContent is overflow:auto, so a
|
|
149
|
+
// definite height lands as the template's own scroller instead of a document
|
|
150
|
+
// one. Same anchor as dashboard.tsx:618, side-gallery.tsx:74.
|
|
151
|
+
const pageStyle: CSSProperties = { height: "100dvh" };
|
|
152
|
+
|
|
153
|
+
// The footer's two end zones divide the leftover space equally rather than
|
|
154
|
+
// sizing to their own contents, which is what keeps the step counter on the
|
|
155
|
+
// page's centre line. Without the zero basis the counter drifts by half the
|
|
156
|
+
// difference between the zones — most visibly on the last step, where the
|
|
157
|
+
// primary button's label grows from "Next" to "Create workspace".
|
|
158
|
+
const footerZone: CSSProperties = { flexBasis: 0 };
|
|
159
|
+
|
|
160
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
const money = (n: number) => (n === 0 ? "$0" : `$${n.toLocaleString()}`);
|
|
163
|
+
|
|
164
|
+
/** Split a textarea of addresses on newlines and commas, dropping blanks. */
|
|
165
|
+
function parseEmails(raw: string): string[] {
|
|
166
|
+
return raw
|
|
167
|
+
.split(/[\n,]/)
|
|
168
|
+
.map((line) => line.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default function FormWizardPage() {
|
|
173
|
+
// Field wraps controls that have no label of their own; it needs an id to
|
|
174
|
+
// point its label at. useId, not a hand-written string, so two of these
|
|
175
|
+
// templates on one page cannot collide.
|
|
176
|
+
const planGroupID = useId();
|
|
177
|
+
// The disabled submit's reason is a visible line beside the button; the id
|
|
178
|
+
// ties it to the control as its accessible description, so the reason is not
|
|
179
|
+
// merely adjacent to the thing it explains.
|
|
180
|
+
const blockerHintID = useId();
|
|
181
|
+
|
|
182
|
+
// Focus targets for every field the rules can complain about, keyed by the
|
|
183
|
+
// same names they use. A blocked Next sends focus to the first of these that
|
|
184
|
+
// has a problem, which scrolls it into view and lets the field's own error
|
|
185
|
+
// announce itself — so the footer needs no summary line, and there is no
|
|
186
|
+
// second copy of the message to keep in sync.
|
|
187
|
+
const nameRef = useRef<HTMLInputElement>(null);
|
|
188
|
+
const slugRef = useRef<HTMLInputElement>(null);
|
|
189
|
+
const invitesRef = useRef<HTMLTextAreaElement>(null);
|
|
190
|
+
const seatsRef = useRef<HTMLInputElement>(null);
|
|
191
|
+
const termsRef = useRef<HTMLInputElement>(null);
|
|
192
|
+
const fieldRefs = {
|
|
193
|
+
name: nameRef,
|
|
194
|
+
slug: slugRef,
|
|
195
|
+
invites: invitesRef,
|
|
196
|
+
seats: seatsRef,
|
|
197
|
+
terms: termsRef,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const [step, setStep] = useState(0);
|
|
201
|
+
// Steps the person has tried to advance past. Errors stay silent until a
|
|
202
|
+
// step is in this set, so an untouched field is never pre-flagged red.
|
|
203
|
+
const [attempted, setAttempted] = useState<ReadonlySet<number>>(new Set());
|
|
204
|
+
|
|
205
|
+
// Step 1 — workspace
|
|
206
|
+
const [name, setName] = useState("Moonhollow Press");
|
|
207
|
+
const [slug, setSlug] = useState("moonhollow-press");
|
|
208
|
+
const [region, setRegion] = useState("us-east");
|
|
209
|
+
const [visibility, setVisibility] = useState("invite");
|
|
210
|
+
|
|
211
|
+
// Step 2 — coven
|
|
212
|
+
const [invites, setInvites] = useState(DEFAULT_INVITES);
|
|
213
|
+
const [role, setRole] = useState("member");
|
|
214
|
+
const [sendNow, setSendNow] = useState(true);
|
|
215
|
+
const [inviteNote, setInviteNote] = useState("");
|
|
216
|
+
|
|
217
|
+
// Step 3 — plan
|
|
218
|
+
const [planId, setPlanId] = useState("growth");
|
|
219
|
+
const [seats, setSeats] = useState(8);
|
|
220
|
+
const [addons, setAddons] = useState<string[]>(["audit"]);
|
|
221
|
+
const [digest, setDigest] = useState(true);
|
|
222
|
+
|
|
223
|
+
// Step 4 — review
|
|
224
|
+
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
225
|
+
const [isCreated, setIsCreated] = useState(false);
|
|
226
|
+
|
|
227
|
+
const emails = useMemo(() => parseEmails(invites), [invites]);
|
|
228
|
+
const invalidEmails = useMemo(
|
|
229
|
+
() => emails.filter((email) => !EMAIL_PATTERN.test(email)),
|
|
230
|
+
[emails],
|
|
231
|
+
);
|
|
232
|
+
const plan = PLANS.find((p) => p.id === planId) ?? PLANS[0];
|
|
233
|
+
const inviteCount = emails.length;
|
|
234
|
+
const selectedAddons = useMemo(() => {
|
|
235
|
+
const chosen = new Set(addons);
|
|
236
|
+
return ADDONS.filter((a) => chosen.has(a.id));
|
|
237
|
+
}, [addons]);
|
|
238
|
+
const addonCostPerSeat = selectedAddons.reduce((sum, a) => sum + a.price, 0);
|
|
239
|
+
const monthlyTotal = (plan.pricePerSeat + addonCostPerSeat) * seats;
|
|
240
|
+
|
|
241
|
+
// One rule set, read by the Next button, the Stepper tint, the review step's
|
|
242
|
+
// issue list and the disabled submit. Keyed by step index, so a step's
|
|
243
|
+
// validity is a lookup and the first key is the first field on the page.
|
|
244
|
+
const errorsByStep = useMemo<Array<Record<string, string>>>(() => {
|
|
245
|
+
const workspace: Record<string, string> = {};
|
|
246
|
+
if (!name.trim()) {
|
|
247
|
+
workspace.name = "Enter a name for the workspace.";
|
|
248
|
+
}
|
|
249
|
+
if (!slug.trim()) {
|
|
250
|
+
workspace.slug = "Enter a URL for the workspace.";
|
|
251
|
+
} else if (!SLUG_PATTERN.test(slug)) {
|
|
252
|
+
workspace.slug =
|
|
253
|
+
"Use lowercase letters, numbers, and hyphens, starting and ending with a letter or number.";
|
|
254
|
+
} else if (TAKEN_SLUGS.includes(slug)) {
|
|
255
|
+
workspace.slug = `grimoire.app/${slug} is already taken.`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const team: Record<string, string> = {};
|
|
259
|
+
if (invalidEmails.length > 0) {
|
|
260
|
+
team.invites = `Not a valid email address: ${invalidEmails.join(", ")}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const planStep: Record<string, string> = {};
|
|
264
|
+
if (seats < 1) {
|
|
265
|
+
planStep.seats = "A workspace needs at least one seat.";
|
|
266
|
+
} else if (seats > plan.seatLimit) {
|
|
267
|
+
planStep.seats = `${plan.label} tops out at ${plan.seatLimit} seats. Choose a larger plan or reduce the seat count.`;
|
|
268
|
+
} else if (seats < inviteCount + 1) {
|
|
269
|
+
planStep.seats = `You are inviting ${inviteCount} ${
|
|
270
|
+
inviteCount === 1 ? "person" : "people"
|
|
271
|
+
} plus yourself, so you need at least ${inviteCount + 1} seats.`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const review: Record<string, string> = {};
|
|
275
|
+
if (!acceptedTerms) {
|
|
276
|
+
review.terms = "Accept the terms to create the workspace.";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return [workspace, team, planStep, review];
|
|
280
|
+
}, [name, slug, invalidEmails, seats, plan, inviteCount, acceptedTerms]);
|
|
281
|
+
|
|
282
|
+
/** Errors to render for a step: only once the person has tried to leave it. */
|
|
283
|
+
const shownErrors = (index: number): Record<string, string> =>
|
|
284
|
+
attempted.has(index) ? errorsByStep[index] : {};
|
|
285
|
+
|
|
286
|
+
const currentErrors = shownErrors(step);
|
|
287
|
+
const isLastStep = step === STEPS.length - 1;
|
|
288
|
+
|
|
289
|
+
// Every issue the form currently has, in step then field order, whether or
|
|
290
|
+
// not the step has been attempted yet. The submit is the one control that
|
|
291
|
+
// has to answer for a field it has not visited, so it reads the ungated set.
|
|
292
|
+
const blockers = errorsByStep.flatMap((stepErrors) => Object.values(stepErrors));
|
|
293
|
+
|
|
294
|
+
// The one control that is disabled rather than left to explain itself on
|
|
295
|
+
// press. `isCreated` keeps it disabled once the workspace exists.
|
|
296
|
+
const submitBlocked = isLastStep && !isCreated && blockers.length > 0;
|
|
297
|
+
|
|
298
|
+
// Steps other than the current one that the person has already tried and
|
|
299
|
+
// left broken — surfaced on the review step so nothing hides behind a panel.
|
|
300
|
+
const outstanding = STEPS.map((s, i) => ({
|
|
301
|
+
...s,
|
|
302
|
+
index: i,
|
|
303
|
+
issues: Object.values(shownErrors(i)),
|
|
304
|
+
})).filter((s) => s.index !== step && s.issues.length > 0);
|
|
305
|
+
|
|
306
|
+
const markAttemptedThrough = (index: number) =>
|
|
307
|
+
setAttempted((prev) => {
|
|
308
|
+
const next = new Set(prev);
|
|
309
|
+
for (let i = 0; i <= index; i += 1) {
|
|
310
|
+
next.add(i);
|
|
311
|
+
}
|
|
312
|
+
return next;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const goNext = () => {
|
|
316
|
+
markAttemptedThrough(step);
|
|
317
|
+
|
|
318
|
+
// `errorsByStep` records each step's problems in the order the fields
|
|
319
|
+
// appear, so the first key is the first thing on the page that needs
|
|
320
|
+
// attention. Sending focus there is the whole response to a blocked Next:
|
|
321
|
+
// it scrolls the field into view, and the error the field is already
|
|
322
|
+
// showing becomes its accessible description, so a screen reader hears
|
|
323
|
+
// exactly which field and why rather than a count of how many.
|
|
324
|
+
const [firstProblem] = Object.keys(errorsByStep[step]);
|
|
325
|
+
if (firstProblem) {
|
|
326
|
+
fieldRefs[firstProblem as keyof typeof fieldRefs]?.current?.focus();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (isLastStep) {
|
|
331
|
+
setIsCreated(true);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
setStep((s) => s + 1);
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Skip is not Next with a different label. Next asserts the step is done and
|
|
339
|
+
// validates it; Skip declines the step outright, so it clears what the step
|
|
340
|
+
// collects and moves on without validating. Clearing is the point: a
|
|
341
|
+
// half-filled optional step that rides along invisibly is the bug this
|
|
342
|
+
// button exists to prevent.
|
|
343
|
+
const goSkip = () => {
|
|
344
|
+
setInvites("");
|
|
345
|
+
setInviteNote("");
|
|
346
|
+
setStep((s) => Math.min(STEPS.length - 1, s + 1));
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const goTo = (index: number) => {
|
|
350
|
+
// Leaving a step forward is an assertion that every step passed is done;
|
|
351
|
+
// leaving it backward is not, so only mark when moving on. A created
|
|
352
|
+
// workspace is no longer created the moment the review is re-entered
|
|
353
|
+
// after an edit, so the confirmation clears with it.
|
|
354
|
+
if (index > step) {
|
|
355
|
+
markAttemptedThrough(index - 1);
|
|
356
|
+
}
|
|
357
|
+
if (index !== step) {
|
|
358
|
+
setIsCreated(false);
|
|
359
|
+
}
|
|
360
|
+
setStep(index);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
<Layout
|
|
365
|
+
style={pageStyle}
|
|
366
|
+
height="fill"
|
|
367
|
+
contentWidth={720}
|
|
368
|
+
// One inset owner for the whole page. Padding set here reaches the
|
|
369
|
+
// header, the body and the footer, so the title, the stepper, every
|
|
370
|
+
// field label and the Back button all start on the same vertical line
|
|
371
|
+
// — and changing the page's breathing room is one number, not three.
|
|
372
|
+
padding={6}
|
|
373
|
+
// Same idea for the fences: the footer's divider comes from one
|
|
374
|
+
// declaration rather than the slot opting in. The header is the one
|
|
375
|
+
// exception, so it is the one place that says so.
|
|
376
|
+
defaultHasDividers
|
|
377
|
+
header={
|
|
378
|
+
// The stepper lives in the header, not the body. It is the one thing
|
|
379
|
+
// on the page that must never scroll away: it answers "where am I and
|
|
380
|
+
// how much is left" at the exact moment a long step makes you ask.
|
|
381
|
+
// The header slot also inherits the Layout's contentWidth, so the
|
|
382
|
+
// stepper and the fields below it share one content line.
|
|
383
|
+
<LayoutHeader hasDivider={false}>
|
|
384
|
+
<VStack gap={5}>
|
|
385
|
+
<Heading level={1} type="display-2">
|
|
386
|
+
Create workspace
|
|
387
|
+
</Heading>
|
|
388
|
+
{/* Indicators sit on the track, and the steps carry a label
|
|
389
|
+
only. A pinned header pays for every line it keeps, and a
|
|
390
|
+
description under each step buys nothing the panel below
|
|
391
|
+
does not already say in full. One stepper at every width: it
|
|
392
|
+
collapses itself once the header is too narrow to label four
|
|
393
|
+
steps, keeping the nodes on the rail tappable. */}
|
|
394
|
+
<Stepper
|
|
395
|
+
activeStep={step}
|
|
396
|
+
orientation="horizontal"
|
|
397
|
+
onStepClick={goTo}
|
|
398
|
+
label="Create workspace progress"
|
|
399
|
+
density="balanced"
|
|
400
|
+
indicatorPosition="on-track"
|
|
401
|
+
>
|
|
402
|
+
{STEPS.map((s, i) => (
|
|
403
|
+
<Step
|
|
404
|
+
key={s.label}
|
|
405
|
+
step={i}
|
|
406
|
+
label={s.label}
|
|
407
|
+
isOptional={s.isOptional}
|
|
408
|
+
status={Object.keys(shownErrors(i)).length > 0 ? "error" : undefined}
|
|
409
|
+
/>
|
|
410
|
+
))}
|
|
411
|
+
</Stepper>
|
|
412
|
+
</VStack>
|
|
413
|
+
</LayoutHeader>
|
|
414
|
+
}
|
|
415
|
+
content={
|
|
416
|
+
// No Card around the step. The pinned header and footer already fence
|
|
417
|
+
// this region, so a card here would be a border inside a border — and
|
|
418
|
+
// its own padding would push every field off the content line the
|
|
419
|
+
// title and stepper sit on.
|
|
420
|
+
<LayoutContent>
|
|
421
|
+
{step === 0 && (
|
|
422
|
+
<VStack gap={5}>
|
|
423
|
+
<VStack gap={1}>
|
|
424
|
+
<Heading level={2}>Workspace details</Heading>
|
|
425
|
+
<Text type="body" color="secondary">
|
|
426
|
+
Name your workspace and pick where its data lives. The region is permanent —
|
|
427
|
+
moving data later means a migration.
|
|
428
|
+
</Text>
|
|
429
|
+
</VStack>
|
|
430
|
+
{/* FormLayout, not a VStack: form fields are the one case where
|
|
431
|
+
the container owns spacing outright, and it also carries
|
|
432
|
+
`defaultOptionality` so the whole wizard marks optionality
|
|
433
|
+
one way. Everything here is optional unless it says
|
|
434
|
+
otherwise, so only the two required fields get an
|
|
435
|
+
indicator — no field wears both kinds of badge. */}
|
|
436
|
+
<FormLayout defaultOptionality="optional">
|
|
437
|
+
<TextInput
|
|
438
|
+
label="Workspace name"
|
|
439
|
+
ref={fieldRefs.name}
|
|
440
|
+
isRequired
|
|
441
|
+
value={name}
|
|
442
|
+
onChange={setName}
|
|
443
|
+
placeholder="Moonhollow Press"
|
|
444
|
+
status={
|
|
445
|
+
currentErrors.name ? { type: "error", message: currentErrors.name } : undefined
|
|
446
|
+
}
|
|
447
|
+
/>
|
|
448
|
+
<TextInput
|
|
449
|
+
label="Workspace URL"
|
|
450
|
+
ref={fieldRefs.slug}
|
|
451
|
+
isRequired
|
|
452
|
+
value={slug}
|
|
453
|
+
onChange={setSlug}
|
|
454
|
+
placeholder="moonhollow-press"
|
|
455
|
+
description={`grimoire.app/${slug.trim() || "your-workspace"}`}
|
|
456
|
+
status={
|
|
457
|
+
currentErrors.slug ? { type: "error", message: currentErrors.slug } : undefined
|
|
458
|
+
}
|
|
459
|
+
/>
|
|
460
|
+
<Selector
|
|
461
|
+
label="Data region"
|
|
462
|
+
options={REGIONS}
|
|
463
|
+
value={region}
|
|
464
|
+
onChange={setRegion}
|
|
465
|
+
description="Where projects, files, and audit logs are stored at rest."
|
|
466
|
+
/>
|
|
467
|
+
{/* A RadioList, not a SegmentedControl. These three options
|
|
468
|
+
differ in who they let through, and that only lands if
|
|
469
|
+
each one can say so next to itself — a segmented control
|
|
470
|
+
has room for a label and nothing else, so the consequence
|
|
471
|
+
has to move to a single description that rewrites itself
|
|
472
|
+
as you click, showing one option's meaning while offering
|
|
473
|
+
three. Radios give every option its own line and let all
|
|
474
|
+
three be compared before choosing. */}
|
|
475
|
+
<RadioList label="Who can join" value={visibility} onChange={setVisibility}>
|
|
476
|
+
<RadioListItem
|
|
477
|
+
value="invite"
|
|
478
|
+
label="Invite only"
|
|
479
|
+
description="People join only when an admin invites them."
|
|
480
|
+
/>
|
|
481
|
+
<RadioListItem
|
|
482
|
+
value="domain"
|
|
483
|
+
label="Same domain"
|
|
484
|
+
description="Anyone with a verified @moonhollow.dev address can join without an invite."
|
|
485
|
+
/>
|
|
486
|
+
<RadioListItem
|
|
487
|
+
value="open"
|
|
488
|
+
label="Anyone"
|
|
489
|
+
description="Anyone with the workspace link can request to join."
|
|
490
|
+
/>
|
|
491
|
+
</RadioList>
|
|
492
|
+
</FormLayout>
|
|
493
|
+
</VStack>
|
|
494
|
+
)}
|
|
495
|
+
|
|
496
|
+
{step === 1 && (
|
|
497
|
+
<VStack gap={5}>
|
|
498
|
+
<VStack gap={1}>
|
|
499
|
+
<Heading level={2}>Invite your coven</Heading>
|
|
500
|
+
<Text type="body" color="secondary">
|
|
501
|
+
Coven-mates get an email when the workspace is created. You can skip this and
|
|
502
|
+
invite people later from settings.
|
|
503
|
+
</Text>
|
|
504
|
+
</VStack>
|
|
505
|
+
{/* Nothing on this step is required, and under
|
|
506
|
+
`defaultOptionality="optional"` that is exactly what it
|
|
507
|
+
looks like: no indicators at all. Marking each field
|
|
508
|
+
"Optional" would say the same thing four times. */}
|
|
509
|
+
<FormLayout defaultOptionality="optional">
|
|
510
|
+
<TextArea
|
|
511
|
+
label="Email addresses"
|
|
512
|
+
ref={fieldRefs.invites}
|
|
513
|
+
rows={4}
|
|
514
|
+
value={invites}
|
|
515
|
+
onChange={setInvites}
|
|
516
|
+
placeholder={"jordan@moonhollow.dev\npriya@moonhollow.dev"}
|
|
517
|
+
description="One address per line, or separate them with commas."
|
|
518
|
+
status={
|
|
519
|
+
currentErrors.invites
|
|
520
|
+
? { type: "error", message: currentErrors.invites }
|
|
521
|
+
: emails.length > 0
|
|
522
|
+
? {
|
|
523
|
+
type: "success",
|
|
524
|
+
message: `${emails.length} ${
|
|
525
|
+
emails.length === 1 ? "address" : "addresses"
|
|
526
|
+
} ready to invite.`,
|
|
527
|
+
}
|
|
528
|
+
: undefined
|
|
529
|
+
}
|
|
530
|
+
/>
|
|
531
|
+
<RadioList
|
|
532
|
+
label="Default role for invitees"
|
|
533
|
+
value={role}
|
|
534
|
+
onChange={setRole}
|
|
535
|
+
description="Each person's role can be changed individually after they join."
|
|
536
|
+
>
|
|
537
|
+
<RadioListItem
|
|
538
|
+
value="admin"
|
|
539
|
+
label="Admin"
|
|
540
|
+
description="Manage billing, members, and workspace settings."
|
|
541
|
+
/>
|
|
542
|
+
<RadioListItem
|
|
543
|
+
value="member"
|
|
544
|
+
label="Member"
|
|
545
|
+
description="Create and edit projects. No access to admin settings."
|
|
546
|
+
/>
|
|
547
|
+
<RadioListItem
|
|
548
|
+
value="guest"
|
|
549
|
+
label="Guest"
|
|
550
|
+
description="View and comment on projects they are added to."
|
|
551
|
+
/>
|
|
552
|
+
</RadioList>
|
|
553
|
+
<Divider />
|
|
554
|
+
<Switch
|
|
555
|
+
label="Send invites as soon as the workspace is created"
|
|
556
|
+
description="Turn this off to create the workspace now and send invites yourself later."
|
|
557
|
+
value={sendNow}
|
|
558
|
+
onChange={setSendNow}
|
|
559
|
+
labelPosition="start"
|
|
560
|
+
labelSpacing="spread"
|
|
561
|
+
/>
|
|
562
|
+
{sendNow && (
|
|
563
|
+
<TextArea
|
|
564
|
+
label="Add a note to the invite"
|
|
565
|
+
rows={2}
|
|
566
|
+
maxLength={280}
|
|
567
|
+
value={inviteNote}
|
|
568
|
+
onChange={setInviteNote}
|
|
569
|
+
placeholder="We're moving planning off email — join us here."
|
|
570
|
+
/>
|
|
571
|
+
)}
|
|
572
|
+
</FormLayout>
|
|
573
|
+
</VStack>
|
|
574
|
+
)}
|
|
575
|
+
|
|
576
|
+
{step === 2 && (
|
|
577
|
+
<VStack gap={5}>
|
|
578
|
+
<VStack gap={1}>
|
|
579
|
+
<Heading level={2}>Choose a plan</Heading>
|
|
580
|
+
<Text type="body" color="secondary">
|
|
581
|
+
Billed monthly per seat. Change plans or add capabilities at any time from
|
|
582
|
+
workspace settings.
|
|
583
|
+
</Text>
|
|
584
|
+
</VStack>
|
|
585
|
+
<FormLayout defaultOptionality="optional">
|
|
586
|
+
{/* A plan picker is the case SelectableCard is for: people
|
|
587
|
+
decide by comparing the options side by side, not by
|
|
588
|
+
reading a list of labels. Wrapped in a Field so the group
|
|
589
|
+
gets a visible label on the same line as every other
|
|
590
|
+
field's; the label is wired to the grid as a group, since
|
|
591
|
+
a card is a checkbox rather than a labelled input. */}
|
|
592
|
+
<Field
|
|
593
|
+
label="Plan"
|
|
594
|
+
inputID={planGroupID}
|
|
595
|
+
labelID={`${planGroupID}-label`}
|
|
596
|
+
isGroupLabel
|
|
597
|
+
>
|
|
598
|
+
<Grid
|
|
599
|
+
id={planGroupID}
|
|
600
|
+
role="group"
|
|
601
|
+
aria-labelledby={`${planGroupID}-label`}
|
|
602
|
+
columns={{ minWidth: 200 }}
|
|
603
|
+
gap={3}
|
|
604
|
+
>
|
|
605
|
+
{PLANS.map((p) => (
|
|
606
|
+
<SelectableCard
|
|
607
|
+
key={p.id}
|
|
608
|
+
label={`${p.label} plan`}
|
|
609
|
+
isSelected={planId === p.id}
|
|
610
|
+
onChange={() => setPlanId(p.id)}
|
|
611
|
+
padding={4}
|
|
612
|
+
>
|
|
613
|
+
<VStack gap={2}>
|
|
614
|
+
<HStack gap={2} vAlign="center" hAlign="between">
|
|
615
|
+
<Text type="label">{p.label}</Text>
|
|
616
|
+
{p.id === "growth" && <Token label="Recommended" color="yellow" />}
|
|
617
|
+
</HStack>
|
|
618
|
+
<Text type="large" weight="semibold">
|
|
619
|
+
{p.pricePerSeat === 0 ? "Free" : `${money(p.pricePerSeat)}/seat`}
|
|
620
|
+
</Text>
|
|
621
|
+
<Text type="body" color="secondary">
|
|
622
|
+
{p.description}
|
|
623
|
+
</Text>
|
|
624
|
+
</VStack>
|
|
625
|
+
</SelectableCard>
|
|
626
|
+
))}
|
|
627
|
+
</Grid>
|
|
628
|
+
</Field>
|
|
629
|
+
<NumberInput
|
|
630
|
+
label="Seats"
|
|
631
|
+
ref={fieldRefs.seats}
|
|
632
|
+
value={seats}
|
|
633
|
+
onChange={setSeats}
|
|
634
|
+
min={1}
|
|
635
|
+
max={plan.seatLimit}
|
|
636
|
+
isIntegerOnly
|
|
637
|
+
description={`Includes you. ${plan.label} allows up to ${plan.seatLimit}.`}
|
|
638
|
+
status={
|
|
639
|
+
currentErrors.seats
|
|
640
|
+
? { type: "error", message: currentErrors.seats }
|
|
641
|
+
: undefined
|
|
642
|
+
}
|
|
643
|
+
/>
|
|
644
|
+
<CheckboxList
|
|
645
|
+
label="Add-ons"
|
|
646
|
+
description="Billed per seat alongside the plan."
|
|
647
|
+
value={addons}
|
|
648
|
+
onChange={setAddons}
|
|
649
|
+
>
|
|
650
|
+
{ADDONS.map((a) => (
|
|
651
|
+
<CheckboxListItem
|
|
652
|
+
key={a.id}
|
|
653
|
+
value={a.id}
|
|
654
|
+
label={`${a.label} — ${money(a.price)}/seat`}
|
|
655
|
+
description={a.description}
|
|
656
|
+
/>
|
|
657
|
+
))}
|
|
658
|
+
</CheckboxList>
|
|
659
|
+
<Divider />
|
|
660
|
+
<Switch
|
|
661
|
+
label="Weekly usage digest"
|
|
662
|
+
description="Email admins a summary of seats and activity every Monday."
|
|
663
|
+
value={digest}
|
|
664
|
+
onChange={setDigest}
|
|
665
|
+
labelPosition="start"
|
|
666
|
+
labelSpacing="spread"
|
|
667
|
+
/>
|
|
668
|
+
</FormLayout>
|
|
669
|
+
{/* Outside the FormLayout, because it is a readout and not a
|
|
670
|
+
field — putting it inside would make the form's last row
|
|
671
|
+
something you cannot fill in. A muted Card is the recessed
|
|
672
|
+
well this theme reserves for insets and tracks: the total
|
|
673
|
+
is the consequence of every control above it, so it reads
|
|
674
|
+
as its own output rather than competing with the plan cards
|
|
675
|
+
for the eye. */}
|
|
676
|
+
<Card variant="muted" padding={4}>
|
|
677
|
+
<HStack gap={3} vAlign="center" hAlign="between">
|
|
678
|
+
<VStack gap={0.5}>
|
|
679
|
+
<Text type="label">Estimated monthly cost</Text>
|
|
680
|
+
<Text type="supporting" color="secondary" hasTabularNumbers>
|
|
681
|
+
{seats} {seats === 1 ? "seat" : "seats"} ×{" "}
|
|
682
|
+
{money(plan.pricePerSeat + addonCostPerSeat)}
|
|
683
|
+
{addonCostPerSeat > 0
|
|
684
|
+
? ` (${money(plan.pricePerSeat)} plan + ${money(addonCostPerSeat)} add-ons)`
|
|
685
|
+
: ""}
|
|
686
|
+
</Text>
|
|
687
|
+
</VStack>
|
|
688
|
+
<Text type="display-3" weight="semibold" hasTabularNumbers>
|
|
689
|
+
{money(monthlyTotal)}
|
|
690
|
+
</Text>
|
|
691
|
+
</HStack>
|
|
692
|
+
</Card>
|
|
693
|
+
</VStack>
|
|
694
|
+
)}
|
|
695
|
+
|
|
696
|
+
{step === 3 && (
|
|
697
|
+
<VStack gap={5}>
|
|
698
|
+
<VStack gap={1}>
|
|
699
|
+
<Heading level={2}>Review and create</Heading>
|
|
700
|
+
<Text type="body" color="secondary">
|
|
701
|
+
{isCreated
|
|
702
|
+
? "The workspace is created. Change any of this later from workspace settings."
|
|
703
|
+
: "Nothing is saved until you create the workspace. Use the steps above to go back and edit."}
|
|
704
|
+
</Text>
|
|
705
|
+
</VStack>
|
|
706
|
+
|
|
707
|
+
{isCreated && (
|
|
708
|
+
<Banner
|
|
709
|
+
status="success"
|
|
710
|
+
// Core 0.7.0 stopped deriving the live-region role from
|
|
711
|
+
// `status`, so this banner states its own. It mirrors the
|
|
712
|
+
// submit result, and a screen reader must hear it.
|
|
713
|
+
role="status"
|
|
714
|
+
title="Workspace created"
|
|
715
|
+
description={`${name.trim()} is live at grimoire.app/${slug.trim()} with ${seats} ${
|
|
716
|
+
seats === 1 ? "seat" : "seats"
|
|
717
|
+
} on the ${plan.label} plan.`}
|
|
718
|
+
/>
|
|
719
|
+
)}
|
|
720
|
+
|
|
721
|
+
{outstanding.length > 0 && (
|
|
722
|
+
<Banner
|
|
723
|
+
status="error"
|
|
724
|
+
title={`${outstanding.length} ${
|
|
725
|
+
outstanding.length === 1 ? "step needs" : "steps need"
|
|
726
|
+
} attention`}
|
|
727
|
+
// Banner collapses children by default. An error summary
|
|
728
|
+
// is the one case where that is wrong: what is broken has
|
|
729
|
+
// to be readable without a second click.
|
|
730
|
+
collapsible={false}
|
|
731
|
+
>
|
|
732
|
+
<VStack gap={2}>
|
|
733
|
+
{outstanding.map((s) => (
|
|
734
|
+
<VStack key={s.label} gap={1}>
|
|
735
|
+
<Text type="body">
|
|
736
|
+
<Text type="inherit" weight="semibold">
|
|
737
|
+
{s.label}
|
|
738
|
+
</Text>
|
|
739
|
+
: {s.issues.join(" ")}
|
|
740
|
+
</Text>
|
|
741
|
+
</VStack>
|
|
742
|
+
))}
|
|
743
|
+
<HStack gap={2}>
|
|
744
|
+
<Button
|
|
745
|
+
label={`Go to ${outstanding[0].label}`}
|
|
746
|
+
variant="secondary"
|
|
747
|
+
size="sm"
|
|
748
|
+
onClick={() => goTo(outstanding[0].index)}
|
|
749
|
+
/>
|
|
750
|
+
</HStack>
|
|
751
|
+
</VStack>
|
|
752
|
+
</Banner>
|
|
753
|
+
)}
|
|
754
|
+
|
|
755
|
+
{/* One column, label beside value. A review step is read top
|
|
756
|
+
to bottom against the steps that produced it, and a
|
|
757
|
+
two-column grid breaks that into a reading order nobody
|
|
758
|
+
follows — it also strands the long values (a URL, a
|
|
759
|
+
sentence about who can join) in half the width they need. */}
|
|
760
|
+
<MetadataList orientation="vertical">
|
|
761
|
+
<MetadataListItem label="Workspace name">{name.trim() || "—"}</MetadataListItem>
|
|
762
|
+
<MetadataListItem label="Workspace URL">
|
|
763
|
+
grimoire.app/{slug.trim() || "—"}
|
|
764
|
+
</MetadataListItem>
|
|
765
|
+
<MetadataListItem label="Data region">
|
|
766
|
+
{REGIONS.find((r) => r.value === region)?.label ?? region}
|
|
767
|
+
</MetadataListItem>
|
|
768
|
+
<MetadataListItem label="Who can join">
|
|
769
|
+
{visibility === "invite"
|
|
770
|
+
? "Invite only"
|
|
771
|
+
: visibility === "domain"
|
|
772
|
+
? "Anyone at moonhollow.dev"
|
|
773
|
+
: "Anyone with the link"}
|
|
774
|
+
</MetadataListItem>
|
|
775
|
+
<MetadataListItem label="Invitations">
|
|
776
|
+
{emails.length === 0
|
|
777
|
+
? "None"
|
|
778
|
+
: `${emails.length} ${
|
|
779
|
+
emails.length === 1 ? "person" : "people"
|
|
780
|
+
} as ${ROLE_LABELS[role]}${sendNow ? "" : ", sent later"}`}
|
|
781
|
+
</MetadataListItem>
|
|
782
|
+
<MetadataListItem label="Plan">
|
|
783
|
+
{plan.pricePerSeat === 0
|
|
784
|
+
? `${plan.label} (Free)`
|
|
785
|
+
: `${plan.label} · ${money(plan.pricePerSeat)}/seat`}
|
|
786
|
+
</MetadataListItem>
|
|
787
|
+
<MetadataListItem label="Add-ons">
|
|
788
|
+
{selectedAddons.length === 0
|
|
789
|
+
? "None"
|
|
790
|
+
: selectedAddons.map((a) => a.label).join(", ")}
|
|
791
|
+
</MetadataListItem>
|
|
792
|
+
<MetadataListItem label="Estimated monthly cost">
|
|
793
|
+
{money(monthlyTotal)} · {seats} {seats === 1 ? "seat" : "seats"}
|
|
794
|
+
</MetadataListItem>
|
|
795
|
+
</MetadataList>
|
|
796
|
+
|
|
797
|
+
<Divider />
|
|
798
|
+
|
|
799
|
+
<CheckboxInput
|
|
800
|
+
label="I agree to the Terms of Service and the Data Processing Addendum"
|
|
801
|
+
ref={fieldRefs.terms}
|
|
802
|
+
value={acceptedTerms}
|
|
803
|
+
// Withdrawing acceptance is an answer the field can be
|
|
804
|
+
// wrong about, so it counts as an attempt; ticking the box
|
|
805
|
+
// is not an attempt at anything and stays silent.
|
|
806
|
+
onChange={(next) => {
|
|
807
|
+
setAcceptedTerms(next);
|
|
808
|
+
if (!next) {
|
|
809
|
+
markAttemptedThrough(step);
|
|
810
|
+
}
|
|
811
|
+
}}
|
|
812
|
+
status={
|
|
813
|
+
currentErrors.terms ? { type: "error", message: currentErrors.terms } : undefined
|
|
814
|
+
}
|
|
815
|
+
/>
|
|
816
|
+
</VStack>
|
|
817
|
+
)}
|
|
818
|
+
</LayoutContent>
|
|
819
|
+
}
|
|
820
|
+
footer={
|
|
821
|
+
<LayoutFooter>
|
|
822
|
+
<HStack gap={3} vAlign="center">
|
|
823
|
+
{/* Skip belongs at the far end from the button it competes
|
|
824
|
+
with. Sitting next to Next it reads as a second way forward
|
|
825
|
+
and collects mis-clicks; alone on the opposite edge it
|
|
826
|
+
reads as what it is — the exit from a step you were never
|
|
827
|
+
required to fill in. The zone stays mounted on steps that
|
|
828
|
+
cannot be skipped so the counter does not jump when it
|
|
829
|
+
appears. */}
|
|
830
|
+
<StackItem size="fill" style={footerZone}>
|
|
831
|
+
{STEPS[step].isOptional && <Button label="Skip" variant="ghost" onClick={goSkip} />}
|
|
832
|
+
</StackItem>
|
|
833
|
+
{/* Just the position, at every moment. A blocked step used to
|
|
834
|
+
put a summary here, which was a second copy of a message the
|
|
835
|
+
field was already showing and told you a count rather than
|
|
836
|
+
which field. Now Next moves focus to the offending field
|
|
837
|
+
instead, so the answer arrives where the fix has to happen. */}
|
|
838
|
+
<Text type="supporting" color="secondary">
|
|
839
|
+
{step + 1} of {STEPS.length}
|
|
840
|
+
</Text>
|
|
841
|
+
<StackItem size="fill" style={footerZone}>
|
|
842
|
+
<VStack gap={1} hAlign="end">
|
|
843
|
+
{/* The only control on the page that explains itself while
|
|
844
|
+
disabled: the reason is the first blocker in the same
|
|
845
|
+
`errorsByStep` order the panels and the stepper read. */}
|
|
846
|
+
{submitBlocked && (
|
|
847
|
+
<Text id={blockerHintID} type="body" color="secondary">
|
|
848
|
+
{blockers[0]}
|
|
849
|
+
</Text>
|
|
850
|
+
)}
|
|
851
|
+
<HStack gap={3} hAlign="end" wrap="wrap">
|
|
852
|
+
<Button
|
|
853
|
+
label="Back"
|
|
854
|
+
variant="secondary"
|
|
855
|
+
isDisabled={step === 0}
|
|
856
|
+
onClick={() => goTo(step - 1)}
|
|
857
|
+
/>
|
|
858
|
+
<Button
|
|
859
|
+
label={isLastStep ? "Create workspace" : "Next"}
|
|
860
|
+
variant="primary"
|
|
861
|
+
aria-describedby={submitBlocked ? blockerHintID : undefined}
|
|
862
|
+
// Never disabled before the last step: pressing Next
|
|
863
|
+
// is how a step reveals what is wrong. On the review
|
|
864
|
+
// step the panel lists every issue and the reason sits
|
|
865
|
+
// beside this button, so a dead control there still
|
|
866
|
+
// explains itself.
|
|
867
|
+
isDisabled={isLastStep && (isCreated || blockers.length > 0)}
|
|
868
|
+
onClick={goNext}
|
|
869
|
+
/>
|
|
870
|
+
</HStack>
|
|
871
|
+
</VStack>
|
|
872
|
+
</StackItem>
|
|
873
|
+
</HStack>
|
|
874
|
+
</LayoutFooter>
|
|
875
|
+
}
|
|
876
|
+
/>
|
|
877
|
+
);
|
|
878
|
+
}
|