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

This commit is contained in:
2026-03-02 10:22:52 +01:00
parent 92bc88dfbd
commit 1a136540d0
8 changed files with 210 additions and 6 deletions

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

@@ -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';
@@ -56,13 +58,58 @@ const jsxConverters: JSXConverters = {
...defaultJSXConverters,
// Handle Lexical linebreak nodes (explicit shift+enter)
linebreak: () => <br />,
// Custom text converter: preserve \n inside text nodes as <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>;
@@ -209,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}
@@ -1053,6 +1111,14 @@ const jsxConverters: JSXConverters = {
<CTA data={node?.fields} />
</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)
// This natively reconstructs Next.js <Image /> tags pointing to the focal-point cropped sizes