feat(pro): harden enterprise form with mandatory fields and lead qualification (#1382)

* feat(pro): harden enterprise contact form with mandatory fields and lead qualification

- Add mandatory phone number and company fields (client + server validation)
- Block free email domains (gmail, yahoo, hotmail, etc.) with 422 response and inline error
- Include phone (clickable tel: link) and email domain (clickable company link) in sales notification
- Add i18n translations for phone placeholder and work email error across all 21 locales
- Tighten phone regex to require start/end with digit, rejecting junk input

* fix(pro): rebuild static assets and fix contact handler tests

- Rebuild public/pro/ bundle to include new phone/company/email validation fields
- Add phone field to test validBody() fixture
- Add tests for free email domain rejection (422), missing org, missing/invalid phone
This commit is contained in:
Elie Habib
2026-03-10 17:25:09 +04:00
committed by GitHub
parent 1453a23a79
commit bbe6a828f1
50 changed files with 218 additions and 53 deletions

View File

@@ -4,9 +4,23 @@ import { ConvexHttpClient } from 'convex/browser';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_RE = /^[+(]?\d[\d\s()./-]{4,23}\d$/;
const MAX_FIELD = 500;
const MAX_MESSAGE = 2000;
const FREE_EMAIL_DOMAINS = new Set([
'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.fr', 'yahoo.co.uk', 'yahoo.co.jp',
'hotmail.com', 'hotmail.fr', 'hotmail.co.uk', 'outlook.com', 'outlook.fr',
'live.com', 'live.fr', 'msn.com', 'aol.com', 'icloud.com', 'me.com', 'mac.com',
'protonmail.com', 'proton.me', 'mail.com', 'zoho.com', 'yandex.com', 'yandex.ru',
'gmx.com', 'gmx.net', 'gmx.de', 'web.de', 'mail.ru', 'inbox.com',
'fastmail.com', 'tutanota.com', 'tuta.io', 'hey.com',
'qq.com', '163.com', '126.com', 'sina.com', 'foxmail.com',
'rediffmail.com', 'ymail.com', 'rocketmail.com',
'wanadoo.fr', 'free.fr', 'laposte.net', 'orange.fr', 'sfr.fr',
't-online.de', 'libero.it', 'virgilio.it',
]);
const rateLimitMap = new Map();
const RATE_LIMIT = 3;
const RATE_WINDOW_MS = 60 * 60 * 1000;
@@ -45,13 +59,14 @@ async function verifyTurnstile(token, ip) {
}
}
async function sendNotificationEmail(name, email, organization, message) {
async function sendNotificationEmail(name, email, organization, phone, message) {
const resendKey = process.env.RESEND_API_KEY;
if (!resendKey) {
console.error('[contact] RESEND_API_KEY not set — lead stored in Convex but notification NOT sent');
return false;
}
const notifyEmail = process.env.CONTACT_NOTIFY_EMAIL || 'sales@worldmonitor.app';
const emailDomain = (email.split('@')[1] || '').toLowerCase();
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
@@ -62,14 +77,16 @@ async function sendNotificationEmail(name, email, organization, message) {
body: JSON.stringify({
from: 'World Monitor <noreply@worldmonitor.app>',
to: [notifyEmail],
subject: `[WM Enterprise] ${sanitizeForSubject(name)} from ${sanitizeForSubject(organization || 'N/A')}`,
subject: `[WM Enterprise] ${sanitizeForSubject(name)} from ${sanitizeForSubject(organization)}`,
html: `
<div style="font-family: -apple-system, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4ade80;">New Enterprise Contact</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Name</td><td style="padding: 8px;">${escapeHtml(name)}</td></tr>
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Email</td><td style="padding: 8px;"><a href="mailto:${escapeHtml(email)}">${escapeHtml(email)}</a></td></tr>
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Organization</td><td style="padding: 8px;">${escapeHtml(organization || 'N/A')}</td></tr>
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Domain</td><td style="padding: 8px;"><a href="https://${escapeHtml(emailDomain)}" target="_blank">${escapeHtml(emailDomain)}</a></td></tr>
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Company</td><td style="padding: 8px;">${escapeHtml(organization)}</td></tr>
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Phone</td><td style="padding: 8px;"><a href="tel:${escapeHtml(phone)}">${escapeHtml(phone)}</a></td></tr>
<tr><td style="padding: 8px; font-weight: bold; color: #666;">Message</td><td style="padding: 8px;">${escapeHtml(message || 'N/A')}</td></tr>
</table>
<p style="color: #999; font-size: 12px; margin-top: 24px;">Sent from worldmonitor.app enterprise contact form</p>
@@ -159,7 +176,7 @@ export default async function handler(req) {
});
}
const { email, name, organization, message, source } = body;
const { email, name, organization, phone, message, source } = body;
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email)) {
return new Response(JSON.stringify({ error: 'Invalid email' }), {
@@ -167,15 +184,37 @@ export default async function handler(req) {
headers: { 'Content-Type': 'application/json', ...cors },
});
}
const emailDomain = email.split('@')[1]?.toLowerCase();
if (emailDomain && FREE_EMAIL_DOMAINS.has(emailDomain)) {
return new Response(JSON.stringify({ error: 'Please use your work email address' }), {
status: 422,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return new Response(JSON.stringify({ error: 'Name is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
if (!organization || typeof organization !== 'string' || organization.trim().length === 0) {
return new Response(JSON.stringify({ error: 'Company is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
if (!phone || typeof phone !== 'string' || !PHONE_RE.test(phone.trim())) {
return new Response(JSON.stringify({ error: 'Valid phone number is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
const safeName = name.slice(0, MAX_FIELD);
const safeOrg = typeof organization === 'string' ? organization.slice(0, MAX_FIELD) : undefined;
const safeOrg = organization.slice(0, MAX_FIELD);
const safePhone = phone.trim().slice(0, 30);
const safeMsg = typeof message === 'string' ? message.slice(0, MAX_MESSAGE) : undefined;
const safeSource = typeof source === 'string' ? source.slice(0, 100) : 'enterprise-contact';
@@ -193,11 +232,12 @@ export default async function handler(req) {
name: safeName,
email: email.trim(),
organization: safeOrg,
phone: safePhone,
message: safeMsg,
source: safeSource,
});
const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safeMsg);
const emailSent = await sendNotificationEmail(safeName, email.trim(), safeOrg, safePhone, safeMsg);
return new Response(JSON.stringify({ status: 'sent', emailSent }), {
status: 200,

View File

@@ -6,6 +6,7 @@ export const submit = mutation({
name: v.string(),
email: v.string(),
organization: v.optional(v.string()),
phone: v.optional(v.string()),
message: v.optional(v.string()),
source: v.string(),
},
@@ -14,6 +15,7 @@ export const submit = mutation({
name: args.name,
email: args.email,
organization: args.organization,
phone: args.phone,
message: args.message,
source: args.source,
receivedAt: Date.now(),

View File

@@ -18,6 +18,7 @@ export default defineSchema({
name: v.string(),
email: v.string(),
organization: v.optional(v.string()),
phone: v.optional(v.string()),
message: v.optional(v.string()),
source: v.string(),
receivedAt: v.number(),

View File

@@ -1059,13 +1059,26 @@ const EnterprisePage = () => (
email: fd.get('email'),
name: fd.get('name'),
organization: fd.get('organization'),
phone: fd.get('phone'),
message: fd.get('message'),
source: 'enterprise-contact',
website: honeypot,
turnstileToken,
}),
});
if (!res.ok) throw new Error();
const errorEl = form.querySelector('[data-form-error]') as HTMLElement | null;
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (res.status === 422 && errorEl) {
errorEl.textContent = data.error || t('enterpriseShowcase.workEmailRequired');
errorEl.classList.remove('hidden');
btn.textContent = origText;
btn.disabled = false;
return;
}
throw new Error();
}
if (errorEl) errorEl.classList.add('hidden');
btn.textContent = t('enterpriseShowcase.contactSent');
btn.className = btn.className.replace('bg-wm-green', 'bg-wm-card border border-wm-green text-wm-green');
} catch {
@@ -1083,7 +1096,11 @@ const EnterprisePage = () => (
<input type="text" name="name" placeholder={t('enterpriseShowcase.namePlaceholder')} required className="bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono" />
<input type="email" name="email" placeholder={t('enterpriseShowcase.emailPlaceholder')} required className="bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono" />
</div>
<input type="text" name="organization" placeholder={t('enterpriseShowcase.orgPlaceholder')} className="w-full bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono" />
<span data-form-error className="hidden text-red-400 text-xs font-mono block" />
<div className="grid grid-cols-2 gap-4">
<input type="text" name="organization" placeholder={t('enterpriseShowcase.orgPlaceholder')} required className="bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono" />
<input type="tel" name="phone" placeholder={t('enterpriseShowcase.phonePlaceholder')} required className="bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono" />
</div>
<textarea name="message" placeholder={t('enterpriseShowcase.messagePlaceholder')} rows={4} className="w-full bg-wm-bg border border-wm-border rounded-sm px-4 py-3 text-sm focus:outline-none focus:border-wm-green transition-colors font-mono resize-none" />
<div className="cf-turnstile mx-auto" />
<button type="submit" className="w-full bg-wm-green text-wm-bg py-3 rounded-sm font-mono text-sm uppercase tracking-wider font-bold hover:bg-green-400 transition-colors">

View File

@@ -153,7 +153,10 @@
"risk": "استشارات المخاطر",
"riskDesc": "محاكاة سيناريوهات، شخصيات مستثمرين، تقارير PDF/PowerPoint ذات علامة تجارية عند الطلب.",
"soc": "SOCs و CERT",
"socDesc": "طبقة تهديدات سيبرانية، تكامل SIEM، مراقبة شذوذات BGP، تغذيات برامج الفدية."
"socDesc": "طبقة تهديدات سيبرانية، تكامل SIEM، مراقبة شذوذات BGP، تغذيات برامج الفدية.",
"orgPlaceholder": "الشركة *",
"phonePlaceholder": "رقم الهاتف *",
"workEmailRequired": "يرجى استخدام بريدك الإلكتروني المهني"
},
"pricingTable": {
"title": "مقارنة الفئات",

View File

@@ -153,7 +153,10 @@
"risk": "Консултанти по риска",
"riskDesc": "Симулация на сценарии, инвеститорски профили, брандирани PDF/PowerPoint доклади при поискване.",
"soc": "SOCs и CERT",
"socDesc": "Слой на кибер заплахи, SIEM интеграция, мониторинг на BGP аномалии, ransomware потоци."
"socDesc": "Слой на кибер заплахи, SIEM интеграция, мониторинг на BGP аномалии, ransomware потоци.",
"orgPlaceholder": "Компания *",
"phonePlaceholder": "Телефонен номер *",
"workEmailRequired": "Моля, използвайте служебния си имейл"
},
"pricingTable": {
"title": "Сравнете плановете",

View File

@@ -153,7 +153,10 @@
"risk": "Rizikové poradenství",
"riskDesc": "Simulace scénářů, investorské persony, brandované PDF/PowerPoint reporty na vyžádání.",
"soc": "SOC a CERT",
"socDesc": "Vrstva kybernetických hrozeb, integrace SIEM, monitoring BGP anomálií, ransomware feedy."
"socDesc": "Vrstva kybernetických hrozeb, integrace SIEM, monitoring BGP anomálií, ransomware feedy.",
"orgPlaceholder": "Společnost *",
"phonePlaceholder": "Telefonní číslo *",
"workEmailRequired": "Použijte prosím svůj pracovní e-mail"
},
"pricingTable": {
"title": "Porovnání úrovní",

View File

@@ -153,7 +153,10 @@
"risk": "Risikoberatungen",
"riskDesc": "Szenariosimulation, Investoren-Personas, gebrandete PDF-/PowerPoint-Berichte auf Abruf.",
"soc": "SOCs & CERT",
"socDesc": "Cyberbedrohungs-Ebene, SIEM-Integration, BGP-Anomalie-Monitoring, Ransomware-Feeds."
"socDesc": "Cyberbedrohungs-Ebene, SIEM-Integration, BGP-Anomalie-Monitoring, Ransomware-Feeds.",
"orgPlaceholder": "Unternehmen *",
"phonePlaceholder": "Telefonnummer *",
"workEmailRequired": "Bitte verwenden Sie Ihre geschäftliche E-Mail"
},
"pricingTable": {
"title": "Tarife vergleichen",

View File

@@ -153,7 +153,10 @@
"risk": "Σύμβουλοι κινδύνου",
"riskDesc": "Προσομοίωση σεναρίων, προφίλ επενδυτών, επώνυμες αναφορές PDF/PowerPoint κατ' απαίτηση.",
"soc": "SOC & CERT",
"socDesc": "Επίπεδο κυβερνοαπειλών, ενσωμάτωση SIEM, παρακολούθηση ανωμαλιών BGP, ροές ransomware."
"socDesc": "Επίπεδο κυβερνοαπειλών, ενσωμάτωση SIEM, παρακολούθηση ανωμαλιών BGP, ροές ransomware.",
"orgPlaceholder": "Εταιρεία *",
"phonePlaceholder": "Τηλέφωνο *",
"workEmailRequired": "Χρησιμοποιήστε το επαγγελματικό σας email"
},
"pricingTable": {
"title": "Σύγκριση επιπέδων",

View File

@@ -215,8 +215,10 @@
"contactFormSubtitle": "Tell us about your organization and we'll get back to you within one business day.",
"namePlaceholder": "Your name",
"emailPlaceholder": "Work email",
"orgPlaceholder": "Organization",
"orgPlaceholder": "Company *",
"phonePlaceholder": "Phone number *",
"messagePlaceholder": "What are you looking for?",
"workEmailRequired": "Please use your work email address",
"submitContact": "Send Message",
"contactSending": "Sending...",
"contactSent": "Message sent. We'll be in touch.",

View File

@@ -153,7 +153,10 @@
"risk": "Consultoras de riesgo",
"riskDesc": "Simulación de escenarios, personas de inversor, informes PDF/PowerPoint personalizados bajo demanda.",
"soc": "SOCs & CERT",
"socDesc": "Capa de amenazas cibernéticas, integración SIEM, monitoreo de anomalías BGP, feeds de ransomware."
"socDesc": "Capa de amenazas cibernéticas, integración SIEM, monitoreo de anomalías BGP, feeds de ransomware.",
"orgPlaceholder": "Empresa *",
"phonePlaceholder": "Teléfono *",
"workEmailRequired": "Use su correo electrónico corporativo"
},
"pricingTable": {
"title": "Comparar planes",

View File

@@ -153,7 +153,10 @@
"risk": "Cabinets de conseil en risque",
"riskDesc": "Simulation de scénarios, personas investisseur, rapports PDF/PowerPoint personnalisés à la demande.",
"soc": "SOCs & CERT",
"socDesc": "Couche de menaces cyber, intégration SIEM, surveillance des anomalies BGP, flux ransomware."
"socDesc": "Couche de menaces cyber, intégration SIEM, surveillance des anomalies BGP, flux ransomware.",
"orgPlaceholder": "Entreprise *",
"phonePlaceholder": "Téléphone *",
"workEmailRequired": "Veuillez utiliser votre e-mail professionnel"
},
"pricingTable": {
"title": "Comparer les offres",

View File

@@ -153,7 +153,10 @@
"risk": "Consulenze di rischio",
"riskDesc": "Simulazione di scenari, persona investitore, report PDF/PowerPoint personalizzati on demand.",
"soc": "SOCs & CERT",
"socDesc": "Livello minacce cyber, integrazione SIEM, monitoraggio anomalie BGP, feed ransomware."
"socDesc": "Livello minacce cyber, integrazione SIEM, monitoraggio anomalie BGP, feed ransomware.",
"orgPlaceholder": "Azienda *",
"phonePlaceholder": "Telefono *",
"workEmailRequired": "Utilizzare l'e-mail aziendale"
},
"pricingTable": {
"title": "Confronta i piani",

View File

@@ -153,7 +153,10 @@
"risk": "リスクコンサルティング",
"riskDesc": "シナリオシミュレーション、投資家ペルソナ、ブランド付きPDF/PowerPointレポートをオンデマンドで。",
"soc": "SOCs・CERT",
"socDesc": "サイバー脅威レイヤー、SIEM統合、BGP異常モニタリング、ランサムウェアフィード。"
"socDesc": "サイバー脅威レイヤー、SIEM統合、BGP異常モニタリング、ランサムウェアフィード。",
"orgPlaceholder": "会社名 *",
"phonePlaceholder": "電話番号 *",
"workEmailRequired": "業務用メールアドレスをご使用ください"
},
"pricingTable": {
"title": "ティア比較",

View File

@@ -153,7 +153,10 @@
"risk": "리스크 컨설팅",
"riskDesc": "시나리오 시뮬레이션, 투자자 페르소나, 주문형 브랜드 PDF/PowerPoint 보고서.",
"soc": "SOCs 및 CERT",
"socDesc": "사이버 위협 레이어, SIEM 통합, BGP 이상 모니터링, 랜섬웨어 피드."
"socDesc": "사이버 위협 레이어, SIEM 통합, BGP 이상 모니터링, 랜섬웨어 피드.",
"orgPlaceholder": "회사명 *",
"phonePlaceholder": "전화번호 *",
"workEmailRequired": "업무용 이메일을 사용해 주세요"
},
"pricingTable": {
"title": "티어 비교",

View File

@@ -153,7 +153,10 @@
"risk": "Risicoadviesbureaus",
"riskDesc": "Scenariosimulatie, beleggersprofielen, branded PDF/PowerPoint-rapporten op aanvraag.",
"soc": "SOCs & CERT",
"socDesc": "Cyberdreigingslaag, SIEM-integratie, BGP-anomaliemonitoring, ransomware-feeds."
"socDesc": "Cyberdreigingslaag, SIEM-integratie, BGP-anomaliemonitoring, ransomware-feeds.",
"orgPlaceholder": "Bedrijf *",
"phonePlaceholder": "Telefoonnummer *",
"workEmailRequired": "Gebruik uw zakelijke e-mailadres"
},
"pricingTable": {
"title": "Vergelijk tiers",

View File

@@ -153,7 +153,10 @@
"risk": "Firmy doradztwa ryzyka",
"riskDesc": "Symulacja scenariuszy, profile inwestorów, brandowane raporty PDF/PowerPoint na żądanie.",
"soc": "SOCs i CERT",
"socDesc": "Warstwa zagrożeń cybernetycznych, integracja SIEM, monitoring anomalii BGP, kanały ransomware."
"socDesc": "Warstwa zagrożeń cybernetycznych, integracja SIEM, monitoring anomalii BGP, kanały ransomware.",
"orgPlaceholder": "Firma *",
"phonePlaceholder": "Numer telefonu *",
"workEmailRequired": "Użyj służbowego adresu e-mail"
},
"pricingTable": {
"title": "Porównaj plany",

View File

@@ -153,7 +153,10 @@
"risk": "Consultorias de risco",
"riskDesc": "Simulacao de cenarios, personas de investidor, relatorios PDF/PowerPoint personalizados sob demanda.",
"soc": "SOCs & CERT",
"socDesc": "Camada de ameacas cyber, integracao SIEM, monitoramento de anomalias BGP, feeds de ransomware."
"socDesc": "Camada de ameacas cyber, integracao SIEM, monitoramento de anomalias BGP, feeds de ransomware.",
"orgPlaceholder": "Empresa *",
"phonePlaceholder": "Telefone *",
"workEmailRequired": "Use seu e-mail corporativo"
},
"pricingTable": {
"title": "Comparar planos",

View File

@@ -153,7 +153,10 @@
"risk": "Consultanțe de risc",
"riskDesc": "Simulare scenarii, profiluri de investitori, rapoarte PDF/PowerPoint de brand la cerere.",
"soc": "SOCs și CERT",
"socDesc": "Strat de amenințări cibernetice, integrare SIEM, monitorizare anomalii BGP, fluxuri ransomware."
"socDesc": "Strat de amenințări cibernetice, integrare SIEM, monitorizare anomalii BGP, fluxuri ransomware.",
"orgPlaceholder": "Companie *",
"phonePlaceholder": "Telefon *",
"workEmailRequired": "Vă rugăm să utilizați e-mailul de serviciu"
},
"pricingTable": {
"title": "Compară planurile",

View File

@@ -153,7 +153,10 @@
"risk": "Консалтинг по рискам",
"riskDesc": "Моделирование сценариев, профили инвесторов, брендированные отчёты PDF/PowerPoint по запросу.",
"soc": "SOCs и CERT",
"socDesc": "Слой киберугроз, интеграция с SIEM, мониторинг аномалий BGP, потоки данных о вымогателях."
"socDesc": "Слой киберугроз, интеграция с SIEM, мониторинг аномалий BGP, потоки данных о вымогателях.",
"orgPlaceholder": "Компания *",
"phonePlaceholder": "Телефон *",
"workEmailRequired": "Используйте рабочий адрес электронной почты"
},
"pricingTable": {
"title": "Сравнение тарифов",

View File

@@ -153,7 +153,10 @@
"risk": "Riskkonsulter",
"riskDesc": "Scenariosimulering, investerarprofiler, varumärkta PDF/PowerPoint-rapporter på begäran.",
"soc": "SOCs & CERT",
"socDesc": "Cyberhotslager, SIEM-integration, BGP-anomaliövervakning, ransomware-flöden."
"socDesc": "Cyberhotslager, SIEM-integration, BGP-anomaliövervakning, ransomware-flöden.",
"orgPlaceholder": "Företag *",
"phonePlaceholder": "Telefonnummer *",
"workEmailRequired": "Använd din jobbmail"
},
"pricingTable": {
"title": "Jämför tiers",

View File

@@ -153,7 +153,10 @@
"risk": "ที่ปรึกษาด้านความเสี่ยง",
"riskDesc": "จำลองสถานการณ์ โปรไฟล์นักลงทุน รายงาน PDF/PowerPoint พร้อมแบรนด์ตามต้องการ",
"soc": "SOC & CERT",
"socDesc": "เลเยอร์ภัยคุกคามทางไซเบอร์ การรวม SIEM การตรวจสอบความผิดปกติ BGP ฟีด ransomware"
"socDesc": "เลเยอร์ภัยคุกคามทางไซเบอร์ การรวม SIEM การตรวจสอบความผิดปกติ BGP ฟีด ransomware",
"orgPlaceholder": "บริษัท *",
"phonePlaceholder": "หมายเลขโทรศัพท์ *",
"workEmailRequired": "กรุณาใช้อีเมลที่ทำงาน"
},
"pricingTable": {
"title": "เปรียบเทียบระดับ",

View File

@@ -153,7 +153,10 @@
"risk": "Risk danışmanlığı",
"riskDesc": "Senaryo simülasyonu, yatırımcı profilleri, talep üzerine markalı PDF/PowerPoint raporları.",
"soc": "SOC & CERT",
"socDesc": "Siber tehdit katmanı, SIEM entegrasyonu, BGP anomali izleme, ransomware akışları."
"socDesc": "Siber tehdit katmanı, SIEM entegrasyonu, BGP anomali izleme, ransomware akışları.",
"orgPlaceholder": "Şirket *",
"phonePlaceholder": "Telefon numarası *",
"workEmailRequired": "Lütfen iş e-postanızı kullanın"
},
"pricingTable": {
"title": "Seviyeleri karşılaştırın",

View File

@@ -153,7 +153,10 @@
"risk": "Tư vấn rủi ro",
"riskDesc": "Mô phỏng kịch bản, hồ sơ nhà đầu tư, báo cáo PDF/PowerPoint có thương hiệu theo yêu cầu.",
"soc": "SOC & CERT",
"socDesc": "Lớp mối đe dọa mạng, tích hợp SIEM, giám sát bất thường BGP, nguồn cấp ransomware."
"socDesc": "Lớp mối đe dọa mạng, tích hợp SIEM, giám sát bất thường BGP, nguồn cấp ransomware.",
"orgPlaceholder": "Công ty *",
"phonePlaceholder": "Số điện thoại *",
"workEmailRequired": "Vui lòng sử dụng email công việc"
},
"pricingTable": {
"title": "So sánh các gói",

View File

@@ -153,7 +153,10 @@
"risk": "风险咨询",
"riskDesc": "情景模拟、投资者画像、按需生成品牌化 PDF/PowerPoint 报告。",
"soc": "SOCs 与 CERT",
"socDesc": "网络威胁图层、SIEM 集成、BGP 异常监测、勒索软件数据流。"
"socDesc": "网络威胁图层、SIEM 集成、BGP 异常监测、勒索软件数据流。",
"orgPlaceholder": "公司 *",
"phonePlaceholder": "电话号码 *",
"workEmailRequired": "请使用工作邮箱"
},
"pricingTable": {
"title": "版本对比",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -118,8 +118,8 @@
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script type="module" crossorigin src="/pro/assets/index-LQSfc11x.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-LgCjjFIZ.css">
<script type="module" crossorigin src="/pro/assets/index-DpkIQTUK.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-BVBdmjMT.css">
</head>
<body>
<div id="root">

View File

@@ -21,6 +21,7 @@ function validBody(overrides = {}) {
name: 'Test User',
email: 'test@example.com',
organization: 'TestCorp',
phone: '+1 555 123 4567',
message: 'Hello',
source: 'enterprise-contact',
turnstileToken: 'valid-token',
@@ -90,6 +91,48 @@ describe('api/contact', () => {
assert.match(data.error, /name/i);
});
it('rejects free email domains with 422', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ email: 'test@gmail.com' })));
assert.equal(res.status, 422);
const data = await res.json();
assert.match(data.error, /work email/i);
});
it('rejects missing organization', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ organization: '' })));
assert.equal(res.status, 400);
const data = await res.json();
assert.match(data.error, /company/i);
});
it('rejects missing phone', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ phone: '' })));
assert.equal(res.status, 400);
const data = await res.json();
assert.match(data.error, /phone/i);
});
it('rejects invalid phone format', async () => {
globalThis.fetch = async (url) => {
if (url.includes('turnstile')) return new Response(JSON.stringify({ success: true }));
return new Response('{}');
};
const res = await handler(makeRequest(validBody({ phone: '(((((' })));
assert.equal(res.status, 400);
});
it('rejects disallowed origins', async () => {
const req = new Request('https://worldmonitor.app/api/contact', {
method: 'POST',