2026.06.04 frontend építés közben
This commit is contained in:
202
frontend/src/App.vue
Executable file → Normal file
202
frontend/src/App.vue
Executable file → Normal file
@@ -1,197 +1,15 @@
|
||||
<script setup>
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import DailyQuizModal from '@/components/DailyQuizModal.vue'
|
||||
import QuickActionsFAB from '@/components/actions/QuickActionsFAB.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout()
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
themeStore.toggleTheme()
|
||||
}
|
||||
|
||||
// Legal modal state
|
||||
const showLegalModal = ref(false)
|
||||
const legalModalTitle = ref('')
|
||||
const legalModalContent = ref('')
|
||||
|
||||
const openLegalModal = (type) => {
|
||||
if (type === 'aszf') {
|
||||
legalModalTitle.value = 'Általános Szerződési Feltételek (ÁSZF)'
|
||||
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... A Service Finder szolgáltatás használatával Ön elfogadja az Általános Szerződési Feltételeket, amelyek meghatározzák a szolgáltatás használatának feltételeit, a felelősség korlátozását és a felhasználói jogokat.'
|
||||
} else if (type === 'adatkezeles') {
|
||||
legalModalTitle.value = 'Adatkezelési Tájékoztató (GDPR)'
|
||||
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... A Service Finder tiszteletben tartja az Ön adatvédelmét. E tájékoztató részletezi, hogyan gyűjtjük, tároljuk és kezeljük személyes adatait az Európai Unió Általános Adatvédelmi Rendelete (GDPR) és a vonatkozó magyar jogszabályok értelmében.'
|
||||
} else if (type === 'cookies') {
|
||||
legalModalTitle.value = 'Sütik (Cookies) Használata'
|
||||
legalModalContent.value = 'A jogi dokumentáció feltöltés alatt... Weboldalunk sütiket (cookies) használ a felhasználói élmény javítása, a funkciók működésének biztosítása és a forgalom elemzése érdekében. A sütik használatával kapcsolatos részletes információkat itt találja.'
|
||||
}
|
||||
showLegalModal.value = true
|
||||
}
|
||||
|
||||
const closeLegalModal = () => {
|
||||
showLegalModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['min-h-screen flex flex-col font-sans transition-all duration-500', themeStore.themeClasses.background, themeStore.themeClasses.text]">
|
||||
<!-- Premium Navigation -->
|
||||
<nav class="bg-gradient-to-r from-slate-800 to-slate-900 text-white p-4 shadow-xl flex justify-between items-center z-50 border-b border-slate-700/50 backdrop-blur-md">
|
||||
<div class="flex items-center gap-3 cursor-pointer group" @click="router.push('/')">
|
||||
<div class="p-2 rounded-xl bg-gradient-to-br from-blue-500 to-cyan-500 group-hover:from-blue-600 group-hover:to-cyan-600 transition-all duration-200">
|
||||
<span class="text-2xl">🚗</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-xl tracking-tight">Service Finder</span>
|
||||
<span class="text-xs text-slate-300">Premium Vehicle Management</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-x-6 hidden md:flex items-center">
|
||||
<template v-if="authStore.isLoggedIn">
|
||||
<router-link to="/" class="nav-link text-slate-200 hover:text-white font-medium transition-colors duration-200 hover:scale-105">Dashboard</router-link>
|
||||
<router-link to="/expenses" class="nav-link text-slate-200 hover:text-white font-medium transition-colors duration-200 hover:scale-105">Költségek</router-link>
|
||||
<router-link v-if="authStore.isAdmin" to="/admin" class="text-amber-300 font-bold hover:text-amber-200 transition-colors duration-200 hover:scale-105">⚙️ Admin</router-link>
|
||||
|
||||
<!-- User Role Badge -->
|
||||
<div v-if="authStore.isTester" class="px-3 py-1.5 bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg text-xs font-bold text-white border border-purple-500/50 shadow-md animate-pulse">
|
||||
🧪 {{ authStore.displayName }}
|
||||
</div>
|
||||
<div v-else-if="authStore.isAdmin" class="px-3 py-1.5 bg-gradient-to-r from-amber-600 to-orange-600 rounded-lg text-xs font-bold text-white border border-amber-500/50 shadow-md">
|
||||
⚡ {{ authStore.displayName }}
|
||||
</div>
|
||||
|
||||
<button @click="toggleTheme" :class="['px-4 py-2.5 rounded-xl text-sm transition-all duration-200 active:scale-95 border shadow-md hover:shadow-lg', themeStore.isLuxury ? 'bg-gradient-to-r from-amber-700 to-amber-800 border-amber-600/50 text-white hover:from-amber-800 hover:to-amber-900' : 'bg-gradient-to-r from-orange-700 to-orange-800 border-orange-600/50 text-white hover:from-orange-800 hover:to-orange-900']">
|
||||
{{ themeStore.isLuxury ? '🏛️ Luxury' : '🔧 Workshop' }}
|
||||
</button>
|
||||
<button @click="handleLogout" class="bg-gradient-to-r from-slate-700 to-slate-800 px-5 py-2.5 rounded-xl text-sm hover:from-slate-800 hover:to-slate-900 transition-all duration-200 active:scale-95 border border-slate-600/50 shadow-md hover:shadow-lg">
|
||||
Kijelentkezés
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login" class="text-slate-200 hover:text-white font-medium transition-colors duration-200">Bejelentkezés</router-link>
|
||||
<router-link to="/register" class="bg-gradient-to-r from-blue-600 to-cyan-600 px-5 py-2.5 rounded-xl text-sm hover:from-blue-700 hover:to-cyan-700 transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
|
||||
Regisztráció
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button class="md:hidden p-2 rounded-lg bg-slate-700/50 hover:bg-slate-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 p-4 md:p-6 max-w-7xl mx-auto w-full">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Daily Quiz Modal -->
|
||||
<DailyQuizModal v-if="authStore.isLoggedIn && route.path !== '/login' && route.path !== '/register' && route.path !== '/debug'" />
|
||||
|
||||
<!-- Quick Actions FAB -->
|
||||
<QuickActionsFAB v-if="authStore.isLoggedIn && route.path !== '/login' && route.path !== '/register' && route.path !== '/debug'" />
|
||||
|
||||
<!-- Legal Modal -->
|
||||
<div v-if="showLegalModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm transition-all duration-300">
|
||||
<div class="bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl shadow-2xl border border-slate-700/50 w-full max-w-2xl mx-4 overflow-hidden transform transition-all duration-300 scale-100">
|
||||
<div class="p-6 border-b border-slate-700/50">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-white">{{ legalModalTitle }}</h3>
|
||||
<button @click="closeLegalModal" class="text-slate-400 hover:text-white transition-colors duration-200 p-2 rounded-lg hover:bg-slate-700/50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<div class="prose prose-invert max-w-none">
|
||||
<p class="text-slate-300 mb-4">{{ legalModalContent }}</p>
|
||||
<div class="bg-slate-800/50 rounded-xl p-4 border border-slate-700/50 mt-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 flex items-center justify-center">
|
||||
<span class="text-white font-bold">ℹ️</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-400">Ez egy helykitöltő szöveg. A végleges jogi dokumentáció a termék bevezetésével együtt kerül feltöltésre.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-700/50 bg-slate-900/50">
|
||||
<div class="flex justify-end">
|
||||
<button @click="closeLegalModal" class="px-6 py-3 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white font-medium rounded-xl transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
|
||||
Bezárás
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Premium Footer -->
|
||||
<footer class="mt-auto bg-gradient-to-r from-slate-800 to-slate-900 text-slate-300 p-6 border-t border-slate-700/50">
|
||||
<div class="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-2xl">🚗</span>
|
||||
<span class="font-bold text-white">Service Finder</span>
|
||||
</div>
|
||||
<p class="text-sm text-slate-400">Premium vehicle management for individuals and businesses</p>
|
||||
</div>
|
||||
<div class="flex gap-6">
|
||||
<button @click="openLegalModal('aszf')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">ÁSZF</button>
|
||||
<button @click="openLegalModal('adatkezeles')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">Adatkezelési Tájékoztató</button>
|
||||
<button @click="openLegalModal('cookies')" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm hover:underline">Sütik</button>
|
||||
<a href="#" class="text-slate-400 hover:text-white transition-colors duration-200 text-sm">Kapcsolat</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto mt-6 pt-6 border-t border-slate-700/50 text-center text-xs text-slate-500">
|
||||
© 2026 Service Finder. Minden jog fenntartva. Gépjármű-rajongók számára készült precízióval.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-link {
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, #3b82f6, #06b6d4);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
const authStore = useAuthStore()
|
||||
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: color, background-color, border-color, transform, box-shadow;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
</style>
|
||||
onMounted(() => {
|
||||
// Restore session from localStorage token on app start
|
||||
authStore.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
37
frontend/src/api/axios.ts
Normal file
37
frontend/src/api/axios.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor: attach JWT token from localStorage
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor: handle 401 Unauthorized (expired/invalid token)
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid — clear it
|
||||
localStorage.removeItem('access_token')
|
||||
// Optionally redirect to login could be added here later
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
BIN
frontend/src/assets/garage-bg.png
Normal file
BIN
frontend/src/assets/garage-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 MiB |
99
frontend/src/assets/logo copy.svg
Normal file
99
frontend/src/assets/logo copy.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="623"
|
||||
height="469"
|
||||
viewBox="0 0 1600 1204"
|
||||
version="1.1"
|
||||
id="svg440"
|
||||
sodipodi:docname="SF_csak_logo_ (1).svg"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs440" />
|
||||
<sodipodi:namedview
|
||||
id="namedview440"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.7206823"
|
||||
inkscape:cx="390.83333"
|
||||
inkscape:cy="234.5"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="646"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg440" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 1200,296 16,8 20,11 22,14 17,12 16,13 12,11 21,21 9,11 12,15 13,19 9,14 10,18 13,27 6,16 11,30 6,25 5,31 2,30 v 36 l -1,21 -4,29 -5,24 -8,26 -8,21 -13,31 -10,18 -20,30 -3,6 1,5 9,10 13,16 8,5 3,1 2,-4 5,-1 h 11 l 13,5 13,10 11,12 11,11 7,8 25,25 7,8 7,7 7,8 18,18 8,7 9,9 v 2 h 2 l 7,8 9,9 7,8 11,11 7,8 7,10 8,17 2,10 v 15 l -3,12 -4,9 -6,10 -9,10 -10,9 -16,8 -9,2 h -23 l -13,-4 -14,-7 -13,-11 -11,-11 -7,-8 -11,-12 -9,-10 -7,-7 -7,-8 -13,-14 -7,-8 -14,-15 -16,-17 -7,-7 -7,-8 -15,-15 v -2 l -4,-2 -8,-8 -10,-13 -4,-10 -1,-23 -5,-8 -9,-10 -7,-8 -7,-6 -6,1 -15,9 -18,12 -19,11 -26,13 -26,10 -29,8 -21,4 -21,2 h -49 l -26,-4 -12,-1 -18,14 -16,12 -17,11 -25,14 -23,11 -26,10 -28,9 -50,11 -4,2 -13,40 -13,37 -10,27 -2,6 h -2 l -13,-35 -24,-71 -3,-6 v -2 h -11 l -26,-4 -19,-5 -38,-13 -25,-11 -20,-10 -3,-3 16,-12 16,-13 11,-9 13,-11 5,-4 6,2 16,6 26,8 24,5 22,3 12,1 h 24 l 23,-2 29,-5 21,-5 36,-12 20,-8 20,-10 5,-3 -1,-3 -22,-11 -18,-11 -12,-9 -11,-9 -24,-22 -7,-8 -9,-10 -2,-5 9,-9 8,-7 9,-11 12,-17 4,-7 7,6 13,13 11,9 13,11 20,14 19,11 16,8 25,10 24,7 19,4 15,2 h 40 l 26,-3 22,-5 24,-8 21,-9 19,-10 19,-12 17,-12 14,-12 9,-9 h 2 l 2,-4 8,-8 15,-20 12,-19 13,-24 11,-27 10,-34 6,-31 2,-16 v -52 l -4,-27 -6,-24 -10,-30 -9,-21 -11,-20 -13,-21 -13,-17 -12,-14 -17,-17 -11,-9 -13,-11 -18,-13 -22,-13 -25,-12 -1,-3 10,-18 13,-21 z"
|
||||
id="path2"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 658,427 19,1 26,2 4,2 20,4 43,13 2,1 -1,4 -12,10 -14,11 -11,10 -8,7 -10,9 -16,15 -2,2 -17,-1 h -34 l -15,3 -13,5 -13,7 -9,8 -6,10 -3,9 v 11 l 4,12 9,10 12,7 20,8 32,8 43,10 25,7 18,6 20,9 14,8 13,10 9,8 9,11 9,13 8,18 4,15 2,10 1,13 v 13 l -2,18 -4,18 -6,16 -6,11 -10,13 -12,13 -14,11 -17,10 -21,9 -25,9 -25,5 -18,2 -20,1 h -23 l -24,-2 -38,-6 -48,-16 -23,-10 -17,-10 -24,-16 1,-4 41,-82 8,6 17,12 18,10 16,8 21,8 27,7 24,4 h 35 l 19,-3 16,-5 10,-5 8,-7 6,-12 2,-8 v -8 l -3,-12 -6,-10 -9,-8 -16,-8 -26,-8 -43,-10 -42,-12 -22,-8 -25,-12 -15,-10 -11,-9 -10,-10 -9,-14 -7,-15 -5,-16 -2,-10 -1,-13 v -12 l 3,-22 6,-20 7,-14 12,-17 9,-11 8,-8 17,-13 21,-12 24,-10 22,-6 23,-4 z"
|
||||
id="path3"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 775,90 2,1 12,36 14,41 12,35 23,4 31,5 24,7 36,12 23,10 32,16 20,12 15,10 -5,5 -16,10 -23,15 -18,11 -6,-1 -22,-12 -19,-8 -27,-9 -37,-10 -21,-4 -13,-2 -14,-1 h -47 l -27,3 -30,6 -31,9 -25,9 -24,11 -19,10 -19,12 -19,14 -13,11 -12,11 -28,28 -11,14 -15,20 -11,17 -14,24 -13,25 -11,29 -9,29 -8,36 -3,25 -1,14 v 55 l 3,27 6,31 4,19 v 4 l -16,12 -35,23 -24,15 -42,28 -46,30 -5,2 -6,-5 1,-7 6,-10 9,-11 15,-16 22,-22 8,-7 24,-24 8,-7 12,-12 -1,-9 -4,-21 -3,-27 -5,-5 -21,-11 -27,-11 -28,-11 -21,-9 -5,-2 v -2 l 17,-8 64,-25 22,-9 3,-5 7,-39 10,-40 15,-43 9,-20 13,-25 14,-24 13,-21 12,-16 7,-8 -1,-7 -11,-24 -17,-39 -5,-10 -3,-4 1,-2 11,5 39,16 27,13 3,3 4,-2 14,-12 20,-15 29,-19 22,-13 23,-12 15,-6 27,-10 31,-9 26,-6 34,-6 4,-3 12,-36 13,-37 z"
|
||||
id="path4"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 1241,171 1,2 -5,9 -12,19 -12,21 -8,13 -11,19 -15,24 -12,20 -31,51 -9,15 -11,18 -8,14 -16,26 -9,15 -17,28 -11,19 -10,17 -13,21 -16,27 -8,13 -11,19 -9,15 -17,29 -10,16 -10,19 -15,24 -11,18 -13,23 -9,15 -4,8 -3,1 -1,-4 -2,-27 -4,-29 -6,-25 -9,-24 -9,-22 -7,-12 -9,-12 -4,-5 1,-4 8,-10 7,1 6,2 12,1 12,-3 8,-5 3,-1 v -2 h 2 l 7,-14 1,-5 v -9 l -4,-13 -8,-10 -12,-6 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 12,-7 z"
|
||||
id="path5"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#79b085"
|
||||
d="m 1228,180 4,1 -8,10 h -2 l -2,4 -14,15 -7,8 -31,33 -9,9 -7,8 -7,7 -7,8 -11,11 -7,8 -14,15 -67,67 -7,8 -14,15 -20,20 -7,8 -22,22 -7,8 -8,8 -7,8 -13,13 -7,8 -15,14 -4,4 -2,-1 -3,-5 h -8 l -7,-3 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 z"
|
||||
id="path6"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418790"
|
||||
d="m 1032,575 h 2 l 1,52 h 181 l 1,2 v 79 l -154,1 h -29 v 89 l -1,65 -8,1 h -87 l -1,-1 V 744 l 1,-14 51,-84 8,-14 12,-19 10,-17 11,-19 z"
|
||||
id="path7"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 478,900 4,1 24,10 33,12 1,3 -24,16 -26,17 -24,15 -19,12 -32,19 -21,13 -19,11 -28,16 -24,14 -24,13 -19,10 -27,14 -22,12 -30,15 -19,9 -22,12 -30,15 -28,13 -20,9 -13,5 -8,1 -2,-1 1,-4 13,-11 14,-11 16,-13 16,-12 13,-10 48,-32 25,-15 19,-12 25,-16 40,-25 26,-17 19,-12 17,-11 19,-12 36,-24 41,-28 24,-16 z"
|
||||
id="path10"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 577,931 7,1 16,4 24,3 50,3 1,2 -4,6 -7,7 -11,9 -14,13 -11,9 -16,13 -9,7 -15,12 -18,13 -17,12 -20,14 -29,19 -19,12 -24,15 -24,14 -14,6 h -6 l -5,-4 -2,-4 1,-7 6,-8 8,-8 11,-9 16,-13 13,-10 11,-9 15,-12 19,-14 2,-2 -1,-4 -15,-14 -3,-3 1,-4 10,-10 17,-13 16,-13 16,-12 15,-13 z"
|
||||
id="path11"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#78b085"
|
||||
d="m 1115,443 h 125 l 3,2 v 80 l -1,1 -45,1 h -98 l -37,-1 v -3 l 4,-5 16,-27 15,-26 4,-6 3,-6 5,-4 4,-5 z"
|
||||
id="path13"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408690"
|
||||
d="m 433,813 3,1 9,15 3,6 -2,4 1,10 1,9 -10,7 -28,19 -17,12 -15,10 -22,14 -17,12 -24,16 -13,6 h -8 l -8,-5 -4,-5 -2,-5 v -7 l 4,-10 9,-11 10,-10 20,-14 26,-17 22,-14 15,-10 19,-13 19,-14 z"
|
||||
id="path14"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 233,945 h 7 l 6,5 2,5 -1,10 -9,10 -8,7 -18,12 -15,8 -10,3 h -11 l -8,-4 -3,-5 1,-7 4,-6 9,-9 10,-7 18,-10 23,-11 z"
|
||||
id="path20"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#0f4664"
|
||||
d="m 370,1125 h 10 l 6,5 2,5 v 8 l -4,6 -8,8 -13,8 -3,1 h -8 l -7,-6 -1,-2 v -7 l 4,-9 7,-9 11,-7 z"
|
||||
id="path32"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#3f8690"
|
||||
d="m 209,874 6,1 4,5 1,3 v 9 l -3,5 -7,7 -10,6 -3,1 h -7 l -6,-5 -1,-6 5,-10 6,-7 10,-7 z"
|
||||
id="path38"
|
||||
style="display:inline" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
82
frontend/src/assets/logo.svg
Normal file
82
frontend/src/assets/logo.svg
Normal file
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="623"
|
||||
height="469"
|
||||
viewBox="0 0 1600 1204"
|
||||
version="1.1"
|
||||
id="svg440"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs440">
|
||||
<linearGradient id="sfGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#00E5A0" />
|
||||
<stop offset="100%" stop-color="#70BC84" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1200,296 16,8 20,11 22,14 17,12 16,13 12,11 21,21 9,11 12,15 13,19 9,14 10,18 13,27 6,16 11,30 6,25 5,31 2,30 v 36 l -1,21 -4,29 -5,24 -8,26 -8,21 -13,31 -10,18 -20,30 -3,6 1,5 9,10 13,16 8,5 3,1 2,-4 5,-1 h 11 l 13,5 13,10 11,12 11,11 7,8 25,25 7,8 7,7 7,8 18,18 8,7 9,9 v 2 h 2 l 7,8 9,9 7,8 11,11 7,8 7,10 8,17 2,10 v 15 l -3,12 -4,9 -6,10 -9,10 -10,9 -16,8 -9,2 h -23 l -13,-4 -14,-7 -13,-11 -11,-11 -7,-8 -11,-12 -9,-10 -7,-7 -7,-8 -13,-14 -7,-8 -14,-15 -16,-17 -7,-7 -7,-8 -15,-15 v -2 l -4,-2 -8,-8 -10,-13 -4,-10 -1,-23 -5,-8 -9,-10 -7,-8 -7,-6 -6,1 -15,9 -18,12 -19,11 -26,13 -26,10 -29,8 -21,4 -21,2 h -49 l -26,-4 -12,-1 -18,14 -16,12 -17,11 -25,14 -23,11 -26,10 -28,9 -50,11 -4,2 -13,40 -13,37 -10,27 -2,6 h -2 l -13,-35 -24,-71 -3,-6 v -2 h -11 l -26,-4 -19,-5 -38,-13 -25,-11 -20,-10 -3,-3 16,-12 16,-13 11,-9 13,-11 5,-4 6,2 16,6 26,8 24,5 22,3 12,1 h 24 l 23,-2 29,-5 21,-5 36,-12 20,-8 20,-10 5,-3 -1,-3 -22,-11 -18,-11 -12,-9 -11,-9 -24,-22 -7,-8 -9,-10 -2,-5 9,-9 8,-7 9,-11 12,-17 4,-7 7,6 13,13 11,9 13,11 20,14 19,11 16,8 25,10 24,7 19,4 15,2 h 40 l 26,-3 22,-5 24,-8 21,-9 19,-10 19,-12 17,-12 14,-12 9,-9 h 2 l 2,-4 8,-8 15,-20 12,-19 13,-24 11,-27 10,-34 6,-31 2,-16 v -52 l -4,-27 -6,-24 -10,-30 -9,-21 -11,-20 -13,-21 -13,-17 -12,-14 -17,-17 -11,-9 -13,-11 -18,-13 -22,-13 -25,-12 -1,-3 10,-18 13,-21 z"
|
||||
id="path2"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 658,427 19,1 26,2 4,2 20,4 43,13 2,1 -1,4 -12,10 -14,11 -11,10 -8,7 -10,9 -16,15 -2,2 -17,-1 h -34 l -15,3 -13,5 -13,7 -9,8 -6,10 -3,9 v 11 l 4,12 9,10 12,7 20,8 32,8 43,10 25,7 18,6 20,9 14,8 13,10 9,8 9,11 9,13 8,18 4,15 2,10 1,13 v 13 l -2,18 -4,18 -6,16 -6,11 -10,13 -12,13 -14,11 -17,10 -21,9 -25,9 -25,5 -18,2 -20,1 h -23 l -24,-2 -38,-6 -48,-16 -23,-10 -17,-10 -24,-16 1,-4 41,-82 8,6 17,12 18,10 16,8 21,8 27,7 24,4 h 35 l 19,-3 16,-5 10,-5 8,-7 6,-12 2,-8 v -8 l -3,-12 -6,-10 -9,-8 -16,-8 -26,-8 -43,-10 -42,-12 -22,-8 -25,-12 -15,-10 -11,-9 -10,-10 -9,-14 -7,-15 -5,-16 -2,-10 -1,-13 v -12 l 3,-22 6,-20 7,-14 12,-17 9,-11 8,-8 17,-13 21,-12 24,-10 22,-6 23,-4 z"
|
||||
id="path3"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 775,90 2,1 12,36 14,41 12,35 23,4 31,5 24,7 36,12 23,10 32,16 20,12 15,10 -5,5 -16,10 -23,15 -18,11 -6,-1 -22,-12 -19,-8 -27,-9 -37,-10 -21,-4 -13,-2 -14,-1 h -47 l -27,3 -30,6 -31,9 -25,9 -24,11 -19,10 -19,12 -19,14 -13,11 -12,11 -28,28 -11,14 -15,20 -11,17 -14,24 -13,25 -11,29 -9,29 -8,36 -3,25 -1,14 v 55 l 3,27 6,31 4,19 v 4 l -16,12 -35,23 -24,15 -42,28 -46,30 -5,2 -6,-5 1,-7 6,-10 9,-11 15,-16 22,-22 8,-7 24,-24 8,-7 12,-12 -1,-9 -4,-21 -3,-27 -5,-5 -21,-11 -27,-11 -28,-11 -21,-9 -5,-2 v -2 l 17,-8 64,-25 22,-9 3,-5 7,-39 10,-40 15,-43 9,-20 13,-25 14,-24 13,-21 12,-16 7,-8 -1,-7 -11,-24 -17,-39 -5,-10 -3,-4 1,-2 11,5 39,16 27,13 3,3 4,-2 14,-12 20,-15 29,-19 22,-13 23,-12 15,-6 27,-10 31,-9 26,-6 34,-6 4,-3 12,-36 13,-37 z"
|
||||
id="path4"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1241,171 1,2 -5,9 -12,19 -12,21 -8,13 -11,19 -15,24 -12,20 -31,51 -9,15 -11,18 -8,14 -16,26 -9,15 -17,28 -11,19 -10,17 -13,21 -16,27 -8,13 -11,19 -9,15 -17,29 -10,16 -10,19 -15,24 -11,18 -13,23 -9,15 -4,8 -3,1 -1,-4 -2,-27 -4,-29 -6,-25 -9,-24 -9,-22 -7,-12 -9,-12 -4,-5 1,-4 8,-10 7,1 6,2 12,1 12,-3 8,-5 3,-1 v -2 h 2 l 7,-14 1,-5 v -9 l -4,-13 -8,-10 -12,-6 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 12,-7 z"
|
||||
id="path5"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1228,180 4,1 -8,10 h -2 l -2,4 -14,15 -7,8 -31,33 -9,9 -7,8 -7,7 -7,8 -11,11 -7,8 -14,15 -67,67 -7,8 -14,15 -20,20 -7,8 -22,22 -7,8 -8,8 -7,8 -13,13 -7,8 -15,14 -4,4 -2,-1 -3,-5 h -8 l -7,-3 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 z"
|
||||
id="path6"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1032,575 h 2 l 1,52 h 181 l 1,2 v 79 l -154,1 h -29 v 89 l -1,65 -8,1 h -87 l -1,-1 V 744 l 1,-14 51,-84 8,-14 12,-19 10,-17 11,-19 z"
|
||||
id="path7"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 478,900 4,1 24,10 33,12 1,3 -24,16 -26,17 -24,15 -19,12 -32,19 -21,13 -19,11 -28,16 -24,14 -24,13 -19,10 -27,14 -22,12 -30,15 -19,9 -22,12 -30,15 -28,13 -20,9 -13,5 -8,1 -2,-1 1,-4 13,-11 14,-11 16,-13 16,-12 13,-10 48,-32 25,-15 19,-12 25,-16 40,-25 26,-17 19,-12 17,-11 19,-12 36,-24 41,-28 24,-16 z"
|
||||
id="path10"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 577,931 7,1 16,4 24,3 50,3 1,2 -4,6 -7,7 -11,9 -14,13 -11,9 -16,13 -9,7 -15,12 -18,13 -17,12 -20,14 -29,19 -19,12 -24,15 -24,14 -14,6 h -6 l -5,-4 -2,-4 1,-7 6,-8 8,-8 11,-9 16,-13 13,-10 11,-9 15,-12 19,-14 2,-2 -1,-4 -15,-14 -3,-3 1,-4 10,-10 17,-13 16,-13 16,-12 15,-13 z"
|
||||
id="path11"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 1115,443 h 125 l 3,2 v 80 l -1,1 -45,1 h -98 l -37,-1 v -3 l 4,-5 16,-27 15,-26 4,-6 3,-6 5,-4 4,-5 z"
|
||||
id="path13"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 433,813 3,1 9,15 3,6 -2,4 1,10 1,9 -10,7 -28,19 -17,12 -15,10 -22,14 -17,12 -24,16 -13,6 h -8 l -8,-5 -4,-5 -2,-5 v -7 l 4,-10 9,-11 10,-10 20,-14 26,-17 22,-14 15,-10 19,-13 19,-14 z"
|
||||
id="path14"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 233,945 h 7 l 6,5 2,5 -1,10 -9,10 -8,7 -18,12 -15,8 -10,3 h -11 l -8,-4 -3,-5 1,-7 4,-6 9,-9 10,-7 18,-10 23,-11 z"
|
||||
id="path20"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 370,1125 h 10 l 6,5 2,5 v 8 l -4,6 -8,8 -13,8 -3,1 h -8 l -7,-6 -1,-2 v -7 l 4,-9 7,-9 11,-7 z"
|
||||
id="path32"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="url(#sfGradient)"
|
||||
d="m 209,874 6,1 4,5 1,3 v 9 l -3,5 -7,7 -10,6 -3,1 h -7 l -6,-5 -1,-6 5,-10 6,-7 10,-7 z"
|
||||
id="path38"
|
||||
style="display:inline" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
116
frontend/src/assets/main.css
Normal file
116
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,116 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
@apply bg-[#04151F] text-white;
|
||||
background-image: radial-gradient(ellipse 80% 60% at 50% 0%, #0a2a40 0%, #04151F 70%);
|
||||
background-attachment: fixed;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge autofill override — keeps dark theme */
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
input:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 30px #04151F inset !important;
|
||||
-webkit-text-fill-color: white !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-sf-blue text-white rounded-lg hover:bg-sf-accent transition-colors;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 bg-sf-wall text-sf-blue rounded-lg hover:bg-sf-green hover:text-white transition-colors;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-md p-6;
|
||||
}
|
||||
.bento-grid {
|
||||
@apply grid grid-cols-1 md:grid-cols-3 gap-6;
|
||||
}
|
||||
.bento-cell {
|
||||
@apply rounded-2xl p-6 shadow-lg transition-transform hover:scale-[1.02];
|
||||
}
|
||||
|
||||
/* Service Finder specific components */
|
||||
.sf-header-bg {
|
||||
@apply bg-sf-wall bg-sf-wall-pattern;
|
||||
}
|
||||
.sf-card-green {
|
||||
@apply bg-sf-green text-white;
|
||||
}
|
||||
.sf-card-blue {
|
||||
@apply bg-sf-blue text-white;
|
||||
}
|
||||
.sf-card-accent {
|
||||
@apply bg-sf-accent text-white;
|
||||
}
|
||||
|
||||
/* ── Premium Button: Glassmorphism base, turquoise/green glow on hover, white flash on click ── */
|
||||
.btn-premium {
|
||||
@apply relative inline-flex items-center justify-center rounded-xl bg-white/10 px-6 py-3 font-bold text-white backdrop-blur-md border border-white/20 transition-all duration-300 cursor-pointer;
|
||||
}
|
||||
.btn-premium:hover {
|
||||
@apply bg-gradient-to-r from-[#418890] to-[#70BC84] border-transparent shadow-[0_0_20px_rgba(0,229,160,0.5)] -translate-y-0.5;
|
||||
}
|
||||
.btn-premium:active {
|
||||
@apply !bg-white !bg-none text-[#04151F] shadow-[0_0_30px_rgba(255,255,255,0.8)] scale-95 transition-none;
|
||||
}
|
||||
|
||||
/* ── Premium Gradient Text for large headings ── */
|
||||
.text-gradient-premium {
|
||||
@apply bg-clip-text text-transparent bg-gradient-to-r from-white via-[#00E5A0] to-[#418890];
|
||||
}
|
||||
|
||||
/* ── Fade Transition (Zen Mode toggle) ── */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Hotspot Pulse Animation ── */
|
||||
@keyframes hotspot-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
.animate-hotspot-pulse {
|
||||
animation: hotspot-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ── Hotspot Ring Ripple ── */
|
||||
@keyframes hotspot-ring {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-hotspot-ring {
|
||||
animation: hotspot-ring 2.5s ease-out infinite;
|
||||
}
|
||||
.animate-hotspot-ring-delayed {
|
||||
animation: hotspot-ring 2.5s ease-out 0.8s infinite;
|
||||
}
|
||||
}
|
||||
99
frontend/src/assets/sf-brand-logo.svg
Normal file
99
frontend/src/assets/sf-brand-logo.svg
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="623"
|
||||
height="469"
|
||||
viewBox="0 0 1600 1204"
|
||||
version="1.1"
|
||||
id="svg440"
|
||||
sodipodi:docname="SF_csak_logo_ (1).svg"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs440" />
|
||||
<sodipodi:namedview
|
||||
id="namedview440"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.7206823"
|
||||
inkscape:cx="390.83333"
|
||||
inkscape:cy="234.5"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="1912"
|
||||
inkscape:window-y="646"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg440" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 1200,296 16,8 20,11 22,14 17,12 16,13 12,11 21,21 9,11 12,15 13,19 9,14 10,18 13,27 6,16 11,30 6,25 5,31 2,30 v 36 l -1,21 -4,29 -5,24 -8,26 -8,21 -13,31 -10,18 -20,30 -3,6 1,5 9,10 13,16 8,5 3,1 2,-4 5,-1 h 11 l 13,5 13,10 11,12 11,11 7,8 25,25 7,8 7,7 7,8 18,18 8,7 9,9 v 2 h 2 l 7,8 9,9 7,8 11,11 7,8 7,10 8,17 2,10 v 15 l -3,12 -4,9 -6,10 -9,10 -10,9 -16,8 -9,2 h -23 l -13,-4 -14,-7 -13,-11 -11,-11 -7,-8 -11,-12 -9,-10 -7,-7 -7,-8 -13,-14 -7,-8 -14,-15 -16,-17 -7,-7 -7,-8 -15,-15 v -2 l -4,-2 -8,-8 -10,-13 -4,-10 -1,-23 -5,-8 -9,-10 -7,-8 -7,-6 -6,1 -15,9 -18,12 -19,11 -26,13 -26,10 -29,8 -21,4 -21,2 h -49 l -26,-4 -12,-1 -18,14 -16,12 -17,11 -25,14 -23,11 -26,10 -28,9 -50,11 -4,2 -13,40 -13,37 -10,27 -2,6 h -2 l -13,-35 -24,-71 -3,-6 v -2 h -11 l -26,-4 -19,-5 -38,-13 -25,-11 -20,-10 -3,-3 16,-12 16,-13 11,-9 13,-11 5,-4 6,2 16,6 26,8 24,5 22,3 12,1 h 24 l 23,-2 29,-5 21,-5 36,-12 20,-8 20,-10 5,-3 -1,-3 -22,-11 -18,-11 -12,-9 -11,-9 -24,-22 -7,-8 -9,-10 -2,-5 9,-9 8,-7 9,-11 12,-17 4,-7 7,6 13,13 11,9 13,11 20,14 19,11 16,8 25,10 24,7 19,4 15,2 h 40 l 26,-3 22,-5 24,-8 21,-9 19,-10 19,-12 17,-12 14,-12 9,-9 h 2 l 2,-4 8,-8 15,-20 12,-19 13,-24 11,-27 10,-34 6,-31 2,-16 v -52 l -4,-27 -6,-24 -10,-30 -9,-21 -11,-20 -13,-21 -13,-17 -12,-14 -17,-17 -11,-9 -13,-11 -18,-13 -22,-13 -25,-12 -1,-3 10,-18 13,-21 z"
|
||||
id="path2"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 658,427 19,1 26,2 4,2 20,4 43,13 2,1 -1,4 -12,10 -14,11 -11,10 -8,7 -10,9 -16,15 -2,2 -17,-1 h -34 l -15,3 -13,5 -13,7 -9,8 -6,10 -3,9 v 11 l 4,12 9,10 12,7 20,8 32,8 43,10 25,7 18,6 20,9 14,8 13,10 9,8 9,11 9,13 8,18 4,15 2,10 1,13 v 13 l -2,18 -4,18 -6,16 -6,11 -10,13 -12,13 -14,11 -17,10 -21,9 -25,9 -25,5 -18,2 -20,1 h -23 l -24,-2 -38,-6 -48,-16 -23,-10 -17,-10 -24,-16 1,-4 41,-82 8,6 17,12 18,10 16,8 21,8 27,7 24,4 h 35 l 19,-3 16,-5 10,-5 8,-7 6,-12 2,-8 v -8 l -3,-12 -6,-10 -9,-8 -16,-8 -26,-8 -43,-10 -42,-12 -22,-8 -25,-12 -15,-10 -11,-9 -10,-10 -9,-14 -7,-15 -5,-16 -2,-10 -1,-13 v -12 l 3,-22 6,-20 7,-14 12,-17 9,-11 8,-8 17,-13 21,-12 24,-10 22,-6 23,-4 z"
|
||||
id="path3"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418890"
|
||||
d="m 775,90 2,1 12,36 14,41 12,35 23,4 31,5 24,7 36,12 23,10 32,16 20,12 15,10 -5,5 -16,10 -23,15 -18,11 -6,-1 -22,-12 -19,-8 -27,-9 -37,-10 -21,-4 -13,-2 -14,-1 h -47 l -27,3 -30,6 -31,9 -25,9 -24,11 -19,10 -19,12 -19,14 -13,11 -12,11 -28,28 -11,14 -15,20 -11,17 -14,24 -13,25 -11,29 -9,29 -8,36 -3,25 -1,14 v 55 l 3,27 6,31 4,19 v 4 l -16,12 -35,23 -24,15 -42,28 -46,30 -5,2 -6,-5 1,-7 6,-10 9,-11 15,-16 22,-22 8,-7 24,-24 8,-7 12,-12 -1,-9 -4,-21 -3,-27 -5,-5 -21,-11 -27,-11 -28,-11 -21,-9 -5,-2 v -2 l 17,-8 64,-25 22,-9 3,-5 7,-39 10,-40 15,-43 9,-20 13,-25 14,-24 13,-21 12,-16 7,-8 -1,-7 -11,-24 -17,-39 -5,-10 -3,-4 1,-2 11,5 39,16 27,13 3,3 4,-2 14,-12 20,-15 29,-19 22,-13 23,-12 15,-6 27,-10 31,-9 26,-6 34,-6 4,-3 12,-36 13,-37 z"
|
||||
id="path4"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 1241,171 1,2 -5,9 -12,19 -12,21 -8,13 -11,19 -15,24 -12,20 -31,51 -9,15 -11,18 -8,14 -16,26 -9,15 -17,28 -11,19 -10,17 -13,21 -16,27 -8,13 -11,19 -9,15 -17,29 -10,16 -10,19 -15,24 -11,18 -13,23 -9,15 -4,8 -3,1 -1,-4 -2,-27 -4,-29 -6,-25 -9,-24 -9,-22 -7,-12 -9,-12 -4,-5 1,-4 8,-10 7,1 6,2 12,1 12,-3 8,-5 3,-1 v -2 h 2 l 7,-14 1,-5 v -9 l -4,-13 -8,-10 -12,-6 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 12,-7 z"
|
||||
id="path5"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#79b085"
|
||||
d="m 1228,180 4,1 -8,10 h -2 l -2,4 -14,15 -7,8 -31,33 -9,9 -7,8 -7,7 -7,8 -11,11 -7,8 -14,15 -67,67 -7,8 -14,15 -20,20 -7,8 -22,22 -7,8 -8,8 -7,8 -13,13 -7,8 -15,14 -4,4 -2,-1 -3,-5 h -8 l -7,-3 -6,-1 h -11 l -9,3 -9,7 -6,9 -3,7 -1,12 4,13 1,5 -10,10 -1,2 -4,-2 -24,-16 -21,-12 -21,-10 -19,-7 -22,-6 6,-7 16,-16 22,-18 18,-13 16,-11 18,-13 33,-22 16,-11 23,-16 11,-8 22,-14 17,-12 12,-8 35,-23 19,-12 17,-12 18,-12 20,-13 18,-11 23,-15 20,-12 17,-11 25,-16 22,-14 33,-22 z"
|
||||
id="path6"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#418790"
|
||||
d="m 1032,575 h 2 l 1,52 h 181 l 1,2 v 79 l -154,1 h -29 v 89 l -1,65 -8,1 h -87 l -1,-1 V 744 l 1,-14 51,-84 8,-14 12,-19 10,-17 11,-19 z"
|
||||
id="path7"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 478,900 4,1 24,10 33,12 1,3 -24,16 -26,17 -24,15 -19,12 -32,19 -21,13 -19,11 -28,16 -24,14 -24,13 -19,10 -27,14 -22,12 -30,15 -19,9 -22,12 -30,15 -28,13 -20,9 -13,5 -8,1 -2,-1 1,-4 13,-11 14,-11 16,-13 16,-12 13,-10 48,-32 25,-15 19,-12 25,-16 40,-25 26,-17 19,-12 17,-11 19,-12 36,-24 41,-28 24,-16 z"
|
||||
id="path10"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#13597b"
|
||||
d="m 577,931 7,1 16,4 24,3 50,3 1,2 -4,6 -7,7 -11,9 -14,13 -11,9 -16,13 -9,7 -15,12 -18,13 -17,12 -20,14 -29,19 -19,12 -24,15 -24,14 -14,6 h -6 l -5,-4 -2,-4 1,-7 6,-8 8,-8 11,-9 16,-13 13,-10 11,-9 15,-12 19,-14 2,-2 -1,-4 -15,-14 -3,-3 1,-4 10,-10 17,-13 16,-13 16,-12 15,-13 z"
|
||||
id="path11"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#78b085"
|
||||
d="m 1115,443 h 125 l 3,2 v 80 l -1,1 -45,1 h -98 l -37,-1 v -3 l 4,-5 16,-27 15,-26 4,-6 3,-6 5,-4 4,-5 z"
|
||||
id="path13"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408690"
|
||||
d="m 433,813 3,1 9,15 3,6 -2,4 1,10 1,9 -10,7 -28,19 -17,12 -15,10 -22,14 -17,12 -24,16 -13,6 h -8 l -8,-5 -4,-5 -2,-5 v -7 l 4,-10 9,-11 10,-10 20,-14 26,-17 22,-14 15,-10 19,-13 19,-14 z"
|
||||
id="path14"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#408790"
|
||||
d="m 233,945 h 7 l 6,5 2,5 -1,10 -9,10 -8,7 -18,12 -15,8 -10,3 h -11 l -8,-4 -3,-5 1,-7 4,-6 9,-9 10,-7 18,-10 23,-11 z"
|
||||
id="path20"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#0f4664"
|
||||
d="m 370,1125 h 10 l 6,5 2,5 v 8 l -4,6 -8,8 -13,8 -3,1 h -8 l -7,-6 -1,-2 v -7 l 4,-9 7,-9 11,-7 z"
|
||||
id="path32"
|
||||
style="display:inline" />
|
||||
<path
|
||||
fill="#3f8690"
|
||||
d="m 209,874 6,1 4,5 1,3 v 9 l -3,5 -7,7 -10,6 -3,1 h -7 l -6,-5 -1,-6 5,-10 6,-7 10,-7 z"
|
||||
id="path38"
|
||||
style="display:inline" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
@@ -1,231 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useQuizStore } from '@/stores/quizStore'
|
||||
|
||||
const quizStore = useQuizStore()
|
||||
|
||||
const showModal = ref(false)
|
||||
const currentQuestionIndex = ref(0)
|
||||
const selectedOption = ref(null)
|
||||
const showResult = ref(false)
|
||||
const isCorrect = ref(false)
|
||||
const resultExplanation = ref('')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const currentQuestion = computed(() => quizStore.questions[currentQuestionIndex.value])
|
||||
const totalQuestions = computed(() => quizStore.totalQuestions)
|
||||
const progress = computed(() => ((currentQuestionIndex.value + 1) / totalQuestions.value) * 100)
|
||||
|
||||
// Auto-show modal after 3-5 seconds if canPlayToday
|
||||
onMounted(() => {
|
||||
if (quizStore.canPlayToday) {
|
||||
setTimeout(() => {
|
||||
openModal()
|
||||
}, 3500) // 3.5 seconds
|
||||
}
|
||||
})
|
||||
|
||||
async function openModal() {
|
||||
if (!quizStore.canPlayToday) {
|
||||
alert('Már játszottál ma! Holnap próbáld újra.')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
// Fetch daily quiz questions from API
|
||||
await quizStore.fetchDailyQuiz()
|
||||
resetQuiz()
|
||||
showModal.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to load daily quiz:', error)
|
||||
alert('Hiba történt a kvíz betöltése közben. Próbáld újra később.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
function resetQuiz() {
|
||||
currentQuestionIndex.value = 0
|
||||
selectedOption.value = null
|
||||
showResult.value = false
|
||||
isCorrect.value = false
|
||||
resultExplanation.value = ''
|
||||
}
|
||||
|
||||
async function selectOption(optionIndex) {
|
||||
if (showResult.value) return
|
||||
selectedOption.value = optionIndex
|
||||
|
||||
try {
|
||||
const result = await quizStore.answerQuestion(currentQuestion.value.id, optionIndex)
|
||||
isCorrect.value = result.is_correct
|
||||
resultExplanation.value = result.explanation
|
||||
showResult.value = true
|
||||
} catch (error) {
|
||||
console.error('Failed to submit answer:', error)
|
||||
alert('Hiba történt a válasz beküldése közben.')
|
||||
}
|
||||
}
|
||||
|
||||
function nextQuestion() {
|
||||
if (currentQuestionIndex.value < totalQuestions.value - 1) {
|
||||
currentQuestionIndex.value++
|
||||
selectedOption.value = null
|
||||
showResult.value = false
|
||||
} else {
|
||||
finishQuiz()
|
||||
}
|
||||
}
|
||||
|
||||
async function finishQuiz() {
|
||||
try {
|
||||
await quizStore.completeDailyQuiz()
|
||||
showModal.value = false
|
||||
alert(`Kvíz befejezve! Szerezttél ${quizStore.userPoints} pontot. Streak: ${quizStore.currentStreak}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to complete quiz:', error)
|
||||
alert('Hiba történt a kvíz befejezése közben.')
|
||||
}
|
||||
}
|
||||
|
||||
function skipToday() {
|
||||
quizStore.completeDailyQuiz() // mark as played today
|
||||
closeModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showModal" class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="relative w-full max-w-2xl rounded-2xl bg-gradient-to-br from-blue-50 to-white shadow-2xl p-6 md:p-8 border border-blue-200">
|
||||
<!-- Close button -->
|
||||
<button @click="closeModal" class="absolute top-4 right-4 text-gray-500 hover:text-gray-800 text-2xl">
|
||||
×
|
||||
</button>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-r from-blue-500 to-purple-500 mb-4">
|
||||
<span class="text-3xl">🧠</span>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-900">Napi Kvíz</h2>
|
||||
<p class="text-gray-600 mt-2">Teszteld tudásod és szerezz pontokat!</p>
|
||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-700">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">Pontok:</span>
|
||||
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full">{{ quizStore.userPoints }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-bold">Streak:</span>
|
||||
<span class="bg-amber-100 text-amber-800 px-3 py-1 rounded-full">{{ quizStore.currentStreak }} nap</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between text-sm text-gray-700 mb-2">
|
||||
<span>Kérdés {{ currentQuestionIndex + 1 }} / {{ totalQuestions }}</span>
|
||||
<span>{{ Math.round(progress) }}%</span>
|
||||
</div>
|
||||
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-6">{{ currentQuestion.question }}</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
v-for="(option, idx) in currentQuestion.options"
|
||||
:key="idx"
|
||||
@click="selectOption(idx)"
|
||||
class="p-4 text-left rounded-xl border-2 transition-all duration-300"
|
||||
:class="{
|
||||
'border-blue-500 bg-blue-50': selectedOption === idx,
|
||||
'border-gray-300 hover:border-blue-400 hover:bg-blue-50': selectedOption === null && !showResult,
|
||||
'border-green-500 bg-green-50': showResult && idx === currentQuestion.correctAnswer,
|
||||
'border-red-300 bg-red-50': showResult && selectedOption === idx && !isCorrect,
|
||||
}"
|
||||
:disabled="showResult"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center mr-3"
|
||||
:class="{
|
||||
'bg-blue-100 text-blue-800': selectedOption === idx && !showResult,
|
||||
'bg-green-100 text-green-800': showResult && idx === currentQuestion.correctAnswer,
|
||||
'bg-red-100 text-red-800': showResult && selectedOption === idx && !isCorrect,
|
||||
'bg-gray-100 text-gray-800': selectedOption !== idx && !showResult,
|
||||
}">
|
||||
{{ String.fromCharCode(65 + idx) }}
|
||||
</div>
|
||||
<span class="font-medium">{{ option }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result & Explanation -->
|
||||
<div v-if="showResult" class="mb-8 p-5 rounded-xl" :class="isCorrect ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center" :class="isCorrect ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'">
|
||||
{{ isCorrect ? '✅' : '❌' }}
|
||||
</div>
|
||||
<h4 class="text-xl font-bold" :class="isCorrect ? 'text-green-800' : 'text-red-800'">
|
||||
{{ isCorrect ? 'Helyes válasz!' : 'Sajnos nem talált!' }}
|
||||
</h4>
|
||||
</div>
|
||||
<p class="text-gray-800">{{ resultExplanation }}</p>
|
||||
<div class="mt-4 text-sm text-gray-700">
|
||||
<span class="font-bold">Pontok:</span> {{ isCorrect ? '+10' : '0' }} |
|
||||
<span class="font-bold">Streak:</span> {{ isCorrect ? 'növelve' : 'nullázva' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<button
|
||||
v-if="!showResult"
|
||||
@click="skipToday"
|
||||
class="flex-1 py-3 px-6 rounded-xl border-2 border-gray-300 text-gray-700 font-semibold hover:bg-gray-100 transition"
|
||||
>
|
||||
Emlékeztess később
|
||||
</button>
|
||||
<button
|
||||
v-if="showResult && currentQuestionIndex < totalQuestions - 1"
|
||||
@click="nextQuestion"
|
||||
class="flex-1 py-3 px-6 rounded-xl bg-gradient-to-r from-blue-500 to-purple-500 text-white font-bold hover:opacity-90 transition"
|
||||
>
|
||||
Következő kérdés
|
||||
</button>
|
||||
<button
|
||||
v-if="showResult && currentQuestionIndex === totalQuestions - 1"
|
||||
@click="finishQuiz"
|
||||
class="flex-1 py-3 px-6 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 text-white font-bold hover:opacity-90 transition"
|
||||
>
|
||||
Kvíz befejezése
|
||||
</button>
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="flex-1 py-3 px-6 rounded-xl bg-gray-200 text-gray-800 font-semibold hover:bg-gray-300 transition"
|
||||
>
|
||||
Bezárás
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer note -->
|
||||
<div class="mt-8 text-center text-sm text-gray-500">
|
||||
A napi kvíz csak egyszer játszható 24 óránként. Streaked növeléséhez válaszolj helyesen minden nap!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
624
frontend/src/components/LoginModal.vue
Normal file
624
frontend/src/components/LoginModal.vue
Normal file
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<Transition name="slide-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center px-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<!-- Dark Blurred Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-[#062535]/80 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
></div>
|
||||
|
||||
<!-- 3D Perspective Container -->
|
||||
<div class="perspective-1000 relative z-10 w-full max-w-md">
|
||||
<!-- Flippable Card Body -->
|
||||
<div
|
||||
class="relative transition-transform duration-700 transform-style-3d"
|
||||
:class="{
|
||||
'rotate-y-180': authMode === 'register',
|
||||
'rotate-x-180': authMode === 'forgot'
|
||||
}"
|
||||
>
|
||||
<!-- ==================== FRONT FACE: LOGIN ==================== -->
|
||||
<form
|
||||
@submit.prevent="handleLogin"
|
||||
class="backface-hidden w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
|
||||
:class="{ 'pointer-events-none': authMode !== 'login' }"
|
||||
>
|
||||
<!-- Close Button (X) -->
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="absolute right-4 top-4 text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
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="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-8 text-center text-2xl font-bold text-white">
|
||||
Belépés a garázsba
|
||||
</h2>
|
||||
|
||||
<!-- Email Input -->
|
||||
<div class="mb-5">
|
||||
<label for="login-email" class="mb-2 block text-sm font-medium text-white/80">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="login-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="pelda@email.com"
|
||||
autocomplete="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div class="mb-6 relative">
|
||||
<label for="login-password" class="mb-2 block text-sm font-medium text-white/80">
|
||||
Jelszó
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="••••••••"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 pr-12 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"
|
||||
/>
|
||||
<!-- Eye icon (hold to show) -->
|
||||
<button
|
||||
type="button"
|
||||
@mousedown="showPassword = true"
|
||||
@mouseup="showPassword = false"
|
||||
@mouseleave="showPassword = false"
|
||||
@touchstart.prevent="showPassword = true"
|
||||
@touchend="showPassword = false"
|
||||
class="absolute right-3 top-[42px] text-white/40 hover:text-white/70 transition-colors cursor-pointer"
|
||||
tabindex="-1"
|
||||
aria-label="Jelszó megtekintése"
|
||||
>
|
||||
<!-- Eye open (password visible) -->
|
||||
<svg
|
||||
v-if="showPassword"
|
||||
class="h-5 w-5"
|
||||
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="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<path d="M1 1l22 22" />
|
||||
</svg>
|
||||
<!-- Eye closed (password hidden) -->
|
||||
<svg
|
||||
v-else
|
||||
class="h-5 w-5"
|
||||
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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Forgot Password Link (right-aligned, always visible) -->
|
||||
<div class="mb-6 text-right">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="switchMode('forgot')"
|
||||
class="text-xs text-[#75A882] hover:text-[#D4AF37] transition-colors"
|
||||
>
|
||||
Elfelejtetted a jelszavad?
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="authStore.error && authMode === 'login'"
|
||||
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
|
||||
>
|
||||
{{ authStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Login Button (Premium) -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.isLoading"
|
||||
class="btn-premium w-full px-4 py-3 text-lg"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Kérlek várj...' : 'Belépés' }}
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<span class="flex-1 border-t border-white/10"></span>
|
||||
<span class="text-sm text-white/40">vagy</span>
|
||||
<span class="flex-1 border-t border-white/10"></span>
|
||||
</div>
|
||||
|
||||
<!-- Social Login Placeholders -->
|
||||
<div class="mb-6 grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/50 transition-all duration-200 hover:border-white/20 hover:text-white/80"
|
||||
>
|
||||
<!-- Google Logo Placeholder -->
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/50 transition-all duration-200 hover:border-white/20 hover:text-white/80"
|
||||
>
|
||||
<!-- Apple Logo Placeholder -->
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
</svg>
|
||||
Apple
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Registration Link (Triggers Flip) -->
|
||||
<p class="text-center text-sm text-white/50">
|
||||
Még nincs garázsod?
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="switchMode('register')"
|
||||
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
|
||||
>
|
||||
Regisztráció
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- ==================== BACK FACE: REGISTER ==================== -->
|
||||
<form
|
||||
v-if="activeBackFace === 'register'"
|
||||
@submit.prevent="handleRegister"
|
||||
class="backface-hidden rotate-y-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
|
||||
>
|
||||
<!-- Close Button (X) -->
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="absolute right-4 top-4 text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
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="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-8 text-center text-2xl font-bold text-white">
|
||||
Új Garázs Nyitása
|
||||
</h2>
|
||||
|
||||
<!-- Last Name Input -->
|
||||
<div class="mb-5">
|
||||
<label for="reg-lastname" class="mb-2 block text-sm font-medium text-white/80">
|
||||
Vezetéknév
|
||||
</label>
|
||||
<input
|
||||
id="reg-lastname"
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
placeholder="Vezetéknév"
|
||||
autocomplete="family-name"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- First Name Input -->
|
||||
<div class="mb-5">
|
||||
<label for="reg-firstname" class="mb-2 block text-sm font-medium text-white/80">
|
||||
Keresztnév
|
||||
</label>
|
||||
<input
|
||||
id="reg-firstname"
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
placeholder="Keresztnév"
|
||||
autocomplete="given-name"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email Input -->
|
||||
<div class="mb-5">
|
||||
<label for="reg-email" class="mb-2 block text-sm font-medium text-white/80">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
v-model="regEmail"
|
||||
type="email"
|
||||
placeholder="pelda@email.com"
|
||||
autocomplete="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div class="mb-6 relative">
|
||||
<label for="reg-password" class="mb-2 block text-sm font-medium text-white/80">
|
||||
Jelszó
|
||||
</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
v-model="regPassword"
|
||||
:type="showRegPassword ? 'text' : 'password'"
|
||||
placeholder="••••••••"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 pr-12 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"
|
||||
/>
|
||||
<!-- Eye icon (hold to show) -->
|
||||
<button
|
||||
type="button"
|
||||
@mousedown="showRegPassword = true"
|
||||
@mouseup="showRegPassword = false"
|
||||
@mouseleave="showRegPassword = false"
|
||||
@touchstart.prevent="showRegPassword = true"
|
||||
@touchend="showRegPassword = false"
|
||||
class="absolute right-3 top-[42px] text-white/40 hover:text-white/70 transition-colors cursor-pointer"
|
||||
tabindex="-1"
|
||||
aria-label="Jelszó megtekintése"
|
||||
>
|
||||
<!-- Eye open (password visible) -->
|
||||
<svg
|
||||
v-if="showRegPassword"
|
||||
class="h-5 w-5"
|
||||
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="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
|
||||
<path d="M1 1l22 22" />
|
||||
</svg>
|
||||
<!-- Eye closed (password hidden) -->
|
||||
<svg
|
||||
v-else
|
||||
class="h-5 w-5"
|
||||
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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="authStore.error && authMode === 'register'"
|
||||
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
|
||||
>
|
||||
{{ authStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Register Button (Premium) -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.isLoading"
|
||||
class="btn-premium w-full px-4 py-3 text-lg"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Kérlek várj...' : 'Regisztráció' }}
|
||||
</button>
|
||||
|
||||
<!-- Switch back to Login -->
|
||||
<p class="mt-6 text-center text-sm text-white/50">
|
||||
Már van garázsod?
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="switchMode('login')"
|
||||
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
|
||||
>
|
||||
Belépés
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<!-- ==================== THIRD FACE: FORGOT PASSWORD ==================== -->
|
||||
<form
|
||||
v-if="activeBackFace === 'forgot'"
|
||||
@submit.prevent="handleForgotPassword"
|
||||
class="backface-hidden rotate-x-180 absolute inset-0 w-full rounded-2xl border border-[#75A882]/30 bg-[#062535] p-8 shadow-2xl"
|
||||
>
|
||||
<!-- Close Button (X) -->
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="absolute right-4 top-4 text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
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="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Title -->
|
||||
<h2 class="mb-6 text-center text-2xl font-bold text-white">
|
||||
Új jelszó igénylése
|
||||
</h2>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mb-6 rounded-xl bg-[#1A6B5A]/20 px-4 py-3 text-center text-sm text-[#5EC4B0]">
|
||||
Add meg az e-mail címed, és küldünk egy biztonsági linket.
|
||||
</p>
|
||||
|
||||
<!-- Email Input -->
|
||||
<div class="mb-6">
|
||||
<label for="forgot-email" class="mb-2 block text-sm font-medium text-white/80">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
id="forgot-email"
|
||||
v-model="forgotEmail"
|
||||
type="email"
|
||||
placeholder="pelda@email.com"
|
||||
autocomplete="email"
|
||||
required
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="authStore.error && authMode === 'forgot'"
|
||||
class="mb-4 rounded-xl bg-red-500/20 px-4 py-3 text-sm text-red-300"
|
||||
>
|
||||
{{ authStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Send Link Button (Premium) -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="authStore.isLoading"
|
||||
class="btn-premium w-full px-4 py-3 text-lg"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Kérlek várj...' : 'Link Küldése' }}
|
||||
</button>
|
||||
|
||||
<!-- Back to Login -->
|
||||
<p class="mt-6 text-center text-sm text-white/50">
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="switchMode('login')"
|
||||
class="font-semibold text-[#D4AF37] transition-colors hover:text-[#D4AF37]/80"
|
||||
>
|
||||
Vissza a belépéshez
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Login form fields ──
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
|
||||
// ── Register form fields ──
|
||||
const lastName = ref('')
|
||||
const firstName = ref('')
|
||||
const regEmail = ref('')
|
||||
const regPassword = ref('')
|
||||
|
||||
// ── Forgot password form fields ──
|
||||
const forgotEmail = ref('')
|
||||
|
||||
// ── Password visibility states (hold-to-show) ──
|
||||
const showPassword = ref(false)
|
||||
const showRegPassword = ref(false)
|
||||
|
||||
// ── 3D Flip state machine ──
|
||||
const authMode = ref<'login' | 'register' | 'forgot'>('login')
|
||||
const activeBackFace = ref<'register' | 'forgot'>('register')
|
||||
|
||||
// ── Reset modal state when it closes ──
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
// Reset view to login
|
||||
authMode.value = 'login'
|
||||
activeBackFace.value = 'register'
|
||||
// Clear all form fields
|
||||
email.value = ''
|
||||
password.value = ''
|
||||
lastName.value = ''
|
||||
firstName.value = ''
|
||||
regEmail.value = ''
|
||||
regPassword.value = ''
|
||||
forgotEmail.value = ''
|
||||
// Clear store error
|
||||
authStore.clearError()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Clear store error when switching modes ──
|
||||
watch(authMode, () => {
|
||||
authStore.clearError()
|
||||
})
|
||||
|
||||
function switchMode(mode: 'login' | 'register' | 'forgot') {
|
||||
if (mode !== 'login') {
|
||||
activeBackFace.value = mode
|
||||
}
|
||||
authMode.value = mode
|
||||
}
|
||||
|
||||
// ── Smart Login Logic ──
|
||||
async function handleLogin() {
|
||||
try {
|
||||
await authStore.login(email.value, password.value)
|
||||
|
||||
// After successful login, check KYC status
|
||||
if (authStore.isKycComplete) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push('/complete-kyc')
|
||||
}
|
||||
|
||||
// Close the modal
|
||||
emit('close')
|
||||
} catch {
|
||||
// Error is already set in authStore.error — UI displays it automatically
|
||||
}
|
||||
}
|
||||
|
||||
// ── Register Logic ──
|
||||
async function handleRegister() {
|
||||
try {
|
||||
await authStore.register({
|
||||
email: regEmail.value,
|
||||
password: regPassword.value,
|
||||
first_name: firstName.value,
|
||||
last_name: lastName.value,
|
||||
})
|
||||
|
||||
// Registration successful — flip back to login so user can sign in
|
||||
switchMode('login')
|
||||
// Pre-fill the email field
|
||||
email.value = regEmail.value
|
||||
// Clear register fields
|
||||
lastName.value = ''
|
||||
firstName.value = ''
|
||||
regEmail.value = ''
|
||||
regPassword.value = ''
|
||||
} catch {
|
||||
// Error is already set in authStore.error
|
||||
}
|
||||
}
|
||||
|
||||
// ── Forgot Password Logic ──
|
||||
async function handleForgotPassword() {
|
||||
try {
|
||||
await authStore.forgotPassword(forgotEmail.value)
|
||||
|
||||
// Success — flip back to login
|
||||
switchMode('login')
|
||||
forgotEmail.value = ''
|
||||
} catch {
|
||||
// Error is already set in authStore.error
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Slide-Fade Transition: enters from top-right, fades in — slower & more elegant */
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
.slide-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(48px) translateY(-48px) scale(0.95);
|
||||
}
|
||||
.slide-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(48px) translateY(-48px) scale(0.95);
|
||||
}
|
||||
|
||||
/* 3D Card Flip Utilities */
|
||||
.perspective-1000 {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.transform-style-3d {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.backface-hidden {
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.rotate-x-180 {
|
||||
transform: rotateX(180deg);
|
||||
}
|
||||
</style>
|
||||
66
frontend/src/components/Logo.vue
Normal file
66
frontend/src/components/Logo.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 relative -mb-8 z-50 group cursor-pointer">
|
||||
|
||||
<svg
|
||||
viewBox="0 0 240 240"
|
||||
class="w-24 h-24 drop-shadow-[0_10px_20px_rgba(0,0,0,0.5)] transition-transform duration-500 group-hover:scale-105"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sf-green" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#418890" />
|
||||
<stop offset="100%" stop-color="#79B085" />
|
||||
</linearGradient>
|
||||
<linearGradient id="sf-white" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="100%" stop-color="#D1D5DB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M 80 180 L 30 210" stroke="url(#sf-green)" stroke-width="8" stroke-linecap="round"/>
|
||||
<circle cx="15" cy="219" r="4" fill="url(#sf-green)"/>
|
||||
|
||||
<path d="M 110 205 L 40 245" stroke="url(#sf-green)" stroke-width="12" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="255" r="6" fill="url(#sf-green)"/>
|
||||
|
||||
<path d="M 140 225 L 90 255" stroke="url(#sf-green)" stroke-width="6" stroke-linecap="round"/>
|
||||
<circle cx="75" cy="264" r="3" fill="url(#sf-green)"/>
|
||||
|
||||
<polygon points="120,30 108,50 132,50" fill="url(#sf-white)"/>
|
||||
<polygon points="120,210 108,190 132,190" fill="url(#sf-white)"/>
|
||||
<polygon points="30,120 50,108 50,132" fill="url(#sf-white)"/>
|
||||
<polygon points="65,65 85,78 70,88" fill="url(#sf-white)"/>
|
||||
|
||||
<circle cx="120" cy="120" r="70" stroke="url(#sf-white)" stroke-width="14"/>
|
||||
|
||||
<text x="90" y="155" font-family="Arial, Helvetica, sans-serif" font-weight="900" font-size="95" text-anchor="middle" fill="url(#sf-white)">S</text>
|
||||
<text x="155" y="155" font-family="Arial, Helvetica, sans-serif" font-weight="900" font-size="95" text-anchor="middle" fill="url(#sf-green)">F</text>
|
||||
|
||||
<path d="M 170 170 L 215 215" stroke="url(#sf-white)" stroke-width="26" stroke-linecap="round"/>
|
||||
<path d="M 180 180 L 210 210" stroke="#062535" stroke-width="6" stroke-linecap="round"/>
|
||||
|
||||
<circle cx="125" cy="110" r="15" fill="url(#sf-white)"/>
|
||||
<circle cx="125" cy="110" r="6" fill="#062535"/>
|
||||
|
||||
<polygon points="215,25 125,110 95,70" fill="#2C5A63"/>
|
||||
|
||||
<polygon points="215,25 125,110 165,140" fill="url(#sf-green)"/>
|
||||
|
||||
</svg>
|
||||
|
||||
<div class="hidden sm:flex flex-col justify-center mt-3">
|
||||
<div class="font-black tracking-widest text-3xl leading-none drop-shadow-md">
|
||||
<span class="text-white">SERVICE</span> <span class="text-[#75A882]">FINDER</span>
|
||||
</div>
|
||||
<div class="text-[#75A882] text-[0.65rem] tracking-[0.25em] font-bold mt-2 uppercase opacity-90">
|
||||
Online Járműnyilvántartó & Szervizkereső
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Tiszta, újrahasználható, kézzel optimalizált SVG logó.
|
||||
</script>
|
||||
@@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<h1 class="text-4xl font-bold text-center mb-4 text-slate-900">Welcome to Service Finder</h1>
|
||||
<p class="text-lg text-slate-600 text-center mb-12 max-w-2xl mx-auto">
|
||||
Choose your experience based on how you use vehicles. Your selection will customize the dashboard and features.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
|
||||
<!-- Private Garage Card - Vibrant gradient border & playful -->
|
||||
<div
|
||||
class="relative rounded-3xl p-8 cursor-pointer transition-all duration-300 hover:scale-[1.02] active:scale-95 bg-gradient-to-br from-white to-slate-50/80 backdrop-blur-sm border border-slate-200/60"
|
||||
:class="{
|
||||
'selected shadow-2xl ring-4 ring-opacity-30 ring-amber-400/50': isPrivateGarage,
|
||||
'private-garage': true
|
||||
}"
|
||||
@click="selectMode('private_garage')"
|
||||
>
|
||||
<!-- Vibrant gradient border effect -->
|
||||
<div v-if="isPrivateGarage" class="absolute -inset-0.5 bg-gradient-to-r from-amber-400 via-orange-400 to-pink-400 rounded-3xl blur-sm opacity-70 -z-10"></div>
|
||||
|
||||
<div class="mb-6 p-4 rounded-2xl inline-flex bg-gradient-to-br from-amber-100 to-orange-100 text-amber-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-4 text-slate-900">Private Garage</h2>
|
||||
<p class="text-slate-700 mb-6 leading-relaxed">
|
||||
Perfect for individual vehicle owners. Track expenses, maintenance, and get personalized recommendations for your personal cars, motorcycles, or recreational vehicles.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-3 card-features-private">
|
||||
<li class="flex items-center text-sm text-slate-700">Personal vehicle management</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Expense tracking & budgeting</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Maintenance reminders</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Fuel efficiency analytics</li>
|
||||
</ul>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-amber-100 to-orange-100 text-amber-800 border border-amber-200/60">For Individuals</span>
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-lg font-medium transition-all duration-200 bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 active:scale-95 shadow-md hover:shadow-lg"
|
||||
:class="{ 'ring-2 ring-amber-300 ring-offset-2': isPrivateGarage }"
|
||||
>
|
||||
{{ isPrivateGarage ? '✓ Selected' : 'Select' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Corporate Fleet Card - Minimalist sharp design -->
|
||||
<div
|
||||
class="relative rounded-3xl p-8 cursor-pointer transition-all duration-300 hover:scale-[1.02] active:scale-95 bg-gradient-to-br from-white to-slate-50/80 backdrop-blur-sm border border-slate-200/60"
|
||||
:class="{
|
||||
'selected shadow-2xl ring-4 ring-opacity-30 ring-blue-400/50': isCorporateFleet,
|
||||
'corporate-fleet': true
|
||||
}"
|
||||
@click="selectMode('corporate_fleet')"
|
||||
>
|
||||
<!-- Sharp business accent line -->
|
||||
<div v-if="isCorporateFleet" class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-t-3xl"></div>
|
||||
|
||||
<div class="mb-6 p-4 rounded-2xl inline-flex bg-gradient-to-br from-blue-50 to-cyan-50 text-blue-700 border border-blue-200/40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-4 text-slate-900">Corporate Fleet</h2>
|
||||
<p class="text-slate-700 mb-6 leading-relaxed">
|
||||
Designed for fleet managers and businesses. Monitor multiple vehicles, optimize TCO (Total Cost of Ownership), and manage service schedules across your entire fleet.
|
||||
</p>
|
||||
<ul class="mb-8 space-y-3 card-features-corporate">
|
||||
<li class="flex items-center text-sm text-slate-700">Multi-vehicle fleet management</li>
|
||||
<li class="flex items-center text-sm text-slate-700">TCO & ROI analytics</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Driver assignment & reporting</li>
|
||||
<li class="flex items-center text-sm text-slate-700">Bulk service scheduling</li>
|
||||
</ul>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="px-3 py-1.5 rounded-full text-xs font-semibold bg-gradient-to-r from-blue-50 to-cyan-50 text-blue-800 border border-blue-200/60">For Businesses</span>
|
||||
<button
|
||||
class="px-6 py-2.5 rounded-lg font-medium transition-all duration-200 bg-gradient-to-r from-blue-600 to-cyan-600 text-white hover:from-blue-700 hover:to-cyan-700 active:scale-95 shadow-md hover:shadow-lg"
|
||||
:class="{ 'ring-2 ring-blue-300 ring-offset-2': isCorporateFleet }"
|
||||
>
|
||||
{{ isCorporateFleet ? '✓ Selected' : 'Select' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row justify-between items-center p-6 border-t border-slate-200/60">
|
||||
<p class="text-sm text-slate-500">
|
||||
You can change this later from the header menu.
|
||||
</p>
|
||||
<button
|
||||
class="mt-4 md:mt-0 px-8 py-3.5 bg-gradient-to-r from-blue-600 to-cyan-600 text-white font-semibold rounded-xl hover:from-blue-700 hover:to-cyan-700 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center shadow-lg hover:shadow-xl active:scale-95"
|
||||
@click="continueToDashboard"
|
||||
:disabled="!mode"
|
||||
>
|
||||
Continue to Dashboard
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ml-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10.293 5.293a1 1 0 011.414 0l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L12.586 11H5a1 1 0 110-2h7.586l-2.293-2.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { mode, isPrivateGarage, isCorporateFleet } = storeToRefs(appModeStore)
|
||||
|
||||
function selectMode(newMode) {
|
||||
appModeStore.setMode(newMode)
|
||||
}
|
||||
|
||||
function continueToDashboard() {
|
||||
console.log('ProfileSelector: Continuing to dashboard with mode', mode.value)
|
||||
try {
|
||||
router.push('/')
|
||||
console.log('ProfileSelector: Redirect to dashboard successful')
|
||||
} catch (error) {
|
||||
console.error('ProfileSelector: Failed to redirect to dashboard:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Keep only pseudo-element and media query styles */
|
||||
.card-features-private li::before,
|
||||
.card-features-corporate li::before {
|
||||
content: '✓';
|
||||
margin-right: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-features-private li::before {
|
||||
color: #f59e0b; /* amber-500 */
|
||||
}
|
||||
|
||||
.card-features-corporate li::before {
|
||||
color: #06b6d4; /* cyan-500 */
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.rounded-3xl.p-8 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.flex.flex-col.md\:flex-row {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,245 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useExpenseStore } from '@/stores/expenseStore'
|
||||
import { useGarageStore } from '@/stores/garageStore'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const expenseStore = useExpenseStore()
|
||||
const garageStore = useGarageStore()
|
||||
|
||||
// Use selected vehicle from garage store, or default to first vehicle
|
||||
const selectedAssetId = ref(garageStore.selectedVehicle?.id || garageStore.vehicles[0]?.id || '')
|
||||
|
||||
const amount = ref('')
|
||||
const category = ref('fuel')
|
||||
const date = ref(new Date().toISOString().split('T')[0]) // today
|
||||
const description = ref('')
|
||||
const mileage = ref('')
|
||||
const isLoading = ref(false)
|
||||
const showDraftLimitModal = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedAssetId.value) {
|
||||
alert('Nincs kiválasztott jármű. Kérjük, először adj hozzá egy járművet.')
|
||||
return
|
||||
}
|
||||
if (!amount.value || !date.value) {
|
||||
alert('Kérjük, töltsd ki a kötelező mezőket.')
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const expenseData = {
|
||||
asset_id: selectedAssetId.value,
|
||||
cost_type: category.value, // fuel, service, tax, insurance
|
||||
amount_local: parseFloat(amount.value),
|
||||
currency_local: 'HUF', // default, could be dynamic
|
||||
date: new Date(date.value).toISOString(),
|
||||
description: description.value,
|
||||
mileage_at_cost: mileage.value ? parseInt(mileage.value) : null,
|
||||
data: {}
|
||||
}
|
||||
await expenseStore.createExpense(expenseData)
|
||||
|
||||
// Success
|
||||
alert('Költség sikeresen mentve!')
|
||||
|
||||
// Reset form
|
||||
amount.value = ''
|
||||
category.value = 'fuel'
|
||||
date.value = new Date().toISOString().split('T')[0]
|
||||
description.value = ''
|
||||
mileage.value = ''
|
||||
|
||||
// Close modal
|
||||
emit('close')
|
||||
} catch (error) {
|
||||
console.error('Error saving expense:', error)
|
||||
if (error.message === 'DRAFT_LIMIT_REACHED') {
|
||||
// Show custom modal for draft limit
|
||||
showDraftLimitModal.value = true
|
||||
} else {
|
||||
alert(`Hiba történt a mentés során: ${expenseStore.error || error.message}`)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const closeDraftLimitModal = () => {
|
||||
showDraftLimitModal.value = false
|
||||
}
|
||||
|
||||
const openEditVehicle = () => {
|
||||
// TODO: Implement opening edit vehicle modal
|
||||
// For now, just close the draft limit modal and maybe show a message
|
||||
alert('Edit vehicle functionality to be implemented')
|
||||
closeDraftLimitModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Költség / Üzemanyag hozzáadása</h2>
|
||||
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Asset selection (if multiple vehicles) -->
|
||||
<div v-if="garageStore.vehicles.length > 0">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Jármű</label>
|
||||
<select
|
||||
v-model="selectedAssetId"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option v-for="vehicle in garageStore.vehicles" :key="vehicle.id" :value="vehicle.id">
|
||||
{{ vehicle.name || vehicle.license_plate || vehicle.vin }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-else class="text-sm text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||
Nincs még járműved. Először adj hozzá egy járművet a "Jármű Hozzáadása" gombbal.
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Összeg (HUF)</label>
|
||||
<input
|
||||
v-model="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kategória</label>
|
||||
<select
|
||||
v-model="category"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option value="fuel">Üzemanyag</option>
|
||||
<option value="service">Szerviz / Karbantartás</option>
|
||||
<option value="tax">Adó / Díj</option>
|
||||
<option value="insurance">Biztosítás</option>
|
||||
<option value="parking">Parkolás</option>
|
||||
<option value="toll">Útdíj</option>
|
||||
<option value="other">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Dátum</label>
|
||||
<input
|
||||
v-model="date"
|
||||
type="date"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mileage (optional) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Kilométeróra állása (opcionális)</label>
|
||||
<input
|
||||
v-model="mileage"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="pl. 123456"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Leírás (opcionális)</label>
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="Pl.: Tankolás, olajcsere..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading || !selectedAssetId"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="isLoading">Mentés...</span>
|
||||
<span v-else>Mentés</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draft Limit Modal -->
|
||||
<div v-if="showDraftLimitModal" class="fixed inset-0 z-[200] flex items-center justify-center bg-black bg-opacity-70 p-4">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="text-center mb-6">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-yellow-100 text-yellow-600 mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-2">You have reached the limit for unregistered vehicles! 🔒</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Please edit this vehicle and provide the VIN (Chassis Number) or exact Catalog ID to unlock full expense tracking.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="closeDraftLimitModal"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="openEditVehicle"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
>
|
||||
Edit Vehicle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
@@ -1,404 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useGarageStore } from '../../stores/garageStore'
|
||||
import { useAuthStore } from '../../stores/authStore'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const garageStore = useGarageStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Form fields
|
||||
const make = ref('')
|
||||
const model = ref('')
|
||||
const generation = ref('')
|
||||
const engine = ref('')
|
||||
const engineId = ref(null) // NEW: Store the engine/catalog ID
|
||||
const licensePlate = ref('')
|
||||
const year = ref('')
|
||||
const fuelType = ref('petrol')
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Catalog data
|
||||
const makes = ref([])
|
||||
const models = ref([])
|
||||
const generations = ref([])
|
||||
const engines = ref([])
|
||||
|
||||
// Loading states
|
||||
const loadingMakes = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const loadingGenerations = ref(false)
|
||||
const loadingEngines = ref(false)
|
||||
|
||||
// Search filters
|
||||
const makeSearch = ref('')
|
||||
const modelSearch = ref('')
|
||||
const generationSearch = ref('')
|
||||
const engineSearch = ref('')
|
||||
|
||||
// Filtered lists based on search
|
||||
const filteredMakes = ref([])
|
||||
const filteredModels = ref([])
|
||||
const filteredGenerations = ref([])
|
||||
const filteredEngines = ref([])
|
||||
|
||||
// Fetch makes on component mount
|
||||
onMounted(async () => {
|
||||
await fetchMakes()
|
||||
})
|
||||
|
||||
// Watch for search changes
|
||||
watch(makeSearch, (val) => {
|
||||
filteredMakes.value = makes.value.filter(m =>
|
||||
m.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
watch(modelSearch, (val) => {
|
||||
filteredModels.value = models.value.filter(m =>
|
||||
m.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
watch(generationSearch, (val) => {
|
||||
filteredGenerations.value = generations.value.filter(g =>
|
||||
g.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
watch(engineSearch, (val) => {
|
||||
filteredEngines.value = engines.value.filter(e =>
|
||||
e.variant.toLowerCase().includes(val.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
// Watch for make selection to fetch models
|
||||
watch(make, async (newMake) => {
|
||||
if (newMake) {
|
||||
await fetchModels(newMake)
|
||||
model.value = ''
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
} else {
|
||||
models.value = []
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for model selection to fetch generations
|
||||
watch(model, async (newModel) => {
|
||||
if (newModel && make.value) {
|
||||
await fetchGenerations(make.value, newModel)
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
engines.value = []
|
||||
} else {
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for generation selection to fetch engines
|
||||
watch(generation, async (newGen) => {
|
||||
if (newGen && make.value && model.value) {
|
||||
await fetchEngines(make.value, model.value, newGen)
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
} else {
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for engine selection to auto-fill fuel type and capture ID
|
||||
watch(engine, (newEngine) => {
|
||||
if (newEngine) {
|
||||
const selectedEngine = engines.value.find(e => e.variant === newEngine)
|
||||
if (selectedEngine) {
|
||||
// Store the engine ID for API call
|
||||
engineId.value = selectedEngine.id
|
||||
// Auto-fill fuel type if available
|
||||
if (selectedEngine.fuel_type) {
|
||||
fuelType.value = selectedEngine.fuel_type.toLowerCase()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
engineId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// API calls
|
||||
async function fetchMakes() {
|
||||
loadingMakes.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/makes`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch makes: ${response.status}`)
|
||||
makes.value = await response.json()
|
||||
filteredMakes.value = makes.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching makes:', err)
|
||||
error.value = 'Could not load makes. Please try again.'
|
||||
} finally {
|
||||
loadingMakes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchModels(makeName) {
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/models?make=${encodeURIComponent(makeName)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch models: ${response.status}`)
|
||||
models.value = await response.json()
|
||||
filteredModels.value = models.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching models:', err)
|
||||
error.value = 'Could not load models for this make.'
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGenerations(makeName, modelName) {
|
||||
loadingGenerations.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/generations?make=${encodeURIComponent(makeName)}&model=${encodeURIComponent(modelName)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch generations: ${response.status}`)
|
||||
generations.value = await response.json()
|
||||
filteredGenerations.value = generations.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching generations:', err)
|
||||
error.value = 'Could not load generations for this model.'
|
||||
} finally {
|
||||
loadingGenerations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEngines(makeName, modelName, genName) {
|
||||
loadingEngines.value = true
|
||||
try {
|
||||
const token = authStore.token
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'
|
||||
const response = await fetch(`${apiBaseUrl}/catalog/engines?make=${encodeURIComponent(makeName)}&model=${encodeURIComponent(modelName)}&gen=${encodeURIComponent(genName)}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch engines: ${response.status}`)
|
||||
const engineData = await response.json()
|
||||
engines.value = engineData.map(e => ({
|
||||
variant: e.variant,
|
||||
fuel_type: e.fuel_type,
|
||||
id: e.id
|
||||
}))
|
||||
filteredEngines.value = engines.value
|
||||
} catch (err) {
|
||||
console.error('Error fetching engines:', err)
|
||||
error.value = 'Could not load engines for this generation.'
|
||||
} finally {
|
||||
loadingEngines.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Prepare vehicle data for the API
|
||||
const vehicleData = {
|
||||
make: make.value,
|
||||
model: model.value,
|
||||
licensePlate: licensePlate.value,
|
||||
year: parseInt(year.value) || new Date().getFullYear(),
|
||||
fuelType: fuelType.value,
|
||||
generation: generation.value,
|
||||
engine: engine.value,
|
||||
// Include the catalog ID (engine ID) for API
|
||||
catalogId: engineId.value,
|
||||
// Include organization ID (use active organization ID)
|
||||
organizationId: authStore.activeOrgId
|
||||
}
|
||||
|
||||
// Call the garage store to add vehicle
|
||||
await garageStore.addVehicle(vehicleData)
|
||||
|
||||
// Show success message
|
||||
alert('Sikeres mentés! Jármű hozzáadva.')
|
||||
|
||||
// Reset form
|
||||
make.value = ''
|
||||
model.value = ''
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
engineId.value = null
|
||||
licensePlate.value = ''
|
||||
year.value = ''
|
||||
fuelType.value = 'petrol'
|
||||
makes.value = []
|
||||
models.value = []
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
|
||||
// Close modal
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
console.error('Error adding vehicle:', err)
|
||||
error.value = err.message || 'Ismeretlen hiba történt'
|
||||
alert(`Hiba: ${error.value}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Jármű hozzáadása</h2>
|
||||
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Make -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Gyártó</label>
|
||||
<input
|
||||
v-model="make"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="Pl.: Toyota"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Model -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Modell</label>
|
||||
<input
|
||||
v-model="model"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="Pl.: Corolla"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- License Plate -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Rendszám</label>
|
||||
<input
|
||||
v-model="licensePlate"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="Pl.: ABC-123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Year and Fuel Type in a grid -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Year -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Évjárat</label>
|
||||
<input
|
||||
v-model="year"
|
||||
type="number"
|
||||
min="1900"
|
||||
:max="new Date().getFullYear() + 1"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900 placeholder-blue-700"
|
||||
placeholder="2023"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Üzemanyag típus</label>
|
||||
<select
|
||||
v-model="fuelType"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition text-blue-900"
|
||||
>
|
||||
<option value="petrol">Benzin</option>
|
||||
<option value="diesel">Dízel</option>
|
||||
<option value="electric">Elektromos</option>
|
||||
<option value="hybrid">Hibrid</option>
|
||||
<option value="lpg">LPG</option>
|
||||
<option value="other">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
:disabled="isLoading"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
class="flex-1 px-4 py-3 bg-purple-600 hover:bg-purple-700 text-white font-medium rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<span v-if="isLoading" class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-white border-r-transparent mr-2"></span>
|
||||
{{ isLoading ? 'Feldolgozás...' : 'Jármű hozzáadása' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const serviceType = ref('maintenance')
|
||||
const location = ref('')
|
||||
const urgency = ref('medium')
|
||||
|
||||
const handleSubmit = () => {
|
||||
// In a real app, you would call an API here
|
||||
console.log('Service search submitted:', {
|
||||
serviceType: serviceType.value,
|
||||
location: location.value,
|
||||
urgency: urgency.value
|
||||
})
|
||||
|
||||
// Show success message
|
||||
alert('Szerviz keresés elindítva! Hamarosan értesítünk.')
|
||||
|
||||
// Reset form
|
||||
serviceType.value = 'maintenance'
|
||||
location.value = ''
|
||||
urgency.value = 'medium'
|
||||
|
||||
// Close modal
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black bg-opacity-50 p-4" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-bold text-gray-900">Szerviz Keresése</h2>
|
||||
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6">
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<!-- Service Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Szerviz típusa</label>
|
||||
<select
|
||||
v-model="serviceType"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option value="maintenance">Általános karbantartás</option>
|
||||
<option value="repair">Javítás</option>
|
||||
<option value="diagnostic">Diagnosztika</option>
|
||||
<option value="tire">Gumiszerviz</option>
|
||||
<option value="oil">Olajcsere</option>
|
||||
<option value="brake">Fékrendszer</option>
|
||||
<option value="other">Egyéb</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Helyszín (város/irányítószám)</label>
|
||||
<input
|
||||
v-model="location"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="Pl.: Budapest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Urgency -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Sürgősség</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center">
|
||||
<input v-model="urgency" type="radio" value="low" class="mr-2">
|
||||
<span class="text-gray-700">Alacsony</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input v-model="urgency" type="radio" value="medium" class="mr-2">
|
||||
<span class="text-gray-700">Közepes</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input v-model="urgency" type="radio" value="high" class="mr-2">
|
||||
<span class="text-gray-700">Magas</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Részletes leírás (opcionális)</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
placeholder="Pl.: Motorhiba, féknyikorgás..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="flex-1 px-4 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Mégse
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex-1 px-4 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition"
|
||||
>
|
||||
Szerviz keresése
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Additional custom styles if needed */
|
||||
</style>
|
||||
@@ -1,115 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AddExpenseModal from './AddExpenseModal.vue'
|
||||
import SmartVehicleRegistration from './SmartVehicleRegistration.vue'
|
||||
import FindServiceModal from './FindServiceModal.vue'
|
||||
|
||||
const isOpen = ref(false)
|
||||
const showExpenseModal = ref(false)
|
||||
const showSmartVehicleRegistration = ref(false)
|
||||
const showServiceModal = ref(false)
|
||||
|
||||
const toggleMenu = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
const openExpenseModal = () => {
|
||||
showExpenseModal.value = true
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const openSmartVehicleRegistration = () => {
|
||||
showSmartVehicleRegistration.value = true
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const openServiceModal = () => {
|
||||
showServiceModal.value = true
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const closeExpenseModal = () => {
|
||||
showExpenseModal.value = false
|
||||
}
|
||||
|
||||
const closeSmartVehicleRegistration = () => {
|
||||
showSmartVehicleRegistration.value = false
|
||||
}
|
||||
|
||||
const closeServiceModal = () => {
|
||||
showServiceModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Floating Action Button -->
|
||||
<div class="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
||||
<!-- Action Menu (shown when open) -->
|
||||
<div v-if="isOpen" class="mb-4 space-y-3">
|
||||
<!-- Add Expense Button -->
|
||||
<button
|
||||
@click="openExpenseModal"
|
||||
class="flex items-center justify-end gap-3 bg-blue-600 hover:bg-blue-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span class="text-sm font-semibold">Költség / Üzemanyag</span>
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-blue-800 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Find Service Button -->
|
||||
<button
|
||||
@click="openServiceModal"
|
||||
class="flex items-center justify-end gap-3 bg-green-600 hover:bg-green-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span class="text-sm font-semibold">Szerviz Keresése</span>
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-green-800 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Add Vehicle Button -->
|
||||
<button
|
||||
@click="openSmartVehicleRegistration"
|
||||
class="flex items-center justify-end gap-3 bg-purple-600 hover:bg-purple-700 text-white px-4 py-3 rounded-full shadow-lg transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
<span class="text-sm font-semibold">Jármű Hozzáadása</span>
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-purple-800 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main FAB Button -->
|
||||
<button
|
||||
@click="toggleMenu"
|
||||
class="w-14 h-14 flex items-center justify-center bg-blue-700 hover:bg-blue-800 text-white rounded-full shadow-xl transition-all duration-200 transform hover:scale-110"
|
||||
:class="{ 'rotate-45': isOpen }"
|
||||
>
|
||||
<svg v-if="!isOpen" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<AddExpenseModal v-if="showExpenseModal" @close="closeExpenseModal" />
|
||||
<SmartVehicleRegistration v-if="showSmartVehicleRegistration" @close="closeSmartVehicleRegistration" />
|
||||
<FindServiceModal v-if="showServiceModal" @close="closeServiceModal" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Smooth transitions */
|
||||
button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
@@ -1,964 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useGarageStore } from '../../stores/garageStore'
|
||||
import { useAuthStore } from '../../stores/authStore'
|
||||
import { useAppModeStore } from '../../stores/appModeStore'
|
||||
import { catalogApi, organizationApi } from '../../services/api'
|
||||
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
|
||||
// Store instances
|
||||
const garageStore = useGarageStore()
|
||||
const authStore = useAuthStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
// Organization context
|
||||
const selectedOrganizationId = ref(null)
|
||||
const userOrganizations = ref([])
|
||||
const loadingOrganizations = ref(false)
|
||||
|
||||
// Owner vs Operator toggle
|
||||
const ownershipType = ref('owner') // 'owner' or 'operator'
|
||||
|
||||
// Wizard steps - now 4 steps total (0: Organization, 1: Classification, 2: Catalog, 3: Details)
|
||||
const currentStep = ref(0)
|
||||
const totalSteps = 4
|
||||
|
||||
// Check if organization step is needed
|
||||
const needsOrganizationSelection = computed(() => {
|
||||
// Always show in fleet mode, or if user has multiple organizations
|
||||
return isFleetMode.value || (userOrganizations.value && userOrganizations.value.length > 1)
|
||||
})
|
||||
|
||||
// Step 0: Organization Selection - Categorized
|
||||
const categorizedOrganizations = computed(() => {
|
||||
const privateOrgs = []
|
||||
const businessOrgs = []
|
||||
|
||||
if (userOrganizations.value && userOrganizations.value.length > 0) {
|
||||
userOrganizations.value.forEach(org => {
|
||||
// Check if organization has tax_number or org_type is not 'individual'
|
||||
// Note: org_type values: 'individual', 'company', 'non_profit', 'government'
|
||||
const isBusiness = org.tax_number || (org.org_type && org.org_type !== 'individual')
|
||||
|
||||
if (isBusiness) {
|
||||
businessOrgs.push({
|
||||
id: org.organization_id,
|
||||
name: org.display_name || org.name,
|
||||
description: org.full_name || 'Céges flotta',
|
||||
type: 'business',
|
||||
originalOrg: org
|
||||
})
|
||||
} else {
|
||||
privateOrgs.push({
|
||||
id: org.organization_id,
|
||||
name: 'Személyes Garázsom',
|
||||
description: 'Személyes járművek',
|
||||
type: 'private',
|
||||
originalOrg: org
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { privateOrgs, businessOrgs }
|
||||
})
|
||||
|
||||
// Backward compatibility - flat list of all organizations
|
||||
const organizationOptions = computed(() => {
|
||||
const options = []
|
||||
const { privateOrgs, businessOrgs } = categorizedOrganizations.value
|
||||
|
||||
// Add private organizations first
|
||||
privateOrgs.forEach(org => {
|
||||
options.push(org)
|
||||
})
|
||||
|
||||
// Add business organizations
|
||||
businessOrgs.forEach(org => {
|
||||
options.push(org)
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// Step 1: Classification
|
||||
const vehicleClass = ref('')
|
||||
const vehicleClasses = ref([
|
||||
{ value: 'passenger_car', label: 'Személygépkocsi', icon: '🚗' },
|
||||
{ value: 'motorcycle', label: 'Motorkerékpár', icon: '🏍️' },
|
||||
{ value: 'commercial_vehicle', label: 'Tehergépkocsi', icon: '🚚' },
|
||||
{ value: 'bus', label: 'Busz', icon: '🚌' },
|
||||
{ value: 'special_vehicle', label: 'Különleges jármű', icon: '🚜' }
|
||||
])
|
||||
|
||||
// Step 2: Catalog Auto-Complete
|
||||
const make = ref('')
|
||||
const model = ref('')
|
||||
const generation = ref('')
|
||||
const engine = ref('')
|
||||
const catalogId = ref(null)
|
||||
|
||||
// Catalog data
|
||||
const makes = ref([])
|
||||
const models = ref([])
|
||||
const generations = ref([])
|
||||
const engines = ref([])
|
||||
|
||||
// Loading states
|
||||
const loadingMakes = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const loadingGenerations = ref(false)
|
||||
const loadingEngines = ref(false)
|
||||
|
||||
// Step 3: Unique Details
|
||||
const licensePlate = ref('')
|
||||
const vin = ref('')
|
||||
const currentMileage = ref('')
|
||||
const color = ref('')
|
||||
|
||||
// Form state
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
const successMessage = ref('')
|
||||
|
||||
// Progress indicator
|
||||
const progressPercentage = ref(0)
|
||||
|
||||
// UI Mode styling
|
||||
const uiMode = computed(() => appModeStore.mode)
|
||||
const isFleetMode = computed(() => uiMode.value === 'fleet')
|
||||
const isPersonalMode = computed(() => uiMode.value === 'personal')
|
||||
|
||||
// Computed properties for empty states
|
||||
const hasGenerations = computed(() => generations.value && generations.value.length > 0)
|
||||
const hasEngines = computed(() => engines.value && engines.value.length > 0)
|
||||
const generationsLoaded = computed(() => !loadingGenerations.value)
|
||||
const enginesLoaded = computed(() => !loadingEngines.value)
|
||||
|
||||
// Step validation - handle empty generations/engines gracefully
|
||||
const canProceedToNextStep = computed(() => {
|
||||
if (currentStep.value === 0) {
|
||||
// Organization step: only required if selection is needed
|
||||
if (needsOrganizationSelection.value) {
|
||||
// Must have a valid organization ID (not null, not undefined)
|
||||
return selectedOrganizationId.value != null
|
||||
}
|
||||
return true // Skip if not needed
|
||||
} else if (currentStep.value === 1) {
|
||||
return !!vehicleClass.value
|
||||
} else if (currentStep.value === 2) {
|
||||
// Basic requirements
|
||||
if (!make.value || !model.value) return false
|
||||
|
||||
// If generations are still loading, wait
|
||||
if (loadingGenerations.value) return false
|
||||
|
||||
// Check if generations are available
|
||||
if (hasGenerations.value) {
|
||||
// If generations exist, require generation selection
|
||||
if (!generation.value) return false
|
||||
|
||||
// If engines are still loading, wait
|
||||
if (loadingEngines.value) return false
|
||||
|
||||
// Check if engines are available for the selected generation
|
||||
if (hasEngines.value) {
|
||||
// If engines exist, require engine selection
|
||||
if (!engine.value) return false
|
||||
}
|
||||
// If no engines available, generation selection is enough
|
||||
return true
|
||||
}
|
||||
// If no generations available, make and model are enough
|
||||
return true
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Calculate progress based on filled fields
|
||||
const calculateProgress = () => {
|
||||
let filledFields = 0
|
||||
let totalFields = 0
|
||||
|
||||
// Step 0: Organization (if needed)
|
||||
if (needsOrganizationSelection.value) {
|
||||
if (selectedOrganizationId.value !== undefined) filledFields++
|
||||
totalFields++
|
||||
}
|
||||
|
||||
// Step 1 fields
|
||||
if (vehicleClass.value) filledFields++
|
||||
totalFields++
|
||||
|
||||
// Step 2 fields (weighted more)
|
||||
if (make.value) filledFields++
|
||||
if (model.value) filledFields++
|
||||
if (generation.value) filledFields++
|
||||
if (engine.value) filledFields++
|
||||
totalFields += 4
|
||||
|
||||
// Step 3 fields
|
||||
if (licensePlate.value) filledFields++
|
||||
if (vin.value) filledFields++
|
||||
if (currentMileage.value) filledFields++
|
||||
totalFields += 3
|
||||
|
||||
progressPercentage.value = Math.round((filledFields / totalFields) * 100)
|
||||
}
|
||||
|
||||
// Watch all form fields for progress updates
|
||||
watch([selectedOrganizationId, vehicleClass, make, model, generation, engine, licensePlate, vin, currentMileage], () => {
|
||||
calculateProgress()
|
||||
})
|
||||
|
||||
// Fetch organizations and makes on component mount
|
||||
onMounted(async () => {
|
||||
await fetchUserOrganizations()
|
||||
await fetchMakes()
|
||||
calculateProgress()
|
||||
})
|
||||
|
||||
// Fetch user's organizations
|
||||
async function fetchUserOrganizations() {
|
||||
if (!authStore.isLoggedIn) return
|
||||
|
||||
loadingOrganizations.value = true
|
||||
try {
|
||||
const data = await organizationApi.getMyOrganizations()
|
||||
userOrganizations.value = data
|
||||
|
||||
// If user has only one organization and is in fleet mode, auto-select it
|
||||
if (isFleetMode.value && data.length === 1) {
|
||||
selectedOrganizationId.value = data[0].organization_id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching organizations:', err)
|
||||
// Non-critical error, continue without organizations
|
||||
} finally {
|
||||
loadingOrganizations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch makes from catalog API
|
||||
async function fetchMakes() {
|
||||
loadingMakes.value = true
|
||||
try {
|
||||
const data = await catalogApi.getMakes()
|
||||
makes.value = data
|
||||
} catch (err) {
|
||||
console.error('Error fetching makes:', err)
|
||||
error.value = 'Could not load vehicle makes. Please try again.'
|
||||
} finally {
|
||||
loadingMakes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch models when make is selected
|
||||
watch(make, async (newMake) => {
|
||||
if (newMake) {
|
||||
loadingModels.value = true
|
||||
try {
|
||||
// Pass vehicle_class to filter models by selected vehicle class
|
||||
const data = await catalogApi.getModels(newMake, vehicleClass.value)
|
||||
models.value = data
|
||||
model.value = ''
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
catalogId.value = null
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
} catch (err) {
|
||||
console.error('Error fetching models:', err)
|
||||
error.value = 'Could not load models for this make.'
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
} else {
|
||||
models.value = []
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch generations when model is selected
|
||||
watch(model, async (newModel) => {
|
||||
if (newModel && make.value) {
|
||||
loadingGenerations.value = true
|
||||
try {
|
||||
const data = await catalogApi.getGenerations(make.value, newModel)
|
||||
generations.value = data
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
catalogId.value = null
|
||||
engines.value = []
|
||||
} catch (err) {
|
||||
console.error('Error fetching generations:', err)
|
||||
error.value = 'Could not load generations for this model.'
|
||||
} finally {
|
||||
loadingGenerations.value = false
|
||||
}
|
||||
} else {
|
||||
generations.value = []
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch engines when generation is selected
|
||||
watch(generation, async (newGen) => {
|
||||
if (newGen && make.value && model.value) {
|
||||
loadingEngines.value = true
|
||||
try {
|
||||
const data = await catalogApi.getEngines(make.value, model.value, newGen)
|
||||
engines.value = data
|
||||
engine.value = ''
|
||||
catalogId.value = null
|
||||
} catch (err) {
|
||||
console.error('Error fetching engines:', err)
|
||||
error.value = 'Could not load engines for this generation.'
|
||||
} finally {
|
||||
loadingEngines.value = false
|
||||
}
|
||||
} else {
|
||||
engines.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// Set catalog ID when engine is selected
|
||||
watch(engine, (newEngine) => {
|
||||
if (newEngine) {
|
||||
const selectedEngine = engines.value.find(e => e.variant === newEngine || e.id === newEngine)
|
||||
if (selectedEngine) {
|
||||
catalogId.value = selectedEngine.id
|
||||
}
|
||||
} else {
|
||||
catalogId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// Navigation functions
|
||||
function nextStep() {
|
||||
if (currentStep.value < totalSteps) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission
|
||||
async function handleSubmit() {
|
||||
error.value = null
|
||||
successMessage.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// Determine organization ID: use selected organization, or auth store's active org, or null for personal
|
||||
const organizationId = selectedOrganizationId.value !== null
|
||||
? selectedOrganizationId.value
|
||||
: (isFleetMode.value ? authStore.activeOrgId : null)
|
||||
|
||||
// Determine owner and operator org IDs based on ownership type
|
||||
let ownerOrgId = null
|
||||
let operatorOrgId = null
|
||||
|
||||
if (organizationId !== null) {
|
||||
if (ownershipType.value === 'owner') {
|
||||
// Owner: both owner and operator are the selected organization
|
||||
ownerOrgId = organizationId
|
||||
operatorOrgId = organizationId
|
||||
} else {
|
||||
// Operator: only operator is set, owner remains null
|
||||
operatorOrgId = organizationId
|
||||
// ownerOrgId stays null
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare thick asset payload
|
||||
const vehicleData = {
|
||||
// Core identification
|
||||
vin: vin.value || null,
|
||||
licensePlate: licensePlate.value,
|
||||
catalogId: catalogId.value,
|
||||
organizationId: organizationId,
|
||||
|
||||
// Ownership fields
|
||||
owner_org_id: ownerOrgId,
|
||||
operator_org_id: operatorOrgId,
|
||||
|
||||
// Thick Asset fields
|
||||
brand: make.value,
|
||||
model: model.value,
|
||||
vehicleClass: vehicleClass.value,
|
||||
fuelType: getFuelTypeFromEngine(),
|
||||
year: getYearFromGeneration(),
|
||||
currentMileage: parseInt(currentMileage.value) || 0,
|
||||
color: color.value,
|
||||
|
||||
// Additional metadata
|
||||
status: 'draft',
|
||||
generation: generation.value,
|
||||
engine: engine.value
|
||||
}
|
||||
|
||||
// Call the updated garage store addVehicle action
|
||||
const result = await garageStore.addVehicle(vehicleData)
|
||||
|
||||
successMessage.value = 'Vehicle registered successfully! The backend will now sync catalog data and calculate profile completion.'
|
||||
|
||||
// Emit success event
|
||||
emit('success', result)
|
||||
|
||||
// Reset form after a delay
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}, 2000)
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error registering vehicle:', err)
|
||||
error.value = err.message || 'An unknown error occurred'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getFuelTypeFromEngine() {
|
||||
if (!engine.value || !engines.value.length) return null
|
||||
const selected = engines.value.find(e => e.variant === engine.value || e.id === engine.value)
|
||||
return selected?.fuel_type || null
|
||||
}
|
||||
|
||||
function getYearFromGeneration() {
|
||||
// Extract year from generation string (e.g., "2015-2019" or "2020")
|
||||
if (!generation.value) return null
|
||||
const match = generation.value.match(/\d{4}/)
|
||||
return match ? parseInt(match[0]) : null
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
currentStep.value = needsOrganizationSelection.value ? 0 : 1
|
||||
selectedOrganizationId.value = null
|
||||
ownershipType.value = 'owner' // Reset to default
|
||||
vehicleClass.value = ''
|
||||
make.value = ''
|
||||
model.value = ''
|
||||
generation.value = ''
|
||||
engine.value = ''
|
||||
catalogId.value = null
|
||||
licensePlate.value = ''
|
||||
vin.value = ''
|
||||
currentMileage.value = ''
|
||||
color.value = ''
|
||||
error.value = null
|
||||
successMessage.value = ''
|
||||
progressPercentage.value = 0
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black bg-opacity-50" @click.self="closeModal">
|
||||
<!-- Modal Container -->
|
||||
<div class="w-full max-w-2xl overflow-hidden bg-white rounded-xl shadow-2xl" :class="isFleetMode ? 'border-l-4 border-blue-600' : 'border-l-4 border-green-600'">
|
||||
<!-- Modal Header -->
|
||||
<div class="p-6 border-b border-gray-200" :class="isFleetMode ? 'bg-blue-50' : 'bg-green-50'">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900">
|
||||
Smart Vehicle Registration
|
||||
</h2>
|
||||
<p class="text-sm mt-1 text-gray-600">
|
||||
{{ isFleetMode ? 'Add a new vehicle to your fleet' : 'Register your vehicle in just 3 easy steps!' }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="closeModal" class="p-2 rounded-full text-gray-500 hover:text-gray-700 hover:bg-gray-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">Progress</span>
|
||||
<span class="text-sm font-bold" :class="isFleetMode ? 'text-blue-600' : 'text-green-600'">{{ progressPercentage }}%</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full overflow-hidden bg-gray-200">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="isFleetMode ? 'bg-blue-600' : 'bg-green-600'"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Step Indicators -->
|
||||
<div class="flex justify-between mt-4">
|
||||
<div
|
||||
v-for="step in totalSteps"
|
||||
:key="step"
|
||||
class="flex flex-col items-center"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all duration-300"
|
||||
:class="[
|
||||
step === currentStep
|
||||
? (isFleetMode ? 'bg-blue-600 text-white ring-2 ring-blue-300' : 'bg-green-600 text-white ring-2 ring-green-300')
|
||||
: step < currentStep
|
||||
? (isFleetMode ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700')
|
||||
: 'bg-gray-100 text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ step }}
|
||||
</div>
|
||||
<span class="text-xs mt-1 font-medium" :class="[
|
||||
step === currentStep
|
||||
? (isFleetMode ? 'text-blue-700' : 'text-green-700')
|
||||
: step < currentStep
|
||||
? (isFleetMode ? 'text-blue-600' : 'text-green-600')
|
||||
: 'text-gray-500'
|
||||
]">
|
||||
{{ step === 0 ? 'Context' : step === 1 ? 'Classification' : step === 2 ? 'Catalog' : 'Details' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6 max-h-[60vh] overflow-y-auto">
|
||||
<!-- Step 0: Organization Selection (if needed) -->
|
||||
<div v-if="currentStep === 0 && needsOrganizationSelection">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800">1. lépés: Szervezet kiválasztása</h3>
|
||||
<p class="text-sm mb-6 text-gray-600">
|
||||
Válaszd ki, hogy melyik szervezethez szeretnéd regisztrálni a járművet. A személyes garázsodhoz vagy egy céges flottához.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Private Organizations (Személyes Garázsom) -->
|
||||
<div v-if="categorizedOrganizations.privateOrgs.length > 0">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Személyes Garázsom</h4>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="org in categorizedOrganizations.privateOrgs"
|
||||
:key="org.id"
|
||||
@click="selectedOrganizationId = org.id"
|
||||
class="p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer"
|
||||
:class="selectedOrganizationId === org.id
|
||||
? (isFleetMode ? 'border-blue-500 bg-blue-50' : 'border-green-500 bg-green-50')
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center mr-4"
|
||||
:class="selectedOrganizationId === org.id
|
||||
? (isFleetMode ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600')
|
||||
: 'bg-gray-100 text-gray-500'">
|
||||
<span>🏠</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="font-medium text-gray-900">{{ org.name }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ org.description }}</p>
|
||||
</div>
|
||||
<div v-if="selectedOrganizationId === org.id" class="text-blue-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Organizations (Céges Flották) -->
|
||||
<div v-if="categorizedOrganizations.businessOrgs.length > 0">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Céges Flották</h4>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="org in categorizedOrganizations.businessOrgs"
|
||||
:key="org.id"
|
||||
@click="selectedOrganizationId = org.id"
|
||||
class="p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer"
|
||||
:class="selectedOrganizationId === org.id
|
||||
? (isFleetMode ? 'border-blue-500 bg-blue-50' : 'border-green-500 bg-green-50')
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center mr-4"
|
||||
:class="selectedOrganizationId === org.id
|
||||
? (isFleetMode ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600')
|
||||
: 'bg-gray-100 text-gray-500'">
|
||||
<span>🏢</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="font-medium text-gray-900">{{ org.name }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ org.description }}</p>
|
||||
<p v-if="org.originalOrg.tax_number" class="text-xs text-gray-500 mt-1">
|
||||
Adószám: {{ org.originalOrg.tax_number }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedOrganizationId === org.id" class="text-blue-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No organizations message -->
|
||||
<div v-if="!loadingOrganizations && categorizedOrganizations.privateOrgs.length === 0 && categorizedOrganizations.businessOrgs.length === 0" class="text-center p-6 border border-dashed border-gray-300 rounded-lg">
|
||||
<p class="text-gray-600">Nincs elérhető szervezet. Először hozz létre egy személyes garázst vagy céges flottát a profilodban.</p>
|
||||
</div>
|
||||
|
||||
<!-- Owner vs Operator Toggle (shown when organization is selected) -->
|
||||
<div v-if="selectedOrganizationId !== null" class="mt-6 pt-6 border-t border-gray-200">
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Tulajdonosi státusz</h4>
|
||||
<p class="text-sm text-gray-600 mb-4">Válaszd ki, hogy milyen minőségben vagy kapcsolatban a járművel:</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
@click="ownershipType = 'owner'"
|
||||
class="p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer"
|
||||
:class="ownershipType === 'owner'
|
||||
? (isFleetMode ? 'border-blue-500 bg-blue-50' : 'border-green-500 bg-green-50')
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center mr-4"
|
||||
:class="ownershipType === 'owner'
|
||||
? (isFleetMode ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600')
|
||||
: 'bg-gray-100 text-gray-500'">
|
||||
<span>👑</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="font-medium text-gray-900">Tulajdonos vagyok</h4>
|
||||
<p class="text-sm text-gray-600">A jármű a tulajdonom, én fizetem a költségeket és döntök a szervizelésről.</p>
|
||||
</div>
|
||||
<div v-if="ownershipType === 'owner'" class="text-blue-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@click="ownershipType = 'operator'"
|
||||
class="p-4 rounded-lg border-2 transition-all duration-200 cursor-pointer"
|
||||
:class="ownershipType === 'operator'
|
||||
? (isFleetMode ? 'border-blue-500 bg-blue-50' : 'border-green-500 bg-green-50')
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center mr-4"
|
||||
:class="ownershipType === 'operator'
|
||||
? (isFleetMode ? 'bg-blue-100 text-blue-600' : 'bg-green-100 text-green-600')
|
||||
: 'bg-gray-100 text-gray-500'">
|
||||
<span>🚗</span>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<h4 class="font-medium text-gray-900">Csak üzembentartó/használó vagyok</h4>
|
||||
<p class="text-sm text-gray-600">A járművet használom, de nem én vagyok a tulajdonos (pl. céges autó, lízing).</p>
|
||||
</div>
|
||||
<div v-if="ownershipType === 'operator'" class="text-blue-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-600">
|
||||
<p v-if="ownershipType === 'owner'">
|
||||
<strong>Hatás:</strong> A kiválasztott szervezet lesz a tulajdonos (<code>owner_org_id</code>) és az üzembentartó (<code>operator_org_id</code>) is.
|
||||
</p>
|
||||
<p v-else>
|
||||
<strong>Hatás:</strong> A kiválasztott szervezet lesz csak az üzembentartó (<code>operator_org_id</code>). A tulajdonos mező üres marad (<code>owner_org_id = null</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingOrganizations" class="mt-4 text-center text-gray-500">
|
||||
Szervezetek betöltése...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Classification -->
|
||||
<div v-else-if="currentStep === 1">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800">Step {{ needsOrganizationSelection ? '2' : '1' }}: Vehicle Classification</h3>
|
||||
<p class="text-sm mb-6 text-gray-600">
|
||||
Select the type of vehicle you want to register. This helps us provide relevant catalog data.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
v-for="cls in vehicleClasses"
|
||||
:key="cls.value"
|
||||
@click="vehicleClass = cls.value"
|
||||
class="p-4 rounded-lg border-2 transition-all duration-200 flex flex-col items-center justify-center"
|
||||
:class="vehicleClass === cls.value
|
||||
? (isFleetMode ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-green-500 bg-green-50 text-green-700')
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50'"
|
||||
>
|
||||
<span class="text-2xl mb-2">{{ cls.icon }}</span>
|
||||
<span class="font-medium text-center">{{ cls.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Catalog Auto-Complete -->
|
||||
<div v-else-if="currentStep === 2">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800">Step {{ needsOrganizationSelection ? '3' : '2' }}: Vehicle Catalog</h3>
|
||||
<p class="text-sm mb-6 text-gray-600">
|
||||
Select your vehicle from our catalog. Start by choosing the make, then model, generation, and engine.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Make Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">Make (Brand)</label>
|
||||
<select
|
||||
v-model="make"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
>
|
||||
<option value="">Select a make</option>
|
||||
<option v-for="m in makes" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Model Selection -->
|
||||
<div v-if="make">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">Model</label>
|
||||
<select
|
||||
v-model="model"
|
||||
:disabled="!make || loadingModels"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a model</option>
|
||||
<option v-for="m in models" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<div v-if="loadingModels" class="text-sm text-gray-500 mt-1">Loading models...</div>
|
||||
</div>
|
||||
|
||||
<!-- Generation Selection -->
|
||||
<div v-if="model">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">Generation</label>
|
||||
<select
|
||||
v-model="generation"
|
||||
:disabled="!model || loadingGenerations || !hasGenerations"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select a generation</option>
|
||||
<option v-for="g in generations" :key="g" :value="g">{{ g }}</option>
|
||||
</select>
|
||||
<div v-if="loadingGenerations" class="text-sm text-gray-500 mt-1">Loading generations...</div>
|
||||
<div v-if="generationsLoaded && !hasGenerations" class="text-sm text-amber-600 mt-1 p-2 bg-amber-50 rounded border border-amber-200">
|
||||
ⓘ No generation data available for this model. You can proceed without selecting a generation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Engine Selection -->
|
||||
<div v-if="generation || (model && !hasGenerations)">
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">Engine Variant</label>
|
||||
<select
|
||||
v-model="engine"
|
||||
:disabled="(!generation && hasGenerations) || loadingEngines || !hasEngines"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select an engine</option>
|
||||
<option v-for="e in engines" :key="e.id || e.variant" :value="e.variant">{{ e.variant }} ({{ e.fuel_type }})</option>
|
||||
</select>
|
||||
<div v-if="loadingEngines" class="text-sm text-gray-500 mt-1">Loading engines...</div>
|
||||
<div v-if="enginesLoaded && !hasEngines" class="text-sm text-amber-600 mt-1 p-2 bg-amber-50 rounded border border-amber-200">
|
||||
ⓘ No engine data available for this generation. You can proceed without selecting an engine.
|
||||
</div>
|
||||
<div v-if="catalogId" class="text-xs text-green-600 mt-1">Catalog ID: {{ catalogId }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Unique Details -->
|
||||
<div v-else-if="currentStep === 3">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800">Step {{ needsOrganizationSelection ? '4' : '3' }}: Vehicle Details</h3>
|
||||
<p class="text-sm mb-6 text-gray-600">
|
||||
Provide the unique details for your vehicle.
|
||||
</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- License Plate -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">License Plate <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="licensePlate"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="ABC-123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- VIN (Optional) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">VIN (Opcionális)</label>
|
||||
<input
|
||||
v-model="vin"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="1HGCM82633A123456"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">Járműazonosító szám (17 karakter)</p>
|
||||
|
||||
<!-- Grace Period Warning -->
|
||||
<div v-if="!vin" class="mt-3 p-3 rounded-lg bg-amber-50 border border-amber-200">
|
||||
<div class="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-amber-600 mr-2 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-800">Figyelem: Átmeneti használat</p>
|
||||
<p class="text-xs text-amber-700 mt-1">
|
||||
Az alvázszám (VIN) megadása nélkül a jármű <strong>14 napig</strong>, vagy maximum <strong>10 költség rögzítéséig</strong> használható.
|
||||
Ezt követően a rendszer zárolja a további műveleteket, amíg meg nem adod az alvázszámot.
|
||||
</p>
|
||||
<p class="text-xs text-amber-700 mt-1">
|
||||
Ajánlott az alvázszám megadása a teljes funkcionalitás érdekében.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Mileage -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">Current Mileage (km)</label>
|
||||
<input
|
||||
v-model="currentMileage"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="150000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2 text-gray-700">Color (Optional)</label>
|
||||
<input
|
||||
v-model="color"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 rounded-lg border border-gray-300 text-gray-800 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
placeholder="Red, Black, Silver, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error and Success Messages -->
|
||||
<div v-if="error" class="mt-6 p-4 rounded-lg bg-red-50 border border-red-200">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-red-500 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-red-700 font-medium">Error: {{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="successMessage" class="mt-6 p-4 rounded-lg bg-green-50 border border-green-200">
|
||||
<div class="flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-500 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-green-700 font-medium">{{ successMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="p-6 border-t border-gray-200 bg-gray-50">
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
v-if="currentStep > 1"
|
||||
@click="prevStep"
|
||||
class="px-5 py-2.5 rounded-lg border border-gray-300 text-gray-700 font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="px-5 py-2.5 rounded-lg border border-gray-300 text-gray-700 font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="currentStep < totalSteps"
|
||||
@click="nextStep"
|
||||
:disabled="!canProceedToNextStep"
|
||||
class="px-5 py-2.5 rounded-lg font-medium transition-colors"
|
||||
:class="isFleetMode
|
||||
? (canProceedToNextStep ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-blue-300 text-white cursor-not-allowed')
|
||||
: (canProceedToNextStep ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-green-300 text-white cursor-not-allowed')"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else
|
||||
@click="handleSubmit"
|
||||
:disabled="isLoading || !licensePlate"
|
||||
class="px-5 py-2.5 rounded-lg font-medium transition-colors"
|
||||
:class="isFleetMode
|
||||
? (!isLoading && licensePlate ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-blue-300 text-white cursor-not-allowed')
|
||||
: (!isLoading && licensePlate ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-green-300 text-white cursor-not-allowed')"
|
||||
>
|
||||
<span v-if="isLoading">
|
||||
<svg class="animate-spin h-5 w-5 text-white inline-block mr-2" 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"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Registering...
|
||||
</span>
|
||||
<span v-else>Register Vehicle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for modal body */
|
||||
.max-h-\[60vh\]::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.max-h-\[60vh\]::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.max-h-\[60vh\]::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.max-h-\[60vh\]::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div class="analytics-dashboard">
|
||||
<!-- Header with Mode Toggle -->
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between mb-8 p-6 bg-gradient-to-r from-gray-50 to-white rounded-2xl shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Vehicle Analytics & TCO Dashboard</h1>
|
||||
<p class="text-gray-600 mt-2">
|
||||
{{ isPrivateGarage ? 'Personal driving insights and fun achievements' : 'Corporate fleet performance and cost optimization' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 md:mt-0">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-3 text-sm font-medium text-gray-700">View Mode:</span>
|
||||
<div class="relative inline-block w-64">
|
||||
<div class="bg-gray-100 rounded-xl p-1 flex">
|
||||
<button
|
||||
@click="setMode('private_garage')"
|
||||
:class="[
|
||||
'flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isPrivateGarage
|
||||
? 'bg-white shadow text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="mr-2">🎮</span>
|
||||
<span>Fun Stats</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click="setMode('corporate_fleet')"
|
||||
:class="[
|
||||
'flex-1 py-3 px-4 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
isCorporateFleet
|
||||
? 'bg-white shadow text-gray-900'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="mr-2">📊</span>
|
||||
<span>Business BI</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="px-4 py-3 bg-gradient-to-r from-blue-500 to-indigo-600 text-white rounded-xl font-medium hover:from-blue-600 hover:to-indigo-700 transition-all duration-200 shadow-md hover:shadow-lg flex items-center"
|
||||
>
|
||||
<span class="mr-2">🔄</span>
|
||||
Switch to {{ isPrivateGarage ? 'Business' : 'Fun' }} View
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-500 flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-green-500 mr-2"></div>
|
||||
<span>Live data updated {{ lastUpdated }}</span>
|
||||
<button @click="refreshData" class="ml-4 text-blue-600 hover:text-blue-800 flex items-center">
|
||||
<span class="mr-1">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Indicator -->
|
||||
<div class="mb-6">
|
||||
<div v-if="isPrivateGarage" class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-blue-100 to-indigo-100 text-blue-800">
|
||||
<span class="mr-2">🎯</span>
|
||||
<span class="font-medium">Private Garage Mode</span>
|
||||
<span class="ml-2 text-sm">Personal insights and achievements</span>
|
||||
</div>
|
||||
<div v-else class="inline-flex items-center px-4 py-2 rounded-full bg-gradient-to-r from-green-100 to-emerald-100 text-green-800">
|
||||
<span class="mr-2">🏢</span>
|
||||
<span class="font-medium">Corporate Fleet Mode</span>
|
||||
<span class="ml-2 text-sm">Business intelligence and TCO analysis</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Component -->
|
||||
<div class="mt-6">
|
||||
<component :is="currentComponent" />
|
||||
</div>
|
||||
|
||||
<!-- Footer Notes -->
|
||||
<div class="mt-12 pt-6 border-t border-gray-200">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gray-50 p-4 rounded-xl">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">📈 Data Sources</h4>
|
||||
<p class="text-sm text-gray-600">Vehicle telemetry, fuel receipts, maintenance records, and insurance data aggregated in real-time.</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-xl">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">🎯 Key Metrics</h4>
|
||||
<p class="text-sm text-gray-600">TCO (Total Cost of Ownership), Cost per km, Fuel efficiency, Utilization rate, and Environmental impact.</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 p-4 rounded-xl">
|
||||
<h4 class="font-semibold text-gray-800 mb-2">🔄 Auto-Sync</h4>
|
||||
<p class="text-sm text-gray-600">Data updates every 24 hours. Manual refresh available. Historical data retained for 36 months.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import FunStats from './FunStats.vue'
|
||||
import BusinessBI from './BusinessBI.vue'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode, isPrivateGarage, isCorporateFleet, toggleMode, setMode } = appModeStore
|
||||
|
||||
const lastUpdated = ref('just now')
|
||||
const isLoading = ref(false)
|
||||
|
||||
const currentComponent = shallowRef(FunStats)
|
||||
|
||||
// Watch mode changes and update component
|
||||
import { watch } from 'vue'
|
||||
watch(() => mode.value, (newMode) => {
|
||||
if (newMode === 'private_garage') {
|
||||
currentComponent.value = FunStats
|
||||
} else {
|
||||
currentComponent.value = BusinessBI
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const refreshData = () => {
|
||||
isLoading.value = true
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
const now = new Date()
|
||||
lastUpdated.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
isLoading.value = false
|
||||
|
||||
// Show success notification
|
||||
const event = new CustomEvent('show-toast', {
|
||||
detail: {
|
||||
message: 'Analytics data refreshed successfully',
|
||||
type: 'success'
|
||||
}
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}, 800)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-dashboard {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Smooth transitions for mode switching */
|
||||
.component-enter-active,
|
||||
.component-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.component-enter-from,
|
||||
.component-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,385 +0,0 @@
|
||||
<template>
|
||||
<div class="business-bi">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">📊 Business Intelligence Dashboard</h2>
|
||||
|
||||
<!-- Key Metrics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Fleet Size</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.fleetSize }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-blue-600">🚗</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Active vehicles</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Total Monthly Cost</p>
|
||||
<p class="text-3xl font-bold text-gray-800">€{{ formatNumber(businessMetrics.totalMonthlyCost) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-green-600">💰</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">All expenses combined</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Avg Cost per Km</p>
|
||||
<p class="text-3xl font-bold text-gray-800">€{{ businessMetrics.averageCostPerKm.toFixed(2) }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-purple-600">📈</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Operating efficiency</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Utilization Rate</p>
|
||||
<p class="text-3xl font-bold text-gray-800">{{ businessMetrics.utilizationRate }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl text-amber-600">⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-2">Fleet activity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Monthly Costs Chart -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Monthly Cost Breakdown (Last 6 Months)</h3>
|
||||
<div class="h-80">
|
||||
<canvas ref="monthlyCostsChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 bg-blue-500 rounded-full mr-2"></div>
|
||||
<span>Maintenance</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
|
||||
<span>Fuel</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 bg-amber-500 rounded-full mr-2"></div>
|
||||
<span>Insurance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Efficiency Trend Chart -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Fuel Efficiency Trend (km per liter)</h3>
|
||||
<div class="h-80">
|
||||
<canvas ref="fuelEfficiencyChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Average: <span class="font-semibold">{{ averageFuelEfficiency.toFixed(1) }} km/L</span></p>
|
||||
<p class="text-green-600">↑ {{ ((averageFuelEfficiency - 12) / 12 * 100).toFixed(1) }}% improvement vs industry average</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost per Km and TCO Analysis -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
<!-- Cost per Km Chart -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Cost per Kilometer Trend</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="costPerKmChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Average cost: <span class="font-semibold">€{{ averageCostPerKm.toFixed(2) }}/km</span></p>
|
||||
<p>Target: <span class="font-semibold">€0.38/km</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TCO Breakdown -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Total Cost of Ownership (TCO) Breakdown</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="tcoChart"></canvas>
|
||||
</div>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
<p>Annual TCO: <span class="font-semibold">€{{ formatNumber(businessMetrics.totalMonthlyCost * 12) }}</span></p>
|
||||
<p>Per vehicle: <span class="font-semibold">€{{ formatNumber(Math.round(businessMetrics.totalMonthlyCost * 12 / businessMetrics.fleetSize)) }}/year</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Table -->
|
||||
<div class="bg-white rounded-xl p-6 shadow border border-gray-200">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">Monthly Performance Details</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr class="bg-gray-50">
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Month</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Maintenance</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fuel</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Insurance</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Cost/km</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Efficiency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="(month, index) in monthlyCosts" :key="month.month">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ month.month }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ month.maintenance }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ month.fuel }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ month.insurance }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">€{{ month.total }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">€{{ costPerKmTrends[index]?.cost.toFixed(2) || '0.00' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ fuelEfficiencyTrends[index]?.efficiency.toFixed(1) || '0.0' }} km/L</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Insights Panel -->
|
||||
<div class="mt-8 bg-gradient-to-r from-gray-800 to-gray-900 rounded-2xl p-6 text-white shadow-lg">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center mr-4">
|
||||
<span class="text-xl">💡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Business Insights</h3>
|
||||
<p class="text-gray-300">AI-powered recommendations</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">💰 Cost Optimization</h4>
|
||||
<p class="text-sm text-gray-200">Maintenance costs are {{ getCostComparison() }} than industry average. Consider preventive maintenance scheduling to reduce unexpected repairs.</p>
|
||||
</div>
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">⛽ Fuel Efficiency</h4>
|
||||
<p class="text-sm text-gray-200">Your fleet is {{ getEfficiencyComparison() }} efficient than benchmark. Continue driver training programs for optimal performance.</p>
|
||||
</div>
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">📅 Utilization Rate</h4>
|
||||
<p class="text-sm text-gray-200">{{ businessMetrics.utilizationRate }}% utilization is good. Consider dynamic routing to increase to 85% target.</p>
|
||||
</div>
|
||||
<div class="bg-white/10 p-4 rounded-xl">
|
||||
<h4 class="font-semibold mb-2">🔧 Downtime Management</h4>
|
||||
<p class="text-sm text-gray-200">{{ businessMetrics.downtimeHours }} hours/month downtime. Predictive maintenance could reduce this by 30%.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useAnalyticsStore } from '@/stores/analyticsStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const analyticsStore = useAnalyticsStore()
|
||||
const { monthlyCosts, fuelEfficiencyTrends, costPerKmTrends, businessMetrics, averageFuelEfficiency, averageCostPerKm } = storeToRefs(analyticsStore)
|
||||
|
||||
const monthlyCostsChart = ref(null)
|
||||
const fuelEfficiencyChart = ref(null)
|
||||
const costPerKmChart = ref(null)
|
||||
const tcoChart = ref(null)
|
||||
|
||||
let chartInstances = []
|
||||
|
||||
const formatNumber = (num) => {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
const getCostComparison = () => {
|
||||
const avgMaintenance = monthlyCosts.value.reduce((sum, month) => sum + month.maintenance, 0) / monthlyCosts.value.length
|
||||
return avgMaintenance > 500 ? 'higher' : avgMaintenance < 400 ? 'lower' : 'similar'
|
||||
}
|
||||
|
||||
const getEfficiencyComparison = () => {
|
||||
return averageFuelEfficiency.value > 13 ? 'more' : averageFuelEfficiency.value < 12 ? 'less' : 'equally'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Monthly Costs Chart (Stacked Bar)
|
||||
if (monthlyCostsChart.value) {
|
||||
const ctx = monthlyCostsChart.value.getContext('2d')
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: monthlyCosts.value.map(m => m.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Maintenance',
|
||||
data: monthlyCosts.value.map(m => m.maintenance),
|
||||
backgroundColor: '#3b82f6',
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
{
|
||||
label: 'Fuel',
|
||||
data: monthlyCosts.value.map(m => m.fuel),
|
||||
backgroundColor: '#10b981',
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
{
|
||||
label: 'Insurance',
|
||||
data: monthlyCosts.value.map(m => m.insurance),
|
||||
backgroundColor: '#f59e0b',
|
||||
stack: 'Stack 0',
|
||||
},
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cost (€)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
|
||||
// Fuel Efficiency Chart (Line)
|
||||
if (fuelEfficiencyChart.value) {
|
||||
const ctx = fuelEfficiencyChart.value.getContext('2d')
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: fuelEfficiencyTrends.value.map(m => m.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Fuel Efficiency (km/L)',
|
||||
data: fuelEfficiencyTrends.value.map(m => m.efficiency),
|
||||
borderColor: '#8b5cf6',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'km per liter'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
|
||||
// Cost per Km Chart (Line)
|
||||
if (costPerKmChart.value) {
|
||||
const ctx = costPerKmChart.value.getContext('2d')
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: costPerKmTrends.value.map(m => m.month),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Cost per Kilometer (€)',
|
||||
data: costPerKmTrends.value.map(m => m.cost),
|
||||
borderColor: '#ef4444',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: '€ per km'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
|
||||
// TCO Chart (Doughnut)
|
||||
if (tcoChart.value) {
|
||||
const ctx = tcoChart.value.getContext('2d')
|
||||
const totalMaintenance = monthlyCosts.value.reduce((sum, month) => sum + month.maintenance, 0)
|
||||
const totalFuel = monthlyCosts.value.reduce((sum, month) => sum + month.fuel, 0)
|
||||
const totalInsurance = monthlyCosts.value.reduce((sum, month) => sum + month.insurance, 0)
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Maintenance', 'Fuel', 'Insurance'],
|
||||
datasets: [
|
||||
{
|
||||
data: [totalMaintenance, totalFuel, totalInsurance],
|
||||
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b'],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
chartInstances.push(chart)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstances.forEach(chart => chart.destroy())
|
||||
chartInstances = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.business-bi {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<div class="fun-stats">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6">🎮 Fun Stats & Achievements</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- Moon Trip Card -->
|
||||
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-2xl p-6 shadow-lg border border-blue-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">🌙</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Moon Trip</h3>
|
||||
<p class="text-sm text-gray-600">Distance traveled</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-blue-700">{{ funFacts.moonTrips }}</div>
|
||||
<p class="text-gray-600 mt-2">trips to the Moon</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
You've driven <span class="font-semibold">{{ formatNumber(funFacts.totalKmDriven) }} km</span> - that's {{ funFacts.moonTrips }} trip{{ funFacts.moonTrips !== 1 ? 's' : '' }} to the Moon!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Earth Circuits Card -->
|
||||
<div class="bg-gradient-to-br from-green-50 to-emerald-100 rounded-2xl p-6 shadow-lg border border-green-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">🌍</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Earth Circuits</h3>
|
||||
<p class="text-sm text-gray-600">Around the world</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-green-700">{{ funFacts.earthCircuits }}</div>
|
||||
<p class="text-gray-600 mt-2">times around Earth</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Equivalent to {{ funFacts.earthCircuits }} circuit{{ funFacts.earthCircuits !== 1 ? 's' : '' }} around the equator!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Trees Saved Card -->
|
||||
<div class="bg-gradient-to-br from-amber-50 to-orange-100 rounded-2xl p-6 shadow-lg border border-amber-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">🌳</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Trees Saved</h3>
|
||||
<p class="text-sm text-gray-600">Environmental impact</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-amber-700">{{ funFacts.totalTreesSaved }}</div>
|
||||
<p class="text-gray-600 mt-2">trees preserved</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Your efficient driving saved {{ funFacts.totalTreesSaved }} tree{{ funFacts.totalTreesSaved !== 1 ? 's' : '' }} from CO₂ emissions!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CO₂ Saved Card -->
|
||||
<div class="bg-gradient-to-br from-purple-50 to-pink-100 rounded-2xl p-6 shadow-lg border border-purple-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">☁️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">CO₂ Saved</h3>
|
||||
<p class="text-sm text-gray-600">Carbon footprint</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-purple-700">{{ funFacts.totalCo2Saved }}</div>
|
||||
<p class="text-gray-600 mt-2">tons of CO₂</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
That's like taking {{ Math.round(funFacts.totalCo2Saved * 1.8) }} cars off the road for a year!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Money Saved Card -->
|
||||
<div class="bg-gradient-to-br from-cyan-50 to-teal-100 rounded-2xl p-6 shadow-lg border border-cyan-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-cyan-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">💰</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Money Saved</h3>
|
||||
<p class="text-sm text-gray-600">Smart driving pays off</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-cyan-700">€{{ formatNumber(funFacts.totalMoneySaved) }}</div>
|
||||
<p class="text-gray-600 mt-2">total savings</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
Compared to average drivers, you saved €{{ formatNumber(funFacts.totalMoneySaved) }}!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fuel Efficiency Card -->
|
||||
<div class="bg-gradient-to-br from-rose-50 to-red-100 rounded-2xl p-6 shadow-lg border border-rose-200">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-12 h-12 bg-rose-100 rounded-xl flex items-center justify-center mr-4">
|
||||
<span class="text-2xl">⛽</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Fuel Efficiency</h3>
|
||||
<p class="text-sm text-gray-600">Your average</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center py-4">
|
||||
<div class="text-4xl font-bold text-rose-700">{{ averageFuelEfficiency.toFixed(1) }}</div>
|
||||
<p class="text-gray-600 mt-2">km per liter</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 text-center">
|
||||
{{ getEfficiencyMessage(averageFuelEfficiency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fun Fact of the Day -->
|
||||
<div class="mt-8 bg-gradient-to-r from-indigo-500 to-purple-600 rounded-2xl p-6 text-white shadow-lg">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center mr-4">
|
||||
<span class="text-xl">💡</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xl font-bold">Fun Fact of the Day</h3>
|
||||
<p class="text-indigo-100">Did you know?</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-lg">
|
||||
If every driver in your city achieved your fuel efficiency, we'd save enough CO₂ to fill {{ Math.round(funFacts.totalCo2Saved * 100) }} hot air balloons every year!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAnalyticsStore } from '@/stores/analyticsStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const analyticsStore = useAnalyticsStore()
|
||||
const { funFacts, averageFuelEfficiency } = storeToRefs(analyticsStore)
|
||||
|
||||
const formatNumber = (num) => {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
const getEfficiencyMessage = (efficiency) => {
|
||||
if (efficiency > 15) return "Outstanding! You're among the top 5% most efficient drivers."
|
||||
if (efficiency > 12) return "Great job! You're more efficient than 80% of drivers."
|
||||
if (efficiency > 10) return "Good! You're above average in fuel efficiency."
|
||||
return 'Room for improvement. Check our tips to save more fuel.'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fun-stats {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="achievement-showcase">
|
||||
<!-- Mode indicator -->
|
||||
<div class="mode-indicator mb-8 p-4 rounded-xl bg-gradient-to-r from-slate-50 to-gray-100 border border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center mr-4"
|
||||
:class="isPrivateGarage ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'"
|
||||
>
|
||||
{{ isPrivateGarage ? '🏆' : '🏅' }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg">
|
||||
{{ isPrivateGarage ? 'Private Garage Trophy Showcase' : 'Corporate Fleet Badge Board' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ isPrivateGarage
|
||||
? 'Playful trophies for personal achievements'
|
||||
: 'Professional badges for fleet optimization'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-sm text-gray-500">
|
||||
Current mode:
|
||||
<span class="font-semibold" :class="isPrivateGarage ? 'text-amber-700' : 'text-emerald-700'">
|
||||
{{ isPrivateGarage ? 'Private Garage' : 'Corporate Fleet' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleMode"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||
:class="isPrivateGarage
|
||||
? 'border-amber-300 text-amber-700 bg-amber-50 hover:bg-amber-100'
|
||||
: 'border-emerald-300 text-emerald-700 bg-emerald-50 hover:bg-emerald-100'"
|
||||
>
|
||||
Switch to {{ isPrivateGarage ? 'Corporate' : 'Private' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic component rendering -->
|
||||
<div class="component-container">
|
||||
<TrophyCabinet v-if="isPrivateGarage" />
|
||||
<BadgeBoard v-else />
|
||||
</div>
|
||||
|
||||
<!-- Gamification stats -->
|
||||
<div class="mt-10 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 text-xl mr-4">
|
||||
📈
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ earnedCount }}</div>
|
||||
<div class="text-sm text-gray-600">Achievements Earned</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-full bg-purple-100 flex items-center justify-center text-purple-600 text-xl mr-4">
|
||||
🎯
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ progressPercentage }}%</div>
|
||||
<div class="text-sm text-gray-600">Overall Progress</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 rounded-xl bg-white border border-gray-200 shadow-sm">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center text-green-600 text-xl mr-4">
|
||||
⭐
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ nextAchievement }}</div>
|
||||
<div class="text-sm text-gray-600">Next Achievement</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help text -->
|
||||
<div class="mt-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="flex items-start">
|
||||
<div class="text-gray-500 mr-3">💡</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<span class="font-semibold">How to earn more:</span>
|
||||
{{ isPrivateGarage
|
||||
? 'Add vehicles, log expenses, complete daily quizzes, and find services to unlock trophies.'
|
||||
: 'Optimize fleet efficiency, reduce costs, manage multiple vehicles, and maintain service records to earn badges.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useGamificationStore } from '@/stores/gamificationStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import TrophyCabinet from './TrophyCabinet.vue'
|
||||
import BadgeBoard from './BadgeBoard.vue'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const gamificationStore = useGamificationStore()
|
||||
|
||||
const { isPrivateGarage, isCorporateFleet, toggleMode } = appModeStore
|
||||
const { earnedCount, progressPercentage, lockedAchievements } = storeToRefs(gamificationStore)
|
||||
|
||||
const nextAchievement = computed(() => {
|
||||
if (lockedAchievements.value.length > 0) {
|
||||
return lockedAchievements.value[0].title
|
||||
}
|
||||
return 'All earned!'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-showcase {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div class="badge-board">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-slate-800 mb-2">🏅 Efficiency Badges</h2>
|
||||
<p class="text-gray-600">Professional recognition for fleet optimization and cost management.</p>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-emerald-500 mr-2"></div>
|
||||
<span class="text-sm text-gray-700">Earned: {{ earnedCount }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-3 h-3 rounded-full bg-gray-300 mr-2"></div>
|
||||
<span class="text-sm text-gray-700">Available: {{ totalAchievements - earnedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-gray-500">Fleet Score</div>
|
||||
<div class="text-2xl font-bold text-slate-800">{{ fleetScore }}/100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="achievement in achievements"
|
||||
:key="achievement.id"
|
||||
class="badge-card p-6 rounded-xl border transition-all duration-300"
|
||||
:class="[
|
||||
achievement.isEarned
|
||||
? 'border-emerald-200 bg-white shadow-md hover:shadow-lg'
|
||||
: 'border-gray-200 bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<!-- Badge header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl"
|
||||
:class="achievement.isEarned ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-200 text-gray-400'"
|
||||
>
|
||||
{{ achievement.icon }}
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="font-bold text-lg" :class="achievement.isEarned ? 'text-slate-900' : 'text-gray-500'">
|
||||
{{ achievement.title }}
|
||||
</h3>
|
||||
<div class="text-xs font-medium px-2 py-1 rounded-full inline-block mt-1"
|
||||
:class="achievement.isEarned ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-500'">
|
||||
{{ achievement.category.toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status indicator -->
|
||||
<div v-if="achievement.isEarned" class="text-emerald-600">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="text-gray-400">
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-gray-600 mb-5" :class="{ 'opacity-70': !achievement.isEarned }">
|
||||
{{ achievement.description }}
|
||||
</p>
|
||||
|
||||
<!-- Progress bar for unearned badges -->
|
||||
<div v-if="!achievement.isEarned" class="mt-4">
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div class="bg-gray-400 h-2 rounded-full" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Earned details -->
|
||||
<div v-if="achievement.isEarned" class="mt-4 pt-4 border-t border-gray-100">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="font-medium">Awarded:</span> {{ achievement.earnedDate }}
|
||||
</div>
|
||||
<div class="text-sm font-semibold text-emerald-700">
|
||||
+{{ badgePoints(achievement.category) }} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action button -->
|
||||
<div class="mt-6">
|
||||
<button
|
||||
v-if="!achievement.isEarned"
|
||||
class="w-full py-2 text-sm font-medium rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
disabled
|
||||
>
|
||||
Not Yet Achieved
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="w-full py-2 text-sm font-medium rounded-lg bg-emerald-50 text-emerald-700 border border-emerald-200 hover:bg-emerald-100 transition-colors"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary stats -->
|
||||
<div class="mt-10 p-6 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<h3 class="font-bold text-lg text-slate-800 mb-4">Fleet Performance Summary</h3>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ earnedCount }}</div>
|
||||
<div class="text-sm text-gray-600">Badges Earned</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ fleetScore }}</div>
|
||||
<div class="text-sm text-gray-600">Fleet Score</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ efficiencyBadgesCount }}</div>
|
||||
<div class="text-sm text-gray-600">Efficiency Badges</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-slate-800">{{ corporateBadgesCount }}</div>
|
||||
<div class="text-sm text-gray-600">Corporate Badges</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useGamificationStore } from '@/stores/gamificationStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const gamificationStore = useGamificationStore()
|
||||
const {
|
||||
achievements,
|
||||
earnedCount,
|
||||
totalAchievements
|
||||
} = storeToRefs(gamificationStore)
|
||||
|
||||
// Computed
|
||||
const fleetScore = computed(() => {
|
||||
const base = earnedCount.value * 12
|
||||
return Math.min(base, 100)
|
||||
})
|
||||
|
||||
const efficiencyBadgesCount = computed(() => {
|
||||
return achievements.value.filter(a =>
|
||||
a.category === 'efficiency' && a.isEarned
|
||||
).length
|
||||
})
|
||||
|
||||
const corporateBadgesCount = computed(() => {
|
||||
return achievements.value.filter(a =>
|
||||
a.category === 'corporate' && a.isEarned
|
||||
).length
|
||||
})
|
||||
|
||||
const badgePoints = (category) => {
|
||||
const points = {
|
||||
efficiency: 25,
|
||||
corporate: 30,
|
||||
finance: 20,
|
||||
service: 15,
|
||||
onboarding: 10,
|
||||
knowledge: 15,
|
||||
consistency: 10,
|
||||
social: 5
|
||||
}
|
||||
return points[category] || 10
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.badge-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.badge-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<div class="trophy-cabinet">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-amber-800 mb-2">🏆 Trophy Cabinet</h2>
|
||||
<p class="text-gray-600">Your earned achievements shine here! Collect more to fill your shelf.</p>
|
||||
<div class="mt-4 flex items-center">
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
class="bg-gradient-to-r from-amber-400 to-amber-600 h-3 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${progressPercentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="ml-4 text-sm font-semibold text-amber-700">{{ earnedCount }}/{{ totalAchievements }} ({{ progressPercentage }}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="achievement in achievements"
|
||||
:key="achievement.id"
|
||||
class="relative group"
|
||||
>
|
||||
<div
|
||||
class="trophy-card p-5 rounded-2xl border-2 transition-all duration-300 transform"
|
||||
:class="[
|
||||
achievement.isEarned
|
||||
? 'border-amber-300 bg-gradient-to-br from-amber-50 to-amber-100 shadow-lg hover:shadow-2xl hover:scale-105'
|
||||
: 'border-gray-300 bg-gray-100 opacity-60 grayscale'
|
||||
]"
|
||||
>
|
||||
<!-- Trophy Icon -->
|
||||
<div class="text-5xl mb-4 text-center">
|
||||
{{ achievement.icon }}
|
||||
</div>
|
||||
|
||||
<!-- Lock overlay for unearned -->
|
||||
<div
|
||||
v-if="!achievement.isEarned"
|
||||
class="absolute inset-0 bg-gray-800 bg-opacity-70 rounded-2xl flex items-center justify-center"
|
||||
>
|
||||
<div class="text-white text-center">
|
||||
<div class="text-3xl mb-2">🔒</div>
|
||||
<div class="text-sm font-semibold">Locked</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="text-lg font-bold mb-2" :class="achievement.isEarned ? 'text-gray-900' : 'text-gray-500'">
|
||||
{{ achievement.title }}
|
||||
</h3>
|
||||
<p class="text-sm mb-3" :class="achievement.isEarned ? 'text-gray-700' : 'text-gray-400'">
|
||||
{{ achievement.description }}
|
||||
</p>
|
||||
|
||||
<!-- Category badge -->
|
||||
<div class="inline-block px-3 py-1 text-xs rounded-full"
|
||||
:class="achievement.isEarned ? 'bg-amber-200 text-amber-800' : 'bg-gray-300 text-gray-500'">
|
||||
{{ achievement.category }}
|
||||
</div>
|
||||
|
||||
<!-- Earned date -->
|
||||
<div v-if="achievement.isEarned" class="mt-4 pt-3 border-t border-amber-200">
|
||||
<div class="text-xs text-amber-600 font-medium">
|
||||
🎉 Earned on {{ achievement.earnedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Glow effect for earned trophies on hover -->
|
||||
<div
|
||||
v-if="achievement.isEarned"
|
||||
class="absolute -inset-1 bg-gradient-to-r from-blue-400 to-blue-600 rounded-2xl blur opacity-0 group-hover:opacity-30 transition-opacity duration-300 -z-10"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state message -->
|
||||
<div v-if="earnedCount === 0" class="text-center py-12">
|
||||
<div class="text-6xl mb-4">📭</div>
|
||||
<h3 class="text-xl font-semibold text-gray-700 mb-2">No trophies yet!</h3>
|
||||
<p class="text-gray-500">Start using the app to earn your first achievements.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useGamificationStore } from '@/stores/gamificationStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const gamificationStore = useGamificationStore()
|
||||
const {
|
||||
achievements,
|
||||
earnedCount,
|
||||
totalAchievements,
|
||||
progressPercentage
|
||||
} = storeToRefs(gamificationStore)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trophy-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -1,309 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
vehicles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
// Enhanced status colors for corporate look
|
||||
const statusColors = {
|
||||
'OK': 'bg-emerald-50 text-emerald-700 border border-emerald-200',
|
||||
'Service Due': 'bg-blue-50 text-blue-900 border border-blue-200 animate-pulse',
|
||||
'Warning': 'bg-rose-50 text-rose-700 border border-rose-200',
|
||||
'draft': 'bg-yellow-50 text-yellow-800 border border-yellow-300',
|
||||
'verified': 'bg-green-50 text-green-800 border border-green-300',
|
||||
'active': 'bg-blue-50 text-blue-800 border border-blue-300',
|
||||
'pending': 'bg-gray-50 text-gray-800 border border-gray-300',
|
||||
'incomplete': 'bg-orange-50 text-orange-800 border border-orange-300'
|
||||
}
|
||||
|
||||
const sortedVehicles = computed(() => {
|
||||
return [...props.vehicles].sort((a, b) => b.monthlyExpense - a.monthlyExpense)
|
||||
})
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatMileage = (mileage) => {
|
||||
return new Intl.NumberFormat('en-US').format(mileage)
|
||||
}
|
||||
|
||||
// Country flag mapping
|
||||
const getCountryFlag = (make) => {
|
||||
const makeLower = make.toLowerCase()
|
||||
if (makeLower.includes('bmw') || makeLower.includes('mercedes') || makeLower.includes('audi') || makeLower.includes('volkswagen') || makeLower.includes('porsche')) {
|
||||
return 'https://flagcdn.com/w40/de.png'
|
||||
} else if (makeLower.includes('tesla') || makeLower.includes('ford') || makeLower.includes('chevrolet') || makeLower.includes('dodge')) {
|
||||
return 'https://flagcdn.com/w40/us.png'
|
||||
} else if (makeLower.includes('toyota') || makeLower.includes('honda') || makeLower.includes('nissan') || makeLower.includes('mazda')) {
|
||||
return 'https://flagcdn.com/w40/jp.png'
|
||||
} else if (makeLower.includes('ferrari') || makeLower.includes('lamborghini') || makeLower.includes('fiat') || makeLower.includes('alfa romeo')) {
|
||||
return 'https://flagcdn.com/w40/it.png'
|
||||
} else if (makeLower.includes('volvo') || makeLower.includes('saab')) {
|
||||
return 'https://flagcdn.com/w40/se.png'
|
||||
} else if (makeLower.includes('renault') || makeLower.includes('peugeot') || makeLower.includes('citroen')) {
|
||||
return 'https://flagcdn.com/w40/fr.png'
|
||||
} else if (makeLower.includes('skoda') || makeLower.includes('seat')) {
|
||||
return 'https://flagcdn.com/w40/cz.png'
|
||||
} else {
|
||||
return 'https://flagcdn.com/w40/eu.png'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl border border-gray-300/50 overflow-hidden">
|
||||
<!-- Corporate Glass Header -->
|
||||
<div class="px-8 py-5 border-b border-gray-300/30 bg-gradient-to-r from-slate-900/90 to-slate-800/90 backdrop-blur-md">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-white tracking-tight">Corporate Fleet Management</h2>
|
||||
<p class="text-sm text-slate-300 mt-1">Enterprise-grade vehicle oversight with real-time analytics</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold text-white">{{ formatCurrency(vehicles.reduce((sum, v) => sum + v.monthlyExpense, 0)) }}</div>
|
||||
<div class="text-sm text-slate-300">Total monthly fleet cost • {{ vehicles.length }} assets</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Container -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-300/30">
|
||||
<thead class="bg-gradient-to-r from-slate-100 to-slate-200/80">
|
||||
<tr>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">🚗</span> Vehicle
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">🏷️</span> License Plate
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">📅</span> Year
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">📊</span> Mileage
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">⛽</span> Fuel Type
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">🔧</span> Data Status
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">💰</span> Monthly Cost
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-8 py-4 text-left text-xs font-semibold text-slate-700 uppercase tracking-wider">
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">⚡</span> Actions
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-300/20">
|
||||
<tr
|
||||
v-for="(vehicle, index) in sortedVehicles"
|
||||
:key="vehicle.id"
|
||||
:class="[
|
||||
'transition-all duration-200 hover:bg-slate-100/80',
|
||||
index % 2 === 0 ? 'bg-white' : 'bg-slate-50/70'
|
||||
]"
|
||||
>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="h-12 w-12 flex-shrink-0 bg-gradient-to-br from-slate-200 to-slate-300 rounded-xl overflow-hidden mr-4 shadow-sm border border-slate-300/50">
|
||||
<img
|
||||
:src="vehicle.imageUrl"
|
||||
:alt="`${vehicle.make} ${vehicle.model}`"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-slate-900 text-lg">{{ vehicle.make }} {{ vehicle.model }}</div>
|
||||
<div class="text-sm text-slate-600 mt-1">ID: {{ vehicle.id }} • Asset</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="getCountryFlag(vehicle.make)"
|
||||
:alt="`${vehicle.make} origin flag`"
|
||||
class="w-6 h-4 rounded-sm shadow-md border border-slate-300"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-mono font-bold text-slate-900 text-lg tracking-wider">{{ vehicle.licensePlate }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Registered</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ vehicle.year }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">Model Year</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ formatMileage(vehicle.mileage) }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">km</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<span :class="[
|
||||
'px-4 py-2 rounded-full text-sm font-semibold shadow-sm',
|
||||
vehicle.fuelType === 'Electric' ? 'bg-emerald-100 text-emerald-800 border border-emerald-300' :
|
||||
vehicle.fuelType === 'Diesel' ? 'bg-blue-100 text-blue-800 border border-blue-300' :
|
||||
'bg-amber-100 text-amber-800 border border-amber-300'
|
||||
]">
|
||||
{{ vehicle.fuelType }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="space-y-2">
|
||||
<!-- Data Status Badge -->
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
:class="['px-3 py-1.5 rounded-full text-xs font-semibold flex items-center', statusColors[vehicle.data_status || vehicle.status] || 'bg-slate-100 text-slate-800 border border-slate-300']"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full mr-2"
|
||||
:class="{
|
||||
'bg-emerald-500': (vehicle.data_status || vehicle.status) === 'OK' || (vehicle.data_status || vehicle.status) === 'verified',
|
||||
'bg-blue-500': (vehicle.data_status || vehicle.status) === 'Service Due' || (vehicle.data_status || vehicle.status) === 'active',
|
||||
'bg-rose-500': (vehicle.data_status || vehicle.status) === 'Warning',
|
||||
'bg-yellow-500': (vehicle.data_status || vehicle.status) === 'draft',
|
||||
'bg-gray-500': (vehicle.data_status || vehicle.status) === 'pending',
|
||||
'bg-orange-500': (vehicle.data_status || vehicle.status) === 'incomplete'
|
||||
}"
|
||||
></span>
|
||||
{{ vehicle.data_status || vehicle.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Profile Completion Mini Progress Bar -->
|
||||
<div v-if="vehicle.profile_completion_percentage !== undefined" class="flex items-center space-x-2">
|
||||
<div class="w-16 bg-gray-200 rounded-full h-1.5">
|
||||
<div
|
||||
class="h-1.5 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-yellow-500': (vehicle.profile_completion_percentage || 0) < 50,
|
||||
'bg-blue-500': (vehicle.profile_completion_percentage || 0) >= 50 && (vehicle.profile_completion_percentage || 0) < 80,
|
||||
'bg-green-500': (vehicle.profile_completion_percentage || 0) >= 80
|
||||
}"
|
||||
:style="{ width: (vehicle.profile_completion_percentage || 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-600 font-medium">{{ vehicle.profile_completion_percentage || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-slate-900">{{ formatCurrency(vehicle.monthlyExpense) }}</div>
|
||||
<div class="text-xs text-slate-500 mt-1">per month</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 whitespace-nowrap">
|
||||
<div class="flex space-x-2">
|
||||
<button class="px-4 py-2.5 bg-gradient-to-r from-blue-600 to-cyan-600 hover:from-blue-700 hover:to-cyan-700 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg">
|
||||
View Details
|
||||
</button>
|
||||
<button class="px-3 py-2.5 border border-slate-300 hover:bg-slate-100 rounded-xl text-slate-700 transition-all duration-200 active:scale-95 shadow-sm hover:shadow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Corporate Footer -->
|
||||
<div class="px-8 py-5 border-t border-gray-300/30 bg-gradient-to-r from-slate-100 to-slate-200/80">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-slate-700">
|
||||
<span class="font-semibold">Showing {{ vehicles.length }} of {{ vehicles.length }} corporate assets</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>Last updated: {{ new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }}</span>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<button class="px-5 py-2.5 border border-slate-300 hover:bg-white text-slate-700 font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-sm hover:shadow flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Export CSV
|
||||
</button>
|
||||
<button class="px-5 py-2.5 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Add Vehicle
|
||||
</button>
|
||||
<button class="px-5 py-2.5 bg-gradient-to-r from-slate-700 to-slate-800 hover:from-slate-800 hover:to-slate-900 text-white font-medium rounded-xl text-sm transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom table styles */
|
||||
table {
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Smooth row transitions */
|
||||
tr {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for table */
|
||||
.overflow-x-auto::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -1,163 +0,0 @@
|
||||
<script setup>
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
|
||||
defineProps({
|
||||
vehicle: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
const themeClasses = themeStore.themeClasses
|
||||
|
||||
const statusColors = {
|
||||
'OK': 'bg-green-100 text-green-800',
|
||||
'Service Due': 'bg-blue-100 text-blue-900',
|
||||
'Warning': 'bg-orange-100 text-orange-800',
|
||||
'draft': 'bg-yellow-100 text-yellow-800',
|
||||
'verified': 'bg-green-100 text-green-800',
|
||||
'active': 'bg-blue-100 text-blue-800',
|
||||
'pending': 'bg-gray-100 text-gray-800',
|
||||
'incomplete': 'bg-orange-100 text-orange-800'
|
||||
}
|
||||
|
||||
const brandLogoUrl = (make) => {
|
||||
const cleanMake = (make || '').toLowerCase().replace(/\s+/g, '')
|
||||
// Use simpleicons CDN
|
||||
return `https://cdn.simpleicons.org/${cleanMake}`
|
||||
}
|
||||
|
||||
// Country flag mapping
|
||||
const getCountryFlag = (make) => {
|
||||
const makeLower = (make || '').toLowerCase()
|
||||
if (makeLower.includes('bmw') || makeLower.includes('mercedes') || makeLower.includes('audi') || makeLower.includes('volkswagen') || makeLower.includes('porsche')) {
|
||||
return 'https://flagcdn.com/w40/de.png'
|
||||
} else if (makeLower.includes('tesla') || makeLower.includes('ford') || makeLower.includes('chevrolet') || makeLower.includes('dodge')) {
|
||||
return 'https://flagcdn.com/w40/us.png'
|
||||
} else if (makeLower.includes('toyota') || makeLower.includes('honda') || makeLower.includes('nissan') || makeLower.includes('mazda')) {
|
||||
return 'https://flagcdn.com/w40/jp.png'
|
||||
} else if (makeLower.includes('ferrari') || makeLower.includes('lamborghini') || makeLower.includes('fiat') || makeLower.includes('alfa romeo')) {
|
||||
return 'https://flagcdn.com/w40/it.png'
|
||||
} else if (makeLower.includes('volvo') || makeLower.includes('saab')) {
|
||||
return 'https://flagcdn.com/w40/se.png'
|
||||
} else if (makeLower.includes('renault') || makeLower.includes('peugeot') || makeLower.includes('citroen')) {
|
||||
return 'https://flagcdn.com/w40/fr.png'
|
||||
} else if (makeLower.includes('skoda') || makeLower.includes('seat')) {
|
||||
return 'https://flagcdn.com/w40/cz.png'
|
||||
} else {
|
||||
return 'https://flagcdn.com/w40/eu.png'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all duration-500 border', themeClasses.card]">
|
||||
<!-- Vehicle Image -->
|
||||
<div class="h-48 bg-gray-200 relative overflow-hidden">
|
||||
<img
|
||||
:src="vehicle.imageUrl"
|
||||
:alt="`${vehicle.make} ${vehicle.model}`"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<!-- Brand Logo -->
|
||||
<div class="absolute top-3 left-3 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-md">
|
||||
<img
|
||||
:src="brandLogoUrl(vehicle.make)"
|
||||
:alt="vehicle.make"
|
||||
class="w-8 h-8"
|
||||
@error="(e) => e.target.style.display = 'none'"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute top-3 right-3">
|
||||
<span
|
||||
:class="['px-3 py-1 rounded-full text-xs font-semibold', statusColors[vehicle.data_status || vehicle.status] || 'bg-gray-100 text-gray-800']"
|
||||
>
|
||||
{{ vehicle.data_status || vehicle.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Details -->
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ vehicle.make }} {{ vehicle.model }}</h3>
|
||||
<p class="text-gray-600">{{ vehicle.year }} • {{ vehicle.fuelType }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-blue-700">€{{ vehicle.monthlyExpense }}</div>
|
||||
<div class="text-sm text-gray-500">/month</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- License Plate with Country Flag -->
|
||||
<div class="mb-4">
|
||||
<div class="inline-flex items-center bg-gray-100 px-4 py-2 rounded-lg space-x-3">
|
||||
<img
|
||||
:src="getCountryFlag(vehicle.make)"
|
||||
:alt="`${vehicle.make} origin flag`"
|
||||
class="w-6 h-4 rounded-sm shadow-sm"
|
||||
/>
|
||||
<span class="text-gray-700 font-mono font-bold text-lg">{{ vehicle.licensePlate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Completion Progress Bar (shown for all vehicles with <100% completion) -->
|
||||
<div v-if="(vehicle.profile_completion_percentage || 0) < 100" class="mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
v-if="vehicle.data_status === 'draft'"
|
||||
class="px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800"
|
||||
>
|
||||
DRAFT
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="text-xs text-gray-600 mb-1">Profile: {{ vehicle.profile_completion_percentage || 0 }}% Complete</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-500"
|
||||
:class="{
|
||||
'bg-yellow-500': (vehicle.profile_completion_percentage || 0) < 50,
|
||||
'bg-blue-500': (vehicle.profile_completion_percentage || 0) >= 50 && (vehicle.profile_completion_percentage || 0) < 80,
|
||||
'bg-green-500': (vehicle.profile_completion_percentage || 0) >= 80
|
||||
}"
|
||||
:style="{ width: (vehicle.profile_completion_percentage || 0) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="vehicle.data_status === 'draft'" class="text-xs text-gray-500 mt-1">Edit vehicle to provide VIN or Catalog ID</p>
|
||||
<p v-else class="text-xs text-gray-500 mt-1">Complete your vehicle profile for better service recommendations</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ ((vehicle.mileage || 0) / 1000).toFixed(1) }}k</div>
|
||||
<div class="text-sm text-gray-600">km</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-gray-900">{{ vehicle.fuelType?.charAt(0) || 'U' }}</div>
|
||||
<div class="text-sm text-gray-600">Fuel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex space-x-3">
|
||||
<button class="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors duration-200">
|
||||
View Details
|
||||
</button>
|
||||
<button class="px-4 py-3 border border-gray-300 hover:bg-gray-50 rounded-lg transition-colors duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-600" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for future use */
|
||||
</style>
|
||||
@@ -1,271 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useGarageStore } from '@/stores/garageStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import VehicleCard from './VehicleCard.vue'
|
||||
import FleetTable from './FleetTable.vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const garageStore = useGarageStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Animation state
|
||||
const isMounted = ref(false)
|
||||
const organizations = ref([])
|
||||
const activeOrganizationName = ref('')
|
||||
const loadingOrganizations = ref(false)
|
||||
|
||||
// Fetch vehicles on component mount (simulated)
|
||||
onMounted(() => {
|
||||
garageStore.fetchVehicles()
|
||||
// Trigger animation after mount
|
||||
setTimeout(() => {
|
||||
isMounted.value = true
|
||||
}, 100)
|
||||
|
||||
// Fetch organizations if in fleet mode
|
||||
if (appModeStore.isCorporateFleet) {
|
||||
fetchOrganizations()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for mode changes
|
||||
watch(() => appModeStore.mode, (newMode) => {
|
||||
if (newMode === 'fleet') {
|
||||
fetchOrganizations()
|
||||
} else {
|
||||
activeOrganizationName.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch user's organizations
|
||||
const fetchOrganizations = async () => {
|
||||
if (!authStore.token) return
|
||||
|
||||
try {
|
||||
loadingOrganizations.value = true
|
||||
const response = await api.get('/organizations/my')
|
||||
organizations.value = response.data || []
|
||||
|
||||
// Find active organization name
|
||||
const activeOrgId = authStore.activeOrgId || authStore.userProfile?.active_organization_id
|
||||
if (activeOrgId && organizations.value.length > 0) {
|
||||
// Try to find organization by ID
|
||||
// Note: The current endpoint returns limited data, we may need to enhance it
|
||||
const org = organizations.value.find(o => o.organization_id === parseInt(activeOrgId))
|
||||
if (org) {
|
||||
activeOrganizationName.value = org.display_name || org.name || org.full_name || `Organization #${activeOrgId}`
|
||||
} else {
|
||||
activeOrganizationName.value = 'Corporate Fleet'
|
||||
}
|
||||
} else {
|
||||
activeOrganizationName.value = 'Corporate Fleet'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organizations:', error)
|
||||
activeOrganizationName.value = 'Corporate Fleet'
|
||||
} finally {
|
||||
loadingOrganizations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stats = computed(() => ({
|
||||
totalVehicles: garageStore.totalVehicles,
|
||||
totalMonthlyExpense: garageStore.totalMonthlyExpense,
|
||||
vehiclesNeedingService: garageStore.vehiclesNeedingService
|
||||
}))
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// Computed for header title
|
||||
const headerTitle = computed(() => {
|
||||
if (appModeStore.isPrivateGarage) {
|
||||
return 'Saját Garázs'
|
||||
} else {
|
||||
if (activeOrganizationName.value && activeOrganizationName.value !== 'Corporate Fleet') {
|
||||
return `Céges Flotta: ${activeOrganizationName.value}`
|
||||
}
|
||||
return 'Céges Flotta'
|
||||
}
|
||||
})
|
||||
|
||||
// Computed for header description
|
||||
const headerDescription = computed(() => {
|
||||
if (appModeStore.isPrivateGarage) {
|
||||
return 'Your personal vehicle collection and maintenance tracker'
|
||||
} else {
|
||||
if (activeOrganizationName.value && activeOrganizationName.value !== 'Corporate Fleet') {
|
||||
return `Company-wide vehicle management and cost analytics for ${activeOrganizationName.value}`
|
||||
}
|
||||
return 'Company-wide vehicle management and cost analytics'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Header with Stats -->
|
||||
<div class="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-2xl p-6 border border-blue-100">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
{{ headerTitle }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="appModeStore.isPrivateGarage"
|
||||
class="px-3 py-1 bg-gradient-to-r from-green-500 to-emerald-600 text-white text-xs font-semibold rounded-full shadow-sm"
|
||||
>
|
||||
B2C
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-3 py-1 bg-gradient-to-r from-blue-500 to-indigo-600 text-white text-xs font-semibold rounded-full shadow-sm"
|
||||
>
|
||||
B2B
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-600 mt-2">
|
||||
{{ headerDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
@click="appModeStore.toggleMode"
|
||||
class="px-4 py-2 bg-white border border-gray-300 rounded-lg font-medium text-gray-700 hover:bg-gray-50 transition-all duration-300 flex items-center active:scale-95"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Switch to {{ appModeStore.isPrivateGarage ? 'Corporate' : 'Private' }} View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="(stat, index) in [
|
||||
{ label: 'Total Vehicles', value: stats.totalVehicles, icon: 'check', color: 'blue' },
|
||||
{ label: 'Monthly Cost', value: formatCurrency(stats.totalMonthlyExpense), icon: 'currency', color: 'green' },
|
||||
{ label: 'Need Service', value: stats.vehiclesNeedingService, icon: 'warning', color: 'orange' }
|
||||
]"
|
||||
:key="stat.label"
|
||||
class="bg-white rounded-xl p-5 shadow-sm border border-gray-200 transition-all duration-500 hover:shadow-md hover:-translate-y-1"
|
||||
:style="{
|
||||
opacity: isMounted ? 1 : 0,
|
||||
transform: isMounted ? 'translateY(0)' : 'translateY(20px)',
|
||||
transitionDelay: `${index * 100}ms`
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div :class="[`p-3 bg-${stat.color}-100 rounded-lg mr-4`]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" :class="[`h-6 w-6 text-${stat.color}-600`]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path v-if="stat.icon === 'check'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path v-if="stat.icon === 'currency'" 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" />
|
||||
<path v-if="stat.icon === 'warning'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.998-.833-2.732 0L4.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-900">{{ stat.value }}</div>
|
||||
<div class="text-gray-600">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dual-UI Content -->
|
||||
<div v-if="appModeStore.isPrivateGarage">
|
||||
<!-- Private Garage: Card Grid with TransitionGroup -->
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Vehicles</h2>
|
||||
<div class="text-gray-600">
|
||||
{{ garageStore.vehicles.length }} vehicles in your garage
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="stagger-card"
|
||||
tag="div"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-2 gap-6"
|
||||
>
|
||||
<VehicleCard
|
||||
v-for="(vehicle, index) in garageStore.vehicles"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
:style="{
|
||||
opacity: isMounted ? 1 : 0,
|
||||
transform: isMounted ? 'translateY(0) scale(1)' : 'translateY(30px) scale(0.95)',
|
||||
transitionDelay: `${index * 150}ms`
|
||||
}"
|
||||
class="transition-all duration-700 ease-out"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Corporate Fleet: Table View -->
|
||||
<FleetTable :vehicles="garageStore.vehicles" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State (if no vehicles) -->
|
||||
<div v-if="garageStore.vehicles.length === 0" class="text-center py-12">
|
||||
<div class="text-6xl text-gray-300 mb-4">🚗</div>
|
||||
<h3 class="text-xl font-bold text-gray-500 mb-2">No vehicles yet</h3>
|
||||
<p class="text-gray-600 mb-6">Add your first vehicle to get started</p>
|
||||
<button
|
||||
@click="router.push('/vehicles/add')"
|
||||
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-all duration-300 hover:scale-105 active:scale-95"
|
||||
>
|
||||
Add First Vehicle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Staggered card animations */
|
||||
.stagger-card-move {
|
||||
transition: transform 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.stagger-card-enter-active,
|
||||
.stagger-card-leave-active {
|
||||
transition: all 0.7s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.stagger-card-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
.stagger-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(0.95);
|
||||
}
|
||||
|
||||
.stagger-card-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
/* Smooth hover effects */
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 300ms;
|
||||
}
|
||||
</style>
|
||||
69
frontend/src/components/logo1.vue
Normal file
69
frontend/src/components/logo1.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-4 relative -mb-8 z-50 group cursor-pointer">
|
||||
|
||||
<svg
|
||||
viewBox="0 0 240 240"
|
||||
class="w-24 h-24 drop-shadow-[0_10px_20px_rgba(0,0,0,0.4)] transition-transform duration-500 group-hover:scale-105"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="sf-green" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#418890" />
|
||||
<stop offset="100%" stop-color="#79B085" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="sf-white" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#FFFFFF" />
|
||||
<stop offset="100%" stop-color="#D1D5DB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<path d="M 80 160 L 30 190" stroke="url(#sf-green)" stroke-width="8" stroke-linecap="round"/>
|
||||
<circle cx="15" cy="199" r="4" fill="url(#sf-green)"/>
|
||||
<path d="M 105 185 L 40 225" stroke="url(#sf-green)" stroke-width="12" stroke-linecap="round"/>
|
||||
<circle cx="20" cy="237" r="6" fill="url(#sf-green)"/>
|
||||
<path d="M 130 205 L 80 235" stroke="url(#sf-green)" stroke-width="6" stroke-linecap="round"/>
|
||||
<circle cx="65" cy="244" r="3" fill="url(#sf-green)"/>
|
||||
|
||||
<polygon points="120,25 105,45 135,45" fill="url(#sf-green)"/>
|
||||
<polygon points="35,120 55,105 55,135" fill="url(#sf-green)"/>
|
||||
<polygon points="120,215 105,195 135,195" fill="url(#sf-green)"/>
|
||||
<polygon points="60,60 80,75 65,85" fill="url(#sf-green)"/>
|
||||
|
||||
<circle cx="120" cy="120" r="75" stroke="url(#sf-white)" stroke-width="16"/>
|
||||
|
||||
<text x="120" y="155" font-family="Arial, sans-serif" font-weight="900" font-size="95" text-anchor="middle">
|
||||
<tspan fill="url(#sf-white)" dx="-15">S</tspan>
|
||||
<tspan fill="url(#sf-green)" dx="5">F</tspan>
|
||||
</text>
|
||||
|
||||
<path d="M 165 165 L 210 210" stroke="url(#sf-white)" stroke-width="24" stroke-linecap="round"/>
|
||||
<path d="M 175 175 L 205 205" stroke="#062535" stroke-width="6" stroke-linecap="round"/>
|
||||
|
||||
<polygon points="215,25 130,110 100,70" fill="#2C5A63"/>
|
||||
<polygon points="215,25 130,110 160,140" fill="url(#sf-green)"/>
|
||||
|
||||
<circle cx="130" cy="110" r="14" fill="url(#sf-white)"/>
|
||||
<circle cx="130" cy="110" r="5" fill="#062535"/>
|
||||
|
||||
</svg>
|
||||
|
||||
<div class="hidden sm:flex flex-col justify-center mt-3">
|
||||
<div class="font-black tracking-widest text-3xl leading-none drop-shadow-md">
|
||||
<span class="text-white">SERVICE</span> <span class="text-[#75A882]">FINDER</span>
|
||||
</div>
|
||||
<div class="text-[#75A882] text-[0.65rem] tracking-[0.25em] font-bold mt-2 uppercase opacity-90">
|
||||
Online Járműnyilvántartó & Szervizkereső
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* Service Finder - Végleges SVG Logo Komponens
|
||||
* Optimalizált, kézzel írt kód <linearGradient> használatával.
|
||||
*/
|
||||
</script>
|
||||
16
frontend/src/env.d.ts
vendored
Normal file
16
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
195
frontend/src/layouts/AdminLayout.vue
Normal file
195
frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="admin-layout min-h-screen bg-gray-900 text-gray-100">
|
||||
<!-- Sidebar -->
|
||||
<aside class="fixed inset-y-0 left-0 w-64 bg-gray-800 border-r border-gray-700 z-50">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Logo -->
|
||||
<div class="p-6 border-b border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center">
|
||||
<span class="text-xl font-bold">SF</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xl font-bold">Service Finder</div>
|
||||
<div class="text-xs text-gray-400">Admin Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 p-4 space-y-2">
|
||||
<router-link
|
||||
to="/admin"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
:class="{ 'bg-blue-900/50 hover:bg-blue-900/70': $route.path === '/admin' }"
|
||||
>
|
||||
<span class="text-xl">📊</span>
|
||||
<span class="font-medium">Dashboard</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/users"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">👥</span>
|
||||
<span class="font-medium">User Management</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/vehicles"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">🚗</span>
|
||||
<span class="font-medium">Vehicle Catalog</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/translations"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">🌐</span>
|
||||
<span class="font-medium">i18n Manager</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/system"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">⚙️</span>
|
||||
<span class="font-medium">System Settings</span>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/admin/audit"
|
||||
class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span class="text-xl">📋</span>
|
||||
<span class="font-medium">Audit Logs</span>
|
||||
</router-link>
|
||||
|
||||
<div class="pt-6 border-t border-gray-700">
|
||||
<div class="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider">Tools</div>
|
||||
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors">
|
||||
<span class="text-xl">🔍</span>
|
||||
<span class="font-medium">Database Explorer</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center space-x-3 px-4 py-3 rounded-lg hover:bg-gray-700 transition-colors">
|
||||
<span class="text-xl">📈</span>
|
||||
<span class="font-medium">Analytics</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="p-4 border-t border-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-gray-600 to-gray-800 flex items-center justify-center">
|
||||
<span class="text-lg">AD</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">Admin User</div>
|
||||
<div class="text-xs text-gray-400">Super Administrator</div>
|
||||
</div>
|
||||
<button class="p-2 hover:bg-gray-700 rounded-lg transition-colors">
|
||||
<span class="text-xl">⚙️</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="pl-64">
|
||||
<!-- Top Bar -->
|
||||
<header class="sticky top-0 z-40 bg-gray-800/90 backdrop-blur-sm border-b border-gray-700">
|
||||
<div class="flex justify-between items-center px-8 py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold">{{ pageTitle }}</h1>
|
||||
<div class="text-sm px-3 py-1 bg-gray-700 rounded-full">
|
||||
{{ currentTime }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<slot name="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
|
||||
<button class="px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 text-white rounded-lg hover:opacity-90 transition-opacity">
|
||||
Quick Actions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content Area -->
|
||||
<main class="p-8">
|
||||
<div class="bg-gray-800/50 rounded-xl border border-gray-700 p-6">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer Stats -->
|
||||
<div class="mt-8 grid grid-cols-4 gap-4">
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">System Status</div>
|
||||
<div class="text-2xl font-bold text-green-400">Operational</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">API Response</div>
|
||||
<div class="text-2xl font-bold">42ms</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">Active Sessions</div>
|
||||
<div class="text-2xl font-bold">127</div>
|
||||
</div>
|
||||
<div class="bg-gray-800/50 rounded-lg p-4 border border-gray-700">
|
||||
<div class="text-sm text-gray-400">Uptime</div>
|
||||
<div class="text-2xl font-bold">99.8%</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const currentTime = ref('')
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const name = route.name?.toString() || 'Dashboard'
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
})
|
||||
|
||||
function updateTime() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
let intervalId: number
|
||||
|
||||
onMounted(() => {
|
||||
updateTime()
|
||||
intervalId = setInterval(updateTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
195
frontend/src/layouts/ConsumerLayout.vue
Normal file
195
frontend/src/layouts/ConsumerLayout.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div class="consumer-layout min-h-screen bg-sf-wall bg-sf-wall-pattern">
|
||||
<!-- Header (above decorations) -->
|
||||
<header class="sf-header-bg border-b border-sf-blue/20 shadow-lg z-20 relative">
|
||||
<div class="container mx-auto px-4 sm:px-6 py-4">
|
||||
<!-- Top row: Logo and controls -->
|
||||
<div class="flex justify-between items-center">
|
||||
<!-- Logo Section with actual logo image -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo Image -->
|
||||
<div class="flex items-center">
|
||||
<div class="relative">
|
||||
<img src="/sf_logo_ok.png" alt="Service Finder Logo" class="h-12 w-auto object-contain mix-blend-multiply" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<div class="text-2xl font-bold">
|
||||
<span class="text-sf-blue">SERVICE</span>
|
||||
<span class="text-sf-green"> FINDER</span>
|
||||
</div>
|
||||
<!-- Slogan -->
|
||||
<div class="text-xs font-semibold text-sf-accent mt-1">
|
||||
ONLINE JÁRMŰNYILVÁNTARTÓ & SZERVIZKERESŐ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Controls -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<slot name="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
|
||||
<!-- User Menu -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<router-link to="/login" class="hidden sm:flex items-center space-x-2 px-4 py-2 bg-sf-blue text-white rounded-full hover:bg-sf-blue/90 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
<span>Sign In</span>
|
||||
</router-link>
|
||||
<router-link to="/login" class="sm:hidden p-2 rounded-full bg-sf-blue text-white">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
</router-link>
|
||||
|
||||
<!-- Notification Bell -->
|
||||
<button class="relative p-2 rounded-full hover:bg-gray-100 transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
|
||||
</svg>
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Decorative Background Elements (behind navbar, low z-index) -->
|
||||
<div class="fixed inset-0 pointer-events-none -z-10 overflow-hidden">
|
||||
<!-- Calendar moved lower on the "wall" -->
|
||||
<img src="/sf_naptar.png" alt="Calendar" class="absolute top-32 left-8 w-32 shadow-md opacity-80">
|
||||
<!-- Alarm light on right side, below navbar -->
|
||||
<img src="/sf_alarm_light_01.png" alt="Alarm Light" class="absolute top-32 right-8 w-24 pulse">
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 sm:px-6 py-8 relative z-10">
|
||||
<!-- Content Slot -->
|
||||
<div class="bg-white/80 backdrop-blur-sm rounded-3xl shadow-lg p-6 md:p-8 mb-8">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<div class="bg-white/90 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<div class="w-12 h-12 bg-sf-blue/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-sf-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-gray-800">Vehicle Registration</h3>
|
||||
<p class="text-gray-600">Quickly register your vehicle and get personalized service recommendations.</p>
|
||||
</div>
|
||||
<div class="bg-white/90 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<div class="w-12 h-12 bg-sf-green/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-sf-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-gray-800">Service Booking</h3>
|
||||
<p class="text-gray-600">Book appointments with trusted service providers in your area.</p>
|
||||
</div>
|
||||
<div class="bg-white/90 rounded-2xl p-6 shadow-lg hover:shadow-xl transition-shadow border border-gray-100">
|
||||
<div class="w-12 h-12 bg-sf-teal/10 rounded-xl flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6 text-sf-teal" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2 text-gray-800">Cost Tracking</h3>
|
||||
<p class="text-gray-600">Track your vehicle's maintenance history and total cost of ownership.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-12 bg-white/90 backdrop-blur-sm border-t border-gray-200/50">
|
||||
<div class="container mx-auto px-4 sm:px-6 py-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div class="flex items-center space-x-3 mb-4">
|
||||
<div class="w-8 h-8 rounded-lg bg-sf-blue flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-sf-green" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl font-bold">
|
||||
<span class="text-sf-blue">Service</span>
|
||||
<span class="text-sf-green">Finder</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">Making vehicle maintenance simple, transparent, and affordable.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4 text-gray-800">For Consumers</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-blue text-sm">Find Services</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-blue text-sm">Vehicle Registration</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-blue text-sm">Price Comparison</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4 text-gray-800">For Businesses</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-green text-sm">Partner Program</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-green text-sm">Business Dashboard</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-green text-sm">API Access</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold mb-4 text-gray-800">Connect</h4>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-teal text-sm">Help Center</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-teal text-sm">Contact Us</a></li>
|
||||
<li><a href="#" class="text-gray-600 hover:text-sf-teal text-sm">Privacy Policy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 pt-8 border-t border-gray-200 text-center text-gray-500 text-sm">
|
||||
<p>© 2026 Service Finder. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.consumer-layout {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Mobile background image */
|
||||
@media (max-width: 768px) {
|
||||
.consumer-layout {
|
||||
background: url('/sf_mobile_garage_1.png') no-repeat center center fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation for alarm light */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.8;
|
||||
filter: drop-shadow(0 0 5px rgba(255, 0, 0, 0.5));
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 15px rgba(255, 0, 0, 0.9));
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s infinite ease-in-out;
|
||||
}
|
||||
</style>
|
||||
98
frontend/src/layouts/CorporateLayout.vue
Normal file
98
frontend/src/layouts/CorporateLayout.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="corporate-layout min-h-screen bg-corporate-light">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="container mx-auto px-6 py-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center space-x-8">
|
||||
<div class="text-2xl font-bold text-corporate-blue">
|
||||
Service Finder <span class="text-sm font-normal text-gray-500">Corporate</span>
|
||||
</div>
|
||||
<nav class="hidden md:flex space-x-6">
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Dashboard</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Fleet</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Analytics</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Billing</a>
|
||||
<a href="#" class="text-gray-700 hover:text-corporate-blue font-medium">Settings</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<slot name="header-controls">
|
||||
<LanguageSwitcher />
|
||||
<ModeSwitcher />
|
||||
</slot>
|
||||
<div class="w-10 h-10 rounded-full bg-corporate-blue text-white flex items-center justify-center">
|
||||
CO
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- Sidebar -->
|
||||
<aside class="col-span-3">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Organization</h3>
|
||||
<ul class="space-y-2">
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Overview</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Vehicles</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Drivers</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Maintenance</a></li>
|
||||
<li><a href="#" class="block py-2 px-3 rounded-lg hover:bg-gray-100 text-gray-700">Reports</a></li>
|
||||
</ul>
|
||||
<div class="mt-8 pt-6 border-t border-gray-200">
|
||||
<h3 class="font-semibold text-gray-800 mb-4">Quick Stats</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Active Vehicles</span>
|
||||
<span class="font-semibold">42</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Pending Services</span>
|
||||
<span class="font-semibold">7</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-600">Monthly Cost</span>
|
||||
<span class="font-semibold">€12,450</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="col-span-9">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-gray-200 mt-12">
|
||||
<div class="container mx-auto px-6 py-6">
|
||||
<div class="flex justify-between items-center text-sm text-gray-500">
|
||||
<div>© 2026 Service Finder Corporate. All rights reserved.</div>
|
||||
<div class="flex space-x-6">
|
||||
<a href="#" class="hover:text-gray-800">Privacy Policy</a>
|
||||
<a href="#" class="hover:text-gray-800">Terms of Service</a>
|
||||
<a href="#" class="hover:text-gray-800">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import ModeSwitcher from '../components/ModeSwitcher.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.corporate-layout {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { markRaw } from 'vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Global error handler
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
console.error('Global Vue error caught:', err)
|
||||
console.error('Error info:', info)
|
||||
// Optionally show a user-friendly error message
|
||||
// You could integrate with a notification store here
|
||||
}
|
||||
|
||||
// Global promise rejection handler (for unhandled async errors)
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason)
|
||||
event.preventDefault()
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
// Inject router into all Pinia stores
|
||||
pinia.use(({ store }) => {
|
||||
store.router = markRaw(router)
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -1,151 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
// Nézetek importálása
|
||||
import Dashboard from '../views/Dashboard.vue';
|
||||
import Expenses from '../views/Expenses.vue';
|
||||
import AddExpense from '../views/AddExpense.vue';
|
||||
import Login from '../views/Login.vue';
|
||||
import Register from '../views/Register.vue';
|
||||
import ForgotPassword from '../views/ForgotPassword.vue';
|
||||
import ResetPassword from '../views/ResetPassword.vue';
|
||||
import AddVehicle from '../views/AddVehicle.vue';
|
||||
import AdminStats from '../views/admin/AdminStats.vue';
|
||||
import ProfileSelect from '../views/ProfileSelect.vue';
|
||||
import Debug from '../views/Debug.vue';
|
||||
|
||||
const routes = [
|
||||
// Védett útvonalak
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/expenses', name: 'Expenses', component: Expenses, meta: { requiresAuth: true } },
|
||||
{ path: '/expenses/add', name: 'AddExpense', component: AddExpense, meta: { requiresAuth: true } },
|
||||
{ path: '/vehicles/add', name: 'AddVehicle', component: AddVehicle, meta: { requiresAuth: true } },
|
||||
|
||||
// Profile selection (public but requires auth)
|
||||
{ path: '/profile-select', name: 'ProfileSelect', component: ProfileSelect, meta: { requiresAuth: true } },
|
||||
|
||||
// ADMIN útvonal
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
component: AdminStats,
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
|
||||
// Nyilvános útvonalak
|
||||
{ path: '/login', name: 'Login', component: Login },
|
||||
{ path: '/register', name: 'Register', component: Register },
|
||||
{ path: '/forgot-password', name: 'ForgotPassword', component: ForgotPassword },
|
||||
{ path: '/reset-password', name: 'ResetPassword', component: ResetPassword },
|
||||
|
||||
// DEBUG útvonal (nyilvános, nincs auth check)
|
||||
{ path: '/debug', name: 'Debug', component: Debug },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
// Helper function to check if UI mode is selected
|
||||
function hasUIModeSelected() {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const saved = localStorage.getItem('ui_mode');
|
||||
// Accept both UI values (private_garage, corporate_fleet) and backend values (personal, fleet)
|
||||
return saved === 'private_garage' || saved === 'corporate_fleet' || saved === 'personal' || saved === 'fleet';
|
||||
}
|
||||
|
||||
// A "SOROMPÓ" (Auth Guard) LOGIKA
|
||||
router.beforeEach((to, from, next) => {
|
||||
console.group('🚀 Router Navigation Guard - Flight Recorder');
|
||||
console.log(`📊 Navigation: ${from.path} → ${to.path}`);
|
||||
console.log(`📍 To Route:`, to);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const isAdmin = localStorage.getItem('is_admin') === 'true';
|
||||
const uiMode = localStorage.getItem('ui_mode');
|
||||
|
||||
// Import stores to get real-time state
|
||||
let isAuthenticated = false;
|
||||
let appMode = null;
|
||||
|
||||
try {
|
||||
// Try to get auth store state if available
|
||||
const authStore = window.__pinia?.state.value?.auth;
|
||||
if (authStore) {
|
||||
isAuthenticated = !!authStore.token;
|
||||
console.log('🔐 Auth Store State:', authStore);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Could not access auth store:', e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get app mode store state if available
|
||||
const appModeStore = window.__pinia?.state.value?.appMode;
|
||||
if (appModeStore) {
|
||||
appMode = appModeStore.mode;
|
||||
console.log('🎛️ App Mode Store State:', appModeStore);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Could not access app mode store:', e.message);
|
||||
}
|
||||
|
||||
console.log('📋 Authentication Status:');
|
||||
console.log(' • Token in localStorage:', token ? `YES (${token.substring(0, 20)}...)` : 'NO');
|
||||
console.log(' • isAdmin flag:', isAdmin);
|
||||
console.log(' • UI Mode in localStorage:', uiMode || 'NOT SET');
|
||||
console.log(' • Auth Store isAuthenticated:', isAuthenticated);
|
||||
console.log(' • App Mode Store mode:', appMode);
|
||||
console.log(' • Route requiresAuth:', to.meta.requiresAuth || false);
|
||||
console.log(' • Route requiresAdmin:', to.meta.requiresAdmin || false);
|
||||
|
||||
// Auth check
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
console.warn('❌ AUTH FAILED: Route requires auth but no token found');
|
||||
console.warn(` ↳ Redirecting to /login (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin check
|
||||
if (to.meta.requiresAdmin && !isAdmin) {
|
||||
console.warn('❌ ADMIN CHECK FAILED: Route requires admin but user is not admin');
|
||||
console.warn(` ↳ Redirecting to / (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// UI mode selection logic
|
||||
if (to.meta.requiresAuth && token) {
|
||||
const hasMode = hasUIModeSelected();
|
||||
console.log('🎯 UI Mode Check:');
|
||||
console.log(' • hasUIModeSelected():', hasMode);
|
||||
console.log(' • Target route name:', to.name);
|
||||
|
||||
// If user tries to access dashboard without mode selection, redirect to profile-select
|
||||
if (to.name === 'Dashboard' && !hasMode) {
|
||||
console.warn('⚠️ UI MODE MISSING: Dashboard access without mode selection');
|
||||
console.warn(` ↳ Redirecting to /profile-select (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/profile-select');
|
||||
return;
|
||||
}
|
||||
|
||||
// If user tries to access profile-select but already has mode, redirect to dashboard
|
||||
if (to.name === 'ProfileSelect' && hasMode) {
|
||||
console.warn('⚠️ UI MODE ALREADY SET: Profile-select access with existing mode');
|
||||
console.warn(` ↳ Redirecting to / (from ${to.path})`);
|
||||
console.groupEnd();
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ ALL CHECKS PASSED: Allowing navigation to', to.path);
|
||||
console.groupEnd();
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
42
frontend/src/router/index.ts
Normal file
42
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import LandingView from '../views/LandingView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: LandingView
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
// Lazy-loaded — placeholder until the actual Dashboard view is built
|
||||
component: () => import('../views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: '/complete-kyc',
|
||||
name: 'complete-kyc',
|
||||
// Lazy-loaded — placeholder until the actual KYC view is built
|
||||
component: () => import('../views/CompleteKycView.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// ── Global navigation guard ──────────────────────────────────────────
|
||||
// If an authenticated user navigates to the root ("/"), redirect to "/dashboard".
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
if (to.path === '/') {
|
||||
// Lazy-access the Pinia store inside the guard to avoid any
|
||||
// circular-dependency issues at module-load time.
|
||||
const { useAuthStore } = await import('../stores/auth')
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.isAuthenticated) {
|
||||
return next('/dashboard')
|
||||
}
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,163 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// Create axios instance with base URL from environment variable
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
console.group('📤 Axios Request Interceptor - Flight Recorder');
|
||||
console.log('📊 Request Details:');
|
||||
console.log(' • URL:', config.url);
|
||||
console.log(' • Method:', config.method);
|
||||
console.log(' • Base URL:', config.baseURL);
|
||||
|
||||
// Get token from localStorage (check both 'token' and 'access_token' for compatibility)
|
||||
if (typeof window !== 'undefined') {
|
||||
let token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
console.log('⚠️ Using access_token (legacy) instead of token');
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
console.log('🔐 Adding Authorization header with token');
|
||||
console.log(' • Token present:', token.substring(0, 20) + '...');
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
} else {
|
||||
console.log('⚠️ No auth token found in localStorage');
|
||||
console.log(' • token key:', localStorage.getItem('token') ? 'PRESENT' : 'MISSING');
|
||||
console.log(' • access_token key:', localStorage.getItem('access_token') ? 'PRESENT' : 'MISSING');
|
||||
}
|
||||
} else {
|
||||
console.log('🌐 Window not available (SSR)');
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('❌ Request interceptor error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.group('🚨 Axios Response Interceptor - Flight Recorder');
|
||||
console.log('📊 Interceptor triggered for error:', error);
|
||||
|
||||
if (error.response) {
|
||||
console.log('📡 Response Details:');
|
||||
console.log(' • Status:', error.response.status);
|
||||
console.log(' • URL:', error.config?.url);
|
||||
console.log(' • Method:', error.config?.method);
|
||||
console.log(' • Headers:', error.config?.headers);
|
||||
|
||||
if (error.response.status === 401) {
|
||||
console.warn('🔐 401 UNAUTHORIZED DETECTED!');
|
||||
console.warn(' ↳ This will trigger logout and redirect');
|
||||
|
||||
// Log current auth state before clearing
|
||||
const token = localStorage.getItem('token');
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
console.log(' ↳ Current localStorage state:');
|
||||
console.log(' - token:', token ? `YES (${token.substring(0, 20)}...)` : 'NO');
|
||||
console.log(' - access_token:', accessToken ? `YES (${accessToken.substring(0, 20)}...)` : 'NO');
|
||||
|
||||
// Handle unauthorized - clear ALL auth tokens and redirect to login
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log(' ↳ Clearing auth tokens from localStorage...');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('is_admin');
|
||||
localStorage.removeItem('user_email');
|
||||
localStorage.removeItem('user_role');
|
||||
|
||||
// Also try to call auth store logout if available
|
||||
try {
|
||||
const authStore = window.__pinia?.state.value?.auth;
|
||||
if (authStore && authStore.logout) {
|
||||
console.log(' ↳ Calling auth store logout()...');
|
||||
// We can't call the function directly from here, but we can dispatch an event
|
||||
window.dispatchEvent(new CustomEvent('force-logout'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(' ↳ Could not access auth store:', e.message);
|
||||
}
|
||||
|
||||
console.warn(' ↳ Redirecting to /login');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else if (error.response.status === 403) {
|
||||
console.warn('🔒 403 FORBIDDEN DETECTED');
|
||||
console.warn(' ↳ User lacks permissions for this resource');
|
||||
} else if (error.response.status === 404) {
|
||||
console.warn('🔍 404 NOT FOUND DETECTED');
|
||||
console.warn(' ↳ API endpoint or resource not found');
|
||||
} else if (error.response.status >= 500) {
|
||||
console.error('💥 SERVER ERROR DETECTED (5xx)');
|
||||
console.error(' ↳ Backend server issue');
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('🌐 NETWORK ERROR: Request was made but no response received');
|
||||
console.error(' ↳ Possible network issue or CORS problem');
|
||||
} else {
|
||||
console.error('⚙️ SETUP ERROR: Error in request configuration');
|
||||
console.error(' ↳', error.message);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
// Catalog API functions
|
||||
export const catalogApi = {
|
||||
async getMakes() {
|
||||
const response = await api.get('/catalog/makes')
|
||||
return response.data
|
||||
},
|
||||
async getModels(make, vehicleClass = null) {
|
||||
const params = { make }
|
||||
if (vehicleClass) {
|
||||
params.vehicle_class = vehicleClass
|
||||
}
|
||||
const response = await api.get('/catalog/models', { params })
|
||||
return response.data
|
||||
},
|
||||
async getGenerations(make, model) {
|
||||
const response = await api.get('/catalog/generations', { params: { make, model } })
|
||||
return response.data
|
||||
},
|
||||
async getEngines(make, model, gen) {
|
||||
const response = await api.get('/catalog/engines', { params: { make, model, gen } })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
// Organization API functions
|
||||
export const organizationApi = {
|
||||
async getMyOrganizations() {
|
||||
const response = await api.get('/organizations/my')
|
||||
return response.data
|
||||
},
|
||||
async updateActiveOrganization(organizationId) {
|
||||
const response = await api.patch('/users/me/active-organization', {
|
||||
organization_id: organizationId
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
44
frontend/src/services/translationService.ts
Normal file
44
frontend/src/services/translationService.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
||||
|
||||
const translationService = {
|
||||
async fetchTranslations(lang: string): Promise<Record<string, any>> {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/v1/translations`, {
|
||||
params: { lang }
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch translations for ${lang}, using fallback`, error)
|
||||
// Return empty object as fallback
|
||||
return {}
|
||||
}
|
||||
},
|
||||
|
||||
async fetchAllTranslations(): Promise<Record<string, any>> {
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/v1/translations/all`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch all translations', error)
|
||||
return {}
|
||||
}
|
||||
},
|
||||
|
||||
async updateTranslation(key: string, value: string, lang: string): Promise<boolean> {
|
||||
try {
|
||||
await axios.post(`${API_BASE}/v1/translations`, {
|
||||
key,
|
||||
value,
|
||||
lang
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to update translation', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default translationService
|
||||
@@ -1,202 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
export const useAnalyticsStore = defineStore('analytics', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Real data - initially empty, will be fetched from API
|
||||
const monthlyCosts = ref([])
|
||||
const fuelEfficiencyTrends = ref([])
|
||||
const costPerKmTrends = ref([])
|
||||
const funFacts = ref({
|
||||
totalKmDriven: 0,
|
||||
totalTreesSaved: 0,
|
||||
totalCo2Saved: 0,
|
||||
totalMoneySaved: 0,
|
||||
moonTrips: computed(() => Math.round(funFacts.value.totalKmDriven / 384400)),
|
||||
earthCircuits: computed(() => Math.round(funFacts.value.totalKmDriven / 40075)),
|
||||
})
|
||||
const businessMetrics = ref({
|
||||
fleetSize: 0,
|
||||
averageVehicleAge: 0,
|
||||
totalMonthlyCost: 0,
|
||||
averageCostPerKm: 0,
|
||||
utilizationRate: 0,
|
||||
downtimeHours: 0,
|
||||
})
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const totalCosts = computed(() => {
|
||||
return monthlyCosts.value.reduce((sum, month) => sum + month.total, 0)
|
||||
})
|
||||
|
||||
const averageMonthlyCost = computed(() => {
|
||||
return monthlyCosts.value.length > 0 ? totalCosts.value / monthlyCosts.value.length : 0
|
||||
})
|
||||
|
||||
const averageFuelEfficiency = computed(() => {
|
||||
const sum = fuelEfficiencyTrends.value.reduce((acc, item) => acc + item.efficiency, 0)
|
||||
return fuelEfficiencyTrends.value.length > 0 ? sum / fuelEfficiencyTrends.value.length : 0
|
||||
})
|
||||
|
||||
const averageCostPerKm = computed(() => {
|
||||
const sum = costPerKmTrends.value.reduce((acc, item) => acc + item.cost, 0)
|
||||
return costPerKmTrends.value.length > 0 ? sum / costPerKmTrends.value.length : 0
|
||||
})
|
||||
|
||||
// Actions
|
||||
function addMonthlyCost(data) {
|
||||
monthlyCosts.value.push(data)
|
||||
}
|
||||
|
||||
function updateFuelEfficiency(month, efficiency) {
|
||||
const index = fuelEfficiencyTrends.value.findIndex(item => item.month === month)
|
||||
if (index !== -1) {
|
||||
fuelEfficiencyTrends.value[index].efficiency = efficiency
|
||||
}
|
||||
}
|
||||
|
||||
function updateFunFacts(newFacts) {
|
||||
Object.assign(funFacts.value, newFacts)
|
||||
}
|
||||
|
||||
// Real API fetch - NO MORE MOCK DATA
|
||||
async function fetchDashboardAnalytics() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
// Call real backend API
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/analytics/dashboard`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch analytics: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AnalyticsStore: Fetched dashboard analytics', data)
|
||||
|
||||
// Transform API response to frontend format
|
||||
monthlyCosts.value = data.monthly_costs || []
|
||||
fuelEfficiencyTrends.value = data.fuel_efficiency_trends || []
|
||||
costPerKmTrends.value = data.cost_per_km_trends || []
|
||||
|
||||
if (data.fun_facts) {
|
||||
funFacts.value = {
|
||||
totalKmDriven: data.fun_facts.total_km_driven || 0,
|
||||
totalTreesSaved: data.fun_facts.total_trees_saved || 0,
|
||||
totalCo2Saved: data.fun_facts.total_co2_saved || 0,
|
||||
totalMoneySaved: data.fun_facts.total_money_saved || 0,
|
||||
moonTrips: computed(() => Math.round((data.fun_facts.total_km_driven || 0) / 384400)),
|
||||
earthCircuits: computed(() => Math.round((data.fun_facts.total_km_driven || 0) / 40075)),
|
||||
}
|
||||
}
|
||||
|
||||
if (data.business_metrics) {
|
||||
businessMetrics.value = {
|
||||
fleetSize: data.business_metrics.fleet_size || 0,
|
||||
averageVehicleAge: data.business_metrics.average_vehicle_age || 0,
|
||||
totalMonthlyCost: data.business_metrics.total_monthly_cost || 0,
|
||||
averageCostPerKm: data.business_metrics.average_cost_per_km || 0,
|
||||
utilizationRate: data.business_metrics.utilization_rate || 0,
|
||||
downtimeHours: data.business_metrics.downtime_hours || 0,
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AnalyticsStore: Error fetching analytics', err)
|
||||
error.value = err.message
|
||||
// Keep empty data (no mock fallback)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch vehicle-specific analytics
|
||||
async function fetchVehicleAnalytics(vehicleId) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
// Call vehicle summary endpoint
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/analytics/${vehicleId}/summary`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch vehicle analytics: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AnalyticsStore: Fetched vehicle analytics', data)
|
||||
|
||||
// For now, just return the data - frontend components can use it directly
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AnalyticsStore: Error fetching vehicle analytics', err)
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fleet analytics (aggregated)
|
||||
async function fetchFleetAnalytics() {
|
||||
// For now, use the dashboard endpoint which includes fleet metrics
|
||||
return fetchDashboardAnalytics()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
monthlyCosts,
|
||||
fuelEfficiencyTrends,
|
||||
costPerKmTrends,
|
||||
funFacts,
|
||||
businessMetrics,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
totalCosts,
|
||||
averageMonthlyCost,
|
||||
averageFuelEfficiency,
|
||||
averageCostPerKm,
|
||||
|
||||
// Actions
|
||||
addMonthlyCost,
|
||||
updateFuelEfficiency,
|
||||
updateFunFacts,
|
||||
fetchDashboardAnalytics,
|
||||
fetchVehicleAnalytics,
|
||||
fetchFleetAnalytics,
|
||||
}
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import { useGarageStore } from './garageStore'
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
// State
|
||||
const mode = ref('personal') // backend compatible values: 'personal' or 'fleet'
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Getters
|
||||
const isPrivateGarage = computed(() => mode.value === 'personal')
|
||||
const isCorporateFleet = computed(() => mode.value === 'fleet')
|
||||
|
||||
// Actions
|
||||
async function setMode(newMode) {
|
||||
// Map UI values to backend compatible values
|
||||
let backendMode = newMode
|
||||
if (newMode === 'private_garage') {
|
||||
backendMode = 'personal'
|
||||
} else if (newMode === 'corporate_fleet') {
|
||||
backendMode = 'fleet'
|
||||
}
|
||||
|
||||
if (!['personal', 'fleet'].includes(backendMode)) {
|
||||
console.error('Invalid mode:', newMode)
|
||||
return
|
||||
}
|
||||
mode.value = backendMode
|
||||
persistMode(backendMode)
|
||||
await saveModeToBackend(backendMode)
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
const newMode = mode.value === 'personal' ? 'fleet' : 'personal'
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
// SSR-safe localStorage persistence
|
||||
function persistMode(mode) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('ui_mode', mode)
|
||||
}
|
||||
}
|
||||
|
||||
function getInitialMode() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('ui_mode')
|
||||
// Map UI values to backend compatible values
|
||||
if (saved === 'private_garage' || saved === 'personal') {
|
||||
return 'personal'
|
||||
} else if (saved === 'corporate_fleet' || saved === 'fleet') {
|
||||
return 'fleet'
|
||||
}
|
||||
}
|
||||
// Default mode
|
||||
return 'personal'
|
||||
}
|
||||
|
||||
// Load user preferences from backend on app startup
|
||||
async function loadModeFromBackend() {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await api.get('/users/me')
|
||||
const user = response.data
|
||||
if (user.ui_mode && ['personal', 'fleet'].includes(user.ui_mode)) {
|
||||
mode.value = user.ui_mode
|
||||
persistMode(user.ui_mode)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load UI mode from backend, using local storage', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save mode to backend via PATCH /users/me/preferences
|
||||
async function saveModeToBackend(newMode) {
|
||||
try {
|
||||
await api.patch('/users/me/preferences', {
|
||||
ui_mode: newMode
|
||||
})
|
||||
|
||||
// Also update active organization based on mode
|
||||
// When switching to fleet mode, we need to set an active organization
|
||||
// When switching to personal mode, we should clear the active organization
|
||||
if (newMode === 'fleet') {
|
||||
// Try to get the user's active organization from auth store
|
||||
// For now, we'll just make sure the backend knows we're in fleet mode
|
||||
// The actual organization selection should happen elsewhere
|
||||
console.log('Switched to fleet mode - organization selection may be needed')
|
||||
} else if (newMode === 'personal') {
|
||||
// Clear active organization when switching to personal mode
|
||||
try {
|
||||
await api.patch('/users/me/active-organization', {
|
||||
organization_id: null
|
||||
})
|
||||
console.log('Cleared active organization for personal mode')
|
||||
} catch (orgError) {
|
||||
console.warn('Failed to clear active organization', orgError)
|
||||
// Non-critical error, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the garage store to reflect the new scope
|
||||
try {
|
||||
const garageStore = useGarageStore()
|
||||
await garageStore.fetchVehicles()
|
||||
console.log('Garage store refreshed after mode change')
|
||||
} catch (refreshError) {
|
||||
console.warn('Failed to refresh garage store', refreshError)
|
||||
// Non-critical error, continue
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save UI mode to backend', error)
|
||||
// Optionally revert local state? For now just log.
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
mode.value = getInitialMode()
|
||||
// Load from backend after store creation (call in component's onMounted)
|
||||
// We expose loadModeFromBackend for components to call
|
||||
|
||||
return {
|
||||
mode,
|
||||
isLoading,
|
||||
isPrivateGarage,
|
||||
isCorporateFleet,
|
||||
setMode,
|
||||
toggleMode,
|
||||
loadModeFromBackend,
|
||||
}
|
||||
})
|
||||
93
frontend/src/stores/appModeStore.ts
Normal file
93
frontend/src/stores/appModeStore.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import translationService from '../services/translationService'
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
// State
|
||||
const mode = ref<'B2B' | 'B2C'>('B2C')
|
||||
const language = ref('en')
|
||||
const translations = ref<Record<string, any>>({})
|
||||
|
||||
// Getters
|
||||
const isCorporate = computed(() => mode.value === 'B2B')
|
||||
const isConsumer = computed(() => mode.value === 'B2C')
|
||||
const currentLanguage = computed(() => language.value)
|
||||
|
||||
// Actions
|
||||
function toggleMode() {
|
||||
mode.value = mode.value === 'B2B' ? 'B2C' : 'B2B'
|
||||
// Save to localStorage
|
||||
localStorage.setItem('appMode', mode.value)
|
||||
}
|
||||
|
||||
function setMode(newMode: 'B2B' | 'B2C') {
|
||||
mode.value = newMode
|
||||
localStorage.setItem('appMode', newMode)
|
||||
}
|
||||
|
||||
async function setLanguage(lang: string) {
|
||||
const { locale } = useI18n()
|
||||
language.value = lang
|
||||
locale.value = lang
|
||||
localStorage.setItem('language', lang)
|
||||
|
||||
// Load translations from backend
|
||||
await loadTranslations(lang)
|
||||
}
|
||||
|
||||
async function loadTranslations(lang: string) {
|
||||
try {
|
||||
const data = await translationService.fetchTranslations(lang)
|
||||
translations.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to load translations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function getTranslation(key: string, defaultValue = ''): string {
|
||||
const keys = key.split('.')
|
||||
let value: any = translations.value
|
||||
for (const k of keys) {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k]
|
||||
} else {
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
return typeof value === 'string' ? value : defaultValue
|
||||
}
|
||||
|
||||
// Initialize from localStorage
|
||||
function init() {
|
||||
const savedMode = localStorage.getItem('appMode') as 'B2B' | 'B2C'
|
||||
if (savedMode) {
|
||||
mode.value = savedMode
|
||||
}
|
||||
|
||||
const savedLang = localStorage.getItem('language')
|
||||
if (savedLang) {
|
||||
language.value = savedLang
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
mode,
|
||||
language,
|
||||
translations,
|
||||
|
||||
// Getters
|
||||
isCorporate,
|
||||
isConsumer,
|
||||
currentLanguage,
|
||||
|
||||
// Actions
|
||||
toggleMode,
|
||||
setMode,
|
||||
setLanguage,
|
||||
loadTranslations,
|
||||
getTranslation,
|
||||
init
|
||||
}
|
||||
})
|
||||
258
frontend/src/stores/auth.ts
Normal file
258
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '../router'
|
||||
import api from '../api/axios'
|
||||
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
is_active: boolean
|
||||
role: string
|
||||
region_code: string
|
||||
subscription_plan: string
|
||||
scope_level: string
|
||||
scope_id: string | null
|
||||
ui_mode: string
|
||||
active_organization_id: number | null
|
||||
// person_id is set when KYC is complete (Person record exists)
|
||||
person_id: number | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ────────────────────────────── State ──────────────────────────────
|
||||
const user = ref<UserProfile | null>(null)
|
||||
const token = ref<string | null>(localStorage.getItem('access_token'))
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ────────────────────────────── Getters ────────────────────────────
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isKycComplete = computed(() => {
|
||||
// KYC is complete when the user has a linked Person record (person_id is set)
|
||||
return !!user.value?.person_id
|
||||
})
|
||||
const userRole = computed(() => user.value?.role ?? 'guest')
|
||||
const userName = computed(() => {
|
||||
if (!user.value) return ''
|
||||
const { first_name, last_name } = user.value
|
||||
if (first_name && last_name) return `${first_name} ${last_name}`
|
||||
return first_name || last_name || user.value.email
|
||||
})
|
||||
const userId = computed(() => user.value?.id ?? null)
|
||||
|
||||
// ────────────────────────────── Actions ────────────────────────────
|
||||
|
||||
/**
|
||||
* Login with email + password.
|
||||
* The FastAPI /auth/login endpoint uses OAuth2PasswordRequestForm,
|
||||
* which expects application/x-www-form-urlencoded with `username` and `password`.
|
||||
*/
|
||||
async function login(email: string, password: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams()
|
||||
body.append('username', email) // FastAPI OAuth2 form uses `username` key
|
||||
body.append('password', password)
|
||||
|
||||
const res = await api.post('/auth/login', body, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
|
||||
const data = res.data as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
// Persist token
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
}
|
||||
token.value = data.access_token
|
||||
|
||||
// Immediately fetch the user profile
|
||||
await fetchUser()
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Bejelentkezés sikertelen. Ellenőrizd az adataidat.'
|
||||
error.value = message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user profile from /auth/me.
|
||||
*/
|
||||
async function fetchUser() {
|
||||
if (!token.value) return
|
||||
|
||||
try {
|
||||
const res = await api.get('/auth/me')
|
||||
user.value = res.data as UserProfile
|
||||
} catch (err: any) {
|
||||
// If the token is invalid, clear everything
|
||||
if (err.response?.status === 401) {
|
||||
logout()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user.
|
||||
* The /auth/register endpoint expects JSON with UserLiteRegister schema.
|
||||
* Default hidden values (region_code, lang, timezone) are appended here.
|
||||
*/
|
||||
async function register(data: {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...data,
|
||||
region_code: 'HU',
|
||||
lang: 'hu',
|
||||
timezone: 'Europe/Budapest',
|
||||
}
|
||||
|
||||
const res = await api.post('/auth/register', payload)
|
||||
|
||||
return res.data as {
|
||||
status: string
|
||||
message: string
|
||||
user_id: number
|
||||
email: string
|
||||
}
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Regisztráció sikertelen.'
|
||||
error.value = message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a password reset email.
|
||||
*/
|
||||
async function forgotPassword(email: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.post('/auth/forgot-password', { email })
|
||||
return res.data as { status: string; message: string }
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Hiba történt a jelszó-visszaállítási kérelem elküldésekor.'
|
||||
error.value = message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout — clear token, user state, and localStorage.
|
||||
*/
|
||||
function logout() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
error.value = null
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Redirect to the landing page
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store on app load — if a token exists, fetch the user.
|
||||
*/
|
||||
async function init() {
|
||||
if (token.value) {
|
||||
try {
|
||||
await fetchUser()
|
||||
} catch {
|
||||
// Token invalid — already cleared by fetchUser → logout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete KYC (Know Your Customer) — full profile submission.
|
||||
* POSTs the KYC data to /auth/complete-kyc, then refreshes the user profile.
|
||||
* Returns true on success, throws on failure.
|
||||
*/
|
||||
async function completeKyc(kycData: Record<string, any>): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await api.post('/auth/complete-kyc', kycData)
|
||||
// Refresh the user profile so isKycComplete getter updates
|
||||
await fetchUser()
|
||||
return true
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'A KYC kitöltése sikertelen. Kérlek ellenőrizd az adatokat.'
|
||||
error.value = message
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any stored error message.
|
||||
*/
|
||||
function clearError() {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
isKycComplete,
|
||||
userRole,
|
||||
userName,
|
||||
userId,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
fetchUser,
|
||||
register,
|
||||
forgotPassword,
|
||||
completeKyc,
|
||||
logout,
|
||||
init,
|
||||
clearError,
|
||||
}
|
||||
})
|
||||
@@ -1,322 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '../router'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const isAdmin = ref(localStorage.getItem('is_admin') === 'true')
|
||||
const userEmail = ref(localStorage.getItem('user_email') || '')
|
||||
const userRole = ref(localStorage.getItem('user_role') || '')
|
||||
const userProfile = ref(null) // Full user profile from /users/me
|
||||
const activeOrgId = ref(localStorage.getItem('active_org_id') || null) // Active organization ID
|
||||
|
||||
// Getters
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const isTester = computed(() => userEmail.value === 'tester_pro@profibot.hu' || userRole.value === 'tester')
|
||||
const displayName = computed(() => {
|
||||
if (isTester.value) return 'TESTER PRO'
|
||||
if (isAdmin.value) return 'ADMIN'
|
||||
return 'USER'
|
||||
})
|
||||
|
||||
// Actions
|
||||
const login = async (email, password) => {
|
||||
console.log('AuthStore: Real API login attempt for', email)
|
||||
|
||||
try {
|
||||
// Prepare URL-encoded form data for OAuth2 password grant
|
||||
// FastAPI's OAuth2PasswordRequestForm expects application/x-www-form-urlencoded
|
||||
const params = new URLSearchParams()
|
||||
params.append('username', email)
|
||||
params.append('password', password)
|
||||
|
||||
// Call real backend API
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
|
||||
const response = await fetch(`${apiBaseUrl}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('AuthStore: Login API error', response.status, errorText)
|
||||
throw new Error(`Login failed: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AuthStore: Login API response', data)
|
||||
|
||||
// Extract token and user info
|
||||
const accessToken = data.access_token
|
||||
const refreshToken = data.refresh_token
|
||||
const tokenType = data.token_type
|
||||
const isActive = data.is_active
|
||||
|
||||
// We need to decode the JWT token to get user role and info
|
||||
// For now, we'll make a separate API call to get user info
|
||||
// Or we can parse the JWT token (simple base64 decode)
|
||||
let roleValue = 'user'
|
||||
let adminFlag = false
|
||||
|
||||
try {
|
||||
// Decode JWT token to get payload
|
||||
const tokenParts = accessToken.split('.')
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]))
|
||||
roleValue = payload.role || 'user'
|
||||
adminFlag = roleValue === 'admin' || roleValue === 'superadmin'
|
||||
console.log('AuthStore: Decoded JWT payload', payload)
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn('AuthStore: Could not decode JWT token', decodeError)
|
||||
// Fallback: Make API call to get user info
|
||||
// For now, we'll use a default role
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('token', accessToken)
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
localStorage.setItem('is_admin', adminFlag.toString())
|
||||
localStorage.setItem('user_email', email)
|
||||
localStorage.setItem('user_role', roleValue)
|
||||
|
||||
// Update store state
|
||||
token.value = accessToken
|
||||
isAdmin.value = adminFlag
|
||||
userEmail.value = email
|
||||
userRole.value = roleValue
|
||||
|
||||
console.log('AuthStore: State updated, redirecting to /profile-select')
|
||||
|
||||
// Redirect to profile-select (as per router logic)
|
||||
try {
|
||||
await router.push('/profile-select')
|
||||
console.log('AuthStore: Redirect successful')
|
||||
} catch (error) {
|
||||
console.error('AuthStore: Router redirect failed:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return { success: true, token: accessToken, isAdmin: adminFlag, role: roleValue }
|
||||
|
||||
} catch (error) {
|
||||
console.error('AuthStore: Login failed', error)
|
||||
throw error // Re-throw the error instead of falling back to mock
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('is_admin')
|
||||
localStorage.removeItem('user_email')
|
||||
localStorage.removeItem('user_role')
|
||||
localStorage.removeItem('ui_mode') // Also clear UI mode on logout
|
||||
localStorage.removeItem('active_org_id') // Clear active organization ID
|
||||
|
||||
// Reset store state
|
||||
token.value = ''
|
||||
isAdmin.value = false
|
||||
userEmail.value = ''
|
||||
userRole.value = ''
|
||||
userProfile.value = null
|
||||
activeOrgId.value = null
|
||||
|
||||
// Redirect to login
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
if (!token.value) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
|
||||
const response = await fetch(`${apiBaseUrl}/users/me`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user profile: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AuthStore: Fetched user profile', data)
|
||||
|
||||
// Update user profile state
|
||||
userProfile.value = data
|
||||
|
||||
// Also update email and role from profile if available
|
||||
if (data.email && !userEmail.value) {
|
||||
userEmail.value = data.email
|
||||
localStorage.setItem('user_email', data.email)
|
||||
}
|
||||
|
||||
if (data.role && !userRole.value) {
|
||||
userRole.value = data.role
|
||||
localStorage.setItem('user_role', data.role)
|
||||
isAdmin.value = data.role === 'admin' || data.role === 'superadmin'
|
||||
localStorage.setItem('is_admin', isAdmin.value.toString())
|
||||
}
|
||||
|
||||
// Store active organization ID if available
|
||||
if (data.active_organization_id !== undefined) {
|
||||
activeOrgId.value = data.active_organization_id
|
||||
localStorage.setItem('active_org_id', data.active_organization_id)
|
||||
console.log('AuthStore: Set active organization ID:', data.active_organization_id)
|
||||
} else if (data.scope_id) {
|
||||
// Fallback to scope_id if active_organization_id is not provided
|
||||
activeOrgId.value = data.scope_id
|
||||
localStorage.setItem('active_org_id', data.scope_id)
|
||||
console.log('AuthStore: Set active organization ID from scope_id:', data.scope_id)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AuthStore: Error fetching user profile', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Update active organization
|
||||
const updateActiveOrganization = async (organizationId) => {
|
||||
if (!token.value) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu';
|
||||
const response = await fetch(`${apiBaseUrl}/users/me/active-organization`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
organization_id: organizationId
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update active organization: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('AuthStore: Updated active organization', data)
|
||||
|
||||
// Check if response contains new JWT token (backend now returns {user: {...}, access_token: "...", token_type: "bearer"})
|
||||
if (data.access_token) {
|
||||
console.log('AuthStore: Received new access token from organization switch')
|
||||
|
||||
// Update token in localStorage and store state
|
||||
localStorage.setItem('token', data.access_token)
|
||||
token.value = data.access_token
|
||||
|
||||
// Decode new token to update role if needed
|
||||
try {
|
||||
const tokenParts = data.access_token.split('.')
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]))
|
||||
const roleValue = payload.role || 'user'
|
||||
const adminFlag = roleValue === 'admin' || roleValue === 'superadmin'
|
||||
|
||||
localStorage.setItem('user_role', roleValue)
|
||||
localStorage.setItem('is_admin', adminFlag.toString())
|
||||
|
||||
userRole.value = roleValue
|
||||
isAdmin.value = adminFlag
|
||||
console.log('AuthStore: Updated role from new token:', roleValue)
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn('AuthStore: Could not decode new JWT token', decodeError)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state
|
||||
activeOrgId.value = organizationId
|
||||
localStorage.setItem('active_org_id', organizationId || '')
|
||||
|
||||
// Also update user profile to reflect changes
|
||||
await fetchUserProfile()
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('AuthStore: Error updating active organization', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuth = () => {
|
||||
// Sync with localStorage on page load
|
||||
const storedToken = localStorage.getItem('token') || ''
|
||||
const storedEmail = localStorage.getItem('user_email') || ''
|
||||
const storedRole = localStorage.getItem('user_role') || ''
|
||||
|
||||
// Try to decode JWT token to get role if not stored
|
||||
let decodedRole = storedRole
|
||||
let decodedIsAdmin = storedRole === 'admin' || storedRole === 'superadmin'
|
||||
|
||||
if (storedToken && !storedRole) {
|
||||
try {
|
||||
// Decode JWT token to get payload
|
||||
const tokenParts = storedToken.split('.')
|
||||
if (tokenParts.length === 3) {
|
||||
const payload = JSON.parse(atob(tokenParts[1]))
|
||||
decodedRole = payload.role || 'user'
|
||||
decodedIsAdmin = decodedRole === 'admin' || decodedRole === 'superadmin'
|
||||
console.log('AuthStore: Decoded JWT on checkAuth', payload)
|
||||
|
||||
// Update localStorage with decoded role
|
||||
localStorage.setItem('user_role', decodedRole)
|
||||
localStorage.setItem('is_admin', decodedIsAdmin.toString())
|
||||
}
|
||||
} catch (decodeError) {
|
||||
console.warn('AuthStore: Could not decode JWT token on checkAuth', decodeError)
|
||||
}
|
||||
} else if (storedToken && storedRole) {
|
||||
// Use stored role
|
||||
decodedIsAdmin = storedRole === 'admin' || storedRole === 'superadmin'
|
||||
}
|
||||
|
||||
token.value = storedToken
|
||||
isAdmin.value = decodedIsAdmin
|
||||
userEmail.value = storedEmail
|
||||
userRole.value = decodedRole || 'user'
|
||||
}
|
||||
|
||||
// Initialize on store creation
|
||||
checkAuth()
|
||||
|
||||
return {
|
||||
// State
|
||||
token,
|
||||
isAdmin,
|
||||
userEmail,
|
||||
userRole,
|
||||
userProfile,
|
||||
activeOrgId,
|
||||
|
||||
// Getters
|
||||
isLoggedIn,
|
||||
isTester,
|
||||
displayName,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
fetchUserProfile,
|
||||
updateActiveOrganization
|
||||
}
|
||||
})
|
||||
85
frontend/src/stores/authStore.ts
Normal file
85
frontend/src/stores/authStore.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export type UserRole = 'guest' | 'user' | 'manager' | 'admin' | 'superadmin'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const isAuthenticated = ref(false)
|
||||
const userRole = ref<UserRole>('guest')
|
||||
const userName = ref('')
|
||||
const userId = ref<number | null>(null)
|
||||
|
||||
// Getters
|
||||
const isAdmin = computed(() => {
|
||||
return userRole.value === 'admin' || userRole.value === 'superadmin'
|
||||
})
|
||||
|
||||
const isSuperAdmin = computed(() => userRole.value === 'superadmin')
|
||||
const isManager = computed(() => userRole.value === 'manager')
|
||||
const isRegularUser = computed(() => userRole.value === 'user')
|
||||
|
||||
// Actions
|
||||
function login(role: UserRole, name: string, id: number) {
|
||||
isAuthenticated.value = true
|
||||
userRole.value = role
|
||||
userName.value = name
|
||||
userId.value = id
|
||||
localStorage.setItem('auth', JSON.stringify({ role, name, id }))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
isAuthenticated.value = false
|
||||
userRole.value = 'guest'
|
||||
userName.value = ''
|
||||
userId.value = null
|
||||
localStorage.removeItem('auth')
|
||||
}
|
||||
|
||||
function init() {
|
||||
const saved = localStorage.getItem('auth')
|
||||
if (saved) {
|
||||
try {
|
||||
const { role, name, id } = JSON.parse(saved)
|
||||
isAuthenticated.value = true
|
||||
userRole.value = role
|
||||
userName.value = name
|
||||
userId.value = id
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate fetching user role from backend
|
||||
async function fetchUserRole() {
|
||||
// In a real app, this would be an API call
|
||||
// For now, we'll simulate based on localStorage
|
||||
const saved = localStorage.getItem('auth')
|
||||
if (saved) {
|
||||
const { role } = JSON.parse(saved)
|
||||
userRole.value = role
|
||||
}
|
||||
return userRole.value
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isAuthenticated,
|
||||
userRole,
|
||||
userName,
|
||||
userId,
|
||||
|
||||
// Getters
|
||||
isAdmin,
|
||||
isSuperAdmin,
|
||||
isManager,
|
||||
isRegularUser,
|
||||
|
||||
// Actions
|
||||
login,
|
||||
logout,
|
||||
init,
|
||||
fetchUserRole
|
||||
}
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
export const useExpenseStore = defineStore('expense', () => {
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function createExpense(expenseData) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api.post('/expenses/', expenseData)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || err.message
|
||||
// Check for DRAFT_LIMIT_REACHED error
|
||||
if (err.response?.status === 403 && err.response?.data?.detail === "DRAFT_LIMIT_REACHED") {
|
||||
const draftError = new Error('DRAFT_LIMIT_REACHED')
|
||||
draftError.response = err.response
|
||||
throw draftError
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
createExpense,
|
||||
}
|
||||
})
|
||||
@@ -1,158 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
export const useGamificationStore = defineStore('gamification', () => {
|
||||
// State
|
||||
const achievements = ref([])
|
||||
const badges = ref([])
|
||||
const userStats = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const earnedAchievements = computed(() =>
|
||||
achievements.value.filter(a => a.is_earned)
|
||||
)
|
||||
|
||||
const lockedAchievements = computed(() =>
|
||||
achievements.value.filter(a => !a.is_earned)
|
||||
)
|
||||
|
||||
const totalAchievements = computed(() => achievements.value.length)
|
||||
|
||||
const earnedCount = computed(() => earnedAchievements.value.length)
|
||||
|
||||
const progressPercentage = computed(() => {
|
||||
if (totalAchievements.value === 0) return 0
|
||||
return Math.round((earnedCount.value / totalAchievements.value) * 100)
|
||||
})
|
||||
|
||||
const earnedBadges = computed(() =>
|
||||
badges.value.filter(b => b.is_earned)
|
||||
)
|
||||
|
||||
// Helper function for API calls (using centralized api instance)
|
||||
async function apiFetch(url, options = {}) {
|
||||
try {
|
||||
const response = await api.get(url, options)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
throw new Error(`API error ${error.response.status}: ${JSON.stringify(error.response.data)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function fetchAchievements() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/achievements')
|
||||
achievements.value = data.achievements || []
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch achievements:', err)
|
||||
error.value = err.message
|
||||
// No mock fallback - let the error propagate
|
||||
achievements.value = []
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBadges() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/my-badges')
|
||||
badges.value = data.map(badge => ({
|
||||
id: badge.badge_id,
|
||||
title: badge.badge_name,
|
||||
description: badge.badge_description,
|
||||
icon_url: badge.badge_icon_url,
|
||||
is_earned: true,
|
||||
earned_date: badge.earned_at,
|
||||
category: 'badge'
|
||||
}))
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch badges:', err)
|
||||
error.value = err.message
|
||||
// No mock fallback - propagate error
|
||||
badges.value = []
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserStats() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/me')
|
||||
userStats.value = data
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user stats:', err)
|
||||
error.value = err.message
|
||||
// No mock fallback - propagate error
|
||||
userStats.value = null
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllGamificationData() {
|
||||
await Promise.all([
|
||||
fetchAchievements(),
|
||||
fetchBadges(),
|
||||
fetchUserStats()
|
||||
])
|
||||
}
|
||||
|
||||
async function earnAchievement(id) {
|
||||
// In a real implementation, this would call an API endpoint
|
||||
// For now, we'll just update local state
|
||||
const achievement = achievements.value.find(a => a.id === id)
|
||||
if (achievement && !achievement.is_earned) {
|
||||
achievement.is_earned = true
|
||||
achievement.earned_date = new Date().toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
function resetAchievements() {
|
||||
achievements.value.forEach(a => {
|
||||
a.is_earned = false
|
||||
a.earned_date = null
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize store with data - RE-ENABLED after token fix
|
||||
fetchAllGamificationData()
|
||||
|
||||
return {
|
||||
achievements,
|
||||
badges,
|
||||
userStats,
|
||||
earnedAchievements,
|
||||
lockedAchievements,
|
||||
totalAchievements,
|
||||
earnedCount,
|
||||
progressPercentage,
|
||||
earnedBadges,
|
||||
isLoading,
|
||||
error,
|
||||
fetchAchievements,
|
||||
fetchBadges,
|
||||
fetchUserStats,
|
||||
fetchAllGamificationData,
|
||||
earnAchievement,
|
||||
resetAchievements
|
||||
}
|
||||
})
|
||||
@@ -1,298 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
export const useGarageStore = defineStore('garage', () => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Real vehicle data - initially empty, will be fetched from API
|
||||
const vehicles = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const totalVehicles = computed(() => vehicles.value.length)
|
||||
const totalMonthlyExpense = computed(() =>
|
||||
vehicles.value.reduce((sum, vehicle) => sum + (vehicle.monthlyExpense || 0), 0)
|
||||
)
|
||||
const vehiclesNeedingService = computed(() =>
|
||||
vehicles.value.filter(v => v.status === 'Service Due' || v.status === 'Warning').length
|
||||
)
|
||||
|
||||
// Actions
|
||||
async function addVehicle(vehicle) {
|
||||
// Real API call to POST /api/v1/assets (Thick Asset endpoint)
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
try {
|
||||
// Transform frontend vehicle data to Thick Asset API schema
|
||||
// Include all required fields for thick asset creation
|
||||
const payload = {
|
||||
// Core identification
|
||||
vin: vehicle.vin || null, // Send null for draft vehicles
|
||||
license_plate: vehicle.licensePlate || 'N/A',
|
||||
catalog_id: vehicle.catalogId || null,
|
||||
organization_id: vehicle.organizationId || authStore.activeOrgId,
|
||||
|
||||
// Ownership fields (required by AssetCreate schema)
|
||||
owner_org_id: vehicle.owner_org_id || null,
|
||||
operator_org_id: vehicle.operator_org_id || null,
|
||||
|
||||
// Thick Asset fields - send even if catalog_id is provided for completeness
|
||||
brand: vehicle.brand || vehicle.make || null,
|
||||
model: vehicle.model || null,
|
||||
vehicle_class: vehicle.vehicleClass || vehicle.class || null,
|
||||
fuel_type: vehicle.fuelType || vehicle.fuel || null,
|
||||
year_of_manufacture: vehicle.year || vehicle.yearOfManufacture || null,
|
||||
engine_capacity: vehicle.engineCapacity || null,
|
||||
power_kw: vehicle.powerKw || null,
|
||||
transmission: vehicle.transmission || null,
|
||||
body_type: vehicle.bodyType || null,
|
||||
color: vehicle.color || null,
|
||||
current_mileage: vehicle.currentMileage || vehicle.mileage || 0,
|
||||
|
||||
// Metadata
|
||||
status: vehicle.status || 'draft', // Default to draft for 2-step creation
|
||||
data_status: 'incomplete', // Will be updated by backend snapshot sync
|
||||
profile_completion_percentage: 0 // Will be calculated by backend
|
||||
}
|
||||
|
||||
// Remove null values to keep payload clean
|
||||
Object.keys(payload).forEach(key => {
|
||||
if (payload[key] === null || payload[key] === undefined) {
|
||||
delete payload[key]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/api/v1/assets/vehicles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Failed to add vehicle: ${response.status} ${response.statusText}`
|
||||
|
||||
// Handle specific error cases
|
||||
if (response.status === 409) {
|
||||
errorMessage = 'Duplicate VIN or license plate detected. Please check your input.'
|
||||
} else if (response.status === 429) {
|
||||
errorMessage = 'Rate limit exceeded. Please try again later.'
|
||||
} else if (response.status === 400) {
|
||||
errorMessage = `Invalid input: ${errorText}`
|
||||
} else if (response.status === 403) {
|
||||
errorMessage = 'Permission denied. You may have reached your vehicle limit.'
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('GarageStore: Thick Asset created successfully', data)
|
||||
|
||||
// After successful save, fetch fresh data from server to ensure consistency
|
||||
await fetchVehicles()
|
||||
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('GarageStore: Error adding vehicle', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function removeVehicle(id) {
|
||||
// In a real app, this would be an API call to DELETE /assets/{id}
|
||||
vehicles.value = vehicles.value.filter(v => v.id !== id)
|
||||
}
|
||||
|
||||
function updateVehicle(id, updates) {
|
||||
// In a real app, this would be an API call to PATCH /assets/{id}
|
||||
const index = vehicles.value.findIndex(v => v.id === id)
|
||||
if (index !== -1) {
|
||||
vehicles.value[index] = { ...vehicles.value[index], ...updates }
|
||||
}
|
||||
}
|
||||
|
||||
function getVehicleById(id) {
|
||||
return vehicles.value.find(v => v.id === id)
|
||||
}
|
||||
|
||||
// Real API fetch - NO MORE MOCK DATA
|
||||
async function fetchVehicles() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
const token = authStore.token
|
||||
|
||||
if (!token) {
|
||||
console.error('GarageStore: No authentication token available')
|
||||
throw new Error('Not authenticated')
|
||||
}
|
||||
|
||||
console.log('GarageStore: Starting vehicle fetch with token', token.substring(0, 20) + '...')
|
||||
|
||||
// Call real backend API
|
||||
// First try the assets endpoint for user's vehicles
|
||||
const apiUrl = `${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/assets/vehicles`
|
||||
console.log('GarageStore: Fetching from primary endpoint:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('GarageStore: Primary endpoint response status:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
// If 404, try alternative endpoint
|
||||
if (response.status === 404) {
|
||||
console.log('GarageStore: Primary endpoint returned 404, trying user assets endpoint')
|
||||
// Try user assets endpoint
|
||||
const userApiUrl = `${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/users/me/assets`
|
||||
const userResponse = await fetch(userApiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
console.log('GarageStore: User assets endpoint response status:', userResponse.status, userResponse.statusText)
|
||||
|
||||
if (!userResponse.ok) {
|
||||
let errorBody = ''
|
||||
try {
|
||||
errorBody = await userResponse.text()
|
||||
} catch (e) {
|
||||
errorBody = 'Could not read error response'
|
||||
}
|
||||
console.error('GarageStore: User assets endpoint failed:', {
|
||||
status: userResponse.status,
|
||||
statusText: userResponse.statusText,
|
||||
body: errorBody
|
||||
})
|
||||
throw new Error(`Failed to fetch vehicles: ${userResponse.status} ${userResponse.statusText}`)
|
||||
}
|
||||
|
||||
const data = await userResponse.json()
|
||||
console.log('GarageStore: Fetched vehicles from user assets endpoint', data)
|
||||
vehicles.value = transformApiResponse(data)
|
||||
} else {
|
||||
let errorBody = ''
|
||||
try {
|
||||
errorBody = await response.text()
|
||||
} catch (e) {
|
||||
errorBody = 'Could not read error response'
|
||||
}
|
||||
console.error('GarageStore: Primary endpoint failed:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: errorBody
|
||||
})
|
||||
throw new Error(`Failed to fetch vehicles: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
} else {
|
||||
const data = await response.json()
|
||||
console.log('GarageStore: Fetched vehicles from assets endpoint', data)
|
||||
vehicles.value = transformApiResponse(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('GarageStore: Error fetching vehicles', err)
|
||||
console.error('GarageStore: Full error stack:', err.stack)
|
||||
error.value = err.message
|
||||
// NO MORE MOCK DATA FALLBACK - fail properly
|
||||
vehicles.value = []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
console.log('GarageStore: Fetch completed, vehicles count:', vehicles.value.length)
|
||||
return vehicles.value
|
||||
}
|
||||
|
||||
// Helper function to transform API response to frontend format
|
||||
function transformApiResponse(data) {
|
||||
if (!Array.isArray(data)) {
|
||||
// If single object, wrap in array
|
||||
data = [data]
|
||||
}
|
||||
|
||||
return data.map(item => {
|
||||
// Safely extract catalog properties with optional chaining
|
||||
const catalog = item.catalog || {}
|
||||
const catalogMake = catalog?.make || catalog?.brand || null
|
||||
const catalogModel = catalog?.model || null
|
||||
const catalogFuelType = catalog?.fuel_type || catalog?.fuel || null
|
||||
const catalogYear = catalog?.year || catalog?.year_of_manufacture || null
|
||||
|
||||
// Safely extract profile completion percentage
|
||||
const profileCompletion = item.profile_completion_percentage ||
|
||||
item.profile_completion ||
|
||||
(item.profile_status === 'complete' ? 100 : 0) || 0
|
||||
|
||||
// Extract data_status from API (new field for vehicle data completeness)
|
||||
const dataStatus = item.data_status || item.status || (item.is_active ? 'active' : 'draft')
|
||||
|
||||
return {
|
||||
id: item.id || item.asset_id || 0,
|
||||
make: item.make || item.brand || item.vehicle_make || catalogMake || 'Unknown',
|
||||
model: item.model || item.vehicle_model || catalogModel || 'Unknown',
|
||||
year: item.year || item.year_of_manufacture || item.manufacture_year || catalogYear || 2023,
|
||||
licensePlate: item.license_plate || item.registration_number || 'N/A',
|
||||
status: item.status || (item.is_active ? 'active' : 'draft'),
|
||||
data_status: dataStatus,
|
||||
profile_completion_percentage: profileCompletion,
|
||||
monthlyExpense: item.monthly_expense || item.average_monthly_cost || 0,
|
||||
fuelType: item.fuel_type || item.fuel || catalogFuelType || 'Unknown',
|
||||
mileage: item.mileage || item.current_mileage || item.odometer_reading || 0,
|
||||
imageUrl: item.image_url || item.photo_url || getDefaultImage(item.make || item.brand || catalogMake),
|
||||
// Include catalog object for template access with null safety
|
||||
catalog: catalog || null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to get default image based on make
|
||||
function getDefaultImage(make) {
|
||||
// Handle null/undefined make with optional chaining and fallback
|
||||
const makeLower = (make || '').toLowerCase()
|
||||
if (makeLower.includes('bmw')) {
|
||||
return 'https://images.unsplash.com/photo-1555215695-3004980ad54e?w=400&h=300&fit=crop'
|
||||
} else if (makeLower.includes('audi')) {
|
||||
return 'https://images.unsplash.com/photo-1553440569-bcc63803a83d?w=400&h=300&fit=crop'
|
||||
} else if (makeLower.includes('mercedes')) {
|
||||
return 'https://images.unsplash.com/photo-1563720223485-8d6d5c5c8c3b?w=400&h=300&fit=crop'
|
||||
} else if (makeLower.includes('tesla')) {
|
||||
return 'https://images.unsplash.com/photo-1560958089-b8a1929cea89?w=400&h=300&fit=crop'
|
||||
}
|
||||
return 'https://images.unsplash.com/photo-1549399542-7e3f8b79c341?w=400&h=300&fit=crop'
|
||||
}
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
isLoading,
|
||||
error,
|
||||
totalVehicles,
|
||||
totalMonthlyExpense,
|
||||
vehiclesNeedingService,
|
||||
addVehicle,
|
||||
removeVehicle,
|
||||
updateVehicle,
|
||||
getVehicleById,
|
||||
fetchVehicles
|
||||
}
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useQuizStore = defineStore('quiz', () => {
|
||||
// State
|
||||
const userPoints = ref(0)
|
||||
const currentStreak = ref(0)
|
||||
const lastPlayedDate = ref(null)
|
||||
const questions = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const canPlayToday = computed(() => {
|
||||
if (!lastPlayedDate.value) return true
|
||||
const last = new Date(lastPlayedDate.value)
|
||||
const now = new Date()
|
||||
// Reset at midnight (different calendar day)
|
||||
const lastDay = last.toDateString()
|
||||
const today = now.toDateString()
|
||||
return lastDay !== today
|
||||
})
|
||||
|
||||
const totalQuestions = computed(() => questions.value.length)
|
||||
|
||||
// Helper function to get auth token
|
||||
function getAuthToken() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Try both token keys for compatibility
|
||||
return localStorage.getItem('token') || localStorage.getItem('auth_token')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Helper function for API calls
|
||||
async function apiFetch(url, options = {}) {
|
||||
const token = getAuthToken()
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}${url}`, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API error ${response.status}: ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Actions
|
||||
async function fetchQuizStats() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/quiz/stats')
|
||||
userPoints.value = data.total_quiz_points || 0
|
||||
currentStreak.value = data.current_streak || 0
|
||||
lastPlayedDate.value = data.last_played || null
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch quiz stats:', err)
|
||||
error.value = err.message
|
||||
// Fallback to localStorage if API fails
|
||||
userPoints.value = getStoredPoints()
|
||||
currentStreak.value = getStoredStreak()
|
||||
lastPlayedDate.value = getStoredLastPlayedDate()
|
||||
return {
|
||||
total_quiz_points: userPoints.value,
|
||||
current_streak: currentStreak.value,
|
||||
last_played: lastPlayedDate.value,
|
||||
can_play_today: canPlayToday.value
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDailyQuiz() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await apiFetch('/gamification/quiz/daily')
|
||||
questions.value = data.questions || []
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch daily quiz:', err)
|
||||
error.value = err.message
|
||||
// Fallback to mock questions if API fails
|
||||
questions.value = getMockQuestions()
|
||||
return {
|
||||
questions: questions.value,
|
||||
total_questions: questions.value.length,
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function answerQuestion(questionId, selectedOptionIndex) {
|
||||
try {
|
||||
const response = await apiFetch('/gamification/quiz/answer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
question_id: questionId,
|
||||
selected_option: selectedOptionIndex
|
||||
})
|
||||
})
|
||||
|
||||
if (response.is_correct) {
|
||||
userPoints.value += response.points_awarded
|
||||
currentStreak.value += 1
|
||||
persistState() // Update localStorage as fallback
|
||||
} else {
|
||||
currentStreak.value = 0
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (err) {
|
||||
console.error('Failed to submit quiz answer:', err)
|
||||
error.value = err.message
|
||||
// Fallback to local logic
|
||||
return answerQuestionLocal(questionId, selectedOptionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
async function completeDailyQuiz() {
|
||||
try {
|
||||
await apiFetch('/gamification/quiz/complete', {
|
||||
method: 'POST'
|
||||
})
|
||||
lastPlayedDate.value = new Date().toISOString()
|
||||
persistState()
|
||||
} catch (err) {
|
||||
console.error('Failed to complete daily quiz:', err)
|
||||
error.value = err.message
|
||||
// Fallback to local storage
|
||||
lastPlayedDate.value = new Date().toISOString()
|
||||
persistState()
|
||||
}
|
||||
}
|
||||
|
||||
// Local fallback functions
|
||||
function answerQuestionLocal(questionId, selectedOptionIndex) {
|
||||
const question = questions.value.find(q => q.id === questionId)
|
||||
if (!question) return { is_correct: false, correct_answer: -1, explanation: 'Question not found' }
|
||||
|
||||
const isCorrect = selectedOptionIndex === question.correctAnswer
|
||||
if (isCorrect) {
|
||||
userPoints.value += 10
|
||||
currentStreak.value += 1
|
||||
} else {
|
||||
currentStreak.value = 0
|
||||
}
|
||||
|
||||
persistState()
|
||||
return {
|
||||
is_correct: isCorrect,
|
||||
correct_answer: question.correctAnswer,
|
||||
points_awarded: isCorrect ? 10 : 0,
|
||||
explanation: question.explanation
|
||||
}
|
||||
}
|
||||
|
||||
function resetStreak() {
|
||||
currentStreak.value = 0
|
||||
persistState()
|
||||
}
|
||||
|
||||
function addPoints(points) {
|
||||
userPoints.value += points
|
||||
persistState()
|
||||
}
|
||||
|
||||
// SSR-safe localStorage persistence (fallback only)
|
||||
function persistState() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('quiz_points', userPoints.value.toString())
|
||||
localStorage.setItem('quiz_streak', currentStreak.value.toString())
|
||||
localStorage.setItem('quiz_last_played', lastPlayedDate.value)
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredPoints() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem('quiz_points') || '0')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getStoredStreak() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return parseInt(localStorage.getItem('quiz_streak') || '0')
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getStoredLastPlayedDate() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('quiz_last_played') || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMockQuestions() {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
question: 'Melyik alkatrész felelős a motor levegő‑üzemanyag keverékének szabályozásáért?',
|
||||
options: ['Generátor', 'Lambda‑szonda', 'Féktárcsa', 'Olajszűrő'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'A lambda‑szonda méri a kipufogógáz oxigéntartalmát, és ezen alapul a befecskendezés.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
question: 'Mennyi ideig érvényes egy gépjármű műszaki vizsgája Magyarországon?',
|
||||
options: ['1 év', '2 év', '4 év', '6 év'],
|
||||
correctAnswer: 1,
|
||||
explanation: 'A személygépkocsik műszaki vizsgája 2 évre érvényes, kivéve az újonnan forgalomba helyezett autókat.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
question: 'Melyik anyag NEM része a hibrid autók akkumulátorának?',
|
||||
options: ['Lítium', 'Nikkel', 'Ólom', 'Kobalt'],
|
||||
correctAnswer: 2,
|
||||
explanation: 'A hibrid és elektromos autók akkumulátoraiban általában lítium, nikkel és kobalt található, ólom az ólom‑savas akkukban van.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Initialize store with stats - DISABLED for debugging
|
||||
// fetchQuizStats()
|
||||
console.log('🚨 Quiz store: Auto-fetch DISABLED for debugging')
|
||||
|
||||
return {
|
||||
userPoints,
|
||||
currentStreak,
|
||||
lastPlayedDate,
|
||||
questions,
|
||||
canPlayToday,
|
||||
totalQuestions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchQuizStats,
|
||||
fetchDailyQuiz,
|
||||
answerQuestion,
|
||||
completeDailyQuiz,
|
||||
resetStreak,
|
||||
addPoints
|
||||
}
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useThemeStore = defineStore('theme', {
|
||||
state: () => ({
|
||||
currentTheme: 'luxury_showroom', // 'luxury_showroom' or 'rusty_workshop'
|
||||
}),
|
||||
getters: {
|
||||
isLuxury: (state) => state.currentTheme === 'luxury_showroom',
|
||||
isWorkshop: (state) => state.currentTheme === 'rusty_workshop',
|
||||
themeClasses: (state) => {
|
||||
if (state.currentTheme === 'luxury_showroom') {
|
||||
return {
|
||||
background: 'bg-gradient-to-br from-slate-900 via-slate-800 to-gray-900',
|
||||
text: 'text-amber-100',
|
||||
accent: 'text-amber-400',
|
||||
border: 'border-amber-700',
|
||||
card: 'bg-slate-800/70 backdrop-blur-lg border border-amber-700/30',
|
||||
button: 'bg-amber-700 hover:bg-amber-600 text-white',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
background: 'bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900',
|
||||
text: 'text-orange-100',
|
||||
accent: 'text-orange-400',
|
||||
border: 'border-orange-800',
|
||||
card: 'bg-gray-800/90 border border-dashed border-orange-700/50',
|
||||
button: 'bg-orange-800 hover:bg-orange-700 text-white',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
toggleTheme() {
|
||||
this.currentTheme = this.currentTheme === 'luxury_showroom' ? 'rusty_workshop' : 'luxury_showroom'
|
||||
},
|
||||
setTheme(theme) {
|
||||
if (['luxury_showroom', 'rusty_workshop'].includes(theme)) {
|
||||
this.currentTheme = theme
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true, // optional: if using pinia-plugin-persistedstate
|
||||
})
|
||||
86
frontend/src/stores/vehicle.ts
Normal file
86
frontend/src/stores/vehicle.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/axios'
|
||||
|
||||
/**
|
||||
* Vehicle data shape returned by GET /api/v1/assets/vehicles
|
||||
* Based on the AssetResponse Pydantic schema from the backend.
|
||||
*/
|
||||
export interface Vehicle {
|
||||
id: string
|
||||
vin: string | null
|
||||
license_plate: string | null
|
||||
name: string | null
|
||||
catalog_id: number | null
|
||||
|
||||
// Classification
|
||||
vehicle_class: string | null
|
||||
brand: string | null
|
||||
model: string | null
|
||||
trim_level: string | null
|
||||
|
||||
// Technical specs
|
||||
fuel_type: string | null
|
||||
engine_capacity: number | null
|
||||
power_kw: number | null
|
||||
transmission_type: string | null
|
||||
drive_type: string | null
|
||||
|
||||
// Status
|
||||
current_mileage: number
|
||||
condition_score: number
|
||||
status: string
|
||||
is_verified: boolean
|
||||
|
||||
// Timeline
|
||||
year_of_manufacture: number | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
|
||||
// Sales
|
||||
is_for_sale: boolean
|
||||
price: number | null
|
||||
currency: string
|
||||
}
|
||||
|
||||
export const useVehicleStore = defineStore('vehicle', () => {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const vehicles = ref<Vehicle[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch all vehicles/assets for the current user or their organization.
|
||||
* GET /api/v1/assets/vehicles
|
||||
*/
|
||||
async function fetchVehicles() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.get('/api/v1/assets/vehicles')
|
||||
vehicles.value = res.data as Vehicle[]
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
'Nem sikerült betölteni a járműveket. Ellenőrizd a kapcsolatot.'
|
||||
error.value = message
|
||||
console.error('[VehicleStore] fetchVehicles error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
vehicles,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
fetchVehicles,
|
||||
}
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Premium global styles */
|
||||
body {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); /* slate-50 to slate-100 */
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Subtle grain/noise texture overlay for premium feel */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0.03;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* Global button micro-animations */
|
||||
button,
|
||||
[role="button"],
|
||||
.btn,
|
||||
.button {
|
||||
transition: all 0.2s ease-in-out !important;
|
||||
}
|
||||
|
||||
button:active,
|
||||
[role="button"]:active,
|
||||
.btn:active,
|
||||
.button:active {
|
||||
transform: scale(0.97) !important;
|
||||
}
|
||||
|
||||
/* Smooth scroll */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background-color: rgba(59, 130, 246, 0.2); /* blue-400 with opacity */
|
||||
color: #1e293b; /* slate-800 */
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9; /* slate-100 */
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1; /* slate-300 */
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8; /* slate-400 */
|
||||
}
|
||||
|
||||
/* Premium focus styles */
|
||||
*:focus {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.5);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Smooth transitions for all interactive elements */
|
||||
a, button, input, select, textarea {
|
||||
transition-property: color, background-color, border-color, transform, box-shadow;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
|
||||
/* Fix unreadable yellow input text - change to dark blue */
|
||||
input, select, textarea {
|
||||
color: #1e3a8a; /* dark blue */
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: #3b82f6; /* medium blue */
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Premium card hover effects */
|
||||
.hover-lift {
|
||||
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto bg-white p-8 rounded-2xl shadow-xl border border-gray-100 mt-10">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6 flex items-center gap-2">
|
||||
<span>💸</span> Add Expense
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Kategória</label>
|
||||
<select v-model="form.category" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border">
|
||||
<option value="REFUELING">⛽ Tankolás</option>
|
||||
<option value="SERVICE">🔧 Szerviz</option>
|
||||
<option value="INSURANCE">🛡️ Biztosítás</option>
|
||||
<option value="TOLL">🛣️ Útdíj / Matrica</option>
|
||||
<option value="FINE">👮 Büntetés</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Összeg (Ft)</label>
|
||||
<input v-model="form.amount" type="number" required class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border" placeholder="Pl: 25000">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Km óra állása</label>
|
||||
<input v-model="form.odometer_value" type="number" class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 p-2.5 border" placeholder="Pl: 145200">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-blue-600 text-white font-bold py-3 rounded-lg hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200">
|
||||
Mentés rögzítése
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const form = ref({
|
||||
category: 'REFUELING',
|
||||
amount: null,
|
||||
odometer_value: null,
|
||||
vehicle_id: 'auto-uuid-helye', // Ezt majd a routerből kapjuk
|
||||
date: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
// Map frontend fields to backend schema
|
||||
const categoryMap = {
|
||||
'REFUELING': 'fuel',
|
||||
'SERVICE': 'service',
|
||||
'INSURANCE': 'insurance',
|
||||
'TOLL': 'toll',
|
||||
'FINE': 'fine'
|
||||
}
|
||||
const payload = {
|
||||
cost_type: categoryMap[form.value.category] || form.value.category.toLowerCase(),
|
||||
amount_local: form.value.amount,
|
||||
currency_local: 'HUF',
|
||||
mileage_at_cost: form.value.odometer_value,
|
||||
date: new Date(form.value.date).toISOString(),
|
||||
asset_id: form.value.vehicle_id,
|
||||
description: null,
|
||||
data: {}
|
||||
}
|
||||
await api.post('/expenses/', payload)
|
||||
alert("Sikeresen mentve!")
|
||||
} catch (err) {
|
||||
alert("Hiba történt a mentéskor.")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto bg-white p-8 rounded-2xl shadow-lg mt-6">
|
||||
<h2 class="text-2xl font-bold mb-6 flex items-center gap-2">
|
||||
<span class="text-3xl">➕</span> Új jármű hozzáadása
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Márka keresése</label>
|
||||
<input v-model="searchQuery" @input="searchBrands" type="text" placeholder="Pl: Audi, Toyota..." class="mt-1 block w-full p-3 border rounded-lg shadow-sm" />
|
||||
|
||||
<div v-if="brands.length > 0" class="mt-2 border rounded-lg divide-y bg-gray-50">
|
||||
<div v-for="brand in brands" :key="brand.id" @click="selectBrand(brand)" class="p-3 hover:bg-blue-100 cursor-pointer flex justify-between">
|
||||
<span class="font-bold">{{ brand.name }}</span>
|
||||
<span class="text-xs text-gray-400 italic">{{ brand.country_of_origin }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedBrand" class="p-4 bg-blue-50 rounded-lg border border-blue-200 animate-fade-in">
|
||||
<p class="font-bold text-blue-800">Kiválasztott márka: {{ selectedBrand.name }}</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<input v-model="form.model_name" type="text" placeholder="Modell (Pl: A4, Corolla)" class="p-2 border rounded" />
|
||||
<input v-model="form.plate" type="text" placeholder="Rendszám" class="p-2 border rounded uppercase" />
|
||||
<input v-model="form.vin" type="text" placeholder="Alvázszám (opcionális)" class="p-2 border rounded uppercase" />
|
||||
<input v-model="form.current_odo" type="number" placeholder="Aktuális km állás" class="p-2 border rounded" />
|
||||
</div>
|
||||
<button @click="saveVehicle" class="w-full mt-6 bg-blue-700 text-white py-3 rounded-lg font-bold shadow-lg">Jármű mentése a Széfbe</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const searchQuery = ref('')
|
||||
const brands = ref([])
|
||||
const selectedBrand = ref(null)
|
||||
const form = ref({ model_name: '', plate: '', vin: '', current_odo: 0 })
|
||||
|
||||
const searchBrands = async () => {
|
||||
if (searchQuery.value.length < 2) { brands.value = []; return; }
|
||||
const res = await api.get(`/vehicles/search/brands?q=${searchQuery.value}`)
|
||||
brands.value = res.data.data
|
||||
}
|
||||
|
||||
const selectBrand = (brand) => {
|
||||
selectedBrand.value = brand
|
||||
brands.value = []
|
||||
}
|
||||
|
||||
const saveVehicle = async () => {
|
||||
try {
|
||||
await api.post('/assets/vehicles', {
|
||||
vin: form.value.vin || null,
|
||||
license_plate: form.value.plate || 'N/A',
|
||||
catalog_id: null, // draft mode, no catalog mapping yet
|
||||
organization_id: authStore.activeOrgId // Use active organization ID, must be present
|
||||
})
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
alert("Hiba a mentés során.")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
499
frontend/src/views/CompleteKycView.vue
Normal file
499
frontend/src/views/CompleteKycView.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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'"
|
||||
/>
|
||||
</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 ==================== -->
|
||||
<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">Pontos cím a garázs és flotta kezeléshez</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</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</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</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"
|
||||
/>
|
||||
</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</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-[#00E5A0]" />
|
||||
<span class="text-sm text-white/80">Személyi igazolvány</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"
|
||||
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 -->
|
||||
<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"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// ── Steps definition ──
|
||||
const steps = [
|
||||
{ label: 'Személyes', key: 'personal' },
|
||||
{ label: 'Lakcím', key: 'address' },
|
||||
{ label: 'Okmányok', key: 'security' },
|
||||
]
|
||||
|
||||
const currentStep = ref(1)
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const isCityLoading = ref(false)
|
||||
|
||||
// ── KYC Form (minimal — only DB-required fields) ──
|
||||
const kycForm = reactive({
|
||||
last_name: '',
|
||||
first_name: '',
|
||||
region_code: 'HU',
|
||||
address_zip: '',
|
||||
address_city: '',
|
||||
address_street_name: '',
|
||||
address_street_type: '',
|
||||
address_house_number: '',
|
||||
identity_docs: {
|
||||
ID_CARD: { number: '', expiry_date: '' },
|
||||
LICENSE: { number: '', expiry_date: '' },
|
||||
},
|
||||
preferred_language: 'hu',
|
||||
preferred_currency: 'HUF',
|
||||
})
|
||||
|
||||
// ── Debounced ZIP → City auto-fill via zippopotam.us ──
|
||||
let zipTimeout: ReturnType<typeof setTimeout>
|
||||
watch(
|
||||
() => kycForm.address_zip,
|
||||
(newZip) => {
|
||||
clearTimeout(zipTimeout)
|
||||
if (newZip && newZip.length >= 3) {
|
||||
isCityLoading.value = true
|
||||
zipTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const country = kycForm.region_code.toLowerCase()
|
||||
const response = await fetch(`https://api.zippopotam.us/${country}/${newZip}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
kycForm.address_city = data.places[0]['place name']
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Cím lekérdezési hiba:', error)
|
||||
} finally {
|
||||
isCityLoading.value = false
|
||||
}
|
||||
}, 600) // 600ms késleltetés a gépelés után
|
||||
} else {
|
||||
isCityLoading.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Step circle styling ──
|
||||
function stepCircleClass(idx: number): string {
|
||||
const stepNum = idx + 1
|
||||
if (currentStep.value > stepNum) {
|
||||
return 'bg-[#00E5A0] text-[#04151F]'
|
||||
}
|
||||
if (currentStep.value === stepNum) {
|
||||
return 'bg-[#00E5A0]/20 text-[#00E5A0] border-2 border-[#00E5A0]'
|
||||
}
|
||||
return 'bg-white/5 text-white/30 border border-white/10'
|
||||
}
|
||||
|
||||
// ── Simple step validation ──
|
||||
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 (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 }
|
||||
if (!kycForm.address_street_name) { errorMessage.value = 'Kérlek add meg az utca nevét.'; return false }
|
||||
if (!kycForm.address_street_type) { errorMessage.value = 'Kérlek válaszd ki az utca típusát.'; return false }
|
||||
if (!kycForm.address_house_number) { errorMessage.value = 'Kérlek add meg a házszámot.'; return false }
|
||||
}
|
||||
|
||||
if (currentStep.value === 3) {
|
||||
if (!kycForm.identity_docs.ID_CARD.number) { errorMessage.value = 'Kérlek add meg a személyi igazolvány számát.'; return false }
|
||||
if (!kycForm.identity_docs.ID_CARD.expiry_date) { errorMessage.value = 'Kérlek add meg a személyi lejárati dátumát.'; return false }
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Navigation ──
|
||||
function nextStep() {
|
||||
if (!validateStep()) return
|
||||
if (currentStep.value < 3) {
|
||||
currentStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
errorMessage.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ──
|
||||
async function handleSubmit() {
|
||||
if (!validateStep()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
// Build the minimal payload matching UserKYCComplete schema
|
||||
const payload = {
|
||||
last_name: kycForm.last_name,
|
||||
first_name: kycForm.first_name,
|
||||
region_code: kycForm.region_code,
|
||||
address_zip: kycForm.address_zip,
|
||||
address_city: kycForm.address_city,
|
||||
address_street_name: kycForm.address_street_name,
|
||||
address_street_type: kycForm.address_street_type,
|
||||
address_house_number: kycForm.address_house_number,
|
||||
identity_docs: {
|
||||
ID_CARD: {
|
||||
number: kycForm.identity_docs.ID_CARD.number,
|
||||
expiry_date: kycForm.identity_docs.ID_CARD.expiry_date,
|
||||
},
|
||||
...(kycForm.identity_docs.LICENSE.number
|
||||
? {
|
||||
LICENSE: {
|
||||
number: kycForm.identity_docs.LICENSE.number,
|
||||
expiry_date: kycForm.identity_docs.LICENSE.expiry_date,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
preferred_language: kycForm.preferred_language,
|
||||
preferred_currency: kycForm.preferred_currency,
|
||||
}
|
||||
|
||||
await authStore.completeKyc(payload)
|
||||
|
||||
// Navigate to dashboard on success
|
||||
router.push('/dashboard')
|
||||
} catch (err: any) {
|
||||
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.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,149 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import VehicleShowcase from '@/components/garage/VehicleShowcase.vue'
|
||||
import AchievementShowcase from '@/components/gamification/AchievementShowcase.vue'
|
||||
import AnalyticsDashboard from '@/components/analytics/AnalyticsDashboard.vue'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const report = ref(null)
|
||||
const loading = ref(true)
|
||||
const themeStore = useThemeStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const themeClasses = computed(() => themeStore.themeClasses)
|
||||
|
||||
onMounted(async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
try {
|
||||
// Fetch user profile
|
||||
await authStore.fetchUserProfile()
|
||||
|
||||
// Fetch report summary
|
||||
const res = await axios.get(`${import.meta.env.VITE_API_BASE_URL || 'https://dev.servicefinder.hu'}/reports/summary/latest`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
report.value = res.data
|
||||
} catch (err) {
|
||||
console.log("Még nincs rögzített adat.")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading" :class="['space-y-8 p-6 min-h-screen transition-all duration-500', themeClasses.background]">
|
||||
<!-- Vehicle Showcase (Dual-UI) -->
|
||||
<VehicleShowcase />
|
||||
|
||||
<!-- Gamification Showcase (Dual-UI) -->
|
||||
<div class="backdrop-blur-md bg-white/80 rounded-2xl shadow-xl p-6 border border-slate-200/60 transition-all duration-300 hover:shadow-2xl hover:border-slate-300/80">
|
||||
<AchievementShowcase />
|
||||
</div>
|
||||
|
||||
<!-- Analytics & TCO Dashboard (EPIC 11 - Ticket #126) -->
|
||||
<div class="backdrop-blur-md bg-gradient-to-br from-white to-blue-50/80 rounded-2xl shadow-xl p-6 border border-blue-200/60 transition-all duration-300 hover:shadow-2xl hover:border-blue-300/80">
|
||||
<AnalyticsDashboard />
|
||||
</div>
|
||||
|
||||
<!-- Existing Report Section (if data exists) -->
|
||||
<div v-if="report" class="backdrop-blur-md bg-white/90 rounded-2xl shadow-lg p-6 border border-slate-200/60 transition-all duration-300">
|
||||
<h2 class="text-2xl font-bold text-slate-900 mb-6">Financial Summary</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-gradient-to-br from-blue-50/80 to-blue-100/50 p-5 rounded-xl border border-blue-200/40 transition-all duration-200 hover:scale-[1.02] hover:shadow-md">
|
||||
<div class="text-sm text-blue-700 font-medium">Total Expenses</div>
|
||||
<div class="text-2xl font-bold text-slate-900">€{{ report.total_expenses?.toLocaleString() || '0' }}</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-emerald-50/80 to-emerald-100/50 p-5 rounded-xl border border-emerald-200/40 transition-all duration-200 hover:scale-[1.02] hover:shadow-md">
|
||||
<div class="text-sm text-emerald-700 font-medium">Monthly Average</div>
|
||||
<div class="text-2xl font-bold text-slate-900">€{{ report.monthly_average?.toLocaleString() || '0' }}</div>
|
||||
</div>
|
||||
<div class="bg-gradient-to-br from-violet-50/80 to-violet-100/50 p-5 rounded-xl border border-violet-200/40 transition-all duration-200 hover:scale-[1.02] hover:shadow-md">
|
||||
<div class="text-sm text-violet-700 font-medium">Vehicles Tracked</div>
|
||||
<div class="text-2xl font-bold text-slate-900">{{ report.vehicle_count || '0' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State (no report data) -->
|
||||
<div v-else class="text-center py-12 bg-gradient-to-br from-slate-50/80 to-white rounded-2xl border border-slate-200/60 backdrop-blur-sm">
|
||||
<span class="text-6xl text-slate-300">📊</span>
|
||||
<h2 class="text-xl font-bold text-slate-500 mt-4">Még nincsenek rögzített költségeid.</h2>
|
||||
<p class="text-slate-600 mt-2 mb-6">Start tracking your vehicle expenses to see insights here.</p>
|
||||
<router-link
|
||||
to="/expenses"
|
||||
class="inline-block px-6 py-3 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold rounded-lg transition-all duration-200 active:scale-95 shadow-md hover:shadow-lg"
|
||||
>
|
||||
Kezdd el itt! →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<router-link
|
||||
to="/expenses/add"
|
||||
class="backdrop-blur-sm bg-white/90 p-6 rounded-xl shadow-sm border border-slate-200/60 hover:shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-gradient-to-br from-blue-100 to-blue-200 rounded-lg mr-4 group-hover:from-blue-200 group-hover:to-blue-300 transition-all duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">Add Expense</h3>
|
||||
<p class="text-slate-600 text-sm">Record fuel, maintenance, etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/vehicles/add"
|
||||
class="backdrop-blur-sm bg-white/90 p-6 rounded-xl shadow-sm border border-slate-200/60 hover:shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-gradient-to-br from-emerald-100 to-emerald-200 rounded-lg mr-4 group-hover:from-emerald-200 group-hover:to-emerald-300 transition-all duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">Add Vehicle</h3>
|
||||
<p class="text-slate-600 text-sm">Register a new vehicle</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/expenses"
|
||||
class="backdrop-blur-sm bg-white/90 p-6 rounded-xl shadow-sm border border-slate-200/60 hover:shadow-lg transition-all duration-200 hover:scale-[1.02] active:scale-95 group"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-gradient-to-br from-violet-100 to-violet-200 rounded-lg mr-4 group-hover:from-violet-200 group-hover:to-violet-300 transition-all duration-200">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-violet-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-900">View Reports</h3>
|
||||
<p class="text-slate-600 text-sm">Analytics and insights</p>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-else class="flex justify-center items-center h-64">
|
||||
<div class="text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-4 text-slate-600">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Custom styles */
|
||||
</style>
|
||||
@@ -1,369 +0,0 @@
|
||||
<template>
|
||||
<div class="debug-page">
|
||||
<h1>🚨 DEBUG VIEW - RAW STATE INSPECTOR</h1>
|
||||
|
||||
<div class="credentials">
|
||||
<h2>Test Credentials</h2>
|
||||
<p><strong>Email:</strong> tester_pro@profibot.hu</p>
|
||||
<p><strong>Password:</strong> Password123!</p>
|
||||
<p class="note">Use these credentials to test the login flow</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<h2>Auth Store Actions</h2>
|
||||
<div class="button-group">
|
||||
<button @click="triggerLogin" :disabled="false">
|
||||
🔐 Trigger Login (tester_pro@profibot.hu)
|
||||
</button>
|
||||
|
||||
<button @click="fetchUserProfile" :disabled="!authStore.isLoggedIn">
|
||||
👤 Fetch User Profile
|
||||
</button>
|
||||
|
||||
<button @click="fetchVehicles" :disabled="!authStore.isLoggedIn">
|
||||
🚗 Fetch Vehicles (Garage)
|
||||
</button>
|
||||
|
||||
<button @click="clearState">
|
||||
🗑️ Clear All State
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<p><strong>Status:</strong> {{ statusMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Auth Store State -->
|
||||
<div class="panel">
|
||||
<h2>🔐 Auth Store State</h2>
|
||||
<div class="state-info">
|
||||
<p><span class="label">isLoggedIn:</span> <span :class="authStore.isLoggedIn ? 'yes' : 'no'">{{ authStore.isLoggedIn ? 'YES' : 'NO' }}</span></p>
|
||||
<p><span class="label">isAdmin:</span> <span :class="authStore.isAdmin ? 'yes' : 'no'">{{ authStore.isAdmin ? 'YES' : 'NO' }}</span></p>
|
||||
<p><span class="label">isTester:</span> <span :class="authStore.isTester ? 'yes' : 'no'">{{ authStore.isTester ? 'YES' : 'NO' }}</span></p>
|
||||
</div>
|
||||
|
||||
<h3>Raw JSON State:</h3>
|
||||
<pre>{{ authStoreJSON }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Garage Store State -->
|
||||
<div class="panel">
|
||||
<h2>🚗 Garage Store State</h2>
|
||||
<div class="state-info">
|
||||
<p><span class="label">Vehicles Count:</span> {{ garageStore.vehicles ? garageStore.vehicles.length : 0 }}</p>
|
||||
<p><span class="label">Loading:</span> {{ garageStore.loading ? 'YES' : 'NO' }}</p>
|
||||
</div>
|
||||
|
||||
<h3>Raw JSON State:</h3>
|
||||
<pre>{{ garageStoreJSON }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LocalStorage Inspection -->
|
||||
<div class="panel">
|
||||
<h2>💾 LocalStorage Inspection</h2>
|
||||
<div class="storage-grid">
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">token</div>
|
||||
<div class="storage-value">{{ localStorage.token ? `${localStorage.token.substring(0, 30)}...` : '(empty)' }}</div>
|
||||
</div>
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">user_email</div>
|
||||
<div class="storage-value">{{ localStorage.user_email || '(empty)' }}</div>
|
||||
</div>
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">is_admin</div>
|
||||
<div class="storage-value">{{ localStorage.is_admin || '(empty)' }}</div>
|
||||
</div>
|
||||
<div class="storage-item">
|
||||
<div class="storage-label">user_role</div>
|
||||
<div class="storage-value">{{ localStorage.user_role || '(empty)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="refreshLocalStorage">
|
||||
🔄 Refresh LocalStorage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useGarageStore } from '@/stores/garageStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const garageStore = useGarageStore()
|
||||
|
||||
const statusMessage = ref('Ready')
|
||||
const localStorage = ref({})
|
||||
|
||||
// Computed properties for JSON display
|
||||
const authStoreJSON = computed(() => {
|
||||
return JSON.stringify({
|
||||
token: authStore.token,
|
||||
isLoggedIn: authStore.isLoggedIn,
|
||||
isAdmin: authStore.isAdmin,
|
||||
isTester: authStore.isTester,
|
||||
userEmail: authStore.userEmail,
|
||||
userRole: authStore.userRole,
|
||||
userProfile: authStore.userProfile,
|
||||
displayName: authStore.displayName,
|
||||
activeOrgId: authStore.activeOrgId
|
||||
}, null, 2)
|
||||
})
|
||||
|
||||
const garageStoreJSON = computed(() => {
|
||||
return JSON.stringify({
|
||||
vehicles: garageStore.vehicles,
|
||||
loading: garageStore.loading,
|
||||
error: garageStore.error,
|
||||
selectedVehicle: garageStore.selectedVehicle
|
||||
}, null, 2)
|
||||
})
|
||||
|
||||
// Functions
|
||||
const triggerLogin = async () => {
|
||||
statusMessage.value = 'Logging in with tester_pro@profibot.hu...'
|
||||
try {
|
||||
await authStore.login('tester_pro@profibot.hu', 'Password123!')
|
||||
statusMessage.value = 'Login successful! Check auth store state.'
|
||||
refreshLocalStorage()
|
||||
} catch (error) {
|
||||
statusMessage.value = `Login failed: ${error.message}`
|
||||
console.error('Login error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
statusMessage.value = 'Fetching user profile...'
|
||||
try {
|
||||
await authStore.fetchUserProfile()
|
||||
statusMessage.value = 'User profile fetched successfully!'
|
||||
} catch (error) {
|
||||
statusMessage.value = `Failed to fetch user profile: ${error.message}`
|
||||
console.error('Fetch user profile error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVehicles = async () => {
|
||||
statusMessage.value = 'Fetching vehicles...'
|
||||
try {
|
||||
await garageStore.fetchVehicles()
|
||||
statusMessage.value = `Vehicles fetched successfully! Found ${garageStore.vehicles?.length || 0} vehicles.`
|
||||
} catch (error) {
|
||||
statusMessage.value = `Failed to fetch vehicles: ${error.message}`
|
||||
console.error('Fetch vehicles error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearState = () => {
|
||||
authStore.logout()
|
||||
garageStore.$reset()
|
||||
refreshLocalStorage()
|
||||
statusMessage.value = 'All state cleared!'
|
||||
}
|
||||
|
||||
const refreshLocalStorage = () => {
|
||||
localStorage.value = {
|
||||
token: window.localStorage.getItem('token') || '',
|
||||
user_email: window.localStorage.getItem('user_email') || '',
|
||||
is_admin: window.localStorage.getItem('is_admin') || '',
|
||||
user_role: window.localStorage.getItem('user_role') || '',
|
||||
refresh_token: window.localStorage.getItem('refresh_token') ? '***' : '',
|
||||
ui_mode: window.localStorage.getItem('ui_mode') || '',
|
||||
active_org_id: window.localStorage.getItem('active_org_id') || ''
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
refreshLocalStorage()
|
||||
statusMessage.value = 'Debug view loaded. Use buttons to test auth flow.'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debug-page {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
font-family: monospace;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
border-bottom: 2px solid #1e3a8a;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 15px 0 10px 0;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 10px 0 5px 0;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
}
|
||||
|
||||
.credentials {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.credentials h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 10px 15px;
|
||||
background: #1e3a8a; /* sötétkék */
|
||||
color: white;
|
||||
border: 1px solid #1e3a8a;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1e40af; /* sötétkék - sötétebb */
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
color: #666;
|
||||
border-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
margin-top: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status p {
|
||||
margin: 0;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid #ddd;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.state-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.state-info p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.yes {
|
||||
color: #065f46; /* zöld */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.no {
|
||||
color: #991b1b; /* piros */
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #1e1e1e;
|
||||
color: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.storage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.storage-item {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.storage-label {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: #1e3a8a; /* sötétkék */
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.storage-value {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800">Költségek kezelése</h2>
|
||||
<div class="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<p>Itt tudod majd rögzíteni és listázni a kiadásaidat.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto mt-20 p-8 bg-white rounded-2xl shadow-xl border">
|
||||
<h2 class="text-2xl font-bold text-center mb-4 text-gray-800">Elfelejtett jelszó</h2>
|
||||
<p class="text-sm text-gray-600 mb-6 text-center">Add meg az e-mail címed, és küldünk egy linket a jelszó visszaállításához.</p>
|
||||
|
||||
<form @submit.prevent="handleForgot" class="space-y-4">
|
||||
<input v-model="email" type="email" placeholder="E-mail címed" required class="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500" />
|
||||
<button type="submit" class="w-full bg-blue-700 text-white py-3 rounded-lg font-bold hover:bg-blue-800">Küldés</button>
|
||||
</form>
|
||||
|
||||
<p v-if="message" class="mt-4 text-center font-medium text-blue-600">{{ message }}</p>
|
||||
<div class="mt-6 text-center">
|
||||
<router-link to="/login" class="text-sm text-gray-500 hover:underline">Vissza a belépéshez</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const email = ref('')
|
||||
const message = ref('')
|
||||
|
||||
const handleForgot = async () => {
|
||||
try {
|
||||
await axios.post(`http://192.168.100.43:8000/api/v2/auth/forgot-password?email=${email.value}`)
|
||||
message.value = "Ha a cím létezik, a helyreállító levelet elküldtük."
|
||||
} catch (err) {
|
||||
message.value = "Hiba történt a kérés feldolgozásakor."
|
||||
}
|
||||
}
|
||||
</script>
|
||||
187
frontend/src/views/LandingView.vue
Normal file
187
frontend/src/views/LandingView.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="relative min-h-screen w-full overflow-x-hidden bg-[#04151f]">
|
||||
|
||||
<!-- Background Image with Asymmetric Overlay -->
|
||||
<div class="fixed inset-0 bg-[url('/garage_clean.png')] bg-cover bg-center bg-fixed bg-no-repeat">
|
||||
<!-- Asymmetric Gradient Overlay: Dark left, transparent right -->
|
||||
<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) -->
|
||||
<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">
|
||||
|
||||
<!-- Left: Brand Logo + Text (clickable, links to /) -->
|
||||
<router-link to="/" class="flex items-center cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]">
|
||||
<img src="@/assets/sf-brand-logo.svg" class="h-10 w-auto mr-3 drop-shadow-md" alt="ServiceFinder Logo" />
|
||||
<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: "Garázs Nyitása" Button (Premium) -->
|
||||
<button
|
||||
@click="showLogin = true"
|
||||
class="btn-premium px-6 py-2 text-sm"
|
||||
>
|
||||
Garázs Nyitása
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Hero Section: 2-Column Grid with JS Parallax -->
|
||||
<section class="relative z-10 pt-24">
|
||||
<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
|
||||
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>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Játékos Aszimmetrikus Kártyák (Gyorsan úszik felfelé) -->
|
||||
<div
|
||||
class="relative h-[600px]"
|
||||
:style="{ transform: `translateY(${scrollY * -0.25}px)` }"
|
||||
>
|
||||
|
||||
<!-- Kártya 1: Statisztika (Hátul, fent, kisebb) -->
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Kártya 2: Gamifikáció (Középen, kiemelve, NEON) -->
|
||||
<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">
|
||||
<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/>
|
||||
<path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/>
|
||||
<path d="M4 22h16"/>
|
||||
<path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/>
|
||||
<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>
|
||||
</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-[#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">
|
||||
<div class="h-full bg-gradient-to-r from-[#38bdf8] to-[#7dd3fc] rounded-full" style="width: 68%"></div>
|
||||
</div>
|
||||
<span class="text-white/70 text-xs font-semibold">68%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kártya 3: Költségek (Lent, elöl, átfedi a gamifikációt) -->
|
||||
<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>
|
||||
</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">
|
||||
<div class="flex items-center gap-2">
|
||||
<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">
|
||||
<line x1="3" y1="22" x2="15" y2="22"/>
|
||||
<line x1="4" y1="9" x2="14" y2="9"/>
|
||||
<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>
|
||||
</div>
|
||||
<span class="text-white text-lg font-bold font-mono">32.5k</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Login Modal Component -->
|
||||
<LoginModal :visible="showLogin" @close="showLogin = false" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import Logo from '@/components/Logo.vue'
|
||||
import LoginModal from '@/components/LoginModal.vue'
|
||||
|
||||
const showLogin = ref(false)
|
||||
const scrollY = ref(0)
|
||||
|
||||
// JS Alapú Parallax Görgetés
|
||||
function handleScroll() {
|
||||
scrollY.value = window.scrollY
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
</script>
|
||||
@@ -1,204 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-gray-100 p-4">
|
||||
<div class="max-w-md w-full mx-auto">
|
||||
<!-- Logo & Header -->
|
||||
<div class="text-center mb-10">
|
||||
<div class="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-r from-blue-600 to-blue-800 rounded-3xl shadow-2xl mb-6">
|
||||
<span class="text-4xl">🚗</span>
|
||||
</div>
|
||||
<h1 class="text-4xl font-black text-gray-900 mb-2 tracking-tight">Service Finder</h1>
|
||||
<p class="text-gray-500 font-medium">Smart Garage Management System</p>
|
||||
<div class="mt-4 inline-flex items-center gap-2 bg-blue-100 text-blue-700 px-4 py-2 rounded-full text-sm font-bold">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
V2.0 • Production Ready
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="bg-white rounded-3xl shadow-2xl border border-gray-200 p-8 md:p-10">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-black text-gray-900 mb-2">Login to your account</h2>
|
||||
<p class="text-gray-500 text-sm">Enter your email and password to continue</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<!-- Email Field -->
|
||||
<div class="space-y-2">
|
||||
<label for="email" class="text-sm font-bold text-gray-700 uppercase tracking-wide">Email address</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="w-full pl-12 pr-4 py-4 bg-gray-50 border-2 border-transparent rounded-2xl focus:border-blue-500 focus:bg-white transition-all outline-none text-gray-900 placeholder-gray-400"
|
||||
placeholder="superadmin@profibot.hu"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">For testing use: superadmin@profibot.hu</p>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<label for="password" class="text-sm font-bold text-gray-700 uppercase tracking-wide">Password</label>
|
||||
<router-link to="/forgot-password" class="text-xs font-bold text-blue-600 hover:text-blue-800 transition">
|
||||
Forgot password?
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
class="w-full pl-12 pr-12 py-4 bg-gray-50 border-2 border-transparent rounded-2xl focus:border-blue-500 focus:bg-white transition-all outline-none text-gray-900 placeholder-gray-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPassword = !showPassword"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-blue-600 transition"
|
||||
>
|
||||
<svg v-if="showPassword" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L6.59 6.59m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Any password works for mock login</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="p-4 bg-red-50 border-l-4 border-red-500 text-red-700 rounded-xl text-sm font-bold animate-pulse">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-gradient-to-r from-blue-600 to-blue-700 text-white py-4 rounded-2xl font-black text-lg hover:from-blue-700 hover:to-blue-800 transition-all shadow-xl hover:shadow-2xl hover:shadow-blue-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin h-5 w-5 text-white" 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"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ loading ? 'LOGGING IN...' : 'LOGIN' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-8 flex items-center">
|
||||
<div class="flex-grow border-t border-gray-200"></div>
|
||||
<span class="mx-4 text-gray-400 text-sm font-medium">VAGY</span>
|
||||
<div class="flex-grow border-t border-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- Register Link -->
|
||||
<div class="text-center">
|
||||
<p class="text-gray-500 font-medium mb-4">Nincs még fiókod?</p>
|
||||
<router-link
|
||||
to="/register"
|
||||
class="inline-block w-full py-3 px-6 border-2 border-blue-600 text-blue-600 rounded-2xl font-bold hover:bg-blue-50 transition-all hover:border-blue-700 hover:text-blue-700"
|
||||
>
|
||||
Új Széf létrehozása
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Demo Info -->
|
||||
<div class="mt-8 p-4 bg-blue-50 rounded-2xl border border-blue-100">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<div class="text-sm text-blue-700">
|
||||
<p class="font-bold mb-1">Demo módban vagy</p>
|
||||
<p>A bejelentkezés mockolt, automatikusan sikeres lesz. A rendszer a <span class="font-mono bg-blue-100 px-2 py-0.5 rounded">/profile-select</span> oldalra irányít, ahol kiválaszthatod a felület módot.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 text-center text-gray-400 text-sm">
|
||||
<p>© 2026 Service Finder • Smart Garage Management • v2.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
const email = ref('superadmin@profibot.hu')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
console.log('Login: Starting login process for', email.value)
|
||||
|
||||
try {
|
||||
// Use the auth store for login
|
||||
console.log('Login: Calling authStore.login()')
|
||||
await authStore.login(email.value, password.value)
|
||||
|
||||
console.log('Login: authStore.login() completed successfully')
|
||||
// The auth store will handle the redirect to /profile-select
|
||||
// No need to do anything else here
|
||||
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
error.value = err.message || 'Hiba történt a bejelentkezés során'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Custom scrollbar for the page */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3b82f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-blue-50 p-4">
|
||||
<ProfileSelector />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProfileSelector from '@/components/ProfileSelector.vue'
|
||||
</script>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto mt-10 p-8 bg-white rounded-2xl shadow-xl">
|
||||
<h2 class="text-2xl font-bold mb-6 text-center">Új Széf létrehozása</h2>
|
||||
|
||||
<!-- Registration Form (shown when not successful) -->
|
||||
<div v-if="!success">
|
||||
<form @submit.prevent="handleRegister" class="space-y-4">
|
||||
<input v-model="form.first_name" type="text" placeholder="Keresztnév" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="form.last_name" type="text" placeholder="Vezetéknév" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="form.email" type="email" placeholder="E-mail" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="form.password" type="password" placeholder="Jelszó" required class="w-full p-3 border rounded-lg" />
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="w-full bg-green-600 text-white py-3 rounded-lg font-bold hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span v-if="loading">Feldolgozás...</span>
|
||||
<span v-else>Fiók létrehozása</span>
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="msg && !success" :class="['mt-4 text-center font-medium', isError ? 'text-red-600' : 'text-green-600']">{{ msg }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Success Card (shown after successful registration) -->
|
||||
<div v-else class="text-center">
|
||||
<div class="mb-6">
|
||||
<!-- Animated checkmark -->
|
||||
<div class="relative inline-flex items-center justify-center w-24 h-24">
|
||||
<div class="absolute inset-0 bg-green-100 rounded-full"></div>
|
||||
<svg class="relative w-16 h-16 text-green-600 animate-checkmark" viewBox="0 0 52 52">
|
||||
<circle class="stroke-current" cx="26" cy="26" r="25" fill="none" stroke-width="2"/>
|
||||
<path class="stroke-current" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-bold text-gray-800 mb-3">Sikeres regisztráció!</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Elküldtünk egy megerősítő e-mailt a(z) <span class="font-semibold">{{ form.email }}</span> címre.
|
||||
Kérjük, ellenőrizd a postaládádat és kattints a linkre a fiók aktiválásához.
|
||||
</p>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Fontos:</strong> A megerősítő e-mail néhány perc múlva érkezhet meg.
|
||||
Ha nem találod, ellenőrizd a spam mappát is.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="resetForm"
|
||||
class="w-full bg-gray-200 text-gray-800 py-3 rounded-lg font-bold hover:bg-gray-300 transition"
|
||||
>
|
||||
Új regisztráció
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const form = ref({ email: '', password: '', first_name: '', last_name: '' })
|
||||
const msg = ref('')
|
||||
const isError = ref(false)
|
||||
const loading = ref(false)
|
||||
const success = ref(false)
|
||||
|
||||
const handleRegister = async () => {
|
||||
msg.value = ""; // Üzenet alaphelyzetbe
|
||||
isError.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.post('/auth/register', form.value);
|
||||
// Sikeres válasz: 201 Created
|
||||
success.value = true
|
||||
msg.value = `Verification email sent to ${form.value.email}!`;
|
||||
isError.value = false
|
||||
} catch (err) {
|
||||
isError.value = true
|
||||
success.value = false
|
||||
// ITT A JAVÍTÁS: kiolvassuk a backend hibaüzenetét
|
||||
if (err.response && err.response.data && err.response.data.detail) {
|
||||
msg.value = "Hiba: " + err.response.data.detail;
|
||||
} else {
|
||||
msg.value = "Hiba történt a regisztráció során.";
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
success.value = false
|
||||
form.value = { email: '', password: '', first_name: '', last_name: '' }
|
||||
msg.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes checkmark {
|
||||
0% {
|
||||
stroke-dashoffset: 100;
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-checkmark circle {
|
||||
stroke-dasharray: 166;
|
||||
stroke-dashoffset: 166;
|
||||
animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-checkmark path {
|
||||
stroke-dasharray: 48;
|
||||
stroke-dashoffset: 48;
|
||||
animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards;
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-md mx-auto mt-20 p-8 bg-white rounded-2xl shadow-xl border">
|
||||
<h2 class="text-2xl font-bold text-center mb-6">Új jelszó megadása</h2>
|
||||
|
||||
<form @submit.prevent="handleReset" class="space-y-4">
|
||||
<input v-model="password" type="password" placeholder="Új jelszó" required class="w-full p-3 border rounded-lg" />
|
||||
<input v-model="passwordConfirm" type="password" placeholder="Jelszó megerősítése" required class="w-full p-3 border rounded-lg" />
|
||||
<button type="submit" class="w-full bg-blue-700 text-white py-3 rounded-lg font-bold">Jelszó mentése</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="mt-4 text-red-600 text-center">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const handleReset = async () => {
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = "A két jelszó nem egyezik!";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = route.query.token;
|
||||
await axios.post(`http://192.168.100.43:8000/api/v2/auth/reset-password-confirm`, {
|
||||
token: token,
|
||||
new_password: password.value
|
||||
});
|
||||
alert("Jelszó sikeresen megváltoztatva!");
|
||||
router.push('/login');
|
||||
} catch (err) {
|
||||
error.value = "Hiba történt. Lehetséges, hogy a link lejárt.";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Rendszer Statisztika (Admin)</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-blue-600 text-white p-6 rounded-lg shadow-lg">
|
||||
<p class="text-blue-100 uppercase text-xs font-bold">Összes Felhasználó</p>
|
||||
<p class="text-4xl font-black">1,248</p>
|
||||
</div>
|
||||
<div class="bg-emerald-600 text-white p-6 rounded-lg shadow-lg">
|
||||
<p class="text-emerald-100 uppercase text-xs font-bold">Aktív Voucherek</p>
|
||||
<p class="text-4xl font-black">42</p>
|
||||
</div>
|
||||
<div class="bg-amber-600 text-white p-6 rounded-lg shadow-lg">
|
||||
<p class="text-amber-100 uppercase text-xs font-bold">Bot Találatok (Új)</p>
|
||||
<p class="text-4xl font-black">156</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="p-4 border-b bg-gray-50 flex justify-between items-center">
|
||||
<span class="font-bold text-gray-700">Jóváhagyásra váró szervizek</span>
|
||||
<button class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600">Összes frissítése</button>
|
||||
</div>
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="bg-gray-100 text-gray-600 text-xs uppercase">
|
||||
<tr>
|
||||
<th class="p-3">Név</th>
|
||||
<th class="p-3">Város</th>
|
||||
<th class="p-3">Típus</th>
|
||||
<th class="p-3 text-right">Művelet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 text-sm">
|
||||
<tr v-for="i in 3" :key="i" class="hover:bg-gray-50">
|
||||
<td class="p-3 font-semibold">Profi Gumiszerviz #{{i}}</td>
|
||||
<td class="p-3">Budapest</td>
|
||||
<td class="p-3"><span class="bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs">Gumis</span></td>
|
||||
<td class="p-3 text-right">
|
||||
<button class="text-blue-600 hover:underline mr-3">Szerkeszt</button>
|
||||
<button class="text-green-600 hover:underline font-bold text-lg">✓</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user