initial: client
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[{*.md,*.mdx}]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
coverage
|
||||
*.min.*
|
||||
package-lock.json
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "lf",
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -0,0 +1,204 @@
|
||||
import eslint from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import importX from 'eslint-plugin-import-x'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import eslintPluginPrettier from 'eslint-plugin-prettier'
|
||||
import globals from 'globals'
|
||||
import boundaries from 'eslint-plugin-boundaries'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
const fsdPathGroups = [
|
||||
{ pattern: 'app/**', group: 'internal', position: 'before' },
|
||||
{ pattern: 'pages/**', group: 'internal', position: 'before' },
|
||||
{ pattern: 'widgets/**', group: 'internal', position: 'before' },
|
||||
{ pattern: 'features/**', group: 'internal', position: 'before' },
|
||||
{ pattern: 'entities/**', group: 'internal', position: 'before' },
|
||||
{ pattern: 'shared/**', group: 'internal', position: 'before' },
|
||||
// alias вида "@/shared/..."
|
||||
{ pattern: '@/**', group: 'internal', position: 'before' },
|
||||
]
|
||||
|
||||
/** Правила + FSD-границы. */
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
{
|
||||
name: 'react-plugin-settings',
|
||||
settings: { react: { version: '19' } },
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
react.configs.flat.recommended,
|
||||
react.configs.flat['jsx-runtime'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.es2021 },
|
||||
},
|
||||
settings: {
|
||||
'import/internal-regex': '^(@/)?(app|pages|widgets|features|entities|shared)(/|$)',
|
||||
'import/resolver': {
|
||||
typescript: { project: './tsconfig.json' },
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-console': ['error', { allow: ['warn', 'error', 'info'] }],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }],
|
||||
'max-len': [
|
||||
'warn',
|
||||
{
|
||||
code: 120,
|
||||
ignoreStrings: true,
|
||||
ignoreTrailingComments: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
ignoreComments: true,
|
||||
},
|
||||
],
|
||||
'import-x/extensions': [
|
||||
'warn',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
json: 'always',
|
||||
svg: 'always',
|
||||
},
|
||||
],
|
||||
'import-x/prefer-default-export': 'off',
|
||||
'import-x/no-extraneous-dependencies': 'off',
|
||||
'import-x/no-cycle': 'warn',
|
||||
'import-x/order': [
|
||||
'warn',
|
||||
{
|
||||
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
|
||||
pathGroups: [
|
||||
{ pattern: 'react', group: 'external', position: 'before' },
|
||||
{ pattern: 'react-dom', group: 'external', position: 'before' },
|
||||
{ pattern: '@mui/**', group: 'external', position: 'before' },
|
||||
...fsdPathGroups,
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ['react'],
|
||||
'newlines-between': 'never',
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'ignore' }],
|
||||
'react/display-name': 'off',
|
||||
'react/no-unescaped-entities': 'warn',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }],
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-shadow': 'off',
|
||||
'no-shadow': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'error',
|
||||
'no-use-before-define': 'off',
|
||||
'consistent-return': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-type-constraint': 'warn',
|
||||
'class-methods-use-this': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: { prettier: eslintPluginPrettier },
|
||||
rules: { 'prettier/prettier': ['warn', { endOfLine: 'lf' }] },
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: { boundaries },
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: { project: './tsconfig.json' },
|
||||
},
|
||||
'boundaries/include': ['src/**/*'],
|
||||
'boundaries/elements': [
|
||||
{ type: 'app', pattern: 'src/app/**' },
|
||||
{ type: 'pages', pattern: 'src/pages/**' },
|
||||
{ type: 'widgets', pattern: 'src/widgets/**' },
|
||||
{ type: 'features', pattern: 'src/features/**' },
|
||||
{ type: 'entities', pattern: 'src/entities/**' },
|
||||
{ type: 'shared', pattern: 'src/shared/**' },
|
||||
],
|
||||
},
|
||||
rules: {
|
||||
'boundaries/no-unknown': 'off',
|
||||
'boundaries/no-unknown-files': 'off',
|
||||
'boundaries/dependencies': [
|
||||
'error',
|
||||
{
|
||||
default: 'disallow',
|
||||
checkUnknownLocals: true,
|
||||
rules: [
|
||||
{ from: { type: 'shared' }, allow: { to: { type: 'shared' } } },
|
||||
{
|
||||
from: { type: 'entities' },
|
||||
allow: { to: { type: ['entities', 'shared'] } },
|
||||
},
|
||||
{
|
||||
from: { type: 'features' },
|
||||
allow: { to: { type: ['features', 'entities', 'shared'] } },
|
||||
},
|
||||
{
|
||||
from: { type: 'widgets' },
|
||||
allow: {
|
||||
to: { type: ['widgets', 'features', 'entities', 'shared'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
from: { type: 'pages' },
|
||||
allow: {
|
||||
to: {
|
||||
type: ['pages', 'widgets', 'features', 'entities', 'shared'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
from: { type: 'app' },
|
||||
allow: {
|
||||
to: {
|
||||
type: ['app', 'pages', 'widgets', 'features', 'entities', 'shared'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: {
|
||||
'import-x/no-unresolved': 'off',
|
||||
'import-x/no-named-as-default': 'off',
|
||||
'import-x/no-named-as-default-member': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-48.png" sizes="48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="preconnect" href="https://xn--80abekoceifm0c0a5irb.xn--p1ai" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Любимый Креатив — изделия ручной работы: игрушки, сувениры и другие уникальные товары с душой и вниманием к деталям."
|
||||
/>
|
||||
<meta name="theme-color" content="#1976d2" />
|
||||
<title>Любимый Креатив — Изделия ручной работы</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Любимый Креатив — Изделия ручной работы" />
|
||||
<meta property="og:description" content="Игрушки, сувениры и другие уникальные изделия ручной работы." />
|
||||
<meta property="og:image" content="/favicon-128.png" />
|
||||
<meta property="og:locale" content="ru_RU" />
|
||||
<link rel="preload" href="/fonts/Outfit-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<link rel="preload" href="/fonts/Outfit-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<link rel="canonical" href="https://любимыйкреатив.рф/" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write --ignore-unknown",
|
||||
"format:check": "prettier . --check --ignore-unknown",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dicebear/adventurer": "^9.4.2",
|
||||
"@dicebear/avataaars": "^9.4.2",
|
||||
"@dicebear/big-ears": "^9.4.2",
|
||||
"@dicebear/big-smile": "^9.4.2",
|
||||
"@dicebear/bottts": "^9.4.2",
|
||||
"@dicebear/core": "^9.4.2",
|
||||
"@dicebear/croodles": "^9.4.2",
|
||||
"@dicebear/fun-emoji": "^9.4.2",
|
||||
"@dicebear/identicon": "^9.4.2",
|
||||
"@dicebear/initials": "^9.4.2",
|
||||
"@dicebear/lorelei": "^9.4.2",
|
||||
"@dicebear/micah": "^9.4.2",
|
||||
"@dicebear/notionists": "^9.4.2",
|
||||
"@dicebear/pixel-art": "^9.4.2",
|
||||
"@dicebear/rings": "^9.4.2",
|
||||
"@dicebear/shapes": "^9.4.2",
|
||||
"@dicebear/thumbs": "^9.4.2",
|
||||
"@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",
|
||||
"@tiptap/extension-placeholder": "^3.22.5",
|
||||
"@tiptap/react": "^3.22.5",
|
||||
"@tiptap/starter-kit": "^3.22.5",
|
||||
"axios": "^1.15.2",
|
||||
"effector": "^23.4.4",
|
||||
"effector-react": "^23.3.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"swiper": "^12.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
"eslint-plugin-import-x": "^4.16.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 516 B |
|
After Width: | Height: | Size: 774 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#546E7A"/><text x="16" y="23" text-anchor="middle" font-size="22" fill="white" font-family="sans-serif" font-weight="bold">К</text></svg>
|
||||
|
After Width: | Height: | Size: 240 B |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,106 @@
|
||||
Политика в отношении обработки персональных данных
|
||||
1. Общие положения
|
||||
Настоящая политика обработки персональных данных составлена в соответствии с требованиями Федерального закона от 27.07.2006. №152-ФЗ «О персональных данных» и определяет порядок обработки персональных данных и меры по обеспечению безопасности персональных данных, предпринимаемые индивидуальным предпринимателем Новоселовой Наталией Владимировной (далее – Оператор).
|
||||
|
||||
1.1. Оператор ставит своей важнейшей целью и условием осуществления своей деятельности соблюдение прав и свобод человека и гражданина при обработке его персональных данных, в том числе защиты прав на неприкосновенность частной жизни, личную и семейную тайну.
|
||||
|
||||
1.2. Настоящая политика Оператора в отношении обработки персональных данных (далее – Политика) применяется ко всей информации, которую Оператор может получить о посетителях веб-сайта www.craftedtoys.ru. Оператор не контролирует и не несет ответственности за сайты третьих лиц, на которые Пользователь может перейти по ссылкам, доступным на www.craftedroys.ru.
|
||||
|
||||
2. Основные понятия, используемые в Политике
|
||||
2.1. Автоматизированная обработка персональных данных – обработка персональных данных с помощью средств вычислительной техники;
|
||||
|
||||
2.2. Блокирование персональных данных – временное прекращение обработки персональных данных (за исключением случаев, если обработка необходима для уточнения персональных данных);
|
||||
|
||||
2.3. Веб-сайт – совокупность графических и информационных материалов, а также программ для ЭВМ и баз данных, обеспечивающих их доступность в сети интернет по сетевому адресу www.craftedtoys.ru;
|
||||
|
||||
2.4. Информационная система персональных данных — совокупность содержащихся в базах данных персональных данных, и обеспечивающих их обработку информационных технологий и технических средств;
|
||||
|
||||
2.5. Обезличивание персональных данных — действия, в результате которых невозможно определить без использования дополнительной информации принадлежность персональных данных конкретному Пользователю или иному субъекту персональных данных;
|
||||
|
||||
2.6. Обработка персональных данных – любое действие (операция) или совокупность действий (операций), совершаемых с использованием средств автоматизации или без использования таких средств с персональными данными, включая сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), извлечение, использование, передачу (распространение, предоставление, доступ), обезличивание, блокирование, удаление, уничтожение персональных данных; Оператор осуществляет обработку данных пользователя до момента подачи им заявления на отзыв согласия на обработку персональных данных
|
||||
|
||||
2.7. Оператор – Администрация сайта, индивидуальный предприниматель Индивидуальный предприниматель Новоселова Наталия Владимировна
|
||||
|
||||
ИНН 402900832341
|
||||
|
||||
ОГРНИП 305402922700051
|
||||
|
||||
Адрес: 248000, Россия, г. Калуга, ул. Никитина, д. 12А
|
||||
|
||||
2.8. Персональные данные – любая информация, относящаяся прямо или косвенно к определенному или определяемому Пользователю веб-сайта www.craftedtoys.ru;
|
||||
|
||||
2.9. Пользователь – любой посетитель веб-сайта www.craftedtoys.ru;
|
||||
|
||||
2.10. Предоставление персональных данных – действия, направленные на раскрытие персональных данных определенному лицу или определенному кругу лиц;
|
||||
|
||||
2.11. Распространение персональных данных – любые действия, направленные на раскрытие персональных данных неопределенному кругу лиц передача персональных данных или на ознакомление с персональными данными неограниченного круга лиц, в том числе обнародование персональных данных в средствах массовой информации, размещение в информационно-телекоммуникационных сетях или предоставление доступа к персональным данным каким-либо иным способом;
|
||||
|
||||
2.12. Уничтожение персональных данных – любые действия, в результате которых персональные данные уничтожаются безвозвратно с невозможностью дальнейшего восстановления содержания персональных данных в информационной системе персональных данных и (или) уничтожаются материальные носители персональных данных.
|
||||
|
||||
3. Оператор может обрабатывать следующие персональные данные Пользователя
|
||||
3.1. Персональная информация, которую Пользователь предоставляет о себе самостоятельно при регистрации (создании учетной записи) или в процессе использования Сайта и его сервисов, включая персональные данные Пользователя. Обязательная для предоставления Сервисов информация помечена специальным образом. Иная информация предоставляется Пользователем на его усмотрение.
|
||||
|
||||
3.2. Данные, которые автоматически передаются сервисам Сайта в процессе их использования с помощью установленного на устройстве Пользователя программного обеспечения (а именно программ Yandex.Metrika (предоставляется ООО “Яндекс”), в том числе IP-адрес, данные файлов cookie, информация о браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к сервисам), технические характеристики оборудования и программного обеспечения, используемых Пользователем, дата и время доступа к сервисам, адреса запрашиваемых страниц, реферер (адрес предыдущей страницы) и иная подобная информация.
|
||||
|
||||
4. Категории собираемых персональных данных и цели их обработки
|
||||
4.1. Сайт собирает и хранит только ту персональную информацию, которая необходима для предоставления информации об услугах или исполнения соглашений и договоров с Пользователем, за исключением случаев, когда законодательством предусмотрено обязательное хранение персональной информации в течение определенного законом срока.
|
||||
|
||||
4.2. Персональную информацию Пользователя Сайт обрабатывает в следующих целях:
|
||||
|
||||
4.2.1. Установления с Пользователем обратной связи, включая направление уведомлений, запросов, касающихся использования Сайта, оказания услуг, обработку запросов и заявок от Пользователя.
|
||||
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом, составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||
|
||||
4.2.2. Идентификации Пользователя, зарегистрированного на Сайте, для формирования и исполнения персонализированных предложений и соглашений, а также предоставление Пользователю доступа к персонализированным ресурсам Сайта.
|
||||
|
||||
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона, пользовательский ID, IP-адрес. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом, составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||
|
||||
4.2.3. Предоставления Пользователю эффективной клиентской и технической поддержки.
|
||||
Для достижения данной цели Оператор собирает и обрабатывает следующие категории персональных данных: имя, адрес электронной почты, контактный номер телефона, пользовательский ID, IP-адрес. К субъектам, персональные данные которых обрабатываются для указанной цели, относятся: физические лица, заинтересованные в получении товаров/работ/услуг от Оператора, физические лица, состоящие в гражданско-правовых и иных договорных отношениях с Оператором, представители юридических лиц - контрагентов Оператора либо потенциально заинтересованных в установлении с ним гражданско-правовых отношений. Указанные персональные данные обрабатываются смешанным способом. Срок обработки и хранения персональных данных, собираемых в соответствии с настоящим пунктом составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных досрочно, а именно в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||
4.3. Обезличенные данные Пользователей, собираемые с помощью сервисов интернет-статистики (а именно с помощью программ Yandex.Metrika (предоставляется ООО “Яндекс”), служат для сбора информации о действиях Пользователей на сайте, улучшения качества сайта и его содержания.
|
||||
|
||||
Для достижения данной цели Оператор собирает и обрабатывает следующие категории обезличенных данных: IP-адрес, данные файлов cookie, информация о браузере Пользователя (или иной программе, с помощью которой осуществляется доступ к сервисам), технические характеристики оборудования и программного обеспечения, используемых Пользователем, дата и время доступа к сервисам, адреса запрашиваемых страниц, реферер (адрес предыдущей страницы). Указанные данные обрабатываются машинным способом. Срок обработки и хранения обезличенных данных, собираемых в соответствии с настоящим пунктом, составляет не более 3 лет с момента последнего посещения Пользователем Сайта.
|
||||
|
||||
5. Правовые основания обработки персональных данных
|
||||
5.1. Оператор обрабатывает персональные данные Пользователя только в случае их заполнения и/или отправки Пользователем самостоятельно через специальные формы, расположенные на сайте www.craftedtoys.ru. Заполняя соответствующие формы и/или отправляя свои персональные данные Оператору, Пользователь выражает свое согласие с данной Политикой.
|
||||
|
||||
5.2. Оператор обрабатывает обезличенные данные о Пользователе в случае, если это разрешено в настройках браузера Пользователя (включено сохранение файлов «cookie» и использование технологии JavaScript).
|
||||
|
||||
5.3. Обработка персональных данных осуществляется с согласия субъекта персональных данных на обработку его персональных данных;
|
||||
|
||||
|
||||
5.4 Обработка персональных данных необходима для исполнения договора, стороной которого либо выгодоприобретателем или поручителем по которому является субъект персональных данных, а также для заключения договора по инициативе субъекта персональных данных или договора, по которому субъект персональных данных будет являться выгодоприобретателем.
|
||||
|
||||
6. Порядок сбора, хранения, передачи и других видов обработки персональных данных
|
||||
6.1. Персональная информация Пользователей хранится на территории Российской Федерации с соблюдением всех требований, установленных действующим российским законодательством.
|
||||
|
||||
6.2. В отношении персональной информации Пользователя сохраняется ее конфиденциальность, кроме случаев добровольного предоставления Пользователем информации о себе для общего доступа неограниченному кругу лиц (например, публикация отзывов). В таких случаях Пользователь соглашается с тем, что определенная часть его персональной информации становится общедоступной.
|
||||
|
||||
6.3. Сайт вправе передать персональную информацию Пользователя третьим лицам в следующих случаях:
|
||||
|
||||
6.3.1. Пользователь выразил согласие на такие действия и был проинформирован, какому конкретному третьему лицу и какой объем персональных данных будет передан.
|
||||
6.3.2. Передача необходима для использования Пользователем определенного сервиса либо для исполнения определенного соглашения или договора с Пользователем.
|
||||
6.3.3. Передача предусмотрена российским или иным применимым законодательством в рамках установленной законодательством процедуры.
|
||||
6.4. Обработка персональных данных Пользователя осуществляется любым законным способом, в том числе в информационных системах персональных данных с использованием средств автоматизации или без использования таких средств. Обработка персональных данных Пользователей осуществляется в соответствии с Федеральным законом от 27.07.2006 N 152-ФЗ "О персональных данных". Срок обработки и хранения персональных данных, собираемых Оператором на сайте составляет не более 7 лет с момента получения последней заявки либо иного обращения от Пользователя. При получении Оператором заявления субъекта персональных данных с требованием о прекращении обработки персональных данных Оператор прекращает обработку персональных данных в срок, не превышающий десяти рабочих дней с даты получения соответствующего требования, за исключением случаев, предусмотренных пунктами 2 - 11 части 1 статьи 6 Федерального закона “О персональных данных”. Указанный срок может быть продлен, но не более чем на пять рабочих дней в случае направления оператором в адрес субъекта персональных данных мотивированного уведомления с указанием причин продления срока предоставления запрашиваемой информации.
|
||||
|
||||
6.5. При утрате или разглашении персональных данных Администрация Сайта информирует Пользователя об утрате или разглашении персональных данных.
|
||||
|
||||
6.6. Администрация Сайта принимает необходимые организационные и технические меры для защиты персональной информации Пользователя от неправомерного или случайного доступа, уничтожения, изменения, блокирования, копирования, распространения, а также от иных неправомерных действий третьих лиц.
|
||||
|
||||
6.7. Администрация Сайта совместно с Пользователем принимает все необходимые меры по предотвращению убытков или иных отрицательных последствий, вызванных утратой или разглашением персональных данных Пользователя.
|
||||
|
||||
7. Ответственность
|
||||
7.1. Администрация Сайта, не исполнившая свои обязательства, несет ответственность за убытки, понесенные Пользователем в связи с неправомерным использованием персональных данных, в соответствии с законодательством Российской Федерации.
|
||||
|
||||
7.2. В случае утраты или разглашения конфиденциальной информации Администрация Сайта не несет ответственности, если данная конфиденциальная информация:
|
||||
|
||||
7.2.1. Стала публичным достоянием до ее утраты или разглашения.
|
||||
7.2.2. Была получена от третьей стороны до момента ее получения Администрацией Сайта.
|
||||
7.2.3. Была разглашена с согласия Пользователя.
|
||||
8. Заключительные положения:
|
||||
8.1. Администрация Сайта вправе вносить изменения в настоящую Политику конфиденциальности без согласия Пользователя.
|
||||
|
||||
8.2. Новая Политика конфиденциальности вступает в силу с момента ее размещения на Сайте, если иное не предусмотрено новой редакцией Политики конфиденциальности.
|
||||
|
||||
8.3. Все предложения или вопросы по настоящей Политике конфиденциальности следует сообщать на электронный адрес toy75@mail.ru
|
||||
|
||||
8.4. Действующая Политика конфиденциальности размещена на странице по адресу: https://craftedtoys.ru/rules/
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://любимыйкреатив.рф/sitemap.xml
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://любимыйкреатив.рф/</loc>
|
||||
<priority>1.0</priority>
|
||||
<changefreq>daily</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://любимыйкреатив.рф/info</loc>
|
||||
<priority>0.8</priority>
|
||||
<changefreq>monthly</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://любимыйкреатив.рф/about</loc>
|
||||
<priority>0.7</priority>
|
||||
<changefreq>monthly</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://любимыйкреатив.рф/privacy</loc>
|
||||
<priority>0.5</priority>
|
||||
<changefreq>yearly</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://любимыйкреатив.рф/terms</loc>
|
||||
<priority>0.5</priority>
|
||||
<changefreq>yearly</changefreq>
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AppProviders } from '@/app/providers/AppProviders'
|
||||
import { AppRoutes } from '@/app/routes'
|
||||
import { NotificationStack } from '@/shared/ui/NotificationStack'
|
||||
import { ErrorBoundary } from '@/shared/ui/ErrorBoundary'
|
||||
import { NoiseOverlay } from '@/shared/ui/NoiseOverlay'
|
||||
import { DemoOverlay } from '@/shared/ui/DemoOverlay'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppProviders>
|
||||
<BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<AppRoutes />
|
||||
</ErrorBoundary>
|
||||
<NotificationStack />
|
||||
<NoiseOverlay />
|
||||
<DemoOverlay />
|
||||
</BrowserRouter>
|
||||
</AppProviders>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
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 IconButton from '@mui/material/IconButton'
|
||||
import { alpha, useTheme } from '@mui/material/styles'
|
||||
import Toolbar from '@mui/material/Toolbar'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Menu, Package } from 'lucide-react'
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||
import { useThemeController } from '@/app/providers/theme-controller'
|
||||
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||
import { fetchMyOrders } from '@/entities/order/api/order-api'
|
||||
import { CartBadge } from '@/features/cart/cart-badge'
|
||||
import { UserMenu } from '@/features/user/user-menu'
|
||||
import { STORE_NAME } from '@/shared/config'
|
||||
import { $user, logout, tokenSet } from '@/shared/model/auth'
|
||||
import type { ColorScheme } from '@/shared/model/theme'
|
||||
import { BearLogo } from '@/shared/ui/BearLogo'
|
||||
import { ModeSwitcher } from '@/shared/ui/ModeSwitcher'
|
||||
import { SchemeSwitcher } from '@/shared/ui/SchemeSwitcher'
|
||||
import { NavigationDrawer } from '@/widgets/navigation-drawer'
|
||||
|
||||
type NavItem = { label: string; to: string }
|
||||
|
||||
const navItems: NavItem[] = [{ label: 'Каталог', to: '/' }]
|
||||
|
||||
export const AppHeader = React.memo(function AppHeader() {
|
||||
const { mode, resolvedMode, scheme, setScheme, cycleMode } = useThemeController()
|
||||
const user = useUnit($user)
|
||||
const navigate = useNavigate()
|
||||
const isAdmin = Boolean(user?.isAdmin)
|
||||
const headerNavItems = isAdmin ? [...navItems, { label: 'Админка', to: '/admin' }] : navItems
|
||||
const theme = useTheme()
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const cartQuery = useQuery({
|
||||
queryKey: ['me', 'cart'],
|
||||
queryFn: fetchMyCart,
|
||||
enabled: Boolean(user) && !isAdmin,
|
||||
})
|
||||
const cartCount = cartQuery.data?.items?.length ?? 0
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['me', 'orders'],
|
||||
queryFn: fetchMyOrders,
|
||||
enabled: Boolean(user) && !isAdmin,
|
||||
})
|
||||
const activeOrdersCount = (ordersQuery.data?.items ?? []).filter(
|
||||
(o) => o.status !== 'DONE' && o.status !== 'CANCELLED',
|
||||
).length
|
||||
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setScrolled(window.scrollY > 0)
|
||||
handler()
|
||||
window.addEventListener('scroll', handler, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handler)
|
||||
}, [])
|
||||
|
||||
const go = (to: string) => {
|
||||
setMobileOpen(false)
|
||||
navigate(to)
|
||||
}
|
||||
|
||||
const onLogout = () => {
|
||||
tokenSet(null)
|
||||
logout()
|
||||
setMobileOpen(false)
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar
|
||||
position="sticky"
|
||||
color="primary"
|
||||
elevation={scrolled ? 2 : 0}
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: alpha(theme.palette.primary.main, 0.95),
|
||||
backdropFilter: 'blur(8px)',
|
||||
transition: 'box-shadow 0.2s ease, background-color 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
sx={{
|
||||
'& .MuiButton-text:hover': { bgcolor: 'rgba(255,255,255,0.12)' },
|
||||
'& .MuiIconButton-root:hover': { bgcolor: 'rgba(255,255,255,0.15)' },
|
||||
}}
|
||||
>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Открыть меню"
|
||||
edge="start"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to="/"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<BearLogo scheme={scheme} sx={{ width: 35, height: 35 }} />
|
||||
<Typography variant="h6" sx={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{STORE_NAME}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!isMobile &&
|
||||
headerNavItems.map((i) => (
|
||||
<Button key={i.to} component={RouterLink} to={i.to} color="inherit">
|
||||
{i.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{!isAdmin && (
|
||||
<>
|
||||
{user && (
|
||||
<Tooltip title="Заказы">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => navigate('/me/orders')}
|
||||
aria-label={activeOrdersCount > 0 ? `Заказы (${activeOrdersCount})` : 'Заказы'}
|
||||
>
|
||||
<Badge color="secondary" badgeContent={activeOrdersCount} invisible={activeOrdersCount === 0}>
|
||||
<Package />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<CartBadge user={user} cartCount={cartCount} onNavigate={navigate} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isAdmin && <UserMenu user={user} isAdmin={false} onNavigate={navigate} onLogout={onLogout} />}
|
||||
|
||||
{isAdmin && user && !isMobile && (
|
||||
<UserMenu user={user} isAdmin={true} onNavigate={navigate} onLogout={onLogout} />
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, ml: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.25)',
|
||||
borderRadius: 3,
|
||||
px: 0.5,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<SchemeSwitcher value={scheme} onChange={(s: ColorScheme) => setScheme(s)} />
|
||||
</Box>
|
||||
<ModeSwitcher mode={mode} resolvedMode={resolvedMode} onCycleMode={cycleMode} />
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<NavigationDrawer
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
user={user}
|
||||
isAdmin={isAdmin}
|
||||
navItems={headerNavItems}
|
||||
scheme={scheme}
|
||||
mode={mode}
|
||||
resolvedMode={resolvedMode}
|
||||
onSchemeChange={(s: ColorScheme) => setScheme(s)}
|
||||
onCycleMode={cycleMode}
|
||||
onNavigate={go}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { type PropsWithChildren } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Container from '@mui/material/Container'
|
||||
import Divider from '@mui/material/Divider'
|
||||
import Grid from '@mui/material/Grid'
|
||||
import Link from '@mui/material/Link'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { AppHeader } from '@/app/layout/AppHeader'
|
||||
import vkLogoSrc from '@/shared/assets/vk-logo.svg'
|
||||
import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config'
|
||||
import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner'
|
||||
import { DemoBanner } from '@/shared/ui/DemoBanner'
|
||||
import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
||||
|
||||
export function MainLayout({ children }: PropsWithChildren) {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', minWidth: 0, overflowX: 'hidden' }}>
|
||||
<ScrollOnNavigate />
|
||||
<ScrollToTop />
|
||||
<AppHeader />
|
||||
<DemoBanner />
|
||||
|
||||
<Box component="main" sx={{ flex: 1, py: { xs: 3, md: 5 } }}>
|
||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
borderTop: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
py: { xs: 5, md: 7 },
|
||||
}}
|
||||
>
|
||||
<Container maxWidth="xl" sx={{ px: { xs: 2, sm: 3, md: 4 } }}>
|
||||
<Grid container spacing={5}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h4"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 700, letterSpacing: '-0.5px' }}
|
||||
>
|
||||
{STORE_NAME}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, maxWidth: 260 }}>
|
||||
Изделия ручной работы: вещи с характером и вниманием к деталям.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h4"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
|
||||
>
|
||||
Покупателям
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Link component={RouterLink} to="/me" color="inherit" underline="hover" variant="body2">
|
||||
Личный кабинет
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/info" color="inherit" underline="hover" variant="body2">
|
||||
О покупке
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/about" color="inherit" underline="hover" variant="body2">
|
||||
О нас
|
||||
</Link>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h4"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
|
||||
>
|
||||
Контакты
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2">
|
||||
Email:{' '}
|
||||
<Link href={`mailto:${STORE_EMAIL}`} underline="hover">
|
||||
{STORE_EMAIL}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
Телефон:{' '}
|
||||
<Link href={`tel:${STORE_PHONE.replace(/\s/g, '')}`} underline="hover">
|
||||
{STORE_PHONE}
|
||||
</Link>
|
||||
</Typography>
|
||||
<Link
|
||||
href={VK_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="text.secondary"
|
||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
|
||||
>
|
||||
<Box component="img" src={vkLogoSrc} alt="" sx={{ width: 20, height: 20 }} />
|
||||
VK
|
||||
</Link>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h4"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 600, letterSpacing: '-0.25px' }}
|
||||
>
|
||||
Юридическая информация
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
<Link component={RouterLink} to="/privacy" color="inherit" underline="hover" variant="body2">
|
||||
Политика конфиденциальности
|
||||
</Link>
|
||||
<Link component={RouterLink} to="/terms" color="inherit" underline="hover" variant="body2">
|
||||
Пользовательское соглашение
|
||||
</Link>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Divider sx={{ my: 4 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
|
||||
© {year} {STORE_NAME}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
<CookieConsentBanner />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MainLayout } from '../MainLayout'
|
||||
|
||||
vi.mock('@/app/layout/AppHeader', () => ({
|
||||
AppHeader: () => <header>Шапка</header>,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/CookieConsentBanner', () => ({
|
||||
CookieConsentBanner: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/DemoBanner', () => ({
|
||||
DemoBanner: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/ScrollOnNavigate', () => ({
|
||||
ScrollOnNavigate: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/ScrollToTop', () => ({
|
||||
ScrollToTop: () => null,
|
||||
}))
|
||||
|
||||
describe('MainLayout', () => {
|
||||
it('не задает фиксированную минимальную ширину, которая ломает мобильный экран', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<MainLayout>Контент</MainLayout>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(container.firstElementChild).not.toHaveStyle({ minWidth: '500px' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
import { type PropsWithChildren, useMemo } from 'react'
|
||||
import CssBaseline from '@mui/material/CssBaseline'
|
||||
import { alpha, ThemeProvider, createTheme } from '@mui/material/styles'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ThemeControllerProvider, useThemeController } from '@/app/providers/theme-controller'
|
||||
import { SseProvider } from './SseProvider'
|
||||
|
||||
function AppThemeInner({ children }: PropsWithChildren) {
|
||||
const controller = useThemeController()
|
||||
const isDark = controller.resolvedMode === 'dark'
|
||||
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
palette: (() => {
|
||||
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)' }
|
||||
|
||||
const chip = isDark ? { default: '#0E1510', paper: '#121B14' } : { default: '#F6FAF6', paper: '#FFFFFF' }
|
||||
|
||||
switch (controller.scheme) {
|
||||
case 'forest':
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#8FBC8F' : '#2E8B57' },
|
||||
secondary: { main: isDark ? '#CD853F' : '#8B4513' },
|
||||
info: { main: isDark ? '#4682B4' : '#1E90FF' },
|
||||
success: { main: isDark ? '#90EE90' : '#32CD32' },
|
||||
warning: { main: isDark ? '#FFD700' : '#FFA500' },
|
||||
error: { main: isDark ? '#F08080' : '#CD5C5C' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||
text,
|
||||
chip,
|
||||
background: isDark
|
||||
? { default: '#0F1720', paper: '#1A242E' }
|
||||
: { default: '#F8F6F3', paper: '#FFFFFF' },
|
||||
}
|
||||
case 'ocean':
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#5F9EA0' : '#20B2AA' },
|
||||
secondary: { main: isDark ? '#7B68EE' : '#6A5ACD' },
|
||||
info: { main: isDark ? '#87CEEB' : '#00BFFF' },
|
||||
success: { main: isDark ? '#98FB98' : '#00FA9A' },
|
||||
warning: { main: isDark ? '#FFE4B5' : '#FFDAB9' },
|
||||
error: { main: isDark ? '#FF6347' : '#FF4500' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||
text,
|
||||
chip,
|
||||
background: isDark
|
||||
? { default: '#0A1A2A', paper: '#0F1D35' }
|
||||
: { default: '#F0F8FF', paper: '#FFFFFF' },
|
||||
}
|
||||
case 'berry':
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#9370DB' : '#8A2BE2' },
|
||||
secondary: { main: isDark ? '#FF69B4' : '#FF1493' },
|
||||
info: { main: isDark ? '#00CED1' : '#00BFFF' },
|
||||
success: { main: isDark ? '#00FF7F' : '#7CFC00' },
|
||||
warning: { main: isDark ? '#FFD700' : '#FFA500' },
|
||||
error: { main: isDark ? '#FF4500' : '#FF6347' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||
text,
|
||||
chip,
|
||||
background: isDark
|
||||
? { default: '#1A0A1A', paper: '#250E25' }
|
||||
: { default: '#FFF0F5', paper: '#FFFFFF' },
|
||||
}
|
||||
case 'craft':
|
||||
default:
|
||||
return {
|
||||
...common,
|
||||
primary: { main: isDark ? '#90A4AE' : '#546E7A' },
|
||||
secondary: { main: isDark ? '#78909C' : '#78909C' },
|
||||
info: { main: isDark ? '#7986CB' : '#3F51B5' },
|
||||
success: { main: isDark ? '#66BB6A' : '#43A047' },
|
||||
warning: { main: isDark ? '#FFB74D' : '#F57C00' },
|
||||
error: { main: isDark ? '#EF5350' : '#D32F2F' },
|
||||
divider: isDark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)',
|
||||
text,
|
||||
chip,
|
||||
background: isDark
|
||||
? { default: '#121212', paper: '#1E1E1E' }
|
||||
: { default: '#F5F5F5', paper: '#FFFFFF' },
|
||||
}
|
||||
}
|
||||
})(),
|
||||
shape: { borderRadius: 12 },
|
||||
typography: {
|
||||
fontFamily: '"Outfit", "Segoe UI", system-ui, sans-serif',
|
||||
h1: { fontWeight: 700, letterSpacing: '-1px', lineHeight: 1.1, textWrap: 'balance' },
|
||||
h2: { fontWeight: 700, letterSpacing: '-0.75px', lineHeight: 1.15, textWrap: 'balance' },
|
||||
h3: { fontWeight: 700, letterSpacing: '-0.5px', lineHeight: 1.2, textWrap: 'balance' },
|
||||
h4: { fontWeight: 700, letterSpacing: '-0.5px', textWrap: 'balance' },
|
||||
h5: { fontWeight: 600, letterSpacing: '-0.25px', textWrap: 'balance' },
|
||||
h6: { fontWeight: 600, textWrap: 'balance' },
|
||||
subtitle1: { fontWeight: 600 },
|
||||
subtitle2: { fontWeight: 500 },
|
||||
body1: { fontSize: '0.875rem', lineHeight: 1.6 },
|
||||
body2: { fontSize: '0.75rem', lineHeight: 1.5 },
|
||||
button: { textTransform: 'none', fontWeight: 600 },
|
||||
},
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
borderRadius: 12,
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid currentColor',
|
||||
outlineOffset: 2,
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
boxShadow: '0 4px 14px 0 rgba(0,0,0,0.12)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 6px 20px 0 rgba(0,0,0,0.18)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
'&:active': {
|
||||
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.12)',
|
||||
transform: 'translateY(0) scale(0.98)',
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
border: '1px solid',
|
||||
'&:hover': {
|
||||
boxShadow: '0 2px 8px 0 rgba(0,0,0,0.08)',
|
||||
},
|
||||
'&:active': {
|
||||
boxShadow: 'none',
|
||||
transform: 'scale(0.98)',
|
||||
},
|
||||
},
|
||||
text: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'action.selected',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: 'action.hover',
|
||||
transform: 'scale(1.08)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'action.selected',
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid currentColor',
|
||||
outlineOffset: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid currentColor',
|
||||
outlineOffset: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiLink: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid currentColor',
|
||||
outlineOffset: 2,
|
||||
borderRadius: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&.Mui-focused': {
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
border: '1px solid',
|
||||
boxShadow: 'none',
|
||||
fontWeight: 500,
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
'& .MuiAlert-icon': {
|
||||
padding: 0,
|
||||
marginRight: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'& .MuiAlert-message': {
|
||||
padding: 0,
|
||||
},
|
||||
'& .MuiAlert-action': {
|
||||
padding: 0,
|
||||
marginRight: 0,
|
||||
marginLeft: 8,
|
||||
},
|
||||
},
|
||||
colorSuccess: ({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
const p = theme.palette.success
|
||||
return {
|
||||
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
'&.MuiAlert-outlined': {
|
||||
bgcolor: 'transparent',
|
||||
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
},
|
||||
'&.MuiAlert-filled': {
|
||||
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||
borderColor: 'transparent',
|
||||
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||
},
|
||||
}
|
||||
},
|
||||
colorError: ({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
const p = theme.palette.error
|
||||
return {
|
||||
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
'&.MuiAlert-outlined': {
|
||||
bgcolor: 'transparent',
|
||||
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
},
|
||||
'&.MuiAlert-filled': {
|
||||
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||
borderColor: 'transparent',
|
||||
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||
},
|
||||
}
|
||||
},
|
||||
colorWarning: ({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
const p = theme.palette.warning
|
||||
return {
|
||||
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
'&.MuiAlert-outlined': {
|
||||
bgcolor: 'transparent',
|
||||
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
},
|
||||
'&.MuiAlert-filled': {
|
||||
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||
borderColor: 'transparent',
|
||||
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||
},
|
||||
}
|
||||
},
|
||||
colorInfo: ({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
const p = theme.palette.info
|
||||
return {
|
||||
bgcolor: isDark ? alpha(p.light, 0.08) : alpha(p.main, 0.08),
|
||||
borderColor: isDark ? alpha(p.light, 0.2) : alpha(p.main, 0.2),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
'&.MuiAlert-outlined': {
|
||||
bgcolor: 'transparent',
|
||||
borderColor: isDark ? alpha(p.light, 0.3) : alpha(p.main, 0.3),
|
||||
color: isDark ? p.light : p.dark,
|
||||
'& .MuiAlert-icon': { color: isDark ? p.light : p.dark },
|
||||
},
|
||||
'&.MuiAlert-filled': {
|
||||
bgcolor: isDark ? alpha(p.light, 0.15) : p.dark,
|
||||
borderColor: 'transparent',
|
||||
color: isDark ? alpha(p.light, 0.9) : '#FFFFFF',
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSnackbarContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
border: '1px solid',
|
||||
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.06)',
|
||||
bgcolor: isDark ? '#1E1E1E' : '#FFFFFF',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
|
||||
color: isDark ? '#F2F2F2' : '#1F1B16',
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[controller.resolvedMode, controller.scheme],
|
||||
)
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
const queryClient = useMemo(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SseProvider />
|
||||
<ThemeControllerProvider>
|
||||
<AppThemeInner>{children}</AppThemeInner>
|
||||
</ThemeControllerProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { createEventStream } from '@/shared/lib/sse'
|
||||
import { $token } from '@/shared/model/auth'
|
||||
|
||||
export function SseProvider() {
|
||||
const token = useUnit($token)
|
||||
const queryClient = useQueryClient()
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.close()
|
||||
sourceRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const es = createEventStream(token)
|
||||
sourceRef.current = es
|
||||
|
||||
function invalidateOrderQueries(orderId: unknown) {
|
||||
if (!orderId) return
|
||||
queryClient.invalidateQueries({ queryKey: ['me', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['me', 'orders', orderId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'detail', orderId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
}
|
||||
|
||||
function handleEvent(eventName: string) {
|
||||
return function (event: MessageEvent) {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
const orderId = data.orderId
|
||||
|
||||
switch (eventName) {
|
||||
case 'message:new':
|
||||
queryClient.invalidateQueries({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['me', 'conversations'] })
|
||||
invalidateOrderQueries(orderId)
|
||||
break
|
||||
case 'order:statusChanged':
|
||||
invalidateOrderQueries(orderId)
|
||||
break
|
||||
case 'order:updated':
|
||||
invalidateOrderQueries(orderId)
|
||||
break
|
||||
case 'order:new':
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'orders'] })
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[sse] Failed to parse event data', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageNewHandler = handleEvent('message:new')
|
||||
const orderStatusHandler = handleEvent('order:statusChanged')
|
||||
const orderUpdatedHandler = handleEvent('order:updated')
|
||||
const orderNewHandler = handleEvent('order:new')
|
||||
|
||||
es.addEventListener('message:new', messageNewHandler)
|
||||
es.addEventListener('order:statusChanged', orderStatusHandler)
|
||||
es.addEventListener('order:updated', orderUpdatedHandler)
|
||||
es.addEventListener('order:new', orderNewHandler)
|
||||
|
||||
return () => {
|
||||
es.removeEventListener('message:new', messageNewHandler)
|
||||
es.removeEventListener('order:statusChanged', orderStatusHandler)
|
||||
es.removeEventListener('order:updated', orderUpdatedHandler)
|
||||
es.removeEventListener('order:new', orderNewHandler)
|
||||
es.close()
|
||||
sourceRef.current = null
|
||||
}
|
||||
}, [token, queryClient])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SseProvider } from '../SseProvider'
|
||||
|
||||
const mockInvalidateQueries = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-query', async () => {
|
||||
const actual = await vi.importActual('@tanstack/react-query')
|
||||
return { ...actual, useQueryClient: () => ({ invalidateQueries: mockInvalidateQueries }) }
|
||||
})
|
||||
|
||||
vi.mock('@/shared/model/auth', () => ({
|
||||
$token: {
|
||||
defaultState: null,
|
||||
subscribe: () => () => {},
|
||||
getState: () => null,
|
||||
watch: () => () => {},
|
||||
on: () => {},
|
||||
reset: () => {},
|
||||
},
|
||||
}))
|
||||
|
||||
let mockToken: string | null = null
|
||||
let mockEventHandlers: Record<string, (event: MessageEvent) => void> = {}
|
||||
let mockCloseCalls = 0
|
||||
|
||||
class MockEventSource {
|
||||
url: string
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
mockCloseCalls = 0
|
||||
mockEventHandlers = {}
|
||||
}
|
||||
addEventListener(type: string, handler: (event: MessageEvent) => void) {
|
||||
mockEventHandlers[type] = handler
|
||||
}
|
||||
removeEventListener(type: string, _handler: (event: MessageEvent) => void) {
|
||||
delete mockEventHandlers[type]
|
||||
}
|
||||
close() {
|
||||
mockCloseCalls++
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/shared/lib/sse', () => ({
|
||||
createEventStream: (token: string) => {
|
||||
mockToken = token
|
||||
return new MockEventSource(`/api/sse/stream?token=${token}`) as unknown as EventSource
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', async () => {
|
||||
const actual = await vi.importActual('effector-react')
|
||||
return { ...actual, useUnit: () => mockToken }
|
||||
})
|
||||
|
||||
function renderSse() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SseProvider />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SseProvider', () => {
|
||||
afterEach(() => {
|
||||
mockToken = null
|
||||
mockInvalidateQueries.mockReset()
|
||||
mockCloseCalls = 0
|
||||
mockEventHandlers = {}
|
||||
})
|
||||
|
||||
it('renders nothing (returns null)', () => {
|
||||
mockToken = null
|
||||
const { container } = renderSse()
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('does not create EventSource when token is null', () => {
|
||||
mockToken = null
|
||||
renderSse()
|
||||
expect(mockToken).toBeNull()
|
||||
})
|
||||
|
||||
it('creates EventSource when token is set', () => {
|
||||
mockToken = 'test-jwt'
|
||||
renderSse()
|
||||
expect(mockToken).toBe('test-jwt')
|
||||
})
|
||||
|
||||
it('closes EventSource on unmount', () => {
|
||||
mockToken = 'test-jwt'
|
||||
const { unmount } = renderSse()
|
||||
expect(mockCloseCalls).toBe(0)
|
||||
unmount()
|
||||
expect(mockCloseCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('invalidates unread-count and conversations on message:new', () => {
|
||||
mockToken = 'test-jwt'
|
||||
renderSse()
|
||||
const handler = mockEventHandlers['message:new']
|
||||
expect(handler).toBeDefined()
|
||||
handler(new MessageEvent('message:new', { data: JSON.stringify({ orderId: 'o1' }) }))
|
||||
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'messages', 'unread-count'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'conversations'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o1'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o1'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
})
|
||||
|
||||
it('invalidates order queries on order:statusChanged', () => {
|
||||
mockToken = 'test-jwt'
|
||||
renderSse()
|
||||
const handler = mockEventHandlers['order:statusChanged']
|
||||
handler(new MessageEvent('order:statusChanged', { data: JSON.stringify({ orderId: 'o2' }) }))
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o2'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o2'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
})
|
||||
|
||||
it('invalidates order queries on order:updated', () => {
|
||||
mockToken = 'test-jwt'
|
||||
renderSse()
|
||||
const handler = mockEventHandlers['order:updated']
|
||||
handler(new MessageEvent('order:updated', { data: JSON.stringify({ orderId: 'o3' }) }))
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['me', 'orders', 'o3'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'detail', 'o3'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
})
|
||||
|
||||
it('invalidates admin queries on order:new', () => {
|
||||
mockToken = 'test-jwt'
|
||||
renderSse()
|
||||
const handler = mockEventHandlers['order:new']
|
||||
handler(new MessageEvent('order:new', { data: JSON.stringify({ orderId: 'o4' }) }))
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders', 'summary'] })
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin', 'orders'] })
|
||||
})
|
||||
|
||||
it('handles invalid JSON gracefully', () => {
|
||||
mockToken = 'test-jwt'
|
||||
renderSse()
|
||||
const handler = mockEventHandlers['message:new']
|
||||
expect(() => {
|
||||
handler(new MessageEvent('message:new', { data: ':heartbit' }))
|
||||
}).not.toThrow()
|
||||
expect(mockInvalidateQueries).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createContext, type PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { PaletteMode } from '@mui/material'
|
||||
import type { ColorScheme, ThemeModePreference } from '@/shared/model/theme'
|
||||
|
||||
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 (err) {
|
||||
console.warn('[theme] Failed to read stored theme', err)
|
||||
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 (err) {
|
||||
console.warn('[theme] Failed to persist theme setting', err)
|
||||
}
|
||||
}, [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>
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import { MainLayout } from '@/app/layout/MainLayout'
|
||||
import { usePageTitleReset } from '@/shared/lib/use-page-title'
|
||||
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
|
||||
|
||||
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout').then((m) => ({ default: m.AdminLayoutPage })))
|
||||
const MeLayoutPage = lazy(() => import('@/pages/me').then((m) => ({ default: m.MeLayoutPage })))
|
||||
|
||||
const HomePage = lazy(() => import('@/pages/home').then((m) => ({ default: m.HomePage })))
|
||||
const AuthPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthPage })))
|
||||
const AuthCallbackPage = lazy(() => import('@/pages/auth').then((m) => ({ default: m.AuthCallbackPage })))
|
||||
const CartPage = lazy(() => import('@/pages/cart').then((m) => ({ default: m.CartPage })))
|
||||
const CheckoutPage = lazy(() => import('@/pages/checkout').then((m) => ({ default: m.CheckoutPage })))
|
||||
const AboutPage = lazy(() => import('@/pages/about').then((m) => ({ default: m.AboutPage })))
|
||||
const InfoPage = lazy(() => import('@/pages/info').then((m) => ({ default: m.InfoPage })))
|
||||
const PrivacyPolicyPage = lazy(() => import('@/pages/privacy-policy').then((m) => ({ default: m.PrivacyPolicyPage })))
|
||||
const TermsPage = lazy(() => import('@/pages/terms').then((m) => ({ default: m.TermsPage })))
|
||||
const ProductPage = lazy(() => import('@/pages/product').then((m) => ({ default: m.ProductPage })))
|
||||
const NotFoundPage = lazy(() => import('@/pages/not-found').then((m) => ({ default: m.NotFoundPage })))
|
||||
|
||||
export function AppRoutes() {
|
||||
usePageTitleReset()
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/*"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<AdminLayoutPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/auth"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<AuthPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/auth/callback"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<AuthCallbackPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cart"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<CartPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/checkout"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<CheckoutPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/about"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<AboutPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/info"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<InfoPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/privacy"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<PrivacyPolicyPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/terms"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<TermsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/*"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<MeLayoutPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products/:id"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<ProductPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Suspense fallback={<SkeletonPage />}>
|
||||
<NotFoundPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('/fonts/Outfit-Regular.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('/fonts/Outfit-Medium.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/fonts/Outfit-SemiBold.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Outfit';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('/fonts/Outfit-Bold.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,21 @@
|
||||
import type { CartItem } from '@/entities/cart/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type CartResponse = { items: CartItem[] }
|
||||
|
||||
export async function fetchMyCart(): Promise<CartResponse> {
|
||||
const { data } = await apiClient.get<CartResponse>('me/cart')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function addToCart(body: { productId: string; qty?: number }): Promise<void> {
|
||||
await apiClient.post('me/cart/items', body)
|
||||
}
|
||||
|
||||
export async function setCartQty(id: string, qty: number): Promise<void> {
|
||||
await apiClient.patch(`me/cart/items/${id}`, { qty })
|
||||
}
|
||||
|
||||
export async function removeCartItem(id: string): Promise<void> {
|
||||
await apiClient.delete(`me/cart/items/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type { CartItem } from './model/types'
|
||||
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
|
||||
export type { CartResponse } from './api/cart-api'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { fetchMyCart } from '../api/cart-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
|
||||
export function useCartQuery() {
|
||||
const user = useUnit($user)
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['me', 'cart'],
|
||||
queryFn: fetchMyCart,
|
||||
enabled: Boolean(user),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
|
||||
export type CartItem = {
|
||||
id: string
|
||||
qty: number
|
||||
product: Product
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { CatalogSliderSlide, AdminCatalogSliderSlide } from '../model/types'
|
||||
|
||||
export async function fetchCatalogSlider(): Promise<{ slides: CatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.get<{ slides: CatalogSliderSlide[] }>('catalog-slider')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminCatalogSlider(): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.get<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function putAdminCatalogSlider(body: {
|
||||
slides: Array<{ galleryImageId: string; caption: string; textColor?: string }>
|
||||
}): Promise<{ slides: AdminCatalogSliderSlide[] }> {
|
||||
const { data } = await apiClient.put<{ slides: AdminCatalogSliderSlide[] }>('admin/catalog-slider', body)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
|
||||
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
|
||||
@@ -0,0 +1,10 @@
|
||||
export type CatalogSliderSlide = {
|
||||
id: string
|
||||
url: string
|
||||
caption: string
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
|
||||
galleryImageId: string
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { GalleryImageItem } from '@/entities/gallery/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
|
||||
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteGalleryImage(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/gallery/${id}`)
|
||||
}
|
||||
|
||||
export async function uploadGalleryImages(files: File[]): Promise<string[]> {
|
||||
for (const f of files) {
|
||||
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const fd = new FormData()
|
||||
for (const f of files) {
|
||||
fd.append('files', f, f.name)
|
||||
}
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
const base = apiBaseURL.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/admin/gallery/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) {
|
||||
throw new Error(
|
||||
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||
)
|
||||
}
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
|
||||
export async function resizeGalleryImage(id: string): Promise<{ url: string }> {
|
||||
const { data } = await apiClient.post<{ url: string }>(`admin/gallery/${id}/resize`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
|
||||
export type { GalleryImageItem } from './model/types'
|
||||
export { GalleryGrid } from './ui/GalleryGrid'
|
||||
@@ -0,0 +1,7 @@
|
||||
export type GalleryImageItem = {
|
||||
id: string
|
||||
url: string
|
||||
isResized: boolean
|
||||
createdAt: string
|
||||
inUse?: boolean
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import AutoFixHighOutlinedIcon from '@mui/icons-material/AutoFixHighOutlined'
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import type { GalleryImageItem } from '../model/types'
|
||||
|
||||
type Props = {
|
||||
items: GalleryImageItem[]
|
||||
deleting?: boolean
|
||||
resizing?: string | null
|
||||
onDelete: (id: string) => void
|
||||
onResize: (id: string) => void
|
||||
}
|
||||
|
||||
export function GalleryGrid({ items, deleting, resizing, onDelete, onResize }: Props) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
aspectRatio: '1',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={item.url}
|
||||
alt=""
|
||||
sizes="140px"
|
||||
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
<Box sx={{ position: 'absolute', top: 4, left: 4 }}>
|
||||
{item.isResized ? (
|
||||
<Chip
|
||||
label="Готово"
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircleOutlineOutlinedIcon fontSize="small" />}
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 }, '& .MuiChip-icon': { fontSize: 14, ml: 0.5 } }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="Не обработано"
|
||||
size="small"
|
||||
color="warning"
|
||||
sx={{ height: 24, '& .MuiChip-label': { px: 0.75 } }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 0.5 }}>
|
||||
{!item.isResized && (
|
||||
<Tooltip title="Обработать (resize)">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'primary.light', color: 'primary.contrastText' },
|
||||
}}
|
||||
disabled={resizing === item.id}
|
||||
onClick={() => onResize(item.id)}
|
||||
>
|
||||
<AutoFixHighOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Удалить из галереи">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
sx={{
|
||||
bgcolor: 'background.paper',
|
||||
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
|
||||
}}
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
<DeleteOutlineOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export interface UserNotificationSettings {
|
||||
id: string
|
||||
userId: string
|
||||
globalEnabled: boolean
|
||||
orderCreated: boolean
|
||||
orderStatusChanged: boolean
|
||||
orderMessageReceived: boolean
|
||||
paymentStatusChanged: boolean
|
||||
deliveryFeeAdjusted: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AdminNotificationSettings {
|
||||
id: string
|
||||
emailEnabled: boolean
|
||||
telegramEnabled: boolean
|
||||
telegramChatId: string | null
|
||||
newOrder: boolean
|
||||
newOrderMessage: boolean
|
||||
newReview: boolean
|
||||
authCodeDuplicate: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export async function fetchUserNotificationSettings(): Promise<{ settings: UserNotificationSettings }> {
|
||||
const { data } = await apiClient.get<{ settings: UserNotificationSettings }>('me/notifications/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateUserNotificationSettings(
|
||||
settings: Partial<UserNotificationSettings>,
|
||||
): Promise<{ settings: UserNotificationSettings }> {
|
||||
const { data } = await apiClient.put<{ settings: UserNotificationSettings }>('me/notifications/settings', settings)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminNotificationSettings(): Promise<{ settings: AdminNotificationSettings }> {
|
||||
const { data } = await apiClient.get<{ settings: AdminNotificationSettings }>('admin/notifications/settings')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAdminNotificationSettings(
|
||||
settings: Partial<AdminNotificationSettings>,
|
||||
): Promise<{ settings: AdminNotificationSettings }> {
|
||||
const { data } = await apiClient.put<{ settings: AdminNotificationSettings }>(
|
||||
'admin/notifications/settings',
|
||||
settings,
|
||||
)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
fetchUserNotificationSettings,
|
||||
updateUserNotificationSettings,
|
||||
fetchAdminNotificationSettings,
|
||||
updateAdminNotificationSettings,
|
||||
} from './api/notifications-api'
|
||||
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
|
||||
@@ -0,0 +1,96 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AdminOrderListItem = {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryFeeLocked: boolean
|
||||
deliveryCarrier?: string | null
|
||||
paymentMethod?: 'online' | 'on_pickup'
|
||||
totalCents: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: { id: string; email: string }
|
||||
itemsCount: number
|
||||
}
|
||||
|
||||
export type AdminOrdersListResponse = {
|
||||
items: AdminOrderListItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type AdminOrderDetailResponse = {
|
||||
item: {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: string | null
|
||||
paymentMethod?: 'online' | 'on_pickup'
|
||||
itemsSubtotalCents: number
|
||||
deliveryFeeCents: number
|
||||
deliveryFeeLocked: boolean
|
||||
totalCents: number
|
||||
currency: string
|
||||
addressSnapshotJson: string | null
|
||||
comment: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
items: Array<{
|
||||
id: string
|
||||
productId: string
|
||||
qty: number
|
||||
titleSnapshot: string
|
||||
priceCentsSnapshot: number
|
||||
}>
|
||||
messages: Array<{
|
||||
id: string
|
||||
authorType: string
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminOrdersSummary(): Promise<{ attentionCount: number }> {
|
||||
const { data } = await apiClient.get<{ attentionCount: number }>('admin/orders/summary')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminOrders(params?: {
|
||||
status?: string
|
||||
deliveryType?: 'delivery' | 'pickup'
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<AdminOrdersListResponse> {
|
||||
const { data } = await apiClient.get<AdminOrdersListResponse>('admin/orders', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminOrder(id: string): Promise<AdminOrderDetailResponse> {
|
||||
const { data } = await apiClient.get<AdminOrderDetailResponse>(`admin/orders/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function setAdminOrderStatus(id: string, status: string): Promise<void> {
|
||||
await apiClient.patch(`admin/orders/${id}/status`, { status })
|
||||
}
|
||||
|
||||
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
|
||||
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
|
||||
}
|
||||
|
||||
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
|
||||
await apiClient.post(`admin/orders/${id}/messages`, { text })
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
||||
|
||||
export type OrderListItem = {
|
||||
id: string
|
||||
status: string
|
||||
totalCents: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
itemsCount: number
|
||||
}
|
||||
|
||||
export type OrderListResponse = { items: OrderListItem[] }
|
||||
|
||||
export type OrderPaymentMethod = 'online' | 'on_pickup'
|
||||
|
||||
export type OrderDetailResponse = {
|
||||
item: {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: DeliveryCarrierCode | null
|
||||
paymentMethod?: OrderPaymentMethod
|
||||
itemsSubtotalCents: number
|
||||
deliveryFeeCents: number
|
||||
deliveryFeeLocked: boolean
|
||||
totalCents: number
|
||||
currency: string
|
||||
addressSnapshotJson: string | null
|
||||
comment: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items: Array<{
|
||||
id: string
|
||||
productId: string
|
||||
qty: number
|
||||
titleSnapshot: string
|
||||
priceCentsSnapshot: number
|
||||
}>
|
||||
messages: Array<{
|
||||
id: string
|
||||
authorType: 'user' | 'admin'
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOrder(body: {
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: DeliveryCarrierCode | null
|
||||
paymentMethod?: OrderPaymentMethod
|
||||
addressId?: string | null
|
||||
comment?: string | null
|
||||
}): Promise<{ orderId: string }> {
|
||||
const { data } = await apiClient.post<{ orderId: string }>('me/orders', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchMyOrders(): Promise<OrderListResponse> {
|
||||
const { data } = await apiClient.get<OrderListResponse>('me/orders')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchMyOrder(id: string): Promise<OrderDetailResponse> {
|
||||
const { data } = await apiClient.get<OrderDetailResponse>(`me/orders/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Создать платёж в ЮKassa и получить URL для редиректа на форму оплаты. */
|
||||
export async function createOrderPayment(orderId: string): Promise<{ confirmationUrl: string }> {
|
||||
const { data } = await apiClient.post<{ confirmationUrl: string }>(`me/orders/${orderId}/pay`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Получить статус платежа для заказа. */
|
||||
export async function getOrderPaymentStatus(orderId: string): Promise<{ status: string | null; paid: boolean }> {
|
||||
const { data } = await apiClient.get<{ status: string | null; paid: boolean }>(`me/orders/${orderId}/payment`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function postOrderMessage(id: string, text: string): Promise<void> {
|
||||
await apiClient.post(`me/orders/${id}/messages`, { text })
|
||||
}
|
||||
|
||||
export async function confirmOrderReceived(id: string): Promise<{ ok: boolean; status: string }> {
|
||||
const { data } = await apiClient.post<{ ok: boolean; status: string }>(`me/orders/${id}/confirm-received`)
|
||||
return data
|
||||
}
|
||||
|
||||
export type ReviewEligibilityItem = { productId: string; title: string; hasReview: boolean }
|
||||
|
||||
export async function fetchOrderReviewEligibility(orderId: string): Promise<{
|
||||
canReview: boolean
|
||||
items: ReviewEligibilityItem[]
|
||||
}> {
|
||||
const { data } = await apiClient.get<{ canReview: boolean; items: ReviewEligibilityItem[] }>(
|
||||
`me/orders/${orderId}/review-eligibility`,
|
||||
)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
fetchMyOrders,
|
||||
createOrder,
|
||||
confirmOrderReceived,
|
||||
fetchMyOrder,
|
||||
fetchOrderReviewEligibility,
|
||||
} from './api/order-api'
|
||||
export { createOrderPayment, getOrderPaymentStatus, postOrderMessage } from './api/order-api'
|
||||
export type { OrderListResponse, OrderDetailResponse } from './api/order-api'
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { apiBaseURL } from '@/shared/config'
|
||||
import { ADMIN_UPLOAD_IMAGE_MAX_BYTES, formatAdminImageMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function fetchAdminProducts(): Promise<Product[]> {
|
||||
const { data } = await apiClient.get<Product[]>('admin/products')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createProduct(body: {
|
||||
title: string
|
||||
slug?: string
|
||||
shortDescription?: string | null
|
||||
description?: string | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl?: string | null
|
||||
imageUrls?: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}): Promise<Product> {
|
||||
const { data } = await apiClient.post<Product>('admin/products', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
id: string,
|
||||
body: Partial<{
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity: number
|
||||
materials: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
published: boolean
|
||||
categoryId: string
|
||||
}>,
|
||||
): Promise<Product> {
|
||||
const { data } = await apiClient.patch<Product>(`admin/products/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/products/${id}`)
|
||||
}
|
||||
|
||||
export async function createCategory(body: { name: string; slug?: string; sort?: number }): Promise<Category> {
|
||||
const { data } = await apiClient.post<Category>('admin/categories', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchAdminCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<{ items: Category[] }>('admin/categories')
|
||||
return data.items
|
||||
}
|
||||
|
||||
export async function updateAdminCategory(
|
||||
id: string,
|
||||
body: Partial<{ name: string; slug: string; sort: number }>,
|
||||
): Promise<Category> {
|
||||
const { data } = await apiClient.patch<Category>(`admin/categories/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminCategory(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/categories/${id}`)
|
||||
}
|
||||
|
||||
/** FormData: не задавать Content-Type вручную (boundary задаёт браузер). */
|
||||
export async function uploadAdminProductImages(files: FileList | readonly File[]): Promise<string[]> {
|
||||
const list = Array.from(files)
|
||||
for (const f of list) {
|
||||
if (f.size > ADMIN_UPLOAD_IMAGE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`Файл «${f.name}» слишком большой (максимум ${formatAdminImageMaxSizeHint()} на одно изображение).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
const fd = new FormData()
|
||||
for (const f of list) {
|
||||
fd.append('files', f, f.name)
|
||||
}
|
||||
const token = localStorage.getItem('craftshop_auth_token')
|
||||
const base = apiBaseURL.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/admin/uploads`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
const payload = (await res.json().catch(() => ({}))) as { urls?: string[]; error?: string }
|
||||
if (!res.ok) {
|
||||
if (res.status === 413) {
|
||||
throw new Error(
|
||||
'Сервер отклонил файл как слишком большой (413). На проде часто лимит nginx: добавьте client_max_body_size для /api/ (см. docs/nginx-upload-limit.md). Проверьте также MAX_UPLOAD_BODY_BYTES в .env на сервере.',
|
||||
)
|
||||
}
|
||||
throw new Error(typeof payload.error === 'string' ? payload.error : `Ошибка загрузки (${res.status})`)
|
||||
}
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
throw new Error('Некорректный ответ сервера')
|
||||
}
|
||||
return payload.urls
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Category, Product } from '@/entities/product/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type PublicProductsResponse = {
|
||||
items: Product[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchPublicProducts(params?: {
|
||||
categorySlug?: string
|
||||
q?: string
|
||||
sort?: 'price_asc' | 'price_desc' | ''
|
||||
page?: number
|
||||
pageSize?: number
|
||||
priceMinCents?: number
|
||||
priceMaxCents?: number
|
||||
}): Promise<PublicProductsResponse> {
|
||||
const { data } = await apiClient.get<PublicProductsResponse>('products', {
|
||||
params: {
|
||||
categorySlug: params?.categorySlug || undefined,
|
||||
q: params?.q || undefined,
|
||||
sort: params?.sort || undefined,
|
||||
page: params?.page || undefined,
|
||||
pageSize: params?.pageSize || undefined,
|
||||
priceMin: params?.priceMinCents ?? undefined,
|
||||
priceMax: params?.priceMaxCents ?? undefined,
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchPublicProduct(id: string): Promise<Product> {
|
||||
const { data } = await apiClient.get<Product>(`products/${id}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
const { data } = await apiClient.get<Category[]>('categories')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
|
||||
export type { PublicProductsResponse } from './api/product-api'
|
||||
@@ -0,0 +1,33 @@
|
||||
export type Category = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
export type ProductReviewsSummary = {
|
||||
approvedReviewCount: number
|
||||
avgRating: number | null
|
||||
latestApprovedText: string | null
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
title: string
|
||||
slug: string
|
||||
shortDescription: string | null
|
||||
description: string | null
|
||||
quantity: number
|
||||
materials?: string[]
|
||||
priceCents: number
|
||||
imageUrl: string | null
|
||||
imageUrls?: string[] // legacy-friendly (used only in admin payloads)
|
||||
published: boolean
|
||||
categoryId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
category?: Category
|
||||
images?: { id: string; url: string; sort: number }[]
|
||||
/** Для опубликованных товаров с публичного API. */
|
||||
reviewsSummary?: ProductReviewsSummary | null
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import Box from '@mui/material/Box'
|
||||
import Card from '@mui/material/Card'
|
||||
import CardMedia from '@mui/material/CardMedia'
|
||||
import Chip from '@mui/material/Chip'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import 'swiper/css'
|
||||
import type { Product } from '@/entities/product/model/types'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
|
||||
import type { Swiper as SwiperType } from 'swiper/types'
|
||||
|
||||
type Props = { product: Product; mediaHeight?: number; actions?: ReactNode }
|
||||
|
||||
const ProductCardInner = ({ product, mediaHeight = 390, actions }: Props) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:600px)')
|
||||
const swiperRef = useRef<SwiperType | null>(null)
|
||||
const imageUrls = useMemo(() => {
|
||||
const fromImages = (product.images ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map((x) => x.url)
|
||||
const urls = fromImages.length ? fromImages : product.imageUrl ? [product.imageUrl] : []
|
||||
return urls
|
||||
}, [product.images, product.imageUrl])
|
||||
|
||||
const materials = (product.materials ?? []).slice(0, 3)
|
||||
const moreMaterials = Math.max(0, (product.materials?.length ?? 0) - materials.length)
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!swiperRef.current) return
|
||||
if (imageUrls.length <= 1) return
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const rel = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width))
|
||||
const idx = Math.min(imageUrls.length - 1, Math.floor(rel * imageUrls.length))
|
||||
swiperRef.current.slideTo(idx, 0)
|
||||
}
|
||||
|
||||
const goToProduct = useCallback(() => {
|
||||
navigate(`/products/${product.id}`)
|
||||
}, [navigate, product.id])
|
||||
|
||||
const stockLabel = product.quantity > 0 ? null : { label: 'Нет в наличии', color: 'default' as const }
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={goToProduct}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderRadius: '16px 16px 12px 12px',
|
||||
border: 'none',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
transition: 'transform 250ms ease, box-shadow 300ms ease',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-6px)',
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.12)',
|
||||
},
|
||||
'&:hover .product-card__media': { transform: 'scale(1.06)' },
|
||||
'&:hover .product-card__title': { color: 'primary.main' },
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:hover': { transform: 'none' },
|
||||
'&:hover .product-card__media': { transform: 'none' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{imageUrls.length ? (
|
||||
<Box
|
||||
onMouseMove={!isMobile ? onMouseMove : undefined}
|
||||
sx={{ width: '100%', aspectRatio: '3/4', maxHeight: mediaHeight, overflow: 'hidden' }}
|
||||
>
|
||||
<Swiper
|
||||
slidesPerView={1}
|
||||
spaceBetween={16}
|
||||
allowTouchMove={!isMobile}
|
||||
onSwiper={(s) => {
|
||||
swiperRef.current = s
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
|
||||
>
|
||||
{imageUrls.map((url) => (
|
||||
<SwiperSlide key={url}>
|
||||
<Box
|
||||
className="product-card__media"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transition: 'transform 320ms ease',
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
userSelect: 'none',
|
||||
bgcolor: 'grey.50',
|
||||
}}
|
||||
>
|
||||
<OptimizedImage
|
||||
src={url}
|
||||
alt={product.title}
|
||||
sizes="(max-width: 600px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||
sx={{
|
||||
width: '101%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</Box>
|
||||
) : (
|
||||
<CardMedia
|
||||
component="div"
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '3/4',
|
||||
maxHeight: mediaHeight,
|
||||
bgcolor: 'grey.50',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography color="text.disabled" variant="body2">
|
||||
Нет фото
|
||||
</Typography>
|
||||
</CardMedia>
|
||||
)}
|
||||
|
||||
{stockLabel && (
|
||||
<Chip
|
||||
label={stockLabel.label}
|
||||
size="small"
|
||||
color={stockLabel.color}
|
||||
variant="filled"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 2,
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
backdropFilter: 'blur(4px)',
|
||||
bgcolor: 'rgba(0,0,0,0.55)',
|
||||
color: 'common.white',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', p: 2, pb: 2 }}>
|
||||
<Stack spacing={1.25} sx={{ flexGrow: 1 }}>
|
||||
{product.category && (
|
||||
<Chip
|
||||
label={product.category.name}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.65rem',
|
||||
height: 22,
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
component="h2"
|
||||
className="product-card__title"
|
||||
sx={{
|
||||
textDecoration: 'none',
|
||||
color: 'text.primary',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
transition: 'color 150ms ease',
|
||||
}}
|
||||
>
|
||||
{product.title}
|
||||
</Typography>
|
||||
|
||||
{(product.materials?.length ?? 0) > 0 && (
|
||||
<Stack direction="row" spacing={0.5} useFlexGap sx={{ flexWrap: 'wrap' }}>
|
||||
{materials.map((m) => (
|
||||
<Chip
|
||||
key={m}
|
||||
label={m}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'chip.default',
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{moreMaterials > 0 && (
|
||||
<Chip
|
||||
label={`+${moreMaterials}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
bgcolor: 'chip.default',
|
||||
color: 'text.secondary',
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
WebkitLineClamp: 2,
|
||||
display: '-webkit-box',
|
||||
WebkitBoxOrient: 'vertical',
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{product.shortDescription ?? 'Описание появится позже.'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1.5 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
component="p"
|
||||
color="primary"
|
||||
sx={{ fontWeight: 700, fontSize: '1.1rem', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{formatPriceRub(product.priceCents)}
|
||||
</Typography>
|
||||
{actions}
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProductCard = React.memo(ProductCardInner, (prev, next) => {
|
||||
return prev.product.id === next.product.id && prev.mediaHeight === next.mediaHeight && prev.actions === next.actions
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AdminReview = {
|
||||
id: string
|
||||
rating: number
|
||||
text: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
moderatedAt: string | null
|
||||
user: { id: string; email: string; displayName: string | null }
|
||||
product: { id: string; title: string }
|
||||
}
|
||||
|
||||
export type AdminReviewsListResponse = {
|
||||
items: AdminReview[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchAdminReviews(params?: {
|
||||
status?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<AdminReviewsListResponse> {
|
||||
const { data } = await apiClient.get<AdminReviewsListResponse>('admin/reviews', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function moderateReview(id: string, action: 'approve' | 'reject'): Promise<void> {
|
||||
await apiClient.patch(`admin/reviews/${id}`, { action })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
|
||||
|
||||
export async function postProductReview(
|
||||
productId: string,
|
||||
body: { rating: number; text?: string | null; imageUrl?: string | null },
|
||||
): Promise<void> {
|
||||
await apiClient.post(`products/${productId}/reviews`, body)
|
||||
}
|
||||
|
||||
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
|
||||
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
|
||||
throw new Error(`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`)
|
||||
}
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('file', file, file.name)
|
||||
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
|
||||
return data
|
||||
}
|
||||
|
||||
export type PublicReviewFeedItem = {
|
||||
id: string
|
||||
rating: number
|
||||
text: string | null
|
||||
imageUrl: string | null
|
||||
createdAt: string
|
||||
authorId: string
|
||||
authorDisplay: string
|
||||
authorAvatar?: string | null
|
||||
authorAvatarStyle?: string | null
|
||||
product: {
|
||||
id: string
|
||||
title: string
|
||||
published: boolean
|
||||
slug: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PublicReviewsLatestResponse = {
|
||||
items: PublicReviewFeedItem[]
|
||||
}
|
||||
|
||||
export async function fetchLatestApprovedReviews(limit = 5): Promise<PublicReviewsLatestResponse> {
|
||||
const { data } = await apiClient.get<PublicReviewsLatestResponse>('reviews/latest', {
|
||||
params: { limit },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export type PublicProductReviewItem = {
|
||||
id: string
|
||||
rating: number
|
||||
text: string | null
|
||||
imageUrl: string | null
|
||||
createdAt: string
|
||||
authorId: string
|
||||
authorDisplay: string
|
||||
authorAvatar?: string | null
|
||||
authorAvatarStyle?: string | null
|
||||
}
|
||||
|
||||
export type PublicProductReviewsResponse = {
|
||||
items: PublicProductReviewItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchPublicProductReviews(
|
||||
productId: string,
|
||||
params?: { page?: number; pageSize?: number },
|
||||
): Promise<PublicProductReviewsResponse> {
|
||||
const { data } = await apiClient.get<PublicProductReviewsResponse>(`products/${productId}/reviews`, {
|
||||
params: { page: params?.page, pageSize: params?.pageSize },
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
postProductReview,
|
||||
uploadReviewImage,
|
||||
fetchLatestApprovedReviews,
|
||||
fetchPublicProductReviews,
|
||||
} from './api/reviews-api'
|
||||
export type {
|
||||
PublicReviewFeedItem,
|
||||
PublicReviewsLatestResponse,
|
||||
PublicProductReviewItem,
|
||||
PublicProductReviewsResponse,
|
||||
} from './api/reviews-api'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type ChecklistResultDto = {
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export type TestChecklistResponse = {
|
||||
results: Record<string, ChecklistResultDto>
|
||||
}
|
||||
|
||||
export type UpdateChecklistItemResponse = {
|
||||
itemKey: string
|
||||
passed: boolean
|
||||
comment: string | null
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
|
||||
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateTestChecklistItem(
|
||||
itemKey: string,
|
||||
passed: boolean,
|
||||
comment?: string | null,
|
||||
): Promise<UpdateChecklistItemResponse> {
|
||||
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
|
||||
itemKey,
|
||||
passed,
|
||||
comment: passed ? null : (comment ?? null),
|
||||
})
|
||||
return data.result
|
||||
}
|
||||
|
||||
export async function resetTestChecklist(): Promise<void> {
|
||||
await apiClient.post('admin/test-checklist/reset')
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ShippingAddress } from '@/entities/user/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AddressesListResponse = { items: ShippingAddress[] }
|
||||
|
||||
export async function fetchMyAddresses(): Promise<AddressesListResponse> {
|
||||
const { data } = await apiClient.get<AddressesListResponse>('me/addresses')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createMyAddress(body: {
|
||||
label?: string | null
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment?: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
isDefault?: boolean
|
||||
}): Promise<{ item: ShippingAddress }> {
|
||||
const { data } = await apiClient.post<{ item: ShippingAddress }>('me/addresses', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateMyAddress(
|
||||
id: string,
|
||||
body: Partial<{
|
||||
label: string | null
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
isDefault: boolean
|
||||
}>,
|
||||
): Promise<{ item: ShippingAddress }> {
|
||||
const { data } = await apiClient.patch<{ item: ShippingAddress }>(`me/addresses/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteMyAddress(id: string): Promise<void> {
|
||||
await apiClient.delete(`me/addresses/${id}`)
|
||||
}
|
||||
|
||||
export async function setMyAddressDefault(id: string): Promise<{ item: ShippingAddress }> {
|
||||
const { data } = await apiClient.post<{ item: ShippingAddress }>(`me/addresses/${id}/default`)
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export async function fetchUnreadMessageCount(): Promise<{ count: number }> {
|
||||
const { data } = await apiClient.get<{ count: number }>('me/messages/unread-count')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function markOrderMessagesRead(orderId: string): Promise<void> {
|
||||
await apiClient.post(`me/orders/${orderId}/messages/read`)
|
||||
}
|
||||
|
||||
export type ConversationSummary = {
|
||||
orderId: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
lastMessageAt: string
|
||||
preview: string
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export async function fetchMyConversations(): Promise<{ items: ConversationSummary[] }> {
|
||||
const { data } = await apiClient.get<{ items: ConversationSummary[] }>('me/conversations')
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AdminUser } from '@/entities/user/model/types'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
|
||||
export type AdminUsersListResponse = {
|
||||
items: AdminUser[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export async function fetchAdminUsers(params?: {
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}): Promise<AdminUsersListResponse> {
|
||||
const { data } = await apiClient.get<AdminUsersListResponse>('admin/users', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createAdminUser(body: { email: string; displayName?: string | null }): Promise<AdminUser> {
|
||||
const { data } = await apiClient.post<AdminUser>('admin/users', body)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAdminUser(
|
||||
id: string,
|
||||
body: Partial<{ email: string; displayName: string | null }>,
|
||||
): Promise<AdminUser> {
|
||||
const { data } = await apiClient.patch<AdminUser>(`admin/users/${id}`, body)
|
||||
return data
|
||||
}
|
||||
|
||||
export type AdminAvatarResponse = {
|
||||
avatar: string | null
|
||||
avatarStyle: string | null
|
||||
}
|
||||
|
||||
export async function fetchAdminAvatar(): Promise<AdminAvatarResponse> {
|
||||
const { data } = await apiClient.get<AdminAvatarResponse>('admin/avatar')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAdminUser(id: string): Promise<void> {
|
||||
await apiClient.delete(`admin/users/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type { AdminUser, ShippingAddress } from './model/types'
|
||||
export { fetchAdminUsers, createAdminUser, updateAdminUser, deleteAdminUser } from './api/user-api'
|
||||
export type { AdminUsersListResponse } from './api/user-api'
|
||||
export {
|
||||
fetchMyAddresses,
|
||||
createMyAddress,
|
||||
updateMyAddress,
|
||||
deleteMyAddress,
|
||||
setMyAddressDefault,
|
||||
} from './api/address-api'
|
||||
@@ -0,0 +1,23 @@
|
||||
export type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
displayName: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type ShippingAddress = {
|
||||
id: string
|
||||
label: string | null
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string | null
|
||||
lat: number
|
||||
lng: number
|
||||
isDefault: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AddressFormDialog } from './ui/AddressFormDialog'
|
||||
export type { AddressFormValues } from './ui/AddressFormDialog'
|
||||
@@ -0,0 +1,127 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import FormControlLabel from '@mui/material/FormControlLabel'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { Controller, type UseFormReturn } from 'react-hook-form'
|
||||
import { AddressMapPicker } from '@/features/address-map-picker'
|
||||
|
||||
export type AddressFormValues = {
|
||||
label: string
|
||||
recipientName: string
|
||||
recipientPhone: string
|
||||
addressLine: string
|
||||
comment: string
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export function AddressFormDialog({
|
||||
open,
|
||||
onClose,
|
||||
editing,
|
||||
form,
|
||||
onSubmit,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
editing: boolean
|
||||
form: UseFormReturn<AddressFormValues>
|
||||
onSubmit: () => void
|
||||
isPending: boolean
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
|
||||
<DialogTitle>{editing ? 'Редактировать адрес' : 'Новый адрес'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="label"
|
||||
render={({ field }) => <TextField label="Метка (например: Дом/Работа)" fullWidth {...field} />}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientName"
|
||||
render={({ field }) => <TextField label="ФИО получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="recipientPhone"
|
||||
render={({ field }) => <TextField label="Телефон получателя" fullWidth required {...field} />}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="addressLine"
|
||||
render={({ field }) => <TextField label="Адрес" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="comment"
|
||||
render={({ field }) => <TextField label="Комментарий (необязательно)" fullWidth {...field} />}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lat"
|
||||
render={({ field: latField }) => (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="lng"
|
||||
render={({ field: lngField }) => (
|
||||
<AddressMapPicker
|
||||
value={
|
||||
latField.value !== null && lngField.value !== null
|
||||
? { lat: latField.value, lng: lngField.value }
|
||||
: null
|
||||
}
|
||||
onChange={(v) => {
|
||||
latField.onChange(v.lat)
|
||||
lngField.onChange(v.lng)
|
||||
if (v.addressLine) form.setValue('addressLine', v.addressLine, { shouldDirty: true })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="isDefault"
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={Boolean(field.value)} onChange={(_, v) => field.onChange(v)} />}
|
||||
label="Адрес по умолчанию"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
disabled={
|
||||
isPending ||
|
||||
!form.watch('recipientName').trim() ||
|
||||
!form.watch('recipientPhone').trim() ||
|
||||
!form.watch('addressLine').trim()
|
||||
}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { LatLng, NominatimItem } from '../model/types'
|
||||
|
||||
export async function reverseGeocode(pos: LatLng): Promise<string | null> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/reverse')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('lat', String(pos.lat))
|
||||
url.searchParams.set('lon', String(pos.lng))
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
const res = await fetch(url.toString(), { headers: { 'User-Agent': 'craftshop-demo' } })
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { display_name?: string }
|
||||
return data.display_name ? String(data.display_name) : null
|
||||
}
|
||||
|
||||
export async function searchPlaces(q: string, signal?: AbortSignal): Promise<NominatimItem[]> {
|
||||
const url = new URL('https://nominatim.openstreetmap.org/search')
|
||||
url.searchParams.set('format', 'jsonv2')
|
||||
url.searchParams.set('q', q)
|
||||
url.searchParams.set('accept-language', 'ru')
|
||||
url.searchParams.set('limit', '5')
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'User-Agent': 'craftshop-demo' },
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as NominatimItem[]
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AddressMapPicker } from './ui/AddressMapPicker'
|
||||
@@ -0,0 +1,3 @@
|
||||
export type NominatimItem = { display_name: string; lat: string; lon: string }
|
||||
|
||||
export type LatLng = { lat: number; lng: number }
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { reverseGeocode, searchPlaces } from '../api/map-geocoding'
|
||||
import { MapPickerMap } from './MapPickerMap'
|
||||
import type { LatLng, NominatimItem } from '../model/types'
|
||||
|
||||
export function AddressMapPicker(props: {
|
||||
value: { lat: number; lng: number } | null
|
||||
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||
}) {
|
||||
const { value, onChange } = props
|
||||
const [q, setQ] = useState('')
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [results, setResults] = useState<NominatimItem[]>([])
|
||||
const [hint, setHint] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const lastQueryRef = useRef<string>('')
|
||||
const lastRequestAtRef = useRef<number>(0)
|
||||
|
||||
const qTrimmed = q.trim()
|
||||
const visibleResults = qTrimmed.length >= 3 ? results : []
|
||||
|
||||
const center = useMemo(() => {
|
||||
if (value) return { lat: value.lat, lng: value.lng }
|
||||
return { lat: 55.751244, lng: 37.618423 }
|
||||
}, [value])
|
||||
|
||||
const pick = async (pos: LatLng) => {
|
||||
setHint(null)
|
||||
onChange({ lat: pos.lat, lng: pos.lng })
|
||||
try {
|
||||
const addr = await reverseGeocode(pos)
|
||||
if (addr) {
|
||||
setHint(addr)
|
||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[address-map-picker] Failed to reverse geocode', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const s = qTrimmed
|
||||
if (s.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
const t = window.setTimeout(async () => {
|
||||
const now = Date.now()
|
||||
if (now - lastRequestAtRef.current < 900) return
|
||||
if (s === lastQueryRef.current) return
|
||||
|
||||
lastQueryRef.current = s
|
||||
lastRequestAtRef.current = now
|
||||
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
|
||||
setSearching(true)
|
||||
try {
|
||||
setResults(await searchPlaces(s, ac.signal))
|
||||
} catch (e) {
|
||||
if ((e as { name?: string })?.name !== 'AbortError') {
|
||||
setResults([])
|
||||
}
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, 450)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(t)
|
||||
}
|
||||
}, [qTrimmed])
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle2">Выбор на карте</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||
<TextField size="small" label="Найти адрес" value={q} onChange={(e) => setQ(e.target.value)} fullWidth />
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={async () => {
|
||||
const s = q.trim()
|
||||
if (!s) return
|
||||
abortRef.current?.abort()
|
||||
const ac = new AbortController()
|
||||
abortRef.current = ac
|
||||
setSearching(true)
|
||||
try {
|
||||
lastQueryRef.current = s
|
||||
lastRequestAtRef.current = Date.now()
|
||||
setResults(await searchPlaces(s, ac.signal))
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}}
|
||||
disabled={searching || !q.trim()}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
{searching ? <CircularProgress size={18} /> : 'Найти'}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{visibleResults.length > 0 && (
|
||||
<List dense sx={{ border: 1, borderColor: 'divider', borderRadius: 2 }}>
|
||||
{visibleResults.map((r) => (
|
||||
<ListItemButton
|
||||
key={`${r.lat}:${r.lon}:${r.display_name}`}
|
||||
onClick={() => {
|
||||
const lat = Number(r.lat)
|
||||
const lng = Number(r.lon)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
<ListItemText primary={r.display_name} />
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<MapPickerMap value={value} onChange={onChange} center={center} />
|
||||
|
||||
<Box sx={{ minHeight: 32, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{hint && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Подсказка адреса: {hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import MyLocationOutlinedIcon from '@mui/icons-material/MyLocationOutlined'
|
||||
import Box from '@mui/material/Box'
|
||||
import CircularProgress from '@mui/material/CircularProgress'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import Map, { Marker, type MapMouseEvent, type MapRef } from 'react-map-gl/maplibre'
|
||||
import { reverseGeocode } from '../api/map-geocoding'
|
||||
import type { LatLng } from '../model/types'
|
||||
import type * as maplibregl from 'maplibre-gl'
|
||||
|
||||
let maplibreglPromise: Promise<typeof maplibregl> | null = null
|
||||
|
||||
function loadMaplibre() {
|
||||
if (!maplibreglPromise) {
|
||||
maplibreglPromise = Promise.all([import('maplibre-gl'), import('maplibre-gl/dist/maplibre-gl.css')]).then(
|
||||
([mod]) => mod,
|
||||
)
|
||||
}
|
||||
return maplibreglPromise
|
||||
}
|
||||
|
||||
type MapPickerMapProps = {
|
||||
value: { lat: number; lng: number } | null
|
||||
onChange: (v: { lat: number; lng: number; addressLine?: string | null }) => void
|
||||
center: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
export function MapPickerMap({ value, onChange, center }: MapPickerMapProps) {
|
||||
const mapRef = useRef<MapRef | null>(null)
|
||||
const [maplibre, setMaplibre] = useState<typeof maplibregl | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [locating, setLocating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadMaplibre().then((mod) => {
|
||||
if (!cancelled) {
|
||||
setMaplibre(mod)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pick = async (pos: LatLng) => {
|
||||
onChange({ lat: pos.lat, lng: pos.lng })
|
||||
try {
|
||||
const addr = await reverseGeocode(pos)
|
||||
if (addr) {
|
||||
onChange({ lat: pos.lat, lng: pos.lng, addressLine: addr })
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[map-picker] Failed to reverse geocode', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !maplibre) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 280,
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 280,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Map
|
||||
mapLib={maplibre}
|
||||
ref={mapRef}
|
||||
initialViewState={{ latitude: center.lat, longitude: center.lng, zoom: 12 }}
|
||||
style={{ width: '100%', height: 280 }}
|
||||
mapStyle={{
|
||||
version: 8,
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
||||
}}
|
||||
onClick={(e: MapMouseEvent) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
>
|
||||
{value && (
|
||||
<Marker
|
||||
longitude={value.lng}
|
||||
latitude={value.lat}
|
||||
draggable
|
||||
onDragEnd={(e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
void pick({ lat, lng })
|
||||
}}
|
||||
anchor="bottom"
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '50%',
|
||||
border: 2,
|
||||
borderColor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
}}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
|
||||
<Tooltip title="Моё местоположение">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={locating || !('geolocation' in navigator)}
|
||||
onClick={() => {
|
||||
if (!('geolocation' in navigator)) return
|
||||
setLocating(true)
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const lat = pos.coords.latitude
|
||||
const lng = pos.coords.longitude
|
||||
mapRef.current?.flyTo({ center: [lng, lat], zoom: 15, duration: 800 })
|
||||
void pick({ lat, lng })
|
||||
setLocating(false)
|
||||
},
|
||||
() => {
|
||||
setLocating(false)
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 8000, maximumAge: 60_000 },
|
||||
)
|
||||
}}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
bgcolor: 'background.paper',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
boxShadow: 2,
|
||||
'&:hover': { bgcolor: 'background.paper' },
|
||||
}}
|
||||
aria-label="Моё местоположение"
|
||||
>
|
||||
{locating ? <CircularProgress size={16} /> : <MyLocationOutlinedIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthCodeForm } from './ui/AuthCodeForm'
|
||||
@@ -0,0 +1,67 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AuthCodeForm } from '../ui/AuthCodeForm'
|
||||
|
||||
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
|
||||
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
|
||||
|
||||
function renderForm() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const onSuccess = vi.fn()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<AuthCodeForm onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthCodeForm', () => {
|
||||
it('renders email field, code field, and buttons', () => {
|
||||
renderForm()
|
||||
expect(screen.getByLabelText(/Email/i)).toBeTruthy()
|
||||
expect(screen.getByLabelText(/Код/i)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables send button when email is empty', () => {
|
||||
renderForm()
|
||||
expect(screen.getByRole('button', { name: 'Отправить код' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables login button when code.length !== 6', () => {
|
||||
renderForm()
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123' } })
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('enables login button when code is 6 digits', async () => {
|
||||
renderForm()
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSuccess after successful verify', async () => {
|
||||
const { apiClient } = await import('@/shared/api/client')
|
||||
const { tokenSet } = await import('@/shared/model/auth')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
|
||||
renderForm()
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Код/i), { target: { value: '123456' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).not.toBeDisabled()
|
||||
await waitFor(() => {
|
||||
expect(tokenSet).toHaveBeenCalledWith('test-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Link from '@mui/material/Link'
|
||||
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 { Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||
import { tokenSet } from '@/shared/model/auth'
|
||||
|
||||
type AuthResponse = {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName?: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
code: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function AuthCodeForm({ onSuccess }: Props) {
|
||||
const { register, watch } = useForm<FormValues>({
|
||||
defaultValues: { email: '', code: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const code = watch('code')
|
||||
|
||||
const requestCodeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/request-code', { email })
|
||||
},
|
||||
})
|
||||
|
||||
const verifyCodeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/verify-code', { email, code })
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Email"
|
||||
{...register('email')}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => requestCodeMutation.mutate()}
|
||||
disabled={!email || requestCodeMutation.isPending}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Отправить код
|
||||
</Button>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} sx={{ flex: 1 }} />
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => verifyCodeMutation.mutate()}
|
||||
disabled={!email || code.length !== 6 || verifyCodeMutation.isPending}
|
||||
sx={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{(requestCodeMutation.error || verifyCodeMutation.error) && (
|
||||
<TextField
|
||||
error
|
||||
helperText={getApiErrorMessage(requestCodeMutation.error) || getApiErrorMessage(verifyCodeMutation.error)}
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Нажимая «Войти», вы принимаете{' '}
|
||||
<Link component={RouterLink} to="/terms" underline="hover">
|
||||
пользовательское соглашение
|
||||
</Link>{' '}
|
||||
и{' '}
|
||||
<Link component={RouterLink} to="/privacy" underline="hover">
|
||||
политику конфиденциальности
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthForgotForm } from './ui/AuthForgotForm'
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
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 { Lock, Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||
|
||||
type Step = 'request' | 'reset'
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
code: string
|
||||
newPassword: string
|
||||
passwordConfirm: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function AuthForgotForm({ onBack }: Props) {
|
||||
const [step, setStep] = useState<Step>('request')
|
||||
|
||||
const { register, watch } = useForm<FormValues>({
|
||||
defaultValues: { email: '', code: '', newPassword: '', passwordConfirm: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const email = watch('email')
|
||||
const code = watch('code')
|
||||
const newPassword = watch('newPassword')
|
||||
const passwordConfirm = watch('passwordConfirm')
|
||||
|
||||
const forgotCodeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/forgot-password', { email })
|
||||
},
|
||||
onSuccess: () => setStep('reset'),
|
||||
})
|
||||
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('auth/reset-password', { email, code, newPassword })
|
||||
},
|
||||
})
|
||||
|
||||
const passwordError = newPassword && passwordConfirm && newPassword !== passwordConfirm ? 'Пароли не совпадают' : null
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
{step === 'request'
|
||||
? 'Введите email, на который будет отправлен код для сброса пароля'
|
||||
: 'Введите код и новый пароль'}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
{...register('email')}
|
||||
disabled={step === 'reset'}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{step === 'reset' && (
|
||||
<>
|
||||
<TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} fullWidth />
|
||||
<TextField
|
||||
label="Новый пароль"
|
||||
type="password"
|
||||
{...register('newPassword')}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Подтверждение пароля"
|
||||
type="password"
|
||||
{...register('passwordConfirm')}
|
||||
fullWidth
|
||||
error={Boolean(passwordError)}
|
||||
helperText={passwordError}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'request' ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!email || forgotCodeMutation.isPending}
|
||||
onClick={() => forgotCodeMutation.mutate()}
|
||||
>
|
||||
Отправить код
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
!code ||
|
||||
code.length !== 6 ||
|
||||
!newPassword ||
|
||||
newPassword.length < 8 ||
|
||||
Boolean(passwordError) ||
|
||||
resetPasswordMutation.isPending
|
||||
}
|
||||
onClick={() => resetPasswordMutation.mutate()}
|
||||
>
|
||||
Сменить пароль
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="text" size="small" onClick={onBack}>
|
||||
Назад к входу
|
||||
</Button>
|
||||
|
||||
{(forgotCodeMutation.error || resetPasswordMutation.error) && (
|
||||
<TextField
|
||||
error
|
||||
helperText={getApiErrorMessage(forgotCodeMutation.error) || getApiErrorMessage(resetPasswordMutation.error)}
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { OAuthButtons } from '../ui/OAuthButtons'
|
||||
|
||||
describe('OAuthButtons', () => {
|
||||
it('renders Yandex and VK buttons', () => {
|
||||
render(<OAuthButtons />)
|
||||
expect(screen.getByText('Войти через Яндекс ID')).toBeDefined()
|
||||
expect(screen.getByText('Войти через VK ID')).toBeDefined()
|
||||
})
|
||||
|
||||
it('buttons have correct href', () => {
|
||||
render(<OAuthButtons />)
|
||||
const yaBtn = screen.getByText('Войти через Яндекс ID').closest('a')
|
||||
const vkBtn = screen.getByText('Войти через VK ID').closest('a')
|
||||
expect(yaBtn?.getAttribute('href')).toContain('/auth/oauth/yandex')
|
||||
expect(vkBtn?.getAttribute('href')).toContain('/auth/oauth/vk')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { OAuthButtons } from './ui/OAuthButtons'
|
||||
@@ -0,0 +1,24 @@
|
||||
import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'
|
||||
|
||||
export type OAuthProvider = {
|
||||
id: 'yandex' | 'vk'
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const oauthProviders: OAuthProvider[] = [
|
||||
{
|
||||
id: 'yandex',
|
||||
label: 'Яндекс ID',
|
||||
color: '#FC3F1D',
|
||||
},
|
||||
{
|
||||
id: 'vk',
|
||||
label: 'VK ID',
|
||||
color: '#0077FF',
|
||||
},
|
||||
]
|
||||
|
||||
export function getOAuthUrl(provider: 'yandex' | 'vk'): string {
|
||||
return oauthAuthorizeUrl(provider)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers'
|
||||
|
||||
export function OAuthButtons() {
|
||||
return (
|
||||
<Stack direction="row" spacing={1} sx={{ justifyContent: 'center' }}>
|
||||
{oauthProviders.map((p) => (
|
||||
<Button
|
||||
key={p.id}
|
||||
variant="outlined"
|
||||
href={getOAuthUrl(p.id)}
|
||||
sx={{
|
||||
borderColor: p.color,
|
||||
color: p.color,
|
||||
'&:hover': {
|
||||
borderColor: p.color,
|
||||
bgcolor: `${p.color}14`,
|
||||
borderWidth: '1px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Войти через {p.label}
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AuthPasswordForm } from './ui/AuthPasswordForm'
|
||||
@@ -0,0 +1,74 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AuthPasswordForm } from '../ui/AuthPasswordForm'
|
||||
|
||||
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
|
||||
vi.mock('@/shared/model/auth', () => ({ tokenSet: vi.fn() }))
|
||||
|
||||
function renderForm(isRegister: boolean) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const onSuccess = vi.fn()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<AuthPasswordForm isRegister={isRegister} onRegisterChange={vi.fn()} onSuccess={onSuccess} />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuthPasswordForm', () => {
|
||||
it('renders login button when isRegister=false', () => {
|
||||
renderForm(false)
|
||||
expect(screen.getByRole('button', { name: 'Войти' })).toBeTruthy()
|
||||
expect(screen.getByText('Вход')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders register button and passwordConfirm when isRegister=true', () => {
|
||||
renderForm(true)
|
||||
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeTruthy()
|
||||
expect(screen.getByLabelText(/Подтверждение пароля/i)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables button when password < 8 chars', async () => {
|
||||
const { apiClient } = await import('@/shared/api/client')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({} as never)
|
||||
renderForm(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: '123' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Зарегистрироваться' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when passwords do not match', async () => {
|
||||
renderForm(true)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
|
||||
fireEvent.change(screen.getByLabelText(/Подтверждение пароля/i), { target: { value: 'different' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Пароли не совпадают')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSuccess after successful login', async () => {
|
||||
const { apiClient } = await import('@/shared/api/client')
|
||||
const { tokenSet } = await import('@/shared/model/auth')
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { token: 'test-token' } } as never)
|
||||
renderForm(false)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Email/i), { target: { value: 'test@test.com' } })
|
||||
fireEvent.change(screen.getByLabelText(/Пароль/i), { target: { value: 'password123' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Войти' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(tokenSet).toHaveBeenCalledWith('test-token')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import InputAdornment from '@mui/material/InputAdornment'
|
||||
import Link from '@mui/material/Link'
|
||||
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 { Lock, Mail } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import { getApiErrorMessage } from '@/shared/lib/get-api-error-message'
|
||||
import { tokenSet } from '@/shared/model/auth'
|
||||
|
||||
type AuthResponse = {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
displayName?: string | null
|
||||
avatar?: string | null
|
||||
avatarStyle?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
type FormValues = {
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirm: string
|
||||
displayName: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
isRegister: boolean
|
||||
onRegisterChange: (v: boolean) => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Props) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const password = watch('password')
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/login', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
})
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (values: FormValues) => {
|
||||
const { data } = await apiClient.post<AuthResponse>('auth/register', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
displayName: values.displayName || undefined,
|
||||
})
|
||||
tokenSet(data.token)
|
||||
},
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const apiError = loginMutation.error || registerMutation.error
|
||||
const apiErrorMessage = apiError ? getApiErrorMessage(apiError) : null
|
||||
|
||||
const onSubmit = isRegister
|
||||
? handleSubmit((values) => registerMutation.mutate(values))
|
||||
: handleSubmit((values) => loginMutation.mutate(values))
|
||||
|
||||
const isPending = loginMutation.isPending || registerMutation.isPending
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={onSubmit} noValidate>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'center' }} spacing={3}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
color: !isRegister ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: !isRegister ? 2 : 0,
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 0,
|
||||
pb: 0.5,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => onRegisterChange(false)}
|
||||
>
|
||||
Вход
|
||||
</Button>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
sx={{
|
||||
color: isRegister ? 'primary.main' : 'text.secondary',
|
||||
borderBottom: isRegister ? 2 : 0,
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 0,
|
||||
pb: 0.5,
|
||||
textTransform: 'none',
|
||||
}}
|
||||
onClick={() => onRegisterChange(true)}
|
||||
>
|
||||
Регистрация
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<TextField
|
||||
label="Email"
|
||||
{...register('email', {
|
||||
required: 'Введите email',
|
||||
pattern: {
|
||||
value: EMAIL_PATTERN,
|
||||
message: 'Некорректный email',
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
autoComplete="email"
|
||||
error={Boolean(errors.email)}
|
||||
helperText={errors.email?.message}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Mail size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Имя (необязательно)"
|
||||
{...register('displayName')}
|
||||
fullWidth
|
||||
helperText="Если не указать, будет использована часть email до @"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Пароль"
|
||||
type="password"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
{...register('password', {
|
||||
required: 'Введите пароль',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Пароль должен быть не менее 8 символов',
|
||||
},
|
||||
})}
|
||||
fullWidth
|
||||
error={Boolean(errors.password)}
|
||||
helperText={errors.password?.message}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Lock size={18} />
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{isRegister && (
|
||||
<TextField
|
||||
label="Подтверждение пароля"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...register('passwordConfirm', {
|
||||
required: 'Подтвердите пароль',
|
||||
validate: (value) => value === password || 'Пароли не совпадают',
|
||||
})}
|
||||
fullWidth
|
||||
error={Boolean(errors.passwordConfirm)}
|
||||
helperText={errors.passwordConfirm?.message}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<Alert
|
||||
severity="error"
|
||||
variant="outlined"
|
||||
onClose={() => {
|
||||
loginMutation.reset()
|
||||
registerMutation.reset()
|
||||
}}
|
||||
>
|
||||
{apiErrorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isRegister ? (
|
||||
<Button variant="contained" size="large" type="submit" disabled={isPending}>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" size="large" type="submit" disabled={isPending}>
|
||||
Войти
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center' }}>
|
||||
Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '}
|
||||
<Link component={RouterLink} to="/terms" underline="hover">
|
||||
пользовательское соглашение
|
||||
</Link>{' '}
|
||||
и{' '}
|
||||
<Link component={RouterLink} to="/privacy" underline="hover">
|
||||
политику конфиденциальности
|
||||
</Link>
|
||||
.
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AddToCartButton } from './ui/AddToCartButton'
|
||||
@@ -0,0 +1,46 @@
|
||||
import Button from '@mui/material/Button'
|
||||
import type { ButtonProps } from '@mui/material/Button'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { addToCart } from '@/entities/cart/api/cart-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { addNotification } from '@/shared/model/notification'
|
||||
|
||||
type Props = {
|
||||
productId: string
|
||||
qty?: number
|
||||
loggedOutLabel?: string
|
||||
} & Omit<ButtonProps, 'onClick'>
|
||||
|
||||
export function AddToCartButton(props: Props) {
|
||||
const { productId, qty = 1, loggedOutLabel = 'Войдите, чтобы купить', disabled, children, ...rest } = props
|
||||
const qc = useQueryClient()
|
||||
const user = useUnit($user)
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...rest}
|
||||
disabled={Boolean(disabled) || !user || addMut.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
addMut.mutate()
|
||||
}}
|
||||
>
|
||||
{user ? (children ?? 'В корзину') : loggedOutLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as notifications from '@/shared/model/notification'
|
||||
import { AddToCartButton } from '../AddToCartButton'
|
||||
|
||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||
addToCart: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', async () => {
|
||||
const actual = await vi.importActual('effector-react')
|
||||
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
|
||||
})
|
||||
|
||||
describe('AddToCartButton', () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
qc.clear()
|
||||
})
|
||||
|
||||
it('calls addNotification after successful add', async () => {
|
||||
const spy = vi.spyOn(notifications, 'addNotification')
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<AddToCartButton productId="test-product" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { CartBadge } from './ui/CartBadge'
|
||||
@@ -0,0 +1,31 @@
|
||||
import Badge from '@mui/material/Badge'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
import type { AuthUser } from '@/shared/model/auth'
|
||||
|
||||
type Props = {
|
||||
user: AuthUser | null
|
||||
cartCount: number
|
||||
onNavigate: (to: string) => void
|
||||
}
|
||||
|
||||
export function CartBadge({ user, cartCount, onNavigate }: Props) {
|
||||
return (
|
||||
<Tooltip title={user ? 'Корзина' : 'Авторизуйтесь для совершения покупок'}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
if (!user) onNavigate('/auth')
|
||||
else onNavigate('/cart')
|
||||
}}
|
||||
aria-label={user ? `Корзина (${cartCount})` : 'Авторизуйтесь для совершения покупок'}
|
||||
>
|
||||
<Badge color="secondary" badgeContent={user ? cartCount : 0} invisible={!user || cartCount === 0}>
|
||||
<ShoppingCart />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ToggleCartIcon } from './ui/ToggleCartIcon'
|
||||
@@ -0,0 +1,77 @@
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Tooltip from '@mui/material/Tooltip'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { addToCart, removeCartItem } from '@/entities/cart/api/cart-api'
|
||||
import { useCartQuery } from '@/entities/cart/lib/use-cart-query'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { addNotification } from '@/shared/model/notification'
|
||||
|
||||
export function ToggleCartIcon(props: {
|
||||
productId: string
|
||||
size?: 'small' | 'medium'
|
||||
disabledReason?: string | null
|
||||
}) {
|
||||
const { productId, size = 'small', disabledReason = null } = props
|
||||
const user = useUnit($user)
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const cartQuery = useCartQuery()
|
||||
|
||||
const existing = cartQuery.data?.items.find((x) => x.product.id === productId) ?? null
|
||||
const inCart = Boolean(existing)
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: () => addToCart({ productId, qty: 1 }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['me', 'cart'] })
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: () => removeCartItem(existing!.id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['me', 'cart'] }),
|
||||
})
|
||||
|
||||
const disabled = Boolean(disabledReason)
|
||||
const busy = addMut.isPending || removeMut.isPending
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (disabledReason) return
|
||||
if (!user) {
|
||||
navigate('/auth')
|
||||
return
|
||||
}
|
||||
if (inCart) removeMut.mutate()
|
||||
else addMut.mutate()
|
||||
}
|
||||
|
||||
const tooltip = disabledReason
|
||||
? disabledReason
|
||||
: !user
|
||||
? 'Авторизуйтесь для совершения покупок'
|
||||
: inCart
|
||||
? 'Убрать из корзины'
|
||||
: 'В корзину'
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<IconButton size={size} onClick={onClick} disabled={disabled || busy} aria-label={tooltip} type="button">
|
||||
{user ? inCart ? <ShoppingCart fill="currentColor" /> : <ShoppingCart /> : <ShoppingCart />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as api from '@/entities/cart/api/cart-api'
|
||||
import * as notifications from '@/shared/model/notification'
|
||||
import { ToggleCartIcon } from '../ToggleCartIcon'
|
||||
|
||||
vi.mock('@/entities/cart/api/cart-api', () => ({
|
||||
addToCart: vi.fn(() => Promise.resolve()),
|
||||
fetchMyCart: vi.fn(() => Promise.resolve({ items: [] })),
|
||||
removeCartItem: vi.fn(() => Promise.resolve()),
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', async () => {
|
||||
const actual = await vi.importActual('effector-react')
|
||||
return { ...actual, useUnit: () => ({ id: '1', email: 'test@test.com' }) }
|
||||
})
|
||||
|
||||
describe('ToggleCartIcon', () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
qc.clear()
|
||||
})
|
||||
|
||||
it('calls addNotification after successful add', async () => {
|
||||
const spy = vi.spyOn(notifications, 'addNotification')
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ToggleCartIcon productId="test-product" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /в корзину/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'Товар добавлен в корзину',
|
||||
actionLabel: 'Перейти в корзину',
|
||||
actionPath: '/cart',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call addNotification on remove', async () => {
|
||||
vi.mocked(api.fetchMyCart).mockResolvedValueOnce({
|
||||
items: [{ id: 'cart-1', qty: 1, product: { id: 'test-product' } as never }],
|
||||
})
|
||||
const spy = vi.spyOn(notifications, 'addNotification')
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ToggleCartIcon productId="test-product" />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /убрать из корзины/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /убрать из корзины/i }))
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderChat } from './ui/OrderChat'
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { fetchAdminAvatar } from '@/entities/user/api/user-api'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
|
||||
type Message = {
|
||||
id: string
|
||||
authorType: 'user' | 'admin'
|
||||
text: string
|
||||
attachmentUrl?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
messages: Message[]
|
||||
isPending: boolean
|
||||
onSend: (text: string) => void
|
||||
}
|
||||
|
||||
export function OrderChat({ messages, isPending, onSend }: Props) {
|
||||
const [text, setText] = useState('')
|
||||
const canSend = text.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
const currentUser = useUnit($user)
|
||||
|
||||
const adminAvatarQuery = useQuery({
|
||||
queryKey: ['admin', 'avatar'],
|
||||
queryFn: fetchAdminAvatar,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const handleSend = () => {
|
||||
if (!canSend || isPending) return
|
||||
onSend(text.trim())
|
||||
setText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Чат по заказу
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
{messages.map((m) => {
|
||||
const isAdminMsg = m.authorType === 'admin'
|
||||
const adminAv = adminAvatarQuery.data
|
||||
const avatarNode = isAdminMsg ? (
|
||||
<UserAvatar userId="admin" avatarUrl={adminAv?.avatar} avatarStyle={adminAv?.avatarStyle} size={24} />
|
||||
) : currentUser ? (
|
||||
<UserAvatar
|
||||
userId={currentUser.id}
|
||||
avatarUrl={currentUser.avatar}
|
||||
avatarStyle={currentUser.avatarStyle}
|
||||
size={24}
|
||||
/>
|
||||
) : null
|
||||
return (
|
||||
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{isAdminMsg ? 'Админ' : 'Вы'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} imageAlt="Чек или вложение" />
|
||||
</ChatMessageBubble>
|
||||
)
|
||||
})}
|
||||
{messages.length === 0 && <Typography color="text.secondary">Пока сообщений нет.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={text} onChange={setText} placeholder="Сообщение" />
|
||||
</Box>
|
||||
<Button variant="contained" onClick={handleSend} disabled={isPending || !canSend} sx={{ minWidth: 160 }}>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { DeliveryFeeAdjustmentForm } from './ui/DeliveryFeeAdjustmentForm'
|
||||
export { OrderDetailContent } from './ui/OrderDetailContent'
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { patchAdminOrderDeliveryFee } from '@/entities/order/api/admin-order-api'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
|
||||
export function DeliveryFeeAdjustmentForm({
|
||||
orderId,
|
||||
deliveryFeeCents,
|
||||
}: {
|
||||
orderId: string
|
||||
deliveryFeeCents: number
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
|
||||
const feeMut = useMutation({
|
||||
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Доставка, ₽"
|
||||
type="number"
|
||||
value={rub}
|
||||
onChange={(e) => setRub(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
feeMut.isPending ||
|
||||
!rub.trim() ||
|
||||
!Number.isFinite(Number.parseFloat(rub)) ||
|
||||
Number.parseFloat(rub) < 0 ||
|
||||
!Number.isInteger(Number.parseFloat(rub))
|
||||
}
|
||||
onClick={() => feeMut.mutate()}
|
||||
>
|
||||
Утвердить доставку и открыть оплату
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Link from '@mui/material/Link'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useUnit } from 'effector-react'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
|
||||
|
||||
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
|
||||
const qc = useQueryClient()
|
||||
const [msg, setMsg] = useState('')
|
||||
|
||||
const statusMut = useMutation({
|
||||
mutationFn: (next: string) => setAdminOrderStatus(orderId, next),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
const msgMut = useMutation({
|
||||
mutationFn: () => postAdminOrderMessage(orderId, msg.trim()),
|
||||
onSuccess: async () => {
|
||||
setMsg('')
|
||||
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
|
||||
},
|
||||
})
|
||||
|
||||
const deliverySnapshot = useMemo(
|
||||
() => (detail.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const nextStatuses = useMemo(
|
||||
() => getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery'),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
const currentUser = useUnit($user)
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {ORDER_STATUS_MAP[detail.status] ?? detail.status} ·{' '}
|
||||
{formatPriceRub(detail.totalCents)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{detail.deliveryType === 'delivery' && (
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
Адрес и получатель (на момент заказа)
|
||||
</Typography>
|
||||
{deliverySnapshot ? (
|
||||
<Stack spacing={0.75}>
|
||||
{deliverySnapshot.label?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Метка: {deliverySnapshot.label}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Получатель:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientName ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Телефон:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientPhone ?? '—'}
|
||||
</Typography>
|
||||
{deliverySnapshot.comment?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Комментарий к адресу: {deliverySnapshot.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Данные адреса в заказе отсутствуют или не распознаны.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
Товары в заказе
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{detail.items.map((item) => (
|
||||
<Stack
|
||||
key={item.id}
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{ alignItems: 'center', py: 0.5, px: 1, borderRadius: 1, bgcolor: 'action.hover' }}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Link component={RouterLink} to={`/products/${item.productId}`} underline="hover" color="primary">
|
||||
{item.titleSnapshot}
|
||||
</Link>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{item.qty} × {formatPriceRub(item.priceCentsSnapshot)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(item.priceCentsSnapshot * item.qty)}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<Alert severity="info">
|
||||
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
Быстрый переход статуса
|
||||
</Typography>
|
||||
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
|
||||
{nextStatuses.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Статус финальный, смена недоступна
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||||
{nextStatuses.map((nextStatus) => {
|
||||
const isCancelled = nextStatus === 'CANCELLED'
|
||||
return (
|
||||
<Button
|
||||
key={nextStatus}
|
||||
variant={isCancelled ? 'outlined' : 'contained'}
|
||||
color={isCancelled ? 'error' : 'primary'}
|
||||
disabled={statusMut.isPending}
|
||||
onClick={() => statusMut.mutate(nextStatus)}
|
||||
>
|
||||
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Сообщения
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||||
{detail.messages.map((m) => {
|
||||
const isAdminMsg = m.authorType === 'admin'
|
||||
const avatarNode = isAdminMsg ? (
|
||||
currentUser && (
|
||||
<UserAvatar
|
||||
userId={currentUser.id}
|
||||
avatarUrl={currentUser.avatar}
|
||||
avatarStyle={currentUser.avatarStyle}
|
||||
size={24}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UserAvatar
|
||||
userId={detail.user.id}
|
||||
avatarUrl={detail.user.avatar}
|
||||
avatarStyle={detail.user.avatarStyle}
|
||||
size={24}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||||
</ChatMessageBubble>
|
||||
)
|
||||
})}
|
||||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||||
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => msgMut.mutate()}
|
||||
disabled={msgMut.isPending || !canSendMessage}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
Отправить
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||
import { OrderDetailContent } from '../OrderDetailContent'
|
||||
|
||||
vi.mock('@/entities/order/api/admin-order-api', () => ({
|
||||
setAdminOrderStatus: vi.fn(),
|
||||
postAdminOrderMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('effector-react', () => ({
|
||||
useUnit: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/RichTextMessageEditor.lazy', () => ({
|
||||
RichTextMessageEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (next: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
}) => <textarea aria-label="Ответ админа" value={value} onChange={(e) => onChange(e.target.value)} />,
|
||||
}))
|
||||
|
||||
vi.mock('@/shared/ui/ChatMessageBubble', () => ({
|
||||
ChatMessageBubble: ({ authorType, children }: { authorType: 'admin' | 'user'; children: ReactNode }) => (
|
||||
<div data-testid={`chat-message-${authorType}`}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
|
||||
|
||||
function createDetail(overrides?: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
|
||||
return {
|
||||
id: 'order-12345678',
|
||||
status: 'PENDING_PAYMENT',
|
||||
deliveryType: 'delivery',
|
||||
deliveryCarrier: null,
|
||||
paymentMethod: 'online',
|
||||
itemsSubtotalCents: 3000,
|
||||
deliveryFeeCents: 300,
|
||||
deliveryFeeLocked: true,
|
||||
totalCents: 3300,
|
||||
currency: 'RUB',
|
||||
addressSnapshotJson: null,
|
||||
comment: null,
|
||||
createdAt: '2026-05-28T10:00:00.000Z',
|
||||
updatedAt: '2026-05-28T10:00:00.000Z',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'buyer@example.com',
|
||||
displayName: 'Покупатель',
|
||||
avatar: null,
|
||||
avatarStyle: null,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
productId: 'product-1',
|
||||
qty: 1,
|
||||
titleSnapshot: 'Тестовый товар',
|
||||
priceCentsSnapshot: 3000,
|
||||
},
|
||||
],
|
||||
messages: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function renderComponent(detail: AdminOrderDetailResponse['item'], orderId = 'order-12345678') {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OrderDetailContent detail={detail} orderId={orderId} />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('OrderDetailContent quick status transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setAdminOrderStatusMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('рендерит кнопки доступных переходов статуса', async () => {
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||
|
||||
expect(screen.getByText('Быстрый переход статуса')).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeInTheDocument()
|
||||
const cancelledButton = screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })
|
||||
expect(cancelledButton).toBeInTheDocument()
|
||||
expect(cancelledButton).toHaveClass('MuiButton-outlined')
|
||||
expect(cancelledButton).toHaveClass('MuiButton-colorError')
|
||||
})
|
||||
|
||||
it('по клику вызывает setAdminOrderStatus(orderId, статус)', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }), 'order-click-test')
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
|
||||
|
||||
expect(setAdminOrderStatusMock).toHaveBeenCalledWith('order-click-test', 'PAID')
|
||||
})
|
||||
|
||||
it('в pending состоянии дизейблит кнопки перехода', async () => {
|
||||
const user = userEvent.setup()
|
||||
const deferred = createDeferred<void>()
|
||||
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise)
|
||||
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).toBeDisabled()
|
||||
})
|
||||
|
||||
deferred.resolve(undefined)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('для финального статуса показывает сообщение без кнопок перехода', () => {
|
||||
renderComponent(createDetail({ status: 'CANCELLED' }))
|
||||
|
||||
expect(screen.getByText('Статус финальный, смена недоступна')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('показывает ошибку мутации и после завершения запроса снова даёт кликнуть', async () => {
|
||||
const user = userEvent.setup()
|
||||
const deferred = createDeferred<void>()
|
||||
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise).mockResolvedValueOnce(undefined)
|
||||
|
||||
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||
|
||||
const paidButton = await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })
|
||||
await user.click(paidButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paidButton).toBeDisabled()
|
||||
})
|
||||
|
||||
deferred.reject(new Error('request failed'))
|
||||
|
||||
const errorAlert = await screen.findByText('Не удалось сменить статус')
|
||||
expect(errorAlert).toBeInTheDocument()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(paidButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
await user.click(paidButton)
|
||||
expect(setAdminOrderStatusMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('передает фактический authorType в пузырь сообщения', () => {
|
||||
renderComponent(
|
||||
createDetail({
|
||||
messages: [
|
||||
{
|
||||
id: 'message-admin',
|
||||
authorType: 'admin',
|
||||
text: 'Ответ администратора',
|
||||
attachmentUrl: null,
|
||||
createdAt: '2026-05-28T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'message-user',
|
||||
authorType: 'user',
|
||||
text: 'Сообщение покупателя',
|
||||
attachmentUrl: null,
|
||||
createdAt: '2026-05-28T10:01:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('chat-message-admin')).toHaveTextContent('Админ (вы)')
|
||||
expect(screen.getByTestId('chat-message-user')).toHaveTextContent('Пользователь')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export { OrderPaymentSection } from './ui/OrderPaymentSection'
|
||||