base commit

This commit is contained in:
@kirill.komarov
2026-04-28 21:36:30 +05:00
parent 55480d4aa5
commit 2148fd7a12
24 changed files with 1578 additions and 121 deletions
+4
View File
@@ -2,7 +2,9 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AppProviders } from '@/app/providers/AppProviders'
import { AdminPage } from '@/pages/admin'
import { AuthPage } from '@/pages/auth'
import { HomePage } from '@/pages/home'
import { MePage } from '@/pages/me/ui/MePage'
export function App() {
return (
@@ -12,6 +14,8 @@ export function App() {
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/auth" element={<AuthPage />} />
<Route path="/me" element={<MePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MainLayout>
+154 -2
View File
@@ -1,14 +1,65 @@
import { type PropsWithChildren } from 'react'
import { type PropsWithChildren, useState } from 'react'
import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined'
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined'
import AppBar from '@mui/material/AppBar'
import Badge from '@mui/material/Badge'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Container from '@mui/material/Container'
import FormControl from '@mui/material/FormControl'
import IconButton from '@mui/material/IconButton'
import InputLabel from '@mui/material/InputLabel'
import ListItemText from '@mui/material/ListItemText'
import Menu from '@mui/material/Menu'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
import type { SelectChangeEvent } from '@mui/material/Select'
import Toolbar from '@mui/material/Toolbar'
import Typography from '@mui/material/Typography'
import { Link as RouterLink } from 'react-router-dom'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useUnit } from 'effector-react'
import type { ColorScheme } from '@/app/providers/theme-controller'
import { useThemeController } from '@/app/providers/theme-controller'
import { STORE_NAME } from '@/shared/config'
import { $user, logout, tokenSet } from '@/shared/model/auth'
export function MainLayout({ children }: PropsWithChildren) {
const { mode, resolvedMode, scheme, setMode, setScheme, cycleMode } = useThemeController()
const user = useUnit($user)
const navigate = useNavigate()
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const menuOpen = Boolean(anchorEl)
const onSchemeChange = (e: SelectChangeEvent<string>) => {
setScheme(e.target.value as ColorScheme)
}
const onModeChange = (e: SelectChangeEvent<string>) => {
const v = e.target.value
if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
}
const openUserMenu = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget)
const closeUserMenu = () => setAnchorEl(null)
const onLogout = () => {
tokenSet(null)
logout()
closeUserMenu()
navigate('/')
}
const goToAuth = () => {
closeUserMenu()
navigate('/auth')
}
const goToMe = () => {
closeUserMenu()
navigate('/me')
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="sticky" color="primary" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
@@ -27,6 +78,107 @@ export function MainLayout({ children }: PropsWithChildren) {
<Button component={RouterLink} to="/admin" color="inherit">
Админка
</Button>
<IconButton color="inherit" onClick={openUserMenu} sx={{ ml: 1 }} aria-label="Пользователь">
<Badge
variant="dot"
color="success"
overlap="circular"
invisible={!user}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<AccountCircleOutlinedIcon />
</Badge>
</IconButton>
<Menu
anchorEl={anchorEl}
open={menuOpen}
onClose={closeUserMenu}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{user ? (
<>
<MenuItem onClick={goToMe}>
<ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />
</MenuItem>
<MenuItem onClick={onLogout}>Выход</MenuItem>
</>
) : (
<MenuItem onClick={goToAuth}>Войти / регистрация</MenuItem>
)}
</Menu>
<FormControl
size="small"
sx={{
ml: 2,
minWidth: 160,
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="scheme-label">Тема</InputLabel>
<Select
labelId="scheme-label"
value={scheme}
label="Тема"
onChange={onSchemeChange}
sx={{
color: 'inherit',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
'.MuiSvgIcon-root': { color: 'inherit' },
}}
>
<MenuItem value="craft">Крафт</MenuItem>
<MenuItem value="forest">Лес</MenuItem>
<MenuItem value="ocean">Океан</MenuItem>
<MenuItem value="berry">Ягоды</MenuItem>
</Select>
</FormControl>
<FormControl
size="small"
sx={{
ml: 2,
minWidth: 150,
'& .MuiInputLabel-root': { color: 'rgba(255,255,255,0.85)' },
'& .MuiInputLabel-root.Mui-focused': { color: '#fff' },
'& .MuiInputLabel-root.MuiInputLabel-shrink': { color: 'rgba(255,255,255,0.92)' },
}}
>
<InputLabel id="mode-label">Режим</InputLabel>
<Select
labelId="mode-label"
value={mode}
label="Режим"
onChange={onModeChange}
sx={{
color: 'inherit',
'.MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.5)' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.9)' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'rgba(255,255,255,0.95)' },
'.MuiSvgIcon-root': { color: 'inherit' },
}}
>
<MenuItem value="system">Авто (система)</MenuItem>
<MenuItem value="light">Светлая</MenuItem>
<MenuItem value="dark">Тёмная</MenuItem>
</Select>
</FormControl>
<IconButton
color="inherit"
onClick={cycleMode}
sx={{ ml: 1 }}
aria-label="Переключить режим темы (авто/светлая/тёмная)"
title={`Сейчас: ${mode === 'system' ? `авто (${resolvedMode})` : mode}`}
>
{resolvedMode === 'dark' ? <LightModeOutlinedIcon /> : <DarkModeOutlinedIcon />}
</IconButton>
</Toolbar>
</AppBar>
<Box component="main" sx={{ flex: 1, py: 3 }}>
+100 -23
View File
@@ -2,6 +2,103 @@ import { type PropsWithChildren, useMemo } from 'react'
import CssBaseline from '@mui/material/CssBaseline'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
function AppThemeInner({ children }: PropsWithChildren) {
const controller = useThemeController()
const theme = useMemo(
() =>
createTheme({
palette: (() => {
const isDark = controller.resolvedMode === 'dark'
const common = { mode: controller.resolvedMode }
const text = isDark
? { primary: '#F2F2F2', secondary: 'rgba(242,242,242,0.72)', disabled: 'rgba(242,242,242,0.48)' }
: { primary: '#1F1B16', secondary: 'rgba(31,27,22,0.72)', disabled: 'rgba(31,27,22,0.48)' }
switch (controller.scheme) {
case 'forest':
return {
...common,
primary: { main: isDark ? '#4CAF50' : '#2E7D32' },
secondary: { main: isDark ? '#A1887F' : '#6D4C41' },
info: { main: isDark ? '#29B6F6' : '#0288D1' },
success: { main: isDark ? '#66BB6A' : '#2E7D32' },
warning: { main: isDark ? '#FFB74D' : '#ED6C02' },
error: { main: isDark ? '#EF5350' : '#D32F2F' },
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
text,
background: isDark
? { default: '#0E1510', paper: '#121B14' }
: { default: '#F6FAF6', paper: '#FFFFFF' },
}
case 'ocean':
return {
...common,
primary: { main: isDark ? '#42A5F5' : '#1565C0' },
secondary: { main: isDark ? '#4DD0E1' : '#00838F' },
info: { main: isDark ? '#4FC3F7' : '#0288D1' },
success: { main: isDark ? '#26C6DA' : '#00838F' },
warning: { main: isDark ? '#FFCC80' : '#ED6C02' },
error: { main: isDark ? '#EF5350' : '#D32F2F' },
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
text,
background: isDark
? { default: '#0B1220', paper: '#0F172A' }
: { default: '#F6FAFF', paper: '#FFFFFF' },
}
case 'berry':
return {
...common,
primary: { main: isDark ? '#BA68C8' : '#7B1FA2' },
secondary: { main: isDark ? '#F06292' : '#C2185B' },
info: { main: isDark ? '#64B5F6' : '#1976D2' },
success: { main: isDark ? '#81C784' : '#2E7D32' },
warning: { main: isDark ? '#FFB74D' : '#ED6C02' },
error: { main: isDark ? '#EF5350' : '#D32F2F' },
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
text,
background: isDark
? { default: '#140A17', paper: '#1B0F20' }
: { default: '#FFF7FD', paper: '#FFFFFF' },
}
case 'craft':
default:
return {
...common,
primary: { main: isDark ? '#BCAAA4' : '#6D4C41' },
secondary: { main: isDark ? '#FFCCBC' : '#8D6E63' },
info: { main: isDark ? '#90CAF9' : '#1976D2' },
success: { main: isDark ? '#A5D6A7' : '#2E7D32' },
warning: { main: isDark ? '#FFB74D' : '#ED6C02' },
error: { main: isDark ? '#EF9A9A' : '#D32F2F' },
divider: isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.10)',
text,
background: isDark
? { default: '#12100F', paper: '#191615' }
: { default: '#FAF8F5', paper: '#FFFFFF' },
}
}
})(),
shape: { borderRadius: 12 },
typography: {
fontFamily: '"Segoe UI", system-ui, sans-serif',
h4: { fontWeight: 700 },
h5: { fontWeight: 600 },
},
}),
[controller.resolvedMode, controller.scheme],
)
return (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
)
}
export function AppProviders({ children }: PropsWithChildren) {
const queryClient = useMemo(
@@ -18,31 +115,11 @@ export function AppProviders({ children }: PropsWithChildren) {
[],
)
const theme = useMemo(
() =>
createTheme({
palette: {
mode: 'light',
primary: { main: '#6d4c41' },
secondary: { main: '#8d6e63' },
background: { default: '#faf8f5', paper: '#ffffff' },
},
shape: { borderRadius: 12 },
typography: {
fontFamily: '"Segoe UI", system-ui, sans-serif',
h4: { fontWeight: 700 },
h5: { fontWeight: 600 },
},
}),
[],
)
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
<ThemeControllerProvider>
<AppThemeInner>{children}</AppThemeInner>
</ThemeControllerProvider>
</QueryClientProvider>
)
}
@@ -0,0 +1,115 @@
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
import type { PaletteMode } from '@mui/material'
export type ColorScheme = 'craft' | 'forest' | 'ocean' | 'berry'
export type ThemeModePreference = 'system' | PaletteMode
export type ThemeSettings = {
mode: ThemeModePreference
scheme: ColorScheme
}
export type ThemeController = ThemeSettings & {
/** Итоговый режим, учитывая system. */
resolvedMode: PaletteMode
setMode: (mode: ThemeModePreference) => void
toggleMode: () => void
cycleMode: () => void
setScheme: (scheme: ColorScheme) => void
}
const THEME_STORAGE_KEY = 'craftshop_theme'
function readStoredTheme(): ThemeSettings | null {
try {
const raw = localStorage.getItem(THEME_STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw)
const mode: unknown = parsed?.mode
const scheme: unknown = parsed?.scheme
const modeOk = mode === 'light' || mode === 'dark' || mode === 'system'
const schemeOk = scheme === 'craft' || scheme === 'forest' || scheme === 'ocean' || scheme === 'berry'
if (!modeOk || !schemeOk) return null
return { mode, scheme }
} catch {
return null
}
}
function getSystemMode(): PaletteMode {
if (typeof window === 'undefined') return 'light'
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function resolveMode(pref: ThemeModePreference): PaletteMode {
return pref === 'system' ? getSystemMode() : pref
}
const ThemeControllerContext = createContext<ThemeController | null>(null)
export function useThemeController(): ThemeController {
const ctx = useContext(ThemeControllerContext)
if (!ctx) throw new Error('useThemeController must be used within ThemeControllerProvider')
return ctx
}
export function ThemeControllerProvider({ children }: PropsWithChildren) {
const [settings, setSettings] = useState<ThemeSettings>(
() => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
)
const [systemMode, setSystemMode] = useState<PaletteMode>(() => getSystemMode())
useEffect(() => {
const mql = window.matchMedia?.('(prefers-color-scheme: dark)')
if (!mql) return
const handler = () => setSystemMode(mql.matches ? 'dark' : 'light')
// начальное значение
handler()
if (typeof mql.addEventListener === 'function') {
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}
// Safari старых версий
mql.addListener(handler)
return () => mql.removeListener(handler)
}, [])
useEffect(() => {
try {
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(settings))
} catch {
// ignore
}
}, [settings])
const resolvedMode = settings.mode === 'system' ? systemMode : settings.mode
const controller = useMemo<ThemeController>(
() => ({
mode: settings.mode,
resolvedMode,
scheme: settings.scheme,
setMode: (mode) => setSettings((s) => ({ ...s, mode })),
toggleMode: () =>
setSettings((s) => ({
...s,
mode: resolveMode(s.mode) === 'light' ? 'dark' : 'light',
})),
cycleMode: () =>
setSettings((s) => ({
...s,
mode: s.mode === 'system' ? 'light' : s.mode === 'light' ? 'dark' : 'system',
})),
setScheme: (scheme) => setSettings((s) => ({ ...s, scheme })),
}),
[resolvedMode, settings.mode, settings.scheme],
)
return <ThemeControllerContext.Provider value={controller}>{children}</ThemeControllerContext.Provider>
}
+3
View File
@@ -2,6 +2,9 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from '@/app/App'
import '@/app/styles/global.css'
import { readStoredToken, tokenSet } from '@/shared/model/auth'
tokenSet(readStoredToken())
createRoot(document.getElementById('root')!).render(
<StrictMode>
+121 -91
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -21,6 +21,7 @@ import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form'
import {
createCategory,
createProduct,
@@ -55,14 +56,32 @@ const emptyForm = (): FormState => ({
export function AdminPage() {
const queryClient = useQueryClient()
const [tokenInput, setTokenInput] = useState('')
const [token, setToken] = useState<string | null>(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<Product | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [catOpen, setCatOpen] = useState(false)
const [catName, setCatName] = useState('')
const [catSlug, setCatSlug] = useState('')
const tokenForm = useForm<{ token: string }>({
defaultValues: { token: '' },
mode: 'onChange',
})
const productForm = useForm<FormState>({
defaultValues: emptyForm(),
mode: 'onChange',
})
const categoryForm = useForm<{ name: string; slug: string }>({
defaultValues: { name: '', slug: '' },
mode: 'onChange',
})
const titleValue = productForm.watch('title')
const categoryIdValue = productForm.watch('categoryId')
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const categoriesQuery = useQuery({
queryKey: ['categories'],
@@ -76,7 +95,7 @@ export function AdminPage() {
})
const saveToken = () => {
const t = tokenInput.trim()
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setToken(null)
@@ -88,13 +107,13 @@ export function AdminPage() {
const openCreate = () => {
setEditing(null)
setForm(emptyForm())
productForm.reset(emptyForm())
setDialogOpen(true)
}
const openEdit = (p: Product) => {
setEditing(p)
setForm({
productForm.reset({
title: p.title,
slug: p.slug,
description: p.description ?? '',
@@ -108,6 +127,7 @@ export function AdminPage() {
const createMut = useMutation({
mutationFn: async () => {
const form = productForm.getValues()
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
await createProduct(token!, {
@@ -129,6 +149,7 @@ export function AdminPage() {
const updateMut = useMutation({
mutationFn: async () => {
const form = productForm.getValues()
const priceCents = Math.round(Number(form.priceRub.replace(',', '.')) * 100)
if (!Number.isFinite(priceCents) || priceCents < 0) throw new Error('Некорректная цена')
await updateProduct(token!, editing!.id, {
@@ -157,16 +178,17 @@ export function AdminPage() {
})
const createCategoryMut = useMutation({
mutationFn: () =>
createCategory(token!, {
name: catName.trim(),
slug: catSlug.trim() || undefined,
}),
mutationFn: () => {
const v = categoryForm.getValues()
return createCategory(token!, {
name: v.name.trim(),
slug: v.slug.trim() || undefined,
})
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['categories'] })
setCatOpen(false)
setCatName('')
setCatSlug('')
categoryForm.reset({ name: '', slug: '' })
},
})
@@ -191,13 +213,18 @@ export function AdminPage() {
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder={token ? '••••••••' : ''}
<Controller
control={tokenForm.control}
name="token"
render={({ field }) => (
<TextField
label="Токен (Bearer)"
type="password"
fullWidth
{...field}
placeholder={token ? '••••••••' : ''}
/>
)}
/>
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
Сохранить
@@ -267,63 +294,63 @@ export function AdminPage() {
<DialogTitle>{editing ? 'Редактировать товар' : 'Новый товар'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Название"
fullWidth
required
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
<Controller
control={productForm.control}
name="title"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<TextField
label="Slug (URL)"
fullWidth
value={form.slug}
onChange={(e) => setForm((f) => ({ ...f, slug: e.target.value }))}
helperText="Можно оставить пустым при создании — сгенерируется из названия"
/>
<TextField
label="Описание"
fullWidth
multiline
minRows={2}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
<TextField
label="Цена, ₽"
fullWidth
value={form.priceRub}
onChange={(e) => setForm((f) => ({ ...f, priceRub: e.target.value }))}
/>
<TextField
label="Ссылка на изображение"
fullWidth
value={form.imageUrl}
onChange={(e) => setForm((f) => ({ ...f, imageUrl: e.target.value }))}
/>
<FormControl fullWidth required>
<InputLabel id="cat-label">Категория</InputLabel>
<Select
labelId="cat-label"
label="Категория"
value={form.categoryId}
onChange={(e) => setForm((f) => ({ ...f, categoryId: String(e.target.value) }))}
>
{(categoriesQuery.data ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControlLabel
control={
<Switch
checked={form.published}
onChange={(e) => setForm((f) => ({ ...f, published: e.target.checked }))}
<Controller
control={productForm.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug (URL)"
fullWidth
{...field}
helperText="Можно оставить пустым при создании — сгенерируется из названия"
/>
}
label="Показывать в каталоге"
)}
/>
<Controller
control={productForm.control}
name="description"
render={({ field }) => <TextField label="Описание" fullWidth multiline minRows={2} {...field} />}
/>
<Controller
control={productForm.control}
name="priceRub"
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
/>
<Controller
control={productForm.control}
name="imageUrl"
render={({ field }) => <TextField label="Ссылка на изображение" fullWidth {...field} />}
/>
<Controller
control={productForm.control}
name="categoryId"
render={({ field }) => (
<FormControl fullWidth required>
<InputLabel id="cat-label">Категория</InputLabel>
<Select labelId="cat-label" label="Категория" {...field}>
{(categoriesQuery.data ?? []).map((c) => (
<MenuItem key={c.id} value={c.id}>
{c.name}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={productForm.control}
name="published"
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(_, v) => field.onChange(v)} />}
label="Показывать в каталоге"
/>
)}
/>
</Stack>
</DialogContent>
@@ -332,7 +359,7 @@ export function AdminPage() {
<Button
variant="contained"
onClick={handleSubmit}
disabled={!form.title.trim() || !form.categoryId || createMut.isPending || updateMut.isPending}
disabled={!titleValue.trim() || !categoryIdValue || createMut.isPending || updateMut.isPending}
>
{editing ? 'Сохранить' : 'Создать'}
</Button>
@@ -343,19 +370,22 @@ export function AdminPage() {
<DialogTitle>Новая категория</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Название"
fullWidth
required
value={catName}
onChange={(e) => setCatName(e.target.value)}
<Controller
control={categoryForm.control}
name="name"
render={({ field }) => <TextField label="Название" fullWidth required {...field} />}
/>
<TextField
label="Slug"
fullWidth
value={catSlug}
onChange={(e) => setCatSlug(e.target.value)}
helperText="Необязательно — можно сгенерировать из названия на сервере"
<Controller
control={categoryForm.control}
name="slug"
render={({ field }) => (
<TextField
label="Slug"
fullWidth
{...field}
helperText="Необязательно — можно сгенерировать из названия на сервере"
/>
)}
/>
</Stack>
</DialogContent>
@@ -363,7 +393,7 @@ export function AdminPage() {
<Button onClick={() => setCatOpen(false)}>Отмена</Button>
<Button
variant="contained"
disabled={!catName.trim() || createCategoryMut.isPending}
disabled={!categoryForm.watch('name').trim() || createCategoryMut.isPending}
onClick={() => createCategoryMut.mutate()}
>
Создать
+1
View File
@@ -0,0 +1 @@
export { AuthPage } from './ui/AuthPage'
+139
View File
@@ -0,0 +1,139 @@
import { useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { apiClient } from '@/shared/api/client'
import { tokenSet } from '@/shared/model/auth'
type AuthResponse = { token: string; user: { id: string; email: string } }
function getApiErrorMessage(err: unknown): string | null {
if (!err || typeof err !== 'object') return null
const anyErr = err as Record<string, unknown>
const response = anyErr.response as Record<string, unknown> | undefined
const data = response?.data as Record<string, unknown> | undefined
const msg = data?.error
return typeof msg === 'string' ? msg : null
}
export function AuthPage() {
const [message, setMessage] = useState<string | null>(null)
const { register, watch } = useForm<{
email: string
code: string
password: string
}>({
defaultValues: { email: '', code: '', password: '' },
mode: 'onChange',
})
const email = watch('email')
const code = watch('code')
const password = watch('password')
const requestCode = useMutation({
mutationFn: async () => {
await apiClient.post('auth/request-code', { email })
},
onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'),
})
const verifyCode = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
tokenSet(data.token)
setMessage(`Вход выполнен: ${data.user.email}`)
},
})
const registerPassword = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/register', { email, password })
tokenSet(data.token)
setMessage(`Регистрация выполнена: ${data.user.email}`)
},
})
const loginPassword = useMutation({
mutationFn: async () => {
const { data } = await apiClient.post<AuthResponse>('auth/login', { email, password })
tokenSet(data.token)
setMessage(`Вход выполнен: ${data.user.email}`)
},
})
const errMsg = getApiErrorMessage(
requestCode.error || verifyCode.error || registerPassword.error || loginPassword.error,
)
return (
<Box>
<Typography variant="h4" gutterBottom>
Вход / регистрация
</Typography>
{message && (
<Alert severity="success" sx={{ mb: 2 }}>
{message}
</Alert>
)}
{errMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{errMsg}
</Alert>
)}
<Stack spacing={2} sx={{ maxWidth: 520 }}>
<TextField label="Email" {...register('email')} fullWidth />
<Typography variant="h6">Вариант 1: Email + код</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="outlined" onClick={() => requestCode.mutate()} disabled={!email || requestCode.isPending}>
Отправить код
</Button>
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
<Button
variant="contained"
onClick={() => verifyCode.mutate()}
disabled={!email || code.length !== 6 || verifyCode.isPending}
>
Войти
</Button>
</Stack>
<Divider />
<Typography variant="h6">Вариант 2: Email + пароль</Typography>
<TextField
label="Пароль"
type="password"
{...register('password')}
fullWidth
helperText="Минимум 8 символов для регистрации"
/>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button
variant="contained"
onClick={() => registerPassword.mutate()}
disabled={!email || password.length < 8 || registerPassword.isPending}
>
Зарегистрироваться
</Button>
<Button
variant="outlined"
onClick={() => loginPassword.mutate()}
disabled={!email || !password || loginPassword.isPending}
>
Войти
</Button>
</Stack>
</Stack>
</Box>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { MePage } from './ui/MePage'
+178
View File
@@ -0,0 +1,178 @@
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Divider from '@mui/material/Divider'
import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography'
import { useUnit } from 'effector-react'
import { useForm } from 'react-hook-form'
import {
$changePasswordError,
$requestEmailChangeCodeError,
$updateProfileError,
$user,
$verifyEmailChangeError,
changePasswordFx,
requestEmailChangeCodeFx,
updateProfileFx,
verifyEmailChangeFx,
} from '@/shared/model/auth'
export function MePage() {
const user = useUnit($user)
const pendingPassword = useUnit(changePasswordFx.pending)
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
const pendingProfile = useUnit(updateProfileFx.pending)
const errorPassword = useUnit($changePasswordError)
const errorEmailReq = useUnit($requestEmailChangeCodeError)
const errorProfile = useUnit($updateProfileError)
const errorEmailVerify = useUnit($verifyEmailChangeError)
const passwordForm = useForm<{ currentPassword: string; newPassword: string }>({
defaultValues: { currentPassword: '', newPassword: '' },
mode: 'onChange',
})
const emailForm = useForm<{ newEmail: string; code: string }>({
defaultValues: { newEmail: '', code: '' },
mode: 'onChange',
})
const profileForm = useForm<{ name: string }>({
defaultValues: { name: user?.name ? String(user.name) : '' },
mode: 'onChange',
})
const passwordErrorMsg =
(errorPassword as any)?.response?.data?.error ? String((errorPassword as any).response.data.error) : null
const emailErrorMsg =
(errorEmailReq as any)?.response?.data?.error
? String((errorEmailReq as any).response.data.error)
: (errorEmailVerify as any)?.response?.data?.error
? String((errorEmailVerify as any).response.data.error)
: null
const profileErrorMsg =
(errorProfile as any)?.response?.data?.error ? String((errorProfile as any).response.data.error) : null
if (!user) {
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
}
return (
<Box>
<Typography variant="h4" gutterBottom>
Профиль
</Typography>
<Typography color="text.secondary" sx={{ mb: 3 }}>
Текущая почта: <b>{user.email}</b>
</Typography>
{passwordErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{passwordErrorMsg}
</Alert>
)}
{emailErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{emailErrorMsg}
</Alert>
)}
{profileErrorMsg && (
<Alert severity="error" sx={{ mb: 2 }}>
{profileErrorMsg}
</Alert>
)}
<Stack spacing={3} sx={{ maxWidth: 560 }}>
<Box>
<Typography variant="h6" gutterBottom>
Имя / ник
</Typography>
<Stack spacing={2}>
<TextField
label="Имя или ник"
helperText="До 40 символов"
slotProps={{ htmlInput: { maxLength: 40 } }}
{...profileForm.register('name')}
/>
<Button
variant="contained"
disabled={pendingProfile}
onClick={() => {
const raw = profileForm.getValues('name')
const name = raw.trim()
updateProfileFx({ name: name.length ? name : null })
}}
>
Сохранить имя
</Button>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена почты
</Typography>
<Stack spacing={2}>
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
<Button
variant="outlined"
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
>
Отправить код на новую почту
</Button>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
<Button
variant="contained"
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
onClick={() =>
verifyEmailChangeFx({
newEmail: emailForm.getValues('newEmail').trim(),
code: emailForm.getValues('code').trim(),
})
}
>
Подтвердить
</Button>
</Stack>
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="h6" gutterBottom>
Смена пароля
</Typography>
<Stack spacing={2}>
<TextField
label="Текущий пароль (если установлен)"
type="password"
{...passwordForm.register('currentPassword')}
/>
<TextField label="Новый пароль" type="password" {...passwordForm.register('newPassword')} />
<Button
variant="contained"
disabled={passwordForm.watch('newPassword').length < 8 || pendingPassword}
onClick={() =>
changePasswordFx({
currentPassword: passwordForm.getValues('currentPassword') || undefined,
newPassword: passwordForm.getValues('newPassword'),
})
}
>
Сохранить пароль
</Button>
</Stack>
</Box>
</Stack>
</Box>
)
}
+12
View File
@@ -5,3 +5,15 @@ export const apiClient = axios.create({
baseURL: apiBaseURL,
headers: { 'Content-Type': 'application/json' },
})
apiClient.interceptors.request.use((config) => {
try {
const token = localStorage.getItem('craftshop_auth_token')
if (!token) return config
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
return config
} catch {
return config
}
})
+98
View File
@@ -0,0 +1,98 @@
import { createEffect, createEvent, createStore, sample } from 'effector'
import { apiClient } from '@/shared/api/client'
export type AuthUser = { id: string; email: string; name?: string | null }
const TOKEN_KEY = 'craftshop_auth_token'
export const tokenSet = createEvent<string | null>()
export const logout = createEvent()
export const $token = createStore<string | null>(null)
.on(tokenSet, (_, t) => t)
.reset(logout)
export const $user = createStore<AuthUser | null>(null).reset(logout)
export const changePasswordFx = createEffect(async (params: { currentPassword?: string; newPassword: string }) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/password', params)
return data.user
})
export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => {
await apiClient.post('me/change-email/request-code', { newEmail })
})
export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => {
const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params)
return data.user
})
export const updateProfileFx = createEffect(async (params: { name: string | null }) => {
const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params)
return data.user
})
export const $changePasswordError = createStore<unknown | null>(null)
.on(changePasswordFx.failData, (_, e) => e)
.reset(changePasswordFx, logout)
export const $requestEmailChangeCodeError = createStore<unknown | null>(null)
.on(requestEmailChangeCodeFx.failData, (_, e) => e)
.reset(requestEmailChangeCodeFx, logout)
export const $verifyEmailChangeError = createStore<unknown | null>(null)
.on(verifyEmailChangeFx.failData, (_, e) => e)
.reset(verifyEmailChangeFx, logout)
export const $updateProfileError = createStore<unknown | null>(null)
.on(updateProfileFx.failData, (_, e) => e)
.reset(updateProfileFx, logout)
export const meFx = createEffect(async (token: string) => {
const { data } = await apiClient.get<{ user: AuthUser | null }>('me', {
headers: { Authorization: `Bearer ${token}` },
})
return data.user
})
sample({
clock: tokenSet,
filter: (t): t is string => Boolean(t),
target: meFx,
})
sample({
clock: meFx.doneData,
target: $user,
})
sample({
clock: [changePasswordFx.doneData, verifyEmailChangeFx.doneData, updateProfileFx.doneData],
target: $user,
})
$token.watch((t) => {
try {
if (!t) localStorage.removeItem(TOKEN_KEY)
else localStorage.setItem(TOKEN_KEY, t)
} catch {
// ignore
}
})
logout.watch(() => {
try {
localStorage.removeItem(TOKEN_KEY)
} catch {
// ignore
}
})
export function readStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY)
} catch {
return null
}
}