Skip to content

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">
            &#123;&#123; 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"

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">&#123;&#123; 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">&#123;&#123; t('admin.products') }}</h1>
      <span
        v-if="statusFilter === 'pending'"
        class="text-sm text-gray-500 dark:text-gray-400"
      >
        &#123;&#123; 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"
      >
        &#123;&#123; 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">&#123;&#123; 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">&#123;&#123; t('listing.title') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">&#123;&#123; t('admin.seller') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">&#123;&#123; t('product.price') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">&#123;&#123; t('admin.date') }}</th>
              <th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">&#123;&#123; 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">&#123;&#123; product.title }}</span>
                </div>
              </td>
              <td class="px-4 py-3 text-sm">&#123;&#123; product.seller?.display_name ?? '—' }}</td>
              <td class="px-4 py-3 text-sm font-medium">&#123;&#123; formattedPrice(product.price) }}</td>
              <td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">&#123;&#123; 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">&#123;&#123; product.title }}</span>
              <ProductStatusBadge :status="product.status" />
            </div>
            <div class="text-sm font-bold mt-0.5">&#123;&#123; formattedPrice(product.price) }}</div>
            <div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
              &#123;&#123; product.seller?.display_name ?? '—' }} &middot; &#123;&#123; 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">
        &#123;&#123; 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">&#123;&#123; t('productDetail.notFound') }}</p>
      <UButton to="/admin/products">&#123;&#123; 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">&#123;&#123; product.title }}</h1>
          <p class="text-3xl font-bold mt-2">&#123;&#123; formattedPrice }}</p>

          <!-- Specs -->
          <div class="mt-6">
            <h3 class="font-semibold mb-2">&#123;&#123; 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">&#123;&#123; product.oem_number }}</span>
              </div>
              <div v-if="product.manufacturer" class="flex justify-between">
                <span class="text-gray-500 dark:text-gray-400">&#123;&#123; t('listing.manufacturer') }}</span>
                <span class="font-medium">&#123;&#123; product.manufacturer }}</span>
              </div>
              <div class="flex justify-between">
                <span class="text-gray-500 dark:text-gray-400">&#123;&#123; t('listing.steering') }}</span>
                <span class="font-medium">&#123;&#123; 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">&#123;&#123; 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"
              >
                &#123;&#123; 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">&#123;&#123; t('productDetail.description') }}</h3>
            <p class="text-gray-600 dark:text-gray-400 whitespace-pre-line">&#123;&#123; 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">&#123;&#123; 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">&#123;&#123; product.seller.display_name }}</div>
                <div class="text-sm text-gray-500 dark:text-gray-400">
                  &#123;&#123; t('admin.memberSince', { date: memberSinceDate }) }}
                </div>
              </div>
            </div>
            <div class="text-sm text-gray-500 dark:text-gray-400">
              &#123;&#123; 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-equal

Step 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 lint

Fix any issues found.

Step 2: Run typecheck

bash
npm run typecheck

Fix any issues found.

Step 3: Run tests

bash
npm run test:run

All 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 — ensure features/*/composables is 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

TaskDescriptionDependencies
1Admin middlewareNone
2i18n translationsNone
3Admin layoutTask 2
4Route rules + nav linkTask 2
5useAdminProducts composableTask 10
6ModerationActions componentTask 2
7Admin index pageTasks 1, 3
8Admin products list pageTasks 1, 3, 5, 6
9Admin product detail pageTasks 1, 3, 5, 6
10Install fast-deep-equalNone
11Lint/typecheck/testAll above
12Update CLAUDE.mdAll 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.