- Backend: config.py — добавлена настройка n8n_profile_update_webhook (читает N8N_PROFILE_UPDATE_WEBHOOK из .env). - Backend: profile.py — общий хелпер _resolve_profile_identity(), обновлён _fetch_contact(), новый эндпоинт POST /api/v1/profile/contact/update, который отправляет данные профиля в N8N_PROFILE_UPDATE_WEBHOOK. - Frontend: Profile.tsx — если verification === "0", показывается форма редактирования (все поля, кроме телефона, обязательны к заполнению, телефон только для чтения) и сохранение вызывает /api/v1/profile/contact/update; иначе профиль остаётся только для просмотра.
254 lines
9.8 KiB
TypeScript
254 lines
9.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd';
|
||
import { User } from 'lucide-react';
|
||
import './Profile.css';
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
/** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */
|
||
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [
|
||
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия', editable: true },
|
||
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя', editable: true },
|
||
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество', editable: true },
|
||
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения', editable: true },
|
||
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения', editable: true },
|
||
{ key: 'inn', keys: ['inn'], label: 'ИНН', editable: true },
|
||
{ key: 'email', keys: ['email'], label: 'Электронная почта', editable: true },
|
||
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации', editable: true },
|
||
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес', editable: true },
|
||
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения', editable: true },
|
||
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон', editable: false },
|
||
];
|
||
|
||
function getValue(obj: Record<string, unknown>, keys: string[]): string {
|
||
for (const k of keys) {
|
||
const v = obj[k];
|
||
if (v != null && String(v).trim() !== '') return String(v).trim();
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */
|
||
function canEditProfile(contact: Record<string, unknown>): boolean {
|
||
const v = contact?.verification ?? contact?.Verification;
|
||
return v === '0' || v === 0;
|
||
}
|
||
|
||
interface ProfileProps {
|
||
onNavigate?: (path: string) => void;
|
||
}
|
||
|
||
export default function Profile({ onNavigate }: ProfileProps) {
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
|
||
const [form] = Form.useForm();
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
const token = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|
||
|| localStorage.getItem('session_token');
|
||
if (!token) {
|
||
setLoading(false);
|
||
onNavigate?.('/hello');
|
||
return;
|
||
}
|
||
const entryChannel =
|
||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||
: 'web';
|
||
const chatId = (() => {
|
||
if (typeof window === 'undefined') return undefined;
|
||
const tg = (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id;
|
||
if (tg != null) return String(tg);
|
||
const max = (window as any).WebApp?.initDataUnsafe?.user?.id;
|
||
if (max != null) return String(max);
|
||
return undefined;
|
||
})();
|
||
setLoading(true);
|
||
setError(null);
|
||
const params = new URLSearchParams({ session_token: token, entry_channel: entryChannel });
|
||
if (chatId) params.set('chat_id', chatId);
|
||
fetch(`/api/v1/profile/contact?${params.toString()}`)
|
||
.then((res) => {
|
||
if (!res.ok) {
|
||
if (res.status === 401) {
|
||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||
localStorage.removeItem('session_token');
|
||
throw new Error('Сессия истекла');
|
||
}
|
||
throw new Error('Ошибка загрузки');
|
||
}
|
||
return res.json();
|
||
})
|
||
.then((data: { items?: unknown[] }) => {
|
||
if (cancelled) return;
|
||
const items = Array.isArray(data?.items) ? data.items : [];
|
||
const first = items.length > 0 && typeof items[0] === 'object' && items[0] !== null
|
||
? (items[0] as Record<string, unknown>)
|
||
: null;
|
||
setContact(first);
|
||
if (first && canEditProfile(first)) {
|
||
const initial: Record<string, string> = {};
|
||
PROFILE_FIELDS.forEach(({ key, keys }) => {
|
||
initial[key] = getValue(first, keys) || '';
|
||
});
|
||
form.setFieldsValue(initial);
|
||
}
|
||
})
|
||
.catch((e) => {
|
||
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
|
||
})
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [onNavigate, form]);
|
||
|
||
const handleSave = async () => {
|
||
if (!contact || !canEditProfile(contact)) return;
|
||
const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token');
|
||
if (!token) {
|
||
message.error('Сессия истекла');
|
||
onNavigate?.('/hello');
|
||
return;
|
||
}
|
||
const entryChannel =
|
||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||
: 'web';
|
||
let values: Record<string, string>;
|
||
try {
|
||
values = await form.validateFields();
|
||
} catch {
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch('/api/v1/profile/contact/update', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
session_token: token,
|
||
entry_channel: entryChannel,
|
||
last_name: values.last_name ?? '',
|
||
first_name: values.first_name ?? '',
|
||
middle_name: values.middle_name ?? '',
|
||
birth_date: values.birth_date ?? '',
|
||
birth_place: values.birth_place ?? '',
|
||
inn: values.inn ?? '',
|
||
email: values.email ?? '',
|
||
registration_address: values.registration_address ?? '',
|
||
mailing_address: values.mailing_address ?? '',
|
||
bank_for_compensation: values.bank_for_compensation ?? '',
|
||
phone: getValue(contact, ['phone', 'mobile', 'mobile_phone']) || undefined,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
const detail = typeof data?.detail === 'string' ? data.detail : data?.detail?.[0]?.msg || 'Не удалось сохранить профиль';
|
||
message.error(detail);
|
||
return;
|
||
}
|
||
message.success('Профиль сохранён');
|
||
setContact({ ...contact, ...values });
|
||
} catch (e) {
|
||
message.error('Не удалось сохранить профиль, попробуйте позже');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="profile-page">
|
||
<Card className="profile-card">
|
||
<div className="profile-loading">
|
||
<Spin size="large" tip="Загрузка профиля..." />
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="profile-page">
|
||
<Card className="profile-card">
|
||
<Title level={4}>Профиль</Title>
|
||
<Text type="danger">{error}</Text>
|
||
<div style={{ marginTop: 16 }}>
|
||
<Button type="primary" onClick={() => onNavigate?.('/hello')}>
|
||
Войти снова
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!contact) {
|
||
return (
|
||
<div className="profile-page">
|
||
<Card className="profile-card">
|
||
<Title level={4}>Профиль</Title>
|
||
<Text type="secondary">Контактных данных пока нет. Они появятся после обработки ваших обращений.</Text>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const canEdit = canEditProfile(contact);
|
||
|
||
if (canEdit) {
|
||
return (
|
||
<div className="profile-page">
|
||
<Card
|
||
className="profile-card"
|
||
title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}
|
||
extra={onNavigate ? <Button type="link" onClick={() => onNavigate('/')}>Домой</Button> : null}
|
||
>
|
||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||
{PROFILE_FIELDS.map(({ key, keys, label, editable }) => (
|
||
<Form.Item
|
||
key={key}
|
||
name={key}
|
||
label={label}
|
||
rules={editable ? [{ required: true, message: 'Обязательное поле' }] : undefined}
|
||
>
|
||
<Input disabled={!editable} placeholder={editable ? undefined : '—'} />
|
||
</Form.Item>
|
||
))}
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit" loading={saving}>
|
||
Сохранить изменения
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const items = PROFILE_FIELDS.map(({ keys, label }) => ({
|
||
key: keys[0],
|
||
label,
|
||
children: getValue(contact, keys) || '—',
|
||
}));
|
||
|
||
return (
|
||
<div className="profile-page">
|
||
<Card className="profile-card" title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}>
|
||
<Descriptions column={1} size="small" bordered>
|
||
{items.map((item) => (
|
||
<Descriptions.Item key={item.key} label={item.label}>
|
||
{item.children}
|
||
</Descriptions.Item>
|
||
))}
|
||
</Descriptions>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|