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 +

+
+ {NIP05_PROVIDERS.map((p) => ( + +
+
+ {p.name} + +
+
{p.desc}
+
+ + {p.free ? 'Free' : 'Paid'} + +
+ ))} +
+
+ ); + + 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}