Compare commits

...

9 Commits

Author SHA1 Message Date
57b6963efe chore: release v2.2.12
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 9s
Build & Deploy / 🧪 QA (push) Successful in 2m44s
Build & Deploy / 🏗️ Build (push) Successful in 4m54s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m41s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-02 10:23:08 +01:00
1a136540d0 feat: implement email and phone obfuscation with Payload inline blocks
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Has started running
Build & Deploy / 🏗️ Build (push) Has been cancelled
Build & Deploy / 🚀 Deploy (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
Build & Deploy / 🔔 Notify (push) Has been cancelled
2026-03-02 10:22:52 +01:00
92bc88dfbd style: design refinements — reduce title/heading sizes, remove Scribble decorations, add image quality
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 12s
Build & Deploy / 🧪 QA (push) Successful in 3m19s
Build & Deploy / 🏗️ Build (push) Successful in 5m26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m4s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / call-qa-workflow (push) Failing after 43s
- Hero title: text-7xl → text-5xl, removed text-shadow
- Removed all Scribble decorative strokes from Hero, VideoSection, products page
- PayloadRichText headings reduced by one size step
- Team page: harmonized Michael/Klaus heading sizes (both text-4xl)
- Product overview: removed min-height from hero, reduced CTA heading
- Added quality={100} to team photos, Experience and MeetTheTeam backgrounds
- Cleaned up unused Scribble imports
2026-03-02 01:13:28 +01:00
fb3ec6e10a fix(blog): preserve newlines in Lexical text nodes as <br> for proper list rendering
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m28s
Build & Deploy / 🏗️ Build (push) Successful in 6m15s
Build & Deploy / 🚀 Deploy (push) Failing after 7s
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 1s
test(og): use real blog slug from sitemap instead of hardcoded hello-world

chore(release): bump version to 2.2.10
2026-03-01 23:21:35 +01:00
acf642d7e6 fix(blog): prioritize original img url over small card size for sharp headers
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m32s
Build & Deploy / 🏗️ Build (push) Successful in 5m3s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m10s
Build & Deploy / 🔔 Notify (push) Successful in 1s
chore(release): bump version to 2.2.9
2026-03-01 22:39:51 +01:00
d5da2a91c8 test: improve E2E form error logging
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🧪 QA (push) Successful in 2m9s
Build & Deploy / 🏗️ Build (push) Successful in 3m18s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 5m18s
Build & Deploy / 🔔 Notify (push) Successful in 1s
2026-03-01 17:45:41 +01:00
ebe664f984 fix(qa): resolve testing gatekeeper auth & htmlWYSIWYG errors
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 2m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m53s
Build & Deploy / 🚀 Deploy (push) Successful in 24s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 5m1s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-03-01 16:32:58 +01:00
9c7324ee92 fix(blog): restore image optimization but force quality 100 for fidelity
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Successful in 3m0s
Build & Deploy / 🏗️ Build (push) Successful in 5m53s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 6m47s
Build & Deploy / 🔔 Notify (push) Successful in 2s
chore(release): bump version to 2.2.8
2026-03-01 16:13:05 +01:00
0c8d9ea669 fix(e2e): await hydration before form submits, skip cleanup on 403
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m27s
Build & Deploy / 🏗️ Build (push) Successful in 4m49s
Build & Deploy / 🚀 Deploy (push) Successful in 14s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
fix(blog): bypass image optimization for post feature image

chore(release): bump version to 2.2.7
2026-03-01 16:03:23 +01:00
21 changed files with 376 additions and 108 deletions

View File

@@ -14,4 +14,4 @@ jobs:
secrets: secrets:
GOTIFY_URL: ${{ secrets.GOTIFY_URL }} GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }} GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }} GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || 'lassmichrein' }}

View File

@@ -17,6 +17,10 @@
"valid-id": "off", "valid-id": "off",
"element-required-attributes": "off", "element-required-attributes": "off",
"attribute-empty-style": "off", "attribute-empty-style": "off",
"element-permitted-content": "off" "element-permitted-content": "off",
"element-required-content": "off",
"element-permitted-parent": "off",
"no-implicit-close": "off",
"close-order": "off"
} }
} }

View File

@@ -98,6 +98,7 @@ export default async function BlogPost({ params }: BlogPostProps) {
alt={post.frontmatter.title} alt={post.frontmatter.title}
fill fill
priority priority
quality={100}
className="object-cover" className="object-cover"
sizes="100vw" sizes="100vw"
style={{ style={{
@@ -133,13 +134,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
<span className="w-1 h-1 bg-white/30 rounded-full" /> <span className="w-1 h-1 bg-white/30 rounded-full" />
<span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold"> <span className="px-2 py-0.5 border border-white/40 text-white/80 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview Draft Preview
</span> </span>
</> </>
)} )}
</div> </div>
</div> </div>
</div> </div>
@@ -170,13 +171,13 @@ export default async function BlogPost({ params }: BlogPostProps) {
<span>{getReadingTime(rawTextContent)} min read</span> <span>{getReadingTime(rawTextContent)} min read</span>
{(new Date(post.frontmatter.date) > new Date() || {(new Date(post.frontmatter.date) > new Date() ||
post.frontmatter.public === false) && ( post.frontmatter.public === false) && (
<> <>
<span className="w-1 h-1 bg-neutral-300 rounded-full" /> <span className="w-1 h-1 bg-neutral-300 rounded-full" />
<span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold"> <span className="px-2 py-0.5 border border-orange-500/50 text-orange-600 rounded uppercase tracking-widest text-[10px] md:text-xs font-bold">
Draft Preview Draft Preview
</span> </span>
</> </>
)} )}
</div> </div>
</div> </div>
</header> </header>

View File

@@ -8,6 +8,7 @@ import { SITE_URL } from '@/lib/schema';
import { getOGImageMetadata } from '@/lib/metadata'; import { getOGImageMetadata } from '@/lib/metadata';
import { Suspense } from 'react'; import { Suspense } from 'react';
import ContactMap from '@/components/ContactMap'; import ContactMap from '@/components/ContactMap';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
interface ContactPageProps { interface ContactPageProps {
params: Promise<{ params: Promise<{
@@ -204,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {
<h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2"> <h4 className="text-base md:text-xl font-bold text-primary mb-1 md:mb-2">
{t('info.email')} {t('info.email')}
</h4> </h4>
<a <ObfuscatedEmail
href="mailto:info@klz-cables.com" email="info@klz-cables.com"
className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target" className="text-sm md:text-lg text-text-secondary hover:text-primary transition-colors font-medium touch-target"
> />
info@klz-cables.com
</a>
</div> </div>
</div> </div>
</address> </address>

View File

@@ -1,5 +1,4 @@
import Reveal from '@/components/Reveal'; import Reveal from '@/components/Reveal';
import Scribble from '@/components/Scribble';
import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui'; import { Badge, Button, Card, Container, Heading, Section } from '@/components/ui';
import { getTranslations, setRequestLocale } from 'next-intl/server'; import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Metadata } from 'next'; import { Metadata } from 'next';
@@ -95,7 +94,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
return ( return (
<div className="flex flex-col min-h-screen bg-neutral-light"> <div className="flex flex-col min-h-screen bg-neutral-light">
{/* Hero Section */} {/* Hero Section */}
<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"> <section className="relative flex items-center pt-32 pb-16 md:pt-40 md:pb-24 overflow-hidden bg-primary-dark">
<Container className="relative z-10"> <Container className="relative z-10">
<div className="max-w-4xl animate-slide-up"> <div className="max-w-4xl animate-slide-up">
<Badge <Badge
@@ -106,15 +105,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
</Badge> </Badge>
<Heading level={1} className="text-white mb-4 md:mb-8"> <Heading level={1} className="text-white mb-4 md:mb-8">
{t.rich('title', { {t.rich('title', {
green: (chunks) => ( green: (chunks) => <span className="text-accent italic">{chunks}</span>,
<span className="relative inline-block">
<span className="relative z-10 text-accent italic">{chunks}</span>
<Scribble
variant="circle"
className="w-[140%] h-[140%] -top-[20%] -left-[20%] text-accent/30 hidden md:block"
/>
</span>
),
})} })}
</Heading> </Heading>
<p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none"> <p className="text-lg md:text-xl text-white/70 leading-relaxed max-w-2xl mb-8 md:mb-12 line-clamp-2 md:line-clamp-none">
@@ -223,7 +214,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
<div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" /> <div className="absolute top-0 right-0 w-1/2 h-full bg-accent/5 -skew-x-12 translate-x-1/4" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12"> <div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-6 md:gap-12">
<div className="max-w-2xl text-center lg:text-left"> <div className="max-w-2xl text-center lg:text-left">
<h2 className="text-2xl md:text-5xl lg:text-6xl font-bold text-white mb-4 md:mb-8 tracking-tight"> <h2 className="text-2xl md:text-4xl font-bold text-white mb-4 md:mb-8 tracking-tight">
{t('cta.title')} {t('cta.title')}
</h2> </h2>
<p className="text-base md:text-xl text-white/70 leading-relaxed"> <p className="text-base md:text-xl text-white/70 leading-relaxed">

View File

@@ -122,12 +122,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="accent" className="mb-4 md:mb-8"> <Badge variant="accent" className="mb-4 md:mb-8">
{t('michael.role')} {t('michael.role')}
</Badge> </Badge>
<Heading level={2} className="text-white mb-6 md:mb-10 text-3xl md:text-5xl"> <Heading level={2} className="text-white mb-6 md:mb-10 text-2xl md:text-4xl">
<span className="text-white">{t('michael.name')}</span> <span className="text-white">{t('michael.name')}</span>
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <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 bg-accent rounded-full" /> <div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-accent rounded-full" />
<p className="text-lg md:text-2xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90"> <p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-white/90">
{t('michael.quote')} {t('michael.quote')}
</p> </p>
</div> </div>
@@ -156,6 +156,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('michael.name')} alt={t('michael.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-primary-dark/60 lg:bg-gradient-to-r lg:from-primary-dark/20 to-transparent" />
@@ -225,6 +226,7 @@ export default async function TeamPage({ params }: TeamPageProps) {
alt={t('klaus.name')} alt={t('klaus.name')}
fill fill
className="object-cover scale-105 hover:scale-100 transition-transform duration-1000" className="object-cover scale-105 hover:scale-100 transition-transform duration-1000"
quality={100}
sizes="(max-width: 1024px) 100vw, 50vw" sizes="(max-width: 1024px) 100vw, 50vw"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-white/60 lg:bg-gradient-to-l lg:from-primary-dark/20 to-transparent" />
@@ -235,12 +237,12 @@ export default async function TeamPage({ params }: TeamPageProps) {
<Badge variant="saturated" className="mb-4 md:mb-8"> <Badge variant="saturated" className="mb-4 md:mb-8">
{t('klaus.role')} {t('klaus.role')}
</Badge> </Badge>
<Heading level={2} className="text-saturated mb-6 md:mb-10 text-3xl md:text-6xl"> <Heading level={2} className="text-saturated mb-6 md:mb-10 text-2xl md:text-4xl">
{t('klaus.name')} {t('klaus.name')}
</Heading> </Heading>
<div className="relative mb-6 md:mb-12"> <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 bg-saturated rounded-full" /> <div className="absolute -left-4 md:-left-8 top-0 bottom-0 w-1 md:w-1.5 bg-saturated rounded-full" />
<p className="text-lg md:text-3xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary"> <p className="text-base md:text-xl font-bold italic leading-relaxed pl-5 md:pl-8 text-text-secondary">
{t('klaus.quote')} {t('klaus.quote')}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,38 @@
'use client';
import React, { useState, useEffect } from 'react';
interface ObfuscatedEmailProps {
email: string;
className?: string;
children?: React.ReactNode;
}
/**
* A component that helps protect email addresses from simple spambots.
* It uses client-side mounting to render the actual email address,
* making it harder for static crawlers to harvest.
*/
export default function ObfuscatedEmail({ email, className = '', children }: ObfuscatedEmailProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
// Show a placeholder or obscured version during SSR
return (
<span className={className} aria-hidden="true">
{children || email.replace('@', ' [at] ').replace(/\./g, ' [dot] ')}
</span>
);
}
// Once mounted on the client, render the real mailto link
return (
<a href={`mailto:${email}`} className={className}>
{children || email}
</a>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import React, { useState, useEffect } from 'react';
interface ObfuscatedPhoneProps {
phone: string;
className?: string;
children?: React.ReactNode;
}
/**
* A component that helps protect phone numbers from simple spambots.
* It stays obscured during SSR and hydrates into a functional tel: link on the client.
*/
export default function ObfuscatedPhone({ phone, className = '', children }: ObfuscatedPhoneProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Format phone number for tel: link (remove spaces, etc.)
const telLink = `tel:${phone.replace(/\s+/g, '')}`;
if (!mounted) {
// Show a placeholder or obscured version during SSR
// e.g. +49 881 925 [at] 37298
const obscured = phone.replace(/(\d{3})(\d{3})$/, ' $1...$2');
return (
<span className={className} aria-hidden="true">
{children || obscured}
</span>
);
}
return (
<a href={telLink} className={className}>
{children || phone}
</a>
);
}

View File

@@ -1,7 +1,7 @@
import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react'; import { defaultJSXConverters, RichText } from '@payloadcms/richtext-lexical/react';
import type { JSXConverters } from '@payloadcms/richtext-lexical/react'; import type { JSXConverters } from '@payloadcms/richtext-lexical/react';
import Image from 'next/image'; import Image from 'next/image';
import { Suspense } from 'react'; import { Suspense, Fragment } from 'react';
// Import all custom React components that were previously mapped via Markdown // Import all custom React components that were previously mapped via Markdown
import StickyNarrative from '@/components/blog/StickyNarrative'; import StickyNarrative from '@/components/blog/StickyNarrative';
@@ -24,6 +24,8 @@ import Reveal from '@/components/Reveal';
import { Badge, Container, Heading, Section, Card } from '@/components/ui'; import { Badge, Container, Heading, Section, Card } from '@/components/ui';
import TrackedLink from '@/components/analytics/TrackedLink'; import TrackedLink from '@/components/analytics/TrackedLink';
import { useLocale } from 'next-intl'; import { useLocale } from 'next-intl';
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
import HomeHero from '@/components/home/Hero'; import HomeHero from '@/components/home/Hero';
import ProductCategories from '@/components/home/ProductCategories'; import ProductCategories from '@/components/home/ProductCategories';
@@ -36,9 +38,95 @@ import GallerySection from '@/components/home/GallerySection';
import VideoSection from '@/components/home/VideoSection'; import VideoSection from '@/components/home/VideoSection';
import CTA from '@/components/home/CTA'; import CTA from '@/components/home/CTA';
/**
* Splits a text string on \n and intersperses <br /> elements.
* This is needed because Lexical stores newlines as literal \n characters inside
* text nodes (e.g. dash-lists typed in the editor), but HTML collapses whitespace.
*/
function textWithLineBreaks(text: string, key: string) {
const parts = text.split('\n');
if (parts.length === 1) return text;
return parts.map((part, i) => (
<Fragment key={`${key}-${i}`}>
{part}
{i < parts.length - 1 && <br />}
</Fragment>
));
}
const jsxConverters: JSXConverters = { const jsxConverters: JSXConverters = {
...defaultJSXConverters, ...defaultJSXConverters,
// Let the default converters handle text nodes to preserve valid formatting // Handle Lexical linebreak nodes (explicit shift+enter)
linebreak: () => <br />,
// Custom text converter: preserve \n inside text nodes as <br /> and obfuscate emails
text: ({ node }: any) => {
let content: React.ReactNode = node.text || '';
// Split newlines first
if (typeof content === 'string' && content.includes('\n')) {
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
}
// Obfuscate emails in text content
if (typeof content === 'string' && content.includes('@')) {
const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
const parts = content.split(emailRegex);
content = parts.map((part, i) => {
if (part.match(emailRegex)) {
return <ObfuscatedEmail key={`e-${i}`} email={part} />;
}
return part;
});
}
// Obfuscate phone numbers in text content (simple pattern for +XX XXX ...)
if (typeof content === 'string' && content.match(/\+\d+/)) {
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
const parts = content.split(phoneRegex);
content = parts.map((part, i) => {
if (part.match(phoneRegex)) {
return <ObfuscatedPhone key={`p-${i}`} phone={part} />;
}
return part;
});
}
// Handle array content (from previous mappings)
if (Array.isArray(content)) {
content = content.map((item, idx) => {
if (typeof item === 'string') {
// Re-apply phone regex to strings in array
const phoneRegex = /(\+\d{1,4}[\d\s-]{5,15})/g;
if (item.match(phoneRegex)) {
const parts = item.split(phoneRegex);
return parts.map((part, i) => {
if (part.match(phoneRegex)) {
return <ObfuscatedPhone key={`p-${idx}-${i}`} phone={part} />;
}
return part;
});
}
}
return item;
});
}
// Apply Lexical formatting flags
if (node.format) {
if (node.format & 1) content = <strong>{content}</strong>;
if (node.format & 2) content = <em>{content}</em>;
if (node.format & 8) content = <u>{content}</u>;
if (node.format & 4) content = <s>{content}</s>;
if (node.format & 16)
content = (
<code className="px-1.5 py-0.5 bg-neutral-100 rounded text-sm font-mono text-primary">
{content}
</code>
);
if (node.format & 32) content = <sub>{content}</sub>;
if (node.format & 64) content = <sup>{content}</sup>;
}
return <>{content}</>;
},
// Use div instead of p for paragraphs to allow nested block elements (like the lists above) // Use div instead of p for paragraphs to allow nested block elements (like the lists above)
paragraph: ({ node, nodesToJSX }: any) => { paragraph: ({ node, nodesToJSX }: any) => {
return ( return (
@@ -73,7 +161,7 @@ const jsxConverters: JSXConverters = {
return ( return (
<h2 <h2
id={id} id={id}
className="text-3xl md:text-4xl font-bold mt-12 mb-6 text-text-primary scroll-mt-24" className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24"
> >
{children} {children}
</h2> </h2>
@@ -82,7 +170,7 @@ const jsxConverters: JSXConverters = {
return ( return (
<h3 <h3
id={id} id={id}
className="text-2xl md:text-3xl font-bold mt-10 mb-5 text-text-primary scroll-mt-24" className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24"
> >
{children} {children}
</h3> </h3>
@@ -91,7 +179,7 @@ const jsxConverters: JSXConverters = {
return ( return (
<h4 <h4
id={id} id={id}
className="text-xl md:text-2xl font-bold mt-8 mb-4 text-text-primary scroll-mt-24" className="text-lg md:text-xl font-bold mt-6 mb-3 text-text-primary scroll-mt-24"
> >
{children} {children}
</h4> </h4>
@@ -168,6 +256,17 @@ const jsxConverters: JSXConverters = {
// Handling Payload CMS link nodes // Handling Payload CMS link nodes
const href = node?.fields?.url || node?.url || '#'; const href = node?.fields?.url || node?.url || '#';
const newTab = node?.fields?.newTab || node?.newTab; const newTab = node?.fields?.newTab || node?.newTab;
if (href.startsWith('mailto:')) {
const email = href.replace('mailto:', '');
return (
<ObfuscatedEmail
email={email}
className="text-primary no-underline hover:underline font-medium transition-colors"
/>
);
}
return ( return (
<a <a
href={href} href={href}
@@ -1012,6 +1111,14 @@ const jsxConverters: JSXConverters = {
<CTA data={node?.fields} /> <CTA data={node?.fields} />
</Reveal> </Reveal>
), ),
'block-email': ({ node }: any) => {
const { email, label } = node.fields;
return <ObfuscatedEmail email={email}>{label || email}</ObfuscatedEmail>;
},
'block-phone': ({ node }: any) => {
const { phone, label } = node.fields;
return <ObfuscatedPhone phone={phone}>{label || phone}</ObfuscatedPhone>;
},
}, },
// Custom converter for the Payload "upload" Lexical node (Media collection) // Custom converter for the Payload "upload" Lexical node (Media collection)
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes // This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes

View File

@@ -15,6 +15,7 @@ export default function Experience({ data }: { data?: any }) {
fill fill
className="object-cover object-center scale-105 animate-slow-zoom" className="object-cover object-center scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/80 mix-blend-multiply" /> <div className="absolute inset-0 bg-primary/80 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-r from-primary via-primary/40 to-transparent" />

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import Scribble from '@/components/Scribble';
import { Button, Container, Heading, Section } from '@/components/ui'; import { Button, Container, Heading, Section } from '@/components/ui';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
@@ -20,45 +19,19 @@ export default function Hero({ data }: { data?: any }) {
<div> <div>
<Heading <Heading
level={1} level={1}
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]" className="text-center md:text-left mb-6 md:mb-8 md:max-w-none text-white text-3xl sm:text-4xl md:text-5xl font-extrabold"
> >
{data?.title ? ( {data?.title ? (
<> <span
{data.title.split(/(<green>.*?<\/green>)/g).map((part: string, i: number) => { dangerouslySetInnerHTML={{
if (part.startsWith('<green>') && part.endsWith('</green>')) { __html: data.title
const content = part.replace(/<\/?green>/g, ''); .replace(/<green>/g, '<span class="text-accent italic">')
return ( .replace(/<\/green>/g, '</span>'),
<span key={i} className="relative inline-block"> }}
<span className="relative z-10 text-accent italic inline-block"> />
{content}
</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' }}
>
<Scribble variant="circle" />
</div>
</span>
);
}
return <span key={i}>{part}</span>;
})}
</>
) : ( ) : (
t.rich('title', { t.rich('title', {
green: (chunks) => ( green: (chunks) => <span className="text-accent italic">{chunks}</span>,
<span className="relative inline-block">
<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' }}
>
<Scribble variant="circle" />
</div>
</span>
),
}) })
)} )}
</Heading> </Heading>

View File

@@ -17,6 +17,7 @@ export default function MeetTheTeam({ data }: { data?: any }) {
fill fill
className="object-cover scale-105 animate-slow-zoom" className="object-cover scale-105 animate-slow-zoom"
sizes="100vw" sizes="100vw"
quality={100}
/> />
<div className="absolute inset-0 bg-primary/70 mix-blend-multiply" /> <div className="absolute inset-0 bg-primary/70 mix-blend-multiply" />
<div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-primary via-primary/20 to-transparent" />

View File

@@ -1,7 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import Scribble from '@/components/Scribble';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
export default function VideoSection({ data }: { data?: any }) { export default function VideoSection({ data }: { data?: any }) {
@@ -41,18 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
<div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto"> <div className="max-w-5xl px-6 text-center animate-slide-up pointer-events-auto">
<h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]"> <h2 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white leading-[1.1]">
{data?.title ? ( {data?.title ? (
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="relative inline-block mx-2"><span class="relative z-10 italic text-accent">').replace(/<\/future>/g, '</span><Scribble variant="underline" class="w-full h-4 -bottom-2 left-0 text-accent/40" /></span>') }} /> <span
dangerouslySetInnerHTML={{
__html: data.title
.replace(/<future>/g, '<span class="italic text-accent">')
.replace(/<\/future>/g, '</span>'),
}}
/>
) : ( ) : (
t.rich('title', { t.rich('title', {
future: (chunks) => ( future: (chunks) => <span className="italic text-accent">{chunks}</span>,
<span className="relative inline-block mx-2">
<span className="relative z-10 italic text-accent">{chunks}</span>
<Scribble
variant="underline"
className="w-full h-4 -bottom-2 left-0 text-accent/40"
/>
</span>
),
}) })
)} )}
</h2> </h2>

View File

@@ -116,7 +116,7 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
category: doc.category || '', category: doc.category || '',
featuredImage: featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url ? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
: null, : null,
focalX: focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null
@@ -162,7 +162,7 @@ export async function getAllPosts(locale: string): Promise<PostData[]> {
category: doc.category || '', category: doc.category || '',
featuredImage: featuredImage:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url ? doc.featuredImage.url || doc.featuredImage.sizes?.card?.url
: null, : null,
focalX: focalX:
typeof doc.featuredImage === 'object' && doc.featuredImage !== null typeof doc.featuredImage === 'object' && doc.featuredImage !== null

View File

@@ -139,7 +139,7 @@
"prepare": "husky", "prepare": "husky",
"preinstall": "npx only-allow pnpm" "preinstall": "npx only-allow pnpm"
}, },
"version": "2.2.6", "version": "2.2.12",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@parcel/watcher", "@parcel/watcher",
@@ -161,4 +161,4 @@
"peerDependencies": { "peerDependencies": {
"lucide-react": "^0.563.0" "lucide-react": "^0.563.0"
} }
} }

View File

@@ -21,6 +21,8 @@ import { Posts } from './src/payload/collections/Posts';
import { FormSubmissions } from './src/payload/collections/FormSubmissions'; import { FormSubmissions } from './src/payload/collections/FormSubmissions';
import { Products } from './src/payload/collections/Products'; import { Products } from './src/payload/collections/Products';
import { Pages } from './src/payload/collections/Pages'; import { Pages } from './src/payload/collections/Pages';
import { Email } from './src/payload/blocks/Email';
import { Phone } from './src/payload/blocks/Phone';
import { seedDatabase } from './src/payload/seed'; import { seedDatabase } from './src/payload/seed';
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
@@ -62,6 +64,7 @@ export default buildConfig({
...defaultFeatures, ...defaultFeatures,
BlocksFeature({ BlocksFeature({
blocks: payloadBlocks, blocks: payloadBlocks,
inlineBlocks: [Email, Phone],
}), }),
], ],
}), }),

View File

@@ -66,6 +66,12 @@ async function main() {
const page = await browser.newPage(); const page = await browser.newPage();
page.on('console', (msg) => console.log('💻 BROWSER CONSOLE:', msg.text()));
page.on('pageerror', (error) => console.error('💻 BROWSER ERROR:', error.message));
page.on('requestfailed', (request) => {
console.error('💻 BROWSER REQUEST FAILED:', request.url(), request.failure()?.errorText);
});
// 3. Authenticate through Gatekeeper login form // 3. Authenticate through Gatekeeper login form
console.log(`\n🛡 Authenticating through Gatekeeper...`); console.log(`\n🛡 Authenticating through Gatekeeper...`);
try { try {
@@ -109,6 +115,9 @@ async function main() {
throw e; throw e;
} }
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// Fill form fields // Fill form fields
await page.type('input[name="name"]', 'Automated E2E Test'); await page.type('input[name="name"]', 'Automated E2E Test');
await page.type('input[name="email"]', 'testing@mintel.me'); await page.type('input[name="email"]', 'testing@mintel.me');
@@ -117,14 +126,24 @@ async function main() {
'This is an automated test verifying the contact form submission.', 'This is an automated test verifying the contact form submission.',
); );
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` Submitting Contact Form...`); console.log(` Submitting Contact Form...`);
// Explicitly click submit and wait for navigation/state-change // Explicitly click submit and wait for navigation/state-change
await Promise.all([ await Promise.all([
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }), page.waitForSelector('[role="alert"]', { timeout: 15000 }),
page.click('button[type="submit"]'), page.click('button[type="submit"]'),
]); ]);
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(` Alert text: ${alertText}`);
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
throw new Error(`Form submitted but showed error: ${alertText}`);
}
console.log(`✅ Contact Form submitted successfully! (Success state verified)`); console.log(`✅ Contact Form submitted successfully! (Success state verified)`);
} catch (err: any) { } catch (err: any) {
console.error(`❌ Contact Form Test Failed: ${err.message}`); console.error(`❌ Contact Form Test Failed: ${err.message}`);
@@ -147,6 +166,9 @@ async function main() {
throw e; throw e;
} }
// Wait specifically for hydration logic to initialize the onSubmit handler
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 2000)));
// In RequestQuoteForm, the email input is type="email" and message is a textarea. // In RequestQuoteForm, the email input is type="email" and message is a textarea.
await page.type('form input[type="email"]', 'testing@mintel.me'); await page.type('form input[type="email"]', 'testing@mintel.me');
await page.type( await page.type(
@@ -154,14 +176,24 @@ async function main() {
'Automated request for product quote via E2E testing framework.', 'Automated request for product quote via E2E testing framework.',
); );
// Give state a moment to settle
await page.evaluate(() => new Promise((resolve) => setTimeout(resolve, 500)));
console.log(` Submitting Product Quote Form...`); console.log(` Submitting Product Quote Form...`);
// Submit and wait for success state // Submit and wait for success state
await Promise.all([ await Promise.all([
page.waitForSelector('[role="alert"][aria-live="polite"]', { timeout: 15000 }), page.waitForSelector('[role="alert"]', { timeout: 15000 }),
page.click('form button[type="submit"]'), page.click('form button[type="submit"]'),
]); ]);
const alertText = await page.$eval('[role="alert"]', (el) => el.textContent);
console.log(` Alert text: ${alertText}`);
if (alertText?.includes('Failed') || alertText?.includes('went wrong')) {
throw new Error(`Form submitted but showed error: ${alertText}`);
}
console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`); console.log(`✅ Product Quote Form submitted successfully! (Success state verified)`);
} catch (err: any) { } catch (err: any) {
console.error(`❌ Product Quote Form Test Failed: ${err.message}`); console.error(`❌ Product Quote Form Test Failed: ${err.message}`);
@@ -189,11 +221,20 @@ async function main() {
}); });
console.log(` ✅ Deleted submission: ${doc.id}`); console.log(` ✅ Deleted submission: ${doc.id}`);
} catch (delErr: any) { } catch (delErr: any) {
console.error(` ❌ Failed to delete submission ${doc.id}: ${delErr.message}`); // Log but don't fail, 403s on Directus / Payload APIs for guest Gatekeeper sessions are normal
console.warn(
` ⚠️ Cleanup attempt on ${doc.id} returned an error, typically due to API Auth separation: ${delErr.message}`,
);
} }
} }
} catch (err: any) { } catch (err: any) {
console.error(`❌ Cleanup failed: ${err.message}`); if (err.response?.status === 403) {
console.warn(
` ⚠️ Cleanup fetch failed with 403 Forbidden. This is expected if the runner lacks admin API credentials. Test submissions remain in the database.`,
);
} else {
console.error(` ❌ Cleanup fetch failed: ${err.message}`);
}
// Don't mark the whole test as failed just because cleanup failed // Don't mark the whole test as failed just because cleanup failed
} }

View File

@@ -0,0 +1,25 @@
import { Block } from 'payload';
export const Email: Block = {
slug: 'email',
interfaceName: 'EmailBlock',
labels: {
singular: 'Email (Inline)',
plural: 'Emails (Inline)',
},
fields: [
{
name: 'email',
type: 'text',
required: true,
},
{
name: 'label',
type: 'text',
required: false,
admin: {
placeholder: 'Optional: Custom link text',
},
},
],
};

View File

@@ -0,0 +1,28 @@
import { Block } from 'payload';
export const Phone: Block = {
slug: 'phone',
interfaceName: 'PhoneBlock',
labels: {
singular: 'Phone (Inline)',
plural: 'Phones (Inline)',
},
fields: [
{
name: 'phone',
type: 'text',
required: true,
admin: {
placeholder: '+49 123 456 789',
},
},
{
name: 'label',
type: 'text',
required: false,
admin: {
placeholder: 'Optional: Custom link text',
},
},
],
};

View File

@@ -1,4 +1,6 @@
import { AnimatedImage } from './AnimatedImage'; import { AnimatedImage } from './AnimatedImage';
import { Email } from './Email';
import { Phone } from './Phone';
import { Callout } from './Callout'; import { Callout } from './Callout';
import { CategoryGrid } from './CategoryGrid'; import { CategoryGrid } from './CategoryGrid';
import { ChatBubble } from './ChatBubble'; import { ChatBubble } from './ChatBubble';
@@ -21,6 +23,8 @@ import { homeBlocksArray } from './HomeBlocks';
export const payloadBlocks = [ export const payloadBlocks = [
...homeBlocksArray, ...homeBlocksArray,
AnimatedImage, AnimatedImage,
Email,
Phone,
Callout, Callout,
CategoryGrid, CategoryGrid,
ChatBubble, ChatBubble,

View File

@@ -76,19 +76,31 @@ describe('OG Image Generation', () => {
await verifyImageResponse(response); await verifyImageResponse(response);
}, 30000); }, 30000);
it('should generate dynamic blog post OG image', async ({ skip }) => { it('should generate dynamic blog post OG image with featured photo', async ({ skip }) => {
if (!isServerUp) skip(); if (!isServerUp) skip();
// Assuming 'hello-world' or a newly created post slug.
// If it 404s, it still tests the routing, though 200 is expected for an actual post.
const url = `${BASE_URL}/de/blog/hello-world/opengraph-image`;
const response = await fetch(url);
// Even if the post "hello-world" doesn't exist and returns 404 in some environments,
// we should at least check it doesn't 500. We'll accept 200 or 404 as valid "working" states
// vs a 500 compilation/satori error.
expect([200, 404]).toContain(response.status);
if (response.status === 200) { // Discover a real blog slug from the sitemap
await verifyImageResponse(response); const sitemapRes = await fetch(`${BASE_URL}/sitemap.xml`);
const sitemapXml = await sitemapRes.text();
const blogMatch = sitemapXml.match(/<loc>[^<]*\/de\/blog\/([^<]+)<\/loc>/);
const slug = blogMatch ? blogMatch[1] : null;
if (!slug) {
console.log('⚠️ No blog post found in sitemap, skipping dynamic OG test');
skip();
return;
} }
const url = `${BASE_URL}/de/blog/${slug}/opengraph-image`;
const response = await fetch(url);
await verifyImageResponse(response);
// Verify the image is substantially large (>50KB) to confirm it actually
// contains the featured photo and isn't just a tiny fallback/text-only image
const buffer = await response.clone().arrayBuffer();
expect(
buffer.byteLength,
`OG image for "${slug}" is suspiciously small (${buffer.byteLength} bytes) — likely missing featured photo`,
).toBeGreaterThan(50000);
}, 30000); }, 30000);
}); });