Files
service-finder/frontend/src/components/provider/ProviderDetailModal.vue
2026-06-17 22:07:55 +00:00

368 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<div
v-if="isOpen && provider"
class="fixed inset-0 z-[10001] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="emit('close')"
>
<div
class="relative w-full max-w-lg mx-4 rounded-2xl border border-slate-200 bg-white shadow-2xl overflow-hidden animate-fade-in-up"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-sf-accent to-sf-blue text-white shadow-sm">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-slate-800">{{ t('provider.detailTitle') }}</h3>
</div>
</div>
<button
@click="emit('close')"
class="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-500 cursor-pointer"
:aria-label="t('serviceFinder.close')"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="p-6 space-y-5">
<!-- Provider Name + Source Badge -->
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<h2 class="text-2xl font-extrabold text-slate-800 truncate">{{ provider.name }}</h2>
<span
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold mt-1"
:class="sourceBadgeClass"
>
{{ sourceLabel }}
</span>
</div>
</div>
<!-- Categories (chips/badges) -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailCategories') }}</p>
<div v-if="provider.categories && provider.categories.length > 0" class="flex flex-wrap gap-1.5">
<span
v-for="cat in provider.categories"
:key="cat.id"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="categoryBadgeClass(cat.level)"
>
{{ categoryName(cat) }}
</span>
</div>
<p v-else class="text-sm text-slate-400 italic">{{ t('provider.detailNoCategories') }}</p>
</div>
<!-- Contact Grid -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailContact') }}</p>
<div class="grid grid-cols-1 gap-2">
<!-- Phone -->
<a
v-if="provider.contact_phone"
:href="'tel:' + provider.contact_phone"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span>{{ provider.contact_phone }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoPhone') }}</span>
</div>
<!-- Email -->
<a
v-if="provider.contact_email"
:href="'mailto:' + provider.contact_email"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="truncate">{{ provider.contact_email }}</span>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoEmail') }}</span>
</div>
<!-- Website -->
<a
v-if="provider.website"
:href="provider.website"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 transition-colors hover:bg-sf-accent/10 hover:text-sf-accent group"
>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent transition-colors group-hover:bg-sf-accent group-hover:text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<span class="truncate">{{ provider.website }}</span>
<svg class="h-3.5 w-3.5 shrink-0 text-slate-400 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<div v-else class="flex items-center gap-3 rounded-xl bg-slate-50 px-4 py-3 text-sm text-slate-400">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
</div>
<span class="italic">{{ t('provider.detailNoWebsite') }}</span>
</div>
</div>
</div>
<!-- Location & Plus Code -->
<div v-if="hasAddress || provider.plus_code">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">{{ t('provider.detailLocation') }}</p>
<div class="rounded-xl bg-slate-50 p-4 space-y-3">
<!-- Address -->
<div v-if="hasAddress" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<p class="text-sm font-medium text-slate-700">{{ formattedAddress }}</p>
</div>
<!-- Plus Code -->
<div v-if="provider.plus_code" class="flex items-start gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sf-accent/10 text-sf-accent">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
</div>
<p class="text-sm font-medium text-slate-700 font-mono">{{ provider.plus_code }}</p>
</div>
<!-- Navigate Button (Google Maps) -->
<a
:href="googleMapsUrl"
target="_blank"
rel="noopener noreferrer"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
{{ t('provider.detailNavigate') }}
</a>
</div>
</div>
<!-- Divider -->
<hr class="border-slate-200" />
<!-- Edit / Complete Data Button -->
<button
@click="emit('edit-request', provider)"
class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-sf-accent to-sf-blue px-6 py-3.5 text-sm font-bold text-white shadow-md transition-all hover:shadow-lg hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{{ t('serviceFinder.editRequest') }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
// ── Types matching backend ProviderSearchResult ──
interface CategoryInfo {
id: number
name_hu: string | null
name_en: string | null
level: number
key: string
}
interface ProviderSearchResult {
id: number
name: string
category: string | null
specialization: string[]
categories: CategoryInfo[]
city: string | null
address: string | null
address_zip: string | null
address_street_name: string | null
address_street_type: string | null
address_house_number: string | null
plus_code: string | null
contact_phone: string | null
contact_email: string | null
website: string | null
tags: string[]
source: 'verified_org' | 'staged_data' | 'crowd_added'
is_verified: boolean
rating: number | null
}
// ── Props ──
const props = defineProps<{
isOpen: boolean
provider: ProviderSearchResult | null
}>()
// ── Emits ──
const emit = defineEmits<{
(e: 'close'): void
(e: 'edit-request', provider: ProviderSearchResult): void
}>()
// ── Computed helpers ──
const sourceBadgeClass = computed(() => {
switch (props.provider?.source) {
case 'verified_org': return 'bg-emerald-100 text-emerald-700'
case 'crowd_added': return 'bg-amber-100 text-amber-700'
case 'staged_data': return 'bg-slate-100 text-slate-600'
default: return 'bg-slate-100 text-slate-600'
}
})
const sourceLabel = computed(() => {
switch (props.provider?.source) {
case 'verified_org': return t('serviceFinder.sourceVerified')
case 'crowd_added': return t('serviceFinder.sourceCommunity')
case 'staged_data': return t('serviceFinder.sourceRobot')
default: return props.provider?.source || ''
}
})
/**
* Category badge color based on level.
* Level 0 = Vehicle Type (purple), Level 1 = Industry (blue),
* Level 2 = Profession (teal), Level 3 = Specific Tag (slate).
*/
function categoryBadgeClass(level: number): string {
switch (level) {
case 0: return 'bg-purple-100 text-purple-700'
case 1: return 'bg-blue-100 text-blue-700'
case 2: return 'bg-teal-100 text-teal-700'
case 3: return 'bg-slate-100 text-slate-600'
default: return 'bg-slate-100 text-slate-600'
}
}
/**
* Get the localized category name based on current locale.
*/
function categoryName(cat: CategoryInfo): string {
if (locale.value === 'hu' && cat.name_hu) return cat.name_hu
if (cat.name_en) return cat.name_en
return cat.key
}
/**
* P1 CRITICAL ALIGN: Atomizált címmezők intelligens összefűzése.
*/
const hasAddress = computed(() => {
const p = props.provider
if (!p) return false
return !!(p.city || p.address_street_name || p.address_street_type || p.address_house_number)
})
const formattedAddress = computed(() => {
const p = props.provider
if (!p) return ''
const parts: string[] = []
// ZIP + City
const locationParts: string[] = []
if (p.address_zip) locationParts.push(p.address_zip)
if (p.city) locationParts.push(p.city)
if (locationParts.length > 0) parts.push(locationParts.join(' '))
// Atomizált cím: street_name + street_type + house_number
const streetParts: string[] = []
if (p.address_street_name) streetParts.push(p.address_street_name)
if (p.address_street_type) streetParts.push(p.address_street_type)
if (p.address_house_number) streetParts.push(p.address_house_number)
if (streetParts.length > 0) {
parts.push(streetParts.join(' '))
}
return parts.join(', ')
})
/**
* Google Maps URL: prefer plus_code, fall back to address query.
*/
const googleMapsUrl = computed(() => {
const p = props.provider
if (!p) return 'https://maps.google.com'
if (p.plus_code) {
return `https://maps.google.com/maps?q=${encodeURIComponent(p.plus_code)}`
}
const queryParts: string[] = []
if (p.address_street_name) queryParts.push(p.address_street_name)
if (p.address_street_type) queryParts.push(p.address_street_type)
if (p.address_house_number) queryParts.push(p.address_house_number)
if (p.city) queryParts.push(p.city)
if (p.address_zip) queryParts.push(p.address_zip)
if (queryParts.length > 0) {
return `https://maps.google.com/maps?q=${encodeURIComponent(queryParts.join(' '))}`
}
return `https://maps.google.com/maps?q=${encodeURIComponent(p.name)}`
})
</script>
<style scoped>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.animate-fade-in-up {
animation: fadeInUp 0.25s ease-out forwards;
}
</style>