Add Explore/Quote dual mode to catalog page
All checks were successful
Build Docker Image / build (push) Successful in 3m52s
All checks were successful
Build Docker Image / build (push) Successful in 3m52s
- Add CatalogMode type (explore/quote) to useCatalogSearch - Create ExplorePanel component with view toggle (offers/hubs/suppliers) - Create QuoteForm and QuotePanel components for search form - Refactor CatalogPage to fullscreen map with overlay panel - Simplify catalog/index.vue to use new components - Add translations for modes and quote form (ru/en) The catalog now has two modes: - Explore: Browse map with offers/hubs/suppliers toggle - Quote: Search form with product/hub/qty filters to find offers
This commit is contained in:
81
app/components/catalog/ExplorePanel.vue
Normal file
81
app/components/catalog/ExplorePanel.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- View toggle -->
|
||||
<div class="join join-horizontal">
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="{ 'btn-active': mapViewMode === 'offers' }"
|
||||
@click="setMapViewMode('offers')"
|
||||
>
|
||||
<Icon name="lucide:shopping-bag" size="16" />
|
||||
<span class="hidden sm:inline">{{ $t('catalog.views.offers') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="{ 'btn-active': mapViewMode === 'hubs' }"
|
||||
@click="setMapViewMode('hubs')"
|
||||
>
|
||||
<Icon name="lucide:warehouse" size="16" />
|
||||
<span class="hidden sm:inline">{{ $t('catalog.views.hubs') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm join-item"
|
||||
:class="{ 'btn-active': mapViewMode === 'suppliers' }"
|
||||
@click="setMapViewMode('suppliers')"
|
||||
>
|
||||
<Icon name="lucide:factory" size="16" />
|
||||
<span class="hidden sm:inline">{{ $t('catalog.views.suppliers') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Selected item info -->
|
||||
<div v-if="selectedItem" class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="card-title text-base">{{ selectedItem.name }}</h3>
|
||||
<p v-if="selectedItem.country" class="text-sm text-base-content/70">
|
||||
{{ selectedItem.country }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm btn-circle" @click="emit('close-selected')">
|
||||
<Icon name="lucide:x" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<button class="btn btn-primary btn-sm" @click="emit('view-details', selectedItem)">
|
||||
{{ $t('common.viewDetails') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint when nothing selected -->
|
||||
<div v-else class="text-sm text-base-content/60">
|
||||
<p>{{ $t('catalog.explore.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface SelectedItem {
|
||||
uuid: string
|
||||
name?: string
|
||||
country?: string
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
selectedItem?: SelectedItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close-selected': []
|
||||
'view-details': [item: SelectedItem]
|
||||
}>()
|
||||
|
||||
const { mapViewMode, setMapViewMode } = useCatalogSearch()
|
||||
</script>
|
||||
129
app/components/catalog/QuoteForm.vue
Normal file
129
app/components/catalog/QuoteForm.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-semibold text-lg">{{ $t('catalog.quote.title') }}</h3>
|
||||
|
||||
<!-- Product chip -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs text-base-content/70">{{ $t('catalog.filters.product') }}</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="productLabel"
|
||||
class="btn btn-outline btn-sm justify-start gap-2"
|
||||
@click="emit('edit-filter', 'product')"
|
||||
>
|
||||
<Icon name="lucide:package" size="16" />
|
||||
<span class="flex-1 text-left truncate">{{ productLabel }}</span>
|
||||
<span
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
@click.stop="emit('remove-filter', 'product')"
|
||||
>
|
||||
<Icon name="lucide:x" size="14" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-ghost btn-sm justify-start gap-2 border border-dashed border-base-content/30"
|
||||
@click="emit('edit-filter', 'product')"
|
||||
>
|
||||
<Icon name="lucide:plus" size="16" class="text-base-content/50" />
|
||||
<span class="text-base-content/50">{{ $t('catalog.quote.selectProduct') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hub chip -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs text-base-content/70">{{ $t('catalog.filters.hub') }}</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="hubLabel"
|
||||
class="btn btn-outline btn-sm justify-start gap-2"
|
||||
@click="emit('edit-filter', 'hub')"
|
||||
>
|
||||
<Icon name="lucide:map-pin" size="16" />
|
||||
<span class="flex-1 text-left truncate">{{ hubLabel }}</span>
|
||||
<span
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
@click.stop="emit('remove-filter', 'hub')"
|
||||
>
|
||||
<Icon name="lucide:x" size="14" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-ghost btn-sm justify-start gap-2 border border-dashed border-base-content/30"
|
||||
@click="emit('edit-filter', 'hub')"
|
||||
>
|
||||
<Icon name="lucide:plus" size="16" class="text-base-content/50" />
|
||||
<span class="text-base-content/50">{{ $t('catalog.quote.selectHub') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quantity input -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs text-base-content/70">{{ $t('catalog.filters.quantity') }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="localQuantity"
|
||||
type="number"
|
||||
:placeholder="$t('catalog.quote.enterQty')"
|
||||
class="input input-bordered input-sm"
|
||||
min="1"
|
||||
@blur="emit('update-quantity', localQuantity)"
|
||||
@keyup.enter="emit('update-quantity', localQuantity)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="btn btn-primary flex-1"
|
||||
:disabled="!canSearch"
|
||||
@click="emit('search')"
|
||||
>
|
||||
<Icon name="lucide:search" size="16" />
|
||||
{{ $t('catalog.quote.search') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasAnyFilter"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="emit('clear-all')"
|
||||
>
|
||||
{{ $t('catalog.quote.clear') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
productId?: string
|
||||
productLabel?: string
|
||||
hubId?: string
|
||||
hubLabel?: string
|
||||
supplierId?: string
|
||||
supplierLabel?: string
|
||||
quantity?: string
|
||||
canSearch: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'edit-filter': [type: string]
|
||||
'remove-filter': [type: string]
|
||||
'update-quantity': [value: string]
|
||||
'search': []
|
||||
'clear-all': []
|
||||
}>()
|
||||
|
||||
const localQuantity = ref(props.quantity || '')
|
||||
|
||||
watch(() => props.quantity, (newVal) => {
|
||||
localQuantity.value = newVal || ''
|
||||
})
|
||||
|
||||
const hasAnyFilter = computed(() => {
|
||||
return !!(props.productId || props.hubId || props.supplierId || props.quantity)
|
||||
})
|
||||
</script>
|
||||
81
app/components/catalog/QuotePanel.vue
Normal file
81
app/components/catalog/QuotePanel.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<!-- Quote form -->
|
||||
<QuoteForm
|
||||
:product-id="productId"
|
||||
:product-label="productLabel"
|
||||
:hub-id="hubId"
|
||||
:hub-label="hubLabel"
|
||||
:supplier-id="supplierId"
|
||||
:supplier-label="supplierLabel"
|
||||
:quantity="quantity"
|
||||
:can-search="canSearch"
|
||||
@edit-filter="emit('edit-filter', $event)"
|
||||
@remove-filter="emit('remove-filter', $event)"
|
||||
@update-quantity="emit('update-quantity', $event)"
|
||||
@search="emit('search')"
|
||||
@clear-all="emit('clear-all')"
|
||||
/>
|
||||
|
||||
<!-- Divider -->
|
||||
<div v-if="showResults" class="divider my-0" />
|
||||
|
||||
<!-- Results section -->
|
||||
<div v-if="showResults" class="flex-1 overflow-y-auto">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="offers.length === 0" class="text-center py-8 text-base-content/60">
|
||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||
<p>{{ $t('catalog.empty.noOffers') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<Text tone="muted" class="text-sm">
|
||||
{{ $t('catalog.headers.offers') }}: {{ offers.length }}
|
||||
</Text>
|
||||
<div
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
class="cursor-pointer"
|
||||
@click="emit('select-offer', offer)"
|
||||
>
|
||||
<slot name="offer-card" :offer="offer">
|
||||
<OfferCard :offer="offer" linkable />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Offer {
|
||||
uuid: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
productId?: string
|
||||
productLabel?: string
|
||||
hubId?: string
|
||||
hubLabel?: string
|
||||
supplierId?: string
|
||||
supplierLabel?: string
|
||||
quantity?: string
|
||||
canSearch: boolean
|
||||
showResults: boolean
|
||||
loading: boolean
|
||||
offers: Offer[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'edit-filter': [type: string]
|
||||
'remove-filter': [type: string]
|
||||
'update-quantity': [value: string]
|
||||
'search': []
|
||||
'clear-all': []
|
||||
'select-offer': [offer: Offer]
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user