447 lines
21 KiB
TypeScript
Executable File
447 lines
21 KiB
TypeScript
Executable File
|
||
import React, { useState, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { X, Send, Check, Loader2, AlertCircle } from 'lucide-react';
|
||
import { ModalData } from '../types';
|
||
|
||
interface ModalProps {
|
||
data: ModalData;
|
||
onClose: () => void;
|
||
}
|
||
|
||
// --- КОНФИГУРАЦИЯ ДЛЯ ПРОДАКШЕНА ---
|
||
// В реальном проекте используйте переменные окружения (process.env.REACT_APP_...)
|
||
const UNISENDER_CONFIG = {
|
||
// Если true, запрос идет на ваш бэкенд (рекомендуется для PROD).
|
||
// Если false, запрос идет напрямую в Unisender (работает только если отключен CORS, для DEV).
|
||
USE_PROXY: true,
|
||
|
||
// URL вашего бэкенда, который перенаправит запрос в Unisender
|
||
PROXY_URL: '/api/unisender-proxy',
|
||
};
|
||
|
||
const formatText = (text: string, isLight: boolean) => {
|
||
const lines = text.split(/\r?\n/);
|
||
const elements: React.ReactNode[] = [];
|
||
let currentList: React.ReactNode[] = [];
|
||
|
||
const processInline = (str: string) => {
|
||
const parts = str.split(/(\*\*.*?\*\*)/g);
|
||
return parts.map((part, i) => {
|
||
if (part.startsWith('**') && part.endsWith('**')) {
|
||
return <strong key={i} className={`font-bold ${isLight ? 'text-black' : 'text-white'}`}>{part.slice(2, -2)}</strong>;
|
||
}
|
||
return part;
|
||
});
|
||
};
|
||
|
||
lines.forEach((line, i) => {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) {
|
||
if (currentList.length > 0) {
|
||
elements.push(<ul key={`list-${i}`} className="list-disc pl-5 my-4 space-y-2 marker:text-current">{currentList}</ul>);
|
||
currentList = [];
|
||
}
|
||
return;
|
||
}
|
||
if (trimmed.startsWith('* ')) {
|
||
currentList.push(<li key={`li-${i}`} className="pl-1">{processInline(trimmed.slice(2))}</li>);
|
||
} else {
|
||
if (currentList.length > 0) {
|
||
elements.push(<ul key={`list-${i}`} className="list-disc pl-5 my-4 space-y-2 marker:text-current">{currentList}</ul>);
|
||
currentList = [];
|
||
}
|
||
elements.push(<p key={`p-${i}`} className="mb-4 last:mb-0 leading-relaxed">{processInline(trimmed)}</p>);
|
||
}
|
||
});
|
||
|
||
if (currentList.length > 0) {
|
||
elements.push(<ul key="list-end" className="list-disc pl-5 my-4 space-y-2 marker:text-current">{currentList}</ul>);
|
||
}
|
||
|
||
return elements;
|
||
};
|
||
|
||
const Modal: React.FC<ModalProps> = ({ data, onClose }) => {
|
||
const isLight = data.theme === 'light';
|
||
|
||
// Form State
|
||
const [formState, setFormState] = useState({ name: '', phone: '', email: '', honeypot: '' });
|
||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||
|
||
// Phone Mask Handler
|
||
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
let input = e.target.value;
|
||
// Оставляем только цифры
|
||
let numbers = input.replace(/\D/g, '');
|
||
|
||
// Блокируем удаление префикса +7, если пользователь пытается стереть все
|
||
if (!numbers) {
|
||
setFormState(prev => ({ ...prev, phone: '' }));
|
||
return;
|
||
}
|
||
|
||
// Если ввод начинается с 7, 8 или 9 - считаем это российским номером
|
||
if (['7', '8', '9'].includes(numbers[0])) {
|
||
// Нормализация: если первая цифра 9, добавляем 7 в начало. Если 8 - меняем на 7.
|
||
if (numbers[0] === '9') numbers = '7' + numbers;
|
||
if (numbers[0] === '8') numbers = '7' + numbers.slice(1);
|
||
|
||
// Обрезаем до 11 символов (7 + 10 цифр)
|
||
numbers = numbers.slice(0, 11);
|
||
|
||
// Формирование маски +7 (XXX) XXX-XX-XX
|
||
let formatted = '+7';
|
||
if (numbers.length > 1) formatted += ' (' + numbers.slice(1, 4);
|
||
if (numbers.length >= 5) formatted += ') ' + numbers.slice(4, 7);
|
||
if (numbers.length >= 8) formatted += '-' + numbers.slice(7, 9);
|
||
if (numbers.length >= 10) formatted += '-' + numbers.slice(9, 11);
|
||
|
||
setFormState(prev => ({ ...prev, phone: formatted }));
|
||
} else {
|
||
// Для международных номеров просто добавляем +
|
||
setFormState(prev => ({ ...prev, phone: '+' + numbers }));
|
||
}
|
||
};
|
||
|
||
// Validation
|
||
const validateForm = () => {
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
// Проверка телефона: должен содержать минимум 10 цифр.
|
||
// Российский номер в маске +7 (XXX) XXX-XX-XX содержит 11 цифр.
|
||
const phoneDigits = formState.phone.replace(/\D/g, '');
|
||
const isRussianLike = formState.phone.startsWith('+7');
|
||
|
||
if (!formState.name.trim()) return "Введите имя";
|
||
if (!emailRegex.test(formState.email)) return "Некорректный Email";
|
||
|
||
if (!formState.phone) return "Введите телефон";
|
||
if (isRussianLike && phoneDigits.length !== 11) return "Введите полный номер телефона";
|
||
if (phoneDigits.length < 10) return "Слишком короткий номер телефона";
|
||
|
||
return null;
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setErrorMessage(null);
|
||
|
||
// 1. Anti-spam check (Honeypot)
|
||
if (formState.honeypot) {
|
||
// Бот заполнил скрытое поле, имитируем успех, но не отправляем
|
||
console.warn("Spam detected");
|
||
setIsSubmitted(true);
|
||
return;
|
||
}
|
||
|
||
// 2. Validation
|
||
const validationError = validateForm();
|
||
if (validationError) {
|
||
setErrorMessage(validationError);
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
const formData = new URLSearchParams();
|
||
|
||
// --- Логика формирования запроса для Unisender ---
|
||
// Если используем прокси, API ключ лучше добавлять на бэкенде, но здесь показан полный payload
|
||
formData.append('format', 'json');
|
||
formData.append('api_key', UNISENDER_CONFIG.API_KEY);
|
||
|
||
// Настройка полей
|
||
formData.append('field_names[0]', 'email');
|
||
formData.append('field_names[1]', 'phone');
|
||
formData.append('field_names[2]', 'Name');
|
||
formData.append('field_names[3]', 'email_list_ids');
|
||
|
||
// Значения полей
|
||
formData.append('data[0][0]', formState.email);
|
||
formData.append('data[0][1]', formState.phone);
|
||
formData.append('data[0][2]', formState.name);
|
||
formData.append('data[0][3]', UNISENDER_CONFIG.LIST_ID);
|
||
|
||
// Опции
|
||
// formData.append('double_optin', '3'); // Раскомментировать, если нужно письмо-подтверждение
|
||
// NOTE: Параметр 'overwrite' удален, так как он вызывает ошибку invalid_arg в методе importContacts
|
||
|
||
const targetUrl = UNISENDER_CONFIG.USE_PROXY
|
||
? UNISENDER_CONFIG.PROXY_URL
|
||
: UNISENDER_CONFIG.DIRECT_URL;
|
||
|
||
console.log(`Sending to: ${targetUrl} (Proxy Mode: ${UNISENDER_CONFIG.USE_PROXY})`);
|
||
|
||
const response = await fetch(targetUrl, {
|
||
method: 'POST',
|
||
body: formData,
|
||
headers: {
|
||
'Content-Type': 'application/x-www-form-urlencoded',
|
||
}
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP Error: ${response.status}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Проверяем ошибки самого API Unisender
|
||
if (result.error) {
|
||
throw new Error(`Unisender Error: ${result.error} (code: ${result.code})`);
|
||
}
|
||
|
||
setIsSubmitted(true);
|
||
|
||
} catch (error: any) {
|
||
console.error('Submission error:', error);
|
||
|
||
// Удобное сообщение для разработчика/пользователя
|
||
if (error.message && error.message.includes('Failed to fetch')) {
|
||
setErrorMessage('Ошибка CORS или Сети. Для продакшена требуется серверный прокси, т.к. браузер блокирует прямые запросы к Unisender.');
|
||
} else {
|
||
setErrorMessage(error.message || 'Произошла ошибка при отправке данных.');
|
||
}
|
||
|
||
// --- DEV ONLY: Фейковый успех для демонстрации интерфейса, если ключи не настроены ---
|
||
if (UNISENDER_CONFIG.API_KEY === 'YOUR_PRODUCTION_API_KEY') {
|
||
console.warn("DEV MODE: Simulating success because API Key is generic.");
|
||
setTimeout(() => {
|
||
setErrorMessage(null);
|
||
setIsSubmitted(true);
|
||
}, 1500);
|
||
}
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={`fixed inset-0 z-[100] flex items-center justify-center p-4 md:p-8 ${isLight ? 'text-[#1a1a1a]' : 'text-white'}`}>
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={onClose}
|
||
className="absolute inset-0 bg-black/60 backdrop-blur-sm cursor-pointer"
|
||
/>
|
||
|
||
<motion.div
|
||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||
className={`relative w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-sm border shadow-2xl flex flex-col
|
||
${isLight
|
||
? 'bg-[#e3e1db] border-[#c4c3be]'
|
||
: 'bg-[#161618] border-[#333]'
|
||
}`}
|
||
>
|
||
<div className={`sticky top-0 z-10 px-6 py-4 border-b flex justify-between items-center
|
||
${isLight
|
||
? 'bg-[#e3e1db]/90 border-[#c4c3be]'
|
||
: 'bg-[#161618]/90 border-[#333]'
|
||
} backdrop-blur-md`}
|
||
>
|
||
<h2 className="text-xl md:text-3xl font-black uppercase tracking-tight">{data.title}</h2>
|
||
<button
|
||
onClick={onClose}
|
||
className={`p-2 rounded-full transition-colors ${isLight ? 'hover:bg-black/10' : 'hover:bg-white/10'}`}
|
||
>
|
||
<X className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 md:p-10">
|
||
|
||
{data.type === 'article' && data.content.text && (
|
||
<div className={`prose max-w-none font-light ${isLight ? 'text-[#5c5c55]' : 'text-[#888899]'}`}>
|
||
{formatText(data.content.text, isLight)}
|
||
</div>
|
||
)}
|
||
|
||
{data.type === 'table' && data.content.headers && data.content.rows && (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-left border-collapse">
|
||
<thead>
|
||
<tr className={`border-b-2 ${isLight ? 'border-black/10' : 'border-white/10'}`}>
|
||
{data.content.headers.map((h, i) => (
|
||
<th key={i} className="py-3 px-4 font-mono text-sm uppercase opacity-70">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.content.rows.map((row, i) => (
|
||
<tr key={i} className={`border-b ${isLight ? 'border-black/5 hover:bg-black/5' : 'border-white/5 hover:bg-white/5'} transition-colors`}>
|
||
{row.map((cell, j) => (
|
||
<td key={j} className="py-3 px-4">{cell}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{data.type === 'list' && data.content.items && (
|
||
<ul className="space-y-4">
|
||
{data.content.items.map((item, i) => (
|
||
<li key={i} className={`flex items-start gap-4 p-4 border rounded-sm ${isLight ? 'border-black/10 bg-black/5' : 'border-white/10 bg-white/5'}`}>
|
||
<span className={`font-mono text-xs mt-1 ${isLight ? 'text-[#008f72]' : 'text-[#20e3b2]'}`}>{(i + 1).toString().padStart(2, '0')} //</span>
|
||
<span className="text-lg">{item}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{data.type === 'form' && (
|
||
<div className="w-full max-w-2xl mx-auto">
|
||
<AnimatePresence mode="wait">
|
||
{!isSubmitted ? (
|
||
<motion.form
|
||
key="form"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onSubmit={handleSubmit}
|
||
className="space-y-6"
|
||
autoComplete="off"
|
||
>
|
||
<p className={`mb-8 ${isLight ? 'text-[#5c5c55]' : 'text-[#888899]'}`}>
|
||
{data.content.text || 'Заполните форму, и мы свяжемся с вами для обсуждения деталей внедрения.'}
|
||
</p>
|
||
|
||
{/* Honeypot Field (Invisible to humans) */}
|
||
<input
|
||
type="text"
|
||
name="details_confirm"
|
||
value={formState.honeypot}
|
||
onChange={(e) => setFormState({...formState, honeypot: e.target.value})}
|
||
style={{ display: 'none' }}
|
||
tabIndex={-1}
|
||
autoComplete="off"
|
||
/>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-mono uppercase tracking-widest opacity-70">ФИО *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
name="name"
|
||
disabled={isSubmitting}
|
||
value={formState.name}
|
||
onChange={(e) => setFormState({...formState, name: e.target.value})}
|
||
className={`w-full p-4 bg-transparent border rounded-sm outline-none focus:border-[#ff4b4b] transition-colors disabled:opacity-50
|
||
${isLight ? 'border-black/20 text-black' : 'border-white/20 text-white'}`}
|
||
placeholder="Иванов Иван Иванович"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-mono uppercase tracking-widest opacity-70">Телефон *</label>
|
||
<input
|
||
type="tel"
|
||
required
|
||
name="phone"
|
||
disabled={isSubmitting}
|
||
value={formState.phone}
|
||
onChange={handlePhoneChange}
|
||
className={`w-full p-4 bg-transparent border rounded-sm outline-none focus:border-[#ff4b4b] transition-colors disabled:opacity-50
|
||
${isLight ? 'border-black/20 text-black' : 'border-white/20 text-white'}`}
|
||
placeholder="+7 (999) 000-00-00"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-mono uppercase tracking-widest opacity-70">Email *</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
name="email"
|
||
disabled={isSubmitting}
|
||
value={formState.email}
|
||
onChange={(e) => setFormState({...formState, email: e.target.value})}
|
||
className={`w-full p-4 bg-transparent border rounded-sm outline-none focus:border-[#ff4b4b] transition-colors disabled:opacity-50
|
||
${isLight ? 'border-black/20 text-black' : 'border-white/20 text-white'}`}
|
||
placeholder="corp@gov.rb.ru"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{errorMessage && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: 'auto' }}
|
||
className="p-4 bg-red-500/10 border border-red-500/30 text-red-500 text-sm flex items-start gap-3 rounded-sm"
|
||
>
|
||
<AlertCircle className="w-5 h-5 shrink-0" />
|
||
<span>{errorMessage}</span>
|
||
</motion.div>
|
||
)}
|
||
|
||
<div className="pt-4">
|
||
<button
|
||
type="submit"
|
||
disabled={isSubmitting}
|
||
className={`flex items-center justify-center gap-3 w-full p-4 font-bold uppercase tracking-widest text-white transition-all
|
||
bg-[#ff4b4b] hover:bg-[#ff4b4b]/80 disabled:opacity-70 disabled:cursor-not-allowed`}
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
Отправка...
|
||
</>
|
||
) : (
|
||
<>
|
||
Отправить заявку
|
||
<Send className="w-4 h-4" />
|
||
</>
|
||
)}
|
||
</button>
|
||
<p className="text-[10px] opacity-50 mt-4 text-center leading-normal">
|
||
Нажимая кнопку, вы подтверждаете согласие с <span className="underline cursor-pointer">Политикой обработки данных</span> и даете разрешение на коммуникацию.
|
||
</p>
|
||
</div>
|
||
</motion.form>
|
||
) : (
|
||
<motion.div
|
||
key="success"
|
||
initial={{ opacity: 0, scale: 0.9 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
className="flex flex-col items-center justify-center text-center py-12"
|
||
>
|
||
<div className="w-20 h-20 bg-[#20e3b2]/20 rounded-full flex items-center justify-center mb-6 text-[#20e3b2]">
|
||
<Check className="w-10 h-10" />
|
||
</div>
|
||
<h3 className="text-2xl font-bold mb-2">Заявка принята</h3>
|
||
<p className={`${isLight ? 'text-[#5c5c55]' : 'text-[#888899]'}`}>
|
||
Успешно. <br/>Наши специалисты свяжутся с вами в ближайшее время.
|
||
</p>
|
||
<button
|
||
onClick={onClose}
|
||
className={`mt-8 px-8 py-3 border rounded-sm uppercase text-xs font-mono hover:bg-[#20e3b2] hover:text-white hover:border-[#20e3b2] transition-colors
|
||
${isLight ? 'border-black/20' : 'border-white/20'}`}
|
||
>
|
||
Закрыть окно
|
||
</button>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
|
||
<div className={`p-2 text-right text-[10px] font-mono opacity-40 uppercase tracking-widest
|
||
${isLight ? 'bg-black/5' : 'bg-white/5'}`}>
|
||
iiEasy Secure Modal // System v2.5
|
||
</div>
|
||
</motion.div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Modal; |