Files
webapp/app/composables/useCatalogSearch.ts
Ruslan Bakiev 6545eeabea
All checks were successful
Build Docker Image / build (push) Successful in 4m14s
feat(catalog): persist bounds filter state in URL
- Add urlBounds and filterByBounds computed from URL query
- Add setBoundsInUrl and clearBoundsFromUrl actions
- Update index.vue to use URL-based bounds state
- Bounds written to URL as comma-separated values (west,south,east,north)

This enables sharing links with map viewport bounds filter.
2026-01-26 21:40:44 +07:00

426 lines
12 KiB
TypeScript

import type { LocationQuery } from 'vue-router'
export type SelectMode = 'product' | 'supplier' | 'hub' | null
export type MapViewMode = 'offers' | 'hubs' | 'suppliers'
export type CatalogMode = 'explore' | 'quote'
export type InfoEntityType = 'hub' | 'supplier' | 'offer'
export type DisplayMode =
| 'map-default'
| 'grid-products'
| 'grid-suppliers'
| 'grid-hubs'
| 'grid-hubs-for-product'
| 'grid-products-from-supplier'
| 'grid-products-in-hub'
| 'grid-offers'
export interface InfoId {
type: InfoEntityType
uuid: string
}
export interface SearchFilter {
type: 'product' | 'supplier' | 'hub' | 'quantity'
id: string
label: string
}
export interface SearchState {
selectMode: SelectMode
product: { id: string; name: string } | null
supplier: { id: string; name: string } | null
hub: { id: string; name: string } | null
quantity: string | null
}
// Color scheme for entity types
export const entityColors = {
product: '#f97316', // orange
supplier: '#3b82f6', // blue
hub: '#22c55e', // green
offer: '#f97316' // orange (same as product context)
} as const
// Filter labels cache (to show names instead of UUIDs)
const filterLabels = ref<Record<string, Record<string, string>>>({
product: {},
supplier: {},
hub: {}
})
export function useCatalogSearch() {
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
// Parse current state from query params
const selectMode = computed<SelectMode>(() => {
const select = route.query.select as string | undefined
if (select === 'product' || select === 'supplier' || select === 'hub') {
return select
}
return null
})
// Parse info state from query param (format: "type:uuid")
const infoId = computed<InfoId | null>(() => {
const info = route.query.info as string | undefined
if (!info) return null
const [type, uuid] = info.split(':')
if (type && uuid && ['hub', 'supplier', 'offer'].includes(type)) {
return { type: type as InfoEntityType, uuid }
}
return null
})
// Info panel tab (stored in URL for sharing)
const infoTab = computed(() => route.query.infoTab as string | undefined)
// Info panel selected product (stored in URL for sharing)
const infoProduct = computed(() => route.query.infoProduct as string | undefined)
const productId = computed(() => route.query.product as string | undefined)
const supplierId = computed(() => route.query.supplier as string | undefined)
const hubId = computed(() => route.query.hub as string | undefined)
const quantity = computed(() => route.query.qty as string | undefined)
// Map bounds from URL (format: west,south,east,north)
const urlBounds = computed(() => {
const b = route.query.bounds as string | undefined
if (!b) return null
const parts = b.split(',').map(Number)
if (parts.length !== 4 || parts.some(isNaN)) return null
return { west: parts[0], south: parts[1], east: parts[2], north: parts[3] }
})
// Filter by bounds checkbox state from URL
const filterByBounds = computed(() => route.query.bounds !== undefined)
// Get label for a filter (from cache or fallback to ID)
const getLabel = (type: string, id: string | undefined): string | null => {
if (!id) return null
return filterLabels.value[type]?.[id] || id.slice(0, 8) + '...'
}
// Set label in cache (trigger reactivity by reassigning)
const setLabel = (type: string, id: string, label: string) => {
filterLabels.value = {
...filterLabels.value,
[type]: {
...filterLabels.value[type],
[id]: label
}
}
}
// Active tokens for display
const activeTokens = computed(() => {
const tokens: Array<{ type: string; id: string; label: string; icon: string }> = []
if (productId.value) {
tokens.push({
type: 'product',
id: productId.value,
label: getLabel('product', productId.value) || t('catalog.filters.product'),
icon: 'lucide:package'
})
}
if (supplierId.value) {
tokens.push({
type: 'supplier',
id: supplierId.value,
label: getLabel('supplier', supplierId.value) || t('catalog.filters.supplier'),
icon: 'lucide:factory'
})
}
if (hubId.value) {
tokens.push({
type: 'hub',
id: hubId.value,
label: getLabel('hub', hubId.value) || t('catalog.filters.hub'),
icon: 'lucide:map-pin'
})
}
if (quantity.value) {
tokens.push({
type: 'quantity',
id: quantity.value,
label: `${quantity.value} т`,
icon: 'lucide:scale'
})
}
return tokens
})
// Available chips (filters not yet set)
const availableChips = computed(() => {
const chips: Array<{ type: string; label: string }> = []
if (!productId.value && selectMode.value !== 'product') {
chips.push({ type: 'product', label: t('catalog.filters.product') })
}
if (!supplierId.value && selectMode.value !== 'supplier') {
chips.push({ type: 'supplier', label: t('catalog.filters.supplier') })
}
if (!hubId.value && selectMode.value !== 'hub') {
chips.push({ type: 'hub', label: t('catalog.filters.hub') })
}
// Quantity only available after product is selected
if (productId.value && !quantity.value) {
chips.push({ type: 'quantity', label: t('catalog.filters.quantity') })
}
return chips
})
// Determine what content to show
const displayMode = computed<DisplayMode>(() => {
// Selection mode takes priority
if (selectMode.value === 'product') return 'grid-products'
if (selectMode.value === 'supplier') return 'grid-suppliers'
if (selectMode.value === 'hub') return 'grid-hubs'
// Results based on filters
if (productId.value && hubId.value) return 'grid-offers'
if (supplierId.value && productId.value) return 'grid-offers'
if (productId.value) return 'grid-hubs-for-product'
if (supplierId.value) return 'grid-products-from-supplier'
if (hubId.value) return 'grid-products-in-hub'
// Default: show map with all offers
return 'map-default'
})
// Check if we're on the main page (not /catalog)
const localePath = useLocalePath()
const isMainPage = computed(() => {
const catalogPath = localePath('/catalog')
return !route.path.startsWith(catalogPath)
})
// Navigation helpers
const updateQuery = (updates: Partial<LocationQuery>) => {
const newQuery = { ...route.query }
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === undefined) {
delete newQuery[key]
} else {
newQuery[key] = value as string
}
})
// If on main page and adding filters, navigate to /catalog
if (isMainPage.value && Object.keys(newQuery).length > 0) {
router.push({ path: localePath('/catalog'), query: newQuery })
} else {
router.push({ query: newQuery })
}
}
const startSelect = (type: SelectMode) => {
updateQuery({ select: type })
}
const cancelSelect = () => {
updateQuery({ select: null })
}
const selectItem = (type: string, id: string, label: string) => {
setLabel(type, id, label)
updateQuery({
[type]: id,
select: null, // Exit selection mode
info: null // Exit info mode
})
}
const removeFilter = (type: string) => {
updateQuery({ [type]: null })
}
const editFilter = (type: string) => {
updateQuery({ select: type as SelectMode })
}
const setQuantity = (value: string) => {
const qty = value ? value : null
updateQuery({ qty })
}
// Set map bounds in URL (for filter by map feature)
const setBoundsInUrl = (bounds: { west: number; south: number; east: number; north: number } | null) => {
if (bounds) {
const boundsStr = `${bounds.west.toFixed(4)},${bounds.south.toFixed(4)},${bounds.east.toFixed(4)},${bounds.north.toFixed(4)}`
updateQuery({ bounds: boundsStr })
} else {
updateQuery({ bounds: null })
}
}
// Clear bounds from URL
const clearBoundsFromUrl = () => {
updateQuery({ bounds: null })
}
const openInfo = (type: InfoEntityType, uuid: string) => {
updateQuery({ info: `${type}:${uuid}`, select: null, infoTab: null, infoProduct: null })
}
const closeInfo = () => {
updateQuery({ info: null, infoTab: null, infoProduct: null })
}
const setInfoTab = (tab: string) => {
updateQuery({ infoTab: tab })
}
const setInfoProduct = (productUuid: string | null) => {
updateQuery({ infoProduct: productUuid })
}
const clearAll = () => {
if (isMainPage.value) {
router.push({ path: localePath('/catalog'), query: {} })
} else {
router.push({ query: {} })
}
}
// Text search (for filtering within current grid) - shared via useState
const searchQuery = useState<string>('catalog-search-query', () => '')
// Map view mode (stored in URL query param 'view')
const mapViewMode = computed<MapViewMode>(() => {
const view = route.query.view as string | undefined
if (view === 'hubs' || view === 'suppliers' || view === 'offers') {
return view
}
return 'offers' // default
})
const setMapViewMode = (mode: MapViewMode) => {
updateQuery({ view: mode === 'offers' ? null : mode })
}
// Drawer state for list view
const isDrawerOpen = ref(false)
const drawerSelectedItem = ref<{ uuid: string; name: string } | null>(null)
const openDrawer = () => {
isDrawerOpen.value = true
drawerSelectedItem.value = null
// Set selectMode based on mapViewMode so SelectionPanel shows the right list
const newSelectMode: SelectMode = mapViewMode.value === 'hubs' ? 'hub'
: mapViewMode.value === 'suppliers' ? 'supplier'
: 'product'
startSelect(newSelectMode)
}
const closeDrawer = () => {
isDrawerOpen.value = false
drawerSelectedItem.value = null
cancelSelect() // Also exit selection mode
}
const selectDrawerItem = (uuid: string, name: string) => {
drawerSelectedItem.value = { uuid, name }
}
const applyDrawerFilter = () => {
if (!drawerSelectedItem.value) {
closeDrawer()
return
}
// Determine filter type based on mapViewMode
const type = mapViewMode.value === 'hubs' ? 'hub'
: mapViewMode.value === 'suppliers' ? 'supplier'
: 'product' // offers -> select product from offer
const { uuid, name } = drawerSelectedItem.value
selectItem(type, uuid, name)
closeDrawer()
}
// Catalog mode: explore (map browsing) or quote (search for offers)
const catalogMode = computed<CatalogMode>(() => {
return route.query.mode === 'quote' ? 'quote' : 'explore'
})
const setCatalogMode = (newMode: CatalogMode) => {
updateQuery({ mode: newMode === 'explore' ? null : newMode })
}
// Can search for offers (product + hub or product + supplier required)
const canSearch = computed(() => {
return !!(productId.value && (hubId.value || supplierId.value))
})
// Labels for Quote mode display
const productLabel = computed(() => getLabel('product', productId.value))
const hubLabel = computed(() => getLabel('hub', hubId.value))
const supplierLabel = computed(() => getLabel('supplier', supplierId.value))
return {
// State
selectMode,
infoId,
infoTab,
infoProduct,
displayMode,
catalogMode,
productId,
supplierId,
hubId,
quantity,
searchQuery,
mapViewMode,
urlBounds,
filterByBounds,
// Drawer state
isDrawerOpen,
drawerSelectedItem,
// Colors
entityColors,
// Computed
activeTokens,
availableChips,
canSearch,
productLabel,
hubLabel,
supplierLabel,
// Actions
startSelect,
cancelSelect,
selectItem,
removeFilter,
editFilter,
setQuantity,
setBoundsInUrl,
clearBoundsFromUrl,
openInfo,
closeInfo,
setInfoTab,
setInfoProduct,
clearAll,
setLabel,
setMapViewMode,
setCatalogMode,
// Drawer actions
openDrawer,
closeDrawer,
selectDrawerItem,
applyDrawerFilter,
// Labels cache
filterLabels
}
}