Files
eranos/src/components/OnboardingGate.tsx
T
Goblin f131198feb
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Eranos: Grin-only fundraising (rebrand + Grin payments + gold)
Rebrand Agora to Eranos and strip the non-Grin rails. Add Grin donations:
a GoblinPay client + GrinPayDialog, on-chain payment-proof verification
(receiver-sig + kernel-on-chain + dedupe), and a proof-verified campaign
tally (kind 3414). Shift the brand from orange to gold. 118 tests green.
2026-07-02 08:12:51 -04:00

775 lines
27 KiB
TypeScript

import {
useCallback,
useMemo,
useState,
type ReactNode,
} from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
ArrowRight,
BadgeCheck,
Bitcoin,
Download,
Eye,
EyeOff,
HandCoins,
Link2,
Loader2,
Megaphone,
User,
X,
} from 'lucide-react';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { VerifierIdentityStep } from '@/components/onboarding/VerifierIdentityStep';
import { VerifierBioStep } from '@/components/onboarding/VerifierBioStep';
import { VerifierStatementEditor } from '@/components/organizations/VerifierStatementEditor';
import { VerifyTutorial } from '@/components/organizations/VerifyTutorial';
import { usePublishOrgProfile } from '@/hooks/usePublishOrgProfile';
import { useSetVerifierStatement } from '@/hooks/useVerifierStatement';
import { emptyProfileDraft, type ProfileDraft } from '@/lib/profileDraft';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useOnboarding, type OnboardingRole } from '@/contexts/onboardingContextDef';
import { useToast } from '@/hooks/useToast';
import { downloadTextFile } from '@/lib/downloadFile';
import { cn } from '@/lib/utils';
/**
* Step state machine for the captive signup flow.
*
* Base order (creator / donor):
* keygen → secure → role
*
* Picking the *verifier* role doesn't navigate away — it branches into a
* captive sub-flow that continues from the role step:
* role → orgIdentity → orgBio → orgStatement → orgVerifyHowto
*
* 1. orgIdentity — banner, avatar, org name, website (kind-0 identity)
* 2. orgBio — the organization's bio (kind-0 about)
* 3. orgStatement — publish the verifier statement (kind 14672)
* 4. orgVerifyHowto— teach the verify gesture, then "View Campaigns"
*
* The old flow had a separate "wallet-coupling explainer" step and a
* separate "outro" celebration screen; both were folded in. The coupling
* explainer was redundant with `secure` (both screens are about the key), so
* the secure step now carries the "this key is your account AND your wallet"
* framing inline. For creator/donor the role pick *is* the outro.
*
* Login is handled by the existing `AuthDialog` modal — the captive flow is
* only ever opened by an explicit `startSignup()` call (e.g. from
* AuthDialog's "Create a new Nostr account" button), so the user has
* already picked "signup" by the time we mount.
*/
type Step =
| 'keygen'
| 'secure'
| 'role'
| 'orgIdentity'
| 'orgBio'
| 'orgStatement'
| 'orgVerifyHowto';
/** Base steps that count toward the progress bar for creator/donor. */
const SIGNUP_STEPS: Step[] = ['keygen', 'secure', 'role'];
/**
* Steps that count toward the progress bar once the user has chosen the
* verifier role. The role step is shared with the base flow, then the four
* verifier sub-flow steps extend it.
*/
const VERIFIER_STEPS: Step[] = [
'keygen',
'secure',
'role',
'orgIdentity',
'orgBio',
'orgStatement',
'orgVerifyHowto',
];
/** Ordered verifier sub-flow steps, used for sequential next/back nav. */
const VERIFIER_SUBFLOW: Step[] = [
'orgIdentity',
'orgBio',
'orgStatement',
'orgVerifyHowto',
];
/**
* The captive onboarding gate. Render this as a sibling of `<AppRouter />`;
* it renders nothing when inactive and a fullscreen `fixed inset-0 z-50`
* overlay when `useOnboarding().active === true`.
*
* The flow guides a brand-new user through:
* 1. Key generation
* 2. Save the nsec (with inline wallet-coupling framing)
* 3. Role pick — primary CTA navigates by intent: creator → /campaigns/new,
* donor → / (campaign grid)
*
* The overlay sits above all app chrome and cannot be dismissed by clicking
* outside; users must either complete the flow or use the explicit Close (X)
* button in the top-right corner.
*/
export function OnboardingGate({ children }: { children: ReactNode }) {
const { active } = useOnboarding();
return (
<>
{children}
{active && <CaptiveOverlay />}
</>
);
}
/** Inner overlay component — only mounted while the flow is active so the
* per-flow state resets cleanly between sessions. */
function CaptiveOverlay() {
const { t } = useTranslation();
const { cancel, role: contextRole, setRole: setContextRole } = useOnboarding();
const navigate = useNavigate();
const { toast } = useToast();
const login = useLoginActions();
const { user } = useCurrentUser();
// Decide the entry step.
// - Already-authenticated users normally land on `role` directly.
const initialStep: Step = useMemo(() => {
if (user) return 'role';
return 'keygen';
}, [user]);
const [step, setStep] = useState<Step>(initialStep);
// Signup state
const [nsec, setNsec] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [showKey, setShowKey] = useState(false);
// Verifier sub-flow: the organization's kind-0 profile draft, accumulated
// across the identity + bio steps and published once at the end. Held here
// so back-navigation between sub-flow steps preserves what's entered.
const [orgDraft, setOrgDraft] = useState<ProfileDraft>(emptyProfileDraft);
const patchOrgDraft = useCallback(
(patch: Partial<ProfileDraft>) =>
setOrgDraft((prev) => ({ ...prev, ...patch })),
[],
);
// Pubkey of the key generated in this captive flow, if any. Used as the
// `expectedPubkey` guard when publishing the org profile so a failed
// auto-login can't overwrite a different account's kind-0. Empty when the
// user was already authenticated on entry (no guard needed then).
const signupPubkey = useMemo(() => {
if (!nsec) return undefined;
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') return undefined;
return getPublicKey(decoded.data);
} catch {
return undefined;
}
}, [nsec]);
const { mutateAsync: publishOrgProfile, isPending: isPublishingOrg } =
usePublishOrgProfile();
// Linear progress bar position. Once the user has chosen the verifier
// role, the bar tracks the extended verifier step list so the four
// sub-flow screens are reflected; otherwise the base three-step list is
// used (creator/donor progress math is unaffected).
const isVerifierFlow =
contextRole === 'verifier' || VERIFIER_SUBFLOW.includes(step);
const progressSteps = isVerifierFlow ? VERIFIER_STEPS : SIGNUP_STEPS;
const currentProgressIndex = progressSteps.indexOf(step);
const progress = currentProgressIndex < 0
? 0
: ((currentProgressIndex + 1) / progressSteps.length) * 100;
// Navigation helpers ------------------------------------------------------
const goTo = useCallback((target: Step) => {
setStep(target);
}, []);
const showBackButton = !(step === 'keygen' && isGenerating);
const handleBack = useCallback(() => {
if (step === 'keygen') {
cancel();
} else if (step === 'secure') {
goTo('keygen');
} else if (VERIFIER_SUBFLOW.includes(step)) {
// Within the verifier sub-flow: step back one screen, or back to the
// role picker from the first sub-flow step.
const idx = VERIFIER_SUBFLOW.indexOf(step);
goTo(idx <= 0 ? 'role' : VERIFIER_SUBFLOW[idx - 1]);
} else {
// role step
if (user) cancel();
else goTo('secure');
}
}, [step, user, cancel, goTo]);
// Advance one screen within the verifier sub-flow. The first call (from
// the role pick) enters at `orgIdentity`; subsequent calls walk the list.
const goNextVerifierStep = useCallback(() => {
const idx = VERIFIER_SUBFLOW.indexOf(step);
if (idx < 0) {
goTo(VERIFIER_SUBFLOW[0]);
} else if (idx < VERIFIER_SUBFLOW.length - 1) {
goTo(VERIFIER_SUBFLOW[idx + 1]);
}
}, [step, goTo]);
// Role pick. For creator/donor this is the final step: it records the
// choice and navigates to the matching surface (creator → /campaigns/new,
// donor → /campaigns). The verifier role does NOT navigate away — it
// records the role and enters the captive verifier sub-flow, which
// finishes on its own terms ("View Campaigns").
const handleRolePick = useCallback(
(next: 'creator' | 'donor' | 'verifier') => {
setContextRole(next);
if (next === 'verifier') {
goTo('orgIdentity');
return;
}
cancel();
if (next === 'creator') {
navigate('/campaigns/new');
} else {
navigate('/campaigns');
}
},
[setContextRole, cancel, navigate, goTo],
);
// Terminal CTA for the verifier sub-flow — drop the new verifier on the
// campaign grid so they can immediately start vouching.
const handleVerifierFinish = useCallback(() => {
cancel();
navigate('/campaigns');
}, [cancel, navigate]);
// Leaving the bio step: publish the assembled kind-0 org profile, then
// advance to the statement step. Publishing is best-effort — a failure
// surfaces a non-fatal toast and the user still proceeds (they can fix the
// profile later from settings), mirroring the InitialSyncGate behavior.
const handleBioContinue = useCallback(async () => {
try {
await publishOrgProfile({ draft: orgDraft, expectedPubkey: signupPubkey });
} catch {
toast({
title: t('onboarding.verifier.publishFailedTitle'),
description: t('onboarding.verifier.publishFailedDescription'),
variant: 'destructive',
});
}
goNextVerifierStep();
}, [publishOrgProfile, orgDraft, signupPubkey, toast, t, goNextVerifierStep]);
// Key generation ----------------------------------------------------------
const handleGenerateKey = useCallback(() => {
setIsGenerating(true);
// Brief visible spinner — the generation itself is instantaneous, but
// an instant transition feels too "did anything happen?" given the
// weight of what just got created.
setTimeout(() => {
const sk = generateSecretKey();
setNsec(nip19.nsecEncode(sk));
setIsGenerating(false);
goTo('secure');
}, 700);
}, [goTo]);
// Download + install nsec into the login store ---------------------------
const handleDownloadAndContinue = useCallback(async () => {
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== 'nsec') throw new Error('Invalid nsec');
const pubkey = getPublicKey(decoded.data);
const npub = nip19.npubEncode(pubkey);
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
await downloadTextFile(filename, nsec);
login.nsec(nsec);
goTo('role');
} catch {
toast({
title: t('onboarding.secure.downloadFailedTitle'),
description: t('onboarding.secure.downloadFailedDescription'),
variant: 'destructive',
});
}
}, [nsec, login, goTo, toast, t]);
// Step renderer -----------------------------------------------------------
const stepBody = (() => {
switch (step) {
case 'keygen':
// First step. Back closes the captive flow entirely — the user got
// here from the AuthDialog and already chose "signup".
return (
<KeygenStep
isGenerating={isGenerating}
onGenerate={handleGenerateKey}
/>
);
case 'secure':
return (
<SecureStep
nsec={nsec}
showKey={showKey}
onToggleShow={() => setShowKey((v) => !v)}
onContinue={handleDownloadAndContinue}
/>
);
case 'role':
// Final step. Picking a role navigates to the matching surface
// (creator → /campaigns/new, donor → /); Back goes to secure if the
// user signed up through the full flow, or cancels the overlay if
// they were already-authenticated and landed here directly.
return (
<RoleStep
role={contextRole}
onPick={handleRolePick}
/>
);
case 'orgIdentity':
// Verifier sub-flow step 1 — organization identity (kind-0).
return (
<VerifierIdentityStep
draft={orgDraft}
onChange={patchOrgDraft}
onContinue={goNextVerifierStep}
/>
);
case 'orgBio':
// Verifier sub-flow step 2 — organization bio (kind-0 about).
return (
<VerifierBioStep
draft={orgDraft}
onChange={patchOrgDraft}
onContinue={handleBioContinue}
isPublishing={isPublishingOrg}
/>
);
case 'orgStatement':
// Verifier sub-flow step 3 — publish the verifier statement
// (kind 14672), reusing the shared editor.
return (
<VerifierStatementStep
onContinue={goNextVerifierStep}
/>
);
case 'orgVerifyHowto':
// Verifier sub-flow step 4 — teach the verify gesture, then finish.
return (
<VerifierHowtoStep draft={orgDraft} onFinish={handleVerifierFinish} />
);
}
})();
return (
<div
className="fixed inset-0 z-50 bg-background overflow-y-auto flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t('onboarding.ariaLabel')}
>
{/* Progress bar — every step in the flow counts toward it. */}
<div className="sticky top-0 z-10 h-1 bg-muted">
<div
className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{showBackButton && (
<button
type="button"
onClick={handleBack}
aria-label={t('common.back')}
className="absolute left-4 top-4 z-20 sm:left-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<ArrowLeft className="h-5 w-5 rtl:rotate-180" />
</button>
)}
{/* Top-right close. Lets users escape if they truly don't want to
continue — but it's deliberately unobtrusive vs. a backdrop click
so casual taps don't drop them out of the flow. */}
<button
type="button"
onClick={cancel}
aria-label={t('onboarding.close')}
className="absolute right-4 top-4 z-20 sm:right-6 sm:top-6 inline-flex h-9 w-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="flex-1 flex items-start sm:items-center justify-center px-6 pt-16 pb-12">
<div
key={step}
className={cn(
'w-full mx-auto animate-in fade-in slide-in-from-bottom-2 duration-300',
// Bio, statement & how-to steps host a text surface / markdown
// editor / tutorial and want a slightly roomier column than the
// narrow base screens — but not the full-width 3xl that left the
// text boxes and tutorial feeling oversized.
step === 'orgBio' || step === 'orgStatement' || step === 'orgVerifyHowto'
? 'max-w-xl'
: 'max-w-md',
)}
>
{stepBody}
</div>
</div>
</div>
);
}
// =============================================================================
// Step components
// =============================================================================
interface RoleStepProps {
role: OnboardingRole;
onPick: (role: 'creator' | 'donor' | 'verifier') => void;
}
/**
* Two-card role picker, modeled on the Treasures CreateCacheLanding pattern.
* Both cards use primary-tinted icon chips (both roles are first-class) and
* the three-line "title / what you do / what the other side sees"
* structure that makes the choice feel like a role rather than a feature
* menu.
*/
function RoleStep({ role, onPick }: RoleStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.role.title')}</h2>
<p className="text-sm text-muted-foreground">{t('onboarding.role.subtitle')}</p>
</div>
<div className="space-y-3">
<RoleCard
icon={<Megaphone className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
title={t('onboarding.role.creator.title')}
description={t('onboarding.role.creator.description')}
finderNote={t('onboarding.role.creator.finderNote')}
selected={role === 'creator'}
onClick={() => onPick('creator')}
/>
<RoleCard
icon={<HandCoins className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
title={t('onboarding.role.donor.title')}
description={t('onboarding.role.donor.description')}
finderNote={t('onboarding.role.donor.finderNote')}
selected={role === 'donor'}
onClick={() => onPick('donor')}
/>
<RoleCard
icon={<BadgeCheck className="h-5 w-5 md:h-6 md:w-6 text-primary" />}
title={t('onboarding.role.verifier.title')}
description={t('onboarding.role.verifier.description')}
finderNote={t('onboarding.role.verifier.finderNote')}
selected={role === 'verifier'}
onClick={() => onPick('verifier')}
/>
</div>
</div>
);
}
/**
* Verifier sub-flow step 4 — teach the verify gesture with the shared
* {@link VerifyTutorial}, then offer the terminal "View campaigns" CTA.
*/
function VerifierHowtoStep({
draft,
onFinish,
}: {
draft: ProfileDraft;
onFinish: () => void;
}) {
const { t } = useTranslation();
const [hasSeenLoop, setHasSeenLoop] = useState(false);
const handleLoopComplete = useCallback(() => setHasSeenLoop(true), []);
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">
{t('onboarding.verifier.howto.title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('onboarding.verifier.howto.subtitle')}
</p>
</div>
<VerifyTutorial
hideHeader
bare
stacked
verifierName={draft.name}
verifierPicture={draft.picture}
onLoopComplete={handleLoopComplete}
/>
<Button
onClick={onFinish}
disabled={!hasSeenLoop}
className="w-full h-12 text-base rounded-full"
>
{t('onboarding.verifier.howto.finish')}
<ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />
</Button>
</div>
);
}
interface VerifierStatementStepProps {
onContinue: () => void;
}
/**
* Verifier sub-flow step 3 — publish the verifier statement (kind 14672).
*
* One header and one combined subtext sit above a borderless
* {@link VerifierStatementEditor}. There's no separate publish button: the
* primary button publishes the statement (when there's content) and then
* advances. Withdrawing happens later from the profile's "How We Verify" card.
*/
function VerifierStatementStep({
onContinue,
}: VerifierStatementStepProps) {
const { t } = useTranslation();
const { toast } = useToast();
const { mutateAsync: setStatement, isPending } = useSetVerifierStatement();
const [value, setValue] = useState('');
const trimmed = value.trim();
const handleContinue = useCallback(async () => {
try {
await setStatement(trimmed);
onContinue();
} catch (error) {
toast({
title: t('verifier.errorToast'),
description: error instanceof Error ? error.message : String(error),
variant: 'destructive',
});
}
}, [setStatement, trimmed, toast, t, onContinue]);
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">
{t('onboarding.verifier.statement.title')}
</h2>
<p className="text-sm text-muted-foreground">
{t('onboarding.verifier.statement.subtitle')}
</p>
</div>
<VerifierStatementEditor
value={value}
onChange={setValue}
/>
<Button
onClick={handleContinue}
disabled={!trimmed || isPending}
className="w-full h-12 text-base rounded-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t('common.continue')}
{!isPending && <ArrowRight className="ml-2 h-4 w-4 rtl:rotate-180" />}
</Button>
</div>
);
}
interface RoleCardProps {
icon: ReactNode;
title: string;
description: string;
finderNote: string;
selected: boolean;
onClick: () => void;
}
/** A single role card. Three text lines, primary-tinted icon chip, hover and
* selected states matching the Treasures pattern. */
function RoleCard({ icon, title, description, finderNote, selected, onClick }: RoleCardProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'w-full text-left rounded-xl border bg-card p-5 md:p-6 transition-all',
'hover:border-primary/50 hover:shadow-md',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
selected && 'border-primary shadow-md ring-1 ring-primary',
)}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-10 h-10 md:w-12 md:h-12 rounded-full bg-primary/10 flex items-center justify-center">
{icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm md:text-base font-semibold text-foreground">{title}</h3>
<p className="text-xs md:text-sm text-muted-foreground mt-0.5">{description}</p>
<p className="text-xs md:text-sm font-medium text-primary mt-1.5">{finderNote}</p>
</div>
<ArrowRight className="h-4 w-4 md:h-5 md:w-5 text-muted-foreground flex-shrink-0 mt-1 rtl:rotate-180" />
</div>
</button>
);
}
interface KeygenStepProps {
isGenerating: boolean;
onGenerate: () => void;
}
/** Key generation step — a single CTA that fires off `generateSecretKey()`
* with a brief visible spinner for tactile feedback. */
function KeygenStep({ isGenerating, onGenerate }: KeygenStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6 text-center">
<div className="relative w-24 h-24 mx-auto">
{isGenerating ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="w-14 h-14 text-primary animate-spin" />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<AgoraBoltIcon className="size-20 drop-shadow-md" />
</div>
)}
</div>
<div className="space-y-2">
<h2 className="text-2xl font-bold tracking-tight">
{isGenerating ? t('onboarding.keygen.generatingTitle') : t('onboarding.keygen.title')}
</h2>
<p className="text-sm text-muted-foreground">
{isGenerating ? t('onboarding.keygen.generatingDescription') : t('onboarding.keygen.description')}
</p>
</div>
{!isGenerating && (
<Button onClick={onGenerate} className="w-full h-12 text-base rounded-full">
{t('onboarding.keygen.button')}
</Button>
)}
</div>
);
}
interface SecureStepProps {
nsec: string;
showKey: boolean;
onToggleShow: () => void;
onContinue: () => void;
}
/**
* Reveals + downloads the nsec, then installs it into the login store and
* advances.
*
* Three stacked elements communicate the weight of saving the key:
* 1. The key itself (revealable input + download button).
* 2. A "your account and your wallet share this key" coupling callout —
* large linked icons make the relationship the visual centerpiece.
* 3. A no-recovery emphasis block — calm, informational tone (not a red
* destructive alert) but typographically dominant so the user can't
* sail past the "there is no way to get this back" point.
*
* This is the only captive-flow surface that explains the coupling and the
* permanence to brand-new users, so it has to carry weight without scaring
* them.
*/
function SecureStep({ nsec, showKey, onToggleShow, onContinue }: SecureStepProps) {
const { t } = useTranslation();
return (
<div className="space-y-6">
<div className="space-y-2 text-center">
<h2 className="text-2xl font-bold tracking-tight">{t('onboarding.secure.title')}</h2>
<p className="text-sm text-muted-foreground">{t('onboarding.secure.subtitle')}</p>
</div>
<div className="relative">
<Input
type={showKey ? 'text' : 'password'}
value={nsec}
readOnly
className="pr-10 font-mono text-sm"
aria-label={t('onboarding.secure.secretKeyAriaLabel')}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
onClick={onToggleShow}
>
{showKey ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
{/* Single coupling+permanence card. The linked-icon visual at the
top communicates "your account and your wallet share this key";
the typographically heavier line below it makes the no-recovery
point inescapable without resorting to red/warning iconography.
Both messages live in one card so the user can't skim past
either. */}
<div className="rounded-xl bg-primary/10 border-2 border-primary/30 p-5 space-y-4">
<div className="flex items-center justify-center gap-3">
<div className="w-14 h-14 rounded-full bg-background ring-2 ring-primary/30 flex items-center justify-center shadow-sm">
<User className="h-7 w-7 text-primary" />
</div>
<div className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow">
<Link2 className="h-4 w-4" />
</div>
<div className="w-14 h-14 rounded-full bg-background ring-2 ring-primary/30 flex items-center justify-center shadow-sm">
<Bitcoin className="h-7 w-7 text-primary" />
</div>
</div>
<p className="text-sm text-foreground text-center leading-relaxed">
{t('onboarding.secure.couplingNote')}
</p>
<div className="border-t border-primary/20 pt-3 text-center">
<p className="text-base font-bold tracking-tight text-foreground leading-snug">
{t('onboarding.secure.permanenceHeadline')}
</p>
<p className="text-xs text-muted-foreground mt-1.5 leading-relaxed">
{t('onboarding.secure.permanenceBody')}
</p>
</div>
</div>
<Button onClick={onContinue} className="w-full h-12 text-base rounded-full">
<Download className="w-4 h-4 mr-2" />
{t('onboarding.secure.button')}
</Button>
</div>
);
}