Appearance
Extract Formatters to shared/lib — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Extract duplicated business logic (price formatting, date formatting, product image URL selection, text highlighting) from 9+ Vue components into testable shared/lib/ utilities with full unit test coverage.
Architecture: Pure functions in shared/lib/*.ts with co-located *.test.ts files. Nuxt auto-imports from shared/lib/ so no explicit imports needed in components. Follows existing phone.ts / phone.test.ts pattern.
Tech Stack: TypeScript, Vitest, Intl.NumberFormat, Intl.DateTimeFormat
Task 1: formatPrice
Files:
- Create:
app/shared/lib/format-price.ts - Test:
app/shared/lib/format-price.test.ts
Step 1: Write the failing test
ts
// app/shared/lib/format-price.test.ts
import { describe, it, expect } from 'vitest'
import { formatPrice } from './format-price'
describe('formatPrice', () => {
it('formats a regular price with thousands separator and ruble sign', () => {
expect(formatPrice(3500)).toBe('3\u00A0500 \u20BD')
})
it('formats zero', () => {
expect(formatPrice(0)).toBe('0 \u20BD')
})
it('formats large numbers', () => {
expect(formatPrice(1234567)).toBe('1\u00A0234\u00A0567 \u20BD')
})
it('formats small numbers without separator', () => {
expect(formatPrice(42)).toBe('42 \u20BD')
})
it('truncates decimals (Intl behavior)', () => {
// Intl.NumberFormat('ru-RU') by default rounds to 0 decimal places for integers
// but if given 99.99 it formats to "100" — verify actual behavior
const result = formatPrice(99.99)
expect(result).toContain('\u20BD')
})
})Note on
\u00A0:Intl.NumberFormat('ru-RU')uses non-breaking space (U+00A0) as thousands separator, NOT regular space. Tests must use\u00A0in expected strings.
Step 2: Run test to verify it fails
Run: npm run test:run -- --reporter=verbose app/shared/lib/format-price.test.ts Expected: FAIL — formatPrice is not a function / module not found
Step 3: Write minimal implementation
ts
// app/shared/lib/format-price.ts
const formatter = new Intl.NumberFormat('ru-RU')
export function formatPrice(value: number): string {
return formatter.format(value) + ' \u20BD'
}Step 4: Run test to verify it passes
Run: npm run test:run -- --reporter=verbose app/shared/lib/format-price.test.ts Expected: All 5 tests PASS
Step 5: Commit
bash
git add app/shared/lib/format-price.ts app/shared/lib/format-price.test.ts
git commit -m "feat(shared): add formatPrice utility with tests"Task 2: formatDate (5 functions)
Files:
- Create:
app/shared/lib/format-date.ts - Test:
app/shared/lib/format-date.test.ts
Step 1: Write the failing test
ts
// app/shared/lib/format-date.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
formatTime,
formatDateShort,
formatDateTime,
formatMonthYear,
formatSmartDate,
} from './format-date'
describe('format-date', () => {
afterEach(() => {
vi.useRealTimers()
})
describe('formatTime', () => {
it('returns HH:mm format', () => {
// Use a fixed date: 2026-03-12T14:30:00
const result = formatTime('2026-03-12T14:30:00')
expect(result).toBe('14:30')
})
it('handles Date object', () => {
const date = new Date(2026, 2, 12, 9, 5)
const result = formatTime(date)
expect(result).toBe('09:05')
})
it('returns empty string for empty input', () => {
expect(formatTime('')).toBe('')
})
})
describe('formatDateShort', () => {
it('returns day + short month', () => {
const result = formatDateShort('2026-03-12T14:30:00')
// "12 мар." — Intl ru-RU short month
expect(result).toMatch(/12\s+мар/)
})
it('returns empty string for empty input', () => {
expect(formatDateShort('')).toBe('')
})
})
describe('formatDateTime', () => {
it('returns DD.MM HH:mm format', () => {
const result = formatDateTime('2026-03-12T14:30:00')
expect(result).toBe('12.03 14:30')
})
it('returns empty string for empty input', () => {
expect(formatDateTime('')).toBe('')
})
})
describe('formatMonthYear', () => {
it('returns long month + year in Russian', () => {
const result = formatMonthYear('2026-03-12T14:30:00')
// "март 2026"
expect(result).toMatch(/март\s+2026/)
})
it('returns empty string for empty input', () => {
expect(formatMonthYear('')).toBe('')
})
})
describe('formatSmartDate', () => {
it('returns time for today', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 2, 12, 16, 0))
const result = formatSmartDate('2026-03-12T14:30:00')
expect(result).toBe('14:30')
})
it('returns short date for past days', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 2, 15, 10, 0))
const result = formatSmartDate('2026-03-12T14:30:00')
expect(result).toMatch(/12\s+мар/)
})
it('returns empty string for empty input', () => {
expect(formatSmartDate('')).toBe('')
})
})
})Step 2: Run test to verify it fails
Run: npm run test:run -- --reporter=verbose app/shared/lib/format-date.test.ts Expected: FAIL — module not found
Step 3: Write minimal implementation
ts
// app/shared/lib/format-date.ts
function toDate(value: string | Date): Date | null {
if (!value) return null
const d = value instanceof Date ? value : new Date(value)
return isNaN(d.getTime()) ? null : d
}
export function formatTime(value: string | Date): string {
const d = toDate(value)
if (!d) return ''
return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
}
export function formatDateShort(value: string | Date): string {
const d = toDate(value)
if (!d) return ''
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
export function formatDateTime(value: string | Date): string {
const d = toDate(value)
if (!d) return ''
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${day}.${month} ${hours}:${minutes}`
}
export function formatMonthYear(value: string | Date): string {
const d = toDate(value)
if (!d) return ''
return d.toLocaleDateString('ru-RU', { month: 'long', year: 'numeric' })
}
export function formatSmartDate(value: string | Date): string {
const d = toDate(value)
if (!d) return ''
const now = new Date()
if (d.toDateString() === now.toDateString()) {
return formatTime(d)
}
return formatDateShort(d)
}Step 4: Run test to verify it passes
Run: npm run test:run -- --reporter=verbose app/shared/lib/format-date.test.ts Expected: All tests PASS
Step 5: Commit
bash
git add app/shared/lib/format-date.ts app/shared/lib/format-date.test.ts
git commit -m "feat(shared): add date formatting utilities with tests"Task 3: getProductImageUrl
Files:
- Create:
app/shared/lib/product-image.ts - Test:
app/shared/lib/product-image.test.ts
Step 1: Write the failing test
ts
// app/shared/lib/product-image.test.ts
import { describe, it, expect } from 'vitest'
import { getProductImageUrl } from './product-image'
// Minimal ProductImage-like shape for tests
function makeImage(overrides: Record<string, unknown> = {}) {
return {
id: 1,
status: 'ready' as const,
is_primary: false,
sort_order: 0,
thumbnail_webp: null,
thumbnail_jpeg: null,
medium_webp: null,
medium_jpeg: null,
large_webp: null,
large_jpeg: null,
...overrides,
}
}
describe('getProductImageUrl', () => {
it('returns medium_webp of primary image by default', () => {
const images = [
makeImage({ id: 1, medium_webp: '/img/1-m.webp' }),
makeImage({ id: 2, is_primary: true, medium_webp: '/img/2-m.webp' }),
]
expect(getProductImageUrl(images)).toBe('/img/2-m.webp')
})
it('falls back to first image when no primary', () => {
const images = [
makeImage({ id: 1, medium_webp: '/img/1-m.webp' }),
makeImage({ id: 2, medium_webp: '/img/2-m.webp' }),
]
expect(getProductImageUrl(images)).toBe('/img/1-m.webp')
})
it('falls back webp → jpeg for medium size', () => {
const images = [
makeImage({ id: 1, medium_webp: null, medium_jpeg: '/img/1-m.jpg' }),
]
expect(getProductImageUrl(images)).toBe('/img/1-m.jpg')
})
it('respects size parameter: thumbnail', () => {
const images = [
makeImage({ id: 1, thumbnail_webp: '/img/1-t.webp', thumbnail_jpeg: '/img/1-t.jpg' }),
]
expect(getProductImageUrl(images, 'thumbnail')).toBe('/img/1-t.webp')
})
it('respects size parameter: large', () => {
const images = [
makeImage({ id: 1, large_webp: '/img/1-l.webp' }),
]
expect(getProductImageUrl(images, 'large')).toBe('/img/1-l.webp')
})
it('returns null for empty array', () => {
expect(getProductImageUrl([])).toBeNull()
})
it('returns null for undefined', () => {
expect(getProductImageUrl(undefined)).toBeNull()
})
it('returns null when all URLs are null', () => {
const images = [makeImage()]
expect(getProductImageUrl(images)).toBeNull()
})
})Step 2: Run test to verify it fails
Run: npm run test:run -- --reporter=verbose app/shared/lib/product-image.test.ts Expected: FAIL — module not found
Step 3: Write minimal implementation
ts
// app/shared/lib/product-image.ts
import type { ProductImage } from '~/entities/product/model/product.schema'
type ImageSize = 'thumbnail' | 'medium' | 'large'
export function getProductImageUrl(
images: readonly ProductImage[] | undefined,
size: ImageSize = 'medium',
): string | null {
if (!images?.length) return null
const image = images.find(i => i.is_primary) ?? images[0]
return image[`${size}_webp`] ?? image[`${size}_jpeg`] ?? null
}Step 4: Run test to verify it passes
Run: npm run test:run -- --reporter=verbose app/shared/lib/product-image.test.ts Expected: All 8 tests PASS
Step 5: Commit
bash
git add app/shared/lib/product-image.ts app/shared/lib/product-image.test.ts
git commit -m "feat(shared): add getProductImageUrl utility with tests"Task 4: highlightText + escapeHtml (XSS bugfix)
Files:
- Create:
app/shared/lib/highlight-text.ts - Test:
app/shared/lib/highlight-text.test.ts
Context: MessageBubble.vue has highlightText that does NOT escape HTML — XSS risk. GlobalSearchResults.vue has a correct version with escapeHtml. The extracted version will always escape HTML first.
Step 1: Write the failing test
ts
// app/shared/lib/highlight-text.test.ts
import { describe, it, expect } from 'vitest'
import { escapeHtml, highlightText } from './highlight-text'
describe('escapeHtml', () => {
it('escapes angle brackets', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe(
'<script>alert("xss")</script>',
)
})
it('escapes ampersand', () => {
expect(escapeHtml('A & B')).toBe('A & B')
})
it('returns empty string for empty input', () => {
expect(escapeHtml('')).toBe('')
})
})
describe('highlightText', () => {
it('wraps matching text in <mark> tags', () => {
const result = highlightText('Купить запчасти', 'запчасти')
expect(result).toContain('<mark')
expect(result).toContain('запчасти')
expect(result).toContain('</mark>')
})
it('is case-insensitive', () => {
const result = highlightText('BMW запчасти', 'bmw')
expect(result).toContain('<mark')
expect(result).toContain('BMW')
})
it('escapes HTML entities in source text', () => {
const result = highlightText('<b>bold</b> text', 'text')
expect(result).not.toContain('<b>')
expect(result).toContain('<b>')
expect(result).toContain('<mark')
})
it('handles special regex characters in query', () => {
const result = highlightText('price (100)', '(100)')
expect(result).toContain('<mark')
expect(result).toContain('(100)')
})
it('returns escaped text when query is too short (< 2 chars)', () => {
expect(highlightText('hello', 'h')).toBe('hello')
})
it('returns escaped text for empty query', () => {
expect(highlightText('hello', '')).toBe('hello')
})
it('returns empty string for empty text', () => {
expect(highlightText('', 'query')).toBe('')
})
})Step 2: Run test to verify it fails
Run: npm run test:run -- --reporter=verbose app/shared/lib/highlight-text.test.ts Expected: FAIL — module not found
Step 3: Write minimal implementation
ts
// app/shared/lib/highlight-text.ts
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
}
export function highlightText(text: string, query: string): string {
if (!text) return ''
if (!query || query.length < 2) return escapeHtml(text)
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const safeText = escapeHtml(text)
return safeText.replace(
new RegExp(`(${escapedQuery})`, 'gi'),
'<mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>',
)
}Step 4: Run test to verify it passes
Run: npm run test:run -- --reporter=verbose app/shared/lib/highlight-text.test.ts Expected: All 10 tests PASS
Step 5: Commit
bash
git add app/shared/lib/highlight-text.ts app/shared/lib/highlight-text.test.ts
git commit -m "feat(shared): add highlightText with HTML escaping (XSS fix)"Task 5: Replace duplicates in ProductCard.vue
Files:
- Modify:
app/entities/product/ui/ProductCard.vue
What changes:
- Remove inline
imageUrlcomputed (lines 24-28) → replace withgetProductImageUrl() - Remove inline
formattedPricecomputed (lines 30-32) → replace withformatPrice() - Both functions are auto-imported from
shared/lib/by Nuxt
Step 1: Replace imageUrl computed
Find in ProductCard.vue:
ts
const imageUrl = computed(() => {
const primary =
props.product.images?.find((i) => i.is_primary) ?? props.product.images?.[0]
return primary?.medium_webp ?? primary?.medium_jpeg ?? primary?.thumbnail_webp ?? null
})Replace with:
ts
const imageUrl = computed(() => getProductImageUrl(props.product.images))Note: The old code had a quirky fallback chain (
medium_webp → medium_jpeg → thumbnail_webp). The newgetProductImageUrlusesmedium_webp → medium_jpegwhich is cleaner. If thumbnail fallback is truly needed, usegetProductImageUrl(images) ?? getProductImageUrl(images, 'thumbnail')but this is unlikely.
Step 2: Replace formattedPrice computed
Find:
ts
const formattedPrice = computed(() => {
return new Intl.NumberFormat('ru-RU').format(props.product.price) + ' \u20BD'
})Replace with:
ts
const formattedPrice = computed(() => formatPrice(props.product.price))Step 3: Remove unused imports
The ProductImage type import is no longer needed by this file if it's only used for the imageUrl computed. Check: it's used in the props type { images?: readonly ProductImage[] } — so keep it.
Step 4: Verify
Run: npm run test:run -- --reporter=verbose Run: npm run typecheck Visually check catalog page shows prices and images correctly.
Step 5: Commit
bash
git add app/entities/product/ui/ProductCard.vue
git commit -m "refactor(product): use shared formatPrice and getProductImageUrl"Task 6: Replace duplicates in product/[id].vue
Files:
- Modify:
app/pages/product/[id].vue
What changes:
- Replace
formattedPricecomputed (lines 134-137) withformatPrice() - Replace
memberSinceDatecomputed (lines 139-145) withformatMonthYear() - Replace SEO image logic (lines 30-34) with
getProductImageUrl(images, 'large')
Step 1: Replace formattedPrice
Find:
ts
const formattedPrice = computed(() => {
if (!product.value) return ''
return new Intl.NumberFormat('ru-RU').format(product.value.price) + ' \u20BD'
})Replace with:
ts
const formattedPrice = computed(() => {
if (!product.value) return ''
return formatPrice(product.value.price)
})Step 2: Replace memberSinceDate
Find:
ts
const memberSinceDate = computed(() => {
if (!product.value?.seller.created_at) return ''
return new Date(product.value.seller.created_at).toLocaleDateString('ru-RU', {
month: 'long',
year: 'numeric',
})
})Replace with:
ts
const memberSinceDate = computed(() => {
if (!product.value?.seller.created_at) return ''
return formatMonthYear(product.value.seller.created_at)
})Step 3: Replace SEO image
Find:
ts
if (product.value) {
const primaryImage = product.value.images.find(i => i.is_primary) ?? product.value.images[0]
useSeoMeta({
title: product.value.title,
description: product.value.description?.substring(0, 160) ?? product.value.title,
ogImage: primaryImage?.large_webp ?? primaryImage?.large_jpeg,
})
}Replace with:
ts
if (product.value) {
useSeoMeta({
title: product.value.title,
description: product.value.description?.substring(0, 160) ?? product.value.title,
ogImage: getProductImageUrl(product.value.images, 'large'),
})
}Step 4: Verify
Run: npm run typecheck Visually check product detail page.
Step 5: Commit
bash
git add app/pages/product/[id].vue
git commit -m "refactor(product-detail): use shared formatters"Task 7: Replace duplicates in SearchBar.vue + HeroSearchBar.vue
Files:
- Modify:
app/features/search/ui/SearchBar.vue - Modify:
app/features/search/ui/HeroSearchBar.vue
What changes (identical in both):
- Remove
formattedPricefunction (line 56/76) - Remove
getImageUrlfunction (lines 58-61 / 78-81) - Remove
ProductImageimport (no longer needed locally) - Use auto-imported
formatPrice()andgetProductImageUrl(..., 'thumbnail')in template
Step 1: Modify SearchBar.vue
Remove these lines:
ts
const formattedPrice = (price: number) => new Intl.NumberFormat('ru-RU').format(price) + ' \u20BD'
const getImageUrl = (product: Product & { images?: readonly ProductImage[] }) => {
const primary = product.images?.find((i) => i.is_primary) ?? product.images?.[0]
return primary?.thumbnail_webp ?? primary?.thumbnail_jpeg ?? null
}Update template — change formattedPrice(product.price) → formatPrice(product.price) and getImageUrl(product) → getProductImageUrl(product.images, 'thumbnail').
Update import line — remove ProductImage from the import (keep Product if still used, or remove entire import if not).
Step 2: Modify HeroSearchBar.vue (same changes)
Same replacements as Step 1.
Step 3: Verify
Run: npm run typecheck Visually check search autocomplete shows images + prices.
Step 4: Commit
bash
git add app/features/search/ui/SearchBar.vue app/features/search/ui/HeroSearchBar.vue
git commit -m "refactor(search): use shared formatPrice and getProductImageUrl"Task 8: Replace duplicates in seller/[id].vue
Files:
- Modify:
app/pages/seller/[id].vue
What changes:
- Replace
memberSinceDatecomputed (lines 58-64) withformatMonthYear()
Step 1: Replace memberSinceDate
Find:
ts
const memberSinceDate = computed(() => {
if (!seller.value?.created_at) return ''
return new Date(seller.value.created_at).toLocaleDateString('ru-RU', {
month: 'long',
year: 'numeric',
})
})Replace with:
ts
const memberSinceDate = computed(() => {
if (!seller.value?.created_at) return ''
return formatMonthYear(seller.value.created_at)
})Step 2: Verify
Run: npm run typecheck
Step 3: Commit
bash
git add app/pages/seller/[id].vue
git commit -m "refactor(seller): use shared formatMonthYear"Task 9: Replace duplicates in ConversationCard.vue
Files:
- Modify:
app/entities/conversation/ui/ConversationCard.vue
What changes:
- Replace
timeLabelcomputed (lines 7-14) withformatSmartDate() - Replace inline
.toLocaleString('ru-RU')price (line 68) withformatPrice()
Step 1: Replace timeLabel
Find:
ts
const timeLabel = computed(() => {
if (!props.conversation.last_message) return ''
const date = new Date(props.conversation.last_message.created_at)
const now = new Date()
if (date.toDateString() === now.toDateString())
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
})Replace with:
ts
const timeLabel = computed(() => {
if (!props.conversation.last_message) return ''
return formatSmartDate(props.conversation.last_message.created_at)
})Step 2: Replace price in template
Find in template:
html
<div class="text-xs font-semibold text-primary">{{ conversation.product.price.toLocaleString('ru-RU') }} ₽</div>Replace with:
html
<div class="text-xs font-semibold text-primary">{{ formatPrice(conversation.product.price) }}</div>Step 3: Verify
Run: npm run typecheck
Step 4: Commit
bash
git add app/entities/conversation/ui/ConversationCard.vue
git commit -m "refactor(conversation): use shared formatSmartDate and formatPrice"Task 10: Replace duplicates in MessageBubble.vue (XSS fix)
Files:
- Modify:
app/entities/message/ui/MessageBubble.vue
What changes:
- Remove local
highlightTextfunction (lines 31-38) — it lacks HTML escaping (XSS) - Remove local
timeLabelcomputed (lines 24-27) → useformatTime() - Use auto-imported
highlightTextfromshared/lib/highlight-text.ts(with escaping) - Use auto-imported
formatTimefromshared/lib/format-date.ts
Step 1: Replace timeLabel
Find:
ts
const timeLabel = computed(() => {
const date = new Date(props.message.created_at)
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
})Replace with:
ts
const timeLabel = computed(() => formatTime(props.message.created_at))Step 2: Remove local highlightText and update highlightedText
Remove the entire local function:
ts
function highlightText(text: string, query: string): string {
if (!query || query.length < 2) return text
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return text.replace(
new RegExp(`(${escaped})`, 'gi'),
'<mark class="bg-yellow-200 dark:bg-yellow-800 rounded px-0.5">$1</mark>',
)
}The highlightedText computed already calls highlightText() — it will now use the auto-imported version from shared/lib which includes HTML escaping.
Step 3: Verify
Run: npm run typecheck
Step 4: Commit
bash
git add app/entities/message/ui/MessageBubble.vue
git commit -m "fix(message): use shared highlightText with HTML escaping (XSS fix)"Task 11: Replace duplicates in GlobalSearchResults.vue
Files:
- Modify:
app/features/chat-search/ui/GlobalSearchResults.vue
What changes:
- Remove local
highlightTextfunction (lines 16-24) - Remove local
escapeHtmlfunction (lines 26-32) - Remove local
formatDatefunction (lines 34-41) - Use auto-imported
highlightTextfrom shared/lib - Use auto-imported
formatDateTimefrom shared/lib (replacesformatDate)
Step 1: Remove all three local functions
Delete lines 16-41 (the three function declarations).
Step 2: Update template references
The template uses highlightText(msg.text ?? '', query) — this stays the same (auto-imported).
Find in template:
html
<span class="text-xs text-gray-400">{{ formatDate(msg.created_at) }}</span>Replace with:
html
<span class="text-xs text-gray-400">{{ formatDateTime(msg.created_at) }}</span>Step 3: Verify
Run: npm run typecheck
Step 4: Commit
bash
git add app/features/chat-search/ui/GlobalSearchResults.vue
git commit -m "refactor(chat-search): use shared highlightText and formatDateTime"Task 12: Replace duplicates in messages/[id].vue
Files:
- Modify:
app/pages/cabinet/messages/[id].vue
What changes:
- Replace two inline
.toLocaleString('ru-RU')price calls (lines 269, 300) withformatPrice()
Step 1: Replace price in header
Find (line 269):
html
<span v-else>{{ conversation.product.title }} · {{ conversation.product.price.toLocaleString('ru-RU') }} ₽</span>Replace with:
html
<span v-else>{{ conversation.product.title }} · {{ formatPrice(conversation.product.price) }}</span>Step 2: Replace price in product strip
Find (line 300):
html
<div class="text-sm font-semibold text-primary">{{ conversation.product.price.toLocaleString('ru-RU') }} ₽</div>Replace with:
html
<div class="text-sm font-semibold text-primary">{{ formatPrice(conversation.product.price) }}</div>Step 3: Verify
Run: npm run typecheck
Step 4: Commit
bash
git add app/pages/cabinet/messages/[id].vue
git commit -m "refactor(messages): use shared formatPrice"Task 13: Final verification
Step 1: Run all tests
bash
npm run test:runExpected: All tests pass (existing 22 + ~30 new = ~52 total).
Step 2: Run lint
bash
npm run lint:fixFix any issues.
Step 3: Run typecheck
bash
npm run typecheckExpected: No errors.
Step 4: Manual spot-check
Open npm run dev and verify:
/catalog— prices show with₽and thousands separator/product/1— price, member since date, SEO og:image- Search bar autocomplete — thumbnail images and prices
/cabinet/messages— conversation cards, time labels, prices/seller/1— member since date
Step 5: Commit any lint fixes
bash
git add -A
git commit -m "chore: lint fixes after formatter extraction"