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

40 KiB

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

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

// 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

// 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.

// 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:

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:

/** Сохранить один буфер изображения в 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:

// 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:

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

// 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
// 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
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

// 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

// 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

// 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

// 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

// 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

// 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
// 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

// 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

// 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
// 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

<!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

// 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
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
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
cd /mnt/d/my_projects/shop/client && npm run lint

Expected: No errors.

  • Step 3: Run client tests
cd /mnt/d/my_projects/shop/client && npm test

Expected: All tests pass.

  • Step 4: Run server tests
cd /mnt/d/my_projects/shop/server && npm test

Expected: All tests pass.

  • Step 5: Commit all changes
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"