Appearance
Admin Moderation Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add admin moderation panel for reviewing and approving/rejecting product listings.
Architecture: Embedded admin section in the existing Nuxt app using a dedicated layout, middleware, and FSD feature slice. Reuses existing API client, cursor pagination, and product schemas. Polling via VueUse useIntervalFn for auto-refreshing pending listings.
Tech Stack: Nuxt 4.3, Vue 3.5, Nuxt UI v3 (UTable, UTabs, UBadge, UModal), Pinia, VueUse, Vitest
Task 1: Admin middleware
Files:
- Create:
app/middleware/admin.ts - Test:
app/middleware/admin.test.ts
Step 1: Write the failing test
ts
// app/middleware/admin.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockNavigateTo = vi.fn()
const mockFetchUser = vi.fn()
let mockUser: { is_admin: boolean } | null = null
vi.mock('#imports', () => ({
defineNuxtRouteMiddleware: (fn: Function) => fn,
navigateTo: (...args: unknown[]) => mockNavigateTo(...args),
}))
vi.mock('~/stores/auth', () => ({
useAuthStore: () => ({
get isAuthenticated() { return mockUser !== null },
get isAdmin() { return mockUser?.is_admin ?? false },
fetchUser: mockFetchUser,
}),
}))
// Must import AFTER mocks
const middleware = await import('./admin').then(m => m.default)
describe('admin middleware', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUser = null
})
it('redirects to / when user is not authenticated', async () => {
await middleware()
expect(mockNavigateTo).toHaveBeenCalledWith('/')
})
it('redirects to / when user is authenticated but not admin', async () => {
mockUser = { is_admin: false }
await middleware()
expect(mockNavigateTo).toHaveBeenCalledWith('/')
})
it('allows access when user is admin', async () => {
mockUser = { is_admin: true }
const result = await middleware()
expect(mockNavigateTo).not.toHaveBeenCalled()
expect(result).toBeUndefined()
})
it('fetches user if not authenticated before checking', async () => {
mockFetchUser.mockImplementation(() => {
mockUser = { is_admin: true }
})
await middleware()
expect(mockFetchUser).toHaveBeenCalled()
expect(mockNavigateTo).not.toHaveBeenCalled()
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/middleware/admin.test.ts Expected: FAIL — module ./admin not found
Step 3: Write minimal implementation
ts
// app/middleware/admin.ts
export default defineNuxtRouteMiddleware(async () => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
await authStore.fetchUser()
}
if (!authStore.isAuthenticated || !authStore.isAdmin) {
return navigateTo('/')
}
})Step 4: Run test to verify it passes
Run: npx vitest run app/middleware/admin.test.ts Expected: PASS
Step 5: Commit
bash
git add app/middleware/admin.ts app/middleware/admin.test.ts
git commit -m "feat(admin): add admin route middleware"Task 2: i18n — admin translations
Files:
- Modify:
i18n/locales/ru.json
Step 1: No test needed (static data)
Step 2: Add admin translations
Add the following admin key to i18n/locales/ru.json after the "geo" block:
json
"admin": {
"title": "Админ-панель",
"products": "Объявления",
"allProducts": "Все",
"pending": "На модерации",
"active": "Активные",
"draft": "Черновики",
"rejected": "Отклонённые",
"archived": "В архиве",
"sold": "Проданные",
"approve": "Одобрить",
"reject": "Отклонить",
"rejectReason": "Причина отклонения",
"rejectReasonPlaceholder": "Укажите причину отклонения объявления...",
"rejectConfirm": "Отклонить объявление",
"approveConfirm": "Вы уверены, что хотите одобрить это объявление?",
"approveSuccess": "Объявление одобрено",
"rejectSuccess": "Объявление отклонено",
"lastUpdated": "Обновлено {seconds}с назад",
"noProducts": "Нет объявлений",
"seller": "Продавец",
"date": "Дата",
"category": "Категория",
"backToList": "К списку",
"sellerInfo": "Информация о продавце",
"memberSince": "На сайте с {date}",
"productsCount": "Объявлений: {count}",
"sellerEmail": "Email"
}Step 3: Commit
bash
git add i18n/locales/ru.json
git commit -m "feat(admin): add Russian translations for admin panel"Task 3: Admin layout
Files:
- Create:
app/layouts/admin.vue
Step 1: No unit test (layout is UI-only, tested via integration)
Step 2: Create admin layout
html
<!-- app/layouts/admin.vue -->
<script setup lang="ts">
const { t } = useI18n()
const authStore = useAuthStore()
const mobileMenuOpen = ref(false)
const navItems = computed(() => [
{ label: t('admin.products'), to: '/admin/products', icon: 'i-lucide-package' },
])
async function logout() {
mobileMenuOpen.value = false
await authStore.logout()
await navigateTo('/auth/login')
}
</script>
<template>
<div class="min-h-screen flex flex-col">
<header class="border-b border-gray-200 dark:border-gray-800">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<NuxtLink to="/">
<SharedAppLogo />
</NuxtLink>
<UBadge color="warning" variant="subtle" size="sm">
{{ t('admin.title') }}
</UBadge>
</div>
<div class="flex items-center gap-2">
<UColorModeButton />
<UButton
:label="t('common.logout')"
variant="ghost"
icon="i-lucide-log-out"
class="hidden md:flex"
@click="logout"
/>
<UButton
icon="i-lucide-menu"
variant="ghost"
class="md:hidden"
@click="mobileMenuOpen = true"
/>
</div>
</div>
</header>
<USlideover v-model:open="mobileMenuOpen" side="left" class="md:hidden">
<template #body>
<nav class="flex flex-col gap-1 p-4">
<UButton
v-for="item in navItems"
:key="item.to"
:label="item.label"
:to="item.to"
:icon="item.icon"
variant="ghost"
block
class="justify-start"
@click="mobileMenuOpen = false"
/>
<USeparator class="my-2" />
<UButton
:label="t('common.logout')"
variant="ghost"
block
class="justify-start"
icon="i-lucide-log-out"
@click="logout"
/>
</nav>
</template>
</USlideover>
<div class="flex-1 container mx-auto px-4 py-6 flex gap-6">
<aside class="hidden md:block w-56 shrink-0">
<nav class="flex flex-col gap-1">
<UButton
v-for="item in navItems"
:key="item.to"
:label="item.label"
:to="item.to"
:icon="item.icon"
variant="ghost"
block
class="justify-start"
/>
</nav>
</aside>
<div class="flex-1 min-w-0">
<slot />
</div>
</div>
</div>
</template>Step 3: Commit
bash
git add app/layouts/admin.vue
git commit -m "feat(admin): add admin layout with sidebar navigation"Task 4: Admin route rules + navigation link
Files:
- Modify:
nuxt.config.ts— add/admin/**SSR rule - Modify:
app/layouts/default.vue— add admin link for admin users
Step 1: Add route rule for admin (SPA mode like cabinet)
In nuxt.config.ts, inside routeRules, add:
ts
'/admin/**': { ssr: false },Also add to robots.disallow:
ts
disallow: ['/cabinet/', '/auth', '/admin/'],Step 2: Add admin link in default layout header
In app/layouts/default.vue, in the desktop nav (after the cabinet button, before logout), add:
html
<UButton
v-if="authStore.isAdmin"
:label="t('admin.title')"
to="/admin"
variant="ghost"
icon="i-lucide-shield"
/>In the mobile menu (after settings, before separator), add:
html
<UButton
v-if="authStore.isAdmin"
:label="t('admin.title')"
to="/admin"
variant="ghost"
block
class="justify-start"
icon="i-lucide-shield"
@click="mobileMenuOpen = false"
/>Step 3: Commit
bash
git add nuxt.config.ts app/layouts/default.vue
git commit -m "feat(admin): add admin route rules and navigation link"Task 5: useAdminProducts composable
Files:
- Create:
app/features/admin-moderation/composables/useAdminProducts.ts - Test:
app/features/admin-moderation/composables/useAdminProducts.test.ts
Step 1: Write the failing test
ts
// app/features/admin-moderation/composables/useAdminProducts.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
const mockGet = vi.fn()
const mockPut = vi.fn()
vi.mock('~/composables/useApi', () => ({
useApi: () => ({
get: mockGet,
put: mockPut,
}),
}))
vi.mock('~/shared/api/useCursorPagination', () => ({
useCursorPagination: vi.fn((fetcher: Function) => {
const items = ref([])
const hasMore = ref(false)
const isLoading = ref(false)
const error = ref(null)
return {
items,
hasMore,
isLoading,
error,
refresh: vi.fn(() => fetcher({ limit: 20 })),
loadMore: vi.fn(),
}
}),
}))
const { useAdminProducts } = await import('./useAdminProducts')
describe('useAdminProducts', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: [], meta: { has_more: false, next_cursor: null } })
})
it('approves a product', async () => {
mockPut.mockResolvedValue({ success: true })
const { approveProduct } = useAdminProducts()
await approveProduct(42)
expect(mockPut).toHaveBeenCalledWith('/admin/products/42/approve')
})
it('rejects a product with reason', async () => {
mockPut.mockResolvedValue({ success: true })
const { rejectProduct } = useAdminProducts()
await rejectProduct(42, 'Bad quality photos')
expect(mockPut).toHaveBeenCalledWith('/admin/products/42/reject', { reason: 'Bad quality photos' })
})
})Step 2: Run test to verify it fails
Run: npx vitest run app/features/admin-moderation/composables/useAdminProducts.test.ts Expected: FAIL — module not found
Step 3: Write implementation
ts
// app/features/admin-moderation/composables/useAdminProducts.ts
import { useIntervalFn, useTimestamp } from '@vueuse/core'
import isEqual from 'fast-deep-equal'
import type { ProductDetail } from '~/entities/product/model/product.schema'
import type { ApiItemResponse, ApiListResponse } from '~/shared/api/types'
const POLL_INTERVAL = 30_000
export function useAdminProducts() {
const api = useApi()
const statusFilter = ref<string | null>('pending')
const { items, hasMore, isLoading, error, refresh, loadMore } = useCursorPagination<ProductDetail>(
(params) => {
const query: Record<string, unknown> = { ...params }
if (statusFilter.value) query.status = statusFilter.value
return api.get<ApiListResponse<ProductDetail>>('/admin/products', query)
},
{},
{ limit: 20 },
)
// Polling for pending tab
const lastRefreshedAt = ref(Date.now())
const now = useTimestamp({ interval: 1000 })
const secondsSinceRefresh = computed(() =>
Math.floor((now.value - lastRefreshedAt.value) / 1000),
)
let previousData: string | null = null
async function pollRefresh() {
if (statusFilter.value !== 'pending' || isLoading.value) return
const response = await api.get<ApiListResponse<ProductDetail>>('/admin/products', {
status: 'pending',
limit: 20,
})
const newData = JSON.stringify(response.data)
if (previousData !== newData) {
previousData = newData
refresh()
}
lastRefreshedAt.value = Date.now()
}
const { pause, resume } = useIntervalFn(pollRefresh, POLL_INTERVAL, { immediate: false })
watch(statusFilter, (newStatus) => {
previousData = null
if (newStatus === 'pending') {
resume()
} else {
pause()
}
refresh()
})
function startPolling() {
if (statusFilter.value === 'pending') {
resume()
}
}
function stopPolling() {
pause()
}
// Actions
async function approveProduct(id: number) {
await api.put(`/admin/products/${id}/approve`)
refresh()
}
async function rejectProduct(id: number, reason: string) {
await api.put(`/admin/products/${id}/reject`, { reason })
refresh()
}
async function fetchProduct(id: number) {
const response = await api.get<ApiItemResponse<ProductDetail>>(`/admin/products/${id}`)
return response.data
}
return {
items,
hasMore,
isLoading,
error,
statusFilter,
secondsSinceRefresh,
refresh,
loadMore,
startPolling,
stopPolling,
approveProduct,
rejectProduct,
fetchProduct,
}
}Step 4: Run test to verify it passes
Run: npx vitest run app/features/admin-moderation/composables/useAdminProducts.test.ts Expected: PASS
Step 5: Commit
bash
git add app/features/admin-moderation/composables/useAdminProducts.ts app/features/admin-moderation/composables/useAdminProducts.test.ts
git commit -m "feat(admin): add useAdminProducts composable with polling"Task 6: ModerationActions component
Files:
- Create:
app/features/admin-moderation/ui/ModerationActions.vue
Step 1: No unit test (UI component, will be tested via integration with the page)
Step 2: Create component
html
<!-- app/features/admin-moderation/ui/ModerationActions.vue -->
<script setup lang="ts">
const props = defineProps<{
productId: number
status: string
loading?: boolean
}>()
const emit = defineEmits<{
approve: [id: number]
reject: [id: number, reason: string]
}>()
const { t } = useI18n()
const rejectModalOpen = ref(false)
const rejectReason = ref('')
const approving = ref(false)
const rejecting = ref(false)
async function handleApprove() {
approving.value = true
emit('approve', props.productId)
}
function openRejectModal() {
rejectReason.value = ''
rejectModalOpen.value = true
}
function handleReject() {
if (!rejectReason.value.trim()) return
rejecting.value = true
emit('reject', props.productId, rejectReason.value.trim())
}
</script>
<template>
<div v-if="status === 'pending'" class="flex gap-3">
<UButton
color="primary"
icon="i-lucide-check"
:label="t('admin.approve')"
:loading="approving"
:disabled="loading"
@click="handleApprove"
/>
<UButton
color="error"
variant="outline"
icon="i-lucide-x"
:label="t('admin.reject')"
:disabled="loading"
@click="openRejectModal"
/>
<UModal v-model:open="rejectModalOpen">
<template #header>
<h3 class="text-lg font-semibold">{{ t('admin.rejectConfirm') }}</h3>
</template>
<template #body>
<UTextarea
v-model="rejectReason"
:placeholder="t('admin.rejectReasonPlaceholder')"
:rows="4"
autofocus
/>
</template>
<template #footer>
<div class="flex justify-end gap-3">
<UButton
:label="t('common.cancel')"
variant="ghost"
@click="rejectModalOpen = false"
/>
<UButton
color="error"
:label="t('admin.rejectConfirm')"
:loading="rejecting"
:disabled="!rejectReason.trim()"
@click="handleReject"
/>
</div>
</template>
</UModal>
</div>
</template>Step 3: Commit
bash
git add app/features/admin-moderation/ui/ModerationActions.vue
git commit -m "feat(admin): add ModerationActions component with reject modal"Task 7: Admin index page (redirect)
Files:
- Create:
app/pages/admin/index.vue
Step 1: No test needed (simple redirect)
Step 2: Create page
html
<!-- app/pages/admin/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: 'admin' })
await navigateTo('/admin/products', { redirectCode: 301, replace: true })
</script>
<template>
<div />
</template>Step 3: Commit
bash
git add app/pages/admin/index.vue
git commit -m "feat(admin): add admin index page with redirect to products"Task 8: Admin products list page
Files:
- Create:
app/pages/admin/products/index.vue
Step 1: No unit test (page component — tested via manual/E2E)
Step 2: Create page
html
<!-- app/pages/admin/products/index.vue -->
<script setup lang="ts">
definePageMeta({ layout: 'admin', middleware: 'admin' })
useSeoMeta({ title: 'Модерация объявлений', robots: 'noindex, nofollow' })
const { t } = useI18n()
const router = useRouter()
const {
items,
hasMore,
isLoading,
statusFilter,
secondsSinceRefresh,
refresh,
loadMore,
startPolling,
stopPolling,
} = useAdminProducts()
const statusTabs = [
{ label: t('admin.pending'), value: 'pending' },
{ label: t('admin.allProducts'), value: null },
{ label: t('admin.active'), value: 'active' },
{ label: t('admin.draft'), value: 'draft' },
{ label: t('admin.rejected'), value: 'rejected' },
{ label: t('admin.archived'), value: 'archived' },
{ label: t('admin.sold'), value: 'sold' },
]
const formattedPrice = (price: number) =>
new Intl.NumberFormat('ru-RU').format(price) + ' \u20BD'
const formattedDate = (date: string) =>
new Date(date).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
function goToProduct(id: number) {
router.push(`/admin/products/${id}`)
}
onMounted(() => {
refresh()
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{{ t('admin.products') }}</h1>
<span
v-if="statusFilter === 'pending'"
class="text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.lastUpdated', { seconds: secondsSinceRefresh }) }}
</span>
</div>
<!-- Status tabs -->
<div class="flex gap-2 mb-6 flex-wrap">
<UButton
v-for="tab in statusTabs"
:key="tab.value ?? 'all'"
:variant="statusFilter === tab.value ? 'solid' : 'ghost'"
size="sm"
@click="statusFilter = tab.value"
>
{{ tab.label }}
</UButton>
</div>
<!-- Empty state -->
<div v-if="!isLoading && items.length === 0" class="text-center py-12">
<p class="text-gray-500 dark:text-gray-400">{{ t('admin.noProducts') }}</p>
</div>
<!-- Products table (desktop) -->
<div class="hidden md:block">
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('listing.title') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.seller') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('product.price') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.date') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('product.condition') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="product in items"
:key="product.id"
class="hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
@click="goToProduct(product.id)"
>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded bg-gray-100 dark:bg-gray-800 shrink-0 overflow-hidden">
<img
v-if="product.images?.[0]"
:src="product.images[0].thumbnail_webp ?? product.images[0].thumbnail_jpeg ?? ''"
class="w-full h-full object-cover"
/>
</div>
<span class="text-sm font-medium line-clamp-1">{{ product.title }}</span>
</div>
</td>
<td class="px-4 py-3 text-sm">{{ product.seller?.display_name ?? '—' }}</td>
<td class="px-4 py-3 text-sm font-medium">{{ formattedPrice(product.price) }}</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">{{ formattedDate(product.created_at) }}</td>
<td class="px-4 py-3">
<ProductStatusBadge :status="product.status" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Products cards (mobile) -->
<div class="md:hidden grid grid-cols-1 gap-3">
<div
v-for="product in items"
:key="product.id"
class="border border-gray-200 dark:border-gray-700 rounded-lg p-3 cursor-pointer"
@click="goToProduct(product.id)"
>
<div class="flex items-center gap-3">
<div class="w-14 h-14 rounded bg-gray-100 dark:bg-gray-800 shrink-0 overflow-hidden">
<img
v-if="product.images?.[0]"
:src="product.images[0].thumbnail_webp ?? product.images[0].thumbnail_jpeg ?? ''"
class="w-full h-full object-cover"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium line-clamp-1">{{ product.title }}</span>
<ProductStatusBadge :status="product.status" />
</div>
<div class="text-sm font-bold mt-0.5">{{ formattedPrice(product.price) }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ product.seller?.display_name ?? '—' }} · {{ formattedDate(product.created_at) }}
</div>
</div>
</div>
</div>
</div>
<!-- Load more -->
<div v-if="hasMore" class="text-center mt-6">
<UButton variant="outline" :loading="isLoading" @click="loadMore">
{{ t('common.loadMore') }}
</UButton>
</div>
</div>
</template>Step 3: Commit
bash
git add app/pages/admin/products/index.vue
git commit -m "feat(admin): add products list page with status tabs and polling"Task 9: Admin product detail / moderation page
Files:
- Create:
app/pages/admin/products/[id].vue
Step 1: No unit test (page component)
Step 2: Create page
html
<!-- app/pages/admin/products/[id].vue -->
<script setup lang="ts">
import type { ProductDetail } from '~/entities/product/model/product.schema'
definePageMeta({ layout: 'admin', middleware: 'admin' })
useSeoMeta({ title: 'Модерация объявления', robots: 'noindex, nofollow' })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const productId = Number(route.params.id)
const { approveProduct, rejectProduct, fetchProduct } = useAdminProducts()
const product = ref<ProductDetail | null>(null)
const loading = ref(true)
const actionLoading = ref(false)
const error = ref<Error | null>(null)
onMounted(async () => {
try {
product.value = await fetchProduct(productId)
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
})
const selectedImageIndex = ref(0)
const formattedPrice = computed(() => {
if (!product.value) return ''
return new Intl.NumberFormat('ru-RU').format(product.value.price) + ' \u20BD'
})
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',
})
})
async function handleApprove(id: number) {
actionLoading.value = true
try {
await approveProduct(id)
await router.push('/admin/products')
} finally {
actionLoading.value = false
}
}
async function handleReject(id: number, reason: string) {
actionLoading.value = true
try {
await rejectProduct(id, reason)
await router.push('/admin/products')
} finally {
actionLoading.value = false
}
}
</script>
<template>
<div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-12">
<UIcon name="i-lucide-loader-2" class="w-8 h-8 animate-spin text-gray-400" />
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-12">
<p class="text-gray-500 dark:text-gray-400 mb-4">{{ t('productDetail.notFound') }}</p>
<UButton to="/admin/products">{{ t('admin.backToList') }}</UButton>
</div>
<!-- Product -->
<div v-else-if="product">
<div class="flex items-center gap-3 mb-6">
<UButton
to="/admin/products"
variant="ghost"
icon="i-lucide-arrow-left"
:label="t('admin.backToList')"
/>
<ProductStatusBadge :status="product.status" />
</div>
<!-- Moderation actions (sticky on top for pending) -->
<div
v-if="product.status === 'pending'"
class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6"
>
<ModerationActions
:product-id="product.id"
:status="product.status"
:loading="actionLoading"
@approve="handleApprove"
@reject="handleReject"
/>
</div>
<div class="flex flex-col lg:flex-row gap-8">
<!-- Gallery -->
<div class="lg:w-1/2">
<div class="aspect-[4/3] bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden">
<img
v-if="product.images[selectedImageIndex]"
:src="product.images[selectedImageIndex]?.large_webp ?? product.images[selectedImageIndex]?.large_jpeg ?? ''"
:alt="product.title"
class="w-full h-full object-contain"
/>
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
<UIcon name="i-heroicons-photo" class="w-16 h-16" />
</div>
</div>
<div v-if="product.images.length > 1" class="flex gap-2 mt-3 overflow-x-auto">
<button
v-for="(image, index) in product.images"
:key="image.id"
class="w-[72px] h-[72px] rounded-lg overflow-hidden border-2 shrink-0"
:class="selectedImageIndex === index ? 'border-primary' : 'border-transparent'"
@click="selectedImageIndex = index"
>
<img
:src="image.thumbnail_webp ?? image.thumbnail_jpeg ?? ''"
class="w-full h-full object-cover"
/>
</button>
</div>
</div>
<!-- Info -->
<div class="lg:w-1/2">
<h1 class="text-2xl font-bold">{{ product.title }}</h1>
<p class="text-3xl font-bold mt-2">{{ formattedPrice }}</p>
<!-- Specs -->
<div class="mt-6">
<h3 class="font-semibold mb-2">{{ t('productDetail.characteristics') }}</h3>
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 flex flex-col gap-2">
<div v-if="product.oem_number" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">OEM</span>
<span class="font-medium">{{ product.oem_number }}</span>
</div>
<div v-if="product.manufacturer" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('listing.manufacturer') }}</span>
<span class="font-medium">{{ product.manufacturer }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('listing.steering') }}</span>
<span class="font-medium">{{ t(`listing.steering${product.steering.charAt(0).toUpperCase() + product.steering.slice(1)}`) }}</span>
</div>
</div>
</div>
<!-- Compatibility -->
<div v-if="product.compatibility.length" class="mt-6">
<h3 class="font-semibold mb-2">{{ t('productDetail.fitsFor') }}</h3>
<div class="flex flex-col gap-1">
<div
v-for="(compat, i) in product.compatibility"
:key="i"
class="text-sm text-gray-600 dark:text-gray-400"
>
{{ compat.note ?? `Make ${compat.make_id}, Model ${compat.model_id ?? '\u2014'}` }}
</div>
</div>
</div>
<!-- Description -->
<div v-if="product.description" class="mt-6">
<h3 class="font-semibold mb-2">{{ t('productDetail.description') }}</h3>
<p class="text-gray-600 dark:text-gray-400 whitespace-pre-line">{{ product.description }}</p>
</div>
<!-- Seller info -->
<div class="mt-6 p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h3 class="font-semibold mb-3">{{ t('admin.sellerInfo') }}</h3>
<div class="flex items-center gap-3 mb-3">
<div class="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<img v-if="product.seller.avatar_url" :src="product.seller.avatar_url" class="w-full h-full rounded-full object-cover" />
<UIcon v-else name="i-heroicons-user" class="w-6 h-6 text-gray-400" />
</div>
<div>
<div class="font-medium">{{ product.seller.display_name }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.memberSince', { date: memberSinceDate }) }}
</div>
</div>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.productsCount', { count: product.seller.products_count }) }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>Step 3: Commit
bash
git add app/pages/admin/products/[id].vue
git commit -m "feat(admin): add product detail page with moderation actions"Task 10: Install fast-deep-equal dependency
Files:
- Modify:
package.json
Step 1: Install
bash
npm install fast-deep-equalStep 2: Commit
bash
git add package.json package-lock.json
git commit -m "chore: add fast-deep-equal for polling dedup"Task 11: Verify and fix lint/typecheck
Step 1: Run lint
bash
npm run lintFix any issues found.
Step 2: Run typecheck
bash
npm run typecheckFix any issues found.
Step 3: Run tests
bash
npm run test:runAll tests should pass.
Step 4: Commit fixes if any
bash
git add -A
git commit -m "fix(admin): resolve lint and type errors"Task 12: Update CLAUDE.md and nuxt.config.ts imports
Files:
- Modify:
CLAUDE.md— add admin section to progress, pages, routing table - Modify:
nuxt.config.ts— ensurefeatures/*/composablesis in imports dirs (already present)
Step 1: Add to CLAUDE.md
In the routing table, add:
| `/admin` | admin | No (SPA) | admin |
| `/admin/products` | admin | No (SPA) | admin |
| `/admin/products/:id` | admin | No (SPA) | admin |In the progress section, add:
- [x] Admin moderation (approve/reject with polling)In the project structure, add admin pages and features.
Step 2: Commit
bash
git add CLAUDE.md
git commit -m "docs: add admin moderation to CLAUDE.md"Execution Order Summary
| Task | Description | Dependencies |
|---|---|---|
| 1 | Admin middleware | None |
| 2 | i18n translations | None |
| 3 | Admin layout | Task 2 |
| 4 | Route rules + nav link | Task 2 |
| 5 | useAdminProducts composable | Task 10 |
| 6 | ModerationActions component | Task 2 |
| 7 | Admin index page | Tasks 1, 3 |
| 8 | Admin products list page | Tasks 1, 3, 5, 6 |
| 9 | Admin product detail page | Tasks 1, 3, 5, 6 |
| 10 | Install fast-deep-equal | None |
| 11 | Lint/typecheck/test | All above |
| 12 | Update CLAUDE.md | All above |
Parallelizable: Tasks 1, 2, 10 can run in parallel. Tasks 3, 4, 6 can run after Task 2. Tasks 7, 8, 9 depend on multiple tasks completing first.