RABAC felépítése megtörtént ill hirdetési portál alapok lerakva
This commit is contained in:
150
.roo/history.md
150
.roo/history.md
@@ -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 ✅
|
- `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 ✅
|
- `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 ✅
|
- 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
49
Pictures/sf_card.svg
Normal 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 |
@@ -5,7 +5,8 @@ from app.api.v1.endpoints import (
|
|||||||
services, admin, expenses, evidence, social, security,
|
services, admin, expenses, evidence, social, security,
|
||||||
billing, finance_admin, analytics, vehicles, system_parameters,
|
billing, finance_admin, analytics, vehicles, system_parameters,
|
||||||
gamification, translations, users, reports, dictionaries,
|
gamification, translations, users, reports, dictionaries,
|
||||||
admin_packages, admin_services, constants, providers
|
admin_packages, admin_services, constants, providers,
|
||||||
|
subscriptions, marketing,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(billing.router, prefix="/billing", tags=["Billing & Wallet"])
|
||||||
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
api_router.include_router(constants.router, prefix="/constants", tags=["Constants"])
|
||||||
api_router.include_router(providers.router, prefix="/providers", tags=["Providers"])
|
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"])
|
||||||
@@ -7,7 +7,7 @@ from app.api.deps import get_db, get_current_user
|
|||||||
from app.models.identity import User
|
from app.models.identity import User
|
||||||
from app.models import Asset # JAVÍTVA: Asset modell
|
from app.models import Asset # JAVÍTVA: Asset modell
|
||||||
from app.models.marketplace.organization import Organization
|
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__)
|
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 ──
|
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
|
||||||
# Instead of hardcoded plan-based lookup from system_parameters,
|
# Instead of hardcoded plan-based lookup from system_parameters,
|
||||||
# we now read the organization's assigned subscription_tier rules.
|
# 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
|
max_allowed = 1 # default fallback
|
||||||
|
|
||||||
if current_user.scope_id:
|
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
|
# Fallback to legacy base_asset_limit if no tier assigned
|
||||||
max_allowed = max(org.base_asset_limit or 1, 1)
|
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(
|
stmt_count = select(func.count(Asset.id)).where(
|
||||||
Asset.owner_org_id == current_user.scope_id,
|
Asset.owner_org_id == current_user.scope_id,
|
||||||
Asset.status == "active"
|
Asset.status == "active"
|
||||||
|
|||||||
136
backend/app/api/v1/endpoints/marketing.py
Normal file
136
backend/app/api/v1/endpoints/marketing.py
Normal 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,
|
||||||
|
)
|
||||||
176
backend/app/api/v1/endpoints/subscriptions.py
Normal file
176
backend/app/api/v1/endpoints/subscriptions.py
Normal 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
|
||||||
@@ -50,6 +50,13 @@ from .identity.security import PendingAction, ActionStatus
|
|||||||
from .system.legal import LegalDocument, LegalAcceptance
|
from .system.legal import LegalDocument, LegalAcceptance
|
||||||
from .marketplace.logistics import Location, LocationType
|
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
|
# Aliasok a Digital Twin kompatibilitáshoz
|
||||||
Vehicle = Asset
|
Vehicle = Asset
|
||||||
@@ -81,5 +88,9 @@ __all__ = [
|
|||||||
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
|
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition", "ReferenceLookup",
|
||||||
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
|
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
|
||||||
"Location", "LocationType", "Issuer", "IssuerType", "CostCategory", "VehicleCost", "ExternalReferenceLibrary", "ExternalReferenceQueue",
|
"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",
|
||||||
]
|
]
|
||||||
@@ -41,6 +41,11 @@ class OrganizationSubscription(Base):
|
|||||||
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
valid_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=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):
|
class UserSubscription(Base):
|
||||||
"""
|
"""
|
||||||
Felhasználók aktuális előfizetései és azok érvényessége.
|
Felhasználók aktuális előfizetései és azok érvényessége.
|
||||||
|
|||||||
300
backend/app/models/marketing.py
Normal file
300
backend/app/models/marketing.py
Normal 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())
|
||||||
@@ -6,7 +6,10 @@ A rules struktúra a seed_packages.py-ban definiált formátumot követi:
|
|||||||
|
|
||||||
{
|
{
|
||||||
"type": "private" | "corporate",
|
"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},
|
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
|
||||||
"entitlements": ["SRV_..."],
|
"entitlements": ["SRV_..."],
|
||||||
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
"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 ──────────────────────
|
# ── 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):
|
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)")
|
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)")
|
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")
|
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")
|
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):
|
class LifecycleModel(BaseModel):
|
||||||
"""Életciklus állapotok egy előfizetési csomaghoz."""
|
"""É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)")
|
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")
|
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):
|
class SubscriptionRulesModel(BaseModel):
|
||||||
"""
|
"""
|
||||||
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
|
A subscription_tiers.rules JSONB oszlopának erősen tipizált modellje.
|
||||||
@@ -59,11 +90,15 @@ class SubscriptionRulesModel(BaseModel):
|
|||||||
Fields:
|
Fields:
|
||||||
type: "private" (magánszemély) vagy "corporate" (vállalati)
|
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")
|
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)
|
allowances: Korlátok (max jármű, garázs, kredit)
|
||||||
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
|
entitlements: Jogosultságok listája (pl. SRV_DATA_EXPORT)
|
||||||
affiliate: Partnerprogram beállítások
|
affiliate: Partnerprogram beállítások
|
||||||
lifecycle: Életciklus állapotok (opcionális, pl. is_public)
|
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")
|
type: str = Field(..., pattern=r"^(private|corporate)$", description="Csomag típusa")
|
||||||
display_name: Optional[str] = Field(
|
display_name: Optional[str] = Field(
|
||||||
@@ -72,7 +107,11 @@ class SubscriptionRulesModel(BaseModel):
|
|||||||
)
|
)
|
||||||
pricing: Optional[PricingModel] = Field(
|
pricing: Optional[PricingModel] = Field(
|
||||||
default=None,
|
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(
|
allowances: Optional[AllowancesModel] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -87,6 +126,18 @@ class SubscriptionRulesModel(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
description="Életciklus állapotok (is_public, available_until)"
|
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")
|
@field_validator("type")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -109,6 +160,23 @@ class SubscriptionTierResponse(BaseModel):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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):
|
class SubscriptionTierCreate(BaseModel):
|
||||||
"""Új előfizetési csomag létrehozása (POST /)."""
|
"""Ú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)")
|
name: str = Field(..., min_length=2, max_length=100, description="Csomag neve (pl. private_pro_v2)")
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ Architektúra:
|
|||||||
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
|
A csomagok rules JSONB oszlopa egy szabványos struktúrát követ:
|
||||||
{
|
{
|
||||||
"type": "private" | "corporate",
|
"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},
|
"allowances": {"max_vehicles": int, "max_garages": int, "monthly_free_credits": int},
|
||||||
"entitlements": ["SRV_..."],
|
"entitlements": ["SRV_..."],
|
||||||
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
"affiliate": {"commission_rate_percent": int, "referral_bonus_credits": int}
|
||||||
@@ -31,6 +34,46 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger("Seed-Packages")
|
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
|
# Csomagdefiníciók
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -46,6 +89,12 @@ PACKAGES = [
|
|||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"credit_price": 0,
|
"credit_price": 0,
|
||||||
},
|
},
|
||||||
|
"pricing_zones": pricing_zones(
|
||||||
|
eur_monthly=0.0,
|
||||||
|
eur_yearly=0.0,
|
||||||
|
huf_monthly=0,
|
||||||
|
huf_yearly=0,
|
||||||
|
),
|
||||||
"allowances": {
|
"allowances": {
|
||||||
"max_vehicles": 1,
|
"max_vehicles": 1,
|
||||||
"max_garages": 1,
|
"max_garages": 1,
|
||||||
@@ -59,6 +108,19 @@ PACKAGES = [
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"is_public": True,
|
"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,
|
"is_custom": False,
|
||||||
},
|
},
|
||||||
@@ -73,6 +135,17 @@ PACKAGES = [
|
|||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"credit_price": 500,
|
"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": {
|
"allowances": {
|
||||||
"max_vehicles": 3,
|
"max_vehicles": 3,
|
||||||
"max_garages": 1,
|
"max_garages": 1,
|
||||||
@@ -86,6 +159,20 @@ PACKAGES = [
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"is_public": True,
|
"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,
|
"is_custom": False,
|
||||||
},
|
},
|
||||||
@@ -100,6 +187,17 @@ PACKAGES = [
|
|||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"credit_price": 1200,
|
"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": {
|
"allowances": {
|
||||||
"max_vehicles": 10,
|
"max_vehicles": 10,
|
||||||
"max_garages": 1,
|
"max_garages": 1,
|
||||||
@@ -113,6 +211,20 @@ PACKAGES = [
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"is_public": True,
|
"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,
|
"is_custom": False,
|
||||||
},
|
},
|
||||||
@@ -127,6 +239,17 @@ PACKAGES = [
|
|||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"credit_price": 3000,
|
"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": {
|
"allowances": {
|
||||||
"max_vehicles": 20,
|
"max_vehicles": 20,
|
||||||
"max_garages": 3,
|
"max_garages": 3,
|
||||||
@@ -140,6 +263,20 @@ PACKAGES = [
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"is_public": True,
|
"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,
|
"is_custom": False,
|
||||||
},
|
},
|
||||||
@@ -154,6 +291,17 @@ PACKAGES = [
|
|||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"credit_price": 6000,
|
"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": {
|
"allowances": {
|
||||||
"max_vehicles": 50,
|
"max_vehicles": 50,
|
||||||
"max_garages": 10,
|
"max_garages": 10,
|
||||||
@@ -171,6 +319,20 @@ PACKAGES = [
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"is_public": True,
|
"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,
|
"is_custom": False,
|
||||||
},
|
},
|
||||||
@@ -185,6 +347,17 @@ PACKAGES = [
|
|||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
"credit_price": 15000,
|
"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": {
|
"allowances": {
|
||||||
"max_vehicles": 200,
|
"max_vehicles": 200,
|
||||||
"max_garages": 50,
|
"max_garages": 50,
|
||||||
@@ -203,6 +376,20 @@ PACKAGES = [
|
|||||||
"lifecycle": {
|
"lifecycle": {
|
||||||
"is_public": True,
|
"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,
|
"is_custom": False,
|
||||||
},
|
},
|
||||||
@@ -215,28 +402,31 @@ async def seed():
|
|||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
# 1. Töröljük a meglévő rekordokat (tiszta lap)
|
# 1. UPSERT: Meglévő csomagok frissítése vagy új beszúrása
|
||||||
logger.info("Törlöm a meglévő subscription_tiers rekordokat...")
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
await db.execute(delete(SubscriptionTier))
|
|
||||||
await db.commit()
|
|
||||||
logger.info("✅ Meglévő rekordok törölve.")
|
|
||||||
|
|
||||||
# 2. Beszúrjuk a 6 új csomagot
|
upserted_count = 0
|
||||||
inserted_count = 0
|
|
||||||
for pkg in PACKAGES:
|
for pkg in PACKAGES:
|
||||||
tier = SubscriptionTier(
|
stmt = pg_insert(SubscriptionTier).values(
|
||||||
name=pkg["name"],
|
name=pkg["name"],
|
||||||
rules=pkg["rules"],
|
rules=pkg["rules"],
|
||||||
is_custom=pkg["is_custom"],
|
is_custom=pkg["is_custom"],
|
||||||
)
|
)
|
||||||
db.add(tier)
|
stmt = stmt.on_conflict_do_update(
|
||||||
inserted_count += 1
|
index_elements=[SubscriptionTier.name],
|
||||||
logger.info(f" ➕ Hozzáadva: {pkg['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()
|
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("-" * 60)
|
||||||
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
|
logger.info("Visszaigazolás - system.subscription_tiers tartalma:")
|
||||||
logger.info("-" * 60)
|
logger.info("-" * 60)
|
||||||
@@ -256,7 +446,7 @@ async def seed():
|
|||||||
|
|
||||||
logger.info(f"\n✅ Összesen {len(tiers)} aktív csomag a rendszerben.")
|
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:
|
if tiers:
|
||||||
sample = tiers[0]
|
sample = tiers[0]
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|||||||
@@ -494,9 +494,11 @@ class AssetService:
|
|||||||
Get the vehicle limit for a user, checking:
|
Get the vehicle limit for a user, checking:
|
||||||
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
|
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
|
||||||
2. Organization-level base_asset_limit (fallback if no user subscription)
|
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: 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.
|
Legacy string-based subscription_plan lookups have been removed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -508,7 +510,7 @@ class AssetService:
|
|||||||
Maximum allowed vehicles
|
Maximum allowed vehicles
|
||||||
"""
|
"""
|
||||||
from app.models.identity import User
|
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.models.marketplace.organization import Organization
|
||||||
from app.services.config_service import config
|
from app.services.config_service import config
|
||||||
|
|
||||||
@@ -577,7 +579,33 @@ class AssetService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {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
|
config_limit = None
|
||||||
try:
|
try:
|
||||||
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
limits = await config.get_setting(db, "VEHICLE_LIMIT")
|
||||||
@@ -591,16 +619,20 @@ class AssetService:
|
|||||||
if config_limit is None:
|
if config_limit is None:
|
||||||
config_limit = 1 # absolute fallback
|
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
|
final_limit = config_limit
|
||||||
if org_limit is not None:
|
if org_limit is not None:
|
||||||
final_limit = max(final_limit, org_limit)
|
final_limit = max(final_limit, org_limit)
|
||||||
if subscription_limit is not None:
|
if subscription_limit is not None:
|
||||||
final_limit = max(final_limit, subscription_limit)
|
final_limit = max(final_limit, subscription_limit)
|
||||||
|
|
||||||
|
# Add extra_allowances on top of the base limit
|
||||||
|
final_limit = final_limit + extra_vehicles
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
|
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}"
|
f"final={final_limit}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -727,6 +727,9 @@ async def upgrade_subscription(
|
|||||||
"""
|
"""
|
||||||
Felhasználó előfizetésének frissítése (csomagváltás).
|
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:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
user_id: Felhasználó ID
|
user_id: Felhasználó ID
|
||||||
@@ -735,7 +738,7 @@ async def upgrade_subscription(
|
|||||||
Returns:
|
Returns:
|
||||||
Dict: Tranzakció részletei és az új előfizetés információi
|
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
|
from app.models.identity import User
|
||||||
|
|
||||||
# 1. Ellenőrizze, hogy a cél csomag létezik-e
|
# 1. Ellenőrizze, hogy a cél csomag létezik-e
|
||||||
@@ -746,23 +749,67 @@ async def upgrade_subscription(
|
|||||||
if not tier:
|
if not tier:
|
||||||
raise ValueError(f"Subscription tier '{target_package}' not found")
|
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)
|
# 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
|
price = tier.rules.get("price", 0.0) if tier.rules else 0.0
|
||||||
if price <= 0:
|
if price <= 0:
|
||||||
# Ingyenes csomag, nincs levonás
|
# Ingyenes csomag, nincs levonás
|
||||||
logger.info(f"Upgrading user {user_id} to free tier {target_package}")
|
logger.info(f"Upgrading user {user_id} to free tier {target_package}")
|
||||||
|
|
||||||
# Frissítse a felhasználó subscription_plan mezőjét
|
# Frissítse a felhasználó subscription_plan mezőjét
|
||||||
user_stmt = select(User).where(User.id == user_id)
|
user_stmt = select(User).where(User.id == user_id)
|
||||||
user_result = await db.execute(user_stmt)
|
user_result = await db.execute(user_stmt)
|
||||||
user = user_result.scalar_one()
|
user = user_result.scalar_one()
|
||||||
user.subscription_plan = target_package
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Upgraded to {target_package} (free)",
|
"message": f"Upgraded to {target_package} (free)",
|
||||||
"new_plan": target_package,
|
"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
|
# 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
|
# 5. Frissítse a felhasználó előfizetési adatait
|
||||||
user.subscription_plan = target_package
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"transaction": transaction,
|
"transaction": transaction,
|
||||||
"new_plan": target_package,
|
"new_plan": target_package,
|
||||||
"price_paid": final_price,
|
"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()
|
"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/
|
subscription_tier_id FK on the Organization record, and also creates/
|
||||||
updates a record in finance.org_subscriptions for audit trail.
|
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:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
org_id: Organization ID
|
org_id: Organization ID
|
||||||
@@ -860,24 +949,54 @@ async def upgrade_org_subscription(
|
|||||||
sub_result = await db.execute(sub_stmt)
|
sub_result = await db.execute(sub_stmt)
|
||||||
existing_sub = sub_result.scalar_one_or_none()
|
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:
|
if existing_sub:
|
||||||
# Deaktiváljuk a régit
|
# Deaktiváljuk a régit
|
||||||
existing_sub.is_active = False
|
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(
|
new_sub = OrganizationSubscription(
|
||||||
org_id=org_id,
|
org_id=org_id,
|
||||||
tier_id=tier_id,
|
tier_id=tier_id,
|
||||||
valid_from=datetime.utcnow(),
|
valid_from=now,
|
||||||
|
valid_until=new_valid_until,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
db.add(new_sub)
|
db.add(new_sub)
|
||||||
|
|
||||||
|
# Frissítsük a szervezet denormalizált mezőit is
|
||||||
|
org.subscription_expires_at = new_valid_until
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Org subscription upgraded: org_id={org_id}, "
|
f"Org subscription upgraded: org_id={org_id}, "
|
||||||
f"tier={tier.name} (id={tier_id}), "
|
f"tier={tier.name} (id={tier_id}), "
|
||||||
f"max_vehicles={max_vehicles}, "
|
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}"
|
f"actor={actor_user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -887,6 +1006,9 @@ async def upgrade_org_subscription(
|
|||||||
"tier_id": tier_id,
|
"tier_id": tier_id,
|
||||||
"tier_name": tier.name,
|
"tier_name": tier.name,
|
||||||
"max_vehicles": max_vehicles,
|
"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}"
|
"message": f"Organization {org_id} upgraded to {tier.name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
259
docs/p0_sf_card_svg_analysis_basecard_plan.md
Normal file
259
docs/p0_sf_card_svg_analysis_basecard_plan.md
Normal 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
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
@click.self="$emit('close')"
|
@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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
|
<div class="flex items-center justify-between px-6 py-5 border-b border-gray-700">
|
||||||
<h2 class="text-xl font-bold text-white">
|
<h2 class="text-xl font-bold text-white">
|
||||||
@@ -254,6 +254,157 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Error Display -->
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage"
|
v-if="errorMessage"
|
||||||
@@ -311,6 +462,16 @@ const store = useAdminPackagesStore()
|
|||||||
|
|
||||||
const isEditing = computed(() => !!props.package)
|
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 ──────────────────────────────────────────────────────────────
|
// ── Form State ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
@@ -328,10 +489,15 @@ const form = ref({
|
|||||||
available_until: '' as string,
|
available_until: '' as string,
|
||||||
commission_rate_percent: 0,
|
commission_rate_percent: 0,
|
||||||
referral_bonus_credits: 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 selectedEntitlements = ref<string[]>([])
|
||||||
const entitlementToAdd = ref('')
|
const entitlementToAdd = ref('')
|
||||||
|
const pricingZones = ref<PricingZoneFormItem[]>([])
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const errorMessage = ref<string | null>(null)
|
const errorMessage = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -367,6 +533,25 @@ watch(
|
|||||||
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
|
form.value.commission_rate_percent = rules.affiliate.commission_rate_percent
|
||||||
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
|
form.value.referral_bonus_credits = rules.affiliate.referral_bonus_credits
|
||||||
selectedEntitlements.value = [...rules.entitlements]
|
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 {
|
} else {
|
||||||
resetForm()
|
resetForm()
|
||||||
}
|
}
|
||||||
@@ -390,8 +575,12 @@ function resetForm() {
|
|||||||
available_until: '',
|
available_until: '',
|
||||||
commission_rate_percent: 0,
|
commission_rate_percent: 0,
|
||||||
referral_bonus_credits: 0,
|
referral_bonus_credits: 0,
|
||||||
|
marketing_subtitle: '',
|
||||||
|
marketing_badge: '',
|
||||||
|
marketing_highlight_color: '',
|
||||||
}
|
}
|
||||||
selectedEntitlements.value = []
|
selectedEntitlements.value = []
|
||||||
|
pricingZones.value = []
|
||||||
errorMessage.value = null
|
errorMessage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +598,22 @@ function removeEntitlement(ent: string) {
|
|||||||
selectedEntitlements.value = selectedEntitlements.value.filter((e) => e !== ent)
|
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 ──────────────────────────────────────────────────────
|
// ── Build Rules Object ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRules(): SubscriptionRulesModel {
|
function buildRules(): SubscriptionRulesModel {
|
||||||
@@ -445,6 +650,40 @@ function buildRules(): SubscriptionRulesModel {
|
|||||||
lifecycle.available_until = form.value.available_until
|
lifecycle.available_until = form.value.available_until
|
||||||
}
|
}
|
||||||
rules.lifecycle = lifecycle as any
|
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
|
return rules
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
133
frontend/src/components/dashboard/AdPlacementWidget.vue
Normal file
133
frontend/src/components/dashboard/AdPlacementWidget.vue
Normal 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>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<BaseCard
|
||||||
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"
|
hoverable
|
||||||
@click="$emit('open-card', 'stats')"
|
@click="$emit('open-card', 'stats')"
|
||||||
>
|
>
|
||||||
<!-- Top header bar -->
|
<!-- ═══ HEADER: Profile title ═══ -->
|
||||||
<div class="h-12 bg-slate-700 w-full shrink-0 flex items-center px-4">
|
<template #header>
|
||||||
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('header.profile') }}</span>
|
<span class="text-white font-bold text-sm tracking-wide">🛡️ {{ t('header.profile') }}</span>
|
||||||
</div>
|
</template>
|
||||||
<!-- Content -->
|
|
||||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
<!-- ═══ BODY: User info + Trust Score + Stats ═══ -->
|
||||||
<div class="flex-1 space-y-3">
|
<div class="flex-1 space-y-3">
|
||||||
<!-- User info -->
|
<!-- User info -->
|
||||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
@@ -37,18 +37,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<!-- Bottom CTA button -->
|
<!-- ═══ FOOTER: 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">
|
<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') }} →
|
{{ t('header.profile') }} →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
|
</BaseCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useVehicleStore } from '../../stores/vehicle'
|
import { useVehicleStore } from '../../stores/vehicle'
|
||||||
|
import BaseCard from '../ui/BaseCard.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|||||||
139
frontend/src/components/dashboard/SubscriptionStatusWidget.vue
Normal file
139
frontend/src/components/dashboard/SubscriptionStatusWidget.vue
Normal 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>
|
||||||
286
frontend/src/components/subscription/PlanDetailsModal.vue
Normal file
286
frontend/src/components/subscription/PlanDetailsModal.vue
Normal 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>
|
||||||
80
frontend/src/components/ui/BaseCard.vue
Normal file
80
frontend/src/components/ui/BaseCard.vue
Normal 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>
|
||||||
@@ -6,6 +6,39 @@ export default {
|
|||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
optional: 'optional',
|
optional: 'optional',
|
||||||
select: 'Select',
|
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: {
|
landing: {
|
||||||
openGarage: 'Open Garage',
|
openGarage: 'Open Garage',
|
||||||
|
|||||||
@@ -6,6 +6,39 @@ export default {
|
|||||||
saving: 'Mentés...',
|
saving: 'Mentés...',
|
||||||
optional: 'opcionális',
|
optional: 'opcionális',
|
||||||
select: 'Válassz',
|
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: {
|
landing: {
|
||||||
openGarage: 'Garázs Nyitása',
|
openGarage: 'Garázs Nyitása',
|
||||||
|
|||||||
@@ -47,6 +47,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ t('company.companyDataMenu') }}
|
{{ t('company.companyDataMenu') }}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -73,7 +84,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
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 { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useThemeStore } from '../stores/theme'
|
import { useThemeStore } from '../stores/theme'
|
||||||
@@ -84,6 +95,7 @@ import HeaderProfile from '../components/header/HeaderProfile.vue'
|
|||||||
import CompanyDataModal from '../components/organization/CompanyDataModal.vue'
|
import CompanyDataModal from '../components/organization/CompanyDataModal.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
@@ -113,6 +125,13 @@ function openCompanyData() {
|
|||||||
showCompanyDataModal.value = true
|
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 ──────────────────────────────
|
// ── Close hamburger on outside click ──────────────────────────────
|
||||||
function handleOutsideClick(e: MouseEvent) {
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
|||||||
@@ -96,6 +96,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ t('menu.service') }}
|
{{ t('menu.service') }}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -156,6 +167,12 @@ function navigateToDashboard() {
|
|||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Navigate to Subscription Plans page ────────────────────────────
|
||||||
|
function navigateToSubscription() {
|
||||||
|
isHamburgerOpen.value = false
|
||||||
|
router.push('/dashboard/subscription')
|
||||||
|
}
|
||||||
|
|
||||||
// ── Close hamburger on outside click ──────────────────────────────
|
// ── Close hamburger on outside click ──────────────────────────────
|
||||||
function handleOutsideClick(e: MouseEvent) {
|
function handleOutsideClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ const router = createRouter({
|
|||||||
path: 'service-finder',
|
path: 'service-finder',
|
||||||
name: 'service-finder',
|
name: 'service-finder',
|
||||||
component: () => import('../views/ServiceFinderView.vue')
|
component: () => import('../views/ServiceFinderView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subscription',
|
||||||
|
name: 'subscription',
|
||||||
|
component: () => import('../views/SubscriptionPlansView.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -35,6 +40,11 @@ const router = createRouter({
|
|||||||
path: '',
|
path: '',
|
||||||
name: 'organization-dashboard',
|
name: 'organization-dashboard',
|
||||||
component: () => import('../views/organization/CompanyGarageView.vue')
|
component: () => import('../views/organization/CompanyGarageView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'subscription',
|
||||||
|
name: 'org-subscription',
|
||||||
|
component: () => import('../views/SubscriptionPlansView.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ export interface PricingModel {
|
|||||||
credit_price?: number | null
|
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 {
|
export interface AllowancesModel {
|
||||||
max_vehicles: number
|
max_vehicles: number
|
||||||
max_garages: number
|
max_garages: number
|
||||||
@@ -44,10 +57,12 @@ export interface SubscriptionRulesModel {
|
|||||||
type: 'private' | 'corporate'
|
type: 'private' | 'corporate'
|
||||||
display_name?: string | null
|
display_name?: string | null
|
||||||
pricing: PricingModel
|
pricing: PricingModel
|
||||||
|
pricing_zones?: Record<string, PricingZoneModel> | null
|
||||||
allowances: AllowancesModel
|
allowances: AllowancesModel
|
||||||
entitlements: string[]
|
entitlements: string[]
|
||||||
affiliate: AffiliateModel
|
affiliate: AffiliateModel
|
||||||
lifecycle?: LifecycleModel | null
|
lifecycle?: LifecycleModel | null
|
||||||
|
marketing?: MarketingModel | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionTierItem {
|
export interface SubscriptionTierItem {
|
||||||
|
|||||||
@@ -74,6 +74,19 @@
|
|||||||
@open-card="openCard"
|
@open-card="openCard"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div><!-- end relative z-10 content wrapper -->
|
</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 GamificationCard from '../components/dashboard/GamificationCard.vue'
|
||||||
import ProfileTrustCard from '../components/dashboard/ProfileTrustCard.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 ──
|
// ── Shared Modals ──
|
||||||
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
import PrivateVehicleManager from '../components/dashboard/PrivateVehicleManager.vue'
|
||||||
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
import VehicleDetailModal from '../components/vehicle/VehicleDetailModal.vue'
|
||||||
|
|||||||
328
frontend/src/views/SubscriptionPlansView.vue
Normal file
328
frontend/src/views/SubscriptionPlansView.vue
Normal 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>
|
||||||
962
plans/p0_architecture_subscription_stacking_ad_engine.md
Normal file
962
plans/p0_architecture_subscription_stacking_ad_engine.md
Normal 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
|
||||||
428
plans/p0_subscription_ui_timers_ad_policy_plan.md
Normal file
428
plans/p0_subscription_ui_timers_ad_policy_plan.md
Normal 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
|
||||||
Reference in New Issue
Block a user