795 lines
34 KiB
TypeScript
795 lines
34 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 ───────────────────
|
|
|
|
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 */
|
|
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: 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 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 ─────────────────────────────────────────────────────────────────────
|
|
|
|
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">
|
|
The community newsletter
|
|
</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://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
|
|
</p>
|
|
</div>
|
|
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|