Files
gov/components/Modal.tsx

447 lines
21 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;