Merge branch 'main' into feature/excel
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 1m57s
Build & Deploy / 🧪 QA (push) Failing after 2m6s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 9s
CI - Lint, Typecheck & Test / quality-assurance (pull_request) Failing after 1m57s
Build & Deploy / 🧪 QA (push) Failing after 2m6s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
This commit is contained in:
38
components/ObfuscatedEmail.tsx
Normal file
38
components/ObfuscatedEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
components/ObfuscatedPhone.tsx
Normal file
41
components/ObfuscatedPhone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Suspense, Fragment } from 'react';
|
||||
|
||||
// Import all custom React components that were previously mapped via Markdown
|
||||
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 TrackedLink from '@/components/analytics/TrackedLink';
|
||||
import { useLocale } from 'next-intl';
|
||||
import ObfuscatedEmail from '@/components/ObfuscatedEmail';
|
||||
import ObfuscatedPhone from '@/components/ObfuscatedPhone';
|
||||
|
||||
import HomeHero from '@/components/home/Hero';
|
||||
import ProductCategories from '@/components/home/ProductCategories';
|
||||
@@ -36,9 +38,95 @@ import GallerySection from '@/components/home/GallerySection';
|
||||
import VideoSection from '@/components/home/VideoSection';
|
||||
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 = {
|
||||
...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)
|
||||
paragraph: ({ node, nodesToJSX }: any) => {
|
||||
return (
|
||||
@@ -57,16 +145,16 @@ const jsxConverters: JSXConverters = {
|
||||
const textContent = node.children ? node.children.map((c: any) => c.text || '').join('') : '';
|
||||
const id = textContent
|
||||
? textContent
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[*_`]/g, '')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
: undefined;
|
||||
|
||||
if (tag === 'h1')
|
||||
@@ -168,6 +256,17 @@ const jsxConverters: JSXConverters = {
|
||||
// Handling Payload CMS link nodes
|
||||
const href = node?.fields?.url || node?.url || '#';
|
||||
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 (
|
||||
<a
|
||||
href={href}
|
||||
|
||||
@@ -25,23 +25,13 @@ export default function Hero({ data }: { data?: any }) {
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(
|
||||
/<green>/g,
|
||||
'<span class="text-accent italic">',
|
||||
)
|
||||
.replace(
|
||||
/<\/green>/g,
|
||||
'</span>',
|
||||
),
|
||||
.replace(/<green>/g, '<span class="text-accent italic">')
|
||||
.replace(/<\/green>/g, '</span>'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
green: (chunks) => (
|
||||
<span className="text-accent italic">
|
||||
{chunks}
|
||||
</span>
|
||||
),
|
||||
green: (chunks) => <span className="text-accent italic">{chunks}</span>,
|
||||
})
|
||||
)}
|
||||
</Heading>
|
||||
|
||||
@@ -40,12 +40,16 @@ export default function VideoSection({ data }: { data?: any }) {
|
||||
<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]">
|
||||
{data?.title ? (
|
||||
<span dangerouslySetInnerHTML={{ __html: data.title.replace(/<future>/g, '<span class="italic text-accent">').replace(/<\/future>/g, '</span>') }} />
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: data.title
|
||||
.replace(/<future>/g, '<span class="italic text-accent">')
|
||||
.replace(/<\/future>/g, '</span>'),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
t.rich('title', {
|
||||
future: (chunks) => (
|
||||
<span className="italic text-accent">{chunks}</span>
|
||||
),
|
||||
future: (chunks) => <span className="italic text-accent">{chunks}</span>,
|
||||
})
|
||||
)}
|
||||
</h2>
|
||||
|
||||
Reference in New Issue
Block a user