diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index b63bfdf..17649c3 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> {/* Marketplace */} } /> + {/* Newsletter */} + } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9d8134e..2a21a8b 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -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) {
Made with ❤️ for the skateboarding community
+ + Newsletter + + + {/* Newsletter: floating pill + Monday admin reminder */} +
); } 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. +

+ setDismissed(todayKey)} + > + + Write Issue #{String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')} + +
+ +
+
+
+ ); +} + +// ─── 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. +

+
+ + + The Lab + + + + + Events + + + + + Spot Map + + +
+
+
+ + {/* SUPPORT */} +
+ +
+

+ SkateSpots is 100% open source, community funded, no ads, no investors. If it's + useful to you, zap the project or contribute on Geyser. +

+ + + geyser.fund/project/skatespots + + +
+
+ +
+ ); +} + +// ─── Shared sub-components ──────────────────────────────────────────────────── + +function SectionHeader({ emoji, label, color }: { emoji: string; label: string; color: string }) { + return ( +
+ {emoji} +

+ {label} +

+
+
+ ); +} + +const TAG_COLORS: Record = { + 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 ( +
+
+
{icon}
+
+
+

{title}

+ + {tag} + +
+

{children}

+
+
+
+ ); +} + +// ─── 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 ( + +
+ + {/* ── Masthead ── */} +
+
+ + Weekly Drop +
+

+ + SKATE + +
+ SPOTS +

+

+ The community newsletter +

+
+ + {/* ── Issues ── */} +
+ {NEWSLETTER_ISSUES.map((issue) => ( +
+ {/* Issue header */} +
+
+
+ {formatIssueDate(issue.date)} +
+

+ #{String(issue.number).padStart(3, '0')} +

+

+ {issue.vibe} +

+
+
+
+ + #{String(issue.number).padStart(3, '0')} + +
+
+
+ + {/* Issue body */} + {issue.content} +
+ ))} +
+ + {/* ── Footer blurb ── */} +
+

+ Built on Nostr + {' · '} + Open source + {' · '} + Support us +

+

+ You are free to skateboard +

+
+ +
+ + {/* Latest issue pill and Monday reminder are rendered globally via Layout */} +
+ ); +}