test commit
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
# 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
|
||||
@@ -0,0 +1,543 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -0,0 +1,174 @@
|
||||
# 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
Reference in New Issue
Block a user