diff --git a/BUDGET_IMPLEMENTATION.md b/BUDGET_IMPLEMENTATION.md
index ab25502..4161a97 100644
--- a/BUDGET_IMPLEMENTATION.md
+++ b/BUDGET_IMPLEMENTATION.md
@@ -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
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index e9aa048..b0401de 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -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`
diff --git a/src/components/Nip05Required.tsx b/src/components/Nip05Required.tsx
new file mode 100644
index 0000000..3222908
--- /dev/null
+++ b/src/components/Nip05Required.tsx
@@ -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 = (
+
+
+ Get a free / cheap NIP-05
+
+
+
+ );
+
+ if (variant === 'banner') {
+ return (
+
+
+
+
+
+
+ NIP-05 Verification Required
+
+
+ {reason ??
+ `To prevent spam and build trust, ${role} need a verified Nostr address (NIP-05).`}
+
+
+
+ {providerList}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ NIP-05 Verification Required
+
+
+ {reason ??
+ `To prevent spam and build trust, ${role} must have NIP-05 verification.`}
+
+
+ NIP-05 proves your identity by linking your Nostr profile to a domain — like{' '}
+ you@example.com.
+
+
+ {providerList}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/TourCard.tsx b/src/components/TourCard.tsx
index 9a6d676..b283e83 100644
--- a/src/components/TourCard.tsx
+++ b/src/components/TourCard.tsx
@@ -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) {
+ {countdown && (
+
+ {countdown.label}
+
+ )}
{tourStatus && (
+ {countdown && (
+
+
+ {countdown.label}
+
+ )}
{tourStatus && (
Pour it, weld it, build it — list your ramp & park building
{!hasNip05 && (
-
-
-
-
⚠️
-
-
NIP-05 Verification Required
-
- Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
-
-
-
-
-
-
-
-
+
)}
{/* Wallet Requirement */}
diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx
index 126b505..f45a70c 100644
--- a/src/pages/BecomeCoachPage.tsx
+++ b/src/pages/BecomeCoachPage.tsx
@@ -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() {
Share your skateboarding knowledge
{!hasNip05 && (
-
-
-
-
⚠️
-
-
NIP-05 Verification Required
-
- 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.
-
-
-
-
-
-
-
-
+
)}
{/* Wallet Requirement */}
diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx
index b59fa27..58d17bd 100644
--- a/src/pages/BecomeFilmerPage.tsx
+++ b/src/pages/BecomeFilmerPage.tsx
@@ -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() {
Offer your skate video filming — clips, lines, full parts & contest coverage
{!hasNip05 && (
-
-
-
-
⚠️
-
-
NIP-05 Verification Required
-
- 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.
-
-
-
-
-
-
-
-
+
)}
{/* Wallet Requirement */}
diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx
index b121728..ec367cb 100644
--- a/src/pages/BecomePhotographerPage.tsx
+++ b/src/pages/BecomePhotographerPage.tsx
@@ -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() {
Shoot the sessions — offer your skate photography
{!hasNip05 && (
-
-
-
-
⚠️
-
-
NIP-05 Verification Required
-
- Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
-
-
-
-
-
-
-
-
+
)}
{/* Wallet Requirement */}
diff --git a/src/pages/CreateListingPage.tsx b/src/pages/CreateListingPage.tsx
index efb7b8e..7deb4ea 100644
--- a/src/pages/CreateListingPage.tsx
+++ b/src/pages/CreateListingPage.tsx
@@ -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 && (
-
-
-
-
-
-
NIP-05 Verification Required
-
- To prevent spam and protect buyers, you need a verified Nostr address (NIP-05) to list gear.
-
-
-
-
-
-
-
-
+
)}
{hasNip05 && (
diff --git a/src/pages/CreateTourPage.tsx b/src/pages/CreateTourPage.tsx
index 277e5b1..135f1d9 100644
--- a/src/pages/CreateTourPage.tsx
+++ b/src/pages/CreateTourPage.tsx
@@ -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 (
-
-
-
-
-
- NIP-05 Verification Required
-
- To prevent spam and build trust, tour organizers must have NIP-05 verification.
-
-
- NIP-05 proves your identity by linking your Nostr profile to a domain you control.
-
-
-
-
-
-
-
-
-
+
);
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index d823d10..070fc5a 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -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 (
diff --git a/src/pages/TourDetailPage.tsx b/src/pages/TourDetailPage.tsx
index 87ffced..4abac3b 100644
--- a/src/pages/TourDetailPage.tsx
+++ b/src/pages/TourDetailPage.tsx
@@ -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() {
-
- {isLocalSession ? 'Scheduled Session' : tour.category === 'destruction' ? 'Competition Window' : 'Trip Dates'}
-
+
+
+ {isLocalSession ? 'Scheduled Session' : tour.category === 'destruction' ? 'Competition Window' : 'Trip Dates'}
+
+ {countdownLabel && (
+
+ {countdownLabel.text}
+
+ )}
+
{formatSchedule()}
{durationLabel && (
Runs about {durationLabel}