/** * Peptide News Feed — React Frontend * * A clean, accessible news feed component that displays peptide research * articles with title, summary, source, date, and keyword tags. * * @since 2.0.0 */ const { useState, useEffect, useCallback, useRef, memo } = React; /* ── Constants ──────────────────────────────────────────────────────── */ const ARTICLES_PER_PAGE = 10; const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /* ── Utilities ──────────────────────────────────────────────────────── */ /** * Format an ISO date string to a human-readable form. * * @param {string} dateStr ISO date string. * @returns {string} Formatted date (e.g., "Apr 5, 2026"). */ function formatDate(dateStr) { if (!dateStr) return ''; const d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); } /** * Calculate a human-readable relative time string. * * @param {string} dateStr ISO date string. * @returns {string} Relative time (e.g., "2 hours ago"). */ function timeAgo(dateStr) { if (!dateStr) return ''; const now = Date.now(); const then = new Date(dateStr).getTime(); if (isNaN(then)) return ''; const diffMs = now - then; const diffMin = Math.floor(diffMs / 60000); if (diffMin < 1) return 'Just now'; if (diffMin < 60) return `${diffMin}m ago`; const diffHr = Math.floor(diffMin / 60); if (diffHr < 24) return `${diffHr}h ago`; const diffDay = Math.floor(diffHr / 24); if (diffDay < 7) return `${diffDay}d ago`; return formatDate(dateStr); } /* ── Simple in-memory cache ─────────────────────────────────────────── */ const apiCache = new Map(); function getCached(key) { const entry = apiCache.get(key); if (!entry) return null; if (Date.now() - entry.ts > CACHE_TTL_MS) { apiCache.delete(key); return null; } return entry.data; } function setCache(key, data) { apiCache.set(key, { data, ts: Date.now() }); } /* ── API Layer ──────────────────────────────────────────────────────── */ /** * Fetch articles from the WP REST API with caching. * * @param {string} restUrl Base REST URL. * @param {number} page Page number. * @param {number} count Articles per page. * @param {AbortSignal} signal Abort signal for cleanup. * @returns {Promise} Response data. */ async function fetchArticles(restUrl, page, count, signal) { const cacheKey = `${restUrl}:${page}:${count}`; const cached = getCached(cacheKey); if (cached) return cached; const url = `${restUrl}articles?count=${count}&page=${page}`; const resp = await fetch(url, { signal }); if (!resp.ok) { throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); } const data = await resp.json(); setCache(cacheKey, data); return data; } /* ── Click Tracking ─────────────────────────────────────────────────── */ /** * Track an outbound click via sendBeacon or fallback XHR. * * @param {number} articleId */ function trackClick(articleId) { const config = window.peptideNewsFeed || {}; if (!config.ajaxUrl || !config.nonce) return; const formData = new FormData(); formData.append('action', 'peptide_news_track_click'); formData.append('nonce', config.nonce); formData.append('article_id', articleId); formData.append('referrer', document.referrer || ''); formData.append('page_url', window.location.href); formData.append('session_id', config.sessionId || ''); if (navigator.sendBeacon) { navigator.sendBeacon(config.ajaxUrl, formData); } else { fetch(config.ajaxUrl, { method: 'POST', body: formData }).catch(() => {}); } } /* ── Components ─────────────────────────────────────────────────────── */ /** * Single keyword tag pill. */ const Tag = memo(function Tag({ label }) { return {label}; }); /** * Single article card. */ const ArticleCard = memo(function ArticleCard({ article }) { const summary = article.ai_summary || article.excerpt || ''; const tagSource = article.tags || article.categories || ''; const tags = tagSource ? tagSource.split(',').map((t) => t.trim()).filter(Boolean).slice(0, 5) : []; const handleClick = useCallback(() => { trackClick(article.id); }, [article.id]); return (
{article.source}

{article.title}

{summary && (

{summary}

)} {article.author && ( {article.author} )} {tags.length > 0 && (
{tags.map((tag) => ( ))}
)}
); }); /** * Loading skeleton placeholder. */ function LoadingSkeleton({ count = 3 }) { return (
{Array.from({ length: count }, (_, i) => (
))}
); } /** * Error state with retry. */ function ErrorMessage({ message, onRetry }) { return (

Unable to load articles. {message}

); } /** * Empty state. */ function EmptyState() { return (

No peptide news articles available yet. Check back soon!

); } /** * Pagination controls. */ const Pagination = memo(function Pagination({ page, totalPages, onPageChange }) { if (totalPages <= 1) return null; return ( ); }); /* ── Main Feed Component ────────────────────────────────────────────── */ /** * PeptideNewsFeed — root component. * * Reads configuration from window.peptideNewsFeed which is * localized by the PHP shortcode renderer. */ function PeptideNewsFeed() { const config = window.peptideNewsFeed || {}; const restUrl = config.restUrl || '/wp-json/peptide-news/v1/'; const perPage = config.count || ARTICLES_PER_PAGE; const [articles, setArticles] = useState([]); const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const feedRef = useRef(null); const loadArticles = useCallback( async (pageNum, signal) => { setLoading(true); setError(null); try { const data = await fetchArticles(restUrl, pageNum, perPage, signal); setArticles(data.articles || []); setTotalPages(data.total_pages || 1); } catch (err) { if (err.name !== 'AbortError') { setError(err.message || 'Something went wrong.'); } } finally { setLoading(false); } }, [restUrl, perPage] ); useEffect(() => { const controller = new AbortController(); loadArticles(page, controller.signal); return () => controller.abort(); }, [page, loadArticles]); const handlePageChange = useCallback((newPage) => { setPage(newPage); // Scroll feed container into view on pagination. if (feedRef.current) { feedRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }, []); const handleRetry = useCallback(() => { loadArticles(page); }, [page, loadArticles]); return (
{loading && } {!loading && error && ( )} {!loading && !error && articles.length === 0 && } {!loading && !error && articles.length > 0 && ( <>
{articles.map((article) => ( ))}
)}
); } /* ── Mount ──────────────────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', function () { const containers = document.querySelectorAll('[data-peptide-news-feed]'); containers.forEach(function (el) { const root = ReactDOM.createRoot(el); root.render(React.createElement(PeptideNewsFeed)); }); });