Skip to content

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 \u00A0 in 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(
      '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;',
    )
  })

  it('escapes ampersand', () => {
    expect(escapeHtml('A & B')).toBe('A &amp; 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('&lt;b&gt;')
    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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}

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 imageUrl computed (lines 24-28) → replace with getProductImageUrl()
  • Remove inline formattedPrice computed (lines 30-32) → replace with formatPrice()
  • 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 new getProductImageUrl uses medium_webp → medium_jpeg which is cleaner. If thumbnail fallback is truly needed, use getProductImageUrl(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 formattedPrice computed (lines 134-137) with formatPrice()
  • Replace memberSinceDate computed (lines 139-145) with formatMonthYear()
  • 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 formattedPrice function (line 56/76)
  • Remove getImageUrl function (lines 58-61 / 78-81)
  • Remove ProductImage import (no longer needed locally)
  • Use auto-imported formatPrice() and getProductImageUrl(..., '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 memberSinceDate computed (lines 58-64) with formatMonthYear()

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 timeLabel computed (lines 7-14) with formatSmartDate()
  • Replace inline .toLocaleString('ru-RU') price (line 68) with formatPrice()

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">&#123;&#123; conversation.product.price.toLocaleString('ru-RU') }} &#8381;</div>

Replace with:

html
<div class="text-xs font-semibold text-primary">&#123;&#123; 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 highlightText function (lines 31-38) — it lacks HTML escaping (XSS)
  • Remove local timeLabel computed (lines 24-27) → use formatTime()
  • Use auto-imported highlightText from shared/lib/highlight-text.ts (with escaping)
  • Use auto-imported formatTime from shared/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 highlightText function (lines 16-24)
  • Remove local escapeHtml function (lines 26-32)
  • Remove local formatDate function (lines 34-41)
  • Use auto-imported highlightText from shared/lib
  • Use auto-imported formatDateTime from shared/lib (replaces formatDate)

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">&#123;&#123; formatDate(msg.created_at) }}</span>

Replace with:

html
<span class="text-xs text-gray-400">&#123;&#123; 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) with formatPrice()

Step 1: Replace price in header

Find (line 269):

html
<span v-else>&#123;&#123; conversation.product.title }} &middot; &#123;&#123; conversation.product.price.toLocaleString('ru-RU') }} &#8381;</span>

Replace with:

html
<span v-else>&#123;&#123; conversation.product.title }} &middot; &#123;&#123; formatPrice(conversation.product.price) }}</span>

Step 2: Replace price in product strip

Find (line 300):

html
<div class="text-sm font-semibold text-primary">&#123;&#123; conversation.product.price.toLocaleString('ru-RU') }} &#8381;</div>

Replace with:

html
<div class="text-sm font-semibold text-primary">&#123;&#123; 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:run

Expected: All tests pass (existing 22 + ~30 new = ~52 total).

Step 2: Run lint

bash
npm run lint:fix

Fix any issues.

Step 3: Run typecheck

bash
npm run typecheck

Expected: 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"