Refactor: use topnav layout + CatalogPage component
All checks were successful
Build Docker Image / build (push) Successful in 4m11s
All checks were successful
Build Docker Image / build (push) Successful in 4m11s
- Remove catalog.vue layout and useCatalogLayout.ts (broken provide/inject) - All catalog/clientarea list pages now use topnav layout - Pages use CatalogPage component for SearchBar + Map functionality - Clean architecture: layout handles nav, component handles features
This commit is contained in:
@@ -1,81 +1,101 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Add button -->
|
||||
<div class="mb-4">
|
||||
<CatalogPage
|
||||
:items="displayItems"
|
||||
:map-items="itemsWithCoords"
|
||||
:loading="isLoading"
|
||||
with-map
|
||||
map-id="seller-offers-map"
|
||||
point-color="#f59e0b"
|
||||
:selected-id="selectedOfferId"
|
||||
:hovered-id="hoveredOfferId"
|
||||
:total-count="totalOffers"
|
||||
@select="onSelectOffer"
|
||||
@update:hovered-id="hoveredOfferId = $event"
|
||||
>
|
||||
<template #searchBar="{ displayedCount, totalCount }">
|
||||
<CatalogSearchBar
|
||||
v-model:search-query="searchQuery"
|
||||
:active-filters="activeFilterBadges"
|
||||
:displayed-count="displayedCount"
|
||||
:total-count="totalCount"
|
||||
@remove-filter="onRemoveFilter"
|
||||
@search="onSearch"
|
||||
>
|
||||
<template #filters>
|
||||
<div class="p-2 space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('clientOffersList.filters.status_label') }}</div>
|
||||
<ul class="menu menu-compact">
|
||||
<li v-for="filter in statusFilters" :key="filter.id">
|
||||
<a
|
||||
:class="{ 'active': selectedStatus === filter.id }"
|
||||
@click="selectedStatus = filter.id"
|
||||
>{{ filter.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CatalogSearchBar>
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<!-- 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>
|
||||
<Button @click="loadOffers">{{ t('clientOffersList.error.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<!-- Add button -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
<Button @click="loadOffers">{{ t('clientOffersList.error.retry') }}</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<!-- List content -->
|
||||
<div v-else-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<template #card="{ item }">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientOffersList.states.loading') }}</Text>
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" justify="between" align="center">
|
||||
<Heading :level="3">{{ item.productName || t('clientOffersList.labels.untitled') }}</Heading>
|
||||
<Badge :variant="getStatusVariant(item.status)">
|
||||
{{ getStatusText(item.status) }}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Text v-if="item.categoryName" tone="muted">{{ item.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">{{ item.quantity }} {{ item.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(item.pricePerUnit, item.currency) }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.location') }}</Text>
|
||||
<Text>{{ item.locationName || t('clientOffersList.labels.not_specified') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="1">
|
||||
<Text size="sm" tone="muted">{{ t('clientOffersList.labels.valid_until') }}</Text>
|
||||
<Text>{{ formatDate(item.validUntil) }}</Text>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<PaginationLoadMore
|
||||
v-if="offers.length > 0"
|
||||
:shown="offers.length"
|
||||
@@ -85,29 +105,27 @@
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<template #empty>
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
</CatalogPage>
|
||||
</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: 'catalog',
|
||||
layout: 'topnav',
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
@@ -131,10 +149,6 @@ const searchQuery = ref('')
|
||||
const searchWithMap = ref(false)
|
||||
const currentBounds = ref<MapBounds | null>(null)
|
||||
|
||||
const onBoundsChange = (bounds: MapBounds) => {
|
||||
currentBounds.value = bounds
|
||||
}
|
||||
|
||||
const {
|
||||
data: offersData,
|
||||
pending: offersPending,
|
||||
@@ -187,14 +201,6 @@ const displayItems = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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(() => [
|
||||
@@ -230,59 +236,6 @@ const onSelectOffer = (offer: any) => {
|
||||
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