Improve Create Tour: consistent schedules, calendar export & punk redesign

- Add a unified Schedule section across all four tour categories:
  - Local: choose a fixed session (date+time) or flexible/by-appointment
  - National/International: required departure + optional return trip dates
  - Destruction: existing competition window, now with end>=start validation
- Reuse start-date/end-date tags for every category (parser already supported them)
- Fix datetime handling: dedicated toDateInput/inputToSecs helpers so date and
  datetime-local inputs round-trip correctly (previous mix of toLocalInput +
  type="date" was inconsistent)
- Consistent, logical mandatory-field validation with clear required markers
- Redesign the header into an immersive, category-tinted, grunge-striped banner
  and rebuild the category selector as elegant glowing cards
- New calendar utilities (.ics + Google Calendar) and AddToCalendar component
- TourDetailPage is now fully category-aware (badge, guide label, duration,
  SEO title) with a Schedule card + Add to Calendar for attendees
- TourCard shows the schedule for all categories (time for local sessions)
- Update NIP.md to document start-date/end-date across categories

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 22:16:16 +07:00
parent a984084464
commit 1c295e96e2
6 changed files with 663 additions and 75 deletions

4
NIP.md
View file

@ -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 |

View file

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className={cn('gap-2 font-bold', className)}
style={{ borderColor: `${color}55`, color }}
>
<CalendarPlus className="h-4 w-4" />
Add to Calendar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
onClick={() => downloadICS(event)}
className="cursor-pointer"
>
<Download className="h-4 w-4 mr-2" />
Apple / Outlook (.ics)
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<a
href={googleCalendarUrl(event)}
target="_blank"
rel="noopener noreferrer"
>
<CalendarClock className="h-4 w-4 mr-2" />
Google Calendar
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View file

@ -59,10 +59,21 @@ 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;
@ -292,11 +303,11 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
</h3>
</div>
{/* Tour dates for destruction tours */}
{tour.category === 'destruction' && tour.startDate && (
{/* Tour schedule — shown whenever the tour has a date set */}
{tour.startDate && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Calendar className="h-4 w-4" style={{ color: categoryInfo.color }} />
<span>{formatDateRange(tour.startDate, tour.endDate)}</span>
<span>{formatDateRange(tour.startDate, tour.endDate, tour.category === 'local')}</span>
</div>
)}

143
src/lib/calendar.ts Normal file
View file

@ -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);
}

View file

@ -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<TourCategory>(existing?.category ?? 'local');
// Form state — prefilled from `existing` when editing.
@ -251,9 +267,18 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
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<boolean>(
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 (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
{/* Immersive header — layered, category-tinted, skate-punk */}
<div className="mb-8">
<Link to="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-5">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Map
</Link>
<h1 className="text-3xl font-black uppercase" style={{
fontFamily: 'Impact, "Arial Black", sans-serif',
color: TOUR_CATEGORIES[tourCategory].color,
textShadow: `0 0 10px ${TOUR_CATEGORIES[tourCategory].color}50`,
}}>{isEditing ? 'Edit Tour' : 'Create a Tour'}</h1>
<p className="text-gray-400 mt-1">
{TOUR_CATEGORIES[tourCategory].description}
</p>
<div
className="relative overflow-hidden rounded-2xl border-2 p-6 sm:p-8"
style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}55`,
background: `radial-gradient(120% 120% at 0% 0%, ${TOUR_CATEGORIES[tourCategory].color}22 0%, rgba(0,0,0,0) 55%), linear-gradient(160deg, rgba(0,0,0,0.6), rgba(0,0,0,0.9))`,
}}
>
{/* Diagonal grunge stripes */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.07]"
style={{
backgroundImage: `repeating-linear-gradient(45deg, ${TOUR_CATEGORIES[tourCategory].color} 0px, ${TOUR_CATEGORIES[tourCategory].color} 2px, transparent 2px, transparent 14px)`,
}}
/>
<div className="relative flex items-center gap-4">
<div
className="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-xl text-3xl shadow-lg"
style={{
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}22`,
border: `2px solid ${TOUR_CATEGORIES[tourCategory].color}`,
boxShadow: `0 0 30px ${TOUR_CATEGORIES[tourCategory].color}40`,
}}
>
{TOUR_CATEGORIES[tourCategory].emoji}
</div>
<div className="min-w-0">
<p
className="text-[11px] font-black uppercase tracking-[0.25em]"
style={{ color: TOUR_CATEGORIES[tourCategory].color }}
>
{isEditing ? 'Edit' : 'New'} · {TOUR_CATEGORIES[tourCategory].label}
</p>
<h1
className="text-3xl sm:text-4xl font-black uppercase leading-none"
style={{
fontFamily: 'Impact, "Arial Black", sans-serif',
color: 'white',
textShadow: `0 0 18px ${TOUR_CATEGORIES[tourCategory].color}70`,
}}
>
{isEditing ? 'Edit Tour' : 'Drop a Tour'}
</h1>
<p className="text-gray-400 mt-1 text-sm">
{TOUR_CATEGORIES[tourCategory].description}
</p>
</div>
</div>
</div>
</div>
{/* Tour Category Selection */}
<div className="mb-6">
<Label className="text-base font-bold mb-3 block">Tour Type</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<Label className="text-xs font-black uppercase tracking-widest text-gray-400 mb-3 block">
Pick your ride
</Label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
{Object.entries(TOUR_CATEGORIES).map(([key, cat]) => (
<Button
<button
key={key}
type="button"
variant={tourCategory === key ? 'default' : 'outline'}
onClick={() => setTourCategory(key as TourCategory)}
className={cn(
'flex-col h-auto py-3 gap-1',
'group relative flex flex-col items-center gap-1 rounded-xl border-2 py-4 px-2 transition-all duration-200 overflow-hidden',
tourCategory === key
? 'border-2'
: 'border-gray-700 hover:border-gray-500'
? 'scale-[1.02]'
: 'border-gray-800 bg-black/40 hover:border-gray-600 hover:-translate-y-0.5'
)}
style={{
backgroundColor: tourCategory === key ? cat.color : undefined,
borderColor: tourCategory === key ? cat.color : undefined,
color: tourCategory === key ? (key === 'local' || key === 'international' ? 'white' : 'black') : undefined,
}}
style={
tourCategory === key
? {
borderColor: cat.color,
backgroundColor: `${cat.color}18`,
boxShadow: `0 8px 30px ${cat.color}30, inset 0 0 0 1px ${cat.color}30`,
}
: undefined
}
>
<span className="text-xl">{cat.emoji}</span>
<span className="text-xs font-bold">{cat.label}</span>
<span className="text-[10px] opacity-70">{cat.durationLabel}</span>
</Button>
<span
className="text-2xl transition-transform duration-200 group-hover:scale-110"
style={{ filter: tourCategory === key ? `drop-shadow(0 0 8px ${cat.color})` : undefined }}
>
{cat.emoji}
</span>
<span
className="text-[11px] font-black uppercase tracking-wide leading-tight text-center"
style={{ color: tourCategory === key ? cat.color : '#e5e7eb' }}
>
{cat.label}
</span>
<span className="text-[9px] font-medium uppercase tracking-wider text-gray-500">
{cat.durationLabel}
</span>
{tourCategory === key && (
<span
className="absolute bottom-0 left-0 h-1 w-full"
style={{ backgroundColor: cat.color }}
/>
)}
</button>
))}
</div>
</div>
@ -951,10 +1098,11 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
<>
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>How Local Tours Work</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• You set your price - <strong>platform takes 10%</strong></li>
<li>• Hidden spots stay hidden - you show them in person</li>
<li>• Visitors book via DM and pay in Lightning</li>
<li>• Build your reputation with reviews and zaps</li>
<li>• You set your price — <strong>platform takes 10%</strong></li>
<li>• Hidden spots stay hidden — you show them in person</li>
<li>• Run a <strong>scheduled session</strong> or keep it flexible / by-appointment</li>
<li>• Skaters book via DM, pay in Lightning &amp; can add it to their calendar</li>
<li>• Build your rep with reviews and zaps</li>
</ul>
</>
)}
@ -963,8 +1111,9 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>How National Tours Work</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• Multi-city road trips across your country</li>
<li>• You set the price - <strong>platform takes only 7%</strong></li>
<li>• Include transport/accommodation info for participants</li>
<li>• You set the price — <strong>platform takes only 7%</strong></li>
<li>• <strong>Set trip dates</strong> so skaters can plan time off &amp; travel</li>
<li>• Include transport / accommodation info for the crew</li>
<li>• Build legendary skate adventures!</li>
</ul>
</>
@ -974,7 +1123,8 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
<p className="font-bold mb-1" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>How International Tours Work</p>
<ul className="text-gray-300 space-y-1 text-xs">
<li>• Cross-border adventures across multiple countries</li>
<li>• You set the price - <strong>platform takes only 5%</strong></li>
<li>• You set the price — <strong>platform takes only 5%</strong></li>
<li>• <strong>Set departure &amp; return dates</strong> so the crew can book flights</li>
<li>• Connect skaters from around the world</li>
<li>• Create unforgettable global experiences!</li>
</ul>
@ -1153,6 +1303,140 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
</p>
</div>
{/* ============ SCHEDULE (local / national / international) ============ */}
{/* Local: optional fixed session date+time OR flexible/by-appointment. */}
{tourCategory === 'local' && (
<div
className="space-y-3 p-4 rounded-lg border-2"
style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`,
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}08`,
}}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
<Label className="text-base font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
Schedule
</Label>
</div>
</div>
<p className="text-xs text-gray-400 -mt-1">
Run this as a one-off scheduled session, or keep it flexible and let skaters book a time by DM.
</p>
{/* Fixed vs flexible toggle */}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={hasFixedSession ? 'default' : 'outline'}
onClick={() => setHasFixedSession(true)}
className={cn(
'h-auto py-2.5 flex-col items-start gap-0.5',
!hasFixedSession && 'border-gray-700 hover:border-gray-500'
)}
style={hasFixedSession ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color, color: 'white' } : undefined}
>
<span className="text-xs font-bold flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" /> Fixed session
</span>
<span className={cn('text-[10px]', hasFixedSession ? 'text-white/80' : 'text-gray-500')}>
A set date &amp; time
</span>
</Button>
<Button
type="button"
variant={!hasFixedSession ? 'default' : 'outline'}
onClick={() => setHasFixedSession(false)}
className={cn(
'h-auto py-2.5 flex-col items-start gap-0.5',
hasFixedSession && 'border-gray-700 hover:border-gray-500'
)}
style={!hasFixedSession ? { backgroundColor: TOUR_CATEGORIES[tourCategory].color, color: 'white' } : undefined}
>
<span className="text-xs font-bold flex items-center gap-1">
<Clock className="h-3.5 w-3.5" /> Flexible
</span>
<span className={cn('text-[10px]', !hasFixedSession ? 'text-white/80' : 'text-gray-500')}>
Book anytime by DM
</span>
</Button>
</div>
{hasFixedSession ? (
<div className="space-y-2 pt-1">
<Label className="text-xs">Session date &amp; time *</Label>
<Input
type="datetime-local"
value={sessionDateTime}
onChange={(e) => setSessionDateTime(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }}
/>
<p className="text-[11px] text-gray-500">
Skaters will see this time and can add it to their calendar from the tour page.
</p>
</div>
) : (
<div className="space-y-2 pt-1">
<Label htmlFor="availability" className="text-xs">When are you usually available?</Label>
<Input
id="availability"
placeholder="e.g., Weekends, Mornings only, By appointment"
value={availability}
onChange={(e) => setAvailability(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30` }}
/>
</div>
)}
</div>
)}
{/* National & International: trip window (departure required, return optional). */}
{(tourCategory === 'national' || tourCategory === 'international') && (
<div
className="space-y-3 p-4 rounded-lg border-2"
style={{
borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`,
backgroundColor: `${TOUR_CATEGORIES[tourCategory].color}08`,
}}
>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5" style={{ color: TOUR_CATEGORIES[tourCategory].color }} />
<Label className="text-base font-bold" style={{ color: TOUR_CATEGORIES[tourCategory].color }}>
Trip Dates
</Label>
</div>
<p className="text-xs text-gray-400 -mt-1">
Lock in the dates so skaters can request time off, book flights, and plan the trip.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Departure *</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }}
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Return</Label>
<Input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(e) => setEndDate(e.target.value)}
className="bg-black"
style={{ borderColor: `${TOUR_CATEGORIES[tourCategory].color}30`, colorScheme: 'dark' }}
/>
</div>
</div>
</div>
)}
{/* Skill Level - not shown for destruction tours (always advanced) */}
{tourCategory !== 'destruction' && (
<div className="space-y-3">
@ -1448,12 +1732,13 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs">Start Date</Label>
<Label className="text-xs">Start Date *</Label>
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-black border-red-500/30"
style={{ colorScheme: 'dark' }}
/>
</div>
<div className="space-y-2">
@ -1461,8 +1746,10 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
<Input
type="date"
value={endDate}
min={startDate || undefined}
onChange={(e) => setEndDate(e.target.value)}
className="bg-black border-red-500/30"
style={{ colorScheme: 'dark' }}
/>
</div>
</div>
@ -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' }}
/>
<p className="text-xs text-gray-500">
When films will be available. Leave blank to release immediately after competition ends.
@ -1962,17 +2250,8 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
</div>
)}
{/* Availability */}
<div className="space-y-2">
<Label htmlFor="availability">Availability</Label>
<Input
id="availability"
placeholder="e.g., Weekends, By appointment, Mornings only"
value={availability}
onChange={(e) => setAvailability(e.target.value)}
className="bg-black border-cyan-500/30"
/>
</div>
{/* Availability for local tours is handled in the Schedule section above.
National/International/Destruction use concrete dates instead. */}
{/* Partner Shop Discount */}
<Collapsible open={shopExpanded} onOpenChange={setShopExpanded}>

View file

@ -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 (
<Layout>
@ -176,8 +233,15 @@ export default function TourDetailPage() {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Badge className="bg-cyan-500/20 text-cyan-300 border-cyan-500/30">
LOCAL TOUR
<Badge
className="font-bold uppercase"
style={{
backgroundColor: `${accent}22`,
color: accent,
borderColor: `${accent}55`,
}}
>
{categoryInfo.emoji} {categoryInfo.label}
</Badge>
<ProBadge
featuredUntil={tour.featuredUntil}
@ -209,7 +273,9 @@ export default function TourDetailPage() {
</Avatar>
<div>
<p className="font-bold text-white">{guideName}</p>
<p className="text-xs text-cyan-400">Local Guide</p>
<p className="text-xs" style={{ color: accent }}>
{tour.category === 'destruction' ? 'Organizer' : 'Tour Guide'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
@ -225,6 +291,31 @@ export default function TourDetailPage() {
</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">
<p className="text-[11px] font-black uppercase tracking-widest" style={{ color: accent }}>
{isLocalSession ? 'Scheduled Session' : tour.category === 'destruction' ? 'Competition Window' : 'Trip Dates'}
</p>
<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>
@ -241,7 +332,7 @@ export default function TourDetailPage() {
<Clock className="h-4 w-4" />
<span className="text-xs">Duration</span>
</div>
<p className="font-bold text-white">{tour.duration} hours</p>
<p className="font-bold text-white">{durationLabel}</p>
</div>
)}