SkateSpots/src/pages/NewsletterPage.tsx
shakespeare.diy 8b080fb7f1 Spot detail: make mapper clickable + add Message Mapper button
The "Tip the Mapper" card now links the avatar/name to the mapper's profile
(/{npub} via the NIP-19 router) and adds a "Message Mapper" button that opens
an encrypted DM (/messages/{npub}). Makes it easy to contact whoever added a
spot — e.g. to ask them to add photos to a photoless submission. The message
button is hidden when you're viewing your own spot.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-15 10:03:58 +07:00

1130 lines
63 KiB
TypeScript

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 ───────────────────
import { isAdminPubkey } from '@/lib/admins';
// ─── 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: 5,
date: '2026-08-08',
vibe: "It's a WEEKLY newsletter and we ship whenever the f*** we want — and this week the geniuses at OpenCrack announced they're 'fixing Bitcoin's cryptography libraries,' which is a bit like hiring the guy who set the kitchen on fire to redesign your smoke detector. We watched a room full of $300-hoodie engineers 'harden' a curve implementation that worked perfectly fine until they touched it. Meanwhile, over here in the parking lot, we made the Lab three times bigger, gave Crews a spine and a lie-detector, and — this'll shock you — we STILL have not audited a single line of this app. One day we will. And on that glorious day, we will finally have the tooling to shoot ourselves in both feet simultaneously. Progress!",
updates: [
// ── OpenCrack roast (topical rant, thinly disguised as a PSA) ──────────
{
icon: 'security',
title: "OpenCrack is here to 'fix' Bitcoin's crypto libraries — sleep tight",
tag: 'Not Financial Advice',
body: "This week a well-funded lab we'll charitably call OpenCrack declared they're going to 'improve and harden' the cryptographic libraries the entire Bitcoin ecosystem leans on. Wonderful. Nothing soothes the soul like a team that ships a chatbot every Tuesday volunteering to rewrite the math that guards a trillion dollars. These are the same wizards whose last 'safe' release needed three emergency patches before lunch. Look — secp256k1 has been battle-tested by fifteen years of people trying very hard to steal from it. It does not need a growth-hacked refactor with a launch video and a Medium post. As Groucho said, before he was a Bitcoiner: 'Those are my principles, and if you don't like them… well, I have others — check the changelog.' We'll be over here NOT touching the curve.",
},
{
icon: 'community',
title: "A humble confession: we have never audited this app. Not once.",
tag: 'Vibes',
body: "In the spirit of the honesty we keep demanding from firmware vendors: SkateSpots has received exactly zero security audits. None. A big beautiful nothing. This entire app is held together by optimism, Nostr, and a Sasquatch. We custody no Bitcoin, no seeds, no funds — the worst we can leak is your favorite curb — so the blast radius is 'someone knows where you kickflip.' But we're not going to pretend. ONE DAY, when we finally hire a real auditor and get real tools, we fully expect to discover the perfect vulnerability, patch it heroically, and in doing so shoot ourselves in both feet, reload, and go for the kneecaps. Until then: it's BETA, it's free, and we told you so. Groucho voice: 'I refuse to trust any app that would have me as a user.'",
},
// ── The Lab (main feature) ─────────────────────────────────────────────
{
icon: 'feature',
title: "The Lab got THREE times bigger — it's your whole scene now",
tag: 'The Lab',
body: "The Lab used to be coaches, filmers, photographers, builders and editors. Now it covers the entire ecosystem around a session: we added Designers/Artists, Graffiti writers, Tattoo artists, Trainers/Physios and Musicians as first-class crafts. Ten crafts, one directory. Whether you need someone to film the line, ink the memory of it, or fix the ankle you rolled attempting it — the Lab has a human for that. List your craft, or go find one.",
},
{
icon: 'ux',
title: "The Lab stopped hiding half its crafts off the side of your phone",
tag: 'Fix',
body: "With ten crafts, the mobile filter row turned into a sideways-scrolling mystery box — you'd see 'Coaches, Filmers, Photo…' and have to swipe into the void to discover the other seven existed. Gross. It's now a clean, tidy grid: every craft visible at a glance, no horizontal scrolling, nothing lurking off-screen. Revolutionary concept, showing people the buttons.",
},
// ── Crews (the other big feature) ──────────────────────────────────────
{
icon: 'community',
title: "Crews now cover every scene — art, music, wellness, the works",
tag: 'Crews',
body: "Crews aren't just for skate spots anymore. Pick from seven scene types — including Art, Music and Wellness — and they map straight onto the new Lab crafts, so a crew and the talent it needs speak the same language. Your local isn't just a spot list anymore; it's a whole subculture with an address.",
},
{
icon: 'security',
title: "Crews got a lie-detector: opt-in visibility + web-of-trust vouching",
tag: 'Trust',
body: "Two upgrades. First, founders can now see their members — but it's OPT-IN, so nobody gets outed into a roster they didn't agree to join. Second, a lightweight web-of-trust vouch system: real people vouch for real people, so a crew of one guy and eleven sockpuppets can't cosplay as a movement. It's anti-fraud without a middleman — which, unlike certain crypto libraries, we did NOT need a launch event to build.",
},
{
icon: 'ux',
title: "The Crew page got a full redesign",
tag: 'Crews',
body: "While we were in there, we tore the CrewDetail page down to the studs and rebuilt it — cleaner layout, the members, spots, events and (if they've got one) the band all connected and legible instead of stacked like moving boxes. It reads like a crew now, not a database dump.",
},
// ── Music ↔ Events interconnect ────────────────────────────────────────
{
icon: 'feature',
title: "Music and Events are wired together now",
tag: 'Music',
body: "Because half of skateboarding is the soundtrack and the other half is arguing about it: events can now feature a musician from the Lab, and a musician's profile lists their upcoming gigs. Crews with a band get nudged to hook into the Lab's music side so bookings and collabs actually connect instead of living in separate universes. Book the session AND the after-party in one place.",
},
// ── Honesty / beta ─────────────────────────────────────────────────────
{
icon: 'ux',
title: "Still loudly, proudly BETA — and still un-audited (see above)",
tag: 'Beta',
body: "The first-launch beta heads-up and the little BETA badge in the footer are still there, still telling the truth. We'd rather warn you it's cooking than slap 'production-ready' on it, ship it to a hardware wallet, and lose $88 million — a mistake we are structurally incapable of making, because we hold none of your money and, as established, have audited none of our code.",
},
],
spotOfTheWeek: undefined,
communityNote:
"No cryptographic curves were harmed — or 'improved' — in the making of this newsletter. We didn't touch secp256k1, we didn't ship an unaudited seed generator, and we still don't hold a satoshi of yours; the only thing SkateSpots custodies is a list of curbs. Go add a craft to the Lab, start a crew, book a musician. Everything syncs through wss://goskatespots.nostr1.com. Keep your keys, distrust anyone launching a 'crypto library' with a hype video, and keep skating.",
nostrText: `🛹 SkateSpots Drop #005
It's a WEEKLY newsletter and we ship whenever the f*** we want.
This week OpenCrack announced they're "fixing" Bitcoin's crypto libraries — the guy who burned the kitchen now designs smoke detectors. We, meanwhile, tripled the size of the Lab (10 crafts now), gave Crews a spine + a web-of-trust lie-detector, wired Music into Events, and stopped the Lab hiding half its buttons off your screen. Full disclosure: we have NEVER audited this app. One day we will — and on that day we'll finally have the tools to shoot ourselves in both feet at once. It's BETA. We hold none of your Bitcoin. Skate on.
https://skate-spots.org/#/newsletter
#skateboarding #nostr #skatespots #spotr #newsletter #skatespotsnews`,
},
{
number: 4,
date: '2026-08-01',
vibe: "It's a WEEKLY newsletter and we ship whenever the f*** we want — and this week the whole 'trustless hardware' cult got its seed phrases enumerated on a rented GPU. While the Bitcoin influencer complex was busy filming themselves whispering 'not your keys, not your coins' into a $400 microphone, ~$88 MILLION walked out of Coldcard wallets through a random number generator that forgot how to be random. We, meanwhile, taught a Sasquatch to plan your skate trip, built you a real crew, and made the app stop face-planting. Priorities.",
updates: [
// ── The Coldcard bit (topical rant, thinly disguised as a PSA) ─────────
{
icon: 'security',
title: "PSA: your $150 'unhackable' brick may have been rolling loaded dice since 2021",
tag: 'Not Financial Advice',
body: "In case you were online this week: a firmware flaw made certain Coldcard hardware wallets skip their actual hardware random number generator and quietly fall back to a software one seeded off the chip's serial number and clock. Translation: seeds that were supposed to be 1-in-2²⁵⁶ collapsed to a space you can brute-force on a couple of rented GPUs in a weekend. An attacker regenerated candidate seeds OFFLINE, matched them to funded addresses on-chain, and swept ~1,367 BTC (~$88M) from 4,500+ wallets — without ever touching a single device. Affected: Mk2/Mk3 on firmware 4.0.1+, and Mk4/Q/Mk5 on older builds. Not affected: anyone who used the boring dice-roll option. The nerds who rolled 50 dice like dorks and got clowned for it are the only ones sleeping tonight. Skate lesson: the trick you practiced 500 times in the flat is the one that saves you in the street.",
},
{
icon: 'community',
title: "The 'not your keys, not your coins' guys have gone very quiet",
tag: 'Vibes',
body: "For years the hardware-wallet influencer complex sold self-custody as a personality — affiliate links in every bio, 'DYOR' tattooed on the forehead, a laser-eyes PFP screaming that touching an exchange makes you a peasant. Turns out you CAN own your keys, follow every rule, air-gap the thing in a Faraday bag inside a safe inside a mountain — and still get drained because a build flag skipped one function call. Self-custody is still the move. But maybe stop treating a mantra like it's a security audit. Cope, seethe, roll dice. SkateSpots holds zero of your Bitcoin and never will — the only thing we custody is a list of curbs.",
},
// ── Sasquatch (the actual big feature) ─────────────────────────────────
{
icon: 'feature',
title: "Sasquatch went Pro — and it actually does stuff now",
tag: 'Sasquatch',
body: "The hairy one graduated from 'answers questions' to 'gets things done.' Sasquatch Pro is a read-only personal assistant that can look at your spots, crews and messages, then DRAFT actions for you — post a spot, reply to a gig — and hand them over for YOU to confirm before anything's signed or published. Nothing goes out without your tap. Your keys, your call. (See? We can say that sentence without it costing anyone $88 million.)",
},
{
icon: 'feature',
title: "Trip Planner: tell Sasquatch where you're going, get a session",
tag: 'Sasquatch',
body: "New Trip Planner: give Sasquatch a city and it stitches together nearby spots, shops and any local events into an actual skate itinerary instead of you tab-hopping around the map like it's 2011. Land in a new town, ask, go skate.",
},
{
icon: 'security',
title: "Sasquatch Pro paywall — npub-bound, Lightning-gated",
tag: 'Sasquatch',
body: "Pro features unlock via a Lightning payment tied to your npub — pay once, it follows your identity, not a device or an account we could lock you out of. You get a receipt DM to your own inbox. Free tier still answers plenty; it just stops short of doing your homework for you.",
},
{
icon: 'fix',
title: "Sasquatch no longer folds when the AI gets rate-limited",
tag: 'Sasquatch',
body: "When the upstream AI throttles us, Sasquatch now falls back to a local glossary + a backup provider instead of shrugging and dying mid-sentence. Ask about a trick, get an answer, even when the robots are busy.",
},
// ── Trick Glossary ─────────────────────────────────────────────────────
{
icon: 'feature',
title: "The Trick Glossary got massively fatter",
tag: 'Glossary',
body: "We nuked the fake 'difficulty levels' (a switch isn't 'beginner' just because YouTube said so) and instead piled in way more tricks, terminology and hardware terms — corrected the wrong ones, added the missing ones. It's a proper reference now, not a listicle.",
},
// ── Crews (the other big feature) ──────────────────────────────────────
{
icon: 'community',
title: "Skate Crews got a spine: proximity, rep, hiring & a treasury",
tag: 'Crews',
body: "Crews leveled all the way up. Proximity discovery surfaces crews near you. Activity and rep signals show which ones are actually alive vs which are two guys and a group chat. There's a hiring pipeline for gigs, crew-hosted events wired straight into the calendar, and reverse links so a crew's spots, events and members all connect. We also renamed 'Start a Crew' → 'Create Crew' and drew a clear line between a Crew (your people) and the Lab (hired guns).",
},
{
icon: 'feature',
title: "Crew bands: your session has a soundtrack now",
tag: 'Crews',
body: "A crew can flag itself as a band / music project and take gig bookings — because half of skateboarding is the music and the other half is arguing about the music. Book the crew for the session AND the after-party.",
},
// ── Funding: Geyser → Angor ────────────────────────────────────────────
{
icon: 'feature',
title: "Ripped out Geyser, wired in Angor across the whole app",
tag: 'Funding',
body: "Support-the-project funding now runs through Angor — decentralized, Bitcoin-native crowdfunding with no platform sitting between you and the sats. On-brand for a Nostr app: fewer middlemen, more direct. Every 'Supporters' surface in the app now points at Angor.",
},
// ── Profiles overhaul ──────────────────────────────────────────────────
{
icon: 'ux',
title: "Two 'Profiles' were confusing everyone — so we unified them",
tag: 'Profiles',
body: "There was a public Profile page AND an Edit Profile screen and nobody could tell which was which. They're now clearly named and cross-linked: your public page has an obvious 'Edit' path, and editing shows you exactly what the world sees. Profiles also got richer — crews you're in, a bot flag, and CLINK contact links all render inline.",
},
{
icon: 'feature',
title: "Real gig messaging + a best-in-class message picker",
tag: 'Messaging',
body: "Booking someone from the Lab or a crew now opens actual messaging instead of a dead end. The New Message picker got rebuilt so finding a person is fast and painless — search, tap, talk.",
},
{
icon: 'fix',
title: "Profile page stopped hanging off the edge of your phone",
tag: 'Fix',
body: "Profiles were overflowing horizontally on narrow screens — sideways-scrolling into the void. Boxed in and behaving on every screen size now.",
},
// ── Stability / crash fixes ────────────────────────────────────────────
{
icon: 'fix',
title: "The Stash stopped crashing when you had no browser extension",
tag: 'Keys',
body: "A whole class of crashes traced back to the app poking at a nip44 encryption getter that isn't there when you're not using an extension signer. We stopped touching it on the render path entirely — check the login type and window.nostr directly instead. Your encrypted Stash opens without taking the app down with it.",
},
{
icon: 'ux',
title: "First-launch beta disclaimer — because honesty is a feature",
tag: 'Beta',
body: "SkateSpots is v1.1.0 and proudly, loudly BETA. You now get a one-time heads-up on first launch and a little BETA badge in the footer. We'd rather tell you it's still cooking than pretend it's flawless — looking at you, 'production-ready' firmware.",
},
// ── Infra ──────────────────────────────────────────────────────────────
{
icon: 'perf',
title: "Android versioning now auto-syncs — no more manual gradle rituals",
tag: 'Android',
body: "The Android version now derives straight from package.json, so ZapStore releases stop tripping over hand-edited gradle numbers. Boring, load-bearing, and the only reason new builds keep landing on your phone.",
},
],
spotOfTheWeek: undefined,
communityNote:
"No funds were harmed in the making of this newsletter, because we don't hold any of yours — SkateSpots custodies curbs, not coins. If you run a hardware wallet: check your model and firmware, and if you're in scope, migrate to a fresh seed you don't generate on a bad build. Then come map a spot. Everything syncs through wss://goskatespots.nostr1.com. Keep your keys, roll your dice, and keep skating.",
nostrText: `🛹 SkateSpots Drop #004
It's a WEEKLY newsletter and we ship whenever the f*** we want.
This week ~$88M walked out of "unhackable" Coldcard wallets through a busted random number generator — while the "not your keys, not your coins" crowd went suspiciously quiet. Meanwhile we shipped Sasquatch Pro (an AI that DRAFTS actions and hands them to you to confirm), a Trip Planner, a proper Crews system with proximity + hiring + bands, swapped funding to Angor, unified Profiles, and killed the Stash crash. Self-custody is still the move — just stop treating a mantra like a security audit. Roll your dice.
https://skate-spots.org/#/newsletter
#skateboarding #nostr #skatespots #spotr #newsletter #skatespotsnews`,
},
{
number: 3,
date: '2026-07-25',
vibe: "It's a WEEKLY newsletter and we ship whenever the f*** we want — cope. This drop we went to war with a single Android phone across a dozen builds until it finally admitted where it was standing on Earth. We won. Then we gave you a pin, taught the map to snitch on dead spots, fixed the camera, unstuck your keys, and let you bury your own bad crews. It's a fat one. Strap in.",
updates: [
// ── The Great Geolocation War ──────────────────────────────────────────
{
icon: 'fix',
title: "The 'Near Me' button stopped assassinating the app",
tag: 'Android',
body: "Tap Near Me → grant location → the ENTIRE app instantly dies and yeets you back to the app store. Adorable. The native geolocation plugin was compiled against a newer Kotlin than our build could read — a NoClassDefFoundError detonating on Android's main thread, the exact kind of crash no JavaScript try/catch on this green Earth can catch. We hauled the whole Android build up to Kotlin 2.1 — plugin classpath, forced stdlib, coroutines, all of it — and put a bullet in it. Dead. Not coming back.",
},
{
icon: 'feature',
title: "GPS that actually finds you — zero Google required",
tag: 'Map',
body: "Once it stopped crashing it did something almost ruder: nothing. It'd beg the sky for one flawless satellite fix, wait a few seconds, then quietly give up. Useless indoors, extra useless on de-Googled phones (hi GrapheneOS) with no fused-location cheat code. So we rebuilt it from scratch: run high- AND low-accuracy location watches in PARALLEL, grab the very first real fix that comes back, and give a cold GPS chip a proper 60 seconds to lock instead of rage-quitting after five. That's the trick that makes Google Maps feel instant — and we do it with no Google in the building.",
},
{
icon: 'ux',
title: "'Locating you…' — because a dead spinner is psychological warfare",
tag: 'Map',
body: "A cold GPS lock can genuinely take up to a minute. So instead of a silent spinner that has you wondering if the app died AGAIN, you now get a 'Locating you…' banner while it hunts and a clean 'Found you' when it snaps in. Telling the user what's happening: groundbreaking, apparently.",
},
{
icon: 'feature',
title: "You finally have a pin — with main-character energy",
tag: 'Map',
body: "Near Me used to just slide the map over and leave you squinting at a pile of markers going 'wait, which blob is me?'. Now there's a proper 'you are here': the SkateSpots logo in a circular badge with a pulsing halo, planted exactly where you're standing, floating on top of every other pin. Google's sad little blue dot could never.",
},
{
icon: 'fix',
title: "Native geolocation is now crash-proof, permanently",
tag: 'Android',
body: "We didn't just defuse the Kotlin bomb — we bricked up the whole hallway. The app now checks the geolocation plugin is GENUINELY registered before it dares touch it, falls back gracefully instead of self-destructing, ignores garbage 0°/0° 'null island' readings, and prints the real underlying error straight to your screen when something's off — so we never debug blind again. Belt, suspenders, and a second belt bolted over the first.",
},
{
icon: 'perf',
title: "'No location provider' now says so, like an adult",
tag: 'Android',
body: "Privacy-hardened phone with no network-location provider? Instead of a vague 'it timed out lol', the app now correctly detects the actual problem and tells you exactly what to do — flip on Network Location, or get GPS a look at the sky. Honest errors beat mystery errors every single time.",
},
// ── Community: Spot Status Reports ──────────────────────────────────────
{
icon: 'community',
title: "Spot Status Reports — rat out the knobbed ones",
tag: 'Community',
body: "Spots die. Ledges get knobbed, plazas get bulldozed, mellow spots go full bust overnight — and the person who mapped it is basically NEVER the one who finds out. So now ANYONE can slap a status on any spot: 🟢 Active, 🟡 Bust, 🟠 Skate-stopped, ⚪ Closed, or 🔴 Gone — with an optional note like 'new knobs on the ledge' or 'guard boots you after 6pm'. Brand new event kind, fully documented, 100% Nostr-native.",
},
{
icon: 'community',
title: "Consensus that can't be gamed",
tag: 'Trust',
body: "One salty local can't nuke a good spot out of spite. Reports blend into a single verdict weighted by how FRESH they are (30-day half-life, so a rebuilt spot heals itself over time) and how TRUSTED the reporter is (your web-of-trust score). Only your latest report counts — no ballot-stuffing — and anything older than six months is ignored. It's democracy, but for curbs.",
},
{
icon: 'ux',
title: "Status badges everywhere you look",
tag: 'Map',
body: "The verdict shows up as a color-coded badge on the spot page AND on every spot card, with a tooltip telling you how many skaters chimed in and how recently. Tap 'Report Status' on any spot, pick a state, drop a note, done. The pulse of the streets, live on the map.",
},
{
icon: 'ux',
title: "Demolished spots ghost the map",
tag: 'Map',
body: "When the crowd agrees a spot is 🔴 Gone, it vanishes from the map markers so you're not bombing 20 minutes across town to skate a fresh parking lot. It stays (dimmed, wearing its Gone badge) in the list — because Nostr never forgets and neither should the history books. Pour one out for the fallen.",
},
// ── Camera / Quick Snap ─────────────────────────────────────────────────
{
icon: 'feature',
title: "Quick Snap goes camera-first",
tag: 'Camera',
body: "Quick Snap kept fumbling open your gallery on native when you clearly wanted to, you know, take a damn photo. Now it fires the actual camera through the native plugin on the app, and a clean file picker on web. Snap a spot in the wild without spelunking through menus.",
},
{
icon: 'ux',
title: "Snap → straight to Add Spot",
tag: 'Camera',
body: "Take the shot and you land instantly on the Add Spot form — the photo uploads in the background while you fill in the details. No awkward 'now wait for the upload' purgatory staring at your screen in a parking lot.",
},
// ── Signing / Keys ──────────────────────────────────────────────────────
{
icon: 'security',
title: "Amber signing no longer hangs into the void",
tag: 'Keys',
body: "Signing through Amber (the external key-holder app) could just… hang. Silently. Forever. With zero feedback. We added a timeout and a one-time package handshake so a stuck signer surfaces as an actual error you can see and retry — instead of a frozen screen and a quiet prayer. Your keys stay in Amber; your sanity stays with you.",
},
{
icon: 'fix',
title: "'Capacitor Amber plugin not available' — exorcised",
tag: 'Keys',
body: "Uploads and signing on the native app would sometimes whine 'Amber plugin not available' and bail. Fixed the plugin wiring so the signer is reliably present when you upload or publish. Fewer gremlins standing between you and posting a clip.",
},
// ── Crews & The Lab (deletion — the NEW bit) ────────────────────────────
{
icon: 'community',
title: "You can finally bury your own crews & Lab profiles",
tag: 'Community',
body: "Made a crew you regret at 2am? Set up a Lab profile you no longer vibe with? You can now properly dissolve a crew or delete a Lab profile (builder, photographer, filmer, editor, coach) via NIP-09 — instead of it haunting the map until the heat death of the universe. Clean up your own mess. The delete button is yours.",
},
// ── Build / infra ───────────────────────────────────────────────────────
{
icon: 'fix',
title: "Android builds that don't fight us",
tag: 'Android',
body: "A mountain of unglamorous but load-bearing native-build plumbing: externalized the plugins that kept blowing up the compile, sorted the JSR package registry, and tamed the Capacitor sync so the APK ships to ZapStore clean. The boring stuff that's the only reason the fun stuff ever reaches your phone.",
},
],
spotOfTheWeek: undefined,
communityNote:
"This hit ZapStore roughly a dozen times this week, because apparently 'just bump the version number' is an extreme sport. If Near Me still can't pin you on a de-Googled phone: flip on Network Location in your OS settings, or give GPS a peek at the sky — satellites get shy indoors. Everything syncs through wss://goskatespots.nostr1.com. Keep mapping, keep reporting, keep skating.",
nostrText: `🛹 SkateSpots Drop #003
It's a WEEKLY newsletter and we ship whenever the f*** we want.
We beat a native crash that was killing the app on Near Me, then taught the map to actually find you on a de-Googled phone — 60s cold GPS lock, first-fix-wins, no Google. You get a pulsing "you are here" pin now. Plus Spot Status Reports: anyone can flag a spot as bust, skate-stopped or straight-up GONE, and demolished spots quietly vanish from the map. Camera, Amber signing and crew/Lab deletes all got fixed too.
https://skate-spots.org/#/newsletter
#skateboarding #nostr #skatespots #spotr #newsletter #skatespotsnews`,
},
{
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 <span key={j} className="text-[#1d9bf0]">{word}</span>;
if (word.startsWith('https://')) return <span key={j} className="text-[#1d9bf0] underline underline-offset-2">{word}</span>;
return <span key={j}>{word}</span>;
});
return <p key={i} className={line === '' ? 'h-3' : ''}>{parts}</p>;
});
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="bg-black border-2 border-[#ccff00]/40 max-w-lg w-[calc(100vw-2rem)] mx-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Eye className="h-4 w-4 text-[#ccff00]" />
{editing ? 'Edit' : 'Preview'} — Drop #{String(issue.number).padStart(3, '0')}
</DialogTitle>
<DialogDescription className="text-gray-500 text-xs">
{editing
? 'Edit the note text below. Changes only affect this publish — the source file stays unchanged.'
: 'This is how the note will appear on most Nostr clients. Formatting varies by client.'}
</DialogDescription>
</DialogHeader>
{editing ? (
/* ── Edit mode ── */
<div className="space-y-2 mt-2">
<Textarea
value={editedText}
onChange={e => setEditedText(e.target.value)}
className="bg-[#0f0f0f] border-white/10 text-gray-200 text-sm font-mono leading-relaxed min-h-[200px] resize-none focus:border-[#ccff00]/40"
autoFocus
/>
<p className="text-[10px] text-gray-600 text-right">{editedText.length} characters</p>
</div>
) : (
/* ── Preview mode ── */
<>
<div className="mt-2 rounded-2xl border border-white/10 bg-[#0f0f0f] overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<Avatar className="h-10 w-10 border-2 border-[#ccff00]/30 flex-shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ccff00]/10 text-[#ccff00] text-xs font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white font-bold text-sm truncate">{displayName}</p>
<p className="text-gray-500 text-xs">{timeStr} · via skate-spots.org</p>
</div>
</div>
<div className="px-4 pb-4 text-sm text-gray-200 leading-relaxed space-y-0">
{renderPreviewText(currentText)}
</div>
<div className="flex items-center gap-5 px-4 py-3 border-t border-white/5 text-gray-600 text-xs">
<span>💬 0</span><span>🔁 0</span><span>⚡ 0</span>
</div>
</div>
<p className="text-[10px] text-gray-600 text-right -mt-1">{currentText.length} characters</p>
</>
)}
{/* Actions */}
<div className="flex gap-3 mt-1">
{editing ? (
<>
<Button
variant="outline"
onClick={() => setEditing(false)}
className="flex-1 border-gray-700 text-gray-300 hover:text-white h-11"
>
Preview
</Button>
<Button
onClick={() => onConfirm(editedText)}
disabled={isPending || editedText.trim().length === 0}
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-11"
>
<Send className="h-3.5 w-3.5" />
{isPending ? 'Publishing...' : 'Publish'}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={() => { setEditedText(issue.nostrText); setEditing(true); }}
className="flex-1 border-gray-700 text-gray-300 hover:text-white h-11"
>
Edit first
</Button>
<Button
onClick={() => onConfirm(currentText)}
disabled={isPending}
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-11"
>
<Send className="h-3.5 w-3.5" />
{isPending ? 'Publishing...' : 'Publish'}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
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 = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
if (!user || !isAdmin) return null;
if (published) {
return (
<div className="flex items-center gap-2 text-green-400 text-sm font-bold">
<CheckCircle2 className="h-4 w-4" />
Published to Nostr
</div>
);
}
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 (
<>
<Button
onClick={() => setShowPreview(true)}
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-10 w-full sm:w-auto"
>
<Share2 className="h-3.5 w-3.5" />
Post to Nostr
</Button>
<NostrPreviewModal
issue={issue}
open={showPreview}
onClose={() => setShowPreview(false)}
onConfirm={handleConfirmPublish}
isPending={isPending}
/>
</>
);
}
// ─── Live community stats block ───────────────────────────────────────────────
function LiveStatsBlock() {
const { data: stats, isLoading } = useGrowthStats();
const items = [
{ icon: <MapPin className="h-4 w-4 text-[#ff0040]" />, label: 'Spots', value: stats?.totalSpots, trusted: stats?.trustedSpots, color: '#ff0040' },
{ icon: <Store className="h-4 w-4 text-[#4ade80]" />, label: 'Shops', value: stats?.totalShops, color: '#4ade80' },
{ icon: <Calendar className="h-4 w-4 text-[#ccff00]" />, label: 'Events', value: stats?.totalEvents, color: '#ccff00' },
{ icon: <Users className="h-4 w-4 text-yellow-400" />, label: 'Mappers', value: stats?.totalMappers, trusted: stats?.trustedMappers, color: '#facc15' },
{ icon: <GraduationCap className="h-4 w-4 text-[#ff6600]" />, label: 'Coaches', value: stats?.totalCoaches, color: '#ff6600' },
{ icon: <Route className="h-4 w-4 text-pink-400" />, label: 'Tours', value: stats?.totalTours, color: '#f472b6' },
];
return (
<div className="mt-4 p-4 rounded-2xl border-2 border-white/8 bg-white/3">
<div className="grid grid-cols-3 gap-4">
{items.map((item) => (
<div key={item.label} className="text-center py-2">
<div className="flex justify-center mb-1.5">{item.icon}</div>
{isLoading ? (
<Skeleton className="h-7 w-12 mx-auto mb-1" />
) : (
<div className="font-black text-2xl leading-none" style={{ color: item.color }}>
{(item.value ?? 0).toLocaleString()}
</div>
)}
<div className="text-[10px] uppercase tracking-widest text-gray-500 mt-1">{item.label}</div>
{item.trusted !== undefined && !isLoading && (
<div className="text-[10px] text-green-500/60 mt-0.5">
🛡 {item.trusted}
</div>
)}
</div>
))}
</div>
<p className="text-[10px] text-gray-600 text-center mt-3 uppercase tracking-widest">
Live · goskatespots.nostr1.com
</p>
</div>
);
}
// ─── Shared sub-components ────────────────────────────────────────────────────
function SectionHeader({ emoji, label, color }: { emoji: string; label: string; color: string }) {
return (
<div className="flex items-center gap-3">
<span className="text-xl">{emoji}</span>
<h2 className="text-xs font-black uppercase tracking-widest" style={{ color }}>
{label}
</h2>
<div className="flex-1 h-px" style={{ background: `${color}30` }} />
</div>
);
}
const TAG_COLORS: Record<string, string> = {
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<NewsletterUpdate['icon'], React.ReactNode> = {
shield: <Shield className="h-4 w-4 text-blue-400" />,
security: <Shield className="h-4 w-4 text-green-400" />,
ux: <Layers className="h-4 w-4 text-[#ff0040]" />,
feature: <Zap className="h-4 w-4 text-[#ccff00]" />,
fix: <CheckCircle2 className="h-4 w-4 text-purple-400" />,
community: <Crown className="h-4 w-4 text-yellow-400" />,
perf: <Radio className="h-4 w-4 text-orange-400" />,
};
// 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 (
<div className="p-4 rounded-xl border border-white/8 bg-white/3 hover:bg-white/5 transition-colors">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">{ICON_MAP[update.icon]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-bold text-white text-sm">{update.title}</h3>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${TAG_COLORS[colorKey]}`}>
{update.tag}
</span>
</div>
<p className="text-gray-400 text-sm leading-relaxed">{update.body}</p>
</div>
</div>
</div>
);
}
function SpotOfTheWeekBlock({ spot }: { spot: SpotOfTheWeek }) {
const inner = (
<div className="mt-4 rounded-2xl border-2 border-[#ff0040]/30 bg-[#ff0040]/5 overflow-hidden hover:border-[#ff0040]/50 transition-colors">
{spot.image && (
<div className="aspect-video w-full overflow-hidden">
<img src={spot.image} alt={spot.title} className="w-full h-full object-cover" />
</div>
)}
<div className="p-5">
<h3 className="font-black text-white text-lg uppercase mb-1">{spot.title}</h3>
<p className="text-[#ff0040] text-xs font-bold flex items-center gap-1 mb-3">
<MapPin className="h-3 w-3" />
{spot.location}
</p>
<p className="text-gray-300 text-sm leading-relaxed">{spot.why}</p>
{spot.mapperName && (
<p className="text-gray-500 text-xs mt-3">
Mapped by <span className="text-gray-300 font-medium">{spot.mapperName}</span>
</p>
)}
{spot.href && (
<div className="mt-4">
<span className="inline-flex items-center gap-1.5 text-[#ccff00] text-xs font-bold">
View on map <ArrowRight className="h-3 w-3" />
</span>
</div>
)}
</div>
</div>
);
return spot.href ? <Link to={spot.href}>{inner}</Link> : 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 (
<article>
{/* Issue header */}
<div className="mb-8 pb-6 border-b border-white/8">
{/* Top row: issue number badge + date */}
<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">
{formatIssueDate(issue.date)}
</div>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] flex items-center justify-center shadow-lg shadow-[#ff0040]/30 flex-shrink-0">
<span className="text-white font-black text-[10px]" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</span>
</div>
</div>
{/* Issue number large */}
<h2 className="text-4xl sm:text-5xl font-black uppercase leading-none text-white mb-2"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</h2>
{/* Vibe line */}
<p className="text-gray-400 text-sm leading-relaxed mb-4">{issue.vibe}</p>
{/* Publish button — full width on mobile, auto on larger */}
<div className="flex">
<PublishToNostrButton issue={issue} />
</div>
</div>
<div className="space-y-10">
{/* ── Community Stats — live numbers ── */}
<section>
<SectionHeader emoji="📊" label="Community by the Numbers" color="#facc15" />
<LiveStatsBlock />
</section>
{/* ── What's New ── */}
<section>
<SectionHeader emoji="🛠" label="What's New" color="#ccff00" />
<div className="space-y-3 mt-4">
{issue.updates.map((u, i) => (
<UpdateCard key={i} update={u} />
))}
</div>
</section>
{/* ── Spot of the Week ── */}
<section>
<SectionHeader emoji="📍" label="Spot of the Week" color="#ff0040" />
{issue.spotOfTheWeek ? (
<SpotOfTheWeekBlock spot={issue.spotOfTheWeek} />
) : (
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff0040]/20 bg-[#ff0040]/5">
<p className="text-gray-500 text-sm italic">
No spot picked this week. Submit yours and it might make the next drop.
</p>
<Link
to="/map"
className="inline-flex items-center gap-2 mt-3 text-[#ccff00] text-sm font-bold hover:opacity-80 transition-opacity"
>
Browse the map <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
)}
</section>
{/* ── Community ── */}
<section>
<SectionHeader emoji="👥" label="Community" color="#ff6600" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff6600]/30 bg-[#ff6600]/5">
<p className="text-gray-300 text-sm leading-relaxed">{issue.communityNote}</p>
<div className="mt-4 flex flex-wrap gap-2">
<Link to="/lab">
<Badge className="bg-[#ff0040]/20 text-[#ff0040] border border-[#ff0040]/40 hover:bg-[#ff0040]/30 cursor-pointer">
The Lab
</Badge>
</Link>
<Link to="/events">
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/40 hover:bg-[#ccff00]/30 cursor-pointer">
Events
</Badge>
</Link>
<Link to="/map">
<Badge className="bg-[#ff6600]/20 text-[#ff6600] border border-[#ff6600]/40 hover:bg-[#ff6600]/30 cursor-pointer">
Spot Map
</Badge>
</Link>
<Link to="/stats">
<Badge className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/40 hover:bg-yellow-500/30 cursor-pointer">
Stats
</Badge>
</Link>
</div>
</div>
</section>
{/* ── Support ── */}
<section>
<SectionHeader emoji="⚡" label="Support the Project" color="#f7931a" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#f7931a]/30 bg-[#f7931a]/5">
<p className="text-sm text-gray-300 mb-3">
100% open source, community funded, no ads, no investors. If it's useful zap it or back it on Angor.
</p>
<a
href="https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[#f7931a] text-sm font-bold hover:opacity-80 transition-opacity"
>
<Zap className="h-4 w-4" />
angor.io — SkateSpots
<ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
</section>
</div>
</article>
);
}
// ─── 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.',
});
return (
<Layout>
<div className="container max-w-2xl px-4 py-8 md:py-14 pb-24 md:pb-14">
{/* ── Masthead ── */}
<div className="mb-12 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-5">
<Radio className="h-3 w-3" />
Weekly Drops
</div>
<h1
className="text-4xl sm:text-6xl font-black uppercase leading-none mb-3"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
>
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
SKATE
</span>
<span className="text-white">SPOTS</span>
</h1>
<p className="text-gray-500 text-xs uppercase tracking-widest">
A weekly newsletter · shipped whenever the f*** we want
</p>
</div>
{/* ── All issues ── */}
<div className="space-y-20">
{NEWSLETTER_ISSUES.map((issue) => (
<IssueArticle key={issue.number} issue={issue} />
))}
</div>
{/* ── Page footer ── */}
<div className="mt-16 pt-8 border-t border-white/8 text-center">
<p className="text-xs text-gray-600 mb-2">
Built on{' '}
<a href="https://nostr.com" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-[#ccff00] transition-colors">
Nostr
</a>
{' · '}
<Link to="/license" className="text-gray-500 hover:text-[#ccff00] transition-colors">
Open source
</Link>
{' · '}
<a href="https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-[#ccff00] transition-colors">
Support us
</a>
</p>
<p className="text-[10px] text-gray-700 uppercase tracking-widest">
You are free to skateboard
</p>
</div>
</div>
</Layout>
);
}