All checks were successful
Build Docker Image / build (push) Successful in 4m14s
- 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.
426 lines
12 KiB
TypeScript
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
|
|
}
|
|
}
|