Merge main into feature/ai-search and resolve conflicts

This commit is contained in:
2026-02-27 18:45:34 +01:00
32 changed files with 1433 additions and 554 deletions

View File

@@ -49,11 +49,11 @@ const jsxConverters: JSXConverters = {
const startsWithDash = text.trimStart().startsWith('- ');
const prefix = startsWithDash ? null : parts.shift();
return (
<>
<div className="my-4">
{prefix && (
<span dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
<div dangerouslySetInnerHTML={prefix.includes('<') ? { __html: prefix } : undefined}>
{!prefix.includes('<') ? prefix : undefined}
</span>
</div>
)}
<ul className="list-disc pl-6 my-4 space-y-2">
{parts.map((item: string, i: number) => {
@@ -64,7 +64,7 @@ const jsxConverters: JSXConverters = {
return <li key={i}>{cleanItem}</li>;
})}
</ul>
</>
</div>
);
}
@@ -118,14 +118,92 @@ const jsxConverters: JSXConverters = {
);
}
if (node.format === 1) return <strong>{text}</strong>;
if (node.format === 2) return <em>{text}</em>;
return <span>{text}</span>;
if (node.format === 1) return <strong key="bold">{text}</strong>;
if (node.format === 2) return <em key="italic">{text}</em>;
return <span key="text">{text}</span>;
},
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
paragraph: ({ children }: any) => (
<div className="mb-6 leading-relaxed text-text-secondary">{children}</div>
),
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
heading: ({ node, children }: any) => {
const tag = node?.tag;
if (tag === 'h1')
return (
<h2 className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary">{children}</h2>
);
if (tag === 'h2')
return (
<h3 className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary">{children}</h3>
);
if (tag === 'h3')
return (
<h4 className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary">{children}</h4>
);
if (tag === 'h4')
return (
<h5 className="text-lg md:text-xl font-bold mt-6 mb-4 text-text-primary">{children}</h5>
);
if (tag === 'h5')
return (
<h6 className="text-base md:text-lg font-bold mt-6 mb-4 text-text-primary">{children}</h6>
);
return <h6 className="text-base font-bold mt-6 mb-4 text-text-primary">{children}</h6>;
},
list: ({ node, children }: any) => {
if (node?.listType === 'number') {
return (
<ol className="list-decimal pl-6 my-6 space-y-2 text-text-secondary marker:text-primary marker:font-bold">
{children}
</ol>
);
}
if (node?.listType === 'check') {
return <ul className="list-none pl-0 my-6 space-y-2 text-text-secondary">{children}</ul>;
}
return (
<ul className="list-disc pl-6 my-6 space-y-2 text-text-secondary marker:text-primary">
{children}
</ul>
);
},
listitem: ({ node, children }: any) => {
if (node?.checked != null) {
return (
<li className="flex items-center gap-3 mb-2 leading-relaxed">
<input
type="checkbox"
checked={node.checked}
readOnly
className="mt-1 w-4 h-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<span>{children}</span>
</li>
);
}
return <li className="mb-2 leading-relaxed">{children}</li>;
},
quote: ({ children }: any) => (
<blockquote className="border-l-4 border-primary bg-primary/5 rounded-r-2xl pl-6 py-4 my-8 italic text-text-secondary shadow-sm">
{children}
</blockquote>
),
link: ({ node, children }: any) => {
// Handling Payload CMS link nodes
const href = node?.fields?.url || node?.url || '#';
const newTab = node?.fields?.newTab || node?.newTab;
return (
<a
href={href}
target={newTab ? '_blank' : undefined}
rel={newTab ? 'noopener noreferrer' : undefined}
className="text-primary no-underline hover:underline font-medium transition-colors"
>
{children}
</a>
);
},
// Scale headings to prevent multiple H1s (H1 -> H2, etc)
h1: ({ children }: any) => <h2 className="text-3xl md:text-4xl font-bold my-6">{children}</h2>,
h2: ({ children }: any) => <h3 className="text-2xl md:text-3xl font-bold my-5">{children}</h3>,
h3: ({ children }: any) => <h4 className="text-xl md:text-2xl font-bold my-4">{children}</h4>,
blocks: {
// ... preserved existing blocks ...
@@ -170,10 +248,10 @@ const jsxConverters: JSXConverters = {
/>
),
technicalGrid: ({ node }: any) => (
<TechnicalGrid title={node.fields.title} items={node.fields.items} />
<TechnicalGrid title={node?.fields?.title} items={node?.fields?.items} />
),
'block-technicalGrid': ({ node }: any) => {
console.log('[PayloadRichText] Rendering block-technicalGrid:', node.fields.title);
if (!node?.fields) return null;
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
},
highlightBox: ({ node }: any) => (
@@ -246,20 +324,23 @@ const jsxConverters: JSXConverters = {
{node.fields.title}
</SplitHeading>
),
productTabs: ({ node }: any) => (
<ProductTabs
technicalData={
<ProductTechnicalData
data={{
technicalItems: node.fields.technicalItems,
voltageTables: node.fields.voltageTables,
}}
/>
}
>
<></>
</ProductTabs>
),
productTabs: ({ node }: any) => {
if (!node?.fields) return null;
return (
<ProductTabs
technicalData={
<ProductTechnicalData
data={{
technicalItems: node.fields.technicalItems,
voltageTables: node.fields.voltageTables,
}}
/>
}
>
<></>
</ProductTabs>
);
},
'block-productTabs': ({ node }: any) => (
<ProductTabs
technicalData={

View File

@@ -41,10 +41,8 @@ export default async function RelatedProducts({
];
const catFileSlug =
categorySlugs.find((slug) => {
const key = slug.replace(/-cables$/, '').replace(/-([a-z])/g, (g) => g[1].toUpperCase());
const title = t(`categories.${key}.title`);
return product.frontmatter.categories.some(
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug || cat === title,
(cat) => cat.toLowerCase().replace(/\s+/g, '-') === slug,
);
}) || 'low-voltage-cables';

View File

@@ -0,0 +1,26 @@
'use client';
import { useEffect } from 'react';
import { useAnalytics } from './useAnalytics';
import { AnalyticsEvents } from './analytics-events';
export default function ClientNotFoundTracker({ path }: { path: string }) {
const { trackEvent } = useAnalytics();
useEffect(() => {
trackEvent(AnalyticsEvents.ERROR, {
type: '404_not_found',
path,
});
import('@sentry/nextjs').then((Sentry) => {
Sentry.withScope((scope) => {
scope.setTag('status_code', '404');
scope.setTag('path', path);
Sentry.captureMessage(`Route Not Found: ${path}`, 'warning');
});
});
}, [trackEvent, path]);
return null;
}

View File

@@ -37,12 +37,26 @@ export default function Hero({ data }: { data?: any }) {
className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-4xl sm:text-5xl md:text-7xl font-extrabold [text-shadow:_-2px_-2px_0_#002b49,_2px_-2px_0_#002b49,_-2px_2px_0_#002b49,_2px_2px_0_#002b49,_-2px_0_0_#002b49,_2px_0_0_#002b49,_0_-2px_0_#002b49,_0_2px_0_#002b49]"
>
{data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<green>/g, '<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">').replace(/<\/green>/g, '</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>') }} />
<span
dangerouslySetInnerHTML={{
__html: data.title
.replace(
/<green>/g,
'<span class="relative inline-block"><span class="relative z-10 text-accent italic inline-block">',
)
.replace(
/<\/green>/g,
'</span><div class="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both" style="animation-delay: 500ms;"><Scribble variant="circle" /></div></span>',
),
}}
/>
) : (
t.rich('title', {
green: (chunks) => (
<span className="relative inline-block">
<span className="relative z-10 text-accent italic inline-block">{chunks}</span>
<span className="relative z-10 text-accent italic inline-block">
{chunks}
</span>
<div
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block absolute -z-10 animate-in fade-in zoom-in-0 duration-1000 ease-out fill-mode-both"
style={{ animationDelay: '500ms' }}

View File

@@ -3,7 +3,7 @@ import Image from 'next/image';
import Link from 'next/link';
import { getAllPosts } from '@/lib/blog';
import { getTranslations } from 'next-intl/server';
import { Section, Container, Heading, Card, Badge } from '../../components/ui';
import { Section, Container, Heading } from '../../components/ui';
interface RecentPostsProps {
locale: string;
@@ -13,7 +13,7 @@ interface RecentPostsProps {
export default async function RecentPosts({ locale, data }: RecentPostsProps) {
const t = await getTranslations('Blog');
const posts = await getAllPosts(locale);
const recentPosts = posts.slice(0, 3);
const recentPosts = posts.slice(0, 4);
if (recentPosts.length === 0) return null;
@@ -21,9 +21,9 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
const subtitle = data?.subtitle || t('latestNews');
return (
<Section className="bg-neutral py-16 md:py-24">
<Container>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between mb-12 md:mb-16 gap-6">
<Section className="bg-neutral-light py-0 md:py-0 lg:py-0">
<Container className="py-12 md:py-16">
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6">
<Heading level={2} subtitle={subtitle} className="mb-0 text-primary">
{title}
</Heading>
@@ -35,74 +35,73 @@ export default async function RecentPosts({ locale, data }: RecentPostsProps) {
<span className="ml-2 transition-transform group-hover:translate-x-2">&rarr;</span>
</Link>
</div>
<ul className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-10 list-none p-0 m-0">
{recentPosts.map((post, idx) => (
<li key={`${post.slug}-${idx}`}>
<Link href={`/${locale}/blog/${post.slug}`} className="group block h-full">
<Card
tag="article"
className="h-full flex flex-col border-none shadow-lg hover:shadow-2xl transition-all duration-500 rounded-3xl"
>
{post.frontmatter.featuredImage && (
<div className="relative h-64 overflow-hidden">
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title}
fill
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, 33vw"
loading="lazy"
/>
<div className="absolute inset-0 image-overlay-gradient opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{post.frontmatter.category && (
<Badge variant="accent" className="absolute top-4 left-4 shadow-md">
{post.frontmatter.category}
</Badge>
)}
</div>
)}
<div className="p-6 md:p-8 flex flex-col flex-grow">
<div className="text-xs md:text-sm font-medium text-text-light mb-3 md:mb-4 flex items-center gap-2">
<span className="w-6 md:w-8 h-px bg-neutral-medium" />
<time dateTime={post.frontmatter.date}>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
</div>
<h3 className="text-lg md:text-xl font-bold text-primary group-hover:text-accent-dark transition-colors line-clamp-2 mb-4 md:mb-6 leading-tight">
{post.frontmatter.title}
</h3>
<div className="mt-auto flex items-center text-primary font-bold group-hover:underline decoration-2 underline-offset-4">
{t('readMore')}
<svg
className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8l4 4m0 0l-4 4m4-4H3"
/>
</svg>
</div>
</div>
</Card>
</Link>
</li>
))}
</ul>
</Container>
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 m-0 p-0 list-none">
{recentPosts.map((post, idx) => (
<li key={`${post.slug}-${idx}`} className="block">
<Link
href={`/${locale}/blog/${post.slug}`}
className="group block relative h-[400px] md:h-[500px] lg:h-[650px] overflow-hidden border-b md:border-b-0 md:border-r border-white/10 last:border-0 focus:outline-none"
>
{post.frontmatter.featuredImage && (
<>
<Image
src={post.frontmatter.featuredImage.split('?')[0]}
alt={post.frontmatter.title}
fill
className="object-cover transition-transform duration-1000 group-hover:scale-110"
style={{
objectPosition: `${post.frontmatter.focalX ?? 50}% ${post.frontmatter.focalY ?? 50}%`,
}}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 25vw"
loading="lazy"
/>
<div className="absolute inset-0 bg-primary-dark/40 group-hover:bg-primary-dark/60 transition-all duration-500" />
</>
)}
<div className="absolute inset-0 p-8 md:p-10 flex flex-col justify-end text-white">
<div className="mb-4 md:mb-6 transform transition-all duration-500 group-hover:-translate-y-4">
<div className="flex flex-wrap items-center gap-2 mb-4">
{post.frontmatter.category && (
<span className="px-3 py-1 bg-accent text-primary-dark rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider shadow-sm">
{post.frontmatter.category}
</span>
)}
<time
dateTime={post.frontmatter.date}
suppressHydrationWarning
className="px-3 py-1 text-white/80 text-[10px] md:text-xs font-bold uppercase tracking-widest border border-white/20 rounded-full bg-white/10 backdrop-blur-md"
>
{new Date(post.frontmatter.date).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
{(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && (
<span className="px-2 py-0.5 border border-white/40 text-white/90 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold bg-neutral-dark/40 shadow-sm">
Draft Preview
</span>
)}
</div>
<h3 className="text-xl md:text-2xl font-bold mb-2 md:mb-4 leading-tight drop-shadow-md">
{post.frontmatter.title}
</h3>
</div>
<div className="flex items-center text-accent font-bold tracking-wider uppercase text-xs md:text-xs opacity-100 md:opacity-0 group-hover:opacity-100 transition-all duration-500 delay-100">
{t('readMore')}{' '}
<span className="ml-2 transition-transform group-hover:translate-x-2">
&rarr;
</span>
</div>
</div>
</Link>
</li>
))}
</ul>
</Section>
);
}