mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): onboarding step dots, identity/key page restyle, overflow scroll (#1993)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { AlertTriangle, Info, RefreshCw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { getNsec } from "@/shared/api/tauriIdentity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { StepProgress } from "@/shared/ui/step-progress";
|
||||
import {
|
||||
type OnboardingTransitionDirection,
|
||||
OnboardingSlideTransition,
|
||||
@@ -14,51 +13,33 @@ import { NsecMaskedDisplay } from "./NsecMaskedDisplay";
|
||||
/**
|
||||
* Pure helper so the disabled logic can be unit-tested without a DOM.
|
||||
*
|
||||
* Disabled when:
|
||||
* - still loading (key not fetched yet)
|
||||
* - load failed (only the explicit "Skip for now" ghost advances past an error)
|
||||
* - key loaded AND checkbox not yet checked
|
||||
*
|
||||
* Enabled when key is null after a clean (non-error) load — backend returned
|
||||
* nothing, so there is nothing to acknowledge and the user may proceed.
|
||||
* Disabled while loading (key not fetched yet) or after a failed load (only
|
||||
* the explicit "Skip for now" ghost advances past an error).
|
||||
*/
|
||||
export function backupNextDisabled({
|
||||
isLoading,
|
||||
loadError,
|
||||
nsec,
|
||||
hasAcknowledged,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
loadError: string | null;
|
||||
nsec: string | null;
|
||||
hasAcknowledged: boolean;
|
||||
}): boolean {
|
||||
return isLoading || loadError !== null || (nsec !== null && !hasAcknowledged);
|
||||
return isLoading || loadError !== null;
|
||||
}
|
||||
|
||||
type BackupStepProps = {
|
||||
currentStep: number;
|
||||
direction: OnboardingTransitionDirection;
|
||||
totalSteps: number;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Onboarding backup step — shows the user their nsec and requires
|
||||
* acknowledgement before advancing. Only shown on the fresh-key path.
|
||||
* Onboarding backup step — shows the user their freshly created key so they
|
||||
* can save it somewhere safe. Only shown on the fresh-key path.
|
||||
*/
|
||||
export function BackupStep({
|
||||
currentStep,
|
||||
direction,
|
||||
totalSteps,
|
||||
onBack,
|
||||
onNext,
|
||||
}: BackupStepProps) {
|
||||
export function BackupStep({ direction, onBack, onNext }: BackupStepProps) {
|
||||
const [nsec, setNsec] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState<string | null>(null);
|
||||
const [hasAcknowledged, setHasAcknowledged] = React.useState(false);
|
||||
const cancelledRef = React.useRef(false);
|
||||
|
||||
const loadNsec = React.useCallback(async () => {
|
||||
@@ -92,30 +73,29 @@ export function BackupStep({
|
||||
|
||||
return (
|
||||
<OnboardingSlideTransition
|
||||
className="flex w-full flex-col items-center pb-36 lg:pb-0"
|
||||
className="flex w-full flex-col items-center"
|
||||
data-testid="onboarding-page-backup"
|
||||
direction={direction}
|
||||
transitionKey={`backup-${direction}`}
|
||||
>
|
||||
<div className="w-full max-w-[500px] text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
|
||||
Save your private key
|
||||
Your unique identity has been created
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
Buzz generated a Nostr private key for you. This key is stored in your
|
||||
system keychain, but you should also save it somewhere safe in case
|
||||
you ever need to restore your account on another device.
|
||||
<p className="mt-3 text-sm leading-6 text-foreground/80">
|
||||
This key is stored in your system keychain, but save it some place
|
||||
safe in case you ever need to restore your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full max-w-[500px] space-y-4 text-left">
|
||||
<div className="mt-10 w-full max-w-[640px]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border/70 bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-foreground/70">
|
||||
<Spinner className="h-4 w-4 border-2" />
|
||||
Loading your private key…
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className="space-y-3">
|
||||
<div className="mx-auto max-w-[500px] space-y-3 text-left">
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
data-testid="backup-load-error"
|
||||
@@ -140,46 +120,31 @@ export function BackupStep({
|
||||
</Button>
|
||||
</div>
|
||||
) : nsec ? (
|
||||
<NsecMaskedDisplay nsec={nsec} />
|
||||
<div className="rounded-3xl bg-white/85 px-6 py-5 shadow-[0_0_70px_45px_rgba(255,255,255,0.85)]">
|
||||
<NsecMaskedDisplay nsec={nsec} variant="bare" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-center text-sm text-foreground/70">
|
||||
No key available to back up.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{nsec ? (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm leading-5 text-amber-700 dark:text-amber-400">
|
||||
<strong>Never share your private key.</strong> Anyone with it can
|
||||
impersonate you and access everything in your account.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !loadError && nsec ? (
|
||||
<label className="flex cursor-pointer items-start gap-3">
|
||||
<input
|
||||
checked={hasAcknowledged}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-primary"
|
||||
data-testid="backup-acknowledge"
|
||||
onChange={(e) => setHasAcknowledged(e.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="text-sm leading-5">
|
||||
I've saved my private key in a safe place
|
||||
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-foreground/70">
|
||||
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Never share your private key. Anyone can impersonate you and
|
||||
access everything in your account.
|
||||
</span>
|
||||
</label>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3 max-lg:pointer-events-none max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:z-40 max-lg:mt-0 max-lg:max-w-none max-lg:border-t max-lg:border-border max-lg:bg-background max-lg:p-4 max-lg:pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<div className="mt-12 flex flex-col items-center gap-3">
|
||||
<Button
|
||||
className="h-10 w-full max-lg:pointer-events-auto"
|
||||
className="h-10 rounded-full px-8"
|
||||
data-testid="onboarding-next"
|
||||
disabled={backupNextDisabled({
|
||||
isLoading,
|
||||
loadError,
|
||||
nsec,
|
||||
hasAcknowledged,
|
||||
})}
|
||||
disabled={backupNextDisabled({ isLoading, loadError })}
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
>
|
||||
@@ -188,7 +153,7 @@ export function BackupStep({
|
||||
|
||||
{loadError ? (
|
||||
<Button
|
||||
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
|
||||
className="h-9 rounded-full px-5 text-muted-foreground hover:text-accent-foreground"
|
||||
data-testid="backup-skip"
|
||||
onClick={onNext}
|
||||
type="button"
|
||||
@@ -199,7 +164,7 @@ export function BackupStep({
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="h-10 w-full text-muted-foreground hover:text-accent-foreground max-lg:pointer-events-auto"
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
@@ -207,15 +172,6 @@ export function BackupStep({
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<StepProgress
|
||||
activeSegmentClassName="bg-primary"
|
||||
className="mt-1 max-lg:pointer-events-auto lg:hidden"
|
||||
completeSegmentClassName="bg-primary/35"
|
||||
currentStep={currentStep}
|
||||
inactiveSegmentClassName="bg-muted-foreground/25"
|
||||
totalSteps={totalSteps}
|
||||
/>
|
||||
</div>
|
||||
</OnboardingSlideTransition>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BackupStep } from "./BackupStep";
|
||||
import { LandingBees } from "./LandingBees";
|
||||
import { NostrKeyImportForm } from "./NostrKeyImportForm";
|
||||
import { OnboardingSlideTransition } from "./OnboardingSlideTransition";
|
||||
import { OnboardingStepDots } from "./OnboardingStepDots";
|
||||
import { SetupStep } from "./SetupStep";
|
||||
|
||||
type MachinePage = "identity" | "key-import" | "backup" | "setup";
|
||||
@@ -87,14 +88,17 @@ export function MachineOnboardingFlow({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex items-center justify-center px-4 py-8 text-foreground ${
|
||||
page === "identity" ? "buzz-onboarding-welcome" : ""
|
||||
}`}
|
||||
className={`buzz-onboarding-neutral-theme buzz-startup-shell flex max-h-dvh items-start justify-center overflow-y-auto px-4 text-foreground ${
|
||||
page === "setup" ? "py-24" : "py-8"
|
||||
} ${page === "identity" ? "buzz-onboarding-welcome" : ""}`}
|
||||
data-testid="machine-onboarding-gate"
|
||||
>
|
||||
<StartupWindowDragRegion />
|
||||
{page === "identity" ? <LandingBees /> : null}
|
||||
<div className="relative flex w-full max-w-[920px] flex-col items-center text-center">
|
||||
{page !== "identity" ? (
|
||||
<OnboardingStepDots current={page === "setup" ? 3 : 2} />
|
||||
) : null}
|
||||
<div className="relative my-auto flex w-full max-w-[920px] flex-col items-center text-center">
|
||||
{page === "identity" ? (
|
||||
<OnboardingSlideTransition
|
||||
className="flex w-full max-w-[720px] flex-col items-center text-center"
|
||||
@@ -135,17 +139,17 @@ export function MachineOnboardingFlow({
|
||||
</OnboardingSlideTransition>
|
||||
) : page === "key-import" ? (
|
||||
<OnboardingSlideTransition
|
||||
className="flex w-full max-w-[500px] flex-col items-center text-center"
|
||||
className="flex w-full max-w-[640px] flex-col items-center text-center"
|
||||
direction="forward"
|
||||
transitionKey="machine-key-import"
|
||||
>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
{identityLost ? "Re-import your key" : "Use your existing key"}
|
||||
{identityLost ? "Re-import your key" : "Enter your private key"}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
<p className="mt-3 max-w-[440px] text-sm leading-6 text-foreground/80">
|
||||
{identityLost
|
||||
? "Your identity is no longer in the system keyring. Re-import your nsec to restore it."
|
||||
: "Import your Nostr private key to use that identity with Buzz."}
|
||||
: "If you already have a Nostr account, enter your private key below to get started."}
|
||||
</p>
|
||||
<NostrKeyImportForm
|
||||
backLabel={identityLost ? "Start new identity" : "Back"}
|
||||
@@ -155,15 +159,14 @@ export function MachineOnboardingFlow({
|
||||
: () => setPage("identity")
|
||||
}
|
||||
onImport={importExistingIdentity}
|
||||
variant="spotlight"
|
||||
/>
|
||||
</OnboardingSlideTransition>
|
||||
) : page === "backup" ? (
|
||||
<BackupStep
|
||||
currentStep={2}
|
||||
direction="forward"
|
||||
onBack={() => setPage("identity")}
|
||||
onNext={() => setPage("setup")}
|
||||
totalSteps={3}
|
||||
/>
|
||||
) : (
|
||||
<SetupStep
|
||||
|
||||
@@ -15,6 +15,8 @@ type NostrKeyImportFormProps = {
|
||||
errorMessage?: string | null;
|
||||
onBack: () => void;
|
||||
onImport: (nsec: string) => Promise<void>;
|
||||
/** "spotlight" is the first-launch treatment: glowy centered input, no drop zone, pill buttons. */
|
||||
variant?: "default" | "spotlight";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,7 @@ export function NostrKeyImportForm({
|
||||
errorMessage: externalErrorMessage = null,
|
||||
onBack,
|
||||
onImport,
|
||||
variant = "default",
|
||||
}: NostrKeyImportFormProps) {
|
||||
const [nsecInput, setNsecInput] = React.useState("");
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
@@ -114,23 +117,31 @@ export function NostrKeyImportForm({
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5 text-left">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="nostr-private-key"
|
||||
>
|
||||
Private key
|
||||
</label>
|
||||
{variant === "spotlight" ? null : (
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="nostr-private-key"
|
||||
>
|
||||
Private key
|
||||
</label>
|
||||
)}
|
||||
<Input
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="h-10 bg-background"
|
||||
className={
|
||||
variant === "spotlight"
|
||||
? "h-16 rounded-2xl border-0 bg-white/85 text-center font-mono !text-xl shadow-[0_0_70px_45px_rgba(255,255,255,0.85)] placeholder:text-foreground/30 focus-visible:ring-0"
|
||||
: "h-10 bg-background"
|
||||
}
|
||||
data-testid="nostr-import-nsec-input"
|
||||
id="nostr-private-key"
|
||||
onChange={(event) => {
|
||||
setNsecInput(event.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="nsec1..."
|
||||
placeholder={
|
||||
variant === "spotlight" ? "Enter your key here" : "nsec1..."
|
||||
}
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
@@ -138,86 +149,90 @@ export function NostrKeyImportForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
accept=".key,text/plain"
|
||||
className="sr-only"
|
||||
disabled={isInteractionDisabled}
|
||||
onChange={(event) => {
|
||||
void handleFiles(event.currentTarget.files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
{variant === "spotlight" ? null : (
|
||||
<>
|
||||
<input
|
||||
accept=".key,text/plain"
|
||||
className="sr-only"
|
||||
disabled={isInteractionDisabled}
|
||||
onChange={(event) => {
|
||||
void handleFiles(event.currentTarget.files);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
|
||||
isDragging &&
|
||||
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
|
||||
)}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
data-testid="nostr-import-drop"
|
||||
disabled={isInteractionDisabled}
|
||||
onClick={openFilePicker}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isInteractionDisabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
event.currentTarget.contains(event.relatedTarget as Node | null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isInteractionDisabled) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (isInteractionDisabled) {
|
||||
return;
|
||||
}
|
||||
void handleFiles(event.dataTransfer.files);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
|
||||
isDragging && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
<KeyRound
|
||||
className={cn(
|
||||
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
|
||||
isDragging && "text-primary",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
|
||||
isDragging && "text-primary",
|
||||
)}
|
||||
>
|
||||
Drop a key here
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex h-[120px] flex-col items-center justify-center gap-3 overflow-hidden rounded-xl border border-transparent bg-muted text-foreground transition-[background-color,border-color,box-shadow,color] duration-[250ms] ease-out hover:bg-muted/80 disabled:opacity-60",
|
||||
isDragging &&
|
||||
"border-primary bg-primary/10 text-primary ring-1 ring-primary/35 hover:bg-primary/10",
|
||||
)}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
data-testid="nostr-import-drop"
|
||||
disabled={isInteractionDisabled}
|
||||
onClick={openFilePicker}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isInteractionDisabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
event.currentTarget.contains(event.relatedTarget as Node | null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!isInteractionDisabled) {
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsDragging(false);
|
||||
if (isInteractionDisabled) {
|
||||
return;
|
||||
}
|
||||
void handleFiles(event.dataTransfer.files);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-[inherit] bg-primary/10 opacity-0 transition-opacity duration-[250ms] ease-out",
|
||||
isDragging && "opacity-100",
|
||||
)}
|
||||
/>
|
||||
<KeyRound
|
||||
className={cn(
|
||||
"relative h-8 w-8 text-muted-foreground transition-colors duration-[250ms] ease-out",
|
||||
isDragging && "text-primary",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"relative text-sm font-medium text-muted-foreground transition-colors duration-[250ms] ease-out",
|
||||
isDragging && "text-primary",
|
||||
)}
|
||||
>
|
||||
Drop a key here
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{previewNpub ? (
|
||||
<div
|
||||
@@ -246,22 +261,36 @@ export function NostrKeyImportForm({
|
||||
<p className="text-center text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full flex-col gap-3 pt-1">
|
||||
<div
|
||||
className={
|
||||
variant === "spotlight"
|
||||
? "mt-4 flex w-full flex-col items-center gap-3"
|
||||
: "flex w-full flex-col gap-3 pt-1"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="h-10 w-full"
|
||||
className={
|
||||
variant === "spotlight" ? "h-10 rounded-full px-8" : "h-10 w-full"
|
||||
}
|
||||
data-testid="nostr-import-submit"
|
||||
disabled={!isValid || isInteractionDisabled}
|
||||
type="submit"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Spinner aria-label="Importing key" className="h-4 w-4 border-2" />
|
||||
) : variant === "spotlight" ? (
|
||||
"Next"
|
||||
) : (
|
||||
"Continue with this key"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
|
||||
className={
|
||||
variant === "spotlight"
|
||||
? "h-9 rounded-full bg-foreground/10 px-6 hover:bg-foreground/15"
|
||||
: "h-10 w-full text-muted-foreground hover:text-accent-foreground"
|
||||
}
|
||||
disabled={isImporting}
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Button } from "@/shared/ui/button";
|
||||
|
||||
type NsecMaskedDisplayProps = {
|
||||
nsec: string;
|
||||
/** "bare" drops the boxed chrome for the onboarding spotlight treatment. */
|
||||
variant?: "boxed" | "bare";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -14,7 +16,10 @@ type NsecMaskedDisplayProps = {
|
||||
* - select is disabled while masked (user-select: none)
|
||||
* - state is cleared when the component unmounts
|
||||
*/
|
||||
export function NsecMaskedDisplay({ nsec }: NsecMaskedDisplayProps) {
|
||||
export function NsecMaskedDisplay({
|
||||
nsec,
|
||||
variant = "boxed",
|
||||
}: NsecMaskedDisplayProps) {
|
||||
const [isRevealed, setIsRevealed] = React.useState(false);
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
const copyTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -40,10 +45,18 @@ export function NsecMaskedDisplay({ nsec }: NsecMaskedDisplayProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border/70 bg-muted/30">
|
||||
<div
|
||||
className={
|
||||
variant === "boxed"
|
||||
? "overflow-hidden rounded-lg border border-border/70 bg-muted/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
<p
|
||||
className={`min-w-0 flex-1 break-all font-mono text-xs leading-5 ${
|
||||
className={`min-w-0 flex-1 break-all font-mono leading-5 ${
|
||||
variant === "bare" ? "text-base" : "text-xs"
|
||||
} ${
|
||||
isRevealed
|
||||
? "select-text text-foreground"
|
||||
: "select-none text-muted-foreground blur-[4px]"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
||||
|
||||
/**
|
||||
* Positions in the first-launch flow: landing, identity/key, provider setup,
|
||||
* community choice, community profile, meet the team.
|
||||
*/
|
||||
export const TOTAL_ONBOARDING_PAGES = 6;
|
||||
|
||||
/**
|
||||
* Top-left paging indicator for the first-launch flow. The bee marks the
|
||||
* current page on a dot track; shown on every page after the landing screen.
|
||||
*/
|
||||
export function OnboardingStepDots({
|
||||
current,
|
||||
total = TOTAL_ONBOARDING_PAGES,
|
||||
}: {
|
||||
current: number;
|
||||
total?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed left-6 top-12 z-10 flex items-center gap-2.5 text-foreground"
|
||||
data-testid="onboarding-step-dots"
|
||||
>
|
||||
{Array.from({ length: total }, (_, i) => i + 1).map((position) =>
|
||||
position === current ? (
|
||||
<span className="block w-9" key={position}>
|
||||
<BuzzMark className="h-auto w-full" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="block h-[7px] w-[7px] rounded-full bg-foreground"
|
||||
key={position}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,66 +59,21 @@ test("currentStep_falls_back_to_1_for_pages_outside_the_step_list", () => {
|
||||
test("backup_next_disabled_while_loading", () => {
|
||||
// During a slow keychain read, Next must be blocked — user cannot race past
|
||||
// the key display before it is shown.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
isLoading: true,
|
||||
loadError: null,
|
||||
nsec: null,
|
||||
hasAcknowledged: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(backupNextDisabled({ isLoading: true, loadError: null }), true);
|
||||
});
|
||||
|
||||
test("backup_next_disabled_on_load_error", () => {
|
||||
// Error state: only the explicit "Skip for now" ghost advances; Next blocked.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
isLoading: false,
|
||||
loadError: "IPC error",
|
||||
nsec: null,
|
||||
hasAcknowledged: false,
|
||||
}),
|
||||
backupNextDisabled({ isLoading: false, loadError: "IPC error" }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_disabled_when_nsec_loaded_and_not_acknowledged", () => {
|
||||
// Key shown but checkbox unchecked.
|
||||
test("backup_next_enabled_after_clean_load", () => {
|
||||
// Key shown (or backend cleanly returned none) — user may proceed.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
nsec: "nsec1test",
|
||||
hasAcknowledged: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_enabled_when_nsec_loaded_and_acknowledged", () => {
|
||||
// Key shown and checkbox checked — the normal happy path.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
nsec: "nsec1test",
|
||||
hasAcknowledged: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("backup_next_enabled_when_backend_returned_null_key_cleanly", () => {
|
||||
// Backend returned no key without an error (edge case): nothing to acknowledge,
|
||||
// allow forward progress so onboarding is never bricked.
|
||||
assert.equal(
|
||||
backupNextDisabled({
|
||||
isLoading: false,
|
||||
loadError: null,
|
||||
nsec: null,
|
||||
hasAcknowledged: false,
|
||||
}),
|
||||
backupNextDisabled({ isLoading: false, loadError: null }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user