179 lines
7.5 KiB
TypeScript
179 lines
7.5 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 '@/lib/newsletterIssues';
|
|
import { useTranslation } from '@/contexts/language';
|
|
import { isAdminPubkey } from '@/lib/admins';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/**
|
|
* 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 { t } = useTranslation();
|
|
const location = useLocation();
|
|
const latest = NEWSLETTER_ISSUES[0];
|
|
const [dismissed, setDismissed] = useLocalStorage<number>(
|
|
'newsletter-pill-dismissed-issue',
|
|
0
|
|
);
|
|
// Guard: no archive yet ⇒ nothing to promote.
|
|
if (!latest) return null;
|
|
const age = daysSinceIssue(latest.date);
|
|
|
|
// 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">
|
|
{t('walletUi.newsletter.dropIsOut', { number: formatIssueNumber(latest.number) })}
|
|
</span>
|
|
<span className="text-[10px] text-gray-500 hidden sm:inline">
|
|
· {age === 0 ? t('time.today') : age === 1 ? t('time.yesterday') : t('walletUi.newsletter.daysAgo', { count: age })}
|
|
</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={t('walletUi.newsletter.dismiss')}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Monday admin reminder ────────────────────────────────────────────────────
|
|
|
|
function MondayAdminReminder() {
|
|
const { t } = useTranslation();
|
|
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 ||
|
|
NEWSLETTER_ISSUES.length === 0 ||
|
|
location.pathname === '/newsletter'
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={cn(
|
|
'fixed z-[1200] w-auto max-w-none animate-in slide-in-from-bottom-4 duration-300',
|
|
// Mobile: a bottom banner above the nav — never collides with the map
|
|
// toolbar / search bar (which sits at the top of the map).
|
|
'inset-x-3 bottom-[calc(4.75rem+env(safe-area-inset-bottom,0px))]',
|
|
// Desktop: the top-right card.
|
|
'md:inset-x-auto md:bottom-auto md:top-20 md:right-4 md:w-80',
|
|
)}>
|
|
<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">
|
|
{t('walletUi.newsletter.itsMonday')}
|
|
</p>
|
|
<p className="text-gray-300 text-xs leading-relaxed mb-3">
|
|
{t('walletUi.newsletter.mondayDesc')}
|
|
</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" />
|
|
{t('walletUi.newsletter.writeDrop', { number: `#${String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}` })}
|
|
</a>
|
|
</div>
|
|
<button
|
|
onClick={() => setDismissed(todayKey)}
|
|
className="min-h-0 min-w-0 flex-shrink-0 text-gray-600 hover:text-gray-400 transition-colors"
|
|
aria-label={t('walletUi.newsletter.dismissReminder')}
|
|
>
|
|
<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 />
|
|
</>
|
|
);
|
|
}
|