feat: UX improvements - credits, draggable sasquatch, mobile fixes

1. AboutPage: add Credits section (Zapstore, ContextVM, Angor, Geyser, MKStack,
   Shakespeare, Nostr), add Zapstore download button, fix powered-by text overflow
   on mobile (wrap-friendly flex layout)

2. SasquatchGuide: make minimized bubble draggable on all devices
   - pointer events for mouse, touch, and stylus
   - saves position to localStorage, persists across sessions
   - tap still opens, drag moves (5px threshold to distinguish)

3. Layout (burger menu): add overflow-y-auto + pb-8 to SheetContent so
   mobile menu is scrollable when content exceeds screen height

4. CashuWalletCard (wallet): fix input+button rows overflowing on mobile
   - flex-col on small screens, flex-row on xs+ (stacked on phones)
   - whitespace-nowrap on buttons to prevent text wrapping

5. LabPage: compact action buttons on mobile (h-8, text-[10px], px-2)
   so all 4 buttons fit in 2x2 grid without overflow/truncation

6. LoginDialog (Amber): improve visibility-change handler - use fresh
   SimplePool subscription on each foreground return instead of querySync
   (which doesn't exist in nostr-tools v2.13)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-23 09:33:46 +07:00
parent 84d80ac3a7
commit 5dffa5cac3
6 changed files with 231 additions and 108 deletions

View file

@ -571,8 +571,8 @@ export function Layout({ children }: LayoutProps) {
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="right" className="w-80"> <SheetContent side="right" className="w-80 overflow-y-auto">
<div className="flex flex-col gap-6 mt-6"> <div className="flex flex-col gap-6 mt-6 pb-8">
{/* Wallet Card - Mobile Menu */} {/* Wallet Card - Mobile Menu */}
{user && ( {user && (
<Link <Link

View file

@ -7,7 +7,7 @@
* Falls back to static FAQ if AI is unavailable. * Falls back to static FAQ if AI is unavailable.
*/ */
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@ -322,6 +322,53 @@ export function SasquatchGuide({ message, onDismiss }: SasquatchGuideProps) {
const [streamingText, setStreamingText] = useState(''); const [streamingText, setStreamingText] = useState('');
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Draggable position state for minimized bubble
const [dragPos, setDragPos] = useLocalStorage<{ x: number; y: number } | null>('sasquatch-pos', null);
const [isDragging, setIsDragging] = useState(false);
const dragRef = useRef<HTMLDivElement>(null);
const dragStart = useRef<{ mx: number; my: number; bx: number; by: number } | null>(null);
const dragMoved = useRef(false);
// Dragging handlers
const onPointerDown = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
dragMoved.current = false;
const rect = dragRef.current.getBoundingClientRect();
dragStart.current = { mx: e.clientX, my: e.clientY, bx: rect.left, by: rect.top };
dragRef.current.setPointerCapture(e.pointerId);
setIsDragging(false);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
const dx = e.clientX - dragStart.current.mx;
const dy = e.clientY - dragStart.current.my;
if (!dragMoved.current && Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
dragMoved.current = true;
setIsDragging(true);
const newX = Math.max(0, Math.min(window.innerWidth - 56, dragStart.current.bx + dx));
const newY = Math.max(0, Math.min(window.innerHeight - 56, dragStart.current.by + dy));
setDragPos({ x: newX, y: newY });
}, [setDragPos]);
const onPointerUp = useCallback((e: React.PointerEvent) => {
if (dragRef.current) dragRef.current.releasePointerCapture(e.pointerId);
const wasDrag = dragMoved.current;
dragStart.current = null;
dragMoved.current = false;
setTimeout(() => setIsDragging(false), 50);
if (!wasDrag) {
setIsMinimized(false);
}
}, [setIsMinimized]);
const bubbleStyle = useMemo(() => {
if (dragPos) {
return { position: 'fixed' as const, left: dragPos.x, top: dragPos.y, bottom: 'auto', right: 'auto', zIndex: 50 };
}
return undefined;
}, [dragPos]);
useEffect(() => { useEffect(() => {
if (message) { if (message) {
const timer = setTimeout(() => setIsVisible(true), 500); const timer = setTimeout(() => setIsVisible(true), 500);
@ -501,12 +548,25 @@ Instructions:
return ( return (
<> <>
{/* ── Minimized bubble — always visible, bottom-left ── */} {/* ── Minimized bubble — draggable ── */}
{isMinimized && ( {isMinimized && (
<div className="fixed bottom-20 md:bottom-6 left-3 md:left-4 z-50"> <div
<button ref={dragRef}
onClick={() => setIsMinimized(false)} className={cn(
className="relative group flex items-center justify-center w-12 h-12 md:w-14 md:h-14 rounded-full shadow-2xl border-2 border-[#ccff00]/60 hover:border-[#ccff00] transition-all duration-200 hover:scale-110 active:scale-95" 'z-50 touch-none select-none',
dragPos ? '' : 'fixed bottom-20 md:bottom-6 left-3 md:left-4',
)}
style={bubbleStyle}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
title="Press and drag to move · Tap to open"
>
<div
className={cn(
'relative group flex items-center justify-center w-12 h-12 md:w-14 md:h-14 rounded-full shadow-2xl border-2 border-[#ccff00]/60 transition-all duration-200',
isDragging ? 'scale-110 border-[#ccff00] cursor-grabbing' : 'hover:border-[#ccff00] hover:scale-110 active:scale-95 cursor-grab',
)}
style={{ style={{
background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)', background: 'radial-gradient(circle at 40% 35%, #2d4a1e 0%, #1a2e0f 50%, #0d1a07 100%)',
boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)', boxShadow: '0 0 20px rgba(204,255,0,0.2), 0 4px 20px rgba(0,0,0,0.8)',
@ -516,13 +576,11 @@ Instructions:
<img <img
src="/images/sasquatch-guide.webp" src="/images/sasquatch-guide.webp"
alt="The Sasquatch" alt="The Sasquatch"
className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover" className="w-8 h-8 md:w-9 md:h-9 rounded-full object-cover pointer-events-none"
/> />
{/* Neon pulse ring */}
<span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00]" /> <span className="absolute inset-0 rounded-full animate-ping opacity-20 border-2 border-[#ccff00]" />
{/* Live dot */}
<span className="absolute top-0 right-0 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" /> <span className="absolute top-0 right-0 w-3 h-3 bg-[#ccff00] rounded-full border-2 border-black shadow-[0_0_6px_#ccff00]" />
</button> </div>
</div> </div>
)} )}

View file

@ -157,49 +157,54 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const pending = pendingRef.current || loadPendingNC(); const pending = pendingRef.current || loadPendingNC();
if (!pending || amberStep !== 'waiting') return; if (!pending || amberStep !== 'waiting') return;
setAmberStatus('Checking relay for Amber response...'); setAmberStatus('Checking for Amber response...');
// Quick one-shot relay check when app comes back to foreground
const { nsecDecode } = await import('nostr-tools/nip19').catch(() => ({ nsecDecode: null }));
if (!nsecDecode) return;
try { try {
const decoded = nip19.decode(pending.clientNsec); const decoded = nip19.decode(pending.clientNsec);
if (decoded.type !== 'nsec') return; if (decoded.type !== 'nsec') return;
const clientSk = decoded.data; const clientSk = decoded.data;
const pool = new SimplePool();
const since = Math.floor(pending.timestamp / 1000) - 30; const since = Math.floor(pending.timestamp / 1000) - 30;
// Fetch recent events — one-shot query // Re-subscribe with a fresh pool each time app comes to foreground
const events = await pool.querySync( const freshPool = new SimplePool();
let found = false;
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => { resolve(); }, 6000);
const sub = freshPool.subscribeMany(
NOSTRCONNECT_RELAYS, NOSTRCONNECT_RELAYS,
[{ kinds: [24133], '#p': [pending.clientPubkey], since }],
{ {
kinds: [24133], onevent: async (event) => {
'#p': [pending.clientPubkey], if (found) return;
since,
},
{ maxWait: 5000 }
);
pool.close(NOSTRCONNECT_RELAYS);
for (const event of events) {
try { try {
const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content); const decrypted = await nip04.decrypt(clientSk, event.pubkey, event.content);
const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string }; const parsed = JSON.parse(decrypted) as { id: string; result: string; error?: string };
if (parsed.error && parsed.error !== '') continue; if (parsed.error && parsed.error !== '') return;
if (parsed.result !== pending.secret && parsed.result !== 'ack') continue; if (parsed.result !== pending.secret && parsed.result !== 'ack') return;
found = true;
// Found it! clearTimeout(timeout);
sub.close();
freshPool.close(NOSTRCONNECT_RELAYS);
pendingRef.current = null; pendingRef.current = null;
abortRef.current?.abort(); abortRef.current?.abort();
await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays); await completeLogin(event.pubkey, clientSk, pending.clientNsec, pending.relays);
return; resolve();
} catch { /* wrong encryption or format */ } } catch { /* wrong encryption */ }
},
oneose: () => {
// All caught up — wait a bit more then give up
setTimeout(() => { sub.close(); freshPool.close(NOSTRCONNECT_RELAYS); resolve(); }, 2000);
},
} }
);
});
setAmberStatus('No response yet — did you approve in Amber?'); if (!found) {
setAmberStatus('Not received yet. Did you approve in Amber? Tap retry or come back after approving.');
}
} catch { /* ignore */ } } catch { /* ignore */ }
}; };

View file

@ -296,17 +296,18 @@ export function CashuWalletCard() {
{/* Lightning Invoice */} {/* Lightning Invoice */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium">Receive Lightning Payment</Label> <Label className="text-sm font-medium">Receive Lightning Payment</Label>
<div className="flex gap-2"> <div className="flex flex-col xs:flex-row gap-2">
<Input <Input
type="number" type="number"
placeholder="Amount in sats" placeholder="Amount in sats"
value={quoteAmount} value={quoteAmount}
onChange={(e) => setQuoteAmount(e.target.value)} onChange={(e) => setQuoteAmount(e.target.value)}
className="min-w-0"
/> />
<Button <Button
onClick={handleLightningReceive} onClick={handleLightningReceive}
disabled={!activeMintUrl || !quoteAmount} disabled={!activeMintUrl || !quoteAmount}
className="bg-orange-500 hover:bg-orange-600" className="bg-orange-500 hover:bg-orange-600 whitespace-nowrap xs:flex-shrink-0"
> >
Generate Invoice Generate Invoice
</Button> </Button>
@ -359,17 +360,18 @@ export function CashuWalletCard() {
{/* Send Token */} {/* Send Token */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium">Create Cashu Token</Label> <Label className="text-sm font-medium">Create Cashu Token</Label>
<div className="flex gap-2"> <div className="flex flex-col xs:flex-row gap-2">
<Input <Input
type="number" type="number"
placeholder="Amount in sats" placeholder="Amount in sats"
value={sendAmount} value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)} onChange={(e) => setSendAmount(e.target.value)}
className="min-w-0"
/> />
<Button <Button
onClick={handleTokenSend} onClick={handleTokenSend}
disabled={!activeMintUrl || !sendAmount} disabled={!activeMintUrl || !sendAmount}
className="bg-orange-500 hover:bg-orange-600" className="bg-orange-500 hover:bg-orange-600 whitespace-nowrap xs:flex-shrink-0"
> >
Create Token Create Token
</Button> </Button>

View file

@ -16,6 +16,8 @@ import {
ExternalLink, ExternalLink,
BarChart3, BarChart3,
AlertCircle, AlertCircle,
Download,
Smartphone,
} from 'lucide-react'; } from 'lucide-react';
export default function AboutPage() { export default function AboutPage() {
@ -235,26 +237,82 @@ Version: v0.1.0`;
</CardContent> </CardContent>
</Card> </Card>
{/* Powered By */} {/* Download on Zapstore */}
<div className="text-center mt-12 p-6"> <Card className="bg-black/90 border-2 border-[#ccff00]/50 mt-8">
<p className="text-gray-400 mb-2 uppercase tracking-wide">Powered by</p> <CardContent className="p-6">
<div className="flex items-center justify-center gap-3"> <div className="flex flex-col sm:flex-row items-center gap-4">
<div className="flex-1 text-center sm:text-left">
<div className="flex items-center gap-2 justify-center sm:justify-start mb-2">
<Smartphone className="h-5 w-5 text-[#ccff00]" />
<h3 className="font-black uppercase text-[#ccff00]">Download the App</h3>
</div>
<p className="text-sm text-gray-300">
Install SkateSpots on Android via Zapstore — the open-source, Bitcoin-native app store.
</p>
</div>
<a <a
href="https://nostr.com" href="https://zapstore.dev/apps/org.skatespots.app"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[#ccff00] hover:text-[#ccff00]/80 font-black text-xl uppercase" className="flex-shrink-0"
> >
<Button className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase px-6">
<Download className="h-4 w-4" />
Get on Zapstore
</Button>
</a>
</div>
</CardContent>
</Card>
{/* Credits */}
<Card className="bg-black/90 border-2 border-gray-700 mt-8">
<CardContent className="p-6">
<h3 className="font-black uppercase text-white mb-4 flex items-center gap-2">
<Heart className="h-5 w-5 text-[#ff0040]" />
Credits
</h3>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
{[
{ label: 'Built with', name: 'MKStack', url: 'https://soapbox.pub/mkstack', desc: 'Nostr client framework' },
{ label: 'Vibed with', name: 'Shakespeare', url: 'https://shakespeare.diy', desc: 'AI-powered development' },
{ label: 'Powered by', name: 'Nostr', url: 'https://nostr.com', desc: 'Decentralized protocol' },
{ label: 'Crowdfunding', name: 'Geyser', url: 'https://geyser.fund/project/skatespots', desc: 'Bitcoin Lightning crowdfunding' },
{ label: 'Crowdfunding', name: 'Angor', url: 'https://angor.io/', desc: 'Bitcoin crowdfunding platform' },
{ label: 'Distributed on', name: 'Zapstore', url: 'https://zapstore.dev/apps/org.skatespots.app', desc: 'Open-source app store' },
{ label: 'AI powered by', name: 'ContextVM', url: 'https://contextvm.org/s/e3564bb877adb0c99a7e4973ccb22e516d7d9eca0027d0bd62a04174ed670530', desc: 'Nostr AI infrastructure' },
].map((item) => (
<a
key={item.name}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-black/50 border border-gray-800 hover:border-[#ccff00]/40 hover:bg-[#ccff00]/5 transition-all group"
>
<ExternalLink className="h-4 w-4 text-gray-600 group-hover:text-[#ccff00] flex-shrink-0" />
<div className="min-w-0">
<span className="text-gray-500 text-xs">{item.label}: </span>
<span className="font-bold text-white group-hover:text-[#ccff00] transition-colors">{item.name}</span>
<p className="text-xs text-gray-500 truncate">{item.desc}</p>
</div>
</a>
))}
</div>
</CardContent>
</Card>
{/* Powered By */}
<div className="text-center mt-8 pb-6 px-4">
<p className="text-gray-400 mb-3 uppercase tracking-wide text-sm">Powered by</p>
<div className="flex flex-wrap items-center justify-center gap-2 md:gap-3">
<a href="https://nostr.com" target="_blank" rel="noopener noreferrer"
className="text-[#ccff00] hover:text-[#ccff00]/80 font-black text-lg md:text-xl uppercase">
Nostr Nostr
</a> </a>
<span className="text-gray-600">•</span> <span className="text-gray-600">•</span>
<span className="text-[#ff0040] font-black text-xl uppercase"> <span className="text-[#ff0040] font-black text-lg md:text-xl uppercase">Decentralized</span>
Decentralized
</span>
<span className="text-gray-600">•</span> <span className="text-gray-600">•</span>
<span className="text-orange-400 font-black text-xl uppercase"> <span className="text-orange-400 font-black text-lg md:text-xl uppercase">Community-Owned</span>
Community-Owned
</span>
</div> </div>
</div> </div>
</div> </div>

View file

@ -111,29 +111,29 @@ export default function LabPage() {
<p className="text-gray-400">{t('labPage.subtitle')}</p> <p className="text-gray-400">{t('labPage.subtitle')}</p>
</div> </div>
{user ? ( {user ? (
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2 w-full md:w-auto"> <div className="grid grid-cols-2 gap-1.5 w-full md:w-auto md:flex md:flex-wrap md:gap-2">
<Link to="/become-coach" className="col-span-1"> <Link to="/become-coach">
<Button className="w-full bg-red-500 hover:bg-red-600 gap-1.5 uppercase font-black text-xs sm:text-sm"> <Button size="sm" className="w-full bg-red-500 hover:bg-red-600 gap-1 uppercase font-black text-[10px] sm:text-xs h-8 px-2">
<Plus className="h-3.5 w-3.5 flex-shrink-0" /> <Plus className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{t('labPage.offerCoaching')}</span> <span className="truncate">{t('labPage.offerCoaching')}</span>
</Button> </Button>
</Link> </Link>
<Link to="/become-filmer" className="col-span-1"> <Link to="/become-filmer">
<Button className="w-full bg-orange-500 hover:bg-orange-600 gap-1.5 uppercase font-black text-xs sm:text-sm"> <Button size="sm" className="w-full bg-orange-500 hover:bg-orange-600 gap-1 uppercase font-black text-[10px] sm:text-xs h-8 px-2">
<Plus className="h-3.5 w-3.5 flex-shrink-0" /> <Plus className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{t('labPage.offerFilming')}</span> <span className="truncate">{t('labPage.offerFilming')}</span>
</Button> </Button>
</Link> </Link>
<Link to="/become-editor" className="col-span-1"> <Link to="/become-editor">
<Button className="w-full bg-purple-600 hover:bg-purple-700 gap-1.5 uppercase font-black text-xs sm:text-sm"> <Button size="sm" className="w-full bg-purple-600 hover:bg-purple-700 gap-1 uppercase font-black text-[10px] sm:text-xs h-8 px-2">
<Plus className="h-3.5 w-3.5 flex-shrink-0" /> <Plus className="h-3 w-3 flex-shrink-0" />
<span className="truncate">Offer Editing</span> <span className="truncate">Offer Editing</span>
</Button> </Button>
</Link> </Link>
<Link to="/card" className="col-span-1"> <Link to="/card">
<Button variant="outline" className="w-full gap-1.5 uppercase font-black border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10 text-xs sm:text-sm"> <Button size="sm" variant="outline" className="w-full gap-1 uppercase font-black border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10 text-[10px] sm:text-xs h-8 px-2">
<CreditCard className="h-3.5 w-3.5 flex-shrink-0" /> <CreditCard className="h-3 w-3 flex-shrink-0" />
<span className="truncate">Get Your Card</span> <span className="truncate">Get Card</span>
</Button> </Button>
</Link> </Link>
</div> </div>