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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,17 @@
</svg>
{{ t('menu.service') }}
</button>
<!-- Előfizetésem & Csomagok -->
<button
@click="navigateToSubscription"
class="flex w-full items-center gap-3 px-4 py-2.5 text-sm text-white/80 transition-all duration-150 hover:bg-white/5 hover:text-white"
>
<svg class="w-4 h-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
{{ t('menu.subscription') }}
</button>
</div>
</div>
</Transition>
@@ -156,6 +167,12 @@ function navigateToDashboard() {
router.push('/dashboard')
}
// ── Navigate to Subscription Plans page ────────────────────────────
function navigateToSubscription() {
isHamburgerOpen.value = false
router.push('/dashboard/subscription')
}
// ── Close hamburger on outside click ──────────────────────────────
function handleOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement

View File

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

View File

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

View File

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

View File

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