Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57b6963efe | |||
| 1a136540d0 |
@@ -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>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -56,13 +58,58 @@ const jsxConverters: JSXConverters = {
|
|||||||
...defaultJSXConverters,
|
...defaultJSXConverters,
|
||||||
// Handle Lexical linebreak nodes (explicit shift+enter)
|
// Handle Lexical linebreak nodes (explicit shift+enter)
|
||||||
linebreak: () => <br />,
|
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) => {
|
text: ({ node }: any) => {
|
||||||
let content: React.ReactNode = node.text || '';
|
let content: React.ReactNode = node.text || '';
|
||||||
// Split newlines first
|
// Split newlines first
|
||||||
if (typeof content === 'string' && content.includes('\n')) {
|
if (typeof content === 'string' && content.includes('\n')) {
|
||||||
content = textWithLineBreaks(content, `t-${(node.text || '').slice(0, 8)}`);
|
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
|
// Apply Lexical formatting flags
|
||||||
if (node.format) {
|
if (node.format) {
|
||||||
if (node.format & 1) content = <strong>{content}</strong>;
|
if (node.format & 1) content = <strong>{content}</strong>;
|
||||||
@@ -209,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}
|
||||||
@@ -1053,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
|
||||||
|
|||||||
@@ -139,7 +139,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"preinstall": "npx only-allow pnpm"
|
"preinstall": "npx only-allow pnpm"
|
||||||
},
|
},
|
||||||
"version": "2.2.11",
|
"version": "2.2.12",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
|||||||
@@ -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],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|||||||
25
src/payload/blocks/Email.ts
Normal file
25
src/payload/blocks/Email.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
28
src/payload/blocks/Phone.ts
Normal file
28
src/payload/blocks/Phone.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user