@remit/web-client 0.0.229 → 0.0.230

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.
@@ -0,0 +1,772 @@
1
+ /**
2
+ * The config import screens, one component per surface (#1021 stage 5).
3
+ *
4
+ * Every one of these is a pure function of props: the wizard around them owns
5
+ * the file, the network and the step. That is what lets Storybook render each
6
+ * state — including the ones that only exist when a server refuses something —
7
+ * against the same components the app runs.
8
+ */
9
+
10
+ import {
11
+ Badge,
12
+ Banner,
13
+ Button,
14
+ CheckRow,
15
+ ConnectorTile,
16
+ WizardShell,
17
+ } from "@remit/ui";
18
+ import {
19
+ FileJson,
20
+ FolderClock,
21
+ Inbox,
22
+ KeyRound,
23
+ Loader2,
24
+ Merge,
25
+ Upload,
26
+ X,
27
+ } from "lucide-react";
28
+ import type { DragEvent, ReactNode } from "react";
29
+ import { useId, useRef } from "react";
30
+ import {
31
+ countVerdicts,
32
+ type FailureCopy,
33
+ formatFileSize,
34
+ type PendingFolder,
35
+ type ReportSection,
36
+ type SectionResult,
37
+ verdictTone,
38
+ } from "@/lib/config-import";
39
+
40
+ export const IMPORT_STEPS = ["File", "Review", "Credentials", "Folders"];
41
+
42
+ interface StepNav {
43
+ onBack?: () => void;
44
+ onNext?: () => void;
45
+ }
46
+
47
+ export function RawError({ children }: { children: string }) {
48
+ return (
49
+ <code className="block rounded bg-surface-sunken px-2.5 py-2 text-2xs text-fg-muted">
50
+ {children}
51
+ </code>
52
+ );
53
+ }
54
+
55
+ export interface ChosenFile {
56
+ name: string;
57
+ size: number;
58
+ }
59
+
60
+ /**
61
+ * Where the file has got to. `reading` is the browser pulling it off disk;
62
+ * `checking` is the dry run in flight at the server. They are different waits
63
+ * with different failures, and a control that cannot tell them apart lands the
64
+ * reader on a button that looks ready before there is anything to send.
65
+ */
66
+ export type ChooseFileState = "idle" | "dragging" | "reading" | "checking";
67
+
68
+ export function StepChooseFile({
69
+ state = "idle",
70
+ file,
71
+ ready = false,
72
+ failure,
73
+ onChoose,
74
+ onDragStateChange,
75
+ onNext,
76
+ }: {
77
+ state?: ChooseFileState;
78
+ file?: ChosenFile;
79
+ /**
80
+ * A document has parsed and is there to send. Without it "Check the file"
81
+ * would offer to send what the browser has not finished reading, or what
82
+ * turned out not to be a configuration at all.
83
+ */
84
+ ready?: boolean;
85
+ /** A file that could not even be read as a configuration document. */
86
+ failure?: FailureCopy;
87
+ onChoose?: (file: File) => void;
88
+ onDragStateChange?: (dragging: boolean) => void;
89
+ } & StepNav) {
90
+ const inputId = useId();
91
+ const inputRef = useRef<HTMLInputElement>(null);
92
+ const dragging = state === "dragging";
93
+ const reading = state === "reading";
94
+ const checking = state === "checking";
95
+ const busy = reading || checking;
96
+
97
+ const handleDrop = (event: DragEvent<HTMLDivElement>) => {
98
+ event.preventDefault();
99
+ onDragStateChange?.(false);
100
+ const dropped = event.dataTransfer.files.item(0);
101
+ if (dropped) onChoose?.(dropped);
102
+ };
103
+
104
+ return (
105
+ <WizardShell
106
+ steps={IMPORT_STEPS}
107
+ activeStep={0}
108
+ title="Import a config file"
109
+ subtitle="Accounts, folder roles, rules, senders and appearance — everything except passwords."
110
+ footer={
111
+ <>
112
+ <span className="text-2xs text-fg-subtle">
113
+ Nothing is written until you have seen what will change.
114
+ </span>
115
+ <Button
116
+ variant="primary"
117
+ disabled={busy || !ready}
118
+ icon={
119
+ busy ? <Loader2 className="size-3.5 animate-spin" /> : undefined
120
+ }
121
+ onClick={onNext}
122
+ >
123
+ {reading ? "Reading…" : checking ? "Checking…" : "Check the file"}
124
+ </Button>
125
+ </>
126
+ }
127
+ >
128
+ <div className="space-y-4">
129
+ {failure && (
130
+ <div className="space-y-2">
131
+ <Banner tone="danger">{failure.title}</Banner>
132
+ <p className="text-sm text-fg">{failure.explanation}</p>
133
+ <p className="text-sm text-fg-muted">{failure.fix}</p>
134
+ <RawError>{failure.raw}</RawError>
135
+ </div>
136
+ )}
137
+ {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: the drop target's keyboard and pointer route is the Choose file control inside it; the drag handlers add a second way in, never the only one */}
138
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: same — dropping is an alternative to a control that is already focusable and labelled */}
139
+ <div
140
+ onDragOver={(event) => {
141
+ event.preventDefault();
142
+ onDragStateChange?.(true);
143
+ }}
144
+ onDragLeave={() => onDragStateChange?.(false)}
145
+ onDrop={handleDrop}
146
+ className={
147
+ dragging
148
+ ? "flex flex-col items-center gap-3 rounded-sm border-2 border-dashed border-accent bg-accent-soft px-4 py-9 text-center"
149
+ : "flex flex-col items-center gap-3 rounded-sm border-2 border-dashed border-line px-4 py-9 text-center"
150
+ }
151
+ >
152
+ <span className="flex size-11 items-center justify-center rounded-md bg-surface-sunken">
153
+ <Upload className="size-5 text-fg-muted" />
154
+ </span>
155
+ <div>
156
+ <p className="text-sm font-medium text-fg">
157
+ {dragging
158
+ ? "Drop it here"
159
+ : "Drop a config file here, or choose one"}
160
+ </p>
161
+ <p className="mt-1 text-xs text-fg-subtle">
162
+ A .json file written by Reader — up to 2 MB.
163
+ </p>
164
+ </div>
165
+ <input
166
+ ref={inputRef}
167
+ id={inputId}
168
+ type="file"
169
+ accept="application/json,.json"
170
+ className="sr-only"
171
+ onChange={(event) => {
172
+ const picked = event.target.files?.item(0);
173
+ if (picked) onChoose?.(picked);
174
+ }}
175
+ />
176
+ <Button
177
+ variant="secondary"
178
+ size="sm"
179
+ onClick={() => inputRef.current?.click()}
180
+ >
181
+ Choose file
182
+ </Button>
183
+ </div>
184
+ {file && (
185
+ <div className="flex items-start gap-3 rounded-sm border border-line px-3 py-2.5">
186
+ <FileJson className="mt-0.5 size-4 shrink-0 text-fg-muted" />
187
+ <div className="min-w-0">
188
+ <p className="truncate text-sm text-fg">{file.name}</p>
189
+ <p className="mt-0.5 text-xs text-fg-subtle">
190
+ {formatFileSize(file.size)} ·{" "}
191
+ {reading
192
+ ? "reading it from disk"
193
+ : checking
194
+ ? "checking it against this instance"
195
+ : ready
196
+ ? "ready to check — nothing has been sent yet"
197
+ : "not a configuration this instance can read"}
198
+ </p>
199
+ </div>
200
+ </div>
201
+ )}
202
+ <p className="text-2xs text-fg-subtle">
203
+ Export one from Settings → Advanced on the instance you are moving
204
+ from, or run{" "}
205
+ <code className="rounded bg-surface-sunken px-1 py-0.5">
206
+ remit config save
207
+ </code>{" "}
208
+ on its host.
209
+ </p>
210
+ </div>
211
+ </WizardShell>
212
+ );
213
+ }
214
+
215
+ function VerdictSummary({ sections }: { sections: ReportSection[] }) {
216
+ const counts = countVerdicts(
217
+ sections.flatMap((section) =>
218
+ section.entries.map((entry) => ({
219
+ section: section.id,
220
+ key: entry.label,
221
+ verdict: entry.verdict,
222
+ })),
223
+ ),
224
+ );
225
+ return (
226
+ <div className="flex flex-wrap gap-1.5">
227
+ {(["created", "updated", "unchanged", "skipped", "rejected"] as const)
228
+ .filter((verdict) => counts[verdict] > 0)
229
+ .map((verdict) => (
230
+ <Badge key={verdict} tone={verdictTone[verdict]} dot>
231
+ {counts[verdict]} {verdict}
232
+ </Badge>
233
+ ))}
234
+ </div>
235
+ );
236
+ }
237
+
238
+ function SectionReport({ section }: { section: ReportSection }) {
239
+ return (
240
+ <section>
241
+ <h2 className="text-sm font-semibold text-fg">{section.title}</h2>
242
+ <ul className="mt-1.5 divide-y divide-line">
243
+ {section.entries.map((entry) => (
244
+ <li key={entry.id} className="flex items-start gap-3 py-2">
245
+ <Badge
246
+ tone={verdictTone[entry.verdict]}
247
+ className="mt-0.5 shrink-0"
248
+ >
249
+ {entry.verdict}
250
+ </Badge>
251
+ <div className="min-w-0">
252
+ <p className="text-sm text-fg">{entry.label}</p>
253
+ {entry.reason && (
254
+ <p
255
+ className={
256
+ entry.verdict === "rejected"
257
+ ? "mt-1 text-xs text-danger"
258
+ : "mt-1 text-xs text-warning"
259
+ }
260
+ >
261
+ {entry.reason}
262
+ </p>
263
+ )}
264
+ </div>
265
+ </li>
266
+ ))}
267
+ </ul>
268
+ </section>
269
+ );
270
+ }
271
+
272
+ export function StepDryRunReport({
273
+ sections,
274
+ fileName,
275
+ warnings = [],
276
+ applying = false,
277
+ onBack,
278
+ onNext,
279
+ }: {
280
+ sections: ReportSection[];
281
+ fileName: string;
282
+ /** Non-blocking notes worth reading before the write, e.g. a missing folder. */
283
+ warnings?: string[];
284
+ applying?: boolean;
285
+ } & StepNav) {
286
+ const counts = countVerdicts(
287
+ sections.flatMap((section) =>
288
+ section.entries.map((entry) => ({
289
+ section: section.id,
290
+ key: entry.label,
291
+ verdict: entry.verdict,
292
+ })),
293
+ ),
294
+ );
295
+ const willWrite = counts.created + counts.updated;
296
+ const held = counts.skipped + counts.rejected;
297
+ return (
298
+ <WizardShell
299
+ steps={IMPORT_STEPS}
300
+ activeStep={1}
301
+ title="What this file will change"
302
+ subtitle={`${fileName} · nothing has been written yet.`}
303
+ footer={
304
+ <>
305
+ <Button variant="ghost" onClick={onBack}>
306
+ Choose another file
307
+ </Button>
308
+ <Button
309
+ variant="primary"
310
+ disabled={applying}
311
+ icon={
312
+ applying ? (
313
+ <Loader2 className="size-3.5 animate-spin" />
314
+ ) : undefined
315
+ }
316
+ onClick={onNext}
317
+ >
318
+ {applying ? "Importing…" : `Import ${willWrite} changes`}
319
+ </Button>
320
+ </>
321
+ }
322
+ >
323
+ <div className="space-y-5">
324
+ <VerdictSummary sections={sections} />
325
+ {held > 0 && (
326
+ <Banner tone="warning">
327
+ {held} entries will not be imported. Each says why below — read them
328
+ before you continue, because nothing here retries on its own.
329
+ </Banner>
330
+ )}
331
+ {warnings.length > 0 && (
332
+ <ul className="space-y-1 text-xs text-warning">
333
+ {warnings.map((warning) => (
334
+ <li key={warning}>{warning}</li>
335
+ ))}
336
+ </ul>
337
+ )}
338
+ {sections.map((section) => (
339
+ <SectionReport key={section.id} section={section} />
340
+ ))}
341
+ </div>
342
+ </WizardShell>
343
+ );
344
+ }
345
+
346
+ export function StepInstanceAlreadyConfigured({
347
+ choice,
348
+ held,
349
+ onChoose,
350
+ onBack,
351
+ onNext,
352
+ }: {
353
+ choice?: "abort" | "merge";
354
+ /** What the instance already holds, as the 409 counted it. */
355
+ held: string;
356
+ onChoose?: (choice: "abort" | "merge") => void;
357
+ } & StepNav) {
358
+ return (
359
+ <WizardShell
360
+ steps={IMPORT_STEPS}
361
+ activeStep={1}
362
+ title="This instance already has configuration"
363
+ subtitle={held}
364
+ footer={
365
+ <>
366
+ <Button variant="ghost" onClick={onBack}>
367
+ Choose another file
368
+ </Button>
369
+ <Button variant="primary" disabled={!choice} onClick={onNext}>
370
+ {choice === "merge" ? "Continue with merge" : "Continue"}
371
+ </Button>
372
+ </>
373
+ }
374
+ >
375
+ <div className="space-y-4">
376
+ <Banner tone="warning">
377
+ An import into a configured instance can only add and update — it
378
+ never deletes what is here. Pick how the two should meet.
379
+ </Banner>
380
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
381
+ <ConnectorTile
382
+ name="Stop, keep this instance"
383
+ description="Nothing is written. Use this if you meant to import into a fresh instance."
384
+ icon={<X className="size-5" />}
385
+ selected={choice === "abort"}
386
+ onSelect={() => onChoose?.("abort")}
387
+ />
388
+ <ConnectorTile
389
+ name="Merge the file in"
390
+ description="Adds what is missing and updates what differs. Existing accounts keep their server settings."
391
+ icon={<Merge className="size-5" />}
392
+ selected={choice === "merge"}
393
+ onSelect={() => onChoose?.("merge")}
394
+ />
395
+ </div>
396
+ {choice === "merge" && (
397
+ <p className="text-xs text-fg-muted">
398
+ The next screen lists every add and update before anything is
399
+ written.
400
+ </p>
401
+ )}
402
+ </div>
403
+ </WizardShell>
404
+ );
405
+ }
406
+
407
+ export type CredentialState = "needed" | "entered" | "failed";
408
+
409
+ export interface ImportedAccount {
410
+ accountId: string;
411
+ address: string;
412
+ displayName: string;
413
+ connector: "imap" | "microsoft";
414
+ server: string;
415
+ state: CredentialState;
416
+ /** What went wrong on the last attempt, and the likely fix. */
417
+ failure?: string;
418
+ }
419
+
420
+ function credentialBadge(state: CredentialState): ReactNode {
421
+ if (state === "entered") return <Badge tone="positive">ready</Badge>;
422
+ if (state === "failed") return <Badge tone="danger">sign-in failed</Badge>;
423
+ return <Badge tone="danger">credentials needed</Badge>;
424
+ }
425
+
426
+ export function AccountCredentialsList({
427
+ accounts,
428
+ busyAccountId,
429
+ onOpen,
430
+ }: {
431
+ accounts: ImportedAccount[];
432
+ /** The account whose OAuth redirect is in flight. */
433
+ busyAccountId?: string;
434
+ onOpen?: (account: ImportedAccount) => void;
435
+ }) {
436
+ return (
437
+ <ul className="divide-y divide-line">
438
+ {accounts.map((account) => (
439
+ <li
440
+ key={account.accountId}
441
+ className={
442
+ account.state === "entered"
443
+ ? "flex flex-wrap items-start justify-between gap-3 py-3"
444
+ : "flex flex-wrap items-start justify-between gap-3 border-l-2 border-danger bg-danger-soft py-3 pl-3"
445
+ }
446
+ >
447
+ <div className="min-w-0">
448
+ <div className="flex flex-wrap items-center gap-2">
449
+ <span className="text-sm font-medium text-fg">
450
+ {account.address}
451
+ </span>
452
+ {credentialBadge(account.state)}
453
+ </div>
454
+ <p className="mt-0.5 text-xs text-fg-subtle">
455
+ {account.displayName} ·{" "}
456
+ {account.connector === "microsoft"
457
+ ? "Microsoft 365"
458
+ : "IMAP / SMTP"}{" "}
459
+ · {account.server}
460
+ </p>
461
+ {account.state !== "entered" && (
462
+ <p className="mt-1 text-xs text-danger">
463
+ {account.failure ??
464
+ (account.connector === "microsoft"
465
+ ? "Config files never carry OAuth tokens. Reconnect with Microsoft to start syncing this account."
466
+ : "Config files never carry passwords. Enter this mailbox's password to start syncing.")}
467
+ </p>
468
+ )}
469
+ </div>
470
+ {account.state !== "entered" && (
471
+ <Button
472
+ variant="primary"
473
+ size="sm"
474
+ disabled={busyAccountId === account.accountId}
475
+ icon={
476
+ busyAccountId === account.accountId ? (
477
+ <Loader2 className="size-3.5 animate-spin" />
478
+ ) : account.connector === "microsoft" ? (
479
+ <Inbox className="size-3.5" />
480
+ ) : (
481
+ <KeyRound className="size-3.5" />
482
+ )
483
+ }
484
+ onClick={() => onOpen?.(account)}
485
+ >
486
+ {busyAccountId === account.accountId
487
+ ? "Redirecting…"
488
+ : account.connector === "microsoft"
489
+ ? "Reconnect"
490
+ : account.state === "failed"
491
+ ? "Try again"
492
+ : "Enter password"}
493
+ </Button>
494
+ )}
495
+ </li>
496
+ ))}
497
+ </ul>
498
+ );
499
+ }
500
+
501
+ export function StepCredentialsOverview({
502
+ accounts,
503
+ busyAccountId,
504
+ error,
505
+ onOpen,
506
+ onNext,
507
+ }: {
508
+ accounts: ImportedAccount[];
509
+ busyAccountId?: string;
510
+ error?: string;
511
+ onOpen?: (account: ImportedAccount) => void;
512
+ } & StepNav) {
513
+ const outstanding = accounts.filter(
514
+ (account) => account.state !== "entered",
515
+ ).length;
516
+ return (
517
+ <WizardShell
518
+ steps={IMPORT_STEPS}
519
+ activeStep={2}
520
+ title="These accounts need credentials"
521
+ subtitle="The config file carries servers and settings. Passwords and Microsoft sign-ins it deliberately leaves out."
522
+ footer={
523
+ <>
524
+ <span className="text-2xs text-fg-subtle">
525
+ {outstanding > 0
526
+ ? "Settings › Accounts keeps asking until every account is signed in."
527
+ : "Every account can sync."}
528
+ </span>
529
+ <Button variant="primary" onClick={onNext}>
530
+ {outstanding > 0 ? "Finish later" : "All accounts connected"}
531
+ </Button>
532
+ </>
533
+ }
534
+ >
535
+ <div className="space-y-4">
536
+ {error && <Banner tone="danger">{error}</Banner>}
537
+ {outstanding > 0 && (
538
+ <Banner tone="danger">
539
+ {outstanding} of {accounts.length} accounts cannot sync yet. They
540
+ stay listed in Settings, marked like this, until you sign in — no
541
+ mail arrives for them in the meantime.
542
+ </Banner>
543
+ )}
544
+ <AccountCredentialsList
545
+ accounts={accounts}
546
+ busyAccountId={busyAccountId}
547
+ onOpen={onOpen}
548
+ />
549
+ </div>
550
+ </WizardShell>
551
+ );
552
+ }
553
+
554
+ export function StepAccountReconnectMicrosoft({
555
+ account,
556
+ position,
557
+ total,
558
+ error,
559
+ busy = false,
560
+ onBack,
561
+ onNext,
562
+ }: {
563
+ account: ImportedAccount;
564
+ position: number;
565
+ total: number;
566
+ error?: string;
567
+ busy?: boolean;
568
+ } & StepNav) {
569
+ return (
570
+ <WizardShell
571
+ steps={IMPORT_STEPS}
572
+ activeStep={2}
573
+ title={`Reconnect ${account.address}`}
574
+ subtitle={`Account ${position} of ${total} · Microsoft 365`}
575
+ footer={
576
+ <>
577
+ <Button variant="ghost" onClick={onBack}>
578
+ Back
579
+ </Button>
580
+ <Button
581
+ variant="primary"
582
+ disabled={busy}
583
+ icon={
584
+ busy ? <Loader2 className="size-3.5 animate-spin" /> : undefined
585
+ }
586
+ onClick={onNext}
587
+ >
588
+ {busy ? "Redirecting…" : "Sign in with Microsoft"}
589
+ </Button>
590
+ </>
591
+ }
592
+ >
593
+ <div className="space-y-4">
594
+ {error && <Banner tone="danger">{error}</Banner>}
595
+ <Banner tone="info">
596
+ A config file never carries an OAuth token — one only works for the
597
+ instance it was granted to. Signing in here mints a fresh one for this
598
+ instance.
599
+ </Banner>
600
+ <div className="rounded-sm border border-line px-3 py-2.5">
601
+ <p className="text-sm text-fg">{account.displayName}</p>
602
+ <p className="mt-0.5 text-xs text-fg-subtle">
603
+ {account.address} · {account.server}
604
+ </p>
605
+ </div>
606
+ <p className="text-2xs text-fg-subtle">
607
+ You will be sent to Microsoft and returned here. If your organisation
608
+ requires admin approval, the sign-in stops there and this account
609
+ keeps waiting.
610
+ </p>
611
+ </div>
612
+ </WizardShell>
613
+ );
614
+ }
615
+
616
+ export function StepPendingFolders({
617
+ folders,
618
+ onNext,
619
+ }: { folders: PendingFolder[] } & StepNav) {
620
+ return (
621
+ <WizardShell
622
+ steps={IMPORT_STEPS}
623
+ activeStep={3}
624
+ title={folders.length > 0 ? "Waiting on folders" : "Import finished"}
625
+ subtitle={
626
+ folders.length > 0
627
+ ? "The config names folders this server has not shown us yet."
628
+ : "Everything in the file landed, and every folder it named resolved."
629
+ }
630
+ footer={
631
+ <>
632
+ <span className="text-2xs text-fg-subtle">
633
+ Folder discovery runs with every sync.
634
+ </span>
635
+ <Button variant="primary" onClick={onNext}>
636
+ Go to inbox
637
+ </Button>
638
+ </>
639
+ }
640
+ >
641
+ <div className="space-y-4">
642
+ {folders.length > 0 ? (
643
+ <>
644
+ <Banner tone="warning">
645
+ {folders.length} settings are imported but switched off. Each
646
+ turns itself on the moment its folder appears — nothing was
647
+ dropped. If a folder never appears, create it in your mail client,
648
+ or edit the setting in Settings → Senders & Rules.
649
+ </Banner>
650
+ <div className="flex items-center gap-2 text-xs text-fg-muted">
651
+ <FolderClock className="size-4" />
652
+ Folder discovery runs with every sync of the account.
653
+ </div>
654
+ <div className="divide-y divide-line">
655
+ {folders.map((folder) => (
656
+ <CheckRow
657
+ key={`${folder.accountId ?? ""}-${folder.path}-${folder.waitingFor}`}
658
+ label={folder.path}
659
+ detail={folder.waitingFor}
660
+ state="pending"
661
+ />
662
+ ))}
663
+ </div>
664
+ </>
665
+ ) : (
666
+ <Banner tone="success">
667
+ Every account, rule, label and sender in the file is live on this
668
+ instance.
669
+ </Banner>
670
+ )}
671
+ </div>
672
+ </WizardShell>
673
+ );
674
+ }
675
+
676
+ export function StepFileRejected({
677
+ failure,
678
+ onBack,
679
+ }: { failure: FailureCopy } & StepNav) {
680
+ return (
681
+ <WizardShell
682
+ steps={IMPORT_STEPS}
683
+ activeStep={0}
684
+ title={failure.title}
685
+ subtitle="Nothing was imported."
686
+ footer={
687
+ <>
688
+ <span className="text-2xs text-fg-subtle">
689
+ This instance is unchanged.
690
+ </span>
691
+ <Button variant="primary" onClick={onBack}>
692
+ Choose another file
693
+ </Button>
694
+ </>
695
+ }
696
+ >
697
+ <div className="space-y-3">
698
+ <Banner tone="danger">{failure.explanation}</Banner>
699
+ <p className="text-sm text-fg">{failure.fix}</p>
700
+ <RawError>{failure.raw}</RawError>
701
+ </div>
702
+ </WizardShell>
703
+ );
704
+ }
705
+
706
+ /**
707
+ * A section nobody can vouch for reads as failed, never as pending. Pending
708
+ * says "still coming"; this section is finished and its outcome is unknown,
709
+ * which is the row the reader has to go and check for themselves.
710
+ */
711
+ function resultState(state: SectionResult["state"]) {
712
+ if (state === "landed") return "ok" as const;
713
+ if (state === "failed" || state === "unknown") return "failed" as const;
714
+ return "pending" as const;
715
+ }
716
+
717
+ export function StepPartialImport({
718
+ results,
719
+ message,
720
+ raw,
721
+ onBack,
722
+ onNext,
723
+ }: {
724
+ results: SectionResult[];
725
+ /** The server's own account of what survived, which only it knows. */
726
+ message: string;
727
+ raw: string;
728
+ } & StepNav) {
729
+ const landed = results.filter((result) => result.state === "landed").length;
730
+ const unknown = results.some((result) => result.state === "unknown");
731
+ return (
732
+ <WizardShell
733
+ steps={IMPORT_STEPS}
734
+ activeStep={1}
735
+ title="The import stopped part-way"
736
+ subtitle={
737
+ unknown
738
+ ? "It did not say how far it got, so check Settings before importing again."
739
+ : `${landed} of ${results.length} sections landed.`
740
+ }
741
+ footer={
742
+ <>
743
+ <Button variant="ghost" onClick={onBack}>
744
+ Choose another file
745
+ </Button>
746
+ <Button variant="primary" onClick={onNext}>
747
+ Retry the rest
748
+ </Button>
749
+ </>
750
+ }
751
+ >
752
+ <div className="space-y-4">
753
+ <Banner tone="danger">{message}</Banner>
754
+ <p className="text-sm text-fg-muted">
755
+ Retrying is safe: an import folds into what is already here rather
756
+ than replacing it, so what landed is recognised and left alone.
757
+ </p>
758
+ <div className="divide-y divide-line">
759
+ {results.map((result) => (
760
+ <CheckRow
761
+ key={result.section}
762
+ label={result.title}
763
+ detail={result.detail}
764
+ state={resultState(result.state)}
765
+ />
766
+ ))}
767
+ </div>
768
+ <RawError>{raw}</RawError>
769
+ </div>
770
+ </WizardShell>
771
+ );
772
+ }