diff --git a/AGENTS.md b/AGENTS.md index e447db2..fd1e0a3 100644 --- a/AGENTS.md +++ b/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 diff --git a/src/components/FirstRunChecklist.tsx b/src/components/FirstRunChecklist.tsx index 29f2edc..c7a4de6 100644 --- a/src/components/FirstRunChecklist.tsx +++ b/src/components/FirstRunChecklist.tsx @@ -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(DISMISS_KEY, false); + const [seen, setSeen] = useLocalStorage(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'; diff --git a/src/components/auth/SignupDialog.tsx b/src/components/auth/SignupDialog.tsx index 3945b3a..cdcde83 100644 --- a/src/components/auth/SignupDialog.tsx +++ b/src/components/auth/SignupDialog.tsx @@ -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 = ({ 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(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 &&

{encError}

}