Refactor catalog layout: replace Teleport with provide/inject
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Create useCatalogLayout composable for data transfer from pages to layout - Layout now owns SearchBar and CatalogMap components directly - Pages provide data via provideCatalogLayout() - Fixes navigation glitches (multiple SearchBars) when switching tabs - Support custom subNavItems for clientarea pages - Unify 6 pages to use catalog layout: - catalog/offers, suppliers, hubs - clientarea/orders, addresses, offers
This commit is contained in:
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<Stack gap="6">
|
||||
<PageHeader :title="t('clientOffersList.header.title')">
|
||||
<template #actions>
|
||||
<NuxtLink :to="localePath('/clientarea/offers/new')">
|
||||
<Button>
|
||||
<Icon name="lucide:plus" size="16" class="mr-2" />
|
||||
{{ t('clientOffersList.actions.add') }}
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<div>
|
||||
<!-- Add button -->
|
||||
<div class="mb-4">
|
||||
<NuxtLink :to="localePath('/clientarea/offers/new')">
|
||||
<Button class="w-full">
|
||||
<Icon name="lucide:plus" size="16" class="mr-2" />
|
||||
{{ t('clientOffersList.actions.add') }}
|
||||
</Button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<!-- Error state -->
|
||||
<Alert v-if="hasError" variant="error" class="mb-4">
|
||||
<Stack gap="2">
|
||||
<Heading :level="4" weight="semibold">{{ t('clientOffersList.error.title') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
@@ -19,75 +19,95 @@
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Stack v-else-if="isLoading" align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientOffersList.states.loading') }}</Text>
|
||||
</Stack>
|
||||
<!-- List content -->
|
||||
<div v-else-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientOffersList.states.loading') }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<Stack v-if="offers.length" gap="4">
|
||||
<Card v-for="offer in offers" :key="offer.uuid" padding="lg">
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="3">{{ offer.productName || t('clientOffersList.labels.untitled') }}</Heading>
|
||||
<Badge :variant="getStatusVariant(offer.status)">
|
||||
{{ getStatusText(offer.status) }}
|
||||
</Badge>
|
||||
<Stack gap="3">
|
||||
<div
|
||||
v-for="offer in displayItems"
|
||||
:key="offer.uuid"
|
||||
:class="{ 'ring-2 ring-primary rounded-lg': offer.uuid === selectedOfferId }"
|
||||
@click="onSelectOffer(offer)"
|
||||
@mouseenter="hoveredOfferId = offer.uuid"
|
||||
@mouseleave="hoveredOfferId = undefined"
|
||||
>
|
||||
<Card padding="lg">
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="3">{{ offer.productName || t('clientOffersList.labels.untitled') }}</Heading>
|
||||
<Badge :variant="getStatusVariant(offer.status)">
|
||||
{{ getStatusText(offer.status) }}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Text v-if="offer.categoryName" tone="muted">{{ offer.categoryName }}</Text>
|
||||
|
||||
<Grid :cols="1" :md="4" :gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.quantity') }}</Text>
|
||||
<Text weight="semibold">{{ offer.quantity }} {{ offer.unit || t('search.units.tons_short') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.price') }}</Text>
|
||||
<Text weight="semibold">{{ formatPrice(offer.pricePerUnit, offer.currency) }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.location') }}</Text>
|
||||
<Text>{{ offer.locationName || t('clientOffersList.labels.not_specified') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.valid_until') }}</Text>
|
||||
<Text>{{ formatDate(offer.validUntil) }}</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Text v-if="offer.categoryName" tone="muted">{{ offer.categoryName }}</Text>
|
||||
|
||||
<Grid :cols="1" :md="4" :gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.quantity') }}</Text>
|
||||
<Text weight="semibold">{{ offer.quantity }} {{ offer.unit || t('search.units.tons_short') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.price') }}</Text>
|
||||
<Text weight="semibold">{{ formatPrice(offer.pricePerUnit, offer.currency) }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.location') }}</Text>
|
||||
<Text>{{ offer.locationName || t('clientOffersList.labels.not_specified') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.valid_until') }}</Text>
|
||||
<Text>{{ formatDate(offer.validUntil) }}</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<PaginationLoadMore
|
||||
:shown="offers.length"
|
||||
:total="totalOffers"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<EmptyState
|
||||
v-else
|
||||
icon="🏷️"
|
||||
:title="t('clientOffersList.empty.title')"
|
||||
:description="t('clientOffersList.empty.subtitle')"
|
||||
:action-label="t('clientOffersList.actions.addOffer')"
|
||||
:action-to="localePath('/clientarea/offers/new')"
|
||||
action-icon="lucide:plus"
|
||||
<PaginationLoadMore
|
||||
v-if="offers.length > 0"
|
||||
:shown="offers.length"
|
||||
:total="totalOffers"
|
||||
:can-load-more="canLoadMore"
|
||||
:loading="isLoadingMore"
|
||||
@load-more="loadMore"
|
||||
class="mt-4"
|
||||
/>
|
||||
|
||||
<Stack v-if="displayItems.length === 0" align="center" gap="2" class="py-8">
|
||||
<EmptyState
|
||||
icon="🏷️"
|
||||
:title="t('clientOffersList.empty.title')"
|
||||
:description="t('clientOffersList.empty.subtitle')"
|
||||
:action-label="t('clientOffersList.actions.addOffer')"
|
||||
:action-to="localePath('/clientarea/offers/new')"
|
||||
action-icon="lucide:plus"
|
||||
/>
|
||||
</Stack>
|
||||
</template>
|
||||
</Stack>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, defineComponent } from 'vue'
|
||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||
import { provideCatalogLayout } from '~/composables/useCatalogLayout'
|
||||
import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'topnav',
|
||||
layout: 'catalog',
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
@@ -101,6 +121,20 @@ const offers = ref<any[]>([])
|
||||
const totalOffers = ref(0)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const selectedOfferId = ref<string>()
|
||||
const hoveredOfferId = ref<string>()
|
||||
|
||||
// Search bar
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Search with map checkbox
|
||||
const searchWithMap = ref(false)
|
||||
const currentBounds = ref<MapBounds | null>(null)
|
||||
|
||||
const onBoundsChange = (bounds: MapBounds) => {
|
||||
currentBounds.value = bounds
|
||||
}
|
||||
|
||||
const {
|
||||
data: offersData,
|
||||
pending: offersPending,
|
||||
@@ -126,6 +160,129 @@ const canLoadMore = computed(() => offers.value.length < totalOffers.value)
|
||||
const hasError = computed(() => !!loadError.value)
|
||||
const error = computed(() => loadError.value?.message || t('clientOffersList.error.load'))
|
||||
|
||||
// Map items with coordinates
|
||||
const itemsWithCoords = computed(() =>
|
||||
offers.value.filter(item =>
|
||||
item.locationLatitude != null &&
|
||||
item.locationLongitude != null &&
|
||||
!isNaN(Number(item.locationLatitude)) &&
|
||||
!isNaN(Number(item.locationLongitude))
|
||||
).map(item => ({
|
||||
uuid: item.uuid,
|
||||
name: item.productName || '',
|
||||
latitude: Number(item.locationLatitude),
|
||||
longitude: Number(item.locationLongitude)
|
||||
}))
|
||||
)
|
||||
|
||||
// Filtered items when searchWithMap is enabled
|
||||
const displayItems = computed(() => {
|
||||
if (!searchWithMap.value || !currentBounds.value) return offers.value
|
||||
return offers.value.filter(item => {
|
||||
if (item.locationLatitude == null || item.locationLongitude == null) return false
|
||||
const { west, east, north, south } = currentBounds.value!
|
||||
const lng = Number(item.locationLongitude)
|
||||
const lat = Number(item.locationLatitude)
|
||||
return lng >= west && lng <= east && lat >= south && lat <= north
|
||||
})
|
||||
})
|
||||
|
||||
// Hovered item with coordinates for map highlight
|
||||
const hoveredItem = computed(() => {
|
||||
if (!hoveredOfferId.value) return null
|
||||
const item = offers.value.find(i => i.uuid === hoveredOfferId.value)
|
||||
if (!item?.locationLatitude || !item?.locationLongitude) return null
|
||||
return { latitude: Number(item.locationLatitude), longitude: Number(item.locationLongitude) }
|
||||
})
|
||||
|
||||
// Active filter badges (status filter)
|
||||
const selectedStatus = ref<string>('all')
|
||||
const statusFilters = computed(() => [
|
||||
{ id: 'all', label: t('clientOffersList.filters.all') },
|
||||
{ id: 'active', label: t('clientOffersList.status.active') },
|
||||
{ id: 'draft', label: t('clientOffersList.status.draft') },
|
||||
{ id: 'expired', label: t('clientOffersList.status.expired') },
|
||||
])
|
||||
|
||||
const activeFilterBadges = computed(() => {
|
||||
const badges: { id: string; label: string }[] = []
|
||||
if (selectedStatus.value && selectedStatus.value !== 'all') {
|
||||
const filter = statusFilters.value.find(f => f.id === selectedStatus.value)
|
||||
if (filter) badges.push({ id: `status:${filter.id}`, label: filter.label })
|
||||
}
|
||||
return badges
|
||||
})
|
||||
|
||||
// Remove filter badge
|
||||
const onRemoveFilter = (id: string) => {
|
||||
if (id.startsWith('status:')) {
|
||||
selectedStatus.value = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
// Search handler
|
||||
const onSearch = () => {
|
||||
// TODO: Implement search
|
||||
}
|
||||
|
||||
const onSelectOffer = (offer: any) => {
|
||||
selectedOfferId.value = offer.uuid
|
||||
navigateTo(localePath(`/clientarea/offers/${offer.uuid}`))
|
||||
}
|
||||
|
||||
// Handle selection from map
|
||||
const onMapSelect = (uuid: string) => {
|
||||
const offer = offers.value.find(i => i.uuid === uuid)
|
||||
if (offer) {
|
||||
selectedOfferId.value = uuid
|
||||
navigateTo(localePath(`/clientarea/offers/${uuid}`))
|
||||
}
|
||||
}
|
||||
|
||||
// Filter component for the layout
|
||||
const SellerOffersFilterComponent = defineComponent({
|
||||
setup() {
|
||||
return () => h('div', { class: 'p-2 space-y-3' }, [
|
||||
h('div', {}, [
|
||||
h('div', { class: 'text-xs font-semibold mb-1 text-base-content/70' }, t('clientOffersList.filters.status_label')),
|
||||
h('ul', { class: 'menu menu-compact' },
|
||||
statusFilters.value.map(filter =>
|
||||
h('li', { key: filter.id },
|
||||
h('a', {
|
||||
class: selectedStatus.value === filter.id ? 'active' : '',
|
||||
onClick: () => { selectedStatus.value = filter.id }
|
||||
}, filter.label)
|
||||
)
|
||||
)
|
||||
)
|
||||
])
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
// Provide data to layout
|
||||
provideCatalogLayout({
|
||||
// Custom subnav for seller clientarea
|
||||
subNavItems: [
|
||||
{ label: t('cabinetNav.myOffers'), path: '/clientarea/offers' },
|
||||
],
|
||||
searchQuery,
|
||||
activeFilters: activeFilterBadges,
|
||||
displayedCount: computed(() => displayItems.value.length),
|
||||
totalCount: computed(() => totalOffers.value),
|
||||
onSearch,
|
||||
onRemoveFilter,
|
||||
filterComponent: SellerOffersFilterComponent,
|
||||
mapItems: itemsWithCoords,
|
||||
mapId: 'seller-offers-map',
|
||||
pointColor: '#f59e0b',
|
||||
hoveredItemId: hoveredOfferId,
|
||||
hoveredItem,
|
||||
onMapSelect,
|
||||
onBoundsChange,
|
||||
searchWithMap
|
||||
})
|
||||
|
||||
const getStatusVariant = (status: string) => {
|
||||
const variants: Record<string, string> = {
|
||||
active: 'success',
|
||||
|
||||
Reference in New Issue
Block a user