import { useEffect, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Zap, MapPin, Shield, Layers, Crown, Radio,
ArrowRight, Share2, CheckCircle2, Users, Store, Calendar,
GraduationCap, Route, Eye, Send,
} from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useGrowthStats } from '@/hooks/useGrowthStats';
import { useToast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { NewsletterComposer } from '@/components/NewsletterComposer';
import { NewsletterBackfillPanel } from '@/components/NewsletterBackfillPanel';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterSubscriberCount } from '@/hooks/useNewsletterSubscriberCount';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
import { isAdminPubkey } from '@/lib/admins';
// ─── Newsletter issue structure ───────────────────────────────────────────────
//
import { NEWSLETTER_ISSUES, type NewsletterUpdate, type SpotOfTheWeek, type NewsletterIssue } from '@/lib/newsletterIssues';
// ─── Nostr post preview + publish ────────────────────────────────────────────
function NostrPreviewModal({
issue,
open,
onClose,
onConfirm,
isPending,
}: {
issue: NewsletterIssue;
open: boolean;
onClose: () => void;
onConfirm: (text: string) => void;
isPending: boolean;
}) {
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? '');
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
const avatar = metadata?.picture;
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const [editing, setEditing] = useState(false);
const [editedText, setEditedText] = useState(issue.nostrText);
const currentText = editing ? editedText : issue.nostrText;
const renderPreviewText = (text: string) => {
return text.split('\n').map((line, i) => {
const parts = line.split(/(\s+)/).map((word, j) => {
if (word.startsWith('#')) return {word};
if (word.startsWith('https://')) return {word};
return {word};
});
return
{parts}
;
});
};
return (
);
}
function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [published, setPublished] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const isAdmin = isAdminPubkey(user?.pubkey);
if (!user || !isAdmin) return null;
if (published) {
return (
Published to Nostr
);
}
const handleConfirmPublish = async (text: string) => {
try {
await publishEvent({
kind: 1,
content: text,
tags: [
['t', 'skateboarding'],
['t', 'nostr'],
['t', 'skatespots'],
['t', 'spotr'],
['t', 'newsletter'],
['t', 'skatespotsnews'],
['r', 'https://skate-spots.org/#/newsletter'],
],
});
setPublished(true);
setShowPreview(false);
toast({ title: 'Published!', description: `Drop #${String(issue.number).padStart(3, '0')} posted to Nostr.` });
} catch (err) {
toast({
title: 'Publish failed',
description: err instanceof Error ? err.message : 'Something went wrong',
variant: 'destructive',
});
}
};
return (
<>
setShowPreview(false)}
onConfirm={handleConfirmPublish}
isPending={isPending}
/>
>
);
}
// ─── Live community stats block ───────────────────────────────────────────────
function LiveStatsBlock() {
const { data: stats, isLoading } = useGrowthStats();
const items = [
{ icon: , label: 'Spots', value: stats?.totalSpots, trusted: stats?.trustedSpots, color: '#ff0040' },
{ icon: , label: 'Shops', value: stats?.totalShops, color: '#4ade80' },
{ icon: , label: 'Events', value: stats?.totalEvents, color: '#ccff00' },
{ icon: , label: 'Mappers', value: stats?.totalMappers, trusted: stats?.trustedMappers, color: '#facc15' },
{ icon: , label: 'Coaches', value: stats?.totalCoaches, color: '#ff6600' },
{ icon: , label: 'Tours', value: stats?.totalTours, color: '#f472b6' },
];
return (
{items.map((item) => (
{item.icon}
{isLoading ? (
) : (
{(item.value ?? 0).toLocaleString()}
)}
{item.label}
{item.trusted !== undefined && !isLoading && (
🛡 {item.trusted}
)}
))}
Live · goskatespots.nostr1.com
);
}
// ─── Shared sub-components ────────────────────────────────────────────────────
function SectionHeader({ emoji, label, color }: { emoji: string; label: string; color: string }) {
return (
);
}
const TAG_COLORS: Record = {
green: 'bg-green-500/15 text-green-400 border-green-500/30',
blue: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
yellow: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
red: 'bg-[#ff0040]/15 text-[#ff0040] border-[#ff0040]/30',
purple: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
lime: 'bg-[#ccff00]/15 text-[#ccff00] border-[#ccff00]/30',
orange: 'bg-[#ff6600]/15 text-[#ff6600] border-[#ff6600]/30',
};
const ICON_MAP: Record = {
shield: ,
security: ,
ux: ,
feature: ,
fix: ,
community: ,
perf: ,
};
// Tag label → TAG_COLORS key (best-effort match)
function tagColorKey(tag: string): string {
const t = tag.toLowerCase();
if (t.includes('security') || t.includes('protect')) return 'green';
if (t.includes('ux') || t.includes('map')) return 'red';
if (t.includes('spam') || t.includes('defense')) return 'purple';
if (t.includes('transparent') || t.includes('trust')) return 'lime';
if (t.includes('badge') || t.includes('imperson')) return 'yellow';
if (t.includes('feed') || t.includes('quality')) return 'orange';
return 'lime';
}
function UpdateCard({ update }: { update: NewsletterUpdate }) {
const colorKey = tagColorKey(update.tag);
return (
{ICON_MAP[update.icon]}
{update.title}
{update.tag}
{update.body}
);
}
function SpotOfTheWeekBlock({ spot }: { spot: SpotOfTheWeek }) {
const inner = (
{spot.image && (
)}
{spot.title}
{spot.location}
{spot.why}
{spot.mapperName && (
Mapped by {spot.mapperName}
)}
{spot.href && (
)}
);
return spot.href ? {inner} : inner;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatIssueDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
}
// ─── Issue renderer ───────────────────────────────────────────────────────────
function IssueArticle({ issue }: { issue: NewsletterIssue }) {
return (
{/* Issue header */}
{/* Top row: issue number badge + date */}
{formatIssueDate(issue.date)}
#{String(issue.number).padStart(3, '0')}
{/* Issue number large */}
#{String(issue.number).padStart(3, '0')}
{/* Vibe line */}
{issue.vibe}
{/* Publish button — full width on mobile, auto on larger */}
{/* ── Community Stats — live numbers ── */}
{/* ── What's New ── */}
{issue.updates.map((u, i) => (
))}
{/* ── Spot of the Week ── */}
{issue.spotOfTheWeek ? (
) : (
No spot picked this week. Submit yours and it might make the next drop.
Browse the map
)}
{/* ── Community ── */}
{issue.communityNote}
The Lab
Events
Spot Map
Stats
{/* ── Support ── */}
100% open source, community funded, no ads, no investors. If it's useful zap it or back it on Angor.
angor.io — SkateSpots
);
}
// ─── Live issues (published to Nostr as NIP-23 articles) ─────────────────────
function LiveIssueCard({ issue }: { issue: SkateNewsletterIssue }) {
const naddr = nip19.naddrEncode({
kind: NEWSLETTER_KIND,
pubkey: issue.pubkey,
identifier: issue.identifier,
});
const date = new Date(issue.publishedAt * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
{issue.image && (
)}
{date}
{issue.title}
{issue.summary &&
{issue.summary}
}
Read the full issue
);
}
/** Social-proof subscriber tally shown under the subscribe CTA. */
function SubscriberCount() {
const { data: count, isLoading } = useNewsletterSubscriberCount();
if (isLoading || !count || count < 3) return null; // hide until it's meaningful
const rounded = count >= 100 ? `${Math.floor(count / 50) * 50}+` : `${count}`;
return (
Join {rounded} skaters getting the drops
);
}
function LiveIssuesSection() {
const { data: issues, isLoading } = useNewsletterIssues();
if (isLoading) {
return (
);
}
if (!issues || issues.length === 0) return null;
return (
{issues.map((issue) => (
))}
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function NewsletterPage() {
useSeoMeta({
title: 'Newsletter - SkateSpots',
description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.',
});
// Opening the newsletter clears the "News" unread badge in the bell.
const { markNewsletterRead } = useNewsletterRead();
useEffect(() => {
markNewsletterRead();
}, [markNewsletterRead]);
return (
<>
{/* ── Masthead ── */}
Weekly Drops
SKATE
SPOTS
A weekly newsletter · shipped whenever the f*** we want
{/* Subscribe — routes new drops into the notification bell */}
Free. Powered by Nostr — your subscription lives on your keys and follows you
across devices. New drops land in your notification bell.
{/* Admin-only: publish the static archive issues to Nostr. */}
{/* ── Live issues published to Nostr ── */}
{/* ── All issues ── */}
{NEWSLETTER_ISSUES.map((issue) => (
))}
{/* ── Page footer ── */}
Built on{' '}
Nostr
{' · '}
Open source
{' · '}
Support us
You are free to skateboard
>
);
}