Files
webapp/app/pages/catalog/offers/detail/[offerId].vue
Ruslan Bakiev 2dbe600d8a
All checks were successful
Build Docker Image / build (push) Successful in 4m3s
refactor: remove all any types, add strict GraphQL scalar typing
- Add strictScalars: true to codegen.ts with proper scalar mappings
  (Date, Decimal, JSONString, JSON, UUID, BigInt → string/Record)
- Replace all ref<any[]> with proper GraphQL-derived types
- Add type guards for null filtering in arrays
- Fix bugs exposed by typing (locationLatitude vs latitude, etc.)
- Add interfaces for external components (MapboxSearchBox)

This enables end-to-end type safety from GraphQL schema to frontend.
2026-01-27 11:34:12 +07:00

180 lines
5.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Section variant="plain">
<Stack gap="6">
<!-- Loading -->
<Card v-if="isLoading" padding="lg">
<Stack align="center" gap="3">
<Spinner />
<Text tone="muted">Загрузка оффера...</Text>
</Stack>
</Card>
<!-- Error -->
<Alert v-else-if="!offer" variant="error">
<Text>Оффер не найден</Text>
</Alert>
<!-- Content -->
<template v-else>
<!-- Offer Header -->
<Card padding="lg">
<Stack gap="4">
<!-- Product & Price -->
<div class="flex items-start justify-between">
<div>
<Text weight="semibold" size="xl">{{ offer.productName || 'Товар' }}</Text>
<Text v-if="offer.locationName" tone="muted">{{ offer.locationName }}</Text>
</div>
<Text v-if="priceDisplay" weight="bold" class="text-primary text-2xl">
{{ priceDisplay }}
</Text>
</div>
<!-- Quantity -->
<div v-if="offer.quantity" class="flex items-center gap-2">
<Icon name="lucide:package" size="16" class="text-base-content/60" />
<Text tone="muted">Доступно: {{ offer.quantity }} {{ offer.unit || 'т' }}</Text>
</div>
<!-- Location on map -->
<div v-if="offer.locationLatitude && offer.locationLongitude" class="h-48 rounded-lg overflow-hidden">
<ClientOnly>
<MapboxMap
map-id="offer-location-map"
class="w-full h-full"
:options="{
style: 'mapbox://styles/mapbox/streets-v12',
center: [offer.locationLongitude, offer.locationLatitude],
zoom: 8
}"
>
<MapboxDefaultMarker
:lnglat="[offer.locationLongitude, offer.locationLatitude]"
color="#10b981"
/>
</MapboxMap>
</ClientOnly>
</div>
</Stack>
</Card>
<!-- Supplier Info -->
<Card v-if="supplier" padding="lg">
<Stack gap="3">
<Text weight="semibold" size="lg">Поставщик</Text>
<div class="flex items-center gap-3">
<div v-if="supplier.logoUrl" class="w-12 h-12 rounded-full overflow-hidden bg-base-200">
<img :src="supplier.logoUrl" :alt="supplier.name" class="w-full h-full object-cover" />
</div>
<div v-else class="w-12 h-12 rounded-full bg-base-200 flex items-center justify-center">
<Icon name="lucide:building-2" size="24" class="text-base-content/40" />
</div>
<div>
<Text weight="medium" size="lg">{{ supplier.name }}</Text>
<Text v-if="supplier.country" tone="muted">{{ supplier.country }}</Text>
</div>
<div v-if="supplier.isVerified" class="badge badge-success ml-auto">Верифицирован</div>
</div>
</Stack>
</Card>
<!-- KYC Profile (full company info) -->
<KycProfileCard v-if="supplier?.kycProfileUuid" :kyc-profile-uuid="supplier.kycProfileUuid" />
<!-- Actions -->
<Card padding="lg">
<Stack gap="3">
<Button variant="primary" size="lg" class="w-full">
<Icon name="lucide:message-circle" size="18" />
Связаться с поставщиком
</Button>
<Button variant="outline" size="lg" class="w-full" @click="goBack">
<Icon name="lucide:arrow-left" size="18" />
Назад к результатам
</Button>
</Stack>
</Card>
</template>
</Stack>
</Section>
</template>
<script setup lang="ts">
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
type Offer = NonNullable<GetOfferQueryResult['getOffer']>
type SupplierProfile = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
definePageMeta({
layout: 'topnav'
})
const route = useRoute()
const router = useRouter()
const { execute } = useGraphQL()
const offerId = computed(() => route.params.offerId as string)
const isLoading = ref(true)
const offer = ref<Offer | null>(null)
const supplier = ref<SupplierProfile | null>(null)
// Load offer data
const loadOffer = async () => {
if (!offerId.value) return
isLoading.value = true
try {
const data = await execute(GetOfferDocument, { uuid: offerId.value }, 'public', 'exchange')
offer.value = data?.getOffer || null
// Load supplier if we have teamUuid
if (offer.value?.teamUuid) {
const supplierData = await execute(
GetSupplierProfileByTeamDocument,
{ teamUuid: offer.value.teamUuid },
'public',
'exchange'
)
supplier.value = supplierData?.getSupplierProfileByTeam || null
}
} catch (error) {
console.error('Error loading offer:', error)
} finally {
isLoading.value = false
}
}
await loadOffer()
// Price display
const priceDisplay = computed(() => {
if (!offer.value?.pricePerUnit) return null
const currSymbol = getCurrencySymbol(offer.value.currency)
const unitName = offer.value.unit || 'т'
return `${currSymbol}${offer.value.pricePerUnit.toLocaleString()}/${unitName}`
})
const getCurrencySymbol = (currency?: string | null) => {
switch (currency?.toUpperCase()) {
case 'USD': return '$'
case 'EUR': return '€'
case 'RUB': return '₽'
case 'CNY': return '¥'
default: return '$'
}
}
const goBack = () => {
router.back()
}
// SEO
useHead(() => ({
title: offer.value?.productName
? `${offer.value.productName} - Оффер`
: 'Оффер'
}))
</script>