Add tour countdown badges + fix/standardize NIP-05 provider prompts

Countdown badges (upcoming tours):
- New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" /
  "Happening now" (pulsing) based on start/end dates, category-tinted
- Shown on both compact and full TourCard, and in the TourDetailPage schedule card

NIP-05 provider fix (removes obsolete nip05.social):
- New single source of truth src/lib/nip05Providers.ts with working, curated
  providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid)
- New reusable Nip05Required component (full-page "gate" + inline "banner"
  variants) listing those providers and deep-linking to /settings?section=nip05
- Wired into CreateTourPage (gate), CreateListingPage (banner) and all four
  Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt
- SettingsPage NIP-05 screen now reads from the shared provider list
- Updated internal planning docs to match

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-09 22:31:44 +07:00
parent 1c295e96e2
commit 5d96f8dfa8
13 changed files with 344 additions and 155 deletions

View file

@ -40,8 +40,8 @@
### ✅ Phase 5: NIP-05 Providers Guide ($1.50)
- Added NIP-05 section to Settings profile tab
- **Free providers**: nostr.directory, nip05.social
- **Paid providers**: nostrplebs.com ($3/mo), nostr.band (one-time)
- **Free providers**: nostrcheck.me, nostr.directory
- **Paid providers**: Nostr Plebs, nostr.band
- All providers clickable with descriptions
- Includes helpful tip about adding NIP-05 to profile

View file

@ -180,18 +180,18 @@
**Steps**:
1. Create NIP-05 section in SettingsPage
2. List 4 providers:
- **Free**: nostr.directory, nip05.social
- **Paid**: nostrplebs.com, nostr.band
- **Free**: nostrcheck.me, nostr.directory
- **Paid**: Nostr Plebs, nostr.band
3. Add links and brief descriptions
4. Show current NIP-05 if set
5. Add "Test your NIP-05" button
6. Style with neon theme
**Providers to Include**:
**Providers to Include** (single source of truth: `src/lib/nip05Providers.ts`):
- nostrcheck.me (Free)
- nostr.directory (Free)
- nip05.social (Free)
- nostrplebs.com (Paid - $3/month)
- nostr.band (Paid - one-time)
- Nostr Plebs (Paid)
- nostr.band (Paid)
**Files**: `src/pages/SettingsPage.tsx`

View file

@ -0,0 +1,138 @@
import { Link } from 'react-router-dom';
import { ShieldCheck, ExternalLink, AlertTriangle } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
import { cn } from '@/lib/utils';
interface Nip05RequiredProps {
/** What the user is trying to do, e.g. "tour organizers", "sellers". */
role?: string;
/** Extra sentence explaining why it matters for this feature. */
reason?: string;
/** "gate" = full centered card (blocks the page). "banner" = inline notice. */
variant?: 'gate' | 'banner';
/** Accent hex color. Defaults to the app red used for required gates. */
color?: string;
className?: string;
}
/**
* Consistent "NIP-05 verification required" prompt used across create/become
* flows. Lists the curated, working providers (see lib/nip05Providers) and
* links to Settings. Single source of truth so obsolete providers never linger.
*/
export function Nip05Required({
role = 'creators',
reason,
variant = 'gate',
color = '#ef4444',
className,
}: Nip05RequiredProps) {
const providerList = (
<div className="space-y-2">
<p className="text-[11px] font-black uppercase tracking-widest text-gray-400">
Get a free / cheap NIP-05
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{NIP05_PROVIDERS.map((p) => (
<a
key={p.name}
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center justify-between gap-2 rounded-lg border border-white/10 bg-black/40 p-3 text-left transition-all hover:border-white/30 hover:bg-white/5"
>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-sm font-bold text-white">
<span className="truncate">{p.name}</span>
<ExternalLink className="h-3 w-3 flex-shrink-0 text-gray-500 group-hover:text-gray-300" />
</div>
<div className="text-xs text-gray-400 truncate">{p.desc}</div>
</div>
<span
className={cn(
'flex-shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold',
p.free
? 'border-green-500/30 bg-green-500/15 text-green-400'
: 'border-yellow-500/30 bg-yellow-500/15 text-yellow-400'
)}
>
{p.free ? 'Free' : 'Paid'}
</span>
</a>
))}
</div>
</div>
);
if (variant === 'banner') {
return (
<Card
className={cn('border-2', className)}
style={{ borderColor: `${color}55`, backgroundColor: `${color}12` }}
>
<CardContent className="p-4 space-y-3">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 flex-shrink-0 mt-0.5" style={{ color }} />
<div>
<h3 className="font-bold mb-1" style={{ color }}>
NIP-05 Verification Required
</h3>
<p className="text-sm text-gray-300">
{reason ??
`To prevent spam and build trust, ${role} need a verified Nostr address (NIP-05).`}
</p>
</div>
</div>
{providerList}
<Link to="/settings?section=nip05">
<Button
size="sm"
variant="outline"
className="font-bold"
style={{ borderColor: `${color}55`, color }}
>
Add NIP-05 in Settings
</Button>
</Link>
</CardContent>
</Card>
);
}
return (
<Card
className={cn('max-w-xl mx-auto border-2', className)}
style={{ borderColor: `${color}55`, backgroundColor: `${color}10` }}
>
<CardContent className="py-10 px-6 text-center">
<div
className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4"
style={{ backgroundColor: `${color}22` }}
>
<ShieldCheck className="h-8 w-8" style={{ color }} />
</div>
<h2 className="text-xl font-bold mb-2" style={{ color }}>
NIP-05 Verification Required
</h2>
<p className="text-gray-300 mb-2">
{reason ??
`To prevent spam and build trust, ${role} must have NIP-05 verification.`}
</p>
<p className="text-gray-500 text-sm mb-6">
NIP-05 proves your identity by linking your Nostr profile to a domain — like{' '}
<span className="text-gray-300">you@example.com</span>.
</p>
<div className="max-w-md mx-auto mb-6">{providerList}</div>
<Link to="/settings?section=nip05">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
Update Your Profile
</Button>
</Link>
</CardContent>
</Card>
);
}

View file

@ -84,6 +84,49 @@ function formatDateRange(startDate?: number, endDate?: number, withTime = false)
return `${startStr} - ${endStr}`;
}
/**
* A short, human countdown for an upcoming (or live) tour based on its start
* date. Returns null when there's no date or the tour is well in the past.
*/
function getCountdown(
tour: SkateTour
): { label: string; color: string; live?: boolean } | null {
if (!tour.startDate) return null;
const nowSecs = Date.now() / 1000;
const start = tour.startDate;
const end = tour.endDate;
const accent = TOUR_CATEGORIES[tour.category].color;
// Currently running (has an end date and we're inside the window).
if (start <= nowSecs && end && nowSecs <= end) {
return { label: 'Happening now', color: '#ff0040', live: true };
}
// Already started but single-session / no end — treat same-day as live, else past.
if (start <= nowSecs) {
if (nowSecs - start < 12 * 60 * 60) {
return { label: 'Happening now', color: '#ff0040', live: true };
}
return null;
}
const diffSecs = start - nowSecs;
const days = Math.floor(diffSecs / 86400);
const hours = Math.floor(diffSecs / 3600);
if (hours < 1) return { label: 'Starting soon', color: accent, live: true };
if (hours < 24) return { label: `In ${hours}h`, color: accent };
if (days === 1) return { label: 'Tomorrow', color: accent };
if (days < 7) return { label: `In ${days} days`, color: accent };
if (days < 30) {
const w = Math.round(days / 7);
return { label: `In ${w} week${w > 1 ? 's' : ''}`, color: accent };
}
const m = Math.round(days / 30);
return { label: `In ${m} month${m > 1 ? 's' : ''}`, color: accent };
}
function getTourStatus(tour: SkateTour): { label: string; color: string } | null {
if (tour.category !== 'destruction') return null;
@ -110,6 +153,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
const categoryInfo = TOUR_CATEGORIES[tour.category];
const skillInfo = tour.skillLevel ? TOUR_SKILL_LEVELS[tour.skillLevel as keyof typeof TOUR_SKILL_LEVELS] : null;
const tourStatus = getTourStatus(tour);
const countdown = getCountdown(tour);
const isOwner = user?.pubkey === tour.pubkey;
// Get location text based on category
@ -156,6 +200,17 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<ProBadge featuredUntil={tour.featuredUntil} compact />
{countdown && (
<Badge
className={cn(
'text-[10px] border-0 font-bold uppercase',
countdown.live && 'animate-pulse'
)}
style={{ backgroundColor: countdown.color, color: countdown.color === '#ccff00' ? 'black' : 'white' }}
>
{countdown.label}
</Badge>
)}
{tourStatus && (
<Badge
className="text-xs border-0 font-bold uppercase"
@ -250,6 +305,21 @@ export function TourCard({ tour, compact = false }: TourCardProps) {
{/* Status/Price overlay */}
<div className="absolute top-3 right-3 flex flex-col gap-1 items-end">
<ProBadge featuredUntil={tour.featuredUntil} />
{countdown && (
<Badge
className={cn(
'backdrop-blur-sm shadow-lg font-bold text-xs uppercase',
countdown.live && 'animate-pulse'
)}
style={{
backgroundColor: `${countdown.color}ee`,
color: countdown.color === '#ccff00' ? 'black' : 'white',
}}
>
<Calendar className="h-3 w-3 mr-1" />
{countdown.label}
</Badge>
)}
{tourStatus && (
<Badge
className="backdrop-blur-sm shadow-lg font-bold text-xs uppercase"

44
src/lib/nip05Providers.ts Normal file
View file

@ -0,0 +1,44 @@
/**
* Curated list of NIP-05 verification providers.
*
* Kept in one place so every "NIP-05 required" prompt across the app points at
* the same up-to-date, working services. Ordered best-first.
*
* Note: nip05.social was removed — it is obsolete / no longer reliable.
*/
export interface Nip05Provider {
name: string;
/** Short human description shown under the name. */
desc: string;
url: string;
/** true = has a free tier, false = paid only. */
free: boolean;
}
export const NIP05_PROVIDERS: Nip05Provider[] = [
{
name: 'nostrcheck.me',
desc: 'Free NIP-05 + media hosting',
url: 'https://nostrcheck.me',
free: true,
},
{
name: 'nostr.directory',
desc: 'Free NIP-05 via a tweet',
url: 'https://nostr.directory',
free: true,
},
{
name: 'Nostr Plebs',
desc: '~$5/yr · custom & shared domains',
url: 'https://nostrplebs.com',
free: false,
},
{
name: 'nostr.band',
desc: 'Paid · analytics included',
url: 'https://nostr.band',
free: false,
},
];

View file

@ -18,6 +18,7 @@ import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
@ -275,24 +276,13 @@ export default function BecomeBuilderPage() {
<p className="text-gray-400 mt-1">Pour it, weld it, build it — list your ramp & park building</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
<Nip05Required
variant="banner"
color="#eab308"
role="builders"
reason="Add a NIP-05 identifier to verify your identity and build trust with clients."
className="mt-4"
/>
)}
{/* Wallet Requirement */}

View file

@ -17,6 +17,7 @@ import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
@ -226,24 +227,13 @@ export default function BecomeCoachPage() {
<p className="text-gray-400 mt-1">Share your skateboarding knowledge</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with students. This helps students know you're a legitimate coach.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
<Nip05Required
variant="banner"
color="#eab308"
role="coaches"
reason="Add a NIP-05 identifier to verify your identity and build trust with students — it shows them you're a legitimate coach."
className="mt-4"
/>
)}
{/* Wallet Requirement */}

View file

@ -17,6 +17,7 @@ import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
@ -242,24 +243,13 @@ export default function BecomeFilmerPage() {
<p className="text-gray-400 mt-1">Offer your skate video filming — clips, lines, full parts &amp; contest coverage</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with clients. This helps clients know you're a legitimate filmer.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
<Nip05Required
variant="banner"
color="#eab308"
role="filmers"
reason="Add a NIP-05 identifier to verify your identity and build trust with clients — it shows them you're a legitimate filmer."
className="mt-4"
/>
)}
{/* Wallet Requirement */}

View file

@ -17,6 +17,7 @@ import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
@ -292,24 +293,13 @@ export default function BecomePhotographerPage() {
<p className="text-gray-400 mt-1">Shoot the sessions — offer your skate photography</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
<Nip05Required
variant="banner"
color="#eab308"
role="photographers"
reason="Add a NIP-05 identifier to verify your identity and build trust with clients."
className="mt-4"
/>
)}
{/* Wallet Requirement */}

View file

@ -23,6 +23,7 @@ import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useSkateSpot, useSkateSpots } from '@/hooks/useSkateSpots';
import { useListingRestrictions, LISTING_FEE_SATS, recordListingCreation } from '@/hooks/useListingRestrictions';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpamFeePayment } from '@/components/SpamFeePayment';
import {
MARKETPLACE_ITEM_KIND,
@ -430,28 +431,12 @@ export default function CreateListingPage() {
{/* NIP-05 Requirement Notice - never shown to platform owners */}
{!hasNip05 && !restrictions.isPlatformOwner && (
<Card className="mb-6 bg-red-950/30 border-2 border-red-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-bold text-red-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-3">
To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear.
</p>
<Link to="/settings">
<Button
size="sm"
variant="outline"
className="border-red-500/50 hover:bg-red-500/10 text-red-400"
>
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
<Nip05Required
variant="banner"
role="sellers"
reason="To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear."
className="mb-6"
/>
)}
{hasNip05 && (

View file

@ -17,6 +17,7 @@ import { useShops } from '@/hooks/useShops';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { FeaturedUpgrade } from '@/components/FeaturedEventUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { SKATE_TOUR_KIND, TOUR_SKILL_LEVELS, TOUR_SPOT_TYPES, TOUR_CATEGORIES, type TourCategory, type DestructionChallenge, type SkateTour } from '@/lib/types';
@ -924,50 +925,7 @@ export default function CreateTourPage({ existing }: { existing?: SkateTour } =
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-2 border-red-500/50 bg-red-950/20">
<CardContent className="py-12 text-center">
<div className="w-16 h-16 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-4">
<Route className="h-8 w-8 text-red-400" />
</div>
<h2 className="text-xl font-bold mb-2 text-red-400">NIP-05 Verification Required</h2>
<p className="text-gray-300 mb-2">
To prevent spam and build trust, tour organizers must have NIP-05 verification.
</p>
<p className="text-gray-500 text-sm mb-6">
NIP-05 proves your identity by linking your Nostr profile to a domain you control.
</p>
<div className="space-y-3 max-w-sm mx-auto mb-6">
<p className="text-xs text-gray-400 font-bold uppercase">Get Free NIP-05:</p>
<div className="grid grid-cols-2 gap-2">
<a
href="https://nip05.social"
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-lg bg-black/50 border border-green-500/30 hover:border-green-500/60 transition-all text-left"
>
<div className="font-bold text-white text-sm">nip05.social</div>
<div className="text-xs text-gray-400">Free NIP-05</div>
</a>
<a
href="https://nostr.directory"
target="_blank"
rel="noopener noreferrer"
className="block p-3 rounded-lg bg-black/50 border border-purple-500/30 hover:border-purple-500/60 transition-all text-left"
>
<div className="font-bold text-white text-sm">nostr.directory</div>
<div className="text-xs text-gray-400">Free NIP-05</div>
</a>
</div>
</div>
<Link to="/settings">
<Button className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold">
Update Your Profile
</Button>
</Link>
</CardContent>
</Card>
<Nip05Required role="tour organizers" />
</div>
</Layout>
);

View file

@ -25,6 +25,7 @@ import { useAppContext } from '@/hooks/useAppContext';
import { useCitrine } from '@/hooks/useCitrine';
import { toast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import { NIP05_PROVIDERS } from '@/lib/nip05Providers';
import QRCode from 'qrcode';
import {
User,
@ -219,12 +220,7 @@ function ProfileScreen({ onBack }: { onBack: () => void }) {
// ─── NIP-05 sub-screen ────────────────────────────────────────────────────────
function Nip05Screen({ onBack }: { onBack: () => void }) {
const providers = [
{ name: 'nostr.directory', desc: 'Free NIP-05 addresses', url: 'https://nostr.directory', free: true },
{ name: 'nip05.social', desc: 'Free verification service', url: 'https://nip05.social', free: true },
{ name: 'nostrplebs.com', desc: '~$3/month · Custom domains', url: 'https://nostrplebs.com', free: false },
{ name: 'nostr.band', desc: 'One-time fee · Analytics included', url: 'https://nostr.band', free: false },
];
const providers = NIP05_PROVIDERS;
return (
<div>

View file

@ -168,6 +168,28 @@ export default function TourDetailPage() {
return `${tour.duration} hour${tour.duration > 1 ? 's' : ''}`;
})();
const countdownLabel = (() => {
if (!tour.startDate) return null;
const nowSecs = Date.now() / 1000;
if (tour.startDate <= nowSecs) {
if (tour.endDate && nowSecs <= tour.endDate) return { text: 'Happening now', live: true };
if (nowSecs - tour.startDate < 12 * 60 * 60) return { text: 'Happening now', live: true };
return null;
}
const days = Math.floor((tour.startDate - nowSecs) / 86400);
const hours = Math.floor((tour.startDate - nowSecs) / 3600);
if (hours < 1) return { text: 'Starting soon', live: true };
if (hours < 24) return { text: `In ${hours}h`, live: false };
if (days === 1) return { text: 'Tomorrow', live: false };
if (days < 7) return { text: `In ${days} days`, live: false };
if (days < 30) {
const w = Math.round(days / 7);
return { text: `In ${w} week${w > 1 ? 's' : ''}`, live: false };
}
const m = Math.round(days / 30);
return { text: `In ${m} month${m > 1 ? 's' : ''}`, live: false };
})();
const calendarEvent = tour.startDate
? {
uid: `${SKATE_TOUR_KIND}:${tour.pubkey}:${tour.identifier}`,
@ -304,9 +326,25 @@ export default function TourDetailPage() {
<Calendar className="h-7 w-7" style={{ color: accent }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[11px] font-black uppercase tracking-widest" style={{ color: accent }}>
{isLocalSession ? 'Scheduled Session' : tour.category === 'destruction' ? 'Competition Window' : 'Trip Dates'}
</p>
{countdownLabel && (
<Badge
className={cn(
'border-0 text-[10px] font-bold uppercase',
countdownLabel.live && 'animate-pulse'
)}
style={{
backgroundColor: countdownLabel.live ? '#ff0040' : accent,
color: 'white',
}}
>
{countdownLabel.text}
</Badge>
)}
</div>
<p className="text-white font-bold text-lg leading-tight">{formatSchedule()}</p>
{durationLabel && (
<p className="text-xs text-gray-400 mt-0.5">Runs about {durationLabel}</p>