All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m20s
Build & Deploy / 🏗️ Build (push) Successful in 4m22s
Build & Deploy / 🚀 Deploy (push) Successful in 23s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m16s
Build & Deploy / 🔔 Notify (push) Successful in 1s
- Fix PayloadRichText: migrate custom JSX converters to Lexical v3 nodesToJSX API - paragraph, heading, list, listitem, quote, link converters now use nodesToJSX - Resolves missing product texts since PayloadCMS migration - Fix mobile navigation: move overlay outside <header> to prevent fixed-position clipping - Header transform/backdrop-filter was containing the fixed overlay - Use bg-primary/95 backdrop-blur-3xl for premium blue background - Fix product image mobile layout: use md:-mt-32 responsive prefix - Negative margin only applies on md+ to avoid overlap on mobile - Improve mobile product page UX: - Breadcrumbs: flex-wrap, truncate, reduced separator spacing - Hero: reduced top padding pt-28 on mobile - Product image card: 4/3 aspect ratio and smaller padding on mobile - Section spacing: use responsive md: prefixes throughout - Data tables: 2-col grid on mobile, smaller card padding/radius - Tables: add right-edge scroll hint gradient on mobile
1048 lines
43 KiB
TypeScript
1048 lines
43 KiB
TypeScript
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
|
|
import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
|
|
import Image from 'next/image';
|
|
import { Suspense } from 'react';
|
|
|
|
// Import all custom React components that were previously mapped via Markdown
|
|
import StickyNarrative from '@/components/blog/StickyNarrative';
|
|
import ComparisonGrid from '@/components/blog/ComparisonGrid';
|
|
import VisualLinkPreview from '@/components/blog/VisualLinkPreview';
|
|
import TechnicalGrid from '@/components/blog/TechnicalGrid';
|
|
import HighlightBox from '@/components/blog/HighlightBox';
|
|
import AnimatedImage from '@/components/blog/AnimatedImage';
|
|
import ChatBubble from '@/components/blog/ChatBubble';
|
|
import PowerCTA from '@/components/blog/PowerCTA';
|
|
import { Callout } from '@/components/ui/Callout';
|
|
import Stats from '@/components/blog/Stats';
|
|
import SplitHeading from '@/components/blog/SplitHeading';
|
|
import ProductTabs from '@/components/ProductTabs';
|
|
import ProductTechnicalData from '@/components/ProductTechnicalData';
|
|
import ContactForm from '@/components/ContactForm';
|
|
import ContactMap from '@/components/ContactMap';
|
|
import Gallery from '@/components/team/Gallery';
|
|
import Reveal from '@/components/Reveal';
|
|
import { Badge, Container, Heading, Section, Card } from '@/components/ui';
|
|
import TrackedLink from '@/components/analytics/TrackedLink';
|
|
import { useLocale } from 'next-intl';
|
|
|
|
import HomeHero from '@/components/home/Hero';
|
|
import ProductCategories from '@/components/home/ProductCategories';
|
|
import WhatWeDo from '@/components/home/WhatWeDo';
|
|
import RecentPosts from '@/components/home/RecentPosts';
|
|
import Experience from '@/components/home/Experience';
|
|
import WhyChooseUs from '@/components/home/WhyChooseUs';
|
|
import MeetTheTeam from '@/components/home/MeetTheTeam';
|
|
import GallerySection from '@/components/home/GallerySection';
|
|
import VideoSection from '@/components/home/VideoSection';
|
|
import CTA from '@/components/home/CTA';
|
|
|
|
const jsxConverters: JSXConverters = {
|
|
...defaultJSXConverters,
|
|
// Let the default converters handle text nodes to preserve valid formatting
|
|
// Use div instead of p for paragraphs to allow nested block elements (like the lists above)
|
|
paragraph: ({ node, nodesToJSX }: any) => {
|
|
return (
|
|
<div className="mb-6 leading-relaxed text-text-secondary">
|
|
{nodesToJSX({ nodes: node.children })}
|
|
</div>
|
|
);
|
|
},
|
|
// Scale headings to prevent multiple H1s (H1 -> H2, etc) and style natively
|
|
heading: ({ node, nodesToJSX }: any) => {
|
|
const children = nodesToJSX({ nodes: node.children });
|
|
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, nodesToJSX }: any) => {
|
|
const children = nodesToJSX({ nodes: node.children });
|
|
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, nodesToJSX }: any) => {
|
|
const children = nodesToJSX({ nodes: node.children });
|
|
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: ({ node, nodesToJSX }: any) => {
|
|
const children = nodesToJSX({ nodes: node.children });
|
|
return (
|
|
<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, nodesToJSX }: any) => {
|
|
const children = nodesToJSX({ nodes: node.children });
|
|
// 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>
|
|
);
|
|
},
|
|
|
|
blocks: {
|
|
// ... preserved existing blocks ...
|
|
// Map the custom Payload Blocks created in src/payload/blocks to their React components
|
|
// Payload Lexical exposes blocks using the 'block-[slug]' pattern
|
|
stickyNarrative: ({ node }: any) => (
|
|
<StickyNarrative title={node.fields.title} items={node.fields.items} />
|
|
),
|
|
'block-stickyNarrative': ({ node }: any) => (
|
|
<StickyNarrative title={node.fields.title} items={node.fields.items} />
|
|
),
|
|
comparisonGrid: ({ node }: any) => (
|
|
<ComparisonGrid
|
|
title={node.fields.title}
|
|
leftLabel={node.fields.leftLabel}
|
|
rightLabel={node.fields.rightLabel}
|
|
items={node.fields.items}
|
|
/>
|
|
),
|
|
'block-comparisonGrid': ({ node }: any) => (
|
|
<ComparisonGrid
|
|
title={node.fields.title}
|
|
leftLabel={node.fields.leftLabel}
|
|
rightLabel={node.fields.rightLabel}
|
|
items={node.fields.items}
|
|
/>
|
|
),
|
|
visualLinkPreview: ({ node }: any) => (
|
|
<VisualLinkPreview
|
|
url={node.fields.url}
|
|
title={node.fields.title}
|
|
summary={node.fields.summary}
|
|
image={node.fields.image?.sizes?.card?.url || node.fields.image?.url}
|
|
/>
|
|
),
|
|
'block-visualLinkPreview': ({ node }: any) => (
|
|
<VisualLinkPreview
|
|
url={node.fields.url}
|
|
title={node.fields.title}
|
|
summary={node.fields.summary}
|
|
image={node.fields.image?.sizes?.card?.url || node.fields.image?.url}
|
|
/>
|
|
),
|
|
technicalGrid: ({ node }: any) => (
|
|
<TechnicalGrid title={node?.fields?.title} items={node?.fields?.items} />
|
|
),
|
|
'block-technicalGrid': ({ node }: any) => {
|
|
if (!node?.fields) return null;
|
|
return <TechnicalGrid title={node.fields.title} items={node.fields.items} />;
|
|
},
|
|
highlightBox: ({ node }: any) => (
|
|
<HighlightBox title={node.fields.title} color={node.fields.color}>
|
|
<RichText data={node.fields.content} converters={jsxConverters} />
|
|
</HighlightBox>
|
|
),
|
|
'block-highlightBox': ({ node }: any) => (
|
|
<HighlightBox title={node.fields.title} color={node.fields.color}>
|
|
<RichText data={node.fields.content} converters={jsxConverters} />
|
|
</HighlightBox>
|
|
),
|
|
animatedImage: ({ node }: any) => (
|
|
<AnimatedImage
|
|
src={node.fields.src}
|
|
alt={node.fields.alt}
|
|
width={node.fields.width}
|
|
height={node.fields.height}
|
|
/>
|
|
),
|
|
'block-animatedImage': ({ node }: any) => (
|
|
<AnimatedImage
|
|
src={node.fields.src}
|
|
alt={node.fields.alt}
|
|
width={node.fields.width}
|
|
height={node.fields.height}
|
|
/>
|
|
),
|
|
chatBubble: ({ node }: any) => (
|
|
<ChatBubble
|
|
author={node.fields.author}
|
|
avatar={node.fields.avatar}
|
|
role={node.fields.role}
|
|
align={node.fields.align}
|
|
>
|
|
<RichText data={node.fields.content} converters={jsxConverters} />
|
|
</ChatBubble>
|
|
),
|
|
'block-chatBubble': ({ node }: any) => (
|
|
<ChatBubble
|
|
author={node.fields.author}
|
|
avatar={node.fields.avatar}
|
|
role={node.fields.role}
|
|
align={node.fields.align}
|
|
>
|
|
<RichText data={node.fields.content} converters={jsxConverters} />
|
|
</ChatBubble>
|
|
),
|
|
powerCTA: ({ node }: any) => <PowerCTA locale={node.fields.locale} />,
|
|
'block-powerCTA': ({ node }: any) => <PowerCTA locale={node.fields.locale} />,
|
|
callout: ({ node }: any) => (
|
|
<Callout type={node.fields.type} title={node.fields.title}>
|
|
<RichText data={node.fields.content} converters={jsxConverters} />
|
|
</Callout>
|
|
),
|
|
'block-callout': ({ node }: any) => (
|
|
<Callout type={node.fields.type} title={node.fields.title}>
|
|
<RichText data={node.fields.content} converters={jsxConverters} />
|
|
</Callout>
|
|
),
|
|
stats: ({ node }: any) => <Stats stats={node.fields.stats} />,
|
|
'block-stats': ({ node }: any) => <Stats stats={node.fields.stats} />,
|
|
splitHeading: ({ node }: any) => (
|
|
<SplitHeading id={node.fields.id} level={node.fields.level}>
|
|
{node.fields.title}
|
|
</SplitHeading>
|
|
),
|
|
'block-splitHeading': ({ node }: any) => (
|
|
<SplitHeading id={node.fields.id} level={node.fields.level}>
|
|
{node.fields.title}
|
|
</SplitHeading>
|
|
),
|
|
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={
|
|
<ProductTechnicalData
|
|
data={{
|
|
technicalItems: node.fields.technicalItems,
|
|
voltageTables: node.fields.voltageTables,
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
{node.fields.content && <RichText data={node.fields.content} converters={jsxConverters} />}
|
|
</ProductTabs>
|
|
),
|
|
// ─── New Page Blocks ───────────────────────────────────────────
|
|
heroSection: ({ node }: any) => {
|
|
const f = node.fields;
|
|
const bgSrc = f.backgroundImage?.sizes?.card?.url || f.backgroundImage?.url;
|
|
return (
|
|
<Reveal>
|
|
<section
|
|
className={`relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark ${f.alignment === 'center' ? 'justify-center text-center' : ''}`}
|
|
>
|
|
{bgSrc && (
|
|
<>
|
|
<div className="absolute inset-0 z-0">
|
|
<Image
|
|
src={bgSrc}
|
|
alt={f.title}
|
|
fill
|
|
className="object-cover opacity-30 md:opacity-40"
|
|
style={{
|
|
objectPosition: `${f.backgroundImage?.focalX ?? 50}% ${f.backgroundImage?.focalY ?? 50}%`,
|
|
}}
|
|
sizes="100vw"
|
|
priority
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
|
</div>
|
|
</>
|
|
)}
|
|
<Container className={`relative z-10 ${f.alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
|
<div className={`max-w-4xl ${f.alignment === 'center' ? 'mx-auto' : ''}`}>
|
|
{f.badge && (
|
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
|
{f.badge}
|
|
</Badge>
|
|
)}
|
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
|
{f.title}
|
|
</Heading>
|
|
{f.subtitle && (
|
|
<p className="text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl">
|
|
{f.subtitle}
|
|
</p>
|
|
)}
|
|
{f.ctaLabel && f.ctaHref && (
|
|
<div className="mt-8">
|
|
<a
|
|
href={f.ctaHref}
|
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group"
|
|
>
|
|
{f.ctaLabel}
|
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
|
→
|
|
</span>
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Container>
|
|
</section>
|
|
</Reveal>
|
|
);
|
|
},
|
|
'block-heroSection': ({ node }: any) => {
|
|
const f = node.fields;
|
|
const bgSrc = f.backgroundImage?.sizes?.card?.url || f.backgroundImage?.url;
|
|
return (
|
|
<Reveal>
|
|
<section
|
|
className={`relative min-h-[50vh] md:min-h-[70vh] flex items-center pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden bg-primary-dark ${f.alignment === 'center' ? 'justify-center text-center' : ''}`}
|
|
>
|
|
{bgSrc && (
|
|
<>
|
|
<div className="absolute inset-0 z-0">
|
|
<Image
|
|
src={bgSrc}
|
|
alt={f.title}
|
|
fill
|
|
className="object-cover opacity-30 md:opacity-40"
|
|
style={{
|
|
objectPosition: `${f.backgroundImage?.focalX ?? 50}% ${f.backgroundImage?.focalY ?? 50}%`,
|
|
}}
|
|
sizes="100vw"
|
|
priority
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-b from-primary-dark/80 via-primary-dark/40 to-primary-dark/80" />
|
|
</div>
|
|
</>
|
|
)}
|
|
<Container className={`relative z-10 ${f.alignment === 'center' ? 'max-w-5xl' : ''}`}>
|
|
<div className={`max-w-4xl ${f.alignment === 'center' ? 'mx-auto' : ''}`}>
|
|
{f.badge && (
|
|
<Badge variant="saturated" className="mb-4 md:mb-8 shadow-lg">
|
|
{f.badge}
|
|
</Badge>
|
|
)}
|
|
<Heading level={1} className="text-white mb-4 md:mb-8">
|
|
{f.title}
|
|
</Heading>
|
|
{f.subtitle && (
|
|
<p className="text-lg md:text-2xl text-white/70 font-medium leading-relaxed max-w-2xl">
|
|
{f.subtitle}
|
|
</p>
|
|
)}
|
|
{f.ctaLabel && f.ctaHref && (
|
|
<div className="mt-8">
|
|
<a
|
|
href={f.ctaHref}
|
|
className="inline-flex items-center px-8 py-4 bg-accent text-primary-dark font-bold rounded-full hover:bg-white transition-all duration-300 group"
|
|
>
|
|
{f.ctaLabel}
|
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
|
→
|
|
</span>
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Container>
|
|
</section>
|
|
</Reveal>
|
|
);
|
|
},
|
|
teamProfile: ({ node }: any) => {
|
|
const f = node.fields;
|
|
const imgSrc = f.image?.sizes?.card?.url || f.image?.url;
|
|
const isDark = f.colorScheme === 'dark';
|
|
const isImageRight = f.layout === 'imageRight';
|
|
return (
|
|
<article className="relative bg-white overflow-hidden">
|
|
<div className="flex flex-col lg:flex-row">
|
|
<Reveal
|
|
className={`w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center relative ${isImageRight ? 'order-2 lg:order-1' : 'order-2'} ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-light text-saturated'}`}
|
|
>
|
|
<div className="relative z-10">
|
|
<Badge variant={isDark ? 'accent' : 'saturated'} className="mb-4 md:mb-8">
|
|
{f.role}
|
|
</Badge>
|
|
<Heading
|
|
level={2}
|
|
className={`${isDark ? 'text-white' : 'text-saturated'} mb-6 md:mb-10 text-3xl md:text-5xl`}
|
|
>
|
|
{f.name}
|
|
</Heading>
|
|
{f.quote && (
|
|
<div className="relative mb-6 md:mb-12">
|
|
<div
|
|
className={`absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 ${isDark ? 'bg-accent' : 'bg-saturated'} rounded-full`}
|
|
/>
|
|
<p
|
|
className={`text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 ${isDark ? 'text-white/90' : 'text-text-secondary'}`}
|
|
>
|
|
{f.quote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{f.description && (
|
|
<p
|
|
className={`text-base md:text-xl leading-relaxed mb-6 md:mb-12 max-w-xl ${isDark ? 'text-white/70' : 'text-text-secondary'}`}
|
|
>
|
|
{f.description}
|
|
</p>
|
|
)}
|
|
{f.linkedinUrl && (
|
|
<TrackedLink
|
|
href={f.linkedinUrl}
|
|
className={`inline-flex items-center px-8 py-4 font-bold rounded-full transition-all duration-300 group ${isDark ? 'bg-accent text-primary-dark hover:bg-white' : 'bg-saturated text-white hover:bg-primary'}`}
|
|
eventProperties={{
|
|
type: 'social_linkedin',
|
|
person: f.name,
|
|
location: 'team_page',
|
|
}}
|
|
>
|
|
{f.linkedinLabel || 'LinkedIn'}
|
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
|
→
|
|
</span>
|
|
</TrackedLink>
|
|
)}
|
|
</div>
|
|
</Reveal>
|
|
<Reveal
|
|
className={`w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden ${isImageRight ? 'order-1 lg:order-2' : 'order-1'}`}
|
|
>
|
|
{imgSrc && (
|
|
<>
|
|
<Image
|
|
src={imgSrc}
|
|
alt={f.name}
|
|
fill
|
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
|
style={{
|
|
objectPosition: `${f.image?.focalX ?? 50}% ${f.image?.focalY ?? 50}%`,
|
|
}}
|
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
/>
|
|
<div
|
|
className={`absolute inset-0 ${isDark ? 'bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent' : 'bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent'}`}
|
|
/>
|
|
</>
|
|
)}
|
|
</Reveal>
|
|
</div>
|
|
</article>
|
|
);
|
|
},
|
|
'block-teamProfile': ({ node }: any) => {
|
|
const f = node.fields;
|
|
const imgSrc = f.image?.sizes?.card?.url || f.image?.url;
|
|
const isDark = f.colorScheme === 'dark';
|
|
const isImageRight = f.layout === 'imageRight';
|
|
return (
|
|
<article className="relative bg-white overflow-hidden">
|
|
<div className="flex flex-col lg:flex-row">
|
|
<Reveal
|
|
className={`w-full lg:w-1/2 p-6 md:p-24 lg:p-32 flex flex-col justify-center relative ${isImageRight ? 'order-2 lg:order-1' : 'order-2'} ${isDark ? 'bg-primary-dark text-white' : 'bg-neutral-light text-saturated'}`}
|
|
>
|
|
<div className="relative z-10">
|
|
<Badge variant={isDark ? 'accent' : 'saturated'} className="mb-4 md:mb-8">
|
|
{f.role}
|
|
</Badge>
|
|
<Heading
|
|
level={2}
|
|
className={`${isDark ? 'text-white' : 'text-saturated'} mb-6 md:mb-10 text-3xl md:text-5xl`}
|
|
>
|
|
{f.name}
|
|
</Heading>
|
|
{f.quote && (
|
|
<div className="relative mb-6 md:mb-12">
|
|
<div
|
|
className={`absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 ${isDark ? 'bg-accent' : 'bg-saturated'} rounded-full`}
|
|
/>
|
|
<p
|
|
className={`text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 ${isDark ? 'text-white/90' : 'text-text-secondary'}`}
|
|
>
|
|
{f.quote}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{f.description && (
|
|
<p
|
|
className={`text-base md:text-xl leading-relaxed mb-6 md:mb-12 max-w-xl ${isDark ? 'text-white/70' : 'text-text-secondary'}`}
|
|
>
|
|
{f.description}
|
|
</p>
|
|
)}
|
|
{f.linkedinUrl && (
|
|
<TrackedLink
|
|
href={f.linkedinUrl}
|
|
className={`inline-flex items-center px-8 py-4 font-bold rounded-full transition-all duration-300 group ${isDark ? 'bg-accent text-primary-dark hover:bg-white' : 'bg-saturated text-white hover:bg-primary'}`}
|
|
eventProperties={{
|
|
type: 'social_linkedin',
|
|
person: f.name,
|
|
location: 'team_page',
|
|
}}
|
|
>
|
|
{f.linkedinLabel || 'LinkedIn'}
|
|
<span className="ml-3 transition-transform group-hover:translate-x-2">
|
|
→
|
|
</span>
|
|
</TrackedLink>
|
|
)}
|
|
</div>
|
|
</Reveal>
|
|
<Reveal
|
|
className={`w-full lg:w-1/2 relative min-h-[400px] md:min-h-[600px] lg:min-h-screen overflow-hidden ${isImageRight ? 'order-1 lg:order-2' : 'order-1'}`}
|
|
>
|
|
{imgSrc && (
|
|
<>
|
|
<Image
|
|
src={imgSrc}
|
|
alt={f.name}
|
|
fill
|
|
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
|
|
style={{
|
|
objectPosition: `${f.image?.focalX ?? 50}% ${f.image?.focalY ?? 50}%`,
|
|
}}
|
|
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
/>
|
|
<div
|
|
className={`absolute inset-0 ${isDark ? 'bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent' : 'bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent'}`}
|
|
/>
|
|
</>
|
|
)}
|
|
</Reveal>
|
|
</div>
|
|
</article>
|
|
);
|
|
},
|
|
contactSection: ({ node }: any) => {
|
|
const f = node.fields;
|
|
return (
|
|
<Section className="bg-neutral-light py-12 md:py-28">
|
|
<Container>
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
|
{f.showForm && (
|
|
<div className="lg:col-span-7">
|
|
<Suspense
|
|
fallback={
|
|
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl" />
|
|
}
|
|
>
|
|
<ContactForm />
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Container>
|
|
{f.showMap && (
|
|
<section className="mt-12 h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
|
<Suspense
|
|
fallback={<div className="h-full w-full bg-neutral-medium animate-pulse" />}
|
|
>
|
|
<ContactMap
|
|
address="Raiffeisenstraße 22\n73630 Remshalden"
|
|
lat={48.8144}
|
|
lng={9.4144}
|
|
/>
|
|
</Suspense>
|
|
</section>
|
|
)}
|
|
</Section>
|
|
);
|
|
},
|
|
'block-contactSection': ({ node }: any) => {
|
|
const f = node.fields;
|
|
return (
|
|
<Section className="bg-neutral-light py-12 md:py-28">
|
|
<Container>
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 md:gap-16">
|
|
{f.showForm && (
|
|
<div className="lg:col-span-7">
|
|
<Suspense
|
|
fallback={
|
|
<div className="animate-pulse bg-neutral-medium h-96 rounded-2xl md:rounded-3xl" />
|
|
}
|
|
>
|
|
<ContactForm />
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Container>
|
|
{f.showMap && (
|
|
<section className="mt-12 h-[300px] md:h-[500px] bg-neutral-medium relative overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
|
<Suspense
|
|
fallback={<div className="h-full w-full bg-neutral-medium animate-pulse" />}
|
|
>
|
|
<ContactMap
|
|
address="Raiffeisenstraße 22\n73630 Remshalden"
|
|
lat={48.8144}
|
|
lng={9.4144}
|
|
/>
|
|
</Suspense>
|
|
</section>
|
|
)}
|
|
</Section>
|
|
);
|
|
},
|
|
imageGallery: ({ node }: any) => <Gallery />,
|
|
'block-imageGallery': ({ node }: any) => <Gallery />,
|
|
categoryGrid: ({ node }: any) => {
|
|
const cats = node.fields.categories || [];
|
|
return (
|
|
<Section className="bg-neutral-light relative">
|
|
<Container>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
|
{cats.map((cat: any, idx: number) => {
|
|
const imgSrc = cat.image?.sizes?.card?.url || cat.image?.url;
|
|
const iconSrc = cat.icon?.url;
|
|
return (
|
|
<Reveal key={idx} delay={idx * 100}>
|
|
<a href={cat.href} className="group block">
|
|
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
|
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
|
{imgSrc && (
|
|
<Image
|
|
src={imgSrc}
|
|
alt={cat.title}
|
|
fill
|
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
|
style={{
|
|
objectPosition: `${cat.image?.focalX ?? 50}% ${cat.image?.focalY ?? 50}%`,
|
|
}}
|
|
sizes="(max-width: 768px) 100vw, 50vw"
|
|
/>
|
|
)}
|
|
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
|
{iconSrc && (
|
|
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20 shadow-2xl transition-all duration-500 group-hover:scale-110 group-hover:bg-white/20">
|
|
<Image
|
|
src={iconSrc}
|
|
alt=""
|
|
width={24}
|
|
height={24}
|
|
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
|
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight">
|
|
{cat.title}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
<div className="p-5 md:p-10">
|
|
{cat.description && (
|
|
<p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8 line-clamp-2 md:line-clamp-none">
|
|
{cat.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
|
|
<span className="border-b-2 border-saturated/10 group-hover:border-accent-dark transition-colors pb-1">
|
|
{cat.ctaLabel || 'View Products'}
|
|
</span>
|
|
<div className="ml-3 md:ml-4 w-8 h-8 md:w-10 md:h-10 rounded-full bg-primary-light flex items-center justify-center text-saturated group-hover:bg-accent group-hover:text-primary-dark transition-all duration-300 shadow-sm">
|
|
<svg
|
|
className="w-4 h-4 md:w-5 md:h-5 transition-transform group-hover:translate-x-1"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</a>
|
|
</Reveal>
|
|
);
|
|
})}
|
|
</div>
|
|
</Container>
|
|
</Section>
|
|
);
|
|
},
|
|
'block-categoryGrid': ({ node }: any) => {
|
|
const cats = node.fields.categories || [];
|
|
return (
|
|
<Section className="bg-neutral-light relative">
|
|
<Container>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8 lg:gap-12">
|
|
{cats.map((cat: any, idx: number) => {
|
|
const imgSrc = cat.image?.sizes?.card?.url || cat.image?.url;
|
|
const iconSrc = cat.icon?.url;
|
|
return (
|
|
<Reveal key={idx} delay={idx * 100}>
|
|
<a href={cat.href} className="group block">
|
|
<Card className="h-full border-none shadow-sm hover:shadow-2xl transition-all duration-500 rounded-[24px] md:rounded-[48px] overflow-hidden bg-white active:scale-[0.98]">
|
|
<div className="relative h-[200px] md:h-[400px] overflow-hidden">
|
|
{imgSrc && (
|
|
<Image
|
|
src={imgSrc}
|
|
alt={cat.title}
|
|
fill
|
|
className="object-cover transition-transform duration-1000 group-hover:scale-105"
|
|
style={{
|
|
objectPosition: `${cat.image?.focalX ?? 50}% ${cat.image?.focalY ?? 50}%`,
|
|
}}
|
|
sizes="(max-width: 768px) 100vw, 50vw"
|
|
/>
|
|
)}
|
|
<div className="absolute inset-0 image-overlay-gradient opacity-80 group-hover:opacity-90 transition-opacity duration-500" />
|
|
{iconSrc && (
|
|
<div className="absolute top-3 right-3 md:top-8 md:right-8 w-10 h-10 md:w-20 md:h-20 bg-white/10 backdrop-blur-md rounded-xl md:rounded-[24px] flex items-center justify-center border border-white/20">
|
|
<Image
|
|
src={iconSrc}
|
|
alt=""
|
|
width={24}
|
|
height={24}
|
|
className="w-6 h-6 md:w-12 md:h-12 brightness-0 invert opacity-80"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="absolute bottom-4 left-4 md:bottom-10 md:left-10 right-4 md:right-10">
|
|
<h2 className="text-xl md:text-4xl font-bold text-white leading-tight">
|
|
{cat.title}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
<div className="p-5 md:p-10">
|
|
{cat.description && (
|
|
<p className="text-text-secondary text-sm md:text-lg leading-relaxed mb-4 md:mb-8">
|
|
{cat.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center text-saturated font-bold text-base md:text-lg group-hover:text-accent-dark transition-colors">
|
|
<span>{cat.ctaLabel || 'View Products'}</span>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</a>
|
|
</Reveal>
|
|
);
|
|
})}
|
|
</div>
|
|
</Container>
|
|
</Section>
|
|
);
|
|
},
|
|
manifestoGrid: ({ node }: any) => {
|
|
const f = node.fields;
|
|
return (
|
|
<Section className="bg-white text-primary py-16 md:py-28">
|
|
<Container>
|
|
<div className="sticky-narrative-container">
|
|
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
|
|
<div className="lg:sticky lg:top-32">
|
|
{f.title && (
|
|
<Heading level={2} subtitle={f.subtitle}>
|
|
{f.title}
|
|
</Heading>
|
|
)}
|
|
{f.tagline && (
|
|
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
|
{f.tagline}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
|
{(f.items || []).map((item: any, idx: number) => (
|
|
<li
|
|
key={idx}
|
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98]"
|
|
>
|
|
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
|
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
|
0{idx + 1}
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
|
{item.title}
|
|
</h3>
|
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
|
{item.description}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</Container>
|
|
</Section>
|
|
);
|
|
},
|
|
'block-manifestoGrid': ({ node }: any) => {
|
|
const f = node.fields;
|
|
return (
|
|
<Section className="bg-white text-primary py-16 md:py-28">
|
|
<Container>
|
|
<div className="sticky-narrative-container">
|
|
<div className="sticky-narrative-sidebar mb-8 lg:mb-0">
|
|
<div className="lg:sticky lg:top-32">
|
|
{f.title && (
|
|
<Heading level={2} subtitle={f.subtitle}>
|
|
{f.title}
|
|
</Heading>
|
|
)}
|
|
{f.tagline && (
|
|
<p className="text-base md:text-xl text-text-secondary leading-relaxed">
|
|
{f.tagline}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ul className="sticky-narrative-content grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-10 list-none p-0 m-0">
|
|
{(f.items || []).map((item: any, idx: number) => (
|
|
<li
|
|
key={idx}
|
|
className="p-6 md:p-10 bg-neutral-light rounded-2xl md:rounded-[40px] border border-transparent hover:border-accent hover:bg-white hover:shadow-2xl transition-all duration-500 group active:scale-[0.98]"
|
|
>
|
|
<div className="w-10 h-10 md:w-16 md:h-16 bg-white rounded-xl md:rounded-2xl flex items-center justify-center mb-4 md:mb-8 shadow-sm group-hover:bg-accent transition-colors duration-500">
|
|
<span className="text-primary font-extrabold text-lg md:text-2xl group-hover:text-primary-dark">
|
|
0{idx + 1}
|
|
</span>
|
|
</div>
|
|
<h3 className="text-lg md:text-2xl font-bold mb-2 md:mb-4 text-primary">
|
|
{item.title}
|
|
</h3>
|
|
<p className="text-sm md:text-lg text-text-secondary leading-relaxed">
|
|
{item.description}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</Container>
|
|
</Section>
|
|
);
|
|
},
|
|
homeHero: ({ node }: any) => {
|
|
console.log('[PayloadRichText] Rendering homeHero block');
|
|
return <HomeHero data={node?.fields} />;
|
|
},
|
|
'block-homeHero': ({ node }: any) => {
|
|
console.log('[PayloadRichText] Rendering block-homeHero block');
|
|
return <HomeHero data={node?.fields} />;
|
|
},
|
|
homeProductCategories: ({ node }: any) => (
|
|
<Reveal>
|
|
<ProductCategories data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeProductCategories': ({ node }: any) => (
|
|
<Reveal>
|
|
<ProductCategories data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeWhatWeDo: ({ node }: any) => (
|
|
<Reveal>
|
|
<WhatWeDo data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeWhatWeDo': ({ node }: any) => (
|
|
<Reveal>
|
|
<WhatWeDo data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeExperience: ({ node }: any) => (
|
|
<Reveal>
|
|
<Experience data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeExperience': ({ node }: any) => (
|
|
<Reveal>
|
|
<Experience data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeWhyChooseUs: ({ node }: any) => (
|
|
<Reveal>
|
|
<WhyChooseUs data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeWhyChooseUs': ({ node }: any) => (
|
|
<Reveal>
|
|
<WhyChooseUs data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeMeetTheTeam: ({ node }: any) => (
|
|
<Reveal>
|
|
<MeetTheTeam data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeMeetTheTeam': ({ node }: any) => (
|
|
<Reveal>
|
|
<MeetTheTeam data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeGallery: ({ node }: any) => (
|
|
<Reveal>
|
|
<GallerySection data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeGallery': ({ node }: any) => (
|
|
<Reveal>
|
|
<GallerySection data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeVideo: ({ node }: any) => (
|
|
<Reveal>
|
|
<VideoSection data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeVideo': ({ node }: any) => (
|
|
<Reveal>
|
|
<VideoSection data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
homeCTA: ({ node }: any) => (
|
|
<Reveal className="content-visibility-auto">
|
|
<CTA data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
'block-homeCTA': ({ node }: any) => (
|
|
<Reveal className="content-visibility-auto">
|
|
<CTA data={node?.fields} />
|
|
</Reveal>
|
|
),
|
|
},
|
|
// Custom converter for the Payload "upload" Lexical node (Media collection)
|
|
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes
|
|
upload: ({ node }: any) => {
|
|
// Attempt to extract the highly optimized 'card' generated size from Payload, fallback to raw url
|
|
let src = node?.value?.sizes?.card?.url || node?.value?.url;
|
|
const alt = node?.value?.alt || 'Blog Post Media';
|
|
|
|
if (!src) return null;
|
|
|
|
// Strip legacy imgproxy query parameters (e.g. ?ar=16:9) that crash Next.js 14+ localPatterns
|
|
if (src.includes('?')) {
|
|
src = src.split('?')[0];
|
|
}
|
|
|
|
// Fallback dimensions if unmapped or loading from raw
|
|
const width = node?.value?.sizes?.card?.width || 800;
|
|
const height = node?.value?.sizes?.card?.height || 600;
|
|
|
|
return (
|
|
<figure className="my-8 md:my-12 relative w-full rounded-2xl md:rounded-[32px] overflow-hidden shadow-xl md:shadow-2xl">
|
|
<Image
|
|
src={src}
|
|
alt={alt}
|
|
width={width}
|
|
height={height}
|
|
className="w-full object-cover transition-transform duration-700 hover:scale-105"
|
|
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 75vw, 60vw"
|
|
style={{
|
|
objectPosition: `${node?.value?.focalX ?? 50}% ${node?.value?.focalY ?? 50}%`,
|
|
}}
|
|
/>
|
|
{node?.value?.caption && (
|
|
<figcaption className="p-4 bg-neutral-dark text-white/80 text-sm text-center italic border-t border-white/10">
|
|
{node.value.caption}
|
|
</figcaption>
|
|
)}
|
|
</figure>
|
|
);
|
|
},
|
|
};
|
|
|
|
export default function PayloadRichText({
|
|
data,
|
|
className = 'article-content max-w-none',
|
|
}: {
|
|
data: any;
|
|
className?: string;
|
|
}) {
|
|
const locale = useLocale();
|
|
|
|
if (!data) return null;
|
|
|
|
if (data.root?.children?.length > 0) {
|
|
console.log('[PayloadRichText DEBUG] received children', data.root.children.length);
|
|
}
|
|
|
|
const dynamicConverters: JSXConverters = {
|
|
...jsxConverters,
|
|
blocks: {
|
|
...jsxConverters.blocks,
|
|
homeRecentPosts: () => (
|
|
<Reveal>
|
|
<RecentPosts locale={locale} />
|
|
</Reveal>
|
|
),
|
|
'block-homeRecentPosts': () => (
|
|
<Reveal>
|
|
<RecentPosts locale={locale} />
|
|
</Reveal>
|
|
),
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
<RichText data={data} converters={dynamicConverters} />
|
|
</div>
|
|
);
|
|
}
|