feat(onboarding): clearer signup steps, backup confirmation, home area

Signup and onboarding are the first impression, so tighten both.

Signup (SignupDialog):
- Add a Choose -> Profile -> Save key step indicator.
- Make backup a confirmation, not a skip: an "I've saved my recovery code"
  checkbox enables "Let's skate" (downloading the key also sets it); the old
  co-equal "Skip for now" is now a quiet link.
- Fix copy that contradicted step 3 ("nothing to remember") and warn that a
  signer may ask to approve the profile publish.
- Platform-aware signer nudge instead of implying a recommendation.

LoginArea: drop the dead SignupMethodDialog/MnemonicSignupDialog wiring (never
opened) and rename the misleading nsecSignupDialogOpen state.

Onboarding: add a "Where do you skate?" first step (Use my location caches the
fix so the map opens on the skater's area) and remove the dead disabled
"Log in first" button.
This commit is contained in:
SkateSpots 2026-10-01 09:44:47 +07:00
parent 907da9b497
commit b89136161c
4 changed files with 207 additions and 54 deletions

View file

@ -679,6 +679,27 @@ session with **no other memory of this project**. So:
`index.css` offsets `.leaflet-top.leaflet-left` by `4.25rem` **only under
`max-width:767px`** (desktop toolbar is in flow, no offset). Verified at
390×844: zoom top 135px vs search bottom 108px.
- **Signup & onboarding UX pass (2026-10-01):** hardening the two highest-stakes
first impressions.
- **Signup is one fork.** `LoginArea` also rendered a `SignupMethodDialog`
("Recovery Phrase vs Secret Key") + `MnemonicSignupDialog`, but
**`setSignupMethodDialogOpen(true)` existed nowhere** — the whole method
branch was DEAD code (unreachable). Removed the wiring (files kept on disk);
the misleading `nsecSignupDialogOpen` state was renamed `signupDialogOpen`.
`SignupDialog` (`welcome → profile → done`) is the only signup path.
- **`SignupDialog` gained a step indicator** (`STEP_ORDER`/`STEP_LABELS`:
Choose → Profile → Save key) and **makes backup a confirmation, not a skip**:
a checkbox ("I've saved my recovery code…") enables "Let's skate"; the
Download button also sets it. The old co-equal "Skip for now, skate" is now a
quiet text link ("I'll save it later from Settings"). Welcome copy no longer
claims "nothing to remember" (it contradicted step 3), the profile step warns
a signer may ask once, and non-Android copy points at signer apps instead of
implying a recommendation.
- **Onboarding adds "Where do you skate?"** (`OnboardingPage`): for a logged-in
skater with no cached fix, "Use my location" calls `getCurrentPosition()`
(which also `saveLastLocation()`s), so `MapPage`'s mount-time
`getLastLocation()` opens centered on their area. The logged-out wallet
section's dead disabled "Log in first" button became a plain line.
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
allLocations.length > 0`) and a new "No spots mapped yet — be the first" card

View file

@ -6,8 +6,6 @@ import { User, UserPlus } from 'lucide-react';
import { Button } from '@/components/ui/button.tsx';
import LoginDialog from './LoginDialog';
import SignupDialog from './SignupDialog';
import SignupMethodDialog from './SignupMethodDialog';
import MnemonicSignupDialog from './MnemonicSignupDialog';
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
import { AccountSwitcher } from './AccountSwitcher';
import { cn } from '@/lib/utils';
@ -21,15 +19,11 @@ export interface LoginAreaProps {
export function LoginArea({ className, compact }: LoginAreaProps) {
const { currentUser } = useLoggedInAccounts();
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupMethodDialogOpen, setSignupMethodDialogOpen] = useState(false);
const [mnemonicSignupDialogOpen, setMnemonicSignupDialogOpen] = useState(false);
const [nsecSignupDialogOpen, setNsecSignupDialogOpen] = useState(false);
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
const handleLogin = () => {
setLoginDialogOpen(false);
setSignupMethodDialogOpen(false);
setMnemonicSignupDialogOpen(false);
setNsecSignupDialogOpen(false);
setSignupDialogOpen(false);
};
return (
@ -40,7 +34,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
// Compact mode for mobile headers - icon only
<div className="flex gap-1 justify-center">
<Button
onClick={() => setNsecSignupDialogOpen(true)}
onClick={() => setSignupDialogOpen(true)}
size="icon"
className='h-9 w-9 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
title="Sign Up"
@ -61,7 +55,7 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
// Default full-size buttons
<div className="flex gap-3 justify-center w-full">
<Button
onClick={() => setNsecSignupDialogOpen(true)}
onClick={() => setSignupDialogOpen(true)}
className='flex items-center gap-2 px-5 py-2.5 rounded-full bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold transition-all flex-1 sm:flex-none'
>
<UserPlus className='w-4 h-4' />
@ -82,36 +76,17 @@ export function LoginArea({ className, compact }: LoginAreaProps) {
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
onLogin={handleLogin}
onSignup={() => setNsecSignupDialogOpen(true)}
/>
<SignupMethodDialog
isOpen={signupMethodDialogOpen}
onClose={() => setSignupMethodDialogOpen(false)}
onSelectMnemonic={() => {
setSignupMethodDialogOpen(false);
setMnemonicSignupDialogOpen(true);
}}
onSelectNsec={() => {
setSignupMethodDialogOpen(false);
setNsecSignupDialogOpen(true);
}}
/>
<MnemonicSignupDialog
isOpen={mnemonicSignupDialogOpen}
onClose={() => setMnemonicSignupDialogOpen(false)}
onSwitchToNsec={() => {
setMnemonicSignupDialogOpen(false);
setNsecSignupDialogOpen(true);
}}
onSignup={() => setSignupDialogOpen(true)}
/>
{/* One signup entry point. The old "Recovery Phrase vs Secret Key" method
chooser was dead code (never opened) and pure jargon for a newcomer —
SignupDialog owns the whole flow now. */}
<SignupDialog
isOpen={nsecSignupDialogOpen}
onClose={() => setNsecSignupDialogOpen(false)}
isOpen={signupDialogOpen}
onClose={() => setSignupDialogOpen(false)}
onUseAmber={() => {
setNsecSignupDialogOpen(false);
setSignupDialogOpen(false);
setLoginDialogOpen(true);
}}
/>

View file

@ -7,6 +7,7 @@ import { Download, Key, Shield, User, Sparkles, LogIn, CheckCircle, Upload, Smar
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Card, CardContent } from '@/components/ui/card';
import { toast } from '@/hooks/useToast';
@ -33,12 +34,28 @@ const sanitizeFilename = (filename: string) => {
return filename.replace(/[^a-z0-9_.-]/gi, '_');
}
// The signup flow is deliberately finite and visible: a newcomer should always
// know there are three steps and how far they've got. Labels avoid jargon
// ("Save key", not "Backup nsec").
const STEP_ORDER = ['welcome', 'profile', 'done'] as const;
type SignupStep = typeof STEP_ORDER[number];
const STEP_LABELS: Record<SignupStep, string> = {
welcome: 'Choose',
profile: 'Profile',
done: 'Save key',
};
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete, onUseAmber }) => {
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome');
const [step, setStep] = useState<SignupStep>('welcome');
const [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
const [showNsec, setShowNsec] = useState(false);
// The recovery-code acknowledgement. Saving the key is the one irreversible
// step, so "Let's skate" stays locked until the user confirms it's saved (or
// downloaded it). The escape hatch stays, but it's a quiet link — never
// presented as an equal choice to saving.
const [backupAck, setBackupAck] = useState(false);
const [profileData, setProfileData] = useState({
name: '',
about: '',
@ -101,6 +118,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
// Mark as secured — and record a confirmed backup so the reminder stops.
setKeySecured('downloaded');
setBackupAck(true);
setBackupConfirmed(true);
toast({
@ -276,6 +294,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setStep('welcome');
setNsec('');
setKeySecured('none');
setBackupAck(false);
setShowNsec(false);
setProfileData({ name: '', about: '', picture: '' });
setNip05Handle('');
@ -309,6 +328,42 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
{getTitle()}
</DialogTitle>
</DialogHeader>
{/* Step indicator — the flow is finite and the person should always know
where they are (Choose → Profile → Save key). */}
<div className='flex items-center justify-center gap-1.5 px-4 sm:px-6 pt-2 pb-1 flex-shrink-0'>
{STEP_ORDER.map((s, i) => {
const idx = STEP_ORDER.indexOf(step);
return (
<div key={s} className='flex items-center gap-1.5'>
<span
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-black transition-colors',
i < idx
? 'bg-[#00ff00] text-black'
: i === idx
? 'bg-[#ccff00] text-black'
: 'bg-white/10 text-gray-500'
)}
>
{i < idx ? '✓' : i + 1}
</span>
<span
className={cn(
'text-[10px] font-black uppercase tracking-wide',
i === idx ? 'text-white' : 'text-gray-600'
)}
>
{STEP_LABELS[s]}
</span>
{i < STEP_ORDER.length - 1 && (
<span className={cn('h-px w-3 sm:w-5', i < idx ? 'bg-[#00ff00]/50' : 'bg-white/10')} />
)}
</div>
);
})}
</div>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Welcome Step — recommend Amber first, then offer the quick local key */}
{step === 'welcome' && (
@ -388,15 +443,23 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
<Sparkles className='mr-2 h-4 w-4' />
Create quick account
</Button>
{/* Reassurance: effortless + safe, no jargon */}
{/* Reassurance: effortless + safe, no jargon. Deliberately does
NOT promise "nothing to remember" — step 3 asks them to save
the key, and the copy must not contradict that. */}
<p className='flex items-center justify-center gap-1.5 text-[11px] text-gray-500'>
<CheckCircle className='h-3 w-3 text-[#00ff00]/70' />
Takes 5 seconds · stays on this device · nothing to remember
Takes 5 seconds · lives on this device · we'll help you back it up next
</p>
</div>
{/* Platform-aware nudge. On Android the Amber card above already
leads; elsewhere point at signer apps/extensions honestly. */}
<p className='text-xs text-gray-500'>
Prefer another signer?{' '}
{onUseAmber && isAndroid ? (
<>Prefer another signer?{' '}</>
) : (
<>On iOS or desktop? A signer app or browser extension is the most secure option —{' '}</>
)}
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>See all options</Link>
</p>
</div>
@ -570,6 +633,14 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
Add a display name to continue.
</p>
)}
{/* Set expectations before it happens: saving publishes a kind-0
profile event, so an interactive signer (Amber / NIP-07) will
show an approval prompt. Explaining it first avoids the "why
is it asking me to sign?" moment mid-signup. */}
<p className='text-center text-[11px] text-muted-foreground/80'>
Saving publishes your profile to Nostr — your signer may ask you to approve it once.
</p>
</div>
</div>
)}
@ -658,28 +729,51 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
</CardContent>
</Card>
{/* Skate now — clearly available, intentionally quieter than "save".
Copy adapts: once saved, it's a confident go; if not, it's an
honest "you can do this later" so we never trap the user. */}
{/* Backup is a confirmation, not a skip. The primary action stays
locked until the key is saved (download sets it) or explicitly
acknowledged with the checkbox. The escape hatch remains — we
never trap anyone — but it's a quiet text link, never an equal
button competing with "save". */}
<div className='space-y-2'>
<label className='flex items-start gap-2.5 rounded-xl border border-white/10 bg-black/40 p-3 cursor-pointer select-none'>
<Checkbox
checked={backupAck}
onCheckedChange={(v) => {
const on = v === true;
setBackupAck(on);
if (on) setBackupConfirmed(true);
}}
className='mt-0.5'
aria-label="I've saved my recovery code"
/>
<span className='text-xs text-gray-300 leading-snug'>
I've saved my recovery code somewhere safe — a password manager, or written down offline.
</span>
</label>
<Button
variant={keySecured === 'downloaded' ? 'default' : 'outline'}
className={cn(
'w-full rounded-full py-3 font-black uppercase',
keySecured === 'downloaded'
backupAck
? 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
: 'border-white/20 text-white hover:bg-white/5'
: 'bg-white/10 text-gray-500 cursor-not-allowed'
)}
disabled={!backupAck}
onClick={handleFinishComplete}
>
<LogIn className='h-4 w-4 mr-2' />
{keySecured === 'downloaded' ? "Let's skate" : 'Skip for now, skate'}
Let's skate
</Button>
{keySecured !== 'downloaded' && (
<p className='text-center text-[11px] text-gray-500'>
No stress — we'll remind you, and you can save it anytime from Settings.
</p>
{!backupAck && (
<button
onClick={handleFinishComplete}
className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'
>
I'll save it later from Settings
</button>
)}
<Link to='/signers' onClick={handleFinishComplete} className='block'>
<button className='w-full text-xs text-gray-500 hover:text-gray-400 py-1'>
Advanced: use a signer app instead

View file

@ -7,6 +7,8 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCashu } from '@/hooks/wallet/useCashu';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { getCurrentPosition } from '@/lib/geolocation';
import { getLastLocation } from '@/lib/lastLocation';
import { LoginArea } from '@/components/auth/LoginArea';
import { WalletModal } from '@/components/WalletModal';
import { RelayEducation } from '@/components/onboarding/RelayEducation';
@ -113,6 +115,32 @@ export default function OnboardingPage() {
// "remind me later" can never silently become "never" for this critical step.
const [backupConfirmed] = useLocalStorage('backup-confirmed', false);
// "Where do you skate?" — the single most useful first step. We reuse the
// same last-location cache MapPage reads on mount, so setting it here means
// the map opens on the skater's own area instead of the whole world, and no
// permission prompt surprises them at map load. `hasHome` just hides the
// card once a location exists.
const [locating, setLocating] = useState(false);
const [hasHome, setHasHome] = useState(() => !!getLastLocation());
const handleUseMyLocation = async () => {
setLocating(true);
try {
// getCurrentPosition() also persists the fix via saveLastLocation().
await getCurrentPosition();
setHasHome(true);
toast({ title: 'Home set 🛹', description: 'The map will now open on your area.' });
} catch {
toast({
title: "Couldn't get your location",
description: 'Check location permissions, or pick your area from the map.',
variant: 'destructive',
});
} finally {
setLocating(false);
}
};
const handleRelayEducationComplete = () => {
setShowRelayEducation(false);
setHasSeenRelayEducation(true);
@ -235,6 +263,43 @@ export default function OnboardingPage() {
)}
{/* ── First run: where do you skate? ── */}
{user && !hasHome && (
<div className="rounded-2xl border border-[#ccff00]/30 bg-gradient-to-br from-[#ccff00]/8 to-black/40 p-5 mb-6">
<div className="flex items-start gap-3 mb-4">
<div className="w-9 h-9 rounded-xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0">
<MapPin className="h-5 w-5 text-[#ccff00]" />
</div>
<div>
<h2 className="font-black text-white uppercase tracking-wide text-sm">Where do you skate?</h2>
<p className="text-xs text-gray-400 mt-0.5 leading-snug">
Set your home area once and the map opens on your spots every time.
Your GPS never leaves your device.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleUseMyLocation}
disabled={locating}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 gap-2 font-black uppercase text-xs"
>
{locating ? (
<div className="h-3.5 w-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<MapPin className="h-3.5 w-3.5" />
)}
Use my location
</Button>
<Link to="/map">
<Button variant="outline" className="border-white/20 text-white hover:bg-white/5 text-xs font-bold">
Pick on the map
</Button>
</Link>
</div>
</div>
)}
{/* ── Login saved confirmation ── */}
{user && backupConfirmed && (
<div className="flex items-center gap-3 p-3 rounded-2xl bg-green-500/6 border border-green-500/20 mb-8">
@ -399,9 +464,7 @@ export default function OnboardingPage() {
</Button>
</WalletModal>
) : (
<Button size="sm" variant="outline" className="w-full text-xs" disabled>
Log in first
</Button>
<p className="text-xs text-gray-500">Log in to connect your own wallet.</p>
)}
</div>
</div>