Compare commits

..

4 Commits

Author SHA1 Message Date
Redsandyg
582f5330c8 Добавлены компоненты для управления профилем пользователя, включая редактирование личной информации, смену пароля и настройки уведомлений. Обновлен контекст пользователя для хранения имени и фамилии. Обновлены стили для страницы аккаунта и компонентов. 2025-06-03 20:38:11 +03:00
Redsandyg
0e024b00a1 Добавлено сохранение логина пользователя в куки при авторизации и отображение его в навигации. Обновлен компонент Navigation для отображения первых двух букв логина или имени по умолчанию. 2025-06-03 17:28:15 +03:00
Redsandyg
af0c52dbb6 Добавлен компонент AuthGuard для защиты страниц от неавторизованных пользователей. Обновлен middleware для редиректа на страницу авторизации при отсутствии токена. Обернуты страницы дашборда, аккаунта, статистики и финансов в AuthGuard для проверки авторизации. 2025-06-03 14:58:35 +03:00
Redsandyg
6ab1a42be7 Добавлен middleware для обработки авторизации, страница входа с формой и валидацией, а также стили для страницы авторизации. Обновлены зависимости js-cookie и @types/js-cookie. 2025-06-03 12:07:03 +03:00
23 changed files with 1442 additions and 150 deletions

21
middleware.ts Normal file
View File

@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Получаем access_token из куков (SSR)
const token = request.cookies.get('access_token');
// Если не на /auth и нет токена, редиректим на /auth
if (pathname !== '/auth' && !token) {
return NextResponse.redirect(new URL('/auth', request.url));
}
// Если на /auth и токен есть, редиректим на главную
if (pathname === '/auth' && token) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

18
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.0",
"@types/react-datepicker": "^6.2.0",
"js-cookie": "^3.0.5",
"material-react-table": "^3.2.1",
"next": "15.3.3",
"react": "^19.0.0",
@ -20,6 +21,7 @@
"recharts": "^2.15.3"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
@ -1497,6 +1499,13 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.57",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.57.tgz",
@ -2067,6 +2076,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -13,6 +13,7 @@
"@mui/material": "^7.1.0",
"@mui/x-data-grid": "^8.5.0",
"@types/react-datepicker": "^6.2.0",
"js-cookie": "^3.0.5",
"material-react-table": "^3.2.1",
"next": "15.3.3",
"react": "^19.0.0",
@ -21,6 +22,7 @@
"recharts": "^2.15.3"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@ -1,50 +1,86 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import AuthGuard from "../../components/AuthGuard";
import styles from "../../styles/dashboard.module.css";
import navStyles from "../../styles/navigation.module.css";
import accountStyles from "../../styles/account.module.css";
import {
Person as UserIcon,
Email as MailIcon,
Phone as PhoneIcon,
LocationOn as MapPinIcon,
CalendarMonth as CalendarIcon,
Business as CompanyIcon,
Work as WorkIcon,
Edit as EditIcon,
Save as SaveIcon,
Lock as KeyIcon,
Visibility as EyeIcon,
VisibilityOff as EyeOffIcon,
Notifications as BellIcon
} from "@mui/icons-material";
import AccountProfile from "../../components/AccountProfile";
import AccountSecurity from "../../components/AccountSecurity";
import AccountNotifications from "../../components/AccountNotifications";
interface AccountData {
id: number;
login: string;
name: string | null;
email: string | null;
balance: number;
}
const initialNotifications = {
emailNotifications: true,
smsNotifications: false,
pushNotifications: true,
weeklyReports: true,
payoutAlerts: true
};
export default function AccountPage() {
const [account, setAccount] = useState<AccountData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [activeTab, setActiveTab] = useState("profile");
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: ""
});
const [notifications, setNotifications] = useState(initialNotifications);
useEffect(() => {
fetch("/api/account")
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных аккаунта");
return res.json();
})
.then((data) => {
setAccount(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, []);
if (loading) return <div className={styles.dashboard}>Загрузка...</div>;
if (error) return <div className={styles.dashboard}>Ошибка: {error}</div>;
if (!account) return <div className={styles.dashboard}>Нет данных</div>;
const tabs = [
{ id: "profile", label: "Профиль", icon: <UserIcon fontSize="small" /> },
{ id: "security", label: "Безопасность", icon: <KeyIcon fontSize="small" /> },
{ id: "notifications", label: "Уведомления", icon: <BellIcon fontSize="small" /> },
];
return (
<div className={styles.dashboard}>
<h1 className={styles.title}>Аккаунт</h1>
<div className={styles.card} style={{ maxWidth: 400, margin: "0 auto" }}>
<div><b>ID:</b> {account.id}</div>
<div><b>Логин:</b> {account.login}</div>
<div><b>Имя:</b> {account.name || "-"}</div>
<div><b>Email:</b> {account.email || "-"}</div>
<div><b>Баланс:</b> {account.balance.toLocaleString("ru-RU", { style: "currency", currency: "RUB" })}</div>
<AuthGuard>
<div className={styles.dashboard}>
<h1 className={styles.title}>Аккаунт</h1>
<div className={accountStyles.accountTabsNav}>
<nav className={accountStyles.accountTabsNav}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={
activeTab === tab.id
? `${accountStyles.accountTabsButton} ${accountStyles.accountTabsButtonActive}`
: accountStyles.accountTabsButton
}
>
{tab.icon}
{tab.label}
</button>
))}
</nav>
</div>
{activeTab === "profile" && (
<AccountProfile />
)}
{activeTab === "security" && (
<AccountSecurity />
)}
{activeTab === "notifications" && (
<AccountNotifications notifications={notifications} setNotifications={setNotifications} />
)}
</div>
</div>
</AuthGuard>
);
}

104
src/app/auth/page.tsx Normal file
View File

@ -0,0 +1,104 @@
"use client";
import { useState, useEffect } from "react";
import Cookies from "js-cookie";
import styles from "../../styles/auth.module.css";
import { useUser } from "../../components/UserContext";
function hasToken() {
if (typeof document === "undefined") return false;
return Cookies.get('access_token') !== undefined;
}
export default function AuthPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { setUser } = useUser();
useEffect(() => {
// Удаляем токен при заходе на страницу авторизации
Cookies.remove('access_token', { path: '/' });
}, []);
useEffect(() => {
if (hasToken()) {
window.location.href = "/";
}
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
if (!email || !password) {
setError("Пожалуйста, заполните все поля");
setLoading(false);
return;
}
try {
const res = await fetch("/api/token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
login: email,
password: password,
}),
});
if (!res.ok) {
const data = await res.json();
setError(data.detail || "Ошибка авторизации");
setLoading(false);
return;
}
const data = await res.json();
// Сохраняем токен и логин в куки на 60 минут через js-cookie
Cookies.set('access_token', data.access_token, { expires: 1/24, path: '/' });
Cookies.set('user_login', email, { expires: 1/24, path: '/' });
setError("");
// Получаем имя и фамилию пользователя
const accRes = await fetch("/api/account", {
headers: { "Authorization": `Bearer ${data.access_token}` }
});
if (accRes.ok) {
const accData = await accRes.json();
setUser({ firstName: accData.firstName || "", surname: accData.surname || "" });
}
window.location.href = "/";
} catch (err) {
setError("Ошибка сети или сервера");
} finally {
setLoading(false);
}
};
return (
<div className={styles.authContainer}>
<h1 className={styles.authTitle}>Вход в систему</h1>
<form className={styles.authForm} onSubmit={handleSubmit}>
<input
className={styles.authInput}
type="text"
placeholder="Логин"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="username"
/>
<input
className={styles.authInput}
type="password"
placeholder="Пароль"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="current-password"
/>
{error && <div className={styles.authError}>{error}</div>}
<button className={styles.authButton} type="submit" disabled={loading}>
{loading ? "Вход..." : "Войти"}
</button>
</form>
</div>
);
}

View File

@ -7,6 +7,7 @@ import BillingMetricCards from "../../components/BillingMetricCards";
import PayoutsTransactionsTable from "../../components/PayoutsTransactionsTable";
import BillingStatChart from "../../components/BillingStatChart";
import DateFilters from "../../components/DateFilters";
import AuthGuard from "../../components/AuthGuard";
export default function BillingPage() {
const [payoutForm, setPayoutForm] = useState({
@ -36,29 +37,31 @@ export default function BillingPage() {
}
return (
<div className={styles.billingPage}>
<h1 className={styles.title}>Финансы</h1>
<BillingMetricCards />
<div className={styles.grid2}>
<BillingStatChart />
<BillingPieChart />
<AuthGuard>
<div className={styles.billingPage}>
<h1 className={styles.title}>Финансы</h1>
<BillingMetricCards />
<div className={styles.grid2}>
<BillingStatChart />
<BillingPieChart />
</div>
<DateFilters
dateStart={filters.dateStart}
dateEnd={filters.dateEnd}
onChange={(field, value) => {
if (field === "dateStart") {
if (filters.dateEnd && value > filters.dateEnd) return;
}
if (field === "dateEnd") {
if (filters.dateStart && value < filters.dateStart) return;
}
setFilters(f => ({ ...f, [field]: value }));
}}
onApply={handleApply}
onClear={handleClear}
/>
<PayoutsTransactionsTable filters={filters} reloadKey={reloadKey} />
</div>
<DateFilters
dateStart={filters.dateStart}
dateEnd={filters.dateEnd}
onChange={(field, value) => {
if (field === "dateStart") {
if (filters.dateEnd && value > filters.dateEnd) return;
}
if (field === "dateEnd") {
if (filters.dateStart && value < filters.dateStart) return;
}
setFilters(f => ({ ...f, [field]: value }));
}}
onApply={handleApply}
onClear={handleClear}
/>
<PayoutsTransactionsTable filters={filters} reloadKey={reloadKey} />
</div>
</AuthGuard>
);
}

View File

@ -6,7 +6,7 @@
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--foreground: #312f7f;
}
}
@ -40,3 +40,8 @@ a {
color-scheme: dark;
}
}
input, select, textarea {
background: #fff;
color: #111827;
}

View File

@ -2,6 +2,8 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navigation from "../components/Navigation";
import { UserProvider } from "../components/UserContext";
import UserInitializer from "../components/UserInitializer";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -29,18 +31,21 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable}`}
style={{ background: "#f9fafb", minHeight: "100vh", margin: 0 }}
>
<Navigation />
<main
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "32px 8px",
width: "100%",
boxSizing: "border-box",
}}
>
{children}
</main>
<UserProvider>
<UserInitializer />
<Navigation />
<main
style={{
maxWidth: "1200px",
margin: "0 auto",
padding: "32px 8px",
width: "100%",
boxSizing: "border-box",
}}
>
{children}
</main>
</UserProvider>
</body>
</html>
);

View File

@ -1,8 +1,8 @@
import mockData from "../data/mockData";
'use client'
import MetricCards from "../components/MetricCards";
import Table from "../components/Table";
import StatCharts from "../components/StatCharts";
import styles from "../styles/dashboard.module.css";
import AuthGuard from "../components/AuthGuard";
function formatCurrency(amount: number) {
return amount.toLocaleString("ru-RU", {
@ -14,31 +14,33 @@ function formatCurrency(amount: number) {
export default function DashboardPage() {
return (
<div className={styles.dashboard}>
<h1 className={styles.title}>Дашборд</h1>
<MetricCards />
<StatCharts />
{/* <div className={styles.tableBlock}>
<h3 className={styles.tableTitle}>Последние продажи</h3>
<Table
headers={["ID реферала", "Агент", "Сумма продажи", "Комиссия", "Дата", "Статус"]}
data={mockData.dashboard.recentSales}
renderRow={(sale: any, index: number) => (
<tr key={index}>
<td>{sale.id}</td>
<td>{sale.agent}</td>
<td>{formatCurrency(sale.amount)}</td>
<td>{formatCurrency(sale.commission)}</td>
<td>{sale.date}</td>
<td>
<span className={sale.status === "Выплачено" ? styles.statusPaid : styles.statusPending}>
{sale.status}
</span>
</td>
</tr>
)}
/>
</div> */}
</div>
<AuthGuard>
<div className={styles.dashboard}>
<h1 className={styles.title}>Дашборд</h1>
<MetricCards />
<StatCharts />
{/* <div className={styles.tableBlock}>
<h3 className={styles.tableTitle}>Последние продажи</h3>
<Table
headers={["ID реферала", "Агент", "Сумма продажи", "Комиссия", "Дата", "Статус"]}
data={mockData.dashboard.recentSales}
renderRow={(sale: any, index: number) => (
<tr key={index}>
<td>{sale.id}</td>
<td>{sale.agent}</td>
<td>{formatCurrency(sale.amount)}</td>
<td>{formatCurrency(sale.commission)}</td>
<td>{sale.date}</td>
<td>
<span className={sale.status === "Выплачено" ? styles.statusPaid : styles.statusPending}>
{sale.status}
</span>
</td>
</tr>
)}
/>
</div> */}
</div>
</AuthGuard>
);
}

View File

@ -7,6 +7,7 @@ import SalesTable from "../../components/SalesTable";
import styles from "../../styles/stat.module.css";
import DateInput from "../../components/DateInput";
import DateFilters from "../../components/DateFilters";
import AuthGuard from "../../components/AuthGuard";
const tabs = [
{ id: "agents", label: "Агенты" },
@ -32,53 +33,53 @@ export default function StatPage() {
setReloadKey(k => k + 1);
}
return (
<div className={styles.statPage}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Статистика и аналитика</h1>
{/* <button className={styles.exportBtn}>Экспорт</button> */}
</div>
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.id}
className={activeTab === tab.id ? styles.activeTab : styles.tab}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<AuthGuard>
<div className={styles.statPage}>
<div className={styles.headerRow}>
<h1 className={styles.title}>Статистика и аналитика</h1>
{/* <button className={styles.exportBtn}>Экспорт</button> */}
</div>
<div className={styles.tabs}>
{tabs.map((tab) => (
<button
key={tab.id}
className={activeTab === tab.id ? styles.activeTab : styles.tab}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<DateFilters
dateStart={filters.dateStart}
dateEnd={filters.dateEnd}
onChange={(field, value) => {
if (field === "dateStart") {
if (filters.dateEnd && value > filters.dateEnd) return;
}
if (field === "dateEnd") {
if (filters.dateStart && value < filters.dateStart) return;
}
setFilters(f => ({ ...f, [field]: value }));
}}
onApply={handleApply}
onClear={handleClear}
/>
<DateFilters
dateStart={filters.dateStart}
dateEnd={filters.dateEnd}
onChange={(field, value) => {
if (field === "dateStart") {
if (filters.dateEnd && value > filters.dateEnd) return;
}
if (field === "dateEnd") {
if (filters.dateStart && value < filters.dateStart) return;
}
setFilters(f => ({ ...f, [field]: value }));
}}
onApply={handleApply}
onClear={handleClear}
/>
<div className={styles.tabContent}>
{activeTab === "agents" && (
<AgentsTable filters={filters} reloadKey={reloadKey} />
)}
{activeTab === "referrals" && (
<ReferralsTable filters={filters} reloadKey={reloadKey} />
)}
{activeTab === "sales" && (
<SalesTable filters={filters} reloadKey={reloadKey} />
)}
<div className={styles.tabContent}>
{activeTab === "agents" && (
<AgentsTable filters={filters} reloadKey={reloadKey} />
)}
{activeTab === "referrals" && (
<ReferralsTable filters={filters} reloadKey={reloadKey} />
)}
{activeTab === "sales" && (
<SalesTable filters={filters} reloadKey={reloadKey} />
)}
</div>
</div>
</div>
</AuthGuard>
);
}

View File

@ -0,0 +1,66 @@
import React from "react";
import styles from "../styles/account.module.css";
interface AccountNotificationsProps {
notifications: {
emailNotifications: boolean;
smsNotifications: boolean;
pushNotifications: boolean;
weeklyReports: boolean;
payoutAlerts: boolean;
};
setNotifications: (value: any) => void;
}
const notificationSettings = [
{ key: 'emailNotifications', label: 'Email уведомления', description: 'Получать уведомления на электронную почту' },
{ key: 'smsNotifications', label: 'SMS уведомления', description: 'Получать SMS на указанный номер телефона' },
{ key: 'pushNotifications', label: 'Push уведомления', description: 'Получать push-уведомления в браузере' },
{ key: 'weeklyReports', label: 'Еженедельные отчеты', description: 'Автоматическая отправка еженедельной статистики' },
{ key: 'payoutAlerts', label: 'Уведомления о выплатах', description: 'Получать уведомления о статусе выплат' },
];
const AccountNotifications: React.FC<AccountNotificationsProps> = ({ notifications, setNotifications }) => {
return (
<div className={styles.notificationsContainer}>
<div className={styles.notificationsCard}>
<h3 className={styles.notificationsTitle}>Настройки уведомлений</h3>
<div className={styles.notificationsList}>
{notificationSettings.map(setting => (
<div key={setting.key} className={styles.notificationsItem}>
<div>
<div className={styles.notificationsItemLabel}>{setting.label}</div>
<div className={styles.notificationsItemDescription}>{setting.description}</div>
</div>
<label className={styles.notificationsSwitchLabel}>
<input
type="checkbox"
checked={notifications[setting.key as keyof typeof notifications]}
onChange={e => setNotifications({ ...notifications, [setting.key]: e.target.checked })}
className={styles.notificationsSwitchInput}
/>
<span
className={
notifications[setting.key as keyof typeof notifications]
? `${styles.notificationsSwitchTrack} ${styles.notificationsSwitchTrackActive}`
: styles.notificationsSwitchTrack
}
>
<span
className={
notifications[setting.key as keyof typeof notifications]
? `${styles.notificationsSwitchThumb} ${styles.notificationsSwitchThumbActive}`
: styles.notificationsSwitchThumb
}
/>
</span>
</label>
</div>
))}
</div>
</div>
</div>
);
};
export default AccountNotifications;

View File

@ -0,0 +1,203 @@
import React, { useEffect, useState } from "react";
import styles from "../styles/account.module.css";
import {
Edit as EditIcon,
Save as SaveIcon,
Mail as MailIcon,
Phone as PhoneIcon,
LocationOn as MapPinIcon,
CalendarMonth as CalendarIcon,
ContentCopy as ContentCopyIcon
} from "@mui/icons-material";
import Cookies from "js-cookie";
import AccountProfileTitle from "./AccountProfileTitle";
import AccountProfileInfo from "./AccountProfileInfo";
import AccountProfileCompany from "./AccountProfileCompany";
import { useUser } from "./UserContext";
interface ProfileData {
firstName: string;
lastName: string;
email: string;
phone: string;
registrationDate: string;
company: string;
commissionRate: number;
companyKey: string;
address?: string;
birthDate?: string;
position?: string;
referralCode?: string;
}
interface AccountProfileProps {
onNameChange?: (user: { firstName: string; surname: string }) => void;
}
const AccountProfile: React.FC<AccountProfileProps> = ({ onNameChange }) => {
const [profileData, setProfileData] = useState<ProfileData | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCopySuccess, setShowCopySuccess] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const { setUser } = useUser();
const formatDate = (dateString: string) => {
if (!dateString) return "";
const date = new Date(dateString);
return date.toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" });
};
const handleCopy = () => {
if (!profileData) return;
if (typeof navigator !== "undefined" && navigator.clipboard) {
navigator.clipboard.writeText(profileData.companyKey);
} else {
// fallback для старых браузеров и SSR
const textarea = document.createElement("textarea");
textarea.value = profileData.companyKey;
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
setShowCopySuccess(true);
setTimeout(() => setShowCopySuccess(false), 1500);
};
const onProfileFieldChange = (field: string, value: string) => {
if (!profileData) return;
setProfileData({ ...profileData, [field]: value });
};
const handleProfileSave = async () => {
if (!profileData) return;
// Валидация
if (!profileData.firstName.trim() || !profileData.lastName.trim() || !profileData.email.trim() || !profileData.phone.trim()) {
setValidationError("Все поля должны быть заполнены");
setTimeout(() => setValidationError(null), 2000);
return;
}
setLoading(true);
setError(null);
setValidationError(null);
try {
const token = Cookies.get("access_token");
const res = await fetch("/api/account/profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
firstName: profileData.firstName,
surname: profileData.lastName,
email: profileData.email,
phone: profileData.phone
})
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Ошибка сохранения профиля");
}
setIsEditing(false);
if (onNameChange) {
onNameChange({ firstName: profileData.firstName, surname: profileData.lastName });
}
setUser({ firstName: profileData.firstName, surname: profileData.lastName });
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setLoading(false);
}
};
useEffect(() => {
const fetchProfile = async () => {
setLoading(true);
setError(null);
try {
const token = Cookies.get("access_token");
const res = await fetch("/api/account/profile", {
headers: {
"Authorization": `Bearer ${token}`
}
});
if (!res.ok) throw new Error("Ошибка загрузки профиля");
const data = await res.json();
setProfileData({
firstName: data.firstName || "",
lastName: data.surname || "",
email: data.email || "",
phone: data.phone || "",
registrationDate: data.create_dttm || "",
company: data.company?.name || "",
commissionRate: data.company?.commission || 0,
companyKey: data.company?.key || "",
});
if (onNameChange) {
onNameChange({ firstName: data.firstName || "", surname: data.surname || "" });
}
} catch (e: any) {
setError(e.message || "Ошибка");
} finally {
setLoading(false);
}
};
fetchProfile();
}, [onNameChange]);
if (loading) return <div>Загрузка...</div>;
if (error) return <div style={{ color: 'red' }}>{error}</div>;
if (!profileData) return null;
return (
<div className={styles.accountContainer}>
<div className={styles.card}>
<AccountProfileTitle
firstName={profileData.firstName}
lastName={profileData.lastName}
registrationDate={profileData.registrationDate}
isEditing={isEditing}
onEditClick={() => setIsEditing(!isEditing)}
formatDate={formatDate}
/>
</div>
<AccountProfileInfo
firstName={profileData.firstName}
lastName={profileData.lastName}
email={profileData.email}
phone={profileData.phone}
isEditing={isEditing}
onChange={onProfileFieldChange}
/>
<AccountProfileCompany
company={profileData.company}
companyKey={profileData.companyKey}
commissionRate={profileData.commissionRate}
onCopy={handleCopy}
/>
{isEditing && (
<div className={styles.actionRow}>
<button onClick={() => setIsEditing(false)} className={styles.cancelButton}>Отмена</button>
<button onClick={handleProfileSave} className={styles.saveButton}>
<SaveIcon fontSize="small" />Сохранить
</button>
</div>
)}
{showCopySuccess && (
<div className={styles.copySuccessToast}>
Скопировано!
</div>
)}
{validationError && (
<div className={styles.copySuccessToast} style={{background: '#e53e3e'}}>
{validationError}
</div>
)}
</div>
);
};
export default AccountProfile;

View File

@ -0,0 +1,42 @@
import React from "react";
import styles from "../styles/account.module.css";
import { ContentCopy as ContentCopyIcon } from "@mui/icons-material";
interface AccountProfileCompanyProps {
company: string;
companyKey: string;
commissionRate: number;
onCopy: () => void;
}
const AccountProfileCompany: React.FC<AccountProfileCompanyProps> = ({
company,
companyKey,
commissionRate,
onCopy
}) => (
<div className={styles.card}>
<h3 className={styles.sectionTitle}>Рабочая информация</h3>
<div className={styles.infoGrid}>
<div>
<label className={styles.label}>Компания</label>
<div className={styles.value}>{company}</div>
</div>
<div>
<label className={styles.label}>Код компании</label>
<div className={styles.companyCodeRow}>
<div className={styles.companyCodeBox}>{companyKey}</div>
<button className={styles.copyButton} onClick={onCopy} title="Скопировать код компании">
<ContentCopyIcon fontSize="small" />
</button>
</div>
</div>
<div>
<label className={styles.label}>Процент комиссии</label>
<div className={styles.commissionValue}>{commissionRate}%</div>
</div>
</div>
</div>
);
export default AccountProfileCompany;

View File

@ -0,0 +1,67 @@
import React from "react";
import styles from "../styles/account.module.css";
import { Mail as MailIcon, Phone as PhoneIcon } from "@mui/icons-material";
interface AccountProfileInfoProps {
firstName: string;
lastName: string;
email: string;
phone: string;
isEditing: boolean;
onChange: (field: string, value: string) => void;
}
const AccountProfileInfo: React.FC<AccountProfileInfoProps> = ({
firstName,
lastName,
email,
phone,
isEditing,
onChange
}) => (
<div className={styles.card}>
<h3 className={styles.sectionTitle}>Личная информация</h3>
<div className={styles.infoGrid}>
<div>
<label className={styles.label}>Имя</label>
{isEditing ? (
<input type="text" value={firstName} onChange={e => onChange("firstName", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{firstName}</div>
)}
</div>
<div>
<label className={styles.label}>Фамилия</label>
{isEditing ? (
<input type="text" value={lastName} onChange={e => onChange("lastName", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{lastName}</div>
)}
</div>
<div>
<label className={styles.label}>Email</label>
<div className={styles.iconInputRow}>
<MailIcon fontSize="small" style={{ color: "#9ca3af" }} />
{isEditing ? (
<input type="email" value={email} onChange={e => onChange("email", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{email}</div>
)}
</div>
</div>
<div>
<label className={styles.label}>Телефон</label>
<div className={styles.iconInputRow}>
<PhoneIcon fontSize="small" style={{ color: "#9ca3af" }} />
{isEditing ? (
<input type="tel" value={phone} onChange={e => onChange("phone", e.target.value)} className={styles.input} />
) : (
<div className={styles.value}>{phone}</div>
)}
</div>
</div>
</div>
</div>
);
export default AccountProfileInfo;

View File

@ -0,0 +1,38 @@
import React from "react";
import styles from "../styles/account.module.css";
import { Edit as EditIcon } from "@mui/icons-material";
interface AccountProfileTitleProps {
firstName: string;
lastName: string;
registrationDate: string;
isEditing: boolean;
onEditClick: () => void;
formatDate: (dateString: string) => string;
}
const AccountProfileTitle: React.FC<AccountProfileTitleProps> = ({
firstName,
lastName,
registrationDate,
isEditing,
onEditClick,
formatDate
}) => (
<div className={styles.profileHeader}>
<div className={styles.profileAvatar}>{firstName[0]}{lastName[0]}</div>
<div className={styles.profileHeaderMain}>
<h2 className={styles.profileTitle}>{firstName} {lastName}</h2>
<div className={styles.profileSince}>Партнер с {formatDate(registrationDate)}</div>
</div>
<button
onClick={onEditClick}
className={styles.editButton}
>
<EditIcon fontSize="small" />
{isEditing ? "Отмена" : "Редактировать"}
</button>
</div>
);
export default AccountProfileTitle;

View File

@ -0,0 +1,125 @@
import { useState } from "react";
import {
Lock as KeyIcon,
Visibility as EyeIcon,
VisibilityOff as EyeOffIcon
} from "@mui/icons-material";
import styles from "../styles/account.module.css";
import Cookies from "js-cookie";
const initialPasswordForm = {
currentPassword: "",
newPassword: "",
confirmPassword: ""
};
export default function AccountSecurity() {
const [showPassword, setShowPassword] = useState(false);
const [passwordForm, setPasswordForm] = useState(initialPasswordForm);
const [error, setError] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handlePasswordChange = async () => {
setError(null);
setSuccess(null);
setValidationError(null);
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
setValidationError("Новый пароль и подтверждение не совпадают");
setTimeout(() => setValidationError(null), 2000);
return;
}
if (!passwordForm.currentPassword || !passwordForm.newPassword) {
setValidationError("Все поля должны быть заполнены");
setTimeout(() => setValidationError(null), 2000);
return;
}
try {
const token = Cookies.get("access_token");
const res = await fetch("/api/account/password", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
})
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || "Ошибка смены пароля");
}
setSuccess("Пароль успешно изменён");
setPasswordForm(initialPasswordForm);
setTimeout(() => setSuccess(null), 2000);
} catch (e: any) {
setError(e.message || "Ошибка");
setTimeout(() => setError(null), 2000);
}
};
return (
<div className={styles.securityContainer}>
<div className={styles.securityCard}>
<h3 className={styles.securitySectionTitle}>Смена пароля</h3>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label className={styles.securityLabel}>Текущий пароль</label>
<div style={{ position: "relative" }}>
<input
type={showPassword ? "text" : "password"}
value={passwordForm.currentPassword}
onChange={e => setPasswordForm({ ...passwordForm, currentPassword: e.target.value })}
className={styles.securityInput}
/>
<button type="button" onClick={() => setShowPassword(!showPassword)} className={styles.securityPasswordToggleBtn}>
{showPassword ? <EyeOffIcon fontSize="small" /> : <EyeIcon fontSize="small" />}
</button>
</div>
</div>
<div>
<label className={styles.securityLabel}>Новый пароль</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={e => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
className={styles.securityInput}
/>
</div>
<div>
<label className={styles.securityLabel}>Подтвердите новый пароль</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={e => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
className={styles.securityInput}
/>
</div>
<button
onClick={handlePasswordChange}
className={styles.securitySaveButton}
>
<KeyIcon fontSize="small" />Изменить пароль
</button>
{validationError && (
<div className={styles.copySuccessToast} style={{background: '#e53e3e'}}>
{validationError}
</div>
)}
{error && (
<div className={styles.copySuccessToast} style={{background: '#e53e3e'}}>
{error}
</div>
)}
{success && (
<div className={styles.copySuccessToast}>
{success}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,23 @@
"use client";
import { useEffect, useState, ReactNode } from "react";
import Cookies from "js-cookie";
interface AuthGuardProps {
children: ReactNode;
}
export default function AuthGuard({ children }: AuthGuardProps) {
const [checked, setChecked] = useState(false);
useEffect(() => {
const token = Cookies.get('access_token');
if (!token) {
window.location.href = "/auth";
} else {
setChecked(true);
}
}, []);
if (!checked) return null;
return <>{children}</>;
}

View File

@ -25,7 +25,7 @@ export default function MetricCards() {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("api/dashboard/cards")
fetch("/api/dashboard/cards")
.then((res) => {
if (!res.ok) throw new Error("Ошибка загрузки данных");
return res.json();

View File

@ -2,6 +2,9 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import styles from "../styles/navigation.module.css";
import Cookies from "js-cookie";
import { useEffect, useState } from "react";
import { useUser } from "./UserContext";
interface NavItem {
id: string;
@ -17,6 +20,17 @@ const navItems: NavItem[] = [
const Navigation: React.FC = () => {
const pathname = usePathname();
const [login, setLogin] = useState<string>("");
const { firstName, surname } = useUser();
useEffect(() => {
if (typeof document !== "undefined") {
const userLogin = Cookies.get('user_login');
if (userLogin) setLogin(userLogin);
}
}, []);
if (pathname === "/auth") return null;
return (
<nav className={styles.nav}>
<div className={styles.logo}>RE:Premium Partner</div>
@ -36,8 +50,14 @@ const Navigation: React.FC = () => {
))}
</div>
<div className={styles.profile}>
<div className={styles.avatar}>ПП</div>
<span className={styles.profileName}>Партнер RE:Premium</span>
<Link href="/account" style={{ display: 'flex', alignItems: 'center', gap: 12, textDecoration: 'none' }}>
<div className={styles.avatar}>
{firstName && surname ? `${firstName[0]}${surname[0]}`.toUpperCase() : (login ? login.slice(0, 2).toUpperCase() : "ПП")}
</div>
<span className={styles.profileName}>
{firstName && surname ? `${firstName} ${surname}` : (login ? login : "Партнер RE:Premium")}
</span>
</Link>
</div>
</nav>
);

View File

@ -0,0 +1,35 @@
"use client";
import React, { createContext, useContext, useState } from "react";
interface UserContextType {
firstName: string;
surname: string;
setUser: (user: { firstName: string; surname: string }) => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [firstName, setFirstName] = useState("");
const [surname, setSurname] = useState("");
const setUser = (user: { firstName: string; surname: string }) => {
setFirstName(user.firstName);
setSurname(user.surname);
};
return (
<UserContext.Provider value={{ firstName, surname, setUser }}>
{children}
</UserContext.Provider>
);
};
export const useUser = () => {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser должен использоваться внутри UserProvider");
}
return context;
};

View File

@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { useUser } from "./UserContext";
import Cookies from "js-cookie";
export default function UserInitializer() {
const { firstName, surname, setUser } = useUser();
useEffect(() => {
if (!firstName && !surname) {
const token = Cookies.get("access_token");
if (token) {
fetch("/api/account", {
headers: { "Authorization": `Bearer ${token}` }
})
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data && data.firstName && data.surname) {
setUser({ firstName: data.firstName, surname: data.surname });
}
});
}
}
}, [firstName, surname, setUser]);
return null;
}

View File

@ -0,0 +1,394 @@
/* Стили для страницы аккаунта и профиля */
.accountContainer {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.card {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 24px;
margin-bottom: 24px;
}
.profileHeader {
display: flex;
align-items: center;
gap: 24px;
}
.profileAvatar {
width: 64px;
height: 64px;
font-size: 28px;
background: #2563eb;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: #fff;
}
.profileHeaderMain {
flex: 1;
}
.profileTitle {
font-size: 24px;
font-weight: 700;
margin: 0;
}
.profileSince {
color: #9ca3af;
font-size: 14px;
}
.editButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.sectionTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.infoGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.label {
color: #6b7280;
font-size: 14px;
}
.input,
.input[type="text"],
.input[type="email"],
.input[type="tel"] {
width: 100%;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-top: 4px;
box-sizing: border-box;
}
.value {
font-size: 16px;
}
.companyCodeRow {
display: flex;
align-items: center;
gap: 8px;
}
.companyCodeBox {
font-family: monospace;
background: #f3f4f6;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.copyButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
}
.commissionValue {
font-size: 18px;
font-weight: 600;
}
.actionRow {
display: flex;
justify-content: flex-end;
gap: 16px;
}
.cancelButton {
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
cursor: pointer;
}
.saveButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.tabsNav {
display: flex;
gap: 32px;
}
.tabsButton {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #6b7280;
font-weight: 500;
font-size: 16px;
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.tabsButtonActive {
border-bottom: 2px solid #2563eb;
color: #2563eb;
font-weight: 600;
}
.dashboardTitle {
font-size: 32px;
font-weight: 700;
margin-bottom: 24px;
}
.copySuccessToast {
position: fixed;
top: 24px;
right: 24px;
background: #2563eb;
color: #fff;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(16,30,54,0.12);
z-index: 1000;
font-weight: 500;
}
.iconInputRow {
display: flex;
align-items: center;
gap: 8px;
}
.passwordToggleBtn {
position: absolute;
right: 8px;
top: 12px;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
/* --- Стили для AccountSecurity --- */
.securityContainer {
max-width: 500px;
margin: 0 auto;
width: 100%;
}
.securityCard {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 24px;
margin-bottom: 24px;
}
.securitySectionTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.securityLabel {
color: #6b7280;
font-size: 14px;
}
.securityInput {
width: 100%;
padding: 8px;
border: 1px solid #e5e7eb;
border-radius: 6px;
margin-top: 4px;
box-sizing: border-box;
}
.securitySaveButton {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 20px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
margin-top: 8px;
}
.securityPasswordToggleBtn {
position: absolute;
right: 8px;
top: 12px;
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
/* --- Стили для вкладки уведомлений --- */
.notificationsContainer {
max-width: 500px;
margin: 0 auto;
width: 100%;
}
.notificationsCard {
background: #fff;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 24px;
}
.notificationsTitle {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.notificationsList {
display: flex;
flex-direction: column;
gap: 16px;
}
.notificationsItem {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
padding: 8px 0;
}
.notificationsItemLabel {
font-weight: 500;
}
.notificationsItemDescription {
color: #9ca3af;
font-size: 14px;
}
.notificationsSwitchLabel {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.notificationsSwitchInput {
width: 0;
height: 0;
opacity: 0;
position: absolute;
}
.notificationsSwitchTrack {
width: 44px;
height: 24px;
border-radius: 12px;
position: relative;
display: inline-block;
transition: background 0.2s;
background: #e5e7eb;
}
.notificationsSwitchTrackActive {
background: #2563eb;
}
.notificationsSwitchThumb {
position: absolute;
top: 2px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(16,30,54,0.04);
transition: left 0.2s;
left: 2px;
}
.notificationsSwitchThumbActive {
left: 22px;
}
/* --- Стили для табов и навигации из page.tsx --- */
.accountTabsNav {
display: flex;
gap: 32px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 24px;
}
.accountTabsButton {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #6b7280;
font-weight: 500;
font-size: 16px;
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.accountTabsButtonActive {
border-bottom: 2px solid #2563eb;
color: #2563eb;
font-weight: 600;
}
/* ... можно добавить другие стили по необходимости ... */

View File

@ -0,0 +1,55 @@
.authContainer {
max-width: 400px;
margin: 60px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 16px rgba(0,0,0,0.07);
padding: 32px 32px 24px 32px;
display: flex;
flex-direction: column;
align-items: center;
}
.authTitle {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
color: #222;
}
.authForm {
width: 100%;
display: flex;
flex-direction: column;
gap: 18px;
}
.authInput {
padding: 12px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
outline: none;
transition: border 0.2s;
}
.authInput:focus {
border: 1.5px solid #0070f3;
}
.authButton {
background: #0070f3;
color: #fff;
border: none;
border-radius: 8px;
padding: 12px 0;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.authButton:hover {
background: #005bb5;
}
.authError {
color: #e53935;
font-size: 0.95rem;
margin-top: -10px;
margin-bottom: 10px;
text-align: center;
}