From 248f5b5ff27b02a179f007f93f5a10c05fc614c4 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 18 Jun 2026 14:45:59 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20newsletter=20v2=20=E2=80=94=20structure?= =?UTF-8?q?d=20issues,=20live=20stats,=20Nostr=20publish=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structured issue schema (NewsletterIssue): - updates[]: typed update items with icon/title/tag/body - spotOfTheWeek?: optional typed spot block - communityNote: free-text community section - nostrText: plain-text ready to post as kind 1 Live community stats block (LiveStatsBlock): - Pulls from useGrowthStats on every page load - Shows spots, shops, events, mappers, coaches, tours - Trusted/active account counts shown below spots + mappers - "Live · pulled from goskatespots.nostr1.com" attribution Publish to Nostr button (PublishToNostrButton): - Only visible to 2 admin pubkeys — nobody else sees it - Publishes kind 1 with nostrText + tags (skateboarding, skatespots, nostr) - Includes relay URL tag pointing to /newsletter - One-shot: button becomes "Published to Nostr" checkmark after success - Full error handling with toast Issue template now enforces consistent sections every week: 1. Community by the Numbers (live stats) 2. What's New (typed update cards) 3. Spot of the Week (optional — shows placeholder when omitted) 4. Community note 5. Support / Geyser Co-authored-by: shakespeare.diy --- src/pages/NewsletterPage.tsx | 676 ++++++++++++++++++++++------------- 1 file changed, 421 insertions(+), 255 deletions(-) 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 ( +
+ + Published to Nostr +
+ ); + } + + const handlePublish = async () => { + try { + await publishEvent({ + kind: 1, + content: issue.nostrText, + tags: [ + ['t', 'skateboarding'], + ['t', 'skatespots'], + ['t', 'nostr'], + ['r', `https://skate-spots.org/newsletter`], + ], + }); + setPublished(true); + toast({ title: 'Published!', description: `Issue #${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', + }); + } + }; -function Issue001() { return ( -
+ + ); +} - {/* WHAT'S NEW */} -
- -
+// ─── 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. -

-
- - - The Lab - - - - - Events - - - - - Spot Map - - + return ( +
+
+ {items.map((item) => ( +
+
{item.icon}
+ {isLoading ? ( + + ) : ( +
+ {(item.value ?? 0).toLocaleString()} +
+ )} +
{item.label}
+ {item.trusted !== undefined && !isLoading && ( +
+ 🛡 {item.trusted} +
+ )}
-
-
- - {/* SUPPORT */} -
- -
-

- SkateSpots is 100% open source, community funded, no ads, no investors. If it's - useful to you, zap the project or contribute on Geyser. -

- - - geyser.fund/project/skatespots - - -
-
- + ))} +
+

+ Live · pulled from wss://goskatespots.nostr1.com +

); } @@ -226,10 +249,7 @@ function SectionHeader({ emoji, label, color }: { emoji: string; label: string; return (
{emoji} -

+

{label}

@@ -247,42 +267,208 @@ const TAG_COLORS: Record = { orange: 'bg-[#ff6600]/15 text-[#ff6600] border-[#ff6600]/30', }; -function UpdateCard({ - icon, - title, - tag, - tagColor, - children, -}: { - icon: React.ReactNode; - title: string; - tag: string; - tagColor: string; - children: React.ReactNode; -}) { +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}
+
+
{ICON_MAP[update.icon]}
-

{title}

- - {tag} +

{update.title}

+ + {update.tag}
-

{children}

+

{update.body}

); } +function SpotOfTheWeekBlock({ spot }: { spot: SpotOfTheWeek }) { + const inner = ( +
+ {spot.image && ( +
+ {spot.title} +
+ )} +
+

{spot.title}

+

+ + {spot.location} +

+

{spot.why}

+ {spot.mapperName && ( +

+ Mapped by {spot.mapperName} +

+ )} + {spot.href && ( +
+ + View on map + +
+ )} +
+
+ ); + + return spot.href ? {inner} : inner; +} + // ─── Helpers ────────────────────────────────────────────────────────────────── function formatIssueDate(iso: string): string { - const d = new Date(iso); - return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', month: 'long', day: 'numeric', + }); +} + +// ─── Issue renderer ─────────────────────────────────────────────────────────── + +function IssueArticle({ issue }: { issue: NewsletterIssue }) { + return ( +
+ {/* Issue header */} +
+
+
+ {formatIssueDate(issue.date)} +
+

+ #{String(issue.number).padStart(3, '0')} +

+

{issue.vibe}

+
+
+
+ + #{String(issue.number).padStart(3, '0')} + +
+ +
+
+ +
+ + {/* ── 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 issue. +

+ + 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 Geyser. +

+ + + geyser.fund/project/skatespots + + +
+
+ +
+
+ ); } // ─── Page ───────────────────────────────────────────────────────────────────── @@ -293,8 +479,6 @@ export default function NewsletterPage() { description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.', }); - const latest = NEWSLETTER_ISSUES[0]; - return (
@@ -305,8 +489,10 @@ export default function NewsletterPage() { Weekly Drop
-

+

SKATE @@ -318,46 +504,28 @@ export default function NewsletterPage() {

- {/* ── Issues ── */} -
+ {/* ── All issues ── */} +
{NEWSLETTER_ISSUES.map((issue) => ( -
- {/* Issue header */} -
-
-
- {formatIssueDate(issue.date)} -
-

- #{String(issue.number).padStart(3, '0')} -

-

- {issue.vibe} -

-
-
-
- - #{String(issue.number).padStart(3, '0')} - -
-
-
- - {/* Issue body */} - {issue.content} -
+ ))}
- {/* ── Footer blurb ── */} + {/* ── Page footer ── */}

- Built on Nostr + Built on{' '} + + Nostr + {' · '} - Open source + + Open source + {' · '} - Support us + + Support us +

You are free to skateboard @@ -365,8 +533,6 @@ export default function NewsletterPage() {

- - {/* Latest issue pill and Monday reminder are rendered globally via Layout */} ); }