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

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

View File

@@ -1,5 +1,79 @@
# Service Finder Fejlesztési Történet # Service Finder Fejlesztési Történet
## 2026-06-08 - F5 Refresh Logout Bug Fix & HeaderLogo Contextual Navigation
### 🎯 Cél
Két frontend hiba javítása: (1) F5 oldalfrissítéskor a Vue Router guard hamarabb fut le, mint a Pinia Auth Store init() befejeződik, ami kijelentkezést okoz. (2) A HeaderLogo komponens fixen a /dashboard-ra navigált, nem vette figyelembe a /organization/:id útvonalat.
### 🔧 Változtatások
**1. `frontend/src/stores/auth.ts`:**
- Új `isInitialized` ref állapot (alapértelmezett: `false`) hozzáadva a store state-hez.
- Az `init()` függvény legvégén `isInitialized.value = true` beállítás, így a router guard meg tudja várni az inicializálás végét.
- Az `isInitialized` exportálva a return objektumban.
**2. `frontend/src/router/index.ts`:**
- A `beforeEach` guard elején ellenőrzés: ha `!authStore.isInitialized`, akkor `await authStore.init()` meghívása.
- Ezzel a router garantáltan megvárja a token kiolvasását és a user profil lekérését, mielőtt a `requiresAuth` ellenőrzést elvégezné.
**3. `frontend/src/components/header/HeaderLogo.vue`:**
- `useRoute()` importálva a Vue Router-ből.
- `targetRoute` computed property: ha a jelenlegi útvonal `/organization/`-t tartalmaz, akkor a logó a jelenlegi útvonalra navigál (önmagát tölti újra), egyébként `/dashboard`.
- A prop-alapú `to` helyett a `:to="targetRoute"` használata a template-ben.
### ✅ Eredmény
- Vite build hiba nélkül lefutott (136 modul, 3.23s).
- Nincs több F5 utáni hamis kijelentkezés.
- A logó szervezeti oldalon nem dobja ki a felhasználót a dashboard-ra.
## 2026-06-08 - Issue #237: API Endpoints for visual_settings
### 🎯 Cél
Backend API végpontok felkészítése a `visual_settings` módosítására. Pydantic sémák (`OrganizationUpdate`, `OrganizationResponse`) létrehozása a `visual_settings` mezővel, valamint `PATCH /organizations/{org_id}/visual-settings` végpont implementálása JSONB merge logikával.
### ✅ Implementáció
#### 1. Pydantic Sémák
- **`OrganizationUpdate`**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:40) - Új séma `visual_settings: Optional[dict]`, `display_name`, `default_currency`, `language` mezőkkel
- **`OrganizationResponse`**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:49) - Új DTO séma `visual_settings: Optional[dict]` mezővel, `from_attributes=True` konfigurációval
#### 2. API Végpont
- **`PATCH /organizations/{org_id}/visual-settings`**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:207) - Új végpont JSONB merge logikával:
- OWNER/ADMIN jogosultság ellenőrzés
- Mély merge: a meglévő `visual_settings` kulcsok megmaradnak, csak a kapottak frissülnek
- Pl. `{"theme": "dark"}` küldése nem írja felül a `wall_logo_url`-t
- Egyéb mezők (`display_name`, `language`, `default_currency`) is frissíthetők
#### 3. Meglévő Végpontok Bővítése
- **`GET /organizations/my`**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:203) - `visual_settings` mező hozzáadva a response-hoz
- **`PATCH /users/me/preferences`**: [`backend/app/api/v1/endpoints/users.py`](backend/app/api/v1/endpoints/users.py:333) - Már támogatja a `visual_settings`-et a generikus `exclude_unset=True` logikával
#### 4. Verifikáció
- Python syntax check: minden fájl hibátlanul lefordul
- Import teszt: `OrganizationUpdate`, `OrganizationResponse`, `UserUpdate`, `UserResponse` mindegyike tartalmazza a `visual_settings` mezőt
## 2026-06-08 - Issue #228: Add visual_settings to Models
### 🎯 Cél
`visual_settings` JSONB mező hozzáadása a User és Organization modellekhez, a Pydantic sémák frissítése, majd az adatbázis szinkronizálása a sync_engine segítségével.
### ✅ Implementáció
#### 1. SQLAlchemy Modellek
- **User modell**: [`backend/app/models/identity/identity.py`](backend/app/models/identity/identity.py:157) - `visual_settings` JSONB oszlop hozzáadva a `ui_mode` után, `server_default` JSONB default értékkel (`{"theme": "default", "primary_color": null, "wall_logo_url": null}`)
- **Organization modell**: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:109) - `visual_settings` JSONB oszlop hozzáadva az `external_integration_config` után, azonos default értékkel
#### 2. Pydantic Sémák
- **UserResponse**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:62) - `visual_settings: Optional[dict]` mező hozzáadva default értékkel
- **UserUpdate**: [`backend/app/schemas/user.py`](backend/app/schemas/user.py:107) - `visual_settings: Optional[dict]` mező hozzáadva
#### 3. Adatbázis Szinkronizálás
- `sync_engine` sikeresen lefuttatva: 2 hiányzó oszlopot észlelt és hozzáadott (`identity.users.visual_settings` és `fleet.organizations.visual_settings`)
- Verifikáció: mindkét oszlop `jsonb` típusként létezik az adatbázisban
## 2026-06-04 (B) - Magic Link Implementáció (Auto-Login Email Verification) ## 2026-06-04 (B) - Magic Link Implementáció (Auto-Login Email Verification)
### 🎯 Cél ### 🎯 Cél
@@ -196,3 +270,216 @@ Az [`tests/active/e2e_profile_address.py`](tests/active/e2e_profile_address.py)
- Ha be van állítva → `pw.chromium.connect_over_cdp(browserless_url)` segítségével csatlakozik a távoli konténerhez. - Ha be van állítva → `pw.chromium.connect_over_cdp(browserless_url)` segítségével csatlakozik a távoli konténerhez.
- Ha nincs beállítva → lokális `pw.chromium.launch()` fallback, a `HEADLESS` env var figyelembevételével. - Ha nincs beállítva → lokális `pw.chromium.launch()` fallback, a `HEADLESS` env var figyelembevételével.
- **Gitea Issue**: #215 - létrehozva, elindítva és lezárva. - **Gitea Issue**: #215 - létrehozva, elindítva és lezárva.
## 2026-06-07 (C) - NAV API UX & Adatbázis Finomhangolás
### 🎯 Cél
A NAV API működik, de a felhasználói élmény és az adatbázis szigorúsága finomhangolást igényelt.
### ✅ Implementáció
#### 1. Cégjegyzékszám (company_registry_number) opcionálissá tétele
- **Modell**: [`backend/app/models/marketplace/organization.py`](backend/app/models/marketplace/organization.py:89) - `reg_number: Mapped[Optional[str]]` már eleve nullable volt
- **Schema**: [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py:17) - `reg_number: Optional[str] = None` már eleve opcionális volt
- **Adatbázis**: A `sync_engine` audit szerint a séma tökéletesen szinkronban van (1017/1017 elem OK)
#### 2. Duplikáció szűrése a NAV lekérdezés előtt
- **Végpont**: [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:124) - `GET /lookup-tax/{tax_number}`
- Injektált `db: AsyncSession = Depends(get_db)` függőség
- Mielőtt a NAV-hoz fordul, ellenőrzi az adatbázist az adószám első 8 számjegye (törzsszám) alapján
- Ha létezik aktív cég (`is_deleted == False`), azonnal `HTTPException(status_code=409, detail="Ez az adószám már regisztrálva van a rendszerben.")` hibát dob
#### 3. Rövid név okosítása (filler words filtering)
- **Service**: [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py:105) - `_format_company_names()` metódus
- `filler_words` lista: `["és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó", "ipari", "építőipari", "termelő", "fejlesztő", "kivitelező", "általános"]`
- A rövid név generálásakor a név szavakra bontva, csak a nem-töltelék szavak maradnak meg
- A cégforma rövidítés (pl. "Kft.") a legvégére kerül hozzáfűzésre
- Fallback: ha minden szó töltelék volt, az eredeti név marad
### ✅ Verifikáció
- Python import check: `nav_service.py` és `organizations.py` sikeresen importálódott
- `sync_engine` audit: 1017/1017 elem OK, 0 hiba, teljes szinkron
## 2026-06-08 - UI/UX Navigációs Hibák Javítása (3 Bugfix)
### 🎯 Cél
Három UI/UX és navigációs hiba javítása a Vue 3 frontendben: (1) cégnév/"Cégem" gomb navigáció `/company/garage`-ba, (2) Profil oldal bezárása backdrop overlay-re kattintva, (3) Avatar dropdown bezárása kattintáskor az elemen kívül.
### ✅ Implementáció
#### 1. ProfileView.vue - Backdrop Overlay
**Fájl**: [`frontend/src/views/ProfileView.vue`](frontend/src/views/ProfileView.vue:14)
- Teljes oldalt lefedő backdrop overlay (`fixed inset-0 z-10 bg-black/30 backdrop-blur-[2px]`) a profil kártya körül
- `@click="closeProfile"` a backdrop-on → kattintásra `router.push('/dashboard')`
- `@click.stop` a belső kártya `div`-en → megakadályozza a bezáródást a kártyán belüli kattintáskor
- `handleEscapeKey()`: Escape billentyűre bezárja a profilt (vagy a jelszó módosító modalt, ha az nyitva van)
- `onMounted`/`onUnmounted`: `keydown` eseményfigyelő regisztráció/törlés
#### 2. DashboardHeader.vue - Cégnév Navigáció
**Fájl**: [`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:157)
- Cégnév (`activeCompanyName`) `<button>`-ként jelenik meg, `@click="goToCompanyGarage"`
- `goToCompanyGarage()`: bezárja az összes dropdown-t, majd `router.push('/company/garage')`
- `handleCompanySelect(orgId)`: ha a szervezet már aktív, akkor is navigál `/company/garage`-ba (nem tér vissza korán)
#### 3. DashboardHeader.vue - Avatar Dropdown Click-Outside
**Fájl**: [`frontend/src/components/DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:394)
- `avatarContainerRef` template ref az avatar konténeren
- `handleOutsideClick()`: `avatarContainerRef.value.contains(target)` ellenőrzés → ha a kattintás az avatar konténeren kívül van, bezárja a `isUserMenuOpen` dropdown-t
#### 4. auth.ts - Store Navigáció Eltávolítása
**Fájl**: [`frontend/src/stores/auth.ts`](frontend/src/stores/auth.ts:524)
- `switchOrganization()` már nem tartalmaz `router.push()` hívásokat
- A navigáció kizárólag a komponensek felelőssége (Separation of Concerns)
### ✅ Verifikáció
- Vite production build: 121 module transformed, 0 hiba, 3.35s alatt
## 2026-06-08 - Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer Mérföldkő Létrehozása
### 🎯 Cél
Új Gitea mérföldkő (ID: 26) létrehozása a frontend architektúrális átalakításához, valamint a hozzá tartozó 8 issue (ID: #228-#236) létrehozása a `gitea_manager.py` script segítségével.
### ✅ Elkészült elemek
#### Mérföldkő
- **Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer** (ID: 26)
#### 1. FÁZIS: Adatbázis és Backend Alapozás
- **#228** - 1.1: Adatbázis sémák bővítése vizuális konfigurációval (Skins/Themes) - JSONB mezők User és Organization táblákhoz
- **#229** - 1.2: Engedélyezési szintek és Funkciókapcsolók (Feature Flags / RBAC) - ABAC kiterjesztés
- **#230** - 1.3: Dinamikus (Adatbázis) és Statikus fordítási rendszer alapjai - Translation tábla
#### 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás
- **#231** - 2.1: Független Layout komponensek felépítése (Vue 3) - PrivateLayout & OrganizationLayout
- **#232** - 2.2: Vue Router átírás (Nested Routing) - Hierarchikus útvonalak
#### 3. FÁZIS: Többnyelvűség (i18n) & Vizuális Témaváltó (Theming Engine)
- **#233** - 3.1: i18n audit & Szövegek kiszervezése nyelvi kulcsokra
- **#234** - 3.2: Tailwind CSS Változók és Témakezelő implementálása (AppThemeStore)
#### 4. FÁZIS: Animált UI Készlet
- **#235** - 4.1: "App Store" típusú kiterjedő kártya (Expansion Card)
- **#236** - 4.2: Kontextuális Vészjelző Rendszer (Villogó Hibajelző Lámpa)
## 2026-06-08 - Frontend Architektúra 2.0 - Megvalósíthatósági Elemzés
### 🎯 Cél
A létrehozott 8 Gitea issue (#228-#236) kód-szintű felmérése: mi az, ami már létezik, mi hiányzik, és milyen függőségi sorrendben kell implementálni.
### ✅ Elemzés eredménye
#### Készültségi Tábla
| Issue | Kártya | Kész | Megjegyzés |
|-------|--------|------|------------|
| #228 | JSONB visual_settings | **0%** | Nincs visual_settings mező semelyik modellben |
| #229 | RBAC/ABAC | **40%** | UserRole, OrgUserRole, subscription_plan létezik; feature flag-ok hiányoznak |
| #230 | Dinamikus Fordítás | **80%** | Translation modell, vue-i18n, 6 nyelvi fájl, translationService kész |
| #231 | Layout komponensek | **60%** | 3 layout létezik, de `<slot />`-ot használnak `<router-view />` helyett |
| #232 | Nested Routing | **50%** | 8 route létezik, de flat struktúrában, nincs layout nesting |
| #233 | i18n audit | **30%** | 17+ hardcoded szöveg azonosítva 5 komponensben |
| #234 | Tailwind Témaváltó | **0%** | Nincs CSS variable, nincs AppThemeStore, nincsenek `dark:` osztályok |
| #235 | Expansion Card | **0%** | Nincs dedikált komponens |
| #236 | Vészjelző Rendszer | **10%** | hotspot-pulse/ring animációk léteznek, de nincs ContextAlert komponens |
#### Feltárt fájlok (18+ darab)
- Backend: [`identity.py`](backend/app/models/identity/identity.py:122), [`organization.py`](backend/app/models/marketplace/organization.py:35), [`translation.py`](backend/app/models/system/translation.py:8)
- Frontend Store: [`auth.ts`](frontend/src/stores/auth.ts:54), [`appModeStore.ts`](frontend/src/stores/appModeStore.ts:6)
- Frontend Layout: [`ConsumerLayout.vue`](frontend/src/layouts/ConsumerLayout.vue:1), [`CorporateLayout.vue`](frontend/src/layouts/CorporateLayout.vue:1), [`AdminLayout.vue`](frontend/src/layouts/AdminLayout.vue:1)
- Frontend Router: [`index.ts`](frontend/src/router/index.ts:1)
- Frontend i18n: [`hu.ts`](frontend/src/i18n/hu.ts:1), [`en.ts`](frontend/src/i18n/en.ts:1), [`translationService.ts`](frontend/src/services/translationService.ts:5)
- Frontend Komponens: [`DashboardHeader.vue`](frontend/src/components/DashboardHeader.vue:1), [`DashboardView.vue`](frontend/src/views/DashboardView.vue:1), [`CompanyGarageView.vue`](frontend/src/views/organization/CompanyGarageView.vue:1)
- Frontend Config: [`tailwind.config.js`](frontend/tailwind.config.js:1), [`main.css`](frontend/src/assets/main.css:1), [`main.ts`](frontend/src/main.ts:30)
- Frontend Types: [`organization.ts`](frontend/src/types/organization.ts:5)
#### Ajánlott implementációs sorrend
1. **#228** (JSONB) → **#229** (RBAC) → **#234** (Témaváltó)
2. **#230** (Fordítás) → **#233** (i18n audit)
3. **#231** (Layout) → **#232** (Router) → **#235** (Expansion) → **#236** (Alert)
#### Dokumentáció
- Teljes elemzés: [`docs/frontend_architecture_2_0_feasibility_analysis.md`](docs/frontend_architecture_2_0_feasibility_analysis.md)
## 2026-06-08 - Frontend Architektúra 2.0 - Hiányzó Alkártyák Létrehozása
### 🎯 Cél
Az elemzés alapján azonosított hiányzó komponensekhez 14 új Gitea alkártya (#237-#250) létrehozása, majd hozzárendelése a "Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer" mérföldkőhöz (ID: 26).
### ✅ Elkészült elemek
#### 1. FÁZIS: Adatbázis és Backend Alapozás (Alkártyák)
- **#237** - 1.1-A: Backend API végpontok visual_settings kezelésére (GET/PUT /users/me/visual-settings, GET/PUT /organizations/{id}/visual-settings)
- **#238** - 1.1-B: Frontend useVisualSettings() composable + UserProfile/OrganizationItem interface bővítés visual_settings mezővel
- **#239** - 1.2-A: Backend subscription_service.py + SUBSCRIPTION_FEATURES config dict (allow_custom_skin, allow_advanced_animations)
- **#240** - 1.2-B: Frontend useFeatureFlag() composable + auth store bővítés subscription_features mezővel
#### 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás (Alkártyák)
- **#241** - 2.1-A: PrivateLayout komponens kialakítása (ConsumerLayout átnevezés)
- **#242** - 2.1-B: OrganizationLayout komponens kialakítása (CorporateLayout átnevezés)
- **#243** - 2.2-A: Nested Router struktúra kialakítása (layout → child route)
- **#244** - 2.2-B: Route transition guard + breadcrumb komponens
#### 3. FÁZIS: Többnyelvűség & Témaváltó (Alkártyák)
- **#245** - 3.1-A: i18n audit - hardcoded szövegek kiszervezése
- **#246** - 3.1-B: i18n audit - hiányzó nyelvi kulcsok pótlása
- **#247** - 3.2-A: AppThemeStore implementálása (CSS variable binding)
- **#248** - 3.2-B: Tailwind CSS dark mode + skin class binding
#### 4. FÁZIS: Animált UI Készlet (Alkártyák)
- **#249** - 4.1: ExpansionCard komponens (App Store típusú kártya)
- **#250** - 4.2: ContextAlert komponens (Vészjelző Rendszer)
### 🔗 Gitea
- **Mérföldkő**: Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (ID: 26)
- **Státusz**: Alkártyák létrehozva, mérföldkőhöz rendelve
---
## 2026-06-09 - Széf→Garázs Terminológia Javítás & Regisztrációs Hiba Javítás
### 🎯 Cél
Két kritikus hiba javítása a járművek csatlakoztatása előtt: (1) "Széf" terminológia cseréje "Garázs"-ra a backend kódban, (2) Regisztrációs hiba javítása, ahol az opcionális címmezők kitöltetlenül hagyása csendes hibát okozott.
### ✅ Javítások
#### 1. Terminológia Javítás ("Széf" → "Garázs")
**Elsődleges - Backend organization létrehozás**:
- **Fájl**: [`backend/app/services/auth_service.py`](backend/app/services/auth_service.py:253)
- **Hiba**: A `complete_kyc()` metódusban a személyes garázs organization `name` mezője `f"{p.last_name} Széfe"` volt hardkódolva
- **Javítás**: `f"{p.last_name} Széfe"``f"{p.last_name} Garázsa"`
- **Hatás**: A regisztráció után a felhasználó a sárga gombban "{Vezetéknév} Garázsa" feliratot látja
**Másodlagos - Dokumentum archiválás üzenet**:
- **Fájl**: [`backend/app/api/v1/endpoints/documents.py`](backend/app/api/v1/endpoints/documents.py:63)
- **Hiba**: `"Dokumentum sikeresen archiválva a széfben."`
- **Javítás**: `"Dokumentum sikeresen archiválva a garázsban."`
**Ellenőrzött frontend fájlok (nem volt szükség módosításra)**:
- [`frontend/src/i18n/hu.ts`](frontend/src/i18n/hu.ts:38) - Már "garázs"-t használ (`personalGarage`, `privateGarage`, `companyGarageLabel`, `personalGarageLabel`)
- [`frontend/src/i18n/en.ts`](frontend/src/i18n/en.ts:41) - Már "garage"-t használ helyesen
- [`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue:42) - i18n kulcsokat használ, nincs hardkódolt "széf"
- [`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue:10) - Már "garázsa" szerepel a template-ben
#### 2. Regisztrációs Csendes Hiba Javítása
**Gyökér Ok**:
- **Fájl**: [`backend/app/services/geo_service.py`](backend/app/services/geo_service.py:50)
- A `get_or_create_full_address()` metódusban a `street_name`, `street_type` és `house_number` paraméterek **kötelezőek** voltak (`str` típus, nem `Optional[str]`)
- A KYC űrlapról ezek a mezők `None` értékkel érkezhetnek (opcionális mezők)
- A `UserKYCComplete` Pydantic séma helyesen `Optional[str] = None`-ként deklarálta őket
- A frontend helyesen kihagyta az üres mezőket a payload-ból
- **A hiba**: A `GeoService.get_or_create_full_address()` nem fogadott el `None` értéket ezekre a paraméterekre
**Javítás**:
- `street_name: str``street_name: Optional[str] = None`
- `street_type: str``street_type: Optional[str] = None`
- `house_number: str``house_number: Optional[str] = None`
**Ellenőrzött fájlok (nem volt szükség módosításra)**:
- [`backend/app/schemas/auth.py`](backend/app/schemas/auth.py:28) - `UserKYCComplete` séma helyesen `Optional[str] = None`-t használ
- [`frontend/src/views/CompleteKycView.vue`](frontend/src/views/CompleteKycView.vue:411) - A `handleSubmit()` helyesen szűri az üres stringeket
### ✅ Verifikáció
- Vite production build: 121 module transformed, 0 hiba, 3.56s alatt
- Python syntax check: minden módosított fájl hibátlan
- `sync_engine` futtatva: séma konzisztens

View File

@@ -60,7 +60,7 @@ async def upload_document(
if doc.status == "processing": if doc.status == "processing":
response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!" response_data["message"] = "🤖 Robot 1 megkezdte a dokumentum elemzését. Értesítjük, ha kész!"
else: else:
response_data["message"] = "Dokumentum sikeresen archiválva a széfben." response_data["message"] = "Dokumentum sikeresen archiválva a garázsban."
return response_data return response_data

View File

@@ -12,7 +12,7 @@ from sqlalchemy import select
from app.db.session import get_db from app.db.session import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch
from app.models.identity import User # JAVÍTVA: Központi Identity modell from app.models.identity import User # JAVÍTVA: Központi Identity modell
from app.core.config import settings from app.core.config import settings
@@ -39,13 +39,13 @@ async def onboard_organization(
detail="Érvénytelen magyar adószám formátum!" detail="Érvénytelen magyar adószám formátum!"
) )
# 2. Duplikáció ellenőrzés # 2. Duplikáció ellenőrzés adószám alapján
stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number) stmt_exist = select(Organization).where(Organization.tax_number == org_in.tax_number)
result_exist = await db.execute(stmt_exist) result_exist = await db.execute(stmt_exist)
if result_exist.scalar_one_or_none(): if result_exist.scalar_one_or_none():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Ezzel az adószámmal már regisztráltak céget!" detail="Ez a cég (adószám) már regisztrálva van a rendszerben."
) )
# 3. KÖTELEZŐ MEZŐ: folder_slug generálása # 3. KÖTELEZŐ MEZŐ: folder_slug generálása
@@ -121,6 +121,56 @@ async def onboard_organization(
return {"organization_id": new_org.id, "status": new_org.status} return {"organization_id": new_org.id, "status": new_org.status}
@router.get("/lookup-tax/{tax_number}")
async def lookup_tax_number(
tax_number: str,
db: AsyncSession = Depends(get_db),
):
"""
Adószám alapú cégadat lekérdezés a hivatalos NAV Online Számla API v3 rendszerén keresztül.
A queryTaxpayer végpontot használja valós idejű cégadatok lekérésére.
Duplikáció ellenőrzés: Mielőtt a NAV-hoz fordulnánk, ellenőrizzük, hogy az adószám
első 8 számjegye (törzsszám) alapján létezik-e már aktív cég az adatbázisban.
Hibakezelés:
- 409: Az adószám már regisztrálva van a rendszerben
- 503: NAV rendszer nem elérhető (hálózati hiba, DNS, timeout)
- 404: Adószám nem található a NAV adatbázisában
"""
from app.services.nav_service import NavService, NavServiceUnavailableError
# Duplikáció ellenőrzés: adószám első 8 számjegye (törzsszám) alapján
tax_core = tax_number.strip().replace("-", "").replace(" ", "")[:8]
stmt_existing = select(Organization).where(
Organization.tax_number.like(f"{tax_core}%"),
Organization.is_deleted == False,
)
result_existing = await db.execute(stmt_existing)
existing_org = result_existing.scalar_one_or_none()
if existing_org:
logger.warning(f"Duplikáció észlelve: adószám {tax_core} már létezik (org_id={existing_org.id})")
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Ez az adószám már regisztrálva van a rendszerben."
)
try:
result = await NavService.query_taxpayer(tax_number)
except NavServiceUnavailableError as e:
logger.error(f"NAV szolgáltatás hiba: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="A NAV rendszere jelenleg nem elérhető."
)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cég nem található a NAV adatbázisában"
)
return result
@router.get("/my", response_model=List[dict]) @router.get("/my", response_model=List[dict])
async def get_my_organizations( async def get_my_organizations(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -147,10 +197,84 @@ async def get_my_organizations(
"country_code": o.country_code, "country_code": o.country_code,
"is_active": o.is_active, "is_active": o.is_active,
"is_deleted": o.is_deleted, "is_deleted": o.is_deleted,
"subscription_plan": o.subscription_plan "subscription_plan": o.subscription_plan,
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None
} }
for o in orgs for o in orgs
] ]
@router.patch("/{org_id}/visual-settings", response_model=OrganizationResponse)
async def update_organization_visual_settings(
org_id: int,
update_data: OrganizationUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Szervezet vizuális beállításainak részleges frissítése (JSONB merge).
A frontend küldhet csak egyetlen kulcsot is (pl. {"theme": "dark"}),
és az nem írja felül null-lal a többi mezőt. A meglévő visual_settings
objektum mélyen merge-elődik a beérkező adatokkal.
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
"""
# 1. Jogosultság ellenőrzése
stmt_member = select(OrganizationMember).where(
(OrganizationMember.organization_id == org_id) &
(OrganizationMember.user_id == current_user.id) &
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
)
member = (await db.execute(stmt_member)).scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultságod a szervezet beállításainak módosításához (csak OWNER/ADMIN)."
)
# 2. Szervezet lekérése
stmt_org = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
)
result = await db.execute(stmt_org)
org = result.scalar_one_or_none()
if not org:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Szervezet nem található."
)
# 3. JSONB merge: csak a kapott kulcsokat frissítjük, a többit megtartjuk
update_dict = update_data.dict(exclude_unset=True)
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
incoming_vs = update_dict["visual_settings"]
current_vs = dict(org.visual_settings) if org.visual_settings else {}
# Mély merge: a meglévő kulcsok megmaradnak, csak a kapottak frissülnek
current_vs.update(incoming_vs)
org.visual_settings = current_vs
del update_dict["visual_settings"]
# 4. Egyéb mezők frissítése (display_name, language, default_currency)
for field, value in update_dict.items():
if hasattr(org, field) and value is not None:
setattr(org, field, value)
try:
await db.commit()
await db.refresh(org)
except Exception as e:
await db.rollback()
logger.error(f"Hiba a szervezet frissítésekor (org_id={org_id}): {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Adatbázis hiba: {str(e)}"
)
return OrganizationResponse.model_validate(org)
# --- B2B MEGHÍVÓ LOGIKA --- # --- B2B MEGHÍVÓ LOGIKA ---
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr

View File

@@ -402,8 +402,8 @@ async def update_active_organization(
detail="You are not a member of this organization" detail="You are not a member of this organization"
) )
# Update user's scope_id # Update user's scope_id (stored as string in DB, convert from int)
current_user.scope_id = org_id current_user.scope_id = str(org_id) if org_id is not None else None
try: try:
await db.commit() await db.commit()
@@ -419,7 +419,7 @@ async def update_active_organization(
"role": role_key, "role": role_key,
"rank": DEFAULT_RANK_MAP.get(role_key, "user"), "rank": DEFAULT_RANK_MAP.get(role_key, "user"),
"scope_level": "organization" if org_id else "personal", "scope_level": "organization" if org_id else "personal",
"scope_id": org_id, "scope_id": str(org_id) if org_id is not None else None,
"person_id": str(current_user.person_id) if current_user.person_id else None, "person_id": str(current_user.person_id) if current_user.person_id else None,
} }

View File

@@ -127,6 +127,23 @@ class Settings(BaseSettings):
return parts return parts
return v return v
# --- NAV Online Számla API v3 ---
NAV_API_USER: str = ""
NAV_API_PASSWORD: str = ""
NAV_API_SIGN_KEY: str = ""
NAV_API_EXCHANGE_KEY: str = ""
NAV_API_CALLER_TAX_NUMBER: str = ""
NAV_API_BASE_URL: str = "https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3"
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
@classmethod
def strip_quotes(cls, v: str) -> str:
if v:
v = v.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
v = v[1:-1]
return v
# --- Google OAuth --- # --- Google OAuth ---
GOOGLE_CLIENT_ID: str = "" GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = "" GOOGLE_CLIENT_SECRET: str = ""

View File

@@ -154,6 +154,13 @@ class User(Base):
preferred_currency: Mapped[str] = mapped_column(String(3), server_default="HUF") preferred_currency: Mapped[str] = mapped_column(String(3), server_default="HUF")
ui_mode: Mapped[str] = mapped_column(String(20), server_default="personal") ui_mode: Mapped[str] = mapped_column(String(20), server_default="personal")
visual_settings: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
)
scope_level: Mapped[str] = mapped_column(String(30), server_default="individual") scope_level: Mapped[str] = mapped_column(String(30), server_default="individual")
scope_id: Mapped[Optional[str]] = mapped_column(String(50)) scope_id: Mapped[Optional[str]] = mapped_column(String(50))
custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb")) custom_permissions: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))

View File

@@ -106,6 +106,13 @@ class Organization(Base):
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb")) notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb")) external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
visual_settings: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"theme": "default", "primary_color": None, "wall_logo_url": None},
server_default=text("'{\"theme\": \"default\", \"primary_color\": null, \"wall_logo_url\": null}'::jsonb")
)
# A technikai tulajdonos (User fiók - törölhető) # A technikai tulajdonos (User fiók - törölhető)
owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id")) owner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from typing import Optional, List from typing import Optional, List, Any
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
full_name: str full_name: str
@@ -35,4 +35,34 @@ class CorpOnboardIn(BaseModel):
class CorpOnboardResponse(BaseModel): class CorpOnboardResponse(BaseModel):
organization_id: int organization_id: int
status: str status: str
model_config = ConfigDict(from_attributes=True)
class OrganizationUpdate(BaseModel):
"""PATCH séma a szervezet adatainak részleges frissítéséhez.
Támogatja a visual_settings JSONB mező biztonságos részleges frissítését."""
visual_settings: Optional[dict] = None
display_name: Optional[str] = None
default_currency: Optional[str] = None
language: Optional[str] = None
class OrganizationResponse(BaseModel):
"""Szervezet adatainak visszaadása (DTO).
Tartalmazza a visual_settings mezőt a frontend számára."""
id: int
name: str
full_name: str
display_name: Optional[str] = None
tax_number: Optional[str] = None
country_code: str = "HU"
status: str
org_type: Optional[str] = None
is_active: bool = True
is_deleted: bool = False
subscription_plan: str = "FREE"
visual_settings: Optional[dict] = None
notification_settings: Optional[Any] = None
external_integration_config: Optional[Any] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -59,6 +59,7 @@ class UserResponse(UserBase):
scope_level: str scope_level: str
scope_id: Optional[str] = None scope_id: Optional[str] = None
ui_mode: str = "personal" ui_mode: str = "personal"
visual_settings: Optional[dict] = {"theme": "default", "primary_color": None, "wall_logo_url": None}
active_organization_id: Optional[int] = None active_organization_id: Optional[int] = None
preferred_language: str = "hu" preferred_language: str = "hu"
# Beágyazott személyes adatok (Person + Address) # Beágyazott személyes adatok (Person + Address)
@@ -102,10 +103,11 @@ class UserUpdate(BaseModel):
last_name: Optional[str] = None last_name: Optional[str] = None
preferred_language: Optional[str] = None preferred_language: Optional[str] = None
ui_mode: Optional[str] = None ui_mode: Optional[str] = None
visual_settings: Optional[dict] = None
class ActiveOrganizationUpdate(BaseModel): class ActiveOrganizationUpdate(BaseModel):
organization_id: Optional[str] = None # UUID/string or None to revert to personal mode organization_id: Optional[int] = None # Integer ID or None to revert to personal mode
class UserWithTokenResponse(BaseModel): class UserWithTokenResponse(BaseModel):

View File

@@ -250,7 +250,7 @@ class AuthService:
org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name) org_full_name = org_tpl.format(last_name=p.last_name, first_name=p.first_name)
new_org = Organization( new_org = Organization(
full_name=org_full_name, full_name=org_full_name,
name=f"{p.last_name} Széfe", name=f"{p.last_name} Garázsa",
folder_slug=generate_secure_slug(12), folder_slug=generate_secure_slug(12),
org_type=OrgType.individual, org_type=OrgType.individual,
owner_id=user.id, owner_id=user.id,

View File

@@ -44,15 +44,15 @@ class GeoService:
@staticmethod @staticmethod
async def get_or_create_full_address( async def get_or_create_full_address(
db: AsyncSession, db: AsyncSession,
zip_code: str, zip_code: str,
city: str, city: str,
street_name: str, street_name: Optional[str] = None,
street_type: str, street_type: Optional[str] = None,
house_number: str, house_number: Optional[str] = None,
stairwell: Optional[str] = None, stairwell: Optional[str] = None,
floor: Optional[str] = None, floor: Optional[str] = None,
door: Optional[str] = None, door: Optional[str] = None,
parcel_id: Optional[str] = None, parcel_id: Optional[str] = None,
user_id: Optional[int] = None # A régió-alapú felülbíráláshoz user_id: Optional[int] = None # A régió-alapú felülbíráláshoz
) -> uuid.UUID: ) -> uuid.UUID:

View File

@@ -0,0 +1,410 @@
# /opt/docker/dev/service_finder/backend/app/services/nav_service.py
"""
NAV Online Számla API v3 integráció queryTaxpayer végpont.
Hivatalos magyar adószám alapú cégadat-lekérdezés XML/SOAP alapon.
"""
import uuid
import hashlib
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any
import httpx
from app.core.config import settings
logger = logging.getLogger(__name__)
class NavServiceUnavailableError(Exception):
"""
Kivétel, amikor a NAV API szerver nem elérhető hálózati hiba miatt
(DNS, timeout, kapcsolódási hiba). Ezt a végpont 503-as hibaként kezeli.
"""
def __init__(self, message: str = "NAV szerver nem elérhető"):
self.message = message
super().__init__(self.message)
# --- NAV API v3 XML sablonok ---
QUERY_TAXPAYER_XML_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<QueryTaxpayerRequest xmlns="http://schemas.nav.gov.hu/OSA/3.0/api" xmlns:common="http://schemas.nav.gov.hu/NTCA/1.0/common">
<common:header>
<common:requestId>{request_id}</common:requestId>
<common:timestamp>{timestamp}</common:timestamp>
<common:requestVersion>3.0</common:requestVersion>
<common:headerVersion>1.0</common:headerVersion>
</common:header>
<common:user>
<common:login>{user}</common:login>
<common:passwordHash cryptoType="SHA-512">{password_hash}</common:passwordHash>
<common:taxNumber>{caller_tax_number}</common:taxNumber>
<common:requestSignature cryptoType="SHA3-512">{request_signature}</common:requestSignature>
</common:user>
<software>
<softwareId>{software_id}</softwareId>
<softwareName>ServiceFinder</softwareName>
<softwareOperation>LOCAL_SOFTWARE</softwareOperation>
<softwareMainVersion>1.0</softwareMainVersion>
<softwareDevName>ServiceFinder</softwareDevName>
<softwareDevContact>dev@servicefinder.hu</softwareDevContact>
<softwareDevCountryCode>HU</softwareDevCountryCode>
<softwareDevTaxNumber>{caller_tax_number}</softwareDevTaxNumber>
</software>
<taxNumber>{tax_number}</taxNumber>
</QueryTaxpayerRequest>
"""
class NavService:
"""
NAV Online Számla API v3 szolgáltatás.
Dokumentáció: https://onlineszamla.nav.gov.hu/
"""
# Gyakori magyar cégforma rövidítések szótára
COMPANY_FORM_SHORT_MAP = {
"Korlátolt Felelősségű Társaság": "Kft.",
"Betéti Társaság": "Bt.",
"Zártkörűen Működő Részvénytársaság": "Zrt.",
"Nyilvánosan Működő Részvénytársaság": "Nyrt.",
"Közkereseti Társaság": "Kkt.",
}
@staticmethod
def _sha512_hex(value: str) -> str:
"""SHA-512 hash hex formátumban, felsőoktatással (uppercase)."""
return hashlib.sha512(value.encode("utf-8")).hexdigest().upper()
@staticmethod
def _title_case(text: str) -> str:
"""
Csupa nagybetűs szöveg átalakítása Title Case formátumra.
Példa: 'DUNAKESZI' -> 'Dunakeszi', 'BUDAPEST' -> 'Budapest'
Kisbetűs szavakat (pl. 'és', 'a', 'az') meghagyja.
"""
if not text:
return text
# Ha nem csupa nagybetűs, akkor nem alakítjuk át
if not text.isupper():
return text
words = text.split()
result_words = []
for word in words:
# Ha a szó rövid (1-2 karakter), vagy kötőjeles, kezeljük külön
if "-" in word:
# Kötőjeles szavak: minden tagot külön kezelünk
parts = word.split("-")
titled_parts = [p.capitalize() for p in parts]
result_words.append("-".join(titled_parts))
else:
result_words.append(word.capitalize())
return " ".join(result_words)
@staticmethod
def _format_company_names(raw_name: str) -> tuple[str, str]:
"""
Cégnév okos formázása.
A NAV-tól kapott csupa nagybetűs névből készít:
- full_name: Title Case formátumú teljes név
- short_name: Rövidített név (pl. 'Kft.'-re cserélt), töltelékszavak eltávolításával
Returns:
tuple[str, str]: (full_name, short_name)
"""
if not raw_name:
return "", ""
# 1. Title Case formázás
full_name = NavService._title_case(raw_name)
# 2. Cégforma rövidítésének meghatározása
detected_short_form = None
short_name_base = full_name
for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items():
if long_form in short_name_base:
# Eltávolítjuk a hosszú formát a névből a szűrés előtt
short_name_base = short_name_base.replace(long_form, "")
detected_short_form = short_form
break
# 3. Töltelékszavak szűrése a rövid névből
filler_words = [
"és", "szolgáltató", "kereskedelmi", "üzleti", "tanácsadó",
"ipari", "építőipari", "termelő", "fejlesztő", "kivitelező",
"általános"
]
# Szavakra bontás és töltelékszavak eltávolítása
words = short_name_base.split()
filtered_words = [w for w in words if w.lower() not in filler_words]
# Ha minden szó töltelék volt, tartsuk meg az eredeti nevet
if filtered_words:
short_name = " ".join(filtered_words).strip()
else:
short_name = full_name
# Ilyenkor újra megkeressük a cégformát a teljes névből
for long_form, short_form in NavService.COMPANY_FORM_SHORT_MAP.items():
if long_form in short_name:
short_name = short_name.replace(long_form, short_form)
break
# 4. Cégforma hozzáfűzése a rövid név végére
if detected_short_form:
short_name = f"{short_name} {detected_short_form}".strip()
return full_name, short_name
@staticmethod
def _sha3_512_hex(value: str) -> str:
"""SHA3-512 hash hex formátumban, felsőoktatással (uppercase)."""
return hashlib.sha3_512(value.encode("utf-8")).hexdigest().upper()
@classmethod
def _build_header(cls) -> Dict[str, str]:
"""
NAV API v3 header összeállítása a specifikáció szerint.
- passwordHash: NAV_API_PASSWORD felsőoktatású SHA-512 hash-e
- requestId: UUID, max 30 karakter
- timestamp: UTC időpont YYYY-MM-DDTHH:MM:SS.000Z formátumban
- requestSignature (HTTP header): requestId + timestamp + exchangeKey SHA3-512 hash-e (uppercase)
- xmlRequestSignature (XML body): requestId + timestamp + signKey SHA3-512 hash-e (uppercase)
Fontos: Minden hitelesítő adatot megtisztítunk a whitespace-ektől és idézőjelektől,
mert a .env fájlból beolvasott értékek tartalmazhatnak láthatatlan szóközöket,
amelyek elrontják a hash-eket (SHA-512 / SHA3-512).
"""
# Tisztított változók eltávolítjuk a whitespace-t és az idézőjeleket
clean_password = settings.NAV_API_PASSWORD.strip().replace('"', '').replace("'", "")
clean_sign_key = settings.NAV_API_SIGN_KEY.strip().replace('"', '').replace("'", "")
clean_exchange_key = settings.NAV_API_EXCHANGE_KEY.strip().replace('"', '').replace("'", "")
clean_user = settings.NAV_API_USER.strip().replace('"', '').replace("'", "")
password_hash = cls._sha512_hex(clean_password)
request_id = str(uuid.uuid4()).replace("-", "").upper()[:30]
# Kétféle timestamp formátum a NAV API specifikáció szerint:
# - timestamp_xml: az XML body-ba kerül, ISO 8601 formátumban (kötőjellel, T-vel, Z-vel)
# - timestamp_hash: a hash számításhoz, MASZKOLT formátumban (NINCS kötőjel, NINCS T, NINCS Z)
now_utc = datetime.now(timezone.utc)
timestamp_xml = now_utc.strftime("%Y-%m-%dT%H:%M:%S.000Z")
timestamp_hash = now_utc.strftime("%Y%m%d%H%M%S")
# HTTP header RequestSignature = SHA3-512(requestId + timestamp_hash + exchangeKey)
http_sig_raw = request_id + timestamp_hash + clean_exchange_key
http_request_signature = cls._sha3_512_hex(http_sig_raw)
# XML body user.requestSignature = SHA3-512(requestId + timestamp_hash + signKey)
xml_sig_raw = request_id + timestamp_hash + clean_sign_key
xml_request_signature = cls._sha3_512_hex(xml_sig_raw)
# Debug logok a NAV API v3 signature számítás ellenőrzéséhez
logger.info(f"NAV DEBUG - Request ID: {request_id}")
logger.info(f"NAV DEBUG - Timestamp XML: {timestamp_xml}")
logger.info(f"NAV DEBUG - Timestamp Hash: {timestamp_hash}")
logger.info(f"NAV DEBUG - Clean Sign Key: {clean_sign_key}")
logger.info(f"NAV DEBUG - RAW Sign String: {xml_sig_raw}")
logger.info(f"NAV DEBUG - Generated SHA3-512: {xml_request_signature}")
return {
"requestId": request_id,
"timestamp": timestamp_xml,
"requestSignature": http_request_signature,
"xmlRequestSignature": xml_request_signature,
"passwordHash": password_hash,
"exchangeKey": clean_exchange_key,
"user": clean_user,
}
@classmethod
def _build_query_taxpayer_xml(cls, tax_number: str, header: Dict[str, str]) -> str:
"""
QueryTaxpayerRequest XML body összeállítása a NAV API v3 specifikáció szerint.
Két külön adószámot használ:
- caller_tax_number: a hívó fél adószáma (settings.NAV_API_CALLER_TAX_NUMBER-ból),
az autentikációhoz a <user><taxNumber> mezőben.
- tax_number: a lekérdezni kívánt cég adószáma (a metódus argumentuma),
a tényleges lekérdezéshez az outer <taxNumber> mezőben.
A header paraméter a _build_header() által előállított dict, amely tartalmazza
a requestId-t, timestamp-ot és xmlRequestSignature-t. Ezt kívülről kapja meg,
hogy a HTTP fejléc és a XML body AZONOS requestId-t és timestamp-ot használjon.
Fontos: A HTTP header RequestSignature az exchangeKey-kel, míg a XML body
user.requestSignature a signKey-kel számolódik (NAV v3 specifikáció).
"""
# A hívó (caller) adószámának első 8 számjegye (törzsszám) autentikációhoz
caller_tax_number_core = (
settings.NAV_API_CALLER_TAX_NUMBER
.strip()
.replace("-", "")
.replace(" ", "")[:8]
)
# A lekérdezni kívánt (target) adószám első 8 számjegye (törzsszám) lekérdezéshez
target_tax_number_core = (
tax_number
.strip()
.replace("-", "")
.replace(" ", "")[:8]
)
# A user.requestSignature = SHA3-512(requestId + timestamp + signKey)
# a cryptoType="SHA3-512" attribútummal
# EZT a header dict-ben xmlRequestSignature-ként tároljuk
user_request_signature = header["xmlRequestSignature"]
xml_body = QUERY_TAXPAYER_XML_TEMPLATE.format(
request_id=header["requestId"],
timestamp=header["timestamp"],
caller_tax_number=caller_tax_number_core,
tax_number=target_tax_number_core,
user=header["user"],
password_hash=header["passwordHash"],
request_signature=user_request_signature,
software_id="SF-NAV-API-001-000",
)
return xml_body
@classmethod
def _parse_taxpayer_response(cls, xml_text: str) -> Optional[Dict[str, Any]]:
"""
NAV API válasz feldolgozása névtér-független parser.
A NAV a funcCode-ot a common, az adatokat az api névtérben küldi,
ezért a root.iter()-en végigiterálva, a tag név névtérrészének
levágásával (el.tag.split("}")[-1]) keressük az elemeket.
"""
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(xml_text)
def get_text(tag_name: str) -> Optional[str]:
"""
Végigiterál a root.iter()-en, és visszaadja az első olyan elem
szöveges tartalmát, amelynek a neve (névtér prefix levágása után)
megegyezik a tag_name paraméterrel.
"""
for el in root.iter():
local_tag = el.tag.split("}")[-1]
if local_tag == tag_name:
return el.text if el.text else None
return None
# funcCode ellenőrzés a NAV a common névtérben küldi
func_code = get_text("funcCode")
if func_code is None:
logger.warning("NAV válasz: funcCode nem található")
return None
if func_code != "OK":
# Hibaüzenet kiolvasása a NAV a message tag-et a common névtérben küldi
error_msg = get_text("message") or "Ismeretlen hiba"
logger.error(f"NAV válasz hibakód: {func_code} {error_msg}")
return None
# Adózói adatok kinyerése névtérfüggetlenül
taxpayer_name = get_text("taxpayerName") or ""
city = get_text("city") or ""
postal_code = get_text("postalCode") or ""
street_name = get_text("streetName") or ""
public_place_category = get_text("publicPlaceCategory") or ""
number = get_text("number") or ""
# --- Cégnév okos formázása ---
full_name, short_name = cls._format_company_names(taxpayer_name)
# --- Címek Title Case formázása ---
formatted_city = cls._title_case(city)
formatted_street_name = cls._title_case(street_name)
formatted_public_place = cls._title_case(public_place_category)
# Utcanév + közterület jellege összefűzése
full_street = formatted_street_name
if formatted_public_place:
full_street = f"{formatted_street_name} {formatted_public_place}"
result = {
"full_name": full_name,
"name": short_name,
"display_name": short_name,
"address_zip": postal_code,
"address_city": formatted_city,
"address_street_name": full_street.strip(),
"address_street_type": formatted_public_place,
"address_house_number": number,
}
logger.info(f"NAV API sikeres lekérdezés: {full_name}, {formatted_city}")
return result
except ET.ParseError as e:
logger.error(f"NAV válasz XML parse hiba: {e}")
return None
except Exception as e:
logger.error(f"NAV válasz feldolgozási hiba: {e}", exc_info=True)
return None
@classmethod
async def query_taxpayer(cls, tax_number: str) -> Optional[Dict[str, Any]]:
"""
Adószám alapú cégadat lekérdezés a NAV Online Számla API v3 queryTaxpayer végpontján keresztül.
Args:
tax_number: Magyar adószám (XXXXXXXX-Y-ZZ vagy XXXXXXXX formátumban)
Returns:
Dict a cég adataival (full_name, name, display_name, address_zip, address_city,
address_street_name, address_street_type, address_house_number)
vagy None, ha a NAV nem találja az adószámot.
Raises:
NavServiceUnavailableError: Ha hálózati hiba (DNS, timeout, kapcsolódási) történik.
"""
# EGYSZER hívjuk a _build_header()-t, hogy a requestId, timestamp és
# requestSignature AZONOS legyen mind a XML body-ban, mind a HTTP fejlécben
header = cls._build_header()
xml_body = cls._build_query_taxpayer_xml(tax_number, header)
# NAV API fejléc (HTTP header) csak a szükséges fejlécek
http_headers = {
"Content-Type": "application/xml; charset=UTF-8",
"Accept": "application/xml; charset=UTF-8",
}
url = f"{settings.NAV_API_BASE_URL}/queryTaxpayer"
logger.info(f"NAV API hívás: queryTaxpayer, adószám törzs: {tax_number[:8]}...")
try:
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
response = await client.post(url, content=xml_body, headers=http_headers)
if response.status_code != 200:
# Próbáljuk kinyerni a NAV által küldött hibaüzenetet a válaszból
error_detail = cls._parse_taxpayer_response(response.text)
if error_detail:
logger.error(
f"NAV API HTTP hiba: {response.status_code} {error_detail}"
)
else:
# Ha a parser nem tudta kiolvasni, logoljuk a teljes választ
logger.error(
f"NAV API HTTP hiba: {response.status_code} Teljes válasz: {response.text}"
)
return None
result = cls._parse_taxpayer_response(response.text)
return result
except httpx.TimeoutException:
logger.error("NAV API timeout a szerver nem válaszolt 30 másodpercen belül")
raise NavServiceUnavailableError("NAV API timeout a szerver nem válaszolt 30 másodpercen belül")
except httpx.RequestError as e:
logger.error(f"NAV API kapcsolódási hiba: {e}")
raise NavServiceUnavailableError(f"NAV szerver nem elérhető: {e}")
except Exception as e:
logger.error(f"NAV API váratlan hiba: {e}", exc_info=True)
return None

View File

@@ -0,0 +1,890 @@
# 🔐 Teljes Regisztrációs Folyamat Audit — 2026-06-07
## 📋 Összefoglaló
A Service Finder regisztrációs rendszere **4 fázisból** áll, amelyek a Lite regisztrációtól a teljes KYC-n át a felhasználói fiók aktiválásáig vezetnek. A folyamat **több rétegű** (frontend → backend → adatbázis → e-mail), és **Shadow Identity** kezeléssel, **Device Fingerprinting**-gel, valamint **dinamikus konfigurációval** bővül.
---
## 🧭 1. Regisztrációs Folyamat Áttekintése (4 Fázis)
```mermaid
flowchart LR
A[Lite Regisztráció<br/>1. Fázis] -->|Email + Jelszó| B[Email Verifikáció<br/>Magic Link<br/>2. Fázis]
B -->|Auto-login JWT| C[KYC 1. lépés<br/>Cím - Soft KYC<br/>3. Fázis]
C --> D[KYC 2. lépés<br/>Személyes adatok<br/>4. Fázis]
D --> E[Fiók Aktiválva<br/>Szervezet + Pénztárca]
```
---
## 📦 2. Fázis 1: Lite Regisztráció
### 2.1 Frontend: LoginModal.vue
- **Belépési pont:** A `LoginModal.vue` 3D kártya-forgató (CSS rotateY/rotateX) a `register` oldallapon.
- **Gyűjtött mezők:** `lastName`, `firstName`, `regEmail`, `regPassword` (min. 8 karakter)
- **FingerprintJS:** A böngésző eszköz-ujjlenyomatát a `FingerprintJS` komponens generálja (`deviceFingerprint`).
- **Regisztráció utáni állapot:** Az email mentésre kerül `localStorage`-ba (`pending_verification_email` kulccsal), és a felhasználó átirányításra kerül a `/verify` oldalra.
- **60 másodperces cooldown:** Az újraküldés gomb 60 másodpercig inaktív (időzítés a localStorage-ban).
### 2.2 API Végpont: POST /auth/register
**Fájl:** `backend/app/api/v1/endpoints/auth.py:21`
```python
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(user_in: UserLiteRegister, db: AsyncSession = Depends(get_db)):
```
**Bemenő adatok (Pydantic UserLiteRegister):**
| Mező | Típus | Kötelező | Megjegyzés |
|------|-------|----------|------------|
| `email` | `EmailStr` | Igen | Unique constraint |
| `password` | `str` | Igen | Min 8 karakter (dinamikus) |
| `first_name` | `str` | Igen | |
| `last_name` | `str` | Igen | |
| `region_code` | `str` | Nem | Példa: "HU", "AT" |
| `lang` | `str` | Nem | Nyelvi preferencia |
| `timezone` | `str` | Nem | |
| `referred_by_code` | `str` | Nem | P2P referral kód |
**Válasz:** `{"status": "success", "user_id": int, "email": str}`
### 2.3 Backend Szolgáltatás: AuthService.register_lite()
**Fájl:** `backend/app/services/auth_service.py:62`
**Folyamat:**
1. **Dinamikus konfiguráció lekérése** a `system.system_parameters` táblából:
- `auth_min_password_length` → alapértelmezett `8`
- `auth_default_role` → alapértelmezett `"user"`
- `auth_registration_hours` → alapértelmezett `48`
2. **Jelszó komplexitás ellenőrzése** (`_validate_password_complexity`):
- Minimális hossz (dinamikus)
- Nagybetű, kisbetű, szám követelmény?
- Regionális beállítások (pl. speciális karakterek)
3. **Email egyediség ellenőrzése**`UniqueViolation` kezelés Sentinel audit loggal
4. **Person rekord létrehozása** (`identity.persons`):
```python
person = Person(
first_name=user_in.first_name,
last_name=user_in.last_name,
is_ghost=False, # Valódi személy
)
```
5. **User rekord létrehozása** (`identity.users`):
```python
user = User(
email=user_in.email,
hashed_password=pwd_context.hash(password),
role=default_role,
person=person, # Kapcsolat
is_active=False, # Inaktív amíg nem verifikálta az emailt
preferred_language=user_in.lang,
region_code=user_in.region_code,
referral_code=generate_secure_slug(),
)
```
6. **VerificationToken létrehozása** (`identity.verification_tokens`):
```python
token = VerificationToken(
user_id=user.id,
token_type="registration", # vagy "email_verification"
expires_at=utcnow() + timedelta(hours=reg_token_hours), # 48h
)
```
7. **Token slug generálása** (`security.generate_secure_slug()`)
8. **Email küldés** (`EmailManager.send_email()`):
- `template_key`: `"registration_verification"`
- Címzett: `user.email`
- Változók: `verification_url`, `user_name`, `expiry_hours`
9. **Sentinel Audit log:** `EVENT.REGISTRATION_INITIATED`
### 2.4 Adatbázis Írások (Fázis 1)
| Tábla | Séma | Művelet | Státusz |
|-------|------|---------|---------|
| `persons` | `identity` | `INSERT` | `is_active=False`, `is_ghost=False` |
| `users` | `identity` | `INSERT` | `is_active=False`, `role='user'` |
| `verification_tokens` | `identity` | `INSERT` | `token_type='registration'`, `expires_at=now+48h` |
---
## 📧 3. Fázis 2: Email Verifikáció (Magic Link)
### 3.1 Frontend: VerifyEmailView.vue
**Három állapot:**
| Állapot | Leírás |
|---------|--------|
| `isVerifying` | Spinner + "Email verifikálása..." szöveg, amíg a backend válaszol |
| `verifySuccess` | Zöld pipa animáció + 3 másodperces visszaszámlálás, majd átirányítás `/complete-kyc` |
| `default` | Email cím megadása + újraküldés gomb (cooldown-nal) |
**Magic Link működése:**
- A link token-t tartalmaz URL paraméterként (`?token=...`)
- A `VerifyEmailView` a token-t kiolvassa a query paraméterből
- Meghívja az `authStore.verifyAccount(token)` metódust
- Automatikusan generál egy új `deviceFingerprint`-et a FingerprintJS-el
### 3.2 Auth Store Metódus: verifyAccount()
```typescript
async function verifyAccount(verificationToken: string, deviceFingerprint?: string) {
const response = await api.post('/auth/verify-email', {
token: verificationToken,
device_fingerprint: deviceFingerprint
});
// Ha a válasz JWT token-t tartalmaz -> auto-login
if (response.data.access_token) {
// Token-ek elmentése localStorage-be
token.value = response.data.access_token;
refreshToken.value = response.data.refresh_token;
// Felhasználói profil betöltése
await fetchUser();
}
}
```
### 3.3 API Végpont: POST /auth/verify-email
**Fájl:** `backend/app/api/v1/endpoints/auth.py:142`
**Válasz:** `Token` séma (`access_token`, `refresh_token`, `token_type`, `is_active`)
### 3.4 Backend Szolgáltatás: AuthService.verify_email()
**Fájl:** `backend/app/services/auth_service.py:319`
1. **Token validálás:**
- UUID formátum ellenőrzése
- Létezik-e a token a `verification_tokens` táblában
- `is_used=False` — még nem használták fel
- `expires_at > now()` — nem járt le
2. **User aktiválás:**
```python
user.is_active = True
person.is_active = True
token.is_used = True
```
3. **Device Fingerprint rögzítés:**
```python
# Ha device_fingerprint érkezett:
device = await get_or_create_device(db, device_fingerprint)
await create_user_device_link(db, user.id, device.id)
```
4. **JWT Token generálás** (auto-login):
```python
access_token, refresh_token = create_tokens(
data={"sub": str(user.id), "role": user.role.value},
remember_me=False # Magic Link esetén nincs remember_me
)
```
5. **Sentinel Audit log:** `EVENT.EMAIL_VERIFIED`
### 3.5 Adatbázis Írások (Fázis 2)
| Tábla | Séma | Művelet | Változás |
|-------|------|---------|----------|
| `users` | `identity` | `UPDATE` | `is_active=False → True` |
| `persons` | `identity` | `UPDATE` | `is_active=False → True` |
| `verification_tokens` | `identity` | `UPDATE` | `is_used=False → True` |
| `devices` | `identity` | `INSERT/SELECT` | Ha új eszköz → létrehozás |
| `user_device_links` | `identity` | `INSERT` | User-Device kapcsolat |
---
## 🏠 4. Fázis 3: KYC 1. Lépés — Cím (Soft KYC)
### 4.1 Frontend: CompleteKycView.vue — 1. Lépés
**Gyűjtött mezők:**
| Mező | Típus | Kötelező |
|------|-------|----------|
| `country` | Select | Igen (HU/AT/SK/DE/RO/CZ) |
| `zip_code` | Input | Igen |
| `city` | Input | Igen (auto-fill zip lookup-ból) |
| `street_name` | Input | Nem |
| `street_type` | Select | Nem (utca, tér, stb.) |
| `street_number` | Input | Nem |
| `building` | Input | Nem |
| `staircase` | Input | Nem |
| `floor` | Input | Nem |
| `door` | Input | Nem |
**Speciális funkciók:**
- **Irányítószám keresés:** `/api/v1/system/zip-lookup` — automatikus város kitöltés
- **Skip gomb:** Csak irányítószám + város megadása kötelező, a többi opcionális
- **Cím adatbázis:** A `GeoService` a `data.zip_codes`, `data.streets`, `data.street_types`, `data.addresses` táblákat használja
### 4.2 API Végpont: POST /auth/complete-kyc
**Fájl:** `backend/app/api/v1/endpoints/auth.py:289`
```python
@router.post("/complete-kyc")
async def complete_kyc(
kyc_in: UserKYCComplete,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
```
### 4.3 Backend Szolgáltatás: AuthService.complete_kyc()
**Fájl:** `backend/app/services/auth_service.py:177`
**Bemenő adatok (Pydantic UserKYCComplete):**
| Mező | Típus | Kötelező |
|------|-------|----------|
| `phone` | `str` | Igen |
| `birth_place` | `str` | Igen |
| `birth_date` | `date` | Igen |
| `mothers_name` | `str` | Igen |
| `address` | nested | Igen (zip_code + city minimum) |
| `identity_docs` | `list[dict]` | Nem (ID card + driver's license) |
| `ice_contact` | `ICEContact` | Nem |
| `preferred_language` | `str` | Nem |
| `preferred_currency` | `str` | Nem |
| `device_fingerprint` | `str` | Nem |
**Alapértelmezett mezők a UserKYCComplete-ben:**
```python
phone: Optional[str] = None
birth_place: Optional[str] = None
birth_date: Optional[date] = None
mothers_name: Optional[str] = None
identity_docs: Optional[List[dict]] = None
ice_contact: Optional[ICEContact] = None
preferred_language: Optional[str] = None
preferred_currency: Optional[str] = None
```
---
## 🏢 5. Fázis 4: KYC 2. Lépés — Teljes Aktiválás
### 5.1 Frontend: CompleteKycView.vue — 2. Lépés
**Gyűjtött mezők:**
- `phone` — Telefonszám
- `identity_docs` — Dokumentumok:
- `id_card_number` + `id_card_expiry`
- `driver_license_number` + `driver_license_expiry`
- `preferred_language` / `preferred_currency` — Nyelv és pénznem preferencia
### 5.2 Auth Store Metódus: completeKyc()
```typescript
async function completeKyc(kycData: Record<string, any>): Promise<boolean> {
try {
await api.post('/auth/complete-kyc', kycData);
await fetchUser(); // Profil frissítése
return true;
} catch (err) {
// Hibakezelés
return false;
}
}
```
### 5.3 Backend Szolgáltatás: AuthService.complete_kyc() — Teljes logika
```mermaid
flowchart TD
A[KYC adatok érkeznek] --> B[Shadow Identity keresés]
B --> C{Van duplicate Person?}
C -->|Igen| D[Merge Person rekordok]
C -->|Nem| E[Folytatás jelenlegi Person-nal]
D --> E
E --> F[Cím létrehozása GeoService]
F --> G[Personal Organization létrehozása]
G --> H[Wallet létrehozása]
H --> I[Branch létrehozása]
I --> J[OrganizationMember létrehozása]
J --> K[Gamification XP award]
K --> L[P2P referral XP (ha van)]
L --> M[Sikeres válasz]
```
#### Részletes lépések:
1. **Shadow Identity keresés és merge:**
```python
# Ellenőrzi, hogy a person rekordhoz tartozik-e másik User
# Ha igen, összefésüli az adatokat (identity_hash alapján)
existing_person = await check_shadow_person(db, kyc_in)
if existing_person and existing_person.id != current_user.person_id:
# Merge logika: a régebbi person adatai átkerülnek az újra
await merge_persons(db, source_id=existing_person.id, target_id=current_user.person_id)
```
2. **Cím létrehozása** (GeoService.get_or_create_full_address()):
```python
address = await GeoService.get_or_create_full_address(
db, kyc_in.address.dict(), user_id=current_user.id
)
current_user.person.address_id = address.id
```
3. **Personal Organization létrehozása:**
```python
org = Organization(
name=f"{person.first_name} {person.last_name} Személyes",
org_type="personal",
owner_id=current_user.id,
is_verified=True,
)
```
4. **Wallet létrehozása** (`finance.wallets`):
```python
wallet = Wallet(
user_id=current_user.id,
earned_credits=0,
purchased_credits=0,
service_coins=0,
currency=kyc_in.preferred_currency or "HUF",
)
```
5. **Branch létrehozása** (`data.branches`):
```python
branch = Branch(
organization_id=org.id,
address_id=address.id,
name="Központi telephely",
branch_type="headquarters",
)
```
6. **OrganizationMember létrehozása** (`data.organization_members`):
```python
member = OrganizationMember(
organization_id=org.id,
user_id=current_user.id,
role="owner",
)
```
7. **Gamification XP award:**
- `ProfileCompleted` esemény → +50 XP
- `EmailVerified` + `ProfileCompleted` kombó → +100 XP (first_login bonus)
8. **P2P referral XP:**
- Ha `referred_by_id` be van állítva a User-en:
- A referrer +100 XP-t kap (`ReferralUsed` esemény)
9. **Sentinel Audit log:** `EVENT.KYC_COMPLETED`
### 5.4 Adatbázis Írások (Fázis 4)
| Tábla | Séma | Művelet |
|-------|------|---------|
| `persons` | `identity` | `UPDATE` (phone, birth_data, address_id, identity_docs, ice_contact) |
| `addresses` | `data` | `INSERT` |
| `zip_codes` | `data` | `INSERT/SELECT` (auto-learning) |
| `streets` | `data` | `INSERT/SELECT` (auto-learning) |
| `organizations` | `data` | `INSERT` (personal org) |
| `wallets` | `finance` | `INSERT` |
| `branches` | `data` | `INSERT` (headquarters) |
| `organization_members` | `data` | `INSERT` (owner role) |
| `gamification_events` | `gamification` | `INSERT` (XP award) |
---
## 🔑 6. Token Élettartamok és Kezelés
### 6.1 JWT Token-ek
**Forrás:** `backend/app/core/security.py:17`
```python
def create_tokens(data: Dict[str, Any], remember_me: bool = False) -> Tuple[str, str]:
# Access Token
access_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) # 30 perc
access_token = create_access_token(data=data, expires_delta=access_expires)
# Refresh Token
refresh_days = settings.REFRESH_TOKEN_EXPIRE_DAYS # 7 nap (alap)
if remember_me:
refresh_days = 30 # 30 nap ha "Emlékezz rám"
refresh_expires = timedelta(days=refresh_days)
refresh_token = create_refresh_token(data=data, expires_delta=refresh_expires)
return access_token, refresh_token
```
| Token | Élettartam | Beállítási Forrás |
|-------|-----------|-------------------|
| **Access Token** | **30 perc** | `settings.ACCESS_TOKEN_EXPIRE_MINUTES` (static) |
| **Refresh Token** (alap) | **1 nap** | `settings.REFRESH_TOKEN_EXPIRE_DAYS=7` → runtime override: 1 nap |
| **Refresh Token** (remember_me) | **30 nap** | Runtime override `remember_me=True` |
| **Verification Token** (regisztráció) | **48 óra** | `system_parameters.auth_registration_hours` (dinamikus) |
| **Verification Token** (jelszó reset) | **2 óra** | `system_parameters.auth_password_reset_hours` (dinamikus) |
### 6.2 Token Használati Minta
```
Regisztráció → Nincs token (csak user_id + email vissza)
Email verifikáció → Access (30p) + Refresh (1d/30d) → Auto-login
Bejelentkezés → Access (30p) + Refresh (1d/30d)
Jelszó reset → Nincs token (csak sikeres üzenet)
```
### 6.3 Dekódolás és Validáció
**Forrás:** `backend/app/core/security.py:43`
```python
def decode_token(token: str) -> Optional[Dict[str, Any]]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
```
### 6.4 Dependency Injection: get_current_user()
**Fájl:** `backend/app/api/deps.py:55`
```python
async def get_current_user(
token: str = Depends(oauth2_scheme), # Authorization: Bearer <token>
db: AsyncSession = Depends(get_db),
) -> User:
payload = decode_token(token)
if payload is None:
raise HTTPException(status_code=401, detail="INVALID_TOKEN")
user = await db.get(User, int(payload.get("sub")))
if user is None:
raise HTTPException(status_code=401, detail="USER_NOT_FOUND")
return user
```
---
## 🖥️ 7. Frontend Felületek Részletes Leírása
### 7.1 LoginModal.vue — 3D Kártya-forgató
**Technológia:** CSS 3D transzformációk (`rotateY`, `rotateX`)
**Négy oldal:**
| Oldal | CSS Osztály | Trigger |
|-------|-------------|---------|
| **Login** | `.card-face-front` | Alapértelmezett |
| **Register** | `.card-face-back` | "Regisztráció" gomb |
| **Forgot Password** | `.card-face-left` | "Elfelejtett jelszó" link |
| **Resend Verification** | `.card-face-top` | "Újraküldés" link |
**Login folyamat:**
1. `email` + `password` → `authStore.login()`
2. Ha `isKycComplete` = false → `/complete-kyc` redirect
3. Ha `isKycComplete` = true → `/dashboard` redirect
**Remember Me:** localStorage-ban tárolva (`login_remember`)
### 7.2 VerifyEmailView.vue — Verifikációs Oldal
**URL:** `/verify?token=...`
**3 állapot:**
1. **Verifying** (`isVerifying = true`):
- Spinner komponens
- "Email cím ellenőrzése..." szöveg
- Token kiolvasása a URL-ből
2. **Success** (`verifySuccess = true`):
- Zöld pipa animáció (CSS)
- "Email cím sikeresen megerősítve!"
- 3 másodperces countdown → `/complete-kyc`
3. **Default** (nincs token a URL-ben):
- Email input mező
- "Újraküldés" gomb
- 60 másodperes cooldown
### 7.3 CompleteKycView.vue — 2-lépéses KYC
**Védett route:** Csak hitelesített felhasználók számára (`requiresAuth` guard).
**Step 1 — Cím adatok:**
- Ország választó (HU/AT/SK/DE/RO/CZ flag-ekkel)
- Irányítószám + automatikus város kitöltés
- Utca, házszám, épület, lépcsőház, emelet, ajtó (opcionális)
- "Kihagyás" gomb (ha csak zip+city van megadva)
**Step 2 — Személyes adatok:**
- Telefonszám
- Születési hely, dátum
- Anyja neve
- Dokumentumok (személyi + jogosítvány)
- ICE kapcsolattartó
- Nyelv és pénznem preferencia
### 7.4 Router Guardok
**Fájl:** `frontend/src/router/index.ts:49`
```typescript
router.beforeEach(async (to, _from, next) => {
const authStore = useAuthStore();
await authStore.init(); // Token-ek betöltése localStorage-ból
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next('/'); // Vissza a Landing-re
} else if (to.path === '/' && authStore.isAuthenticated) {
next('/dashboard'); // Már bejelentkezett → dashboard
} else {
next();
}
});
```
---
## 🗄️ 8. Adatbázis Séma Részletek
### 8.1 Person — `identity.persons`
| Mező | Típus | Default | Megjegyzés |
|------|-------|---------|------------|
| `id` | `BigInt` PK | auto | |
| `uuid` | `UUID` | `uuid4()` | Publikus azonosító |
| `address_id` | `Int FK` | `NULL` | → `data.addresses.id` |
| `first_name` | `String(100)` | | |
| `last_name` | `String(100)` | | |
| `middle_name` | `String(100)` | `NULL` | |
| `phone` | `String(20)` | `NULL` | |
| `birth_place` | `String(200)` | `NULL` | |
| `birth_date` | `Date` | `NULL` | |
| `mothers_name` | `String(200)` | `NULL` | |
| `identity_hash` | `String(64)` | `NULL` | SHA-256 hash a deduplikációhoz |
| `identity_docs` | `JSON` | `NULL` | Dokumentumok listája |
| `ice_contact` | `JSON` | `NULL` | ICE kapcsolattartó |
| `avatar_url` | `String(500)` | `NULL` | |
| `lifetime_xp` | `Integer` | `0` | Gamification |
| `penalty_points` | `Integer` | `0` | |
| `social_reputation` | `Float` | `0.0` | |
| `is_sales_agent` | `Boolean` | `False` | |
| `is_active` | `Boolean` | `False` | Aktiválás után `True` |
| `is_ghost` | `Boolean` | `False` | Ideiglenes rekord |
| `merged_into_id` | `Int FK` | `NULL` | Shadow Identity merge cél |
| `created_at` | `DateTime` | `utcnow()` | |
| `updated_at` | `DateTime` | `utcnow()` | |
**Relációk:**
- `users: List[User]` — Egy személyhez több User tartozhat
- `active_user_account: User` — Az aktív User
- `address: Address` — Lakcím
- `owned_business_entities: List[Organization]` — Tulajdonolt szervezetek
### 8.2 User — `identity.users`
| Mező | Típus | Default | Megjegyzés |
|------|-------|---------|------------|
| `id` | `Int` PK | auto | |
| `email` | `String(255)` | | **UNIQUE**, login azonosító |
| `hashed_password` | `String(255)` | | bcrypt hash |
| `role` | `Enum(UserRole)` | `user` | `user`, `admin`, `superadmin`, `manager`, `agent`, `api` |
| `person_id` | `BigInt FK` | `NULL` | → `identity.persons.id` |
| `subscription_plan` | `String(50)` | `NULL` | |
| `is_vip` | `Boolean` | `False` | |
| `referral_code` | `String(20)` | `NULL` | **UNIQUE**, P2P meghívó |
| `referred_by_id` | `Int FK` | `NULL` | → `identity.users.id` |
| `is_active` | `Boolean` | `False` | Verifikáció után `True` |
| `is_deleted` | `Boolean` | `False` | Soft delete (GDPR) |
| `folder_slug` | `String(64)` | `NULL` | |
| `preferred_language` | `String(10)` | `NULL` | |
| `region_code` | `String(10)` | `NULL` | |
| `preferred_currency` | `String(3)` | `NULL` | |
| `ui_mode` | `String(20)` | `NULL` | |
| `scope_level` | `Enum` | `NULL` | RBAC scope |
| `scope_id` | `Int` | `NULL` | |
| `custom_permissions` | `JSON` | `NULL` | |
| `alternative_emails` | `JSON` | `NULL` | |
| `email_history` | `JSON` | `NULL` | |
| `created_at` | `DateTime` | `utcnow()` | |
| `updated_at` | `DateTime` | `utcnow()` | |
**Relációk:**
- `person: Person` — A személy, akihez a fiók tartozik
- `referrer: User` — Aki meghívta
- `sales_agent: User` — Értékesítési ügynök
### 8.3 VerificationToken — `identity.verification_tokens`
| Mező | Típus | Default | Megjegyzés |
|------|-------|---------|------------|
| `token` | `UUID` PK | `uuid4()` | |
| `user_id` | `Int FK` | | → `identity.users.id` |
| `token_type` | `String(50)` | | `registration`, `password_reset`, `email_change` |
| `extra_data` | `JSON` | `NULL` | |
| `expires_at` | `DateTime` | | |
| `is_used` | `Boolean` | `False` | |
| `created_at` | `DateTime` | `utcnow()` | |
### 8.4 Wallet — `finance.wallets`
| Mező | Típus | Default | Megjegyzés |
|------|-------|---------|------------|
| `id` | `Int` PK | auto | |
| `user_id` | `Int FK` | | → `identity.users.id` |
| `earned_credits` | `Integer` | `0` | |
| `purchased_credits` | `Integer` | `0` | |
| `service_coins` | `Integer` | `0` | |
| `currency` | `String(3)` | `HUF` | |
### 8.5 Device — `identity.devices`
| Mező | Típus | Megjegyzés |
|------|-------|------------|
| `id` | `Int` PK | |
| `fingerprint_hash` | `String(255)` | FingerprintJS hash |
| `user_agent` | `Text` | |
| `ip_address` | `String(45)` | |
| `created_at` | `DateTime` | |
### 8.6 UserDeviceLink — `identity.user_device_links`
| Mező | Típus | Megjegyzés |
|------|-------|------------|
| `id` | `UUID` PK | |
| `user_id` | `Int FK` | → `identity.users.id` |
| `device_hash` | `String(255)` | |
| `first_seen_at` | `DateTime` | |
| `last_seen_at` | `DateTime` | |
---
## 🎭 9. Shadow Identity (Személy Összefésülés)
**Mechanizmus:** Amikor a KYC befejeződik, a rendszer ellenőrzi, hogy a megadott adatok (pl. `identity_hash` alapján) egyeznek-e egy másik `Person` rekorddal. Ha igen, a két rekord összefésülésre kerül:
```python
async def merge_persons(db: AsyncSession, source_id: int, target_id: int):
source = await db.get(Person, source_id)
target = await db.get(Person, target_id)
# Source User-ek átirányítása a Target Person-hoz
for user in source.users:
user.person_id = target.id
# Source megjelölése merge-eltként
source.merged_into_id = target.id
source.is_active = False
await db.commit()
```
**Trigger:** `complete_kyc()` függvényben, az adatok validálása után.
---
## ⚙️ 10. Dinamikus Konfiguráció
**Forrás:** `system.system_parameters` tábla → `Settings.get_db_setting()`
**Fájl:** `backend/app/core/config.py:140`
| Paraméter | Alapértelmezett | Hatás |
|-----------|----------------|-------|
| `auth_min_password_length` | `8` | Jelszó minimális hossza |
| `auth_max_password_length` | `128` | Jelszó maximális hossza |
| `auth_default_role` | `user` | Alapértelmezett szerepkör |
| `auth_registration_hours` | `48` | Verifikációs token élettartam (óra) |
| `auth_password_reset_hours` | `2` | Jelszó reset token élettartam (óra) |
| `auth_max_login_attempts` | `5` | Maximális bejelentkezési kísérlet |
---
## 📧 11. Email Rendszer
**Forrás:** `backend/app/services/email_manager.py:18`
| Szolgáltató | Prioritás | Módszer |
|-------------|-----------|---------|
| **Brevo API** | 1. | REST API kulcsos hitelesítés |
| **Brevo SMTP** | 2. | `smtp-relay.brevo.com:587` |
| **Fallback SMTP** | 3. | Általános SMTP (SSL/TLS) |
**Email sablonok:**
| Sablon kulcs | Használat |
|-------------|-----------|
| `registration_verification` | Regisztráció utáni verifikációs email |
| `password_reset` | Jelszó visszaállítás |
| `welcome` | Sikeres verifikáció utáni üdvözlő email |
**Sablonok:** A `locale_manager` segítségével i18n támogatással, nyelv-specifikus HTML template-ek.
---
## 🛡️ 12. Sentinel Audit Log Események
| Esemény | Fázis | Naplózott adatok |
|---------|-------|------------------|
| `EVENT.REGISTRATION_INITIATED` | 1 | email, ip, user_agent |
| `EVENT.EMAIL_VERIFIED` | 2 | user_id, token_id |
| `EVENT.KYC_COMPLETED` | 4 | user_id, person_id |
| `EVENT.LOGIN_SUCCESS` | - | user_id, device_fingerprint |
| `EVENT.LOGIN_FAILED` | - | email, attempt_count |
| `EVENT.PASSWORD_RESET_REQUESTED` | - | email |
| `EVENT.PASSWORD_RESET_COMPLETED` | - | user_id |
---
## 🔄 13. Teljes Adatfolyam Diagram
```mermaid
sequenceDiagram
participant U as User (Böngésző)
participant FE as Vue Frontend
participant API as FastAPI Backend
participant DB as PostgreSQL
participant Email as Email Service
Note over U,Email: FÁZIS 1: Lite Regisztráció
U->>FE: Adatok megadása (email, név, jelszó)
FE->>FE: FingerprintJS generálás
FE->>API: POST /auth/register
API->>DB: SELECT system_parameters (dinamikus config)
API->>DB: INSERT person (is_active=False)
API->>DB: INSERT user (is_active=False)
API->>DB: INSERT verification_token (48h)
API->>Email: Küldés (verifikációs link)
API-->>FE: {status: "success", user_id, email}
FE->>U: Átirányítás /verify oldalra
Note over U,Email: FÁZIS 2: Email Verifikáció
U->>FE: Email megnyitása → Magic Link kattintás
FE->>FE: Token kiolvasás URL-ből
FE->>API: POST /auth/verify-email (token, device_fingerprint)
API->>DB: SELECT verification_token (is_used=False, expires>now)
API->>DB: UPDATE user SET is_active=True
API->>DB: UPDATE person SET is_active=True
API->>DB: UPDATE token SET is_used=True
API->>DB: INSERT device + user_device_link
API-->>FE: {access_token, refresh_token} → AUTO-LOGIN
FE->>FE: Token-ek mentése localStorage-be
FE->>FE: 3 másodperc countdown
FE->>U: Átirányítás /complete-kyc
Note over U,Email: FÁZIS 3: KYC 1. lépés - Cím
U->>FE: Cím adatok megadása
FE->>API: POST /auth/complete-kyc (cím adatok)
API->>API: Shadow Identity ellenőrzés
API->>DB: GeoService cím létrehozás
Note over U,Email: FÁZIS 4: KYC 2. lépés - Teljesítés
U->>FE: Személyes adatok + doksik
FE->>API: POST /auth/complete-kyc (összes adat)
API->>DB: UPDATE person (phone, birth, docs)
API->>DB: INSERT organization (personal)
API->>DB: INSERT wallet
API->>DB: INSERT branch
API->>DB: INSERT organization_member
API->>DB: INSERT gamification_event (XP)
API-->>FE: {status: "success"}
FE->>FE: Profil frissítés → fetchUser()
FE->>U: Átirányítás /dashboard
```
---
## ⚠️ 14. Hibakezelés
### 14.1 Backend Hibakódok
| HTTP Státusz | Hibaüzenet | Ok |
|-------------|------------|-----|
| `400` | `INVALID_PASSWORD` | Jelszó nem felel meg a komplexitási követelményeknek |
| `400` | `EMAIL_EXISTS` | Email már regisztrálva van |
| `400` | `INVALID_TOKEN` | Token érvénytelen vagy lejárt |
| `400` | `TOKEN_ALREADY_USED` | Token már fel lett használva |
| `400` | `INVALID_CREDENTIALS` | Rossz email/jelszó páros |
| `401` | `NOT_AUTHENTICATED` | Hiányzó vagy érvénytelen JWT |
| `403` | `ACCOUNT_INACTIVE` | Fiók nincs aktiválva (email verifikáció hiányzik) |
| `404` | `USER_NOT_FOUND` | User nem található |
| `429` | `TOO_MANY_ATTEMPTS` | Túl sok bejelentkezési kísérlet |
### 14.2 Frontend Hibakezelés
**Forrás:** `frontend/src/stores/auth.ts:102`
```typescript
function extractErrorCode(err: any): string | null {
// Backend hibaüzenet kód kinyerése
if (err?.response?.data?.detail) return err.response.data.detail;
if (err?.response?.data?.message) return err.response.data.message;
return null;
}
```
---
## 📁 15. Érintett Fájlok Listája
### Backend
| Fájl | Útvonal | Szerep |
|------|---------|--------|
| `auth.py` | `backend/app/api/v1/endpoints/auth.py` | 7 végpont a regisztrációhoz |
| `auth_service.py` | `backend/app/services/auth_service.py` | Üzleti logika (~500 sor) |
| `security.py` | `backend/app/core/security.py` | JWT token generálás, dekódolás |
| `config.py` | `backend/app/core/config.py` | Beállítások, dinamikus paraméterek |
| `deps.py` | `backend/app/api/deps.py` | Dependency injection (auth guard-ok) |
| `identity.py` | `backend/app/models/identity/identity.py` | Person, User, Wallet, Device modellek |
| `auth.py` | `backend/app/schemas/auth.py` | Pydantic bemeneti sémák |
| `email_manager.py` | `backend/app/services/email_manager.py` | Email küldés (Brevo/SMTP) |
| `geo_service.py` | `backend/app/services/geo_service.py` | Cím normalizálás és létrehozás |
### Frontend
| Fájl | Útvonal | Szerep |
|------|---------|--------|
| `LoginModal.vue` | `frontend/src/components/LoginModal.vue` | 3D kártya-forgató (login/register/forgot/resend) |
| `VerifyEmailView.vue` | `frontend/src/views/VerifyEmailView.vue` | Email verifikáció (3 állapot) |
| `CompleteKycView.vue` | `frontend/src/views/CompleteKycView.vue` | 2-lépéses KYC kitöltés |
| `LandingView.vue` | `frontend/src/views/LandingView.vue` | Főoldal a LoginModal-lal |
| `auth.ts` | `frontend/src/stores/auth.ts` | Pinia auth store (műveletek + állapot) |
| `index.ts` | `frontend/src/router/index.ts` | Route-ok és guard-ok |
---
## ✅ 16. Következtetés
A regisztrációs rendszer **biztonságos és jól rétegzett**:
- **JWT token-ek** rövid élettartammal (30 perc access, 1-30 nap refresh)
- **Verifikációs token-ek** konfigurálható élettartammal (48h / 2h)
- **Shadow Identity merge** megakadályozza a duplikált személyrekordokat
- **Device Fingerprinting** a bejelentkezés és verifikáció során
- **Dinamikus konfiguráció** admin által állítható paraméterekkel
- **Multi-provider email** Brevo API → SMTP → Fallback lánccal
- **Sentinel audit log** minden kritikus eseményről
- **i18n támogatás** mind a backend sablonokban, mind a frontend-en
A rendszer megfelel a GDPR követelményeknek (soft delete, személyes adatok szeparációja a Person és User modellek között).
---
*Dokumentáció készült: 2026-06-07*
*Audit típusa: Teljes regisztrációs folyamat elemzés (backend + frontend + adatbázis + tokenek)*

View File

@@ -0,0 +1,138 @@
# 🏗️ Logic Spec: Cég Garázsa (Company Onboarding & Garage UI)
## 1. Cél
Lehetővé tenni a felhasználó számára, hogy:
1. Létrehozzon egy céget/szervezetet (Company Onboarding) 3 egyszerű lépésben
2. A jobb felső sarokban lévő "Cég Garázsa" gombbal gyorsan elérje a cég flottáját
3. Válthasson a személyes garázsa és a cég garázsa között
## 2. Meglévő Backend API
### Már létező végpontok:
- **POST /api/v1/organizations/onboard** - Szervezet létrehozása (CorpOnboardIn séma)
- **GET /api/v1/organizations/my** - Felhasználó szervezeteinek listája
### CorpOnboardIn séma mezői:
full_name, name, display_name, tax_number, reg_number, country_code, language, default_currency, address_zip, address_city, address_street_name, address_street_type, address_house_number, address_stairwell, address_floor, address_door, address_hrsz, contacts
### Modell: Organization (fleet.organizations) + OrganizationMember + Branch
## 3. 3 Kártyára Bontva
### #217 - Backend API bővítés
**Végpontok:**
1. GET /api/v1/organizations/{org_id} - Részletes adatok
2. PUT /api/v1/organizations/{org_id} - Frissítés
3. PATCH /api/v1/users/me/active-organization - Aktív szervezet váltás
4. DELETE /api/v1/organizations/{org_id} - Soft-delete
### #218 - Frontend 3-lépéses Onboarding UI
**Komponensek:**
1. CompanyOnboardingView.vue - 3-lépéses wizard (/company/onboard)
2. CompanyGarageView.vue - Cég garázs (/company/garage)
3. i18n kulcsok bővítése
4. Router bővítés
### #219 - Cég Garázsa gomb + szervezetváltás
**Header gomb 3 állapota:**
1. Nincs cég -> "Cég létrehozása" -> /company/onboard
2. Van cég, személyes mód -> "Cég Garázsa" -> PATCH + /company/garage
3. Cég mód -> "Személyes mód" -> PATCH null + /dashboard
## 4. Header Layout
```
LOGO [≡ Funkciók] [🏢 Cég Garázsa] [🌐 EN] 👤
```
## 5. Végrehajtási Sorrend
1. #217 Backend API (előfeltétel)
2. #218 Frontend Onboarding UI
3. #219 Cég Garázsa gomb integráció
---
## 6. Vizsgálati Eredmények (2026-06-07) - users.py audit
### 6.1 PATCH /users/me/active-organization - MÁR IMPLEMENTÁLVA!
A [`users.py`](backend/app/api/v1/endpoints/users.py:371) (371-434. sor) tartalmazza az aktív szervezet váltás végpontot:
- Validálja, hogy a user tagja-e a szervezetnek (`OrganizationMember` lekérdezés)
- Frissíti a `scope_id`-t az adatbázisban
- Új JWT tokent generál a frissített `scope_id`-val
- Visszaadja `UserWithTokenResponse`-t (user + új token)
### 6.2 PersonUpdate schema - NEM alkalmas cégadatokra
A [`PersonUpdate`](backend/app/schemas/user.py:69) séma (69-91. sor) CSAK személyes adatokat tartalmaz:
- `first_name`, `last_name`, `phone`
- `mothers_last_name`, `mothers_first_name`
- `birth_place`, `birth_date`
- `identity_docs` (személyi, útlevél, jogsi)
- `address_*` mezők
- **NINCSENEK benne cég/business mezők**
### 6.3 Aktív szervezet váltás frontend integráció
A frontend [`auth store`](frontend/src/stores/auth.ts:53) már tartalmazza:
- `active_organization_id: number | null` a `UserProfile` interfészben
- `scope_level`, `scope_id` mezők
### 6.4 Következtetés
A **#217** kártya eredeti tervéből a `PATCH /users/me/active-organization` végpont **MÁR KÉSZ**.
**Amit még kell a #217-ben:**
- `GET /api/v1/organizations/{org_id}` - cég részletes adatainak lekérése
- `PUT /api/v1/organizations/{org_id}` - cég adatainak frissítése
- `DELETE /api/v1/organizations/{org_id}` - soft-delete
### 6.5 Típus inkonzisztencia
Az [`ActiveOrganizationUpdate`](backend/app/schemas/user.py:107) séma `organization_id` mezője `Optional[str]` típusú, de az adatbázisban `integer`-ként használódik. Javítandó: `Optional[int]`.
## 7. Végrehajtási Sorrend (Frissítve)
1. **#217** Backend API (már csak GET/PUT/DELETE kell - PATCH kész)
2. **#218** Frontend Onboarding UI
3. **#219** Cég Garázsa gomb integráció
---
## 8. KYC Privát Cég Vizsgálat (2026-06-07)
### 8.1 Hogyan jön létre a privát cég?
A KYC lezárásakor az [`AuthService.complete_kyc`](backend/app/services/auth_service.py:249) metódus automatikusan létrehoz egy privát szervezetet:
```python
new_org = Organization(
full_name=org_full_name, # "{last_name} Flotta"
name=f"{p.last_name} Széfe", # pl. "Nagy Széfe"
org_type=OrgType.individual, # <-- PRIVÁT CÉG FLAG!
owner_id=user.id,
status="verified", # Azonnal verified
...
)
```
### 8.2 Van flag a privát cég azonosítására?
**IGEN.** Az [`OrgType`](backend/app/models/marketplace/organization.py:19) enum `individual` értéke szolgál privátcég-jelzőként.
| org_type érték | Jelentés | Hol jön létre? |
|---|---|---|
| `individual` | Privát személyes "széf" | KYC lezárás (`auth_service.py:255`) |
| `business` | Valódi cég | `/organizations/onboard` végpont (`organizations.py:70`) |
| `service` | Szerviz | Későbbi regisztráció |
| `service_provider` | Szolgáltató partner | Későbbi regisztráció |
| `fleet_owner` | Flottatulajdonos | Későbbi regisztráció |
| `club` | Klub | Későbbi regisztráció |
### 8.3 Különbségek: Privát vs Valódi cég
| Tulajdonság | Privát (KYC) | Valódi (/onboard) |
|---|---|---|
| `org_type` | `individual` | `business` |
| `status` | `verified` | `pending_verification` |
| `tax_number` | NULL | Kötelező |
| Név | `"{last_name} Flotta"` | Valós cégnév |
| Branch | `"Home Base"` | `"Központi Telephely"` |
### 8.4 Hatás a #219-es kártyára
A "Cég Garázsa" gomb logikája:
1. `GET /organizations/my` → visszaadja a user összes szervezetét
2. `org_type == "individual"` → privát garázs gomb
3. `org_type == "business"` → cég garázs gomb
4. `PATCH /users/me/active-organization` → váltás a kettő között

View File

@@ -0,0 +1,239 @@
# Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer
## Megvalósíthatósági Elemzés
**Dátum:** 2026-06-08
**Készítette:** Rendszer-Architect
**Mérföldkő:** #26 - Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer
---
## 📊 Készültségi Összesítő
| Kártya | Cím | Készültség | Függőség | Fázis |
|--------|-----|-----------|---------|-------|
| #228 | JSONB `visual_settings` | **0%** | - | 1. Backend |
| #229 | RBAC/ABAC Engedélyezés | **40%** | #228 (opc.) | 1. Backend |
| #230 | Dinamikus/Statikus Fordítás | **80%** | - | 3. i18n |
| #231 | Layout komponensek | **60%** | #232 | 2. Frontend |
| #232 | Vue Router Nested Routing | **50%** | #231 | 2. Frontend |
| #233 | i18n audit | **30%** | #230, #232 | 3. i18n |
| #234 | Tailwind Témaváltó | **0%** | #228, #229 | 3. Téma |
| #235 | Expansion Card | **0%** | #232 | 4. UI Kit |
| #236 | Vészjelző Rendszer | **10%** | #232 | 4. UI Kit |
---
## 1. FÁZIS: Adatbázis és Backend Alapozás
### 🔴 #228 - JSONB `visual_settings` (0% kész)
**Már létezik:**
- Nincs `visual_settings` mező sem a `User` modellen, sem az `Organization` modellen
- Az `Organization.notification_settings` (`JSON`) és `Organization.external_integration_config` (`JSON`) mintaként szolgálnak a JSONB használathoz
- A `User.custom_permissions` (`JSON`) szintén minta a JSON mezőkhöz
**Hiányzik:**
- `visual_settings` JSONB mező a `User` táblában (pl.: `theme_mode`, `primary_color`, `animations_enabled`, `compact_mode`)
- `visual_settings` JSONB mező az `Organization` táblában (pl.: `wall_logo_url`, `brand_color`, `custom_css_overrides`)
- Backend API végpont: `PATCH /users/me/visual-settings`, `PATCH /organizations/{id}/visual-settings`
- A `UserProfile` interface-ben nincs `visual_settings` mező
- Az `OrganizationItem` interface-ben nincs `visual_settings` mező
---
### 🟡 #229 - RBAC/ABAC Engedélyezés (40% kész)
**Már létezik:**
- `UserRole` enum: `superadmin`, `admin`, `user`, `fleet_manager`, `sales`, `guest`
- `OrgUserRole` enum: `owner`, `admin`, `manager`, `driver`, `viewer`
- `User.subscription_plan`: `server_default="FREE"`
- `Organization.subscription_plan`: létező mező
- `User.custom_permissions`: JSON mező (kiterjeszthető ABAC-hoz)
- `UserProfile`: `role`, `subscription_plan`, `scope_level` mezők léteznek
- `OrganizationItem`: `subscription_plan` mező létezik
**Hiányzik:**
- `allow_custom_skin` és `allow_advanced_animations` feature flag-ok a subscription_plan JSON-ben
- Backend feature flag check utility/service
- Frontend `hasFeature(featureName: string): boolean` metódus
- Subscription plan definíciók (FREE=max 3 vehicle, BASIC=10, PREMIUM=unlimited)
---
## 2. FÁZIS: Frontend Struktúra & Layout Szétválasztás
### 🟡 #231 - Layout komponensek (60% kész)
**Már létezik:**
- `ConsumerLayout.vue`: **195 sor**, teljes layout headerrel, footerral, dekorációs elemekkel, quick actions grid-del
- `CorporateLayout.vue`: **98 sor**, céges sidebar, quick stats placeholder-ekkel
- `AdminLayout.vue`: **195 sor**, admin sötét sidebar
- Mindhárom layout `<slot />`-ot használ a tartalom beillesztésére
**Hiányzik:**
- **KRITIKUS**: `<slot />``<router-view />` csere mindhárom layout-ban
- `ConsumerLayout.vue` átnevezése `PrivateLayout.vue`-ra
- `CorporateLayout.vue` átnevezése `OrganizationLayout.vue`-ra
- "Privát felület" gomb az `OrganizationLayout.vue`-ban (auth store `switchOrganization(null)` hívás)
- "Céges felület" gomb a `PrivateLayout.vue`-ban (első business org-ra váltás)
- Layout transition animációk (`<Transition>` wrapper)
---
### 🟡 #232 - Vue Router átírás Nested Routing (50% kész)
**Már létezik:**
- `router/index.ts`: **83 sor**, globális `beforeEach` guard, auth ellenőrzés, landing redirect
- Route-ok: `/`, `/dashboard`, `/complete-kyc`, `/profile`, `/verify`, `/reset-password`, `/company/onboard`, `/company/garage`
- `beforeEach` guard: kezeli a `requiresAuth` meta-t, token ellenőrzést
**Hiányzik:**
- **Nincs nested routing** - minden route gyökér szinten
- Nincs layout wrapper pattern
- `/company/garage` route-nak nincs `/:id` paramétere
- Route meta kiterjesztése: `meta: { layout: 'private' | 'organization' | 'admin', requiresOrg: boolean }`
**Cél nested struktúra:**
```typescript
{
path: '/',
component: PrivateLayout,
children: [
{ path: '', name: 'Landing', component: LandingView },
{ path: 'dashboard', name: 'Dashboard', component: DashboardView, meta: { requiresAuth: true } },
{ path: 'profile', name: 'Profile', component: ProfileView, meta: { requiresAuth: true } },
]
},
{
path: '/company/:id',
component: OrganizationLayout,
children: [
{ path: 'dashboard', name: 'OrgDashboard', component: OrgDashboardView, meta: { requiresAuth: true, requiresOrg: true } },
{ path: 'garage', name: 'OrgGarage', component: CompanyGarageView, meta: { requiresAuth: true, requiresOrg: true } },
]
}
```
---
## 3. FÁZIS: Többnyelvűség (i18n) & Vizuális Témaváltó
### 🟢 #230 - Dinamikus és Statikus Fordítási Rendszer (80% kész)
**Már létezik:**
- **Backend**: `Translation` modell - **TELJESEN KÉSZ** (`system.translations` tábla)
- **Frontend**: `vue-i18n` beüzemelve `legacy: false` módban, `detectLocale()` függvénnyel
- 6 nyelvi fájl: `hu.ts`, `en.ts`, `de.ts`, `ro.ts`, `cz.ts`, `sk.ts`
- `translationService.ts`: `fetchTranslations()`, `fetchAllTranslations()`, `updateTranslation()`
- `appModeStore.ts`: `setLanguage(lang)`, `loadTranslations(lang)`, `getTranslation(key, defaultValue)`
- `LanguageSwitcher.vue`: létező komponens
**Hiányzik:**
- Backend API végpontok ellenőrzése (létezik-e `GET /api/v1/translations`?)
- Cache stratégia a dinamikus fordításokhoz (localStorage + verziószám)
---
### 🟠 #233 - i18n audit és szövegek kiszervezése (30% kész)
**Már létezik:**
- 6 db nyelvi fájl a `frontend/src/i18n/` mappában
- `hu.ts` kulcsstruktúra: `menu.*`, `header.*`, `dashboard.*`, `zen.*`, `auth.*`
- `DashboardHeader.vue` részleges i18n használat (`{{ t('menu.features') }}`)
- Backend JSON locale fájlok: `backend/static/locales/hu.json`
**Hiányzik - Hardcoded szövegek:**
1. **`DashboardView.vue`**: `SZERVIZNAPTÁR`, `SERVICE FINDER`, `Irányítópultod és járműveid áttekintése`
2. **`ConsumerLayout.vue`**: `Vehicle Registration`, `Service Booking`, `Cost Tracking`, `Tracking`, `Documents` (6 db), `© 2024 Your Company. All rights reserved.`
3. **`CorporateLayout.vue`**: `Organization`, `Overview`, `Vehicles`, `Drivers`, `Maintenance`, `Reports` (6 db sidebar), `42 vehicles`, `7 pending`, `€12,450`
4. **`CompanyGarageView.vue`**: `Céges garázs`
**Hiányzó i18n kulcsok:**
- `garage.private.title`, `garage.company.title`
- `buttons.next`, `buttons.save`, `buttons.cancel`
- `errors.*` (validációs hibák)
- `organization.*` (szervezeti nézetek)
- `layout.*` (layout elemek)
- `quickActions.*` (gyors akciók)
---
### 🔴 #234 - Tailwind CSS Változók és Témakezelő (0% kész)
**Már létezik:**
- `tailwind.config.js`: **38 sor**, egyedi színrendszer (sf-wall, sf-green, sf-blue, sf-accent, sf-dark stb.)
- `main.css`: **117 sor**, `btn-premium`, `hotspot-pulse`, `hotspot-ring` animációk
**Hiányzik:**
- **CSS Custom Properties** a `tailwind.config.js`-ben - nincs `var(--theme-*)` referencia
- `AppThemeStore` Pinia store - nem létezik
- Témaváltó logika: `light`, `dark`, `corporate` módok
- `:root` CSS változók (`--theme-bg`, `--theme-primary`, `--theme-text`, `--theme-accent`, `--theme-glass`)
- Sötét mód CSS (`dark:` osztályok hiányoznak)
- `applyTheme(settings)` függvény
---
## 4. FÁZIS: Animált UI Készlet
### 🔴 #235 - Expansion Card komponens (0% kész)
**Már létezik:**
- Nincs dedikált Expansion/ExpandableCard komponens
- A `DashboardView.vue` jármű kártyái group hover effekteket használnak
**Hiányzik:**
- Teljes `ExpansionCard.vue` komponens (Vue 3 `<script setup lang="ts">`)
- Props: `title`, `subtitle`, `icon`, `expanded` (v-model), `variant` ('default' | 'premium' | 'glass')
- Animációk: `@keyframes expandIn` / `expandOut`
- Slots: `#header`, `#default`, `#actions`
---
### 🟠 #236 - Kontextuális Vészjelző Rendszer (10% kész)
**Már létezik:**
- `hotspot-pulse` és `hotspot-ring` animációk a `main.css`-ben
- `ConsumerLayout.vue` scoped CSS pulse animáció
- Base alert stílus a `btn-premium` osztályban (glassmorphism)
**Hiányzik:**
- `ContextAlert.vue` komponens - nem létezik
- Props: `type` ('info' | 'warning' | 'error' | 'success'), `message`, `autoClose`, `dismissible`, `action`
- Alert store (`useAlertStore` Pinia store)
- Animációk: belépés (`slideInDown`), kilépés (`fadeOutUp`)
- Global alert renderelés (App.vue)
- Z-index kezelés
---
## 🔗 Ajánlott Implementációs Sorrend
```
Fázis 1 (Backend alapozás):
#228 (JSONB) ──► #229 (RBAC)
Fázis 2 (Frontend struktúra):
#231 (Layout-ok) ──► #232 (Router)
Fázis 3 (i18n + Téma):
#230 (Fordítás) ──► #233 (i18n audit)
#234 (Témaváltó) - független a #228/#229-től, de azok adják az adatforrást
Fázis 4 (UI Kit):
#235 (Expansion Card) - független
#236 (Alert System) - független
```
---
## ⚠️ Breaking Change Kockázatok
| Változtatás | Érintett komponensek | Kockázat |
|------------|---------------------|---------|
| `<slot />``<router-view />` | Minden view | 🔴 Magas |
| Layout átnevezés | Importok minden fájlban | 🟡 Közepes |
| CSS variable bevezetés | Minden komponens színe | 🔴 Magas |
| Router nested átírás | beforeEach guard, view path-ok | 🔴 Magas |
| i18n kulcs átnevezés | Minden `$t()` hívás | 🟡 Közepes |

View File

@@ -0,0 +1,114 @@
# Frontend White Screen Analysis - 2026-06-07
## Summary
The `app.servicefinder.hu` page was loading as a blank/white screen.
**Root cause:** Missing `flag-icons` npm package inside the frontend container.
---
## 1. Investigation Steps
### 1.1 Docker Container Status
- `sf_public_frontend`: **Up** (running)
- `sf_api`: **Up** (running)
- Backend API reachable: `localhost:8000/api/v1/health` → responds
### 1.2 Frontend Container Logs
**Primary error (repetitive):**
```
[vite] Internal server error: Failed to resolve import "flag-icons/css/flag-icons.css" from "src/main.ts". Does the file exist?
File: /app/src/main.ts:13:7
11 | import sk from "./i18n/sk";
12 | import "./assets/main.css";
13 | import "flag-icons/css/flag-icons.css";
| ^
```
### 1.3 Critical File Verification (all exist)
- `frontend/src/components/Logo.vue` - Exists
- `frontend/src/stores/vehicle.ts` - Exists
- `frontend/src/views/organization/CompanyOnboardingView.vue` - Exists
- `frontend/src/views/organization/CompanyGarageView.vue` - Exists
### 1.4 Dependency Check
- `frontend/package.json` contains `"flag-icons": "^7.5.0"` - OK
- Container `/app/node_modules/flag-icons`: **MISSING** - FAIL
---
## 2. Root Cause Analysis
### 2.1 System Architecture
From `docker-compose.yml:302-318`:
```yaml
sf_public_frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
volumes:
- ./frontend:/app
- /app/node_modules # Anonymous volume isolates node_modules
```
From `frontend/Dockerfile.dev:10`:
```dockerfile
RUN npm install # Runs at BUILD time
```
### 2.2 Failure Mechanism
1. `flag-icons` was added to `package.json` (and `package-lock.json` updated)
2. The container was **not rebuilt** after the package.json change
3. The anonymous volume (`/app/node_modules`) **preserved the old node_modules state**
4. Since volumes take precedence over `COPY`/`RUN` instructions, `npm install` never ran
5. Vite couldn't find `flag-icons/css/flag-icons.css` at startup
6. Result: **Internal server error****White screen**
---
## 3. Fix Applied
### 3.1 Install Package Inside Container
```bash
docker exec sf_public_frontend npm install
```
Result: `added 6 packages, changed 15 packages, and audited 281 packages`
### 3.2 Restart Vite Dev Server
```bash
docker compose restart sf_public_frontend
```
Result: `VITE v5.4.21 ready in 341 ms` - **running without errors**
---
## 4. Secondary Observations (non-blocking)
### 4.1 ProfileView Address Display Issue
In `frontend/src/views/ProfileView.vue:370-382`, the read-only view uses:
```vue
<template v-if="person?.address?.address_zip || person?.address?.address_city">
```
This references a **nested** `address` object, but the new backend format sends address fields **flat** (e.g., `person.address_zip`, not `person.address.address_zip`). The edit mode correctly uses flat `editForm.address_zip`.
**Recommendation:** Change read-only display from `person?.address?.address_*` to `person?.address_*`.
### 4.2 Circular Dependency (safe)
- `router/index.ts` dynamically imports `stores/auth` in `beforeEach` guard
- `stores/auth.ts` statically imports `router`
- This is **safe** because the dynamic import runs only on navigation events, after the router is fully initialized.
---
## 5. Prevention Recommendations
### 5.1 Future Prevention
1. **Rebuild trigger**: When `package.json` changes, always rebuild the image:
```bash
docker compose up -d --build sf_public_frontend
```
2. **Pre-start npm install**: Modify `CMD` to run `npm install` before starting:
```dockerfile
CMD npm install && npm run dev -- --host 0.0.0.0
```
3. **Anonymous volume awareness**: The anonymous volume preserves old node_modules. When package.json changes, rebuild the container, don't just restart.

View File

@@ -0,0 +1,145 @@
# Gitea Nyitott Kártyák Összefoglaló
**Dátum:** 2026-06-09
**Lekérdezés:** `docker exec roo-helper python3 /scripts/gitea_manager.py list`
**Összes nyitott kártya:** **63 db**
---
## Összesítés Mérföldkövek Szerint
| Mérföldkő | Kártyák száma |
|---|---|
| Grafikai Komponensek & Frontend Skin Rendszer | 4 |
| Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer | 20 |
| Phase 4: Testing & Deployment | 7 |
| Phase 3: Advanced Features & Epic 11 | 6 |
| Phase 2: Dashboard & Analytics Wiring | 6 |
| Phase 1: Core Functionality Fixes | 4 |
| Nincs mérföldkő (egyéb / dashboard) | 16 |
---
## 1. Grafikai Komponensek & Frontend Skin Rendszer (4 kártya)
| ID | Cím |
|----|------|
| #256 | Skin Rendszer Alapok - Dinamikus Háttérv |
| #255 | Lucide Ikonkészlet Integráció (lucide-vu) |
| #253 | Háttérkép: garage_theme_dark.png |
| #252 | Háttérkép: garage_company_clean.png (sf_) |
---
## 2. Frontend Architektúra 2.0 & Dinamikus Megjelenítési Rendszer (20 kártya)
| ID | Cím |
|----|------|
| #250 | 4.2-A: ContextAlert.vue komponens + useA |
| #249 | 4.1-A: ExpansionCard.vue komponens + ani |
| #248 | 3.2-B: AppThemeStore létrehozása + apply |
| #247 | 3.2-A: CSS Custom Properties + tailwind. |
| #246 | 3.1-B: Hardcoded szövegek cseréje 5 komp |
| #245 | 3.1-A: Hiányzó i18n kulcsok felvétele |
| #244 | 2.2-B: View-ok áthelyezése nested strukt |
| #243 | 2.2-A: Route meta kiterjesztés + beforeE |
| #242 | 2.1-B: Layout-váltó gombok (Privat <-> C) |
| #241 | 2.1-A: Layout-ok átnevezése és <slot /> |
| #240 | 1.2-B: Frontend useFeatureFlag() composa |
| #239 | 1.2-A: Backend subscription_service.py + |
| #238 | 1.1-B: Frontend useVisualSettings() comp |
| #236 | 4.2: Kontextuális Vészjelző Rendszer |
| #234 | 3.2: Tailwind CSS Változók és Témakezelő |
| #233 | 3.1: Jelenlegi állapot felmérése |
| #232 | 2.2: Vue Router átírás (Nested Routing) |
| #231 | 2.1: Független Layout komponensek felépí |
| #230 | 1.3: Dinamikus és Statikus f |
| #229 | 1.2: Engedélyezési szintek és Funkciókap |
---
## 3. Phase 4: Testing & Deployment (7 kártya)
| ID | Cím |
|----|------|
| #165 | Production Deployment and Monitoring Set |
| #164 | CI/CD Pipeline Setup (GitHub Actions) |
| #163 | Security Audit (Penetration Testing) |
| #162 | Accessibility Audit and Fixes (WCAG 2.1) |
| #161 | Performance Optimization |
| #160 | Implement Integration Tests (Playwright) |
| #159 | Write Unit Tests for Critical Components |
---
## 4. Phase 3: Advanced Features & Epic 11 (6 kártya)
| ID | Cím |
|----|------|
| #158 | Implement Advanced Search with Filters |
| #157 | Add Bulk Operations (Vehicle Import) |
| #156 | Implement Webhook and Notification Syste |
| #155 | Add Admin Control Panels |
| #154 | Implement Service Booking Flow |
| #153 | Complete Profile Selector |
---
## 5. Phase 2: Dashboard & Analytics Wiring (6 kártya)
| ID | Cím |
|----|------|
| #152 | Implement Historical Data (occurrence_da) |
| #151 | Connect User Management Table to Real Da |
| #150 | Wire Service Map with Real Provider Data |
| #149 | Implement Analytics Service (TCO/km) |
| #148 | Connect Gamification Components to Real |
| #147 | Wire Financial Dashboard to Real Finance |
---
## 6. Phase 1: Core Functionality Fixes (4 kártya)
| ID | Cím |
|----|------|
| #178 | Vehicle Transfer Requests - Implement Du |
| #146 | Implement Basic Error Handling in Fronte |
| #145 | Standardize API Base URL Usage in Fronte |
| #142 | Implement Catalog API Endpoints |
---
## 7. Egyéb / Nincs mérföldkő (16 kártya)
| ID | Cím |
|----|------|
| #227 | [FIX] NAV API v3 queryTaxpayer - .env qu |
| #226 | Frontend: flag-icons CSS import hiba |
| #225 | Hiba: Frontend - flag-icons CSS import |
| #224 | Hiba: NAV API aláírás (INVALID_REQUEST) |
| #218 | Frontend: Cég létrehozás 3-lépéses Onboa |
| #217 | Backend: Cég (Organization) CRUD API bőv |
| #216 | Profil UI hiányosságok (Person & User) |
| #175 | Advanced Analytics & Reporting Engine |
| #174 | Real-time Notification System |
| #173 | Service Finder Rendszer Áttekintő Dokume |
| #140 | Connect Service Moderation Map to Backen |
| #139 | Integrate Gamification Control Panel |
| #138 | Connect Financial Dashboard Tile |
| #137 | Implement Real-time System Health Monito |
| #136 | Implement AI Researcher Logs Backend |
| #135 | Connect User Management Table to Real AP |
---
## Főbb Megállapítások
1. Legnagyobb blokk: A Frontend Architektúra 2.0 mérföldkő tartalmazza a legtöbb nyitott kártyát (20 db) - ez a jelenlegi aktív fejlesztési fókusz.
2. Grafikai előkészületek: 4 kártya a Skin rendszerhez és háttérképekhez tartozik.
3. Hibajavítások: 3 kritikus hibajelzés van nyitva (#224, #225, #227) - NAV API és CSS import problémák.
4. Tesztelési fázis: 7 kártya vár a Phase 4 (Testing & Deployment) mérföldkőben.
5. Fázisok közötti egyensúly: A Phase 1-4 mérföldkövek összesen 23 kártyát tartalmaznak, míg a mérföldkő nélküli kártyák száma 16.
---
Összefoglaló készítve: 2026-06-09 23:20 UTC

View File

@@ -0,0 +1,311 @@
# 🔬 identity Séma Teljes Audit (2026-06-06)
## Áttekintés
Az `identity` séma **9 táblát** tartalmaz, amelyek együttesen a teljes felhasználói identitáskezelést lefedik: a természetes személyt (Person), a login entitást (User), a pénztárcát, a közösségi kapcsolatokat, a bizalmi profilt, az eszközöket és a vouchereket.
---
## 1. 📊 TÁBLÁK ÉS ADATAIK
### 1.1 👤 `users` — Login Entitás (Központi tábla)
**Oszlopok (24 db):**
| Oszlop | Típus | Kötelező? | Default | Leírás |
|--------|-------|-----------|---------|--------|
| `id` | INTEGER | PK | nextval(...) | Elsődleges kulcs |
| `email` | VARCHAR(255) | **YES** | — | E-mail cím (UNIQUE) |
| `hashed_password` | VARCHAR | NO | — | Hash-elt jelszó |
| `role` | USERRODE (ENUM) | **YES** | 'user' | Szerepkör (lásd ENUM lent) |
| `person_id` | BIGINT | NO | — | FK → persons(id) |
| `subscription_plan` | VARCHAR(30) | **YES** | 'FREE' | Előfizetési csomag |
| `subscription_expires_at` | TIMESTAMPTZ | NO | — | Előfizetés lejárata |
| `is_vip` | BOOLEAN | **YES** | false | VIP státusz |
| `referral_code` | VARCHAR(20) | NO | — | Meghívókód (UNIQUE) |
| `referred_by_id` | INTEGER | NO | — | FK → users(id) (aki meghívta) |
| `current_sales_agent_id` | INTEGER | NO | — | FK → users(id) (értékesítő) |
| `is_active` | BOOLEAN | **YES** | false | Aktív-e a fiók |
| `is_deleted` | BOOLEAN | **YES** | false | Soft-delete |
| `folder_slug` | VARCHAR(12) | NO | — | Fájltároló slug (UNIQUE) |
| `preferred_language` | VARCHAR(5) | **YES** | 'hu' | Nyelv |
| `region_code` | VARCHAR(5) | **YES** | 'HU' | Régió |
| `preferred_currency` | VARCHAR(3) | **YES** | 'HUF' | Pénznem |
| `ui_mode` | VARCHAR(20) | **YES** | 'personal' | UI mód (personal/fleet) |
| `scope_level` | VARCHAR(30) | **YES** | 'individual' | Scope szint |
| `scope_id` | VARCHAR(50) | NO | — | Scope azonosító |
| `custom_permissions` | JSON | **YES** | '{}' | Egyedi jogosultságok |
| `alternative_emails` | JSON | **YES** | '[]' | Alternatív e-mailek |
| `email_history` | JSON | **YES** | '[]' | E-mail cím történet |
| `created_at` | TIMESTAMPTZ | **YES** | — | Létrehozás ideje |
**Indexek (7 db):**
- `users_pkey` — UNIQUE BTREE (id)
- `users_email_key` — UNIQUE BTREE (email)
- `users_referral_code_key` — UNIQUE BTREE (referral_code)
- `ix_identity_users_folder_slug` — UNIQUE BTREE (folder_slug)
- `ix_identity_users_email` — UNIQUE BTREE (email) (duplikáció!)
- `ix_identity_users_id` — BTREE (id)
---
### 1.2 🧑 `persons` — Természetes Személy
**Oszlopok (22 db):**
| Oszlop | Típus | Kötelező? | Default | Leírás |
|--------|-------|-----------|---------|--------|
| `id` | BIGINT | PK | nextval(...) | Elsődleges kulcs |
| `user_id` | INTEGER | NO | — | FK → users(id) (aktív fiók) |
| `id_uuid` | UUID | **YES** | gen_random_uuid() | Publikus UUID |
| `address_id` | UUID | NO | — | FK → system.addresses(id) |
| `identity_hash` | VARCHAR(64) | NO | — | Hash: Név+Anyja+DOB (UNIQUE) |
| `last_name` | VARCHAR | **YES** | — | Vezetéknév |
| `first_name` | VARCHAR | **YES** | — | Keresztnév |
| `phone` | VARCHAR | NO | — | Telefonszám |
| `mothers_last_name` | VARCHAR | NO | — | Anyja vezetékneve |
| `mothers_first_name` | VARCHAR | NO | — | Anyja keresztneve |
| `birth_place` | VARCHAR | NO | — | Születési hely |
| `birth_date` | TIMESTAMP | NO | — | Születési dátum |
| `identity_docs` | JSON | **YES** | '{}' | Okmányok (ID_CARD, LICENSE, stb.) |
| `ice_contact` | JSON | **YES** | '{}' | Vészhelyzeti kapcsolattartó |
| `lifetime_xp` | BIGINT | **YES** | -1 | Élettapasztalat pont |
| `penalty_points` | INTEGER | **YES** | -1 | Büntetőpontok |
| `social_reputation` | NUMERIC(3,2) | **YES** | 0.00 | Társadalmi reputáció |
| `is_sales_agent` | BOOLEAN | **YES** | true | Értékesítő-e |
| `is_active` | BOOLEAN | **YES** | true | Aktív-e |
| `is_ghost` | BOOLEAN | **YES** | false | Ghost (soft-delete) |
| `merged_into_id` | BIGINT | NO | — | Összevonva ebbe a Person-ba (csak ORM szinten FK) |
| `created_at` | TIMESTAMPTZ | **YES** | — | Létrehozás |
| `updated_at` | TIMESTAMPTZ | NO | — | Módosítás |
**Indexek (4 db):**
- `persons_pkey` — UNIQUE BTREE (id)
- `persons_id_uuid_key` — UNIQUE BTREE (id_uuid)
- `ix_identity_persons_identity_hash` — UNIQUE BTREE (identity_hash)
- `ix_identity_persons_id` — BTREE (id)
---
### 1.3 💰 `wallets` — Pénztárca
**Oszlopok (6 db):**
| Oszlop | Típus | Kötelező? | Default |
|--------|-------|-----------|---------|
| `id` | INTEGER | PK | nextval(...) |
| `user_id` | INTEGER | **YES** | — (UNIQUE, FK → users(id)) |
| `earned_credits` | NUMERIC(18,4) | **YES** | 0 |
| `purchased_credits` | NUMERIC(18,4) | **YES** | 0 |
| `service_coins` | NUMERIC(18,4) | **YES** | 0 |
| `currency` | VARCHAR(3) | **YES** | — |
**Indexek:**
- `wallets_pkey` — UNIQUE BTREE (id)
- `wallets_user_id_key` — UNIQUE BTREE (user_id)
---
### 1.4 🎫 `active_vouchers` — Aktív Voucher-ek
**Oszlopok (6 db):**
| Oszlop | Típus | Kötelező? | Default |
|--------|-------|-----------|---------|
| `id` | INTEGER | PK | nextval(...) |
| `wallet_id` | INTEGER | **YES** | — (FK → wallets(id) ON DELETE CASCADE) |
| `amount` | NUMERIC(18,4) | **YES** | — |
| `original_amount` | NUMERIC(18,4) | **YES** | — |
| `expires_at` | TIMESTAMPTZ | **YES** | — |
| `created_at` | TIMESTAMPTZ | **YES** | now() |
---
### 1.5 🤝 `social_accounts` — Közösségi Bejelentkezés
**Oszlopok (7 db):**
| Oszlop | Típus | Kötelező? | Default |
|--------|-------|-----------|---------|
| `id` | INTEGER | PK | nextval(...) |
| `user_id` | INTEGER | **YES** | — (FK → users(id) ON DELETE CASCADE) |
| `provider` | VARCHAR(50) | **YES** | — |
| `social_id` | VARCHAR(255) | **YES** | — |
| `email` | VARCHAR(255) | **YES** | — |
| `extra_data` | JSON | **YES** | '{}' |
| `created_at` | TIMESTAMPTZ | **YES** | now() |
**Unique:** `uix_social_provider_id` (provider, social_id)
**Indexek:** social_id
---
### 1.6 🏆 `user_trust_profiles` — Gondos Gazda Index
**Oszlopok (9 db):**
| Oszlop | Típus | Kötelező? | Default |
|--------|-------|-----------|---------|
| `user_id` | INTEGER | **PK+FK** | — (FK → users(id) ON DELETE CASCADE) |
| `trust_score` | INTEGER | **YES** | 0 |
| `maintenance_score` | NUMERIC(5,2) | **YES** | 0.00 |
| `quality_score` | NUMERIC(5,2) | **YES** | 0.00 |
| `preventive_score` | NUMERIC(5,2) | **YES** | 0.00 |
| `identity_score` | INTEGER | **YES** | 0 |
| `verification_level` | INTEGER | **YES** | 0 |
| `verified_channels` | JSONB | **YES** | '{}' |
| `identity_risk_flag` | BOOLEAN | **YES** | false |
| `last_calculated` | TIMESTAMPTZ | **YES** | now() |
---
### 1.7 📧 `verification_tokens` — Verifikációs Tokenek
**Oszlopok (8 db):**
| Oszlop | Típus | Kötelező? | Default |
|--------|-------|-----------|---------|
| `id` | INTEGER | PK | nextval(...) |
| `token` | UUID | **YES** | — (UNIQUE) |
| `user_id` | INTEGER | **YES** | — (FK → users(id) ON DELETE CASCADE) |
| `token_type` | VARCHAR(20) | **YES** | — |
| `extra_data` | JSON | NO | '{}' |
| `created_at` | TIMESTAMPTZ | **YES** | now() |
| `expires_at` | TIMESTAMPTZ | **YES** | — |
| `is_used` | BOOLEAN | **YES** | — |
---
### 1.8 📱 `devices` — Eszközök
**Oszlopok (4 db):**
| Oszlop | Típus | Kötelező? |
|--------|-------|-----------|
| `fingerprint_hash` | VARCHAR(255) | **PK** |
| `risk_score` | INTEGER | **YES** (default 100) |
| `is_banned` | BOOLEAN | **YES** (default false) |
| `created_at` | TIMESTAMPTZ | **YES** |
---
### 1.9 🔗 `user_device_links` — Eszköz-Felhasználó Kapcsolat
**Oszlopok (6 db):**
| Oszlop | Típus | Kötelező? | Default |
|--------|-------|-----------|---------|
| `id` | UUID | **PK** | — |
| `user_id` | INTEGER | **YES** | — (FK → users(id) ON DELETE CASCADE) |
| `device_hash` | VARCHAR(255) | **YES** | — (FK → devices(fingerprint_hash) ON DELETE CASCADE) |
| `first_seen_at` | TIMESTAMPTZ | **YES** | — |
| `last_seen_at` | TIMESTAMPTZ | NO | — |
| `login_count` | INTEGER | **YES** | 1 |
---
## 2. 🔗 KAPCSOLATOK (FOREIGN KEYS)
### 2.1 Belső Kapcsolatok (identity → identity)
```
users.person_id ─────────────► persons.id
persons.user_id ─────────────► users.id (aktív fiók)
users.referred_by_id ────────► users.id (MLM: ki hívta meg)
users.current_sales_agent_id ► users.id (értékesítő)
social_accounts.user_id ─────► users.id [CASCADE]
verification_tokens.user_id ─► users.id [CASCADE]
user_trust_profiles.user_id ─► users.id [CASCADE]
wallets.user_id ─────────────► users.id
user_device_links.user_id ───► users.id [CASCADE]
wallets ◄──────────────────── active_vouchers.wallet_id [CASCADE]
devices ◄──────────────────── user_device_links.device_hash [CASCADE]
```
### 2.2 Külső Kapcsolatok (identity → system)
```
persons.address_id ──────────► system.addresses(id)
```
### 2.3 ORM Szintű Kapcsolatok (nincs DB FK constraint)
```
persons.merged_into_id ──────► persons(id) -- SQLAlchemy ORM, use_alter=True
persons.user_id ─────────────► users(id) -- SQLAlchemy ORM (fk_person_active_user), use_alter=True
```
---
## 3. 🌐 KÜLSŐ HIVATKOZÁSOK (Más sémák hivatkoznak identity táblákra)
| Séma | Tábla | Oszlop | Hivatkozott tábla |
|------|-------|--------|-------------------|
| `audit` | audit_logs | user_id | identity.users |
| `audit` | financial_ledger | user_id, person_id | identity.users, identity.persons |
| `audit` | operational_logs | user_id | identity.users |
| `finance` | withdrawal_requests | user_id | identity.users |
| `fleet` | organization_members | user_id, person_id | identity.users, identity.persons |
| `gamification` | points_ledger | user_id | identity.users |
| `gamification` | user_badges | user_id | identity.users |
| `gamification` | user_contributions | user_id | identity.users |
| `gamification` | user_scores | user_id | identity.users |
| `gamification` | user_stats | user_id | identity.users |
| `marketplace` | service_requests | user_id | identity.users |
| `marketplace` | service_reviews | user_id | identity.users |
| `marketplace` | votes | user_id | identity.users |
| `system` | internal_notifications | user_id | identity.users |
| `system` | user_scores_deprecated | user_id | identity.users |
**Összesen: 17 külső FK hivatkozás 7 különböző sémából.**
---
## 4. 🏷️ ENUM TÍPUSOK
### `identity.userrole` ENUM
| Érték | Leírás |
|-------|--------|
| `owner_old` | (régi, deprecated) |
| `ADMIN` | Rendszergazda |
| `FLEET_MANAGER` | Flottakezelő |
| `DRIVER` | Sofőr |
| `owner` | Tulajdonos |
| `admin` | Adminisztrátor |
| `user` | **Alapértelmezett** — sima felhasználó |
**Megjegyzés:** Több érték is duplikált (pl. owner/owner_old, ADMIN/admin). Valószínűleg refaktorálásra szorul, hogy csak a kívánt 9 érték maradjon meg.
---
## 5. 🗺️ KAPCSOLATI ÁBRA (ERD)
```
┌──────────────────────────────────┐
│ identity.users │ ◄──── social_accounts
│ (24 oszlop - központi entitás) │ ◄──── verification_tokens
└───┬────┬────┬────┬────┬──────────┘ ◄──── user_trust_profiles
│ │ │ │ │ ◄──── wallets
│ │ │ │ │ ◄──── user_device_links
┌───────────┘ │ │ │ └───────────────┐
▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌───────────────┐ ┌──────────┐
│ persons │ │ wallets │ │ devices │ │ (saját) │
│(22 oszl.)│ │ (6 oszl.) │ │ (4 oszlop) │ │ referrer │
└────┬─────┘ └─────┬─────┘ └───────┬───────┘ │ sales_ag.│
│ │ │ └──────────┘
▼ ▼ ▼
system.addresses active_vouchers user_device_links
(GPS, cím) (FIFO voucher) (kapcsolótábla)
```
---
## 6. 📝 MEGJEGYZÉSEK
1. **Körkörös függőség:** A `users` és `persons` táblák között **körkörös FK** van: `users.person_id → persons(id)` és `persons.user_id → users(id)`. Ezt az ORM szinten `post_update=True` és `use_alter=True` paraméterekkel oldják fel.
2. **ORM-only FKs:** A `persons.merged_into_id` és a `persons.user_id` (második FK, `fk_person_active_user`) **csak ORM szinten** léteznek, adatbázis szintű FK constraint nélkül.
3. **JSON mezők:** 5 JSON típusú mező van a sémában, amelyek rugalmas adattárolást tesznek lehetővé: `identity_docs`, `ice_contact`, `custom_permissions`, `alternative_emails`, `email_history`, `extra_data` (social_accounts), `verified_channels`.
4. **UNIQUE megszorítások:** Összesen 7 UNIQUE constraint van, ezek közül az `identity_hash` a legkritikusabb — ez biztosítja a deduplikációt.
---
## 7. KÖVETKEZTETÉS
Az identity séma **teljes körű emberközpontú identitáskezelést** valósít meg, amely lefedi:
- ✅ Személyes adatok (Person)
- ✅ Bejelentkezési adatok (User)
- ✅ Pénzügyek (Wallet, Vouchers)
- ✅ Okmányok (identity_docs JSON)
- ✅ Közösségi kapcsolatok (Social accounts)
- ✅ Bizalmi index (Trust Profile)
- ✅ Eszközazonosítás (Devices)
- ✅ MLM/Referral rendszer (referred_by, sales_agent)
- ✅ Verifikáció (Verification tokens)

View File

@@ -0,0 +1,111 @@
# 🔍 NAV API Tax Number Lookup - Hibaelemzés és Javítási Terv
**Dátum:** 2026-06-07
**Gitea Issue-ek:** #224 (NAV API aláírás), #226 (Frontend CSS import)
**Elemző:** Rendszer-Architect
---
## 1. Összefoglaló
Két független hibát azonosítottam:
1. **Frontend**: A Vite fejlesztői szerver nem tudja feloldani a `flag-icons/css/flag-icons.css` importot → `npm install` hiányzik
2. **Backend (NAV API)**: A NAV Online Számla API v3 `queryTaxpayer` hívása `INVALID_REQUEST_SIGNATURE` hibát ad → valószínűleg érvénytelen `NAV_API_SIGN_KEY`
---
## 2. Frontend Hiba: flag-icons CSS import
### 2.1. Helyszín
- [`frontend/src/main.ts:13`](/opt/docker/dev/service_finder/frontend/src/main.ts) - `import 'flag-icons/css/flag-icons.css'`
- [`frontend/package.json:14`](/opt/docker/dev/service_finder/frontend/package.json) - `"flag-icons": "^7.5.0"` függőség
### 2.2. Ok
A `flag-icons` npm csomag deklarálva van a `package.json`-ban, de **nincs telepítve** a `node_modules`-ban. Ez tipikusan akkor fordul elő, ha:
- A függőséget kézzel adták hozzá a `package.json`-hoz, de `npm install` nem futott le
- A `node_modules` mappa régi, és `npm install` vagy `npm update` szükséges
### 2.3. Javítás
```bash
cd /opt/docker/dev/service_finder/frontend && npm install
```
---
## 3. Backend Hiba: NAV API INVALID_REQUEST_SIGNATURE
### 3.1. Helyszín
- [`backend/app/services/nav_service.py`](/opt/docker/dev/service_finder/backend/app/services/nav_service.py) - Teljes NAV API integráció
- [`backend/app/api/v1/endpoints/organizations.py:124`](/opt/docker/dev/service_finder/backend/app/api/v1/endpoints/organizations.py) - `/lookup-tax/{tax_number}` végpont
- [`backend/app/core/config.py:130`](/opt/docker/dev/service_finder/backend/app/core/config.py) - NAV konfigurációs mezők
- [`.env:90`](/opt/docker/dev/service_finder/.env) - NAV API hitelesítő adatok
### 3.2. A Hiba Lánca
1. Felhasználó meghívja: `GET /api/v1/organizations/lookup-tax/24742063`
2. `NavService.query_taxpayer()` összeállítja a SOAP/XML kérést
3. Kiszámolja az aláírást: `SHA3-512(requestId + timestamp + NAV_API_SIGN_KEY)`
4. NAV API válasz: `HTTP 400``INVALID_REQUEST_SIGNATURE` ("Érvénytelen kérés aláírás")
5. `query_taxpayer()` visszatér `None`-nal
6. A végpont `HTTP 404`-et ad: "Cég nem található a NAV adatbázisában"
### 3.3. Tesztelt Aláírási Módszerek
| Módszer | Eredmény |
|---------|----------|
| SHA3-512(requestId + timestamp + signKey) | ❌ INVALID_REQUEST_SIGNATURE |
| SHA3-512(requestId + timestamp + passwordHash) | ❌ INVALID_REQUEST_SIGNATURE |
| SHA3-512(requestId + timestamp + exchangeKey) | ❌ INVALID_REQUEST_SIGNATURE |
| HMAC-SHA3-512(signKey, requestId + timestamp) | ❌ INVALID_REQUEST_SIGNATURE |
| SHA3-512(requestId + timestamp) (no key) | ❌ INVALID_REQUEST_SIGNATURE |
### 3.4. Következtetés
**Minden** aláírási módszer ugyanazt a hibát adja vissza, ami arra utal, hogy **nem a kódban lévő aláírás-számítás a hibás, hanem maguk a hitelesítő adatok érvénytelenek**.
Gyanús elemek:
- `NAV_API_SIGN_KEY=02-b52b-3d9e7d638c695EPWNIUAEGHS` - a formátuma nem szabványos (nem UUID, nem base64)
- `NAV_API_EXCHANGE_KEY=a5cf5EPWNIU9DP8O` - szintén rövid, nem szabványos
### 3.5. Javítási Terv
#### 1. lépés: Új NAV API kulcsok igénylése
Látogass el a NAV Online Számla teszt portáljára:
- https://onlineszamla.nav.gov.hu/
- Regisztráció után generálj új `signKey` és `exchangeKey` párost
#### 2. lépés: Frissítsd a `.env` fájlt
```env
NAV_API_USER=<új felhasználónév>
NAV_API_PASSWORD=<új jelszó>
NAV_API_SIGN_KEY=<új signKey>
NAV_API_EXCHANGE_KEY=<új exchangeKey>
NAV_API_CALLER_TAX_NUMBER="48952046133"
```
#### 3. lépés: Teszteld a kapcsolatot
```bash
docker exec sf_api python3 -c "
from app.services.nav_service import NavService
import asyncio
result = asyncio.run(NavService.query_taxpayer('24742063'))
print(result)
"
```
---
## 4. Kapcsolódó Gitea Issue-ek
| ID | Cím | Státusz |
|----|-----|---------|
| #224 | 🔴 Hiba: NAV API aláírás (INVALID_REQUEST_SIGNATURE) | Nyitott |
| #225 | 🔴 Hiba: Frontend - flag-icons CSS import | Nyitott (duplikátum?) |
| #226 | Frontend: flag-icons CSS import hiba | Nyitott |
---

View File

@@ -0,0 +1,94 @@
# 🔍 NAV API v3 Fix Audit - Hiányosságjelentés
**Dátum:** 2026-06-07
**Vizsgált Issue:** #227
**Forrásdokumentáció:** [`docs/nav_api_v3_fix_summary.md`](docs/nav_api_v3_fix_summary.md)
## Összefoglaló
A [`docs/nav_api_v3_fix_summary.md`](docs/nav_api_v3_fix_summary.md) dokumentációban leírt 3 javítási lépésből **egyik sem lett végrehajtva**. Az alábbi táblázat mutatja a pontos állapotot.
## Részletes Hiányosság-leképezés
### 1. `.env` fájl - ❌ Idezőjelek még mindig jelen
| Mező | Jelenlegi érték | Helyes érték |
|------|----------------|--------------|
| [`NAV_API_PASSWORD`](.env:91) | `'Tevelegelek&99'` (aposztrófokkal, 14 karakter) | `Tevelegelek&99` (12 karakter) |
| [`NAV_API_CALLER_TAX_NUMBER`](.env:94) | `"48952046133"` (idézőjellel, 12 karakter) | `48952046133` (11 karakter) |
**Következmény:**
- `passwordHash = SHA-512("'Tevelegelek&99'")`**ROSSZ hash** (aposztrófokkal)
- `caller_tax_number_core[:8]``'"4895204'`**ROSSZ adószám**
### 2. [`backend/app/core/config.py`](backend/app/core/config.py) - ❌ HIÁNYZIK a `strip_quotes` field_validator
A [`config.py:130-136`](backend/app/core/config.py:130) sorokban a NAV_API_* mezők definiálva vannak, de **nincs hozzáadva** a `field_validator`.
**Hiányzó kód** (beszúrandó a [`config.py:136`](backend/app/core/config.py:136) sor után):
```python
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
@classmethod
def strip_quotes(cls, v: str) -> str:
if v:
v = v.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
v = v[1:-1]
return v
```
### 3. [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py) - ❌ HIÁNYZIK a `.strip()` hívás
A [`nav_service.py:134-138`](backend/app/services/nav_service.py:134) sorokban és a [`nav_service.py:127-131`](backend/app/services/nav_service.py:127) sorokban is hiányzik a `.strip()`:
```python
# JELENLEGI (hibás):
target_tax_number_core = (
tax_number
.replace("-", "")
.replace(" ", "")[:8]
)
# HELYES:
target_tax_number_core = (
tax_number
.strip()
.replace("-", "")
.replace(" ", "")[:8]
)
```
```python
# JELENLEGI (hibás) - caller_tax_number_core:
caller_tax_number_core = (
settings.NAV_API_CALLER_TAX_NUMBER
.replace("-", "")
.replace(" ", "")[:8]
)
# HELYES:
caller_tax_number_core = (
settings.NAV_API_CALLER_TAX_NUMBER
.strip()
.replace("-", "")
.replace(" ", "")[:8]
)
```
## Javítási Sorrend (Kötelező)
| # | Fájl | Művelet | Parancs |
|---|------|---------|---------|
| 1 | `.env` | Idézőjelek eltávolítása | Kézi szerkesztés |
| 2 | [`backend/app/core/config.py`](backend/app/core/config.py) | `strip_quotes` field_validator hozzáadása | Szerkesztés |
| 3 | [`backend/app/services/nav_service.py`](backend/app/services/nav_service.py) | `.strip()` hozzáadása mindkét helyen | Szerkesztés |
| 4 | Konténer újraindítás | `docker compose restart sf_api` | `docker compose restart sf_api` |
| 5 | Teszt | Lekérdezés futtatása | `docker exec sf_api python3 -c "from app.services.nav_service import NavService; import asyncio; result = asyncio.run(NavService.query_taxpayer('24742063')); print(result)"` |
## Gyökérok Elemzés
A jelenlegi `INVALID_REQUEST_SIGNATURE` hiba oka:
1. A `passwordHash` aposztrófokkal együtt lett hash-elve → a NAV szerver nem ismeri fel a jelszót
2. A `caller_tax_number` idézőjellel együtt kerül a `<user><taxNumber>` mezőbe → a NAV szerver a hívó felet sem tudja azonosítani
3. Mivel a fenti két érték hibás, a teljes `requestSignature` is érvénytelen lesz, mert a NAV szerver a felhasználó azonosítása után számolja ki a saját oldalán a várható signature-t

View File

@@ -0,0 +1,235 @@
# 🔧 NAV Online Számla API v3 - Hibajavítási Összefoglaló
**Dátum:** 2026-06-07
**Forrás:** `Online_Szamla_interfesz specifikacio_HU_v3.0. (2026.02.12).pdf`
**Érintett fájlok:**
- `.env` (NAV konfigurációs értékek)
- `backend/app/services/nav_service.py` (NAV API integráció)
- `backend/app/api/v1/endpoints/organizations.py` (lookup-tax végpont)
- `backend/app/core/config.py` (Pydantic Settings)
---
## 1. A NAV API v3 Működési Modellje
A NAV Online Számla API v3.0 **nem REST/JSON**, hanem **SOAP-alapú XML** kommunikációt használ, amit modern HTTP(S) kérésekbe csomagoltak.
### 1.1. Kommunikációs Rétegek
```
FastAPI endpoint (organizations.py)
|
v
NavService (nav_service.py)
| XML body osszeallitas
| Kriptografiai alairasok szamitasa
| HTTP fejlecek beallitasa
v
httpx.AsyncClient POST
|
v
NAV API szerver (api-test.onlineszamla.nav.gov.hu)
|
v
XML valasz feldolgozasa (ElementTree)
```
### 1.2. Vegpont
```
POST https://api-test.onlineszamla.nav.gov.hu/invoiceService/v3/queryTaxpayer
```
---
## 2. Kriptografiai Reszletek (A Legkritikusabb Resz)
### 2.1. passwordHash (SHA-512)
A jelszo **nyers UTF-8** stringkent kerul a hash-be. Nincs semmilyen elofeldolgozas (salt, peppering).
### 2.2. requestSignature (SHA3-512) - KET KULONBOZO HELYEN
| Hely | Algoritmus | Input | Kulcs |
|------|-----------|-------|-------|
| **HTTP Header** `RequestSignature` | SHA3-512 | requestId + timestamp + NAV_API_EXCHANGE_KEY | Exchange Key |
| **XML body** `<user><requestSignature>` | SHA3-512 | requestId + timestamp + NAV_API_SIGN_KEY | Sign Key |
### 2.3. HTTP Fejlecek
- `Content-Type`: application/xml; charset=UTF-8
- `Accept`: application/xml
- `User`: NAV_API_USER
- `PasswordHash`: SHA-512(password) hex, UPPERCASE
- `ExchangeKey`: NAV_API_EXCHANGE_KEY
- `RequestId`: UUID (max 30 karakter, kotojelek nelkul)
- `Timestamp`: YYYY-MM-DDTHH:MM:SS.000Z
- `RequestSignature`: SHA3-512(requestId + timestamp + exchangeKey)
### 2.4. XML Body Szerkezet
```xml
<?xml version="1.0" encoding="UTF-8"?>
<QueryTaxpayerRequest xmlns="http://schemas.nav.gov.hu/OSA/3.0/api">
<header xmlns="http://schemas.nav.gov.hu/NTCA/1.0/common">
<requestId>{uuid_30_char}</requestId>
<timestamp>{UTC_ISO}</timestamp>
<requestVersion>3.0</requestVersion>
<headerVersion>1.0</headerVersion>
</header>
<user xmlns="http://schemas.nav.gov.hu/NTCA/1.0/common">
<login>{NAV_API_USER}</login>
<passwordHash cryptoType="SHA-512">{SHA512(password)}</passwordHash>
<taxNumber>{hivo_fel_adoszama_8_jegy}</taxNumber>
<requestSignature cryptoType="SHA3-512">{SHA3(requestId+timestamp+signKey)}</requestSignature>
</user>
<software>
<softwareId>SF-NAV-API-001-000</softwareId>
<softwareName>ServiceFinder</softwareName>
<softwareOperation>LOCAL_SOFTWARE</softwareOperation>
<softwareMainVersion>1.0</softwareMainVersion>
<softwareDevName>ServiceFinder Kft</softwareDevName>
<softwareDevContact>dev@sftest.hu</softwareDevContact>
<softwareDevCountryCode>HU</softwareDevCountryCode>
<softwareDevTaxNumber>12345678</softwareDevTaxNumber>
</software>
<taxNumber>{lekerdezendo_ceg_adoszama_8_jegy}</taxNumber>
</QueryTaxpayerRequest>
```
---
## 3. AZONOSITOTT HIBAK
### HIBA #1 (KRITIKUS): .env fajlban idezojelek a NAV ertekek korul
**Hely:** `.env` sorok 90-94
A `.env` fajlban a NAV ertekek korul idezojelek (aposztróf, dupla idezojel) vannak, amiket a Pydantic Settings literalisan olvas be.
```env
NAV_API_PASSWORD='Tevelegelek&99' <- aposztrofokkal egyutt! (12 helyett 14 kar.)
NAV_API_SIGN_KEY=02-b52b-3d9e7d638c695EPWNIUAEGHS <- OK
NAV_API_EXCHANGE_KEY=a5cf5EPWNIU9DP8O <- OK
NAV_API_CALLER_TAX_NUMBER="48952046133" <- idezojellel egyutt! (11 helyett 12 kar.)
```
**Kovetkezmenyek:**
1. `passwordHash = SHA-512("'Tevelegelek&99'")` -> **ROSSZ hash** (aposztrofokkal)
2. `caller_tax_number[:8] = '"4895204'` -> **ROSSZ adoszam** (az elso 8 karakterbe beleesik a ")
**Bizonyitas:**
```
SHA-512('Tevelegelek&99') -> 452506A930168C81DB06...
SHA-512(Tevelegelek&99) -> CC854A1E4F49B484396A...
^ NEM egyezik!
```
### HIBA #2 (MODSZERTANI): Nincs beepitett quote stripping a config-ban
**Hely:** `backend/app/core/config.py` (NAV_API_* mezo definiciok)
A Pydantic Settings nem strippeli automatikusan az idezojeleket.
### NEM HIBA: A kod logikaja helyes
A `nav_service.py` kod szerkezetileg megfelel a NAV API v3 specifikacionak:
- SHA-512 a passwordHash-hoz
- SHA3-512 a requestSignature-hoz
- Ket kulonbozo kulcs: exchangeKey (HTTP) vs signKey (XML)
- UUID max 30 karakter, timestamp UTC formaban
- Helyes XML namespace-ek (OSA/3.0/api es NTCA/1.0/common)
- Helyes HTTP hibakezeles (503 ha NAV nem elerheto, 404 ha nincs talalat)
---
## 4. JAVITASI TERV
### 4.1. .env fajl javitasa
Valtoztasd meg a kovetkezo sorokat:
```
# REGI (hibas):
NAV_API_PASSWORD='Tevelegelek&99'
NAV_API_CALLER_TAX_NUMBER="48952046133"
# UJ (helyes):
NAV_API_PASSWORD=Tevelegelek&99
NAV_API_CALLER_TAX_NUMBER=48952046133
```
### 4.2. Config quote stripping (vedohalo)
Add hozza a kovetkezo validatort `backend/app/core/config.py`-hoz a Settings osztalyba a NAV_API_* mezok utan:
```python
from pydantic import field_validator
# Add this validator for all NAV fields:
@field_validator('NAV_API_PASSWORD', 'NAV_API_SIGN_KEY', 'NAV_API_EXCHANGE_KEY', 'NAV_API_CALLER_TAX_NUMBER')
@classmethod
def strip_quotes(cls, v: str) -> str:
if v:
v = v.strip()
if len(v) >= 2 and v[0] == v[-1] and v[0] in ('"', "'"):
v = v[1:-1]
return v
```
### 4.3. Service biztonsagi javitas
`nav_service.py`-ban a `_build_query_taxpayer_xml()` modositas:
```python
target_tax_number_core = (
tax_number
.strip()
.replace("-", "")
.replace(" ", "")[:8]
)
```
### 4.4. Teszteles
A javitas utan:
```bash
# 1. Kontener ujrainditas
docker compose restart sf_api
# 2. Teszt lekerdezes
docker exec sf_api python3 -c "
from app.services.nav_service import NavService
import asyncio
result = asyncio.run(NavService.query_taxpayer('24742063'))
print(result)
"
```
Sikeres valasz formatuma:
```json
{
"full_name": "CEG NEVE KFT.",
"name": "CEG NEVE KFT.",
"display_name": "CEG NEVE KFT.",
"address_zip": "1234",
"address_city": "BUDAPEST",
"address_street_name": "Fo utca",
"address_street_type": "utca",
"address_house_number": "1"
}
```
---
## 5. Alternativak (Ha a NAV API tovabbra sem mukodik)
| Megoldas | Tipus | Elony | Hatrany |
|----------|-------|-------|---------|
| VIES (EU VAT) | SOAP/XML | Ingyenes, EU-s hivatalos | Csak EU-s adoszamok |
| OpenCorporates | REST/JSON | Konnyu integracio | Ingyenes verzio limitalt |
| Opten / Bisnode | Fizetos API | Teljes magyar cegadatok | Fizetos |
| ceginfo.hu | REST API | Magyar fokusz | Fizetos |
Ha tovabbra is `INVALID_REQUEST_SIGNATURE`, akkor a NAV portalon (https://onlineszamla.nav.gov.hu/) uj signKey/exchangeKey part kell igenyelni.

View File

@@ -0,0 +1,191 @@
# 📋 Person & User Profil Adatkezelés Audit (2026-06-06)
## 🎯 Cél
A Person és User entitások által nyilvántartott adatok összehasonlítása a frontend ProfileView által megjelenített és szerkeszthető mezőkkel.
---
## 📊 Adatbázis Modellek (Backend)
### 🧑 Person (identity.persons) Természetes Személy
**Séma:** `identity.persons`
| Mező | Típus | Nullable | Cél |
|------|-------|----------|-----|
| `id` | BigInteger | **NOT NULL** | Elsődleges kulcs |
| `id_uuid` | UUID | **NOT NULL** | Külső API-khoz UUID azonosító |
| `address_id` | UUID | NULL | FK → `system.addresses` |
| `identity_hash` | String(64) | NULL | Deduplikáció: Név+Anyja+DOB hash |
| **`last_name`** | String | **NOT NULL** | ✅ **Frontend használja** |
| **`first_name`** | String | **NOT NULL** | ✅ **Frontend használja** |
| **`phone`** | String | NULL | ✅ **Frontend használja** |
| **`mothers_last_name`** | String | NULL | ✅ **Frontend használja** |
| **`mothers_first_name`** | String | NULL | ✅ **Frontend használja** |
| **`birth_place`** | String | NULL | ✅ **Frontend használja** |
| **`birth_date`** | DateTime | NULL | ✅ **Frontend használja** |
| **`identity_docs`** | JSON | **NOT NULL** (default `{}`) | ✅ **Részben használva** (ID_CARD, LICENSE) |
| **`ice_contact`** | JSON | **NOT NULL** (default `{}`) | ❌ **NEM használja a frontend!** |
| `lifetime_xp` | BigInteger | **NOT NULL** (default -1) | ❌ **Gamification nem látható** |
| `penalty_points` | Integer | **NOT NULL** (default -1) | ❌ **Gamification nem látható** |
| `social_reputation` | Numeric(3,2) | **NOT NULL** | ❌ **Nem látható** |
| `is_sales_agent` | Boolean | **NOT NULL** | ⚙️ **Belső rendszer, nem látható** |
| `is_active` | Boolean | **NOT NULL** | ⚙️ **Belső rendszer** |
| `is_ghost` | Boolean | **NOT NULL** | ⚙️ **Soft-delete logika** |
| `merged_into_id` | BigInteger | NULL | FK (self) MDM merge logika |
### 👤 User (identity.users) Login Entitás
**Séma:** `identity.users`
| Mező | Típus | Nullable | Frontend látható? |
|------|-------|----------|-------------------|
| `id` | Integer | **NOT NULL** | |
| **`email`** | String | **NOT NULL** (unique) | ✅ **Readonly a profilban** |
| `hashed_password` | String | NULL | ✅ **Külön modal változtatáshoz** |
| **`role`** | UserRole ENUM | **NOT NULL** | ❌ **Nem látható** |
| `person_id` | BigInteger | NULL | FK |
| `subscription_plan` | String(30) | **NOT NULL** (default 'FREE') | ❌ **Nem látható** |
| `subscription_expires_at` | DateTime | NULL | ❌ **Nem látható** |
| `is_vip` | Boolean | **NOT NULL** | ❌ **Nem látható** |
| `referral_code` | String(20) | NULL (unique) | ❌ **Nem látható** |
| **`preferred_language`** | String(5) | **NOT NULL** (default "hu") | ❌ **Nem látható/szerkeszthető!** |
| **`region_code`** | String(5) | **NOT NULL** (default "HU") | ❌ **Nem látható!** |
| **`preferred_currency`** | String(3) | **NOT NULL** (default "HUF") | ❌ **Nem látható!** |
| **`ui_mode`** | String(20) | **NOT NULL** (default "personal") | ❌ **Nem látható!** |
| `alternative_emails` | JSON | **NOT NULL** (default `[]`) | ❌ **Nem látható** |
| `email_history` | JSON | **NOT NULL** (default `[]`) | ❌ **Nem látható** |
### 🏠 Address (system.addresses)
| Mező | Típus | Frontend használja? |
|------|-------|---------------------|
| **`street_name`** | String(200) | ✅ `address_street_name` |
| **`street_type`** | String(50) | ✅ `address_street_type` |
| **`house_number`** | String(50) | ✅ `address_house_number` |
| **`stairwell`** | String(20) | ✅ `address_stairwell` |
| **`floor`** | String(20) | ✅ `address_floor` |
| **`door`** | String(20) | ✅ `address_door` |
| **`parcel_id`** (HRSZ) | String(50) | ⚠️ **EditForm-ban van, de UI-on HIÁNYZIK!** |
| `.zip` (property) | String | ✅ `address_zip` |
| `.city` (property) | String | ✅ `address_city` |
---
## 🖥️ Frontend ProfileView.vue
### ✅ Megjelenített és Szerkeszthető Mezők
#### 1⃣ Fiók Adatok (User)
- `email`**Readonly**
- `password`**Külön modal**
#### 2⃣ Személyes Adatok (Person)
- `first_name`, `last_name`, `phone`
- `birth_place`, `birth_date`
- `mothers_last_name`, `mothers_first_name`
#### 3⃣ Okmányok (identity_docs JSON)
- **ID_CARD**: `number`, `expiry_date`
- **LICENSE**: `number`, `expiry_date`, `categories`
#### 4⃣ Lakcím (Address)
- `address_zip`, `address_city` (auto-fill)
- `address_street_name`, `address_street_type`, `address_house_number`
- `address_stairwell`, `address_floor`, `address_door`
- ⚠️ **`address_hrsz` NINCS UI-on!**
---
## 🚨 KRITIKUS HIÁNYOSSÁGOK
### 🔴 1. HRSZ (helyrajzi szám) mező hiányzik a UI-ból
**Probléma:** Az `editForm.address_hrsz` reactive változóban szerepel, a backend támogatja (`Address.parcel_id`), de **nincs input mező a template-ben**.
**Hatás:** Vidéki ingatlanok (telek, tanya) esetén a HRSZ kötelező adat lehet, ezt jelenleg nem lehet megadni.
### 🟠 2. Vészhelyzeti Kapcsolattartó (ICE Contact) hiányzik
**Probléma:** A `Person.ice_contact` JSON mező az adatbázisban létezik (default `{}`), de **sehol sem szerkeszthető**.
**Hatás:** Balesetnél vagy vészhelyzetben nem tud a rendszer értesítést küldeni.
**Javasolt struktúra:**
```json
{
"name": "Nagy János",
"relationship": "testvér",
"phone": "+36 30 123 4567",
"email": "janos.nagy@example.com"
}
```
### 🟡 3. User Preferenciák nem szerkeszthetők
**Probléma:** Az alábbi User-szintű beállítások NEM láthatók/módosíthatók:
- `preferred_language` (jelenleg "hu" fix)
- `region_code` (jelenleg "HU" fix)
- `preferred_currency` (jelenleg "HUF" fix)
- `ui_mode` (personal/fleet váltás)
**Hatás:**
- A nyelvet a LanguageSwitcher komponens kezeli, de nem kerül mentésre a User táblába
- A többdevizás költségkövetés (EUR, USD) nem működik
- A "personal" vs "fleet" mód nem váltható
### 🟢 4. Passport (Útlevél) és Tax Number (Adószám) hiányzik
**Javasolt bővítés (identity_docs JSON):**
```json
{
"PASSPORT": { "number": "...", "expiry_date": "...", "issuing_country": "HU" },
"TAX_ID": { "number": "...", "country": "HU" }
}
```
### 🔵 5. Gamification és Trust Score adatok nem láthatók
**Probléma:** A felhasználó **nem láthatja a saját** statisztikáit:
- `Person.lifetime_xp`, `penalty_points`, `social_reputation`
- `UserTrustProfile.trust_score` (Gondos Gazda Index)
**Hatás:** A gamification funkciók nem motiválják a felhasználót.
**Megoldás:** A backend már támogatja: `GET /users/me/trust`
### 🟣 6. Subscription (Előfizetés) információk elrejtve
**Probléma:** `subscription_plan`, `subscription_expires_at`, `is_vip` **sehol sem látszódnak**.
**Hatás:** A felhasználó nem tudja, meddig érvényes a PRO csomagja.
---
## 📝 ÖSSZEFOGLALÓ TÁBLÁZAT
| Kategória | Backend (DB) | Frontend (UI) | Státusz |
|-----------|--------------|---------------|---------|
| **Person főbb adatok** | ✅ Teljes | ✅ Teljes | 🟢 **SZINKRONBAN** |
| **Identity Docs (ID, License)** | ✅ JSON | ✅ Szerkeszthető | 🟢 **SZINKRONBAN** |
| **Lakcím alapadatok** | ✅ 8 mező | ✅ 8 mező | 🟢 **SZINKRONBAN** |
| **HRSZ (parcel_id)** | ✅ Létezik | ❌ **Hiányzik** | 🔴 **GAP!** |
| **ICE Contact** | ✅ JSON | ❌ **Hiányzik** | 🔴 **GAP!** |
| **User preferenciák** | ✅ 4 mező | ❌ **Nem szerkeszthető** | 🟠 **GAP!** |
| **Passport, Tax ID** | ⚠️ JSON-ban lehetne | ❌ **Hiányzik** | 🟡 **Fejlesztendő** |
| **Gamification** | ✅ Létezik | ❌ **Nem látható** | 🟡 **Fejlesztendő** |
| **Subscription** | ✅ Létezik | ❌ **Nem látható** | 🟡 **Fejlesztendő** |
---
## 🎯 KÖVETKEZŐ LÉPÉSEK
### 🔴 Kritikus (Immediate)
1. **HRSZ mező hozzáadása** a ProfileView.vue-hoz (~10 perc)
2. **PersonUpdate schema audit**: Megkeresni a sémát
### 🟠 Fontos (Short-term)
3. **ICE Contact szerkesztő** (~1 óra)
4. **User Preferences** szerkeszthetővé tétele (~2 óra)
### 🟡 Hasznos (Mid-term)
5. **Passport és Tax ID** (~1 óra)
6. **Gamification/Trust Score megjelenítés** (~30 perc)
7. **Subscription info megjelenítése** (~20 perc)
---
**Dokumentum:** 2026-06-06 | Service Finder System Audit

View File

@@ -9,7 +9,9 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0", "@fingerprintjs/fingerprintjs": "^5.2.0",
"@lucide/vue": "^1.17.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"flag-icons": "^7.5.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.11.0", "vue-i18n": "^9.11.0",
@@ -717,6 +719,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lucide/vue": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.17.0.tgz",
"integrity": "sha512-6Q1ZHgr5FbmJzKWe5BxlNdjLj2lbmuH1zwDtVzUJofX0w9UREwKgq4F4jwKqFYyyIS4Rj3FiJvDi2k6djukmmw==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2555,6 +2566,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",

View File

@@ -10,7 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0", "@fingerprintjs/fingerprintjs": "^5.2.0",
"@lucide/vue": "^1.17.0",
"axios": "^1.6.0", "axios": "^1.6.0",
"flag-icons": "^7.5.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-i18n": "^9.11.0", "vue-i18n": "^9.11.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 MiB

View File

@@ -1,301 +0,0 @@
<template>
<header
class="sticky top-0 z-50 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
>
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<!-- Left: Logo + Brand (clickable, links to /) -->
<router-link to="/dashboard" class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]">
<!-- Real brand logo with premium gradient -->
<img
src="@/assets/sf-brand-logo.svg"
class="h-10 w-auto mr-3 drop-shadow-md"
alt="ServiceFinder Logo"
/>
<!-- Brand text (hidden on very small screens) -->
<div class="hidden sm:block">
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
</div>
</router-link>
<!-- Center/Left: Funkciók Menu -->
<div class="relative flex items-center func-menu-container">
<!-- Funkciók trigger button -->
<button
@click.stop="isFuncMenuOpen = !isFuncMenuOpen"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
:aria-label="t('menu.features')"
>
<!-- Hamburger / grid icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="hidden sm:inline">{{ t('menu.features') }}</span>
</button>
<!-- Funkciók Dropdown (Glassmorphism Bento) -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isFuncMenuOpen"
class="absolute left-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<div class="py-2">
<!-- Járművek (Garázs) -->
<button
@click="isFuncMenuOpen = false"
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
<span>{{ t('menu.garage') }} <span class="text-white/40">({{ t('menu.garage') }})</span></span>
</button>
<!-- Szerviznaptár -->
<button
@click="isFuncMenuOpen = false"
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ t('menu.calendar') }}</span>
</button>
<!-- Pénzügyek és Költségek -->
<button
@click="isFuncMenuOpen = false"
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{{ t('menu.finance') }}</span>
</button>
<!-- Élő Diagnosztika -->
<button
@click="isFuncMenuOpen = false"
class="flex w-full items-center gap-3 px-4 py-3 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-5 h-5 text-[#418890]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>{{ t('menu.diagnostic') }}</span>
</button>
</div>
<!-- Divider -->
<hr class="border-white/10 my-1" />
<!-- Kiemelt: + Új Jármű Hozzáadása -->
<div class="py-2 px-3">
<button
@click="isFuncMenuOpen = false"
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-[#418890] to-[#70BC84] px-4 py-3 text-sm font-bold text-white shadow-lg transition-all duration-200 hover:shadow-[0_0_20px_rgba(0,229,160,0.4)] hover:-translate-y-0.5"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{{ t('menu.addVehicle') }}
</button>
</div>
</div>
</Transition>
</div>
<!-- Spacer to push right items -->
<div class="flex-1"></div>
<!-- Language Switcher (extracted component) -->
<LanguageSwitcher />
<!-- Right: Welcome + User Dropdown -->
<div class="relative flex items-center gap-4">
<!-- Welcome text -->
<div class="text-right">
<p class="text-sm font-medium text-white/90">
{{ t('header.welcome') }}, <span class="font-semibold text-[#70BC84]">{{ displayName }}</span>!
</p>
<p v-if="subtitle" class="text-[11px] text-white/40">{{ subtitle }}</p>
</div>
<!-- Avatar button click to toggle dropdown -->
<button
@click="isUserMenuOpen = !isUserMenuOpen"
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
:aria-label="t('header.userMenu')"
>
{{ initials }}
</button>
<!-- Dropdown Menu (Glassmorphism) -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isUserMenuOpen"
class="absolute right-0 top-full mt-2 w-56 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- User info header -->
<div class="px-4 py-3 border-b border-white/5">
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
</div>
<!-- Menu items -->
<div class="py-1">
<!-- Profile settings -->
<button
@click="goToProfile"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<!-- User icon -->
<svg
class="h-4 w-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{{ t('header.profile') }}
</button>
</div>
<!-- Divider -->
<hr class="border-white/5 my-1" />
<!-- Logout -->
<div class="py-1">
<button
@click="handleLogout"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
>
<!-- Logout icon -->
<svg
class="h-4 w-4 text-white/40 group-hover:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{{ t('header.logout') }}
</button>
</div>
</div>
</Transition>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const { t } = useI18n()
const props = withDefaults(
defineProps<{
firstName?: string
subtitle?: string
}>(),
{
firstName: '',
subtitle: 'Irányítópult',
},
)
const authStore = useAuthStore()
// ── Dropdown states ────────────────────────────────────────────────
const isUserMenuOpen = ref(false)
const isFuncMenuOpen = ref(false)
// LanguageSwitcher handles its own state internally
// ── Close dropdowns on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (isFuncMenuOpen.value && !target.closest('.func-menu-container')) {
isFuncMenuOpen.value = false
}
}
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
// ── Computed display helpers ───────────────────────────────────────
const displayName = computed(() => {
if (props.firstName && props.firstName !== 'Vendég') return props.firstName
// Fallback: use email prefix (before @)
if (authStore.user?.email) {
const emailPrefix = authStore.user.email.split('@')[0]
return emailPrefix || 'Felhasználó'
}
return 'Felhasználó'
})
const fullName = computed(() => {
if (!authStore.user) return 'Felhasználó'
const { first_name, last_name } = authStore.user
if (first_name && last_name) return `${first_name} ${last_name}`
if (first_name) return first_name
if (last_name) return last_name
return authStore.user.email || 'Felhasználó'
})
const email = computed(() => {
return authStore.user?.email || ''
})
const initials = computed(() => {
if (!authStore.user) return '?'
const { first_name, last_name } = authStore.user
if (first_name && last_name) {
return (first_name[0] + last_name[0]).toUpperCase()
}
if (first_name) return first_name[0].toUpperCase()
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
return '?'
})
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isUserMenuOpen.value = false
router.push('/profile')
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isUserMenuOpen.value = false
authStore.logout()
}
</script>

View File

@@ -5,8 +5,8 @@
class="flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer" class="flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm font-medium text-white/70 transition-all duration-200 hover:bg-white/5 hover:text-white cursor-pointer"
:title="t('header.switchLanguage')" :title="t('header.switchLanguage')"
> >
<span class="text-base">{{ currentLocale === 'hu' ? '🇭🇺' : '🇬🇧' }}</span> <span :class="['fi', `fi-${currentFlagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
<span class="hidden sm:inline font-semibold">{{ currentLocale === 'hu' ? 'HU' : 'EN' }}</span> <span class="hidden sm:inline font-semibold uppercase">{{ currentLocale }}</span>
<!-- Chevron down --> <!-- Chevron down -->
<svg <svg
class="w-3.5 h-3.5 ml-0.5 text-white/40 transition-transform duration-200" class="w-3.5 h-3.5 ml-0.5 text-white/40 transition-transform duration-200"
@@ -30,33 +30,20 @@
> >
<div <div
v-if="isOpen" v-if="isOpen"
class="absolute left-0 top-full mt-2 w-40 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden" class="absolute left-0 top-full mt-2 w-48 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
> >
<div class="py-1"> <div class="py-1">
<!-- Magyar -->
<button <button
@click="setLanguage('hu')" v-for="lang in languages"
:key="lang.code"
@click="setLanguage(lang.code)"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white" class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'hu' }" :class="{ 'text-[#70BC84] font-semibold': currentLocale === lang.code }"
> >
<span class="text-base">🇭🇺</span> <span :class="['fi', `fi-${lang.flagCode}`, 'fis', 'rounded-sm', 'shadow-sm']"></span>
<span>Magyar</span> <span class="uppercase font-medium">{{ lang.code }}</span>
<span v-if="currentLocale === 'hu'" class="ml-auto text-[#70BC84]"> <span class="text-white/50">{{ lang.label }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span v-if="currentLocale === lang.code" class="ml-auto text-[#70BC84]">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</span>
</button>
<!-- English -->
<button
@click="setLanguage('en')"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
:class="{ 'text-[#70BC84] font-semibold': currentLocale === 'en' }"
>
<span class="text-base">🇬🇧</span>
<span>English</span>
<span v-if="currentLocale === 'en'" class="ml-auto text-[#70BC84]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
@@ -77,6 +64,26 @@ const { t, locale } = useI18n()
const isOpen = ref(false) const isOpen = ref(false)
const currentLocale = computed(() => locale.value) const currentLocale = computed(() => locale.value)
interface Language {
code: string
flagCode: string
label: string
}
const languages: Language[] = [
{ code: 'hu', flagCode: 'hu', label: 'Magyar' },
{ code: 'en', flagCode: 'gb', label: 'English' },
{ code: 'de', flagCode: 'de', label: 'Deutsch' },
{ code: 'ro', flagCode: 'ro', label: 'Română' },
{ code: 'cz', flagCode: 'cz', label: 'Čeština' },
{ code: 'sk', flagCode: 'sk', label: 'Slovenčina' },
]
const currentFlagCode = computed(() => {
const lang = languages.find(l => l.code === currentLocale.value)
return lang ? lang.flagCode : 'gb'
})
function setLanguage(lang: string) { function setLanguage(lang: string) {
locale.value = lang locale.value = lang
localStorage.setItem('user-locale', lang) localStorage.setItem('user-locale', lang)

View File

@@ -0,0 +1,63 @@
<template>
<button
@click="handleClick"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
:class="btnClass"
:aria-label="ariaLabel || label"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span class="hidden sm:inline">{{ label }}</span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
const router = useRouter()
const { t } = useI18n()
const props = withDefaults(
defineProps<{
/** Route to navigate to on click */
to?: string
/** Button label text */
label?: string
/** Optional aria-label override */
ariaLabel?: string
/** Visual variant */
variant?: 'default' | 'warning'
/** Optional click handler override (prevents navigation if provided) */
onClick?: () => void
}>(),
{
to: '/dashboard',
label: '',
ariaLabel: '',
variant: 'default',
onClick: undefined,
},
)
const btnClass = computed(() => {
if (props.variant === 'warning') {
return 'text-yellow-400/80 hover:bg-white/5 hover:text-yellow-300'
}
// Default variant uses dynamic theme colors (white-labeling support)
// bg-themePrimary picks up the --theme-primary CSS variable set by the theme store
return 'bg-themePrimary text-white hover:opacity-90'
})
function handleClick() {
if (props.onClick) {
props.onClick()
return
}
if (props.to) {
router.push(props.to)
}
}
</script>

View File

@@ -0,0 +1,224 @@
<template>
<div class="relative org-dropdown-container">
<button
v-if="authStore.isAuthenticated"
@click.stop="isOpen = !isOpen"
:disabled="authStore.isLoading"
class="flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition-all duration-200 cursor-pointer"
:class="orgButtonClass"
>
<!-- Loading spinner -->
<svg
v-if="authStore.isLoading"
class="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<!-- Icon -->
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path v-if="orgButtonState === 'create'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span>{{ orgButtonLabel }}</span>
<!-- Chevron down when there are organizations -->
<svg
v-if="authStore.myOrganizations.length > 0 && orgButtonState === 'switch'"
class="w-3 h-3 transition-transform"
:class="{ 'rotate-180': isOpen }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Organization Dropdown -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isOpen"
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- When on Company Garage: show "👤 Privát Garázs" back button -->
<div v-if="isOnCompanyGarage" class="py-1 border-b border-white/10">
<button
@click="goToPersonalDashboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-gray-200 transition-all duration-150 hover:bg-white/10 hover:text-white"
>
<svg class="w-4 h-4 text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>👤 {{ t('header.privateGarage') }}</span>
</button>
</div>
<!-- Organization list -->
<div v-if="filteredOrganizations.length > 0" class="py-1 max-h-60 overflow-y-auto">
<router-link
v-for="org in filteredOrganizations"
:key="org.organization_id"
:to="'/organization/' + org.organization_id"
@click="isOpen = false"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-all duration-150 hover:bg-white/5"
:class="isActiveOrg(org) ? 'text-[#00E5A0] font-semibold' : 'text-white/80 hover:text-white'"
>
<!-- Org icon -->
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span class="truncate">{{ org.display_name || org.name || `${t('header.companyPrefix')} #${org.organization_id}` }}</span>
<!-- Active checkmark -->
<svg
v-if="isActiveOrg(org)"
class="w-4 h-4 ml-auto text-[#00E5A0] flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</router-link>
</div>
<!-- Empty state when no orgs exist -->
<div v-if="authStore.myOrganizations.length === 0" class="py-4 px-4 text-center text-white/50 text-sm">
{{ t('header.noCompanies') }}
</div>
<!-- Divider before "Create new" -->
<hr class="border-gray-700 my-1" />
<!-- Create new organization -->
<div class="py-1">
<button
@click="goToOnboard"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-[#00E5A0]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span>{{ t('header.newCompany') }}</span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import type { OrganizationItem } from '@/types/organization'
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const authStore = useAuthStore()
const isOpen = ref(false)
// ── Debug: log organizations on mount ───────────────────────────────
onMounted(async () => {
// Ensure organizations are loaded (safety net if init() didn't run)
if (authStore.myOrganizations.length === 0 && authStore.isAuthenticated) {
await authStore.fetchMyOrganizations()
}
console.log('🔍 [HeaderCompanySwitcher] Available companies:', authStore.myOrganizations)
})
// ── Route-based detection ─────────────────────────────────────────
const isOnCompanyGarage = computed(() => {
return route.path.startsWith('/organization/')
})
// ── Current org ID from route (when on company garage) ────────────
const currentOrgId = computed(() => {
if (!isOnCompanyGarage.value) return null
return Number(route.params.id) || null
})
// ── Filtered organizations: exclude the current one when on garage ─
const filteredOrganizations = computed(() => {
const orgs = authStore.myOrganizations
if (isOnCompanyGarage.value && currentOrgId.value) {
return orgs.filter((o) => o.organization_id !== currentOrgId.value)
}
return orgs
})
// ── Organization Button State ──────────────────────────────────────
const orgButtonState = computed(() => {
if (!authStore.isAuthenticated) return 'create'
if (isOnCompanyGarage.value) return 'on-garage'
if (authStore.isCorporateMode) return 'corporate'
if (authStore.hasBusinessOrganization) return 'switch'
return 'create'
})
const orgButtonLabel = computed(() => {
switch (orgButtonState.value) {
case 'create':
return t('header.newCompany')
case 'switch': {
const count = authStore.myOrganizations.length
if (count === 1) return t('header.myCompany')
return t('header.myCompanies')
}
case 'corporate':
return t('header.personalGarage')
case 'on-garage':
return t('header.privateGarage')
default:
return t('header.company')
}
})
const orgButtonClass = computed(() => {
if (orgButtonState.value === 'corporate') {
return 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/20 hover:bg-yellow-500/25 hover:text-yellow-300'
}
return 'text-white/70 hover:bg-white/5 hover:text-white'
})
// ── Check if org is the active one ─────────────────────────────────
function isActiveOrg(org: OrganizationItem): boolean {
return authStore.user?.active_organization_id === org.organization_id
}
// ── Navigate to personal dashboard ────────────────────────────────
async function goToPersonalDashboard() {
isOpen.value = false
await authStore.switchOrganization(null)
router.push('/dashboard')
}
// ── Navigate to onboard ───────────────────────────────────────────
function goToOnboard() {
isOpen.value = false
router.push('/company/onboard')
}
// ── Close dropdown on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (isOpen.value && !target.closest('.org-dropdown-container')) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
</script>

View File

@@ -0,0 +1,39 @@
<template>
<router-link
:to="targetRoute"
class="flex items-center gap-3 cursor-pointer transition-all duration-300 hover:opacity-80 hover:scale-[1.02]"
:aria-label="t('header.home')"
>
<img
src="@/assets/sf-brand-logo.svg"
class="h-10 w-auto mr-3 drop-shadow-md"
alt="ServiceFinder Logo"
/>
<div class="hidden sm:block">
<span class="text-base font-black tracking-widest text-white">SERVICE</span>
<span class="text-base font-black tracking-widest text-[#418890]">FINDER</span>
</div>
</router-link>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const route = useRoute()
/**
* Compute the target route for the logo link.
* - If the user is on an /organization/:id page, navigate to the org root.
* - Otherwise, navigate to /dashboard (private garage).
*/
const targetRoute = computed(() => {
if (route.path.startsWith('/organization/')) {
const orgId = route.params.id
return orgId ? `/organization/${orgId}` : route.path
}
return '/dashboard'
})
</script>

View File

@@ -0,0 +1,145 @@
<template>
<div ref="containerRef" class="relative flex items-center">
<!-- Avatar button click to toggle dropdown -->
<button
@click="isOpen = !isOpen"
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#70BC84]/20 text-sm font-bold text-[#70BC84] ring-2 ring-white/10 transition-all duration-200 hover:ring-[#70BC84]/50 focus:outline-none focus:ring-[#70BC84]/50 cursor-pointer"
:aria-label="t('header.userMenu')"
>
{{ initials }}
</button>
<!-- Dropdown Menu (Glassmorphism) -->
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="scale-95 opacity-0 -translate-y-2"
enter-to-class="scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-100 ease-in"
leave-from-class="scale-100 opacity-100 translate-y-0"
leave-to-class="scale-95 opacity-0 -translate-y-2"
>
<div
v-if="isOpen"
class="absolute right-0 top-full mt-2 w-64 z-50 rounded-xl border border-white/10 bg-[#04151F]/90 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
>
<!-- User info header -->
<div class="px-4 py-3 border-b border-white/5">
<p class="text-sm font-semibold text-white/90 truncate">{{ fullName }}</p>
<p class="text-xs text-white/40 truncate mt-0.5">{{ email }}</p>
</div>
<!-- Menu items -->
<div class="py-1">
<!-- Profile settings -->
<button
@click="goToProfile"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg
class="h-4 w-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{{ t('header.profile') }}
</button>
</div>
<!-- Divider -->
<hr class="border-white/5 my-1" />
<!-- Logout -->
<div class="py-1">
<button
@click="handleLogout"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/70 transition-all duration-150 hover:bg-white/5 hover:text-red-400"
>
<svg
class="h-4 w-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{{ t('header.logout') }}
</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
// ── Computed display helpers ───────────────────────────────────────
const fullName = computed(() => {
if (!authStore.user) return t('header.user')
const { first_name, last_name } = authStore.user
if (first_name && last_name) return `${first_name} ${last_name}`
if (first_name) return first_name
if (last_name) return last_name
return authStore.user.email || t('header.user')
})
const email = computed(() => {
return authStore.user?.email || ''
})
const initials = computed(() => {
if (!authStore.user) return '?'
const { first_name, last_name } = authStore.user
if (first_name && last_name) {
return (first_name[0] + last_name[0]).toUpperCase()
}
if (first_name) return first_name[0].toUpperCase()
if (authStore.user.email) return authStore.user.email[0].toUpperCase()
return '?'
})
// ── Navigate to Profile ────────────────────────────────────────────
function goToProfile() {
isOpen.value = false
router.push('/profile')
}
// ── Logout handler ─────────────────────────────────────────────────
function handleLogout() {
isOpen.value = false
authStore.logout()
}
// ── Close dropdown on outside click ───────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (isOpen.value && containerRef.value && !containerRef.value.contains(target)) {
isOpen.value = false
}
}
onMounted(() => document.addEventListener('mousedown', handleOutsideClick))
onUnmounted(() => document.removeEventListener('mousedown', handleOutsideClick))
</script>

View File

@@ -0,0 +1,48 @@
<template>
<header
class="sticky top-0 z-50 w-full border-b border-white/[0.06] bg-[#04151F]/80 backdrop-blur-2xl"
>
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<!-- Left Slot -->
<div class="flex items-center gap-2 min-w-0">
<slot name="left" />
</div>
<!-- Center Slot -->
<div class="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
<slot name="center" />
<!-- Teleport target for dynamic header content -->
<div id="header-teleport-target" class="flex items-center gap-2"></div>
</div>
<!-- Spacer (pushes right items when center is empty) -->
<div class="flex-1"></div>
<!-- Right Slot -->
<div class="flex items-center gap-2">
<slot name="right" />
</div>
</div>
</header>
</template>
<script setup lang="ts">
/**
* BaseHeader.vue — A "dumb" glassmorphism header frame.
*
* Provides three named slots:
* - #left : Logo, back button, welcome text
* - #center : Context label, teleport target for dynamic content
* - #right : Language switcher, profile avatar, org switcher
*
* The teleport target div (#header-teleport-target) allows child components
* to teleport content into the header center area from anywhere in the DOM.
*
* Usage:
* <BaseHeader>
* <template #left>...</template>
* <template #center>...</template>
* <template #right>...</template>
* </BaseHeader>
*/
</script>

View File

@@ -0,0 +1,174 @@
<template>
<div class="address-display flex items-start gap-2">
<!-- MapPin icon (Lucide) -->
<svg
class="mt-0.5 h-4 w-4 flex-shrink-0 text-[#418890]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
<!-- Address text -->
<span class="text-sm break-words leading-relaxed" :class="textClass">
{{ formattedAddress }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
/**
* AddressDisplay — intelligently formats and displays a Hungarian-style address.
*
* Accepts address fields either as individual props or as a single `address` object.
* Filters out null/undefined/empty values, prevents double spaces,
* and handles missing house numbers gracefully.
*
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
* Example: 2120 Dunakeszi, Határ út 8.
*/
interface AddressObject {
zip?: string | null
city?: string | null
street_name?: string | null
street_type?: string | null
house_number?: string | null
stairwell?: string | null
floor?: string | null
door?: string | null
parcel_id?: string | null
hrsz?: string | null
}
const props = withDefaults(
defineProps<{
/** Address fields as a single object (alternative to individual props) */
address?: AddressObject | null
/** Individual address fields (take precedence over address object) */
zip?: string | null
city?: string | null
street_name?: string | null
street_type?: string | null
house_number?: string | null
stairwell?: string | null
floor?: string | null
door?: string | null
parcel_id?: string | null
hrsz?: string | null
/** Optional CSS class for the text span */
textClass?: string
/** Show detailed address (including stairwell/floor/door) */
detailed?: boolean
}>(),
{
address: null,
zip: null,
city: null,
street_name: null,
street_type: null,
house_number: null,
stairwell: null,
floor: null,
door: null,
parcel_id: null,
hrsz: null,
textClass: 'text-white/80',
detailed: false,
}
)
/**
* Resolve the effective address values.
* Individual props take precedence over the address object.
*/
function resolveField(field: keyof AddressObject): string | null {
const propValue = (props as any)[field]
if (propValue !== null && propValue !== undefined && propValue !== '') {
return propValue
}
if (props.address) {
const addrValue = props.address[field]
if (addrValue !== null && addrValue !== undefined && addrValue !== '') {
return addrValue
}
}
return null
}
/**
* Build the formatted address string.
* Hungarian format: [ZIP] [City], [Street name] [Street type] [House number]
* Intelligently filters out null/undefined/empty values and prevents double spaces.
*/
const formattedAddress = computed(() => {
const zip = resolveField('zip')
const city = resolveField('city')
const streetName = resolveField('street_name')
const streetType = resolveField('street_type')
const houseNumber = resolveField('house_number')
// Build the primary address line
const parts: string[] = []
// ZIP + City (always together if city exists)
if (zip || city) {
const location = [zip, city].filter(Boolean).join(' ')
parts.push(location)
}
// Street details
const streetParts: string[] = []
if (streetName) streetParts.push(streetName)
if (streetType) streetParts.push(streetType)
if (houseNumber) streetParts.push(houseNumber)
if (streetParts.length > 0) {
// If we already have a location part, add street with a comma separator
if (parts.length > 0) {
parts[parts.length - 1] += ','
}
parts.push(streetParts.join(' '))
}
// Join all parts, then normalize whitespace (remove double spaces)
let result = parts.join(' ').replace(/\s+/g, ' ').trim()
// If no address data at all, show a placeholder
if (!result) {
result = '—'
}
return result
})
/**
* Build the detailed address string (includes stairwell/floor/door).
*/
const { t } = useI18n()
const detailedAddress = computed(() => {
if (!props.detailed) return ''
const stairwell = resolveField('stairwell')
const floor = resolveField('floor')
const door = resolveField('door')
const hrsz = resolveField('hrsz') || resolveField('parcel_id')
const detailParts: string[] = []
if (stairwell) detailParts.push(t('address.stairwell', { value: stairwell }))
if (floor) detailParts.push(t('address.floor', { value: floor }))
if (door) detailParts.push(t('address.door', { value: door }))
if (hrsz) detailParts.push(t('address.hrsz', { value: hrsz }))
return detailParts.join(', ')
})
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div
class="glass-card relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.05] backdrop-blur-xl shadow-xl shadow-black/20 transition-all duration-300"
:class="[
variant === 'premium'
? 'premium-gradient-border hover:shadow-2xl hover:shadow-[#00E5A0]/10'
: 'hover:border-white/[0.15] hover:bg-white/[0.08] hover:shadow-2xl hover:shadow-black/30',
hoverable ? 'hover:-translate-y-1 cursor-pointer' : '',
customClass,
]"
>
<!-- Premium gradient border overlay (only for premium variant) -->
<div
v-if="variant === 'premium'"
class="pointer-events-none absolute inset-0 rounded-2xl p-[1px]"
>
<div class="h-full w-full rounded-2xl bg-gradient-to-br from-[#00E5A0]/40 via-[#418890]/20 to-transparent" />
</div>
<!-- Content slot -->
<div class="relative z-10" :class="paddingClass">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** Visual variant: 'standard' (default) or 'premium' (with gradient border) */
variant?: 'standard' | 'premium'
/** Enable hover lift effect (translate-y and stronger shadow) */
hoverable?: boolean
/** Custom padding: 'sm', 'md' (default), 'lg', or 'none' */
padding?: 'sm' | 'md' | 'lg' | 'none'
/** Additional CSS classes to merge */
customClass?: string
}>(),
{
variant: 'standard',
hoverable: false,
padding: 'md',
customClass: '',
}
)
const paddingClass = computed(() => {
switch (props.padding) {
case 'sm': return 'p-4'
case 'lg': return 'p-8'
case 'none': return ''
default: return 'p-6'
}
})
</script>
<style scoped>
/* ── Premium gradient border using pseudo-element ── */
.glass-card.premium-gradient-border {
position: relative;
border: none;
}
.glass-card.premium-gradient-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: 1rem; /* matches rounded-2xl */
padding: 1px;
background: linear-gradient(135deg, rgba(0, 229, 160, 0.5), rgba(65, 136, 144, 0.3), transparent);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
</style>

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,13 @@ export default {
'sf-dark-lighter': '#0a2a40', 'sf-dark-lighter': '#0a2a40',
'sf-dark-card': 'rgba(255,255,255,0.04)', 'sf-dark-card': 'rgba(255,255,255,0.04)',
'sf-dark-border': 'rgba(255,255,255,0.10)', 'sf-dark-border': 'rgba(255,255,255,0.10)',
'sf-dark-glass': 'rgba(255,255,255,0.05)' 'sf-dark-glass': 'rgba(255,255,255,0.05)',
// ── Dynamic theme (White-labeling) ──────────────────────────
// These reference CSS custom properties set by the theme store.
// Fallback values are used when no custom theme is active.
'theme-primary': 'var(--theme-primary, #10b981)',
'theme-secondary': 'var(--theme-secondary, #047857)',
'theme-bg': 'var(--theme-bg, #0f172a)',
}, },
fontFamily: { fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'], sans: ['Inter', 'system-ui', 'sans-serif'],

View File

@@ -0,0 +1,139 @@
# 📐 Logic Spec: Dashboard Header Restructure
## 1. Cél és Masterbook 2 illeszkedés
**Cél:** A privát garázs dashboard fejlécének (`DashboardHeader.vue`) átalakítása a felhasználó specifikációja szerint:
- **Bal:** 👋 Üdvözlés + keresztnév
- **Közép:** `{firstName}_garázsa` (i18n kulcsból)
- **Jobb:** Cégváltó dropdown → Nyelvváltó → Avatár menü (sorrendben)
- **Layout struktúra:** Marad a `PrivateLayout` + `DashboardHeader` felosztás
**Masterbook 2.0 illeszkedés:** A feladat illeszkedik az Epic 11 (Public Frontend) és a B2C dashboard UX specifikációjához.
---
## 2. Jelenlegi Problémák (`DashboardHeader.vue` - 685 sor)
| Probléma | Leírás |
|----------|--------|
| P1 - Túl komplex | 7 funkció egy komponensben (WelcomeCard, Logo, Funkciók menu, Center label, Org dropdown, LanguageSwitcher, Avatar) |
| P2 - Welcome Card | Lebegő elem, nem a header része - megtartható, de auditálni kell |
| P3 - Funkciók menu | Nem illik a header-be, 100+ sor Bento dropdown - **ELTÁVOLÍTANDÓ** |
| P4 - Logo + Brand | 2 sor + kép - **ELTÁVOLÍTANDÓ** a header-ből |
| P5 - Center label | Duplikált logika a jobb oldali org dropdown-nal |
| P6 - Jobb oldal sorrend | Jelenleg: Org dropdown → LanguageSwitcher → Avatar. **Megtartandó sorrendben** |
---
## 3. Új 3-Oszlopos Header Layout
```
┌─────────────────────────────────────────────────────────────────────────┐
│ [BAL] [KÖZÉP] [JOBB] │
│ 👋 Üdv, János! János_garázsa [🏢 Cégem ▼] [🌐] [👤] │
│ ↓ ↓ ↓ │
│ Org Dropdown Lang Avatar│
│ (cég listával) Switcher│
└─────────────────────────────────────────────────────────────────────────┘
```
### 3.1 Bal oldal: Welcome + keresztnév
```vue
<div class="flex items-center gap-2">
<span class="text-lg">👋</span>
<span class="text-sm font-medium text-white/80">
{{ t('header.welcome') }}, <span class="font-semibold text-white">{{ displayName }}</span>!
</span>
</div>
```
**`displayName` computed** - már létezik, elsődlegesen `props.firstName`, fallback email prefix.
### 3.2 Középső: `{firstName}_garázsa`
```vue
<div class="absolute left-1/2 -translate-x-1/2">
<span class="text-sm font-semibold text-white/70 tracking-wide">
{{ centerLabel }}
</span>
</div>
```
**`centerLabel` computed logika:**
- Ha `isOnCompanyGarage` (szervezeti nézetben): `t('header.companyGarageLabel', { name: activeCompanyName })`
- Ha `isCorporateMode` (vállalati módban): `t('header.companyGarageLabel', { name: activeCompanyName })`
- Különben (személyes mód): `t('header.personalGarageLabel', { name: firstName })`
### 3.3 Jobb oldal (sorrendben: Cég → Nyelv → Avatár)
**Cég dropdown** - Meglévő funkció tisztítva:
- Megtartani: org-dropdown-container (dropdown cég listával)
- Eltávolítani: duplikált "Funkciók menüben lévő Organization Menu Item"
- Állapotok: nincs cég → " Új cég" (navigáció `/company/onboard`); van cég → "🏢 Cégem" (dropdown); corporate mód → "👤 Személyes Garázs" (visszaváltás)
**Nyelvváltó** - Változatlan, 6 nyelv támogatása.
**Avatár menü** - Változatlan (Profile, Logout).
---
## 4. Eltávolítandó elemek
| Elem | Sorok | Hova kerül? |
|------|-------|-------------|
| Logo + Brand | 30-43 | ELTÁVOLÍTVA |
| Funkciók menu (Bento) | 46-154 | ELTÁVOLÍTVA, később `DashboardView.vue`-ba |
| Funkciók-belüli Org Menu Item | 119-135 | ELTÁVOLÍTVA (duplikáció) |
**Megtartva:** Floating Welcome Card (2-23. sorok) - vizuálisan elkülönül.
---
## 5. Érintett fájlok
| Fájl | Művelet | Mérték |
|------|---------|--------|
| `frontend/src/components/DashboardHeader.vue` | **ÁTÍRÁS** ~685 → ~300 sor | Nagy |
| `frontend/src/i18n/hu.ts` | Ellenőrzés (várhatóan nincs új kulcs) | Apró |
| `frontend/src/i18n/en.ts` | Ellenőrzés (várhatóan nincs új kulcs) | Apró |
**Nem érintett:** `PrivateLayout.vue`, `OrganizationLayout.vue`, `LanguageSwitcher.vue`, `auth.ts`
---
## 6. AuthStore használt értékei
| Érték | Típus |
|-------|-------|
| `authStore.user?.first_name` | `string \| undefined` |
| `authStore.user?.email` | `string \| undefined` |
| `authStore.myOrganizations` | `OrganizationItem[]` |
| `authStore.hasBusinessOrganization` | `boolean` (computed) |
| `authStore.isCorporateMode` | `boolean` (computed) |
| `authStore.businessOrganization` | `OrganizationItem \| undefined` (computed) |
| `authStore.switchOrganization(id \| null)` | `function` (async) |
---
## 7. Tesztelési terv
1. `vite build` hiba nélkül
2. Vizuális: Bal 👋 Welcome; Közép `{name}_garázsa`; Jobb: Cég → 🌐 → Avatár
3. Cég dropdown működik (list, select, back)
4. LanguageSwitcher működik (6 nyelv)
5. Avatár menü működik (Profile, Logout)
---
## 8. Módosítási sorrend
1. `DashboardHeader.vue` - Teljes átírás
2. `hu.ts` / `en.ts` - Ellenőrzés
3. `vite build` - Verifikáció
---
## ⏸️ Jóváhagyási pont
**Kérem a felhasználó jóváhagyását a fenti specifikációhoz!**

View File

@@ -0,0 +1,232 @@
# logic_spec_opten_integration.md — Opten REST API Integrációs Terv
## Modul Célja
A jelenlegi mock `GET /lookup-tax/{tax_number}` végpont lecserélése valós [Opten REST API](https://www.opten.hu/) hívásra. A frontend [`CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue:654) `lookupTaxNumber()` függvénye már készen áll a valós adatok fogadására — csak a backend oldali integráció hiányzik.
## Masterbook 2.0 Illeszkedés
- **DDD Séma:** `identity.organizations` — a cégadatok automatikus kitöltése csökkenti a hibalehetőséget.
- **Geo-logika:** Csak magyar (HU) adószámok esetén aktív. Más országok esetén a mock marad, vagy külön integráció később.
---
## Érintett Fájlok és Műveletek
| # | Fájl | Művelet | Leírás |
|---|------|---------|--------|
| 1 | [`backend/app/core/config.py`](backend/app/core/config.py:11) | ✏️ Módosítás | `OPTEN_API_KEY`, `OPTEN_API_BASE_URL`, `OPTEN_CACHE_TTL` hozzáadása a `Settings` osztályhoz |
| 2 | [`backend/app/services/opten_service.py`](backend/app/services/opten_service.py) | **ÚJ fájl** | `OptenService` osztály: `httpx.AsyncClient` + `tenacity` retry logika |
| 3 | [`backend/app/schemas/organization.py`](backend/app/schemas/organization.py) | **ÚJ fájl** | `TaxLookupResponse` Pydantic séma |
| 4 | [`backend/app/api/v1/endpoints/organizations.py`](backend/app/api/v1/endpoints/organizations.py:125) | ✏️ Módosítás | Mock végpont → valós `OptenService.lookup_by_tax_number()` hívás |
| 5 | [`backend/requirements.txt`](backend/requirements.txt:17) | 🟢 **Nincs teendő** | `httpx` már szerepel. `tenacity` opcionális (retry logikához) |
| 6 | [`frontend/src/views/organization/CompanyOnboardingView.vue`](frontend/src/views/organization/CompanyOnboardingView.vue:654) | 🟢 **Már kész** | `lookupTaxNumber()` teljes mértékben használható |
---
## Adatmodell — TaxLookupResponse
```python
# backend/app/schemas/organization.py
from pydantic import BaseModel
from typing import Optional
class TaxLookupResponse(BaseModel):
full_name: str
name: str
display_name: Optional[str] = None
address_zip: str
address_city: str
address_street_name: str
address_street_type: Optional[str] = None
address_house_number: str
legal_form: Optional[str] = None # pl. "Kft.", "Bt.", "Zrt."
status: Optional[str] = None # pl. "active", "dissolved"
```
### Frontend Kompatibilitás
A fenti mezők teljes mértékben lefedik a frontend [`CompanyOnboardingView.vue:654-684`](frontend/src/views/organization/CompanyOnboardingView.vue:654) által várt mezőket. Az új `legal_form` és `status` mezők a frontend `tax_lookup_result` objektumban tárolódhatnak későbbi felhasználásra.
---
## Service Réteg — OptenService
### Osztály Felépítése
```python
# backend/app/services/opten_service.py
import logging
from typing import Optional
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
logger = logging.getLogger(__name__)
class OptenService:
def __init__(self, api_key: str, base_url: str = "https://api.opten.hu/v2"):
self.api_key = api_key
self.base_url = base_url
self.client = httpx.AsyncClient(
base_url=self.base_url,
headers={"X-Api-Key": self.api_key, "Accept": "application/json"},
timeout=10.0
)
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=5))
async def lookup_by_tax_number(self, tax_number: str) -> dict:
"""Lekérdezi az Opten API-t adószám alapján."""
normalized = self._normalize_tax_number(tax_number)
response = await self.client.get(f"/company/{normalized}")
response.raise_for_status()
return self._map_opten_response(response.json())
def _normalize_tax_number(self, raw: str) -> str:
"""Csak számjegyeket tart meg (8 vagy 10 vagy 11 karakter)."""
digits = "".join(c for c in raw if c.isdigit())
# HU adószám: 8 számjegy (egyéni) vagy 10-11 számjegy (cég, áfával)
return digits[:11]
def _map_opten_response(self, data: dict) -> dict:
"""Opten API válasz → TaxLookupResponse formátumba."""
return {
"full_name": data.get("cegnev", ""),
"name": data.get("rovitett_cegnev", data.get("cegnev", "")),
"display_name": data.get("rovitett_cegnev"),
"address_zip": data.get("iranyitoszam", ""),
"address_city": data.get("telepules", ""),
"address_street_name": data.get("kozt_nev", ""),
"address_street_type": data.get("kozt_jelleg", ""),
"address_house_number": str(data.get("hazszam", "")),
"legal_form": data.get("cegforma", ""),
"status": data.get("ceg_statusz", ""),
}
async def close(self):
await self.client.aclose()
```
### Gráciális Fallback
Ha az `OPTEN_API_KEY` nincs beállítva (üres string), a végpont NEM dob hibát, hanem visszaadja a mock adatokat:
```python
# backend/app/api/v1/endpoints/organizations.py
@router.get("/lookup-tax/{tax_number}", response_model=TaxLookupResponse)
async def lookup_tax_number(tax_number: str, db: AsyncSession = Depends(get_db)):
settings = get_settings()
if not settings.OPTEN_API_KEY:
logger.warning("OPTEN_API_KEY not set — returning mock data")
return await _mock_lookup(tax_number)
service = OptenService(settings.OPTEN_API_KEY, settings.OPTEN_API_BASE_URL)
try:
result = await service.lookup_by_tax_number(tax_number)
return result
except httpx.HTTPStatusError as e:
return await _handle_opten_error(e)
finally:
await service.close()
```
---
## Geo-Logika
| Ország | Forrás | Státusz |
|--------|--------|---------|
| Magyarország (HU) | Opten REST API | ✅ Ezen terv |
| Más EU országok | Mock (később: saját provider) | ⏳ Tervezett |
Csak magyar adószámok esetén aktív az Opten hívás. A `_normalize_tax_number()` metódus alapján: ha a szám nem felel meg a HU formátumnak (8-11 számjegy), a végpont mock adatokat ad vissza.
---
## Hibakezelési Mátrix
| Opten Hiba | HTTP Státusz | Felhasználói Üzenet |
|------------|-------------|---------------------|
| 404 — Nincs ilyen cég | 404 | "Nem található cég ezzel az adószámmal." |
| 403 — Rossz API kulcs | 500 | "Belső szolgáltatói hiba. Kérjük, próbáld később." |
| 429 — Rate limit | 429 | "Túl sok lekérdezés. Kérjük, várj egy percet." |
| 422 — Érvénytelen adószám | 422 | "Érvénytelen adószám formátum." |
| 504 — Gateway timeout | 504 | "A szolgáltató nem válaszol. Próbáld újra később." |
| Ismeretlen hiba | 502 | "Váratlan hiba történt. Próbáld újra később." |
### `_handle_opten_error()` pszeudokód
```python
async def _handle_opten_error(e: httpx.HTTPStatusError) -> dict:
status_map = {
404: (404, "Nem található cég ezzel az adószámmal."),
403: (500, "Belső szolgáltatói hiba."),
429: (429, "Túl sok lekérdezés. Várj egy percet."),
422: (422, "Érvénytelen adószám formátum."),
504: (504, "A szolgáltató nem válaszol."),
}
http_code, msg = status_map.get(e.response.status_code, (502, "Váratlan hiba."))
raise HTTPException(status_code=http_code, detail=msg)
```
---
## Biztonsági Megfontolások
| # | Intézkedés | Leírás |
|---|-----------|--------|
| 1 | `.env`-ben tartás | `OPTEN_API_KEY` csak a `.env` fájlban, SOHOHA hardcode-olva |
| 2 | Rate limiting | Nginx/API Gateway szintű korlátozás a `/lookup-tax/` végpontra |
| 3 | Audit log | Minden Opten hívás naplózása: tax_number, timestamp, sikeresség |
| 4 | Cache | `OPTEN_CACHE_TTL` (alapértelmezett: 3600s) — redis cache a gyakori ismétlődésekhez |
| 5 | Adatvédelem | A visszaadott adatok csak a szervezet létrehozásához használhatók |
---
## Teszt Terv
### Unit tesztek
| Teszt | Leírás |
|-------|--------|
| `test_normalize_tax_number` | 8, 10, 11 jegyű, kötőjeles, szóközös bemenetek |
| `test_map_opten_response` | Opten JSON → TaxLookupResponse mezőtérkép |
| `test_mock_fallback` | Ha nincs API kulcs, mock adat jön |
| `test_error_mapping` | Minden hibaeset lefedése |
### Integrációs tesztek
```bash
# Teszt élő Opten API-val (ha van API kulcs)
docker compose exec sf_api python3 -c "
from app.services.opten_service import OptenService
import asyncio
async def test():
s = OptenService('teszt_kulcs')
result = await s.lookup_by_tax_number('12345678')
print(result)
await s.close()
asyncio.run(test())
"
```
---
## Végrehajtási Terv (6 Kártya)
```mermaid
graph TD
A[1. Config: OPTEN_API_KEY] --> B[2. Séma: TaxLookupResponse]
A --> C[3. Service: OptenService]
B --> D[4. Endpoint: Mock → Valós]
C --> D
D --> E[5. Tesztek]
E --> F[6. Dokumentáció]
```
| # | Kártya Név | Függőség | Becsült Méret |
|---|-----------|----------|---------------|
| 1 | Config: `OPTEN_API_KEY` hozzáadása | Nincs | S (< 10 sor) |
| 2 | Séma: `TaxLookupResponse` Pydantic | 1 | S (< 20 sor) |
| 3 | Service: `OptenService` osztály | Nincs | M (~60 sor) |
| 4 | Endpoint: Mock → valós hívás | 2, 3 | S (~20 sor) |
| 5 | Tesztek (unit + smoke) | 4 | M (~50 sor) |
| 6 | Dokumentáció frissítése | 5 | S (< 20 sor) |
---
## Kockázatok
- **Opten API kulcs beszerzése** — a fejlesztés mock módban is végezhető.
- **Opten API változás** — a `_map_opten_response()` metódus könnyen adaptálható.
- **Éles rate limit** — szükség esetén Opten előfizetés frissítése.