diff --git a/client/eslint.config.js b/client/eslint.config.js
index 7e3c584..7786ff6 100644
--- a/client/eslint.config.js
+++ b/client/eslint.config.js
@@ -185,6 +185,14 @@ export default tseslint.config(
],
},
},
+ {
+ files: ['src/app/providers/theme-controller.tsx'],
+ rules: { 'react-refresh/only-export-components': 'off' },
+ },
+ {
+ files: ['src/pages/**/ui/**/*.tsx'],
+ rules: { 'react-hooks/incompatible-library': 'off' },
+ },
{
files: ['eslint.config.js'],
rules: {
diff --git a/client/package-lock.json b/client/package-lock.json
index 589b2ec..3954447 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -10,11 +10,15 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5",
"axios": "^1.15.2",
+ "effector": "^23.4.4",
+ "effector-react": "^23.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-hook-form": "^7.74.0",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
@@ -733,6 +737,32 @@
"url": "https://opencollective.com/mui-org"
}
},
+ "node_modules/@mui/icons-material": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-9.0.0.tgz",
+ "integrity": "sha512-oDwyvI6LgjWRC9MBcSGvLkPud9S9ELgSBQFYxa1rYcZn6Br55dn22SyvsPDMsn0G8OndFk53iMT45W5mNqrogw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/mui-org"
+ },
+ "peerDependencies": {
+ "@mui/material": "^9.0.0",
+ "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@mui/material": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.0.tgz",
@@ -2681,6 +2711,51 @@
"node": ">= 0.4"
}
},
+ "node_modules/effector": {
+ "version": "23.4.4",
+ "resolved": "https://registry.npmjs.org/effector/-/effector-23.4.4.tgz",
+ "integrity": "sha512-QkZboRN28K/iwxigDhlJcI3ux3aNbt8kYGGH/GkqWG0OlGeyuBhb7PdM89Iu+ogV8Lmz16xIlwnXR2UNWI6psg==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/zero_bias"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/effector"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/effector-react": {
+ "version": "23.3.0",
+ "resolved": "https://registry.npmjs.org/effector-react/-/effector-react-23.3.0.tgz",
+ "integrity": "sha512-QR0+x1EnbiWhO80Yc0GVF+I9xCYoxBm3t+QLB5Wg+1uY1Q1BrSWDmKvJaJJZ/+9BU4RAr25yS5J2EkdWnicu8g==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/zero_bias"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/effector"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=11.0.0"
+ },
+ "peerDependencies": {
+ "effector": "^23.0.0",
+ "react": ">=16.8.0 <20.0.0"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.344",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
@@ -5464,6 +5539,22 @@
"react": "^19.2.5"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.74.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
+ "integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "19.2.5",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz",
@@ -6373,6 +6464,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
diff --git a/client/package.json b/client/package.json
index 8db3525..2c828ee 100644
--- a/client/package.json
+++ b/client/package.json
@@ -15,11 +15,15 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
+ "@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@tanstack/react-query": "^5.100.5",
"axios": "^1.15.2",
+ "effector": "^23.4.4",
+ "effector-react": "^23.3.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-hook-form": "^7.74.0",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx
index b9e28db..e850815 100644
--- a/client/src/app/App.tsx
+++ b/client/src/app/App.tsx
@@ -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() {
} />
} />
+ } />
+ } />
} />
diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx
index f70a949..6fa7063 100644
--- a/client/src/app/layout/MainLayout.tsx
+++ b/client/src/app/layout/MainLayout.tsx
@@ -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)
+ const menuOpen = Boolean(anchorEl)
+
+ const onSchemeChange = (e: SelectChangeEvent) => {
+ setScheme(e.target.value as ColorScheme)
+ }
+
+ const onModeChange = (e: SelectChangeEvent) => {
+ const v = e.target.value
+ if (v === 'system' || v === 'light' || v === 'dark') setMode(v)
+ }
+
+ const openUserMenu = (e: React.MouseEvent) => 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 (
@@ -27,6 +78,107 @@ export function MainLayout({ children }: PropsWithChildren) {
+
+
+
+
+
+
+
+
+
+ Тема
+
+
+
+
+ Режим
+
+
+
+
+ {resolvedMode === 'dark' ? : }
+
diff --git a/client/src/app/providers/AppProviders.tsx b/client/src/app/providers/AppProviders.tsx
index 979d8cc..7237b57 100644
--- a/client/src/app/providers/AppProviders.tsx
+++ b/client/src/app/providers/AppProviders.tsx
@@ -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 (
+
+
+ {children}
+
+ )
+}
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 (
-
-
- {children}
-
+
+ {children}
+
)
}
diff --git a/client/src/app/providers/theme-controller.tsx b/client/src/app/providers/theme-controller.tsx
new file mode 100644
index 0000000..b0937b0
--- /dev/null
+++ b/client/src/app/providers/theme-controller.tsx
@@ -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(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(
+ () => readStoredTheme() ?? { mode: 'system', scheme: 'craft' },
+ )
+
+ const [systemMode, setSystemMode] = useState(() => 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(
+ () => ({
+ 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 {children}
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 32fdd50..7e48bfd 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -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(
diff --git a/client/src/pages/admin/ui/AdminPage.tsx b/client/src/pages/admin/ui/AdminPage.tsx
index abfabbb..957de20 100644
--- a/client/src/pages/admin/ui/AdminPage.tsx
+++ b/client/src/pages/admin/ui/AdminPage.tsx
@@ -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(() => getAdminToken())
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState(null)
- const [form, setForm] = useState(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({
+ 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() {
- setTokenInput(e.target.value)}
- placeholder={token ? '••••••••' : ''}
+ (
+
+ )}
/>