diff --git a/NIP.md b/NIP.md index fa543c5..b035a73 100644 --- a/NIP.md +++ b/NIP.md @@ -905,7 +905,9 @@ Skate tours allow experienced skaters to organize and host various types of skat | `skill-level` | Recommended skill level: `beginner`, `intermediate`, `advanced`, or `all` | | `max-group` | Maximum number of participants | | `price` | Price per person in satoshis (host decides) | -| `available` | When host is available (e.g., "Weekends", "By appointment") | +| `available` | When host is available for a flexible/by-appointment local tour (e.g., "Weekends", "By appointment") | +| `start-date` | Unix timestamp of the tour's scheduled start. For **local** tours this is a specific session date+time; for **national/international** it is the trip departure day; for **destruction** it is the competition start (challenges reveal). Omit for flexible local tours. | +| `end-date` | Unix timestamp of the tour's end/return day (national, international, destruction). | | `languages` | Languages the host speaks | | `shop` | Reference to partner skate shop (naddr) | | `shop-discount` | Percentage discount at partner shop for tour participants | diff --git a/src/components/AddToCalendar.tsx b/src/components/AddToCalendar.tsx new file mode 100644 index 0000000..fc52a6f --- /dev/null +++ b/src/components/AddToCalendar.tsx @@ -0,0 +1,62 @@ +import { CalendarPlus, Download, CalendarClock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + downloadICS, + googleCalendarUrl, + type CalendarEventInput, +} from '@/lib/calendar'; +import { cn } from '@/lib/utils'; + +interface AddToCalendarProps { + event: CalendarEventInput; + /** Accent color for the button (category color). */ + color?: string; + className?: string; +} + +/** + * Small button that lets attendees drop a tour into their calendar of choice. + * Offers a native .ics download (Apple/Outlook) and a Google Calendar link. + */ +export function AddToCalendar({ event, color = '#06b6d4', className }: AddToCalendarProps) { + return ( + + + + + + downloadICS(event)} + className="cursor-pointer" + > + + Apple / Outlook (.ics) + + + + + Google Calendar + + + + + ); +} diff --git a/src/components/TourCard.tsx b/src/components/TourCard.tsx index 693d1da..9a6d676 100644 --- a/src/components/TourCard.tsx +++ b/src/components/TourCard.tsx @@ -59,17 +59,28 @@ function formatDuration(duration?: number, unit?: string): string { } } -function formatDateRange(startDate?: number, endDate?: number): string { +function formatDateRange(startDate?: number, endDate?: number, withTime = false): string { if (!startDate) return ''; - + const start = new Date(startDate * 1000); + + if (withTime) { + // Local scheduled session — show the day and start time. + return start.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + const startStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - + if (!endDate) return startStr; - + const end = new Date(endDate * 1000); const endStr = end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - + return `${startStr} - ${endStr}`; } @@ -292,11 +303,11 @@ export function TourCard({ tour, compact = false }: TourCardProps) { - {/* Tour dates for destruction tours */} - {tour.category === 'destruction' && tour.startDate && ( + {/* Tour schedule — shown whenever the tour has a date set */} + {tour.startDate && (
- {formatDateRange(tour.startDate, tour.endDate)} + {formatDateRange(tour.startDate, tour.endDate, tour.category === 'local')}
)} diff --git a/src/lib/calendar.ts b/src/lib/calendar.ts new file mode 100644 index 0000000..7b8e2ad --- /dev/null +++ b/src/lib/calendar.ts @@ -0,0 +1,143 @@ +/** + * Lightweight iCalendar (.ics) generation for skate tours & events. + * + * No external deps — we build a spec-compliant VEVENT string and hand it to the + * browser as a downloadable file so attendees can drop a tour straight into + * Apple Calendar, Google Calendar, Outlook, etc. + */ + +export interface CalendarEventInput { + /** Stable unique id (we use the Nostr addressable coordinate). */ + uid: string; + title: string; + description?: string; + location?: string; + /** Start time in unix seconds. */ + start: number; + /** Optional end time in unix seconds. If omitted, defaults to start + 2h. */ + end?: number; + /** Optional URL to include in the event body. */ + url?: string; + /** When true, the event is treated as an all-day / multi-day date range. */ + allDay?: boolean; +} + +/** Escape text per RFC 5545 (commas, semicolons, backslashes, newlines). */ +function escapeICS(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); +} + +/** Format unix seconds to a UTC timestamp: YYYYMMDDTHHMMSSZ */ +function toICSDateTime(secs: number): string { + const d = new Date(secs * 1000); + return ( + d.getUTCFullYear().toString().padStart(4, '0') + + (d.getUTCMonth() + 1).toString().padStart(2, '0') + + d.getUTCDate().toString().padStart(2, '0') + + 'T' + + d.getUTCHours().toString().padStart(2, '0') + + d.getUTCMinutes().toString().padStart(2, '0') + + d.getUTCSeconds().toString().padStart(2, '0') + + 'Z' + ); +} + +/** Format unix seconds to an all-day date value: YYYYMMDD (local calendar day). */ +function toICSDate(secs: number): string { + const d = new Date(secs * 1000); + return ( + d.getFullYear().toString().padStart(4, '0') + + (d.getMonth() + 1).toString().padStart(2, '0') + + d.getDate().toString().padStart(2, '0') + ); +} + +/** Build a full VCALENDAR document containing a single VEVENT. */ +export function buildICS(input: CalendarEventInput): string { + const now = toICSDateTime(Math.floor(Date.now() / 1000)); + + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//SkateSpots//Tours//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'BEGIN:VEVENT', + `UID:${escapeICS(input.uid)}@skate-spots.org`, + `DTSTAMP:${now}`, + ]; + + if (input.allDay) { + // All-day: DTEND is exclusive, so add one day to the last calendar day. + const endSecs = input.end ?? input.start; + lines.push(`DTSTART;VALUE=DATE:${toICSDate(input.start)}`); + lines.push(`DTEND;VALUE=DATE:${toICSDate(endSecs + 24 * 60 * 60)}`); + } else { + const endSecs = input.end ?? input.start + 2 * 60 * 60; + lines.push(`DTSTART:${toICSDateTime(input.start)}`); + lines.push(`DTEND:${toICSDateTime(endSecs)}`); + } + + lines.push(`SUMMARY:${escapeICS(input.title)}`); + + const descParts: string[] = []; + if (input.description) descParts.push(input.description); + if (input.url) descParts.push(input.url); + if (descParts.length > 0) { + lines.push(`DESCRIPTION:${escapeICS(descParts.join('\n\n'))}`); + } + + if (input.location) lines.push(`LOCATION:${escapeICS(input.location)}`); + if (input.url) lines.push(`URL:${escapeICS(input.url)}`); + + lines.push('END:VEVENT', 'END:VCALENDAR'); + + return lines.join('\r\n'); +} + +/** Trigger a browser download of an .ics file for the given event. */ +export function downloadICS(input: CalendarEventInput, filename?: string): void { + const ics = buildICS(input); + const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename ?? `${slug(input.title) || 'skate-tour'}.ics`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Give the browser a tick to start the download before revoking. + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** Build a Google Calendar "add event" URL as a fallback for the web. */ +export function googleCalendarUrl(input: CalendarEventInput): string { + const fmt = input.allDay ? toICSDate : toICSDateTime; + const endSecs = input.allDay + ? (input.end ?? input.start) + 24 * 60 * 60 + : input.end ?? input.start + 2 * 60 * 60; + + const params = new URLSearchParams({ + action: 'TEMPLATE', + text: input.title, + dates: `${fmt(input.start)}/${fmt(endSecs)}`, + }); + if (input.description || input.url) { + params.set('details', [input.description, input.url].filter(Boolean).join('\n\n')); + } + if (input.location) params.set('location', input.location); + + return `https://calendar.google.com/calendar/render?${params.toString()}`; +} + +function slug(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 50); +} diff --git a/src/pages/CreateTourPage.tsx b/src/pages/CreateTourPage.tsx index d88ec17..277e5b1 100644 --- a/src/pages/CreateTourPage.tsx +++ b/src/pages/CreateTourPage.tsx @@ -204,7 +204,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = const { toast } = useToast(); // Tour category state - // Convert a unix-seconds timestamp to a value the datetime-local input accepts. + // Convert a unix-seconds timestamp to a value a datetime-local input accepts (YYYY-MM-DDTHH:mm). const toLocalInput = (secs?: number) => { if (!secs) return ''; const d = new Date(secs * 1000); @@ -213,6 +213,22 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = return new Date(d.getTime() - off).toISOString().slice(0, 16); }; + // Convert a unix-seconds timestamp to a value a date input accepts (YYYY-MM-DD). + const toDateInput = (secs?: number) => { + if (!secs) return ''; + const d = new Date(secs * 1000); + const off = d.getTimezoneOffset() * 60000; + return new Date(d.getTime() - off).toISOString().slice(0, 10); + }; + + // Parse a date OR datetime-local input string back to unix seconds (local time respected). + const inputToSecs = (val: string): number | undefined => { + if (!val) return undefined; + const ms = new Date(val).getTime(); + if (Number.isNaN(ms)) return undefined; + return Math.floor(ms / 1000); + }; + const [tourCategory, setTourCategory] = useState(existing?.category ?? 'local'); // Form state — prefilled from `existing` when editing. @@ -250,10 +266,19 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = const [newCountry, setNewCountry] = useState(''); const [transportInfo, setTransportInfo] = useState(existing?.transportInfo ?? ''); const [accommodationInfo, setAccommodationInfo] = useState(existing?.accommodationInfo ?? ''); - - // World Destruction Tour specific - const [startDate, setStartDate] = useState(toLocalInput(existing?.startDate)); - const [endDate, setEndDate] = useState(toLocalInput(existing?.endDate)); + + // ============ SCHEDULE (all categories) ============ + // Local tours: a specific session date+time (datetime-local). Optional — a guide + // may run an ongoing, book-anytime tour instead of a fixed session. + const [sessionDateTime, setSessionDateTime] = useState(toLocalInput(existing?.startDate)); + // Whether the local tour has a fixed scheduled session (vs. flexible/by-appointment). + const [hasFixedSession, setHasFixedSession] = useState( + existing?.category === 'local' ? !!existing?.startDate : false + ); + + // World Destruction Tour + National/International trip window (date only) + const [startDate, setStartDate] = useState(toDateInput(existing?.startDate)); + const [endDate, setEndDate] = useState(toDateInput(existing?.endDate)); const [entryFee, setEntryFee] = useState(existing?.entryFee != null ? String(existing.entryFee) : ''); const [prizePool, setPrizePool] = useState(existing?.prizePool != null ? String(existing.prizePool) : ''); const [prizeDistribution, setPrizeDistribution] = useState( @@ -278,7 +303,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = const [enableFilmRelease, setEnableFilmRelease] = useState(!!existing?.filmRelease); const [filmReleaseAtShops, setFilmReleaseAtShops] = useState(existing?.filmRelease?.atShops ?? true); // Release at partner skate shops const [filmReleaseOnline, setFilmReleaseOnline] = useState(existing?.filmRelease?.online ?? true); // Also release online - const [filmReleaseDate, setFilmReleaseDate] = useState(toLocalInput(existing?.filmRelease?.releaseDate)); // Optional specific date for film release + const [filmReleaseDate, setFilmReleaseDate] = useState(toDateInput(existing?.filmRelease?.releaseDate)); // Optional specific date for film release const [showAvailableShops, setShowAvailableShops] = useState(false); // Toggle to show available shops for premieres // Fetch shops for partner selection @@ -520,6 +545,38 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = return; } + // ---- Schedule validation (consistent across categories) ---- + + // Local: if the guide flagged a fixed session, a date/time is mandatory. + if (tourCategory === 'local' && hasFixedSession && !sessionDateTime) { + toast({ + title: 'Session date required', + description: 'Pick the date and time for your scheduled session, or switch to a flexible schedule.', + variant: 'destructive', + }); + return; + } + + // National & International: a trip needs a start date so skaters can plan/book travel. + if (tourCategory === 'national' || tourCategory === 'international') { + if (!startDate) { + toast({ + title: 'Departure date required', + description: 'Set when the trip departs so skaters can plan and book.', + variant: 'destructive', + }); + return; + } + if (endDate && inputToSecs(endDate)! < inputToSecs(startDate)!) { + toast({ + title: 'Return before departure', + description: 'The return date must be on or after the departure date.', + variant: 'destructive', + }); + return; + } + } + // Destruction tour validations if (tourCategory === 'destruction') { if (!startDate) { @@ -530,6 +587,14 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = }); return; } + if (endDate && inputToSecs(endDate)! < inputToSecs(startDate)!) { + toast({ + title: 'End before start', + description: 'The competition end date must be on or after the start date.', + variant: 'destructive', + }); + return; + } if (challenges.length === 0) { toast({ title: 'Challenges required', @@ -606,8 +671,8 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = tags.push(['price', price]); } - // Availability only for non-destruction tours - if (availability.trim() && tourCategory !== 'destruction') { + // Availability only for local tours running a flexible (by-appointment) schedule. + if (availability.trim() && tourCategory === 'local' && !hasFixedSession) { tags.push(['available', availability.trim()]); } @@ -630,6 +695,21 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = } } + // ============ SCHEDULE TAGS (local / national / international) ============ + // Destruction dates are written in its own block below. For the other three + // categories we store the schedule with the same `start-date` / `end-date` + // tags so every tour surfaces a real date + an "Add to calendar" action. + if (tourCategory === 'local' && hasFixedSession) { + const sessionSecs = inputToSecs(sessionDateTime); + if (sessionSecs) tags.push(['start-date', sessionSecs.toString()]); + } + if (tourCategory === 'national' || tourCategory === 'international') { + const startSecs = inputToSecs(startDate); + const endSecs = inputToSecs(endDate); + if (startSecs) tags.push(['start-date', startSecs.toString()]); + if (endSecs) tags.push(['end-date', endSecs.toString()]); + } + // ============ NATIONAL/INTERNATIONAL SPECIFIC TAGS ============ if (tourCategory === 'national' || tourCategory === 'international') { if (cities.length > 0) { @@ -667,12 +747,14 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = } // Competition dates - challenges are revealed on start date - const startTimestamp = startDate ? (new Date(startDate).getTime() / 1000).toString() : ''; + const startSecs = inputToSecs(startDate); + const startTimestamp = startSecs ? startSecs.toString() : ''; if (startTimestamp) { tags.push(['start-date', startTimestamp]); } - if (endDate) { - tags.push(['end-date', (new Date(endDate).getTime() / 1000).toString()]); + const endSecs = inputToSecs(endDate); + if (endSecs) { + tags.push(['end-date', endSecs.toString()]); } // Entry fee @@ -712,8 +794,9 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = if (releaseOptions.length > 0) { tags.push(['film-release', ...releaseOptions]); } - if (filmReleaseDate) { - tags.push(['film-release-date', (new Date(filmReleaseDate).getTime() / 1000).toString()]); + const filmSecs = inputToSecs(filmReleaseDate); + if (filmSecs) { + tags.push(['film-release-date', filmSecs.toString()]); } } } @@ -893,47 +976,111 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = return (
-
- + {/* Immersive header — layered, category-tinted, skate-punk */} +
+ Back to Map -

{isEditing ? 'Edit Tour' : 'Create a Tour'}

-

- {TOUR_CATEGORIES[tourCategory].description} -

+
+ {/* Diagonal grunge stripes */} +
+
+
+ {TOUR_CATEGORIES[tourCategory].emoji} +
+
+

+ {isEditing ? 'Edit' : 'New'} · {TOUR_CATEGORIES[tourCategory].label} +

+

+ {isEditing ? 'Edit Tour' : 'Drop a Tour'} +

+

+ {TOUR_CATEGORIES[tourCategory].description} +

+
+
+
{/* Tour Category Selection */}
- -
+ +
{Object.entries(TOUR_CATEGORIES).map(([key, cat]) => ( - + + {cat.emoji} + + + {cat.label} + + + {cat.durationLabel} + + {tourCategory === key && ( + + )} + ))}
@@ -951,10 +1098,11 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = <>

How Local Tours Work

    -
  • • You set your price - platform takes 10%
  • -
  • • Hidden spots stay hidden - you show them in person
  • -
  • • Visitors book via DM and pay in Lightning
  • -
  • • Build your reputation with reviews and zaps
  • +
  • • You set your price — platform takes 10%
  • +
  • • Hidden spots stay hidden — you show them in person
  • +
  • • Run a scheduled session or keep it flexible / by-appointment
  • +
  • • Skaters book via DM, pay in Lightning & can add it to their calendar
  • +
  • • Build your rep with reviews and zaps
)} @@ -963,8 +1111,9 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =

How National Tours Work

  • • Multi-city road trips across your country
  • -
  • • You set the price - platform takes only 7%
  • -
  • • Include transport/accommodation info for participants
  • +
  • • You set the price — platform takes only 7%
  • +
  • • Set trip dates so skaters can plan time off & travel
  • +
  • • Include transport / accommodation info for the crew
  • • Build legendary skate adventures!
@@ -974,7 +1123,8 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =

How International Tours Work

  • • Cross-border adventures across multiple countries
  • -
  • • You set the price - platform takes only 5%
  • +
  • • You set the price — platform takes only 5%
  • +
  • • Set departure & return dates so the crew can book flights
  • • Connect skaters from around the world
  • • Create unforgettable global experiences!
@@ -1153,6 +1303,140 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =

+ {/* ============ SCHEDULE (local / national / international) ============ */} + {/* Local: optional fixed session date+time OR flexible/by-appointment. */} + {tourCategory === 'local' && ( +
+
+
+ + +
+
+

+ Run this as a one-off scheduled session, or keep it flexible and let skaters book a time by DM. +

+ + {/* Fixed vs flexible toggle */} +
+ + +
+ + {hasFixedSession ? ( +
+ + setSessionDateTime(e.target.value)} + className="bg-black" + style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }} + /> +

+ Skaters will see this time and can add it to their calendar from the tour page. +

+
+ ) : ( +
+ + setAvailability(e.target.value)} + className="bg-black" + style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }} + /> +
+ )} +
+ )} + + {/* National & International: trip window (departure required, return optional). */} + {(tourCategory === 'national' || tourCategory === 'international') && ( +
+
+ + +
+

+ Lock in the dates so skaters can request time off, book flights, and plan the trip. +

+
+
+ + setStartDate(e.target.value)} + className="bg-black" + style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }} + /> +
+
+ + setEndDate(e.target.value)} + className="bg-black" + style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }} + /> +
+
+
+ )} + {/* Skill Level - not shown for destruction tours (always advanced) */} {tourCategory !== 'destruction' && (
@@ -1448,12 +1732,13 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =

- + setStartDate(e.target.value)} className="bg-black border-red-500/30" + style={{ colorScheme: 'dark' }} />
@@ -1461,8 +1746,10 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = setEndDate(e.target.value)} className="bg-black border-red-500/30" + style={{ colorScheme: 'dark' }} />
@@ -1792,8 +2079,9 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } = type="date" value={filmReleaseDate} onChange={(e) => setFilmReleaseDate(e.target.value)} - min={endDate || startDate} + min={endDate || startDate || undefined} className="bg-black border-purple-500/30" + style={{ colorScheme: 'dark' }} />

When films will be available. Leave blank to release immediately after competition ends. @@ -1962,17 +2250,8 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =

)} - {/* Availability */} -
- - setAvailability(e.target.value)} - className="bg-black border-cyan-500/30" - /> -
+ {/* Availability for local tours is handled in the Schedule section above. + National/International/Destruction use concrete dates instead. */} {/* Partner Shop Discount */} diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx index 9c46453..87ffced 100644 --- a/src/pages/TourDetailPage.tsx +++ b/src/pages/TourDetailPage.tsx @@ -11,9 +11,10 @@ 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, SKATE_TOUR_KIND } from '@/lib/types'; +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, @@ -45,8 +46,10 @@ export default function TourDetailPage() { const guideName = metadata?.name ?? genUserName(tour?.pubkey || ''); useSeoMeta({ - title: tour ? `${tour.title} - Local Tour - SkateSpots` : 'Local Tour - SkateSpots', - description: tour?.description || 'Discover hidden skate spots with a local guide', + 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 = () => { @@ -123,6 +126,60 @@ export default function TourDetailPage() { } 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 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 ( @@ -176,8 +233,15 @@ export default function TourDetailPage() {
- - LOCAL TOUR + + {categoryInfo.emoji} {categoryInfo.label}

{guideName}

-

Local Guide

+

+ {tour.category === 'destruction' ? 'Organizer' : 'Tour Guide'} +

@@ -225,6 +291,31 @@ export default function TourDetailPage() {
+ {/* Schedule + Add to Calendar */} + {hasSchedule && calendarEvent && ( +
+
+ +
+
+

+ {isLocalSession ? 'Scheduled Session' : tour.category === 'destruction' ? 'Competition Window' : 'Trip Dates'} +

+

{formatSchedule()}

+ {durationLabel && ( +

Runs about {durationLabel}

+ )} +
+ +
+ )} + {/* Description */} {tour.description && (
@@ -241,7 +332,7 @@ export default function TourDetailPage() { Duration
-

{tour.duration} hours

+

{durationLabel}

)}