frontend 2026-06-10 bontva a 2 felület
This commit is contained in:
@@ -1,301 +0,0 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
|
||||
>
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<!-- ── Left: Logo + Brand (clickable, links to /) ─────────────── -->
|
||||
<router-link to="/dashboard" class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]">
|
||||
<!-- Real brand logo with premium gradient -->
|
||||
<img
|
||||
src="@/assets/sf-brand-logo.svg"
|
||||
class="h-10 w-auto mr-3 drop-shadow-md"
|
||||
alt="ServiceFinder Logo"
|
||||
/>
|
||||
|
||||
<!-- Brand text (hidden on very small screens) -->
|
||||
<div class="hidden sm:block">
|
||||
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
|
||||
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<!-- ── Center/Left: Funkciók Menu ─────────────────────────────── -->
|
||||
<div class="relative flex items-center func-menu-container">
|
||||
<!-- Funkciók trigger button -->
|
||||
<button
|
||||
@click.stop="isFuncMenuOpen = !isFuncMenuOpen"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
:aria-label="t('menu.features')"
|
||||
>
|
||||
<!-- Hamburger / grid icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ t('menu.features') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- ── Funkciók Dropdown (Glassmorphism Bento) ──────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isFuncMenuOpen"
|
||||
class="absolute left-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-2">
|
||||
<!-- Járművek (Garázs) -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
<span>{{ t('menu.garage') }} <span class="text-white/40">({{ t('menu.garage') }})</span></span>
|
||||
</button>
|
||||
|
||||
<!-- Szerviznaptár -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>{{ t('menu.calendar') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Pénzügyek és Költségek -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ t('menu.finance') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Élő Diagnosztika -->
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>{{ t('menu.diagnostic') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/10 my-1" />
|
||||
|
||||
<!-- Kiemelt: + Új Jármű Hozzáadása -->
|
||||
<div class="py-2 px-3">
|
||||
<button
|
||||
@click="isFuncMenuOpen = false"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#418890] to-[#70BC84] px-4 py-3 text-sm font-bold text-white shadow-lg transition-all duration-200 hover:shadow-[0_0_20px_rgba(0,229,160,0.4)] hover:-translate-y-0.5"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('menu.addVehicle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- ── Spacer to push right items ─────────────────────────────── -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- ── Language Switcher (extracted component) ────────────────── -->
|
||||
<LanguageSwitcher />
|
||||
|
||||
<!-- ── Right: Welcome + User Dropdown ─────────────────────────── -->
|
||||
<div class="relative flex items-center gap-4">
|
||||
<!-- Welcome text -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-medium text-white/90">
|
||||
{{ t('header.welcome') }}, <span class="font-semibold text-[#70BC84]">{{ displayName }}</span>!
|
||||
</p>
|
||||
<p v-if="subtitle" class="text-[11px] text-white/40">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Avatar button — click to toggle dropdown -->
|
||||
<button
|
||||
@click="isUserMenuOpen = !isUserMenuOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
|
||||
:aria-label="t('header.userMenu')"
|
||||
>
|
||||
{{ initials }}
|
||||
</button>
|
||||
|
||||
<!-- ── Dropdown Menu (Glassmorphism) ──────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isUserMenuOpen"
|
||||
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- User info header -->
|
||||
<div class="px-4 py-3 border-b border-white/5">
|
||||
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
|
||||
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="py-1">
|
||||
<!-- Profile settings -->
|
||||
<button
|
||||
@click="goToProfile"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<!-- User icon -->
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.profile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/5 my-1" />
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
|
||||
>
|
||||
<!-- Logout icon -->
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40 group-hover:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
firstName?: string
|
||||
subtitle?: string
|
||||
}>(),
|
||||
{
|
||||
firstName: '',
|
||||
subtitle: 'Irányítópult',
|
||||
},
|
||||
)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Dropdown states ────────────────────────────────────────────────
|
||||
const isUserMenuOpen = ref(false)
|
||||
const isFuncMenuOpen = ref(false)
|
||||
// LanguageSwitcher handles its own state internally
|
||||
|
||||
// ── Close dropdowns on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isFuncMenuOpen.value && !target.closest('.func-menu-container')) {
|
||||
isFuncMenuOpen.value = false
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
|
||||
// ── Computed display helpers ───────────────────────────────────────
|
||||
const displayName = computed(() => {
|
||||
if (props.firstName && props.firstName !== 'Vendég') return props.firstName
|
||||
// Fallback: use email prefix (before @)
|
||||
if (authStore.user?.email) {
|
||||
const emailPrefix = authStore.user.email.split('@')[0]
|
||||
return emailPrefix || 'Felhasználó'
|
||||
}
|
||||
return 'Felhasználó'
|
||||
})
|
||||
|
||||
const fullName = computed(() => {
|
||||
if (!authStore.user) return 'Felhasználó'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
if (first_name) return first_name
|
||||
if (last_name) return last_name
|
||||
return authStore.user.email || 'Felhasználó'
|
||||
})
|
||||
|
||||
const email = computed(() => {
|
||||
return authStore.user?.email || ''
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!authStore.user) return '?'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
if (first_name) return first_name[0].toUpperCase()
|
||||
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
|
||||
return '?'
|
||||
})
|
||||
|
||||
// ── Navigate to Profile ────────────────────────────────────────────
|
||||
function goToProfile() {
|
||||
isUserMenuOpen.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// ── Logout handler ─────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
isUserMenuOpen.value = false
|
||||
authStore.logout()
|
||||
}
|
||||
</script>
|
||||
@@ -5,8 +5,8 @@
|
||||
class="flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
|
||||
:title="t('header.switchLanguage')"
|
||||
>
|
||||
<span class="text-base">{{ currentLocale === 'hu' ? '🇭🇺' : '🇬🇧' }}</span>
|
||||
<span class="hidden sm:inline font-semibold">{{ currentLocale === 'hu' ? 'HU' : 'EN' }}</span>
|
||||
<span :class="['fi', `fi-${currentFlagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
|
||||
<span class="hidden sm:inline font-semibold uppercase">{{ currentLocale }}</span>
|
||||
<!-- Chevron down -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 ml-0.5 text-white/40 transition-transform duration-200"
|
||||
@@ -30,33 +30,20 @@
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute left-0 top-full mt-2 w-40 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
class="absolute left-0 top-full mt-2 w-48 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<div class="py-1">
|
||||
<!-- Magyar -->
|
||||
<button
|
||||
@click="setLanguage('hu')"
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
@click="setLanguage(lang.code)"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'hu' }"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === lang.code }"
|
||||
>
|
||||
<span class="text-base">🇭🇺</span>
|
||||
<span>Magyar</span>
|
||||
<span v-if="currentLocale === 'hu'" class="ml-auto text-[#70BC84]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- English -->
|
||||
<button
|
||||
@click="setLanguage('en')"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'en' }"
|
||||
>
|
||||
<span class="text-base">🇬🇧</span>
|
||||
<span>English</span>
|
||||
<span v-if="currentLocale === 'en'" class="ml-auto text-[#70BC84]">
|
||||
<span :class="['fi', `fi-${lang.flagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
|
||||
<span class="uppercase font-medium">{{ lang.code }}</span>
|
||||
<span class="text-white/50">{{ lang.label }}</span>
|
||||
<span v-if="currentLocale === lang.code" class="ml-auto text-[#70BC84]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
@@ -77,6 +64,26 @@ const { t, locale } = useI18n()
|
||||
const isOpen = ref(false)
|
||||
const currentLocale = computed(() => locale.value)
|
||||
|
||||
interface Language {
|
||||
code: string
|
||||
flagCode: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const languages: Language[] = [
|
||||
{ code: 'hu', flagCode: 'hu', label: 'Magyar' },
|
||||
{ code: 'en', flagCode: 'gb', label: 'English' },
|
||||
{ code: 'de', flagCode: 'de', label: 'Deutsch' },
|
||||
{ code: 'ro', flagCode: 'ro', label: 'Română' },
|
||||
{ code: 'cz', flagCode: 'cz', label: 'Čeština' },
|
||||
{ code: 'sk', flagCode: 'sk', label: 'Slovenčina' },
|
||||
]
|
||||
|
||||
const currentFlagCode = computed(() => {
|
||||
const lang = languages.find(l => l.code === currentLocale.value)
|
||||
return lang ? lang.flagCode : 'gb'
|
||||
})
|
||||
|
||||
function setLanguage(lang: string) {
|
||||
locale.value = lang
|
||||
localStorage.setItem('user-locale', lang)
|
||||
|
||||
63
frontend/src/components/header/HeaderBackButton.vue
Normal file
63
frontend/src/components/header/HeaderBackButton.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<button
|
||||
@click="handleClick"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
|
||||
:class="btnClass"
|
||||
:aria-label="ariaLabel || label"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Route to navigate to on click */
|
||||
to?: string
|
||||
/** Button label text */
|
||||
label?: string
|
||||
/** Optional aria-label override */
|
||||
ariaLabel?: string
|
||||
/** Visual variant */
|
||||
variant?: 'default' | 'warning'
|
||||
/** Optional click handler override (prevents navigation if provided) */
|
||||
onClick?: () => void
|
||||
}>(),
|
||||
{
|
||||
to: '/dashboard',
|
||||
label: '',
|
||||
ariaLabel: '',
|
||||
variant: 'default',
|
||||
onClick: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const btnClass = computed(() => {
|
||||
if (props.variant === 'warning') {
|
||||
return 'text-yellow-400/80 hover:bg-white/5 hover:text-yellow-300'
|
||||
}
|
||||
// Default variant uses dynamic theme colors (white-labeling support)
|
||||
// bg-themePrimary picks up the --theme-primary CSS variable set by the theme store
|
||||
return 'bg-themePrimary text-white hover:opacity-90'
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.onClick) {
|
||||
props.onClick()
|
||||
return
|
||||
}
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
224
frontend/src/components/header/HeaderCompanySwitcher.vue
Normal file
224
frontend/src/components/header/HeaderCompanySwitcher.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="relative org-dropdown-container">
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
@click.stop="isOpen = !isOpen"
|
||||
:disabled="authStore.isLoading"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
|
||||
:class="orgButtonClass"
|
||||
>
|
||||
<!-- Loading spinner -->
|
||||
<svg
|
||||
v-if="authStore.isLoading"
|
||||
class="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<!-- Icon -->
|
||||
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path v-if="orgButtonState === 'create'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>{{ orgButtonLabel }}</span>
|
||||
<!-- Chevron down when there are organizations -->
|
||||
<svg
|
||||
v-if="authStore.myOrganizations.length > 0 && orgButtonState === 'switch'"
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- ── Organization Dropdown ────────────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- ── When on Company Garage: show "👤 Privát Garázs" back button ── -->
|
||||
<div v-if="isOnCompanyGarage" class="py-1 border-b border-white/10">
|
||||
<button
|
||||
@click="goToPersonalDashboard"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-200 transition-all duration-150 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
<span>👤 {{ t('header.privateGarage') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Organization list ─────────────────────────────────── -->
|
||||
<div v-if="filteredOrganizations.length > 0" class="py-1 max-h-60 overflow-y-auto">
|
||||
<router-link
|
||||
v-for="org in filteredOrganizations"
|
||||
:key="org.organization_id"
|
||||
:to="'/organization/' + org.organization_id"
|
||||
@click="isOpen = false"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
|
||||
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
|
||||
>
|
||||
<!-- Org icon -->
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
|
||||
<!-- Active checkmark -->
|
||||
<svg
|
||||
v-if="isActiveOrg(org)"
|
||||
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Empty state when no orgs exist -->
|
||||
<div v-if="authStore.myOrganizations.length === 0" class="py-4 px-4 text-center text-white/50 text-sm">
|
||||
{{ t('header.noCompanies') }}
|
||||
</div>
|
||||
|
||||
<!-- Divider before "Create new" -->
|
||||
<hr class="border-gray-700 my-1" />
|
||||
|
||||
<!-- Create new organization -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="goToOnboard"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>{{ t('header.newCompany') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OrganizationItem } from '@/types/organization'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
// ── Debug: log organizations on mount ───────────────────────────────
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded (safety net if init() didn't run)
|
||||
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
|
||||
await authStore.fetchMyOrganizations()
|
||||
}
|
||||
console.log('🔍 [HeaderCompanySwitcher] Available companies:', authStore.myOrganizations)
|
||||
})
|
||||
|
||||
// ── Route-based detection ─────────────────────────────────────────
|
||||
const isOnCompanyGarage = computed(() => {
|
||||
return route.path.startsWith('/organization/')
|
||||
})
|
||||
|
||||
// ── Current org ID from route (when on company garage) ────────────
|
||||
const currentOrgId = computed(() => {
|
||||
if (!isOnCompanyGarage.value) return null
|
||||
return Number(route.params.id) || null
|
||||
})
|
||||
|
||||
// ── Filtered organizations: exclude the current one when on garage ─
|
||||
const filteredOrganizations = computed(() => {
|
||||
const orgs = authStore.myOrganizations
|
||||
if (isOnCompanyGarage.value && currentOrgId.value) {
|
||||
return orgs.filter((o) => o.organization_id !== currentOrgId.value)
|
||||
}
|
||||
return orgs
|
||||
})
|
||||
|
||||
// ── Organization Button State ──────────────────────────────────────
|
||||
const orgButtonState = computed(() => {
|
||||
if (!authStore.isAuthenticated) return 'create'
|
||||
if (isOnCompanyGarage.value) return 'on-garage'
|
||||
if (authStore.isCorporateMode) return 'corporate'
|
||||
if (authStore.hasBusinessOrganization) return 'switch'
|
||||
return 'create'
|
||||
})
|
||||
|
||||
const orgButtonLabel = computed(() => {
|
||||
switch (orgButtonState.value) {
|
||||
case 'create':
|
||||
return t('header.newCompany')
|
||||
case 'switch': {
|
||||
const count = authStore.myOrganizations.length
|
||||
if (count === 1) return t('header.myCompany')
|
||||
return t('header.myCompanies')
|
||||
}
|
||||
case 'corporate':
|
||||
return t('header.personalGarage')
|
||||
case 'on-garage':
|
||||
return t('header.privateGarage')
|
||||
default:
|
||||
return t('header.company')
|
||||
}
|
||||
})
|
||||
|
||||
const orgButtonClass = computed(() => {
|
||||
if (orgButtonState.value === 'corporate') {
|
||||
return 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 hover:bg-yellow-500/25 hover:text-yellow-300'
|
||||
}
|
||||
return 'text-white/70 hover:bg-white/5 hover:text-white'
|
||||
})
|
||||
|
||||
// ── Check if org is the active one ─────────────────────────────────
|
||||
function isActiveOrg(org: OrganizationItem): boolean {
|
||||
return authStore.user?.active_organization_id === org.organization_id
|
||||
}
|
||||
|
||||
// ── Navigate to personal dashboard ────────────────────────────────
|
||||
async function goToPersonalDashboard() {
|
||||
isOpen.value = false
|
||||
await authStore.switchOrganization(null)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// ── Navigate to onboard ───────────────────────────────────────────
|
||||
function goToOnboard() {
|
||||
isOpen.value = false
|
||||
router.push('/company/onboard')
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && !target.closest('.org-dropdown-container')) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
39
frontend/src/components/header/HeaderLogo.vue
Normal file
39
frontend/src/components/header/HeaderLogo.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<router-link
|
||||
:to="targetRoute"
|
||||
class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]"
|
||||
:aria-label="t('header.home')"
|
||||
>
|
||||
<img
|
||||
src="@/assets/sf-brand-logo.svg"
|
||||
class="h-10 w-auto mr-3 drop-shadow-md"
|
||||
alt="ServiceFinder Logo"
|
||||
/>
|
||||
<div class="hidden sm:block">
|
||||
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
|
||||
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
/**
|
||||
* Compute the target route for the logo link.
|
||||
* - If the user is on an /organization/:id page, navigate to the org root.
|
||||
* - Otherwise, navigate to /dashboard (private garage).
|
||||
*/
|
||||
const targetRoute = computed(() => {
|
||||
if (route.path.startsWith('/organization/')) {
|
||||
const orgId = route.params.id
|
||||
return orgId ? `/organization/${orgId}` : route.path
|
||||
}
|
||||
return '/dashboard'
|
||||
})
|
||||
</script>
|
||||
145
frontend/src/components/header/HeaderProfile.vue
Normal file
145
frontend/src/components/header/HeaderProfile.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div ref="containerRef" class="relative flex items-center">
|
||||
<!-- Avatar button — click to toggle dropdown -->
|
||||
<button
|
||||
@click="isOpen = !isOpen"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
|
||||
:aria-label="t('header.userMenu')"
|
||||
>
|
||||
{{ initials }}
|
||||
</button>
|
||||
|
||||
<!-- ── Dropdown Menu (Glassmorphism) ──────────────────────────── -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="scale-95 opacity-0 -translate-y-2"
|
||||
enter-to-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="scale-100 opacity-100 translate-y-0"
|
||||
leave-to-class="scale-95 opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
<!-- User info header -->
|
||||
<div class="px-4 py-3 border-b border-white/5">
|
||||
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
|
||||
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Menu items -->
|
||||
<div class="py-1">
|
||||
<!-- Profile settings -->
|
||||
<button
|
||||
@click="goToProfile"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.profile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<hr class="border-white/5 my-1" />
|
||||
|
||||
<!-- Logout -->
|
||||
<div class="py-1">
|
||||
<button
|
||||
@click="handleLogout"
|
||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-white/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('header.logout') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Computed display helpers ───────────────────────────────────────
|
||||
const fullName = computed(() => {
|
||||
if (!authStore.user) return t('header.user')
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
if (first_name) return first_name
|
||||
if (last_name) return last_name
|
||||
return authStore.user.email || t('header.user')
|
||||
})
|
||||
|
||||
const email = computed(() => {
|
||||
return authStore.user?.email || ''
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!authStore.user) return '?'
|
||||
const { first_name, last_name } = authStore.user
|
||||
if (first_name && last_name) {
|
||||
return (first_name[0] + last_name[0]).toUpperCase()
|
||||
}
|
||||
if (first_name) return first_name[0].toUpperCase()
|
||||
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
|
||||
return '?'
|
||||
})
|
||||
|
||||
// ── Navigate to Profile ────────────────────────────────────────────
|
||||
function goToProfile() {
|
||||
isOpen.value = false
|
||||
router.push('/profile')
|
||||
}
|
||||
|
||||
// ── Logout handler ─────────────────────────────────────────────────
|
||||
function handleLogout() {
|
||||
isOpen.value = false
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
// ── Close dropdown on outside click ───────────────────────────────
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (isOpen.value && containerRef.value && !containerRef.value.contains(target)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
|
||||
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
|
||||
</script>
|
||||
48
frontend/src/components/layout/BaseHeader.vue
Normal file
48
frontend/src/components/layout/BaseHeader.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-50 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
|
||||
>
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<!-- ── Left Slot ─────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
|
||||
<!-- ── Center Slot ───────────────────────────────────────────── -->
|
||||
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||
<slot name="center" />
|
||||
<!-- Teleport target for dynamic header content -->
|
||||
<div id="header-teleport-target" class="flex items-center gap-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Spacer (pushes right items when center is empty) ──────── -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- ── Right Slot ────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BaseHeader.vue — A "dumb" glassmorphism header frame.
|
||||
*
|
||||
* Provides three named slots:
|
||||
* - #left : Logo, back button, welcome text
|
||||
* - #center : Context label, teleport target for dynamic content
|
||||
* - #right : Language switcher, profile avatar, org switcher
|
||||
*
|
||||
* The teleport target div (#header-teleport-target) allows child components
|
||||
* to teleport content into the header center area from anywhere in the DOM.
|
||||
*
|
||||
* Usage:
|
||||
* <BaseHeader>
|
||||
* <template #left>...</template>
|
||||
* <template #center>...</template>
|
||||
* <template #right>...</template>
|
||||
* </BaseHeader>
|
||||
*/
|
||||
</script>
|
||||
174
frontend/src/components/ui/AddressDisplay.vue
Normal file
174
frontend/src/components/ui/AddressDisplay.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="address-display flex items-start gap-2">
|
||||
<!-- MapPin icon (Lucide) -->
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-[#418890]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
|
||||
<!-- Address text -->
|
||||
<span class="text-sm break-words leading-relaxed" :class="textClass">
|
||||
{{ formattedAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* AddressDisplay — intelligently formats and displays a Hungarian-style address.
|
||||
*
|
||||
* Accepts address fields either as individual props or as a single `address` object.
|
||||
* Filters out null/undefined/empty values, prevents double spaces,
|
||||
* and handles missing house numbers gracefully.
|
||||
*
|
||||
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
|
||||
* Example: 2120 Dunakeszi, Határ út 8.
|
||||
*/
|
||||
interface AddressObject {
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
hrsz?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Address fields as a single object (alternative to individual props) */
|
||||
address?: AddressObject | null
|
||||
/** Individual address fields (take precedence over address object) */
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
hrsz?: string | null
|
||||
/** Optional CSS class for the text span */
|
||||
textClass?: string
|
||||
/** Show detailed address (including stairwell/floor/door) */
|
||||
detailed?: boolean
|
||||
}>(),
|
||||
{
|
||||
address: null,
|
||||
zip: null,
|
||||
city: null,
|
||||
street_name: null,
|
||||
street_type: null,
|
||||
house_number: null,
|
||||
stairwell: null,
|
||||
floor: null,
|
||||
door: null,
|
||||
parcel_id: null,
|
||||
hrsz: null,
|
||||
textClass: 'text-white/80',
|
||||
detailed: false,
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve the effective address values.
|
||||
* Individual props take precedence over the address object.
|
||||
*/
|
||||
function resolveField(field: keyof AddressObject): string | null {
|
||||
const propValue = (props as any)[field]
|
||||
if (propValue !== null && propValue !== undefined && propValue !== '') {
|
||||
return propValue
|
||||
}
|
||||
if (props.address) {
|
||||
const addrValue = props.address[field]
|
||||
if (addrValue !== null && addrValue !== undefined && addrValue !== '') {
|
||||
return addrValue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the formatted address string.
|
||||
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
|
||||
* Intelligently filters out null/undefined/empty values and prevents double spaces.
|
||||
*/
|
||||
const formattedAddress = computed(() => {
|
||||
const zip = resolveField('zip')
|
||||
const city = resolveField('city')
|
||||
const streetName = resolveField('street_name')
|
||||
const streetType = resolveField('street_type')
|
||||
const houseNumber = resolveField('house_number')
|
||||
|
||||
// Build the primary address line
|
||||
const parts: string[] = []
|
||||
|
||||
// ZIP + City (always together if city exists)
|
||||
if (zip || city) {
|
||||
const location = [zip, city].filter(Boolean).join(' ')
|
||||
parts.push(location)
|
||||
}
|
||||
|
||||
// Street details
|
||||
const streetParts: string[] = []
|
||||
if (streetName) streetParts.push(streetName)
|
||||
if (streetType) streetParts.push(streetType)
|
||||
if (houseNumber) streetParts.push(houseNumber)
|
||||
|
||||
if (streetParts.length > 0) {
|
||||
// If we already have a location part, add street with a comma separator
|
||||
if (parts.length > 0) {
|
||||
parts[parts.length - 1] += ','
|
||||
}
|
||||
parts.push(streetParts.join(' '))
|
||||
}
|
||||
|
||||
// Join all parts, then normalize whitespace (remove double spaces)
|
||||
let result = parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
|
||||
// If no address data at all, show a placeholder
|
||||
if (!result) {
|
||||
result = '—'
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Build the detailed address string (includes stairwell/floor/door).
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
|
||||
const detailedAddress = computed(() => {
|
||||
if (!props.detailed) return ''
|
||||
|
||||
const stairwell = resolveField('stairwell')
|
||||
const floor = resolveField('floor')
|
||||
const door = resolveField('door')
|
||||
const hrsz = resolveField('hrsz') || resolveField('parcel_id')
|
||||
|
||||
const detailParts: string[] = []
|
||||
if (stairwell) detailParts.push(t('address.stairwell', { value: stairwell }))
|
||||
if (floor) detailParts.push(t('address.floor', { value: floor }))
|
||||
if (door) detailParts.push(t('address.door', { value: door }))
|
||||
if (hrsz) detailParts.push(t('address.hrsz', { value: hrsz }))
|
||||
|
||||
return detailParts.join(', ')
|
||||
})
|
||||
</script>
|
||||
77
frontend/src/components/ui/GlassCard.vue
Normal file
77
frontend/src/components/ui/GlassCard.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div
|
||||
class="glass-card relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.05] backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-300"
|
||||
:class="[
|
||||
variant === 'premium'
|
||||
? 'premium-gradient-border hover:shadow-2xl hover:shadow-[#00E5A0]/10'
|
||||
: 'hover:border-white/[0.15] hover:bg-white/[0.08] hover:shadow-2xl hover:shadow-black/30',
|
||||
hoverable ? 'hover:-translate-y-1 cursor-pointer' : '',
|
||||
customClass,
|
||||
]"
|
||||
>
|
||||
<!-- Premium gradient border overlay (only for premium variant) -->
|
||||
<div
|
||||
v-if="variant === 'premium'"
|
||||
class="pointer-events-none absolute inset-0 rounded-2xl p-[1px]"
|
||||
>
|
||||
<div class="h-full w-full rounded-2xl bg-gradient-to-br from-[#00E5A0]/40 via-[#418890]/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
<!-- Content slot -->
|
||||
<div class="relative z-10" :class="paddingClass">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Visual variant: 'standard' (default) or 'premium' (with gradient border) */
|
||||
variant?: 'standard' | 'premium'
|
||||
/** Enable hover lift effect (translate-y and stronger shadow) */
|
||||
hoverable?: boolean
|
||||
/** Custom padding: 'sm', 'md' (default), 'lg', or 'none' */
|
||||
padding?: 'sm' | 'md' | 'lg' | 'none'
|
||||
/** Additional CSS classes to merge */
|
||||
customClass?: string
|
||||
}>(),
|
||||
{
|
||||
variant: 'standard',
|
||||
hoverable: false,
|
||||
padding: 'md',
|
||||
customClass: '',
|
||||
}
|
||||
)
|
||||
|
||||
const paddingClass = computed(() => {
|
||||
switch (props.padding) {
|
||||
case 'sm': return 'p-4'
|
||||
case 'lg': return 'p-8'
|
||||
case 'none': return ''
|
||||
default: return 'p-6'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Premium gradient border using pseudo-element ── */
|
||||
.glass-card.premium-gradient-border {
|
||||
position: relative;
|
||||
border: none;
|
||||
}
|
||||
.glass-card.premium-gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 1rem; /* matches rounded-2xl */
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(0, 229, 160, 0.5), rgba(65, 136, 144, 0.3), transparent);
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user