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
|
(which also `saveLastLocation()`s), so `MapPage`'s mount-time
|
||||||
`getLastLocation()` opens centered on their area. The logged-out wallet
|
`getLastLocation()` opens centered on their area. The logged-out wallet
|
||||||
section's dead disabled "Log in first" button became a plain line.
|
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
|
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
|
||||||
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
|
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
|
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 { X } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -7,22 +8,51 @@ import { useCrews } from '@/hooks/useCrews';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
const DISMISS_KEY = 'skatespots:first-run-dismissed';
|
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.
|
* 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
|
* 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"
|
* "Join a crew" (→ /crews); when there are none it's "Start your crew"
|
||||||
* (→ /create-crew). Logged-out visitors see the welcome banner instead, and
|
* (→ /create-crew). Logged-out visitors see the welcome banner instead.
|
||||||
* dismissal persists (localStorage).
|
*
|
||||||
|
* ⚠️ 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() {
|
export function FirstRunChecklist() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const [dismissed, setDismissed] = useLocalStorage<boolean>(DISMISS_KEY, false);
|
const [dismissed, setDismissed] = useLocalStorage<boolean>(DISMISS_KEY, false);
|
||||||
|
const [seen, setSeen] = useLocalStorage<boolean>(SEEN_KEY, false);
|
||||||
const crews = useCrews(1);
|
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 hasCrews = (crews.data?.length ?? 0) > 0;
|
||||||
const crewTo = hasCrews ? '/crews' : '/create-crew';
|
const crewTo = hasCrews ? '/crews' : '/create-crew';
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,10 @@ import { toast } from '@/hooks/useToast';
|
||||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
|
||||||
import { setSignupOpen } from '@/lib/uiFlags';
|
import { setSignupOpen } from '@/lib/uiFlags';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { backupConfirmedKey } from '@/lib/backup';
|
import { markBackupConfirmed } from '@/lib/backup';
|
||||||
import { generateSecretKey, nip19 } from 'nostr-tools';
|
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
import { SkateNip05Input } from '@/components/SkateNip05Input';
|
||||||
import { useSkateNip05 } from '@/hooks/useSkateNip05';
|
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
|
// 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.
|
// confirmed so the gentle post-signup reminder never nags a safe user.
|
||||||
const { user } = useCurrentUser();
|
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.
|
// Silently generate a key and log the user in immediately.
|
||||||
// No forced download, no crypto vocabulary — the account "just works".
|
// No forced download, no crypto vocabulary — the account "just works".
|
||||||
|
|
@ -93,6 +95,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
||||||
try {
|
try {
|
||||||
const sk = generateSecretKey();
|
const sk = generateSecretKey();
|
||||||
const newNsec = nip19.nsecEncode(sk);
|
const newNsec = nip19.nsecEncode(sk);
|
||||||
|
setNewPubkey(getPublicKey(sk));
|
||||||
setNsec(newNsec);
|
setNsec(newNsec);
|
||||||
login.nsec(newNsec);
|
login.nsec(newNsec);
|
||||||
return true;
|
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.
|
// Mark as secured — and record a confirmed backup so the reminder stops.
|
||||||
setKeySecured('downloaded');
|
setKeySecured('downloaded');
|
||||||
setBackupAck(true);
|
setBackupAck(true);
|
||||||
setBackupConfirmed(true);
|
markBackupConfirmed(newPubkey ?? user?.pubkey);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t('settingsUi.signupDialog.keySavedTitle'),
|
title: t('settingsUi.signupDialog.keySavedTitle'),
|
||||||
|
|
@ -165,7 +168,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
||||||
|
|
||||||
setEncError('');
|
setEncError('');
|
||||||
setBackupAck(true);
|
setBackupAck(true);
|
||||||
setBackupConfirmed(true);
|
markBackupConfirmed(newPubkey ?? user?.pubkey);
|
||||||
toast({
|
toast({
|
||||||
title: t('settingsUi.signupDialog.encSavedTitle'),
|
title: t('settingsUi.signupDialog.encSavedTitle'),
|
||||||
description: t('settingsUi.signupDialog.encSavedDesc'),
|
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'
|
className='border-amber-500/40 text-amber-300 hover:bg-amber-500/10'
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(nsec);
|
navigator.clipboard.writeText(nsec);
|
||||||
setBackupConfirmed(true);
|
markBackupConfirmed(newPubkey ?? user?.pubkey);
|
||||||
toast({ title: t('common.copied'), description: t('settingsUi.signupDialog.recoveryCopied') });
|
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(''); }}
|
onChange={(e) => { setEncPassword(e.target.value); setEncError(''); }}
|
||||||
placeholder={t('settingsUi.signupDialog.choosePassword')}
|
placeholder={t('settingsUi.signupDialog.choosePassword')}
|
||||||
className='rounded-lg'
|
className='rounded-lg'
|
||||||
autoComplete='new-password'
|
autoComplete='off'
|
||||||
|
name='ncryptsec-export-password'
|
||||||
/>
|
/>
|
||||||
{encError && <p className='text-[11px] text-red-400'>{encError}</p>}
|
{encError && <p className='text-[11px] text-red-400'>{encError}</p>}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -833,7 +837,7 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
const on = v === true;
|
const on = v === true;
|
||||||
setBackupAck(on);
|
setBackupAck(on);
|
||||||
if (on) setBackupConfirmed(true);
|
if (on) markBackupConfirmed(newPubkey ?? user?.pubkey);
|
||||||
}}
|
}}
|
||||||
className='mt-0.5'
|
className='mt-0.5'
|
||||||
aria-label={t('settingsUi.signupDialog.savedCodeAria')}
|
aria-label={t('settingsUi.signupDialog.savedCodeAria')}
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,21 @@
|
||||||
export function backupConfirmedKey(pubkey: string | undefined): string {
|
export function backupConfirmedKey(pubkey: string | undefined): string {
|
||||||
return `backup-confirmed:${pubkey ?? 'anon'}`;
|
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