);
}
diff --git a/src/components/NewsletterOverlay.tsx b/src/components/NewsletterOverlay.tsx
new file mode 100644
index 0000000..6dfc657
--- /dev/null
+++ b/src/components/NewsletterOverlay.tsx
@@ -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(
+ '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 (
+
+
+
+
+ {formatIssueNumber(latest.number)} is out
+
+
+ {age === 0 ? 'Today' : age === 1 ? 'Yesterday' : `${age}d ago`}
+
+
+
+
+ );
+}
+
+// ─── Monday admin reminder ────────────────────────────────────────────────────
+
+function MondayAdminReminder() {
+ const { user } = useCurrentUser();
+ const location = useLocation();
+ const [dismissed, setDismissed] = useLocalStorage(
+ '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 (
+
+
+
+
+
+
+
+
+ It's Monday
+
+
+ Time to drop the weekly newsletter. One click and the AI writes it from the git log.
+
+ );
+}
+
+// ─── 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 (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/pages/NewsletterPage.tsx b/src/pages/NewsletterPage.tsx
new file mode 100644
index 0000000..9383005
--- /dev/null
+++ b/src/pages/NewsletterPage.tsx
@@ -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: ,
+ },
+];
+
+// ─── Issue #001 ───────────────────────────────────────────────────────────────
+
+function Issue001() {
+ return (
+
+
+ {/* WHAT'S NEW */}
+
+
+
+
+ }
+ 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.
+
+
+ }
+ 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 https:// and{' '}
+ http:// get through.{' '}
+ javascript:,{' '}
+ data:, and anything else
+ silently drops the link. Zero friction for real websites — every legit URL starts
+ with https.
+
+
+ }
+ 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 👑 Official 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.
+
+
+ }
+ 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.
+
+
+ }
+ 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.
+
+
+ }
+ title="Trusted count on community stats"
+ tag="Transparency"
+ tagColor="lime"
+ >
+ The Community Stats 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.
+
+
+ }
+ 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.
+
+
+ }
+ 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.
+
+
+
+
+
+ {/* SPOT OF THE WEEK */}
+
+
+
+
+ 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.
+
+
+ Browse the map and add yours
+
+
+
+
+
+ {/* COMMUNITY */}
+
+
+
+
+ The relay is live at wss://goskatespots.nostr1.com.
+ 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.
+