Files
gooseek/backend/webui/src/components/settings/AccountTab.tsx
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

321 lines
13 KiB
TypeScript
Raw 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.
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Key, LogOut, Trash2, Camera, Check, X, Loader2 } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext';
import { updateProfile, changePassword, logoutAll, UpdateProfileRequest, ChangePasswordRequest } from '@/lib/auth';
export function AccountTab() {
const { user, logout, refreshUser } = useAuth();
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [profileForm, setProfileForm] = useState({ name: user?.name || '' });
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleProfileSave = async () => {
if (!profileForm.name.trim()) {
setError('Имя не может быть пустым');
return;
}
setLoading('profile');
setError(null);
try {
await updateProfile({ name: profileForm.name } as UpdateProfileRequest);
await refreshUser();
setIsEditingProfile(false);
setSuccess('Профиль обновлён');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка обновления профиля');
} finally {
setLoading(null);
}
};
const handlePasswordChange = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setError('Пароли не совпадают');
return;
}
if (passwordForm.newPassword.length < 8) {
setError('Пароль должен быть минимум 8 символов');
return;
}
setLoading('password');
setError(null);
try {
await changePassword({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
} as ChangePasswordRequest);
setIsChangingPassword(false);
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
setSuccess('Пароль изменён');
setTimeout(() => setSuccess(null), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка смены пароля');
} finally {
setLoading(null);
}
};
const handleLogoutAll = async () => {
setLoading('logoutAll');
try {
await logoutAll();
window.location.href = '/';
} catch {
setError('Ошибка выхода');
} finally {
setLoading(null);
}
};
const handleLogout = async () => {
setLoading('logout');
try {
await logout();
window.location.href = '/';
} catch {
setError('Ошибка выхода');
} finally {
setLoading(null);
}
};
if (!user) return null;
return (
<div className="space-y-6">
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-error/10 border border-error/20 rounded-xl text-error text-sm"
>
{error}
</motion.div>
)}
{success && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="p-3 bg-success/10 border border-success/20 rounded-xl text-success text-sm"
>
{success}
</motion.div>
)}
{/* Profile Section */}
<Section title="Профиль" icon={User}>
<div className="p-4 bg-elevated/40 border border-border/40 rounded-xl">
<div className="flex items-start gap-4">
<div className="relative">
<div className="w-16 h-16 rounded-full bg-accent/20 flex items-center justify-center">
{user.avatar ? (
<img src={user.avatar} alt={user.name} className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-2xl font-semibold text-accent">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<button className="absolute -bottom-1 -right-1 w-7 h-7 rounded-full bg-surface border border-border flex items-center justify-center hover:bg-surface/80 transition-all">
<Camera className="w-3.5 h-3.5 text-muted" />
</button>
</div>
<div className="flex-1 min-w-0">
{isEditingProfile ? (
<div className="space-y-3">
<input
type="text"
value={profileForm.name}
onChange={(e) => setProfileForm({ ...profileForm, name: e.target.value })}
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-sm text-primary focus:outline-none focus:border-accent"
placeholder="Ваше имя"
/>
<div className="flex gap-2">
<button
onClick={handleProfileSave}
disabled={loading === 'profile'}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-white rounded-lg text-sm hover:bg-accent-hover transition-all disabled:opacity-50"
>
{loading === 'profile' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Сохранить
</button>
<button
onClick={() => { setIsEditingProfile(false); setProfileForm({ name: user.name }); }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border text-secondary rounded-lg text-sm hover:text-primary transition-all"
>
<X className="w-4 h-4" />
Отмена
</button>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-primary">{user.name}</h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
user.tier === 'business'
? 'bg-amber-500/15 text-amber-600'
: user.tier === 'pro'
? 'bg-accent/20 text-accent'
: 'bg-surface text-muted'
}`}>
{user.tier === 'business' ? 'Business' : user.tier === 'pro' ? 'Pro' : 'Free'}
</span>
</div>
<p className="text-sm text-muted mt-0.5">{user.email}</p>
<button
onClick={() => setIsEditingProfile(true)}
className="mt-2 text-sm text-accent hover:text-accent-hover transition-colors"
>
Редактировать профиль
</button>
</>
)}
</div>
</div>
</div>
</Section>
{/* Email Section */}
<Section title="Email" icon={Mail}>
<div className="p-4 bg-elevated/40 border border-border/40 rounded-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-primary">{user.email}</p>
<p className="text-xs text-muted mt-0.5">
{user.emailVerified ? (
<span className="text-success">Подтверждён</span>
) : (
<span className="text-warning">Не подтверждён</span>
)}
</p>
</div>
{!user.emailVerified && (
<button className="px-3 py-1.5 text-sm text-accent hover:text-accent-hover transition-colors">
Подтвердить
</button>
)}
</div>
</div>
</Section>
{/* Password Section */}
<Section title="Безопасность" icon={Key}>
<div className="p-4 bg-elevated/40 border border-border/40 rounded-xl">
{isChangingPassword ? (
<div className="space-y-3">
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-sm text-primary focus:outline-none focus:border-accent"
placeholder="Текущий пароль"
/>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-sm text-primary focus:outline-none focus:border-accent"
placeholder="Новый пароль"
/>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
className="w-full px-3 py-2 bg-surface border border-border rounded-lg text-sm text-primary focus:outline-none focus:border-accent"
placeholder="Подтвердите пароль"
/>
<div className="flex gap-2">
<button
onClick={handlePasswordChange}
disabled={loading === 'password'}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-white rounded-lg text-sm hover:bg-accent-hover transition-all disabled:opacity-50"
>
{loading === 'password' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Сохранить
</button>
<button
onClick={() => { setIsChangingPassword(false); setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); }}
className="flex items-center gap-1.5 px-3 py-1.5 bg-surface border border-border text-secondary rounded-lg text-sm hover:text-primary transition-all"
>
<X className="w-4 h-4" />
Отмена
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-primary">Пароль</p>
<p className="text-xs text-muted mt-0.5"></p>
</div>
<button
onClick={() => setIsChangingPassword(true)}
className="px-3 py-1.5 text-sm text-accent hover:text-accent-hover transition-colors"
>
Изменить
</button>
</div>
)}
</div>
</Section>
{/* Session Section */}
<Section title="Сессии" icon={LogOut}>
<div className="space-y-3">
<button
onClick={handleLogout}
disabled={loading === 'logout'}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm bg-surface/40 border border-border/50 text-secondary rounded-xl hover:bg-surface/60 hover:border-border hover:text-primary transition-all disabled:opacity-50"
>
{loading === 'logout' ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogOut className="w-4 h-4" />}
Выйти из аккаунта
</button>
<button
onClick={handleLogoutAll}
disabled={loading === 'logoutAll'}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm bg-warning/5 border border-warning/20 text-warning rounded-xl hover:bg-warning/10 hover:border-warning/30 transition-all disabled:opacity-50"
>
{loading === 'logoutAll' ? <Loader2 className="w-4 h-4 animate-spin" /> : <LogOut className="w-4 h-4" />}
Выйти со всех устройств
</button>
</div>
</Section>
{/* Delete Account */}
<Section title="Опасная зона" icon={Trash2}>
<button className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm bg-error/5 border border-error/20 text-error rounded-xl hover:bg-error/10 hover:border-error/30 transition-all">
<Trash2 className="w-4 h-4" />
Удалить аккаунт
</button>
</Section>
</div>
);
}
function Section({ title, icon: Icon, children }: { title: string; icon: React.ElementType; children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-3"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted" />
<h2 className="text-xs font-semibold text-muted uppercase tracking-wider">{title}</h2>
</div>
{children}
</motion.div>
);
}