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:
parent
2d6ee43001
commit
a2bc42c94b
4 changed files with 82 additions and 12 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue