feat: weekly newsletter system with Issue #001
- NewsletterPage (/newsletter) — zine aesthetic, dark bg, neon accents Issue #001 covers all 8 security measures shipped this week: geohash validation, URL sanitization, content limits, WoT defaults, trusted stats, OfficialBadge, marker clustering, DM phishing warning - NewsletterOverlay — two overlays rendered globally via Layout: 1. Floating "Latest Drop" pill — shows for 7 days after each issue, dismissible per-issue via localStorage, hides on /newsletter page 2. Monday admin reminder — only visible to 2 authorized pubkeys (founder + second admin), shows every Monday, one-click opens Shakespeare AI with prompt pre-filled to write the next issue, dismisses for the rest of that Monday via localStorage - Footer — Newsletter link added to desktop footer alongside License/Supporters - AppRouter — /newsletter route added (lazy loaded) To add Issue #002: prepend a new object to NEWSLETTER_ISSUES array in NewsletterPage.tsx. The pill and Monday reminder auto-update. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
f19851da92
commit
c146d94b9f
4 changed files with 558 additions and 0 deletions
|
|
@ -47,6 +47,7 @@ const SupportersPage = lazy(() => import("./pages/SupportersPage"));
|
|||
const RiderCardPage = lazy(() => import("./pages/RiderCardPage"));
|
||||
const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage"));
|
||||
const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
|
||||
const NewsletterPage = lazy(() => import("./pages/NewsletterPage"));
|
||||
const NotFound = lazy(() => import("./pages/NotFound"));
|
||||
|
||||
// Minimal inline fallback — no extra component needed
|
||||
|
|
@ -113,6 +114,8 @@ export function AppRouter() {
|
|||
<Route path="/amber-callback" element={<AmberCallbackPage />} />
|
||||
{/* Marketplace */}
|
||||
<Route path="/marketplace-listings" element={<MarketplacePage />} />
|
||||
{/* Newsletter */}
|
||||
<Route path="/newsletter" element={<NewsletterPage />} />
|
||||
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { SetStatusDialog } from '@/components/SetStatusDialog';
|
|||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -763,6 +764,12 @@ export function Layout({ children }: LayoutProps) {
|
|||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
|
||||
<span>Made with <span className="text-red-500">❤️</span> for the skateboarding community</span>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<Link
|
||||
to="/newsletter"
|
||||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||||
>
|
||||
Newsletter
|
||||
</Link>
|
||||
<Link
|
||||
to="/license"
|
||||
className="hover:text-[#ccff00] transition-colors font-medium"
|
||||
|
|
@ -817,6 +824,9 @@ export function Layout({ children }: LayoutProps) {
|
|||
open={showStatusDialog}
|
||||
onOpenChange={setShowStatusDialog}
|
||||
/>
|
||||
|
||||
{/* Newsletter: floating pill + Monday admin reminder */}
|
||||
<NewsletterOverlay />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
173
src/components/NewsletterOverlay.tsx
Normal file
173
src/components/NewsletterOverlay.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { X, Radio, Pencil } from 'lucide-react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import { NEWSLETTER_ISSUES } from '@/pages/NewsletterPage';
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Only these two pubkeys see the Monday "write newsletter" reminder.
|
||||
* Everyone else sees nothing.
|
||||
*/
|
||||
const NEWSLETTER_ADMIN_PUBKEYS = [
|
||||
'20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21', // SkateSpots founder — npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
|
||||
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', // npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l
|
||||
];
|
||||
|
||||
/**
|
||||
* How many days the "Latest Drop" pill stays visible after a new issue.
|
||||
* After this window it auto-hides until the next issue drops.
|
||||
*/
|
||||
const PILL_VISIBLE_DAYS = 7;
|
||||
|
||||
/**
|
||||
* Pre-filled Shakespeare chat URL that opens ready to write the next newsletter.
|
||||
* When you click it on a Monday, it takes you straight into the AI with the
|
||||
* prompt already typed — just hit send.
|
||||
*/
|
||||
const SHAKESPEARE_PROMPT = encodeURIComponent(
|
||||
'Write this week\'s SkateSpots newsletter issue. Read the git log for the past 7 days, summarize all changes in plain skater language, pick a spot of the week if any new ones were submitted, and format it following the same structure as Issue #001. Keep it short, punchy, no corporate tone.'
|
||||
);
|
||||
const WRITE_NEWSLETTER_URL = `https://shakespeare.diy/project/skatespots?prompt=${SHAKESPEARE_PROMPT}`;
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function isMonday(): boolean {
|
||||
return new Date().getDay() === 1;
|
||||
}
|
||||
|
||||
function daysSinceIssue(isoDate: string): number {
|
||||
const issued = new Date(isoDate).getTime();
|
||||
const now = Date.now();
|
||||
return Math.floor((now - issued) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function formatIssueNumber(n: number): string {
|
||||
return `#${String(n).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
// ─── Floating "Latest Drop" pill ─────────────────────────────────────────────
|
||||
|
||||
function LatestDropPill() {
|
||||
const location = useLocation();
|
||||
const latest = NEWSLETTER_ISSUES[0];
|
||||
const age = daysSinceIssue(latest.date);
|
||||
const [dismissed, setDismissed] = useLocalStorage<number>(
|
||||
'newsletter-pill-dismissed-issue',
|
||||
0
|
||||
);
|
||||
|
||||
// Hide on newsletter page itself, if too old, or if already dismissed for this issue
|
||||
if (
|
||||
location.pathname === '/newsletter' ||
|
||||
age > PILL_VISIBLE_DAYS ||
|
||||
dismissed === latest.number
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom,0px))] md:bottom-6 right-4 z-40 flex items-center gap-2 animate-in slide-in-from-bottom-4 duration-300">
|
||||
<Link
|
||||
to="/newsletter"
|
||||
className="flex items-center gap-2 pl-3 pr-2 py-2 rounded-full bg-black/95 border border-[#ccff00]/50 shadow-lg shadow-[#ccff00]/10 hover:border-[#ccff00] hover:shadow-[#ccff00]/20 transition-all group"
|
||||
>
|
||||
<Radio className="h-3.5 w-3.5 text-[#ccff00] animate-pulse" />
|
||||
<span className="text-xs font-black text-[#ccff00] uppercase tracking-wide">
|
||||
{formatIssueNumber(latest.number)} is out
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 hidden sm:inline pr-1">
|
||||
{age === 0 ? 'Today' : age === 1 ? 'Yesterday' : `${age}d ago`}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDismissed(latest.number)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-full bg-black/95 border border-white/10 text-gray-500 hover:text-white hover:border-white/30 transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Monday admin reminder ────────────────────────────────────────────────────
|
||||
|
||||
function MondayAdminReminder() {
|
||||
const { user } = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const [dismissed, setDismissed] = useLocalStorage<string>(
|
||||
'newsletter-monday-dismissed',
|
||||
''
|
||||
);
|
||||
|
||||
// Today's date key — so it only shows once per Monday, not every page load
|
||||
const todayKey = new Date().toISOString().slice(0, 10);
|
||||
const isAdmin = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
|
||||
const alreadyDismissedToday = dismissed === todayKey;
|
||||
|
||||
if (
|
||||
!isAdmin ||
|
||||
!isMonday() ||
|
||||
alreadyDismissedToday ||
|
||||
location.pathname === '/newsletter'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-20 md:top-20 right-4 z-50 max-w-xs animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="bg-black/98 border-2 border-[#ccff00]/60 rounded-2xl shadow-2xl shadow-[#ccff00]/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0">
|
||||
<Pencil className="h-4 w-4 text-[#ccff00]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[#ccff00] font-black text-xs uppercase tracking-wide mb-1">
|
||||
It's Monday
|
||||
</p>
|
||||
<p className="text-gray-300 text-xs leading-relaxed mb-3">
|
||||
Time to drop the weekly newsletter. One click and the AI writes it from the git log.
|
||||
</p>
|
||||
<a
|
||||
href={WRITE_NEWSLETTER_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-2 rounded-xl bg-[#ccff00] text-black text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/90 transition-colors"
|
||||
onClick={() => setDismissed(todayKey)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Write Issue #{String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDismissed(todayKey)}
|
||||
className="flex-shrink-0 text-gray-600 hover:text-gray-400 transition-colors"
|
||||
aria-label="Dismiss reminder"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Combined overlay export ──────────────────────────────────────────────────
|
||||
|
||||
export function NewsletterOverlay() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Avoid SSR hydration mismatch — only render client-side
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LatestDropPill />
|
||||
<MondayAdminReminder />
|
||||
</>
|
||||
);
|
||||
}
|
||||
372
src/pages/NewsletterPage.tsx
Normal file
372
src/pages/NewsletterPage.tsx
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Zap, MapPin, Shield, Layers, Crown, Radio, ArrowRight } from 'lucide-react';
|
||||
|
||||
// ─── 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).
|
||||
|
||||
export interface NewsletterIssue {
|
||||
/** Issue number shown as #001 */
|
||||
number: number;
|
||||
/** ISO date string e.g. "2026-06-18" */
|
||||
date: string;
|
||||
/** Short punchy vibe line — shown as subtitle */
|
||||
vibe: string;
|
||||
/** Full rendered content — JSX */
|
||||
content: React.ReactNode;
|
||||
}
|
||||
|
||||
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 />,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Issue #001 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Issue001() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
|
||||
{/* WHAT'S NEW */}
|
||||
<section>
|
||||
<SectionHeader emoji="🛠" label="What's New" color="#ccff00" />
|
||||
<div className="space-y-4 mt-4">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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',
|
||||
};
|
||||
|
||||
function UpdateCard({
|
||||
icon,
|
||||
title,
|
||||
tag,
|
||||
tagColor,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
tag: string;
|
||||
tagColor: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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-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}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm leading-relaxed">{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatIssueDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
// ─── 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.',
|
||||
});
|
||||
|
||||
const latest = NEWSLETTER_ISSUES[0];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container max-w-2xl px-4 py-10 md:py-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 Drop
|
||||
</div>
|
||||
<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>
|
||||
<br />
|
||||
<span className="text-white">SPOTS</span>
|
||||
</h1>
|
||||
<p className="text-gray-500 text-xs uppercase tracking-widest">
|
||||
The community newsletter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Issues ── */}
|
||||
<div className="space-y-16">
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Footer blurb ── */}
|
||||
<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>
|
||||
|
||||
{/* Latest issue pill and Monday reminder are rendered globally via Layout */}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue