RABAC felépítése megtörtént ill hirdetési portál alapok lerakva

This commit is contained in:
Roo
2026-06-19 06:06:34 +00:00
parent fe3c32597d
commit 9ba2d9180d
30 changed files with 4336 additions and 76 deletions

View File

@@ -951,3 +951,153 @@ A PATCH `/api/v1/organizations/{id}` végpont 200 OK-val tért vissza, és az ad
- `GET /api/v1/organizations/my` → **200 OK**, tartalmazza a cím mezőket ✅
- `PATCH /api/v1/organizations/{id}` → **200 OK**, a válasz tartalmazza a cím mezőket ✅
- A cím adatok elérhetők a frontend `OrganizationSettingsModal` form-jában ✅
## 2026-06-18 - P0: Subscription Stacking & Ad Engine Implementation
### 🎯 Cél
Subscription időhalmozás (stacking) implementálása `duration_days` segítségével a JSONB `rules`-ban, valamint a Native Ad Engine adatbázis séma (`marketing` schema) létrehozása.
### 🔧 Változtatások
**1. Pydantic Schema Bővítés - [`subscription.py`](backend/app/schemas/subscription.py:43):**
- `DurationModel`: `days` (default=30) és `allow_stacking` (default=True) mezők
- `AdPolicyModel`: `show_ads`, `ad_free_grace_days`, `max_daily_impressions` mezők
- `SubscriptionRulesModel`: `duration: Optional[DurationModel]` és `ad_policy: Optional[AdPolicyModel]` opcionális mezők
**2. Stacking Logika - [`billing_engine.py`](backend/app/services/billing_engine.py:722):**
- `upgrade_org_subscription()`: `duration_days` kiolvasása `tier.rules`-ból, `valid_until` számítás stackinggel
- `upgrade_subscription()`: `UserSubscription` kezelés stacking logikával
- Képlet: `IF existing.valid_until > NOW → stacked = existing + duration_days ELSE fresh = NOW + duration_days`
**3. Ad Engine Modellek - [`marketing.py`](backend/app/models/marketing.py:1):**
- 7 tábla a `marketing` sémában: `campaigns`, `creatives`, `placements`, `campaign_creatives`, `campaign_placements`, `ad_impressions`, `ad_clicks`
- String-based enumok (`CampaignStatus`, `CreativeType`, `PlacementType`) `CheckConstraint`-tel a `sync_engine` kompatibilitásért
- Priority + weighted random selection logika a hirdetés kiválasztáshoz
**4. Seed Adatok - [`seed_packages.py`](backend/app/scripts/seed_packages.py:266):**
- Mind a 6 csomag frissítve `duration` (30 nap, `allow_stacking=true`) és `ad_policy` mezőkkel
- Free: `show_ads=true`, `max_daily_impressions=50`
- Pro: `show_ads=true`, `grace_days=3`, `max_impressions=100`
- VIP/Corporate: `show_ads=false`
- UPSERT (`INSERT ... ON CONFLICT DO UPDATE`) használata DELETE helyett FK referenciák megőrzéséért
**5. Modell Regisztráció - [`__init__.py`](backend/app/models/__init__.py:54):**
- Marketing modellek importálva és `__all__`-hoz adva
### ✅ Verifikáció
- `sync_engine`: 1152 elem szinkronban, 7 marketing tábla létrehozva ✅
- `seed_packages`: 6 csomag sikeresen frissítve duration/ad_policy mezőkkel ✅
- Pydantic validáció: `DurationModel`, `AdPolicyModel`, `SubscriptionRulesModel` hibátlan ✅
- Stacking logika szimuláció: Fresh (30 nap), Stacked (40 nap = 10 + 30), Expired reset (30 nap) ✅
- Gitea kártya #271 létrehozva és elindítva ✅
## 2026-06-18 - P0 Debug: GET /api/v1/subscriptions/public 404 Fix
### 🎯 Cél
A frontend SubscriptionPlansView által hívott `GET /api/v1/subscriptions/public` végpont 404-es hibájának kivizsgálása és javítása.
### 🔧 Változtatások
**1. ROOT CAUSE - Stale container cache:**
- A [`subscriptions.py`](backend/app/api/v1/endpoints/subscriptions.py) fájl új volt (untracked `??` git statusban)
- A futó uvicorn konténer a régi modulgráfot cache-elte, nem tudott az új router-ről
- **Fix:** `docker compose restart sf_api` — a konténer újratöltése után a végpont 200 OK-val válaszolt ✅
**2. i18n - Missing `common.retry` key:**
- A [`hu.ts`](frontend/src/i18n/hu.ts:9) `common` szekciójába hozzáadva: `retry: 'Újrapróbálkozás'`
- A [`en.ts`](frontend/src/i18n/en.ts:9) `common` szekciójába hozzáadva: `retry: 'Retry'`
**3. `is_public` filtering:**
- A [`get_public_subscriptions`](backend/app/api/v1/endpoints/subscriptions.py:47) végpont most már szűri a csomagokat a `rules.lifecycle.is_public` JSONB mező alapján
- Csak azok a tier-ek kerülnek visszaadásra, ahol `is_public=True` (alapértelmezett: True)
### ✅ Verifikáció
- `GET /api/v1/subscriptions/public` → **200 OK**, 10 tier visszaadva
- Minden tier `is_public=True` értékkel rendelkezik
- A frontend i18n kulcsok (`common.retry`) mindkét nyelven elérhetők
## 2026-06-18 - P0 Booster Architecture & Minimalist Subscription UI
### 🎯 Cél
P0 architektúra frissítés: Booster kvóta rendszer, marketing adatok a subscription tier-ekhez, minimalista előfizetési kártyák és részletes modal.
### 🔧 Változtatások
**1. `extra_allowances` JSONB oszlop**
- [`OrganizationSubscription`](backend/app/models/core_logic.py:42) modellhez hozzáadva: `extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)`
- A `sync_engine` sikeresen létrehozta a `finance.org_subscriptions.extra_allowances` oszlopot
**2. Kvóta motor frissítés**
- [`get_user_vehicle_limit()`](backend/app/services/asset_service.py:610) most beolvassa az `extra_allowances.extra_vehicles` értéket és hozzáadja a végső limithez
- [`scan_registration_document`](backend/app/api/v1/endpoints/evidence.py:43) evidence végpont is figyelembe veszi az `extra_allowances`-t a kvóta ellenőrzésnél
**3. Marketing adatok**
- [`MarketingModel`](backend/app/schemas/subscription.py:68) Pydantic séma létrehozva `subtitle`, `badge`, `highlight_color` mezőkkel
- [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:121) `marketing` mezővel bővítve
- Mind a 6 fő csomag [`seed_packages.py`](backend/app/scripts/seed_packages.py) marketing adatokkal frissítve (magyar subtitle, badge, highlight_color)
**4. Minimalista előfizetési kártyák**
- [`SubscriptionPlansView.vue`](frontend/src/views/SubscriptionPlansView.vue) teljes átírása: csak név, badge, subtitle, ár, 3 kulcs statisztika (max_vehicles, max_garages, monthly_free_credits) megjelenítése
- Gomb szöveg: "Részletek & Vásárlás"
- `h-full` + `flex flex-col` az egyenlő kártyamagasságokért
**5. PlanDetailsModal komponens**
- [`PlanDetailsModal.vue`](frontend/src/components/subscription/PlanDetailsModal.vue) létrehozva: Teleport modal, fejléc highlight színnel, havi/éves váltó, megtakarítás % kijelzés, 3 kulcs statisztika, szolgáltatás lista, rendelés szimuláció gomb
- i18n kulcsok hozzáadva (`hu.ts`, `en.ts`)
### ✅ Verifikáció
- `extra_allowances` oszlop létezik: `finance.org_subscriptions.extra_allowances` JSONB, default `'{}'::jsonb`
- 6 subscription tier rendelkezik marketing adatokkal (private_free, private_pro, private_vip, corp_premium, corp_premium_plus, corp_vip)
- Minden Python fájl szintaktikailag helyes
- `seed_packages.py` sikeresen lefutott, az adatok perzisztálva
## 2026-06-18 - P0: Multi-Region Pricing & Admin Marketing UI
### 🎯 Cél
Multi-region pricing (pricing_zones) bevezetése a subscription rendszerbe: a backend dinamikusan feloldja a megfelelő árat a felhasználó országkódja alapján, a frontend pedig megjeleníti a feloldott árat. Admin UI-ban marketing mezők és pricing zones szerkesztő hozzáadása.
### 🔧 Változtatások
**1. BACKEND - Schema [`subscription.py`](backend/app/schemas/subscription.py):**
- `PricingZoneModel` osztály hozzáadva: `monthly_price`, `yearly_price`, `currency`, `credit_price`
- `pricing_zones: Optional[Dict[str, PricingZoneModel]]` mező a `SubscriptionRulesModel`-ben
- `MarketingModel` osztály hozzáadva: `subtitle`, `badge`, `highlight_color`
- `marketing: Optional[MarketingModel]` mező a `SubscriptionRulesModel`-ben
- `PublicSubscriptionTierResponse` séma: `resolved_pricing: Optional[PricingZoneModel]` mezővel
**2. BACKEND - Seed [`seed_packages.py`](backend/app/scripts/seed_packages.py):**
- `pricing_zones()` helper függvény: DEFAULT (EUR), HU (HUF), US (USD) zónákkal
- Mind a 6 fő csomag (`private_free`, `private_pro`, `private_vip`, `corp_premium`, `corp_premium_plus`, `corp_vip`) `pricing_zones` adatokat kapott
**3. BACKEND - Endpoint [`subscriptions.py`](backend/app/api/v1/endpoints/subscriptions.py):**
- `resolve_pricing(rules, country_code)` függvény: országkód → pricing_zones → DEFAULT → legacy pricing → None
- `get_user_country_code(current_user, db)` aszinkron függvény: aktív org country_code → person address country → "DEFAULT"
- `GET /public` végpont frissítve: `List[PublicSubscriptionTierResponse]` visszatérés, `resolved_pricing` kitöltve
**4. FRONTEND - [`SubscriptionPlansView.vue`](frontend/src/views/SubscriptionPlansView.vue):**
- `resolved_pricing` mező a `SubscriptionPlan` interfészben
- `resolvedMonthlyPrice()`, `resolvedYearlyPrice()`, `resolvedCurrency()`, `yearlySavingsPercent()` helperek
- Dinamikus `Intl.NumberFormat` pénznem formázás (HUF=0 tizedes, más=2 tizedes)
- Havi + éves ár kijelzés megtakarítás %-kal
**5. FRONTEND - [`PlanDetailsModal.vue`](frontend/src/components/subscription/PlanDetailsModal.vue):**
- `resolved_pricing` alapján számított `resolvedMonthlyPrice`, `resolvedYearlyPrice`, `resolvedCurrency` computed property-k
- Dinamikus pénznem formázás
**6. FRONTEND - Admin Store [`adminPackages.ts`](frontend/src/stores/adminPackages.ts):**
- `PricingZoneModel` interfész hozzáadva
- `MarketingModel` interfész hozzáadva
- `pricing_zones` és `marketing` mezők a `SubscriptionRulesModel`-ben
**7. FRONTEND - Admin UI [`PackageEditModal.vue`](frontend/src/components/admin/PackageEditModal.vue):**
- Marketing szekció: Alcím (Subtitle), Jelvény (Badge), Kiemelő szín (Hex color picker)
- Pricing Zones szekció: dinamikus lista zónák hozzáadásával/törlésével/szerkesztésével (country code, havi/éves ár, pénznem, kredit ár)
- `buildRules()` frissítve: marketing és pricing_zones beszúrása a rules objektumba
- `watch` handler frissítve: meglévő marketing és pricing_zones adatok betöltése szerkesztéskor
### ✅ Verifikáció
- `sync_engine` sikeresen lefutott, a séma konzisztens
- `seed_packages.py` sikeresen lefutott, mind a 6 fő csomag rendelkezik `pricing_zones` adatokkal (HU, US, DEFAULT)
- API végpont (`GET /api/v1/subscriptions/public`) helyesen adja vissza a `resolved_pricing` mezőt
- Frontend TypeScript ellenőrzés: nincs új hiba a módosított fájlokban
- Admin UI: marketing mezők és pricing zones szerkesztő működik

49
Pictures/sf_card.svg Normal file
View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="959"
height="1322"
viewBox="0 0 959 1322"
sodipodi:docname="sf_card.svg"
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.61043873"
inkscape:cx="479.16357"
inkscape:cy="661"
inkscape:window-width="1920"
inkscape:window-height="1009"
inkscape:window-x="1912"
inkscape:window-y="646"
inkscape:window-maximized="1"
inkscape:current-layer="g1" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g1">
<g
id="g27"
transform="translate(3.276335,3.2763322)">
<path
style="display:inline;fill:#306081"
d="m 168.62859,1261.8279 c -12.47141,-1.784 -25.24506,-11.5824 -31.03975,-23.81 L 134.5,1231.5 v -50.5204 -50.5205 l 3.35087,-6.8071 c 6.04108,-12.2723 16.85005,-21.1247 28.92039,-23.6854 3.12028,-0.662 110.34418,-0.912 315.22874,-0.7349 l 310.5,0.2683 5.78122,2.709 c 11.5255,5.4006 22.2104,18.6273 24.47002,30.291 0.56162,2.899 0.87028,24.5394 0.73454,51.5 -0.25606,50.8602 -0.20337,50.3688 -6.36068,59.3182 -6.1975,9.0077 -18.01232,16.8142 -27.55689,18.2078 -5.68739,0.8304 -615.17342,1.1267 -620.93962,0.3019 z M 2.25,185.33772 C 0.76962389,184.74037 0.38857818,47.208733 1.8590642,44.236431 2.3315495,43.281394 3.4982244,40.025 4.4516751,37 6.7993569,29.551542 13.230286,19.833498 19.405639,14.402448 25.056145,9.4329844 36.999577,3 40.57534,3 41.843984,3 43.160081,2.55 43.5,2 44.357522,0.61249997 915,0.61249997 915,2 c 0,0.55 1.05312,1 2.34028,1 4.22935,0 14.80939,6.1765597 21.67638,12.654527 7.89958,7.452064 13.30582,16.865801 14.84285,25.845473 1.00124,5.849387 1.33477,138.95439 0.35771,142.75 -0.42827,1.66372 -23.87631,1.74611 -475.58385,1.67105 C 217.31002,185.87763 2.9375,185.61513 2.25,185.33772 Z"
id="path32" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -5,7 +5,8 @@ from app.api.v1.endpoints import (
services, admin, expenses, evidence, social, security,
billing, finance_admin, analytics, vehicles, system_parameters,
gamification, translations, users, reports, dictionaries,
admin_packages, admin_services, constants, providers
admin_packages, admin_services, constants, providers,
subscriptions, marketing,
)
api_router = APIRouter()
@@ -36,3 +37,5 @@ api_router.include_router(admin_services.router, prefix="/admin/services", tags=
api_router.include_router(billing.router, prefix="/billing", tags=["Billing & Wallet"])
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
api_router.include_router(subscriptions.router, prefix="/subscriptions", tags=["Subscriptions"])
api_router.include_router(marketing.router, prefix="/marketing", tags=["Marketing & Ads"])

View File

@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
from app.models.marketplace.organization import Organization
from app.models.core_logic import SubscriptionTier
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
logger = logging.getLogger(__name__)
@@ -18,6 +18,7 @@ async def scan_registration_document(file: UploadFile = File(...), db: AsyncSess
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
# Instead of hardcoded plan-based lookup from system_parameters,
# we now read the organization's assigned subscription_tier rules.
# P0 BOOSTER: extra_allowances.extra_vehicles is added on top.
max_allowed = 1 # default fallback
if current_user.scope_id:
@@ -38,6 +39,22 @@ async def scan_registration_document(file: UploadFile = File(...), db: AsyncSess
# Fallback to legacy base_asset_limit if no tier assigned
max_allowed = max(org.base_asset_limit or 1, 1)
# ── P0 BOOSTER: Add extra_allowances.extra_vehicles ──
try:
org_sub_stmt = select(OrganizationSubscription.extra_allowances).where(
OrganizationSubscription.org_id == current_user.scope_id,
OrganizationSubscription.is_active == True
).limit(1)
org_sub_result = await db.execute(org_sub_stmt)
extra_allowances = org_sub_result.scalar_one_or_none()
if extra_allowances and isinstance(extra_allowances, dict):
extra_vehicles = int(extra_allowances.get('extra_vehicles', 0))
max_allowed += extra_vehicles
if extra_vehicles > 0:
logger.info(f"[P0 BOOSTER] Extra vehicles for org {current_user.scope_id}: +{extra_vehicles}")
except Exception as e:
logger.debug(f"Could not read extra_allowances for org {current_user.scope_id}: {e}")
stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id,
Asset.status == "active"

View File

@@ -0,0 +1,136 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/marketing.py
"""
Marketing & Ad Placement Public API Endpoints.
Provides:
- GET /placements/{zone} — Get active ads for a specific placement zone
"""
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from pydantic import BaseModel
from app.db.session import get_db
from app.models.marketing import (
Placement,
CampaignPlacement,
Campaign,
Creative,
CampaignStatus,
)
router = APIRouter()
class AdResponse(BaseModel):
"""Egy hirdetés válaszmodellje a frontend számára."""
id: int
campaign_id: int
campaign_name: str
creative_id: int
creative_type: str
content_url: Optional[str] = None
html_snippet: Optional[str] = None
alt_text: Optional[str] = None
target_url: Optional[str] = None
priority: int = 0
class PlacementResponse(BaseModel):
"""Egy placement zóna összes aktív hirdetésének válasza."""
zone: str
ads: List[AdResponse]
@router.get("/placements/{zone}", response_model=PlacementResponse)
async def get_placement_ads(
zone: str,
db: AsyncSession = Depends(get_db),
):
"""
Aktív hirdetések lekérése egy adott placement zónához.
**Logika:**
1. Megkeresi a Placement rekordot a `name` alapján (case-insensitive).
2. Lekéri az összes aktív kampányhoz tartozó CampaignPlacement kapcsolatot.
3. Visszaadja a kapcsolódó Creative-okat prioritás szerint rendezve.
4. Ha nincs aktív kampány, üres listával tér vissza (nem dob hibát).
"""
# 1. Find the placement by name (case-insensitive)
placement_stmt = select(Placement).where(
Placement.name.ilike(zone),
Placement.is_active == True,
)
result = await db.execute(placement_stmt)
placement = result.scalar_one_or_none()
if not placement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Placement zone '{zone}' not found",
)
# 2. Find active campaign-placement links with eager-loaded relations
campaign_placements_stmt = (
select(CampaignPlacement)
.options(
selectinload(CampaignPlacement.campaign),
selectinload(CampaignPlacement.creative),
)
.where(
and_(
CampaignPlacement.placement_id == placement.id,
CampaignPlacement.is_active == True,
)
)
.order_by(CampaignPlacement.priority.asc())
)
result = await db.execute(campaign_placements_stmt)
campaign_placements = result.scalars().all()
# 3. Filter: only include CampaignPlacements where the campaign is ACTIVE
now_utc = datetime.now(timezone.utc)
active_ads: List[AdResponse] = []
for cp in campaign_placements:
campaign = cp.campaign
creative = cp.creative
if not campaign or not creative:
continue
# Check campaign status
if campaign.status != CampaignStatus.ACTIVE.value:
continue
# Check campaign date range
if campaign.start_date and campaign.start_date > now_utc:
continue
if campaign.end_date and campaign.end_date < now_utc:
continue
active_ads.append(
AdResponse(
id=cp.id,
campaign_id=campaign.id,
campaign_name=campaign.name,
creative_id=creative.id,
creative_type=creative.creative_type,
content_url=creative.content_url,
html_snippet=creative.html_snippet,
alt_text=creative.alt_text,
target_url=creative.target_url,
priority=cp.priority,
)
)
return PlacementResponse(
zone=zone,
ads=active_ads,
)

View File

@@ -0,0 +1,176 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/subscriptions.py
"""
Subscription Public API Endpoints.
Provides:
- GET /public — Public package catalog with dynamic pricing resolution
based on the user's organization country (or fallback to DEFAULT zone).
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import deps
from app.db.session import get_db
from app.models.core_logic import SubscriptionTier
from app.models.identity import User
from app.models.marketplace.organization import Organization
from app.schemas.subscription import (
PublicSubscriptionTierResponse,
SubscriptionTierResponse,
PricingZoneModel,
)
router = APIRouter()
# ── Pricing Resolution ────────────────────────────────────────────────────────
DEFAULT_COUNTRY = "DEFAULT"
def resolve_pricing(
rules: dict,
country_code: str,
) -> Optional[dict]:
"""
Feloldja a megfelelő árazást a `pricing_zones` JSONB mezőből
a felhasználó országkódja alapján.
**Logika:**
1. Ha a `rules` tartalmaz `pricing_zones`-t, megkeresi a country_code-nak
megfelelő zónát.
2. Ha nincs pontos egyezés, visszaesik a "DEFAULT" zónára.
3. Ha a `pricing_zones` hiányzik, visszaesik a legacy `pricing` mezőre.
4. Ha semmi sincs, None-t ad vissza.
"""
if not rules or not isinstance(rules, dict):
return None
# 1. Try pricing_zones first
pricing_zones = rules.get("pricing_zones")
if pricing_zones and isinstance(pricing_zones, dict):
# Exact match
if country_code in pricing_zones:
return pricing_zones[country_code]
# Fallback to DEFAULT
if "DEFAULT" in pricing_zones:
return pricing_zones["DEFAULT"]
# 2. Fallback to legacy pricing
legacy_pricing = rules.get("pricing")
if legacy_pricing and isinstance(legacy_pricing, dict):
return {
"monthly_price": legacy_pricing.get("monthly_price", 0),
"yearly_price": legacy_pricing.get("yearly_price", 0),
"currency": legacy_pricing.get("currency", "EUR"),
"credit_price": legacy_pricing.get("credit_price"),
}
return None
async def get_user_country_code(
current_user: User,
db: AsyncSession,
) -> str:
"""
Meghatározza a felhasználó országkódját.
**Logika:**
1. Ha a felhasználónak van aktív szervezete (active_organization_id),
lekéri annak country_code mezőjét.
2. Ha nincs aktív szervezet, a felhasználó Person rekordjának
address országát próbálja meg lekérni.
3. Ha egyik sem elérhető, visszaadja a "DEFAULT" értéket.
"""
# 1. Try active organization
active_org_id = getattr(current_user, "active_organization_id", None)
if active_org_id:
org_stmt = select(Organization.country_code).where(
Organization.id == active_org_id,
Organization.is_deleted == False,
)
org_result = await db.execute(org_stmt)
org_country = org_result.scalar_one_or_none()
if org_country:
return org_country.upper()
# 2. Try user's person -> address country
person = getattr(current_user, "person", None)
if person:
address = getattr(person, "address", None)
if address:
country = getattr(address, "country", None)
if country:
return country.upper()
# 3. Fallback
return DEFAULT_COUNTRY
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get(
"/public",
response_model=List[PublicSubscriptionTierResponse],
summary="Publikus csomagok listázása feloldott árazással",
description=(
"Visszaadja az összes publikus előfizetési csomagot, "
"a felhasználó régiójára feloldott árazással (resolved_pricing). "
"A feloldás a pricing_zones JSONB mezőből történik a felhasználó "
"országkódja alapján."
),
)
async def get_public_subscriptions(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
):
"""
Publikus előfizetési csomagok listázása dinamikus árazással.
**Szabály:**
- Csak azokat a csomagokat adja vissza, ahol a `rules.lifecycle.is_public` = true
(JSONB mező alapján szűrve). Ha a mező hiányzik, alapértelmezés szerint publikus.
- Minden csomaghoz tartozik egy `resolved_pricing` mező, amely a felhasználó
országkódja alapján feloldott árazást tartalmazza.
- A frontend tovább szűrhet a `name` mező alapján (private_ vs corp_ előtag).
"""
# 1. Determine user's country code
country_code = await get_user_country_code(current_user, db)
# 2. Fetch all public tiers
stmt = (
select(SubscriptionTier)
.order_by(SubscriptionTier.id)
)
result = await db.execute(stmt)
tiers = result.scalars().all()
# 3. Build response with resolved pricing
response: List[PublicSubscriptionTierResponse] = []
for t in tiers:
rules = t.rules or {}
if not rules.get("lifecycle", {}).get("is_public", True):
continue
# Resolve pricing for this user's country
resolved = resolve_pricing(rules, country_code)
resolved_pricing = None
if resolved:
resolved_pricing = PricingZoneModel(**resolved)
response.append(
PublicSubscriptionTierResponse(
id=t.id,
name=t.name,
rules=rules,
is_custom=t.is_custom,
resolved_pricing=resolved_pricing,
)
)
return response

View File

@@ -50,6 +50,13 @@ from .identity.security import PendingAction, ActionStatus
from .system.legal import LegalDocument, LegalAcceptance
from .marketplace.logistics import Location, LocationType
# 10. Marketing / Ad Engine models
from .marketing import (
Campaign, Creative, Placement, CampaignCreative,
CampaignPlacement, AdImpression, AdClick,
CampaignStatus, CreativeType, PlacementType,
)
# Aliasok a Digital Twin kompatibilitáshoz
Vehicle = Asset
@@ -81,5 +88,9 @@ __all__ = [
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword"
"GbCatalogDiscovery", "Season", "StagedVehicleData", "OneTimePassword",
# Marketing / Ad Engine
"Campaign", "Creative", "Placement", "CampaignCreative",
"CampaignPlacement", "AdImpression", "AdClick",
"CampaignStatus", "CreativeType", "PlacementType",
]

View File

@@ -41,6 +41,11 @@ class OrganizationSubscription(Base):
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# ── P0 BOOSTER ARCHITECTURE: extra_allowances JSONB ──
# Tárolja a darabáron vett extra kvótákat a csomag keretein felül.
# Példa: {"extra_vehicles": 1, "extra_garages": 1, "extra_credits": 100}
extra_allowances: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"), default=dict)
class UserSubscription(Base):
"""
Felhasználók aktuális előfizetései és azok érvényessége.

View File

@@ -0,0 +1,300 @@
# /opt/docker/dev/service_finder/backend/app/models/marketing.py
"""
📢 Ad Engine — Marketing Schema Models
Extensible Native Ad Engine for Service Finder.
Supports:
- Campaign lifecycle (draft → scheduled → active → paused → completed → cancelled)
- Internal (IMAGE) and external (HTML_SNIPPET) creatives
- Priority-based + weighted random ad selection with fallback
- Impression and click tracking for analytics
Schema: marketing
"""
from __future__ import annotations
import enum
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
String, Integer, Boolean, DateTime, Float, Text,
ForeignKey, UniqueConstraint, Index, CheckConstraint, text
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from app.database import Base
# ── Enums (Python-side only; stored as String in DB for portability) ───────────
class CampaignStatus(str, enum.Enum):
"""Kampány életciklus állapotai."""
DRAFT = "draft"
SCHEDULED = "scheduled"
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class CreativeType(str, enum.Enum):
"""Kreatív típusok (bővíthető)."""
IMAGE = "image" # Belső feltöltésű kép
HTML_SNIPPET = "html_snippet" # Külső HTML kód (pl. Google Ads)
# Jövőbeli: VIDEO, CAROUSEL, TEXT
class PlacementType(str, enum.Enum):
"""Hirdetési helyek a felületen."""
SIDEBAR = "sidebar"
BANNER_TOP = "banner_top"
BANNER_BOTTOM = "banner_bottom"
MODAL = "modal"
INLINE = "inline"
DASHBOARD_WIDGET = "dashboard_widget"
# ── Models ─────────────────────────────────────────────────────────────────────
class Campaign(Base):
"""
Hirdetési kampány.
Egy kampány több kreatívot és több elhelyezést (placement) is
tartalmazhat a many-to-many kapcsolaton keresztül.
"""
__tablename__ = "campaigns"
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'scheduled', 'active', 'paused', 'completed', 'cancelled')",
name="ck_campaign_status"
),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Kampány életciklus (String instead of native PG enum for portability)
status: Mapped[str] = mapped_column(
String(20),
default=CampaignStatus.DRAFT.value,
nullable=False,
index=True,
)
# Célzási beállítások (JSONB a rugalmasságért)
# Pl. {"target_tier": ["free", "premium"], "target_country": ["HU", "UK"]}
targeting: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True, server_default=text("'{}'::jsonb"))
# Prioritás (kisebb szám = magasabb prioritás)
priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False)
# Időbeli korlátok
start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
end_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Napi költési limit (impresszióban vagy pénzben)
daily_impression_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
total_impression_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Metrikák (denormalizált gyors lekérdezéshez)
current_impressions: Mapped[int] = mapped_column(Integer, default=0)
current_clicks: Mapped[int] = mapped_column(Integer, default=0)
# Metaadatok
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Admin user ID
# Kapcsolatok
creatives: Mapped[List["CampaignCreative"]] = relationship(
"CampaignCreative", back_populates="campaign", cascade="all, delete-orphan"
)
placements: Mapped[List["CampaignPlacement"]] = relationship(
"CampaignPlacement", back_populates="campaign", cascade="all, delete-orphan"
)
class Creative(Base):
"""
Kreatív (hirdetés anyag).
Két fő típus:
- IMAGE: belső feltöltésű kép (image_url, alt_text)
- HTML_SNIPPET: külső HTML kód (pl. Google Ads vagy más hirdetési hálózat)
"""
__tablename__ = "creatives"
__table_args__ = (
CheckConstraint(
"creative_type IN ('image', 'html_snippet')",
name="ck_creative_type"
),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
# Típus (String instead of native PG enum for portability)
creative_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
)
# IMAGE típushoz
image_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
alt_text: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
click_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Átkattintási link
# HTML_SNIPPET típushoz
html_snippet: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
# Közös mezők
width: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
height: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
weight: Mapped[float] = mapped_column(Float, default=1.0) # Súly a random kiválasztáshoz
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# Kapcsolatok
campaigns: Mapped[List["CampaignCreative"]] = relationship(
"CampaignCreative", back_populates="creative", cascade="all, delete-orphan"
)
class Placement(Base):
"""
Hirdetési hely (placement) a felületen.
Meghatározza, hogy a hirdetés hol jelenjen meg a frontenden.
"""
__tablename__ = "placements"
__table_args__ = (
CheckConstraint(
"placement_type IN ('sidebar', 'banner_top', 'banner_bottom', 'modal', 'inline', 'dashboard_widget')",
name="ck_placement_type"
),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
placement_type: Mapped[str] = mapped_column(
String(20),
nullable=False,
)
description: Mapped[Optional[str]] = mapped_column(String(300), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Kapcsolatok
campaigns: Mapped[List["CampaignPlacement"]] = relationship(
"CampaignPlacement", back_populates="placement", cascade="all, delete-orphan"
)
class CampaignCreative(Base):
"""
Many-to-Many kapcsolótábla: Campaign <-> Creative.
Lehetővé teszi, hogy egy kampány több kreatívot használjon,
és egy kreatív több kampányban is szerepelhessen.
"""
__tablename__ = "campaign_creatives"
__table_args__ = (
UniqueConstraint("campaign_id", "creative_id", name="uq_campaign_creative"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
creative_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="CASCADE"), nullable=False)
# Kapcsolatok
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
creative: Mapped["Creative"] = relationship("Creative", back_populates="campaigns")
class CampaignPlacement(Base):
"""
Many-to-Many kapcsolótábla: Campaign <-> Placement.
Meghatározza, hogy egy kampány mely hirdetési helyeken jelenjen meg.
"""
__tablename__ = "campaign_placements"
__table_args__ = (
UniqueConstraint("campaign_id", "placement_id", name="uq_campaign_placement"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
placement_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"), nullable=False)
# Kapcsolatok
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="placements")
placement: Mapped["Placement"] = relationship("Placement", back_populates="campaigns")
class AdImpression(Base):
"""
Hirdetés megjelenítési napló.
Minden egyes alkalommal rögzíti, amikor egy hirdetés megjelent
a felületen. Ez az adat szolgál a kampányok analitikájához.
"""
__tablename__ = "ad_impressions"
__table_args__ = (
Index("idx_impression_campaign", "campaign_id"),
Index("idx_impression_created", "created_at"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
creative_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="SET NULL"), nullable=True)
placement_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.placements.id", ondelete="SET NULL"), nullable=True)
# Kontextus
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Bejelentkezett user (ha van)
session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Anonymous session
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True) # IPv4 vagy IPv6
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Időbélyeg
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class AdClick(Base):
"""
Hirdetés kattintási napló.
Minden egyes kattintást rögzít, ami egy hirdetésen történt.
"""
__tablename__ = "ad_clicks"
__table_args__ = (
Index("idx_click_campaign", "campaign_id"),
Index("idx_click_created", "created_at"),
{"schema": "marketing"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
impression_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("marketing.ad_impressions.id", ondelete="SET NULL"), nullable=True
)
campaign_id: Mapped[int] = mapped_column(Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"), nullable=False)
creative_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("marketing.creatives.id", ondelete="SET NULL"), nullable=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
session_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -6,7 +6,10 @@ A rules struktúra a seed_packages.py-ban definiált formátumot követi:
{
"type": "private" | "corporate",
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
"pricing_zones": {
"HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"},
"DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"}
},
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
"entitlements": ["SRV_..."],
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
@@ -25,8 +28,16 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
# ── Belső (embedded) modellek a rules JSONB struktúrához ──────────────────────
class PricingZoneModel(BaseModel):
"""Egyetlen árazási zóna adatai (ország-specifikus ár)."""
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj")
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj")
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
credit_price: Optional[int] = Field(default=None, ge=0, description="Kreditár (havi kredit költség a csomaghoz)")
class PricingModel(BaseModel):
"""Árazási információk egy előfizetési csomaghoz."""
"""Árazási információk egy előfizetési csomaghoz (legacy, egyzónás)."""
monthly_price: float = Field(..., ge=0, description="Havi előfizetési díj (EUR)")
yearly_price: float = Field(..., ge=0, description="Éves előfizetési díj (EUR)")
currency: str = Field(default="EUR", min_length=3, max_length=3, description="Pénznem ISO 4217 kódja")
@@ -40,6 +51,19 @@ class AllowancesModel(BaseModel):
monthly_free_credits: int = Field(..., ge=0, description="Havi ingyenes kreditek száma")
class DurationModel(BaseModel):
"""Előfizetés időtartamának beállításai (duration_days stacking)."""
days: int = Field(default=30, ge=1, description="Előfizetés időtartama napokban (pl. 30 = havi, 365 = éves)")
allow_stacking: bool = Field(default=True, description="Időhalmozás engedélyezése (stacking): ha True, a meglévő valid_until-hoz hozzáadódik")
class AdPolicyModel(BaseModel):
"""Hirdetési szabályok a csomaghoz."""
show_ads: bool = Field(default=True, description="Megjelenjenek-e hirdetések a felhasználónak")
ad_free_grace_days: int = Field(default=0, ge=0, description="Hány nap türelmi idő a reklámok eltűnéséig fizetés után")
max_daily_impressions: Optional[int] = Field(default=None, ge=0, description="Maximális napi hirdetésmegjelenítés (None = korlátlan)")
class LifecycleModel(BaseModel):
"""Életciklus állapotok egy előfizetési csomaghoz."""
is_public: bool = Field(default=True, description="Publikus-e a csomag (látszik-e a felhasználóknak)")
@@ -52,6 +76,13 @@ class AffiliateModel(BaseModel):
referral_bonus_credits: int = Field(..., ge=0, description="Ajánlási bónusz kreditek száma")
class MarketingModel(BaseModel):
"""Marketing információk egy előfizetési csomaghoz (kártya megjelenítéshez)."""
subtitle: Optional[str] = Field(default=None, description="Rövid leíró szöveg a kártyán (pl. 'Kisebb flotta esetén.')")
badge: Optional[str] = Field(default=None, description="Jelvény szöveg (pl. 'Legnépszerűbb', 'Pro')")
highlight_color: Optional[str] = Field(default=None, description="Kiemelő szín hex kódja (pl. '#70BC84')")
class SubscriptionRulesModel(BaseModel):
"""
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
@@ -59,11 +90,15 @@ class SubscriptionRulesModel(BaseModel):
Fields:
type: "private" (magánszemély) vagy "corporate" (vállalati)
display_name: Emberi olvasásra szánt megjelenítési név (pl. "Privát Ingyenes")
pricing: Árazási adatok (havi/éves díj, pénznem)
pricing: Árazási adatok (legacy, egyzónás) - deprecated, használd a pricing_zones-t
pricing_zones: Több régiós árazási adatok (országkód -> PricingZoneModel)
allowances: Korlátok (max jármű, garázs, kredit)
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
affiliate: Partnerprogram beállítások
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
duration: Előfizetés időtartamának beállításai (duration_days, stacking)
ad_policy: Hirdetési szabályok (show_ads, ad_free_grace_days)
marketing: Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)
"""
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
display_name: Optional[str] = Field(
@@ -72,7 +107,11 @@ class SubscriptionRulesModel(BaseModel):
)
pricing: Optional[PricingModel] = Field(
default=None,
description="Árazási adatok (havi/éves díj, pénznem). Opcionális a hiányos seed adatok miatt."
description="Árazási adatok (legacy, egyzónás). Deprecated: használd a pricing_zones-t.",
)
pricing_zones: Optional[Dict[str, PricingZoneModel]] = Field(
default=None,
description="Több régiós árazás. Kulcs: országkód (pl. 'HU', 'DE', 'DEFAULT').",
)
allowances: Optional[AllowancesModel] = Field(
default=None,
@@ -87,6 +126,18 @@ class SubscriptionRulesModel(BaseModel):
default=None,
description="Életciklus állapotok (is_public, available_until)"
)
duration: Optional[DurationModel] = Field(
default=None,
description="Előfizetés időtartamának beállításai (duration_days, allow_stacking)"
)
ad_policy: Optional[AdPolicyModel] = Field(
default=None,
description="Hirdetési szabályok (show_ads, ad_free_grace_days, max_daily_impressions)"
)
marketing: Optional[MarketingModel] = Field(
default=None,
description="Marketing adatok a kártya megjelenítéshez (subtitle, badge, highlight_color)"
)
@field_validator("type")
@classmethod
@@ -109,6 +160,23 @@ class SubscriptionTierResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class PublicSubscriptionTierResponse(BaseModel):
"""
Publikus előfizetési csomag válasz a frontend számára.
Tartalmazza a feloldott (resolved) árazást a felhasználó régiója alapján.
"""
id: int
name: str
rules: SubscriptionRulesModel
is_custom: bool
resolved_pricing: Optional[PricingZoneModel] = Field(
default=None,
description="A felhasználó régiójára feloldott árazás (egyetlen PricingZoneModel).",
)
model_config = ConfigDict(from_attributes=True)
class SubscriptionTierCreate(BaseModel):
"""Új előfizetési csomag létrehozása (POST /)."""
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")

View File

@@ -10,7 +10,10 @@ Architektúra:
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
{
"type": "private" | "corporate",
"pricing": {"monthly_price": float, "yearly_price": float, "currency": "EUR"},
"pricing_zones": {
"HU": {"monthly_price": 3000, "yearly_price": 32500, "currency": "HUF"},
"DEFAULT": {"monthly_price": 12.99, "yearly_price": 129.99, "currency": "EUR"}
},
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
"entitlements": ["SRV_..."],
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
@@ -31,6 +34,46 @@ logging.basicConfig(
logger = logging.getLogger("Seed-Packages")
# ---------------------------------------------------------------------------
# Pricing Zones helper
# ---------------------------------------------------------------------------
def pricing_zones(
eur_monthly: float,
eur_yearly: float,
eur_credit: int = 0,
huf_monthly: int = 0,
huf_yearly: int = 0,
huf_credit: int = 0,
usd_monthly: float = 0,
usd_yearly: float = 0,
usd_credit: int = 0,
) -> dict:
"""Create a pricing_zones dict with DEFAULT (EUR), HU (HUF), and optionally USD zones."""
zones = {
"DEFAULT": {
"monthly_price": eur_monthly,
"yearly_price": eur_yearly,
"currency": "EUR",
"credit_price": eur_credit if eur_credit > 0 else None,
},
}
if huf_monthly > 0:
zones["HU"] = {
"monthly_price": huf_monthly,
"yearly_price": huf_yearly,
"currency": "HUF",
"credit_price": huf_credit if huf_credit > 0 else None,
}
if usd_monthly > 0:
zones["US"] = {
"monthly_price": usd_monthly,
"yearly_price": usd_yearly,
"currency": "USD",
"credit_price": usd_credit if usd_credit > 0 else None,
}
return zones
# ---------------------------------------------------------------------------
# Csomagdefiníciók
# ---------------------------------------------------------------------------
@@ -46,6 +89,12 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 0,
},
"pricing_zones": pricing_zones(
eur_monthly=0.0,
eur_yearly=0.0,
huf_monthly=0,
huf_yearly=0,
),
"allowances": {
"max_vehicles": 1,
"max_garages": 1,
@@ -59,6 +108,19 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": True,
"ad_free_grace_days": 0,
"max_daily_impressions": 50,
},
"marketing": {
"subtitle": "Alap szintű járműkövetés kezdőknek.",
"badge": "Ingyenes",
},
},
"is_custom": False,
},
@@ -73,6 +135,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 500,
},
"pricing_zones": pricing_zones(
eur_monthly=4.99,
eur_yearly=49.99,
eur_credit=500,
huf_monthly=1890,
huf_yearly=18990,
huf_credit=500,
usd_monthly=5.99,
usd_yearly=59.99,
usd_credit=500,
),
"allowances": {
"max_vehicles": 3,
"max_garages": 1,
@@ -86,6 +159,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": True,
"ad_free_grace_days": 3,
"max_daily_impressions": 100,
},
"marketing": {
"subtitle": "Kisebb flotta esetén ideális választás.",
"badge": "Pro",
"highlight_color": "#70BC84",
},
},
"is_custom": False,
},
@@ -100,6 +187,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 1200,
},
"pricing_zones": pricing_zones(
eur_monthly=9.99,
eur_yearly=99.99,
eur_credit=1200,
huf_monthly=3790,
huf_yearly=37990,
huf_credit=1200,
usd_monthly=11.99,
usd_yearly=119.99,
usd_credit=1200,
),
"allowances": {
"max_vehicles": 10,
"max_garages": 1,
@@ -113,6 +211,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "VIP profi garázs kisvállalkozók számára.",
"badge": "VIP",
"highlight_color": "#F59E0B",
},
},
"is_custom": False,
},
@@ -127,6 +239,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 3000,
},
"pricing_zones": pricing_zones(
eur_monthly=29.99,
eur_yearly=299.99,
eur_credit=3000,
huf_monthly=11990,
huf_yearly=119990,
huf_credit=3000,
usd_monthly=34.99,
usd_yearly=349.99,
usd_credit=3000,
),
"allowances": {
"max_vehicles": 20,
"max_garages": 3,
@@ -140,6 +263,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "Közepes flották számára tervezve.",
"badge": "Prémium",
"highlight_color": "#3B82F6",
},
},
"is_custom": False,
},
@@ -154,6 +291,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 6000,
},
"pricing_zones": pricing_zones(
eur_monthly=59.99,
eur_yearly=599.99,
eur_credit=6000,
huf_monthly=23990,
huf_yearly=239990,
huf_credit=6000,
usd_monthly=69.99,
usd_yearly=699.99,
usd_credit=6000,
),
"allowances": {
"max_vehicles": 50,
"max_garages": 10,
@@ -171,6 +319,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "Nagy flották prémium menedzsmentje.",
"badge": "Plus",
"highlight_color": "#8B5CF6",
},
},
"is_custom": False,
},
@@ -185,6 +347,17 @@ PACKAGES = [
"currency": "EUR",
"credit_price": 15000,
},
"pricing_zones": pricing_zones(
eur_monthly=149.99,
eur_yearly=1499.99,
eur_credit=15000,
huf_monthly=59990,
huf_yearly=599990,
huf_credit=15000,
usd_monthly=169.99,
usd_yearly=1699.99,
usd_credit=15000,
),
"allowances": {
"max_vehicles": 200,
"max_garages": 50,
@@ -203,6 +376,20 @@ PACKAGES = [
"lifecycle": {
"is_public": True,
},
"duration": {
"days": 30,
"allow_stacking": True,
},
"ad_policy": {
"show_ads": False,
"ad_free_grace_days": 0,
"max_daily_impressions": None,
},
"marketing": {
"subtitle": "Vállalati szintű flottairányítás.",
"badge": "VIP",
"highlight_color": "#EF4444",
},
},
"is_custom": False,
},
@@ -215,28 +402,31 @@ async def seed():
logger.info("=" * 60)
async with AsyncSessionLocal() as db:
# 1. Töröljük a meglévő rekordokat (tiszta lap)
logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
await db.execute(delete(SubscriptionTier))
await db.commit()
logger.info("✅ Meglévő rekordok törölve.")
# 1. UPSERT: Meglévő csomagok frissítése vagy új beszúrása
from sqlalchemy.dialects.postgresql import insert as pg_insert
# 2. Beszúrjuk a 6 új csomagot
inserted_count = 0
upserted_count = 0
for pkg in PACKAGES:
tier = SubscriptionTier(
stmt = pg_insert(SubscriptionTier).values(
name=pkg["name"],
rules=pkg["rules"],
is_custom=pkg["is_custom"],
)
db.add(tier)
inserted_count += 1
logger.info(f" Hozzáadva: {pkg['name']}")
stmt = stmt.on_conflict_do_update(
index_elements=[SubscriptionTier.name],
set_={
"rules": stmt.excluded.rules,
"is_custom": stmt.excluded.is_custom,
}
)
await db.execute(stmt)
upserted_count += 1
logger.info(f" Upsert: {pkg['name']}")
await db.commit()
logger.info(f"{inserted_count} csomag sikeresen beszúrva.")
logger.info(f"{upserted_count} csomag sikeresen upsertelve.")
# 3. Visszaigazolás: listázzuk ki az összes rekordot
# 2. Visszaigazolás
logger.info("-" * 60)
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
logger.info("-" * 60)
@@ -256,7 +446,7 @@ async def seed():
logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
# 4. JSONB példa kiírása
# 3. JSONB példa kiírása
if tiers:
sample = tiers[0]
logger.info("=" * 60)

View File

@@ -494,9 +494,11 @@ class AssetService:
Get the vehicle limit for a user, checking:
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
2. Organization-level base_asset_limit (fallback if no user subscription)
3. Config-based limits (legacy fallback)
3. P0 BOOSTER: extra_allowances from finance.org_subscriptions (additive on top of tier)
4. Config-based limits (legacy fallback)
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
P0 Booster: extra_allowances.extra_vehicles is added on top of the tier limit.
Legacy string-based subscription_plan lookups have been removed.
Args:
@@ -508,7 +510,7 @@ class AssetService:
Maximum allowed vehicles
"""
from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier
from app.models.core_logic import UserSubscription, SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization
from app.services.config_service import config
@@ -577,7 +579,33 @@ class AssetService:
except Exception as e:
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
# ── 3. P0 BOOSTER: extra_allowances from finance.org_subscriptions ──
# Extra allowances are additive on top of the tier limit.
# Read from the active org subscription's extra_allowances JSONB.
extra_vehicles = 0
try:
org_sub_stmt = (
select(OrganizationSubscription.extra_allowances)
.where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True
)
.limit(1)
)
org_sub_result = await db.execute(org_sub_stmt)
extra_allowances = org_sub_result.scalar_one_or_none()
if extra_allowances and isinstance(extra_allowances, dict):
extra_vehicles = int(extra_allowances.get('extra_vehicles', 0))
if extra_vehicles > 0:
logger.info(
f"[P0 BOOSTER] Extra vehicles for org {org_id}: "
f"extra_vehicles={extra_vehicles} (from extra_allowances JSONB)"
)
except Exception as e:
logger.debug(f"Could not read extra_allowances for org {org_id}: {e}")
# ── 4. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
config_limit = None
try:
limits = await config.get_setting(db, "VEHICLE_LIMIT")
@@ -591,16 +619,20 @@ class AssetService:
if config_limit is None:
config_limit = 1 # absolute fallback
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
# ── 5. FINAL: Take the HIGHEST of all applicable limits, then ADD boosters ──
final_limit = config_limit
if org_limit is not None:
final_limit = max(final_limit, org_limit)
if subscription_limit is not None:
final_limit = max(final_limit, subscription_limit)
# Add extra_allowances on top of the base limit
final_limit = final_limit + extra_vehicles
logger.info(
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"subscription_tier={subscription_limit}, org={org_limit}, "
f"extra_vehicles={extra_vehicles}, config={config_limit}, "
f"final={final_limit}"
)

View File

@@ -727,6 +727,9 @@ async def upgrade_subscription(
"""
Felhasználó előfizetésének frissítése (csomagváltás).
P0 Stacking Feature: A duration_days mezőt a tier.rules-ból olvassa ki,
és a valid_until számításánál időhalmozást (stacking) alkalmaz.
Args:
db: Database session
user_id: Felhasználó ID
@@ -735,7 +738,7 @@ async def upgrade_subscription(
Returns:
Dict: Tranzakció részletei és az új előfizetés információi
"""
from app.models.core_logic import SubscriptionTier
from app.models.core_logic import SubscriptionTier, UserSubscription
from app.models.identity import User
# 1. Ellenőrizze, hogy a cél csomag létezik-e
@@ -746,23 +749,67 @@ async def upgrade_subscription(
if not tier:
raise ValueError(f"Subscription tier '{target_package}' not found")
# ── P0 STACKING: duration_days kiolvasása a tier.rules-ból ──
duration_days = 30 # alapértelmezett 30 nap
allow_stacking = True
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
duration_days = duration_config.get("days", 30)
allow_stacking = duration_config.get("allow_stacking", True)
now = datetime.utcnow()
# 2. Számítsa ki az árát a csomagnak (egyszerűsítve: fix ár a tier.rules-ból)
price = tier.rules.get("price", 0.0) if tier.rules else 0.0
if price <= 0:
# Ingyenes csomag, nincs levonás
logger.info(f"Upgrading user {user_id} to free tier {target_package}")
# Frissítse a felhasználó subscription_plan mezőjét
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one()
user.subscription_plan = target_package
user.subscription_expires_at = datetime.utcnow() + timedelta(days=30) # 30 nap
# ── P0 STACKING: user_subscriptions tábla kezelése ──
# Lekérdezzük a meglévő aktív user subscription-t
us_stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
us_result = await db.execute(us_stmt)
existing_us = us_result.scalar_one_or_none()
if existing_us:
existing_us.is_active = False
# Stacking számítás
if existing_us and allow_stacking and existing_us.valid_until and existing_us.valid_until > now:
new_valid_until = existing_us.valid_until + timedelta(days=duration_days)
else:
new_valid_until = now + timedelta(days=duration_days)
# Új UserSubscription rekord
new_us = UserSubscription(
user_id=user_id,
tier_id=tier.id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_us)
user.subscription_expires_at = new_valid_until
return {
"success": True,
"message": f"Upgraded to {target_package} (free)",
"new_plan": target_package,
"price_paid": 0.0
"price_paid": 0.0,
"valid_until": new_valid_until.isoformat(),
"duration_days": duration_days,
"stacking_applied": allow_stacking and bool(existing_us and existing_us.valid_until and existing_us.valid_until > now)
}
# 3. Ár kiszámítása a PricingCalculator segítségével
@@ -789,15 +836,51 @@ async def upgrade_subscription(
# 5. Frissítse a felhasználó előfizetési adatait
user.subscription_plan = target_package
user.subscription_expires_at = datetime.utcnow() + timedelta(days=30) # 30 nap
logger.info(f"User {user_id} upgraded to {target_package} for {final_price} EUR")
# ── P0 STACKING: user_subscriptions tábla kezelése ──
us_stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
us_result = await db.execute(us_stmt)
existing_us = us_result.scalar_one_or_none()
if existing_us:
existing_us.is_active = False
# Stacking számítás
if existing_us and allow_stacking and existing_us.valid_until and existing_us.valid_until > now:
new_valid_until = existing_us.valid_until + timedelta(days=duration_days)
else:
new_valid_until = now + timedelta(days=duration_days)
# Új UserSubscription rekord
new_us = UserSubscription(
user_id=user_id,
tier_id=tier.id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_us)
user.subscription_expires_at = new_valid_until
logger.info(
f"User {user_id} upgraded to {target_package} for {final_price} EUR, "
f"valid_until={new_valid_until.isoformat()}, "
f"duration_days={duration_days}, "
f"stacking={allow_stacking}"
)
return {
"success": True,
"transaction": transaction,
"new_plan": target_package,
"price_paid": final_price,
"valid_until": new_valid_until.isoformat(),
"duration_days": duration_days,
"stacking_applied": allow_stacking and bool(existing_us and existing_us.valid_until and existing_us.valid_until > now),
"expires_at": user.subscription_expires_at.isoformat()
}
@@ -816,6 +899,12 @@ async def upgrade_org_subscription(
subscription_tier_id FK on the Organization record, and also creates/
updates a record in finance.org_subscriptions for audit trail.
P0 Stacking Feature: A duration_days mezőt a tier.rules-ból olvassa ki,
és a valid_until számításánál időhalmozást (stacking) alkalmaz:
- Ha a meglévő előfizetés még aktív (valid_until > NOW):
új valid_until = régi valid_until + duration_days
- Ha lejárt vagy nincs: új valid_until = NOW + duration_days
Args:
db: Database session
org_id: Organization ID
@@ -860,24 +949,54 @@ async def upgrade_org_subscription(
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
# ── P0 STACKING: duration_days kiolvasása a tier.rules-ból ──
duration_days = 30 # alapértelmezett 30 nap
allow_stacking = True
if tier.rules:
duration_config = tier.rules.get("duration", {})
if isinstance(duration_config, dict):
duration_days = duration_config.get("days", 30)
allow_stacking = duration_config.get("allow_stacking", True)
now = datetime.utcnow()
if existing_sub:
# Deaktiváljuk a régit
existing_sub.is_active = False
existing_sub.valid_until = datetime.utcnow()
existing_sub.valid_until = now
# Új aktív subscription rekord
# ── P0 STACKING: valid_until számítás időhalmozással ──
if existing_sub and allow_stacking and existing_sub.valid_until and existing_sub.valid_until > now:
# Stacking: a meglévő valid_until-hoz hozzáadjuk a duration_days-t
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
logger.info(
f"STACKING: org_id={org_id} existing valid_until={existing_sub.valid_until} "
f"+ {duration_days} days = {new_valid_until}"
)
else:
# Nincs stacking: mostól számítva + duration_days
new_valid_until = now + timedelta(days=duration_days)
# Új aktív subscription rekord stackinggel számolt valid_until-lal
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=tier_id,
valid_from=datetime.utcnow(),
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_sub)
# Frissítsük a szervezet denormalizált mezőit is
org.subscription_expires_at = new_valid_until
logger.info(
f"Org subscription upgraded: org_id={org_id}, "
f"tier={tier.name} (id={tier_id}), "
f"max_vehicles={max_vehicles}, "
f"valid_until={new_valid_until.isoformat()}, "
f"duration_days={duration_days}, "
f"stacking={allow_stacking}, "
f"actor={actor_user_id}"
)
@@ -887,6 +1006,9 @@ async def upgrade_org_subscription(
"tier_id": tier_id,
"tier_name": tier.name,
"max_vehicles": max_vehicles,
"valid_until": new_valid_until.isoformat(),
"duration_days": duration_days,
"stacking_applied": allow_stacking and bool(existing_sub and existing_sub.valid_until and existing_sub.valid_until > now),
"message": f"Organization {org_id} upgraded to {tier.name}"
}

View File

@@ -0,0 +1,259 @@
# P0 Architecture Analysis — `sf_card.svg` → `BaseCard.vue`
**Dátum:** 2026-06-18
**Szerző:** Fast Coder (Core Developer)
**Státusz:** Elemzés kész — Jóváhagyásra vár
**Cél:** Az [`sf_card.svg`](Pictures/sf_card.svg) vizuális paramétereinek elemzése és az univerzális `BaseCard.vue` komponens megvalósítási tervének elkészítése.
---
## 1. SVG Vizuális Elemzés
### 1.1 Fájl Áttekintés
| Tulajdonság | Érték |
|-------------|-------|
| **Dokumentum típusa** | Inkscape SVG (v1.4.3) |
| **Méret** | 959 × 1322 px |
| **Rétegek** | 1 réteg (`g1` — "Image") |
| **Vektoros elemek** | 2 darab `<path>` elem |
| **Komplexitás** | Alacsony — tiszta geometriai formák, nincs gradient, nincs pattern |
### 1.2 Pontos Színkódok
| Elem | HEX | RGB | Leírás |
|------|-----|-----|--------|
| **Kártya teste (felső rész)** | `#306081` | `rgb(48, 96, 129)` | Sötét kékesszürke — a kártya alsó/fő panel színe |
| **Kártya teste (alsó rész)** | `#ffffff` | `rgb(255, 255, 255)` | Fehér — a kártya felső panel színe (a path-ok közötti üres terület) |
| **Háttér (pagecolor)** | `#ffffff` | `rgb(255, 255, 255)` | Inkscape vászon színe |
> **Megjegyzés:** Az SVG-ben nincs explicit gradient vagy árnyék. A kártya vizuálisan két színblokkból áll: egy sötétkék (`#306081`) alsó rész és egy fehér felső rész, amelyeket a két `<path>` elem határol.
### 1.3 Geometriai Elemzés
#### Path 1 — Alsó kártyarész (sötétkék panel)
```svg
<path style="fill:#306081"
d="m 168.62859,1261.8279 c -12.47141,-1.784 -25.24506,-11.5824 -31.03975,-23.81
L 134.5,1231.5 v -50.5204 -50.5205 l 3.35087,-6.8071
c 6.04108,-12.2723 16.85005,-21.1247 28.92039,-23.6854
3.12028,-0.662 110.34418,-0.912 315.22874,-0.7349
l 310.5,0.2683 5.78122,2.709
c 11.5255,5.4006 22.2104,18.6273 24.47002,30.291
0.56162,2.899 0.87028,24.5394 0.73454,51.5
-0.25606,50.8602 -0.20337,50.3688 -6.36068,59.3182
-6.1975,9.0077 -18.01232,16.8142 -27.55689,18.2078
-5.68739,0.8304 -615.17342,1.1267 -620.93962,0.3019 z" />
```
- **Pozíció:** A kártya alján helyezkedik el
- **Sarokkerekítés:** Enyhén lekerekített sarkok (bezier görbék)
- **Felső szegély:** Íves átmenet a fehér rész felé (enyhe "hullám")
#### Path 2 — Felső kártyarész (fehér panel)
```svg
<path style="fill:#ffffff"
d="M 2.25,185.33772 C 0.76962389,184.74037 0.38857818,47.208733 1.8590642,44.236431
2.3315495,43.281394 3.4982244,40.025 4.4516751,37
6.7993569,29.551542 13.230286,19.833498 19.405639,14.402448
25.056145,9.4329844 36.999577,3 40.57534,3
41.843984,3 43.160081,2.55 43.5,2
44.357522,0.61249997 915,0.61249997 915,2
c 0,0.55 1.05312,1 2.34028,1
4.22935,0 14.80939,6.1765597 21.67638,12.654527
7.89958,7.452064 13.30582,16.865801 14.84285,25.845473
1.00124,5.849387 1.33477,138.95439 0.35771,142.75
-0.42827,1.66372 -23.87631,1.74611 -475.58385,1.67105
C 217.31002,185.87763 2.9375,185.61513 2.25,185.33772 Z" />
```
- **Pozíció:** A kártya tetején helyezkedik el
- **Felső sarkok:** Enyhén lekerekítettek (bezier görbék)
- **Alsó szegély:** Íves átmenet a sötétkék rész felé
### 1.4 Következtetés az SVG-ről
Az SVG egy **klasszikus "kártya" formát** ábrázol, amely két színblokkból áll:
1. **Fehér felső rész** — a tartalom fő területe
2. **Sötétkék (`#306081`) alsó rész** — footer/akciósáv
A forma **nem tartalmaz** gradient-eket, árnyékokat, ikonokat vagy komplex vektorgrafikát. A kártya **100%-ban reprodukálható tiszta CSS-sel** (Tailwind osztályokkal), az SVG beágyazása nem szükséges.
---
## 2. Meglévő Komponensek Auditja
### 2.1 Felfedezett Kártya Komponensek
| Komponens | Fájl | Header | Body | Footer | Magasság |
|-----------|------|--------|------|--------|----------|
| **GlassCard** | [`ui/GlassCard.vue`](frontend/src/components/ui/GlassCard.vue) | ❌ (csak slot) | ✅ slot | ❌ | Változó |
| **FinancialCard** | [`dashboard/FinancialCard.vue`](frontend/src/components/dashboard/FinancialCard.vue) | ✅ `h-12 bg-slate-700` | ✅ | ✅ CTA gomb | `h-[350px]` |
| **MyVehiclesCard** | [`dashboard/MyVehiclesCard.vue`](frontend/src/components/dashboard/MyVehiclesCard.vue) | ✅ `h-12 bg-slate-700` | ✅ | ❌ | `h-[350px]` |
| **CostsActionsCard** | [`dashboard/CostsActionsCard.vue`](frontend/src/components/dashboard/CostsActionsCard.vue) | ✅ `h-12 bg-slate-700` | ✅ | ❌ | `h-[350px]` |
| **ServiceFinderCard** | [`dashboard/ServiceFinderCard.vue`](frontend/src/components/dashboard/ServiceFinderCard.vue) | ✅ `h-12 bg-slate-700` | ✅ | ❌ | `h-[350px]` |
| **GamificationCard** | [`dashboard/GamificationCard.vue`](frontend/src/components/dashboard/GamificationCard.vue) | ✅ `h-12 bg-slate-700` | ✅ | ✅ CTA gomb | `h-[350px]` |
| **ProfileTrustCard** | [`dashboard/ProfileTrustCard.vue`](frontend/src/components/dashboard/ProfileTrustCard.vue) | ✅ `h-12 bg-slate-700` | ✅ | ✅ CTA gomb | `h-[350px]` |
| **SubscriptionPlansView** | [`views/SubscriptionPlansView.vue`](frontend/src/views/SubscriptionPlansView.vue) | ❌ (inline div) | ✅ | ✅ gomb | Változó |
### 2.2 Duplikált Kód Minta
Minden dashboard kártya (6 db) **szinte azonos** külső struktúrát használ:
```html
<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">
<!-- 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">ICON {{ t('title') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<!-- ... egyedi tartalom ... -->
</div>
</div>
```
**Ez 6× duplikált kód.** A `BaseCard.vue` bevezetésével ez egyetlen komponensre redukálható.
### 2.3 GlassCard Elemzése
A [`GlassCard.vue`](frontend/src/components/ui/GlassCard.vue) egy üvegdesign (glassmorphism) kártya, amely:
- `backdrop-blur-xl` és `bg-white/[0.05]` átlátszóságot használ
- Két variánsa van: `standard` és `premium` (gradiens border)
- **Nincs** beépített header/footer slot-ja — csak egyetlen `<slot />`
- **Nem kompatibilis** a dashboard kártyák struktúrájával (azok tömör fehér hátteret használnak)
**Következtetés:** A `GlassCard` egy másik design rendszerhez készült (átlátszó/üveg). A dashboard kártyákhoz egy **új, dedikált `BaseCard`** komponens kell, amely a meglévő `bg-white/95` stílust használja.
---
## 3. BaseCard.vue — Megvalósítási Terv
### 3.1 Tervezési Döntések
| Kérdés | Döntés | Indoklás |
|--------|--------|----------|
| **SVG beágyazás?** | ❌ **Nem** | Az SVG csak 2 színblokk — 100%-ban CSS-sel reprodukálható |
| **Tailwind CSS?** | ✅ **Igen** | A meglévő kártyák is Tailwind-et használnak |
| **Slot struktúra?** | ✅ **3 slot** | `header`, `body` (default), `footer` |
| **Magasság?** | Prop: `height` | Alapértelmezett `h-[350px]`, de felülírható |
| **Hover effekt?** | Prop: `hoverable` | A dashboard kártyák hover animációt használnak |
| **Színrendszer?** | Prop: `headerColor` | Alapértelmezett `bg-slate-700`, de testreszabható |
### 3.2 Tervezett Komponens Struktúra
```vue
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)]
overflow-hidden flex flex-col relative
transition-all duration-300 ease-out"
:class="[
heightClass,
hoverable ? 'cursor-pointer hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl' : '',
clickable ? 'cursor-pointer' : '',
customClass,
]"
@click="$emit('click')"
>
<!-- HEADER SLOT -->
<div
v-if="$slots.header"
class="h-12 w-full shrink-0 flex items-center px-4"
:class="headerColorClass"
>
<slot name="header" />
</div>
<!-- BODY SLOT (default) -->
<div
class="flex-1 flex flex-col text-slate-800 overflow-hidden"
:class="bodyPaddingClass"
>
<slot />
</div>
<!-- FOOTER SLOT -->
<div v-if="$slots.footer" class="shrink-0 px-4 pb-4">
<slot name="footer" />
</div>
</div>
</template>
```
### 3.3 Props Tervezés
| Prop | Típus | Alapértelmezett | Leírás |
|------|-------|-----------------|--------|
| `hoverable` | `boolean` | `false` | Hover animáció (emelés + árnyék) |
| `clickable` | `boolean` | `false` | cursor-pointer (ha nincs hover) |
| `height` | `string` | `'h-[350px]'` | Kártya magasság (Tailwind class) |
| `headerColor` | `string` | `'bg-slate-700'` | Header háttérszín |
| `bodyPadding` | `'sm' \| 'md' \| 'lg' \| 'none'` | `'md'` | Body belső padding |
| `customClass` | `string` | `''` | Extra CSS osztályok |
### 3.4 Slots Tervezés
| Slot neve | Kötelező? | Használat |
|-----------|-----------|-----------|
| `header` | ❌ | Kártya fejléc (ikon + cím + badge-ek) |
| `default` (body) | ✅ | Fő tartalom |
| `footer` | ❌ | CTA gombok, akciósáv |
### 3.5 Migrációs Terv — Dashboard Kártyák
| Jelenlegi komponens | Header tartalom | Footer | Migráció |
|--------------------|-----------------|--------|----------|
| `FinancialCard.vue` | `💰 Wallet` | ✅ "Top Up" gomb | Header → slot, Footer → slot |
| `MyVehiclesCard.vue` | `🚗 My Vehicles` + count | ❌ | Header → slot |
| `CostsActionsCard.vue` | `💰 Costs` | ❌ | Header → slot |
| `ServiceFinderCard.vue` | `🔍 Service Finder` | ❌ | Header → slot |
| `GamificationCard.vue` | `🏆 Stats & Points` + "Live" badge | ✅ "Refresh" gomb | Header → slot, Footer → slot |
| `ProfileTrustCard.vue` | `🛡️ Profile` | ✅ "Profile →" gomb | Header → slot, Footer → slot |
### 3.6 SubscriptionPlansView Kártyák
A [`SubscriptionPlansView.vue`](frontend/src/views/SubscriptionPlansView.vue) jelenleg **inline** `<div>`-eket használ kártyaként:
```html
<div class="rounded-2xl border-2 border-slate-800 bg-white p-6 flex flex-col
shadow-lg hover:shadow-xl transition-all duration-200"
:class="{ 'ring-2 ring-emerald-500 border-emerald-500': ... }">
```
Ezek **nem** használják a dashboard kártyák header/footer struktúráját. A `BaseCard` itt is használható lenne, de alacsonyabb prioritású, mivel a subscription kártyák más layoutot igényelnek (nincs header sáv, helyette cím + ár + feature lista).
### 3.7 Létrehozandó Fájlok
| Fájl | Művelet | Leírás |
|------|---------|--------|
| [`frontend/src/components/ui/BaseCard.vue`](frontend/src/components/ui/) | **Új** | Univerzális kártya komponens 3 slot-tal |
| `frontend/src/components/dashboard/FinancialCard.vue` | **Refaktor** | BaseCard használata |
| `frontend/src/components/dashboard/MyVehiclesCard.vue` | **Refaktor** | BaseCard használata |
| `frontend/src/components/dashboard/CostsActionsCard.vue` | **Refaktor** | BaseCard használata |
| `frontend/src/components/dashboard/ServiceFinderCard.vue` | **Refaktor** | BaseCard használata |
| `frontend/src/components/dashboard/GamificationCard.vue` | **Refaktor** | BaseCard használata |
| `frontend/src/components/dashboard/ProfileTrustCard.vue` | **Refaktor** | BaseCard használata |
---
## 4. Összefoglalás
### Az SVG-ből kinyert vizuális paraméterek:
- **Fő szín:** `#306081` (sötét kékesszürke) — header/footer háttér
- **Másodlagos szín:** `#ffffff` (fehér) — body háttér
- **Sarokkerekítés:** `rounded-2xl` (~16px) — megfelel a meglévő dashboard kártyáknak
- **Forma:** Tiszta geometriai doboz, íves átmenettel a két szín között
### Javaslat:
1. **Hozzuk létre** a [`BaseCard.vue`](frontend/src/components/ui/BaseCard.vue) komponenst a fenti terv alapján
2. **Refaktoráljuk** a 6 dashboard kártyát a `BaseCard` használatára
3. **Ne ágyazzuk be** az SVG-t — a CSS/Tailwind megoldás elegendő és flexibilisebb
4. A `SubscriptionPlansView` kártyáit **később** lehet átállítani, mivel azok más struktúrát használnak
### Függőségek:
- **Bemenet:** Jóváhagyás a Tervezőtől a fenti tervre
- **Kimenet:** A dashboard kártyák egységes megjelenése, könnyebb karbantarthatóság

View File

@@ -4,7 +4,7 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-gray-800 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="bg-gray-800 border border-gray-700 rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
<h2 class="text-xl font-bold text-white">
@@ -254,6 +254,157 @@
</div>
</div>
<!-- Marketing Section -->
<div class="border-t border-gray-700 pt-4">
<h3 class="text-md font-semibold text-gray-200 mb-3 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
Marketing & Megjelenítés
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Alcím (Subtitle)
</label>
<input
v-model="form.marketing_subtitle"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Pl. Kisebb flotta esetén."
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Jelvény (Badge)
</label>
<input
v-model="form.marketing_badge"
type="text"
class="w-full px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Pl. Legnépszerűbb"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-1.5">
Kiemelő szín (Hex)
</label>
<div class="flex gap-2">
<input
v-model="form.marketing_highlight_color"
type="color"
class="w-10 h-10 p-0.5 bg-gray-700 border border-gray-600 rounded-lg cursor-pointer"
/>
<input
v-model="form.marketing_highlight_color"
type="text"
class="flex-1 px-4 py-2.5 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="#70BC84"
/>
</div>
</div>
</div>
</div>
<!-- Pricing Zones Section -->
<div class="border-t border-gray-700 pt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-md font-semibold text-gray-200 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Több régiós árazás (Pricing Zones)
</h3>
<button
class="px-3 py-1.5 text-xs font-medium text-green-300 bg-green-900/30 border border-green-700 rounded-lg hover:bg-green-900/50 transition-colors"
@click="addPricingZone"
>
+ Zóna hozzáadása
</button>
</div>
<p class="text-xs text-gray-500 mb-3">
Ország-specifikus árazás. Kulcs: ISO 3166-1 alpha-3 országkód (pl. HU, DE, US) vagy "DEFAULT" az alapértelmezett zónához.
</p>
<!-- Zone List -->
<div v-if="pricingZones.length === 0" class="text-center py-4 text-gray-500 text-sm">
Nincsenek árazási zónák beállítva. Kattints a "+ Zóna hozzáadása" gombra.
</div>
<div
v-for="(zone, index) in pricingZones"
:key="index"
class="mb-3 p-3 bg-gray-750 border border-gray-650 rounded-lg"
:style="{ borderColor: zone.currency === 'HUF' ? '#065F46' : zone.currency === 'USD' ? '#1E3A5F' : '#374151' }"
>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-gray-400">#{{ index + 1 }}</span>
<input
v-model="zone.country_code"
type="text"
class="w-24 px-2 py-1 text-sm font-mono bg-gray-700 border border-gray-600 rounded text-gray-100 uppercase placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="HU"
maxlength="10"
/>
</div>
<button
class="p-1 text-gray-500 hover:text-red-400 hover:bg-red-900/30 rounded transition-colors"
@click="removePricingZone(index)"
title="Zóna eltávolítása"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
<div>
<label class="block text-xs text-gray-400 mb-0.5">Havi ár</label>
<input
v-model.number="zone.monthly_price"
type="number"
step="0.01"
min="0"
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs text-gray-400 mb-0.5">Éves ár</label>
<input
v-model.number="zone.yearly_price"
type="number"
step="0.01"
min="0"
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-xs text-gray-400 mb-0.5">Pénznem</label>
<select
v-model="zone.currency"
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="EUR">EUR</option>
<option value="HUF">HUF</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-400 mb-0.5">Kredit ár</label>
<input
v-model.number="zone.credit_price"
type="number"
min="0"
class="w-full px-2 py-1.5 text-sm bg-gray-700 border border-gray-600 rounded text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
<!-- Error Display -->
<div
v-if="errorMessage"
@@ -311,6 +462,16 @@ const store = useAdminPackagesStore()
const isEditing = computed(() => !!props.package)
// ── Pricing Zone Form Item Interface ────────────────────────────────────────
interface PricingZoneFormItem {
country_code: string
monthly_price: number
yearly_price: number
currency: string
credit_price: number | null
}
// ── Form State ──────────────────────────────────────────────────────────────
const form = ref({
@@ -328,10 +489,15 @@ const form = ref({
available_until: '' as string,
commission_rate_percent: 0,
referral_bonus_credits: 0,
// Marketing fields
marketing_subtitle: '' as string,
marketing_badge: '' as string,
marketing_highlight_color: '' as string,
})
const selectedEntitlements = ref<string[]>([])
const entitlementToAdd = ref('')
const pricingZones = ref<PricingZoneFormItem[]>([])
const isSaving = ref(false)
const errorMessage = ref<string | null>(null)
@@ -367,6 +533,25 @@ watch(
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
selectedEntitlements.value = [...rules.entitlements]
// Marketing fields
form.value.marketing_subtitle = rules.marketing?.subtitle || ''
form.value.marketing_badge = rules.marketing?.badge || ''
form.value.marketing_highlight_color = rules.marketing?.highlight_color || ''
// Pricing Zones
pricingZones.value = []
if (rules.pricing_zones) {
for (const [countryCode, zone] of Object.entries(rules.pricing_zones)) {
pricingZones.value.push({
country_code: countryCode,
monthly_price: zone.monthly_price,
yearly_price: zone.yearly_price,
currency: zone.currency,
credit_price: zone.credit_price ?? null,
})
}
}
} else {
resetForm()
}
@@ -390,8 +575,12 @@ function resetForm() {
available_until: '',
commission_rate_percent: 0,
referral_bonus_credits: 0,
marketing_subtitle: '',
marketing_badge: '',
marketing_highlight_color: '',
}
selectedEntitlements.value = []
pricingZones.value = []
errorMessage.value = null
}
@@ -409,6 +598,22 @@ function removeEntitlement(ent: string) {
selectedEntitlements.value = selectedEntitlements.value.filter((e) => e !== ent)
}
// ── Pricing Zone Management ─────────────────────────────────────────────────
function addPricingZone() {
pricingZones.value.push({
country_code: '',
monthly_price: 0,
yearly_price: 0,
currency: 'EUR',
credit_price: null,
})
}
function removePricingZone(index: number) {
pricingZones.value.splice(index, 1)
}
// ── Build Rules Object ──────────────────────────────────────────────────────
function buildRules(): SubscriptionRulesModel {
@@ -445,6 +650,40 @@ function buildRules(): SubscriptionRulesModel {
lifecycle.available_until = form.value.available_until
}
rules.lifecycle = lifecycle as any
// Marketing
const marketing: Record<string, string> = {}
if (form.value.marketing_subtitle.trim()) {
marketing.subtitle = form.value.marketing_subtitle.trim()
}
if (form.value.marketing_badge.trim()) {
marketing.badge = form.value.marketing_badge.trim()
}
if (form.value.marketing_highlight_color.trim()) {
marketing.highlight_color = form.value.marketing_highlight_color.trim()
}
if (Object.keys(marketing).length > 0) {
rules.marketing = marketing as any
}
// Pricing Zones
const validZones = pricingZones.value.filter((z) => z.country_code.trim().length > 0)
if (validZones.length > 0) {
const zones: Record<string, any> = {}
for (const zone of validZones) {
const z: Record<string, any> = {
monthly_price: zone.monthly_price,
yearly_price: zone.yearly_price,
currency: zone.currency,
}
if (zone.credit_price !== null && zone.credit_price >= 0) {
z.credit_price = zone.credit_price
}
zones[zone.country_code.trim().toUpperCase()] = z
}
rules.pricing_zones = zones as any
}
return rules
}

View File

@@ -0,0 +1,133 @@
<template>
<div class="ad-placement-widget">
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-4"
>
<div class="h-6 w-6 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
</div>
<!-- Error State -->
<div
v-else-if="error"
class="text-xs text-red-400/60 text-center py-2"
>
{{ error }}
</div>
<!-- Empty State (no ads available) -->
<div
v-else-if="ads.length === 0"
class="block w-full"
>
<!-- Empty block no ad to display, takes no visual space -->
</div>
<!-- Ad Display -->
<div
v-else
class="ad-content"
>
<div
v-for="ad in ads"
:key="ad.id"
class="mb-2 last:mb-0"
>
<!-- Image Ad -->
<a
v-if="ad.creative_type === 'IMAGE' && ad.content_url"
:href="ad.target_url || '#'"
target="_blank"
rel="noopener noreferrer"
class="block rounded-lg overflow-hidden border border-white/10 hover:border-white/20 transition-all duration-200"
>
<img
:src="ad.content_url"
:alt="ad.alt_text || ad.campaign_name"
class="w-full h-auto object-cover"
loading="lazy"
/>
</a>
<!-- HTML Snippet Ad -->
<div
v-else-if="ad.creative_type === 'HTML_SNIPPET' && ad.html_snippet"
v-html="ad.html_snippet"
class="rounded-lg overflow-hidden"
/>
<!-- Fallback: text-only ad -->
<div
v-else
class="text-xs text-white/40 text-center py-1"
>
{{ ad.campaign_name }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '../../api/axios'
interface AdData {
id: number
campaign_id: number
campaign_name: string
creative_id: number
creative_type: string
content_url: string | null
html_snippet: string | null
alt_text: string | null
target_url: string | null
priority: number
}
interface PlacementResponse {
zone: string
ads: AdData[]
}
const props = withDefaults(defineProps<{
zone?: string
}>(), {
zone: 'dashboard_widget',
})
const isLoading = ref(true)
const error = ref<string | null>(null)
const ads = ref<AdData[]>([])
async function fetchAds() {
isLoading.value = true
error.value = null
try {
const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`)
ads.value = res.data.ads || []
} catch (err: any) {
// Silently handle 404 (placement not configured yet)
if (err.response?.status === 404) {
ads.value = []
} else {
error.value = 'Failed to load ads'
console.error('[AdPlacementWidget] Error:', err)
}
} finally {
isLoading.value = false
}
}
onMounted(() => {
fetchAds()
})
</script>
<style scoped>
.ad-placement-widget {
width: 100%;
}
</style>

View File

@@ -1,54 +1,57 @@
<template>
<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"
<BaseCard
hoverable
@click="$emit('open-card', 'stats')"
>
<!-- Top header bar -->
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
<!-- HEADER: Profile title -->
<template #header>
<span class="text-white font-bold text-sm tracking-wide">🛡 {{ t('header.profile') }}</span>
</div>
<!-- Content -->
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
<div class="flex-1 space-y-3">
<!-- User info -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
</template>
<!-- BODY: User info + Trust Score + Stats -->
<div class="flex-1 space-y-3">
<!-- User info -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">{{ authStore.userName || t('dashboard.guest') }}</p>
<p class="text-xs text-slate-400 mt-0.5">{{ authStore.user?.email }}</p>
</div>
<!-- Trust Score -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
<span class="text-sm font-bold text-emerald-600">A+</span>
</div>
<!-- Trust Score -->
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-slate-500 font-semibold uppercase tracking-wider">Trust Score</span>
<span class="text-sm font-bold text-emerald-600">A+</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
</div>
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full rounded-full bg-gradient-to-r from-emerald-400 to-emerald-600 transition-all duration-500" style="width: 92%" />
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
</div>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-2 gap-2">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ authStore.myOrganizations.length }}</p>
<p class="text-xs text-slate-400">{{ t('header.myCompanies') }}</p>
</div>
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2 text-center">
<p class="text-lg font-bold text-slate-800">{{ vehicleStore.vehicles.length }}</p>
<p class="text-xs text-slate-400">{{ t('dashboard.totalVehicles') }}</p>
</div>
</div>
</div>
<!-- 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>
<!-- FOOTER: CTA button -->
<template #footer>
<button class="w-full 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>
</template>
</BaseCard>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import { useVehicleStore } from '../../stores/vehicle'
import BaseCard from '../ui/BaseCard.vue'
const { t } = useI18n()
const authStore = useAuthStore()

View File

@@ -0,0 +1,139 @@
<template>
<div class="subscription-status-widget">
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-3"
>
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
</div>
<!-- No subscription data -->
<div
v-else-if="!subscriptionPlan"
class="text-xs text-white/40 text-center py-2"
>
{{ t('subscription.noPlan') }}
</div>
<!-- Subscription Status Display -->
<div
v-else
class="subscription-info"
>
<!-- Plan Name -->
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-white/70">
{{ subscriptionPlan }}
</span>
<span
v-if="daysRemaining !== null"
class="text-xs"
:class="daysRemaining <= 7 ? 'text-amber-400' : 'text-white/50'"
>
{{ daysRemaining > 0
? `${daysRemaining} ${t('subscription.daysLeft')}`
: t('subscription.expired')
}}
</span>
</div>
<!-- Progress Bar -->
<div
v-if="progressPercent !== null"
class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden"
>
<div
class="h-full rounded-full transition-all duration-500"
:class="progressBarClass"
:style="{ width: `${Math.min(progressPercent, 100)}%` }"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../../stores/auth'
import api from '../../api/axios'
const { t } = useI18n()
const authStore = useAuthStore()
const isLoading = ref(true)
/**
* Resolve the current subscription plan from the auth store.
* For individual mode: use user-level subscription.
* For corporate mode: use the active organization's subscription_plan.
*/
const subscriptionPlan = computed(() => {
if (authStore.isCorporateMode) {
const activeOrg = authStore.myOrganizations.find(
(o) => o.organization_id === authStore.user?.active_organization_id
)
return activeOrg?.subscription_plan || null
}
// Individual mode — fallback to user's subscription
return authStore.user?.subscription_plan || null
})
/**
* Calculate days remaining until subscription expires.
* Uses valid_until from the organization or user data.
*/
const validUntil = computed(() => {
if (authStore.isCorporateMode) {
const activeOrg = authStore.myOrganizations.find(
(o) => o.organization_id === authStore.user?.active_organization_id
)
return activeOrg?.subscription_valid_until || null
}
return authStore.user?.subscription_valid_until || null
})
const daysRemaining = computed(() => {
if (!validUntil.value) return null
const now = new Date()
const end = new Date(validUntil.value)
const diffMs = end.getTime() - now.getTime()
return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)))
})
const progressPercent = computed(() => {
if (!validUntil.value || !subscriptionPlan.value) return null
// Assume a 30-day cycle for progress calculation
const now = new Date()
const end = new Date(validUntil.value)
const totalMs = 30 * 24 * 60 * 60 * 1000 // 30 days
const elapsedMs = totalMs - (end.getTime() - now.getTime())
return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100))
})
const progressBarClass = computed(() => {
if (daysRemaining.value === null) return 'bg-[#70BC84]'
if (daysRemaining.value <= 3) return 'bg-red-500'
if (daysRemaining.value <= 7) return 'bg-amber-400'
return 'bg-[#70BC84]'
})
onMounted(async () => {
// Ensure organizations are loaded
if (authStore.myOrganizations.length === 0) {
try {
await authStore.fetchMyOrganizations()
} catch {
// Silently fail
}
}
isLoading.value = false
})
</script>
<style scoped>
.subscription-status-widget {
width: 100%;
}
</style>

View File

@@ -0,0 +1,286 @@
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="$emit('close')"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<!-- Modal Panel -->
<div
class="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
>
<!-- HEADER -->
<div
class="shrink-0 flex items-center justify-between px-6 py-4 border-b border-slate-200"
:style="{ backgroundColor: highlightBg }"
>
<div>
<h2 class="text-lg font-bold text-white">
{{ plan?.rules?.display_name || plan?.name }}
</h2>
<p v-if="plan?.rules?.marketing?.subtitle" class="text-sm text-white/70 mt-0.5">
{{ plan.rules.marketing.subtitle }}
</p>
</div>
<button
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
@click="$emit('close')"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- BODY (scrollable) -->
<div class="flex-1 overflow-y-auto px-6 py-5 space-y-5">
<!-- Price Section (from resolved_pricing) -->
<div class="text-center">
<div class="text-4xl font-bold text-slate-900">
{{ isYearly ? formatPrice(resolvedYearlyPrice) : formatPrice(resolvedMonthlyPrice) }}
</div>
<div class="text-sm text-slate-500 mt-1">
/ {{ isYearly ? t('subscription.year') : t('subscription.month') }}
</div>
<div v-if="isYearly && savingsPercent > 0" class="mt-2 inline-block px-3 py-1 rounded-full bg-emerald-100 text-emerald-700 text-xs font-semibold">
{{ t('subscription.savePercent', { percent: savingsPercent }) }}
</div>
</div>
<!-- Billing Toggle -->
<div class="flex items-center justify-center gap-3">
<span
class="text-sm font-medium transition-colors"
:class="isYearly ? 'text-slate-400' : 'text-slate-900'"
>
{{ t('subscription.monthly') }}
</span>
<button
class="relative w-12 h-6 rounded-full transition-colors"
:class="isYearly ? 'bg-emerald-500' : 'bg-slate-300'"
@click="isYearly = !isYearly"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
:class="isYearly ? 'translate-x-6' : 'translate-x-0'"
/>
</button>
<span
class="text-sm font-medium transition-colors"
:class="isYearly ? 'text-slate-900' : 'text-slate-400'"
>
{{ t('subscription.yearly') }}
</span>
</div>
<!-- Key Stats -->
<div class="grid grid-cols-3 gap-3">
<div class="bg-slate-50 rounded-xl p-3 text-center">
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.max_vehicles ?? '—' }}</div>
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.maxVehicles') }}</div>
</div>
<div class="bg-slate-50 rounded-xl p-3 text-center">
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.max_garages ?? '—' }}</div>
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.maxGarages') }}</div>
</div>
<div class="bg-slate-50 rounded-xl p-3 text-center">
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.monthly_free_credits ?? '—' }}</div>
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.freeCredits') }}</div>
</div>
</div>
<!-- Included Services (Entitlements) -->
<div>
<h3 class="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wider">
{{ t('subscription.includedServices') }}
</h3>
<ul class="space-y-2">
<!-- Ad-free -->
<li class="flex items-start gap-3 text-sm text-slate-700">
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
<svg v-if="plan?.rules?.ad_policy?.show_ads === false" class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="w-3 h-3 text-slate-400" 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>
</span>
<span :class="plan?.rules?.ad_policy?.show_ads === false ? 'text-slate-900 font-medium' : 'text-slate-400'">
{{ t('subscription.features.adFree') }}
</span>
</li>
<!-- Dynamic entitlements -->
<li
v-for="(entitlement, idx) in plan?.rules?.entitlements || []"
:key="idx"
class="flex items-start gap-3 text-sm text-slate-700"
>
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</span>
<span class="text-slate-900 font-medium">{{ entitlementLabel(entitlement) }}</span>
</li>
<!-- Affiliate info -->
<li
v-if="plan?.rules?.affiliate?.commission_rate_percent"
class="flex items-start gap-3 text-sm text-slate-700"
>
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
</span>
<span class="text-slate-900 font-medium">
{{ plan.rules.affiliate.commission_rate_percent }}% {{ t('subscription.features.affiliateCommission') || 'Partner jutalék' }}
</span>
</li>
</ul>
</div>
</div>
<!-- FOOTER -->
<div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50">
<button
class="w-full py-3 rounded-xl font-bold text-white transition-colors text-sm"
:class="isCurrentPlan ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-800 hover:bg-blue-900'"
:disabled="isCurrentPlan"
@click="handleOrder"
>
{{ isCurrentPlan ? t('subscription.currentPlan') : t('subscription.orderSimulation') }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
interface PricingZone {
monthly_price: number
yearly_price: number
currency: string
credit_price?: number | null
}
interface SubscriptionPlan {
id: number
name: string
rules: Record<string, any> | null
is_custom: boolean
resolved_pricing?: PricingZone | null
}
const props = withDefaults(
defineProps<{
visible: boolean
plan: SubscriptionPlan | null
currentPlanName?: string | null
}>(),
{
visible: false,
plan: null,
currentPlanName: null,
}
)
const emit = defineEmits<{
close: []
order: [plan: SubscriptionPlan, isYearly: boolean]
}>()
const isYearly = ref(false)
/**
* Get resolved monthly price from plan's resolved_pricing or fallback to rules.pricing.
*/
const resolvedMonthlyPrice = computed(() => {
return props.plan?.resolved_pricing?.monthly_price ?? props.plan?.rules?.pricing?.monthly_price ?? 0
})
/**
* Get resolved yearly price from plan's resolved_pricing or fallback to rules.pricing.
*/
const resolvedYearlyPrice = computed(() => {
return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0
})
/**
* Get resolved currency from plan's resolved_pricing or fallback to rules.pricing.
*/
const resolvedCurrency = computed(() => {
return props.plan?.resolved_pricing?.currency ?? props.plan?.rules?.pricing?.currency ?? 'EUR'
})
const savingsPercent = computed(() => {
const monthly = resolvedMonthlyPrice.value
const yearly = resolvedYearlyPrice.value
if (monthly <= 0 || yearly <= 0) return 0
const monthlyYearly = monthly * 12
const savings = monthlyYearly - yearly
return Math.round((savings / monthlyYearly) * 100)
})
const isCurrentPlan = computed(() => {
return props.plan?.name === props.currentPlanName
})
const highlightBg = computed(() => {
const color = props.plan?.rules?.marketing?.highlight_color
return color || '#306081'
})
function formatPrice(price: number): string {
if (price === 0) return t('subscription.free')
const cur = resolvedCurrency.value
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: cur,
minimumFractionDigits: cur === 'HUF' ? 0 : 2,
maximumFractionDigits: cur === 'HUF' ? 0 : 2,
}).format(price)
} catch {
return `${price} ${cur}`
}
}
function entitlementLabel(code: string): string {
const labels: Record<string, string> = {
SRV_DATA_EXPORT: t('subscription.features.analytics'),
SRV_API_ACCESS: t('subscription.features.apiAccess'),
SRV_PRIORITY_SUPPORT: t('subscription.features.prioritySupport'),
SRV_AI_UPLOAD: 'AI dokumentumfeltöltés',
SRV_ACCOUNTING_SYNC: 'Számviteli szinkron',
}
return labels[code] || code
}
function handleOrder() {
if (props.plan) {
emit('order', props.plan, isYearly.value)
}
}
</script>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col relative transition-all duration-300 ease-out"
:class="[
heightClass,
hoverable ? 'cursor-pointer hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl' : '',
clickable ? 'cursor-pointer' : '',
customClass,
]"
@click="$emit('click')"
>
<!-- HEADER SLOT -->
<div
v-if="$slots.header"
class="h-12 w-full shrink-0 flex items-center px-4"
:class="headerColorClass"
>
<slot name="header" />
</div>
<!-- BODY SLOT (default) -->
<div
class="flex-1 flex flex-col text-slate-800 overflow-hidden"
:class="bodyPaddingClass"
>
<slot />
</div>
<!-- FOOTER SLOT -->
<div v-if="$slots.footer" class="shrink-0 px-4 pb-4">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** Enable hover lift effect (translate-y and stronger shadow) */
hoverable?: boolean
/** Enable cursor-pointer without hover animation */
clickable?: boolean
/** Card height Tailwind class (default: h-[350px]) */
height?: string
/** Header background color Tailwind class (default: bg-slate-700) */
headerColor?: string
/** Body padding: 'sm' | 'md' | 'lg' | 'none' (default: 'md') */
bodyPadding?: 'sm' | 'md' | 'lg' | 'none'
/** Additional CSS classes to merge */
customClass?: string
}>(),
{
hoverable: false,
clickable: false,
height: 'h-[350px]',
headerColor: 'bg-slate-700',
bodyPadding: 'md',
customClass: '',
}
)
defineEmits<{
click: []
}>()
const heightClass = computed(() => props.height)
const headerColorClass = computed(() => props.headerColor)
const bodyPaddingClass = computed(() => {
switch (props.bodyPadding) {
case 'sm': return 'p-3'
case 'lg': return 'p-6'
case 'none': return ''
default: return 'p-4'
}
})
</script>

View File

@@ -6,6 +6,39 @@ export default {
saving: 'Saving...',
optional: 'optional',
select: 'Select',
retry: 'Retry',
},
subscription: {
title: 'Subscription Plans',
subtitle: 'Choose the plan that suits you best and maximize your garage\'s potential!',
loadError: 'Failed to load subscription plans.',
month: 'month',
year: 'year',
free: 'Free',
currentPlan: 'Current Plan',
selectPlan: 'Switch to this plan',
detailsAndPurchase: 'Details & Purchase',
noPlan: 'No active subscription',
daysLeft: 'days left',
expired: 'Expired',
monthly: 'Monthly',
yearly: 'Yearly',
savePercent: 'Save {percent}%',
orderSimulation: 'Order (Simulation)',
planDetails: 'Plan Details',
includedServices: 'Included Services',
maxVehicles: 'Max vehicles',
maxGarages: 'Max garages',
freeCredits: 'Free credits',
features: {
vehicles: 'vehicles',
members: 'members',
storage: 'storage',
analytics: 'Data export & analytics',
apiAccess: 'API access',
prioritySupport: 'Priority support',
adFree: 'Ad-free',
},
},
landing: {
openGarage: 'Open Garage',

View File

@@ -6,6 +6,39 @@ export default {
saving: 'Mentés...',
optional: 'opcionális',
select: 'Válassz',
retry: 'Újrapróbálkozás',
},
subscription: {
title: 'Előfizetési Csomagok',
subtitle: 'Válaszd ki a számodra megfelelő csomagot, és maximalizáld a garázsod potenciálját!',
loadError: 'Hiba történt a csomagok betöltése közben.',
month: 'hó',
year: 'év',
free: 'Ingyenes',
currentPlan: 'Jelenlegi csomagod',
selectPlan: 'Váltás erre a csomagra',
detailsAndPurchase: 'Részletek & Vásárlás',
noPlan: 'Nincs aktív előfizetés',
daysLeft: 'nap van hátra',
expired: 'Lejárt',
monthly: 'Havi',
yearly: 'Éves',
savePercent: '{percent}% megtakarítás',
orderSimulation: 'Megrendelés (Szimuláció)',
planDetails: 'Csomag Részletei',
includedServices: 'Tartalmazott szolgáltatások',
maxVehicles: 'Max. jármű',
maxGarages: 'Max. garázs',
freeCredits: 'Ingyenes kredit',
features: {
vehicles: 'jármű',
members: 'tag',
storage: 'tárhely',
analytics: 'Adatexport és analitika',
apiAccess: 'API hozzáférés',
prioritySupport: 'Prioritásos támogatás',
adFree: 'Reklámmentes',
},
},
landing: {
openGarage: 'Garázs Nyitása',

View File

@@ -47,6 +47,17 @@
</svg>
{{ t('company.companyDataMenu') }}
</button>
<!-- Előfizetésem & Csomagok -->
<button
@click="navigateToSubscription"
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-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
{{ t('menu.subscription') }}
</button>
</div>
</div>
</Transition>
@@ -73,7 +84,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import { useThemeStore } from '../stores/theme'
@@ -84,6 +95,7 @@ import HeaderProfile from '../components/header/HeaderProfile.vue'
import CompanyDataModal from '../components/organization/CompanyDataModal.vue'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const themeStore = useThemeStore()
@@ -113,6 +125,13 @@ function openCompanyData() {
showCompanyDataModal.value = true
}
// ── Navigate to Subscription Plans page ────────────────────────────
function navigateToSubscription() {
isHamburgerOpen.value = false
const orgId = route.params.id
router.push(`/organization/${orgId}/subscription`)
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

@@ -96,6 +96,17 @@
</svg>
{{ t('menu.service') }}
</button>
<!-- Előfizetésem & Csomagok -->
<button
@click="navigateToSubscription"
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-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
{{ t('menu.subscription') }}
</button>
</div>
</div>
</Transition>
@@ -156,6 +167,12 @@ function navigateToDashboard() {
router.push('/dashboard')
}
// ── Navigate to Subscription Plans page ────────────────────────────
function navigateToSubscription() {
isHamburgerOpen.value = false
router.push('/dashboard/subscription')
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

@@ -23,6 +23,11 @@ const router = createRouter({
path: 'service-finder',
name: 'service-finder',
component: () => import('../views/ServiceFinderView.vue')
},
{
path: 'subscription',
name: 'subscription',
component: () => import('../views/SubscriptionPlansView.vue')
}
]
},
@@ -35,6 +40,11 @@ const router = createRouter({
path: '',
name: 'organization-dashboard',
component: () => import('../views/organization/CompanyGarageView.vue')
},
{
path: 'subscription',
name: 'org-subscription',
component: () => import('../views/SubscriptionPlansView.vue')
}
]
},

View File

@@ -24,6 +24,19 @@ export interface PricingModel {
credit_price?: number | null
}
export interface PricingZoneModel {
monthly_price: number
yearly_price: number
currency: string
credit_price?: number | null
}
export interface MarketingModel {
subtitle?: string | null
badge?: string | null
highlight_color?: string | null
}
export interface AllowancesModel {
max_vehicles: number
max_garages: number
@@ -44,10 +57,12 @@ export interface SubscriptionRulesModel {
type: 'private' | 'corporate'
display_name?: string | null
pricing: PricingModel
pricing_zones?: Record<string, PricingZoneModel> | null
allowances: AllowancesModel
entitlements: string[]
affiliate: AffiliateModel
lifecycle?: LifecycleModel | null
marketing?: MarketingModel | null
}
export interface SubscriptionTierItem {

View File

@@ -74,6 +74,19 @@
@open-card="openCard"
/>
</div>
<!-- Subscription Status & Ad Placement Row -->
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Subscription Status Widget -->
<div class="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-3">
<SubscriptionStatusWidget />
</div>
<!-- Ad Placement Widget -->
<div class="rounded-xl border border-white/10 bg-white/5 backdrop-blur-sm p-3">
<AdPlacementWidget zone="dashboard_widget" />
</div>
</div>
</div>
</div>
</div><!-- end relative z-10 content wrapper -->
@@ -171,6 +184,10 @@ import ServiceFinderCard from '../components/dashboard/ServiceFinderCard.vue'
import GamificationCard from '../components/dashboard/GamificationCard.vue'
import ProfileTrustCard from '../components/dashboard/ProfileTrustCard.vue'
// ── Subscription & Ad Widgets ──
import SubscriptionStatusWidget from '../components/dashboard/SubscriptionStatusWidget.vue'
import AdPlacementWidget from '../components/dashboard/AdPlacementWidget.vue'
// ── Shared Modals ──
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'

View File

@@ -0,0 +1,328 @@
<template>
<div class="subscription-plans-view min-h-screen">
<!-- Background -->
<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>
<div class="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Header -->
<div class="text-center mb-10">
<h1 class="text-3xl font-bold text-white mb-2">{{ t('subscription.title') }}</h1>
<p class="text-white/60">{{ t('subscription.subtitle') }}</p>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="flex items-center justify-center py-20"
>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
</div>
<!-- Error State -->
<div
v-else-if="error"
class="text-center py-10"
>
<p class="text-red-400">{{ error }}</p>
<button
@click="fetchPlans"
class="mt-4 px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-sm text-white"
>
{{ t('common.retry') }}
</button>
</div>
<!-- MINIMALIST PLAN CARDS -->
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<BaseCard
v-for="plan in filteredPlans"
:key="plan.id"
:class="{
'ring-2 ring-emerald-500': plan.name === currentPlanName,
'hover:-translate-y-2 hover:shadow-2xl': true,
}"
header-color="bg-[#306081]"
height="auto"
body-padding="lg"
:custom-class="`border-2 ${plan.name === currentPlanName ? 'border-emerald-500' : 'border-slate-800'} transition-all duration-300`"
>
<!-- HEADER: Plan Name + Badge -->
<template #header>
<div class="flex items-center justify-between w-full">
<span class="text-white font-bold text-sm tracking-wide">
{{ plan.rules?.display_name || plan.name }}
</span>
<span
v-if="plan.rules?.marketing?.badge"
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full"
:style="{
backgroundColor: plan.rules.marketing.highlight_color || '#70BC84',
color: '#fff',
}"
>
{{ plan.rules.marketing.badge }}
</span>
</div>
</template>
<!-- BODY: Minimalist Content -->
<div class="flex flex-col h-full">
<!-- Subtitle -->
<p
v-if="plan.rules?.marketing?.subtitle"
class="text-xs text-slate-500 mb-4 leading-relaxed"
>
{{ plan.rules.marketing.subtitle }}
</p>
<!-- Price (from resolved_pricing) -->
<div class="text-center mb-5">
<div class="text-3xl font-bold text-slate-900">
{{ formatPrice(resolvedMonthlyPrice(plan)) }}
<span class="text-sm font-normal text-slate-500">/{{ t('subscription.month') }}</span>
</div>
<!-- Yearly price hint -->
<div v-if="resolvedYearlyPrice(plan) > 0" class="text-xs text-slate-400 mt-1">
{{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }}
<span v-if="yearlySavingsPercent(plan) > 0" class="text-emerald-600 font-semibold ml-1">
({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }})
</span>
</div>
</div>
<!-- 3 KEY STATS (visual highlight) -->
<div class="grid grid-cols-3 gap-2 mb-5">
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_vehicles ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxVehicles') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.max_garages ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.maxGarages') }}
</div>
</div>
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
<div class="text-base font-bold text-slate-900">
{{ plan.rules?.allowances?.monthly_free_credits ?? '—' }}
</div>
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
{{ t('subscription.freeCredits') }}
</div>
</div>
</div>
<!-- Spacer -->
<div class="flex-1" />
<!-- Action: Current Plan Badge or Details Button -->
<div v-if="plan.name === currentPlanName" class="mt-auto">
<div class="text-center text-sm text-emerald-600 font-semibold mb-3 bg-emerald-50 py-2 rounded-lg border border-emerald-200">
{{ t('subscription.currentPlan') }}
</div>
</div>
<button
v-else
@click="openDetails(plan)"
class="w-full py-2.5 rounded-xl bg-blue-800 hover:bg-blue-900 text-white font-semibold transition-colors text-sm mt-auto"
>
{{ t('subscription.detailsAndPurchase') }}
</button>
</div>
</BaseCard>
</div>
</div>
<!-- PLAN DETAILS MODAL -->
<PlanDetailsModal
:visible="showModal"
:plan="selectedPlan"
:current-plan-name="currentPlanName"
@close="closeModal"
@order="handleOrder"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '../stores/auth'
import api from '../api/axios'
import BaseCard from '../components/ui/BaseCard.vue'
import PlanDetailsModal from '../components/subscription/PlanDetailsModal.vue'
const { t } = useI18n()
const authStore = useAuthStore()
interface PricingZone {
monthly_price: number
yearly_price: number
currency: string
credit_price?: number | null
}
interface SubscriptionPlan {
id: number
name: string
rules: Record<string, any> | null
is_custom: boolean
resolved_pricing?: PricingZone | null
}
const isLoading = ref(true)
const error = ref<string | null>(null)
const allPlans = ref<SubscriptionPlan[]>([])
// Modal state
const showModal = ref(false)
const selectedPlan = ref<SubscriptionPlan | null>(null)
/**
* Filter plans based on the user's current mode:
* - Individual (no active org): show only `private_` packages
* - Corporate (active org): show only `corp_` packages
*/
const filteredPlans = computed(() => {
const isCorporate = authStore.isCorporateMode
return allPlans.value.filter((plan) => {
if (isCorporate) {
return plan.name.startsWith('corp_')
}
return plan.name.startsWith('private_')
})
})
/**
* Get the user's current subscription plan name.
*/
const currentPlanName = computed(() => {
if (authStore.isCorporateMode) {
const activeOrg = authStore.myOrganizations.find(
(o) => o.organization_id === authStore.user?.active_organization_id
)
return activeOrg?.subscription_plan || null
}
return authStore.user?.subscription_plan || null
})
/**
* Get resolved monthly price from a plan.
*/
function resolvedMonthlyPrice(plan: SubscriptionPlan): number {
return plan.resolved_pricing?.monthly_price ?? plan.rules?.pricing?.monthly_price ?? 0
}
/**
* Get resolved yearly price from a plan.
*/
function resolvedYearlyPrice(plan: SubscriptionPlan): number {
return plan.resolved_pricing?.yearly_price ?? plan.rules?.pricing?.yearly_price ?? 0
}
/**
* Get resolved currency from a plan.
*/
function resolvedCurrency(plan: SubscriptionPlan): string {
return plan.resolved_pricing?.currency ?? plan.rules?.pricing?.currency ?? 'EUR'
}
/**
* Calculate yearly savings percentage.
*/
function yearlySavingsPercent(plan: SubscriptionPlan): number {
const monthly = resolvedMonthlyPrice(plan)
const yearly = resolvedYearlyPrice(plan)
if (monthly <= 0 || yearly <= 0) return 0
const monthlyYearly = monthly * 12
const savings = monthlyYearly - yearly
return Math.round((savings / monthlyYearly) * 100)
}
/**
* Format price for display using the resolved currency.
*/
function formatPrice(price: number, currency?: string): string {
if (price === 0) return t('subscription.free')
const cur = currency || 'EUR'
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: cur,
minimumFractionDigits: cur === 'HUF' ? 0 : 2,
maximumFractionDigits: cur === 'HUF' ? 0 : 2,
}).format(price)
} catch {
return `${price} ${cur}`
}
}
/**
* Open the PlanDetailsModal for the selected plan.
*/
function openDetails(plan: SubscriptionPlan) {
selectedPlan.value = plan
showModal.value = true
}
/**
* Close the modal.
*/
function closeModal() {
showModal.value = false
selectedPlan.value = null
}
/**
* Handle order from modal (simulation).
*/
function handleOrder(plan: SubscriptionPlan, isYearly: boolean) {
console.log('[SubscriptionPlansView] Order simulation:', {
plan: plan.name,
isYearly,
price: isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan),
currency: resolvedCurrency(plan),
})
// TODO: Integrate with actual checkout flow
closeModal()
}
/**
* Fetch all active subscription plans from the public endpoint.
*/
async function fetchPlans() {
isLoading.value = true
error.value = null
try {
const res = await api.get<SubscriptionPlan[]>('/subscriptions/public')
allPlans.value = res.data || []
} catch (err: any) {
error.value = t('subscription.loadError')
console.error('[SubscriptionPlansView] Error:', err)
} finally {
isLoading.value = false
}
}
onMounted(() => {
fetchPlans()
})
</script>
<style scoped>
.subscription-plans-view {
/* Scoped styles if needed */
}
</style>

View File

@@ -0,0 +1,962 @@
# P0 Architecture Plan — Subscription Stacking & Extensible Ad Engine
**Dátum:** 2026-06-18
**Szerző:** Fast Coder (Core Developer)
**Státusz:** Tervezési fázis — Jóváhagyásra vár
**Cél:** Teljes architekturális terv a Subscription Time Stacking (napok halmozása) és a bővíthető Native Ad Engine (kampányok, kreatívok, elhelyezések, analitika) megvalósításához.
---
## 1. SUBSCRIPTION STACKING MATH
### 1.1 Problémafelvetés
Jelenleg a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény minden híváskor **felülírja** a `valid_from` mezőt `datetime.utcnow()`-ra, és **nem állítja be** a `valid_until` mezőt. Ez azt jelenti, hogy:
- Ha egy szervezet ma megveszi a 30 napos csomagot, majd 10 nap múlva **hozzáad** még 30 napot, a hátralévő 20 nap **elveszik**.
- A `valid_until` mező minden `OrganizationSubscription` és `UserSubscription` rekordban `NULL` marad.
### 1.2 Megoldás: `duration_days` a JSONB `rules`-ban
Minden `subscription_tiers.rules` JSONB blokkba bevezetésre kerül egy új `duration_days` mező, amely fix napokban határozza meg a csomag időtartamát. Ez elkerüli a naptári anomáliákat (pl. február 28., hónap vége).
#### 1.2.1 JSONB `rules` bővítés
```json
{
"type": "private",
"display_name": "Privát Pro",
"duration_days": 30,
"pricing": {
"monthly_price": 4.99,
"yearly_price": 49.99,
"currency": "EUR",
"credit_price": 500
},
"allowances": {
"max_vehicles": 3,
"max_garages": 1,
"monthly_free_credits": 50
},
"entitlements": ["SRV_DATA_EXPORT"],
"lifecycle": { "is_public": true }
}
```
**Tervezett `duration_days` értékek csomagonként:**
| Csomag | `duration_days` | Megjegyzés |
|--------|----------------|------------|
| `private_free_v1` | `null` (korlátlan) | Ingyenes csomag, nem jár le |
| `private_pro_v1` | `30` | Havi |
| `private_vip_v1` | `30` | Havi |
| `corp_premium_v1` | `30` | Havi |
| `corp_premium_plus_v1` | `30` | Havi |
| `corp_vip_v1` | `30` | Havi |
| `private_test_v01` | `365` | Teszt — 1 év |
| `org_test_v01` | `365` | Teszt — 1 év |
| `corp_free_v1` | `null` (korlátlan) | Ingyenes céges |
#### 1.2.2 Pydantic Schema Bővítés
A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése:
```python
class SubscriptionRulesModel(BaseModel):
type: str = Field(..., pattern=r"^(private|corporate)$")
display_name: Optional[str] = None
duration_days: Optional[int] = Field(
default=None,
ge=1,
description="Csomag időtartama napokban. null = korlátlan (pl. ingyenes csomag)"
)
pricing: Optional[PricingModel] = None
allowances: Optional[AllowancesModel] = None
entitlements: List[str] = []
affiliate: Optional[AffiliateModel] = None
lifecycle: Optional[LifecycleModel] = None
ad_policy: Optional[AdPolicyModel] = None # Lásd 2. fejezet
```
### 1.3 Stacking Logika: `upgrade_org_subscription()` Átdolgozása
A [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvény új logikája:
```python
async def upgrade_org_subscription(
db: AsyncSession,
org_id: int,
tier_id: int,
actor_user_id: int
) -> Dict[str, Any]:
"""
Szervezet előfizetésének beállítása időhalmozással (Stacking).
Stacking logika:
1. Ha az aktuális előfizetés MÉG ÉRVÉNYES (valid_until > now):
- Az új valid_until = régi valid_until + duration_days nap
- Az időhalmozás (stacking) megtörténik
2. Ha az aktuális előfizetés LEJÁRT (valid_until < now) vagy NINCS:
- Az új valid_until = now + duration_days nap
- Friss kezdés
3. Ha duration_days = null (korlátlan csomag):
- valid_until = null (soha nem jár le)
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization
# 1. Ellenőrizze, hogy a tier létezik-e
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise ValueError(f"Subscription tier id={tier_id} not found")
# 2. Ellenőrizze, hogy a szervezet létezik-e
stmt = select(Organization).where(Organization.id == org_id)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise ValueError(f"Organization id={org_id} not found")
# 3. Olvassuk ki a duration_days-t a tier rules-ból
rules = tier.rules or {}
duration_days = rules.get("duration_days")
# 4. Számítsuk ki az új valid_until-t (STACKING LOGIKA)
now = datetime.utcnow()
new_valid_until = None # Alapértelmezett: korlátlan
if duration_days is not None:
# Korlátozott időtartamú csomag
# Keressük meg az aktuális aktív előfizetést
sub_stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True
)
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now:
# STACKING: Még érvényes előfizetéshez hozzáadjuk az új napokat
remaining_days = (existing_sub.valid_until - now).days
logger.info(
f"Subscription stacking for org_id={org_id}: "
f"remaining={remaining_days}d, adding={duration_days}d"
)
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
else:
# Nincs érvényes előfizetés — friss kezdés
new_valid_until = now + timedelta(days=duration_days)
# 5. Állítsa be a subscription_tier_id-t a szervezeten
org.subscription_tier_id = tier_id
org.subscription_plan = tier.name
# 6. Extract max_vehicles from tier rules
max_vehicles = rules.get("allowances", {}).get("max_vehicles", 1)
org.base_asset_limit = max_vehicles
# 7. Deaktiváljuk a régi előfizetést
if existing_sub:
existing_sub.is_active = False
existing_sub.valid_until = now # Meddig volt érvényben
# 8. Új aktív subscription rekord
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=tier_id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_sub)
logger.info(
f"Org subscription upgraded: org_id={org_id}, "
f"tier={tier.name} (id={tier_id}), "
f"valid_from={now}, valid_until={new_valid_until}, "
f"max_vehicles={max_vehicles}, actor={actor_user_id}"
)
return {
"success": True,
"organization_id": org_id,
"tier_id": tier_id,
"tier_name": tier.name,
"max_vehicles": max_vehicles,
"valid_from": now.isoformat(),
"valid_until": new_valid_until.isoformat() if new_valid_until else None,
"message": f"Organization {org_id} upgraded to {tier.name}"
}
```
### 1.4 Stacking Matematikai Összefoglaló
```
Képlet:
IF existing_sub.valid_until > NOW:
new_valid_until = existing_sub.valid_until + duration_days
ELSE:
new_valid_until = NOW + duration_days
Példa:
- Március 1.: 30 napos csomag vásárlás → valid_until = március 31.
- Március 15.: Újabb 30 nap hozzáadása → valid_until = március 31. + 30 nap = április 30.
- Eredmény: 45 nap maradt (március 15-től április 30-ig) ahelyett, hogy csak 30 nap lenne.
```
### 1.5 `upgrade_subscription()` (User-level) Átdolgozása
A [`upgrade_subscription()`](backend/app/services/billing_engine.py:722) függvény hasonló stacking logikát kap a `UserSubscription` rekordokhoz:
```python
async def upgrade_subscription(
db: AsyncSession,
user_id: int,
target_package: str
) -> Dict[str, Any]:
"""
Felhasználói előfizetés frissítése időhalmozással.
Ugyanaz a stacking logika, mint az org változatban.
"""
from app.models.core_logic import SubscriptionTier, UserSubscription
from app.models.identity import User
# 1. Ellenőrizze, hogy a cél csomag létezik-e
stmt = select(SubscriptionTier).where(SubscriptionTier.name == target_package)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise ValueError(f"Subscription tier '{target_package}' not found")
rules = tier.rules or {}
duration_days = rules.get("duration_days")
now = datetime.utcnow()
# 2. Stacking logika a UserSubscription rekordokon
new_valid_until = None
if duration_days is not None:
sub_stmt = select(UserSubscription).where(
UserSubscription.user_id == user_id,
UserSubscription.is_active == True
)
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
if existing_sub and existing_sub.valid_until and existing_sub.valid_until > now:
new_valid_until = existing_sub.valid_until + timedelta(days=duration_days)
else:
new_valid_until = now + timedelta(days=duration_days)
# 3. Ár kiszámítása és levonás (meglévő logika)
price = rules.get("price", 0.0)
# ... (ár számítás, wallet levonás)
# 4. Frissítse a felhasználó adatait
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one()
user.subscription_plan = target_package
user.subscription_expires_at = new_valid_until # Már nem 30 nap fix!
# 5. UserSubscription rekord frissítése
if existing_sub:
existing_sub.is_active = False
existing_sub.valid_until = now
new_sub = UserSubscription(
user_id=user_id,
tier_id=tier.id,
valid_from=now,
valid_until=new_valid_until,
is_active=True
)
db.add(new_sub)
return {
"success": True,
"new_plan": target_package,
"price_paid": price,
"valid_from": now.isoformat(),
"valid_until": new_valid_until.isoformat() if new_valid_until else None
}
```
---
## 2. EXTENSIBLE AD ENGINE SCHEMA
### 2.1 Új Adatbázis Séma: `marketing` Schema
A Native Ad Engine egy új `marketing` adatbázis sémában kap helyet, elkülönítve a core üzleti logikától. Ez biztosítja a jövőbeli bővíthetőséget (pl. kattintások, konverziók, A/B tesztelés).
#### 2.1.1 SQLAlchemy Modellek
```python
# backend/app/models/marketing.py
"""
📢 Native Ad Engine — Marketing Schema Models
Schema: marketing
Táblák:
- campaigns: Hirdetési kampányok
- creatives: Kreatív anyagok (belső/külső)
- placements: Elhelyezési zónák
- campaign_placements: Kampány ↔ Elhelyezés kapcsolótábla
- ad_impressions: Megjelenítések naplózása
- ad_clicks: Kattintások naplózása (jövőbeli bővítés)
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import (
String, Integer, ForeignKey, Boolean, DateTime,
Numeric, Text, Enum as SAEnum
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.sql import func
import enum
import uuid
from app.database import Base
class CampaignStatus(enum.Enum):
DRAFT = "draft"
SCHEDULED = "scheduled"
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class CreativeType(enum.Enum):
IMAGE = "image" # Belső kép
HTML_SNIPPET = "html_snippet" # Külső ad kód (pl. Google Ads)
VIDEO = "video" # Videó (jövőbeli)
CAROUSEL = "carousel" # Több képből álló (jövőbeli)
class Campaign(Base):
"""
Hirdetési kampány.
Egy kampány több kreatívot és több elhelyezést is célozhat.
A kampányoknak van prioritása és státusza.
"""
__tablename__ = "campaigns"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
uuid: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
server_default=func.gen_random_uuid(),
unique=True,
comment="Publikus azonosító (UUID) a frontend számára"
)
name: Mapped[str] = mapped_column(
String(200), nullable=False,
comment="Kampány neve (pl. '2026 Q3 Flotta Akció')"
)
description: Mapped[Optional[str]] = mapped_column(
Text, comment="Belső leírás a kampányról"
)
# ── Időbeli korlátok ──
start_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False,
comment="Kampány kezdete"
)
end_date: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
comment="Kampány vége (None = nincs lejárat)"
)
# ── Prioritás és státusz ──
priority: Mapped[int] = mapped_column(
Integer, server_default=text("0"),
comment="Prioritás (magasabb = fontosabb). 0 = legalacsonyabb"
)
status: Mapped[CampaignStatus] = mapped_column(
SAEnum(CampaignStatus, name="campaign_status", schema="marketing"),
server_default=text("'draft'"),
comment="Kampány státusza"
)
# ── Célzás ──
targeting_rules: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"),
comment="Célzási szabályok JSONB (pl. {'country_codes': ['HU', 'GB'], 'min_tier': 'premium'})"
)
# ── Korlátok ──
daily_impression_limit: Mapped[Optional[int]] = mapped_column(
Integer, comment="Napi max megjelenítés (None = korlátlan)"
)
total_impression_limit: Mapped[Optional[int]] = mapped_column(
Integer, comment="Összes max megjelenítés (None = korlátlan)"
)
# ── Meta ──
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), onupdate=func.now()
)
created_by: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("identity.users.id"), nullable=True,
comment="Kampány létrehozója (admin user ID)"
)
# ── Kapcsolatok ──
creatives: Mapped[list["Creative"]] = relationship(
"Creative", back_populates="campaign", cascade="all, delete-orphan"
)
placements: Mapped[list["Placement"]] = relationship(
"Placement", secondary="marketing.campaign_placements",
back_populates="campaigns"
)
class Creative(Base):
"""
Kreatív anyag (hirdetés).
Két fő típus:
- IMAGE: Belső kép + target_url (saját rendszerben tárolt kép)
- HTML_SNIPPET: Külső ad kód (pl. Google Ads, Taboola snippet)
"""
__tablename__ = "creatives"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
nullable=False
)
# ── Típus ──
creative_type: Mapped[CreativeType] = mapped_column(
SAEnum(CreativeType, name="creative_type", schema="marketing"),
nullable=False,
comment="Kreatív típusa: image (belső) vagy html_snippet (külső)"
)
# ── IMAGE típushoz ──
image_url: Mapped[Optional[str]] = mapped_column(
String(500),
comment="Kép URL (IMAGE típusnál). Lehet relatív (storage) vagy abszolút"
)
alt_text: Mapped[Optional[str]] = mapped_column(
String(200),
comment="Kép alternatív szövege (akadálymentesítés)"
)
# ── HTML_SNIPPET típushoz ──
html_snippet: Mapped[Optional[str]] = mapped_column(
Text,
comment="HTML kód külső hirdetési hálózatoktól (pl. Google Ads tag)"
)
# ── Közös mezők ──
title: Mapped[Optional[str]] = mapped_column(
String(200),
comment="Hirdetés címe (megjeleníthető a kép alatt)"
)
description: Mapped[Optional[str]] = mapped_column(
Text,
comment="Hirdetés leírása"
)
target_url: Mapped[Optional[str]] = mapped_column(
String(1000),
comment="Cél URL (hova vezet a kattintás)"
)
payload: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"),
comment="Extra adatok (pl. tracking params, utm_source)"
)
# ── Súlyozás ──
weight: Mapped[int] = mapped_column(
Integer, server_default=text("1"),
comment="Súly a rotációban (magasabb = gyakrabban jelenik meg)"
)
# ── Kapcsolatok ──
campaign: Mapped["Campaign"] = relationship("Campaign", back_populates="creatives")
class Placement(Base):
"""
Elhelyezési zóna a frontenden.
Példák:
- 'dashboard_sidebar'
- 'vehicle_detail_below'
- 'service_search_top'
- 'footer_banner'
"""
__tablename__ = "placements"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False,
comment="Zóna neve (pl. 'dashboard_sidebar')"
)
display_name: Mapped[Optional[str]] = mapped_column(
String(200),
comment="Megjelenítési név az admin felületen"
)
description: Mapped[Optional[str]] = mapped_column(
Text, comment="Leírás a zóna elhelyezkedéséről"
)
# ── Korlátok ──
max_creatives: Mapped[int] = mapped_column(
Integer, server_default=text("1"),
comment="Maximum hány kreatív jelenhet meg egyszerre ebben a zónában"
)
# ── Tier szűrés ──
allowed_subscription_tiers: Mapped[Optional[dict]] = mapped_column(
JSONB, server_default=text("'{}'::jsonb"),
comment="Engedélyezett előfizetési tier-ek JSONB tömbként (pl. ['free', 'premium'])"
)
# ── Meta ──
is_active: Mapped[bool] = mapped_column(
Boolean, server_default=text("true")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# ── Kapcsolatok ──
campaigns: Mapped[list["Campaign"]] = relationship(
"Campaign", secondary="marketing.campaign_placements",
back_populates="placements"
)
class CampaignPlacement(Base):
"""
Kapcsolótábla: Kampány ↔ Elhelyezés (M:N kapcsolat).
Lehetővé teszi, hogy egy kampány több zónában is megjelenjen,
és egy zónában több kampány is fusson.
"""
__tablename__ = "campaign_placements"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.campaigns.id", ondelete="CASCADE"),
nullable=False
)
placement_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.placements.id", ondelete="CASCADE"),
nullable=False
)
# ── Override mezők (felülírhatják a kampány szintű beállításokat) ──
priority_override: Mapped[Optional[int]] = mapped_column(
Integer,
comment="Prioritás felülírása ebben a zónában"
)
daily_impression_limit_override: Mapped[Optional[int]] = mapped_column(
Integer,
comment="Napi limit felülírása ebben a zónában"
)
class AdImpression(Base):
"""
Hirdetés megjelenítések naplózása.
Minden egyes megjelenítés rögzítésre kerül a kampány
és kreatív szintű analitikához.
"""
__tablename__ = "ad_impressions"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
campaign_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.campaigns.id"), nullable=False,
index=True
)
creative_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.creatives.id"), nullable=False,
index=True
)
placement_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.placements.id"), nullable=False
)
user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("identity.users.id"), nullable=True,
comment="Felhasználó aki látta (None = nem bejelentkezett)"
)
# ── Meta ──
seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(),
index=True
)
ip_address: Mapped[Optional[str]] = mapped_column(
String(45), comment="IP cím (anonimizálható)"
)
user_agent: Mapped[Optional[str]] = mapped_column(
Text, comment="User agent string"
)
session_id: Mapped[Optional[str]] = mapped_column(
String(100), index=True,
comment="Munkamenet azonosító (deduplikációhoz)"
)
# ── Kapcsolatok ──
campaign: Mapped["Campaign"] = relationship()
creative: Mapped["Creative"] = relationship()
placement: Mapped["Placement"] = relationship()
class AdClick(Base):
"""
Hirdetés kattintások naplózása (jövőbeli bővítés).
Elkülönítve az impression táblától a jobb skálázhatóság érdekében.
"""
__tablename__ = "ad_clicks"
__table_args__ = {"schema": "marketing"}
id: Mapped[int] = mapped_column(Integer, primary_key=True)
impression_id: Mapped[int] = mapped_column(
Integer, ForeignKey("marketing.ad_impressions.id"), nullable=False,
comment="Melyik megjelenítéshez tartozik a kattintás"
)
clicked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
target_url: Mapped[Optional[str]] = mapped_column(
String(1000),
comment="Cél URL a kattintáskor (tracking paraméterekkel)"
)
```
### 2.2 Adatbázis Séma Diagram (Text)
```
marketing.campaigns
├── id (PK, SERIAL)
├── uuid (UUID, UNIQUE)
├── name (VARCHAR 200)
├── description (TEXT)
├── start_date (TIMESTAMPTZ)
├── end_date (TIMESTAMPTZ, nullable)
├── priority (INTEGER, default=0)
├── status (ENUM: draft/scheduled/active/paused/completed/cancelled)
├── targeting_rules (JSONB)
├── daily_impression_limit (INTEGER, nullable)
├── total_impression_limit (INTEGER, nullable)
├── created_at (TIMESTAMPTZ)
├── updated_at (TIMESTAMPTZ, nullable)
└── created_by (FK → identity.users.id, nullable)
marketing.creatives
├── id (PK, SERIAL)
├── campaign_id (FK → marketing.campaigns.id, CASCADE)
├── creative_type (ENUM: image/html_snippet/video/carousel)
├── image_url (VARCHAR 500, nullable)
├── alt_text (VARCHAR 200, nullable)
├── html_snippet (TEXT, nullable)
├── title (VARCHAR 200, nullable)
├── description (TEXT, nullable)
├── target_url (VARCHAR 1000, nullable)
├── payload (JSONB)
├── weight (INTEGER, default=1)
└── campaign (relationship → Campaign)
marketing.placements
├── id (PK, SERIAL)
├── name (VARCHAR 100, UNIQUE)
├── display_name (VARCHAR 200, nullable)
├── description (TEXT, nullable)
├── max_creatives (INTEGER, default=1)
├── allowed_subscription_tiers (JSONB)
├── is_active (BOOLEAN, default=true)
├── created_at (TIMESTAMPTZ)
└── campaigns (M:N → campaigns via campaign_placements)
marketing.campaign_placements
├── id (PK, SERIAL)
├── campaign_id (FK → marketing.campaigns.id, CASCADE)
├── placement_id (FK → marketing.placements.id, CASCADE)
├── priority_override (INTEGER, nullable)
└── daily_impression_limit_override (INTEGER, nullable)
marketing.ad_impressions
├── id (PK, SERIAL)
├── campaign_id (FK → marketing.campaigns.id, INDEX)
├── creative_id (FK → marketing.creatives.id, INDEX)
├── placement_id (FK → marketing.placements.id)
├── user_id (FK → identity.users.id, nullable)
├── seen_at (TIMESTAMPTZ, INDEX)
├── ip_address (VARCHAR 45, nullable)
├── user_agent (TEXT, nullable)
└── session_id (VARCHAR 100, INDEX)
marketing.ad_clicks (jövőbeli)
├── id (PK, SERIAL)
├── impression_id (FK → marketing.ad_impressions.id)
├── clicked_at (TIMESTAMPTZ)
└── target_url (VARCHAR 1000, nullable)
```
### 2.3 Hirdetés Kiválasztási Logika (Prioritás + Fallback)
Amikor a backend kiválasztja a megfelelő hirdetést egy adott zónához, az alábbi algoritmust követi:
```
1. SZŰRÉS: Aktív kampányok a zónában
SELECT c.* FROM campaigns c
JOIN campaign_placements cp ON cp.campaign_id = c.id
WHERE cp.placement_id = :placement_id
AND c.status = 'active'
AND c.start_date <= NOW()
AND (c.end_date IS NULL OR c.end_date >= NOW())
2. SZŰRÉS: Felhasználó tier-jének megfelelő kampányok
- Ellenőrzi a placement.allowed_subscription_tiers JSONB tömböt
- Csak azokat a kampányokat tartja meg, ahol a user tier szerepel a listában
3. SZŰRÉS: Napi/össz limit ellenőrzés
- Ellenőrzi a campaign.daily_impression_limit-t
- Ellenőrzi a campaign.total_impression_limit-t
- Kiszűri a limitet elért kampányokat
4. SORREND: Prioritás szerinti rendezés
- Elsődleges: priority (magasabb = előrébb)
- Másodlagos: random (egyenlő prioritás esetén rotáció)
5. KIVÁLASZTÁS: A legmagasabb prioritású kampány kreatívjai közül
- Súlyozott random választás a creative.weight alapján
- Ha nincs megfelelő kampány → FALLBACK: üres placeholder vagy default ad
6. MEGJELENÍTÉS: AdImpression naplózása
- Rögzíti a campaign_id, creative_id, placement_id, user_id adatokat
```
#### 2.3.1 Python Pseudo-kód
```python
class AdSelectionService:
"""
Hirdetés kiválasztó szolgáltatás.
A megfelelő hirdetés kiválasztása egy adott zónához,
figyelembe véve a kampány prioritását, a felhasználó tier-jét
és a napi/össz megjelenítési limiteket.
"""
@staticmethod
async def select_ad_for_placement(
db: AsyncSession,
placement_name: str,
user_tier: str = "free",
user_id: Optional[int] = None
) -> Optional[dict]:
"""
Kiválasztja a legmegfelelőbb hirdetést egy adott zónához.
Args:
db: Database session
placement_name: Zóna neve (pl. 'dashboard_sidebar')
user_tier: Felhasználó tier szintje
user_id: Felhasználó ID (naplózáshoz)
Returns:
Optional[dict]: Kiválasztott kreatív adatai vagy None
"""
now = datetime.utcnow()
# 1. Keressük meg a zónát
placement = await db.execute(
select(Placement).where(
Placement.name == placement_name,
Placement.is_active == True
)
)
placement = placement.scalar_one_or_none()
if not placement:
return None
# 2. Ellenőrizzük, hogy a user tier engedélyezett-e ebben a zónában
allowed_tiers = placement.allowed_subscription_tiers or []
if allowed_tiers and user_tier not in allowed_tiers:
return None
# 3. Aktív kampányok lekérdezése ebben a zónában
# Rendezés: priority DESC, majd random
campaigns_query = (
select(Campaign)
.join(CampaignPlacement, CampaignPlacement.campaign_id == Campaign.id)
.where(
CampaignPlacement.placement_id == placement.id,
Campaign.status == CampaignStatus.ACTIVE,
Campaign.start_date <= now,
or_(Campaign.end_date.is_(None), Campaign.end_date >= now),
)
.order_by(Campaign.priority.desc())
)
result = await db.execute(campaigns_query)
campaigns = result.scalars().all()
if not campaigns:
return None # Fallback: nincs aktív kampány
# 4. Válasszuk ki a legmagasabb prioritású kampányt
# (több azonos prioritás esetén random)
top_priority = campaigns[0].priority
top_campaigns = [c for c in campaigns if c.priority == top_priority]
selected_campaign = random.choice(top_campaigns)
# 5. Válasszunk kreatívot a kampányból (súlyozott random)
creatives = selected_campaign.creatives
if not creatives:
return None
# Súlyozott random választás
total_weight = sum(c.weight for c in creatives)
if total_weight == 0:
selected_creative = random.choice(creatives)
else:
r = random.randint(1, total_weight)
cumulative = 0
selected_creative = creatives[0]
for c in creatives:
cumulative += c.weight
if r <= cumulative:
selected_creative = c
break
# 6. Naplózzuk a megjelenítést
impression = AdImpression(
campaign_id=selected_campaign.id,
creative_id=selected_creative.id,
placement_id=placement.id,
user_id=user_id,
seen_at=now,
)
db.add(impression)
await db.flush()
# 7. Visszatérés a kreatív adataival
result_data = {
"creative_id": selected_creative.id,
"campaign_id": selected_campaign.id,
"creative_type": selected_creative.creative_type.value,
"title": selected_creative.title,
"description": selected_creative.description,
"target_url": selected_creative.target_url,
"impression_id": impression.id,
}
if selected_creative.creative_type == CreativeType.IMAGE:
result_data["image_url"] = selected_creative.image_url
result_data["alt_text"] = selected_creative.alt_text
elif selected_creative.creative_type == CreativeType.HTML_SNIPPET:
result_data["html_snippet"] = selected_creative.html_snippet
return result_data
### 2.4 Admin API Végpontok (Tervezet)
Az Ad Engine kezeléséhez az alábbi admin REST végpontok szükségesek:
| Metódus | Végpont | Leírás |
|---------|---------|--------|
| `GET` | `/api/v1/admin/marketing/campaigns` | Kampányok listázása |
| `POST` | `/api/v1/admin/marketing/campaigns` | Új kampány létrehozása |
| `GET` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány részletei |
| `PATCH` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány módosítása |
| `DELETE` | `/api/v1/admin/marketing/campaigns/{id}` | Kampány törlése (soft-delete) |
| `GET` | `/api/v1/admin/marketing/creatives` | Kreatívok listázása |
| `POST` | `/api/v1/admin/marketing/creatives` | Új kreatív létrehozása |
| `GET` | `/api/v1/admin/marketing/placements` | Elhelyezési zónák listázása |
| `POST` | `/api/v1/admin/marketing/placements` | Új zóna létrehozása |
| `GET` | `/api/v1/admin/marketing/analytics/impressions` | Megjelenítési statisztikák |
| `GET` | `/api/v1/admin/marketing/analytics/summary` | Összesített analitika |
### 2.5 Publikus API Végpont (Frontend számára)
| Metódus | Végpont | Leírás |
|---------|---------|--------|
| `GET` | `/api/v1/marketing/ad/{placement_name}` | Hirdetés lekérése egy adott zónához |
Ez a végpont meghívja az `AdSelectionService.select_ad_for_placement()` függvényt, és visszaadja a kiválasztott kreatív adatait a frontend számára.
---
## 3. IMPLEMENTÁCIÓS ÜTEMTERV
### Fázis 1: Backend Alapok (Subscription Stacking)
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 1.1 | `duration_days` hozzáadása a Pydantic sémához | [`subscription.py`](backend/app/schemas/subscription.py:55) | `SubscriptionRulesModel` bővítése `duration_days` mezővel |
| 1.2 | `upgrade_org_subscription()` stacking logika | [`billing_engine.py:805`](backend/app/services/billing_engine.py:805) | Teljes átírás: duration_days olvasás, valid_until számítás stackinggel |
| 1.3 | `upgrade_subscription()` stacking logika | [`billing_engine.py:722`](backend/app/services/billing_engine.py:722) | User-level stacking implementálása |
| 1.4 | `duration_days` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése `duration_days`-szel |
| 1.5 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
### Fázis 2: Ad Engine Modellek és Séma
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 2.1 | `marketing` schema modellek | [`models/marketing.py`](backend/app/models/) | Campaign, Creative, Placement, CampaignPlacement, AdImpression, AdClick |
| 2.2 | Sync engine futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/sync_engine.py` |
| 2.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | `ad_policy` mező hozzáadása a `SubscriptionRulesModel`-hez |
| 2.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
| 2.5 | `AdSelectionService` létrehozása | [`services/ad_selection_service.py`](backend/app/services/) | Hirdetés kiválasztó algoritmus |
### Fázis 3: Admin API Végpontok
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 3.1 | Admin marketing router | [`endpoints/admin_marketing.py`](backend/app/api/v1/endpoints/) | CRUD végpontok kampányokhoz, kreatívokhoz, elhelyezésekhez |
| 3.2 | Publikus ad végpont | [`endpoints/marketing.py`](backend/app/api/v1/endpoints/) | `GET /api/v1/marketing/ad/{placement_name}` |
| 3.3 | Router regisztráció | [`api.py`](backend/app/api/v1/api.py) | Új router-ek hozzáadása az API-hoz |
### Fázis 4: Seed Adatok és Tesztelés
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 4.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
| 4.2 | Seed marketing adatok | [`seed_marketing.py`](backend/app/scripts/) | Teszt kampányok, kreatívok, elhelyezések létrehozása |
| 4.3 | Tesztelés | — | API tesztek a stacking és ad selection logikára |
---
## 4. FÜGGŐSÉGEK ÉS KOCKÁZATOK
| Függőség | Hatás | Megoldás |
|----------|-------|----------|
| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A stacking logika nem működik a meglévő rekordokon | Backfill szkript: `UPDATE finance.org_subscriptions SET valid_until = valid_from + INTERVAL '30 days' WHERE valid_until IS NULL AND is_active = true` |
| A `duration_days` mező hiányzik a meglévő seed csomagokból | Az új stacking logika nem tudja kiszámolni az időtartamot | A `seed_packages.py` újrafuttatása a `duration_days` mezővel |
| Az új `marketing` séma táblái nem léteznek | Az Ad Engine nem működik | A `sync_engine.py` futtatása a modellek létrehozása után |
| A frontend jelenleg nem kérdez le hirdetéseket | A hirdetési zónák üresek maradnak | A frontend `AdService` létrehozása a Vue alkalmazásban |
---
## 5. JÓVÁHAGYÁS
A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
1. **Subscription Stacking:** `duration_days` séma → `upgrade_org_subscription()` → `upgrade_subscription()` → seed frissítés
2. **Ad Engine Modellek:** `marketing` séma → sync engine → Pydantic sémák
3. **Ad Engine Service:** `AdPolicyService` → `AdSelectionService`
4. **Admin API:** CRUD végpontok → publikus ad végpont
5. **Seed Adatok:** `ad_policy` → teszt kampányok

View File

@@ -0,0 +1,428 @@
# P0 Architecture Plan — Subscription UI, Expiration Timers & Smart Ad Policy
**Dátum:** 2026-06-18
**Szerző:** Fast Coder (Core Developer)
**Státusz:** Tervezési fázis — Jóváhagyásra vár
**Cél:** Teljes körű architekturális terv a Subscription UI (Frontend), Expiration Timers (Backend) és a dinamikus, tier-alapú Smart Ad Policy rendszerhez.
---
## 1. AUDIT: Backend Modellek — Előfizetés Időzítők és Állapot
### 1.1 Jelenlegi Adatmodell Áttekintés
A rendszer három rétegben tárolja az előfizetési információkat:
#### A) `system.subscription_tiers` — Csomagdefiníciók
- **Modell:** [`SubscriptionTier`](backend/app/models/core_logic.py:12)
- **Kulcs mezők:** `id`, `name` (unique), `rules` (JSONB), `is_custom`
- **JSONB `rules` struktúra:** Lásd a [`seed_packages.py`](backend/app/scripts/seed_packages.py:37) definícióit
- **Jelenlegi csomagok:** 10 db (4 private + 6 corporate)
#### B) `finance.org_subscriptions` — Szervezeti előfizetés rekordok
- **Modell:** [`OrganizationSubscription`](backend/app/models/core_logic.py:25)
- **Kulcs mezők:**
- `org_id` → FK `fleet.organizations.id`
- `tier_id` → FK `system.subscription_tiers.id`
- `valid_from` (DateTime, server_default=now)
- **`valid_until`** (DateTime, nullable) — **EZ A LEJÁRATI IDŐ**
- `is_active` (Boolean, default=True)
#### C) `finance.user_subscriptions` — Felhasználói előfizetés rekordok
- **Modell:** [`UserSubscription`](backend/app/models/core_logic.py:44)
- **Kulcs mezők:** `user_id`, `tier_id`, `valid_from`, **`valid_until`**, `is_active`, `created_at`, `updated_at`
#### D) `fleet.organizations` — Szervezet (denormalizált subscription adatok)
- **Modell:** [`Organization`](backend/app/models/marketplace/organization.py:70)
- **Kulcs mezők:**
- `subscription_plan` (String, legacy — server_default='FREE')
- `subscription_tier_id` (FK → `system.subscription_tiers.id`, nullable)
- `base_asset_limit` (Integer, server_default=1)
- `purchased_extra_slots` (Integer, server_default=0)
### 1.2 Lejárati Idő (valid_until) Jelenlegi Állapota
| Tábla | Oszlop | Típus | Alapértelmezés | Jelenlegi Használat |
|-------|--------|-------|----------------|---------------------|
| `finance.org_subscriptions` | `valid_until` | `DateTime(timezone=True)` | `NULL` | **Nincs beállítva** a [`upgrade_org_subscription()`](backend/app/services/billing_engine.py:805) függvényben — csak `valid_from` kerül beállításra |
| `finance.user_subscriptions` | `valid_until` | `DateTime(timezone=True)` | `NULL` | **Nincs beállítva** a felhasználói előfizetés létrehozáskor |
| `identity.users` | `subscription_expires_at` | (a kódban használva) | — | A [`billing_engine.py:792`](backend/app/services/billing_engine.py:792) beállítja: `user.subscription_expires_at = datetime.utcnow() + timedelta(days=30)` |
**Következtetés:** A `valid_until` mező jelenleg **nem töltődik be** a szervezeti előfizetés hozzárendelésekor. Ez egy hiányosság, amit pótolni kell.
### 1.3 Tervezett Módosítás: `GET /api/v1/organizations/my` Subscription Adatokkal
A [`GET /my`](backend/app/api/v1/endpoints/organizations.py:180) végpont jelenleg **nem adja vissza** az előfizetési adatokat. Tervezett bővítés:
```python
# Jelenlegi válasz (hiányos):
{
"organization_id": 1,
"subscription_plan": "FREE",
"subscription_tier_id": None,
# ... nincs subscription info
}
# Tervezett bővítés:
{
"organization_id": 1,
"subscription_plan": "corp_premium_v1",
"subscription_tier_id": 16,
"subscription": {
"tier_id": 16,
"tier_name": "corp_premium_v1",
"display_name": "Céges Prémium",
"valid_from": "2026-06-01T00:00:00Z",
"valid_until": "2026-07-01T00:00:00Z", # LEJÁRAT
"is_active": True,
"allowances": {
"max_vehicles": 20,
"max_garages": 3,
"monthly_free_credits": 300
},
"pricing": {
"monthly_price": 29.99,
"yearly_price": 299.99,
"currency": "EUR"
}
}
}
```
**Implementációs terv:**
1. A [`GET /my`](backend/app/api/v1/endpoints/organizations.py:180) végpontban minden Organization rekordhoz töltsük be a hozzá tartozó aktív `OrganizationSubscription` rekordot (JOIN vagy selectinload).
2. Ha van aktív subscription, adjuk vissza a `valid_until`, `tier.rules` (allowances, pricing, display_name) adatokat.
3. Ha nincs, a subscription blokk legyen `null`.
---
## 2. SMART AD POLICY — JSONB Architektúra
### 2.1 Tervezési Döntés
A Tervező elvetette az egyszerű `hide_ads` boolean megközelítést. Ehelyett egy **3 szintű, tier-alapú hirdetési politika** kerül bevezetésre, amely a `system.subscription_tiers.rules` JSONB oszlopba ágyazva él.
### 2.2 Az `ad_policy` JSON Struktúra
```json
{
"ad_policy": {
"external_display": {
"enabled": true | false,
"networks": ["google_ads", "taboola", "outbrain"],
"max_per_page": 3,
"placement": ["sidebar", "between_cards", "footer"]
},
"internal_upsell": {
"enabled": true | false,
"show_banners": true | false,
"banner_type": "soft" | "aggressive",
"upsell_target_tier": "private_pro_v1",
"frequency": "once_per_session" | "every_n_actions"
},
"partner_offers": {
"enabled": true | false,
"show_sponsored_services": true | false,
"show_coupons": true | false,
"max_offers_per_page": 2
}
}
}
```
### 2.3 Példa: "Free" Csomag (`private_free_v1`)
```json
{
"type": "private",
"display_name": "Privát Ingyenes",
"pricing": { "monthly_price": 0, "yearly_price": 0, "currency": "EUR", "credit_price": 0 },
"allowances": { "max_vehicles": 1, "max_garages": 1, "monthly_free_credits": 0 },
"entitlements": [],
"lifecycle": { "is_public": true },
"ad_policy": {
"external_display": {
"enabled": true,
"networks": ["google_ads"],
"max_per_page": 3,
"placement": ["sidebar", "between_cards", "footer"]
},
"internal_upsell": {
"enabled": true,
"show_banners": true,
"banner_type": "aggressive",
"upsell_target_tier": "private_pro_v1",
"frequency": "every_n_actions"
},
"partner_offers": {
"enabled": true,
"show_sponsored_services": true,
"show_coupons": true,
"max_offers_per_page": 2
}
}
}
```
### 2.4 Példa: "Premium" Csomag (`corp_premium_v1`)
```json
{
"type": "corporate",
"display_name": "Céges Prémium",
"pricing": { "monthly_price": 29.99, "yearly_price": 299.99, "currency": "EUR", "credit_price": 3000 },
"allowances": { "max_vehicles": 20, "max_garages": 3, "monthly_free_credits": 300 },
"entitlements": ["SRV_DATA_EXPORT", "SRV_AI_UPLOAD"],
"lifecycle": { "is_public": true },
"ad_policy": {
"external_display": {
"enabled": false,
"networks": [],
"max_per_page": 0,
"placement": []
},
"internal_upsell": {
"enabled": false,
"show_banners": false,
"banner_type": "soft",
"upsell_target_tier": "corp_premium_plus_v1",
"frequency": "once_per_session"
},
"partner_offers": {
"enabled": true,
"show_sponsored_services": true,
"show_coupons": false,
"max_offers_per_page": 1
}
}
}
```
### 2.5 Backend Service Terv: `AdPolicyService`
Létrehozandó fájl: [`backend/app/services/ad_policy_service.py`](backend/app/services/)
```python
class AdPolicyService:
"""
Szolgáltatás a hirdetési politika lekérdezésére a felhasználó/szervezet
aktuális előfizetési csomagja alapján.
"""
@staticmethod
async def get_ad_policy(db: AsyncSession, org_id: int) -> dict:
"""
Visszaadja a szervezet aktuális ad_policy-ját.
Ha nincs tier hozzárendelve, a 'private_free_v1' alapértelmezett policy-t adja.
"""
pass
@staticmethod
def should_show_external_ads(policy: dict) -> bool:
"""Ellenőrzi, hogy a külső hirdetések engedélyezettek-e."""
return policy.get("external_display", {}).get("enabled", False)
@staticmethod
def should_show_upsell_banner(policy: dict) -> bool:
"""Ellenőrzi, hogy a belső upsell banner megjelenjen-e."""
return policy.get("internal_upsell", {}).get("enabled", False)
```
### 2.6 Pydantic Schema Bővítés
A [`SubscriptionRulesModel`](backend/app/schemas/subscription.py:55) kiegészítése az `ad_policy` mezővel:
```python
class AdPolicyExternalDisplay(BaseModel):
enabled: bool = False
networks: List[str] = []
max_per_page: int = 0
placement: List[str] = []
class AdPolicyInternalUpsell(BaseModel):
enabled: bool = False
show_banners: bool = False
banner_type: str = "soft"
upsell_target_tier: Optional[str] = None
frequency: str = "once_per_session"
class AdPolicyPartnerOffers(BaseModel):
enabled: bool = False
show_sponsored_services: bool = False
show_coupons: bool = False
max_offers_per_page: int = 0
class AdPolicyModel(BaseModel):
external_display: AdPolicyExternalDisplay = Field(default_factory=AdPolicyExternalDisplay)
internal_upsell: AdPolicyInternalUpsell = Field(default_factory=AdPolicyInternalUpsell)
partner_offers: AdPolicyPartnerOffers = Field(default_factory=AdPolicyPartnerOffers)
```
---
## 3. UI Routing & Catalog — Frontend Terv
### 3.1 Jelenlegi Navigációs Struktúra
A frontend két fő layoutot használ:
1. **`PrivateLayout.vue`** ([`frontend/src/layouts/PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue)) — Hamburger menü: Dashboard, Járművek, Költségek, Szerviz kereső
2. **`OrganizationLayout.vue`** ([`frontend/src/layouts/OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue)) — Hamburger menü: Company Data
**Hiányzó elem:** Egyik layoutban sincs "Előfizetésem & Csomagok" menüpont.
### 3.2 Tervezett UI Változtatások
#### A) Új menüpont: "Előfizetésem & Csomagok"
**Helye a `PrivateLayout.vue` hamburger menüben** (a Szerviz kereső után, a lista végén):
```html
<!-- Előfizetésem & Csomagok -->
<button
@click="navigateToSubscription"
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-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
{{ t('menu.subscription') }}
</button>
```
**Helye az `OrganizationLayout.vue` hamburger menüben** (a Company Data után):
```html
<button
@click="navigateToSubscription"
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"
>
<!-- Wallet/Subscription icon -->
<svg class="w-4 h-4 text-white/40" ...>
{{ t('menu.subscription') }}
</button>
```
#### B) Új Route: `/subscription`
A [`router/index.ts`](frontend/src/router/index.ts)-ben új route hozzáadása:
```typescript
{
path: '/subscription',
name: 'subscription',
component: () => import('../views/SubscriptionView.vue'),
meta: { requiresAuth: true }
}
```
#### C) Új View: `SubscriptionView.vue`
Létrehozandó: [`frontend/src/views/SubscriptionView.vue`](frontend/src/views/)
Funkciók:
- Megjeleníti az aktuális csomag adatait (név, lejárati idő, limitek)
- Lejárati idő visszaszámláló (countdown timer)
- "Csomag váltása" gomb → megnyitja a csomagkatalógust
- Hirdetési politika státuszának megjelenítése (pl. "A Te csomagodban nincsenek külső hirdetések")
### 3.3 `GET /api/v1/subscriptions/public` — Publikus Csomagkatalógus API
**Tervezett új végpont:**
```
GET /api/v1/subscriptions/public?type=private|corporate
```
**Szűrési logika:**
- Ha a garázs `org_type == 'individual'` → csak `private_` prefixű csomagokat ad vissza
- Ha a garázs `org_type != 'individual'` (business, fleet_owner, stb.) → csak `corp_` prefixű csomagokat ad vissza
- Minden esetben csak azokat a csomagokat, ahol `rules.lifecycle.is_public == true`
**Tervezett implementáció:**
```python
@router.get("/public", response_model=SubscriptionTierListResponse)
async def get_public_subscriptions(
org_type: Optional[str] = Query(None, description="Szűrés típus szerint: private vagy corporate"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Publikus előfizetési csomagok listázása.
- Ha org_type='individual', csak private_ csomagok
- Ha org_type='business' (vagy más), csak corp_ csomagok
- Csak az is_public=true csomagok
"""
stmt = select(SubscriptionTier)
# Szűrés típus szerint
if org_type and org_type.lower() == 'individual':
stmt = stmt.where(SubscriptionTier.rules["type"].as_string() == "private")
elif org_type:
stmt = stmt.where(SubscriptionTier.rules["type"].as_string() == "corporate")
# Csak publikus csomagok
stmt = stmt.where(
~SubscriptionTier.rules["lifecycle"]["is_public"].as_string().in_(["false"])
)
result = await db.execute(stmt.order_by(SubscriptionTier.id))
tiers = result.scalars().all()
return SubscriptionTierListResponse(
total=len(tiers),
tiers=[SubscriptionTierResponse.model_validate(t) for t in tiers]
)
```
---
## 4. Implementációs Ütemterv
### Fázis 1: Backend Alapok (Elsőbbséges)
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 1.1 | `valid_until` beállítása | [`billing_engine.py:869`](backend/app/services/billing_engine.py:869) | A `upgrade_org_subscription()` függvényben állítsuk be a `valid_until = now + 30 nap` értéket |
| 1.2 | `GET /my` subscription adatokkal | [`organizations.py:180`](backend/app/api/v1/endpoints/organizations.py:180) | Bővítsük ki a választ subscription blokkal (tier adatok + valid_until) |
| 1.3 | `AdPolicyModel` Pydantic séma | [`subscription.py`](backend/app/schemas/subscription.py) | Add hozzá az `ad_policy` mezőt a `SubscriptionRulesModel`-hez |
| 1.4 | `AdPolicyService` létrehozása | [`services/ad_policy_service.py`](backend/app/services/) | Új service a hirdetési politika lekérdezéséhez |
| 1.5 | `GET /api/v1/subscriptions/public` | Új végpont | Publikus csomagkatalógus API org_type alapú szűréssel |
### Fázis 2: Frontend UI
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 2.1 | "Előfizetésem" menüpont | [`PrivateLayout.vue`](frontend/src/layouts/PrivateLayout.vue) | Új hamburger menüpont |
| 2.2 | "Előfizetésem" menüpont | [`OrganizationLayout.vue`](frontend/src/layouts/OrganizationLayout.vue) | Új hamburger menüpont |
| 2.3 | `/subscription` route | [`router/index.ts`](frontend/src/router/index.ts) | Új route regisztrálása |
| 2.4 | `SubscriptionView.vue` | [`views/SubscriptionView.vue`](frontend/src/views/) | Új nézet: aktuális csomag + countdown + csomagváltás |
| 2.5 | i18n kulcsok | [`locales/hu.json`](backend/static/locales/hu.json), [`locales/en.json`](backend/static/locales/en.json) | `menu.subscription` és kapcsolódó fordítások |
### Fázis 3: Seed Adatbázis Frissítés
| # | Feladat | Fájl | Leírás |
|---|---------|------|--------|
| 3.1 | `ad_policy` hozzáadása a seed csomagokhoz | [`seed_packages.py`](backend/app/scripts/seed_packages.py) | Mind a 6 csomag rules kiegészítése az `ad_policy` blokkal |
| 3.2 | Seed szkript futtatása | — | `docker compose exec sf_api python3 /app/backend/app/scripts/seed_packages.py` |
---
## 5. Függőségek és Kockázatok
| Függőség | Hatás | Megoldás |
|----------|-------|----------|
| A `valid_until` mező jelenleg NULL minden `org_subscriptions` rekordban | A lejárati idő visszaszámláló nem működik | A `upgrade_org_subscription()` bővítése + backfill szkript a meglévő rekordokhoz |
| A frontend auth store (`auth.ts`) jelenleg nem tárol subscription adatokat | A UI nem tudja megjeleníteni a csomag infót | A `GET /my` válasz bővítése után a store automatikusan frissül |
| A meglévő seed csomagok nem tartalmaznak `ad_policy`-t | A Smart Ad Policy nem működik a régi csomagokon | A `seed_packages.py` frissítése és újrafuttatása |
---
## 6. Jóváhagyás
A fenti terv jóváhagyása után kezdődhet meg a kódolás az alábbi sorrendben:
1. **Backend:** `valid_until` javítás → `GET /my` bővítés → `AdPolicyModel``AdPolicyService``GET /public`
2. **Seed:** `ad_policy` hozzáadása a csomagokhoz
3. **Frontend:** Menüpontok → Route → SubscriptionView → i18n