Skip to content

Seller Profile Page — Implementation Plan

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

Goal: Implement the seller public profile page showing seller info, stats, and paginated product listings.

Architecture: Single page component (app/pages/seller/[id].vue) using useAsyncData for seller profile and useCursorPagination for products. Reuses existing ProductCard entity and useCursorPagination composable. City name resolved via geo API fetch.

Tech Stack: Nuxt 4, Vue 3.5, Nuxt UI v3, i18n, Zod (existing sellerPublicSchema), useCursorPagination

Design doc: docs/plans/2026-02-10-seller-page-design.md


Task 1: Add i18n keys for seller page

Files:

  • Modify: i18n/locales/ru.json (add seller section after productDetail block, line ~195)

Step 1: Add seller i18n keys

Insert a new "seller" section in ru.json after the "productDetail" closing brace (line 195). Add these keys:

json
"seller": {
  "products": "Объявления",
  "reviews": "Отзывы",
  "listingsCount": "Объявлений",
  "rating": "Рейтинг",
  "reviewsCount": "Отзывов",
  "noRating": "Нет оценок",
  "memberSince": "На сайте с {date}",
  "noProducts": "У продавца пока нет активных объявлений",
  "reviewsComingSoon": "Отзывы скоро будут доступны",
  "writeMessage": "Написать",
  "writeMessageMobile": "Написать продавцу",
  "messagingComingSoon": "Скоро будет доступно",
  "messagingDescription": "Система сообщений в разработке",
  "notFound": "Продавец не найден",
  "notFoundDescription": "Возможно, профиль был удалён"
},

Step 2: Verify JSON is valid

Run: node -e "JSON.parse(require('fs').readFileSync('i18n/locales/ru.json', 'utf8')); console.log('OK')" Expected: OK

Step 3: Commit

bash
git add i18n/locales/ru.json
git commit -m "feat(i18n): add seller profile page translations"

Task 2: Implement seller profile page

Files:

  • Modify: app/pages/seller/[id].vue (replace stub — currently 11 lines)

Reference files (read before implementing):

  • app/pages/product/[id].vue — pattern for useAsyncData, error handling, SEO, memberSinceDate computed
  • app/pages/catalog/index.vue — pattern for useCursorPagination, grid, loadMore button
  • app/entities/product/model/product.schema.ts:50-59sellerPublicSchema / SellerPublic type
  • app/entities/product/ui/ProductCard.vue — the card component to reuse
  • app/shared/api/types.tsApiItemResponse, ApiListResponse
  • app/shared/api/useCursorPagination.ts — pagination composable API
  • app/stores/geo.ts — geo store (no city name lookup available — need to fetch)
  • app/entities/geo/model/geo.schema.tsCity type
  • app/composables/useApi.tsuseApi() usage

Step 1: Implement the full page

Replace app/pages/seller/[id].vue with:

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

definePageMeta({ layout: 'default' })

const route = useRoute()
const sellerId = Number(route.params.id)
const { t } = useI18n()
const api = useApi()
const toast = useToast()

// Fetch seller profile
const { data: seller, error } = await useAsyncData(
  `seller-${sellerId}`,
  () => api.get<ApiItemResponse<SellerPublic>>(`/store/sellers/${sellerId}`).then(r => r.data),
)

if (error.value) {
  showError({ statusCode: 404, statusMessage: t('seller.notFound') })
}

// SEO
if (seller.value) {
  useSeoMeta({
    title: seller.value.display_name,
    description: t('seller.products') + ' — ' + seller.value.display_name,
  })
}

// Resolve city name from city_id
const cityName = ref<string | null>(null)
if (import.meta.client && seller.value?.city_id) {
  const geoStore = useGeoStore()
  // Try geo store cache first
  if (geoStore.currentCity?.id === seller.value.city_id) {
    cityName.value = geoStore.currentCity.name
  } else {
    // Fetch regions → cities to resolve name
    try {
      const regions = await api.get<ApiListResponse<{ id: number }>>('/store/geo/regions')
      for (const region of regions.data) {
        const cities = await api.get<ApiListResponse<City>>(
          `/store/geo/regions/${region.id}/cities`,
        )
        const found = cities.data.find(c => c.id === seller.value!.city_id)
        if (found) {
          cityName.value = found.name
          break
        }
      }
    } catch {
      // Silently skip — city name is optional
    }
  }
}

// Member since date
const memberSinceDate = computed(() => {
  if (!seller.value?.created_at) return ''
  return new Date(seller.value.created_at).toLocaleDateString('ru-RU', {
    month: 'long',
    year: 'numeric',
  })
})

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

const { items: products, hasMore, isLoading, refresh, loadMore } = useCursorPagination<ProductWithImages>(
  (params) => api.get<ApiListResponse<ProductWithImages>>(
    `/store/sellers/${sellerId}/products`,
    params,
  ),
  {},
  { limit: 20 },
)

onMounted(() => refresh())

// Tabs
const activeTab = ref(0)

function handleWriteMessage() {
  toast.add({
    title: t('seller.messagingComingSoon'),
    description: t('seller.messagingDescription'),
    color: 'info',
  })
}
</script>

<template>
  <div v-if="error" class="container-wide py-12 text-center">
    <UIcon name="i-heroicons-user-circle" class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
    <h1 class="text-2xl font-bold mb-2">&#123;&#123; t('seller.notFound') }}</h1>
    <p class="text-gray-500 dark:text-gray-400 mb-4">&#123;&#123; t('seller.notFoundDescription') }}</p>
    <UButton to="/catalog">&#123;&#123; t('catalog.filters') }}</UButton>
  </div>

  <div v-else-if="seller" class="container-wide py-6">
    <!-- Profile Header -->
    <div class="flex flex-col sm:flex-row gap-5 mb-6">
      <UAvatar
        :src="seller.avatar_url ?? undefined"
        :alt="seller.display_name"
        size="3xl"
        class="shrink-0"
      />
      <div class="flex-1 min-w-0">
        <h1 class="text-xl font-bold">&#123;&#123; seller.display_name }}</h1>

        <!-- Rating -->
        <div class="flex items-center gap-1.5 mt-1 text-sm text-gray-500 dark:text-gray-400">
          <template v-if="Number(seller.rating) > 0">
            <UIcon name="i-heroicons-star-solid" class="w-4 h-4 text-yellow-400" />
            <span class="font-medium text-gray-900 dark:text-white">&#123;&#123; seller.rating }}</span>
            <button class="hover:underline" @click="activeTab = 1">
              (&#123;&#123; seller.reviews_count }} &#123;&#123; t('seller.reviewsCount').toLowerCase() }})
            </button>
          </template>
          <template v-else>
            <UIcon name="i-heroicons-star" class="w-4 h-4" />
            <span>&#123;&#123; t('seller.noRating') }}</span>
          </template>
        </div>

        <!-- Location -->
        <div v-if="cityName" class="flex items-center gap-1.5 mt-1 text-sm text-gray-500 dark:text-gray-400">
          <UIcon name="i-heroicons-map-pin" class="w-4 h-4" />
          <span>&#123;&#123; cityName }}</span>
        </div>

        <!-- Member since -->
        <div class="flex items-center gap-1.5 mt-1 text-sm text-gray-500 dark:text-gray-400">
          <UIcon name="i-heroicons-calendar" class="w-4 h-4" />
          <span>&#123;&#123; t('seller.memberSince', { date: memberSinceDate }) }}</span>
        </div>
      </div>

      <!-- Write button (desktop) -->
      <div class="hidden sm:flex items-start">
        <UButton variant="outline" icon="i-heroicons-envelope" @click="handleWriteMessage">
          &#123;&#123; t('seller.writeMessage') }}
        </UButton>
      </div>
    </div>

    <!-- Write button (mobile) -->
    <UButton
      class="w-full sm:hidden mb-6"
      variant="outline"
      icon="i-heroicons-envelope"
      @click="handleWriteMessage"
    >
      &#123;&#123; t('seller.writeMessageMobile') }}
    </UButton>

    <!-- Stats -->
    <div class="flex gap-4 py-4 border-y border-gray-200 dark:border-gray-700 mb-6">
      <div class="flex-1 text-center">
        <div class="text-2xl font-bold">&#123;&#123; seller.products_count }}</div>
        <div class="text-sm text-gray-500 dark:text-gray-400">&#123;&#123; t('seller.listingsCount') }}</div>
      </div>
      <div class="flex-1 text-center">
        <div class="text-2xl font-bold flex items-center justify-center gap-1">
          <template v-if="Number(seller.rating) > 0">
            <UIcon name="i-heroicons-star-solid" class="w-5 h-5 text-yellow-400" />
            &#123;&#123; seller.rating }}
          </template>
          <template v-else>—</template>
        </div>
        <div class="text-sm text-gray-500 dark:text-gray-400">&#123;&#123; t('seller.rating') }}</div>
      </div>
      <div class="flex-1 text-center">
        <div class="text-2xl font-bold">&#123;&#123; seller.reviews_count }}</div>
        <div class="text-sm text-gray-500 dark:text-gray-400">&#123;&#123; t('seller.reviewsCount') }}</div>
      </div>
    </div>

    <!-- Tabs -->
    <UTabs
      :model-value="activeTab"
      :items="[
        { label: `${t('seller.products')} (${seller.products_count})` },
        { label: `${t('seller.reviews')} (${seller.reviews_count})` },
      ]"
      @update:model-value="activeTab = Number($event)"
    >
      <template #default="{ item, index }">
        <!-- Listings tab -->
        <div v-if="index === 0" class="pt-6">
          <div v-if="!isLoading && products.length === 0" class="text-center py-12">
            <UIcon name="i-heroicons-inbox" class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
            <p class="text-gray-500 dark:text-gray-400">&#123;&#123; t('seller.noProducts') }}</p>
          </div>

          <div class="grid-catalog">
            <ProductCard v-for="product in products" :key="product.id" :product="product" />
          </div>

          <div v-if="hasMore" class="text-center mt-6">
            <UButton variant="outline" :loading="isLoading" @click="loadMore">
              &#123;&#123; t('common.loadMore') }}
            </UButton>
          </div>

          <div v-if="isLoading && products.length === 0" class="text-center py-12">
            <UIcon name="i-heroicons-arrow-path" class="animate-spin w-8 h-8 text-gray-400" />
          </div>
        </div>

        <!-- Reviews tab -->
        <div v-else-if="index === 1" class="text-center py-12">
          <UIcon name="i-heroicons-chat-bubble-left-right" class="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
          <p class="text-gray-500 dark:text-gray-400">&#123;&#123; t('seller.reviewsComingSoon') }}</p>
        </div>
      </template>
    </UTabs>
  </div>
</template>

Key implementation notes:

  1. Types: Reuse SellerPublic from product.schema.ts:50-59 — it already matches the API response (id, display_name, avatar_url, city_id, rating, reviews_count, products_count, created_at).

  2. rating field is a string in the schema (see product.schema.ts:55), so use Number(seller.rating) for comparisons.

  3. City resolution: Check useGeoStore cache first. If miss, iterate regions → cities via API. This runs client-side only (import.meta.client). If resolution fails, the location line is simply hidden.

  4. Products grid: Uses grid-catalog CSS utility (defined in app/assets/css/main.css:23) — same as catalog and homepage.

  5. UTabs API: Check the actual Nuxt UI v3 UTabs component API. The tabs component may use items array with { label } objects. The template slot receives item and index. Verify the slot API matches — if not, adjust to match Nuxt UI v3 conventions used elsewhere in the project.

  6. Toast: Use useToast() from Nuxt UI — same pattern as used in settings pages.

Step 2: Verify the page loads in dev

Run: npm run dev Navigate to: http://localhost:3000/seller/17 Expected: Seller profile page renders with data from dev API.

Step 3: Run typecheck

Run: npx nuxi typecheck Expected: No TypeScript errors.

Step 4: Run lint

Run: npm run lint Expected: No ESLint errors in app/pages/seller/[id].vue.

Step 5: Commit

bash
git add app/pages/seller/[id].vue
git commit -m "feat(seller): implement seller profile page with stats, products, tabs"

Task 3: Verify UTabs API and fix if needed

Context: Nuxt UI v3 UTabs component API may differ from what's written in Task 2. This task is a safety net.

Step 1: Check UTabs usage in existing code

Search for UTabs in the codebase to see how it's used:

  • app/features/cabinet-settings/ui/SettingsTabs.vue
  • app/pages/cabinet/index.vue
  • app/pages/admin/products/index.vue

Read these files and verify the slot API (#default, #content, or #item), items prop shape, and v-model vs model-value.

Step 2: Adjust seller page if needed

If the UTabs API differs from Task 2's implementation, update app/pages/seller/[id].vue accordingly.

Step 3: Run typecheck + lint

Run: npx nuxi typecheck && npm run lint Expected: No errors.

Step 4: Commit if changes were needed

bash
git add app/pages/seller/[id].vue
git commit -m "fix(seller): adjust UTabs API usage"

Task 4: Visual QA and edge cases

Step 1: Test with real seller data

Navigate to http://localhost:3000/seller/17 and verify:

  • [ ] Avatar displays correctly
  • [ ] Name, rating, member since date display
  • [ ] City name resolves (or hides if null)
  • [ ] Stats cards show correct numbers
  • [ ] Products tab shows products in grid
  • [ ] "Показать ещё" works if > 20 products
  • [ ] "Написать" button shows toast
  • [ ] Reviews tab shows placeholder
  • [ ] Clicking rating text switches to Reviews tab

Step 2: Test error state

Navigate to http://localhost:3000/seller/999999 (non-existent seller). Expected: 404 error page with "Продавец не найден".

Step 3: Test responsive design

Resize browser to mobile width (~375px). Verify:

  • [ ] Avatar and info stack vertically
  • [ ] Full-width "Написать продавцу" button appears
  • [ ] Desktop write button is hidden
  • [ ] Products grid is 2 columns
  • [ ] Stats cards compact but readable

Step 4: Test dark mode

Toggle to dark mode. Verify no broken colors, borders visible, text readable.

Step 5: Final commit if any fixes were needed

bash
git add -A
git commit -m "fix(seller): visual QA fixes"