Compare commits

..

15 Commits

Author SHA1 Message Date
@kirill.komarov 6508091193 Merge branch 'main' of http://192.168.1.110:3000/admin/shop-server
Deploy / deploy (push) Successful in 1s
2026-06-11 17:17:58 +05:00
@kirill.komarov dfa79d0825 test commit 2026-06-11 17:17:53 +05:00
admin b0de47ab94 fix: direct deploy
Deploy / deploy (push) Successful in 1m32s
2026-06-11 12:17:32 +00:00
@kirill.komarov bd63b028f5 test commit
Deploy / deploy (push) Failing after 3s
2026-06-11 17:15:46 +05:00
admin edce8878a1 test: host mode
Deploy / deploy (push) Failing after 3s
2026-06-11 11:48:07 +00:00
mpak a5a1685a67 chore: bump sharp 0.32.6 → 0.35.0
Deploy / deploy (push) Failing after 4s
2026-06-11 16:44:47 +05:00
admin f764e633c5 test: trigger actions
Deploy / deploy (push) Successful in 0s
2026-06-11 09:56:38 +00:00
admin 1e4ae975ba docs: update README
Deploy / deploy (push) Failing after 0s
2026-06-11 09:55:51 +00:00
admin 9841c20701 cleanup test
Deploy / deploy (push) Failing after 0s
2026-06-11 09:55:51 +00:00
admin 922339f27a test: trigger action
Deploy / deploy (push) Failing after 0s
2026-06-11 09:44:17 +00:00
admin b974589693 fix: use webhook instead of SSH
Deploy / deploy (push) Failing after 0s
2026-06-11 09:42:56 +00:00
admin d5393d5db0 chore: add deploy workflow
Deploy / deploy (push) Failing after 0s
2026-06-11 09:41:31 +00:00
admin e6f325a382 test deploy 2026-06-11 09:17:18 +00:00
mpak 7f929c9618 fix: update deploy docs — no VPS/Netbird/deploy-auto.sh 2026-06-11 14:04:17 +05:00
admin 65da047e7c initial: server + shared 2026-06-11 13:41:38 +05:00
496 changed files with 1154 additions and 91722 deletions
-30
View File
@@ -1,30 +0,0 @@
---
description: Основной промт/правила для проекта craftshop (client+server, FSD, ESLint/Prettier)
alwaysApply: true
---
# Craftshop: постоянный промт для агента
## Контекст и цель
- Проект: магазин изделий ручной работы (витрина + админка для загрузки/редактирования данных).
- ОС: Windows. Отвечать пользователю **по-русски**.
## Стек и структура
- **Frontend**: Vite + React + TypeScript, axios, @tanstack/react-query, MUI.
- **Архитектура фронта**: **FSD** (`app/pages/widgets/features/entities/shared`), alias `@` → `client/src`.
- **Backend**: Node.js + Fastify + Prisma + SQLite.
- Данные управляются через фронтенд‑админку; доступ к админ‑API проверяется серверным `verifyAdmin` (JWT пользователя + совпадение `request.user.email` с `ADMIN_EMAIL`).
## Правила работы с кодом
- Всегда придерживаться **FSD границ**: нижние слои не импортируют верхние.
- Для запросов: использовать `apiClient` (axios) и **React Query** (queryKey стабильные, invalidate после мутаций).
- UI: использовать компоненты **MUI**, без “самописного” дизайна там, где есть готовые компоненты.
- Не добавлять зависимости без необходимости; если добавляешь — ставь последние стабильные версии и обновляй README при изменении запуска/скриптов.
## Качество и запуск
- После изменений на фронте: `client` → `npm run lint` и при необходимости `npm run lint:fix`, затем `npm run format:check`.
- Форматирование: Prettier конфиги лежат в `client/.prettierrc.json`, `.prettierignore`, `.editorconfig`.
## Бэкенд соглашения
- Не ломать публичные роуты `/api/categories`, `/api/products`.
- Админ‑роуты должны возвращать понятные ошибки (400/401/404/409) и валидировать входные данные.
@@ -1,12 +0,0 @@
---
description: Актуальные требования к Vite proxy для локальной разработки
globs: client/vite.config.ts
alwaysApply: false
---
# Frontend Dev Server Proxy
- В `client/vite.config.ts` должны проксироваться и API, и загрузки файлов.
- Обязательные прокси:
- `'/api' -> 'http://127.0.0.1:3333'`
- `'/uploads' -> 'http://127.0.0.1:3333'`
-13
View File
@@ -1,13 +0,0 @@
---
description: Правила использования RichTextMessageContent (TipTap) на фронтенде
globs: client/src/**/*.tsx
alwaysApply: false
---
# Frontend Rich Text (TipTap)
- Для отображения rich text использовать общий компонент `shared/ui/RichTextMessageContent`.
- Не дублировать стили ProseMirror локально на страницах и в виджетах без необходимости.
- Для контекста отзывов передавать `tone="review"`.
- Для переписок по заказам передавать `tone="chat"`.
- `tone="default"` использовать только в нейтральных/общих сценариях.
+10
View File
@@ -0,0 +1,10 @@
name: Deploy
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
run: bash /opt/deploy-shop.sh server main
+1 -9
View File
@@ -5,13 +5,5 @@ dist
scripts/deploy.env
server/prisma/dev.db
server/prisma/dev.db-journal
.deployed-commit
# Image resize cache
uploads/.cache/
# Server uploads directory (images)
server/uploads/
# Plans and design docs
.agents
uploads/.cache/
@@ -1,95 +0,0 @@
# Spec: Image Processing Refactor
## Context
Current image handling uses on-demand resize via `/uploads-resized/` route. Admin uploads save originals as-is (jpg/png/webp), and resize happens on first request. User uploads (reviews, 2MB limit) also use on-demand resize.
## Goals
1. **User images (reviews, ≤2MB):** Improve size error messages to be user-friendly
2. **Admin images (products, ≤20MB):** Eager processing at upload time
- Generate all resize widths (320, 640, 1024, 1600) in AVIF + WebP
- Convert original to WebP (delete source file)
- Full-screen viewer shows original in WebP (no width limit)
- Thumbnails use resized versions from cache
## Architecture
### Server Changes
#### 1. `server/src/lib/upload-images.js`
- Add `eager` parameter to `persistMultipartImages`
- When `eager: true`, after saving each file:
1. Call `generateAllSizes(uuid, subdir, fullPath)` — generates all sizes from original
2. Call `convertOriginalToWebp(uuid, subdir)` — converts original to WebP, deletes source
3. Update URL to use `.webp` extension (replace original extension)
#### 2. `server/src/lib/image-resize.js`
- Add `generateAllSizes(uuid, subdir, originalPath)`:
- For each width in [320, 640, 1024, 1600]:
- Generate AVIF and WebP in `.cache/<subdir>/`
- Uses original file path (before conversion to WebP)
- Add `convertOriginalToWebp(uuid, subdir)`:
- Find original file (jpg/png)
- Convert to WebP (quality 80) at same location with `.webp` extension
- Delete original jpg/png file
- Return new `.webp` path
#### 3. `server/src/routes/api/admin-products.js`
- Pass `eager: true` to `persistMultipartImages`
#### 4. `server/src/routes/api/public-reviews.js`
- Improve error message for file too large (413)
### Client Changes
#### 1. `client/src/entities/product/api/product-api.ts`
- Add pre-upload size check for review images
- Clear error message: "Файл «<name>» слишком большой (максимум 2 МБ)"
#### 2. `client/src/shared/ui/OptimizedImage.tsx`
- Update `buildSrcSet` to use cached AVIF/WebP directly
- Full-screen viewer: use original `.webp` URL (no `?w=`)
- Remove fallback to original format for upload URLs
#### 3. `client/src/features/product-review/ui/ReviewDialog.tsx`
- Show user-friendly error message for oversized files
## Data Flow
### Admin Upload (Eager)
1. Client sends FormData to `POST /api/admin/uploads`
2. Server saves original (e.g., `uuid.jpg`)
3. Server generates all sizes in `.cache/` from original
4. Server converts original to WebP (`uuid.webp`), deletes `uuid.jpg`
5. Returns URLs with `.webp` extension (e.g., `/uploads/<uuid>.webp`)
6. Client displays using OptimizedImage with srcset from cache
### User Upload (Reviews)
1. Client validates file size ≤2MB before upload
2. Server validates and saves original
3. On-demand resize still works (existing flow)
4. Clear error messages at both client and server
## Error Handling
### User Upload Size Error
- **Client:** Pre-upload check with message "Файл «<name>» слишком большой (максимум 2 МБ)"
- **Server:** 413 with "Файл слишком большой (максимум 2 МБ)"
### Admin Upload Processing Error
- If sharp fails: return 500 with "Ошибка обработки изображения"
- If file not found after save: return 500 with "Внутренняя ошибка сервера"
## Testing
### Server Tests
- Test `generateAllSizes` creates all width+format combinations
- Test `convertOriginalToWebp` converts and deletes original
- Test `persistMultipartImages` with `eager: true`
- Test error messages for oversized files
### Client Tests
- Test pre-upload size validation for reviews
- Test OptimizedImage srcset generation for WebP originals
- Test error message display in ReviewDialog
@@ -1,543 +0,0 @@
# Image Processing Refactor Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor image processing to use eager generation for admin product images and improve error messages for user uploads.
**Architecture:** Add eager processing functions to `image-resize.js`, integrate into `upload-images.js` via `eager` flag, update client-side validation and error handling.
**Tech Stack:** Node.js, Fastify, sharp, React, TypeScript, MUI
---
### Task 1: Add eager processing functions to image-resize.js
**Files:**
- Modify: `server/src/lib/image-resize.js`
- Test: `server/src/lib/__tests__/image-resize.test.js`
- [ ] **Step 1: Write failing tests for new functions**
Add to `server/src/lib/__tests__/image-resize.test.js`:
```javascript
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager')
const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache')
describe('eager image processing', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('generateAllSizes creates all width+format combinations', async () => {
// Create a test PNG image using sharp
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png')
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(testImagePath)
await generateAllSizes('test-uuid', '', testImagePath)
// Check all cache files exist
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
expect(exists).toBe(true)
}
}
})
it('convertOriginalToWebp converts and deletes original', async () => {
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png')
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
.png()
.toFile(testImagePath)
const result = await convertOriginalToWebp('test-uuid2', '')
expect(result).toBe('/uploads/test-uuid2.webp')
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
expect(pngExists).toBe(false)
const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp')
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
expect(webpExists).toBe(true)
})
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd server && npm test -- --run image-resize.test.js`
Expected: FAIL — `generateAllSizes` and `convertOriginalToWebp` are not defined
- [ ] **Step 3: Implement generateAllSizes and convertOriginalToWebp**
Add to `server/src/lib/image-resize.js` before the final `export` line:
```javascript
/**
* Generate all resize widths in AVIF + WebP for eager processing.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @param {string} originalPath - Full path to the original file
*/
export async function generateAllSizes(uuid, subdir, originalPath) {
const cacheSubdir = subdir ? subdir : ''
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
await fs.promises.mkdir(cacheDir, { recursive: true })
const sharp = (await import('sharp')).default
for (const width of VALID_WIDTHS) {
for (const format of SUPPORTED_FORMATS) {
const cacheFileName = `${uuid}_w${width}.${format}`
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true })
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
}
}
}
/**
* Convert original file to WebP and delete the source file.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @returns {string} New URL path like `/uploads/<uuid>.webp`
*/
export async function convertOriginalToWebp(uuid, subdir) {
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
// Find original file
const originalPath = await findOriginalFile(uuid, subdir)
if (!originalPath) {
throw new Error(`Original file not found for UUID: ${uuid}`)
}
const originalExt = path.extname(originalPath).toLowerCase()
const webpPath = path.join(targetDir, `${uuid}.webp`)
// Convert to WebP
const sharp = (await import('sharp')).default
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
// Delete original if it's not already WebP
if (originalExt !== '.webp') {
await fs.promises.unlink(originalPath)
}
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd server && npm test -- --run image-resize.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js
git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)"
```
---
### Task 2: Integrate eager processing into upload-images.js
**Files:**
- Modify: `server/src/lib/upload-images.js`
- Test: `server/src/lib/__tests__/upload-images.test.js` (create if not exists)
- [ ] **Step 1: Write failing test for eager mode**
Create `server/src/lib/__tests__/upload-images.test.js`:
```javascript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { persistMultipartImages, uploadError } from '../upload-images.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist')
describe('persistMultipartImages with eager mode', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('returns WebP URLs when eager=true', async () => {
// This test verifies the function signature accepts eager parameter
// Full integration test requires mocking multipart request
// For now, test that the function doesn't throw with eager option
const mockRequest = {
isMultipart: () => true,
parts: async function* () {
// Mock part with a small PNG buffer
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
...new Array(100).fill(0), // dummy data
])
yield {
file: true,
filename: 'test.png',
toBuffer: async () => pngHeader,
}
},
}
// Should not throw with eager option
try {
await persistMultipartImages(mockRequest, {
maxFiles: 1,
maxFileBytes: 20 * 1024 * 1024,
subdir: '',
eager: true,
})
} catch (err) {
// If sharp is not available or PNG is invalid, that's expected in unit test
// The key is that the function accepts the eager parameter
expect(err.message).not.toContain('eager')
}
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd server && npm test -- --run upload-images.test.js`
Expected: FAIL — `eager` parameter is not handled
- [ ] **Step 3: Modify persistMultipartImages to support eager mode**
Replace the `persistMultipartImages` function in `server/src/lib/upload-images.js`:
```javascript
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
await fs.promises.mkdir(targetDir, { recursive: true })
const urls = []
const parts = request.parts({
limits: {
fileSize: maxFileBytes,
files: maxFiles,
},
})
for await (const part of parts) {
if (!part.file) continue
if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
}
const ext = safeImageExt(part.filename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const uuid = crypto.randomUUID()
const fileName = `${uuid}${ext}`
const fullPath = path.join(targetDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
if (eager) {
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
await generateAllSizes(uuid, subdir, fullPath)
finalUrl = await convertOriginalToWebp(uuid, subdir)
}
urls.push(finalUrl)
}
if (urls.length === 0) {
throw uploadError(
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
)
}
return urls
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `cd server && npm test -- --run upload-images.test.js`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js
git commit -m "feat: add eager mode to persistMultipartImages"
```
---
### Task 3: Enable eager mode in admin upload route
**Files:**
- Modify: `server/src/routes/api/admin-products.js`
- [ ] **Step 1: Update admin upload route to use eager mode**
Modify the `POST /api/admin/uploads` route in `server/src/routes/api/admin-products.js`:
```javascript
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
eager: true,
})
await upsertGalleryImagesByUrls(urls)
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
```
- [ ] **Step 2: Commit**
```bash
git add server/src/routes/api/admin-products.js
git commit -m "feat: enable eager image processing for admin uploads"
```
---
### Task 4: Improve user upload error messages
**Files:**
- Modify: `client/src/entities/product/api/reviews-api.ts`
- Modify: `client/src/shared/constants/upload-limits.ts`
- Modify: `client/src/features/product-review/ui/ReviewDialog.tsx`
- [ ] **Step 1: Add client-side size validation for review images**
Add to `client/src/shared/constants/upload-limits.ts`:
```typescript
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB
export function formatOtherUploadMaxSizeHint(): string {
return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ`
}
```
- [ ] **Step 2: Add pre-upload size check in reviews-api.ts**
Modify `uploadReviewImage` in `client/src/entities/product/api/reviews-api.ts`:
```typescript
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
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
}
```
- [ ] **Step 3: Update ReviewDialog to show user-friendly error message**
Modify the uploadError display in `client/src/features/product-review/ui/ReviewDialog.tsx`:
Replace:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
) : null}
```
With:
```tsx
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
) : null}
```
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx
git commit -m "feat: improve error messages for user upload size validation"
```
---
### Task 5: Update OptimizedImage for WebP originals
**Files:**
- Modify: `client/src/shared/ui/OptimizedImage.tsx`
- Test: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
- [ ] **Step 1: Update parseUploadUrl to handle .webp originals**
Modify `parseUploadUrl` in `client/src/shared/ui/OptimizedImage.tsx`:
```typescript
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i)
if (!match) return null
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
}
```
- [ ] **Step 2: Update buildSrcSet to use cached AVIF/WebP directly**
Modify `buildSrcSet` and `buildFallbackSrc`:
```typescript
function buildSrcSet(src: string, widths: number[]): string | null {
const parsed = parseUploadUrl(src)
if (!parsed) return null
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ')
}
function buildFallbackSrc(src: string, width: number): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
}
```
- [ ] **Step 3: Add original WebP URL getter for full-screen mode**
Add to `client/src/shared/ui/OptimizedImage.tsx`:
```typescript
/** Get the original WebP URL for full-screen display (no resize) */
export function getOriginalWebpUrl(src: string): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads/${pathPrefix}${parsed.uuid}.webp`
}
```
- [ ] **Step 4: Commit**
```bash
git add client/src/shared/ui/OptimizedImage.tsx
git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl"
```
---
### Task 6: Update ProductPage full-screen viewer
**Files:**
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
- [ ] **Step 1: Find full-screen image viewer code**
Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used.
- [ ] **Step 2: Use getOriginalWebpUrl for full-screen display**
Import and use `getOriginalWebpUrl`:
```typescript
import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage'
```
Replace the full-screen `<img>` src with:
```typescript
getOriginalWebpUrl(imageUrl)
```
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/product/ui/ProductPage.tsx
git commit -m "feat: use WebP original for full-screen product image viewer"
```
---
### Task 7: Run full test suite and lint
- [ ] **Step 1: Run server tests**
```bash
cd server && npm test
```
- [ ] **Step 2: Run client lint and format check**
```bash
cd client && npm run lint && npm run format:check
```
- [ ] **Step 3: Run client tests**
```bash
cd client && npm test
```
- [ ] **Step 4: Run client build**
```bash
cd client && npm run build
```
- [ ] **Step 5: Commit any fixes**
```bash
git add .
git commit -m "fix: address lint and test issues"
```
@@ -1,174 +0,0 @@
# Design: Доработка товара — удаление «под заказ», обязательные quantity и категория
**Дата:** 2026-05-15
**Статус:** На согласовании
## Цель
Упростить модель товара: убрать концепцию «под заказ», сделать количество и категорию обязательными полями. Категория «Не указано» остаётся технической заглушкой для переноса товаров при удалении категории, но не видна в каталоге и не выбирается при редактировании.
## Архитектура изменений
### 1. База данных (Prisma)
**Миграция:**
- Перед удалением полей: все товары с `inStock = false` получают `quantity = 0`
- Удалить поля `inStock` и `leadTimeDays` из модели `Product`
- Статус наличия определяется исключительно по `quantity`:
- `quantity > 0` → «В наличии»
- `quantity = 0` → «Нет в наличии»
**`server/prisma/schema.prisma`:**
```prisma
model Product {
// ... остальные поля без изменений ...
quantity Int @default(0)
// УДАЛЕНО: inStock Boolean @default(true)
// УДАЛЕНО: leadTimeDays Int?
category Category @relation(fields: [categoryId], references: [id], onDelete: Restrict)
categoryId String
// ...
}
```
### 2. Сервер — валидация и CRUD
**`server/src/routes/api/admin-products.js`:**
**CREATE (POST):**
- `quantity` — required, `Int >= 0` (было nullable)
- `categoryId` — required (было: при пустом → авто-назначение «Не указано»)
- Удалить валидацию `leadTimeDays` при `!inStock`
- Удалить принудительную установку `quantity = 1` для «под заказ»
- Вернуть 400: `'Укажите категорию'` если `categoryId` отсутствует
**UPDATE (PATCH):**
- `quantity` — required, `Int >= 0` (было nullable)
- `categoryId` — required (было: при пустом → «Не указано»)
- Удалить логику очистки `leadTimeDays` при `inStock = true`
- Удалить принудительную установку `quantity = 1`
- Вернуть 400 при отсутствии `categoryId`
**JSON Schema:**
- `CREATE_PRODUCT_SCHEMA`: убрать `leadTimeDays`, сделать `quantity` required (убрать `nullable`)
- `PATCH_PRODUCT_SCHEMA`: убрать `leadTimeDays`, `quantity` — если передан, то `>= 0`
**`server/src/routes/api/public-catalog.js`:**
- Удалить ветку `availability === 'in_stock'` и `availability === 'made_to_order'`
- Фильтрация «в наличии» больше не нужна — все товары в каталоге
### 3. Клиент — админка (две страницы)
**`client/src/pages/admin/ui/AdminPage.tsx`** и **`client/src/pages/admin-products/ui/AdminProductsPage.tsx`:**
**FormState:**
- Удалить `inStock: boolean` и `leadTimeDays: string`
- `quantity: string` — без nullable-семантики
**UI:**
- Удалить Switch «В наличии / Под заказ»
- Удалить TextField «Срок исполнения, дней»
- TextField «Количество»:
- Без helper «Оставьте пустым...»
- Новый helper: «0 = нет в наличии»
- Валидация: не может быть пустым, `parseInt >= 0`
- Select «Категория»:
- Удалить `<MenuItem value="">` с «Не указано»
- Валидация: не даёт сохранить без выбранной категории
- Показать ошибку при попытке сохранить без категории
**Submit-валидация:**
- Удалить проверку `leadTimeDays` при `!inStock`
- Добавить проверку: `categoryId` не пустой → blocking error
- Добавить проверку: `quantity` не пустой → blocking error
### 4. Клиент — каталог
**`client/src/entities/product/ui/ProductCard.tsx`:**
- Удалить логику `'Под заказ · {leadTimeDays} дн.'`
- Новый статус:
- `quantity > 0` → «В наличии» (зелёный)
- `quantity === 0` → «Нет в наличии» (серый/red)
**`client/src/pages/product/ui/ProductPage.tsx`:**
- Удалить chip `'Под заказ · {leadTimeDays} дн.'`
- Удалить alert `'Этот товар изготавливается под заказ...'`
- Статус определяется по `quantity`
**`client/src/pages/checkout/ui/CheckoutPage.tsx`:**
- Удалить определение made-to-order товаров в корзине
- Удалить info alert о доставке после изготовления
### 5. Клиент — фильтры
**`client/src/pages/home/lib/use-product-filters.ts`:**
- Удалить `availability: 'all' | 'in_stock' | 'made_to_order'` из state
- Удалить `availability` из параметров `fetchPublicProducts()`
**`client/src/pages/home/ui/ProductFilters.tsx`:**
- Удалить `ToggleButtonGroup` с `'all'`, `'in_stock'`, `'made_to_order'`
- Удалить отображение категории «Не указано» из списка чипов (фильтр `cat.slug !== 'ne-ukazano'`)
### 6. Категория «Не указано» — что остаётся
| Где | Что происходит |
|---|---|
| `server/src/lib/default-category.js` | **Остаётся** — функция `getOrCreateUnspecifiedCategory()` |
| `server/src/index.js` | **Остаётся** — вызов при старте |
| `server/src/routes/api/admin-categories.js` | **Остаётся** — нельзя удалить/переименовать; при удалении категории товары переезжают в «Не указано» |
| Админка категорий | **Остаётся** — кнопка удаления заблокирована |
| Фильтры каталога | **Скрыта** — не показывается в чипах |
| Форма товара | **Скрыта** — не выбирается в Select |
## Статус товара — новая логика
```
quantity > 0 → «В наличии» (зелёный chip/badge)
quantity = 0 → «Нет в наличии» (серый chip/badge)
```
Никаких других статусов. Поле `inStock` больше не существует.
## Файлы для изменения
### Сервер
| Файл | Изменения |
|---|---|
| `server/prisma/schema.prisma` | Удалить `inStock`, `leadTimeDays` |
| `server/src/routes/api/admin-products.js` | Валидация, schema, убрать логику под заказ |
| `server/src/routes/api/public-catalog.js` | Убрать фильтр availability |
### Клиент
| Файл | Изменения |
|---|---|
| `client/src/pages/admin/ui/AdminPage.tsx` | FormState, UI, валидация |
| `client/src/pages/admin-products/ui/AdminProductsPage.tsx` | FormState, UI, валидация |
| `client/src/entities/product/ui/ProductCard.tsx` | Статус по quantity |
| `client/src/pages/product/ui/ProductPage.tsx` | Убрать под заказ UI |
| `client/src/pages/checkout/ui/CheckoutPage.tsx` | Убрать made-to-order detection |
| `client/src/pages/home/ui/ProductFilters.tsx` | Убрать availability toggle, скрыть «Не указано» |
| `client/src/pages/home/lib/use-product-filters.ts` | Убрать `availability` |
## Миграция данных
```javascript
// В Prisma migration:
// 1. UPDATE Product SET quantity = 0 WHERE inStock = false
// 2. ALTER TABLE Product DROP COLUMN inStock
// 3. ALTER TABLE Product DROP COLUMN leadTimeDays
```
## Тестирование
**Сервер:**
- CREATE без categoryId → 400
- CREATE без quantity → 400
- CREATE с quantity = 0 → OK
- PATCH без categoryId → 400
- PATCH с quantity = 0 → OK
**Клиент:**
- Форма не сохраняется без категории
- Форма не сохраняется без количества
- Фильтры не содержат «Под заказ» и «Не указано»
- Карточка товара показывает «Нет в наличии» при quantity = 0
File diff suppressed because it is too large Load Diff
@@ -1,439 +0,0 @@
# Приведение Политики конфиденциальности и Пользовательского соглашения в соответствие с проектом
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Убрать из юридических документов упоминания несуществующих функций (аналитика, рекламные рассылки, персонализация, Яндекс.Метрика) и исправить неточности (OAuth, cookie, IP-логирование, дублирование данных оператора).
**Architecture:** 4 задачи: унификация данных оператора в shared/config, правка Политики конфиденциальности, правка Пользовательского соглашения, финальная проверка.
**Tech Stack:** TypeScript, React, MUI — изменения только в статическом JSX-тексте и shared/config.
---
## Файловая структура изменений
| Файл | Что делаем |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `client/src/shared/config/index.ts` | Добавляем `STORE_OP_NAME`, `STORE_OP_INN`, `STORE_OP_OGRN`, `STORE_OP_ADDR` |
| `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` | Импорт из config, правка пунктов 2, 3, 5, 6, 7, добавляем дату |
| `client/src/pages/terms/ui/TermsPage.tsx` | Импорт из config, правка пунктов 1, 3, 7, 8, 9, убираем противоречие 6.1 vs 2.2 |
---
### Task 1: Вынести данные оператора в shared/config
**Files:**
- Modify: `client/src/shared/config/index.ts`
- [ ] **Step 1: Добавить константы оператора в shared/config**
Вставить после строки 17 (перед `export const VK_URL`):
```ts
export const STORE_OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
export const STORE_OP_INN = "402900832341";
export const STORE_OP_OGRN = "305402922700051";
export const STORE_OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
```
- [ ] **Step 2: Проверить синтаксис**
```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.
---
### Task 2: Исправить Политику конфиденциальности
**Files:**
- Modify: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
- [ ] **Step 1: Заменить импорт и локальные константы**
Заменить строки 4-10 (импорт STORE_EMAIL + локальные константы).
Было:
```ts
import { STORE_EMAIL } from "@/shared/config";
const OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
const OP_INN = "402900832341";
const OP_OGRN = "305402922700051";
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
const SITE_URL = window.location.origin;
```
Стало:
```ts
import {
STORE_EMAIL,
STORE_OP_NAME,
STORE_OP_INN,
STORE_OP_OGRN,
STORE_OP_ADDR,
STORE_PUBLIC_SITE_URL,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
```
И заменить `OP_NAME``STORE_OP_NAME`, `OP_INN``STORE_OP_INN`, `OP_OGRN``STORE_OP_OGRN`, `OP_ADDR``STORE_OP_ADDR` во всём файле (replaceAll).
- [ ] **Step 2: Исправить раздел 2 — актуальный список собираемых данных**
Заменить `items` в секции 2.
Было:
```ts
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— фамилия, имя, отчество;',
'— адрес электронной почты;',
'— номер телефона;',
'— данные файлов cookie;',
'— данные о действиях на сайте (аналитика);',
'— адрес доставки и геолокационные координаты.',
],
```
Стало:
```ts
items: [
'2.1. Оператор обрабатывает следующие персональные данные Пользователей:',
'— адрес электронной почты;',
'— имя (отображаемое имя, может быть указано Пользователем добровольно);',
'— номер телефона (указывается Пользователем добровольно при оформлении доставки);',
'— адрес доставки и геолокационные координаты (указываются Пользователем при оформлении заказа);',
'— аутентификационные данные (сессионные cookie для поддержания входа в Личный кабинет).',
],
```
- [ ] **Step 3: Исправить раздел 3 — убрать несуществующую персонализацию**
Было:
```ts
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация Пользователя;',
'— оказание услуг / продажа товаров;',
'— направление уведомлений и информационных сообщений;',
'— улучшение качества работы сайта;',
'— построение персонализированных предложений и рекомендаций.',
],
```
Стало:
```ts
items: [
'3.1. Оператор обрабатывает персональные данные в следующих целях:',
'— идентификация и аутентификация Пользователя;',
'— оказание услуг / продажа товаров и оформление доставки;',
'— направление транзакционных уведомлений о статусе заказов и информационных сообщений;',
'— улучшение качества работы сайта.',
],
```
- [ ] **Step 4: Исправить раздел 5 — убрать неавтоматизированную обработку и нереалистичный срок**
Было:
```ts
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.',
'5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.',
],
```
Стало:
```ts
items: [
'5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.',
'5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.',
'5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.',
],
```
- [ ] **Step 5: Исправить раздел 6 — Яндекс.Метрика → ЮKassa**
Было:
```ts
items: [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).',
],
```
Стало:
```ts
items: [
'6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:',
'— с согласия субъекта;',
'— по требованию законодательства РФ;',
'— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).',
],
```
- [ ] **Step 6: Добавить дату обновления**
Заменить текст подзаголовка (строка 99):
```
Политика в отношении обработки персональных данных.
```
на:
```
Последнее обновление: 23 мая 2026 г.
```
- [ ] **Step 7: Проверить линтер**
```bash
cd client && npm run lint
```
Expected: 0 новых ошибок.
---
### Task 3: Исправить Пользовательское соглашение
**Files:**
- Modify: `client/src/pages/terms/ui/TermsPage.tsx`
- [ ] **Step 1: Заменить локальные константы на импорт из config**
Заменить строки 4-11.
Было:
```ts
import {
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
const OP_NAME =
"Индивидуальный предприниматель Новоселова Наталия Владимировна";
const OP_INN = "402900832341";
const OP_OGRN = "305402922700051";
const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А";
```
Стало:
```ts
import {
STORE_EMAIL,
STORE_PHONE,
STORE_PUBLIC_SITE_URL,
STORE_OP_NAME,
STORE_OP_INN,
STORE_OP_OGRN,
STORE_OP_ADDR,
} from "@/shared/config";
const SITE_URL =
STORE_PUBLIC_SITE_URL ||
(typeof window !== "undefined" ? window.location.origin : "");
```
Заменить `OP_NAME``STORE_OP_NAME`, `OP_INN``STORE_OP_INN`, `OP_OGRN``STORE_OP_OGRN`, `OP_ADDR``STORE_OP_ADDR` во всём файле (replaceAll).
- [ ] **Step 2: Дополнить раздел 1 — упомянуть OAuth и вход по коду**
В секции 1, в определении «Аутентификационные данные». Найти:
```
'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.',
```
Заменить на:
```
'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.',
```
- [ ] **Step 3: Убрать «рекламные» сообщения из п. 3.7**
Найти:
```
'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.',
```
Заменить на:
```
'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.',
```
- [ ] **Step 4: Исправить противоречие 6.1 vs 2.2 («гарантирует» vs «as is»)**
Найти в секции 6:
```
'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.',
```
Заменить на:
```
'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.',
```
- [ ] **Step 5: Исправить раздел 7 — указать реальных третьих лиц (ЮKassa, OSM вместо рекламы/аналитики)**
Заменить всю секцию 7.
Было:
```ts
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).',
'7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
```
Стало:
```ts
{
title: '7. Доступ к ресурсам третьих лиц',
items: [
'7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей).
'7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.',
'7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.',
],
},
```
- [ ] **Step 6: Исправить раздел 8 — Cookie только для сессии, не для аналитики**
Заменить всю секцию 8.
Было:
```ts
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.',
'8.2. Цели использования cookie:',
'— поддержка функциональности Сайта, требующей использования cookie;',
'— измерение аудитории Сайта;',
'— определение статистических предпочтений Пользователей;',
'— исследование корреляции статистических данных.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.',
],
},
```
Стало:
```ts
{
title: '8. Информация, хранящаяся на стороне браузера',
items: [
'8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.',
'8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.',
'8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.',
],
},
```
- [ ] **Step 7: Исправить раздел 9.3–9.4 — актуальный перечень данных и целей**
Заменить строки 9.3 и 9.4 в секции 9.
Было:
```ts
'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.',
```
Стало:
```ts
'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.',
'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.',
```
- [ ] **Step 8: Проверить линтер**
```bash
cd client && npm run lint
```
Expected: 0 новых ошибок.
---
### Task 4: Финальная проверка
**Files:** No modifications, verification only.
- [ ] **Step 1: TypeScript check**
```bash
cd client && npx tsc -b --noEmit
```
Expected: no errors.
- [ ] **Step 2: Lint**
```bash
cd client && npm run lint
```
Expected: 0 errors (warnings OK).
- [ ] **Step 3: Сборка**
```bash
cd client && npm run build
```
Expected: успешная сборка.
- [ ] **Step 4: Format check**
```bash
cd client && npm run format:check
```
Expected: все файлы отформатированы (или отформатировать через `npm run format`).
@@ -1,64 +0,0 @@
# Юридические документы: приведение к реальности + удаление аккаунта + cookie-баннер
## Данные оператора
- **Имя:** Комарова Лариса Николаевна (самозанятый)
- **ИНН:** 402900832341 (тестовый)
- **Адрес:** 34, ул. Мира, кв. 34, Лысьва, Пермский край, 618909
- **Сайт:** https://любимыйкреатив.рф
- **ОГРН:** отсутствует (самозанятый)
## Задачи
### 1. Shared config — обновить данные оператора
Файл: `client/src/shared/config/index.ts`
- `STORE_OP_NAME`, `STORE_OP_TYPE`, `STORE_OP_INN` (тестовый), `STORE_OP_ADDR`
- Убрать `STORE_OP_OGRN`
- `STORE_PUBLIC_SITE_URL`: https://любимыйкреатив.рф
### 2. Политика конфиденциальности
Файл: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx`
- Импорт из config, убрать локальные константы
- Оператор: самозанятый вместо ИП, без ОГРН
- Раздел 2: только email, имя, телефон (при доставке), адрес, сессионные cookie (без ФИО, аналитики)
- Раздел 3: без персонализации
- Раздел 5: только автообработка, срок до достижения целей
- Раздел 6: Яндекс.Метрика → ЮKassa
- Раздел 7: добавить право на самоудаление (п. 7.3)
- Добавить дату обновления
- Перенести cookie-раздел из Соглашения в Политику
### 3. Пользовательское соглашение
Файл: `client/src/pages/terms/ui/TermsPage.tsx`
- Импорт из config, убрать локальные константы
- Оператор: самозанятый, без ОГРН
- Раздел 1: упомянуть OAuth и вход по коду
- П. 3.7: «рекламные» → «транзакционные»
- П. 6.1: убрать противоречие с «as is»
- Раздел 7: реальные третьи лица (ЮKassa, OSM)
- Раздел 8: cookie только для сессии, не для аналитики
- П. 9.3-9.4: без IP/браузера/персонализации, только реальные данные
### 4. Cookie-баннер
Новый компонент: `client/src/shared/ui/CookieConsentBanner.tsx`
- Снизу, фиксированный, localStorage
- Текст о cookie и ссылка на Политику
- Кнопка «Понятно»
- Рендер в MainLayout перед футером
### 5. Текст согласия на формах входа/регистрации
- AuthPasswordForm, AuthCodeForm
- Под кнопкой отправки: «Нажимая «Продолжить», вы принимаете пользовательское соглашение и политику конфиденциальности»
- Ссылки на /terms и /privacy
### 6. Удаление аккаунта
**Сервер:** `DELETE /api/me` в `server/src/routes/auth.js`
- Проверка активных заказов (не DONE, не CANCELLED)
- Если нет активных — каскадное удаление
- Если есть — 400 с перечнем заказов
**Клиент:** секция в SettingsPage
- Кнопка «Удалить аккаунт» (outlined, error)
- Tooltip при активных заказах
- Диалог подтверждения
- После удаления — редирект на /
@@ -1 +0,0 @@
12063
@@ -1 +0,0 @@
12189
@@ -1 +0,0 @@
12688
@@ -1 +0,0 @@
12844
@@ -1 +0,0 @@
12996
@@ -1 +0,0 @@
13143
@@ -1 +0,0 @@
1476
@@ -1 +0,0 @@
1531
@@ -1 +0,0 @@
1616
@@ -1 +0,0 @@
1702
@@ -1 +0,0 @@
5700
@@ -1 +0,0 @@
{"reason":"idle timeout","timestamp":1779612416287}
@@ -1 +0,0 @@
7319
+21 -56
View File
@@ -1,83 +1,48 @@
# AGENTS.md — shop (craftshop monorepo)
# AGENTS.md — shop-server
## Project structure
- `client/` — frontend (React + Vite + TypeScript + MUI), **FSD architecture**: `app/pages/widgets/features/entities/shared`
- `server/` — backend (Fastify + Prisma + SQLite)
- `shared/constants/` — JS + `.d.ts` files shared between client and server (order statuses, delivery carriers, payment methods, upload limits)
- `server/` — Fastify + Prisma + SQLite backend
- `shared/constants/` — JS + .d.ts shared with client (order statuses, delivery carriers, payment methods, upload limits)
## Developer commands
### Client (`cd client`)
| Command | What it does |
|---|---|
| `npm run dev` | Vite dev server on `:5173`, proxies `/api` and `/uploads` to `http://127.0.0.1:3333` |
| `npm run build` | Runs `tsc -b` first, then `vite build` |
| `npm run dev` | node --env-file=.env --watch src/index.js (requires Node 20.6+) |
| `npm run dev:classic` | node --watch src/index.js (loads .env via dotenv) |
| `npm run lint` | ESLint (flat config) |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm run lint:fix` | ESLint with --fix |
| `npm run format` | Prettier write all |
| `npm run format:check` | Prettier check only |
| `npm test` | vitest run |
| `npm run test:watch` | vitest watch mode |
### Server (`cd server`)
| Command | What it does |
|---|---|
| `npm run dev` | `node --env-file=.env --watch src/index.js` (requires Node 20.6+) |
| `npm run dev:classic` | `node --watch src/index.js` (loads `.env` via dotenv) |
| `npm run lint` | ESLint (flat config) |
| `npm run lint:fix` | ESLint with `--fix` |
| `npm run format` | Prettier write all |
| `npm run format:check` | Prettier check only |
| `npm test` | vitest run |
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses `.env`) |
### Build order (when changing both packages)
```bash
cd server && npm run db:migrate # if schema changed
cd server && npm test # server tests first
cd client && npm run lint && npm run format:check && npm test # then client
cd client && npm run build # full typecheck + build
```
| `npm run db:reset:test` | Reset SQLite DB + re-run migrations + seed (uses .env) |
## Conventions
- **Language**: Отвечай пользователю **на русском**.
- **Language**: Отвечай пользователю на русском.
- **Single quotes**, no semicolons, trailing commas, 120 print width (Prettier + ESLint enforce).
- **FSD import boundaries** enforced by `eslint-plugin-boundaries`. Lower layers cannot import upper layers. If ESLint complains about an import, the architecture is wrong.
- **Aliases**: `@/``client/src/`, `@shared/``shared/` (configured in both vite.config.ts and tsconfig).
- **API requests**: Use `apiClient` (axios wrapper from `shared/api/`) with `@tanstack/react-query`. Invalidate queries after mutations.
- **UI**: Prefer MUI components over custom HTML/CSS.
- **`no-console`**: ESLint error; use `console.warn/error/info` only.
- **Admin access**: Only users with email matching `ADMIN_EMAIL` env var can access admin routes. Server auto-creates the admin user on startup.
- **Server helpers**: `slugify`, `parseMaterialsInput`, `mapProductForApi` are decorated on fastify instance, accessed via `request.server.*`.
- **Alias**: @shared → shared/ (configured in vitest.config.js for tests).
- **Admin access**: Only users with email matching ADMIN_EMAIL env var can access admin routes. Server auto-creates the admin user on startup.
- **Server helpers**: slugify, parseMaterialsInput, mapProductForApi are decorated on fastify instance, accessed via request.server.*.
## Testing
- **Client**: vitest + jsdom + @testing-library/react. Setup file: `client/src/testing/setup.ts`.
- **Server**: vitest with globals enabled.
- Test files live in `__tests__/` directories next to the code they test.
- Vitest with globals enabled.
- Test files live in __tests__/ directories next to the code they test.
## OAuth
- VK callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
- Yandex callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
- Required env vars: `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, `SERVER_PUBLIC_URL`, `CLIENT_PUBLIC_URL`
- VK callback: {SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback
- Yandex callback: {SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback
## Infrastructure (deployment)
## Deployment
- **VPS** runs Nginx Proxy Manager (NPM), connected via Netbird peer-to-peer VPN to the dev machine
- **Local dev machine** runs the project (server + client), also a Netbird peer
- **Traffic flow**: Browser → Domain (A record → VPS IP) → NPM → Netbird tunnel → Local dev machine (`server:3333`)
- NPM manages SSL, domains, and proxy hosts
- `trustProxy: true` on Fastify — `request.ip` works correctly through NPM/Netbird chain
- Gitea CI/CD deploys to the server machine on push to main
- Traffic flow: Browser → Domain → Nginx (server machine) → Fastify (3333)
- trustProxy: true on Fastify
## Notable quirks
- `.env` is gitignored. Copy `.env.example` to `.env` for local dev.
- Vite dev server (client) relies on backend running at `127.0.0.1:3333`. Start server first.
- Rich text rendering uses `shared/ui/RichTextMessageContent` (TipTap). Pass `tone="review"`, `tone="chat"`, or `tone="default"`.
- `db:reset:test` runs `prisma migrate reset --force`, which destroys all data.
- .env is gitignored. Copy .env.example to .env for local dev.
- db:reset:test runs prisma migrate reset --force, which destroys all data.
+1 -145
View File
@@ -1,145 +1 @@
# Магазин изделий ручной работы https://любимыйкреатив.рф
Цель проекта — витрина и админка для магазина изделий ручного труда (игрушки, сувениры и т.п.) с простой загрузкой/редактированием данных через фронтенд‑админку.
Проект сделан как **монорепозиторий**:
- `client/` — фронтенд (витрина + админка)
- `server/` — бэкенд API + БД
## Стек
### Фронтенд
- **React** + **Vite**
- **axios**
- **@tanstack/react-query**
- **MUI (@mui/material)** + emotion
- **React Router**
- **Архитектура**: **FSD (Feature-Sliced Design)** — слои `app/pages/widgets/features/entities/shared`
- **Качество**: ESLint (flat config) + Prettier, границы FSD (`eslint-plugin-boundaries`)
### Бэкенд
- **Node.js**
- **Fastify** (+ CORS)
- **Prisma** (миграции)
- **SQLite** (локальная БД; легко сменить на Postgres через `DATABASE_URL`)
## Основные подходы и договорённости
### FSD на фронте
- Импорты между слоями ограничены правилами `boundaries` (например `features` может импортировать `entities/shared`, но не наоборот).
- Alias `@` указывает на `client/src` (см. `client/vite.config.ts` и `client/tsconfig.app.json`).
### Данные и админка
- Данные загружаются/редактируются через **админку на фронте**.
- Админ‑роуты бэкенда доступны только авторизованному пользователю с email из `ADMIN_EMAIL` в `server/.env`.
### Форматирование и линтинг (client)
- Prettier конфиг: `client/.prettierrc.json`
- Ignore: `client/.prettierignore`
- EditorConfig: `client/.editorconfig`
- Команды:
- `npm run lint` / `npm run lint:fix`
- `npm run format` / `npm run format:check`
## Запуск
### Бэкенд
**Вариант A — типовой `.env`**
```bash
cd server
cp .env.example .env # укажите ADMIN_EMAIL
npm install
npx prisma migrate dev # если база ещё не создана
npx prisma db seed # опционально: тестовые категории и товары
npm run dev:classic # загрузка из `.env`
```
**Вариант B — `.env` файл** (нужен **Node.js 20.6+** из‑за `node --env-file`):
```bash
cd server
cp .env.example .env # укажите ADMIN_EMAIL и другие настройки
npm install
npm run dev # переменные из `.env`
```
Очистка БД до «чистого» тестового состояния (SQLite + миграции + seed): в `server/` выполните `npm run db:reset:test`.
Сервер: `http://127.0.0.1:3333`. Проверка: `GET /health`.
### Фронтенд
В другом терминале:
```bash
cd client
npm install
npm run dev
```
Откройте `http://localhost:5173`. Запросы к `/api` проксируются на бэкенд (см. `client/vite.config.ts`).
## Админка
Раздел админки доступен только по прямой ссылке `/admin` и только для пользователя с email из `ADMIN_EMAIL`. Если такого пользователя нет в БД, сервер создаёт его автоматически при старте.
Для боевого размещения фронта и API на разных доменах задайте `VITE_API_URL` (например `https://api.example.com/api`) и **CORS_ORIGIN** на сервере.
### OAuth VK и Яндекс
В `server/.env` задайте `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, а также **точные** публичные адреса:
- `SERVER_PUBLIC_URL` — базовый URL API (без завершающего `/`), например `https://api.example.com` или `http://127.0.0.1:3333`.
- `CLIENT_PUBLIC_URL` — базовый URL витрины, куда бэкенд редиректит после входа с JWT в query: `/auth/callback?token=...`, например `http://127.0.0.1:5173`.
**Redirect URI в кабинетах провайдеров** (должны совпадать с тем, что шлёт сервер при авторизации):
- VK: `{SERVER_PUBLIC_URL}/api/auth/oauth/vk/callback`
- Яндекс: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback`
Старт входа с витрины: кнопки на странице `/auth` ведут на `GET /api/auth/oauth/vk` и `GET /api/auth/oauth/yandex` (полный URL — тот же origin, что и API: при прокси Vite это `/api/...` относительно фронта; при отдельном домене API — из `VITE_API_URL`).
### Футер витрины (опционально)
В `client/.env` можно задать `VITE_STORE_EMAIL`, `VITE_STORE_PHONE`, `VITE_STORE_SOCIAL_NOTE` для блока контактов в подвале. Для страницы «Политика конфиденциальности» задайте **`VITE_PUBLIC_SITE_URL`** (например `https://example.com`, без завершающего `/`) — иначе в dev подставится текущий origin; на проде лучше указать явно перед `npm run build`.
## API (кратко)
Публичные:
- `GET /api/categories`
- `GET /api/products?categorySlug=...`
Админ:
- `GET /api/admin/products`
- `POST /api/admin/products`
- `PATCH /api/admin/products/:id`
- `DELETE /api/admin/products/:id`
- `POST /api/admin/categories`
## Деплой
```bash
# Заполнить scripts/deploy.env (DEPLOY_HOST, DEPLOY_PATH и т.д.)
# Первичная настройка LXC: см. scripts/SERVER_SETUP.md
# Деплой только изменившихся компонентов:
./scripts/deploy-auto.sh
# Полный деплой (игнорировать diff):
./scripts/deploy-auto.sh --force
# Только фронт или только бэкенд:
./scripts/deploy-auto.sh --frontend-only
./scripts/deploy-auto.sh --backend-only
```
# Shop Server\n\nCraftshop API server\n\n## Deploy\nAuto-deploy via Gitea Actions on push to main
-162
View File
@@ -1,162 +0,0 @@
# План рефакторинга shop
> Составлен на основе анализа кода и правил `.cursor/rules`.
> Дата: 2026-05-13
---
## Статус выполнения
- ✅ 1.1 Сервер: разбить `routes/auth.js` → 6 модулей
- ✅ 1.2 Клиент: разбить `AdminPage.tsx``AdminProductsPage` + `AdminCategoriesPage`
- ✅ 1.3 Клиент: разбить `OrderDetailPage.tsx` (чаты, оплата, отзывы → features)
- ✅ 2.2 FSD: роутинг из `App.tsx``app/routes/index.tsx`
- ✅ 3.1 Клиент: разбить `AppHeader.tsx` (UserMenu, CartBadge, NavigationDrawer)
- ✅ 4.1 Effector: рефакторинг `auth.ts` (persist, sample, createErrorStore)
- ✅ 2.1 Недостающие сегменты FSD (catalog-slider, gallery, info, address-map-picker)
- ✅ 2.3 Дублирование констант клиент/сервер
- ✅ 3.2 HomePage (вынесены фильтры в хук и компонент)
- ✅ 3.3 AdminOrdersPage, AdminUsersPage (shared AdminDialog + AdminTable)
- ✅ 5.1 fastify.decorate вместо параметров
- ✅ 5.2 Валидация через Fastify Schema
- ✅ 6.1 Error Boundary
- ✅ 6.2 Тесты
---
## 1. Критические точки (высокий приоритет) — ✅ Выполнено
### 1.1 Сервер: разбить `server/src/routes/auth.js` (892 → ~200 строк)
| Файл | Роуты |
|---|---|
| `routes/auth.js` | `/api/auth/request-code`, `/api/auth/verify-code`, `/api/me`, `/api/me/change-email/*`, `/api/me/profile` |
| `routes/user-addresses.js` | `/api/me/addresses` (6 роутов CRUD + default) |
| `routes/user-cart.js` | `/api/me/cart` (4 роута CRUD) |
| `routes/user-orders.js` | `/api/me/orders` (создание, список, деталь, подтверждение, review-eligibility) |
| `routes/user-payments.js` | `/api/me/orders/:id/pay` |
| `routes/user-messages.js` | `/api/me/orders/:id/messages`, unread-count, conversations, mark-read |
### 1.2 Клиент: разбить `AdminPage.tsx` (891 → 604 + 295 строк)
- `pages/admin-products/` + `pages/admin-categories/`
- `AdminLayoutPage` — новый нав-айтем «Категории», роут `/admin/categories`
### 1.3 Клиент: разбить `OrderDetailPage.tsx` (609 → 258 строк)
- `features/order-chat/` — чат по заказу
- `features/order-payment/` — секция оплаты + модалка (`OrderPaymentSection`, `PaymentDialog`)
- `features/product-review/` — секция отзывов + модалка (`ReviewSection`, `ReviewDialog`)
---
## 2. FSD-архитектура
### 2.1 Создать недостающие сегменты ✅
| Слайс | Что сделано |
|---|---|
| `entities/catalog-slider` | `model/types.ts`, `index.ts` (barrel), импорты обновлены |
| `entities/gallery` | `ui/GalleryGrid.tsx`, `index.ts`, импорты обновлены |
| `entities/info` | `model/types.ts`, `index.ts`, импорты обновлены |
| `features/address-map-picker` | `api/map-geocoding.ts`, `model/types.ts`, `index.ts`, импорты обновлены |
### 2.2 Вынести роутинг из `App.tsx` → `app/routes/` ✅
`AppRoutes` в `app/routes/index.tsx`. `App.tsx` — чистая точка входа.
### 2.3 Устранить дублирование констант клиент/сервер ✅
Создан `shared/constants/` с каноничными значениями (`order-status.js`, `delivery-carrier.js`, `upload-limits.js`, `payment-method.js`). Все клиентские и серверные константы импортируются оттуда. Vite + tsconfig настроены на `@shared/*` alias.
---
## 3. Клиентские компоненты
### 3.1 `AppHeader.tsx` (406 → 293 строк) ✅
- `UserMenu``features/user/user-menu/`
- `CartBadge``features/cart/cart-badge/`
- `NavigationDrawer``widgets/navigation-drawer/`
### 3.2 `HomePage.tsx` (414 → 157 строк) ✅
- `useProductFilters` хук в `pages/home/lib/`
- `ProductFilters` компонент в `pages/home/ui/`
- Фильтры, сортировка, масштаб карточек вынесены из страницы
### 3.3 `AdminOrdersPage.tsx`, `AdminUsersPage.tsx` ✅
- `shared/ui/AdminTable/` — компонент таблицы с loading/error/skeleton
- `shared/ui/AdminDialog/` — компонент диалога с loading/error/title/actions
- `AdminUsersPage`: таблица и диалог заменены на общие компоненты
- `AdminOrdersPage`: диалог заменён на `AdminDialog`
---
## 4. Effector + состояние — ✅ Выполнено
### 4.1 `shared/model/auth.ts` (96 → 83 строк)
- `.watch()``sample` + `persistTokenFx`
- Убран `tokenPersistInitialized` флаг
- `createErrorStore(effect)` — общий шаблон сторов ошибок
- `readStoredToken``shared/lib/persist-token.ts` (re-export из auth.ts)
- Создан `shared/lib/create-error-store.ts`
---
## 5. Сервер (низкий приоритет)
### 5.1 `fastify.decorate` вместо передачи зависимостей параметрами ✅
`slugify`, `parseMaterialsInput`, `mapProductForApi` декорированы на fastify в `api.js`. Роуты используют `request.server.*` вместо получения через параметры.
### 5.2 Валидация через Fastify Schema ✅
Добавлены JSON Schema для:
- `POST /api/admin/products` — body
- `PATCH /api/admin/products/:id` — body
- `GET /api/products` — querystring (фильтры, пагинация)
---
## 6. Инфраструктура (низкий приоритет)
### 6.1 Error Boundary ✅
Создан `shared/ui/ErrorBoundary/ErrorBoundary.tsx` — class-компонент с `getDerivedStateFromError` / `componentDidCatch`.
- Отображает MUI `Alert` с заголовком «Что-то пошло не так» и кнопкой «Попробовать снова».
- Поддерживает кастомный `fallback` и колбэк `onError`.
- Интегрирован в `App.tsx`: `<ErrorBoundary><AppRoutes /></ErrorBoundary>`.
### 6.2 Тесты ✅
**Клиент (vitest + jsdom + @testing-library/react):**
- `shared/lib/__tests__/get-error-message.test.ts` — 4 теста
- `shared/lib/__tests__/format-price.test.ts` — 3 теста
- `shared/lib/__tests__/group-orders-by-status.test.ts` — 3 теста
- `shared/ui/ErrorBoundary/__tests__/ErrorBoundary.test.tsx` — 4 теста (рендер, падение, кастомный fallback, сброс)
**Сервер (vitest):**
- `src/lib/__tests__/escape-html.test.js` — 4 теста
- `src/lib/__tests__/order-status.test.js` — 9 тестов (`canTransitionAdminOrderStatus`)
**Команды:** `npm test` (vitest run), `npm run test:watch` (vitest).
---
## Сводка изменений
| Область | Файлов создано | Файлов изменено |
|---|---|---|---|
| Server routes | 0 | 4 (декораты + схемы) |
| Client pages | 3 | 2 (HomePage, AdminOrdersPage, AdminUsersPage) |
| Client entities | 6 | 2 (barrel, GalleryGrid, model types) |
| Client features | 3 | 2 (map-geocoding, AddressMapPicker) |
| Client shared/ui | 3 | 0 (AdminDialog, AdminTable, ErrorBoundary) |
| Client app config | 0 | 2 (vite.config, tsconfig) |
| Client tests | 4 | 0 (vitest config, setup, 3 test files) |
| Server tests | 2 | 0 (vitest config, 2 test files) |
| Shared constants | 8 | 0 (order-status, delivery-carrier, etc.) |
| Server constants | 0 | 3 (order-status, delivery-carrier, upload-limits) |
| **Итого** | **29** | **15** |
+1
View File
@@ -0,0 +1 @@
test
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,24 +0,0 @@
# 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
@@ -1,6 +0,0 @@
node_modules
dist
.vite
coverage
*.min.*
package-lock.json
-9
View File
@@ -1,9 +0,0 @@
{
"singleQuote": true,
"semi": false,
"printWidth": 120,
"trailingComma": "all",
"endOfLine": "lf",
"jsxSingleQuote": false,
"arrowParens": "always"
}
-73
View File
@@ -1,73 +0,0 @@
# 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
@@ -1,204 +0,0 @@
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
@@ -1,30 +0,0 @@
<!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>
-10612
View File
File diff suppressed because it is too large Load Diff
-82
View File
@@ -1,82 +0,0 @@
{
"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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

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

Before

Width:  |  Height:  |  Size: 240 B

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

-106
View File
@@ -1,106 +0,0 @@
Политика в отношении обработки персональных данных
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
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://любимыйкреатив.рф/sitemap.xml
-28
View File
@@ -1,28 +0,0 @@
<?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>
-20
View File
@@ -1,20 +0,0 @@
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'
export function App() {
return (
<AppProviders>
<BrowserRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<NotificationStack />
<NoiseOverlay />
</BrowserRouter>
</AppProviders>
)
}
-202
View File
@@ -1,202 +0,0 @@
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
@@ -1,144 +0,0 @@
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>
)
}
@@ -1,36 +0,0 @@
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
@@ -1,361 +0,0 @@
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
@@ -1,83 +0,0 @@
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
}
@@ -1,159 +0,0 @@
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()
})
})
@@ -1,113 +0,0 @@
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
@@ -1,135 +0,0 @@
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
@@ -1,45 +0,0 @@
@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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

-21
View File
@@ -1,21 +0,0 @@
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
@@ -1,3 +0,0 @@
export type { CartItem } from './model/types'
export { fetchMyCart, addToCart, setCartQty, removeCartItem } from './api/cart-api'
export type { CartResponse } from './api/cart-api'
@@ -1,14 +0,0 @@
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
@@ -1,7 +0,0 @@
import type { Product } from '@/entities/product/model/types'
export type CartItem = {
id: string
qty: number
product: Product
}
@@ -1,19 +0,0 @@
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
}
@@ -1,2 +0,0 @@
export { fetchCatalogSlider, fetchAdminCatalogSlider, putAdminCatalogSlider } from './api/catalog-slider-api'
export type { CatalogSliderSlide, AdminCatalogSliderSlide } from './model/types'
@@ -1,10 +0,0 @@
export type CatalogSliderSlide = {
id: string
url: string
caption: string
textColor?: string
}
export type AdminCatalogSliderSlide = CatalogSliderSlide & {
galleryImageId: string
}
@@ -1,52 +0,0 @@
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
@@ -1,3 +0,0 @@
export { fetchAdminGallery, deleteGalleryImage, uploadGalleryImages, resizeGalleryImage } from './api/gallery-api'
export type { GalleryImageItem } from './model/types'
export { GalleryGrid } from './ui/GalleryGrid'
@@ -1,7 +0,0 @@
export type GalleryImageItem = {
id: string
url: string
isResized: boolean
createdAt: string
inUse?: boolean
}
@@ -1,100 +0,0 @@
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>
)
}
@@ -1,54 +0,0 @@
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
}
@@ -1,7 +0,0 @@
export {
fetchUserNotificationSettings,
updateUserNotificationSettings,
fetchAdminNotificationSettings,
updateAdminNotificationSettings,
} from './api/notifications-api'
export type { UserNotificationSettings, AdminNotificationSettings } from './api/notifications-api'
@@ -1,96 +0,0 @@
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
@@ -1,103 +0,0 @@
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
@@ -1,9 +0,0 @@
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'
@@ -1,108 +0,0 @@
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
}
@@ -1,42 +0,0 @@
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
@@ -1,2 +0,0 @@
export { fetchPublicProducts, fetchPublicProduct, fetchCategories } from './api/product-api'
export type { PublicProductsResponse } from './api/product-api'
@@ -1,33 +0,0 @@
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
}
@@ -1,264 +0,0 @@
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
})
@@ -1,32 +0,0 @@
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 })
}
@@ -1,78 +0,0 @@
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
@@ -1,12 +0,0 @@
export {
postProductReview,
uploadReviewImage,
fetchLatestApprovedReviews,
fetchPublicProductReviews,
} from './api/reviews-api'
export type {
PublicReviewFeedItem,
PublicReviewsLatestResponse,
PublicProductReviewItem,
PublicProductReviewsResponse,
} from './api/reviews-api'
@@ -1,40 +0,0 @@
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')
}
@@ -1,49 +0,0 @@
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
}
@@ -1,24 +0,0 @@
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
@@ -1,45 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,23 +0,0 @@
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
}
@@ -1,2 +0,0 @@
export { AddressFormDialog } from './ui/AddressFormDialog'
export type { AddressFormValues } from './ui/AddressFormDialog'
@@ -1,127 +0,0 @@
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>
)
}
@@ -1,28 +0,0 @@
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 +0,0 @@
export { AddressMapPicker } from './ui/AddressMapPicker'
@@ -1,3 +0,0 @@
export type NominatimItem = { display_name: string; lat: string; lon: string }
export type LatLng = { lat: number; lng: number }
@@ -1,144 +0,0 @@
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>
)
}

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