feat: newsletter v2 — structured issues, live stats, Nostr publish button

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 14:45:59 +07:00
parent c146d94b9f
commit 248f5b5ff2

View file

@ -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: <Issue001 />,
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 (
<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 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 (
<div className="space-y-10">
<Button
onClick={handlePublish}
disabled={isPending}
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-9"
>
<Share2 className="h-3.5 w-3.5" />
{isPending ? 'Publishing...' : 'Post to Nostr'}
</Button>
);
}
{/* WHAT'S NEW */}
<section>
<SectionHeader emoji="🛠" label="What's New" color="#ccff00" />
<div className="space-y-4 mt-4">
// ─── Live community stats block ───────────────────────────────────────────────
<UpdateCard
icon={<Shield className="h-4 w-4 text-green-400" />}
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.
</UpdateCard>
function LiveStatsBlock() {
const { data: stats, isLoading } = useGrowthStats();
<UpdateCard
icon={<Shield className="h-4 w-4 text-blue-400" />}
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 <code className="bg-white/10 px-1 rounded text-xs">https://</code> and{' '}
<code className="bg-white/10 px-1 rounded text-xs">http://</code> get through.{' '}
<code className="bg-white/10 px-1 rounded text-xs">javascript:</code>,{' '}
<code className="bg-white/10 px-1 rounded text-xs">data:</code>, and anything else
silently drops the link. Zero friction for real websites — every legit URL starts
with https.
</UpdateCard>
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' },
];
<UpdateCard
icon={<Crown className="h-4 w-4 text-yellow-400" />}
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 <span className="text-yellow-400 font-bold">👑 Official</span> 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.
</UpdateCard>
<UpdateCard
icon={<Layers className="h-4 w-4 text-[#ff0040]" />}
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.
</UpdateCard>
<UpdateCard
icon={<Radio className="h-4 w-4 text-purple-400" />}
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.
</UpdateCard>
<UpdateCard
icon={<Zap className="h-4 w-4 text-[#ccff00]" />}
title="Trusted count on community stats"
tag="Transparency"
tagColor="lime"
>
The <Link to="/stats" className="text-[#ccff00] underline underline-offset-2">Community Stats</Link> 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.
</UpdateCard>
<UpdateCard
icon={<Shield className="h-4 w-4 text-[#ccff00]" />}
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.
</UpdateCard>
<UpdateCard
icon={<MapPin className="h-4 w-4 text-[#ff6600]" />}
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.
</UpdateCard>
</div>
</section>
{/* SPOT OF THE WEEK */}
<section>
<SectionHeader emoji="📍" label="Spot of the Week" color="#ff0040" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff0040]/30 bg-[#ff0040]/5">
<p className="text-gray-400 text-sm italic">
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.
</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 and add yours
<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-400 text-sm">
The relay is live at <code className="bg-white/10 px-1 rounded text-xs">wss://goskatespots.nostr1.com</code>.
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.
</p>
<div className="mt-3 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>
return (
<div className="mt-4 p-5 rounded-2xl border-2 border-white/8 bg-white/3">
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{items.map((item) => (
<div key={item.label} className="text-center">
<div className="flex justify-center mb-1">{item.icon}</div>
{isLoading ? (
<Skeleton className="h-6 w-10 mx-auto mb-1" />
) : (
<div className="font-black text-xl leading-none" style={{ color: item.color }}>
{(item.value ?? 0).toLocaleString()}
</div>
)}
<div className="text-[9px] uppercase tracking-widest text-gray-600 mt-0.5">{item.label}</div>
{item.trusted !== undefined && !isLoading && (
<div className="text-[9px] text-green-500/70 mt-0.5">
🛡 {item.trusted}
</div>
)}
</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">
SkateSpots is 100% open source, community funded, no ads, no investors. If it's
useful to you, zap the project or contribute on Geyser.
</p>
<a
href="https://geyser.fund/project/skatespots"
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" />
geyser.fund/project/skatespots
<ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
</section>
))}
</div>
<p className="text-[10px] text-gray-600 text-center mt-3 uppercase tracking-widest">
Live · pulled from wss://goskatespots.nostr1.com
</p>
</div>
);
}
@ -226,10 +249,7 @@ function SectionHeader({ emoji, label, color }: { emoji: string; label: 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 }}
>
<h2 className="text-xs font-black uppercase tracking-widest" style={{ color }}>
{label}
</h2>
<div className="flex-1 h-px" style={{ background: `${color}30` }} />
@ -247,42 +267,208 @@ const TAG_COLORS: Record<string, string> = {
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<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 mb-2">
<div className="mt-0.5 flex-shrink-0">{icon}</div>
<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">{title}</h3>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${TAG_COLORS[tagColor] ?? TAG_COLORS.lime}`}>
{tag}
<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">{children}</p>
<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 {
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 (
<article>
{/* Issue header */}
<div className="flex items-start justify-between gap-4 mb-8 pb-6 border-b border-white/8">
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">
{formatIssueDate(issue.date)}
</div>
<h2 className="text-3xl font-black uppercase leading-none text-white"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</h2>
<p className="text-gray-400 text-sm mt-2 max-w-md">{issue.vibe}</p>
</div>
<div className="flex flex-col items-end gap-3">
<div className="w-14 h-14 rounded-2xl 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-sm" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</span>
</div>
<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 issue.
</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 Geyser.
</p>
<a
href="https://geyser.fund/project/skatespots"
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" />
geyser.fund/project/skatespots
<ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
</section>
</div>
</article>
);
}
// ─── 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 (
<Layout>
<div className="container max-w-2xl px-4 py-10 md:py-14">
@ -305,8 +489,10 @@ export default function NewsletterPage() {
<Radio className="h-3 w-3" />
Weekly Drop
</div>
<h1 className="text-5xl sm:text-6xl font-black uppercase leading-none mb-3"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
<h1
className="text-5xl 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>
@ -318,46 +504,28 @@ export default function NewsletterPage() {
</p>
</div>
{/* ── Issues ── */}
<div className="space-y-16">
{/* ── All issues ── */}
<div className="space-y-20">
{NEWSLETTER_ISSUES.map((issue) => (
<article key={issue.number}>
{/* Issue header */}
<div className="flex items-start justify-between gap-4 mb-8 pb-6 border-b border-white/8">
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">
{formatIssueDate(issue.date)}
</div>
<h2 className="text-3xl font-black uppercase leading-none text-white">
#{String(issue.number).padStart(3, '0')}
</h2>
<p className="text-gray-400 text-sm mt-2 max-w-md">
{issue.vibe}
</p>
</div>
<div className="flex-shrink-0">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] flex items-center justify-center shadow-lg shadow-[#ff0040]/30">
<span className="text-white font-black text-lg" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</span>
</div>
</div>
</div>
{/* Issue body */}
{issue.content}
</article>
<IssueArticle key={issue.number} issue={issue} />
))}
</div>
{/* ── Footer blurb ── */}
{/* ── 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>
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>
<Link to="/license" className="text-gray-500 hover:text-[#ccff00] transition-colors">
Open source
</Link>
{' · '}
<a href="https://geyser.fund/project/skatespots" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-[#ccff00] transition-colors">Support us</a>
<a href="https://geyser.fund/project/skatespots" 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
@ -365,8 +533,6 @@ export default function NewsletterPage() {
</div>
</div>
{/* Latest issue pill and Monday reminder are rendered globally via Layout */}
</Layout>
);
}