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:
parent
617c6cee82
commit
a464913d6f
2 changed files with 88 additions and 9 deletions
|
|
@ -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. 🛹
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue