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:
parent
a984084464
commit
1c295e96e2
6 changed files with 663 additions and 75 deletions
4
NIP.md
4
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 |
|
||||
|
|
|
|||
62
src/components/AddToCalendar.tsx
Normal file
62
src/components/AddToCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
143
src/lib/calendar.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 & 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 & 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 & 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 & 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 & 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}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue