Countdown badges (upcoming tours): - New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" / "Happening now" (pulsing) based on start/end dates, category-tinted - Shown on both compact and full TourCard, and in the TourDetailPage schedule card NIP-05 provider fix (removes obsolete nip05.social): - New single source of truth src/lib/nip05Providers.ts with working, curated providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid) - New reusable Nip05Required component (full-page "gate" + inline "banner" variants) listing those providers and deep-linking to /settings?section=nip05 - Wired into CreateTourPage (gate), CreateListingPage (banner) and all four Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt - SettingsPage NIP-05 screen now reads from the shared provider list - Updated internal planning docs to match Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
532 lines
21 KiB
TypeScript
532 lines
21 KiB
TypeScript
import { useSeoMeta } from '@unhead/react';
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Layout } from '@/components/Layout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { useTour } from '@/hooks/useTours';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, SKATE_TOUR_KIND } from '@/lib/types';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { ProBadge } from '@/components/ProBadge';
|
|
import { AddToCalendar } from '@/components/AddToCalendar';
|
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
|
import {
|
|
ArrowLeft,
|
|
Route,
|
|
MapPin,
|
|
Clock,
|
|
Users,
|
|
Zap,
|
|
Globe,
|
|
MessageCircle,
|
|
Calendar,
|
|
Star,
|
|
Share2,
|
|
Flag,
|
|
Store,
|
|
Percent,
|
|
Pencil,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { SponsorSlot } from '@/components/SponsorSlot';
|
|
|
|
export default function TourDetailPage() {
|
|
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
|
const navigate = useNavigate();
|
|
const { user } = useCurrentUser();
|
|
const { data: tour, isLoading, error } = useTour(identifier || '', pubkey);
|
|
const author = useAuthor(tour?.pubkey || '');
|
|
const metadata = author.data?.metadata;
|
|
const guideName = metadata?.name ?? genUserName(tour?.pubkey || '');
|
|
|
|
useSeoMeta({
|
|
title: tour
|
|
? `${tour.title} - ${TOUR_CATEGORIES[tour.category].label} - SkateSpots`
|
|
: 'Skate Tour - SkateSpots',
|
|
description: tour?.description || 'Discover skate spots and tours on SkateSpots',
|
|
});
|
|
|
|
const handleBookTour = () => {
|
|
if (!tour) return;
|
|
// Navigate to messages/DM with the guide
|
|
// For now, navigate to messages page - in future can open DM composer
|
|
navigate(`/messages?to=${tour.pubkey}`);
|
|
};
|
|
|
|
const handleShare = async () => {
|
|
if (!tour) return;
|
|
const url = window.location.href;
|
|
if (navigator.share) {
|
|
try {
|
|
await navigator.share({
|
|
title: tour.title,
|
|
text: `Check out this local skate tour: ${tour.title}`,
|
|
url,
|
|
});
|
|
} catch {
|
|
// User cancelled or error
|
|
}
|
|
} else {
|
|
await navigator.clipboard.writeText(url);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-6 max-w-3xl">
|
|
<Skeleton className="h-6 w-32 mb-6" />
|
|
<Card className="bg-black/90 border-cyan-500/30">
|
|
<CardHeader>
|
|
<Skeleton className="h-8 w-3/4 mb-2" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<Skeleton className="h-48 w-full rounded-lg" />
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-full" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
if (error || !tour) {
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-12">
|
|
<Card className="max-w-xl mx-auto border-dashed">
|
|
<CardContent className="py-12 text-center">
|
|
<Route className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
|
|
<h2 className="text-xl font-bold mb-2">Tour Not Found</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
This tour may have been removed or doesn't exist.
|
|
</p>
|
|
<Link to="/map">
|
|
<Button variant="outline">
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Map
|
|
</Button>
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
|
|
const categoryInfo = TOUR_CATEGORIES[tour.category];
|
|
const accent = categoryInfo.color;
|
|
|
|
// A local tour with a fixed session uses a datetime (show time); everything else
|
|
// (national/international/destruction) is a multi-day, all-day date range.
|
|
const isLocalSession = tour.category === 'local';
|
|
const hasSchedule = !!tour.startDate;
|
|
|
|
const formatSchedule = (): string => {
|
|
if (!tour.startDate) return '';
|
|
const start = new Date(tour.startDate * 1000);
|
|
if (isLocalSession) {
|
|
return start.toLocaleString(undefined, {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
});
|
|
}
|
|
const startStr = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
if (tour.endDate) {
|
|
const end = new Date(tour.endDate * 1000);
|
|
const endStr = end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
return `${startStr} → ${endStr}`;
|
|
}
|
|
return startStr;
|
|
};
|
|
|
|
const durationLabel = (() => {
|
|
if (!tour.duration) return '';
|
|
const u = tour.durationUnit;
|
|
if (u === 'weeks') {
|
|
return tour.duration >= 4
|
|
? `${Math.round(tour.duration / 4)} month${Math.round(tour.duration / 4) > 1 ? 's' : ''}`
|
|
: `${tour.duration} week${tour.duration > 1 ? 's' : ''}`;
|
|
}
|
|
if (u === 'days') return `${tour.duration} day${tour.duration > 1 ? 's' : ''}`;
|
|
return `${tour.duration} hour${tour.duration > 1 ? 's' : ''}`;
|
|
})();
|
|
|
|
const countdownLabel = (() => {
|
|
if (!tour.startDate) return null;
|
|
const nowSecs = Date.now() / 1000;
|
|
if (tour.startDate <= nowSecs) {
|
|
if (tour.endDate && nowSecs <= tour.endDate) return { text: 'Happening now', live: true };
|
|
if (nowSecs - tour.startDate < 12 * 60 * 60) return { text: 'Happening now', live: true };
|
|
return null;
|
|
}
|
|
const days = Math.floor((tour.startDate - nowSecs) / 86400);
|
|
const hours = Math.floor((tour.startDate - nowSecs) / 3600);
|
|
if (hours < 1) return { text: 'Starting soon', live: true };
|
|
if (hours < 24) return { text: `In ${hours}h`, live: false };
|
|
if (days === 1) return { text: 'Tomorrow', live: false };
|
|
if (days < 7) return { text: `In ${days} days`, live: false };
|
|
if (days < 30) {
|
|
const w = Math.round(days / 7);
|
|
return { text: `In ${w} week${w > 1 ? 's' : ''}`, live: false };
|
|
}
|
|
const m = Math.round(days / 30);
|
|
return { text: `In ${m} month${m > 1 ? 's' : ''}`, live: false };
|
|
})();
|
|
|
|
const calendarEvent = tour.startDate
|
|
? {
|
|
uid: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`,
|
|
title: tour.title,
|
|
description: tour.description || `${categoryInfo.label} on SkateSpots`,
|
|
location: tour.location || tour.meetingPoint || tour.cities?.join(', ') || tour.countries?.join(', '),
|
|
start: tour.startDate,
|
|
end: tour.endDate,
|
|
url: typeof window !== 'undefined' ? window.location.href : undefined,
|
|
allDay: !isLocalSession,
|
|
}
|
|
: null;
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-6 max-w-3xl">
|
|
{/* Back link + owner/admin controls */}
|
|
<div className="mb-6 flex items-center justify-between gap-2">
|
|
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors">
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Map
|
|
</Link>
|
|
<div className="flex items-center gap-2">
|
|
{user?.pubkey === tour.pubkey && (
|
|
<Button
|
|
asChild
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-1.5 border-cyan-500/40 text-cyan-300 hover:bg-cyan-500/10 hover:text-cyan-200"
|
|
>
|
|
<Link to={`/edit-tour/${tour.pubkey}/${tour.identifier}`}>
|
|
<Pencil className="h-3.5 w-3.5" /> Edit
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
<DeleteEntityDialog
|
|
event={tour.event}
|
|
entityLabel="tour"
|
|
entityName={tour.title}
|
|
redirectTo="/map"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main card */}
|
|
<Card className="bg-black/90 border-2 border-cyan-500/30 overflow-hidden">
|
|
{/* Tour image */}
|
|
{tour.image && (
|
|
<div className="relative h-48 sm:h-64 bg-gradient-to-br from-cyan-500/20 to-cyan-900/20">
|
|
<img
|
|
src={tour.image}
|
|
alt={tour.title}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
|
|
</div>
|
|
)}
|
|
|
|
<CardHeader className={cn(tour.image && '-mt-16 relative z-10')}>
|
|
<div className="flex items-start gap-4">
|
|
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-cyan-500 to-cyan-600 flex items-center justify-center shadow-lg shadow-cyan-500/25 flex-shrink-0">
|
|
<Route className="h-7 w-7 text-white" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<Badge
|
|
className="font-bold uppercase"
|
|
style={{
|
|
backgroundColor: `${accent}22`,
|
|
color: accent,
|
|
borderColor: `${accent}55`,
|
|
}}
|
|
>
|
|
{categoryInfo.emoji} {categoryInfo.label}
|
|
</Badge>
|
|
<ProBadge
|
|
featuredUntil={tour.featuredUntil}
|
|
entityCoord={`${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`}
|
|
/>
|
|
</div>
|
|
<CardTitle className="text-2xl font-black text-white">
|
|
{tour.title}
|
|
</CardTitle>
|
|
{tour.location && (
|
|
<p className="text-gray-400 flex items-center gap-1 mt-1">
|
|
<MapPin className="h-4 w-4" />
|
|
{tour.location}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-6">
|
|
{/* Guide info */}
|
|
<div className="flex items-center justify-between p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
|
<div className="flex items-center gap-3">
|
|
<Avatar className="h-12 w-12 border-2 border-cyan-500/50">
|
|
<AvatarImage src={metadata?.picture} alt={guideName} />
|
|
<AvatarFallback className="bg-cyan-500/20 text-cyan-400">
|
|
{guideName.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<p className="font-bold text-white">{guideName}</p>
|
|
<p className="text-xs" style={{ color: accent }}>
|
|
{tour.category === 'destruction' ? 'Organizer' : 'Tour Guide'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<ZapButton
|
|
target={tour.event}
|
|
className="text-orange-400 hover:text-orange-300 hover:bg-orange-500/10"
|
|
/>
|
|
<Link to={`/${nip19.npubEncode(tour.pubkey)}`}>
|
|
<Button variant="outline" size="sm" className="border-cyan-500/30 text-cyan-400 hover:bg-cyan-500/10">
|
|
View Profile
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Schedule + Add to Calendar */}
|
|
{hasSchedule && calendarEvent && (
|
|
<div
|
|
className="rounded-xl border-2 p-4 flex flex-col sm:flex-row sm:items-center gap-4"
|
|
style={{ borderColor: `${accent}40`, backgroundColor: `${accent}0d` }}
|
|
>
|
|
<div
|
|
className="hidden sm:flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl"
|
|
style={{ backgroundColor: `${accent}22`, border: `2px solid ${accent}55` }}
|
|
>
|
|
<Calendar className="h-7 w-7" style={{ color: accent }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<p className="text-[11px] font-black uppercase tracking-widest" style={{ color: accent }}>
|
|
{isLocalSession ? 'Scheduled Session' : tour.category === 'destruction' ? 'Competition Window' : 'Trip Dates'}
|
|
</p>
|
|
{countdownLabel && (
|
|
<Badge
|
|
className={cn(
|
|
'border-0 text-[10px] font-bold uppercase',
|
|
countdownLabel.live && 'animate-pulse'
|
|
)}
|
|
style={{
|
|
backgroundColor: countdownLabel.live ? '#ff0040' : accent,
|
|
color: 'white',
|
|
}}
|
|
>
|
|
{countdownLabel.text}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-white font-bold text-lg leading-tight">{formatSchedule()}</p>
|
|
{durationLabel && (
|
|
<p className="text-xs text-gray-400 mt-0.5">Runs about {durationLabel}</p>
|
|
)}
|
|
</div>
|
|
<AddToCalendar event={calendarEvent} color={accent} className="w-full sm:w-auto" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Description */}
|
|
{tour.description && (
|
|
<div>
|
|
<h3 className="font-bold text-white mb-2">About This Tour</h3>
|
|
<p className="text-gray-300 whitespace-pre-wrap">{tour.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tour details grid */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
|
{tour.duration && (
|
|
<div className="p-3 rounded-lg bg-gray-900/50 border border-gray-800">
|
|
<div className="flex items-center gap-2 text-gray-400 mb-1">
|
|
<Clock className="h-4 w-4" />
|
|
<span className="text-xs">Duration</span>
|
|
</div>
|
|
<p className="font-bold text-white">{durationLabel}</p>
|
|
</div>
|
|
)}
|
|
|
|
{tour.maxGroupSize && (
|
|
<div className="p-3 rounded-lg bg-gray-900/50 border border-gray-800">
|
|
<div className="flex items-center gap-2 text-gray-400 mb-1">
|
|
<Users className="h-4 w-4" />
|
|
<span className="text-xs">Max Group</span>
|
|
</div>
|
|
<p className="font-bold text-white">{tour.maxGroupSize} people</p>
|
|
</div>
|
|
)}
|
|
|
|
{skillInfo && (
|
|
<div className="p-3 rounded-lg bg-gray-900/50 border border-gray-800">
|
|
<div className="flex items-center gap-2 text-gray-400 mb-1">
|
|
<Star className="h-4 w-4" />
|
|
<span className="text-xs">Skill Level</span>
|
|
</div>
|
|
<p className="font-bold text-white flex items-center gap-1">
|
|
<span>{skillInfo.emoji}</span>
|
|
{skillInfo.label}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{tour.price && (
|
|
<div className="p-3 rounded-lg bg-orange-500/10 border border-orange-500/30">
|
|
<div className="flex items-center gap-2 text-orange-400 mb-1">
|
|
<Zap className="h-4 w-4" />
|
|
<span className="text-xs">Price</span>
|
|
</div>
|
|
<p className="font-bold text-orange-400">{tour.price.toLocaleString()} sats</p>
|
|
<p className="text-xs text-gray-500">per person</p>
|
|
</div>
|
|
)}
|
|
|
|
{tour.availability && (
|
|
<div className="p-3 rounded-lg bg-gray-900/50 border border-gray-800">
|
|
<div className="flex items-center gap-2 text-gray-400 mb-1">
|
|
<Calendar className="h-4 w-4" />
|
|
<span className="text-xs">Availability</span>
|
|
</div>
|
|
<p className="font-bold text-white text-sm">{tour.availability}</p>
|
|
</div>
|
|
)}
|
|
|
|
{tour.languages && tour.languages.length > 0 && (
|
|
<div className="p-3 rounded-lg bg-gray-900/50 border border-gray-800">
|
|
<div className="flex items-center gap-2 text-gray-400 mb-1">
|
|
<Globe className="h-4 w-4" />
|
|
<span className="text-xs">Languages</span>
|
|
</div>
|
|
<p className="font-bold text-white text-sm">
|
|
{tour.languages.map(l => l.toUpperCase()).join(', ')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Spot types */}
|
|
{tour.spotTypes.length > 0 && (
|
|
<div>
|
|
<h3 className="font-bold text-white mb-2">Types of Spots</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{tour.spotTypes.map((type) => {
|
|
const typeInfo = TOUR_SPOT_TYPES[type as keyof typeof TOUR_SPOT_TYPES];
|
|
return (
|
|
<Badge
|
|
key={type}
|
|
variant="outline"
|
|
className="border-cyan-500/30 text-cyan-300"
|
|
>
|
|
{typeInfo?.emoji || '🛹'} {typeInfo?.label || type}
|
|
</Badge>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Partner Shop Discount */}
|
|
{tour.shopDiscount && tour.shopName && (
|
|
<div className="p-4 rounded-xl bg-[#ccff00]/10 border border-[#ccff00]/30">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-12 h-12 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
|
|
<Store className="h-6 w-6 text-[#ccff00]" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Percent className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="font-bold text-[#ccff00]">Partner Shop Discount</span>
|
|
</div>
|
|
<p className="text-white font-bold text-lg mb-1">
|
|
{tour.shopDiscount}% OFF at {tour.shopName}
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
Book this tour to receive a discount at the partner skate shop. Show your booking confirmation to the shop staff.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tour Sponsors - invisible placeholder until sponsors are added */}
|
|
<SponsorSlot
|
|
size="card"
|
|
sponsors={tour.sponsors}
|
|
title="Tour Sponsors"
|
|
className="mt-4"
|
|
/>
|
|
|
|
{/* Meeting point */}
|
|
{tour.meetingPoint && (
|
|
<div className="p-4 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
|
<div className="flex items-center gap-2 text-cyan-400 mb-2">
|
|
<Flag className="h-5 w-5" />
|
|
<span className="font-bold">Meeting Point</span>
|
|
</div>
|
|
<p className="text-white">{tour.meetingPoint}</p>
|
|
{tour.meetingPointCoords && (
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
{tour.meetingPointCoords.lat.toFixed(4)}, {tour.meetingPointCoords.lon.toFixed(4)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col sm:flex-row gap-3 pt-4 border-t border-gray-800">
|
|
<Button
|
|
onClick={handleBookTour}
|
|
className="flex-1 bg-cyan-500 hover:bg-cyan-600 text-white font-bold"
|
|
disabled={!user}
|
|
>
|
|
<MessageCircle className="h-4 w-4 mr-2" />
|
|
{user ? 'Contact Guide to Book' : 'Login to Book'}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleShare}
|
|
className="border-gray-700 hover:bg-gray-800"
|
|
>
|
|
<Share2 className="h-4 w-4 mr-2" />
|
|
Share
|
|
</Button>
|
|
</div>
|
|
|
|
{!user && (
|
|
<p className="text-center text-sm text-gray-500">
|
|
Login to contact the guide and book this tour
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|