fix(onboarding): stop the first-run checklist + backup reminder nagging returning users

- FirstRunChecklist showed to any logged-in user until manually dismissed; it now
  requires a recent signup_completed stamp AND first-run-seen (auto, once), so
  established/imported accounts never see it.
- SignupDialog wrote the backup-confirmed flag under user?.pubkey while the login
  was still settling, so it landed on 'backup-confirmed:anon' and the post-signup
  BackupReminder nagged every session. Now markBackupConfirmed() is called with the
  pubkey derived from the minted nsec.
- NIP-49 export password field autoComplete new-password -> off (fewer browser
  save-password prompts).
This commit is contained in:
SkateSpots 2026-10-12 23:11:42 +07:00
parent 2d6ee43001
commit a2bc42c94b
4 changed files with 82 additions and 12 deletions

View file

@ -1437,6 +1437,24 @@ session with **no other memory of this project**. So:
(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.
- **First-run checklist + backup-reminder gating (fixed 2026-10-12).** Two
onboarding prompts nagged returning users:
1. `FirstRunChecklist` (map) showed for **any logged-in user** until they
tapped ✕ — returning skaters saw it forever. It now requires a **recent
`signup_completed` stamp** (set by the signup flow — imported/older
accounts never match) **and** `first-run-seen` (set once, on first show;
captured at mount so it still shows that session but never again).
2. The `SignupDialog` marked backup done with
`setBackupConfirmed(true)` keyed on `useCurrentUser().pubkey` — but `user`
is **still settling mid-signup**, so it wrote `backup-confirmed:anon` and
the post-signup `BackupReminder` (real pubkey) never learned it → it nagged
**every session**. Now `markBackupConfirmed(pubkey)` (in `lib/backup.ts`,
mirrors the hook's JSON encoding) is called with the pubkey **derived from
the freshly minted nsec** (`getPublicKey(sk)` captured in
`createAccountSilently`). ⚠️ Any new "backup done" write must use the real
pubkey, not `user?.pubkey` during signup. (The NIP-49 export password field
also switched `autoComplete` `new-password` → `off` to cut the browser's
save-password prompt.)
- **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

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
@ -7,22 +8,51 @@ import { useCrews } from '@/hooks/useCrews';
import { useTranslation } from '@/contexts/language';
const DISMISS_KEY = 'skatespots:first-run-dismissed';
/** Set once, the first time the checklist has actually been shown. */
const SEEN_KEY = 'skatespots:first-run-seen';
/** The signup flow stamps this (ms). Only a *recent* signup counts as "first run". */
const SIGNUP_KEY = 'signup_completed';
const FRESH_WINDOW_MS = 2 * 24 * 60 * 60 * 1000; // 2 days
/**
* One-shot "get started" nudge shown on the map for freshly signed-up users.
*
* The crew step is dynamic: when the community already has crews the CTA is
* "Join a crew" (→ /crews); when there are none it's "Start your crew"
* (→ /create-crew). Logged-out visitors see the welcome banner instead, and
* dismissal persists (localStorage).
* (→ /create-crew). Logged-out visitors see the welcome banner instead.
*
* ⚠️ Gating (fixed 2026-10-12): this used to show to ANY logged-in user until
* they tapped ✕, so returning skaters saw it forever. It now shows only when
* (a) the account has a **recent `signup_completed` stamp** (set by the signup
* flow — an imported key or an older account never matches) and (b) it has not
* been shown before (`first-run-seen`). It stays visible for that one session
* and never returns.
*/
export function FirstRunChecklist() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const [dismissed, setDismissed] = useLocalStorage<boolean>(DISMISS_KEY, false);
const [seen, setSeen] = useLocalStorage<boolean>(SEEN_KEY, false);
const crews = useCrews(1);
if (!user || dismissed) return null;
// Was this a fresh in-app signup? Read once (a stable local fact).
const [fresh] = useState(() => {
try {
const at = Number(localStorage.getItem(SIGNUP_KEY) ?? 0);
return Number.isFinite(at) && at > 0 && Date.now() - at < FRESH_WINDOW_MS;
} catch {
return false;
}
});
// Capture `seen` at mount so marking it doesn't hide the checklist in the same
// session — only the next one.
const seenAtMount = useRef(seen);
useEffect(() => {
if (user && fresh && !dismissed && !seenAtMount.current) setSeen(true);
}, [user, fresh, dismissed, setSeen]);
if (!user || dismissed || !fresh || seenAtMount.current) return null;
const hasCrews = (crews.data?.length ?? 0) > 0;
const crewTo = hasCrews ? '/crews' : '/create-crew';

View file

@ -14,11 +14,10 @@ import { toast } from '@/hooks/useToast';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { setSignupOpen } from '@/lib/uiFlags';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { backupConfirmedKey } from '@/lib/backup';
import { generateSecretKey, nip19 } from 'nostr-tools';
import { markBackupConfirmed } from '@/lib/backup';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { cn } from '@/lib/utils';
import { SkateNip05Input } from '@/components/SkateNip05Input';
import { useSkateNip05 } from '@/hooks/useSkateNip05';
@ -84,7 +83,10 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
// Shared source of truth: once the recovery code is saved here, mark backup
// confirmed so the gentle post-signup reminder never nags a safe user.
const { user } = useCurrentUser();
const [, setBackupConfirmed] = useLocalStorage(backupConfirmedKey(user?.pubkey), false);
// The pubkey of the account this dialog just minted. Captured at creation so
// the backup flag is written under the REAL pubkey — `user` is still settling
// mid-flow, and writing it under `user?.pubkey` landed on `…:anon`.
const [newPubkey, setNewPubkey] = useState<string | undefined>(undefined);
// Silently generate a key and log the user in immediately.
// No forced download, no crypto vocabulary — the account "just works".
@ -93,6 +95,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
try {
const sk = generateSecretKey();
const newNsec = nip19.nsecEncode(sk);
setNewPubkey(getPublicKey(sk));
setNsec(newNsec);
login.nsec(newNsec);
return true;
@ -130,7 +133,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);
markBackupConfirmed(newPubkey ?? user?.pubkey);
toast({
title: t('settingsUi.signupDialog.keySavedTitle'),
@ -165,7 +168,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
setEncError('');
setBackupAck(true);
setBackupConfirmed(true);
markBackupConfirmed(newPubkey ?? user?.pubkey);
toast({
title: t('settingsUi.signupDialog.encSavedTitle'),
description: t('settingsUi.signupDialog.encSavedDesc'),
@ -745,7 +748,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
onClick={() => {
navigator.clipboard.writeText(nsec);
setBackupConfirmed(true);
markBackupConfirmed(newPubkey ?? user?.pubkey);
toast({ title: t('common.copied'), description: t('settingsUi.signupDialog.recoveryCopied') });
}}
>
@ -802,7 +805,8 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onChange={(e) => { setEncPassword(e.target.value); setEncError(''); }}
placeholder={t('settingsUi.signupDialog.choosePassword')}
className='rounded-lg'
autoComplete='new-password'
autoComplete='off'
name='ncryptsec-export-password'
/>
{encError && <p className='text-[11px] text-red-400'>{encError}</p>}
<Button
@ -833,7 +837,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
onCheckedChange={(v) => {
const on = v === true;
setBackupAck(on);
if (on) setBackupConfirmed(true);
if (on) markBackupConfirmed(newPubkey ?? user?.pubkey);
}}
className='mt-0.5'
aria-label={t('settingsUi.signupDialog.savedCodeAria')}

View file

@ -8,3 +8,21 @@
export function backupConfirmedKey(pubkey: string | undefined): string {
return `backup-confirmed:${pubkey ?? 'anon'}`;
}
/**
* Record that this account's key was saved.
*
* ⚠️ Call this with the **real** pubkey (e.g. derived from the freshly minted
* nsec), NOT `useCurrentUser().pubkey` during signup — at that moment `user` is
* still settling, so the write landed on `backup-confirmed:anon` and the
* post-signup reminder never learned the key was saved (it nagged every session).
* Mirrors `useLocalStorage`'s JSON encoding so the hook reads it back.
*/
export function markBackupConfirmed(pubkey: string | undefined): void {
if (!pubkey) return;
try {
localStorage.setItem(backupConfirmedKey(pubkey), JSON.stringify(true));
} catch {
// localStorage can throw in strict privacy modes — safe to ignore.
}
}