diff --git a/src/pages/NewsletterPage.tsx b/src/pages/NewsletterPage.tsx
index 9383005..e105110 100644
--- a/src/pages/NewsletterPage.tsx
+++ b/src/pages/NewsletterPage.tsx
@@ -1,221 +1,244 @@
+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 { Link } from 'react-router-dom';
-import { Zap, MapPin, Shield, Layers, Crown, Radio, ArrowRight } from 'lucide-react';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ Zap, MapPin, Shield, Layers, Crown, Radio,
+ ArrowRight, Share2, CheckCircle2, Users, Store, Calendar,
+ GraduationCap, Route,
+} from 'lucide-react';
+import { useCurrentUser } from '@/hooks/useCurrentUser';
+import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { useGrowthStats } from '@/hooks/useGrowthStats';
+import { useToast } from '@/hooks/useToast';
-// ─── Newsletter data ──────────────────────────────────────────────────────────
-// To add a new issue, prepend an object to this array.
-// The first item in the array is always the latest issue (shown in the pill/banner).
+// ─── 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 */
+ /** Issue number — shown as #001 */
number: number;
- /** ISO date string e.g. "2026-06-18" */
+ /** ISO date string "YYYY-MM-DD" */
date: string;
- /** Short punchy vibe line — shown as subtitle */
+ /** One punchy vibe line for the issue */
vibe: string;
- /** Full rendered content — JSX */
- content: React.ReactNode;
+ /** 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 version used for the kind 1 Nostr note */
+ nostrText: string;
}
+// ─── Issues ──────────────────────────────────────────────────────────────────
+// Newest first. The first item is always "latest".
+
export const NEWSLETTER_ISSUES: NewsletterIssue[] = [
{
number: 1,
date: '2026-06-18',
- vibe: 'Security stack, clustering, and a DM warning that\'ll save someone\'s keys.',
- content: ,
+ 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. Sponsors see the organic number.",
+ },
+ {
+ 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 Weekly — Issue #001
+
+Security week. Eight improvements shipped:
+
+🔐 Fake spot protection (geohash validation, null island blocked)
+🔐 URL sanitization on shops & profiles
+👑 Official badge for verified founder content
+🗺 Map marker clustering (cities are navigable again)
+🚫 Content length limits across all parsers
+📊 Trusted count on community stats
+🛡 DM phishing warning banner
+⚡ Smart WoT trust filter defaults
+
+Full breakdown: https://skate-spots.org/newsletter
+
+#skateboarding #nostr #skatespots`,
},
];
-// ─── Issue #001 ───────────────────────────────────────────────────────────────
+// ─── Publish to Nostr button ──────────────────────────────────────────────────
+
+function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
+ const { user } = useCurrentUser();
+ const { mutateAsync: publishEvent, isPending } = useNostrPublish();
+ const { toast } = useToast();
+ const [published, setPublished] = useState(false);
+
+ const isAdmin = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
+ if (!user || !isAdmin) return null;
+ if (published) {
+ return (
+
+// ─── Live community stats block ───────────────────────────────────────────────
- }
- title="Fake spot protection"
- tag="Security"
- tagColor="green"
- >
- The app now validates every geohash before accepting a spot. Minimum 4 characters of
- precision (anything less is just "somewhere in Spain"), real Earth coordinates only,
- and we specifically block 0°N 0°E — a spot in the middle of the Atlantic Ocean that
- bots love to use. VPN and Tor users are completely unaffected: the pin comes from
- where you drag it on the map, not from your IP.
-
+function LiveStatsBlock() {
+ const { data: stats, isLoading } = useGrowthStats();
- }
- title="URL sanitization on shops and profiles"
- tag="Security"
- tagColor="blue"
- >
- Shop websites, coach profiles, and filmer pages now run through a sanitizer before
- rendering as links. Only https:// and{' '}
- http:// get through.{' '}
- javascript:,{' '}
- data:, and anything else
- silently drops the link. Zero friction for real websites — every legit URL starts
- with https.
-
+ 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' },
+ ];
- }
- title="Official badge"
- tag="Anti-impersonation"
- tagColor="yellow"
- >
- Spots, shops, events, and coach profiles published by the verified SkateSpots founder
- pubkey now show a small 👑 Official badge
- next to the author name. If someone tries to create a fake "SkateSpots Official" account
- and publish content under a different key, there's no badge. Immediately obvious.
-
-
- }
- title="Map marker clustering"
- tag="UX"
- tagColor="red"
- >
- When you zoom out and there are many spots close together, they now group into a numbered
- circle (e.g. "34") instead of a pile of overlapping pins. Zoom in or click the cluster
- and they spread apart naturally. Makes cities like Barcelona, NYC, or LA actually
- navigable at city-level zoom. Also means a spam flood of fake spots can't make the map
- unusable.
-
-
- }
- title="Content length limits in all parsers"
- tag="Spam defense"
- tagColor="purple"
- >
- Every spot, shop, coach, filmer, and editor profile now has hard limits on title
- (100 chars), description (3000 chars), and location (200 chars). Events too.
- Anything over that gets trimmed silently. In practice this only affects bots pushing
- multi-megabyte injection payloads — no real skater writes a 3001-character spot
- description.
-
-
- }
- title="Trusted count on community stats"
- tag="Transparency"
- tagColor="lime"
- >
- The Community Stats page
- now shows two numbers alongside the spot count: total spots, and spots from
- "active accounts" — meaning pubkeys that have published at least 2 items in the
- community. Sponsors and curious visitors can now see both the raw number and the
- organic number side by side.
-
-
- }
- title="DM phishing warning"
- tag="Protection"
- tagColor="lime"
- >
- First time you open the messages page there's now a small dismissible banner:
- "SkateSpots will never DM you asking to verify your account, share your keys, or send
- payments." One tap to dismiss, never shows again. Standard practice across every serious
- Bitcoin/Nostr app. Protects new users who don't yet know this is how social engineering
- attacks work.
-
-
- }
- title="WoT trust filter — smart defaults"
- tag="Feed quality"
- tagColor="orange"
- >
- The trust filter on the map and events page now sets its default based on context.
- If you're logged out or new with fewer than 5 follows, you see everything — we don't
- treat new people like suspects. If you're logged in with a proper follow list, trusted
- content floats to the top automatically. You can always flip to "All" with one tap.
- Nothing is ever hidden by default.
-
-
-
-
-
- {/* SPOT OF THE WEEK */}
-
-
-
-
- No community spot selected yet for Issue #001 — this is where we'll highlight one
- spot submitted by the community each week. Photo, location, who mapped it, why it
- rips.
-
-
- Browse the map and add yours
-
-
-
-
-
- {/* COMMUNITY */}
-
-
-
-
- The relay is live at wss://goskatespots.nostr1.com.
- Spots, shops, events, coaches, filmers, editors and tours are all flowing through it.
- Add it to your Nostr client if you want to follow the community on a broader level.
-