fix(crew-network): comprehensive UX/accessibility overhaul
QR rendering:
- Fixed layout shift (CLS) — canvas and spinner now occupy the same fixed
236×236 container; spinner fades out, canvas fades in, no height jump
- Added error state with Retry button when QRCode.toCanvas() throws
- Canvas now has role="img" + descriptive aria-label for screen readers
- "Save QR" button exports canvas → PNG via toDataURL (mobile share flow)
Deep link / "Open in app":
- Replaced document.createElement('a').click() with window.location.href
(the only reliable way to fire custom schemes on iOS Safari PWA)
- Added 2 s fallback timeout: if the page doesn't lose focus (app not
installed), automatically redirects to App Store / GitHub releases
- Button shows "Opening…" state while the timeout is pending
- Added hint text explaining the fallback behaviour
Platform detection:
- detectPlatform() reads navigator.userAgent to identify iOS/Android/desktop
- Download list is sorted so the most-relevant entry appears first
- Each download row now has a sublabel (e.g. "iPhone & iPad", "arm64 devices")
- Added Android APK entry (was missing)
Accessibility:
- All interactive buttons have focus-visible:ring-2 keyboard focus rings
- Decorative icons marked aria-hidden="true" throughout
- "How it works" list changed from <div> to <ol>/<li> with aria-label
- Download list uses role="list" / role="listitem"
- Use-cases grid uses <ul>/<li> with aria-label
- "Learn more" link has full descriptive aria-label inc. "(opens in new tab)"
- Action buttons have aria-label for screen readers
Contrast & copy:
- URI preview: text-muted-foreground/60 → text-white/70 (passes WCAG AA)
- URI box is now select-all / cursor-text for easy manual copy
- Hero icon changed from Network → Shield (semantically: encrypted/private)
- Step 2 text adapts when logged out ("Log in to generate your QR")
- Use-case emoji changed 🎬→🔒 for "Private crew comms" (more on-message)
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
fb6c1bca36
commit
05c7b76d6d
1 changed files with 187 additions and 76 deletions
|
|
@ -404,16 +404,42 @@ function WalletScreen({ onBack }: { onBack: () => void }) {
|
||||||
const NOSTR_VPN_APP_STORE = 'https://apps.apple.com/app/nostr-vpn/id6785410348';
|
const NOSTR_VPN_APP_STORE = 'https://apps.apple.com/app/nostr-vpn/id6785410348';
|
||||||
const NOSTR_VPN_GITHUB = 'https://github.com/mmalmi/nostr-vpn/releases/latest';
|
const NOSTR_VPN_GITHUB = 'https://github.com/mmalmi/nostr-vpn/releases/latest';
|
||||||
const NOSTR_VPN_TESTFLIGHT = 'https://testflight.apple.com/join/58sg4agv';
|
const NOSTR_VPN_TESTFLIGHT = 'https://testflight.apple.com/join/58sg4agv';
|
||||||
|
const NOSTR_VPN_ANDROID = 'https://github.com/mmalmi/nostr-vpn/releases/latest';
|
||||||
|
|
||||||
|
/** Detect the user's OS/platform to surface the most relevant download first. */
|
||||||
|
function detectPlatform(): 'ios' | 'android' | 'desktop' {
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
if (/android/i.test(ua)) return 'android';
|
||||||
|
if (/iphone|ipad|ipod/i.test(ua)) return 'ios';
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_PLATFORMS = [
|
||||||
|
{ id: 'ios', label: 'iOS App Store', sublabel: 'iPhone & iPad', icon: Smartphone, url: NOSTR_VPN_APP_STORE, color: 'text-blue-400', bg: 'bg-blue-500/15', badge: 'Free' },
|
||||||
|
{ id: 'testflight',label: 'iOS TestFlight', sublabel: 'Beta builds', icon: Smartphone, url: NOSTR_VPN_TESTFLIGHT, color: 'text-indigo-400', bg: 'bg-indigo-500/15', badge: 'Beta' },
|
||||||
|
{ id: 'android', label: 'Android APK', sublabel: 'arm64 devices', icon: Smartphone, url: NOSTR_VPN_ANDROID, color: 'text-green-400', bg: 'bg-green-500/15', badge: 'APK' },
|
||||||
|
{ id: 'desktop', label: 'macOS / Linux / Windows',sublabel: 'Native desktop apps', icon: Download, url: NOSTR_VPN_GITHUB, color: 'text-amber-400', bg: 'bg-amber-500/15', badge: 'Open source' },
|
||||||
|
];
|
||||||
|
|
||||||
function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
const { user, metadata } = useCurrentUser();
|
const { user, metadata } = useCurrentUser();
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [qrReady, setQrReady] = useState(false);
|
const [qrReady, setQrReady] = useState(false);
|
||||||
|
const [qrError, setQrError] = useState(false);
|
||||||
|
const [openingApp, setOpeningApp] = useState(false);
|
||||||
|
|
||||||
|
// Detect platform once — sort most-relevant download to the top.
|
||||||
|
const platform = detectPlatform();
|
||||||
|
const platforms = [...ALL_PLATFORMS].sort((a) => {
|
||||||
|
if (platform === 'ios' && (a.id === 'ios' || a.id === 'testflight')) return -1;
|
||||||
|
if (platform === 'android' && a.id === 'android') return -1;
|
||||||
|
if (platform === 'desktop' && a.id === 'desktop') return -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
// Build the join-request deep link using the user's npub as device identity.
|
// Build the join-request deep link using the user's npub as device identity.
|
||||||
// Format: nvpn://join-request?pubkey=<hex>&name=<displayName>
|
// Format: nvpn://join-request?pubkey=<hex>&name=<displayName>&relay=<relay>
|
||||||
// The nostr-vpn app uses the Nostr pubkey as stable device identity.
|
|
||||||
const joinUri = user
|
const joinUri = user
|
||||||
? `nvpn://join-request?pubkey=${user.pubkey}&name=${encodeURIComponent(metadata?.name ?? 'Skater')}&relay=${encodeURIComponent('wss://goskatespots.nostr1.com')}`
|
? `nvpn://join-request?pubkey=${user.pubkey}&name=${encodeURIComponent(metadata?.name ?? 'Skater')}&relay=${encodeURIComponent('wss://goskatespots.nostr1.com')}`
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -421,6 +447,7 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
const renderQR = useCallback(async () => {
|
const renderQR = useCallback(async () => {
|
||||||
if (!canvasRef.current || !joinUri) return;
|
if (!canvasRef.current || !joinUri) return;
|
||||||
try {
|
try {
|
||||||
|
setQrError(false);
|
||||||
await QRCode.toCanvas(canvasRef.current, joinUri, {
|
await QRCode.toCanvas(canvasRef.current, joinUri, {
|
||||||
width: 220,
|
width: 220,
|
||||||
margin: 2,
|
margin: 2,
|
||||||
|
|
@ -429,7 +456,7 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
});
|
});
|
||||||
setQrReady(true);
|
setQrReady(true);
|
||||||
} catch {
|
} catch {
|
||||||
// silently ignore QR render errors
|
setQrError(true);
|
||||||
}
|
}
|
||||||
}, [joinUri]);
|
}, [joinUri]);
|
||||||
|
|
||||||
|
|
@ -442,24 +469,56 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(joinUri);
|
await navigator.clipboard.writeText(joinUri);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2500);
|
||||||
toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' });
|
toast({ title: 'Copied!', description: 'Join request link copied to clipboard.' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Copy failed', description: 'Could not copy to clipboard.', variant: 'destructive' });
|
toast({ title: 'Copy failed', description: 'Could not access clipboard.', variant: 'destructive' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenApp = () => {
|
// Save the QR canvas as a PNG the user can share.
|
||||||
// Try to open the native app via deep link; if not installed, fall through to App Store
|
const handleDownloadQR = () => {
|
||||||
|
if (!canvasRef.current || !qrReady) return;
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = joinUri;
|
link.download = 'nostr-vpn-join-request.png';
|
||||||
|
link.href = canvasRef.current.toDataURL('image/png');
|
||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const platforms = [
|
// Open the deep link on the device. On iOS Safari, window.location.href inside
|
||||||
{ label: 'iOS App Store', icon: Smartphone, url: NOSTR_VPN_APP_STORE, color: 'text-blue-400', bg: 'bg-blue-500/15', badge: 'Free' },
|
// a user-gesture handler is the only reliable way to fire custom schemes.
|
||||||
{ label: 'iOS TestFlight', icon: Smartphone, url: NOSTR_VPN_TESTFLIGHT, color: 'text-indigo-400', bg: 'bg-indigo-500/15', badge: 'Beta' },
|
// After 2 s with no visibility change, assume the app isn't installed and
|
||||||
{ label: 'macOS / Linux / Windows', icon: Download, url: NOSTR_VPN_GITHUB, color: 'text-green-400', bg: 'bg-green-500/15', badge: 'Open source' },
|
// redirect to the correct store page.
|
||||||
|
const handleOpenApp = () => {
|
||||||
|
if (!joinUri) return;
|
||||||
|
setOpeningApp(true);
|
||||||
|
|
||||||
|
const fallbackUrl = platform === 'android' ? NOSTR_VPN_ANDROID : NOSTR_VPN_APP_STORE;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
// Still here → app not installed → go to store/release page
|
||||||
|
window.location.href = fallbackUrl;
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// Cancel the fallback if the page loses focus (app opened successfully).
|
||||||
|
const cancel = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
setOpeningApp(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('blur', cancel, { once: true });
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) cancel();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
window.location.href = joinUri;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Steps adapt: if the user is logged out, step 2 says "log in" instead of "show QR".
|
||||||
|
const steps = [
|
||||||
|
{ n: '1', text: 'Install nostr-vpn on your device (free, open source)' },
|
||||||
|
{ n: '2', text: user ? 'Show your QR code above to a crew admin' : 'Log in to SkateSpots to generate your personal QR' },
|
||||||
|
{ n: '3', text: 'Admin approves — you receive a cryptographically signed roster' },
|
||||||
|
{ n: '4', text: 'WireGuard-grade encrypted mesh tunnel connects you to your crew' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -471,149 +530,200 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ccff00]/5 to-transparent p-5">
|
<div className="relative rounded-2xl overflow-hidden border border-[#ccff00]/20 bg-gradient-to-br from-[#ccff00]/5 to-transparent p-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="w-10 h-10 rounded-2xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0 mt-0.5">
|
<div className="w-10 h-10 rounded-2xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||||
<Network className="h-5 w-5 text-[#ccff00]" />
|
<Shield className="h-5 w-5 text-[#ccff00]" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">Private Mesh VPN</p>
|
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">Private Encrypted Mesh</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||||
<strong className="text-white">nostr-vpn</strong> turns your Nostr keys into a private mesh network for your crew.
|
<strong className="text-white">nostr-vpn</strong> turns your Nostr keys into a private WireGuard mesh for your crew.
|
||||||
No accounts, no servers — your key is your identity. Encrypted peer-to-peer tunnels between skaters.
|
No accounts, no central server — your key is your identity. Your crew, your network.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR + join request — only if logged in */}
|
{/* QR + join request */}
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1">
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1">
|
||||||
Your Join Request
|
Your Join Request
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-2xl border border-white/8 bg-white/2 p-5 flex flex-col items-center gap-4">
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-5 flex flex-col items-center gap-4">
|
||||||
{/* QR code canvas */}
|
|
||||||
<div className="rounded-xl overflow-hidden bg-white p-2 shadow-lg shadow-black/40">
|
{/* QR canvas — fixed container prevents layout shift */}
|
||||||
<canvas
|
<div className="relative w-[236px] h-[236px] flex-shrink-0">
|
||||||
ref={canvasRef}
|
<div className="absolute inset-0 rounded-xl overflow-hidden bg-white shadow-lg shadow-black/40 flex items-center justify-center">
|
||||||
className={cn('block transition-opacity duration-300', qrReady ? 'opacity-100' : 'opacity-0')}
|
{/* Loading spinner — shown until QR renders */}
|
||||||
/>
|
{!qrReady && !qrError && (
|
||||||
{!qrReady && (
|
<div
|
||||||
<div className="w-[220px] h-[220px] flex items-center justify-center">
|
role="status"
|
||||||
<div className="w-8 h-8 rounded-full border-2 border-black/20 border-t-black animate-spin" />
|
aria-label="Generating QR code"
|
||||||
</div>
|
className="flex flex-col items-center gap-2"
|
||||||
)}
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full border-2 border-black/15 border-t-black animate-spin" />
|
||||||
|
<p className="text-[10px] text-black/40 font-medium">Generating…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Error state */}
|
||||||
|
{qrError && (
|
||||||
|
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
||||||
|
<AlertCircle className="h-6 w-6 text-red-400" />
|
||||||
|
<p className="text-[11px] text-black/60">QR failed to render</p>
|
||||||
|
<button
|
||||||
|
onClick={renderQR}
|
||||||
|
className="text-[11px] font-bold text-blue-600 underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
role="img"
|
||||||
|
aria-label="nostr-vpn join request QR code — scan with the nostr-vpn app to join your crew network"
|
||||||
|
className={cn(
|
||||||
|
'block p-2 transition-opacity duration-300',
|
||||||
|
qrReady ? 'opacity-100' : 'opacity-0 absolute'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full space-y-2 text-center">
|
<div className="w-full space-y-2.5 text-center">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
Show this QR to an admin in the nostr-vpn app.
|
Show this QR to an admin in the nostr-vpn app.
|
||||||
They scan it, approve you, and you're on the mesh.
|
They scan → approve → you're on the encrypted mesh.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* URI preview */}
|
{/* URI display — improved contrast, selectable */}
|
||||||
<div className="rounded-xl bg-black/30 border border-white/8 px-3 py-2 text-left">
|
<div className="rounded-xl bg-black/40 border border-white/10 px-3 py-2.5 text-left">
|
||||||
<p className="text-[10px] font-mono text-muted-foreground/60 break-all leading-relaxed">
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 mb-1">Join URI</p>
|
||||||
{joinUri.length > 120 ? joinUri.slice(0, 120) + '…' : joinUri}
|
<p
|
||||||
|
className="text-[10px] font-mono text-white/70 break-all leading-relaxed select-all cursor-text"
|
||||||
|
title="Tap to select all, then copy"
|
||||||
|
>
|
||||||
|
{joinUri.length > 130 ? joinUri.slice(0, 130) + '…' : joinUri}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions row */}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98]"
|
aria-label={copied ? 'Link copied to clipboard' : 'Copy join request link'}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="h-3.5 w-3.5 text-[#ccff00]" /> : <Copy className="h-3.5 w-3.5" />}
|
{copied
|
||||||
|
? <Check className="h-3.5 w-3.5 text-[#ccff00]" aria-hidden="true" />
|
||||||
|
: <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||||
{copied ? 'Copied!' : 'Copy link'}
|
{copied ? 'Copied!' : 'Copy link'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenApp}
|
onClick={handleDownloadQR}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#ccff00]/15 hover:bg-[#ccff00]/25 border border-[#ccff00]/30 text-xs font-bold text-[#ccff00] transition-all active:scale-[0.98]"
|
disabled={!qrReady}
|
||||||
|
aria-label="Download QR code as PNG image"
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-bold text-white transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Network className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
Open in app
|
Save QR
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleOpenApp}
|
||||||
|
disabled={openingApp}
|
||||||
|
aria-label="Open nostr-vpn app with this join request"
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#ccff00]/15 hover:bg-[#ccff00]/25 border border-[#ccff00]/30 text-xs font-bold text-[#ccff00] transition-all active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ccff00]/50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<Network className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{openingApp ? 'Opening…' : 'Open app'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Deep link hint */}
|
||||||
|
<p className="text-[10px] text-muted-foreground/40 leading-tight">
|
||||||
|
"Open app" requires nostr-vpn installed. If not installed, you'll be redirected to the download page.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-white/8 bg-white/2 p-6 text-center">
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-6 text-center">
|
||||||
<Network className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" />
|
<Shield className="h-8 w-8 mx-auto mb-3 text-muted-foreground/30" aria-hidden="true" />
|
||||||
<p className="text-sm font-bold text-white mb-1">Login required</p>
|
<p className="text-sm font-bold text-white mb-1">Login required</p>
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
<p className="text-xs text-muted-foreground mb-4 leading-relaxed">
|
||||||
Log in with your Nostr keys to generate a join request for your crew's mesh network.
|
Log in with your Nostr keys to generate a personal join request QR for your crew's encrypted mesh.
|
||||||
</p>
|
</p>
|
||||||
<LoginArea className="justify-center" />
|
<LoginArea className="justify-center" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works — steps adapt based on login state */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
||||||
How It Works
|
How It Works
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-2xl border border-white/8 bg-white/2 divide-y divide-white/5 overflow-hidden">
|
<ol className="rounded-2xl border border-white/8 bg-white/2 divide-y divide-white/5 overflow-hidden" aria-label="Steps to join a crew network">
|
||||||
{[
|
{steps.map(({ n, text }) => (
|
||||||
{ n: '1', text: 'Install nostr-vpn on your device (free, open source)' },
|
<li key={n} className="flex items-center gap-3 px-4 py-3">
|
||||||
{ n: '2', text: 'Show your QR code above to a crew admin' },
|
<span
|
||||||
{ n: '3', text: 'Admin approves — you get a signed roster back' },
|
aria-hidden="true"
|
||||||
{ n: '4', text: 'Encrypted mesh tunnel connects you to your crew' },
|
className="w-6 h-6 rounded-full bg-[#ccff00]/15 text-[#ccff00] text-[11px] font-black flex items-center justify-center flex-shrink-0"
|
||||||
].map(({ n, text }) => (
|
>
|
||||||
<div key={n} className="flex items-center gap-3 px-4 py-3">
|
|
||||||
<span className="w-6 h-6 rounded-full bg-[#ccff00]/15 text-[#ccff00] text-[11px] font-black flex items-center justify-center flex-shrink-0">
|
|
||||||
{n}
|
{n}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground leading-snug">{text}</p>
|
<p className="text-xs text-muted-foreground leading-snug">{text}</p>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download links */}
|
{/* Download links — platform-sorted */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50 px-1 mb-2">
|
||||||
Get nostr-vpn
|
Get nostr-vpn
|
||||||
</p>
|
</p>
|
||||||
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5 bg-white/2">
|
<div className="rounded-2xl border border-white/8 overflow-hidden divide-y divide-white/5 bg-white/2" role="list" aria-label="Download options">
|
||||||
{platforms.map(({ label, icon: Icon, url, color, bg, badge }) => (
|
{platforms.map(({ id, label, sublabel, icon: Icon, url, color, bg, badge }) => (
|
||||||
<a
|
<a
|
||||||
key={label}
|
key={id}
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 active:scale-[0.99] transition-all"
|
role="listitem"
|
||||||
|
aria-label={`${label} — ${badge}`}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 active:scale-[0.99] transition-all focus-visible:outline-none focus-visible:ring-inset focus-visible:ring-2 focus-visible:ring-white/30"
|
||||||
>
|
>
|
||||||
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', bg)}>
|
<div className={cn('w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0', bg)} aria-hidden="true">
|
||||||
<Icon className={cn('h-4 w-4', color)} />
|
<Icon className={cn('h-4 w-4', color)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold text-white leading-tight">{label}</p>
|
<p className="text-sm font-bold text-white leading-tight">{label}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground leading-tight mt-0.5">{sublabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground border border-white/10 flex-shrink-0">
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/5 text-muted-foreground border border-white/10 flex-shrink-0">
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" />
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What skaters use this for */}
|
{/* Use cases */}
|
||||||
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
|
<div className="rounded-2xl border border-white/8 bg-white/2 p-4 space-y-3">
|
||||||
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50">Use Cases</p>
|
<p className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50">Why Skaters Use It</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<ul className="grid grid-cols-2 gap-2" aria-label="Use cases">
|
||||||
{[
|
{[
|
||||||
{ emoji: '🎬', label: 'Private crew comms' },
|
{ emoji: '🔒', label: 'Private crew comms' },
|
||||||
{ emoji: '📍', label: 'Secret spot sharing' },
|
{ emoji: '📍', label: 'Secret spot sharing' },
|
||||||
{ emoji: '🌍', label: 'Censorship bypass' },
|
{ emoji: '🌍', label: 'Censorship bypass' },
|
||||||
{ emoji: '📶', label: 'Offline session mesh' },
|
{ emoji: '📶', label: 'Offline session mesh' },
|
||||||
].map(({ emoji, label }) => (
|
].map(({ emoji, label }) => (
|
||||||
<div key={label} className="flex items-center gap-2 p-2.5 rounded-xl bg-white/3 border border-white/6">
|
<li key={label} className="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/[0.06]">
|
||||||
<span className="text-base leading-none">{emoji}</span>
|
<span className="text-base leading-none" aria-hidden="true">{emoji}</span>
|
||||||
<p className="text-xs text-white/80 font-medium leading-tight">{label}</p>
|
<p className="text-xs text-white/80 font-medium leading-tight">{label}</p>
|
||||||
</div>
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Learn more */}
|
{/* Learn more */}
|
||||||
|
|
@ -621,10 +731,11 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
href="https://github.com/mmalmi/nostr-vpn"
|
href="https://github.com/mmalmi/nostr-vpn"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-between px-4 py-3.5 rounded-2xl border border-white/8 bg-white/2 hover:bg-white/5 transition-all"
|
aria-label="nostr-vpn on GitHub — source code, docs, protocol spec (opens in new tab)"
|
||||||
|
className="flex items-center justify-between px-4 py-3.5 rounded-2xl border border-white/8 bg-white/2 hover:bg-white/5 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-white/8 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-xl bg-white/8 flex items-center justify-center flex-shrink-0" aria-hidden="true">
|
||||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -632,7 +743,7 @@ function CrewNetworkScreen({ onBack }: { onBack: () => void }) {
|
||||||
<p className="text-xs text-muted-foreground">Source code, docs, protocol spec</p>
|
<p className="text-xs text-muted-foreground">Source code, docs, protocol spec</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" />
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue