Skip to content

ProductCard: Chat Button + Location Display — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add a "Написать" chat button and city/district location line to ProductCard previews across the site.

Architecture: New useGeoLookupStore Pinia store caches city/district names by ID (fetched from existing geo endpoints). ProductCard receives seller_id and geo IDs via extended props, renders chat button (hidden on own listings) and location text. Each consumer page calls geoLookup.resolve(products) after loading data.

Tech Stack: Vue 3.5, Pinia, Nuxt 4, @nuxtjs/i18n, Vitest + @nuxt/test-utils


Task 1: Create useGeoLookupStore

Files:

  • Create: app/stores/geoLookup.ts
  • Test: app/stores/geoLookup.test.ts

Step 1: Write the failing test

ts
// app/stores/geoLookup.test.ts
import { registerEndpoint } from '@nuxt/test-utils/runtime'
import { describe, it, expect, beforeEach } from 'vitest'

const { useGeoLookupStore } = await import('./geoLookup')

describe('useGeoLookupStore', () => {
  beforeEach(() => {
    const store = useGeoLookupStore()
    store.$reset()
  })

  it('resolve fetches cities for region and populates cityMap', async () => {
    registerEndpoint('/api/store/geo/regions/1/cities', {
      method: 'GET',
      handler: () => ({
        data: [
          { id: 10, region_id: 1, name: 'Санкт-Петербург', slug: 'spb' },
          { id: 11, region_id: 1, name: 'Гатчина', slug: 'gatchina' },
        ],
      }),
    })

    const store = useGeoLookupStore()
    await store.resolve([{ region_id: 1, city_id: 10, district_id: null }])

    expect(store.getCityName(10)).toBe('Санкт-Петербург')
    expect(store.getCityName(11)).toBe('Гатчина')
    expect(store.getCityName(999)).toBeNull()
  })

  it('resolve fetches districts for city and populates districtMap', async () => {
    registerEndpoint('/api/store/geo/regions/1/cities', {
      method: 'GET',
      handler: () => ({
        data: [{ id: 10, region_id: 1, name: 'СПб', slug: 'spb' }],
      }),
    })
    registerEndpoint('/api/store/geo/cities/10/districts', {
      method: 'GET',
      handler: () => ({
        data: [
          { id: 100, city_id: 10, name: 'Василеостровский', slug: 'vasileostrovskiy' },
        ],
      }),
    })

    const store = useGeoLookupStore()
    await store.resolve([{ region_id: 1, city_id: 10, district_id: 100 }])

    expect(store.getDistrictName(100)).toBe('Василеостровский')
    expect(store.getDistrictName(999)).toBeNull()
  })

  it('does not re-fetch already resolved regions', async () => {
    let callCount = 0
    registerEndpoint('/api/store/geo/regions/1/cities', {
      method: 'GET',
      handler: () => {
        callCount++
        return { data: [{ id: 10, region_id: 1, name: 'СПб', slug: 'spb' }] }
      },
    })

    const store = useGeoLookupStore()
    await store.resolve([{ region_id: 1, city_id: 10, district_id: null }])
    await store.resolve([{ region_id: 1, city_id: 10, district_id: null }])

    expect(callCount).toBe(1)
  })

  it('skips products with null region_id/city_id', async () => {
    const store = useGeoLookupStore()
    await store.resolve([
      { region_id: null, city_id: null, district_id: null },
      { region_id: undefined, city_id: undefined, district_id: undefined },
    ])
    // No error, no API calls
    expect(store.getCityName(null)).toBeNull()
  })
})

Step 2: Run test to verify it fails

Run: npx vitest run app/stores/geoLookup.test.ts Expected: FAIL — ./geoLookup module not found

Step 3: Write minimal implementation

ts
// app/stores/geoLookup.ts
import { defineStore } from 'pinia'

import type { City, District } from '~/entities/geo/model/geo.schema'
import type { ApiListResponse } from '~/shared/api/types'

interface GeoProduct {
  region_id?: number | null
  city_id?: number | null
  district_id?: number | null
}

interface GeoLookupState {
  cityMap: Map<number, string>
  districtMap: Map<number, string>
  _fetchedRegions: Set<number>
  _fetchedCities: Set<number>
}

export const useGeoLookupStore = defineStore('geoLookup', {
  state: (): GeoLookupState => ({
    cityMap: new Map(),
    districtMap: new Map(),
    _fetchedRegions: new Set(),
    _fetchedCities: new Set(),
  }),
  getters: {
    getCityName: (state) => (id: number | null | undefined): string | null => {
      if (!id) return null
      return state.cityMap.get(id) ?? null
    },
    getDistrictName: (state) => (id: number | null | undefined): string | null => {
      if (!id) return null
      return state.districtMap.get(id) ?? null
    },
  },
  actions: {
    async resolve(products: GeoProduct[]) {
      const api = useApiClient()

      // Collect unique region IDs that haven't been fetched yet
      const regionIds = [
        ...new Set(
          products
            .map((p) => p.region_id)
            .filter((id): id is number => !!id && !this._fetchedRegions.has(id)),
        ),
      ]

      // Fetch cities for each new region
      await Promise.all(
        regionIds.map(async (regionId) => {
          try {
            const res = await api.get<ApiListResponse<City>>(
              `/store/geo/regions/${regionId}/cities`,
            )
            for (const city of res.data) {
              this.cityMap.set(city.id, city.name)
            }
          }
          catch {
            // Non-critical — location just won't display
          }
          this._fetchedRegions.add(regionId)
        }),
      )

      // Collect unique city IDs that have districts requested but not yet fetched
      const cityIds = [
        ...new Set(
          products
            .filter((p) => p.district_id && p.city_id && !this._fetchedCities.has(p.city_id))
            .map((p) => p.city_id as number),
        ),
      ]

      // Fetch districts for each new city
      await Promise.all(
        cityIds.map(async (cityId) => {
          try {
            const res = await api.get<ApiListResponse<District>>(
              `/store/geo/cities/${cityId}/districts`,
            )
            for (const district of res.data) {
              this.districtMap.set(district.id, district.name)
            }
          }
          catch {
            // Non-critical
          }
          this._fetchedCities.add(cityId)
        }),
      )
    },
  },
})

Note: Uses useApiClient() (shared layer, no auth redirect) since geo endpoints are public.

Step 4: Run test to verify it passes

Run: npx vitest run app/stores/geoLookup.test.ts Expected: PASS (all 4 tests)

Step 5: Commit

bash
git add app/stores/geoLookup.ts app/stores/geoLookup.test.ts
git commit -m "feat: add useGeoLookupStore for city/district name resolution"

Task 2: Add i18n key for card chat button

Files:

  • Modify: i18n/locales/ru.json

Step 1: Add the i18n key

In the existing "product" section (if it exists, find it; if not — create it), add "cardWrite" key. Alternatively, reuse "seller.writeMessage" which is already "Написать". Check if a "product" section exists first.

Search for "product" section in ru.json. If none, add inside "product" block near other product keys. The existing seller.writeMessage is "Написать" — we can reuse that key in ProductCard to avoid duplication.

Decision: Reuse seller.writeMessage ("Написать") — it's already the exact text needed. No i18n changes required for the chat button.

For location display, no new key needed — it's just city/district names from the store.

Step 2: Commit (skip if no changes)

No commit needed — no new keys required.


Task 3: Extend ProductCard with chat button and location line

Files:

  • Modify: app/entities/product/ui/ProductCard.vue

Step 1: Read current ProductCard (already read above — lines 1-68)

Step 2: Update ProductCard

Full updated component:

html
<script setup lang="ts">
import type { Conversation } from '~/entities/conversation/model/conversation.schema'
import type { Product, ProductImage } from '~/entities/product/model/product.schema'
import type { ApiItemResponse } from '~/shared/api/types'

import { NuxtLink } from '#components'

const props = defineProps<{
  product: Product & {
    images?: readonly ProductImage[]
    seller_id?: number
    city_id?: number | null
    region_id?: number | null
    district_id?: number | null
    primary_category_id?: number | null
  }
  showStatus?: boolean
  to?: string
}>()

const authStore = useAuthStore()
const geoLookup = useGeoLookupStore()
const { t } = useI18n()

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
})

const formattedPrice = computed(() => {
  return new Intl.NumberFormat('ru-RU').format(props.product.price) + ' \u20BD'
})

const link = computed(() => {
  if (props.to === '') return ''
  return props.to ?? `/product/${props.product.id}`
})

// --- Chat button ---
const isOwnProduct = computed(() => {
  if (!props.product.seller_id || !authStore.user) return true
  return props.product.seller_id === authStore.user.id
})

const chatLoading = ref(false)

async function openChat() {
  if (!authStore.isAuthenticated) {
    await navigateTo('/auth/login')
    return
  }
  chatLoading.value = true
  try {
    const api = useApi()
    const res = await api.post<ApiItemResponse<Conversation>>(
      '/vendor/conversations',
      { product_id: props.product.id },
    )
    await navigateTo(`/cabinet/messages/${res.data.id}`)
  }
  finally {
    chatLoading.value = false
  }
}

// --- Location ---
const locationLabel = computed(() => {
  const parts: string[] = []
  const city = geoLookup.getCityName(props.product.city_id)
  if (city) parts.push(city)
  const district = geoLookup.getDistrictName(props.product.district_id)
  if (district) parts.push(district)
  return parts.join(', ')
})
</script>

<template>
  <component
    :is="link ? NuxtLink : 'div'"
    v-bind="link ? { to: link } : {}"
    class="block overflow-hidden rounded-lg border border-gray-200 transition-shadow dark:border-gray-700"
    :class="link ? 'hover:shadow-md cursor-pointer' : 'opacity-80'"
  >
    <div class="relative aspect-[4/3] bg-gray-100 dark:bg-gray-800">
      <img
        v-if="imageUrl"
        :src="imageUrl"
        :alt="product.title"
        class="h-full w-full object-cover"
        loading="lazy"
      />
      <div
        v-else
        class="flex h-full w-full items-center justify-center text-gray-400"
      >
        <UIcon name="i-heroicons-photo" class="h-12 w-12" />
      </div>
      <ProductStatusBadge
        v-if="showStatus"
        :status="product.status"
        class="absolute left-2 top-2"
      />
      <FavoriteButton
        :product-id="product.id"
        class="absolute right-2 top-2 bg-white/80 dark:bg-gray-900/80 rounded-full backdrop-blur-sm"
      />
    </div>
    <div class="p-3">
      <h3 class="line-clamp-2 min-h-[2.5rem] text-lg font-medium">&#123;&#123; product.title }}</h3>
      <p class="mt-1 text-xl font-bold">&#123;&#123; formattedPrice }}</p>
      <button
        v-if="!isOwnProduct"
        type="button"
        class="mt-2 flex items-center gap-1 text-sm font-medium text-primary hover:underline"
        :disabled="chatLoading"
        @click.prevent.stop="openChat"
      >
        <UIcon name="i-lucide-message-circle" class="h-4 w-4" />
        &#123;&#123; t('seller.writeMessage') }}
      </button>
      <div
        v-if="locationLabel"
        class="mt-1 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
      >
        <UIcon name="i-lucide-map-pin" class="h-3.5 w-3.5 shrink-0" />
        <span class="truncate">&#123;&#123; locationLabel }}</span>
      </div>
    </div>
  </component>
</template>

Step 3: Run lint to verify

Run: npm run lint Expected: PASS (no ESLint errors)

Step 4: Commit

bash
git add app/entities/product/ui/ProductCard.vue
git commit -m "feat(ProductCard): add chat button and location display"

Task 4: Update consumer pages — extend types and call geoLookup.resolve()

Files:

  • Modify: app/pages/catalog/index.vue
  • Modify: app/pages/index.vue
  • Modify: app/pages/seller/[id].vue
  • Modify: app/pages/cabinet/favorites.vue
  • Modify: app/pages/cabinet/index.vue

Each page needs two changes:

  1. Extend the ProductWithImages type with seller_id, region_id, district_id
  2. Call geoLookup.resolve() after products are loaded

4a: app/pages/catalog/index.vue

Step 1: Extend type (line 69)

Change:

ts
type ProductWithImages = Product & { images?: ProductImage[]; city_id?: number }

To:

ts
type ProductWithImages = Product & {
  images?: ProductImage[]
  seller_id?: number
  city_id?: number | null
  region_id?: number | null
  district_id?: number | null
}

Step 2: Add geo resolve after load (after line 81)

After the useCursorPagination block, add a watcher:

ts
const geoLookup = useGeoLookupStore()

watch(items, (products) => {
  if (products.length) geoLookup.resolve(products)
}, { immediate: true })

4b: app/pages/index.vue

Step 1: Extend type (line 31)

Change:

ts
type ProductWithImages = Product & { images?: ProductImage[] }

To:

ts
type ProductWithImages = Product & {
  images?: ProductImage[]
  seller_id?: number
  city_id?: number | null
  region_id?: number | null
  district_id?: number | null
}

Step 2: Add geo resolve (after line 75)

After const listings = computed(...):

ts
const geoLookup = useGeoLookupStore()

watch(listings, (products) => {
  if (products.length) geoLookup.resolve(products)
}, { immediate: true })

4c: app/pages/seller/[id].vue

Step 1: Extend type (line 32)

Change:

ts
type ProductWithImages = Product & { images?: ProductImage[]; city_id?: number }

To:

ts
type ProductWithImages = Product & {
  images?: ProductImage[]
  seller_id?: number
  city_id?: number | null
  region_id?: number | null
  district_id?: number | null
}

Step 2: Replace broken city name resolution (lines 43-49)

Remove the broken geoStore.currentCity code and use geoLookup instead:

ts
const geoLookup = useGeoLookupStore()

watch(products, (items) => {
  if (items.length) geoLookup.resolve(items)
}, { immediate: true })

Also remove the standalone cityName ref (line 96) and replace its template usage (line 150) with geoLookup.getCityName(seller.city_id):

In template, change:

html
<div v-if="cityName" ...>
  ...
  <span>&#123;&#123; cityName }}</span>
</div>

To:

html
<div v-if="geoLookup.getCityName(seller.city_id)" ...>
  ...
  <span>&#123;&#123; geoLookup.getCityName(seller.city_id) }}</span>
</div>

And resolve seller's city on mount via geoLookup.resolve(): In onMounted, replace the broken geoStore logic with:

ts
onMounted(async () => {
  refresh()
  if (seller.value?.city_id) {
    // Resolve seller city name — need region_id which we don't have on SellerPublic.
    // Products from this seller will trigger geo resolve via the watcher above.
  }
})

Note: SellerPublic doesn't have region_id, so seller city name will only appear once products load (which provide region_id). This is acceptable — the broken code before didn't work either.

4d: app/pages/cabinet/favorites.vue

Step 1: Add geo resolve

After the onMounted fetchAll, resolve geo for loaded items:

ts
const geoLookup = useGeoLookupStore()

watch(() => favoritesStore.items, (products) => {
  if (products.length) geoLookup.resolve(products)
}, { immediate: true })

Note: favoritesStore.items returns Product[] — the favorite products from API may not include region_id/city_id/district_id. If they don't, the watcher is a no-op and no location shows. The chat button still works if seller_id is present.

4e: app/pages/cabinet/index.vue

No changes needed — these are own products. The chat button hides automatically (isOwnProduct is true because seller_id === authStore.user.id). Location will show if the data has geo fields.

Step 3: Run lint

Run: npm run lint:fix Expected: PASS

Step 4: Commit

bash
git add app/pages/catalog/index.vue app/pages/index.vue app/pages/seller/\[id\].vue app/pages/cabinet/favorites.vue
git commit -m "feat: wire geoLookup.resolve() into product list pages"

Task 5: Run full verification

Step 1: Run lint

Run: npm run lint Expected: PASS — no errors

Step 2: Run typecheck

Run: npm run typecheck Expected: PASS

Step 3: Run tests

Run: npm run test:run Expected: PASS — all tests including new geoLookup tests

Step 4: Run dev server and verify visually

Run: npm run dev Check:

  • /catalog — cards show location text (if products have city_id), chat button visible on non-own products
  • / (homepage) — same behavior on recent listings
  • /cabinet — own products: no chat button, location still shows
  • Click "Написать" on card — navigates to chat or login if not authenticated
  • Click.prevent.stop works — clicking chat button doesn't navigate to product page

Step 5: Final commit if any fixes needed