import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
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';
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
const NEWSLETTER_ADMIN_PUBKEYS = [
'20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21',
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b',
];
// ─── Newsletter issue structure ───────────────────────────────────────────────
//
// Every issue follows this exact schema.
// AI writes each field; the page renders them consistently.
// To add a new issue: prepend to NEWSLETTER_ISSUES, fill every field.
export interface NewsletterUpdate {
icon: 'shield' | 'ux' | 'feature' | 'fix' | 'community' | 'security' | 'perf';
title: string;
tag: string;
body: string;
}
export interface SpotOfTheWeek {
title: string;
location: string;
/** Optional pubkey of whoever mapped it */
mapperPubkey?: string;
mapperName?: string;
/** Optional image URL */
image?: string;
/** Why it rips */
why: string;
/** Deep link into the app */
href?: string;
}
export interface NewsletterIssue {
/** Issue number — shown as #001 */
number: number;
/** ISO date string "YYYY-MM-DD" */
date: string;
/** One punchy vibe line for the issue */
vibe: string;
/** App update items */
updates: NewsletterUpdate[];
/** Optional — omit if no spot was picked this week */
spotOfTheWeek?: SpotOfTheWeek;
/** Free-text community section — 2-3 sentences max */
communityNote: string;
/**
* Plain-text teaser posted as a kind 1 Nostr note.
*
* TEMPLATE (copy this for every new drop):
*
* 🛹 SkateSpots Drop #00X
*
* [One punchy sentence summing up the week — what was the theme?]
* [One sentence on the most important thing that shipped.]
*
* https://skate-spots.org/newsletter
*
* #skateboarding #nostr #skatespots #spotr #newsletter #skatespotsnews
*
* Rules:
* - Max 5 lines of content before the URL
* - No bullet lists — clients render them differently
* - URL on its own line, no "Full issue:" prefix — cleaner unfurl
* - Hashtags all on one line — some clients break each onto a new line if separated
*/
nostrText: string;
}
// ─── Issues ──────────────────────────────────────────────────────────────────
// Newest first. The first item is always "latest".
export const NEWSLETTER_ISSUES: NewsletterIssue[] = [
{
number: 2,
date: '2026-07-23',
vibe: "The map got a soul. The settings got a spine. The card got a stage. Big UX week.",
updates: [
{
icon: 'ux',
title: 'Map goes dark — CartoDB Voyager tiles',
tag: 'Map',
body: "Swapped the default OpenStreetMap tiles for CartoDB Voyager — warm mid-tone base, character-rich roads, zero visual noise. Your red/orange/neon markers finally pop the way they should. Attribution moved to the top-right corner, out of the way.",
},
{
icon: 'feature',
title: 'Spot bottom sheet',
tag: 'Map',
body: "Tap any map marker and a drawer slides up from the bottom with the spot thumbnail, name, location, type badges, author, and two CTAs — View Details and Directions (opens Google Maps). The map stays fully visible behind it. No more jumping out of context to read a Leaflet popup.",
},
{
icon: 'feature',
title: 'Near Me button',
tag: 'Map',
body: "Floating crosshair button top-right on the map. Tap it and the map flies to your current location at zoom 13. One tap, no searching, no settings.",
},
{
icon: 'ux',
title: 'Right-side control column',
tag: 'Map',
body: "Layer filter pills (Spots / Shops / Gear / Tours) are now a vertical stack on the right side of the map — always visible, no toolbar needed. Quick Snap lives just below them. Everything on the right, Sasquatch on the left. No overlaps.",
},
{
icon: 'ux',
title: 'Settings redesigned — iOS-style menu',
tag: 'Settings',
body: "The 7-tab horizontal scroll is gone. Settings is now a clean vertical menu with three groups (Account, App, Data) and tappable rows with colored icons and chevrons. Each item opens its own sub-screen. Familiar, thumb-friendly, and nothing is buried anymore.",
},
{
icon: 'ux',
title: 'Wallet mobile-first redesign',
tag: 'Wallet',
body: "The wallet page was rebuilt from scratch for mobile — balance hero at the top, send/receive as the primary actions, transaction history below. The duplicate balance card that showed on two pages is gone.",
},
{
icon: 'ux',
title: 'Rider Card — full UX rebuild',
tag: 'Card',
body: "The /card page was a form dump. It's now a proper experience: full-width card preview on a dark stage with a theme-matched glow, a 3-tab nav (My Card / Badges / Award), a single big Share CTA, and all controls in a clean single column. The Badges tab actually renders now — it was built but never shown. The Award tab is inline, no dialog.",
},
{
icon: 'feature',
title: 'Sasquatch repositioned on the map',
tag: 'Map',
body: "The Sasquatch bubble now lives bottom-right on mobile — below Quick Snap — instead of bottom-left where it was fighting the filter pills. Press and hold still lets you drag it anywhere on screen.",
},
{
icon: 'ux',
title: 'Lab & Events — proper filter drawers',
tag: 'UX',
body: "Both /lab and /events got the same treatment: sticky toolbar, a bottom filter drawer with location search, inline trust pills, and active filter chips showing what's on. No more hunting through buried dropdowns.",
},
{
icon: 'fix',
title: 'Leaflet attribution above mobile nav',
tag: 'Fix',
body: "Map attribution was being hidden behind the bottom navigation bar on mobile. It now sits above it, properly visible at all times.",
},
{
icon: 'fix',
title: 'Capacitor native plugin build fixes',
tag: 'Android',
body: "The nostr-signer-capacitor-plugin was being bundled into the APK build and crashing the Vite compile. It's now properly externalized from the Rollup config — clean APK builds every time.",
},
],
spotOfTheWeek: undefined,
communityNote:
"v1.0.54 shipped to ZapStore this week — all the map, wallet, settings and card improvements are live on Android. Pull from the relay at wss://goskatespots.nostr1.com to stay synced with the freshest spots.",
nostrText: `🛹 SkateSpots Drop #002
The whole app got rebuilt from the ground up this week. Map went dark with Voyager tiles + a bottom sheet + Near Me button. Settings ditched the 7-tab scroll for a clean iOS menu. Wallet went mobile-first. The Lab and Events pages got proper filter drawers and location search. The Rider Card went from a form dump to a staged experience with hero preview, 3 tabs, and witnessed badges finally showing. Sasquatch got repositioned. v1.0.54 shipped to ZapStore. A lot moved.
https://skate-spots.org/#/newsletter
#skateboarding #nostr #skatespots #spotr #newsletter #skatespotsnews`,
},
{
number: 1,
date: '2026-06-18',
vibe: "Security stack, clustering, and a DM warning that'll save someone's keys.",
updates: [
{
icon: 'security',
title: 'Fake spot protection',
tag: 'Security',
body: "Every geohash is now validated before a spot is accepted. Minimum 4 chars of precision (less = just 'somewhere in Spain'), real Earth coordinates only, and 0°N 0°E — the classic bot placeholder in the Atlantic — is explicitly blocked. VPN/Tor unaffected: the pin comes from where you drag it on the map, not your IP.",
},
{
icon: 'shield',
title: 'URL sanitization on shops and profiles',
tag: 'Security',
body: "Shop websites, coach profiles, and filmer pages now run through a sanitizer before rendering as links. Only https:// and http:// get through. javascript:, data:, vbscript: and anything else silently drops the link. Zero friction for real websites.",
},
{
icon: 'community',
title: 'Official badge',
tag: 'Anti-impersonation',
body: "Content published by the verified SkateSpots founder pubkey now shows a 👑 Official badge. Fake accounts under a different key won't have it — immediately obvious.",
},
{
icon: 'ux',
title: 'Map marker clustering',
tag: 'UX',
body: "Dense cities (Barcelona, NYC, LA) now cluster nearby pins into numbered circles when zoomed out. Click or zoom in and they spread apart. Also means a bot flood of fake spots can't cover the map.",
},
{
icon: 'security',
title: 'Content length limits',
tag: 'Spam defense',
body: "All parsers (spots, shops, coaches, filmers, editors, events) now enforce hard limits: title 100 chars, description 3000 chars, location 200 chars. Multi-megabyte injection payloads get trimmed silently.",
},
{
icon: 'feature',
title: 'Trusted count on community stats',
tag: 'Transparency',
body: "The stats page now shows spots from 'active accounts' — pubkeys that have published at least 2 items — alongside the raw total. Users can now see both the raw number and the organic number side by side.",
},
{
icon: 'shield',
title: 'DM phishing warning',
tag: 'Protection',
body: "First time you open messages: a small dismissible banner. SkateSpots will never DM you asking for keys or payments. One tap to dismiss, never shows again.",
},
{
icon: 'ux',
title: 'Smart WoT trust filter defaults',
tag: 'Feed quality',
body: "Logged out or under 5 follows? You see everything. Logged in with a real follow list? Trusted content floats to the top automatically. Nothing hidden by default.",
},
],
spotOfTheWeek: undefined,
communityNote:
"The relay at wss://goskatespots.nostr1.com is live and growing. Spots, shops, events, coaches, filmers, editors and tours are all flowing through it. Add it to your Nostr client to follow the scene directly.",
nostrText: `🛹 SkateSpots Drop #001
Security week. Fake spot protection, URL sanitization, map clustering, phishing warnings and more — eight improvements shipped to keep the community clean and the map usable.
https://skate-spots.org/#/newsletter
#skateboarding #nostr #skatespots #spotr #newsletter #skatespotsnews`,
},
];
// ─── 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