SkateSpots/src/components/NewsletterOverlay.tsx
shakespeare.diy 13934c0e74 Fix newsletter admin gating: use isAdminPubkey from lib/admins instead of undefined NEWSLETTER_ADMIN_PUBKEYS
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-08-20 16:16:09 +07:00

165 lines
6.7 KiB
TypeScript

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';
import { isAdminPubkey } from '@/lib/admins';
/**
* 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, map page (to avoid conflicts with map UI), if too old, or if already dismissed
const isMapPage = location.pathname === '/' || location.pathname === '/map';
if (
location.pathname === '/newsletter' ||
isMapPage ||
age > PILL_VISIBLE_DAYS ||
dismissed === latest.number
) {
return null;
}
return (
<div className="fixed bottom-[calc(4.75rem+env(safe-area-inset-bottom,0px))] md:bottom-6 right-3 z-40 flex items-center gap-1.5 animate-in slide-in-from-bottom-4 duration-300 max-w-[calc(100vw-1.5rem)]">
<Link
to="/newsletter"
className="flex items-center gap-2 pl-3 pr-3 py-2 rounded-full bg-black/98 border border-[#ccff00]/50 shadow-lg shadow-[#ccff00]/10 hover:border-[#ccff00] transition-all active:scale-95 touch-manipulation"
>
<Radio className="h-3.5 w-3.5 text-[#ccff00] animate-pulse flex-shrink-0" />
<span className="text-xs font-black text-[#ccff00] uppercase tracking-wide whitespace-nowrap">
Drop {formatIssueNumber(latest.number)} is out
</span>
<span className="text-[10px] text-gray-500 hidden sm:inline">
· {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 flex-shrink-0 rounded-full bg-black/98 border border-white/10 text-gray-500 hover:text-white hover:border-white/30 transition-colors touch-manipulation active:scale-95"
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 = isAdminPubkey(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 Drop #{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 />
</>
);
}