Fix crew card layout, Amber web login hang, Stash encryption gate, and add signup welcome step

- CrewCard: rebuilt to fit any screen width — clip overflow, truncate name/location, single wrapping badge row, consistent avatar sizing; responsive header
- Amber web login: after opening Amber, drop user on manual paste step after 2.5s so the spinner never hangs when the callback opens in a new tab or clipboard is blocked; keep polling on the paste step so a late callback still auto-completes
- Stash: hasNip44/hasNip04 now fall back to the active login when pubkey matching fails, so logged-in nsec/amber users are no longer wrongly told "Encryption Required"
- Signup: added an explicit welcome step that explains account creation and only generates the keypair when the user taps "Create my account" (no more silent auto-creation on dialog open)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 13:27:24 +07:00
parent 945678c957
commit 4ed1d9a803
4 changed files with 163 additions and 64 deletions

View file

@ -80,12 +80,25 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
// Store return path before navigating away
const currentPath = window.location.hash.replace(/^#/, '') || '/';
localStorage.setItem('amber_return_path', currentPath);
// Listen for app regaining focus (after Amber redirect)
// Listen for app regaining focus (after Amber redirect). If Amber's
// redirect succeeded, checkAmberPendingPubkey completes the login; if not,
// it drops the user on the paste step.
const unsub = subscribeToAmberReturn(() => {
checkAmberPendingPubkey();
});
window.__amberUnsubRef = unsub;
openAmberForLogin();
// CRITICAL web-flow fallback: on mobile browsers Amber frequently opens the
// callback in a NEW tab (so the original tab never fires visibilitychange)
// and/or the clipboard read is blocked by permissions. In those cases the
// spinner would hang until the 3-minute timeout — exactly the "stuck
// loading" bug. So after a short grace period we surface the manual paste
// step, which ALWAYS works (user taps "Copy public key" in Amber, pastes
// here). The background poll still auto-completes if the callback lands.
window.setTimeout(() => {
setAmberStep((step) => (step === 'loading' ? 'paste' : step));
}, 2500);
};
// Check if AmberCallbackPage already wrote a pubkey to localStorage
@ -122,9 +135,12 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
setAmberStep('paste');
};
// Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate tab)
// Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate
// tab). We keep polling on BOTH 'loading' and 'paste' so that even after we
// drop the user on the manual paste step, a late-arriving callback still
// auto-completes the login for them.
useEffect(() => {
if (amberStep !== 'loading') return;
if (amberStep !== 'loading' && amberStep !== 'paste') return;
const interval = setInterval(() => {
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
@ -136,7 +152,6 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
// Stop polling after 3 minutes
const timeout = setTimeout(() => {
clearInterval(interval);
if (amberStep === 'loading') setAmberStep('paste');
}, 180_000);
return () => { clearInterval(interval); clearTimeout(timeout); };
// eslint-disable-next-line react-hooks/exhaustive-deps

View file

@ -27,7 +27,7 @@ const sanitizeFilename = (filename: string) => {
}
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete }) => {
const [step, setStep] = useState<'profile' | 'done'>('profile');
const [step, setStep] = useState<'welcome' | 'profile' | 'done'>('welcome');
const [nsec, setNsec] = useState('');
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
const [showNsec, setShowNsec] = useState(false);
@ -199,6 +199,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
};
const getTitle = () => {
if (step === 'welcome') return (
<span className="flex items-center justify-center gap-2">
Join SkateSpots
</span>
);
if (step === 'profile') return (
<span className="flex items-center justify-center gap-2">
Set Up Your Profile
@ -211,20 +216,28 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
);
};
// Reset state when dialog opens, and silently create + log in the account
// so the skater lands straight on profile setup — no crypto steps, no gates.
// Reset state when dialog opens. We now start on an explicit WELCOME step and
// only create the keypair when the skater taps "Create my account" — this
// gives signup a real, understandable flow instead of silently minting an
// account behind their back the instant the dialog opens.
useEffect(() => {
if (isOpen) {
setStep('profile');
setStep('welcome');
setNsec('');
setKeySecured('none');
setShowNsec(false);
setProfileData({ name: '', about: '', picture: '' });
createAccountSilently();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
// Create the account (generate key + log in), then advance to profile setup.
const handleCreateAccount = () => {
if (createAccountSilently()) {
setStep('profile');
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
@ -236,6 +249,54 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
</DialogTitle>
</DialogHeader>
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
{/* Welcome Step — explain what's about to happen, then create on tap */}
{step === 'welcome' && (
<div className='space-y-5 text-center'>
<div className='relative overflow-hidden rounded-2xl border-2 border-[#ccff00]/40 bg-gradient-to-br from-[#ccff00]/10 to-[#00ff00]/5 p-6'>
<div className='absolute inset-0 bg-[#ccff00]/5 blur-2xl' />
<div className='relative z-10 space-y-3'>
<div className='mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-[#ccff00]/15 text-4xl'>
🛹
</div>
<h3 className='text-xl font-black uppercase text-[#ccff00]'>
Create your free account
</h3>
<p className='text-sm text-gray-300'>
No email, no phone, no password. We'll generate a private key that lives
<strong className='text-white'> only on your device</strong> — that key <em>is</em> your account.
</p>
</div>
</div>
<div className='space-y-2 text-left'>
{[
['🔑', 'A secure key is created just for you'],
['📱', 'It stays on this device — nobody else can see it'],
['💾', "You'll get a chance to back it up right after"],
].map(([icon, label]) => (
<div key={label} className='flex items-center gap-3 rounded-lg border border-white/10 bg-black/40 px-3 py-2.5'>
<span className='text-lg'>{icon}</span>
<span className='text-sm text-gray-300'>{label}</span>
</div>
))}
</div>
<Button
className='w-full rounded-full py-4 text-base font-black uppercase bg-[#ccff00] text-black hover:bg-[#ccff00]/90'
onClick={handleCreateAccount}
>
<Sparkles className='mr-2 h-4 w-4' />
Create my account
</Button>
<p className='text-xs text-gray-500'>
By continuing you're generating a Nostr keypair. Advanced users can
{' '}
<Link to='/signers' onClick={onClose} className='text-[#ccff00] underline'>use a signer app</Link>
{' '}instead.
</p>
</div>
)}
{/* Profile Step - Optional profile setup */}
{step === 'profile' && (
<div className='text-center space-y-4'>

View file

@ -26,13 +26,32 @@ function windowNostr(): { nip44?: unknown; nip04?: unknown } | undefined {
return (globalThis as { nostr?: { nip44?: unknown; nip04?: unknown } }).nostr;
}
/**
* Resolve the login that backs the current user.
*
* IMPORTANT: we cannot rely solely on `l.pubkey === user.pubkey`. Depending on
* the login method and how Nostrify rehydrates from localStorage, the top-level
* `pubkey` on a stored login object is not always populated (nsec logins in
* particular expose the pubkey nested under the decrypted key material). When a
* pubkey match fails we fall back to the ACTIVE login (`logins[0]`), which is
* exactly the login `useCurrentUser` turns into `users[0]` / `user`. Without
* this fallback, a perfectly capable nsec/amber user gets wrongly told
* "Encryption Required" on the Stash page.
*/
function resolveLogin(
user: MaybeUser | null | undefined,
logins: readonly MaybeLogin[],
): MaybeLogin | undefined {
if (!user?.pubkey || logins.length === 0) return undefined;
return logins.find((l) => l.pubkey === user.pubkey) ?? logins[0];
}
/** True if the user's signer can do NIP-44 encryption. Never throws. */
export function hasNip44(
user: MaybeUser | null | undefined,
logins: readonly MaybeLogin[],
): boolean {
if (!user?.pubkey) return false;
const login = logins.find((l) => l.pubkey === user.pubkey);
const login = resolveLogin(user, logins);
if (!login) return false;
// nsec + bunker: NIP-44 always available (pure JS / remote signer implements it)
if (login.type === 'nsec' || login.type === 'bunker') return true;
@ -50,8 +69,7 @@ export function hasNip04(
user: MaybeUser | null | undefined,
logins: readonly MaybeLogin[],
): boolean {
if (!user?.pubkey) return false;
const login = logins.find((l) => l.pubkey === user.pubkey);
const login = resolveLogin(user, logins);
if (!login) return false;
if (login.type === 'nsec' || login.type === 'bunker') return true;
if (login.type === 'x-amber') return true;

View file

@ -47,46 +47,68 @@ function CrewCard({
distanceKm: number | null;
}) {
const accent = crewAccent(crew);
const FocusIcon = crew.focus !== 'mixed' ? FOCUS_BADGE_ICONS[crew.focus] : null;
return (
<Link
to={`/crew/${crew.pubkey}/${crew.identifier}`}
className={cn(
'group flex items-center gap-3 rounded-2xl border p-3 transition-all active:scale-[0.99]',
'group flex w-full items-start gap-3 overflow-hidden rounded-2xl border p-3 text-left transition-colors active:scale-[0.99]',
accent.border,
accent.bg,
accent.hoverBorder,
'hover:bg-white/[0.06]',
)}
>
<Avatar className="h-14 w-14 rounded-2xl border-2" style={{ borderColor: `${accent.hex}55` }}>
<Avatar
className="h-12 w-12 flex-shrink-0 rounded-xl border-2"
style={{ borderColor: `${accent.hex}55` }}
>
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
<AvatarFallback className="rounded-2xl font-black" style={{ color: accent.hex, background: `${accent.hex}22` }}>
<AvatarFallback
className="rounded-xl text-sm font-black"
style={{ color: accent.hex, background: `${accent.hex}22` }}
>
{crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h3 className="flex items-center gap-1.5 truncate font-black leading-tight text-white">
<span className="truncate">{crew.name}</span>
<div className="min-w-0 flex-1 space-y-1">
{/* Name + member count */}
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="min-w-0 flex-1 truncate text-sm font-black leading-tight text-white">
{crew.name}
</h3>
<span
className="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold"
style={{ color: accent.hex, background: `${accent.hex}22` }}
>
<Users className="h-2.5 w-2.5" /> {crew.members.length}
<Users className="h-2.5 w-2.5" />
{crew.members.length}
</span>
</h3>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
{/* Location line */}
{(crew.location || distanceKm !== null) && (
<p className="flex min-w-0 items-center gap-1 text-xs text-gray-400">
<MapPin className="h-3 w-3 flex-shrink-0" style={{ color: accent.hex }} />
<span className="min-w-0 truncate">{crew.location}</span>
{distanceKm !== null && (
<span className="shrink-0 font-bold" style={{ color: accent.hex }}>
· {formatDistance(distanceKm)}
</span>
)}
</p>
)}
{/* Badges — all on one wrapping row, clipped so nothing spills */}
<div className="flex min-w-0 flex-wrap items-center gap-1">
<CrewRepBadge crew={crew} />
<CrewActivityPulse activity={activity} />
{crew.focus !== 'mixed' && (() => {
const FocusIcon = FOCUS_BADGE_ICONS[crew.focus];
return (
<span className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-300">
<FocusIcon className="h-2.5 w-2.5" />
{CREW_FOCUS_LABELS[crew.focus]}
</span>
);
})()}
{FocusIcon && (
<span className="inline-flex max-w-full items-center gap-1 truncate rounded-full border border-white/10 bg-white/[0.06] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-gray-300">
<FocusIcon className="h-2.5 w-2.5 shrink-0" />
{CREW_FOCUS_LABELS[crew.focus]}
</span>
)}
{crew.bandName && (
<span
className="inline-flex items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-purple-300"
@ -96,33 +118,16 @@ function CrewCard({
Band
</span>
)}
{crew.vibes.slice(0, 2).map((v) => (
<span
key={v}
className="inline-flex max-w-full items-center truncate rounded-full border px-2 py-0.5 text-[10px] font-bold"
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}18` }}
>
{CREW_VIBE_LABELS[v] || v}
</span>
))}
</div>
{(crew.location || distanceKm !== null) && (
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-gray-400">
<MapPin className="h-3 w-3 flex-shrink-0" style={{ color: accent.hex }} />
{crew.location}
{distanceKm !== null && (
<span className="ml-1 font-bold" style={{ color: accent.hex }}>
· {formatDistance(distanceKm)}
</span>
)}
</p>
)}
{crew.vibes.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{crew.vibes.slice(0, 3).map((v) => (
<span
key={v}
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-bold"
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}18` }}
>
{CREW_VIBE_LABELS[v] || v}
</span>
))}
</div>
)}
</div>
</Link>
);
@ -193,13 +198,13 @@ export default function CrewsPage() {
<Layout>
<div className="container max-w-3xl px-4 py-6">
{/* Header */}
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
className="flex items-center gap-2 text-3xl font-black uppercase text-teal-400"
className="flex items-center gap-2 text-2xl font-black uppercase text-teal-400 sm:text-3xl"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<Users className="h-7 w-7" /> Crews
<Users className="h-6 w-6 shrink-0 sm:h-7 sm:w-7" /> Crews
</h1>
<p className="mt-1 text-sm text-gray-400">Your map-anchored skate family — the people you skate <span className="text-teal-400/80 font-semibold">with</span>, not who you hire (that's The Lab).</p>
{/* Social-proof stat line */}
@ -210,7 +215,7 @@ export default function CrewsPage() {
</p>
)}
</div>
<Link to="/create-crew">
<Link to="/create-crew" className="shrink-0">
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black hover:bg-teal-600">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Crew</span>
@ -300,7 +305,7 @@ export default function CrewsPage() {
) : filtered.length === 0 ? (
<EmptyState query={query} onNearMe={handleNearMe} hasLocation={!!userLoc} />
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{filtered.map((crew) => (
<CrewCard
key={`${crew.pubkey}:${crew.identifier}`}