Appearance
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(addsellersection afterproductDetailblock, 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 foruseAsyncData, error handling, SEO,memberSinceDatecomputedapp/pages/catalog/index.vue— pattern foruseCursorPagination, grid, loadMore buttonapp/entities/product/model/product.schema.ts:50-59—sellerPublicSchema/SellerPublictypeapp/entities/product/ui/ProductCard.vue— the card component to reuseapp/shared/api/types.ts—ApiItemResponse,ApiListResponseapp/shared/api/useCursorPagination.ts— pagination composable APIapp/stores/geo.ts— geo store (no city name lookup available — need to fetch)app/entities/geo/model/geo.schema.ts—Citytypeapp/composables/useApi.ts—useApi()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">{{ t('seller.notFound') }}</h1>
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t('seller.notFoundDescription') }}</p>
<UButton to="/catalog">{{ 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">{{ 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">{{ seller.rating }}</span>
<button class="hover:underline" @click="activeTab = 1">
({{ seller.reviews_count }} {{ t('seller.reviewsCount').toLowerCase() }})
</button>
</template>
<template v-else>
<UIcon name="i-heroicons-star" class="w-4 h-4" />
<span>{{ 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>{{ 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>{{ 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">
{{ 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"
>
{{ 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">{{ seller.products_count }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ 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" />
{{ seller.rating }}
</template>
<template v-else>—</template>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ t('seller.rating') }}</div>
</div>
<div class="flex-1 text-center">
<div class="text-2xl font-bold">{{ seller.reviews_count }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ 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">{{ 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">
{{ 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">{{ t('seller.reviewsComingSoon') }}</p>
</div>
</template>
</UTabs>
</div>
</template>Key implementation notes:
Types: Reuse
SellerPublicfromproduct.schema.ts:50-59— it already matches the API response (id,display_name,avatar_url,city_id,rating,reviews_count,products_count,created_at).ratingfield is a string in the schema (seeproduct.schema.ts:55), so useNumber(seller.rating)for comparisons.City resolution: Check
useGeoStorecache 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.Products grid: Uses
grid-catalogCSS utility (defined inapp/assets/css/main.css:23) — same as catalog and homepage.UTabsAPI: Check the actual Nuxt UI v3UTabscomponent API. The tabs component may useitemsarray with{ label }objects. The template slot receivesitemandindex. Verify the slot API matches — if not, adjust to match Nuxt UI v3 conventions used elsewhere in the project.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.vueapp/pages/cabinet/index.vueapp/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"