Appearance
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">{{ 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"
>
{{ cat.name }}
</NuxtLink>
<span v-if="idx < partCategoryChain.length - 1" class="text-gray-400">›</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">{{ t('listing.category') }}</span>
<span class="font-medium text-right">
{{ 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 dropdownsStep 2: Commit
bash
git add CLAUDE.md
git commit -m "docs: add category-select feature to CLAUDE.md"