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:
parent
c146d94b9f
commit
248f5b5ff2
1 changed files with 421 additions and 255 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue