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:
parent
945678c957
commit
4ed1d9a803
4 changed files with 163 additions and 64 deletions
|
|
@ -80,12 +80,25 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
// Store return path before navigating away
|
// Store return path before navigating away
|
||||||
const currentPath = window.location.hash.replace(/^#/, '') || '/';
|
const currentPath = window.location.hash.replace(/^#/, '') || '/';
|
||||||
localStorage.setItem('amber_return_path', currentPath);
|
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(() => {
|
const unsub = subscribeToAmberReturn(() => {
|
||||||
checkAmberPendingPubkey();
|
checkAmberPendingPubkey();
|
||||||
});
|
});
|
||||||
window.__amberUnsubRef = unsub;
|
window.__amberUnsubRef = unsub;
|
||||||
openAmberForLogin();
|
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
|
// Check if AmberCallbackPage already wrote a pubkey to localStorage
|
||||||
|
|
@ -122,9 +135,12 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
setAmberStep('paste');
|
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(() => {
|
useEffect(() => {
|
||||||
if (amberStep !== 'loading') return;
|
if (amberStep !== 'loading' && amberStep !== 'paste') return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
|
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
|
||||||
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
|
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
|
// Stop polling after 3 minutes
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
if (amberStep === 'loading') setAmberStep('paste');
|
|
||||||
}, 180_000);
|
}, 180_000);
|
||||||
return () => { clearInterval(interval); clearTimeout(timeout); };
|
return () => { clearInterval(interval); clearTimeout(timeout); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const sanitizeFilename = (filename: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete }) => {
|
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 [nsec, setNsec] = useState('');
|
||||||
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
|
const [keySecured, setKeySecured] = useState<'none' | 'downloaded'>('none');
|
||||||
const [showNsec, setShowNsec] = useState(false);
|
const [showNsec, setShowNsec] = useState(false);
|
||||||
|
|
@ -199,6 +199,11 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTitle = () => {
|
const getTitle = () => {
|
||||||
|
if (step === 'welcome') return (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
Join SkateSpots
|
||||||
|
</span>
|
||||||
|
);
|
||||||
if (step === 'profile') return (
|
if (step === 'profile') return (
|
||||||
<span className="flex items-center justify-center gap-2">
|
<span className="flex items-center justify-center gap-2">
|
||||||
Set Up Your Profile
|
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
|
// Reset state when dialog opens. We now start on an explicit WELCOME step and
|
||||||
// so the skater lands straight on profile setup — no crypto steps, no gates.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setStep('profile');
|
setStep('welcome');
|
||||||
setNsec('');
|
setNsec('');
|
||||||
setKeySecured('none');
|
setKeySecured('none');
|
||||||
setShowNsec(false);
|
setShowNsec(false);
|
||||||
setProfileData({ name: '', about: '', picture: '' });
|
setProfileData({ name: '', about: '', picture: '' });
|
||||||
createAccountSilently();
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Create the account (generate key + log in), then advance to profile setup.
|
||||||
|
const handleCreateAccount = () => {
|
||||||
|
if (createAccountSilently()) {
|
||||||
|
setStep('profile');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
|
@ -236,6 +249,54 @@ const SignupDialog: React.FC<SignupDialogProps> = ({ isOpen, onClose, onComplete
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className='px-4 sm:px-6 pt-2 pb-4 space-y-4 overflow-y-auto flex-1'>
|
<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 */}
|
{/* Profile Step - Optional profile setup */}
|
||||||
{step === 'profile' && (
|
{step === 'profile' && (
|
||||||
<div className='text-center space-y-4'>
|
<div className='text-center space-y-4'>
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,32 @@ function windowNostr(): { nip44?: unknown; nip04?: unknown } | undefined {
|
||||||
return (globalThis as { nostr?: { nip44?: unknown; nip04?: unknown } }).nostr;
|
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. */
|
/** True if the user's signer can do NIP-44 encryption. Never throws. */
|
||||||
export function hasNip44(
|
export function hasNip44(
|
||||||
user: MaybeUser | null | undefined,
|
user: MaybeUser | null | undefined,
|
||||||
logins: readonly MaybeLogin[],
|
logins: readonly MaybeLogin[],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!user?.pubkey) return false;
|
const login = resolveLogin(user, logins);
|
||||||
const login = logins.find((l) => l.pubkey === user.pubkey);
|
|
||||||
if (!login) return false;
|
if (!login) return false;
|
||||||
// nsec + bunker: NIP-44 always available (pure JS / remote signer implements it)
|
// nsec + bunker: NIP-44 always available (pure JS / remote signer implements it)
|
||||||
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
||||||
|
|
@ -50,8 +69,7 @@ export function hasNip04(
|
||||||
user: MaybeUser | null | undefined,
|
user: MaybeUser | null | undefined,
|
||||||
logins: readonly MaybeLogin[],
|
logins: readonly MaybeLogin[],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!user?.pubkey) return false;
|
const login = resolveLogin(user, logins);
|
||||||
const login = logins.find((l) => l.pubkey === user.pubkey);
|
|
||||||
if (!login) return false;
|
if (!login) return false;
|
||||||
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
if (login.type === 'nsec' || login.type === 'bunker') return true;
|
||||||
if (login.type === 'x-amber') return true;
|
if (login.type === 'x-amber') return true;
|
||||||
|
|
|
||||||
|
|
@ -47,46 +47,68 @@ function CrewCard({
|
||||||
distanceKm: number | null;
|
distanceKm: number | null;
|
||||||
}) {
|
}) {
|
||||||
const accent = crewAccent(crew);
|
const accent = crewAccent(crew);
|
||||||
|
const FocusIcon = crew.focus !== 'mixed' ? FOCUS_BADGE_ICONS[crew.focus] : null;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
||||||
className={cn(
|
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.border,
|
||||||
accent.bg,
|
accent.bg,
|
||||||
accent.hoverBorder,
|
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" />
|
<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()}
|
{crew.name.slice(0, 2).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="flex items-center gap-1.5 truncate font-black leading-tight text-white">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<span className="truncate">{crew.name}</span>
|
{/* 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
|
<span
|
||||||
className="inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5 text-[10px] font-bold"
|
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` }}
|
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>
|
</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} />
|
<CrewRepBadge crew={crew} />
|
||||||
<CrewActivityPulse activity={activity} />
|
<CrewActivityPulse activity={activity} />
|
||||||
{crew.focus !== 'mixed' && (() => {
|
{FocusIcon && (
|
||||||
const FocusIcon = FOCUS_BADGE_ICONS[crew.focus];
|
<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">
|
||||||
return (
|
<FocusIcon className="h-2.5 w-2.5 shrink-0" />
|
||||||
<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">
|
{CREW_FOCUS_LABELS[crew.focus]}
|
||||||
<FocusIcon className="h-2.5 w-2.5" />
|
</span>
|
||||||
{CREW_FOCUS_LABELS[crew.focus]}
|
)}
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{crew.bandName && (
|
{crew.bandName && (
|
||||||
<span
|
<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"
|
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
|
Band
|
||||||
</span>
|
</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>
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
@ -193,13 +198,13 @@ export default function CrewsPage() {
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="container max-w-3xl px-4 py-6">
|
<div className="container max-w-3xl px-4 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-4 flex items-start justify-between gap-4">
|
<div className="mb-4 flex items-start justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1
|
<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' }}
|
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>
|
</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>
|
<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 */}
|
{/* Social-proof stat line */}
|
||||||
|
|
@ -210,7 +215,7 @@ export default function CrewsPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black hover:bg-teal-600">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Create Crew</span>
|
<span className="hidden sm:inline">Create Crew</span>
|
||||||
|
|
@ -300,7 +305,7 @@ export default function CrewsPage() {
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<EmptyState query={query} onNearMe={handleNearMe} hasLocation={!!userLoc} />
|
<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) => (
|
{filtered.map((crew) => (
|
||||||
<CrewCard
|
<CrewCard
|
||||||
key={`${crew.pubkey}:${crew.identifier}`}
|
key={`${crew.pubkey}:${crew.identifier}`}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue