/** * 🌍 Global Category Store (Composable) * ======================================= * * P0 ARCHITECTURAL REFACTOR (2026-07-07): * This composable fetches GET /api/v1/categories once and stores them * in a reactive Map (id -> { name, name_i18n, parent_id }). It provides: * * - categoryMap: Map * for fast O(1) lookups by ID * - categoryTree: ref β€” the hierarchical tree for UI * - getCategoryName(id): returns the display name for a category ID * (resolves from name_i18n using the active locale) * - getCategoryParentId(id): returns the parent_id for hierarchy automation * - isLoading: ref β€” loading state * - error: ref β€” error state * * i18n Resolution (Phase 4): * The backend now returns name_i18n as a JSONB dictionary, e.g. * { "hu": "BontΓ³", "en": "Dismantler" }. The getCategoryName() helper * resolves the active locale via @nuxtjs/i18n (useI18n()) and falls back: * 1. Active language in name_i18n dict * 2. Hungarian (hu) in name_i18n dict * 3. Deprecated name_hu field * 4. Fallback to #id * * Usage: * const { categoryMap, getCategoryName, isLoading } = useCategories() * const name = getCategoryName(755) // β†’ "Üzemanyag" (if locale=hu) * * Singleton Pattern: * The composable uses a module-level cache (let cache) so that * categories are fetched ONLY ONCE across all components. */ // ── Module-level cache (singleton) ────────────────────── let cache = null /** * Build a flat Map from the tree. * Also returns the tree itself. */ function flattenTree(nodes, parentId = null) { const map = new Map() for (const node of nodes) { map.set(node.id, { name: node.name_hu || node.name_en || node.key, name_i18n: node.name_i18n || null, parent_id: parentId, level: node.level, key: node.key, }) if (node.children && node.children.length > 0) { const childMap = flattenTree(node.children, node.id) for (const [k, v] of childMap) { map.set(k, v) } } } return map } /** * Build a hierarchical tree from the flat category list. * The API returns categories as a flat array with parent_id references. */ function buildTree(categories) { const map = new Map() const roots = [] // First pass: create nodes for (const cat of categories) { map.set(cat.id, { id: cat.id, key: cat.key, name_hu: cat.name_hu || null, name_en: cat.name_en || null, name_i18n: cat.name_i18n || null, level: cat.level || 0, path: cat.path || null, is_official: cat.is_official || false, parent_id: cat.parent_id || null, children: [], }) } // Second pass: link children to parents for (const cat of categories) { const node = map.get(cat.id) if (cat.parent_id && map.has(cat.parent_id)) { map.get(cat.parent_id).children.push(node) } else { roots.push(node) } } return roots } /** * Resolve a category display name using i18n JSONB dictionary. * * Fallback chain: * 1. Active language in name_i18n dict * 2. Hungarian (hu) in name_i18n dict * 3. Deprecated name_hu field * 4. Fallback to #id */ function resolveCategoryName(entry, locale, id) { if (!entry) return `#${id}` // 1. Try active language in i18n dict if (entry.name_i18n && entry.name_i18n[locale]) { return entry.name_i18n[locale] } // 2. Fallback to Hungarian in i18n dict if (entry.name_i18n && entry.name_i18n['hu']) { return entry.name_i18n['hu'] } // 3. Fallback to deprecated name_hu (safe transition) if (entry.name_hu) { return entry.name_hu } // 4. Fallback to ID return `#${id}` } /** * useCategories β€” Global Category Store Composable * * Fetches categories from the API once and caches them. * Returns reactive references to the flat map, tree, and helpers. */ export function useCategories() { const { $fetch } = useNuxtApp() || {} const fetchFn = $fetch || globalThis.$fetch // ── Reactive State ──────────────────────────────────── const categoryMap = ref(new Map()) const categoryTree = ref([]) const isLoading = ref(false) const error = ref(null) const isLoaded = ref(false) // ── i18n locale (dynamic) ───────────────────────────── let currentLocale = 'hu' try { const i18n = useI18n() currentLocale = i18n.locale?.value || i18n.locale || 'hu' } catch { // useI18n() may throw outside of setup context; fallback to 'hu' } // ── Fetch Categories ────────────────────────────────── async function fetchCategories() { // Return cached data if already loaded if (cache) { categoryMap.value = cache.map categoryTree.value = cache.tree isLoaded.value = true return } isLoading.value = true error.value = null try { // Try the flat list endpoint first let data = null try { data = await fetchFn('/api/v1/categories', { headers: getAuthHeaders(), }) } catch (_) { // Fallback: try the tree endpoint data = await fetchFn('/api/v1/providers/categories/tree', { headers: getAuthHeaders(), }) } if (!data) { throw new Error('No data returned from categories API') } // Determine if data is flat list or tree if (Array.isArray(data)) { if (data.length > 0 && data[0].children !== undefined) { // It's a tree β€” flatten it categoryTree.value = data categoryMap.value = flattenTree(data) } else if (data.length > 0 && data[0].parent_id !== undefined) { // It's a flat list β€” build tree and map categoryMap.value = new Map( data.map(cat => [ cat.id, { name: cat.name_hu || cat.name_en || cat.key, name_i18n: cat.name_i18n || null, parent_id: cat.parent_id || null, level: cat.level || 0, key: cat.key, }, ]) ) categoryTree.value = buildTree(data) } else { // Unknown format β€” treat as tree anyway categoryTree.value = data categoryMap.value = flattenTree(data) } } else { throw new Error('Unexpected categories response format') } // Cache the result cache = { map: categoryMap.value, tree: categoryTree.value, } isLoaded.value = true } catch (err) { console.error('[useCategories] Failed to fetch categories:', err) error.value = err?.message || 'Failed to load categories' } finally { isLoading.value = false } } // ── Helper: Get auth headers ────────────────────────── function getAuthHeaders() { try { const token = useCookie('access_token').value const headers = { 'Content-Type': 'application/json' } if (token) headers['Authorization'] = `Bearer ${token}` return headers } catch { return { 'Content-Type': 'application/json' } } } // ── Helper: Get category name by ID (i18n-aware) ────── function getCategoryName(id) { if (!id && id !== 0) return 'β€”' const entry = categoryMap.value.get(Number(id)) return resolveCategoryName(entry, currentLocale, id) } // ── Helper: Get category parent_id by ID ────────────── function getCategoryParentId(id) { if (!id && id !== 0) return null const entry = categoryMap.value.get(Number(id)) return entry ? entry.parent_id : null } // ── Helper: Get full category info by ID ────────────── function getCategory(id) { if (!id && id !== 0) return null return categoryMap.value.get(Number(id)) || null } // ── Auto-fetch on first use ─────────────────────────── if (!isLoaded.value && !cache) { fetchCategories() } return { // State categoryMap, categoryTree, isLoading, error, isLoaded, // Helpers getCategoryName, getCategoryParentId, getCategory, // Actions fetchCategories, } }