Custom i18n system with typed translation dictionaries (~570 keys), LanguageProvider context, and useTranslation hook. All 31 components and pages wired with t() calls. Chatbot backend passes language hint to Claude for Ukrainian responses. Language preference persists via localStorage. SEO meta tags and html lang attribute update dynamically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { motion } from 'framer-motion';
|
|
import SEO from '../components/SEO';
|
|
import { useTranslation } from '../i18n';
|
|
import type { BlogPostFull } from '../types/blog';
|
|
import './BlogPostPage.css';
|
|
|
|
const BlogPostPage: React.FC = () => {
|
|
const { t, lang } = useTranslation();
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const [post, setPost] = useState<BlogPostFull | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [notFound, setNotFound] = useState(false);
|
|
|
|
const dateLocale = lang === 'uk' ? 'uk-UA' : 'en-GB';
|
|
|
|
function formatDate(dateStr: string) {
|
|
const [y, m, d] = dateStr.split('-').map(Number);
|
|
return new Date(y, m - 1, d).toLocaleDateString(dateLocale, {
|
|
month: 'short', day: '2-digit', year: 'numeric',
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!slug) return;
|
|
fetch(`/blog/posts/${slug}.json`)
|
|
.then(r => {
|
|
if (!r.ok) throw new Error('Not found');
|
|
return r.json();
|
|
})
|
|
.then(setPost)
|
|
.catch(() => setNotFound(true))
|
|
.finally(() => setLoading(false));
|
|
}, [slug]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<main className="blog-post-page">
|
|
<div className="blog-post-container">
|
|
<p className="blog-post-loading">{t('blogPost.loading')}</p>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (notFound || !post) {
|
|
return (
|
|
<main className="blog-post-page">
|
|
<div className="blog-post-container">
|
|
<Link to="/blog" className="blog-post-back">{t('blogPost.back')}</Link>
|
|
<h1 className="blog-post-title">{t('blogPost.notFound')}</h1>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const paragraphs = post.body.split('\n\n');
|
|
|
|
return (
|
|
<main className="blog-post-page">
|
|
<motion.div
|
|
className="blog-post-container"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<SEO
|
|
title={`${post.title} | AImpress Blog`}
|
|
description={post.excerpt || post.title}
|
|
url={`https://ai-impress.com/blog/${slug}`}
|
|
image={post.coverImage || undefined}
|
|
type="article"
|
|
/>
|
|
<Link to="/blog" className="blog-post-back">{t('blogPost.back')}</Link>
|
|
|
|
{post.coverImage && (
|
|
<img src={post.coverImage} alt={post.title} className="blog-post-cover" />
|
|
)}
|
|
|
|
<h1 className="blog-post-title">{post.title}</h1>
|
|
|
|
<div className="blog-post-meta">
|
|
<span className="blog-post-date">{formatDate(post.date)}</span>
|
|
{post.hashtags.length > 0 && (
|
|
<div className="blog-post-tags">
|
|
{post.hashtags.map(tag => (
|
|
<span key={tag} className="blog-post-tag">#{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="blog-post-body">
|
|
{paragraphs.map((p, i) => (
|
|
<p key={i}>{p}</p>
|
|
))}
|
|
</div>
|
|
|
|
{post.sourceUrl && (
|
|
<div className="blog-post-source">
|
|
<span className="blog-post-source-label">{t('blogPost.source')}</span>
|
|
<a href={post.sourceUrl} target="_blank" rel="noopener noreferrer">
|
|
{post.sourceTitle || post.sourceUrl}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</main>
|
|
);
|
|
};
|
|
|
|
export default BlogPostPage;
|