fix(ux): Batch F part 2 — shop gallery lightbox; real FAQ content

- ShopDetailPage gallery reuses ImageLightbox: photos are now zoomable and the
  whole set is reachable (it showed at most 5 and clicks did nothing)
- FAQPage gains static quick answers so the page is useful even if The
  Sasquatch guide fails to open (it previously had zero intrinsic content)
This commit is contained in:
SkateSpots 2026-09-26 18:52:05 +07:00
parent 617c6cee82
commit a464913d6f
2 changed files with 88 additions and 9 deletions

View file

@ -32,6 +32,37 @@ const QUICK_FACTS = [
{ icon: Package, color: '#a855f7', label: 'Private gear inventory', value: 'Encrypted Stash' }, { icon: Package, color: '#a855f7', label: 'Private gear inventory', value: 'Encrypted Stash' },
]; ];
/**
* Static answers. The page leans on The Sasquatch, but if the guide fails to
* mount (or events are missed) the FAQ must still answer the basics itself.
*/
const STATIC_FAQ = [
{
q: 'What is SkateSpots?',
a: 'A decentralized map and community for skateboarding, built on Nostr. Discover spots, find coaches and filmers, join crews, list events, and buy/sell gear — with no company in the middle.',
},
{
q: 'How do I add a spot?',
a: 'Tap the map, or use the + / "Add Spot" button, then drop a pin and fill in the details. You need a free Nostr account (tap Log In → Create Account — no email required).',
},
{
q: 'What are zaps?',
a: 'Zaps are Bitcoin Lightning tips. You can zap spots, events, crews and people you appreciate. They go straight to the recipient over Lightning — SkateSpots never holds your funds.',
},
{
q: 'Is it free?',
a: 'Yes. Creating an account, adding spots and browsing are free. Optional paid extras (like featured listings) and tips (zaps) are paid directly between people.',
},
{
q: 'What data is public?',
a: 'Anything you publish — spots, events, your profile — is public on Nostr relays. Your private messages and your Stash are end-to-end encrypted. See the Privacy page for details.',
},
{
q: 'What is The Lab?',
a: 'The Lab is where you find people to hire or learn from: coaches, trainers, filmers, video editors, photographers, builders, artists and more.',
},
];
export default function FAQPage() { export default function FAQPage() {
useSeoMeta({ useSeoMeta({
title: 'FAQ - SkateSpots', title: 'FAQ - SkateSpots',
@ -133,6 +164,24 @@ export default function FAQPage() {
))} ))}
</div> </div>
{/* Static answers — work even if the guide doesn't open */}
<div className="mb-10">
<h3 className="text-sm font-bold uppercase tracking-widest text-gray-500 mb-4 flex items-center gap-2">
<HelpCircle className="h-4 w-4" />
Quick answers
</h3>
<div className="space-y-2">
{STATIC_FAQ.map(({ q, a }) => (
<details key={q} className="group rounded-xl border border-white/10 bg-black/60 p-4">
<summary className="cursor-pointer list-none text-sm font-bold text-white transition-colors group-open:text-[#ccff00]">
{q}
</summary>
<p className="mt-2 text-sm leading-relaxed text-gray-400">{a}</p>
</details>
))}
</div>
</div>
{/* Hint */} {/* Hint */}
<p className="text-center text-xs text-gray-600"> <p className="text-center text-xs text-gray-600">
The Sasquatch uses live Nostr data from the SkateSpots relay + AI to answer your questions. 🛹 The Sasquatch uses live Nostr data from the SkateSpots relay + AI to answer your questions. 🛹

View file

@ -6,6 +6,7 @@ import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { PageError } from '@/components/ErrorState'; import { PageError } from '@/components/ErrorState';
import { ImageWithFallback } from '@/components/ImageWithFallback'; import { ImageWithFallback } from '@/components/ImageWithFallback';
import { ImageLightbox } from '@/components/ImageLightbox';
import { SpotMap } from '@/components/SpotMap'; import { SpotMap } from '@/components/SpotMap';
import { RouteDialog } from '@/components/RouteDialog'; import { RouteDialog } from '@/components/RouteDialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -89,6 +90,7 @@ export default function ShopDetailPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRouteDialog, setShowRouteDialog] = useState(false); const [showRouteDialog, setShowRouteDialog] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const isOwner = user && shop && user.pubkey === shop.pubkey; const isOwner = user && shop && user.pubkey === shop.pubkey;
const isAdmin = isAdminPubkey(user?.pubkey); const isAdmin = isAdminPubkey(user?.pubkey);
@ -218,15 +220,20 @@ export default function ShopDetailPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */} {/* Main Content */}
<div className="lg:col-span-2 space-y-6 min-w-0"> <div className="lg:col-span-2 space-y-6 min-w-0">
{/* Image Gallery */} {/* Image Gallery — click any photo to view full size */}
{shop.images.length > 0 ? ( {shop.images.length > 0 ? (
<div className="aspect-video rounded-xl overflow-hidden bg-muted"> <button
type="button"
onClick={() => setLightboxIndex(0)}
className="group relative aspect-video w-full cursor-zoom-in overflow-hidden rounded-xl bg-muted"
aria-label="View photo full screen"
>
<ImageWithFallback <ImageWithFallback
src={shop.images[0]} src={shop.images[0]}
alt={shop.name} alt={shop.name}
className="w-full h-full object-cover" className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/> />
</div> </button>
) : ( ) : (
<div className="aspect-video rounded-xl bg-gradient-to-br from-[#ccff00]/20 to-[#00ff88]/20 flex items-center justify-center"> <div className="aspect-video rounded-xl bg-gradient-to-br from-[#ccff00]/20 to-[#00ff88]/20 flex items-center justify-center">
<Store className="h-16 w-16 text-[#ccff00]/40" /> <Store className="h-16 w-16 text-[#ccff00]/40" />
@ -236,11 +243,26 @@ export default function ShopDetailPage() {
{/* Additional images */} {/* Additional images */}
{shop.images.length > 1 && ( {shop.images.length > 1 && (
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{shop.images.slice(1, 5).map((img, idx) => ( {shop.images.slice(1, 5).map((img, idx) => {
<div key={idx} className="aspect-square rounded-lg overflow-hidden bg-muted"> const realIndex = idx + 1;
<ImageWithFallback src={img} alt={`${shop.name} ${idx + 2}`} className="w-full h-full object-cover" /> const remaining = shop.images.length - 5;
return (
<button
key={`${img}-${realIndex}`}
type="button"
onClick={() => setLightboxIndex(realIndex)}
className="relative aspect-square cursor-zoom-in overflow-hidden rounded-lg bg-muted"
aria-label={`View photo ${realIndex + 1} of ${shop.images.length}`}
>
<ImageWithFallback src={img} alt={`${shop.name} ${realIndex + 1}`} className="w-full h-full object-cover" />
{idx === 3 && remaining > 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 text-sm font-bold text-white">
+{remaining}
</div> </div>
))} )}
</button>
);
})}
</div> </div>
)} )}
@ -521,6 +543,14 @@ export default function ShopDetailPage() {
destination={coordinates} destination={coordinates}
destinationLabel={shop?.name ?? 'Shop'} destinationLabel={shop?.name ?? 'Shop'}
/> />
{/* Full-screen photo viewer */}
<ImageLightbox
images={shop.images.map((url) => ({ url, isOwner: true }))}
index={lightboxIndex}
onIndexChange={setLightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
</Layout> </Layout>
); );
} }