+мобильная версия

This commit is contained in:
Konstantin Makaev 2026-02-19 13:28:43 +03:00
parent 378f039bb3
commit 08036f52f6
4 changed files with 596 additions and 79 deletions

View File

@ -1,6 +1,41 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated, loginUser, seedDemoUser } from "../../lib/auth";
export default function Login() {
const router = useRouter();
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
useEffect(() => {
seedDemoUser();
if (isAuthenticated()) {
router.replace("/");
}
}, [router]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError("");
if (!login.trim() || !password.trim()) {
setError("Заполните логин и пароль");
return;
}
const result = loginUser(login, password);
if (!result.ok) {
setError(result.message);
return;
}
router.push("/");
};
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
@ -12,7 +47,7 @@ export default function Login() {
Используйте телефон или почту, чтобы продолжить.
</p>
<form className="space-y-4">
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="login">
Телефон или почта
@ -22,6 +57,8 @@ export default function Login() {
name="login"
type="text"
autoComplete="username"
value={login}
onChange={(event) => setLogin(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="example@mail.com"
/>
@ -35,10 +72,13 @@ export default function Login() {
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="••••••••"
/>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<div className="flex items-center justify-between text-sm text-gray-600">
<label className="inline-flex items-center gap-2">
<input
@ -64,6 +104,7 @@ export default function Login() {
Создать аккаунт
</Link>
</form>
<p className="mt-4 text-center text-xs text-gray-500">Демо вход: demo@corp.local / 123456</p>
<p className="mt-6 text-center text-xs text-gray-500">
Продолжая, вы соглашаетесь с условиями использования и подтверждаете ознакомление с политикой конфиденциальности.
</p>

View File

@ -1,6 +1,67 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { isAuthenticated, loginUser, registerUser, seedDemoUser } from "../../lib/auth";
export default function Register() {
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [accepted, setAccepted] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
seedDemoUser();
if (isAuthenticated()) {
router.replace("/");
}
}, [router]);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError("");
if (!firstName.trim() || !lastName.trim() || !login.trim() || !password.trim() || !confirmPassword.trim()) {
setError("Заполните все поля");
return;
}
if (password.length < 6) {
setError("Пароль должен содержать минимум 6 символов");
return;
}
if (password !== confirmPassword) {
setError("Пароли не совпадают");
return;
}
if (!accepted) {
setError("Подтвердите согласие с условиями использования");
return;
}
const registration = registerUser({
firstName: firstName.trim(),
lastName: lastName.trim(),
login: login.trim(),
password,
});
if (!registration.ok) {
setError(registration.message);
return;
}
loginUser(login.trim(), password);
router.push("/");
};
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
@ -12,7 +73,7 @@ export default function Register() {
Создайте новый аккаунт, указав имя и контактные данные.
</p>
<form className="space-y-4">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="firstName">
@ -22,6 +83,8 @@ export default function Register() {
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Иван"
/>
@ -34,6 +97,8 @@ export default function Register() {
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Иванов"
/>
@ -48,6 +113,8 @@ export default function Register() {
name="login"
type="text"
autoComplete="username"
value={login}
onChange={(event) => setLogin(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="example@mail.com"
/>
@ -62,6 +129,8 @@ export default function Register() {
name="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="••••••••"
/>
@ -75,6 +144,8 @@ export default function Register() {
name="confirmPassword"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="••••••••"
/>
@ -83,11 +154,15 @@ export default function Register() {
<label className="flex items-center gap-3 text-sm text-gray-700">
<input
type="checkbox"
checked={accepted}
onChange={(event) => setAccepted(event.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-sky-600 focus:ring-sky-500"
/>
Я согласен с условиями использования
</label>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<button
type="submit"
className="flex w-full items-center justify-center rounded-lg bg-sky-400 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200"

View File

@ -1,9 +1,24 @@
"use client";
import { Bell, Building2, FileSignature, FileText, Home as HomeIcon, MessageSquare, Search, Users, Wallet } from "lucide-react";
import {
Bell,
Building2,
CalendarDays,
FileSignature,
FileText,
Home as HomeIcon,
LogOut,
Menu,
MessageSquare,
Search,
Users,
Wallet,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useRef, useState } from "react";
import { isAuthenticated, logoutUser } from "../lib/auth";
type AppShellProps = {
children: ReactNode;
@ -16,6 +31,15 @@ type PageHeaderProps = {
eyebrow?: string;
};
type HeaderProps = {
onOpenLeftNav: () => void;
onOpenCalendar: () => void;
};
type LeftNavContentProps = {
onNavigate?: () => void;
};
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon },
{ href: "/messages", label: "Сообщения", icon: MessageSquare },
@ -36,35 +60,206 @@ const calendarWeeks = [
["25", "26", "27", "28", "", "", ""],
];
function Header() {
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const [isDesktopProfileMenuOpen, setIsDesktopProfileMenuOpen] = useState(false);
const [isMobileProfileMenuOpen, setIsMobileProfileMenuOpen] = useState(false);
const notificationsRef = useRef<HTMLDivElement>(null);
const desktopProfileRef = useRef<HTMLDivElement>(null);
const mobileProfileRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedOutsideNotifications = notificationsRef.current && !notificationsRef.current.contains(target);
const clickedOutsideDesktopProfile = desktopProfileRef.current && !desktopProfileRef.current.contains(target);
const clickedOutsideMobileProfile = mobileProfileRef.current && !mobileProfileRef.current.contains(target);
if (clickedOutsideNotifications) {
setIsNotificationsOpen(false);
}
if (clickedOutsideDesktopProfile) {
setIsDesktopProfileMenuOpen(false);
}
if (clickedOutsideMobileProfile) {
setIsMobileProfileMenuOpen(false);
}
};
const onEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsNotificationsOpen(false);
setIsDesktopProfileMenuOpen(false);
setIsMobileProfileMenuOpen(false);
}
};
document.addEventListener("mousedown", onClickOutside);
document.addEventListener("keydown", onEscape);
return () => {
document.removeEventListener("mousedown", onClickOutside);
document.removeEventListener("keydown", onEscape);
};
}, []);
const handleLogout = () => {
setIsNotificationsOpen(false);
setIsDesktopProfileMenuOpen(false);
setIsMobileProfileMenuOpen(false);
logoutUser();
router.push("/login");
};
return (
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 sm:px-8">
<Link href="/" className="flex items-center gap-2">
<img src="/logo.png" alt="Логотип" className="h-11 w-auto" />
</Link>
<div className="hidden flex-1 items-center justify-center md:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск"
/>
<header className="sticky top-0 z-40 border-b border-slate-100 bg-white/95 backdrop-blur">
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="hidden h-14 items-center gap-4 lg:flex">
<Link href="/" className="flex items-center gap-2">
<img src="/logo.png" alt="Логотип" className="h-11 w-auto" />
</Link>
<div className="flex flex-1 items-center justify-center">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск"
/>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<div ref={notificationsRef} className="relative">
<button
type="button"
onClick={() => {
setIsNotificationsOpen((prev) => !prev);
setIsDesktopProfileMenuOpen(false);
}}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-haspopup="menu"
aria-expanded={isNotificationsOpen}
aria-label="Открыть уведомления"
>
<Bell size={18} className="text-gray-600" />
</button>
{isNotificationsOpen ? (
<div className="absolute right-0 top-12 z-40 w-56 rounded-xl bg-white p-3 text-sm text-gray-700 shadow-lg ring-1 ring-gray-100">
новых уведомлений не
</div>
) : null}
</div>
<div ref={desktopProfileRef} className="relative">
<button
type="button"
onClick={() => {
setIsDesktopProfileMenuOpen((prev) => !prev);
setIsNotificationsOpen(false);
}}
className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white"
aria-haspopup="menu"
aria-expanded={isDesktopProfileMenuOpen}
aria-label="Открыть меню профиля"
>
КМ
</button>
{isDesktopProfileMenuOpen ? (
<div className="absolute right-0 top-12 z-40 w-44 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-100">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
<LogOut size={16} />
Выйти
</button>
</div>
) : null}
</div>
</div>
</div>
<div className="ml-auto flex items-center gap-1 sm:gap-2">
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
<Bell size={18} className="text-gray-600" />
</button>
<div className="ml-1 flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
<div className="h-14 lg:hidden">
<div className="relative flex h-full items-center justify-between">
<button
type="button"
onClick={onOpenLeftNav}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Открыть левое меню"
>
<Menu size={20} className="text-gray-700" />
</button>
<Link href="/" className="absolute left-1/2 -translate-x-1/2">
<img src="/logo.png" alt="Логотип" className="h-10 w-auto" />
</Link>
<button
type="button"
onClick={onOpenCalendar}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Открыть календарь"
>
<CalendarDays size={20} className="text-gray-700" />
</button>
</div>
</div>
<div className="pb-3 lg:hidden">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск"
/>
</div>
<div ref={mobileProfileRef} className="relative">
<button
type="button"
onClick={() => setIsMobileProfileMenuOpen((prev) => !prev)}
className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white"
aria-haspopup="menu"
aria-expanded={isMobileProfileMenuOpen}
aria-label="Открыть меню профиля"
>
КМ
</button>
{isMobileProfileMenuOpen ? (
<div className="absolute right-0 top-12 z-40 w-44 rounded-xl bg-white p-2 shadow-lg ring-1 ring-gray-100">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
<LogOut size={16} />
Выйти
</button>
</div>
) : null}
</div>
</div>
</div>
</div>
</header>
);
}
function LeftNav() {
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const navLinkClass = (href: string) =>
@ -75,68 +270,80 @@ function LeftNav() {
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
return (
<aside className="hidden w-64 flex-shrink-0 lg:block">
<div className="sticky top-8 space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
<div>
<p className="text-sm font-semibold text-gray-900">Константин Макаев</p>
<p className="text-xs text-gray-500">Онлайн</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-700">
{navItems.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href} className={navLinkClass(href)}>
<Icon size={18} className={navIconClass(href)} />
{label}
</Link>
))}
</div>
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
<div>
<p className="text-sm font-semibold text-gray-900">Константин Макаев</p>
<p className="text-xs text-gray-500">Онлайн</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-700">
{navItems.map(({ href, label, icon: Icon }) => (
<Link key={href} href={href} className={navLinkClass(href)} onClick={onNavigate}>
<Icon size={18} className={navIconClass(href)} />
{label}
</Link>
))}
</div>
</div>
);
}
function LeftNav() {
return (
<aside className="hidden w-64 flex-shrink-0 lg:block">
<div className="sticky top-8 space-y-4">
<LeftNavContent />
</div>
</aside>
);
}
function CalendarCard() {
return (
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Календарь</span>
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{calendarWeeks.map((week, wi) =>
week.map((day, di) => {
const isToday = day === "18";
return (
<div
key={`${wi}-${di}`}
className={`flex h-9 items-center justify-center rounded-full ${
day
? isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: "hover:bg-sky-50"
: ""
}`}
>
{day}
</div>
);
})
)}
</div>
</div>
);
}
function Calendar() {
return (
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
<div className="sticky top-8 space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Календарь</span>
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{calendarWeeks.map((week, wi) =>
week.map((day, di) => {
const isToday = day === "18";
return (
<div
key={`${wi}-${di}`}
className={`flex h-9 items-center justify-center rounded-full ${
day
? isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: "hover:bg-sky-50"
: ""
}`}
>
{day}
</div>
);
})
)}
</div>
</div>
<CalendarCard />
</div>
</aside>
);
@ -160,14 +367,98 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }:
}
export function AppShell({ children }: AppShellProps) {
const router = useRouter();
const pathname = usePathname();
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated()) {
router.replace("/login");
}
}, [router]);
useEffect(() => {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
}, [pathname]);
useEffect(() => {
if (isMobileLeftNavOpen || isMobileCalendarOpen) {
document.body.style.overflow = "hidden";
return;
}
document.body.style.overflow = "";
return () => {
document.body.style.overflow = "";
};
}, [isMobileLeftNavOpen, isMobileCalendarOpen]);
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<Header />
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-8 sm:px-8">
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-6 sm:px-8 lg:py-8">
<LeftNav />
<main className="flex-1 space-y-6">{children}</main>
<Calendar />
</div>
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileLeftNavOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileLeftNavOpen(false)}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
type="button"
onClick={() => setIsMobileLeftNavOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>
<div className={`fixed inset-0 z-50 lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileCalendarOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${
isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={() => setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
<CalendarCard />
</div>
</div>
</div>
);
}

110
src/lib/auth.ts Normal file
View File

@ -0,0 +1,110 @@
export type AuthUser = {
id: string;
firstName: string;
lastName: string;
login: string;
password: string;
};
const USERS_KEY = "corp.front.users";
const SESSION_KEY = "corp.front.session";
function isBrowser() {
return typeof window !== "undefined";
}
function loadUsers(): AuthUser[] {
if (!isBrowser()) {
return [];
}
const raw = window.localStorage.getItem(USERS_KEY);
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw) as AuthUser[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveUsers(users: AuthUser[]) {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(USERS_KEY, JSON.stringify(users));
}
export function seedDemoUser() {
const users = loadUsers();
const exists = users.some((u) => u.login.toLowerCase() === "demo@corp.local");
if (exists) {
return;
}
users.push({
id: "demo-user",
firstName: "Демо",
lastName: "Пользователь",
login: "demo@corp.local",
password: "123456",
});
saveUsers(users);
}
export function registerUser(payload: Omit<AuthUser, "id">): { ok: true } | { ok: false; message: string } {
const users = loadUsers();
const normalizedLogin = payload.login.trim().toLowerCase();
const alreadyExists = users.some((u) => u.login.toLowerCase() === normalizedLogin);
if (alreadyExists) {
return { ok: false, message: "Пользователь с таким логином уже существует" };
}
users.push({
id: crypto.randomUUID(),
...payload,
login: normalizedLogin,
});
saveUsers(users);
return { ok: true };
}
export function loginUser(login: string, password: string): { ok: true } | { ok: false; message: string } {
const users = loadUsers();
const normalizedLogin = login.trim().toLowerCase();
const user = users.find((u) => u.login.toLowerCase() === normalizedLogin && u.password === password);
if (!user) {
return { ok: false, message: "Неверный логин или пароль" };
}
if (isBrowser()) {
window.localStorage.setItem(SESSION_KEY, user.id);
}
return { ok: true };
}
export function logoutUser() {
if (!isBrowser()) {
return;
}
window.localStorage.removeItem(SESSION_KEY);
}
export function isAuthenticated() {
if (!isBrowser()) {
return false;
}
return Boolean(window.localStorage.getItem(SESSION_KEY));
}