team page: publish employee emails with crawler obfuscation
New ObfuscatedEmail client component splits user + domain into two data attributes server-side, then assembles the mailto on mount. Pre-JS DOM shows "[email protected]" placeholder; only after client-side hydration does the real address render. Most simple email scrapers won't execute JS or follow the data-u/data-d pattern, so addresses stay protected while remaining one click away for humans. Editorial persona emails derive from slug (firstname-lastname@broadcastbeat.com). Ops persona emails (Chloe, Riley) come from pulsedesk.personas.email. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { createClient } from "@supabase/supabase-js";
|
|||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||||
|
import ObfuscatedEmail from "@/components/ObfuscatedEmail";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const revalidate = 600;
|
export const revalidate = 600;
|
||||||
@@ -75,6 +76,19 @@ export default async function TeamPage() {
|
|||||||
.map((w) => w.charAt(0).toUpperCase())
|
.map((w) => w.charAt(0).toUpperCase())
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
// Derive @broadcastbeat.com email from the persona slug. Real mailboxes
|
||||||
|
// are set up centrally; this is the authoring address surfaced to readers.
|
||||||
|
const editorialEmail = (p: Persona): { user: string; domain: string } | null => {
|
||||||
|
if (!p.slug) return null;
|
||||||
|
return { user: p.slug, domain: "broadcastbeat.com" };
|
||||||
|
};
|
||||||
|
|
||||||
|
const splitEmail = (email: string): { user: string; domain: string } | null => {
|
||||||
|
const at = email.indexOf("@");
|
||||||
|
if (at <= 0) return null;
|
||||||
|
return { user: email.slice(0, at), domain: email.slice(at + 1) };
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<Header />
|
<Header />
|
||||||
@@ -111,6 +125,15 @@ export default async function TeamPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{p.bio && <p className="text-sm text-[#9ca3af] mt-2 leading-relaxed">{p.bio}</p>}
|
{p.bio && <p className="text-sm text-[#9ca3af] mt-2 leading-relaxed">{p.bio}</p>}
|
||||||
|
{(() => {
|
||||||
|
const e = editorialEmail(p);
|
||||||
|
if (!e) return null;
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-[#9ca3af] mt-2">
|
||||||
|
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -136,11 +159,15 @@ export default async function TeamPage() {
|
|||||||
{p.role}
|
{p.role}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-[#9ca3af] mt-2 space-y-0.5">
|
<div className="text-xs text-[#9ca3af] mt-2 space-y-0.5">
|
||||||
{p.email && (
|
{p.email && (() => {
|
||||||
<div>
|
const e = splitEmail(p.email);
|
||||||
<a href={`mailto:${p.email}`} className="hover:text-[#60a5fa]">{p.email}</a>
|
if (!e) return null;
|
||||||
</div>
|
return (
|
||||||
)}
|
<div>
|
||||||
|
<ObfuscatedEmail user={e.user} domain={e.domain} className="hover:text-[#60a5fa]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{p.phone && (
|
{p.phone && (
|
||||||
<div>
|
<div>
|
||||||
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#60a5fa]">
|
<a href={`tel:${p.phone.replace(/[^\d+]/g, '')}`} className="hover:text-[#60a5fa]">
|
||||||
|
|||||||
50
src/components/ObfuscatedEmail.tsx
Normal file
50
src/components/ObfuscatedEmail.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an email address split into user + domain attributes so simple
|
||||||
|
* HTML scrapers don't see a usable address in the server-rendered DOM.
|
||||||
|
* On mount we assemble the address client-side and wrap in a real mailto.
|
||||||
|
*
|
||||||
|
* Pre-mount HTML looks like:
|
||||||
|
* <span data-u="ryan" data-d="broadcastbeat.com">[email protected]</span>
|
||||||
|
*
|
||||||
|
* Post-mount (JS executed) becomes:
|
||||||
|
* <a href="mailto:ryan@broadcastbeat.com">ryan@broadcastbeat.com</a>
|
||||||
|
*
|
||||||
|
* Plus the @ is rendered with an HTML entity so even rare bots reading the
|
||||||
|
* pre-JS DOM won't get a clean string.
|
||||||
|
*/
|
||||||
|
export default function ObfuscatedEmail({
|
||||||
|
user,
|
||||||
|
domain,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
user: string;
|
||||||
|
domain: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
useEffect(() => setRevealed(true), []);
|
||||||
|
|
||||||
|
if (!revealed) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-u={user}
|
||||||
|
data-d={domain}
|
||||||
|
className={className}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
[email protected]
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addr = `${user}@${domain}`;
|
||||||
|
return (
|
||||||
|
<a href={`mailto:${addr}`} className={className}>
|
||||||
|
{addr}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user