diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index f3b356c8..afab2e98 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -8,6 +8,7 @@ import { SITE_URL } from '@/lib/schema'; import { getOGImageMetadata } from '@/lib/metadata'; import { Suspense } from 'react'; import ContactMap from '@/components/ContactMap'; +import ObfuscatedEmail from '@/components/ObfuscatedEmail'; interface ContactPageProps { params: Promise<{ @@ -204,12 +205,10 @@ export default async function ContactPage({ params }: ContactPageProps) {

{t('info.email')}

- - info@klz-cables.com - + /> diff --git a/components/ObfuscatedEmail.tsx b/components/ObfuscatedEmail.tsx new file mode 100644 index 00000000..01a7ad04 --- /dev/null +++ b/components/ObfuscatedEmail.tsx @@ -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 ( + + ); + } + + // Once mounted on the client, render the real mailto link + return ( + + {children || email} + + ); +} diff --git a/components/ObfuscatedPhone.tsx b/components/ObfuscatedPhone.tsx new file mode 100644 index 00000000..0db10d57 --- /dev/null +++ b/components/ObfuscatedPhone.tsx @@ -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 ( + + ); + } + + return ( + + {children || phone} + + ); +} diff --git a/components/PayloadRichText.tsx b/components/PayloadRichText.tsx index c050199f..0f55abb3 100644 --- a/components/PayloadRichText.tsx +++ b/components/PayloadRichText.tsx @@ -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: () =>
, - // Custom text converter: preserve \n inside text nodes as
+ // Custom text converter: preserve \n inside text nodes as
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 ; + } + 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 ; + } + 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 ; + } + return part; + }); + } + } + return item; + }); + } + // Apply Lexical formatting flags if (node.format) { if (node.format & 1) content = {content}; @@ -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 ( + + ); + } + return ( ), + 'block-email': ({ node }: any) => { + const { email, label } = node.fields; + return {label || email}; + }, + 'block-phone': ({ node }: any) => { + const { phone, label } = node.fields; + return {label || phone}; + }, }, // Custom converter for the Payload "upload" Lexical node (Media collection) // This natively reconstructs Next.js tags pointing to the focal-point cropped sizes diff --git a/payload.config.ts b/payload.config.ts index 289ba29c..eac89177 100644 --- a/payload.config.ts +++ b/payload.config.ts @@ -21,6 +21,8 @@ import { Posts } from './src/payload/collections/Posts'; import { FormSubmissions } from './src/payload/collections/FormSubmissions'; import { Products } from './src/payload/collections/Products'; 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'; const filename = fileURLToPath(import.meta.url); @@ -62,6 +64,7 @@ export default buildConfig({ ...defaultFeatures, BlocksFeature({ blocks: payloadBlocks, + inlineBlocks: [Email, Phone], }), ], }), diff --git a/src/payload/blocks/Email.ts b/src/payload/blocks/Email.ts new file mode 100644 index 00000000..b2460939 --- /dev/null +++ b/src/payload/blocks/Email.ts @@ -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', + }, + }, + ], +}; diff --git a/src/payload/blocks/Phone.ts b/src/payload/blocks/Phone.ts new file mode 100644 index 00000000..8370860a --- /dev/null +++ b/src/payload/blocks/Phone.ts @@ -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', + }, + }, + ], +}; diff --git a/src/payload/blocks/allBlocks.ts b/src/payload/blocks/allBlocks.ts index d5d63066..16d718b6 100644 --- a/src/payload/blocks/allBlocks.ts +++ b/src/payload/blocks/allBlocks.ts @@ -1,4 +1,6 @@ import { AnimatedImage } from './AnimatedImage'; +import { Email } from './Email'; +import { Phone } from './Phone'; import { Callout } from './Callout'; import { CategoryGrid } from './CategoryGrid'; import { ChatBubble } from './ChatBubble'; @@ -21,6 +23,8 @@ import { homeBlocksArray } from './HomeBlocks'; export const payloadBlocks = [ ...homeBlocksArray, AnimatedImage, + Email, + Phone, Callout, CategoryGrid, ChatBubble,