// blog-post-page.jsx — Single blog post template for Green Integrations.
// Rebuilt v1.9.0 to match the Blog Single Post Design & Implementation Guide
// (May 2026 edition). Uses live data from /wp-json/claude-pages/v1/posts/{slug}.
//
// Major changes from v1.8.0 (per the guide):
// • Background switches to cream #F4F2ED throughout body
// • Sidebar: 284px wide, 44px gap (was 300/56)
// • TOC: collapsible chevron toggle, H3 support, IntersectionObserver active tracking
// • Inline Service CTA: dark navy (was amber gradient), placed after 3rd
// by splitting content server-side
// • Author Bio: vertical centered layout, link to WP author archive
// • Share Card: LinkedIn / X / Email with labels and brand SVGs
// • Discovery Call CTA: amber-accent headline, bullets, business-name input
// (visual only), green "Book a discovery call" button
// • Related Service card uses SIDEBAR target (different from inline)
// • Related Articles: white bg, cream cards, "View all →" link
// • All schema (Article, FAQPage, BreadcrumbList) handled server-side in
// includes/blog-schema.php — see that file, not this one.
const API_BASE = '/wp-json/claude-pages/v1';
// Category presentation — slug keys MUST stay in sync with blog-endpoints.php
// and blog-page.jsx. Each entry: chipBg/chipFg for chip rendering, accent for
// gradient placeholders when there's no cover image.
const POST_CATS = {
'power-solar-storage': {
label: 'Power · Solar & Storage', slug: 'power-solar-storage',
chipBg: '#E8F2EC', chipFg: '#1E4D2B', accent: '#1E4D2B',
},
'transform-ev-electrification': {
label: 'Transform · EV & Electrification', slug: 'transform-ev-electrification',
chipBg: '#E4EBF6', chipFg: '#1A3562', accent: '#1A3562',
},
'policy-compliance': {
label: 'Policy & Compliance', slug: 'policy-compliance',
chipBg: '#F7EDDA', chipFg: '#7A5010', accent: '#7A5010',
},
'industry-focus': {
label: 'Industry Focus', slug: 'industry-focus',
chipBg: '#E4EEF5', chipFg: '#1A4060', accent: '#1A4060',
},
'market-technology': {
label: 'Market & Technology', slug: 'market-technology',
chipBg: '#F0EBF7', chipFg: '#4A2D6B', accent: '#4A2D6B',
},
};
// Resolve the slug from the URL: /resources/insights/{slug}/
const getSlugFromUrl = () => {
const parts = window.location.pathname.split('/').filter(Boolean);
if (parts.length >= 3 && parts[0] === 'resources' && parts[1] === 'insights') return parts[2];
return parts[parts.length - 1] || '';
};
// Initials helper for the author avatar fallback
const initialsFromName = (name) => {
if (!name) return '—';
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
// Brand SVG icons (Lucide doesn't ship LinkedIn or X)
const BrandIcon = ({ name, size = 14, color = 'currentColor' }) => {
if (name === 'linkedin') {
return (
);
}
if (name === 'x' || name === 'twitter') {
return (
);
}
return null;
};
// React Context for the post — every component reads from here
const PostContext = React.createContext(null);
const usePost = () => React.useContext(PostContext);
// ============================================================
// 3.1 — Reading progress bar (fixed top, amber fill, 3px)
// ============================================================
const ReadingProgressBar = () => {
const [pct, setPct] = React.useState(0);
React.useEffect(() => {
const onScroll = () => {
const h = document.documentElement;
const total = h.scrollHeight - h.clientHeight;
const p = total > 0 ? Math.min(100, Math.max(0, (h.scrollTop / total) * 100)) : 0;
setPct(p);
};
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
return () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onScroll); };
}, []);
return (
{cat && (
{cat.label}
)}
{post.read_minutes} min read
{post.title}
{post.excerpt && (
{post.excerpt}
)}
{/* RIGHT column — cover image (hidden on mobile via CSS) */}
{post.featured_image && (
)}
);
};
// ============================================================
// 3.5 — Table of Contents (collapsible, gray bg, H3 support, IntersectionObserver)
// ============================================================
const TableOfContents = () => {
const post = usePost();
const items = (post && post.toc) || [];
const [open, setOpen] = React.useState(true);
const [active, setActive] = React.useState(items[0] ? items[0].anchor : null);
// Set default open state per viewport (open on desktop, collapsed on mobile)
React.useEffect(() => {
if (typeof window !== 'undefined' && window.innerWidth < 720) {
setOpen(false);
}
}, []);
// IntersectionObserver for active section tracking (per spec §3.5)
React.useEffect(() => {
if (items.length === 0) return;
const elements = items
.map((it) => document.getElementById(it.anchor))
.filter(Boolean);
if (elements.length === 0) return;
const obs = new IntersectionObserver((entries) => {
// Find the topmost intersecting entry
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) {
setActive(visible[0].target.id);
}
}, { rootMargin: '-8% 0% -72% 0%', threshold: [0] });
elements.forEach((el) => obs.observe(el));
return () => obs.disconnect();
}, [items.length]);
if (items.length === 0) return null;
return (
);
};
// ============================================================
// 3.7 — Inline Service CTA (dark navy, after 3rd
)
// ============================================================
const InlineServiceCta = () => {
const post = usePost();
const cta = post && post.service_cta && post.service_cta.inline;
if (!cta) return null;
return (
);
};
// ============================================================
// 3.6 — Article prose body (live HTML from WP, split at 3rd
)
//
// GI Link Audit (Fix 8): legacy posts may contain external URLs that have
// since 404'd or timed out. Known case (May 2026 crawl):
// /resources/insights/how-businesses-in-ontario-benefit-from-energy-storage/
// → https://www.oeb.ca/newsroom/2019/all-electricity-pricing-pilots-now-underway
// Fix these by editing the post in WP Admin → Posts; this template renders
// post_content as-is and cannot rewrite outbound links.
// ============================================================
const ArticleProse = () => {
const post = usePost();
if (!post) return null;
// If server-side split present, render before / CTA / after.
// Otherwise fall back to the full content with CTA above.
if (post.content_before_cta !== undefined) {
return (
<>
);
// ============================================================
// 4.1 — Author Bio Card (centered avatar/name, left-aligned bio)
// ============================================================
const AuthorBioCard = () => {
const post = usePost();
const a = post && post.author;
if (!a) return null;
return (
About the author
{/* Centered avatar */}
{a.photo
?
: initialsFromName(a.name)}
{a.name || 'Green Integrations'}
{a.role &&
{a.role}
}
{a.bio && (
{a.bio}
)}
{/* GI Link Audit (Fix 9): the "All articles by {author} →" link previously pointed
at /author/{slug}/, a WordPress-auto-generated archive that 404s on this site
("All articles by root →" was the visible artifact). Link removed entirely;
author bio is the only thing rendered here now. */}
);
};
// ============================================================
// 4.2 — Share Card (LinkedIn / X / Email, with labels)
// ============================================================
const ShareCard = () => {
const post = usePost();
if (!post) return null;
const handleShare = (kind) => {
const url = window.location.href;
const text = post.title;
let shareUrl = '#';
if (kind === 'linkedin') shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
if (kind === 'x') shareUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`;
if (kind === 'email') shareUrl = `mailto:?subject=${encodeURIComponent(text)}&body=${encodeURIComponent(url)}`;
if (kind === 'email') {
window.location.href = shareUrl;
} else {
window.open(shareUrl, '_blank', 'noopener,noreferrer');
}
};
const buttons = [
{ kind: 'linkedin', label: 'LinkedIn', icon: 'linkedin' },
{ kind: 'x', label: 'X', icon: 'x' },
{ kind: 'email', label: 'Email', icon: 'mail' },
];
return (
Share this article
{buttons.map((b) => (
))}
);
};
// ============================================================
// 4.3 — Discovery Call CTA card (per spec §4.3, full rebuild)
// ============================================================
const DiscoveryCallCard = () => (
Start a conversation
{/* Headline with amber accent on "stands on energy costs." */}
Find out where your facility{' '}
stands on energy costs.
Schedule a discovery call. No commitment required.
{/* Bullets */}
{[
"Your current electricity spend and where it's going",
'Which incentive programs apply to your operation',
'Whether onsite generation makes financial sense',
].map((b, i) => (
→{b}
))}
{/* Business name input (decorative — submission goes to /about/contact/) */}
{ e.target.style.borderColor = 'rgba(255,255,255,0.35)'; }}
onBlur={(e) => { e.target.style.borderColor = 'rgba(255,255,255,0.15)'; }} />
Book a discovery call →
Typically replied to within one business day
);
// ============================================================
// 4.4 — Related Service Card (amber, sidebar target — DIFFERENT from inline)
// ============================================================
const RelatedServiceCard = () => {
const post = usePost();
const cta = post && post.service_cta && post.service_cta.sidebar;
if (!cta) return null;
return (
Also relevant
);
};
// ============================================================
// 5.x — Contact CTA (full-width dark navy band)
// Rendered after Related Articles, just above the site footer.
// Strict-optional: only renders when post.contact_cta is non-null
// (REST returns null unless all four ACF fields are filled).
// ============================================================
const ContactCtaSection = () => {
const post = usePost();
const cta = post && post.contact_cta;
if (!cta || !cta.title || !cta.paragraph || !cta.button || !cta.button.label || !cta.button.url) {
return null;
}
return (