/* * VOISACE Blog / News & Insights page * - Public: lists published posts with category filter * - Admin panel (owner-only): create / edit / delete posts * - Full i18n via useLanguage() */ import { useState, useEffect, useCallback } from "react"; import { Link } from "wouter"; import Navigation from "@/components/Navigation"; import Footer from "@/components/Footer"; import BlogCover from "@/components/BlogCover"; import { useLanguage } from "@/contexts/LanguageContext"; import { useMetaTags } from "@/hooks/useMetaTags"; import { useBreadcrumb } from "@/hooks/useBreadcrumb"; import { useJsonLd } from "@/hooks/useJsonLd"; import { BlogPost, BLOG_CATEGORIES, BlogLangCode } from "@shared/blog"; import { trackBlogArticleClick } from "@/lib/analytics"; // Resolve a post's title/excerpt/content for the active site language, // falling back to English when a translation is not available. function localizedPost(post: BlogPost, lang: string) { const l = lang as BlogLangCode; const tr = l !== "en" ? post.translations?.[l] : undefined; return { title: tr?.title || post.title, excerpt: tr?.excerpt || post.excerpt, content: tr?.content || post.content, }; } // ─── Admin secret (stored in sessionStorage for the session) ──────────────── const ADMIN_KEY = "voisace_blog_admin"; function getAdminSecret(): string { return sessionStorage.getItem(ADMIN_KEY) || ""; } // ─── API helpers ───────────────────────────────────────────────────────────── async function apiFetch(path: string, opts?: RequestInit) { const res = await fetch(path, opts); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || "Request failed"); } return res.json(); } // ─── Category badge colours ────────────────────────────────────────────────── const CATEGORY_COLORS: Record = { Technical: "bg-blue-500/15 text-blue-300 border-blue-500/20", "Product Update": "bg-[#2EEEA0]/15 text-[#2EEEA0] border-[#2EEEA0]/20", "Industry Insights": "bg-purple-500/15 text-purple-300 border-purple-500/20", "Case Study": "bg-amber-500/15 text-amber-300 border-amber-500/20", "Company News": "bg-pink-500/15 text-pink-300 border-pink-500/20", }; function CategoryBadge({ category }: { category: string }) { const cls = CATEGORY_COLORS[category] || "bg-gray-500/15 text-gray-300 border-gray-500/20"; return ( {category} ); } // ─── Post card ─────────────────────────────────────────────────────────────── function readingTime(text: string): number { const words = text.trim().split(/\s+/).length; return Math.max(1, Math.round(words / 200)); } function PostCard({ post, t, lang, onEdit, onDelete, isAdmin }: { post: BlogPost; t: ReturnType["t"]; lang: string; onEdit?: (p: BlogPost) => void; onDelete?: (id: string) => void; isAdmin: boolean; }) { // Safely parse publishedAt; if invalid, skip rendering this post to avoid breaking the entire list if (!post.publishedAt || typeof post.publishedAt !== 'string') { console.warn(`[Blog] Post "${post.slug}" has invalid publishedAt:`, post.publishedAt); return null; // Don't render posts with invalid dates } const date = post.publishedAt.split('T')[0]; // Format: yyyy-mm-dd const loc = localizedPost(post, lang); const mins = readingTime((loc.content || "") + " " + (loc.excerpt || "")); return (
{ (e.currentTarget as HTMLElement).style.transform = 'translateY(-4px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 12px 32px rgba(46,238,160,0.12)'; }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = ''; (e.currentTarget as HTMLElement).style.boxShadow = ''; }}> {/* Cover image with graceful branded fallback (broken/missing URL) */}
{!post.published && ( {t.blog.blogDraft} )}

{loc.title}

{loc.excerpt}

{t.blog.publishedOn} {date} · {mins} {t.blog.minRead}
{isAdmin && onEdit && onDelete && ( <> )} trackBlogArticleClick(post.slug)}> {t.blog.readMore}
{/* Tags at bottom */} {post.tags && post.tags.length > 0 && (
{post.tags.slice(0, 3).map((tag) => ( e.stopPropagation()} className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-white/5 text-gray-400 border border-white/8 hover:bg-blue-500/15 hover:text-blue-300 hover:border-blue-500/30 transition-all duration-150" > #{tag} ))}
)}
); } // ─── Admin post editor modal ───────────────────────────────────────────────── const EMPTY_POST: Partial = { title: "", slug: "", excerpt: "", content: "", coverUrl: "", category: "Technical", author: "VOISACE Engineering", publishedAt: new Date().toISOString().slice(0, 10), published: true, }; function AdminEditor({ post, onClose, onSaved, t, }: { post: Partial | null; onClose: () => void; onSaved: () => void; t: ReturnType["t"]; }) { const [form, setForm] = useState>(post || EMPTY_POST); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); const isEdit = !!form.id; const handleSave = async (published: boolean) => { setSaving(true); setError(""); try { const payload = { ...form, published }; const secret = getAdminSecret(); if (isEdit) { await apiFetch(`/api/blog/${form.id}`, { method: "PUT", headers: { "Content-Type": "application/json", "x-admin-secret": secret }, body: JSON.stringify(payload), }); } else { await apiFetch("/api/blog", { method: "POST", headers: { "Content-Type": "application/json", "x-admin-secret": secret }, body: JSON.stringify(payload), }); } onSaved(); onClose(); } catch (e: any) { setError(e.message); } finally { setSaving(false); } }; const set = (key: keyof BlogPost, val: string | boolean) => setForm((f: Partial) => ({ ...f, [key]: val })); return (
e.stopPropagation()}>

{isEdit ? t.blog.adminEditPost : t.blog.adminNewPost}

{error && (
{error}
)}
set("title", e.target.value)} className="w-full bg-[#0A0E1A] border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:outline-none focus:border-[#2EEEA0]/50" />
set("slug", e.target.value)} className="w-full bg-[#0A0E1A] border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:outline-none focus:border-[#2EEEA0]/50 font-mono" placeholder="my-post-slug" />
set("author", e.target.value)} className="w-full bg-[#0A0E1A] border border-white/10 rounded-lg px-4 py-2.5 text-white text-sm focus:outline-none focus:border-[#2EEEA0]/50" />