SkateSpots/src/pages/NewsletterPage.tsx

720 lines
30 KiB
TypeScript

import { dateLocaleCode } from '@/lib/dateFnsI18n';
import { useEffect, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
import { ImageWithFallback } from '@/components/ImageWithFallback';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Zap, MapPin, Shield, Layers, Crown, Radio,
ArrowRight, Share2, CheckCircle2, Users, Store, Calendar,
GraduationCap, Route, Eye, Send,
} from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useGrowthStats } from '@/hooks/useGrowthStats';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButton';
import { NewsletterComposer } from '@/components/NewsletterComposer';
import { NewsletterBackfillPanel } from '@/components/NewsletterBackfillPanel';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterSubscriberCount } from '@/hooks/useNewsletterSubscriberCount';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
// ─── Admin pubkeys allowed to publish newsletter as kind 1 ───────────────────
import { isAdminPubkey } from '@/lib/admins';
// ─── Newsletter issue structure ───────────────────────────────────────────────
//
import { NEWSLETTER_ISSUES, type NewsletterUpdate, type SpotOfTheWeek, type NewsletterIssue } from '@/lib/newsletterIssues';
// ─── Nostr post preview + publish ────────────────────────────────────────────
function NostrPreviewModal({
issue,
open,
onClose,
onConfirm,
isPending,
}: {
issue: NewsletterIssue;
open: boolean;
onClose: () => void;
onConfirm: (text: string) => void;
isPending: boolean;
}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey ?? '');
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? genUserName(user?.pubkey ?? '');
const avatar = metadata?.picture;
const now = new Date();
const timeStr = now.toLocaleTimeString(dateLocaleCode(), { hour: 'numeric', minute: '2-digit' });
const [editing, setEditing] = useState(false);
const [editedText, setEditedText] = useState(issue.nostrText);
const currentText = editing ? editedText : issue.nostrText;
const renderPreviewText = (text: string) => {
return text.split('\n').map((line, i) => {
const parts = line.split(/(\s+)/).map((word, j) => {
if (word.startsWith('#')) return <span key={j} className="text-[#1d9bf0]">{word}</span>;
if (word.startsWith('https://')) return <span key={j} className="text-[#1d9bf0] underline underline-offset-2">{word}</span>;
return <span key={j}>{word}</span>;
});
return <p key={i} className={line === '' ? 'h-3' : ''}>{parts}</p>;
});
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="bg-black border-2 border-[#ccff00]/40 max-w-lg w-[calc(100vw-2rem)] mx-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Eye className="h-4 w-4 text-[#ccff00]" />
{editing ? t('common.edit') : t('walletUi.newsletter.previewLabel')} — {t('walletUi.newsletter.dropNumber', { number: `#${String(issue.number).padStart(3, '0')}` })}
</DialogTitle>
<DialogDescription className="text-gray-500 text-xs">
{editing
? t('walletUi.newsletter.editNoteDesc')
: t('walletUi.newsletter.previewNoteDesc')}
</DialogDescription>
</DialogHeader>
{editing ? (
/* ── Edit mode ── */
<div className="space-y-2 mt-2">
<Textarea
value={editedText}
onChange={e => setEditedText(e.target.value)}
className="bg-[#0f0f0f] border-white/10 text-gray-200 text-sm font-mono leading-relaxed min-h-[200px] resize-none focus:border-[#ccff00]/40"
autoFocus
/>
<p className="text-[10px] text-gray-600 text-right">{t('walletUi.newsletter.characters', { count: editedText.length })}</p>
</div>
) : (
/* ── Preview mode ── */
<>
<div className="mt-2 rounded-2xl border border-white/10 bg-[#0f0f0f] overflow-hidden">
<div className="flex items-center gap-3 px-4 pt-4 pb-2">
<Avatar className="h-10 w-10 border-2 border-[#ccff00]/30 flex-shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ccff00]/10 text-[#ccff00] text-xs font-bold">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white font-bold text-sm truncate">{displayName}</p>
<p className="text-gray-500 text-xs">{timeStr} · via skate-spots.org</p>
</div>
</div>
<div className="px-4 pb-4 text-sm text-gray-200 leading-relaxed space-y-0">
{renderPreviewText(currentText)}
</div>
<div className="flex items-center gap-5 px-4 py-3 border-t border-white/5 text-gray-600 text-xs">
<span>💬 0</span><span>🔁 0</span><span>⚡ 0</span>
</div>
</div>
<p className="text-[10px] text-gray-600 text-right -mt-1">{t('walletUi.newsletter.characters', { count: currentText.length })}</p>
</>
)}
{/* Actions */}
<div className="flex gap-3 mt-1">
{editing ? (
<>
<Button
variant="outline"
onClick={() => setEditing(false)}
className="flex-1 border-gray-700 text-gray-300 hover:text-white h-11"
>
{t('walletUi.newsletter.previewLabel')}
</Button>
<Button
onClick={() => onConfirm(editedText)}
disabled={isPending || editedText.trim().length === 0}
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-11"
>
<Send className="h-3.5 w-3.5" />
{isPending ? t('walletUi.newsletter.publishing') : t('walletUi.newsletter.publish')}
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={() => { setEditedText(issue.nostrText); setEditing(true); }}
className="flex-1 border-gray-700 text-gray-300 hover:text-white h-11"
>
{t('walletUi.newsletter.editFirst')}
</Button>
<Button
onClick={() => onConfirm(currentText)}
disabled={isPending}
className="flex-1 gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-11"
>
<Send className="h-3.5 w-3.5" />
{isPending ? t('walletUi.newsletter.publishing') : t('walletUi.newsletter.publish')}
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
function PublishToNostrButton({ issue }: { issue: NewsletterIssue }) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { toast } = useToast();
const [published, setPublished] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const isAdmin = isAdminPubkey(user?.pubkey);
if (!user || !isAdmin) return null;
if (published) {
return (
<div className="flex items-center gap-2 text-green-400 text-sm font-bold">
<CheckCircle2 className="h-4 w-4" />
{t('walletUi.newsletter.publishedToNostr')}
</div>
);
}
const handleConfirmPublish = async (text: string) => {
try {
await publishEvent({
kind: 1,
content: text,
tags: [
['t', 'skateboarding'],
['t', 'nostr'],
['t', 'skatespots'],
['t', 'spotr'],
['t', 'newsletter'],
['t', 'skatespotsnews'],
['r', 'https://skate-spots.org/#/newsletter'],
],
});
setPublished(true);
setShowPreview(false);
toast({ title: t('walletUi.newsletter.publishedToast'), description: t('walletUi.newsletter.postedToNostr', { number: `#${String(issue.number).padStart(3, '0')}` }) });
} catch (err) {
toast({
title: t('walletUi.newsletter.publishFailed'),
description: err instanceof Error ? err.message : t('walletUi.addToCollection.somethingWrong'),
variant: 'destructive',
});
}
};
return (
<>
<Button
onClick={() => setShowPreview(true)}
className="gap-2 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-black uppercase text-xs h-10 w-full sm:w-auto"
>
<Share2 className="h-3.5 w-3.5" />
{t('walletUi.newsletter.postToNostr')}
</Button>
<NostrPreviewModal
issue={issue}
open={showPreview}
onClose={() => setShowPreview(false)}
onConfirm={handleConfirmPublish}
isPending={isPending}
/>
</>
);
}
// ─── Live community stats block ───────────────────────────────────────────────
function LiveStatsBlock() {
const { t } = useTranslation();
const { data: stats, isLoading } = useGrowthStats();
const items = [
{ icon: <MapPin className="h-4 w-4 text-[#ff0040]" />, label: t('walletUi.stats.spots'), value: stats?.totalSpots, trusted: stats?.trustedSpots, color: '#ff0040' },
{ icon: <Store className="h-4 w-4 text-[#4ade80]" />, label: t('walletUi.stats.shops'), value: stats?.totalShops, color: '#4ade80' },
{ icon: <Calendar className="h-4 w-4 text-[#ccff00]" />, label: t('walletUi.stats.events'), value: stats?.totalEvents, color: '#ccff00' },
{ icon: <Users className="h-4 w-4 text-yellow-400" />, label: t('walletUi.stats.mappers'), value: stats?.totalMappers, trusted: stats?.trustedMappers, color: '#facc15' },
{ icon: <GraduationCap className="h-4 w-4 text-[#ff6600]" />, label: t('walletUi.stats.coaches'), value: stats?.totalCoaches, color: '#ff6600' },
{ icon: <Route className="h-4 w-4 text-pink-400" />, label: t('walletUi.stats.tours'), value: stats?.totalTours, color: '#f472b6' },
];
return (
<div className="mt-4 p-4 rounded-2xl border-2 border-white/8 bg-white/3">
<div className="grid grid-cols-3 gap-4">
{items.map((item) => (
<div key={item.label} className="text-center py-2">
<div className="flex justify-center mb-1.5">{item.icon}</div>
{isLoading ? (
<Skeleton className="h-7 w-12 mx-auto mb-1" />
) : (
<div className="font-black text-2xl leading-none" style={{ color: item.color }}>
{(item.value ?? 0).toLocaleString()}
</div>
)}
<div className="text-[10px] uppercase tracking-widest text-gray-500 mt-1">{item.label}</div>
{item.trusted !== undefined && !isLoading && (
<div className="text-[10px] text-green-500/60 mt-0.5">
🛡 {item.trusted}
</div>
)}
</div>
))}
</div>
<p className="text-[10px] text-gray-600 text-center mt-3 uppercase tracking-widest">
{t('walletUi.newsletter.live')} · goskatespots.nostr1.com
</p>
</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',
};
const ICON_MAP: Record<NewsletterUpdate['icon'], React.ReactNode> = {
shield: <Shield className="h-4 w-4 text-blue-400" />,
security: <Shield className="h-4 w-4 text-green-400" />,
ux: <Layers className="h-4 w-4 text-[#ff0040]" />,
feature: <Zap className="h-4 w-4 text-[#ccff00]" />,
fix: <CheckCircle2 className="h-4 w-4 text-purple-400" />,
community: <Crown className="h-4 w-4 text-yellow-400" />,
perf: <Radio className="h-4 w-4 text-orange-400" />,
};
// Tag label → TAG_COLORS key (best-effort match)
function tagColorKey(tag: string): string {
const t = tag.toLowerCase();
if (t.includes('security') || t.includes('protect')) return 'green';
if (t.includes('ux') || t.includes('map')) return 'red';
if (t.includes('spam') || t.includes('defense')) return 'purple';
if (t.includes('transparent') || t.includes('trust')) return 'lime';
if (t.includes('badge') || t.includes('imperson')) return 'yellow';
if (t.includes('feed') || t.includes('quality')) return 'orange';
return 'lime';
}
function UpdateCard({ update }: { update: NewsletterUpdate }) {
const colorKey = tagColorKey(update.tag);
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">
<div className="mt-0.5 flex-shrink-0">{ICON_MAP[update.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">{update.title}</h3>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${TAG_COLORS[colorKey]}`}>
{update.tag}
</span>
</div>
<p className="text-gray-400 text-sm leading-relaxed">{update.body}</p>
</div>
</div>
</div>
);
}
function SpotOfTheWeekBlock({ spot }: { spot: SpotOfTheWeek }) {
const { t } = useTranslation();
const inner = (
<div className="mt-4 rounded-2xl border-2 border-[#ff0040]/30 bg-[#ff0040]/5 overflow-hidden hover:border-[#ff0040]/50 transition-colors">
{spot.image && (
<div className="aspect-video w-full overflow-hidden">
<ImageWithFallback src={spot.image} alt={spot.title} className="w-full h-full object-cover" />
</div>
)}
<div className="p-5">
<h3 className="font-black text-white text-lg uppercase mb-1">{spot.title}</h3>
<p className="text-[#ff0040] text-xs font-bold flex items-center gap-1 mb-3">
<MapPin className="h-3 w-3" />
{spot.location}
</p>
<p className="text-gray-300 text-sm leading-relaxed">{spot.why}</p>
{spot.mapperName && (
<p className="text-gray-500 text-xs mt-3">
{t('walletUi.newsletter.mappedBy')} <span className="text-gray-300 font-medium">{spot.mapperName}</span>
</p>
)}
{spot.href && (
<div className="mt-4">
<span className="inline-flex items-center gap-1.5 text-[#ccff00] text-xs font-bold">
{t('walletUi.newsletter.viewOnMap')} <ArrowRight className="h-3 w-3" />
</span>
</div>
)}
</div>
</div>
);
return spot.href ? <Link to={spot.href}>{inner}</Link> : inner;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatIssueDate(iso: string): string {
return new Date(iso).toLocaleDateString(dateLocaleCode(), {
year: 'numeric', month: 'long', day: 'numeric',
});
}
// ─── Issue renderer ───────────────────────────────────────────────────────────
function IssueArticle({ issue }: { issue: NewsletterIssue }) {
const { t } = useTranslation();
return (
<article>
{/* Cover (optional) */}
{issue.image && (
<div className="mb-6 aspect-[2/1] w-full overflow-hidden rounded-2xl border-2 border-white/8">
<ImageWithFallback
src={issue.image}
alt={`SkateSpots Drop #${String(issue.number).padStart(3, '0')} cover`}
className="h-full w-full object-cover"
/>
</div>
)}
{/* Issue header */}
<div className="mb-8 pb-6 border-b border-white/8">
{/* Top row: issue number badge + date */}
<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500">
{formatIssueDate(issue.date)}
</div>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] flex items-center justify-center shadow-lg shadow-[#ff0040]/30 flex-shrink-0">
<span className="text-white font-black text-[10px]" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</span>
</div>
</div>
{/* Issue number large */}
<h2 className="text-4xl sm:text-5xl font-black uppercase leading-none text-white mb-2"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</h2>
{/* Vibe line */}
<p className="text-gray-400 text-sm leading-relaxed mb-4">{issue.vibe}</p>
{/* Publish button — full width on mobile, auto on larger */}
<div className="flex">
<PublishToNostrButton issue={issue} />
</div>
</div>
<div className="space-y-10">
{/* ── Community Stats — live numbers ── */}
<section>
<SectionHeader emoji="📊" label={t('walletUi.newsletter.sectionStats')} color="#facc15" />
<LiveStatsBlock />
</section>
{/* ── What's New ── */}
<section>
<SectionHeader emoji="🛠" label={t('walletUi.newsletter.sectionWhatsNew')} color="#ccff00" />
<div className="space-y-3 mt-4">
{issue.updates.map((u, i) => (
<UpdateCard key={i} update={u} />
))}
</div>
</section>
{/* ── Spot of the Week ── */}
<section>
<SectionHeader emoji="📍" label={t('walletUi.newsletter.sectionSpotOfWeek')} color="#ff0040" />
{issue.spotOfTheWeek ? (
<SpotOfTheWeekBlock spot={issue.spotOfTheWeek} />
) : (
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff0040]/20 bg-[#ff0040]/5">
<p className="text-gray-500 text-sm italic">
{t('walletUi.newsletter.noSpotPicked')}
</p>
<Link
to="/map"
className="inline-flex items-center gap-2 mt-3 text-[#ccff00] text-sm font-bold hover:opacity-80 transition-opacity"
>
{t('walletUi.newsletter.browseMap')} <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
)}
</section>
{/* ── Community ── */}
<section>
<SectionHeader emoji="👥" label={t('walletUi.newsletter.sectionCommunity')} color="#ff6600" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff6600]/30 bg-[#ff6600]/5">
<p className="text-gray-300 text-sm leading-relaxed">{issue.communityNote}</p>
<div className="mt-4 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">
{t('nav.lab')}
</Badge>
</Link>
<Link to="/events">
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/40 hover:bg-[#ccff00]/30 cursor-pointer">
{t('nav.events')}
</Badge>
</Link>
<Link to="/map">
<Badge className="bg-[#ff6600]/20 text-[#ff6600] border border-[#ff6600]/40 hover:bg-[#ff6600]/30 cursor-pointer">
{t('walletUi.newsletter.spotMap')}
</Badge>
</Link>
<Link to="/stats">
<Badge className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/40 hover:bg-yellow-500/30 cursor-pointer">
{t('walletUi.newsletter.statsBadge')}
</Badge>
</Link>
</div>
</div>
</section>
{/* ── Support ── */}
<section>
<SectionHeader emoji="⚡" label={t('walletUi.newsletter.sectionSupport')} 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">
{t('walletUi.newsletter.supportDesc')}
</p>
<a
href="https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z"
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" />
angor.io — SkateSpots
<ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
</section>
</div>
</article>
);
}
// ─── Live issues (published to Nostr as NIP-23 articles) ─────────────────────
function LiveIssueCard({ issue }: { issue: SkateNewsletterIssue }) {
const { t } = useTranslation();
const naddr = nip19.naddrEncode({
kind: NEWSLETTER_KIND,
pubkey: issue.pubkey,
identifier: issue.identifier,
});
const date = new Date(issue.publishedAt * 1000).toLocaleDateString(dateLocaleCode(), {
year: 'numeric',
month: 'long',
day: 'numeric',
});
return (
<Link
to={`/${naddr}`}
className="group block overflow-hidden rounded-2xl border-2 border-[#ccff00]/20 bg-white/[0.03] transition-all hover:border-[#ccff00]/50 hover:bg-white/[0.06]"
>
{issue.image && (
<div className="aspect-video w-full overflow-hidden">
<img
src={issue.image}
alt={issue.title}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
<div className="p-5">
<p className="mb-1.5 text-[10px] font-black uppercase tracking-widest text-gray-500">{date}</p>
<h3 className="mb-1 text-lg font-black uppercase leading-tight text-white group-hover:text-[#ccff00]">
{issue.title}
</h3>
{issue.summary && <p className="line-clamp-2 text-sm text-gray-400">{issue.summary}</p>}
<span className="mt-3 inline-flex items-center gap-1.5 text-xs font-bold text-[#ccff00]">
{t('walletUi.newsletter.readFull')} <ArrowRight className="h-3.5 w-3.5" />
</span>
</div>
</Link>
);
}
/** Social-proof subscriber tally shown under the subscribe CTA. */
function SubscriberCount() {
const { t } = useTranslation();
const { data: count, isLoading } = useNewsletterSubscriberCount();
if (isLoading || !count || count < 3) return null; // hide until it's meaningful
const rounded = count >= 100 ? `${Math.floor(count / 50) * 50}+` : `${count}`;
return (
<p className="flex items-center justify-center gap-1.5 text-[11px] font-bold uppercase tracking-widest text-[#ccff00]">
<Users className="h-3 w-3" />
{t('walletUi.newsletter.joinSkaters', { count: rounded })}
</p>
);
}
function LiveIssuesSection() {
const { t } = useTranslation();
const { data: issues, isLoading } = useNewsletterIssues();
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Skeleton className="h-56 w-full rounded-2xl" />
<Skeleton className="h-56 w-full rounded-2xl" />
</div>
);
}
if (!issues || issues.length === 0) return null;
return (
<section className="mb-16">
<SectionHeader emoji="📡" label={t('walletUi.newsletter.sectionLatestDrops')} color="#ccff00" />
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
{issues.map((issue) => (
<LiveIssueCard key={issue.id} issue={issue} />
))}
</div>
</section>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function NewsletterPage() {
const { t } = useTranslation();
useSeoMeta({
title: 'Newsletter - SkateSpots',
description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.',
});
// Opening the newsletter clears the "News" unread badge in the bell — once
// per visit. The ref guard means a changing callback identity can never turn
// this into a render loop (it did: `markNewsletterRead` used to change on
// every render, so the effect re-ran forever: "Maximum update depth exceeded").
const { markNewsletterRead } = useNewsletterRead();
const markedReadRef = useRef(false);
useEffect(() => {
if (markedReadRef.current) return;
markedReadRef.current = true;
markNewsletterRead();
}, [markNewsletterRead]);
return (
<>
<div className="container max-w-2xl px-4 py-8 md:py-14 pb-24 md:pb-14">
{/* ── Masthead ── */}
<div className="mb-10 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" />
{t('walletUi.newsletter.weeklyDrops')}
</div>
<h1
className="text-4xl 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>
<span className="text-white">SPOTS</span>
</h1>
<p className="text-gray-500 text-xs uppercase tracking-widest mb-6">
{t('walletUi.newsletter.tagline')}
</p>
{/* Subscribe — routes new drops into the notification bell */}
<div className="flex flex-col items-center gap-2">
<div className="flex flex-wrap items-center justify-center gap-2">
<NewsletterSubscribeButton />
<NewsletterComposer />
</div>
<SubscriberCount />
<p className="max-w-xs text-[11px] leading-snug text-gray-600">
{t('walletUi.newsletter.freeNote')}
</p>
{/* Admin-only: publish the static archive issues to Nostr. */}
<NewsletterBackfillPanel issues={NEWSLETTER_ISSUES} />
</div>
</div>
{/* ── Live issues published to Nostr ── */}
<LiveIssuesSection />
{/* ── All issues ── */}
<div className="space-y-20">
{NEWSLETTER_ISSUES.map((issue) => (
<IssueArticle key={issue.number} issue={issue} />
))}
</div>
{/* ── Page footer ── */}
<div className="mt-16 pt-8 border-t border-white/8 text-center">
<p className="text-xs text-gray-600 mb-2">
{t('walletUi.newsletter.builtOn')}{' '}
<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">
{t('walletUi.newsletter.openSource')}
</Link>
{' · '}
<a href="https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-[#ccff00] transition-colors">
{t('walletUi.newsletter.supportUs')}
</a>
</p>
<p className="text-[10px] text-gray-400 uppercase tracking-widest">
{t('walletUi.newsletter.freeToSkate')}
</p>
</div>
</div>
</>
);
}