initial: client

This commit is contained in:
Shop Deploy
2026-06-11 13:48:08 +05:00
commit a36f96c290
271 changed files with 28009 additions and 0 deletions
+12
View File
@@ -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
+24
View File
@@ -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?
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.vite
coverage
*.min.*
package-lock.json
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 120,
"trailingComma": "all",
"endOfLine": "lf",
"jsxSingleQuote": false,
"arrowParens": "always"
}
+73
View File
@@ -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...
},
},
])
```
+204
View File
@@ -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',
},
},
)
+30
View File
@@ -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>
Generated Executable
+10680
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -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"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 774 B

+1
View File
@@ -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

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+24
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+106
View File
@@ -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/
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://любимыйкреатив.рф/sitemap.xml
+28
View File
@@ -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>
+22
View File
@@ -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>
)
}
+202
View File
@@ -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}
/>
</>
)
})
+144
View File
@@ -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>
)
}
+36
View File
@@ -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' })
})
})
+361
View File
@@ -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>
)
}
+83
View File
@@ -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
}
+159
View File
@@ -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()
})
})
+113
View File
@@ -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>
}
+135
View File
@@ -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>
)
}
+45
View File
@@ -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;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -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

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+21
View File
@@ -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}`)
}
+3
View File
@@ -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'
+14
View File
@@ -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),
})
}
+7
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
+10
View File
@@ -0,0 +1,10 @@
export type CatalogSliderSlide = {
id: string
url: string
caption: string
textColor?: string
}
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
galleryImageId: string
}
+52
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
export type { GalleryImageItem } from './model/types'
export { GalleryGrid } from './ui/GalleryGrid'
+7
View File
@@ -0,0 +1,7 @@
export type GalleryImageItem = {
id: string
url: string
isResized: boolean
createdAt: string
inUse?: boolean
}
+100
View File
@@ -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>
)
}
+54
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
export {
fetchUserNotificationSettings,
updateUserNotificationSettings,
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from './api/notifications-api'
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
+96
View File
@@ -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 })
}
+103
View File
@@ -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
}
+9
View File
@@ -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'
+108
View File
@@ -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
}
+42
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
export type { PublicProductsResponse } from './api/product-api'
+33
View File
@@ -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
}
+264
View File
@@ -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
})
+32
View File
@@ -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 })
}
+78
View File
@@ -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
}
+12
View File
@@ -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')
}
+49
View File
@@ -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
}
+24
View File
@@ -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
}
+45
View File
@@ -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}`)
}
+10
View File
@@ -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'
+23
View File
@@ -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
}
+2
View File
@@ -0,0 +1,2 @@
export { AddressFormDialog } from './ui/AddressFormDialog'
export type { AddressFormValues } from './ui/AddressFormDialog'
+127
View File
@@ -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 : []
}
+1
View File
@@ -0,0 +1 @@
export { AddressMapPicker } from './ui/AddressMapPicker'
+3
View File
@@ -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>
)
}
+177
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthCodeForm } from './ui/AuthCodeForm'
+67
View File
@@ -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')
})
})
})
+115
View File
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export { AuthForgotForm } from './ui/AuthForgotForm'
+145
View File
@@ -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')
})
})
+1
View File
@@ -0,0 +1 @@
export { OAuthButtons } from './ui/OAuthButtons'
+24
View File
@@ -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)
}
+28
View File
@@ -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>
)
}
+1
View File
@@ -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')
})
})
})
+234
View File
@@ -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>
)
}
+1
View File
@@ -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',
})
})
})
})
+1
View File
@@ -0,0 +1 @@
export { CartBadge } from './ui/CartBadge'
+31
View File
@@ -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>
)
}
+1
View File
@@ -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()
})
})
})
+1
View File
@@ -0,0 +1 @@
export { OrderChat } from './ui/OrderChat'
+87
View File
@@ -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>
)
}
+2
View File
@@ -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>
)
}
+245
View File
@@ -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('Пользователь')
})
})
+1
View File
@@ -0,0 +1 @@
export { OrderPaymentSection } from './ui/OrderPaymentSection'

Some files were not shown because too many files have changed in this diff Show More