SkateSpots/src/pages/TourDetailPage.tsx
SkateSpots 7347f874ad i18n(es): batch 4 — Lab option labels, Sasquatch static FAQ, fee tiers
- labOptions.* (430 keys): all ~50 role LABELS/DESCRIPTIONS maps in types.ts
  (coach/filmer/editor/photo/builder/artist/graffiti/tattoo/trainer/musician/
  judge/organizer/barber) + TOUR_SKILL_LEVELS; 51 shared key maps in
  labelKeys.ts; consumers in becomeRoles/CraftProfileForm, LabPage,
  CoachDetailPage, the /become/* pages, TourCard/TourDetailPage/CreateTourPage/
  EventsPage. Trick names + tool/brand nouns stay English.
- sasquatchChat.* (83 keys): the free-tier STATIC_FAQ and local-result/fallback
  chat bodies (live AI already replies in-language; prompts stay English).
- feesUi.* (57 keys): usePurchasePro receipt prose + errors, PlatformFeePayment
  fee/duration tiers (LabProUpgrade/FeaturedEventUpgrade were already done).
- New src/i18n/batch4/ fragments spread into en/es.
2026-10-02 15:57:44 +07:00

537 lines
23 KiB
TypeScript

import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
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 { TOUR_CATEGORY_KEYS, TOUR_SPOT_TYPE_KEYS, TOUR_SKILL_KEYS } from '@/lib/labelKeys';
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';
import { useTranslation } from '@/contexts/language';
export default function TourDetailPage() {
const { t } = useTranslation();
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_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : TOUR_CATEGORIES[tour.category].label} - SkateSpots`
: 'Skate Tour - SkateSpots',
description: tour?.description || 'Discover skate spots and tours on SkateSpots',
});
const handleBookTour = () => {
if (!tour) return;
// Open a DM with the guide. Must use the same /messages/:npub route every
// other entry point uses — the old `/messages?to=` param was ignored, so
// the CTA silently opened an empty inbox.
navigate(`/messages/${nip19.npubEncode(tour.pubkey)}`);
};
const handleShare = async () => {
if (!tour) return;
const url = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: tour.title,
text: t('placesUi.tourDetail.shareText', { title: tour.title }),
url,
});
} catch {
// User cancelled or error
}
} else {
await navigator.clipboard.writeText(url);
}
};
if (isLoading) {
return (
<>
<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>
</>
);
}
if (error || !tour) {
return (
<>
<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">{t('placesUi.tourDetail.notFound')}</h2>
<p className="text-muted-foreground mb-6">
{t('placesUi.tourDetail.notFoundDesc')}
</p>
<Link to="/map">
<Button variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
{t('placesUi.shop.backToMap')}
</Button>
</Link>
</CardContent>
</Card>
</div>
</>
);
}
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const skillKey = tour.skillLevel ? TOUR_SKILL_KEYS[tour.skillLevel] : undefined;
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(dateLocaleCode(), { month: 'short', day: 'numeric', year: 'numeric' });
if (tour.endDate) {
const end = new Date(tour.endDate * 1000);
const endStr = end.toLocaleDateString(dateLocaleCode(), { 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
? t('placesUi.tourCard.durationMonths', { count: Math.round(tour.duration / 4) })
: t('placesUi.tourCard.durationWeeks', { count: tour.duration });
}
if (u === 'days') return t('placesUi.tourCard.durationDays', { count: tour.duration });
return t('placesUi.tourCard.durationHours', { count: tour.duration });
})();
const countdownLabel = (() => {
if (!tour.startDate) return null;
const nowSecs = Date.now() / 1000;
if (tour.startDate <= nowSecs) {
if (tour.endDate && nowSecs <= tour.endDate) return { text: t('placesUi.tourCard.happeningNow'), live: true };
if (nowSecs - tour.startDate < 12 * 60 * 60) return { text: t('placesUi.tourCard.happeningNow'), 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: t('placesUi.tourCard.startingSoon'), live: true };
if (hours < 24) return { text: t('placesUi.tourCard.inHours', { count: hours }), live: false };
if (days === 1) return { text: t('placesUi.tourCard.tomorrow'), live: false };
if (days < 7) return { text: t('placesUi.tourCard.inDays', { count: days }), live: false };
if (days < 30) {
const w = Math.round(days / 7);
return { text: t('placesUi.tourCard.inWeeks', { count: w }), live: false };
}
const m = Math.round(days / 30);
return { text: t('placesUi.tourCard.inMonths', { count: m }), live: false };
})();
const calendarEvent = tour.startDate
? {
uid: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`,
title: tour.title,
description: tour.description || `${TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : 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 (
<>
<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" />
{t('placesUi.shop.backToMap')}
</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" /> {t('common.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} {TOUR_CATEGORY_KEYS[tour.category] ? t(TOUR_CATEGORY_KEYS[tour.category]) : 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' ? t('placesUi.tourDetail.organizer') : t('placesUi.tourDetail.tourGuide')}
</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">
{t('placesUi.tourDetail.viewProfile')}
</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 ? t('placesUi.tourDetail.scheduledSession') : tour.category === 'destruction' ? t('placesUi.tourDetail.competitionWindow') : t('placesUi.tourDetail.tripDates')}
</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">{t('placesUi.tourDetail.runsAbout', { duration: 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">{t('placesUi.tourDetail.about')}</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">{t('placesUi.tourDetail.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">{t('placesUi.tourDetail.maxGroup')}</span>
</div>
<p className="font-bold text-white">{t('placesUi.tourDetail.people', { count: tour.maxGroupSize })}</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">{t('placesUi.tourDetail.skillLevel')}</span>
</div>
<p className="font-bold text-white flex items-center gap-1">
<span>{skillInfo.emoji}</span>
{skillKey ? t(skillKey) : 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">{t('placesUi.tourDetail.price')}</span>
</div>
<p className="font-bold text-orange-400">{t('placesUi.tourCard.price', { amount: tour.price.toLocaleString() })}</p>
<p className="text-xs text-gray-500">{t('placesUi.tourDetail.perPerson')}</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">{t('placesUi.tourDetail.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">{t('placesUi.tourDetail.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">{t('placesUi.tourDetail.spotTypes')}</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 || '🛹'} {TOUR_SPOT_TYPE_KEYS[type] ? t(TOUR_SPOT_TYPE_KEYS[type]) : (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]">{t('placesUi.tourDetail.partnerDiscount')}</span>
</div>
<p className="text-white font-bold text-lg mb-1">
{t('placesUi.tourDetail.discountAt', { percent: tour.shopDiscount, shop: tour.shopName ?? '' })}
</p>
<p className="text-xs text-gray-400">
{t('placesUi.tourDetail.partnerDiscountDesc')}
</p>
</div>
</div>
</div>
)}
{/* Tour Sponsors - invisible placeholder until sponsors are added */}
<SponsorSlot
size="card"
sponsors={tour.sponsors}
title={t('placesUi.tourDetail.tourSponsors')}
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">{t('placesUi.tourDetail.meetingPoint')}</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 ? t('placesUi.tourDetail.contactToBook') : t('placesUi.tourDetail.loginToBook')}
</Button>
<Button
variant="outline"
onClick={handleShare}
className="border-gray-700 hover:bg-gray-800"
>
<Share2 className="h-4 w-4 mr-2" />
{t('common.share')}
</Button>
</div>
{!user && (
<p className="text-center text-sm text-gray-500">
{t('placesUi.tourDetail.loginToBookDesc')}
</p>
)}
</CardContent>
</Card>
</div>
</>
);
}