frontend 2026-06-10 bontva a 2 felület

This commit is contained in:
Roo
2026-06-10 08:06:07 +00:00
parent b84b1bab41
commit 90e3173fbc
59 changed files with 8616 additions and 1412 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

View File

@@ -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>

View File

@@ -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)

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

83
frontend/src/i18n/cz.ts Normal file
View File

@@ -0,0 +1,83 @@
export default {
menu: {
features: 'Funkce',
garage: 'Vozidla',
calendar: 'Servisní kalendář',
finance: 'Finance & Náklady',
diagnostic: 'Živá diagnostika',
addVehicle: '+ Přidat nové vozidlo',
},
header: {
welcome: 'Vítejte',
userMenu: 'Uživatelské menu',
profile: 'Nastavení profilu',
logout: 'Odhlásit se',
subtitle: 'Váš dashboard a přehled vozidel',
switchLanguage: 'Přepnout jazyk',
},
dashboard: {
loading: 'Načítání...',
errorRetry: 'Opakovat',
statusActive: 'Aktivní',
statusUnknown: 'Neznámý',
condition: 'Stav',
mileage: 'km',
// Bento Box layout
myVehicles: 'Moje Vozidla',
vehicles: 'vozidel',
noVehicles: 'Zatím žádná vozidla. Přidejte své první!',
activeProcesses: 'Aktivní Procesy / Oznámení',
noNotifications: 'Vše v pořádku — žádná oznámení',
totalVehicles: 'Celkem Vozidel',
addVehicle: '+ Přidat Vozidlo',
refresh: 'Obnovit',
statsAndPoints: 'Statistiky & Body',
live: 'ŽIVĚ',
monthlyScore: 'Měsíční Skóre',
totalKm: 'Celková Vzdálenost',
fuelCost: 'Náklady na Palivo',
serviceCost: 'Náklady na Servis',
co2Savings: 'Úspora CO₂',
achievements: 'Úspěchy',
},
zen: {
hide: 'Skrýt UI (Zen režim)',
show: 'Zobrazit UI',
},
auth: {
invalid_credentials: 'Neplatný email nebo heslo.',
user_inactive: 'Váš účet ještě není aktivován. Prosím ověřte svůj email!',
forgotPasswordMessage: 'Funkce obnovení hesla je ve vývoji. Prosím kontaktujte administrátora!',
resendTitle: 'Znovu odeslat aktivační email',
resendDescription: 'Zadejte svou emailovou adresu a znovu zašleme aktivační odkaz.',
resendButton: 'Odeslat email',
resend_cooldown: 'Odeslat znovu ({seconds}s)',
send_email: 'Odeslat email',
resendSuccess: 'Aktivační email byl odeslán! Zkontrolujte své složky (včetně Spamu).',
resendLink: 'Nedostali jste aktivační email?',
backToLogin: 'Zpět na přihlášení',
loginTitle: 'Přihlášení do garáže',
loginButton: 'Přihlásit se',
loginLoading: 'Prosím čekejte...',
forgotPassword: 'Zapomněli jste heslo?',
rememberMe: 'Zapamatovat si mě',
noAccount: 'Nemáte ještě garáž?',
registerLink: 'Registrovat',
registerTitle: 'Otevřít novou garáž',
registerButton: 'Registrovat',
haveAccount: 'Již máte garáž?',
loginLink: 'Přihlásit se',
forgotTitle: 'Požádat o nové heslo',
forgotDescription: 'Zadejte svou emailovou adresu a zašleme vám bezpečnostní odkaz.',
forgotSuccess: 'Odkaz pro obnovení byl odeslán na váš email!',
forgotSendButton: 'Odeslat odkaz',
inactiveMessage: 'Váš účet ještě není aktivován! Aktivujte jej pomocí odkazu v emailu nebo požádejte o nový aktivační email.',
resendActivationButton: 'Znovu odeslat aktivační email',
resendActivationSuccess: 'Aktivační email byl znovu odeslán!',
socialDivider: 'Nebo se přihlaste pomocí sociálního účtu',
loginFailed: 'Přihlášení selhalo. Zkontrolujte své přihlašovací údaje.',
registerFailed: 'Registrace selhala.',
resendFailed: 'Odeslání selhalo. Prosím zkuste to znovu později.',
forgotFailed: 'Při odesílání požadavku došlo k chybě. Prosím zkuste to znovu.',
},
}

83
frontend/src/i18n/de.ts Normal file
View File

@@ -0,0 +1,83 @@
export default {
menu: {
features: 'Funktionen',
garage: 'Fahrzeuge',
calendar: 'Servicekalender',
finance: 'Finanzen & Kosten',
diagnostic: 'Live-Diagnose',
addVehicle: '+ Neues Fahrzeug hinzufügen',
},
header: {
welcome: 'Willkommen',
userMenu: 'Benutzermenü',
profile: 'Profileinstellungen',
logout: 'Abmelden',
subtitle: 'Ihr Dashboard und Fahrzeugübersicht',
switchLanguage: 'Sprache wechseln',
},
dashboard: {
loading: 'Wird geladen...',
errorRetry: 'Wiederholen',
statusActive: 'Aktiv',
statusUnknown: 'Unbekannt',
condition: 'Zustand',
mileage: 'km',
// Bento Box layout
myVehicles: 'Meine Fahrzeuge',
vehicles: 'Fahrzeuge',
noVehicles: 'Noch keine Fahrzeuge. Füge dein erstes hinzu!',
activeProcesses: 'Aktive Prozesse / Benachrichtigungen',
noNotifications: 'Alles klar — keine Benachrichtigungen',
totalVehicles: 'Gesamtfahrzeuge',
addVehicle: '+ Fahrzeug hinzufügen',
refresh: 'Aktualisieren',
statsAndPoints: 'Statistiken & Punkte',
live: 'LIVE',
monthlyScore: 'Monatliche Punktzahl',
totalKm: 'Gesamtstrecke',
fuelCost: 'Kraftstoffkosten',
serviceCost: 'Servicekosten',
co2Savings: 'CO₂-Einsparungen',
achievements: 'Errungenschaften',
},
zen: {
hide: 'UI ausblenden (Zen-Modus)',
show: 'UI anzeigen',
},
auth: {
invalid_credentials: 'Ungültige E-Mail oder Passwort.',
user_inactive: 'Ihr Konto ist noch nicht aktiviert. Bitte bestätigen Sie Ihre E-Mail!',
forgotPasswordMessage: 'Die Passwort-Zurücksetzen-Funktion befindet sich in Entwicklung. Bitte kontaktieren Sie den Administrator!',
resendTitle: 'Aktivierungs-E-Mail erneut senden',
resendDescription: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden den Aktivierungslink erneut.',
resendButton: 'E-Mail senden',
resend_cooldown: 'Erneut senden ({seconds}s)',
send_email: 'E-Mail senden',
resendSuccess: 'Die Aktivierungs-E-Mail wurde gesendet! Überprüfen Sie Ihre Ordner (auch Spam).',
resendLink: 'Haben Sie die Aktivierungs-E-Mail nicht erhalten?',
backToLogin: 'Zurück zum Login',
loginTitle: 'Anmelden zur Garage',
loginButton: 'Anmelden',
loginLoading: 'Bitte warten...',
forgotPassword: 'Passwort vergessen?',
rememberMe: 'Angemeldet bleiben',
noAccount: 'Noch keine Garage?',
registerLink: 'Registrieren',
registerTitle: 'Neue Garage eröffnen',
registerButton: 'Registrieren',
haveAccount: 'Bereits eine Garage?',
loginLink: 'Anmelden',
forgotTitle: 'Neues Passwort anfordern',
forgotDescription: 'Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Sicherheitslink.',
forgotSuccess: 'Der Zurücksetzungslink wurde an Ihre E-Mail gesendet!',
forgotSendButton: 'Link senden',
inactiveMessage: 'Ihr Konto ist noch nicht aktiviert! Bitte aktivieren Sie es mit dem Link in der E-Mail oder fordern Sie eine neue Aktivierungs-E-Mail an.',
resendActivationButton: 'Aktivierungs-E-Mail erneut senden',
resendActivationSuccess: 'Die Aktivierungs-E-Mail wurde erneut gesendet!',
socialDivider: 'Oder mit Social-Konto anmelden',
loginFailed: 'Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Anmeldedaten.',
registerFailed: 'Registrierung fehlgeschlagen.',
resendFailed: 'Erneutes Senden fehlgeschlagen. Bitte versuchen Sie es später erneut.',
forgotFailed: 'Beim Senden der Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
},
}

View File

@@ -1,4 +1,20 @@
export default {
landing: {
openGarage: 'Open Garage',
heroTitle: 'Digital Fleet',
heroTitleAccent: 'Management',
heroSubtitle: 'Real-time cost tracking, automatic notifications and gamified point system all in one place.',
cardStats: 'Statistics',
cardStatsLabel: 'Managed Vehicles',
cardStatsValue: '1,245+',
cardGamification: 'Gamification',
cardGamificationLabel: 'Monthly Score',
cardCosts: 'Costs',
cardCostsFuel: 'Fuel',
cardCostsService: 'Service',
cardCostsTotal: 'Total',
cardCostsUnit: 'HUF',
},
menu: {
features: 'Features',
garage: 'Vehicles',
@@ -14,6 +30,26 @@ export default {
logout: 'Logout',
subtitle: 'Your dashboard and vehicle overview',
switchLanguage: 'Switch language',
close: 'Close',
noCompanies: 'No companies yet',
newCompany: ' New Company',
myCompany: '🏢 My Company',
myCompanies: '🏢 My Companies',
personalGarage: '👤 Personal Garage',
privateGarage: '👤 Private Garage',
company: '🏢 Company',
companyGarage: 'My Company Garage',
companyGarageLabel: '{name}_garage',
companyGarageFallback: 'Company_garage',
personalGarageLabel: '{name}_garage',
personalGarageFallback: 'User_garage',
companyPrefix: 'Company',
user: 'User',
welcomeCard: 'Welcome, {name}!',
language: 'Language',
regionSettings: 'Region Settings',
regionDescription: 'Date, time & currency format',
dashboard: 'Dashboard',
},
dashboard: {
loading: 'Loading...',
@@ -22,6 +58,27 @@ export default {
statusUnknown: 'Unknown',
condition: 'Condition',
mileage: 'km',
subtitle: 'Your dashboard and vehicle overview',
guest: 'Guest',
// Bento Box layout
myVehicles: 'My Vehicles',
vehicles: 'vehicles',
noVehicles: 'No vehicles yet. Add your first one!',
activeProcesses: 'Active Processes / Notifications',
noNotifications: 'All clear — no notifications',
totalVehicles: 'Total Vehicles',
addVehicle: '+ Add Vehicle',
refresh: 'Refresh',
statsAndPoints: 'Statistics & Points',
live: 'LIVE',
monthlyScore: 'Monthly Score',
totalKm: 'Total Distance',
fuelCost: 'Fuel Cost',
serviceCost: 'Service Cost',
co2Savings: 'CO₂ Savings',
achievements: 'Achievements',
detailedView: 'Detailed View',
modalPlaceholder: 'Content for this section will be available soon.',
},
zen: {
hide: 'Hide UI (Zen mode)',
@@ -70,4 +127,230 @@ export default {
resendFailed: 'Resend failed. Please try again later.',
forgotFailed: 'An error occurred while sending the request. Please try again.',
},
profile: {
title: 'Profile Settings',
loading: 'Loading profile data...',
completion: 'Profile Completion',
accountInfo: 'Account Info',
email: 'Email',
phone: 'Phone Number',
edit: 'Edit',
cancel: 'Cancel',
save: 'Save',
changePassword: 'Change Password',
// Tabs
tabPersonal: 'Personal & Address',
tabDocs: 'Documents & Emergency',
// Personal data
personalData: 'Personal Data (KYC)',
lastName: 'Last Name',
firstName: 'First Name',
birthPlace: 'Place of Birth',
birthDate: 'Date of Birth',
mothersLastName: 'Mother\'s Last Name',
mothersFirstName: 'Mother\'s First Name',
// Address
addressTitle: 'Address Data',
zipCode: 'ZIP Code',
city: 'City',
street: 'Street',
streetType: 'Street Type',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
hrsz: 'Parcel No.',
selectPlaceholder: 'Select...',
// Identity docs
identityDocs: 'Documents (Identity Docs)',
idCard: 'ID Card (ID_CARD)',
drivingLicense: 'Driving License (LICENSE)',
docNumber: 'Number',
docExpiry: 'Expiry',
categories: 'Categories',
number: 'Number:',
expiry: 'Expiry:',
categoriesLabel: 'Categories:',
// ICE contact
iceTitle: 'Emergency Contact (ICE)',
iceName: 'Name',
iceRelation: 'Relation',
icePhone: 'Phone Number',
iceEmail: 'Email Address',
iceNameLabel: 'Name:',
iceRelationLabel: 'Relation:',
icePhoneLabel: 'Phone:',
iceEmailLabel: 'Email:',
// Password modal
passwordTitle: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmPassword: 'Confirm New Password',
passwordRequired: 'All fields are required.',
passwordMismatch: 'The new password and confirmation do not match.',
passwordMinLength: 'The new password must be at least 6 characters long.',
passwordChanged: 'Password successfully changed!',
passwordChangeFailed: 'Password change failed.',
// Save messages
saveSuccess: 'Profile data and documents updated successfully!',
saveError: 'An error occurred while saving.',
// Regional settings tab
tabRegional: 'Regional Settings',
regionalLanguage: 'Language',
regionalDateFormat: 'Date Format',
regionalCurrency: 'Currency',
dateFormatHU: 'YYYY. MM. DD.',
dateFormatISO: 'YYYY-MM-DD',
currencyHUF: 'HUF (Ft)',
currencyEUR: 'EUR (€)',
currencyUSD: 'USD ($)',
// Back
backToGarage: 'Back to Garage',
// Diagnostic
xray: '🩻 X-RAY: person (address_* fields)',
},
kyc: {
title: 'Garage Activation — Complete Your Account',
stepAddress: 'Address',
stepExtras: 'Extras',
// Step 1
addressTitle: '📍 Address Data',
addressDescription: 'ZIP code and city are required to activate your account.',
softKyc: '(Soft KYC)',
country: 'Country',
zipCode: 'ZIP Code',
city: 'City',
optionalAddress: '📝 Additional Address Details (optional)',
streetName: 'Street Name',
streetType: 'Type',
houseNumber: 'House Number',
// Step 2
extrasTitle: '🔧 Additional Data',
extrasDescription: 'You can provide these details later in your profile.',
extrasOptional: '(All optional)',
phoneNumber: '📞 Phone Number',
documentsTitle: '🪪 Document Details (optional)',
idCard: 'ID Card',
drivingLicense: 'Driving License',
docNumber: 'Document Number',
docExpiry: 'Expiry',
language: '🌐 Language',
currency: '💰 Currency',
// Navigation
back: 'Back',
next: 'Next',
submitting: 'Processing...',
submit: 'Activate Garage 🚀',
skip: 'Skip, I\'ll fill it in later in my profile →',
// Validation
zipRequired: 'Please enter the ZIP code.',
cityRequired: 'Please enter the city.',
addressRequired: 'Address is required to activate your garage.',
// Errors
submitError: 'Garage activation failed. Please try again.',
},
verifyEmail: {
verifying: 'Activating account...',
verifyingDesc: 'Please wait while we verify the confirmation link.',
success: 'Successful activation and login!',
successDesc: 'Your account is now active and you are automatically logged in.',
redirectCountdown: 'Redirecting in {seconds} seconds...',
redirectNow: 'Redirecting...',
goToProfile: 'Go to Profile',
checkEmail: '📧 Check your email inbox',
checkEmailDesc: 'We have sent you a confirmation email. Please <strong>check your inbox and spam folder</strong>.<br><br>If the email <strong>does not arrive within 10 minutes</strong>, you can request a new one by clicking the button below.',
emailLabel: 'Email Address',
emailPlaceholder: 'example@email.com',
resendButton: 'I didn\'t receive the email, resend it',
resending: 'Sending...',
resendSuccess: 'The new confirmation email has been sent!',
resendFailed: 'Resend failed. Please try again later.',
backToLogin: '← Back to Login',
tokenExpired: 'The link has expired or is invalid.',
},
company: {
// CompanyGarageView
garageTitle: 'Company Garage',
user: 'User',
// Dashboard cards
fleetTitle: 'Fleet Overview',
fleetVehicles: 'Managed vehicles: {count}',
fleetActive: 'Active',
fleetInactive: 'Inactive',
gamificationTitle: 'Gamification',
gamificationScore: 'Monthly Score: {score}',
quickActionsTitle: 'Quick Actions',
quickNewWaybill: 'New Waybill',
quickReportIssue: 'Report Issue',
// CompanyOnboardingView
onboardingTitle: 'Company Registration — 3 simple steps',
stepLegal: 'Company Data',
stepAddress: 'Address',
stepContact: 'Contact',
// Step 1
legalTitle: '⚖️ Legal Data',
legalDescription: 'Enter your company\'s tax number, then click <strong>Load Company Data</strong> for automatic filling.',
taxNumber: 'Tax Number',
taxLookup: 'Load Company Data',
taxLookupTitle: 'Load company data by tax number',
fullName: 'Full Company Name',
shortName: 'Short Name',
displayName: 'Display Name',
regNumber: 'Registration Number',
// Step 2
addressTitle: '📍 Address & Finance',
addressDescription: 'Company headquarters address and default financial settings.',
country: 'Country',
language: 'Language',
currency: 'Currency',
zipCode: 'ZIP Code',
city: 'City',
streetName: 'Street Name',
streetType: 'Type',
houseNumber: 'House Number',
stairwell: 'Stairwell',
floor: 'Floor',
door: 'Door',
parcelId: 'Parcel ID',
// Step 3
contactTitle: '📞 Contact',
contactDescription: 'Primary contact details (the logged-in user) cannot be modified.',
contactName: 'Contact Name',
contactEmail: 'Email Address',
contactPhone: 'Phone Number',
// Navigation
cancel: 'Cancel',
back: 'Back',
next: 'Next',
submitting: 'Creating...',
submit: 'Create Company 🚀',
// Validation
taxRequired: 'Please enter the tax number.',
fullNameRequired: 'Please enter the full company name.',
shortNameRequired: 'Please enter the short name.',
displayNameRequired: 'Please enter the display name.',
regNumberRequired: 'Please enter the registration number.',
zipRequired: 'Please enter the ZIP code.',
cityRequired: 'Please enter the city.',
emailRequired: 'Please enter the primary email address.',
phoneRequired: 'Please enter the primary phone number.',
// Errors
lookupError: 'Failed to load company data. Please check the tax number.',
duplicateCompany: 'This company is already registered in the system.',
submitError: 'Company creation failed. Please try again.',
// Success
successMessage: 'Company created successfully! Redirecting to garage...',
},
organization: {
backToPrivate: '⬅️ Back to Private Garage',
fallbackName: 'Company Garage',
},
address: {
stairwell: 'Stairwell: {value}',
floor: 'Floor: {value}',
door: 'Door: {value}',
hrsz: 'Parcel No.: {value}',
placeholder: '—',
},
}

View File

@@ -1,4 +1,20 @@
export default {
landing: {
openGarage: 'Garázs Nyitása',
heroTitle: 'Digitális Flotta',
heroTitleAccent: 'Menedzsment',
heroSubtitle: 'Valós idejű költségkövetés, automatikus értesítések és gamifikált pontrendszer minden egy helyen.',
cardStats: 'Statisztika',
cardStatsLabel: 'Kezelt járművek',
cardStatsValue: '1,245+',
cardGamification: 'Gamifikáció',
cardGamificationLabel: 'Havi Pontszám',
cardCosts: 'Költségek',
cardCostsFuel: 'Üzemanyag',
cardCostsService: 'Szerviz',
cardCostsTotal: 'Összesen',
cardCostsUnit: 'Ft',
},
menu: {
features: 'Funkciók',
garage: 'Járművek',
@@ -14,6 +30,26 @@ export default {
logout: 'Kilépés',
subtitle: 'Irányítópultod és járműveid áttekintése',
switchLanguage: 'Nyelv váltása',
close: 'Bezárás',
noCompanies: 'Még nincs céged',
newCompany: ' Új cég',
myCompany: '🏢 Cégem',
myCompanies: '🏢 Cégeim',
personalGarage: '👤 Személyes Garázs',
privateGarage: '👤 Privát garázs',
company: '🏢 Cég',
companyGarage: 'Cégem garázs',
companyGarageLabel: '{name}_garázsa',
companyGarageFallback: 'Cég_garázsa',
personalGarageLabel: '{name}_garázsa',
personalGarageFallback: 'Felhasználó_garázsa',
companyPrefix: 'Cég',
user: 'Felhasználó',
welcomeCard: 'Üdvözlünk, {name}!',
language: 'Nyelv',
regionSettings: 'Területi beállítások',
regionDescription: 'Dátum, idő és pénznem formátum',
dashboard: 'Irányítópult',
},
dashboard: {
loading: 'Betöltés...',
@@ -22,6 +58,27 @@ export default {
statusUnknown: 'Ismeretlen',
condition: 'Állapot',
mileage: 'km',
subtitle: 'Irányítópultod és járműveid áttekintése',
guest: 'Vendég',
// Bento Box layout
myVehicles: 'Saját Járművek',
vehicles: 'jármű',
noVehicles: 'Még nincsenek járművek. Add hozzá az elsőt!',
activeProcesses: 'Aktív Folyamatok / Értesítések',
noNotifications: 'Minden rendben — nincs értesítés',
totalVehicles: 'Összes Jármű',
addVehicle: '+ Jármű Hozzáadása',
refresh: 'Frissítés',
statsAndPoints: 'Statisztika és Pontok',
live: 'ÉLŐ',
monthlyScore: 'Havi Pontszám',
totalKm: 'Teljes Táv',
fuelCost: 'Üzemanyag Költség',
serviceCost: 'Szerviz Költség',
co2Savings: 'CO₂ Megtakarítás',
achievements: 'Eredmények',
detailedView: 'Részletes Nézet',
modalPlaceholder: 'A szekció tartalma hamarosan elérhető lesz.',
},
zen: {
hide: 'UI elrejtése (Zen mód)',
@@ -70,4 +127,230 @@ export default {
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
forgotFailed: 'Hiba történt a kérelem elküldésekor. Kérlek próbáld újra.',
},
profile: {
title: 'Profil beállítások',
loading: 'Profil adatok betöltése...',
completion: 'Profil kitöltöttsége',
accountInfo: 'Fiók adatok',
email: 'Email',
phone: 'Telefonszám',
edit: 'Szerkesztés',
cancel: 'Mégse',
save: 'Mentés',
changePassword: 'Jelszó módosítása',
// Tabs
tabPersonal: 'Személyes és Lakcím',
tabDocs: 'Okmányok és Vészhelyzet',
// Personal data
personalData: 'Személyes adatok (KYC)',
lastName: 'Vezetéknév',
firstName: 'Keresztnév',
birthPlace: 'Születési hely',
birthDate: 'Születési dátum',
mothersLastName: 'Anyja vezetékneve',
mothersFirstName: 'Anyja keresztneve',
// Address
addressTitle: 'Lakcím adatok',
zipCode: 'Irányítószám',
city: 'Város',
street: 'Utca',
streetType: 'Közterület jellege',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
hrsz: 'Hrsz.',
selectPlaceholder: 'Válassz...',
// Identity docs
identityDocs: 'Okmányok (Identity Docs)',
idCard: 'Személyi Igazolvány (ID_CARD)',
drivingLicense: 'Jogosítvány (LICENSE)',
docNumber: 'Szám',
docExpiry: 'Lejárat',
categories: 'Kategóriák',
number: 'Szám:',
expiry: 'Lejárat:',
categoriesLabel: 'Kategóriák:',
// ICE contact
iceTitle: 'Vészhelyzeti Kapcsolattartó (ICE)',
iceName: 'Név',
iceRelation: 'Viszony',
icePhone: 'Telefonszám',
iceEmail: 'E-mail cím',
iceNameLabel: 'Név:',
iceRelationLabel: 'Viszony:',
icePhoneLabel: 'Telefonszám:',
iceEmailLabel: 'E-mail:',
// Password modal
passwordTitle: 'Jelszó módosítása',
currentPassword: 'Jelenlegi jelszó',
newPassword: 'Új jelszó',
confirmPassword: 'Új jelszó újra',
passwordRequired: 'Minden mező kitöltése kötelező.',
passwordMismatch: 'Az új jelszó és a megerősítés nem egyezik.',
passwordMinLength: 'Az új jelszónak legalább 6 karakter hosszúnak kell lennie.',
passwordChanged: 'Jelszó sikeresen megváltoztatva!',
passwordChangeFailed: 'A jelszó módosítása sikertelen.',
// Save messages
saveSuccess: 'A profil adatok és okmányok sikeresen frissítve!',
saveError: 'Hiba történt a mentés során.',
// Regional settings tab
tabRegional: 'Területi beállítások',
regionalLanguage: 'Nyelv',
regionalDateFormat: 'Dátum formátum',
regionalCurrency: 'Pénznem',
dateFormatHU: 'ÉÉÉÉ. HH. NN.',
dateFormatISO: 'ÉÉÉÉ-HH-NN',
currencyHUF: 'HUF (Ft)',
currencyEUR: 'EUR (€)',
currencyUSD: 'USD ($)',
// Back
backToGarage: 'Vissza a Garázsba',
// Diagnostic
xray: '🩻 RÖNTGEN: person (address_* fields)',
},
kyc: {
title: 'Garázs Aktiválás — Fiók kiegészítése',
stepAddress: 'Lakcím',
stepExtras: 'Kiegészítő',
// Step 1
addressTitle: '📍 Lakcím adatok',
addressDescription: 'Az irányítószám és város kötelező a fiókod aktiválásához.',
softKyc: '(Soft KYC)',
country: 'Ország',
zipCode: 'Irányítószám',
city: 'Város',
optionalAddress: '📝 További címrészletek (opcionális)',
streetName: 'Utca neve',
streetType: 'Típus',
houseNumber: 'Házszám',
// Step 2
extrasTitle: '🔧 Kiegészítő adatok',
extrasDescription: 'Ezeket az adatokat később is megadhatod a profilodban.',
extrasOptional: '(Mind opcionális)',
phoneNumber: '📞 Telefonszám',
documentsTitle: '🪪 Okmányok adatai (opcionális)',
idCard: 'Személyi igazolvány',
drivingLicense: 'Jogosítvány',
docNumber: 'Okmány száma',
docExpiry: 'Lejárat',
language: '🌐 Nyelv',
currency: '💰 Pénznem',
// Navigation
back: 'Vissza',
next: 'Tovább',
submitting: 'Feldolgozás...',
submit: 'Garázs Aktiválása 🚀',
skip: 'Kihagyom, kitöltöm később a profilban →',
// Validation
zipRequired: 'Kérlek add meg az irányítószámot.',
cityRequired: 'Kérlek add meg a várost.',
addressRequired: 'A lakcím megadása kötelező a garázs aktiválásához.',
// Errors
submitError: 'A garázs aktiválása sikertelen. Kérlek próbáld újra.',
},
verifyEmail: {
verifying: 'Fiók aktiválása...',
verifyingDesc: 'Kérlek várj, amíg ellenőrizzük a megerősítő linket.',
success: 'Sikeres aktiválás és bejelentkezés!',
successDesc: 'A fiókod aktív lett, és automatikusan be vagy jelentkezve.',
redirectCountdown: 'Átirányítás {seconds} másodperc múlva...',
redirectNow: 'Átirányítás...',
goToProfile: 'Tovább a Profilhoz',
checkEmail: '📧 Ellenőrizd az e-mail postafiókodat',
checkEmailDesc: 'Küldtünk neked egy megerősítő e-mailt. Kérlek, <strong>ellenőrizd a beérkező leveleidet és a spam mappát is</strong>.<br><br>Ha <strong>10 percen belül nem érkezik meg</strong> a levél, kérhetsz újat az alábbi gombra kattintva.',
emailLabel: 'E-mail cím',
emailPlaceholder: 'pelda@email.com',
resendButton: 'Nem kaptam meg az e-mailt, küldjétek újra',
resending: 'Küldés...',
resendSuccess: 'Az új megerősítő e-mail elküldésre került!',
resendFailed: 'Az újraküldés sikertelen. Kérlek próbáld újra később.',
backToLogin: '← Vissza a bejelentkezéshez',
tokenExpired: 'A link lejárt vagy érvénytelen.',
},
company: {
// CompanyGarageView
garageTitle: 'Céges garázs',
user: 'Felhasználó',
// Dashboard cards
fleetTitle: 'Flotta áttekintés',
fleetVehicles: 'Kezelt járművek: {count}',
fleetActive: 'Aktív',
fleetInactive: 'Inaktív',
gamificationTitle: 'Gamifikáció',
gamificationScore: 'Havi Pontszám: {score}',
quickActionsTitle: 'Gyorsműveletek',
quickNewWaybill: 'Új menetlevél',
quickReportIssue: 'Hiba bejelentése',
// CompanyOnboardingView
onboardingTitle: 'Cég Regisztráció — 3 egyszerű lépésben',
stepLegal: 'Cégadatok',
stepAddress: 'Székhely',
stepContact: 'Kapcsolat',
// Step 1
legalTitle: '⚖️ Jogi adatok',
legalDescription: 'Add meg a céged adószámát, majd kattints a <strong>Cégadatok betöltése</strong> gombra az automatikus kitöltéshez.',
taxNumber: 'Adószám',
taxLookup: 'Cégadatok betöltése',
taxLookupTitle: 'Cégadatok betöltése adószám alapján',
fullName: 'Teljes cégnév',
shortName: 'Rövid név',
displayName: 'Megjelenítendő név',
regNumber: 'Cégjegyzékszám',
// Step 2
addressTitle: '📍 Székhely és pénzügy',
addressDescription: 'A cég székhelyének címe és az alapértelmezett pénzügyi beállítások.',
country: 'Ország',
language: 'Nyelv',
currency: 'Pénznem',
zipCode: 'Irányítószám',
city: 'Város',
streetName: 'Utca neve',
streetType: 'Típus',
houseNumber: 'Házszám',
stairwell: 'Lépcsőház',
floor: 'Emelet',
door: 'Ajtó',
parcelId: 'Helyrajzi szám',
// Step 3
contactTitle: '📞 Kapcsolat',
contactDescription: 'Az elsődleges kapcsolattartó adatai (a bejelentkezett felhasználó) nem módosíthatók.',
contactName: 'Kapcsolattartó neve',
contactEmail: 'E-mail cím',
contactPhone: 'Telefonszám',
// Navigation
cancel: 'Mégsem',
back: 'Vissza',
next: 'Tovább',
submitting: 'Létrehozás...',
submit: 'Cég Létrehozása 🚀',
// Validation
taxRequired: 'Kérlek add meg az adószámot.',
fullNameRequired: 'Kérlek add meg a teljes cégnév.',
shortNameRequired: 'Kérlek add meg a rövid nevet.',
displayNameRequired: 'Kérlek add meg a megjelenítendő nevet.',
regNumberRequired: 'Kérlek add meg a cégjegyzékszámot.',
zipRequired: 'Kérlek add meg az irányítószámot.',
cityRequired: 'Kérlek add meg a várost.',
emailRequired: 'Kérlek add meg az elsődleges e-mail címet.',
phoneRequired: 'Kérlek add meg az elsődleges telefonszámot.',
// Errors
lookupError: 'Nem sikerült betölteni a cégadatokat. Kérlek ellenőrizd az adószámot.',
duplicateCompany: 'Ez a cég már regisztrálva van a rendszerben.',
submitError: 'A cég létrehozása sikertelen. Kérlek próbáld újra.',
// Success
successMessage: 'A cég sikeresen létrejött! Átirányítás a garázsba...',
},
organization: {
backToPrivate: '⬅️ Vissza a Privát Garázsba',
fallbackName: 'Céges Garázs',
},
address: {
stairwell: 'Lépcsőház: {value}',
floor: 'Emelet: {value}',
door: 'Ajtó: {value}',
hrsz: 'Hrsz.: {value}',
placeholder: '—',
},
}

83
frontend/src/i18n/ro.ts Normal file
View File

@@ -0,0 +1,83 @@
export default {
menu: {
features: 'Funcționalități',
garage: 'Vehicule',
calendar: 'Calendar Service',
finance: 'Finanțe & Costuri',
diagnostic: 'Diagnosticare Live',
addVehicle: '+ Adăugați Vehicul Nou',
},
header: {
welcome: 'Bun venit',
userMenu: 'Meniul utilizatorului',
profile: 'Setări Profil',
logout: 'Deconectare',
subtitle: 'Tabloul de bord și prezentarea vehiculelor',
switchLanguage: 'Schimbă limba',
},
dashboard: {
loading: 'Se încarcă...',
errorRetry: 'Reîncercare',
statusActive: 'Activ',
statusUnknown: 'Necunoscut',
condition: 'Stare',
mileage: 'km',
// Bento Box layout
myVehicles: 'Vehiculele Mele',
vehicles: 'vehicule',
noVehicles: 'Încă nu aveți vehicule. Adăugați primul!',
activeProcesses: 'Procese Active / Notificări',
noNotifications: 'Totul în regulă — nicio notificare',
totalVehicles: 'Total Vehicule',
addVehicle: '+ Adăugați Vehicul',
refresh: 'Reîmprospătare',
statsAndPoints: 'Statistici & Puncte',
live: 'LIVE',
monthlyScore: 'Scor Lunar',
totalKm: 'Distanță Totală',
fuelCost: 'Cost Combustibil',
serviceCost: 'Cost Service',
co2Savings: 'Economii CO₂',
achievements: 'Realizări',
},
zen: {
hide: 'Ascunde UI (Mod Zen)',
show: 'Arată UI',
},
auth: {
invalid_credentials: 'Email sau parolă invalidă.',
user_inactive: 'Contul dumneavoastră nu este încă activat. Vă rugăm să verificați emailul!',
forgotPasswordMessage: 'Funcția de resetare a parolei este în dezvoltare. Vă rugăm să contactați administratorul!',
resendTitle: 'Retrimiteți Emailul de Activare',
resendDescription: 'Introduceți adresa de email și vom retrimite linkul de activare.',
resendButton: 'Trimiteți Email',
resend_cooldown: 'Retrimiteți ({seconds}s)',
send_email: 'Trimiteți Email',
resendSuccess: 'Emailul de activare a fost trimis! Verificați dosarele (inclusiv Spam).',
resendLink: 'Nu ați primit emailul de activare?',
backToLogin: 'Înapoi la Autentificare',
loginTitle: 'Autentificare în Garaj',
loginButton: 'Autentificare',
loginLoading: 'Vă rugăm așteptați...',
forgotPassword: 'Ați uitat parola?',
rememberMe: 'Ține-mă minte',
noAccount: 'Nu aveți încă un garaj?',
registerLink: 'Înregistrare',
registerTitle: 'Deschideți un Garaj Nou',
registerButton: 'Înregistrare',
haveAccount: 'Aveți deja un garaj?',
loginLink: 'Autentificare',
forgotTitle: 'Solicitați o Parolă Nouă',
forgotDescription: 'Introduceți adresa de email și vă vom trimite un link de securitate.',
forgotSuccess: 'Linkul de resetare a fost trimis pe email!',
forgotSendButton: 'Trimiteți Linkul',
inactiveMessage: 'Contul dumneavoastră nu este încă activat! Activați-l folosind linkul din email sau solicitați un nou email de activare.',
resendActivationButton: 'Retrimiteți Emailul de Activare',
resendActivationSuccess: 'Emailul de activare a fost retrimis!',
socialDivider: 'Sau autentificați-vă cu un cont Social',
loginFailed: 'Autentificare eșuată. Verificați datele de autentificare.',
registerFailed: 'Înregistrare eșuată.',
resendFailed: 'Retrimiterea a eșuat. Vă rugăm încercați din nou mai târziu.',
forgotFailed: 'A apărut o eroare la trimiterea cererii. Vă rugăm încercați din nou.',
},
}

83
frontend/src/i18n/sk.ts Normal file
View File

@@ -0,0 +1,83 @@
export default {
menu: {
features: 'Funkcie',
garage: 'Vozidlá',
calendar: 'Servisný kalendár',
finance: 'Financie & Náklady',
diagnostic: 'Živá diagnostika',
addVehicle: '+ Pridať nové vozidlo',
},
header: {
welcome: 'Vitajte',
userMenu: 'Používateľské menu',
profile: 'Nastavenia profilu',
logout: 'Odhlásiť sa',
subtitle: 'Váš dashboard a prehľad vozidiel',
switchLanguage: 'Prepnúť jazyk',
},
dashboard: {
loading: 'Načítava sa...',
errorRetry: 'Opakovať',
statusActive: 'Aktívny',
statusUnknown: 'Neznámy',
condition: 'Stav',
mileage: 'km',
// Bento Box layout
myVehicles: 'Moje Vozidlá',
vehicles: 'vozidiel',
noVehicles: 'Zatiaľ žiadne vozidlá. Pridajte svoje prvé!',
activeProcesses: 'Aktívne Procesy / Notifikácie',
noNotifications: 'Všetko v poriadku — žiadne notifikácie',
totalVehicles: 'Celkový Počet Vozidiel',
addVehicle: '+ Pridať Vozidlo',
refresh: 'Obnoviť',
statsAndPoints: 'Štatistiky & Body',
live: 'ŽIVĚ',
monthlyScore: 'Mesačné Skóre',
totalKm: 'Celková Vzdialenosť',
fuelCost: 'Náklady na Palivo',
serviceCost: 'Náklady na Servis',
co2Savings: 'Úspora CO₂',
achievements: 'Úspechy',
},
zen: {
hide: 'Skryť UI (Zen režim)',
show: 'Zobraziť UI',
},
auth: {
invalid_credentials: 'Neplatný email alebo heslo.',
user_inactive: 'Váš účet ešte nie je aktivovaný. Prosím overte svoj email!',
forgotPasswordMessage: 'Funkcia obnovenia hesla je vo vývoji. Prosím kontaktujte administrátora!',
resendTitle: 'Znova odoslať aktivačný email',
resendDescription: 'Zadajte svoju emailovú adresu a znova pošleme aktivačný odkaz.',
resendButton: 'Odoslať email',
resend_cooldown: 'Odoslať znova ({seconds}s)',
send_email: 'Odoslať email',
resendSuccess: 'Aktivačný email bol odoslaný! Skontrolujte svoje priečinky (vrátane Spamu).',
resendLink: 'Nedostali ste aktivačný email?',
backToLogin: 'Späť na prihlásenie',
loginTitle: 'Prihlásenie do garáže',
loginButton: 'Prihlásiť sa',
loginLoading: 'Prosím čakajte...',
forgotPassword: 'Zabudli ste heslo?',
rememberMe: 'Zapamätať si ma',
noAccount: 'Ešte nemáte garáž?',
registerLink: 'Registrovať',
registerTitle: 'Otvoriť novú garáž',
registerButton: 'Registrovať',
haveAccount: 'Už máte garáž?',
loginLink: 'Prihlásiť sa',
forgotTitle: 'Požiadať o nové heslo',
forgotDescription: 'Zadajte svoju emailovú adresu a pošleme vám bezpečnostný odkaz.',
forgotSuccess: 'Odkaz na obnovenie bol odoslaný na váš email!',
forgotSendButton: 'Odoslať odkaz',
inactiveMessage: 'Váš účet ešte nie je aktivovaný! Aktivujte ho pomocou odkazu v emaile alebo požiadajte o nový aktivačný email.',
resendActivationButton: 'Znova odoslať aktivačný email',
resendActivationSuccess: 'Aktivačný email bol znovu odoslaný!',
socialDivider: 'Alebo sa prihláste pomocou sociálneho účtu',
loginFailed: 'Prihlásenie zlyhalo. Skontrolujte svoje prihlasovacie údaje.',
registerFailed: 'Registrácia zlyhala.',
resendFailed: 'Odoslanie zlyhalo. Prosím skúste to znova neskôr.',
forgotFailed: 'Pri odosielaní požiadavky došlo k chybe. Prosím skúste to znova.',
},
}

View File

@@ -0,0 +1,82 @@
<template>
<div class="organization-layout min-h-screen text-white">
<!-- Modular header with BaseHeader + Lego components -->
<BaseHeader>
<template #left>
<HeaderLogo />
</template>
<template #center>
<div class="font-semibold text-lg text-white tracking-wide">{{ orgDisplayName }} garázsa</div>
</template>
<template #right>
<HeaderCompanySwitcher />
<HeaderProfile />
</template>
</BaseHeader>
<!-- Main content slot for child routes -->
<main class="relative z-10">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
import BaseHeader from '../components/layout/BaseHeader.vue'
import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
const themeStore = useThemeStore()
// ── Resolve organization display name from route params ────────────
const orgDisplayName = computed(() => {
const id = Number(route.params.id)
if (!id) return t('organization.fallbackName')
const org = authStore.myOrganizations.find((o) => o.organization_id === id)
return org?.display_name || org?.name || t('organization.fallbackName')
})
// ── Resolve current organization object ────────────────────────────
const currentOrganization = computed(() => {
const id = Number(route.params.id)
if (!id) return null
return authStore.myOrganizations.find((o) => o.organization_id === id) || null
})
// ── Apply theme when organization loads or changes ────────────────
function applyOrgTheme() {
const org = currentOrganization.value
if (org?.visual_settings) {
themeStore.applyTheme(org.visual_settings)
}
}
// Watch for organization changes (e.g. when switching orgs via switcher)
watch(
() => route.params.id,
() => {
applyOrgTheme()
},
)
// Apply theme on mount
onMounted(() => {
applyOrgTheme()
})
// Reset theme when leaving the organization view
onUnmounted(() => {
themeStore.resetTheme()
})
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="private-layout min-h-screen text-white">
<!-- Modular header with BaseHeader + Lego components -->
<BaseHeader>
<template #left>
<HeaderLogo />
</template>
<template #center>
<!-- Center label: {firstName}_garage -->
<span class="text-sm font-semibold text-white/70 tracking-wide">
{{ centerLabel }}
</span>
</template>
<template #right>
<HeaderCompanySwitcher />
<HeaderProfile />
</template>
</BaseHeader>
<!-- Main content slot for child routes -->
<main class="relative z-10">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import BaseHeader from '../components/layout/BaseHeader.vue'
import HeaderLogo from '../components/header/HeaderLogo.vue'
import HeaderCompanySwitcher from '../components/header/HeaderCompanySwitcher.vue'
import HeaderProfile from '../components/header/HeaderProfile.vue'
const { t } = useI18n()
const authStore = useAuthStore()
// ── Center label: shows "{firstName}_garage" ───────────────────────
const centerLabel = computed(() => {
const firstName = authStore.user?.first_name ||
authStore.user?.email?.split('@')[0] ||
t('header.user')
return t('header.personalGarageLabel', { name: firstName })
})
</script>

View File

@@ -5,11 +5,20 @@ import App from './App.vue'
import router from './router'
import hu from './i18n/hu'
import en from './i18n/en'
import de from './i18n/de'
import ro from './i18n/ro'
import cz from './i18n/cz'
import sk from './i18n/sk'
import './assets/main.css'
import 'flag-icons/css/flag-icons.css'
const messages = {
hu,
en,
de,
ro,
cz,
sk,
}
/**
@@ -34,11 +43,73 @@ function detectLocale(): string {
const initialLocale = detectLocale()
/**
* DateTime format configurations per locale.
*
* Usage in Vue templates:
* $d(date, 'short') → "2026. 06. 08." (hu) or "06/08/2026" (en)
* $d(date, 'long') → "2026. június 8., hétfő" (hu) or "Monday, June 8, 2026" (en)
*/
const datetimeFormats: Record<string, Intl.DateTimeFormatOptions> = {
hu: {
short: { year: 'numeric', month: '2-digit', day: '2-digit' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
},
en: {
short: { year: 'numeric', month: '2-digit', day: '2-digit' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
},
}
/**
* Number / currency format configurations per locale.
*
* Usage in Vue templates:
* $n(amount, 'currency') → "1 000 Ft" (hu) or "€1,000.00" (en)
* $n(value, 'decimal') → "1 234,56" (hu) or "1,234.56" (en)
*/
const numberFormats: Record<string, Intl.NumberFormatOptions> = {
hu: {
currency: {
style: 'currency',
currency: 'HUF',
currencyDisplay: 'symbol',
useGrouping: true,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
},
decimal: {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
},
en: {
currency: {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'symbol',
useGrouping: true,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
decimal: {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
},
}
const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: 'en',
messages,
datetimeFormats,
numberFormats,
})
const app = createApp(App)

View File

@@ -11,45 +11,79 @@ const router = createRouter({
},
{
path: '/dashboard',
name: 'dashboard',
// Lazy-loaded — placeholder until the actual Dashboard view is built
component: () => import('../views/DashboardView.vue')
component: () => import('../layouts/PrivateLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'dashboard',
component: () => import('../views/DashboardView.vue')
}
]
},
{
path: '/organization/:id',
component: () => import('../layouts/OrganizationLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'organization-dashboard',
component: () => import('../views/organization/CompanyGarageView.vue')
}
]
},
{
path: '/complete-kyc',
name: 'complete-kyc',
// Lazy-loaded — placeholder until the actual KYC view is built
component: () => import('../views/CompleteKycView.vue')
},
{
path: '/profile',
name: 'profile',
// Auth-protected profile settings page
component: () => import('../views/ProfileView.vue'),
meta: { requiresAuth: true }
},
{
path: '/verify',
name: 'verify',
// Email verification page (accessible without auth)
component: () => import('../views/VerifyEmailView.vue')
},
{
path: '/reset-password',
name: 'reset-password',
// Password reset page — reads ?token= from query param
component: () => import('../views/ResetPasswordView.vue')
},
{
path: '/company/onboard',
name: 'company-onboard',
component: () => import('../views/organization/CompanyOnboardingView.vue'),
meta: { requiresAuth: true }
},
{
path: '/company/garage',
redirect: () => {
// Redirect old /company/garage to /organization/:id
// The auth store will be checked at runtime
return { name: 'dashboard' }
}
}
]
})
// ── Global navigation guard ──────────────────────────────────────────
// If an authenticated user navigates to the root ("/"), redirect to "/dashboard".
// Protected routes (meta.requiresAuth) redirect unauthenticated users to "/".
router.beforeEach(async (to, _from, next) => {
const { useAuthStore } = await import('../stores/auth')
const authStore = useAuthStore()
// CRITICAL: Wait for the auth store to finish initializing
// (reading token from localStorage + fetching user profile).
// Without this, the guard runs before init() completes on F5 refresh,
// causing isAuthenticated to be falsely false -> user gets logged out.
if (!authStore.isInitialized) {
await authStore.init()
}
// Redirect authenticated users away from landing
if (to.path === '/') {
if (authStore.isAuthenticated) {

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '../router'
import api from '../api/axios'
import type { OrganizationItem } from '../types/organization'
/**
* Backend now sends address fields directly on the person object
@@ -76,6 +77,8 @@ export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('access_token'))
const isLoading = ref(false)
const error = ref<string | null>(null)
const myOrganizations = ref<OrganizationItem[]>([])
const isInitialized = ref(false)
// ────────────────────────────── Getters ────────────────────────────
const isAuthenticated = computed(() => !!token.value && !!user.value)
@@ -182,6 +185,9 @@ export const useAuthStore = defineStore('auth', () => {
// Immediately fetch the user profile
await fetchUser()
// Fetch organizations after successful login so the header can show them
await fetchMyOrganizations()
// Clear pending verification email after successful login
localStorage.removeItem('pending_verification_email')
@@ -341,10 +347,14 @@ export const useAuthStore = defineStore('auth', () => {
if (token.value) {
try {
await fetchUser()
// Load organizations so the header switcher has data immediately
await fetchMyOrganizations()
} catch {
// Token invalid — already cleared by fetchUser → logout
}
}
// Mark initialization as complete so the router guard can await it
isInitialized.value = true
}
/**
@@ -471,12 +481,94 @@ export const useAuthStore = defineStore('auth', () => {
error.value = null
}
// ──────────────────────── Organization Actions ──────────────────────
/**
* Fetch the current user's organizations from GET /api/v1/organizations/my.
* Stores them in myOrganizations state.
*/
async function fetchMyOrganizations() {
try {
const res = await api.get('/organizations/my')
myOrganizations.value = res.data as OrganizationItem[]
return myOrganizations.value
} catch (err: any) {
console.error('Failed to fetch organizations:', err)
myOrganizations.value = []
return []
}
}
/**
* Get the user's first business organization (org_type === 'business' or similar).
* Returns the organization item or null if none found.
*/
const businessOrganization = computed(() => {
return myOrganizations.value.find(
(org) => org.org_type === 'business' || org.org_type === 'fleet_owner'
) || null
})
/**
* Check if the user has any business organization.
*/
const hasBusinessOrganization = computed(() => {
return !!businessOrganization.value
})
/**
* Check if the user is currently in corporate mode
* (active_organization_id is set and matches a business org).
*/
const isCorporateMode = computed(() => {
return !!user.value?.active_organization_id
})
/**
* Switch the user's active organization.
* PATCH /api/v1/users/me/active-organization with { organization_id }.
* On success, updates the token and user state, then navigates.
*/
async function switchOrganization(orgId: number | null) {
isLoading.value = true
error.value = null
try {
const res = await api.patch('/users/me/active-organization', {
organization_id: orgId,
})
const data = res.data as {
user: UserProfile
access_token: string
token_type: string
}
// Update the stored token
localStorage.setItem('access_token', data.access_token)
token.value = data.access_token
// Update the user state with the fresh profile
user.value = data.user
} catch (err: any) {
error.value =
err.response?.data?.detail ||
err.response?.data?.message ||
'Failed to switch organization'
throw err
} finally {
isLoading.value = false
}
}
return {
// State
user,
token,
isLoading,
error,
myOrganizations,
isInitialized,
// Getters
isAuthenticated,
@@ -484,6 +576,9 @@ export const useAuthStore = defineStore('auth', () => {
userRole,
userName,
userId,
businessOrganization,
hasBusinessOrganization,
isCorporateMode,
// Actions
login,
@@ -499,5 +594,7 @@ export const useAuthStore = defineStore('auth', () => {
logout,
init,
clearError,
fetchMyOrganizations,
switchOrganization,
}
})

View File

@@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* Default fallback values used when visual_settings are not provided.
* These match the fallbacks in tailwind.config.js.
*/
const DEFAULT_THEME = {
primary_color: '#10b981',
secondary_color: '#047857',
bg_color: '#0f172a',
} as const
/**
* CSS custom property names mapped to visual_settings keys.
*/
const CSS_VAR_MAP: Record<string, string> = {
primary_color: '--theme-primary',
secondary_color: '--theme-secondary',
bg_color: '--theme-bg',
}
export const useThemeStore = defineStore('theme', () => {
// ────────────────────────────── State ──────────────────────────────
/** Tracks whether a custom theme is currently applied. */
const isCustomThemeActive = ref(false)
// ────────────────────────────── Actions ────────────────────────────
/**
* Apply a custom theme by setting CSS custom properties on :root.
* Only known keys (primary_color, secondary_color, bg_color) are applied.
* Unknown keys in visualSettings are silently ignored.
*
* @param visualSettings - A flat object with theme keys, e.g.
* { primary_color: '#3b82f6', secondary_color: '#1d4ed8', bg_color: '#0f172a' }
*/
function applyTheme(visualSettings: Record<string, any>) {
if (!visualSettings || typeof visualSettings !== 'object') {
console.warn('[ThemeStore] applyTheme called with invalid settings:', visualSettings)
return
}
const root = document.documentElement
for (const [key, cssVar] of Object.entries(CSS_VAR_MAP)) {
const value = visualSettings[key]
if (value && typeof value === 'string') {
root.style.setProperty(cssVar, value)
} else {
// Fallback to default if key is missing or invalid
root.style.setProperty(cssVar, (DEFAULT_THEME as Record<string, string>)[key])
}
}
isCustomThemeActive.value = true
}
/**
* Reset the theme by removing all custom CSS properties from :root.
* This causes the Tailwind fallback values (and the original design system)
* to take effect again.
*/
function resetTheme() {
const root = document.documentElement
for (const cssVar of Object.values(CSS_VAR_MAP)) {
root.style.removeProperty(cssVar)
}
isCustomThemeActive.value = false
}
return {
// State
isCustomThemeActive,
// Actions
applyTheme,
resetTheme,
}
})

View File

@@ -0,0 +1,24 @@
/**
* Organization item returned from GET /api/v1/organizations/my
* Maps to the backend Organization model fields.
*/
export interface OrganizationItem {
organization_id: number
status: string
name: string
full_name: string
display_name: string | null
tax_number: string | null
country_code: string
is_active: boolean
is_deleted: boolean
subscription_plan: string
/** org_type is not in the current /my response but we keep it for future use */
org_type?: string
/**
* JSONB visual settings for white-labeling / dynamic theming.
* Contains keys like: primary_color, secondary_color, bg_color, wall_logo_url, etc.
* Set via PATCH /organizations/{org_id}/visual-settings.
*/
visual_settings?: Record<string, any> | null
}

View File

@@ -1,349 +1,402 @@
<template>
<div class="min-h-screen bg-[#04151F] flex flex-col items-center justify-center px-4 py-8">
<!-- Header Logo / Brand -->
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white tracking-tight">
Service<span class="text-[#00E5A0]">Finder</span>
</h1>
<p class="text-white/40 text-sm mt-1">Fiók aktiválás KYC kitöltése</p>
<div class="relative flex min-h-screen items-center justify-center text-white">
<!-- Background Image (like VerifyEmailView and LandingView) -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Progress Bar -->
<div class="w-full max-w-lg mb-8">
<div class="flex items-center justify-between">
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex items-center flex-1"
>
<!-- Step circle + label -->
<div class="flex flex-col items-center">
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
:class="stepCircleClass(idx)"
>
<span v-if="currentStep > idx + 1"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<span
class="text-xs mt-1.5 transition-colors duration-300"
:class="currentStep >= idx + 1 ? 'text-[#00E5A0]' : 'text-white/30'"
>
{{ step.label }}
</span>
</div>
<!-- Connector line -->
<!-- Content Container -->
<div class="relative z-10 mx-auto w-full max-w-2xl px-4 py-8">
<!-- Header Logo / Brand -->
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white tracking-tight">
Service<span class="text-[#00E5A0]">Finder</span>
</h1>
<p class="text-white/60 text-sm mt-2">{{ t('kyc.title') }}</p>
</div>
<!-- Progress Bar (2 steps only) -->
<div class="w-full max-w-lg mx-auto mb-8">
<div class="flex items-center justify-between">
<div
v-if="idx < steps.length - 1"
class="flex-1 h-0.5 mx-3 rounded transition-colors duration-300"
:class="currentStep > idx + 1 ? 'bg-[#00E5A0]' : 'bg-white/10'"
/>
v-for="(step, idx) in steps"
:key="idx"
class="flex items-center flex-1"
>
<!-- Step circle + label -->
<div class="flex flex-col items-center">
<div
class="w-12 h-12 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
:class="stepCircleClass(idx)"
>
<span v-if="currentStep > idx + 1"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<span
class="text-xs mt-2 transition-colors duration-300 font-medium"
:class="currentStep >= idx + 1 ? 'text-[#00E5A0]' : 'text-white/40'"
>
{{ step.label }}
</span>
</div>
<!-- Connector line -->
<div
v-if="idx < steps.length - 1"
class="flex-1 h-1 mx-4 rounded transition-all duration-300"
:class="currentStep > idx + 1 ? 'bg-[#00E5A0]' : 'bg-white/10'"
/>
</div>
</div>
</div>
</div>
<!-- Glassmorphism Card -->
<div class="w-full max-w-lg">
<div
class="backdrop-blur-xl bg-white/5 border border-white/10 rounded-2xl shadow-2xl p-8 md:p-10 transition-all duration-500"
>
<!-- ==================== STEP 1: Personal ==================== -->
<div v-if="currentStep === 1">
<h2 class="text-xl font-semibold text-white mb-1">Személyes adatok</h2>
<p class="text-[#00E5A0]/70 text-sm mb-6">
Vezeték- és keresztnév megadása
</p>
<div class="space-y-4">
<!-- Last name -->
<div>
<label class="block text-sm text-white/60 mb-1">Vezetéknév</label>
<input
v-model="kycForm.last_name"
type="text"
placeholder="pl. Kovács"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<!-- First name -->
<div>
<label class="block text-sm text-white/60 mb-1">Keresztnév</label>
<input
v-model="kycForm.first_name"
type="text"
placeholder="pl. István"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
</div>
</div>
<!-- ==================== STEP 2: Address (Soft KYC) ==================== -->
<div v-if="currentStep === 2">
<h2 class="text-xl font-semibold text-white mb-1">Lakcím adatok</h2>
<p class="text-white/40 text-sm mb-6">Csak az irányítószám és város kötelező <span class="text-[#D4AF37]">(Soft KYC)</span></p>
<div class="space-y-4">
<!-- Country selector (EU-ready) -->
<div>
<label class="block text-sm text-white/60 mb-1">Ország</label>
<select
v-model="kycForm.region_code"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
>
<option value="HU" class="bg-[#04151F]">🇭🇺 Magyarország</option>
<option value="AT" class="bg-[#04151F]">🇦🇹 Ausztria</option>
<option value="SK" class="bg-[#04151F]">🇸🇰 Szlovákia</option>
<option value="DE" class="bg-[#04151F]">🇩🇪 Németország</option>
<option value="RO" class="bg-[#04151F]">🇷🇴 Románia</option>
</select>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-1">
<label class="block text-sm text-white/60 mb-1">Irányítószám</label>
<input
v-model="kycForm.address_zip"
type="text"
placeholder="1234"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div class="col-span-2">
<label class="block text-sm text-white/60 mb-1">Város</label>
<div class="relative">
<input
v-model="kycForm.address_city"
type="text"
placeholder="Budapest"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition pr-10"
:class="{ 'opacity-60': isCityLoading }"
/>
<!-- Loading spinner -->
<div
v-if="isCityLoading"
class="absolute right-3 top-1/2 -translate-y-1/2"
>
<svg
class="animate-spin h-5 w-5 text-[#00E5A0]"
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>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-sm text-white/60 mb-1">Utca neve <span class="text-white/20">(opcionális)</span></label>
<input
v-model="kycForm.address_street_name"
type="text"
placeholder="Kossuth Lajos"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div>
<label class="block text-sm text-white/60 mb-1">Típus <span class="text-white/20">(opcionális)</span></label>
<select
v-model="kycForm.address_street_type"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
>
<option value="" disabled class="bg-[#04151F]">Típus</option>
<option value="utca" class="bg-[#04151F]">utca</option>
<option value="út" class="bg-[#04151F]">út</option>
<option value="tér" class="bg-[#04151F]">tér</option>
<option value="körút" class="bg-[#04151F]">körút</option>
<option value="sugárút" class="bg-[#04151F]">sugárút</option>
<option value="köz" class="bg-[#04151F]">köz</option>
<option value="sor" class="bg-[#04151F]">sor</option>
<option value="liget" class="bg-[#04151F]">liget</option>
<option value="park" class="bg-[#04151F]">park</option>
<option value="dűlő" class="bg-[#04151F]">dűlő</option>
</select>
</div>
</div>
<div>
<label class="block text-sm text-white/60 mb-1">Házszám <span class="text-white/20">(opcionális)</span></label>
<input
v-model="kycForm.address_house_number"
type="text"
placeholder="10/A"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
@keydown.tab.exact="handleHouseNumberTab"
/>
</div>
</div>
</div>
<!-- ==================== STEP 3: Security ==================== -->
<div v-if="currentStep === 3">
<h2 class="text-xl font-semibold text-white mb-1">Okmányok</h2>
<p class="text-white/40 text-sm mb-6">Személyi igazolvány és jogosítvány adatok <span class="text-[#D4AF37]">(opcionális)</span></p>
<div class="space-y-6">
<!-- Identity Documents -->
<div>
<!-- ID Card -->
<div class="bg-white/5 rounded-lg p-4 mb-3 border border-white/5">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-white/20" />
<span class="text-sm text-white/50">Személyi igazolvány <span class="text-white/20">(opcionális)</span></span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/40 mb-1">Okmány száma</label>
<input
v-model="kycForm.identity_docs.ID_CARD.number"
type="text"
placeholder="123456AB"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">Lejárat</label>
<input
v-model="kycForm.identity_docs.ID_CARD.expiry_date"
type="date"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
</div>
</div>
<!-- Driver's License (optional) -->
<div class="bg-white/5 rounded-lg p-4 border border-white/5">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-white/20" />
<span class="text-sm text-white/50">Jogosítvány <span class="text-white/20">(opcionális)</span></span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/40 mb-1">Okmány száma</label>
<input
v-model="kycForm.identity_docs.LICENSE.number"
type="text"
placeholder="HU-1234567"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div>
<label class="block text-xs text-white/40 mb-1">Lejárat</label>
<input
v-model="kycForm.identity_docs.LICENSE.expiry_date"
type="date"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
</div>
</div>
</div>
<!-- Language & Currency Preferences -->
<div class="grid grid-cols-2 gap-3 pt-2">
<div>
<label class="block text-sm text-white/60 mb-1">Nyelv</label>
<select
v-model="kycForm.preferred_language"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
>
<option value="hu" class="bg-[#04151F]">Magyar</option>
<option value="en" class="bg-[#04151F]">English</option>
</select>
</div>
<div>
<label class="block text-sm text-white/60 mb-1">Pénznem</label>
<select
v-model="kycForm.preferred_currency"
class="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
>
<option value="HUF" class="bg-[#04151F]">HUF (Ft)</option>
<option value="EUR" class="bg-[#04151F]">EUR (€)</option>
<option value="USD" class="bg-[#04151F]">USD ($)</option>
</select>
</div>
</div>
</div>
</div>
<!-- ==================== Navigation Buttons ==================== -->
<div class="flex items-center justify-between mt-8 pt-6 border-t border-white/5">
<!-- Back button -->
<button
v-if="currentStep > 1"
@click="prevStep"
class="px-5 py-2.5 rounded-lg text-white/60 hover:text-white border border-white/10 hover:border-white/30 transition-all text-sm"
>
← Vissza
</button>
<div v-else />
<!-- Next / Submit -->
<button
v-if="currentStep < 3"
@click="nextStep"
ref="nextButton"
class="px-6 py-2.5 rounded-lg bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/20 transition-all text-sm"
>
Tovább →
</button>
<button
v-else
@click="handleSubmit"
:disabled="isSubmitting"
class="px-6 py-2.5 rounded-lg font-semibold transition-all text-sm flex items-center gap-2"
:class="isSubmitting
? 'bg-[#00E5A0]/50 text-[#04151F]/70 cursor-not-allowed'
: 'bg-[#00E5A0] text-[#04151F] hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/20'"
>
<!-- Spinner -->
<svg
v-if="isSubmitting"
class="animate-spin h-4 w-4 text-[#04151F]"
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>
<span>{{ isSubmitting ? 'Feldolgozás...' : 'Garázs Hitelesítése és Belépés' }}</span>
</button>
</div>
<!-- Error message -->
<!-- Glassmorphism Card -->
<div class="w-full max-w-lg mx-auto">
<div
v-if="errorMessage"
class="mt-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
class="rounded-2xl border border-white/[0.08] bg-white/[0.05] p-8 md:p-10 backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-500"
>
{{ errorMessage }}
<!-- ==================== STEP 1: Address (Soft KYC) ==================== -->
<div v-if="currentStep === 1">
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('kyc.addressTitle') }}</h2>
<p class="text-white/60 text-sm mb-6 leading-relaxed">
{{ t('kyc.addressDescription') }}
<span class="text-[#00E5A0]">{{ t('kyc.softKyc') }}</span>
</p>
<div class="space-y-5">
<!-- Country selector (EU-ready) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.country') }}</label>
<select
v-model="kycForm.region_code"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="HU" class="bg-[#04151F]">🇭🇺 Magyarország</option>
<option value="AT" class="bg-[#04151F]">🇦🇹 Ausztria</option>
<option value="SK" class="bg-[#04151F]">🇸🇰 Szlovákia</option>
<option value="DE" class="bg-[#04151F]">🇩🇪 Németország</option>
<option value="RO" class="bg-[#04151F]">🇷🇴 Románia</option>
<option value="CZ" class="bg-[#04151F]">🇨🇿 Csehország</option>
</select>
</div>
<div class="grid grid-cols-3 gap-4">
<div class="col-span-1">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('kyc.zipCode') }} <span class="text-red-400">*</span>
</label>
<input
v-model="kycForm.address_zip"
type="text"
placeholder="1234"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('kyc.city') }} <span class="text-red-400">*</span>
</label>
<div class="relative">
<input
v-model="kycForm.address_city"
type="text"
placeholder="Budapest"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all pr-10"
:class="{ 'opacity-60': isCityLoading }"
/>
<!-- Loading spinner -->
<div
v-if="isCityLoading"
class="absolute right-3 top-1/2 -translate-y-1/2"
>
<svg
class="animate-spin h-5 w-5 text-[#00E5A0]"
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>
</div>
</div>
</div>
</div>
<!-- Optional street details (collapsible) -->
<div class="border-t border-white/10 pt-4">
<button
@click="showOptionalAddress = !showOptionalAddress"
type="button"
class="flex items-center justify-between w-full text-sm text-white/60 hover:text-white/80 transition-colors"
>
<span>{{ t('kyc.optionalAddress') }}</span>
<svg
class="w-5 h-5 transition-transform"
:class="{ 'rotate-180': showOptionalAddress }"
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>
<div v-if="showOptionalAddress" class="mt-4 space-y-4">
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.streetName') }}</label>
<input
v-model="kycForm.address_street_name"
type="text"
placeholder="Kossuth Lajos"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.streetType') }}</label>
<select
v-model="kycForm.address_street_type"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
>
<option value="" disabled class="bg-[#04151F]">{{ t('kyc.streetType') }}</option>
<option value="utca" class="bg-[#04151F]">utca</option>
<option value="út" class="bg-[#04151F]">út</option>
<option value="tér" class="bg-[#04151F]">tér</option>
<option value="körút" class="bg-[#04151F]">körút</option>
<option value="sugárút" class="bg-[#04151F]">sugárút</option>
<option value="köz" class="bg-[#04151F]">köz</option>
</select>
</div>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">{{ t('kyc.houseNumber') }}</label>
<input
v-model="kycForm.address_house_number"
type="text"
placeholder="10/A"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2.5 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== STEP 2: Optional Extras ==================== -->
<div v-if="currentStep === 2">
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('kyc.extrasTitle') }}</h2>
<p class="text-white/60 text-sm mb-6 leading-relaxed">
{{ t('kyc.extrasDescription') }} <span class="text-white/40">{{ t('kyc.extrasOptional') }}</span>
</p>
<div class="space-y-6">
<!-- Phone number -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.phoneNumber') }}</label>
<input
v-model="kycForm.phone_number"
type="tel"
placeholder="+36 30 123 4567"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<!-- Identity Documents (collapsible) -->
<div class="border-t border-white/10 pt-4">
<button
@click="showDocuments = !showDocuments"
type="button"
class="flex items-center justify-between w-full text-sm text-white/60 hover:text-white/80 transition-colors"
>
<span>{{ t('kyc.documentsTitle') }}</span>
<svg
class="w-5 h-5 transition-transform"
:class="{ 'rotate-180': showDocuments }"
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>
<div v-if="showDocuments" class="mt-4 space-y-4">
<!-- ID Card -->
<div class="bg-white/5 rounded-lg p-4 border border-white/5">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-white/30" />
<span class="text-sm text-white/70">{{ t('kyc.idCard') }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docNumber') }}</label>
<input
v-model="kycForm.identity_docs.ID_CARD.number"
type="text"
placeholder="123456AB"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div>
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docExpiry') }}</label>
<input
v-model="kycForm.identity_docs.ID_CARD.expiry_date"
type="date"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
</div>
</div>
<!-- Driver's License -->
<div class="bg-white/5 rounded-lg p-4 border border-white/5">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-white/30" />
<span class="text-sm text-white/70">{{ t('kyc.drivingLicense') }}</span>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docNumber') }}</label>
<input
v-model="kycForm.identity_docs.LICENSE.number"
type="text"
placeholder="HU-1234567"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
<div>
<label class="block text-xs text-white/50 mb-1">{{ t('kyc.docExpiry') }}</label>
<input
v-model="kycForm.identity_docs.LICENSE.expiry_date"
type="date"
class="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white [color-scheme:dark] focus:outline-none focus:border-[#00E5A0] focus:ring-1 focus:ring-[#00E5A0]/30 transition"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Language & Currency Preferences -->
<div class="grid grid-cols-2 gap-4 pt-2">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.language') }}</label>
<select
v-model="kycForm.preferred_language"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="hu" class="bg-[#04151F]">Magyar</option>
<option value="en" class="bg-[#04151F]">English</option>
<option value="de" class="bg-[#04151F]">Deutsch</option>
<option value="sk" class="bg-[#04151F]">Slovenčina</option>
<option value="ro" class="bg-[#04151F]">Română</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('kyc.currency') }}</label>
<select
v-model="kycForm.preferred_currency"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="HUF" class="bg-[#04151F]">HUF (Ft)</option>
<option value="EUR" class="bg-[#04151F]">EUR (€)</option>
<option value="USD" class="bg-[#04151F]">USD ($)</option>
</select>
</div>
</div>
</div>
</div>
<!-- ==================== Navigation Buttons ==================== -->
<div class="flex items-center justify-between mt-8 pt-6 border-t border-white/5">
<!-- Back button -->
<button
v-if="currentStep > 1"
@click="prevStep"
class="px-5 py-3 rounded-xl text-white/70 hover:text-white border border-white/10 hover:border-white/30 transition-all text-sm font-medium flex items-center gap-2"
>
<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="M15 19l-7-7 7-7" />
</svg>
{{ t('kyc.back') }}
</button>
<div v-else />
<!-- Next / Submit -->
<button
v-if="currentStep < 2"
@click="nextStep"
ref="nextButton"
class="px-6 py-3 rounded-xl bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30 transition-all text-sm flex items-center gap-2"
>
{{ t('kyc.next') }}
<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="M9 5l7 7-7 7" />
</svg>
</button>
<button
v-else
@click="handleSubmit"
:disabled="isSubmitting"
class="px-6 py-3 rounded-xl font-semibold transition-all text-sm flex items-center gap-2"
:class="isSubmitting
? 'bg-[#00E5A0]/50 text-[#04151F]/70 cursor-not-allowed'
: 'bg-[#00E5A0] text-[#04151F] hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30'"
>
<!-- Spinner -->
<svg
v-if="isSubmitting"
class="animate-spin h-4 w-4 text-[#04151F]"
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>
<span>{{ isSubmitting ? t('kyc.submitting') : t('kyc.submit') }}</span>
</button>
</div>
<!-- Error message -->
<div
v-if="errorMessage"
class="mt-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
>
{{ errorMessage }}
</div>
</div>
</div>
<!-- Skip link (optional) -->
<div class="text-center mt-6">
<button
@click="handleSkip"
class="text-sm text-white/50 hover:text-white/70 transition-colors underline"
>
{{ t('kyc.skip') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick } from 'vue'
import { ref, reactive, watch } 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()
// ── Steps definition ──
// ── Steps definition (2 steps only) ──
const steps = [
{ label: 'Személyes', key: 'personal' },
{ label: 'Lakcím', key: 'address' },
{ label: 'Okmányok', key: 'security' },
{ label: t('kyc.stepAddress'), key: 'address' },
{ label: t('kyc.stepExtras'), key: 'extras' },
]
const currentStep = ref(1)
@@ -351,17 +404,18 @@ const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const isCityLoading = ref(false)
const nextButton = ref<HTMLButtonElement | null>(null)
const showOptionalAddress = ref(false)
const showDocuments = ref(false)
// ── KYC Form (minimal — only DB-required fields) ──
// ── KYC Form (names removed - already provided in registration) ──
const kycForm = reactive({
last_name: '',
first_name: '',
region_code: 'HU',
address_zip: '',
address_city: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
phone_number: '',
identity_docs: {
ID_CARD: { number: '', expiry_date: '' },
LICENSE: { number: '', expiry_date: '' },
@@ -391,7 +445,7 @@ watch(
} finally {
isCityLoading.value = false
}
}, 600) // 600ms késleltetés a gépelés után
}, 600) // 600ms delay after typing
} else {
isCityLoading.value = false
}
@@ -402,38 +456,37 @@ watch(
function stepCircleClass(idx: number): string {
const stepNum = idx + 1
if (currentStep.value > stepNum) {
return 'bg-[#00E5A0] text-[#04151F]'
return 'bg-[#00E5A0] text-[#04151F] shadow-lg shadow-[#00E5A0]/30'
}
if (currentStep.value === stepNum) {
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0]'
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0] shadow-lg shadow-[#00E5A0]/20'
}
return 'bg-white/5 text-white/30 border border-white/10'
return 'bg-white/5 text-white/40 border border-white/10'
}
// ── Simple step validation (Soft KYC) ──
// ── Validation (Soft KYC) ──
function validateStep(): boolean {
errorMessage.value = null
if (currentStep.value === 1) {
if (!kycForm.last_name) { errorMessage.value = 'Kérlek add meg a vezetéknevedet.'; return false }
if (!kycForm.first_name) { errorMessage.value = 'Kérlek add meg a keresztnevedet.'; return false }
if (!kycForm.address_zip) {
errorMessage.value = t('kyc.zipRequired')
return false
}
if (!kycForm.address_city) {
errorMessage.value = t('kyc.cityRequired')
return false
}
}
if (currentStep.value === 2) {
if (!kycForm.address_zip) { errorMessage.value = 'Kérlek add meg az irányítószámot.'; return false }
if (!kycForm.address_city) { errorMessage.value = 'Kérlek add meg a várost.'; return false }
// Soft KYC: Utca, típus, házszám már NEM kötelező
}
// Step 3 has no required validations anymore (Soft KYC - all optional)
// Step 2 has no required fields (all optional)
return true
}
// ── Navigation ──
function nextStep() {
if (!validateStep()) return
if (currentStep.value < 3) {
if (currentStep.value < 2) {
currentStep.value++
}
}
@@ -445,16 +498,6 @@ function prevStep() {
}
}
// ── Tab order fix: Házszám → Tovább gomb ──
function handleHouseNumberTab(event: KeyboardEvent) {
if (currentStep.value === 2 && nextButton.value) {
event.preventDefault()
nextTick(() => {
nextButton.value?.focus()
})
}
}
// ── Submit ──
async function handleSubmit() {
if (!validateStep()) return
@@ -463,10 +506,8 @@ async function handleSubmit() {
errorMessage.value = null
try {
// Build the payload matching UserKYCComplete schema (Soft KYC)
// Build the payload matching UserKYCComplete schema (no names - already in registration)
const payload: any = {
first_name: kycForm.first_name,
last_name: kycForm.last_name,
region_code: kycForm.region_code,
address_zip: kycForm.address_zip,
address_city: kycForm.address_city,
@@ -474,10 +515,11 @@ async function handleSubmit() {
preferred_currency: kycForm.preferred_currency,
}
// Soft KYC: Csak akkor küldjük a részletes címet, ha ki van töltve
// Optional fields
if (kycForm.address_street_name) payload.address_street_name = kycForm.address_street_name
if (kycForm.address_street_type) payload.address_street_type = kycForm.address_street_type
if (kycForm.address_house_number) payload.address_house_number = kycForm.address_house_number
if (kycForm.phone_number) payload.phone_number = kycForm.phone_number
// Only include identity_docs if at least one document is filled
const hasIdCard = kycForm.identity_docs.ID_CARD.number || kycForm.identity_docs.ID_CARD.expiry_date
@@ -507,9 +549,21 @@ async function handleSubmit() {
errorMessage.value =
err.response?.data?.detail ||
err.response?.data?.message ||
'A KYC kitöltése sikertelen. Kérlek ellenőrizd az adatokat és próbáld újra.'
t('kyc.submitError')
} finally {
isSubmitting.value = false
}
}
// ── Skip (minimal KYC - just address required) ──
async function handleSkip() {
// Can only skip if step 1 (address) is completed
if (!kycForm.address_zip || !kycForm.address_city) {
errorMessage.value = t('kyc.addressRequired')
return
}
// Submit with minimal data
await handleSubmit()
}
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div class="relative min-h-screen text-white">
<!-- Garage Background Image (no dark overlay bright, light garage) -->
<!-- Garage Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
@@ -21,117 +21,12 @@
</div>
</div>
<!-- Spatial UI Layer: Hotspots (always visible, even in Zen mode) -->
<div class="fixed inset-0 z-[5] pointer-events-none">
<div class="relative w-full h-full">
<!-- Hotspot 1: Szervizkönyv/Naptár (bal fal) -->
<div class="absolute top-[30%] left-[20%] pointer-events-auto group">
<!-- Pulzáló hotspot kör -->
<div class="relative flex items-center justify-center cursor-pointer">
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring"></div>
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring-delayed"></div>
<div class="relative w-6 h-6 rounded-full bg-[#FF3366] shadow-[0_0_20px_#FF3366] animate-hotspot-pulse"></div>
</div>
<!-- Glassmorphism Bento Box (hover opens) -->
<div class="absolute mt-4 w-64 opacity-0 pointer-events-none scale-95 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto bg-[#04151F]/80 backdrop-blur-xl border border-white/20 rounded-xl p-4 shadow-2xl z-40">
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-[#00E5A0]" 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>
<h3 class="text-lg font-bold text-white">Szerviznaptár</h3>
</div>
<p class="text-sm text-white/60">Közelgő események</p>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
<span>Olajcsere</span>
<span class="text-[#00E5A0]">2 hét</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
<span>Műszaki vizsga</span>
<span class="text-[#00E5A0]">3 hónap</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40">
<span>Gumi szezonális</span>
<span class="text-yellow-400">1 hónap</span>
</div>
</div>
</div>
</div>
<!-- Hotspot 2: Szerszámok/Beállítások (jobb fal) -->
<div class="absolute top-[30%] right-[20%] pointer-events-auto group">
<div class="relative flex items-center justify-center cursor-pointer">
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring"></div>
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring-delayed"></div>
<div class="relative w-6 h-6 rounded-full bg-[#FF3366] shadow-[0_0_20px_#FF3366] animate-hotspot-pulse"></div>
</div>
<div class="absolute mt-4 w-64 opacity-0 pointer-events-none scale-95 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto bg-[#04151F]/80 backdrop-blur-xl border border-white/20 rounded-xl p-4 shadow-2xl z-40">
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<h3 class="text-lg font-bold text-white">Felszerelés</h3>
</div>
<p class="text-sm text-white/60">Alkatrészek és Skinek</p>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
<span>Alkatrész katalógus</span>
<span class="text-[#418890]">42 tétel</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
<span>Skin csomagok</span>
<span class="text-[#418890]">6 elérhető</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40">
<span>Beállítások</span>
<span class="text-white/30"></span>
</div>
</div>
</div>
</div>
<!-- Hotspot 3: Diagnosztika (monitor) -->
<div class="absolute top-[60%] right-[30%] pointer-events-auto group">
<div class="relative flex items-center justify-center cursor-pointer">
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring"></div>
<div class="absolute w-10 h-10 rounded-full bg-[#FF3366]/20 animate-hotspot-ring-delayed"></div>
<div class="relative w-6 h-6 rounded-full bg-[#FF3366] shadow-[0_0_20px_#FF3366] animate-hotspot-pulse"></div>
</div>
<div class="absolute mt-4 w-64 opacity-0 pointer-events-none scale-95 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100 group-hover:pointer-events-auto bg-[#04151F]/80 backdrop-blur-xl border border-white/20 rounded-xl p-4 shadow-2xl z-40">
<div class="flex items-center gap-3 mb-3">
<svg class="w-6 h-6 text-[#70BC84]" 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>
<h3 class="text-lg font-bold text-white">Élő Diagnosztika</h3>
</div>
<p class="text-sm text-white/60">Rendszerállapot</p>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
<span>Motor állapot</span>
<span class="text-[#70BC84]"> Optimális</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40 border-b border-white/5 pb-1">
<span>Akkumulátor</span>
<span class="text-[#70BC84]"> 87%</span>
</div>
<div class="flex items-center justify-between text-xs text-white/40">
<span>Hibakódok</span>
<span class="text-white/30">0 találat</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Zen Mode FAB (always visible, above everything) -->
<!-- Zen Mode FAB -->
<button
@click="isUiVisible = !isUiVisible"
class="fixed bottom-8 right-8 z-50 btn-premium rounded-full p-4 shadow-2xl shadow-black/40"
:title="isUiVisible ? 'UI elrejtése (Zen mód)' : 'UI megjelenítése'"
:title="isUiVisible ? t('zen.hide') : t('zen.show')"
>
<!-- Eye icon when visible, eye-off when hidden -->
<svg
v-if="isUiVisible"
class="w-6 h-6"
@@ -153,114 +48,304 @@
</svg>
</button>
<!-- Top Navbar (always visible, even in Zen mode) -->
<DashboardHeader
:first-name="authStore.user?.first_name || 'Vendég'"
subtitle="Irányítópultod és járműveid áttekintése"
/>
<!-- Content (above the overlay) with fade transition -->
<!-- Content with fade transition -->
<Transition name="fade">
<div v-if="isUiVisible" class="relative z-10">
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<!-- Loading State -->
<div
v-if="vehicleStore.isLoading"
class="flex items-center justify-center py-20"
>
<!-- Bottom-aligned 5-card container -->
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
<!-- Loading State -->
<div
class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]"
/>
</div>
<!-- Garage Grid -->
<div
v-if="!vehicleStore.isLoading"
class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="vehicle in vehicleStore.vehicles"
:key="vehicle.id"
class="group relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.05] p-6 backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-300 hover:border-[#70BC84]/30 hover:bg-white/[0.08] hover:shadow-2xl hover:shadow-black/30"
v-if="vehicleStore.isLoading"
class="flex items-center justify-center py-20"
>
<!-- License Plate (EU-style) -->
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
</div>
<!-- 5-Card Grid -->
<div
v-if="!vehicleStore.isLoading"
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
style="perspective: 1200px;"
>
<!--
Card 1: 🚗 My Garage (Járműveim)
-->
<div
v-if="vehicle.license_plate"
class="mb-4 inline-flex items-center overflow-hidden rounded-md border border-gray-600 shadow-sm"
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('vehicles')"
>
<!-- Blue EU band -->
<div class="flex h-9 w-9 items-center justify-center bg-[#003399] text-[10px] font-bold leading-tight text-white">
<span class="block text-center leading-tight">SF</span>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🚗 {{ t('dashboard.myVehicles') }}</span>
<span class="ml-auto text-xs text-white/60">{{ vehicleStore.vehicles.length }} {{ t('dashboard.vehicles') }}</span>
</div>
<!-- White plate area -->
<div class="flex h-9 items-center bg-white px-3">
<span class="font-mono text-sm font-bold tracking-widest text-gray-900">
{{ vehicle.license_plate }}
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 overflow-y-auto space-y-2">
<div
v-for="vehicle in vehicleStore.vehicles.slice(0, 4)"
:key="vehicle.id"
class="rounded-xl border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 cursor-pointer"
>
<!-- License Plate -->
<div
v-if="vehicle.license_plate"
class="mb-1 inline-flex items-center overflow-hidden rounded-md border border-gray-400 shadow-sm"
>
<div class="flex h-6 w-6 items-center justify-center bg-[#003399] text-[7px] font-bold leading-tight text-white">
<span class="block text-center leading-tight">SF</span>
</div>
<div class="flex h-6 items-center bg-white px-2">
<span class="font-mono text-xs font-bold tracking-widest text-gray-900">
{{ vehicle.license_plate }}
</span>
</div>
</div>
<!-- Brand + Model -->
<h3 class="text-sm font-bold text-slate-800">
{{ vehicle.brand || t('dashboard.statusUnknown') }}
<span v-if="vehicle.model" class="font-normal text-slate-500"> {{ vehicle.model }}</span>
</h3>
<!-- Mileage -->
<p class="mt-0.5 text-xs text-slate-400">
{{ vehicle.current_mileage?.toLocaleString() || 0 }} {{ t('dashboard.mileage') }}
</p>
</div>
<!-- Empty state -->
<div
v-if="vehicleStore.vehicles.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
+ {{ t('dashboard.addVehicle') }}
</button>
</div>
<!--
Card 2: 💰 Costs & TCO (Költségek & Analitika)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('financials')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">💰 {{ t('dashboard.fuelCost') }} & TCO</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Monthly cost summary -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-xs text-slate-500 uppercase tracking-wider font-semibold">{{ t('dashboard.monthlyScore') }}</p>
<p class="text-2xl font-extrabold text-slate-800 mt-1"> 2,450</p>
</div>
<!-- Stat rows -->
<div class="space-y-1.5">
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.totalKm') }}</span>
<span class="font-semibold text-slate-800">12,430 km</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.fuelCost') }}</span>
<span class="font-semibold text-slate-800"> 0.42</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.serviceCost') }}</span>
<span class="font-semibold text-slate-800"> 1.28</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm">
<span class="text-slate-500">{{ t('dashboard.co2Savings') }}</span>
<span class="font-semibold text-emerald-600">-340 kg</span>
</div>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('menu.finance') }}
</button>
</div>
<!--
Card 3: 🔧 Service Finder (Szerviz kereső)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('costs')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🔧 Service Finder</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Active notifications / service alerts -->
<div
v-for="(notif, idx) in notifications"
:key="idx"
class="flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm"
>
<span class="mt-0.5 text-base">{{ notif.icon }}</span>
<div>
<p class="text-slate-800 font-medium">{{ notif.title }}</p>
<p class="text-slate-400 text-xs mt-0.5">{{ notif.time }}</p>
</div>
</div>
<!-- Empty state -->
<div
v-if="notifications.length === 0"
class="flex flex-col items-center justify-center py-6 text-slate-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">{{ t('dashboard.noNotifications') }}</p>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('menu.features') }} 🔍
</button>
</div>
<!--
Card 4: 🏆 Gamification (Játékosítás)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('gamification')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🏆 {{ t('dashboard.statsAndPoints') }}</span>
<span class="ml-auto inline-flex items-center gap-1 rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
{{ t('dashboard.live') }}
</span>
</div>
</div>
<!-- Brand + Model -->
<h3 class="text-xl font-bold text-white">
{{ vehicle.brand || 'Ismeretlen' }}
<span v-if="vehicle.model" class="font-normal text-white/70">{{ vehicle.model }}</span>
</h3>
<!-- Year of manufacture -->
<p v-if="vehicle.year_of_manufacture" class="mt-0.5 text-sm text-white/40">
{{ vehicle.year_of_manufacture }}
</p>
<!-- Divider -->
<div class="my-4 border-t border-white/[0.06]" />
<!-- Condition Score Bar -->
<div class="mb-3">
<div class="mb-1 flex items-center justify-between text-xs">
<span class="text-white/50">Állapot</span>
<span class="font-semibold text-[#70BC84]">{{ vehicle.condition_score }}%</span>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- Score card -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
<p class="text-3xl font-extrabold text-emerald-600">2,450</p>
<p class="text-xs text-slate-500 mt-1">{{ t('dashboard.monthlyScore') }}</p>
</div>
<!-- Achievement badges -->
<div>
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">
{{ t('dashboard.achievements') }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="badge in badges"
:key="badge.label"
class="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600"
>
{{ badge.icon }} {{ badge.label }}
</span>
</div>
</div>
</div>
</div>
<div class="h-2 overflow-hidden rounded-full bg-white/10">
<div
class="h-full rounded-full bg-gradient-to-r from-[#70BC84] to-[#008CA4] transition-all duration-500"
:style="{ width: Math.min(vehicle.condition_score, 100) + '%' }"
/>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('dashboard.refresh') }} 🏆
</button>
</div>
<!--
Card 5: 🛡 My Profile & Trust (Profil & Trust Score)
-->
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col h-[350px] relative cursor-pointer transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
@click="openCard('stats')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('header.profile') }}</span>
</div>
</div>
<!-- Mileage -->
<div class="flex items-center gap-2 text-sm text-white/60">
<svg class="h-4 w-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<span>{{ vehicle.current_mileage?.toLocaleString() || 0 }} km</span>
</div>
<!-- Status Badge -->
<div class="mt-4 flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:class="vehicle.status === 'active'
? 'bg-[#70BC84]/15 text-[#70BC84]'
: 'bg-white/5 text-white/40'"
>
<span
class="mr-1.5 inline-block h-1.5 w-1.5 rounded-full"
:class="vehicle.status === 'active' ? 'bg-[#70BC84]' : 'bg-white/20'"
/>
{{ vehicle.status === 'active' ? 'Aktív' : vehicle.status }}
</span>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- User info -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
</div>
<!-- Trust Score -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
<span class="text-sm font-bold text-emerald-600">A+</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
</div>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
</div>
</div>
</div>
</div>
<!-- Bottom CTA button -->
<button class="mt-auto mb-4 mx-auto px-8 py-3 bg-slate-700 hover:bg-slate-800 text-white rounded-2xl font-medium text-sm transition-colors shadow-md">
{{ t('header.profile') }}
</button>
</div>
</div>
</div>
</div>
</div><!-- end relative z-10 content wrapper -->
</Transition>
<!--
Flip Modal 3D átforduló részletes nézet
-->
<div
v-if="activeCard"
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/85 backdrop-blur-md"
@click.self="activeCard = null"
>
<div class="bg-white w-11/12 max-w-6xl h-[85vh] rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-flip-in relative">
<!-- Close button (X) -->
<button
class="absolute top-4 right-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-slate-200/80 text-slate-600 hover:bg-slate-300 hover:text-slate-800 transition-colors text-xl font-bold"
@click="activeCard = null"
>
</button>
<!-- Dynamic content area -->
<div class="flex-1 p-8 overflow-y-auto text-slate-800">
<h2 class="text-2xl font-bold mb-6">
{{ t('dashboard.detailedView') }}: <span class="text-slate-500">{{ activeCard }}</span>
</h2>
<p class="text-slate-500">
{{ t('dashboard.modalPlaceholder') }}
</p>
</div>
</div>
</div>
</div><!-- end min-h-screen -->
</template>
@@ -269,17 +354,55 @@ import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useVehicleStore } from '../stores/vehicle'
import DashboardHeader from '../components/DashboardHeader.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const vehicleStore = useVehicleStore()
// ── Zen Mode: UI láthatóság állapota ─────────────────────────────────
// ── Zen Mode ──
const isUiVisible = ref(true)
// ── Flip Modal State ──
const activeCard = ref<string | null>(null)
const openCard = (cardId: string) => {
activeCard.value = cardId
}
// ── Placeholder notifications ──
const notifications = ref([
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
{ icon: '🏆', title: 'You earned "Eco Driver" badge!', time: '1 day ago' },
{ icon: '📊', title: 'Monthly report is ready', time: '2 days ago' },
])
// ── Placeholder badges ──
const badges = ref([
{ icon: '🌱', label: 'Eco Driver' },
{ icon: '📏', label: '10k km Club' },
{ icon: '🔧', label: 'Service Pro' },
{ icon: '⭐', label: 'Top Rater' },
])
onMounted(() => {
vehicleStore.fetchVehicles()
authStore.fetchMyOrganizations()
})
</script>
<style scoped>
@keyframes flipIn {
from {
transform: perspective(1200px) rotateY(-90deg);
opacity: 0;
}
to {
transform: perspective(1200px) rotateY(0deg);
opacity: 1;
}
}
.animate-flip-in {
animation: flipIn 0.5s cubic-bezier(0.25, 0.8, 0.25, 1) forwards;
}
</style>

View File

@@ -7,7 +7,7 @@
<div class="absolute inset-0 bg-gradient-to-r from-[#062535]/95 via-[#062535]/70 to-[#062535]/40"></div>
</div>
<!-- FIXED Top Bar (Kőbe vésett fejléc) -->
<!-- FIXED Top Bar -->
<header class="fixed top-0 left-0 w-full z-50 bg-[#062535]/80 backdrop-blur-md border-b border-[#65A5A0]/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
@@ -17,14 +17,14 @@
<span class="text-2xl font-extrabold tracking-wider hidden sm:block"><span class="text-white">SERVICE</span> <span class="text-[#418890]">FINDER</span></span>
</router-link>
<!-- Right: Language Switcher + "Garázs Nyitása" Button (Premium) -->
<!-- Right: Language Switcher + "Open Garage" Button (Premium) -->
<div class="flex items-center gap-3">
<LanguageSwitcher />
<button
@click="showLogin = true"
class="btn-premium px-6 py-2 text-sm"
>
Garázs Nyitása
{{ t('landing.openGarage') }}
</button>
</div>
</div>
@@ -35,52 +35,49 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 min-h-screen items-center py-12">
<!-- LEFT COLUMN: Text Content (Lassan megy lefelé) -->
<div
<!-- LEFT COLUMN: Text Content -->
<div
class="space-y-6"
:style="{ transform: `translateY(${scrollY * 0.15}px)` }"
>
<h1 class="text-5xl md:text-6xl lg:text-7xl font-extrabold leading-tight">
Digitális Flotta<br/>
<span class="text-gradient-premium">Menedzsment</span>
{{ t('landing.heroTitle') }}<br/>
<span class="text-gradient-premium">{{ t('landing.heroTitleAccent') }}</span>
</h1>
<p class="text-white/80 text-lg md:text-xl max-w-xl">
Valós idejű költségkövetés, automatikus értesítések és gamifikált pontrendszer minden egy helyen.
{{ t('landing.heroSubtitle') }}
</p>
</div>
<!-- RIGHT COLUMN: Játékos Aszimmetrikus Kártyák (Gyorsan úszik felfelé) -->
<div
<!-- RIGHT COLUMN: Asymmetric Cards -->
<div
class="relative h-[600px]"
:style="{ transform: `translateY(${scrollY * -0.25}px)` }"
>
<!-- Kártya 1: Statisztika (Hátul, fent, kisebb) -->
<div
<!-- Card 1: Statistics -->
<div
class="absolute top-0 left-0 w-[280px] z-10 transform -rotate-2 transition-transform duration-300 hover:scale-105 hover:rotate-0"
>
<!-- Fejléc Sáv (Sötétebb) -->
<div class="bg-[#04151f]/60 backdrop-blur-md rounded-t-xl px-5 py-3 border-t border-x border-[#38bdf8]/20">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[#38bdf8]" 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">
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/>
<polyline points="16 7 22 7 22 13"/>
</svg>
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">Statisztika</span>
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">{{ t('landing.cardStats') }}</span>
</div>
</div>
<!-- Tartalom (Világosabb Üveg) -->
<div class="bg-[#062535]/40 backdrop-blur-md rounded-b-xl px-5 py-4 border-b border-x border-[#38bdf8]/20 shadow-[0_8px_30px_rgba(6,37,53,0.6)]">
<div class="text-white/60 text-xs uppercase tracking-wide mb-2">Kezelt járművek</div>
<div class="text-[#38bdf8] text-4xl font-bold font-mono">1,245<span class="text-2xl">+</span></div>
<div class="text-white/60 text-xs uppercase tracking-wide mb-2">{{ t('landing.cardStatsLabel') }}</div>
<div class="text-[#38bdf8] text-4xl font-bold font-mono">{{ t('landing.cardStatsValue') }}</div>
</div>
</div>
<!-- Kártya 2: Gamifikáció (Középen, kiemelve, NEON) -->
<div
<!-- Card 2: Gamification -->
<div
class="absolute top-[120px] right-0 w-[320px] z-30 transform rotate-1 transition-transform duration-300 hover:scale-105 hover:rotate-0"
>
<!-- Fejléc Sáv (Sötétebb) -->
<div class="bg-[#04151f]/60 backdrop-blur-md rounded-t-xl px-5 py-3 border-t border-x border-[#38bdf8]">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[#38bdf8]" 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">
@@ -91,12 +88,11 @@
<path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/>
<path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>
</svg>
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">Gamifikáció</span>
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">{{ t('landing.cardGamification') }}</span>
</div>
</div>
<!-- Tartalom (Világosabb Üveg + NEON Keret) -->
<div class="bg-[#062535]/40 backdrop-blur-md rounded-b-xl px-5 py-5 border-b border-x border-[#38bdf8] shadow-[0_0_25px_rgba(56,189,248,0.3)]">
<div class="text-white/60 text-xs uppercase tracking-wide mb-3">Havi Pontszám</div>
<div class="text-white/60 text-xs uppercase tracking-wide mb-3">{{ t('landing.cardGamificationLabel') }}</div>
<div class="text-[#38bdf8] text-5xl font-bold font-mono mb-4">2,840</div>
<div class="flex items-center gap-2">
<div class="flex-1 h-2 bg-[#0B212F]/50 rounded-full overflow-hidden">
@@ -107,21 +103,19 @@
</div>
</div>
<!-- Kártya 3: Költségek (Lent, elöl, átfedi a gamifikációt) -->
<div
<!-- Card 3: Costs -->
<div
class="absolute top-[320px] left-[40px] w-[300px] z-40 transform -rotate-1 transition-transform duration-300 hover:scale-105 hover:rotate-0"
>
<!-- Fejléc Sáv (Sötétebb) -->
<div class="bg-[#04151f]/60 backdrop-blur-md rounded-t-xl px-5 py-3 border-t border-x border-[#65A5A0]/30">
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-[#65A5A0]" 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">
<line x1="12" y1="1" x2="12" y2="23"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">Költségek</span>
<span class="text-white/90 text-sm font-semibold uppercase tracking-wide">{{ t('landing.cardCosts') }}</span>
</div>
</div>
<!-- Tartalom (Világosabb Üveg) -->
<div class="bg-[#062535]/40 backdrop-blur-md rounded-b-xl px-5 py-4 border-b border-x border-[#65A5A0]/30 shadow-[0_8px_30px_rgba(6,37,53,0.6)]">
<div class="space-y-3">
<div class="flex items-center justify-between">
@@ -132,7 +126,7 @@
<path d="M14 22V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v18"/>
<path d="M14 13h2a2 2 0 0 1 2 2v2a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V9.83a2 2 0 0 0-.59-1.42L18 5"/>
</svg>
<span class="text-white/70 text-xs">Üzemanyag</span>
<span class="text-white/70 text-xs">{{ t('landing.cardCostsFuel') }}</span>
</div>
<span class="text-white text-lg font-bold font-mono">32.5k</span>
</div>
@@ -141,14 +135,14 @@
<svg class="w-4 h-4 text-[#65A5A0]" 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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
<span class="text-white/70 text-xs">Szerviz</span>
<span class="text-white/70 text-xs">{{ t('landing.cardCostsService') }}</span>
</div>
<span class="text-white text-lg font-bold font-mono">48.0k</span>
</div>
<div class="pt-2 border-t border-white/10">
<div class="flex items-center justify-between">
<span class="text-white/60 text-xs uppercase">Összesen</span>
<span class="text-[#38bdf8] text-xl font-bold font-mono">80.5k Ft</span>
<span class="text-white/60 text-xs uppercase">{{ t('landing.cardCostsTotal') }}</span>
<span class="text-[#38bdf8] text-xl font-bold font-mono">80.5k {{ t('landing.cardCostsUnit') }}</span>
</div>
</div>
</div>
@@ -169,14 +163,16 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import Logo from '@/components/Logo.vue'
import LoginModal from '@/components/LoginModal.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
const { t } = useI18n()
const showLogin = ref(false)
const scrollY = ref(0)
// JS Alapú Parallax Görgetés
// JS-based Parallax Scrolling
function handleScroll() {
scrollY.value = window.scrollY
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,8 +21,8 @@
</svg>
</div>
</div>
<h1 class="mb-4 text-2xl font-bold text-white">Fiók aktiválása...</h1>
<p class="mb-4 text-white/60">Kérlek várj, amíg ellenőrizzük a megerősítő linket.</p>
<h1 class="mb-4 text-2xl font-bold text-white">{{ t('verifyEmail.verifying') }}</h1>
<p class="mb-4 text-white/60">{{ t('verifyEmail.verifyingDesc') }}</p>
</template>
<!-- STATE: Verify Success (Magic Link - Auto-Login) -->
@@ -35,17 +35,17 @@
</svg>
</div>
</div>
<h1 class="mb-4 text-2xl font-bold text-white">Sikeres aktiválás és bejelentkezés!</h1>
<h1 class="mb-4 text-2xl font-bold text-white">{{ t('verifyEmail.success') }}</h1>
<p class="mb-8 text-white/60 leading-relaxed">
A fiókod aktív lett, és automatikusan be vagy jelentkezve.
{{ autoRedirectCountdown > 0 ? `Átirányítás ${autoRedirectCountdown} másodperc múlva...` : 'Átirányítás...' }}
{{ t('verifyEmail.successDesc') }}
{{ autoRedirectCountdown > 0 ? t('verifyEmail.redirectCountdown', { seconds: autoRedirectCountdown }) : t('verifyEmail.redirectNow') }}
</p>
<!-- Premium CTA Button (opcionális gyorsabb átirányításhoz) -->
<button
@click="goToKyc"
class="btn-premium w-full px-4 py-3 text-lg"
>
Tovább a Profilhoz
{{ t('verifyEmail.goToProfile') }}
</button>
</template>
@@ -62,7 +62,7 @@
<!-- Title -->
<h1 class="mb-4 text-2xl font-bold text-white">
Erősítsd meg az e-mail címed
{{ t('verifyEmail.checkEmail') }}
</h1>
<!-- Error message (shown when token verification failed) -->
@@ -74,20 +74,19 @@
</div>
<!-- Description -->
<p class="mb-8 text-white/60 leading-relaxed">
Kérlek, erősítsd meg az e-mail címedet a kiküldött linkre kattintva!
<p class="mb-8 text-white/60 leading-relaxed" v-html="t('verifyEmail.checkEmailDesc')">
</p>
<!-- Email Input -->
<div class="mb-6">
<label for="verify-email" class="mb-2 block text-sm font-medium text-white/80 text-left">
E-mail cím
{{ t('verifyEmail.emailLabel') }}
</label>
<input
id="verify-email"
v-model="email"
type="email"
placeholder="pelda@email.com"
:placeholder="t('verifyEmail.emailPlaceholder')"
autocomplete="email"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-white placeholder-white/30 backdrop-blur-sm transition-all duration-200 focus:border-[#75A882] focus:outline-none focus:ring-2 focus:ring-[#75A882]/50"
/>
@@ -99,7 +98,7 @@
:disabled="isResending || !email"
class="btn-premium w-full px-4 py-3 text-lg"
>
{{ isResending ? 'Küldés...' : 'Nem kaptam meg az e-mailt, küldjétek újra' }}
{{ isResending ? t('verifyEmail.resending') : t('verifyEmail.resendButton') }}
</button>
<!-- Resend Feedback Message -->
@@ -110,6 +109,16 @@
>
{{ feedbackMessage }}
</div>
<!-- Back to Login Link -->
<div class="mt-6 text-center">
<router-link
to="/"
class="text-sm text-white/60 hover:text-[#75A882] transition-colors duration-200 underline"
>
{{ t('verifyEmail.backToLogin') }}
</router-link>
</div>
</template>
</div>
</div>
@@ -119,11 +128,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import FingerprintJS from '@fingerprintjs/fingerprintjs'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
// ── Device Fingerprinting (FingerprintJS) ──
@@ -207,7 +218,7 @@ async function processToken(token: string) {
// Failure — token expired or invalid
isVerifying.value = false
verifySuccess.value = false
verifyError.value = 'A link lejárt vagy érvénytelen.'
verifyError.value = t('verifyEmail.tokenExpired')
}
}
@@ -243,10 +254,10 @@ async function handleResend() {
try {
await authStore.resendVerification(email.value)
feedbackType.value = 'success'
feedbackMessage.value = 'Az új megerősítő e-mail elküldésre került!'
feedbackMessage.value = t('verifyEmail.resendSuccess')
} catch (err: any) {
feedbackType.value = 'error'
feedbackMessage.value = authStore.error || 'Az újraküldés sikertelen. Kérlek próbáld újra később.'
feedbackMessage.value = authStore.error || t('verifyEmail.resendFailed')
} finally {
isResending.value = false
}

View File

@@ -0,0 +1,283 @@
<template>
<div class="relative min-h-screen">
<!-- Background Image: garage_clean.png (clean garage, no hotspots) -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('/garage_clean.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- 8-Column Bento Box Exhibition Layout floor-aligned grid with perspective -->
<div class="relative z-20 mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div
class="grid grid-cols-1 lg:grid-cols-8 gap-6 lg:gap-12 xl:gap-16 p-4 xl:p-8 min-h-[85vh] items-end"
style="perspective: 1200px;"
>
<!--
CARD 1 LEFT TOWER (col 1-2)
Fleet Overview solid 95% white background, grounded 3D
-->
<div class="rollup-left col-span-1 lg:col-span-2 h-[40vh]">
<GlassCard
variant="standard"
:hoverable="false"
customClass="bg-white/95 backdrop-blur-sm hover:bg-slate-100/95 shadow-2xl drop-shadow-xl rounded-t-lg rounded-b-sm border-b-[8px] border-slate-300"
class="h-full flex flex-col"
>
<template #default>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-slate-700" 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>
<h3 class="text-lg font-semibold text-slate-900">{{ t('company.fleetTitle') }}</h3>
</div>
<p class="text-2xl font-bold text-slate-900">{{ t('company.fleetVehicles', { count: 12 }) }}</p>
<div class="flex items-center gap-4 text-sm">
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-green-400"></span>
<span class="text-green-700">10 {{ t('company.fleetActive') }}</span>
</span>
<span class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-red-400"></span>
<span class="text-red-700">2 {{ t('company.fleetInactive') }}</span>
</span>
</div>
<!-- Fleet breakdown list subtle inner boxes -->
<div class="mt-2 space-y-2">
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<span class="text-slate-600">{{ t('company.totalKm') || 'Total KM' }}</span>
<span class="font-semibold text-slate-900">48,230 km</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<span class="text-slate-600">{{ t('company.avgAge') || 'Avg. Age' }}</span>
<span class="font-semibold text-slate-900">3.2 years</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<span class="text-slate-600">{{ t('company.nextService') || 'Next Service' }}</span>
<span class="font-semibold text-slate-900">4 vehicles</span>
</div>
</div>
</div>
</template>
</GlassCard>
</div>
<!--
CENTER WRAPPER (col 3-6) flex column, bottom-aligned
-->
<div class="col-span-1 lg:col-span-4 h-[80vh] flex flex-col justify-end pb-0">
<!-- CARD 2 CENTER-TOP FLOATING (half-width, floats up)
Dark blue background, glowing white/yellow text -->
<div class="w-1/2 mx-auto aspect-square mb-auto mt-4 floating-center">
<GlassCard
variant="premium"
:hoverable="false"
customClass="bg-slate-900/95 backdrop-blur-sm hover:bg-slate-800/95 shadow-[0_10px_40px_rgba(0,0,0,0.3)] rounded-3xl border border-white/10"
class="h-full flex flex-col"
>
<template #default>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center gap-3">
<span class="inline-block h-2 w-2 rounded-full bg-yellow-400 animate-pulse" />
<h3 class="text-lg font-semibold text-white drop-shadow-md">
{{ t('company.notificationsTitle') || 'Daily Notifications' }}
</h3>
</div>
<div class="space-y-2">
<div
v-for="(notif, idx) in notifications"
:key="idx"
class="flex items-start gap-3 rounded-lg bg-white/10 p-3 text-sm"
>
<span class="mt-0.5 text-base text-yellow-400">{{ notif.icon }}</span>
<div>
<p class="text-gray-100 font-medium">{{ notif.title }}</p>
<p class="text-gray-400 text-xs mt-0.5">{{ notif.time }}</p>
</div>
</div>
<div
v-if="notifications.length === 0"
class="flex flex-col items-center justify-center py-8 text-gray-400"
>
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">{{ t('dashboard.noNotifications') }}</p>
</div>
</div>
</div>
</template>
</GlassCard>
</div>
<!-- CARD 4 CENTER-BOTTOM ROLLUP (full-width, sits on floor)
95% white background, dark text -->
<div class="w-full h-[50vh] rollup-base mt-auto">
<GlassCard
variant="standard"
:hoverable="false"
customClass="bg-white/95 backdrop-blur-sm hover:bg-slate-100/95 shadow-2xl drop-shadow-xl rounded-t-lg rounded-b-sm border-b-[8px] border-slate-300"
class="h-full flex flex-col"
>
<template #default>
<div class="flex flex-col gap-3 p-4">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-slate-700" 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>
<h3 class="text-lg font-semibold text-slate-900">
{{ t('company.recentCostsTitle') || 'Recent Costs & Repairs' }}
</h3>
</div>
<!-- Cost summary row -->
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<span class="text-slate-600">{{ t('company.totalCosts') || 'Total Costs' }}</span>
<span class="font-semibold text-slate-900"> 12,450</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<span class="text-slate-600">{{ t('company.avgPerVehicle') || 'Avg / Vehicle' }}</span>
<span class="font-semibold text-slate-900"> 1,037</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<span class="text-slate-600">{{ t('company.monthlyBudget') || 'Monthly Budget' }}</span>
<span class="font-semibold text-[#70BC84]"> 15,000</span>
</div>
<!-- Recent cost items list -->
<div class="mt-2 pt-2 border-t border-slate-200">
<p class="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
{{ t('company.latestEntries') || 'Latest Entries' }}
</p>
<div class="space-y-2">
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<div class="flex flex-col">
<span class="font-medium text-slate-900">Oil Change BMW X5</span>
<span class="text-xs text-slate-600">Budapest, Kárász Auto</span>
</div>
<span class="font-semibold text-slate-900"> 0.18</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<div class="flex flex-col">
<span class="font-medium text-slate-900">Brake Pads Audi A3</span>
<span class="text-xs text-slate-600">Győr, AutoCenter</span>
</div>
<span class="font-semibold text-slate-900"> 0.42</span>
</div>
<div class="flex items-center justify-between rounded-lg bg-slate-50/80 border border-slate-200 px-3 py-2 text-sm">
<div class="flex flex-col">
<span class="font-medium text-slate-900">Tire Change VW Golf</span>
<span class="text-xs text-slate-600">Debrecen, Gumiservice</span>
</div>
<span class="font-semibold text-slate-900"> 0.09</span>
</div>
</div>
</div>
</div>
</template>
</GlassCard>
</div>
</div>
<!--
CARD 3 RIGHT TOWER (col 7-8)
Quick Actions 95% white background, dark text
-->
<div class="rollup-right col-span-1 lg:col-span-2 h-[40vh]">
<GlassCard
variant="standard"
:hoverable="false"
customClass="bg-white/95 backdrop-blur-sm hover:bg-slate-100/95 shadow-2xl drop-shadow-xl rounded-t-lg rounded-b-sm border-b-[8px] border-slate-300"
class="h-full flex flex-col"
>
<template #default>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<h3 class="text-lg font-semibold text-slate-900">{{ t('company.quickActionsTitle') }}</h3>
</div>
<div class="flex flex-col gap-3 mt-2">
<button class="w-full px-4 py-3 rounded-xl bg-slate-100/80 hover:bg-slate-200/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-slate-200">
{{ t('company.quickNewWaybill') }}
</button>
<button class="w-full px-4 py-3 rounded-xl bg-slate-100/80 hover:bg-slate-200/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-slate-200">
{{ t('company.quickReportIssue') }}
</button>
<button class="w-full px-4 py-3 rounded-xl bg-amber-50/80 hover:bg-amber-100/80 text-slate-900 font-medium text-sm transition-all duration-200 border border-amber-200">
{{ t('company.quickSchedule') || 'Schedule Service' }}
</button>
</div>
<!-- Recent activity mini-list -->
<div class="mt-4 pt-3 border-t border-slate-200">
<p class="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-2">
{{ t('company.recentActivity') || 'Recent Activity' }}
</p>
<div class="space-y-1.5 text-sm text-slate-600">
<p> {{ t('company.activityAdded') || 'Added' }} BMW X5</p>
<p> {{ t('company.activityServiced') || 'Serviced' }} Audi A3</p>
<p> {{ t('company.activityInvoice') || 'Invoice' }} #INV-0421</p>
</div>
</div>
</div>
</template>
</GlassCard>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import GlassCard from '../../components/ui/GlassCard.vue'
const { t } = useI18n()
// ── Placeholder notifications ──
const notifications = ref([
{ icon: '🔧', title: 'Service due for BMW X5', time: '2 hours ago' },
{ icon: '⛽', title: 'Fuel cost updated — +₿ 0.08', time: '5 hours ago' },
{ icon: '📊', title: 'Monthly report is ready', time: '1 day ago' },
{ icon: '🛞', title: 'Tire change reminder — VW Golf', time: '2 days ago' },
])
</script>
<style scoped>
/* ── 3D Rollup Standee Animations (subtle perspective) ── */
.rollup-left {
transform: rotateY(4deg) scale(0.98);
transition: transform 0.5s ease-out;
}
.rollup-left:hover {
transform: rotateY(0deg) scale(1.0);
z-index: 20;
}
.rollup-right {
transform: rotateY(-4deg) scale(0.98);
transition: transform 0.5s ease-out;
}
.rollup-right:hover {
transform: rotateY(0deg) scale(1.0);
z-index: 20;
}
.floating-center {
transform: translateY(0px);
transition: transform 0.5s ease-out;
}
.floating-center:hover {
transform: translateY(-5px);
z-index: 20;
}
.rollup-base {
transform: translateY(4px) scale(0.98);
transition: transform 0.5s ease-out;
}
.rollup-base:hover {
transform: translateY(0px) scale(1.0);
z-index: 20;
}
</style>

View File

@@ -0,0 +1,761 @@
<template>
<div class="relative flex min-h-screen items-center justify-center text-white">
<!-- Background Image -->
<div class="fixed inset-0 z-0">
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
</div>
<!-- Content Container -->
<div class="relative z-10 mx-auto w-full max-w-2xl px-4 py-8">
<!-- Header Logo / Brand -->
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-white tracking-tight">
Service<span class="text-[#00E5A0]">Finder</span>
</h1>
<p class="text-white/60 text-sm mt-2">{{ t('company.onboardingTitle') }}</p>
</div>
<!-- Stepper (3 steps) -->
<div class="w-full max-w-lg mx-auto mb-8">
<div class="flex items-center justify-between">
<div
v-for="(step, idx) in steps"
:key="idx"
class="flex items-center flex-1"
>
<!-- Step circle + label -->
<div class="flex flex-col items-center">
<div
class="w-12 h-12 rounded-full flex items-center justify-center text-sm font-semibold transition-all duration-300"
:class="stepCircleClass(idx)"
>
<span v-if="currentStep > idx + 1"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<span
class="text-xs mt-2 transition-colors duration-300 font-medium text-center"
:class="currentStep >= idx + 1 ? 'text-[#00E5A0]' : 'text-white/40'"
>
{{ step.label }}
</span>
</div>
<!-- Connector line -->
<div
v-if="idx < steps.length - 1"
class="flex-1 h-1 mx-4 rounded transition-all duration-300"
:class="currentStep > idx + 1 ? 'bg-[#00E5A0]' : 'bg-white/10'"
/>
</div>
</div>
</div>
<!-- Glassmorphism Card with strong shadow -->
<div class="w-full max-w-lg mx-auto">
<div
class="rounded-2xl border border-white/[0.08] bg-slate-800/95 p-8 md:p-10 backdrop-blur-xl shadow-2xl shadow-black/50 transition-all duration-500"
>
<!-- ==================== STEP 1: Legal Data ==================== -->
<div v-if="currentStep === 1">
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('company.legalTitle') }}</h2>
<p class="text-white/60 text-sm mb-6 leading-relaxed" v-html="t('company.legalDescription')">
</p>
<div class="space-y-5">
<!-- Tax number + Lookup button (always visible) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.taxNumber') }} <span class="text-red-400">*</span>
</label>
<div class="flex gap-2 items-end">
<div class="flex-1">
<input
v-model="form.tax_number"
@input="onTaxNumberChange"
type="text"
placeholder="12345678-1-43"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<button
@click="lookupTaxNumber"
:disabled="isTaxLookupLoading || !form.tax_number"
class="px-4 py-3 rounded-xl bg-[#00E5A0]/10 border border-[#00E5A0]/30 text-[#00E5A0] hover:bg-[#00E5A0]/20 hover:border-[#00E5A0]/50 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2 text-sm font-medium whitespace-nowrap"
:title="t('company.taxLookupTitle')"
>
<svg
v-if="isTaxLookupLoading"
class="animate-spin h-4 w-4 text-[#00E5A0]"
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>
<svg
v-else
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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{{ t('company.taxLookup') }}
</button>
</div>
</div>
<!-- Hidden fields, shown only after tax lookup or manual reveal -->
<template v-if="taxLookedUp">
<!-- Full company name -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.fullName') }} <span class="text-red-400">*</span>
</label>
<input
v-model="form.full_name"
type="text"
placeholder="pl. Kovács és Társa Kft."
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<!-- Short name + Display name -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.shortName') }} <span class="text-red-400">*</span>
</label>
<input
v-model="form.name"
@input="autoFillDisplayName"
type="text"
placeholder="pl. Kovács Kft."
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.displayName') }} <span class="text-red-400">*</span>
</label>
<input
v-model="form.display_name"
type="text"
placeholder="pl. Kovács Kft."
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
</div>
<!-- Registration number -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.regNumber') }} <span class="text-red-400">*</span>
</label>
<input
v-model="form.reg_number"
type="text"
placeholder="01-02-3456789"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
</template>
</div>
</div>
<!-- ==================== STEP 2: Address & Finance ==================== -->
<div v-if="currentStep === 2">
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('company.addressTitle') }}</h2>
<p class="text-white/60 text-sm mb-6 leading-relaxed">
{{ t('company.addressDescription') }}
</p>
<div class="space-y-5">
<!-- Country, Language, Currency -->
<div class="grid grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.country') }}</label>
<select
v-model="form.country_code"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="HU" class="bg-[#04151F]">🇭🇺 Magyarország</option>
<option value="AT" class="bg-[#04151F]">🇦🇹 Ausztria</option>
<option value="SK" class="bg-[#04151F]">🇸🇰 Szlovákia</option>
<option value="DE" class="bg-[#04151F]">🇩🇪 Németország</option>
<option value="RO" class="bg-[#04151F]">🇷🇴 Románia</option>
<option value="CZ" class="bg-[#04151F]">🇨🇿 Csehország</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.language') }}</label>
<select
v-model="form.language"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="hu" class="bg-[#04151F]">Magyar</option>
<option value="en" class="bg-[#04151F]">English</option>
<option value="de" class="bg-[#04151F]">Deutsch</option>
<option value="sk" class="bg-[#04151F]">Slovenčina</option>
<option value="ro" class="bg-[#04151F]">Română</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.currency') }}</label>
<select
v-model="form.default_currency"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="HUF" class="bg-[#04151F]">HUF (Ft)</option>
<option value="EUR" class="bg-[#04151F]">EUR ()</option>
<option value="USD" class="bg-[#04151F]">USD ($)</option>
</select>
</div>
</div>
<!-- ZIP + City -->
<div class="grid grid-cols-3 gap-4">
<div class="col-span-1">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.zipCode') }} <span class="text-red-400">*</span>
</label>
<input
v-model="form.address_zip"
type="text"
placeholder="1234"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.city') }} <span class="text-red-400">*</span>
</label>
<div class="relative">
<input
v-model="form.address_city"
type="text"
placeholder="Budapest"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all pr-10"
:class="{ 'opacity-60': isCityLoading }"
/>
<!-- Loading spinner -->
<div
v-if="isCityLoading"
class="absolute right-3 top-1/2 -translate-y-1/2"
>
<svg
class="animate-spin h-5 w-5 text-[#00E5A0]"
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>
</div>
</div>
</div>
</div>
<!-- Street details -->
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetName') }}</label>
<input
v-model="form.address_street_name"
type="text"
placeholder="Kossuth Lajos"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.streetType') }}</label>
<select
v-model="form.address_street_type"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
>
<option value="" disabled class="bg-[#04151F]">{{ t('company.streetType') }}</option>
<option value="utca" class="bg-[#04151F]">utca</option>
<option value="út" class="bg-[#04151F]">út</option>
<option value="tér" class="bg-[#04151F]">tér</option>
<option value="körút" class="bg-[#04151F]">körút</option>
<option value="sugárút" class="bg-[#04151F]">sugárút</option>
<option value="köz" class="bg-[#04151F]">köz</option>
</select>
</div>
</div>
<!-- House number + additional address fields -->
<div class="grid grid-cols-4 gap-3">
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.houseNumber') }}</label>
<input
v-model="form.address_house_number"
type="text"
placeholder="10/A"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.stairwell') }}</label>
<input
v-model="form.address_stairwell"
type="text"
placeholder="1."
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.floor') }}</label>
<input
v-model="form.address_floor"
type="text"
placeholder="2."
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.door') }}</label>
<input
v-model="form.address_door"
type="text"
placeholder="5"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
</div>
<!-- HRsz (parcel ID) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">{{ t('company.parcelId') }}</label>
<input
v-model="form.address_hrsz"
type="text"
placeholder="12345/1"
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder-white/30 focus:outline-none focus:border-[#00E5A0] focus:ring-2 focus:ring-[#00E5A0]/30 transition-all"
/>
</div>
</div>
</div>
<!-- ==================== STEP 3: Contacts ==================== -->
<div v-if="currentStep === 3">
<h2 class="text-2xl font-semibold text-white mb-2">{{ t('company.contactTitle') }}</h2>
<p class="text-white/60 text-sm mb-6 leading-relaxed">
{{ t('company.contactDescription') }}
</p>
<div class="space-y-5">
<!-- Primary contact name (read-only) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.contactName') }} <span class="text-red-400">*</span>
</label>
<input
:value="primaryContact.full_name"
type="text"
disabled
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/60 placeholder-white/20 cursor-not-allowed opacity-70"
/>
</div>
<!-- Primary email (read-only) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.contactEmail') }} <span class="text-red-400">*</span>
</label>
<input
:value="primaryContact.email"
type="email"
disabled
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/60 placeholder-white/20 cursor-not-allowed opacity-70"
/>
</div>
<!-- Primary phone (read-only) -->
<div>
<label class="block text-sm font-medium text-white/80 mb-2">
{{ t('company.contactPhone') }} <span class="text-red-400">*</span>
</label>
<input
:value="primaryContact.phone"
type="tel"
disabled
class="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white/60 placeholder-white/20 cursor-not-allowed opacity-70"
/>
</div>
</div>
</div>
<!-- ==================== Navigation Buttons ==================== -->
<div class="flex items-center justify-between mt-8 pt-6 border-t border-white/5">
<!-- Cancel / Mégsem button (always visible) -->
<button
@click="cancelOnboarding"
class="px-4 py-3 rounded-xl text-white/50 hover:text-white/80 border border-white/10 hover:border-white/30 transition-all text-sm font-medium flex items-center gap-2"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
{{ t('company.cancel') }}
</button>
<!-- Back button -->
<button
v-if="currentStep > 1"
@click="prevStep"
class="px-5 py-3 rounded-xl text-white/70 hover:text-white border border-white/10 hover:border-white/30 transition-all text-sm font-medium flex items-center gap-2"
>
<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="M15 19l-7-7 7-7" />
</svg>
{{ t('company.back') }}
</button>
<div v-else class="flex-1" />
<!-- Next / Submit -->
<button
v-if="currentStep < 3"
@click="nextStep"
:disabled="isDuplicateCompany"
class="px-6 py-3 rounded-xl bg-[#00E5A0] text-[#04151F] font-semibold hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30 transition-all text-sm flex items-center gap-2"
:class="isDuplicateCompany ? 'opacity-40 cursor-not-allowed' : ''"
>
{{ t('company.next') }}
<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="M9 5l7 7-7 7" />
</svg>
</button>
<button
v-else
@click="handleSubmit"
:disabled="isSubmitting"
class="px-6 py-3 rounded-xl font-semibold transition-all text-sm flex items-center gap-2"
:class="isSubmitting
? 'bg-[#00E5A0]/50 text-[#04151F]/70 cursor-not-allowed'
: 'bg-[#00E5A0] text-[#04151F] hover:bg-[#00E5A0]/90 hover:shadow-lg hover:shadow-[#00E5A0]/30'"
>
<!-- Spinner -->
<svg
v-if="isSubmitting"
class="animate-spin h-4 w-4 text-[#04151F]"
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>
<span>{{ isSubmitting ? t('company.submitting') : t('company.submit') }}</span>
</button>
</div>
<!-- Error message -->
<div
v-if="errorMessage"
class="mt-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
>
{{ errorMessage }}
</div>
<!-- Success message -->
<div
v-if="successMessage"
class="mt-4 p-3 rounded-xl bg-[#00E5A0]/10 border border-[#00E5A0]/20 text-[#00E5A0] text-sm"
>
{{ successMessage }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import api from '../../api/axios'
import { useAuthStore } from '../../stores/auth'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
// ── Steps definition (3 steps) ──
const steps = [
{ label: t('company.stepLegal'), key: 'legal' },
{ label: t('company.stepAddress'), key: 'address' },
{ label: t('company.stepContact'), key: 'contact' },
]
const currentStep = ref(1)
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const successMessage = ref<string | null>(null)
const isCityLoading = ref(false)
const isTaxLookupLoading = ref(false)
const taxLookedUp = ref(false)
const isDuplicateCompany = ref(false)
// ── CorpOnboardIn Form ──
const form = reactive({
full_name: '',
name: '',
display_name: '',
tax_number: '',
reg_number: '',
country_code: 'HU',
language: 'hu',
default_currency: 'HUF',
address_zip: '',
address_city: '',
address_street_name: '',
address_street_type: '',
address_house_number: '',
address_stairwell: '',
address_floor: '',
address_door: '',
address_hrsz: '',
})
// ── Primary contact ──
const primaryContact = reactive({
email: '',
phone: '',
full_name: '',
})
// ── Pre-fill primary contact with logged-in user data ──
onMounted(() => {
if (authStore.user) {
const person = authStore.user.person
if (authStore.user.email) {
primaryContact.email = authStore.user.email
}
if (person) {
if (person.first_name || person.last_name) {
primaryContact.full_name = [person.first_name, person.last_name].filter(Boolean).join(' ')
}
if (person.phone) {
primaryContact.phone = person.phone
}
}
}
})
// ── Auto-fill display_name from name if display_name is empty ──
function autoFillDisplayName() {
if (!form.display_name) {
form.display_name = form.name
}
}
// ── Step circle styling ──
function stepCircleClass(idx: number): string {
const stepNum = idx + 1
if (currentStep.value > stepNum) {
return 'bg-[#00E5A0] text-[#04151F] shadow-lg shadow-[#00E5A0]/30'
}
if (currentStep.value === stepNum) {
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0] shadow-lg shadow-[#00E5A0]/20'
}
return 'bg-white/5 text-white/40 border border-white/10'
}
// ── Validation per step ──
function validateStep(): boolean {
errorMessage.value = null
if (currentStep.value === 1) {
if (!form.tax_number) {
errorMessage.value = t('company.taxRequired')
return false
}
if (taxLookedUp.value) {
if (!form.full_name) {
errorMessage.value = t('company.fullNameRequired')
return false
}
if (!form.name) {
errorMessage.value = t('company.shortNameRequired')
return false
}
if (!form.display_name) {
errorMessage.value = t('company.displayNameRequired')
return false
}
if (!form.reg_number) {
errorMessage.value = t('company.regNumberRequired')
return false
}
}
}
if (currentStep.value === 2) {
if (!form.address_zip) {
errorMessage.value = t('company.zipRequired')
return false
}
if (!form.address_city) {
errorMessage.value = t('company.cityRequired')
return false
}
}
if (currentStep.value === 3) {
if (!primaryContact.email) {
errorMessage.value = t('company.emailRequired')
return false
}
if (!primaryContact.phone) {
errorMessage.value = t('company.phoneRequired')
return false
}
}
return true
}
// ── Reset duplicate flag when tax number changes ──
function onTaxNumberChange() {
if (isDuplicateCompany.value) {
isDuplicateCompany.value = false
}
}
// ── Navigation ──
function nextStep() {
// Block navigation if duplicate company was detected
if (isDuplicateCompany.value) return
if (!validateStep()) return
if (currentStep.value < 3) {
currentStep.value++
}
}
function prevStep() {
if (currentStep.value > 1) {
currentStep.value--
errorMessage.value = null
}
}
// ── Cancel / Mégsem ──
function cancelOnboarding() {
router.push('/dashboard')
}
// ── Build contacts array ──
function buildContacts(): Array<Record<string, any>> {
const contacts: Array<Record<string, any>> = []
// Primary contact
const primary: Record<string, any> = {
email: primaryContact.email,
phone: primaryContact.phone,
is_primary: true,
}
if (primaryContact.full_name) primary.full_name = primaryContact.full_name
contacts.push(primary)
return contacts
}
// ── Tax number lookup ──
async function lookupTaxNumber() {
if (!form.tax_number) return
isTaxLookupLoading.value = true
errorMessage.value = null
isDuplicateCompany.value = false
try {
const res = await api.get('/organizations/lookup-tax/' + form.tax_number)
const data = res.data
// Auto-fill form fields from response
if (data.full_name) form.full_name = data.full_name
if (data.name) {
form.name = data.name
// Auto-fill display_name if empty
if (!form.display_name) {
form.display_name = data.name
}
}
if (data.address_zip) form.address_zip = data.address_zip
if (data.address_city) form.address_city = data.address_city
if (data.address_street_name) form.address_street_name = data.address_street_name
if (data.address_street_type) form.address_street_type = data.address_street_type
if (data.address_house_number) form.address_house_number = data.address_house_number
// Reveal the hidden fields
taxLookedUp.value = true
} catch (err: any) {
// Detect 409 Conflict — duplicate company
if (err.response?.status === 409) {
isDuplicateCompany.value = true
errorMessage.value =
err.response?.data?.detail ||
t('company.duplicateCompany')
} else {
errorMessage.value =
err.response?.data?.detail ||
t('company.lookupError')
}
} finally {
isTaxLookupLoading.value = false
}
}
// ── Submit ──
async function handleSubmit() {
if (!validateStep()) return
isSubmitting.value = true
errorMessage.value = null
successMessage.value = null
try {
const payload: Record<string, any> = {
full_name: form.full_name,
name: form.name,
display_name: form.display_name,
tax_number: form.tax_number,
reg_number: form.reg_number,
country_code: form.country_code,
language: form.language,
default_currency: form.default_currency,
address_zip: form.address_zip,
address_city: form.address_city,
contacts: buildContacts(),
}
// Optional address fields
if (form.address_street_name) payload.address_street_name = form.address_street_name
if (form.address_street_type) payload.address_street_type = form.address_street_type
if (form.address_house_number) payload.address_house_number = form.address_house_number
if (form.address_stairwell) payload.address_stairwell = form.address_stairwell
if (form.address_floor) payload.address_floor = form.address_floor
if (form.address_door) payload.address_door = form.address_door
if (form.address_hrsz) payload.address_hrsz = form.address_hrsz
await api.post('/organizations/onboard', payload)
successMessage.value = t('company.successMessage')
// Refresh the organization list so the header updates immediately
await authStore.fetchMyOrganizations()
// Navigate to company garage after a short delay
setTimeout(() => {
router.push('/company/garage')
}, 1500)
} catch (err: any) {
errorMessage.value =
err.response?.data?.detail ||
err.response?.data?.message ||
t('company.submitError')
} finally {
isSubmitting.value = false
}
}
</script>