pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
@@ -21,7 +21,7 @@ api.interceptors.request.use(
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor: handle 401 Unauthorized (expired/invalid token)
|
||||
// Response interceptor: handle 401 Unauthorized (expired/invalid token) and 403 Forbidden
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
@@ -30,6 +30,12 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem('access_token')
|
||||
// Optionally redirect to login could be added here later
|
||||
}
|
||||
// P0 BUGFIX (2026-07-28): Log but do NOT retry 403 errors.
|
||||
// Permission errors should be terminal — they should not cause
|
||||
// retry loops in Pinia stores or component watchers.
|
||||
if (error.response?.status === 403) {
|
||||
console.warn(`[API] 403 Forbidden: ${error.config?.url || 'unknown endpoint'}`)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -679,7 +679,8 @@ const FUEL_SUBCATEGORY_CODES = new Set(['OPERATION_FUEL', 'OPERATION_ELECTRIC'])
|
||||
*/
|
||||
const selectedSubCategoryCode = computed<string | null>(() => {
|
||||
if (!form.subCategory) return null
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
const rawCats = (window as any).__allCostCategories
|
||||
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
|
||||
const found = allCats.find((c) => c.id === Number(form.subCategory))
|
||||
return found?.code || null
|
||||
})
|
||||
@@ -791,13 +792,28 @@ const flattenCategories = (cats: any[]): any[] => {
|
||||
}, [])
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 BUGFIX (2026-07-28): Category defensive normalization.
|
||||
*
|
||||
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
|
||||
* This guard prevents TypeError: allCategories.reduce/filter/find is not a function.
|
||||
*/
|
||||
function normalizeCategoryData(data: any): any[] {
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
if (data && Array.isArray(data.data)) return data.data
|
||||
console.warn('[CostEntryWizard] Unexpected category format, using empty array:', data)
|
||||
return []
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
// Pass visibility=b2c so only user-facing categories are returned
|
||||
const res = await api.get('/dictionaries/cost-categories', {
|
||||
params: { visibility: 'b2c' },
|
||||
})
|
||||
const allCategories = flattenCategories(res.data) as CategoryOption[]
|
||||
const normalized = normalizeCategoryData(res.data)
|
||||
const allCategories = flattenCategories(normalized) as CategoryOption[]
|
||||
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
|
||||
window.__allCostCategories = allCategories
|
||||
} catch (err) {
|
||||
@@ -821,7 +837,8 @@ function onMainCategoryChange() {
|
||||
|
||||
if (!form.mainCategory) return
|
||||
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
const rawCats = (window as any).__allCostCategories
|
||||
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
|
||||
subCategories.value = allCats.filter(
|
||||
(c) => c.parent_id === Number(form.mainCategory)
|
||||
)
|
||||
|
||||
@@ -223,10 +223,24 @@ const vehicleLabel = computed(() => {
|
||||
/**
|
||||
* Fetch hierarchical categories from the dictionaries API.
|
||||
*/
|
||||
/**
|
||||
* P1 BUGFIX (2026-07-28): Category defensive normalization.
|
||||
*
|
||||
* Backend may return { items: [...] } or null instead of a flat array.
|
||||
* This defensive guard prevents TypeError: allCategories.filter is not a function.
|
||||
*/
|
||||
function normalizeCategories(data: any): CategoryOption[] {
|
||||
if (Array.isArray(data)) return data as CategoryOption[]
|
||||
if (data && Array.isArray(data.items)) return data.items as CategoryOption[]
|
||||
if (data && Array.isArray(data.data)) return data.data as CategoryOption[]
|
||||
console.warn('[ComplexExpenseModal] Unexpected category format, using empty array:', data)
|
||||
return []
|
||||
}
|
||||
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('/dictionaries/cost-categories')
|
||||
const allCategories = res.data as CategoryOption[]
|
||||
const allCategories = normalizeCategories(res.data)
|
||||
|
||||
// Split into main (parent_id === null) and sub categories
|
||||
mainCategories.value = allCategories.filter((c) => c.parent_id === null)
|
||||
@@ -255,7 +269,8 @@ function onMainCategoryChange() {
|
||||
|
||||
if (!form.mainCategory) return
|
||||
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
const rawCats = (window as any).__allCostCategories
|
||||
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
|
||||
subCategories.value = allCats.filter(
|
||||
(c) => c.parent_id === Number(form.mainCategory)
|
||||
)
|
||||
@@ -296,7 +311,8 @@ async function handleSubmit() {
|
||||
if (props.isFeeMode) {
|
||||
// Fee mode: pre-filled as FEES — resolve dynamically by code, not by hardcoded ID
|
||||
costType = 'fee'
|
||||
const allCats: CategoryOption[] = (window as any).__allCostCategories || []
|
||||
const rawCats = (window as any).__allCostCategories
|
||||
const allCats: CategoryOption[] = Array.isArray(rawCats) ? rawCats : []
|
||||
const feesCat = allCats.find((c: CategoryOption) => c.code === 'FEES' && c.parent_id === null)
|
||||
categoryId = feesCat?.id || 6 // Fallback to ID 6 if lookup fails
|
||||
} else {
|
||||
|
||||
@@ -33,9 +33,18 @@
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex-1 flex flex-col gap-2.5 justify-center">
|
||||
<!-- No vehicle warning -->
|
||||
<!-- ── Local loading skeleton ── -->
|
||||
<div
|
||||
v-if="!selectedActionVehicle"
|
||||
v-if="vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400"
|
||||
>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-slate-200 border-t-[#70BC84] mb-2" />
|
||||
<p class="text-xs">{{ t('dashboard.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- No vehicle warning (only show when data has loaded but there are none) -->
|
||||
<div
|
||||
v-else-if="!selectedActionVehicle && !vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
|
||||
class="flex items-center justify-center rounded-xl bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
<span>⚠️ {{ t('dashboard.noVehicleSelected') }}</span>
|
||||
|
||||
@@ -16,25 +16,36 @@
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto space-y-2">
|
||||
<VehicleCardCompact
|
||||
v-for="vehicle in displayVehicles.slice(0, 3)"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
class="relative z-20"
|
||||
@click.stop="navigateToFleet"
|
||||
@plate-click.stop="openVehicleDetail(vehicle)"
|
||||
/>
|
||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||
<!-- ── Local loading skeleton ── -->
|
||||
<div
|
||||
v-if="displayVehicles.length === 0"
|
||||
v-if="vehicleStore.isLoading && vehicleStore.vehicles.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
|
||||
@click.stop="navigateToFleet"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-slate-200 border-t-[#70BC84] mb-2" />
|
||||
<p class="text-sm">{{ t('dashboard.loading') }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<VehicleCardCompact
|
||||
v-for="vehicle in displayVehicles.slice(0, 3)"
|
||||
:key="vehicle.id"
|
||||
:vehicle="vehicle"
|
||||
class="relative z-20"
|
||||
@click.stop="navigateToFleet"
|
||||
@plate-click.stop="openVehicleDetail(vehicle)"
|
||||
/>
|
||||
<!-- Empty state (only when BOTH real and mock are empty) -->
|
||||
<div
|
||||
v-if="displayVehicles.length === 0"
|
||||
class="flex flex-col items-center justify-center py-6 text-slate-400 relative z-20"
|
||||
@click.stop="navigateToFleet"
|
||||
>
|
||||
<svg class="w-10 h-10 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p class="text-sm">{{ t('dashboard.noVehicles') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -368,10 +368,24 @@ function autoCalculate() {
|
||||
// ── Category Resolution ──
|
||||
const fuelCategoryId = ref<number>(1) // Default fallback
|
||||
|
||||
/**
|
||||
* P1 BUGFIX (2026-07-28): Category defensive normalization.
|
||||
*
|
||||
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
|
||||
* This guard prevents TypeError: allCats.find is not a function.
|
||||
*/
|
||||
function normalizeCategories(data: any): Array<{ id: number; code?: string; name: string; parent_id: number | null }> {
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
if (data && Array.isArray(data.data)) return data.data
|
||||
console.warn('[SimpleFuelModal] Unexpected category format, using empty array:', data)
|
||||
return []
|
||||
}
|
||||
|
||||
async function resolveFuelCategory() {
|
||||
try {
|
||||
const res = await api.get('/dictionaries/cost-categories')
|
||||
const allCats = res.data as Array<{ id: number; code?: string; name: string; parent_id: number | null }>
|
||||
const allCats = normalizeCategories(res.data)
|
||||
const fuelCat = allCats.find((c) => c.code === 'FUEL' && c.parent_id === null)
|
||||
if (fuelCat) {
|
||||
fuelCategoryId.value = fuelCat.id
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<svg class="w-4 h-4 text-[#70BC84]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
{{ subscriptionPlan }}
|
||||
{{ subscriptionDisplayName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="daysRemaining !== null"
|
||||
@@ -112,6 +112,44 @@ const subscriptionPlan = computed(() => {
|
||||
return authStore.user?.subscription_plan || null
|
||||
})
|
||||
|
||||
/**
|
||||
* P0 BUGFIX (2026-07-28): Resolve the human-readable subscription display name.
|
||||
*
|
||||
* Priority:
|
||||
* 1. subscription_display_name from the backend (now populated by _resolve_subscription_data)
|
||||
* 2. For corporate mode: try org-level subscription_display_name
|
||||
* 3. Fallback: slug→human mapping for common plan names
|
||||
* 4. Ultimate fallback: raw subscription_plan string
|
||||
*/
|
||||
const subscriptionDisplayName = computed(() => {
|
||||
// Priority 1: user-level display_name from backend
|
||||
const userDisplayName = authStore.user?.subscription_display_name
|
||||
if (userDisplayName) return userDisplayName
|
||||
|
||||
// Priority 2: corporate mode — check org level
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
if (activeOrg?.subscription_display_name) return activeOrg.subscription_display_name
|
||||
}
|
||||
|
||||
// Priority 3: slug→human mapping
|
||||
const plan = subscriptionPlan.value
|
||||
if (!plan || plan === 'FREE') return 'Ingyenes'
|
||||
const slugMap: Record<string, string> = {
|
||||
'private_free_v1': 'Privát Ingyenes',
|
||||
'private_basic_v1': 'Privát Alap',
|
||||
'private_pro_v1': 'Privát Pro',
|
||||
'private_vip_v1': 'Privát VIP',
|
||||
'corp_basic_v1': 'Céges Alap',
|
||||
'corp_premium_v1': 'Céges Prémium',
|
||||
'corp_premium_plus_v1': 'Céges Prémium Plus',
|
||||
'corp_enterprise_v1': 'Céges Enterprise',
|
||||
}
|
||||
return slugMap[plan] || plan
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate days remaining until subscription expires.
|
||||
* Uses valid_until from the organization or user data.
|
||||
@@ -185,23 +223,20 @@ function formatDate(dateStr: string | null): string {
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded
|
||||
if (authStore.myOrganizations.length === 0) {
|
||||
try {
|
||||
await authStore.fetchMyOrganizations()
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
// Ensure vehicles are loaded
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
try {
|
||||
await vehicleStore.fetchVehicles()
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
/**
|
||||
* P0 BUGFIX (2026-07-28): Admin Loading Loop — removed redundant data fetches.
|
||||
*
|
||||
* ROOT CAUSE: SubscriptionStatusWidget.onMounted() called vehicleStore.fetchVehicles()
|
||||
* when vehicles.length === 0. This set vehicleStore.isLoading = true, which caused
|
||||
* DashboardView to show the spinner and destroy the grid (v-if="!isLoading").
|
||||
* When the fetch completed, isLoading = false, the grid re-mounted, SubscriptionStatusWidget
|
||||
* mounted fresh, checked vehicles.length === 0 again, re-fetched → INFINITE LOOP.
|
||||
*
|
||||
* FIX: The parent DashboardView already calls authStore.fetchMyOrganizations() and
|
||||
* vehicleStore.fetchVehicles() in its own onMounted(). This widget should only
|
||||
* set its local isLoading to false — it should NOT trigger its own data fetches.
|
||||
*/
|
||||
onMounted(() => {
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -187,14 +187,17 @@ const activeGarageName = computed(() => {
|
||||
return activeOrg.display_name || activeOrg.name || `${t('header.companyPrefix')} #${activeOrg.organization_id}`
|
||||
}
|
||||
}
|
||||
// If on dashboard and has an active individual org, show its name
|
||||
// If on dashboard and has an active organization, show its name
|
||||
// ONLY if it's a business/corporate org (not individual/private)
|
||||
if (!isOnCompanyGarage.value && authStore.user?.active_organization_id) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(org) => org.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
if (activeOrg) {
|
||||
if (activeOrg && activeOrg.org_type !== 'individual') {
|
||||
return activeOrg.display_name || activeOrg.name || t('header.garageSelector')
|
||||
}
|
||||
// Individual (private) orgs on dashboard: show "Saját Garázs" / "Private Garage"
|
||||
return t('header.privateGarage')
|
||||
}
|
||||
// Default: show personal garage label
|
||||
return t('header.garageSelector')
|
||||
|
||||
@@ -662,11 +662,25 @@ function onFormFieldChange() {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* P1 BUGFIX (2026-07-28): Category defensive normalization.
|
||||
*
|
||||
* Backend may return { items: [...] }, { data: [...] }, or null instead of a flat array.
|
||||
* This guard prevents TypeError on .filter / .find / .reduce.
|
||||
*/
|
||||
function normalizeCategories(data: any): any[] {
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && Array.isArray(data.items)) return data.items
|
||||
if (data && Array.isArray(data.data)) return data.data
|
||||
console.warn('[ProviderQuickAddModal] Unexpected category format, using empty array:', data)
|
||||
return []
|
||||
}
|
||||
|
||||
// ── Fetch Categories (legacy dropdown fallback) ──
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const res = await api.get('providers/categories')
|
||||
categories.value = res.data || []
|
||||
categories.value = normalizeCategories(res.data)
|
||||
} catch {
|
||||
categories.value = [
|
||||
{ id: 1, key: 'auto_szerelo', name_hu: 'Autószerelő', name_en: 'Car Mechanic', category: 'vehicle_service' },
|
||||
@@ -949,19 +963,11 @@ watch(() => form.name, onFormFieldChange)
|
||||
watch(() => form.city, onFormFieldChange)
|
||||
watch(() => form.address_zip, onFormFieldChange)
|
||||
|
||||
// ── Load categories on mount ──
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
loadCategoryTree()
|
||||
})
|
||||
|
||||
// 🔍 P0 Debug: Universal Level 1 categories count verification
|
||||
watchEffect(() => {
|
||||
console.log(`[ProviderQuickAddModal] universalLevel1Categories count: ${universalLevel1Categories.value.length}`)
|
||||
if (universalLevel1Categories.value.length > 0) {
|
||||
console.log(`[ProviderQuickAddModal] Universal categories:`, universalLevel1Categories.value.map(c => c.name_hu || c.name_en))
|
||||
}
|
||||
})
|
||||
// P0 BUGFIX (2026-07-28): Removed onMounted pre-fetch and debug watchEffect.
|
||||
// loadCategoryTree() and fetchCategories() are already called in the
|
||||
// watch(() => props.isOpen) handler (line 946-959), so pre-fetching on
|
||||
// mount is redundant AND harmful — it triggers API calls even when the
|
||||
// modal is closed, contributing to the infinite admin reactivity loop.
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -142,10 +142,35 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Phase 4: Selected Add-ons Summary (if any) ═══ -->
|
||||
<div
|
||||
v-if="cartAddonTotal > 0"
|
||||
class="rounded-xl border border-emerald-200 bg-emerald-50 p-3"
|
||||
>
|
||||
<p class="text-xs font-semibold text-emerald-700 uppercase tracking-wider mb-2">
|
||||
🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonCount }})
|
||||
</p>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-emerald-800">{{ t('subscription.addonTotal') }}:</span>
|
||||
<span class="font-bold text-emerald-700">{{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER ═══ -->
|
||||
<!-- ═══ FOOTER (with combined total if add-ons selected) ═══ -->
|
||||
<div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<!-- Combined total display -->
|
||||
<div
|
||||
v-if="cartAddonTotal > 0 && !isCurrentPlan"
|
||||
class="flex items-center justify-between text-sm mb-3 px-2"
|
||||
>
|
||||
<span class="text-slate-600 font-medium">{{ t('subscription.monthlyTotal') }}:</span>
|
||||
<span class="text-lg font-bold text-slate-900">
|
||||
{{ formatPrice(combinedMonthlyPrice) }}/{{ t('subscription.month') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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'"
|
||||
@@ -162,6 +187,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Phase 4 CART LOGIC:
|
||||
*
|
||||
* This modal now accepts two new props:
|
||||
* - cartAddonTotal: The sum of monthly prices of selected add-ons
|
||||
* - selectedAddonCount: The number of selected add-ons
|
||||
*
|
||||
* The footer displays the combined monthly total
|
||||
* (base plan monthly price + cartAddonTotal) when add-ons are selected.
|
||||
*
|
||||
* The combined price is only for display purposes — the actual purchase
|
||||
* request sends the base plan's tier_id and includes addon_ids in metadata.
|
||||
* The backend activates both the base plan and the selected add-ons.
|
||||
*/
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -187,11 +226,17 @@ const props = withDefaults(
|
||||
visible: boolean
|
||||
plan: SubscriptionPlan | null
|
||||
currentPlanName?: string | null
|
||||
/** Phase 4: Total monthly price of selected add-ons */
|
||||
cartAddonTotal?: number
|
||||
/** Phase 4: Number of selected add-ons */
|
||||
selectedAddonCount?: number
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
plan: null,
|
||||
currentPlanName: null,
|
||||
cartAddonTotal: 0,
|
||||
selectedAddonCount: 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -216,6 +261,17 @@ const resolvedYearlyPrice = computed(() => {
|
||||
return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Phase 4 CART LOGIC: Compute the combined monthly price (base plan + selected add-ons).
|
||||
*
|
||||
* This is the monthly price the user would pay if they purchase the base plan
|
||||
* together with all selected add-ons. It is for display only — the actual
|
||||
* billing is handled server-side.
|
||||
*/
|
||||
const combinedMonthlyPrice = computed(() => {
|
||||
return resolvedMonthlyPrice.value + props.cartAddonTotal
|
||||
})
|
||||
|
||||
/**
|
||||
* Get resolved currency from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
|
||||
@@ -172,9 +172,31 @@ const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
// ── Computed: Plan display name ──
|
||||
// P0 BUGFIX (2026-07-28): Backend now sends subscription_display_name via
|
||||
// _resolve_subscription_data(). Use it with slug→human fallback matching
|
||||
// SubscriptionStatusWidget logic.
|
||||
const planDisplayName = computed(() => {
|
||||
// Priority 1: Backend-supplied human-readable name from SubscriptionTier.rules
|
||||
const displayName = authStore.user?.subscription_display_name
|
||||
if (displayName) return displayName
|
||||
|
||||
// Priority 2: Slug-to-human mapping fallback
|
||||
const plan = authStore.user?.subscription_plan
|
||||
if (!plan || plan === 'FREE') return t('subscription.free')
|
||||
|
||||
const slugToHuman: Record<string, string> = {
|
||||
'private_basic_v1': 'Privát Alap',
|
||||
'private_pro_v1': 'Privát Pro',
|
||||
'private_vip_v1': 'Privát VIP',
|
||||
'corp_free_v1': 'Céges Ingyenes',
|
||||
'corp_basic_v1': 'Céges Alap',
|
||||
'corp_pro_v1': 'Céges Pro',
|
||||
'corp_vip_v1': 'Céges VIP',
|
||||
'addon_extra_vehicle_v1': 'Extra Jármű',
|
||||
}
|
||||
if (slugToHuman[plan]) return slugToHuman[plan]
|
||||
|
||||
// Priority 3: Raw plan name as ultimate fallback
|
||||
return plan
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export default {
|
||||
// P2 BUGFIX (2026-07-28): Added missing i18n keys for ModeSwitcher component
|
||||
switchMode: 'Switch Mode',
|
||||
consumer: 'Consumer',
|
||||
corporate: 'Corporate',
|
||||
// ── Common / Global ──
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
@@ -71,6 +75,7 @@ export default {
|
||||
apiAccess: 'API access',
|
||||
prioritySupport: 'Priority support',
|
||||
adFree: 'Ad-free',
|
||||
affiliateCommission: 'Affiliate commission',
|
||||
},
|
||||
// P0: Subscription Info Modal
|
||||
subscriptionInfo: 'Subscription Info',
|
||||
@@ -85,6 +90,26 @@ export default {
|
||||
unlimited: 'unlimited',
|
||||
timeRemaining: 'Time Remaining',
|
||||
days: 'days',
|
||||
renewNow: 'Renew Now',
|
||||
upgradePlan: 'Upgrade Plan',
|
||||
// P0 Flip Card
|
||||
registeredAt: 'Registration Date',
|
||||
startedAt: 'Subscription Start',
|
||||
activeAddons: 'Active Add-ons',
|
||||
noAddons: 'No active add-ons',
|
||||
validUntil: 'Valid until',
|
||||
clickToFlipBack: 'Flip back',
|
||||
details: 'Details',
|
||||
// ── Phase 4: Add-on Cart & Back Button ──
|
||||
backToFinance: '← Back to Finance',
|
||||
addonsSection: 'Available Add-ons',
|
||||
addonsDesc: 'Enhance your plan with extra features',
|
||||
addToCart: 'Add to cart',
|
||||
removeFromCart: 'Remove',
|
||||
addonTotal: 'Add-on total',
|
||||
monthlyTotal: 'Monthly total',
|
||||
selectedAddons: 'Selected add-ons',
|
||||
noAddonsAvailable: 'No add-ons available',
|
||||
},
|
||||
landing: {
|
||||
openGarage: 'Open Garage',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export default {
|
||||
// P2 BUGFIX (2026-07-28): Added missing i18n keys for ModeSwitcher component
|
||||
switchMode: 'Mód váltás',
|
||||
consumer: 'Felhasználó',
|
||||
corporate: 'Vállalat',
|
||||
// ── Common / Globális ──
|
||||
common: {
|
||||
cancel: 'Mégse',
|
||||
@@ -71,6 +75,7 @@ export default {
|
||||
apiAccess: 'API hozzáférés',
|
||||
prioritySupport: 'Prioritásos támogatás',
|
||||
adFree: 'Reklámmentes',
|
||||
affiliateCommission: 'Partner jutalék',
|
||||
},
|
||||
// P0: Subscription Info Modal
|
||||
subscriptionInfo: 'Előfizetés Információ',
|
||||
@@ -85,6 +90,26 @@ export default {
|
||||
unlimited: 'korlátlan',
|
||||
timeRemaining: 'Hátralévő idő',
|
||||
days: 'nap',
|
||||
renewNow: 'Újítás most',
|
||||
upgradePlan: 'Csomag váltása',
|
||||
// P0 Flip Card
|
||||
registeredAt: 'Regisztráció dátuma',
|
||||
startedAt: 'Előfizetés kezdete',
|
||||
activeAddons: 'Aktív kiegészítők',
|
||||
noAddons: 'Nincsenek aktív kiegészítők',
|
||||
validUntil: 'Érvényes',
|
||||
clickToFlipBack: 'Visszafordítás',
|
||||
details: 'Részletek',
|
||||
// ── Phase 4: Add-on Cart & Back Button ──
|
||||
backToFinance: '← Vissza a Pénzügyekhez',
|
||||
addonsSection: 'Elérhető kiegészítők',
|
||||
addonsDesc: 'Bővítsd a csomagod extra funkciókkal',
|
||||
addToCart: 'Kosárba',
|
||||
removeFromCart: 'Eltávolítás',
|
||||
addonTotal: 'Kiegészítők összesen',
|
||||
monthlyTotal: 'Havi összesen',
|
||||
selectedAddons: 'Kiválasztott kiegészítők',
|
||||
noAddonsAvailable: 'Nincsenek elérhető kiegészítők',
|
||||
},
|
||||
landing: {
|
||||
openGarage: 'Garázs Nyitása',
|
||||
|
||||
@@ -64,6 +64,22 @@ export interface UserProfile {
|
||||
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
|
||||
max_vehicles?: number
|
||||
max_garages?: number
|
||||
/** P0 Subscription Card Upgrade: Human-readable plan name (e.g., "Privát Pro") */
|
||||
subscription_display_name?: string | null
|
||||
/** P0 Subscription Card Upgrade: FK to system.subscription_tiers */
|
||||
subscription_tier_id?: number | null
|
||||
/** P0 Flip Card: User registration date (ISO 8601) */
|
||||
user_registration_date?: string | null
|
||||
/** P0 Flip Card: Current subscription start date (ISO 8601) */
|
||||
subscription_valid_from?: string | null
|
||||
/** P0 Flip Card: List of active add-on subscriptions */
|
||||
active_addons?: Array<{
|
||||
tier_id: number
|
||||
display_name: string
|
||||
valid_from: string | null
|
||||
valid_until: string | null
|
||||
is_active: boolean
|
||||
}> | null
|
||||
scope_level: string
|
||||
scope_id: string | null
|
||||
ui_mode: string
|
||||
@@ -88,6 +104,23 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const myOrganizations = ref<OrganizationItem[]>([])
|
||||
const isInitialized = ref(false)
|
||||
|
||||
/**
|
||||
* Reactive version counter — incremented on every successful organization switch.
|
||||
* Components and stores watch this to trigger data re-fetches for context-aware data.
|
||||
*/
|
||||
const contextVersion = ref(0)
|
||||
|
||||
// ── Deduplication guards ──────────────────────────────────────────
|
||||
// P0 BUGFIX (2026-07-28): Prevent concurrent fetchUser() calls from
|
||||
// App.vue.onMounted() and router.beforeEach. Without this guard,
|
||||
// user.value gets set twice, triggering a reactivity cascade that
|
||||
// causes the dashboard infinite mount/unmount loop for admin users.
|
||||
let _userFetchInProgress = false
|
||||
|
||||
// P0 BUGFIX (2026-07-28): Single-execution guard for init() to prevent
|
||||
// race conditions between App.vue.onMounted() and router.beforeEach.
|
||||
let _initInProgress = false
|
||||
|
||||
// ────────────────────────────── Getters ────────────────────────────
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isKycComplete = computed(() => {
|
||||
@@ -248,7 +281,11 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
localStorage.removeItem('pending_verification_email')
|
||||
|
||||
// If KYC is not complete, redirect to KYC page
|
||||
if (!isKycComplete.value) {
|
||||
// BUT skip for admin/staff users — they use the admin panel, not the B2C app
|
||||
const staffRoles = ['SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR']
|
||||
const role = user.value?.role
|
||||
const isStaffUser = role && staffRoles.includes(role.toUpperCase())
|
||||
if (!isKycComplete.value && !isStaffUser) {
|
||||
router.push('/complete-kyc')
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -263,8 +300,15 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* Fetch the current user profile from /auth/me.
|
||||
*/
|
||||
async function fetchUser() {
|
||||
// P0 BUGFIX (2026-07-28): Deduplication guard — prevents concurrent
|
||||
// fetchUser() calls from App.vue.onMounted() and router.beforeEach.
|
||||
// Without this, user.value is set twice, triggering a reactivity cascade
|
||||
// that re-evaluates all computed properties in dashboard widgets.
|
||||
if (_userFetchInProgress) return
|
||||
if (!token.value) return
|
||||
|
||||
_userFetchInProgress = true
|
||||
|
||||
try {
|
||||
const res = await api.get('/auth/me')
|
||||
user.value = res.data as UserProfile
|
||||
@@ -274,6 +318,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
logout()
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
_userFetchInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +447,13 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* Initialize the store on app load — if a token exists, fetch the user.
|
||||
*/
|
||||
async function init() {
|
||||
// P0 BUGFIX (2026-07-28): Single-execution guard. Prevents concurrent
|
||||
// init() calls from App.vue.onMounted() and router.beforeEach().
|
||||
// Without this, fetchUser() + fetchMyOrganizations() fire twice,
|
||||
// causing reactivity cascade in dashboard widget computed properties.
|
||||
if (_initInProgress || isInitialized.value) return
|
||||
_initInProgress = true
|
||||
|
||||
if (token.value) {
|
||||
try {
|
||||
await fetchUser()
|
||||
@@ -412,6 +465,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
// Mark initialization as complete so the router guard can await it
|
||||
isInitialized.value = true
|
||||
_initInProgress = false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -689,7 +743,18 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* Fetch the current user's organizations from GET /api/v1/organizations/my.
|
||||
* Stores them in myOrganizations state.
|
||||
*/
|
||||
// P0 BUGFIX (2026-07-28): Deduplication guard to prevent multiple simultaneous
|
||||
// calls from DashboardView.onMounted(), HeaderCompanySwitcher.onMounted(),
|
||||
// and init(). Without this, rapid concurrent fetches cause race conditions
|
||||
// in Pinia state that trigger excessive reactivity updates.
|
||||
let _orgFetchInProgress = false
|
||||
|
||||
async function fetchMyOrganizations() {
|
||||
if (_orgFetchInProgress) {
|
||||
// Skip duplicate: a fetch is already in flight
|
||||
return myOrganizations.value
|
||||
}
|
||||
_orgFetchInProgress = true
|
||||
try {
|
||||
const res = await api.get('/organizations/my')
|
||||
myOrganizations.value = res.data as OrganizationItem[]
|
||||
@@ -698,6 +763,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
console.error('Failed to fetch organizations:', err)
|
||||
myOrganizations.value = []
|
||||
return []
|
||||
} finally {
|
||||
_orgFetchInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,7 +790,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
* (active_organization_id is set and matches a business org).
|
||||
*/
|
||||
const isCorporateMode = computed(() => {
|
||||
return !!user.value?.active_organization_id
|
||||
if (!user.value?.active_organization_id) return false
|
||||
// Find the active org and check its type to distinguish
|
||||
// individual (private) orgs from business (corporate) orgs.
|
||||
const activeOrg = myOrganizations.value.find(
|
||||
(org) => org.organization_id === user.value?.active_organization_id
|
||||
)
|
||||
if (!activeOrg) {
|
||||
// Fallback: if org data isn't loaded yet, assume corporate
|
||||
// to avoid leaking corporate packages to private users
|
||||
return true
|
||||
}
|
||||
// Individual orgs are NOT corporate mode
|
||||
return activeOrg.org_type !== 'individual'
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -752,6 +831,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
// Update the user state with the fresh profile
|
||||
user.value = data.user
|
||||
|
||||
// P0 BUGFIX (2026-07-28): Context-switching data leak fix.
|
||||
// Increment the version counter so DashboardView and other components
|
||||
// watching authStore.contextVersion can trigger re-fetches of
|
||||
// context-scoped data (vehicles, wallet balance, costs, etc.).
|
||||
contextVersion.value++
|
||||
} catch (err: any) {
|
||||
error.value =
|
||||
err.response?.data?.detail ||
|
||||
@@ -771,6 +856,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
error,
|
||||
myOrganizations,
|
||||
isInitialized,
|
||||
contextVersion,
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
|
||||
@@ -138,7 +138,27 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
* Fetch all vehicles/assets for the current user or their organization.
|
||||
* GET /assets/vehicles
|
||||
*/
|
||||
// P0 BUGFIX (2026-07-28): Deduplication guard to prevent multiple simultaneous
|
||||
// calls from DashboardView.onMounted() and child components.
|
||||
let _vehicleFetchInProgress = false
|
||||
|
||||
/**
|
||||
* P0 BUGFIX (2026-07-28): Context-switching data leak fix.
|
||||
* Clears the local vehicle cache and resets the deduplication guard.
|
||||
* Called by DashboardView before re-fetching after organization switch.
|
||||
*/
|
||||
function clearCache() {
|
||||
_vehicleFetchInProgress = false
|
||||
vehicles.value = []
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function fetchVehicles() {
|
||||
if (_vehicleFetchInProgress) {
|
||||
// Skip duplicate: a fetch is already in flight
|
||||
return vehicles.value
|
||||
}
|
||||
_vehicleFetchInProgress = true
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
@@ -154,6 +174,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
console.error('[VehicleStore] fetchVehicles error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
_vehicleFetchInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,6 +454,7 @@ export const useVehicleStore = defineStore('vehicle', () => {
|
||||
sortedVehicles,
|
||||
|
||||
// Actions
|
||||
clearCache,
|
||||
fetchVehicles,
|
||||
fetchVehicleById,
|
||||
createVehicle,
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface OrganizationItem {
|
||||
/** P0: Real subscription limits from the assigned subscription_tier JSONB rules */
|
||||
max_vehicles?: number
|
||||
max_garages?: number
|
||||
/** P0: Human-readable plan display name (e.g. "Privát Pro") */
|
||||
subscription_display_name?: string | null
|
||||
/** org_type is now reliably returned by GET /api/v1/organizations/my */
|
||||
org_type: string
|
||||
/**
|
||||
|
||||
@@ -38,17 +38,12 @@
|
||||
<!-- ── Bottom-aligned 5-card container ── -->
|
||||
<div class="flex flex-col justify-end min-h-[85vh] pb-8">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 w-full">
|
||||
<!-- ── Loading State ── -->
|
||||
<!-- ── 5-Card Grid (P0: ALWAYS mounted, no global v-if guard) ──
|
||||
CRITICAL: Do NOT add v-if="!vehicleStore.isLoading" here.
|
||||
Each child widget has its own local loading state and handles
|
||||
empty data gracefully. A global v-if would destroy the grid on
|
||||
any loading toggle, causing infinite mount/unmount loops. -->
|
||||
<div
|
||||
v-if="vehicleStore.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>
|
||||
|
||||
<!-- ── 5-Card Grid ── -->
|
||||
<div
|
||||
v-if="!vehicleStore.isLoading"
|
||||
class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4"
|
||||
style="perspective: 1200px;"
|
||||
>
|
||||
@@ -362,9 +357,21 @@ function handleSetPrimaryFromDetail(vehicle: VehicleData) {
|
||||
vehicleStore.setPrimaryVehicle(vehicle.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
vehicleStore.fetchVehicles()
|
||||
authStore.fetchMyOrganizations()
|
||||
onMounted(async () => {
|
||||
// P0 BUGFIX (2026-07-28): Sequential fetches to prevent race conditions
|
||||
// between isLoading toggle and myOrganizations state updates that cause
|
||||
// the infinite admin reactivity loop.
|
||||
await vehicleStore.fetchVehicles()
|
||||
|
||||
// P0 BUGFIX (2026-07-28): Only fetch organizations if not already populated.
|
||||
// authStore.init() already loads myOrganizations during app startup and
|
||||
// router navigation guard. Unconditional re-fetching here creates a second
|
||||
// API call that updates the ref, triggering a reactivity cascade in all
|
||||
// dashboard widgets' computed properties (isCorporateMode, subscriptionPlan,
|
||||
// validUntil, maxVehicles, etc.) — causing the admin infinite loop.
|
||||
if (authStore.myOrganizations.length === 0) {
|
||||
await authStore.fetchMyOrganizations()
|
||||
}
|
||||
|
||||
// ── Handle query param card navigation ──────────────────────────
|
||||
const cardParam = route.query.card as string | undefined
|
||||
@@ -382,6 +389,35 @@ watch(() => route.query.card, (newCard) => {
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
|
||||
// ── P0 BUGFIX (2026-07-28): Context-switching data leak fix ─────
|
||||
// Watch authStore.contextVersion to trigger re-fetch of all context-scoped
|
||||
// dashboard data when the user switches organizations (private garage ↔ company).
|
||||
// Without this watcher, DashboardView.onMounted() only runs once, so
|
||||
// vehicle + wallet data remains stale after a context switch.
|
||||
watch(
|
||||
() => authStore.contextVersion,
|
||||
async () => {
|
||||
// Skip initial mount (onMounted already fetches)
|
||||
if (authStore.contextVersion === 0) return
|
||||
|
||||
console.log('[DashboardView] Context switch detected, re-fetching dashboard data...')
|
||||
|
||||
// Clear the vehicle cache and reset dedup guard so the fetch actually runs
|
||||
vehicleStore.clearCache()
|
||||
|
||||
// Re-fetch vehicles for the new organization context
|
||||
vehicleStore.fetchVehicles()
|
||||
|
||||
// Re-fetch wallet balance for the new organization context
|
||||
const { useFinanceStore } = await import('../stores/finance')
|
||||
const financeStore = useFinanceStore()
|
||||
financeStore.fetchWalletBalance()
|
||||
|
||||
// Re-fetch organizations so the header switcher stays in sync
|
||||
authStore.fetchMyOrganizations()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -116,38 +116,157 @@
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Card 2: 📦 Csomagok (Packages)
|
||||
Shows authStore.subscription_plan with active plan name.
|
||||
Card 2: 📦 Csomagok (Packages) — 3D FLIP CARD
|
||||
Front: display_name, expiry, vehicle bar, CTA button.
|
||||
Back: registration date, valid_from, active add-ons list.
|
||||
Click to flip — CTA button uses @click.stop to not trigger flip.
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<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 transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
class="flip-perspective bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden h-[350px] relative transition-all duration-300 ease-out transform hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl"
|
||||
@click="toggleFlip"
|
||||
>
|
||||
<!-- Invisible overlay for click capture -->
|
||||
<div
|
||||
class="absolute inset-0 z-10 cursor-pointer"
|
||||
@click="goTo('/finance/packages')"
|
||||
/>
|
||||
<!-- Header bar -->
|
||||
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Current plan card -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('subscription.active') }}
|
||||
</p>
|
||||
<p class="text-2xl font-extrabold text-sky-600">
|
||||
{{ subscriptionPlanName }}
|
||||
</p>
|
||||
class="flip-inner"
|
||||
:class="{ 'is-flipped': isFlipped }"
|
||||
>
|
||||
<!-- ── FRONT FACE ── -->
|
||||
<div class="flip-face flex flex-col">
|
||||
<!-- Header bar -->
|
||||
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.title') }}</span>
|
||||
<span
|
||||
:class="isExpired ? 'bg-red-500/30 text-red-200' : 'bg-emerald-500/30 text-emerald-200'"
|
||||
class="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
>
|
||||
{{ isExpired ? t('subscription.expired') : t('subscription.active') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Quick info row -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500">{{ t('subscription.subtitle') }}</p>
|
||||
<!-- Content -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800 overflow-hidden">
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- Plan name -->
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3 text-center">
|
||||
<p class="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-1">
|
||||
{{ t('subscription.active') }}
|
||||
</p>
|
||||
<p class="text-2xl font-extrabold text-sky-600">
|
||||
{{ subscriptionDisplayName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Expiry date -->
|
||||
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5 text-center">
|
||||
<p class="text-xs text-slate-500">
|
||||
{{ t('subscription.expiresAt') }}:
|
||||
<span
|
||||
:class="isExpired ? 'text-red-600 font-bold' : 'text-slate-700 font-semibold'"
|
||||
>{{ formattedExpiry }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle usage -->
|
||||
<div v-if="maxVehicles > 0" class="space-y-1">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
||||
<span>🚗 {{ t('subscription.vehicles') }}</span>
|
||||
<span class="font-semibold text-slate-700">{{ vehicleCount }} / {{ maxVehicles }}</span>
|
||||
</div>
|
||||
<div class="w-full h-1.5 rounded-full bg-slate-200 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="vehiclePercent >= 90 ? 'bg-red-500' : vehiclePercent >= 75 ? 'bg-amber-400' : 'bg-sky-500'"
|
||||
:style="{ width: `${Math.min(vehiclePercent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA Button (click.stop prevents flip) -->
|
||||
<div class="shrink-0 pt-2">
|
||||
<router-link
|
||||
:to="ctaRoute"
|
||||
@click.stop
|
||||
class="block w-full text-center px-4 py-2.5 rounded-xl text-sm font-semibold transition-all duration-200 shadow-md hover:shadow-lg active:scale-[0.98]"
|
||||
:class="isExpired
|
||||
? 'bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-400 hover:to-orange-500'
|
||||
: 'bg-gradient-to-r from-sky-600 to-indigo-600 text-white hover:from-sky-500 hover:to-indigo-500'"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── BACK FACE ── -->
|
||||
<div class="flip-face flip-back flex flex-col bg-white rounded-2xl">
|
||||
<!-- Header bar (same gradient, mirrored) -->
|
||||
<div class="h-12 bg-gradient-to-r from-sky-600 to-sky-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">📦 {{ t('subscription.details') }}</span>
|
||||
<button
|
||||
@click.stop="toggleFlip"
|
||||
class="ml-auto text-xs text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
{{ t('subscription.clickToFlipBack') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Back content: scrollable if overflow -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-3 text-slate-800">
|
||||
<!-- Registration date -->
|
||||
<div v-if="userRegistrationDate" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
|
||||
<p class="text-xs text-slate-500">{{ t('subscription.registeredAt') }}:</p>
|
||||
<p class="text-sm font-semibold text-slate-700">{{ userRegistrationDate }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Subscription start date -->
|
||||
<div v-if="subscriptionStartDate" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
|
||||
<p class="text-xs text-slate-500">{{ t('subscription.startedAt') }}:</p>
|
||||
<p class="text-sm font-semibold text-slate-700">{{ subscriptionStartDate }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Exact expiry date (prominent) -->
|
||||
<div v-if="formattedExpiry" class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
|
||||
<p class="text-xs text-slate-500">{{ t('subscription.expiresAt') }}:</p>
|
||||
<p
|
||||
class="text-sm font-semibold"
|
||||
:class="isExpired ? 'text-red-600' : 'text-slate-700'"
|
||||
>{{ formattedExpiry }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Active Add-ons -->
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-2.5">
|
||||
<p class="text-xs text-slate-500 font-semibold mb-2">{{ t('subscription.activeAddons') }}:</p>
|
||||
<div v-if="activeAddons.length === 0" class="text-xs text-slate-400 italic">
|
||||
{{ t('subscription.noAddons') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="addon in activeAddons"
|
||||
:key="addon.tier_id"
|
||||
class="flex items-center justify-between text-xs"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-slate-700">{{ addon.display_name }}</span>
|
||||
<span class="text-slate-400">
|
||||
{{ t('subscription.validUntil') }}: {{ formatAddonDate(addon.valid_until) }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="isAddonExpired(addon)
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-emerald-100 text-emerald-700'"
|
||||
>
|
||||
{{ isAddonExpired(addon) ? t('subscription.expired') : t('subscription.active') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tip to flip back -->
|
||||
<p class="text-center text-[10px] text-slate-400 pt-1">
|
||||
{{ t('subscription.clickToFlipBack') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,16 +341,18 @@
|
||||
* Wallet balance fetched on mount via financeStore.
|
||||
* No flip mechanic — cards use direct @click routing.
|
||||
*/
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useFinanceStore } from '../stores/finance'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useVehicleStore } from '../stores/vehicle'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const financeStore = useFinanceStore()
|
||||
const authStore = useAuthStore()
|
||||
const vehicleStore = useVehicleStore()
|
||||
|
||||
/**
|
||||
* goTo — navigate to a finance sub-route using the router.
|
||||
@@ -246,9 +367,62 @@ function goTo(path: string) {
|
||||
const walletLoading = computed(() => financeStore.isLoading)
|
||||
const walletError = computed(() => financeStore.error)
|
||||
|
||||
// ── Card 2: Subscription plan name (authStore pattern) ────────────────
|
||||
const subscriptionPlanName = computed(() => {
|
||||
return authStore.user?.subscription_plan || '—'
|
||||
// ── Card 2: Subscription data (upgraded) ──────────────────────────
|
||||
const subscriptionDisplayName = computed(() => {
|
||||
// Priority: 1) display_name from backend, 2) humanized slug, 3) fallback
|
||||
const displayName = authStore.user?.subscription_display_name
|
||||
if (displayName) return displayName
|
||||
const plan = authStore.user?.subscription_plan
|
||||
if (!plan || plan === 'FREE') return t('subscription.free')
|
||||
// Simple slug→human mapping as fallback
|
||||
const slugMap: Record<string, string> = {
|
||||
'private_pro_v1': 'Privát Pro',
|
||||
'corp_premium_v1': 'Céges Prémium',
|
||||
'corp_premium_plus_v1': 'Céges Prémium Plus',
|
||||
}
|
||||
return slugMap[plan] || plan
|
||||
})
|
||||
|
||||
const subscriptionExpiresAt = computed(() => {
|
||||
return authStore.user?.subscription_expires_at ?? null
|
||||
})
|
||||
|
||||
const formattedExpiry = computed(() => {
|
||||
const raw = subscriptionExpiresAt.value
|
||||
if (!raw) return null
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return null
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}.${m}.${day}`
|
||||
})
|
||||
|
||||
const isExpired = computed(() => {
|
||||
const raw = subscriptionExpiresAt.value
|
||||
if (!raw) return false
|
||||
return new Date(raw) < new Date()
|
||||
})
|
||||
|
||||
const vehicleCount = computed(() => vehicleStore.vehicles?.length || 0)
|
||||
const maxVehicles = computed(() => authStore.user?.max_vehicles ?? 0)
|
||||
|
||||
const vehiclePercent = computed(() => {
|
||||
if (!maxVehicles.value || maxVehicles.value <= 0) return 0
|
||||
return Math.min(100, (vehicleCount.value / maxVehicles.value) * 100)
|
||||
})
|
||||
|
||||
// CTA routing depends on mode
|
||||
const ctaRoute = computed(() => {
|
||||
if (authStore.isCorporateMode && authStore.user?.active_organization_id) {
|
||||
return `/organization/${authStore.user.active_organization_id}/subscription`
|
||||
}
|
||||
return '/dashboard/subscription'
|
||||
})
|
||||
|
||||
const ctaLabel = computed(() => {
|
||||
if (isExpired.value) return t('subscription.renewNow') || 'Újítás most'
|
||||
return t('subscription.managePlan')
|
||||
})
|
||||
|
||||
// ── Helper: format credits ───────────────────────────────────────────
|
||||
@@ -259,8 +433,90 @@ function formatCredits(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
// ── Fetch wallet balance on mount ────────────────────────────────────
|
||||
// ── Flip card state ─────────────────────────────────────────
|
||||
const isFlipped = ref(false)
|
||||
|
||||
function toggleFlip() {
|
||||
isFlipped.value = !isFlipped.value
|
||||
}
|
||||
|
||||
// ── Back face: user registration date ───────────────────────
|
||||
const userRegistrationDate = computed(() => {
|
||||
const raw = authStore.user?.user_registration_date
|
||||
if (!raw) return null
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return null
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return y + '.' + m + '.' + day
|
||||
})
|
||||
|
||||
// ── Back face: subscription start date ──────────────────────
|
||||
const subscriptionStartDate = computed(() => {
|
||||
const raw = authStore.user?.subscription_valid_from
|
||||
if (!raw) return null
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return null
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return y + '.' + m + '.' + day
|
||||
})
|
||||
|
||||
// ── Back face: active add-ons ───────────────────────────────
|
||||
const activeAddons = computed(() => {
|
||||
return authStore.user?.active_addons || []
|
||||
})
|
||||
|
||||
// ── Back face: add-on helpers ───────────────────────────────
|
||||
function formatAddonDate(raw: string | null): string {
|
||||
if (!raw) return '—'
|
||||
const d = new Date(raw)
|
||||
if (isNaN(d.getTime())) return raw
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return y + '.' + m + '.' + day
|
||||
}
|
||||
|
||||
function isAddonExpired(addon: { valid_until: string | null }): boolean {
|
||||
if (!addon.valid_until) return false
|
||||
return new Date(addon.valid_until) < new Date()
|
||||
}
|
||||
|
||||
// ── Fetch data on mount ─────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
financeStore.fetchWalletBalance()
|
||||
// Ensure vehicles are loaded for usage display
|
||||
if (vehicleStore.vehicles.length === 0) {
|
||||
vehicleStore.fetchVehicles()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── 3D Flip Card ──────────────────────────────────────────── */
|
||||
.flip-perspective {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.flip-inner.is-flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.flip-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
.flip-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,10 +6,24 @@
|
||||
</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>
|
||||
<!-- ═══ HEADER: Title + Back Button ═══ -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<!-- ── Phase 4 UX FIX: Back Button ── -->
|
||||
<!-- Navigates to /finance/packages if coming from finance, otherwise /dashboard -->
|
||||
<button
|
||||
@click="goBack"
|
||||
class="flex items-center gap-2 rounded-xl bg-white/10 px-4 py-2 text-sm text-white/80 transition-all duration-200 hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ t('subscription.backToFinance') }}
|
||||
</button>
|
||||
|
||||
<div class="text-right">
|
||||
<h1 class="text-3xl font-bold text-white">{{ t('subscription.title') }}</h1>
|
||||
<p class="text-white/60 text-sm mt-1">{{ t('subscription.subtitle') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@@ -34,121 +48,257 @@
|
||||
</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`"
|
||||
<!-- ═══ PLANS + ADD-ONS ═══ -->
|
||||
<div v-else class="space-y-10">
|
||||
<!-- ── BASE PLANS SECTION ── -->
|
||||
<div
|
||||
v-if="basePlans.length > 0"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
<!-- ═══ 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) }) }})
|
||||
<BaseCard
|
||||
v-for="plan in basePlans"
|
||||
: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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ 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>
|
||||
<!-- ═══ 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>
|
||||
|
||||
<!-- 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') }}
|
||||
<!-- 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>
|
||||
|
||||
<!-- ── EMPTY BASE PLANS ── -->
|
||||
<div v-else class="text-center py-10">
|
||||
<p class="text-white/50">{{ t('subscription.noPlan') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
Phase 4: ADD-ON SECTION (Issue #432)
|
||||
──
|
||||
PURPOSE: Display available add-on packages beneath base plans.
|
||||
Each add-on card shows its monthly price and a toggle button.
|
||||
The cart state tracks which add-ons the user has selected.
|
||||
|
||||
CART LOGIC:
|
||||
- selectedAddonIds: Set<number> — tracks selected add-on IDs
|
||||
- toggleAddon(id): adds/removes the add-on from the cart
|
||||
- cartAddonTotal: sum of monthly prices of selected add-ons
|
||||
- When the user opens a PlanDetailsModal for a base plan,
|
||||
the modal footer shows the combined total
|
||||
(base plan price + selected add-on prices).
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
<div v-if="addonPlans.length > 0" class="space-y-4">
|
||||
<!-- Section Header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-white">
|
||||
🔧 {{ t('subscription.addonsSection') }}
|
||||
</h2>
|
||||
<p class="text-white/60 text-sm mt-1">{{ t('subscription.addonsDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart summary bar (visible when add-ons are selected) -->
|
||||
<div
|
||||
v-if="cartAddonTotal > 0"
|
||||
class="rounded-xl bg-white/10 backdrop-blur-sm border border-white/20 p-3 flex items-center justify-between text-white"
|
||||
>
|
||||
<span class="text-sm">
|
||||
🛒 {{ t('subscription.selectedAddons') }} ({{ selectedAddonIds.size }}):
|
||||
<strong>{{ formatPrice(cartAddonTotal) }}/{{ t('subscription.month') }}</strong>
|
||||
</span>
|
||||
<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"
|
||||
@click="clearCart"
|
||||
class="text-xs text-white/60 hover:text-white transition-colors underline"
|
||||
>
|
||||
{{ t('subscription.detailsAndPurchase') }}
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
|
||||
<!-- Add-on cards grid — solid white cards with green gradient header -->
|
||||
<!-- Phase 4 BUGFIX (2026-07-28): Replaced dark glass classes (bg-emerald-900/20,
|
||||
border-slate-700, text-white/xx) with solid white card pattern matching
|
||||
the base plan cards. Added green emerald gradient header for visual
|
||||
distinction. See docs/UI_STANDARDS.md Section 6. -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="addon in addonPlans"
|
||||
:key="addon.id"
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col transition-all duration-300"
|
||||
:class="isAddonSelected(addon.id)
|
||||
? 'border-emerald-500 ring-1 ring-emerald-500/50 hover:-translate-y-2 hover:shadow-2xl'
|
||||
: 'border-slate-200 hover:border-slate-400 hover:-translate-y-2 hover:shadow-2xl'"
|
||||
>
|
||||
<!-- Green gradient header bar (distinguishes add-ons from base plan dark blue headers) -->
|
||||
<div class="h-12 bg-gradient-to-r from-emerald-600 to-emerald-700 w-full shrink-0 flex items-center px-4">
|
||||
<span class="text-white font-bold text-sm tracking-wide">
|
||||
{{ addon.rules?.display_name || addon.name }}
|
||||
</span>
|
||||
<span class="ml-auto text-white/80 text-xs font-bold">
|
||||
{{ formatPrice(resolvedMonthlyPrice(addon)) }}/{{ t('subscription.month') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 flex-1 flex flex-col text-slate-800">
|
||||
<!-- Subtitle -->
|
||||
<p v-if="addon.rules?.marketing?.subtitle" class="text-xs text-slate-500 mb-3 leading-relaxed">
|
||||
{{ addon.rules.marketing.subtitle }}
|
||||
</p>
|
||||
|
||||
<!-- Allowances mini stat boxes (matching base plan stat style) -->
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<div v-if="addon.rules?.allowances?.max_vehicles" class="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div class="text-base font-bold text-slate-900">
|
||||
+{{ addon.rules.allowances.max_vehicles }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
|
||||
🚗 {{ t('subscription.vehicles') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="addon.rules?.allowances?.max_garages" class="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div class="text-base font-bold text-slate-900">
|
||||
+{{ addon.rules.allowances.max_garages }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
|
||||
🏠 {{ t('subscription.maxGarages') }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="addon.rules?.allowances?.monthly_free_credits" class="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div class="text-base font-bold text-slate-900">
|
||||
+{{ addon.rules.allowances.monthly_free_credits }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
|
||||
💰 {{ t('subscription.freeCredits') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Empty placeholder when some stat boxes are missing (keeps grid aligned) -->
|
||||
<div v-if="!addon.rules?.allowances?.max_vehicles && !addon.rules?.allowances?.max_garages && !addon.rules?.allowances?.monthly_free_credits" class="col-span-3 text-xs text-slate-400 italic text-center py-2">
|
||||
{{ t('subscription.noAddonsAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Toggle button — solid bg matching standard card buttons -->
|
||||
<button
|
||||
@click="toggleAddon(addon.id)"
|
||||
class="w-full py-2 rounded-xl text-sm font-semibold transition-all duration-200"
|
||||
:class="isAddonSelected(addon.id)
|
||||
? 'bg-emerald-600 hover:bg-emerald-700 text-white'
|
||||
: 'bg-slate-100 hover:bg-emerald-50 text-slate-700 hover:text-emerald-700 border border-slate-200 hover:border-emerald-300'"
|
||||
>
|
||||
<template v-if="isAddonSelected(addon.id)">
|
||||
✓ {{ t('subscription.removeFromCart') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
+ {{ t('subscription.addToCart') }}
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ PLAN DETAILS MODAL ═══ -->
|
||||
<!-- ═══ PLAN DETAILS MODAL (with add-on cart total in footer) ═══ -->
|
||||
<PlanDetailsModal
|
||||
:visible="showModal"
|
||||
:plan="selectedPlan"
|
||||
:current-plan-name="currentPlanName"
|
||||
:cart-addon-total="cartAddonTotal"
|
||||
:selected-addon-count="selectedAddonIds.size"
|
||||
@close="closeModal"
|
||||
@order="handleOrder"
|
||||
/>
|
||||
@@ -157,12 +307,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
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 router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -185,22 +338,140 @@ const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const allPlans = ref<SubscriptionPlan[]>([])
|
||||
|
||||
// ── Phase 4: Add-on Cart State ─────────────────────────────────────────────────
|
||||
/**
|
||||
* Cart state for add-on selection.
|
||||
* Uses a Set<number> to track selected add-on IDs (O(1) lookup).
|
||||
*
|
||||
* CART LOGIC:
|
||||
* - toggleAddon(id): Adds or removes the add-on from the cart.
|
||||
* - clearCart(): Empties the cart.
|
||||
* - cartAddonTotal: Computed sum of monthly prices of all selected add-ons.
|
||||
* This is passed to PlanDetailsModal so the modal footer can show
|
||||
* "Base plan price + Add-on total = Combined monthly total".
|
||||
*/
|
||||
const selectedAddonIds = ref<Set<number>>(new Set())
|
||||
|
||||
/**
|
||||
* Toggle an add-on's selection state.
|
||||
* If already selected, remove it; otherwise add it.
|
||||
*/
|
||||
function toggleAddon(addonId: number) {
|
||||
const newSet = new Set(selectedAddonIds.value)
|
||||
if (newSet.has(addonId)) {
|
||||
newSet.delete(addonId)
|
||||
} else {
|
||||
newSet.add(addonId)
|
||||
}
|
||||
selectedAddonIds.value = newSet
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given add-on ID is currently in the cart.
|
||||
*/
|
||||
function isAddonSelected(addonId: number): boolean {
|
||||
return selectedAddonIds.value.has(addonId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all selected add-ons from the cart.
|
||||
*/
|
||||
function clearCart() {
|
||||
selectedAddonIds.value = new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 4 CART LOGIC: Compute the total monthly price of selected add-ons.
|
||||
*
|
||||
* Iterates over addonPlans and sums the monthly prices of those whose
|
||||
* IDs are in selectedAddonIds. Used by the modal footer to display
|
||||
* the combined monthly total.
|
||||
*/
|
||||
const cartAddonTotal = computed(() => {
|
||||
let total = 0
|
||||
for (const addon of addonPlans.value) {
|
||||
if (selectedAddonIds.value.has(addon.id)) {
|
||||
total += resolvedMonthlyPrice(addon)
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
// ── Phase 4: Separate base plans from add-ons ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Base plans: subscription tiers where rules.type is NOT 'addon'.
|
||||
* These are the main subscription packages (private_*, corp_*, etc.).
|
||||
*/
|
||||
const basePlans = computed(() => {
|
||||
return filteredPlans.value.filter((plan) => {
|
||||
const planType = plan.rules?.type
|
||||
return planType !== 'addon'
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Add-on plans: subscription tiers where rules.type === 'addon'.
|
||||
* These are supplementary packages (extra vehicles, extra garages, etc.).
|
||||
*/
|
||||
const addonPlans = computed(() => {
|
||||
return filteredPlans.value.filter((plan) => {
|
||||
const planType = plan.rules?.type
|
||||
return planType === 'addon'
|
||||
})
|
||||
})
|
||||
|
||||
// Modal state
|
||||
const showModal = ref(false)
|
||||
const selectedPlan = ref<SubscriptionPlan | null>(null)
|
||||
|
||||
/**
|
||||
* Phase 4 UX FIX: Navigate back to the appropriate previous view.
|
||||
*
|
||||
* Navigation logic:
|
||||
* - If the referrer contains 'finance', go to /finance/packages
|
||||
* - If the route has an org_id param, go to /organization/:org_id
|
||||
* - Otherwise go to /dashboard/subscription
|
||||
*/
|
||||
function goBack() {
|
||||
// Check if we're in an organization context
|
||||
const orgId = route.params.org_id
|
||||
if (orgId) {
|
||||
router.push(`/organization/${orgId}`)
|
||||
return
|
||||
}
|
||||
// Check referrer
|
||||
const referrer = document.referrer || ''
|
||||
if (referrer.includes('/finance')) {
|
||||
router.push('/finance/packages')
|
||||
return
|
||||
}
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter plans based on the user's current mode:
|
||||
* - Individual (no active org): show only `private_` packages
|
||||
* - Corporate (active org): show only `corp_` packages
|
||||
* Add-ons (type === 'addon') are NOT filtered by mode — they show for everyone.
|
||||
*/
|
||||
const filteredPlans = computed(() => {
|
||||
const isCorporate = authStore.isCorporateMode
|
||||
return allPlans.value.filter((plan) => {
|
||||
const planType = plan.rules?.type
|
||||
|
||||
// Add-ons are always visible regardless of mode
|
||||
if (planType === 'addon') return true
|
||||
|
||||
// For base plans, apply mode-based filtering
|
||||
if (planType === 'corporate' || planType === 'business') return isCorporate
|
||||
if (planType === 'private' || planType === 'consumer') return !isCorporate
|
||||
|
||||
// Fallback: name prefix matching
|
||||
if (isCorporate) {
|
||||
return plan.name.startsWith('corp_')
|
||||
return plan.name.startsWith('corp_') || plan.name.startsWith('business_')
|
||||
}
|
||||
return plan.name.startsWith('private_')
|
||||
return plan.name.startsWith('private_') || plan.name.startsWith('consumer_')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -285,16 +556,58 @@ function closeModal() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order from modal (simulation).
|
||||
* Phase 4 CART LOGIC: Handle order with add-on IDs in metadata.
|
||||
*
|
||||
* When the user purchases a base plan, the selected add-on IDs are
|
||||
* passed in the `metadata.addon_ids` field so the backend can also
|
||||
* activate the corresponding add-on subscriptions.
|
||||
*/
|
||||
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
|
||||
async function handleOrder(plan: SubscriptionPlan, isYearly: boolean) {
|
||||
const price = isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan)
|
||||
const currency = resolvedCurrency(plan)
|
||||
|
||||
// Determine if this should be an org-level subscription
|
||||
const orgId = authStore.isCorporateMode && authStore.user?.active_organization_id
|
||||
? authStore.user.active_organization_id
|
||||
: null
|
||||
|
||||
try {
|
||||
// Phase 4: Include selected add-on IDs in the metadata
|
||||
const metadata: Record<string, any> = {}
|
||||
if (selectedAddonIds.value.size > 0) {
|
||||
metadata.addon_ids = Array.from(selectedAddonIds.value)
|
||||
}
|
||||
|
||||
const res = await api.post('/financial-manager/purchase-package', {
|
||||
tier_id: plan.id,
|
||||
org_id: orgId,
|
||||
region_code: authStore.user?.region_code || 'GLOBAL',
|
||||
currency: currency,
|
||||
metadata: metadata,
|
||||
})
|
||||
|
||||
const data = res.data
|
||||
if (data.success) {
|
||||
console.log('[SubscriptionPlansView] Purchase successful:', data)
|
||||
// Refresh user profile to get updated subscription state
|
||||
await authStore.fetchUser()
|
||||
if (authStore.user?.active_organization_id) {
|
||||
await authStore.fetchMyOrganizations()
|
||||
}
|
||||
// Clear cart after successful purchase
|
||||
clearCart()
|
||||
// Show success message
|
||||
alert(t('subscription.purchaseSuccess') || 'Subscription activated successfully!')
|
||||
} else {
|
||||
console.error('[SubscriptionPlansView] Purchase failed:', data.error)
|
||||
alert(data.error || t('subscription.purchaseError') || 'Purchase failed.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[SubscriptionPlansView] Purchase error:', err)
|
||||
const detail = err.response?.data?.detail || err.message || 'Purchase failed'
|
||||
alert(detail)
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user