Skip to content

Multi-Level Category Cascade Implementation Plan

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

Goal: Replace hardcoded 2-level category dropdowns with dynamic N-level cascading selects everywhere categories are used (product form, catalog filters, product detail breadcrumbs).

Architecture: New features/category-select/ FSD feature with useCategoryCascade composable (client-side tree from flat data) + CategoryCascadeSelect.vue UI component. Integrates into product form, catalog page, and product/admin detail pages. No API changes — same flat parent_id structure.

Tech Stack: Vue 3 composables, Nuxt UI USelectMenu, Vitest, TypeScript


Task 1: Add i18n key

Files:

  • Modify: i18n/locales/ru.json:148 (listing section)

Step 1: Add the new i18n key

In i18n/locales/ru.json, inside the "listing" object, after "selectSubcategory" (line 148), add:

json
"selectSubcategory": "Выберите подкатегорию",
"selectLevel": "Уточните категорию",

Step 2: Commit

bash
git add i18n/locales/ru.json
git commit -m "i18n: add selectLevel key for category cascade"

Task 2: Create useCategoryCascade composable — failing tests

Files:

  • Create: app/features/category-select/composables/useCategoryCascade.ts (empty stub)
  • Create: app/features/category-select/composables/useCategoryCascade.test.ts

Step 1: Create empty stub

Create app/features/category-select/composables/useCategoryCascade.ts:

ts
import { ref, computed } from 'vue'

import type { Category, CategoryType } from '~/entities/category/model/category.schema'
import type { ProductCategory } from '~/entities/product/model/product.schema'

export function useCategoryCascade(_categories: () => Category[]) {
  return {
    levels: computed(() => [] as { options: Category[]; selected: number | undefined }[]),
    categoryChain: computed(() => [] as number[]),
    primaryCategoryId: computed(() => undefined as number | undefined),
    selectAt: (_level: number, _id: number | undefined) => {},
    setChain: (_ids: number[]) => {},
    reset: () => {},
  }
}

export function buildCategoryChain(
  _productCategories: ProductCategory[],
  _allCategories: Category[],
  _type: CategoryType = 'part',
): Category[] {
  return []
}

Step 2: Write the failing tests

Create app/features/category-select/composables/useCategoryCascade.test.ts:

ts
import { describe, it, expect } from 'vitest'
import { ref, nextTick } from 'vue'
import { useCategoryCascade, buildCategoryChain } from './useCategoryCascade'
import type { Category } from '~/entities/category/model/category.schema'
import type { ProductCategory } from '~/entities/product/model/product.schema'

// Test fixture: 4-level hierarchy
// Запчасти (104) → Для автомобилей (105) → Двигатель (110) → Блок цилиндров (2)
// Запчасти (104) → Для автомобилей (105) → Двигатель (110) → Головка блока (3)
// Запчасти (104) → Для автомобилей (105) → Кузов (111) → Капот (47)
// Аксессуары (124) → Для салона (126) → Коврики (127)
const flatCategories: Category[] = [
  { id: 104, name: 'Запчасти', slug: 'zapchasti', icon: null, parent_id: null, category_type: 'part', sort_order: 0, products_count: 0 },
  { id: 124, name: 'Аксессуары', slug: 'aksessuary', icon: null, parent_id: null, category_type: 'part', sort_order: 1, products_count: 0 },
  { id: 105, name: 'Для автомобилей', slug: 'dlya-avto', icon: null, parent_id: 104, category_type: 'part', sort_order: 0, products_count: 0 },
  { id: 110, name: 'Двигатель', slug: 'dvigatel', icon: null, parent_id: 105, category_type: 'part', sort_order: 0, products_count: 0 },
  { id: 111, name: 'Кузов', slug: 'kuzov', icon: null, parent_id: 105, category_type: 'part', sort_order: 1, products_count: 0 },
  { id: 2, name: 'Блок цилиндров', slug: 'blok-tsilindrov', icon: null, parent_id: 110, category_type: 'part', sort_order: 0, products_count: 0 },
  { id: 3, name: 'Головка блока', slug: 'golovka-bloka', icon: null, parent_id: 110, category_type: 'part', sort_order: 1, products_count: 0 },
  { id: 47, name: 'Капот', slug: 'kapot', icon: null, parent_id: 111, category_type: 'part', sort_order: 0, products_count: 0 },
  { id: 126, name: 'Для салона', slug: 'dlya-salona', icon: null, parent_id: 124, category_type: 'part', sort_order: 1, products_count: 0 },
  { id: 127, name: 'Коврики', slug: 'kovriki', icon: null, parent_id: 126, category_type: 'part', sort_order: 0, products_count: 0 },
]

function createCascade(cats: Category[] = flatCategories) {
  return useCategoryCascade(() => cats)
}

describe('useCategoryCascade', () => {
  it('shows root categories as first level', () => {
    const { levels } = createCascade()
    expect(levels.value).toHaveLength(1)
    expect(levels.value[0].options.map(c => c.id)).toEqual([104, 124])
    expect(levels.value[0].selected).toBeUndefined()
  })

  it('shows second level after selecting root', async () => {
    const { levels, selectAt } = createCascade()
    selectAt(0, 104) // Запчасти
    await nextTick()
    expect(levels.value).toHaveLength(2)
    expect(levels.value[1].options.map(c => c.id)).toEqual([105])
  })

  it('shows third level after selecting second', async () => {
    const { levels, selectAt } = createCascade()
    selectAt(0, 104)
    await nextTick()
    selectAt(1, 105) // Для автомобилей
    await nextTick()
    expect(levels.value).toHaveLength(3)
    expect(levels.value[2].options.map(c => c.id)).toEqual([110, 111])
  })

  it('shows fourth level after selecting third', async () => {
    const { levels, selectAt } = createCascade()
    selectAt(0, 104)
    await nextTick()
    selectAt(1, 105)
    await nextTick()
    selectAt(2, 110) // Двигатель
    await nextTick()
    expect(levels.value).toHaveLength(4)
    expect(levels.value[3].options.map(c => c.id)).toEqual([2, 3])
  })

  it('does not add level for leaf category', async () => {
    const { levels, selectAt } = createCascade()
    selectAt(0, 104)
    await nextTick()
    selectAt(1, 105)
    await nextTick()
    selectAt(2, 110)
    await nextTick()
    selectAt(3, 2) // Блок цилиндров (leaf)
    await nextTick()
    expect(levels.value).toHaveLength(4) // no 5th level
  })

  it('clears deeper levels when changing a selection', async () => {
    const { levels, selectAt, categoryChain } = createCascade()
    selectAt(0, 104)
    await nextTick()
    selectAt(1, 105)
    await nextTick()
    selectAt(2, 110)
    await nextTick()
    selectAt(3, 2)
    await nextTick()
    expect(categoryChain.value).toEqual([104, 105, 110, 2])

    // Change level 2 to Кузов
    selectAt(2, 111)
    await nextTick()
    expect(levels.value).toHaveLength(4) // root, Для авто, Кузов, Кузов children
    expect(levels.value[3].options.map(c => c.id)).toEqual([47])
    expect(categoryChain.value).toEqual([104, 105, 111])
  })

  it('clears all when deselecting root', async () => {
    const { levels, selectAt, categoryChain } = createCascade()
    selectAt(0, 104)
    await nextTick()
    selectAt(1, 105)
    await nextTick()

    selectAt(0, undefined) // deselect root
    await nextTick()
    expect(levels.value).toHaveLength(1) // only root level
    expect(categoryChain.value).toEqual([])
  })

  it('categoryChain returns full path of selected IDs', async () => {
    const { selectAt, categoryChain } = createCascade()
    selectAt(0, 104)
    await nextTick()
    expect(categoryChain.value).toEqual([104])

    selectAt(1, 105)
    await nextTick()
    expect(categoryChain.value).toEqual([104, 105])

    selectAt(2, 110)
    await nextTick()
    expect(categoryChain.value).toEqual([104, 105, 110])
  })

  it('primaryCategoryId returns the deepest selected', async () => {
    const { selectAt, primaryCategoryId } = createCascade()
    expect(primaryCategoryId.value).toBeUndefined()

    selectAt(0, 104)
    await nextTick()
    expect(primaryCategoryId.value).toBe(104)

    selectAt(1, 105)
    await nextTick()
    expect(primaryCategoryId.value).toBe(105)
  })

  it('setChain restores a full selection path', async () => {
    const { levels, categoryChain, setChain } = createCascade()
    setChain([104, 105, 110, 3])
    await nextTick()
    expect(categoryChain.value).toEqual([104, 105, 110, 3])
    expect(levels.value).toHaveLength(4)
    expect(levels.value[0].selected).toBe(104)
    expect(levels.value[1].selected).toBe(105)
    expect(levels.value[2].selected).toBe(110)
    expect(levels.value[3].selected).toBe(3)
  })

  it('reset clears all selections', async () => {
    const { selectAt, categoryChain, levels, reset } = createCascade()
    selectAt(0, 124)
    await nextTick()
    selectAt(1, 126)
    await nextTick()

    reset()
    await nextTick()
    expect(categoryChain.value).toEqual([])
    expect(levels.value).toHaveLength(1)
  })

  it('sorts options by sort_order', () => {
    const { levels } = createCascade()
    // Root: Запчасти (sort_order 0) before Аксессуары (sort_order 1)
    expect(levels.value[0].options[0].name).toBe('Запчасти')
    expect(levels.value[0].options[1].name).toBe('Аксессуары')
  })

  it('handles empty categories', () => {
    const { levels, categoryChain } = createCascade([])
    expect(levels.value).toHaveLength(0)
    expect(categoryChain.value).toEqual([])
  })
})

describe('buildCategoryChain', () => {
  it('builds chain from product categories to root', () => {
    const productCategories: ProductCategory[] = [
      { category_id: 104, is_primary: false },
      { category_id: 105, is_primary: false },
      { category_id: 110, is_primary: false },
      { category_id: 2, is_primary: true },
    ]
    const chain = buildCategoryChain(productCategories, flatCategories)
    expect(chain.map(c => c.id)).toEqual([104, 105, 110, 2])
  })

  it('builds chain even when product only has leaf id', () => {
    // Backend might only return leaf — walk up via parent_id
    const productCategories: ProductCategory[] = [
      { category_id: 2, is_primary: true },
    ]
    const chain = buildCategoryChain(productCategories, flatCategories)
    expect(chain.map(c => c.id)).toEqual([104, 105, 110, 2])
  })

  it('returns empty for empty product categories', () => {
    const chain = buildCategoryChain([], flatCategories)
    expect(chain).toEqual([])
  })

  it('filters by category type', () => {
    const conditionCat: Category = { id: 95, name: 'Новое', slug: 'novoe', icon: null, parent_id: null, category_type: 'condition', sort_order: 0, products_count: 0 }
    const allCats = [...flatCategories, conditionCat]
    const productCategories: ProductCategory[] = [
      { category_id: 95, is_primary: false },
      { category_id: 2, is_primary: true },
    ]
    const chain = buildCategoryChain(productCategories, allCats, 'part')
    // Should only include part categories
    expect(chain.every(c => c.category_type === 'part')).toBe(true)
    expect(chain.map(c => c.id)).toEqual([104, 105, 110, 2])
  })
})

Step 3: Run tests to verify they fail

Run: npx vitest run app/features/category-select/composables/useCategoryCascade.test.ts

Expected: Multiple failures — stub returns empty arrays/undefined.

Step 4: Commit failing tests

bash
git add app/features/category-select/composables/useCategoryCascade.ts app/features/category-select/composables/useCategoryCascade.test.ts
git commit -m "test: add failing tests for useCategoryCascade composable"

Task 3: Implement useCategoryCascade and buildCategoryChain

Files:

  • Modify: app/features/category-select/composables/useCategoryCascade.ts

Step 1: Write the implementation

Replace app/features/category-select/composables/useCategoryCascade.ts:

ts
import { ref, computed, watch } from 'vue'

import type { Category, CategoryType } from '~/entities/category/model/category.schema'
import type { ProductCategory } from '~/entities/product/model/product.schema'

export interface CascadeLevel {
  options: Category[]
  selected: number | undefined
}

/**
 * Manages dynamic N-level cascading category selection.
 * Takes a getter returning a flat array of categories (already filtered by type).
 * Builds parent→children map client-side, no API calls per level.
 */
export function useCategoryCascade(categoriesGetter: () => Category[]) {
  const selections = ref<(number | undefined)[]>([])

  const childrenMap = computed(() => {
    const map = new Map<number | null, Category[]>()
    for (const cat of categoriesGetter()) {
      const key = cat.parent_id
      const list = map.get(key)
      if (list) {
        list.push(cat)
      } else {
        map.set(key, [cat])
      }
    }
    // Sort each group by sort_order
    for (const [, list] of map) {
      list.sort((a, b) => a.sort_order - b.sort_order)
    }
    return map
  })

  const levels = computed<CascadeLevel[]>(() => {
    const roots = childrenMap.value.get(null)
    if (!roots?.length) return []

    const result: CascadeLevel[] = [{ options: roots, selected: selections.value[0] }]

    for (let i = 0; i < selections.value.length; i++) {
      const selectedId = selections.value[i]
      if (selectedId === undefined) break
      const children = childrenMap.value.get(selectedId)
      if (children?.length) {
        result.push({ options: children, selected: selections.value[i + 1] })
      }
    }

    return result
  })

  const categoryChain = computed<number[]>(() => {
    return selections.value.filter((id): id is number => id !== undefined)
  })

  const primaryCategoryId = computed<number | undefined>(() => {
    const chain = categoryChain.value
    return chain.length > 0 ? chain[chain.length - 1] : undefined
  })

  function selectAt(level: number, categoryId: number | undefined) {
    const newSelections = selections.value.slice(0, level)
    if (categoryId !== undefined) {
      newSelections.push(categoryId)
    }
    selections.value = newSelections
  }

  function setChain(ids: number[]) {
    selections.value = [...ids]
  }

  function reset() {
    selections.value = []
  }

  return {
    levels,
    categoryChain,
    primaryCategoryId,
    selectAt,
    setChain,
    reset,
  }
}

/**
 * Build ordered category chain from a product's categories.
 * Finds the leaf part-category, then walks up parent_id to root.
 * Works whether product has full chain or just the leaf ID.
 */
export function buildCategoryChain(
  productCategories: ProductCategory[],
  allCategories: Category[],
  type: CategoryType = 'part',
): Category[] {
  if (!productCategories.length) return []

  const catMap = new Map(allCategories.map(c => [c.id, c]))
  const partCatIds = new Set(
    productCategories
      .map(pc => pc.category_id)
      .filter(id => catMap.get(id)?.category_type === type),
  )

  if (!partCatIds.size) return []

  // Find leaf: category whose id is not the parent_id of any other in set
  let leaf = [...partCatIds].find(id => {
    return ![...partCatIds].some(otherId => catMap.get(otherId)?.parent_id === id)
  })

  // Fallback: if only one, it's the leaf
  if (leaf === undefined && partCatIds.size === 1) {
    leaf = [...partCatIds][0]
  }

  if (leaf === undefined) return []

  // Walk up from leaf to root
  const chain: Category[] = []
  let current = catMap.get(leaf)
  while (current) {
    chain.unshift(current)
    current = current.parent_id ? catMap.get(current.parent_id) : undefined
  }

  return chain
}

Step 2: Run tests to verify they pass

Run: npx vitest run app/features/category-select/composables/useCategoryCascade.test.ts

Expected: All tests PASS.

Step 3: Commit

bash
git add app/features/category-select/composables/useCategoryCascade.ts
git commit -m "feat: implement useCategoryCascade composable with buildCategoryChain"

Task 4: Create CategoryCascadeSelect.vue component

Files:

  • Create: app/features/category-select/ui/CategoryCascadeSelect.vue

Step 1: Write the component

Create app/features/category-select/ui/CategoryCascadeSelect.vue:

html
<script setup lang="ts">
import { watch } from 'vue'
import { useCategoryCascade } from '../composables/useCategoryCascade'
import type { Category } from '~/entities/category/model/category.schema'

const props = defineProps<{
  categories: Category[]
  modelValue: number[]
}>()

const emit = defineEmits<{
  'update:modelValue': [chain: number[]]
}>()

const { t } = useI18n()

const { levels, categoryChain, selectAt, setChain, reset } = useCategoryCascade(
  () => props.categories,
)

// Sync incoming modelValue → internal state
watch(
  () => props.modelValue,
  (incoming) => {
    if (JSON.stringify(incoming) !== JSON.stringify(categoryChain.value)) {
      if (incoming.length) {
        setChain(incoming)
      } else {
        reset()
      }
    }
  },
  { immediate: true },
)

// Sync internal state → emit
watch(categoryChain, (chain) => {
  if (JSON.stringify(chain) !== JSON.stringify(props.modelValue)) {
    emit('update:modelValue', chain)
  }
})

function onSelect(level: number, value: number | undefined) {
  selectAt(level, value)
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <USelectMenu
      v-for="(level, index) in levels"
      :key="index"
      :model-value="level.selected"
      :items="level.options.map(c => ({ label: c.name, value: c.id }))"
      :placeholder="index === 0 ? t('listing.selectCategory') : t('listing.selectLevel')"
      value-key="value"
      label-key="label"
      class="w-full"
      @update:model-value="onSelect(index, $event as number | undefined)"
    />
  </div>
</template>

Step 2: Run lint

Run: npx eslint app/features/category-select/ --fix

Expected: No errors.

Step 3: Commit

bash
git add app/features/category-select/ui/CategoryCascadeSelect.vue
git commit -m "feat: add CategoryCascadeSelect component with dynamic levels"

Task 5: Integrate into Product Form

Files:

  • Modify: app/features/product-form/ui/ProductFormPage.vue:76-102 (category helpers), 228-245 (template)

Step 1: Update script section

In ProductFormPage.vue, replace the category helpers block (lines 76–102) with:

ts
// Category cascade
const selectedCategoryChain = ref<number[]>([])

watch(selectedCategoryChain, (chain) => {
  const last = chain[chain.length - 1]
  form.category_ids = chain.map(id => ({
    category_id: id,
    is_primary: id === last,
  }))
})

Step 2: Update template

Replace the category UFormField (lines 226–246) with:

html
<!-- Category -->
<UFormField :label="t('listing.category')" name="category_ids" :error="fieldErrors['category_ids']?.[0] || fieldErrors['primary_category_id']?.[0]">
  <CategoryCascadeSelect
    :categories="partCategories"
    v-model="selectedCategoryChain"
  />
</UFormField>

Step 3: Update edit mode restoration

In the onMounted block (lines 185–207), replace the category restoration code (lines 193–204) with:

ts
if (product) {
  imageUpload.setServerImages(product.images ?? [])
  // Restore category chain for edit mode
  const chain = buildCategoryChain(
    product.categories ?? [],
    partCategories.value,
    'part',
  )
  if (chain.length) {
    selectedCategoryChain.value = chain.map(c => c.id)
  }
}

Add the import at the top of the script block (the component auto-imports, but buildCategoryChain is a utility function — it will auto-import via Nuxt since it's in features/*/composables/).

Step 4: Run lint and typecheck

Run: npx eslint app/features/product-form/ui/ProductFormPage.vue --fix

Step 5: Commit

bash
git add app/features/product-form/ui/ProductFormPage.vue
git commit -m "feat: integrate category cascade into product form"

Task 6: Integrate into Catalog page

Files:

  • Modify: app/pages/catalog/index.vue:42-53 (categories section), 170-189 (template)

Step 1: Update script

In catalog/index.vue, replace the category-related code.

Remove parentCategories computed (line 53). Add a cascade chain ref:

ts
const selectedCategoryChain = ref<number[]>([])

After loading categories (inside onMounted, after line 49), if there's a pendingCategoryId, build chain:

ts
onMounted(async () => {
  const res = await api.get<ApiListResponse<Category>>('/store/categories')
  categories.value = res.data.filter((c) => c.category_type === 'part')
  if (pendingCategoryId) {
    // Find this category and build its chain to root
    const catMap = new Map(categories.value.map(c => [c.id, c]))
    const chain: number[] = []
    let current = catMap.get(pendingCategoryId)
    while (current) {
      chain.unshift(current.id)
      current = current.parent_id ? catMap.get(current.parent_id) : undefined
    }
    selectedCategoryChain.value = chain
    filters.category_id = pendingCategoryId
  }
  categoriesLoading.value = false
  refresh()
})

Add a watcher to sync cascade → filter:

ts
watch(selectedCategoryChain, (chain) => {
  filters.category_id = chain.length ? chain[chain.length - 1] : undefined
  applyFilters()
})

In resetFilters, also reset the chain:

ts
function resetFilters() {
  // ... existing reset ...
  selectedCategoryChain.value = []
  ymm.reset()
  applyFilters()
}

Step 2: Update template

Replace the category UFormField (lines 171–189) with:

html
<!-- Category -->
<UFormField :label="t('listing.category')">
  <CategoryCascadeSelect
    v-if="!categoriesLoading"
    :categories="categories"
    v-model="selectedCategoryChain"
  />
  <div v-else class="h-9 animate-pulse bg-gray-100 dark:bg-gray-800 rounded" />
</UFormField>

Step 3: Run lint

Run: npx eslint app/pages/catalog/index.vue --fix

Step 4: Commit

bash
git add app/pages/catalog/index.vue
git commit -m "feat: integrate category cascade into catalog filters"

Task 7: Category breadcrumb on Product Detail page

Files:

  • Modify: app/pages/product/[id].vue:44-56 (category helpers), 182-185 (template)

Step 1: Update script — replace category helpers

In product/[id].vue, replace lines 44–56 (category helpers) with:

ts
// Build ordered category chain for part categories
const partCategoryChain = computed(() => {
  if (!product.value?.categories || !allCategories.value?.data?.length) return []
  const catMap = new Map(allCategories.value.data.map(c => [c.id, c]))
  const partCatIds = product.value.categories
    .map(pc => pc.category_id)
    .filter(id => catMap.get(id)?.category_type === 'part')

  if (!partCatIds.length) return []

  // Find leaf (not a parent of any other in set)
  const partSet = new Set(partCatIds)
  const leaf = partCatIds.find(id =>
    !partCatIds.some(otherId => catMap.get(otherId)?.parent_id === id),
  )
  if (!leaf) return []

  const chain: Category[] = []
  let current = catMap.get(leaf)
  while (current) {
    chain.unshift(current)
    current = current.parent_id ? catMap.get(current.parent_id) : undefined
  }
  return chain
})

const conditionCategoryName = computed(() => {
  if (!product.value?.categories || !allCategories.value?.data?.length) return null
  const catMap = new Map(allCategories.value.data.map(c => [c.id, c]))
  const found = product.value.categories
    .map(pc => catMap.get(pc.category_id))
    .find(c => c?.category_type === 'condition')
  return found?.name ?? null
})

const attributeCategoryName = computed(() => {
  if (!product.value?.categories || !allCategories.value?.data?.length) return null
  const catMap = new Map(allCategories.value.data.map(c => [c.id, c]))
  const found = product.value.categories
    .map(pc => catMap.get(pc.category_id))
    .find(c => c?.category_type === 'attribute')
  return found?.name ?? null
})

Step 2: Update template — breadcrumb display

Replace lines 182–185 (part category display) with:

html
<div v-if="partCategoryChain.length" class="flex justify-between">
  <span class="text-gray-500 dark:text-gray-400">&#123;&#123; t('listing.category') }}</span>
  <div class="font-medium text-right flex flex-wrap items-center gap-1">
    <template v-for="(cat, idx) in partCategoryChain" :key="cat.id">
      <NuxtLink
        :to="`/catalog?category_id=${cat.id}`"
        class="hover:underline text-primary"
      >
        &#123;&#123; cat.name }}
      </NuxtLink>
      <span v-if="idx < partCategoryChain.length - 1" class="text-gray-400">&rsaquo;</span>
    </template>
  </div>
</div>

Step 3: Remove unused import/variable

Remove partCategoryNames — it's been replaced by partCategoryChain. Also remove the unused categoryMap computed and productCategoryNamesByType function.

Step 4: Run lint

Run: npx eslint app/pages/product/\\[id\\].vue --fix

Step 5: Commit

bash
git add "app/pages/product/[id].vue"
git commit -m "feat: show category breadcrumb chain on product detail page"

Task 8: Category breadcrumb on Admin Product Detail page

Files:

  • Modify: app/pages/admin/products/[id].vue:83-97 (category helpers), 246-249 (template)

Step 1: Update script — replace category helpers

Replace lines 83–97 with the same breadcrumb pattern (adapted for non-async refs):

ts
// Build ordered category chain for part categories
const partCategoryChain = computed(() => {
  if (!product.value?.categories || !allCategories.value.length) return []
  const catMap = new Map(allCategories.value.map(c => [c.id, c]))
  const partCatIds = product.value.categories
    .map(pc => pc.category_id)
    .filter(id => catMap.get(id)?.category_type === 'part')

  if (!partCatIds.length) return []

  const leaf = partCatIds.find(id =>
    !partCatIds.some(otherId => catMap.get(otherId)?.parent_id === id),
  )
  if (!leaf) return []

  const chain: Category[] = []
  let current = catMap.get(leaf)
  while (current) {
    chain.unshift(current)
    current = current.parent_id ? catMap.get(current.parent_id) : undefined
  }
  return chain
})

const conditionCategoryName = computed(() => {
  if (!product.value?.categories || !allCategories.value.length) return null
  const catMap = new Map(allCategories.value.map(c => [c.id, c]))
  const found = product.value.categories
    .map(pc => catMap.get(pc.category_id))
    .find(c => c?.category_type === 'condition')
  return found?.name ?? null
})

const attributeCategoryName = computed(() => {
  if (!product.value?.categories || !allCategories.value.length) return null
  const catMap = new Map(allCategories.value.map(c => [c.id, c]))
  const found = product.value.categories
    .map(pc => catMap.get(pc.category_id))
    .find(c => c?.category_type === 'attribute')
  return found?.name ?? null
})

Step 2: Update template

Replace lines 246–249 (part category display) with:

html
<div v-if="partCategoryChain.length" class="flex justify-between">
  <span class="text-gray-500 dark:text-gray-400">&#123;&#123; t('listing.category') }}</span>
  <span class="font-medium text-right">
    &#123;&#123; partCategoryChain.map(c => c.name).join(' › ') }}
  </span>
</div>

Note: Admin page doesn't need clickable links — just plain text breadcrumb.

Step 3: Remove unused

Remove partCategoryNames, the old productCategoryNamesByType function, and categoryMap.

Step 4: Run lint

Run: npx eslint app/pages/admin/products/\\[id\\].vue --fix

Step 5: Commit

bash
git add "app/pages/admin/products/[id].vue"
git commit -m "feat: show category breadcrumb on admin product detail page"

Task 9: Run full test suite and lint

Step 1: Run all tests

Run: npx vitest run

Expected: All tests pass.

Step 2: Run lint

Run: npm run lint

Expected: No errors.

Step 3: Run typecheck

Run: npm run typecheck

Expected: No type errors.

Step 4: Fix any issues found

If there are failures, fix them and commit fixes separately.


Task 10: Update CLAUDE.md

Files:

  • Modify: CLAUDE.md

Step 1: Update FSD structure

Add category-select to the features section:

│   ├── category-select/
│   │   ├── composables/useCategoryCascade.ts  # N-level cascade from flat data + buildCategoryChain
│   │   └── ui/CategoryCascadeSelect.vue        # Dynamic cascading dropdowns

Step 2: Commit

bash
git add CLAUDE.md
git commit -m "docs: add category-select feature to CLAUDE.md"