Files
shop-server/docs/superpowers/plans/2026-05-15-lighthouse-optimization-plan.md
T

1293 lines
40 KiB
Markdown

# Lighthouse Optimization 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:** Optimize site loading to achieve ~100 Lighthouse score through image resizing, lazy loading, cache headers, code splitting, and meta tags.
**Architecture:** Server-side image resizing via sharp (on-demand AVIF/WebP generation with disk cache), client `<OptimizedImage>` component with srcset/sizes, route-level lazy loading for admin/me pages, Vite manual chunks for vendor splitting.
**Tech Stack:** sharp (server), React + MUI (client), Vite, Fastify
---
### Task 1: Install sharp dependency
**Files:**
- Modify: `server/package.json`
- [ ] **Step 1: Add sharp to server dependencies**
Run: `npm install sharp` in `server/` directory
```bash
cd /mnt/d/my_projects/shop/server && npm install sharp
```
Expected: sharp added to package.json dependencies, node_modules updated.
---
### Task 2: Server image resize library
**Files:**
- Create: `server/src/lib/image-resize.js`
- [ ] **Step 1: Create the image resize library**
```js
// server/src/lib/image-resize.js
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
const CACHE_DIR = path.join(UPLOADS_DIR, '.cache')
const VALID_WIDTHS = [320, 640, 1024, 1600]
const SUPPORTED_FORMATS = new Set(['avif', 'webp'])
/**
* Find the original file by UUID (without extension) in the uploads directory tree.
* Searches both /uploads/ and /uploads/reviews/.
* Returns full path or null.
*/
export async function findOriginalFile(uuid, subdir = '') {
const searchDirs = subdir ? [subdir] : ['', 'reviews']
for (const dir of searchDirs) {
for (const ext of ['.png', '.jpg', '.jpeg', '.webp']) {
const fullPath = path.join(UPLOADS_DIR, dir, `${uuid}${ext}`)
try {
await fs.promises.access(fullPath)
return fullPath
} catch {
// file not found with this extension, try next
}
}
}
return null
}
/**
* Get or generate a resized image. Returns { path: string, isNew: boolean }.
*/
export async function getOrCreateResized(uuid, width, format, subdir = '') {
const cacheSubdir = subdir ? subdir : ''
const cacheFileName = `${uuid}_w${width}.${format}`
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
try {
await fs.promises.access(cachePath)
return { path: cachePath, isNew: false }
} catch {
// cache miss, need to generate
}
const originalPath = await findOriginalFile(uuid, subdir)
if (!originalPath) {
return null
}
await fs.promises.mkdir(path.dirname(cachePath), { recursive: true })
const sharp = (await import('sharp')).default
let pipeline = sharp(originalPath)
if (width) {
pipeline = pipeline.resize(width, null, { withoutEnlargement: true })
}
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
return { path: cachePath, isNew: true }
}
export { VALID_WIDTHS, SUPPORTED_FORMATS }
```
---
### Task 3: Server route for resized images
**Files:**
- Create: `server/src/routes/uploads-resized.js`
- Modify: `server/src/index.js`
- [ ] **Step 1: Create the uploads-resized route**
```js
// server/src/routes/uploads-resized.js
import fs from 'node:fs'
import path from 'node:path'
import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js'
const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable'
const CACHE_CONTROL_SHORT = 'public, max-age=86400'
/**
* Register GET /uploads-resized/* route for on-demand image resizing.
* Must be registered BEFORE fastify-static for /uploads/.
*/
export function registerUploadsResized(fastify) {
fastify.get('/uploads-resized/*', async (request, reply) => {
const rawPath = request.params['*']
const url = new URL(request.url, 'http://localhost')
const widthParam = url.searchParams.get('w')
// Parse: [subdir/]filename.format
const parts = rawPath.split('/')
let filename, subdir = ''
if (parts.length > 1) {
subdir = parts.slice(0, -1).join('/') + '/'
filename = parts[parts.length - 1]
} else {
filename = parts[0]
}
const dotIdx = filename.lastIndexOf('.')
if (dotIdx === -1) {
return reply.code(400).send({ error: 'Invalid request: no format specified' })
}
const uuid = filename.slice(0, dotIdx)
const format = filename.slice(dotIdx + 1).toLowerCase()
if (!SUPPORTED_FORMATS.has(format)) {
return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` })
}
// Validate width
let width = null
if (widthParam) {
const w = parseInt(widthParam, 10)
if (!VALID_WIDTHS.includes(w)) {
return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` })
}
width = w
}
// If no width requested, serve original with short cache
if (!width) {
const originalPath = await findOriginalFile(uuid, subdir || undefined)
if (!originalPath) {
return reply.code(404).send({ error: 'Image not found' })
}
reply.header('Cache-Control', CACHE_CONTROL_SHORT)
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
return reply.send(fs.createReadStream(originalPath))
}
const result = await getOrCreateResized(uuid, width, format, subdir || undefined)
if (!result) {
return reply.code(404).send({ error: 'Image not found' })
}
reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE)
reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp')
return reply.send(fs.createReadStream(result.path))
})
}
```
- [ ] **Step 2: Register the route in server/src/index.js**
Read `server/src/index.js` and add the import + registration. Insert the route registration BEFORE `fastifyStatic` registration.
```js
// Add import at top
import { registerUploadsResized } from './routes/uploads-resized.js'
// Add BEFORE the fastifyStatic registration (line ~49):
registerUploadsResized(fastify)
```
The import should go after the existing imports, and the `registerUploadsResized(fastify)` call should be placed right before `const uploadsDir = path.join(process.cwd(), 'uploads')`.
- [ ] **Step 3: Add Cache-Control headers to fastify-static for /uploads/**
Modify the fastify-static registration in `server/src/index.js` to add cache headers:
```js
await fastify.register(fastifyStatic, {
root: uploadsDir,
prefix: '/uploads/',
setHeaders(res, filePath) {
// .cache files get immutable headers
if (filePath.includes('/.cache/')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else {
res.setHeader('Cache-Control', 'public, max-age=86400')
}
},
})
```
---
### Task 4: Separate review images into /uploads/reviews/
**Files:**
- Modify: `server/src/lib/upload-images.js`
- Modify: `server/src/routes/api/public-reviews.js`
- [ ] **Step 1: Update saveImageBufferToUploads to accept subdir**
Modify `server/src/lib/upload-images.js`:
```js
/** Сохранить один буфер изображения в uploads/, вернуть путь `/uploads/...`. */
export async function saveImageBufferToUploads(originalFilename, buffer, subdir = '') {
const ext = safeImageExt(originalFilename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
await fs.promises.mkdir(targetDir, { recursive: true })
const fileName = `${crypto.randomUUID()}${ext}`
const fullPath = path.join(targetDir, fileName)
await fs.promises.writeFile(fullPath, buffer)
return subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
}
```
- [ ] **Step 2: Update review upload to use /reviews/ subdir**
Modify `server/src/routes/api/public-reviews.js`, the `/api/reviews/upload-image` handler:
```js
// Change the persistMultipartImages call to use a custom save function
// that puts files in /uploads/reviews/
// Replace the existing handler with:
fastify.post(
'/api/reviews/upload-image',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 1,
maxFileBytes: getOtherUploadMaxFileBytes(),
subdir: 'reviews',
})
if (urls.length !== 1) return reply.code(400).send({ error: 'Нужно прикрепить 1 изображение' })
return { url: urls[0] }
} 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(getOtherUploadMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
```
- [ ] **Step 3: Update persistMultipartImages to accept subdir**
Modify `server/src/lib/upload-images.js`, the `persistMultipartImages` function:
```js
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '' }) {
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 fileName = `${crypto.randomUUID()}${ext}`
const fullPath = path.join(targetDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
urls.push(subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`)
}
if (urls.length === 0) {
throw uploadError(
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
)
}
return urls
}
```
---
### Task 5: Client OptimizedImage component
**Files:**
- Create: `client/src/shared/ui/OptimizedImage.tsx`
- Create: `client/src/shared/ui/__tests__/OptimizedImage.test.tsx`
- [ ] **Step 1: Create the OptimizedImage component**
```tsx
// client/src/shared/ui/OptimizedImage.tsx
import { useMemo } from 'react'
import Box from '@mui/material/Box'
import type { SxProps, Theme } from '@mui/material/styles'
const DEFAULT_WIDTHS = [320, 640, 1024, 1600]
type OptimizedImageProps = {
src: string
alt: string
widths?: number[]
sizes?: string
sx?: SxProps<Theme>
priority?: boolean
}
/** Extract UUID and subdir from a /uploads/... URL */
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() }
}
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}`
}
export function OptimizedImage({ src, alt, widths = DEFAULT_WIDTHS, sizes, sx, priority = false }: OptimizedImageProps) {
const srcSet = useMemo(() => buildSrcSet(src, widths), [src, widths])
const fallbackSrc = useMemo(() => buildFallbackSrc(src, widths[0]), [src, widths])
// If src is not an upload URL, render a plain img
if (!srcSet) {
return (
<Box
component="img"
src={src}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
sx={sx}
/>
)
}
const sizesAttr = sizes ?? '(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px'
return (
<Box
component="picture"
sx={{ display: 'block', lineHeight: 0, ...sx }}
>
<Box
component="source"
type="image/avif"
srcSet={srcSet}
sizes={sizesAttr}
/>
<Box
component="source"
type="image/webp"
srcSet={fallbackSrc}
sizes={sizesAttr}
/>
<Box
component="img"
src={fallbackSrc}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</Box>
)
}
```
- [ ] **Step 2: Create tests for OptimizedImage**
```tsx
// client/src/shared/ui/__tests__/OptimizedImage.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
describe('OptimizedImage', () => {
it('renders a plain img for non-upload URLs', () => {
render(<OptimizedImage src="https://example.com/photo.jpg" alt="test" />)
const img = screen.getByAltText('test') as HTMLImageElement
expect(img.src).toBe('https://example.com/photo.jpg')
expect(img.loading).toBe('lazy')
})
it('renders picture element with srcset for upload URLs', () => {
render(<OptimizedImage src="/uploads/abc123.png" alt="product" />)
const picture = screen.getByAltText('product').closest('picture')
expect(picture).not.toBeNull()
const avifSource = picture?.querySelector('source[type="image/avif"]') as HTMLSourceElement
expect(avifSource).not.toBeNull()
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=320')
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=640')
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=1024')
expect(avifSource.srcSet).toContain('/uploads-resized/abc123.avif?w=1600')
})
it('handles review subdir correctly', () => {
render(<OptimizedImage src="/uploads/reviews/def456.jpg" alt="review" />)
const avifSource = screen.getByAltText('review').closest('picture')?.querySelector('source[type="image/avif"]') as HTMLSourceElement
expect(avifSource.srcSet).toContain('/uploads-resized/reviews/def456.avif?w=320')
})
it('uses eager loading when priority is true', () => {
render(<OptimizedImage src="/uploads/abc123.png" alt="hero" priority />)
const img = screen.getByAltText('hero') as HTMLImageElement
expect(img.loading).toBe('eager')
})
it('respects custom widths', () => {
render(<OptimizedImage src="/uploads/abc123.png" alt="test" widths={[200, 400]} />)
const avifSource = screen.getByAltText('test').closest('picture')?.querySelector('source[type="image/avif"]') as HTMLSourceElement
expect(avifSource.srcSet).toContain('?w=200')
expect(avifSource.srcSet).toContain('?w=400')
expect(avifSource.srcSet).not.toContain('?w=640')
})
})
```
- [ ] **Step 3: Run tests to verify they pass**
```bash
cd /mnt/d/my_projects/shop/client && npm test -- --run src/shared/ui/__tests__/OptimizedImage.test.tsx
```
Expected: All 5 tests pass.
---
### Task 6: Replace images in ProductCard
**Files:**
- Modify: `client/src/entities/product/ui/ProductCard.tsx`
- [ ] **Step 1: Import OptimizedImage and replace img elements**
```tsx
// Add import
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// Replace the <Box component="img" ... /> inside SwiperSlide (around line 88-103):
// OLD:
// {imageUrls.map((url) => (
// <SwiperSlide key={url}>
// <Box
// component="img"
// src={url}
// alt={product.title}
// className="product-card__media"
// sx={{
// width: '100%',
// height: mediaHeight,
// objectFit: 'cover',
// display: 'block',
// transition: 'transform 320ms ease',
// '@media (prefers-reduced-motion: reduce)': { transition: 'none' },
// userSelect: 'none',
// bgcolor: 'grey.50',
// }}
// />
// </SwiperSlide>
// ))}
// NEW:
{imageUrls.map((url) => (
<SwiperSlide key={url}>
<Box
className="product-card__media"
sx={{
width: '100%',
height: mediaHeight,
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) ${mediaHeight}px, (max-width: 1024px) ${Math.round(mediaHeight * 1.5)}px, ${mediaHeight}px`}
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
</SwiperSlide>
))}
```
---
### Task 7: Replace images in ProductPage
**Files:**
- Modify: `client/src/pages/product/ui/ProductPage.tsx`
- [ ] **Step 1: Import OptimizedImage and replace all img elements**
```tsx
// Add import
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// Replace the main product image in SwiperSlide (around line 91-110):
// OLD:
// {imageUrls.map((url, idx) => (
// <SwiperSlide key={url}>
// <Box
// component="img"
// src={url}
// alt={p.title}
// onClick={() => {
// setViewerIndex(idx)
// setViewerOpen(true)
// }}
// sx={{
// width: '100%',
// height: 420,
// objectFit: 'cover',
// display: 'block',
// cursor: 'zoom-in',
// userSelect: 'none',
// }}
// />
// </SwiperSlide>
// ))}
// NEW:
{imageUrls.map((url, idx) => (
<SwiperSlide key={url}>
<Box
onClick={() => {
setViewerIndex(idx)
setViewerOpen(true)
}}
sx={{
width: '100%',
height: 420,
cursor: 'zoom-in',
userSelect: 'none',
}}
>
<OptimizedImage
src={url}
alt={p.title}
sizes="(max-width: 600px) 320px, (max-width: 1024px) 640px, 1024px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
</SwiperSlide>
))}
// Replace the review image (around line 220-234):
// OLD:
// {rv.imageUrl && (
// <Box
// component="img"
// src={rv.imageUrl}
// alt="Фото к отзыву"
// sx={{
// width: 140,
// height: 140,
// objectFit: 'cover',
// borderRadius: 1.5,
// border: 1,
// borderColor: 'divider',
// }}
// />
// )}
// NEW:
{rv.imageUrl && (
<Box
sx={{
width: 140,
height: 140,
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
<OptimizedImage
src={rv.imageUrl}
alt="Фото к отзыву"
widths={[140, 320]}
sizes="140px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
)}
// Replace the fullscreen viewer images (around line 265-274):
// OLD:
// {imageUrls.map((url) => (
// <SwiperSlide key={`fs:${url}`}>
// <Box
// component="img"
// src={url}
// alt={p.title}
// sx={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
// />
// </SwiperSlide>
// ))}
// NEW (viewer uses original — no resize needed for fullscreen):
{imageUrls.map((url) => (
<SwiperSlide key={`fs:${url}`}>
<Box
component="img"
src={url}
alt={p.title}
sx={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }}
/>
</SwiperSlide>
))}
```
---
### Task 8: Replace images in CatalogSlider
**Files:**
- Modify: `client/src/widgets/catalog-slider/ui/CatalogSlider.tsx`
- [ ] **Step 1: Import OptimizedImage and replace img**
```tsx
// Add import
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// Replace the <Box component="img" ... /> inside the slider (around line 64-78):
// OLD:
// <Box
// component="img"
// src={slide.url}
// alt={captionText || 'Слайд каталога'}
// loading={i === 0 ? 'eager' : 'lazy'}
// sx={{
// position: 'absolute',
// inset: 0,
// width: '100%',
// height: '100%',
// objectFit: 'cover',
// display: 'block',
// zIndex: 0,
// }}
// />
// NEW:
<Box
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
zIndex: 0,
}}
>
<OptimizedImage
src={slide.url}
alt={captionText || 'Слайд каталога'}
priority={i === 0}
sizes="(max-width: 600px) 320px, (max-width: 1024px) 1024px, 1600px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
```
---
### Task 9: Replace images in GalleryGrid
**Files:**
- Modify: `client/src/entities/gallery/ui/GalleryGrid.tsx`
- [ ] **Step 1: Import OptimizedImage and replace img**
```tsx
// Add import
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// Replace the <Box component="img" ... /> (around line 34-39):
// OLD:
// <Box
// component="img"
// src={item.url}
// alt=""
// sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
// />
// NEW:
<OptimizedImage
src={item.url}
alt=""
sizes="140px"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
```
---
### Task 10: Replace images in ReviewsBlock
**Files:**
- Modify: `client/src/widgets/reviews-block/ui/ReviewsBlock.tsx`
- [ ] **Step 1: Import OptimizedImage and replace img**
```tsx
// Add import
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// Replace the review image (around line 113-128):
// OLD:
// {r.imageUrl && (
// <Box
// component="img"
// src={r.imageUrl}
// alt="Фото к отзыву"
// sx={{
// mt: 1.5,
// width: 120,
// height: 120,
// objectFit: 'cover',
// borderRadius: 1.5,
// border: 1,
// borderColor: 'divider',
// }}
// />
// )}
// NEW:
{r.imageUrl && (
<Box
sx={{
mt: 1.5,
width: 120,
height: 120,
borderRadius: 1.5,
border: 1,
borderColor: 'divider',
overflow: 'hidden',
}}
>
<OptimizedImage
src={r.imageUrl}
alt="Фото к отзыву"
widths={[120, 320]}
sizes="120px"
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
</Box>
)}
```
---
### Task 11: Replace images in admin pages
**Files:**
- Modify: `client/src/pages/admin-products/ui/AdminProductsPage.tsx`
- Modify: `client/src/pages/admin/ui/AdminPage.tsx`
- [ ] **Step 1: Replace images in AdminProductsPage**
```tsx
// Add import
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// Find all <Box component="img" src={url} ... /> patterns and replace with OptimizedImage.
// There are two locations:
// 1. Product form preview thumbnails (around line 403-429)
// 2. Gallery picker thumbnails (around line 536-541)
// For the form preview (productForm.watch('imageUrls').map):
// OLD pattern:
// <Box
// component="img"
// src={url}
// alt=""
// sx={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 1, display: 'block' }}
// />
// NEW:
<Box sx={{ width: '100%', height: 80, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={url}
alt=""
widths={[80, 160]}
sizes="80px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
// For the gallery picker (around line 536-541):
// OLD:
// <Box
// component="img"
// src={item.url}
// alt=""
// sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
// />
// NEW:
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[120, 240]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
```
- [ ] **Step 2: Replace images in AdminPage**
```tsx
// Add import (if not already added)
import { OptimizedImage } from '@/shared/ui/OptimizedImage'
// For the form preview thumbnails (productForm.watch('imageUrls').map, ~line 556-582):
// OLD:
// <Box
// component="img"
// src={url}
// alt=""
// sx={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 1, display: 'block' }}
// />
// NEW:
<Box sx={{ width: '100%', height: 80, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={url}
alt=""
widths={[80, 160]}
sizes="80px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
// For the gallery picker thumbnails (~line 675-683):
// OLD:
// <Box
// component="img"
// src={item.url}
// alt=""
// sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
// />
// NEW:
<Box sx={{ width: '100%', maxHeight: 100, borderRadius: 1, overflow: 'hidden' }}>
<OptimizedImage
src={item.url}
alt=""
widths={[120, 240]}
sizes="120px"
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
```
---
### Task 12: Vite code splitting
**Files:**
- Modify: `client/vite.config.ts`
- [ ] **Step 1: Add manualChunks to vite config**
```ts
// Modify the defineConfig to include build.rollupOptions:
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(rootDir, 'src'),
'@shared': path.resolve(projectRoot, 'shared'),
},
},
server: {
fs: {
allow: [projectRoot],
},
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:3333',
changeOrigin: true,
},
'/uploads': {
target: 'http://127.0.0.1:3333',
changeOrigin: true,
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-mui': ['@mui/material', '@mui/icons-material', '@emotion/react', '@emotion/styled'],
'vendor-swiper': ['swiper/react', 'swiper/modules'],
'vendor-query': ['@tanstack/react-query'],
'vendor-effector': ['effector', 'effector-react'],
},
},
},
},
})
```
Note: maplibre-gl and @tiptap are only imported on specific pages, so they'll be auto-split by Vite's dynamic import. No need to manually chunk them.
---
### Task 13: Route-level lazy loading
**Files:**
- Modify: `client/src/app/routes/index.tsx`
- [ ] **Step 1: Add lazy imports for admin and me routes**
```tsx
// OLD:
// import { AdminLayoutPage } from '@/pages/admin-layout'
// import { MeLayoutPage } from '@/pages/me'
// NEW:
import { lazy, Suspense } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { MainLayout } from '@/app/layout/MainLayout'
import { AboutPage } from '@/pages/about'
// import { AdminLayoutPage } from '@/pages/admin-layout'
// import { MeLayoutPage } from '@/pages/me'
import { AuthCallbackPage, AuthPage } from '@/pages/auth'
import { CartPage } from '@/pages/cart'
import { CheckoutPage } from '@/pages/checkout'
import { HomePage } from '@/pages/home'
import { InfoPage } from '@/pages/info'
import { PrivacyPolicyPage } from '@/pages/privacy-policy'
import { ProductPage } from '@/pages/product'
import { SkeletonPage } from '@/shared/ui/SkeletonPage'
const AdminLayoutPage = lazy(() => import('@/pages/admin-layout'))
const MeLayoutPage = lazy(() => import('@/pages/me'))
// Wrap lazy routes with Suspense:
// OLD:
// <Route path="/admin/*" element={<AdminLayoutPage />} />
// <Route path="/me/*" element={<MeLayoutPage />} />
// NEW:
<Route path="/admin/*" element={<Suspense fallback={<SkeletonPage />}><AdminLayoutPage /></Suspense>} />
<Route path="/me/*" element={<Suspense fallback={<SkeletonPage />}><MeLayoutPage /></Suspense>} />
```
- [ ] **Step 2: Create SkeletonPage component**
```tsx
// client/src/shared/ui/SkeletonPage.tsx
import Box from '@mui/material/Box'
import Skeleton from '@mui/material/Skeleton'
export function SkeletonPage() {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, py: 3 }}>
<Skeleton variant="text" width="40%" height={48} />
<Skeleton variant="rectangular" height={200} sx={{ borderRadius: 2 }} />
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="80%" />
<Skeleton variant="text" width="50%" />
</Box>
)
}
```
---
### Task 14: HTML meta tags
**Files:**
- Modify: `client/index.html`
- [ ] **Step 1: Add meta tags to index.html**
```html
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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.svg" />
<meta property="og:locale" content="ru_RU" />
<link rel="canonical" href="https://craftshop.example.com/" /> <!-- TODO: Replace with actual domain -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
```
---
### Task 15: Add .cache to .gitignore
**Files:**
- Modify: `server/.gitignore` (or root `.gitignore`)
- [ ] **Step 1: Add uploads cache directory to gitignore**
Check which .gitignore exists and add:
```
# Image resize cache
uploads/.cache/
```
---
### Task 16: Server tests for image resizing
**Files:**
- Create: `server/src/lib/__tests__/image-resize.test.js`
- [ ] **Step 1: Create test for image resize library**
```js
// server/src/lib/__tests__/image-resize.test.js
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { findOriginalFile, getOrCreateResized } from '../image-resize.js'
const TEST_DIR = path.join(process.cwd(), 'uploads', '.test-tmp')
const UPLOADS_DIR = path.join(process.cwd(), 'uploads')
beforeAll(async () => {
await fs.promises.mkdir(TEST_DIR, { recursive: true })
// Create a small test PNG file
const sharp = (await import('sharp')).default
const testPng = path.join(TEST_DIR, 'test-original.png')
await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toFile(testPng)
})
afterAll(async () => {
await fs.promises.rm(TEST_DIR, { recursive: true, force: true })
// Clean up any cache files created during tests
const cacheDir = path.join(UPLOADS_DIR, '.cache')
try {
await fs.promises.rm(cacheDir, { recursive: true, force: true })
} catch {
// ignore
}
})
describe('image-resize', () => {
it('findOriginalFile locates file by UUID', async () => {
const files = await fs.promises.readdir(TEST_DIR)
const pngFile = files.find((f) => f.endsWith('.png'))
const uuid = pngFile.replace('.png', '')
// Temporarily override UPLOADS_DIR by copying file to actual uploads
const destPath = path.join(UPLOADS_DIR, pngFile)
await fs.promises.copyFile(path.join(TEST_DIR, pngFile), destPath)
const found = await findOriginalFile(uuid)
expect(found).not.toBeNull()
expect(found).toBe(destPath)
// Cleanup
await fs.promises.unlink(destPath)
})
it('getOrCreateResized generates AVIF file', async () => {
// Create a test file in uploads/
const sharp = (await import('sharp')).default
const uuid = crypto.randomUUID()
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
await sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
})
.png()
.toFile(testPath)
const result = await getOrCreateResized(uuid, 100, 'avif')
expect(result).not.toBeNull()
expect(result.isNew).toBe(true)
expect(result.path).toContain('.cache')
expect(result.path).toContain('_w100.avif')
const exists = await fs.promises.access(result.path).then(() => true).catch(() => false)
expect(exists).toBe(true)
// Verify it's actually AVIF
const info = await sharp(result.path).metadata()
expect(info.format).toBe('avif')
// Cleanup
await fs.promises.unlink(testPath)
await fs.promises.unlink(result.path)
})
it('getOrCreateResized returns cached file on second call', async () => {
const sharp = (await import('sharp')).default
const uuid = crypto.randomUUID()
const testPath = path.join(UPLOADS_DIR, `${uuid}.png`)
await sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 0, b: 255 } },
})
.png()
.toFile(testPath)
const first = await getOrCreateResized(uuid, 100, 'webp')
expect(first.isNew).toBe(true)
const second = await getOrCreateResized(uuid, 100, 'webp')
expect(second.isNew).toBe(false)
expect(second.path).toBe(first.path)
// Cleanup
await fs.promises.unlink(testPath)
await fs.promises.unlink(first.path)
})
})
```
- [ ] **Step 2: Run server tests**
```bash
cd /mnt/d/my_projects/shop/server && npm test
```
Expected: All tests pass including new image-resize tests.
---
### Task 17: Build and verify
**Files:** N/A
- [ ] **Step 1: Build the client**
```bash
cd /mnt/d/my_projects/shop/client && npm run build
```
Expected: Build succeeds with no TypeScript errors. Check that output has multiple chunk files (vendor-react, vendor-mui, etc.).
- [ ] **Step 2: Run client lint**
```bash
cd /mnt/d/my_projects/shop/client && npm run lint
```
Expected: No errors.
- [ ] **Step 3: Run client tests**
```bash
cd /mnt/d/my_projects/shop/client && npm test
```
Expected: All tests pass.
- [ ] **Step 4: Run server tests**
```bash
cd /mnt/d/my_projects/shop/server && npm test
```
Expected: All tests pass.
- [ ] **Step 5: Commit all changes**
```bash
git add -A
git commit -m "perf: optimize lighthouse score with image resizing, lazy loading, and code splitting
- Add sharp-based on-demand AVIF/WebP image resizing with disk cache
- Add <OptimizedImage> component with srcset/sizes/lazy loading
- Separate review images into /uploads/reviews/
- Add Cache-Control headers for uploads
- Code split vendor bundles (react, mui, swiper, etc.)
- Lazy load admin and me routes
- Add meta tags for SEO and OG"
```