RABAC felépítése megtörtént ill hirdetési portál alapok lerakva
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
133
frontend/src/components/dashboard/AdPlacementWidget.vue
Normal file
133
frontend/src/components/dashboard/AdPlacementWidget.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="ad-placement-widget">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-xs text-red-400/60 text-center py-2"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Empty State (no ads available) -->
|
||||
<div
|
||||
v-else-if="ads.length === 0"
|
||||
class="block w-full"
|
||||
>
|
||||
<!-- Empty block – no ad to display, takes no visual space -->
|
||||
</div>
|
||||
|
||||
<!-- Ad Display -->
|
||||
<div
|
||||
v-else
|
||||
class="ad-content"
|
||||
>
|
||||
<div
|
||||
v-for="ad in ads"
|
||||
:key="ad.id"
|
||||
class="mb-2 last:mb-0"
|
||||
>
|
||||
<!-- Image Ad -->
|
||||
<a
|
||||
v-if="ad.creative_type === 'IMAGE' && ad.content_url"
|
||||
:href="ad.target_url || '#'"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block rounded-lg overflow-hidden border border-white/10 hover:border-white/20 transition-all duration-200"
|
||||
>
|
||||
<img
|
||||
:src="ad.content_url"
|
||||
:alt="ad.alt_text || ad.campaign_name"
|
||||
class="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<!-- HTML Snippet Ad -->
|
||||
<div
|
||||
v-else-if="ad.creative_type === 'HTML_SNIPPET' && ad.html_snippet"
|
||||
v-html="ad.html_snippet"
|
||||
class="rounded-lg overflow-hidden"
|
||||
/>
|
||||
|
||||
<!-- Fallback: text-only ad -->
|
||||
<div
|
||||
v-else
|
||||
class="text-xs text-white/40 text-center py-1"
|
||||
>
|
||||
{{ ad.campaign_name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/axios'
|
||||
|
||||
interface AdData {
|
||||
id: number
|
||||
campaign_id: number
|
||||
campaign_name: string
|
||||
creative_id: number
|
||||
creative_type: string
|
||||
content_url: string | null
|
||||
html_snippet: string | null
|
||||
alt_text: string | null
|
||||
target_url: string | null
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface PlacementResponse {
|
||||
zone: string
|
||||
ads: AdData[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
zone?: string
|
||||
}>(), {
|
||||
zone: 'dashboard_widget',
|
||||
})
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const ads = ref<AdData[]>([])
|
||||
|
||||
async function fetchAds() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.get<PlacementResponse>(`/marketing/placements/${props.zone}`)
|
||||
ads.value = res.data.ads || []
|
||||
} catch (err: any) {
|
||||
// Silently handle 404 (placement not configured yet)
|
||||
if (err.response?.status === 404) {
|
||||
ads.value = []
|
||||
} else {
|
||||
error.value = 'Failed to load ads'
|
||||
console.error('[AdPlacementWidget] Error:', err)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAds()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ad-placement-widget {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,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()
|
||||
|
||||
139
frontend/src/components/dashboard/SubscriptionStatusWidget.vue
Normal file
139
frontend/src/components/dashboard/SubscriptionStatusWidget.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="subscription-status-widget">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-3"
|
||||
>
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- No subscription data -->
|
||||
<div
|
||||
v-else-if="!subscriptionPlan"
|
||||
class="text-xs text-white/40 text-center py-2"
|
||||
>
|
||||
{{ t('subscription.noPlan') }}
|
||||
</div>
|
||||
|
||||
<!-- Subscription Status Display -->
|
||||
<div
|
||||
v-else
|
||||
class="subscription-info"
|
||||
>
|
||||
<!-- Plan Name -->
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-medium text-white/70">
|
||||
{{ subscriptionPlan }}
|
||||
</span>
|
||||
<span
|
||||
v-if="daysRemaining !== null"
|
||||
class="text-xs"
|
||||
:class="daysRemaining <= 7 ? 'text-amber-400' : 'text-white/50'"
|
||||
>
|
||||
{{ daysRemaining > 0
|
||||
? `${daysRemaining} ${t('subscription.daysLeft')}`
|
||||
: t('subscription.expired')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div
|
||||
v-if="progressPercent !== null"
|
||||
class="w-full h-1.5 rounded-full bg-white/10 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="progressBarClass"
|
||||
:style="{ width: `${Math.min(progressPercent, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import api from '../../api/axios'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
|
||||
/**
|
||||
* Resolve the current subscription plan from the auth store.
|
||||
* For individual mode: use user-level subscription.
|
||||
* For corporate mode: use the active organization's subscription_plan.
|
||||
*/
|
||||
const subscriptionPlan = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_plan || null
|
||||
}
|
||||
// Individual mode — fallback to user's subscription
|
||||
return authStore.user?.subscription_plan || null
|
||||
})
|
||||
|
||||
/**
|
||||
* Calculate days remaining until subscription expires.
|
||||
* Uses valid_until from the organization or user data.
|
||||
*/
|
||||
const validUntil = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_valid_until || null
|
||||
}
|
||||
return authStore.user?.subscription_valid_until || null
|
||||
})
|
||||
|
||||
const daysRemaining = computed(() => {
|
||||
if (!validUntil.value) return null
|
||||
const now = new Date()
|
||||
const end = new Date(validUntil.value)
|
||||
const diffMs = end.getTime() - now.getTime()
|
||||
return Math.max(0, Math.ceil(diffMs / (1000 * 60 * 60 * 24)))
|
||||
})
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (!validUntil.value || !subscriptionPlan.value) return null
|
||||
// Assume a 30-day cycle for progress calculation
|
||||
const now = new Date()
|
||||
const end = new Date(validUntil.value)
|
||||
const totalMs = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
const elapsedMs = totalMs - (end.getTime() - now.getTime())
|
||||
return Math.max(0, Math.min(100, (elapsedMs / totalMs) * 100))
|
||||
})
|
||||
|
||||
const progressBarClass = computed(() => {
|
||||
if (daysRemaining.value === null) return 'bg-[#70BC84]'
|
||||
if (daysRemaining.value <= 3) return 'bg-red-500'
|
||||
if (daysRemaining.value <= 7) return 'bg-amber-400'
|
||||
return 'bg-[#70BC84]'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Ensure organizations are loaded
|
||||
if (authStore.myOrganizations.length === 0) {
|
||||
try {
|
||||
await authStore.fetchMyOrganizations()
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
isLoading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscription-status-widget {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
286
frontend/src/components/subscription/PlanDetailsModal.vue
Normal file
286
frontend/src/components/subscription/PlanDetailsModal.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal-fade">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- Modal Panel -->
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-white rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-between px-6 py-4 border-b border-slate-200"
|
||||
:style="{ backgroundColor: highlightBg }"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-white">
|
||||
{{ plan?.rules?.display_name || plan?.name }}
|
||||
</h2>
|
||||
<p v-if="plan?.rules?.marketing?.subtitle" class="text-sm text-white/70 mt-0.5">
|
||||
{{ plan.rules.marketing.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full bg-white/20 hover:bg-white/30 transition-colors text-white"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ BODY (scrollable) ═══ -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<!-- Price Section (from resolved_pricing) -->
|
||||
<div class="text-center">
|
||||
<div class="text-4xl font-bold text-slate-900">
|
||||
{{ isYearly ? formatPrice(resolvedYearlyPrice) : formatPrice(resolvedMonthlyPrice) }}
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 mt-1">
|
||||
/ {{ isYearly ? t('subscription.year') : t('subscription.month') }}
|
||||
</div>
|
||||
<div v-if="isYearly && savingsPercent > 0" class="mt-2 inline-block px-3 py-1 rounded-full bg-emerald-100 text-emerald-700 text-xs font-semibold">
|
||||
{{ t('subscription.savePercent', { percent: savingsPercent }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Billing Toggle -->
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="isYearly ? 'text-slate-400' : 'text-slate-900'"
|
||||
>
|
||||
{{ t('subscription.monthly') }}
|
||||
</span>
|
||||
<button
|
||||
class="relative w-12 h-6 rounded-full transition-colors"
|
||||
:class="isYearly ? 'bg-emerald-500' : 'bg-slate-300'"
|
||||
@click="isYearly = !isYearly"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform"
|
||||
:class="isYearly ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
class="text-sm font-medium transition-colors"
|
||||
:class="isYearly ? 'text-slate-900' : 'text-slate-400'"
|
||||
>
|
||||
{{ t('subscription.yearly') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Key Stats -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.max_vehicles ?? '—' }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.maxVehicles') }}</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.max_garages ?? '—' }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.maxGarages') }}</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-3 text-center">
|
||||
<div class="text-lg font-bold text-slate-900">{{ plan?.rules?.allowances?.monthly_free_credits ?? '—' }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5">{{ t('subscription.freeCredits') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Included Services (Entitlements) -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-700 mb-3 uppercase tracking-wider">
|
||||
{{ t('subscription.includedServices') }}
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<!-- Ad-free -->
|
||||
<li class="flex items-start gap-3 text-sm text-slate-700">
|
||||
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<svg v-if="plan?.rules?.ad_policy?.show_ads === false" class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
<span :class="plan?.rules?.ad_policy?.show_ads === false ? 'text-slate-900 font-medium' : 'text-slate-400'">
|
||||
{{ t('subscription.features.adFree') }}
|
||||
</span>
|
||||
</li>
|
||||
<!-- Dynamic entitlements -->
|
||||
<li
|
||||
v-for="(entitlement, idx) in plan?.rules?.entitlements || []"
|
||||
:key="idx"
|
||||
class="flex items-start gap-3 text-sm text-slate-700"
|
||||
>
|
||||
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-slate-900 font-medium">{{ entitlementLabel(entitlement) }}</span>
|
||||
</li>
|
||||
<!-- Affiliate info -->
|
||||
<li
|
||||
v-if="plan?.rules?.affiliate?.commission_rate_percent"
|
||||
class="flex items-start gap-3 text-sm text-slate-700"
|
||||
>
|
||||
<span class="shrink-0 w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center">
|
||||
<svg class="w-3 h-3 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="text-slate-900 font-medium">
|
||||
{{ plan.rules.affiliate.commission_rate_percent }}% {{ t('subscription.features.affiliateCommission') || 'Partner jutalék' }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER ═══ -->
|
||||
<div class="shrink-0 px-6 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
class="w-full py-3 rounded-xl font-bold text-white transition-colors text-sm"
|
||||
:class="isCurrentPlan ? 'bg-slate-400 cursor-not-allowed' : 'bg-blue-800 hover:bg-blue-900'"
|
||||
:disabled="isCurrentPlan"
|
||||
@click="handleOrder"
|
||||
>
|
||||
{{ isCurrentPlan ? t('subscription.currentPlan') : t('subscription.orderSimulation') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface PricingZone {
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price?: number | null
|
||||
}
|
||||
|
||||
interface SubscriptionPlan {
|
||||
id: number
|
||||
name: string
|
||||
rules: Record<string, any> | null
|
||||
is_custom: boolean
|
||||
resolved_pricing?: PricingZone | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
plan: SubscriptionPlan | null
|
||||
currentPlanName?: string | null
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
plan: null,
|
||||
currentPlanName: null,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
order: [plan: SubscriptionPlan, isYearly: boolean]
|
||||
}>()
|
||||
|
||||
const isYearly = ref(false)
|
||||
|
||||
/**
|
||||
* Get resolved monthly price from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
const resolvedMonthlyPrice = computed(() => {
|
||||
return props.plan?.resolved_pricing?.monthly_price ?? props.plan?.rules?.pricing?.monthly_price ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Get resolved yearly price from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
const resolvedYearlyPrice = computed(() => {
|
||||
return props.plan?.resolved_pricing?.yearly_price ?? props.plan?.rules?.pricing?.yearly_price ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Get resolved currency from plan's resolved_pricing or fallback to rules.pricing.
|
||||
*/
|
||||
const resolvedCurrency = computed(() => {
|
||||
return props.plan?.resolved_pricing?.currency ?? props.plan?.rules?.pricing?.currency ?? 'EUR'
|
||||
})
|
||||
|
||||
const savingsPercent = computed(() => {
|
||||
const monthly = resolvedMonthlyPrice.value
|
||||
const yearly = resolvedYearlyPrice.value
|
||||
if (monthly <= 0 || yearly <= 0) return 0
|
||||
const monthlyYearly = monthly * 12
|
||||
const savings = monthlyYearly - yearly
|
||||
return Math.round((savings / monthlyYearly) * 100)
|
||||
})
|
||||
|
||||
const isCurrentPlan = computed(() => {
|
||||
return props.plan?.name === props.currentPlanName
|
||||
})
|
||||
|
||||
const highlightBg = computed(() => {
|
||||
const color = props.plan?.rules?.marketing?.highlight_color
|
||||
return color || '#306081'
|
||||
})
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
if (price === 0) return t('subscription.free')
|
||||
const cur = resolvedCurrency.value
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: cur,
|
||||
minimumFractionDigits: cur === 'HUF' ? 0 : 2,
|
||||
maximumFractionDigits: cur === 'HUF' ? 0 : 2,
|
||||
}).format(price)
|
||||
} catch {
|
||||
return `${price} ${cur}`
|
||||
}
|
||||
}
|
||||
|
||||
function entitlementLabel(code: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
SRV_DATA_EXPORT: t('subscription.features.analytics'),
|
||||
SRV_API_ACCESS: t('subscription.features.apiAccess'),
|
||||
SRV_PRIORITY_SUPPORT: t('subscription.features.prioritySupport'),
|
||||
SRV_AI_UPLOAD: 'AI dokumentumfeltöltés',
|
||||
SRV_ACCOUNTING_SYNC: 'Számviteli szinkron',
|
||||
}
|
||||
return labels[code] || code
|
||||
}
|
||||
|
||||
function handleOrder() {
|
||||
if (props.plan) {
|
||||
emit('order', props.plan, isYearly.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-fade-enter-active,
|
||||
.modal-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.modal-fade-enter-from,
|
||||
.modal-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
80
frontend/src/components/ui/BaseCard.vue
Normal file
80
frontend/src/components/ui/BaseCard.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
class="bg-white/95 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.12)] overflow-hidden flex flex-col relative transition-all duration-300 ease-out"
|
||||
:class="[
|
||||
heightClass,
|
||||
hoverable ? 'cursor-pointer hover:-translate-y-3 hover:scale-[1.02] hover:shadow-2xl' : '',
|
||||
clickable ? 'cursor-pointer' : '',
|
||||
customClass,
|
||||
]"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- ═══ HEADER SLOT ═══ -->
|
||||
<div
|
||||
v-if="$slots.header"
|
||||
class="h-12 w-full shrink-0 flex items-center px-4"
|
||||
:class="headerColorClass"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
<!-- ═══ BODY SLOT (default) ═══ -->
|
||||
<div
|
||||
class="flex-1 flex flex-col text-slate-800 overflow-hidden"
|
||||
:class="bodyPaddingClass"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- ═══ FOOTER SLOT ═══ -->
|
||||
<div v-if="$slots.footer" class="shrink-0 px-4 pb-4">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Enable hover lift effect (translate-y and stronger shadow) */
|
||||
hoverable?: boolean
|
||||
/** Enable cursor-pointer without hover animation */
|
||||
clickable?: boolean
|
||||
/** Card height Tailwind class (default: h-[350px]) */
|
||||
height?: string
|
||||
/** Header background color Tailwind class (default: bg-slate-700) */
|
||||
headerColor?: string
|
||||
/** Body padding: 'sm' | 'md' | 'lg' | 'none' (default: 'md') */
|
||||
bodyPadding?: 'sm' | 'md' | 'lg' | 'none'
|
||||
/** Additional CSS classes to merge */
|
||||
customClass?: string
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
clickable: false,
|
||||
height: 'h-[350px]',
|
||||
headerColor: 'bg-slate-700',
|
||||
bodyPadding: 'md',
|
||||
customClass: '',
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const heightClass = computed(() => props.height)
|
||||
|
||||
const headerColorClass = computed(() => props.headerColor)
|
||||
|
||||
const bodyPaddingClass = computed(() => {
|
||||
switch (props.bodyPadding) {
|
||||
case 'sm': return 'p-3'
|
||||
case 'lg': return 'p-6'
|
||||
case 'none': return ''
|
||||
default: return 'p-4'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -6,6 +6,39 @@ export default {
|
||||
saving: 'Saving...',
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
328
frontend/src/views/SubscriptionPlansView.vue
Normal file
328
frontend/src/views/SubscriptionPlansView.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="subscription-plans-view min-h-screen">
|
||||
<!-- Background -->
|
||||
<div class="fixed inset-0 z-0">
|
||||
<div class="absolute inset-0 bg-[url('@/assets/garage-bg.png')] bg-cover bg-center bg-fixed"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-10">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ t('subscription.title') }}</h1>
|
||||
<p class="text-white/60">{{ t('subscription.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-20"
|
||||
>
|
||||
<div class="h-10 w-10 animate-spin rounded-full border-4 border-white/10 border-t-[#70BC84]" />
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="text-center py-10"
|
||||
>
|
||||
<p class="text-red-400">{{ error }}</p>
|
||||
<button
|
||||
@click="fetchPlans"
|
||||
class="mt-4 px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-sm text-white"
|
||||
>
|
||||
{{ t('common.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ═══ MINIMALIST PLAN CARDS ═══ -->
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
<BaseCard
|
||||
v-for="plan in filteredPlans"
|
||||
:key="plan.id"
|
||||
:class="{
|
||||
'ring-2 ring-emerald-500': plan.name === currentPlanName,
|
||||
'hover:-translate-y-2 hover:shadow-2xl': true,
|
||||
}"
|
||||
header-color="bg-[#306081]"
|
||||
height="auto"
|
||||
body-padding="lg"
|
||||
:custom-class="`border-2 ${plan.name === currentPlanName ? 'border-emerald-500' : 'border-slate-800'} transition-all duration-300`"
|
||||
>
|
||||
<!-- ═══ HEADER: Plan Name + Badge ═══ -->
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="text-white font-bold text-sm tracking-wide">
|
||||
{{ plan.rules?.display_name || plan.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="plan.rules?.marketing?.badge"
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full"
|
||||
:style="{
|
||||
backgroundColor: plan.rules.marketing.highlight_color || '#70BC84',
|
||||
color: '#fff',
|
||||
}"
|
||||
>
|
||||
{{ plan.rules.marketing.badge }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ═══ BODY: Minimalist Content ═══ -->
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Subtitle -->
|
||||
<p
|
||||
v-if="plan.rules?.marketing?.subtitle"
|
||||
class="text-xs text-slate-500 mb-4 leading-relaxed"
|
||||
>
|
||||
{{ plan.rules.marketing.subtitle }}
|
||||
</p>
|
||||
|
||||
<!-- Price (from resolved_pricing) -->
|
||||
<div class="text-center mb-5">
|
||||
<div class="text-3xl font-bold text-slate-900">
|
||||
{{ formatPrice(resolvedMonthlyPrice(plan)) }}
|
||||
<span class="text-sm font-normal text-slate-500">/{{ t('subscription.month') }}</span>
|
||||
</div>
|
||||
<!-- Yearly price hint -->
|
||||
<div v-if="resolvedYearlyPrice(plan) > 0" class="text-xs text-slate-400 mt-1">
|
||||
{{ formatPrice(resolvedYearlyPrice(plan)) }}/{{ t('subscription.year') }}
|
||||
<span v-if="yearlySavingsPercent(plan) > 0" class="text-emerald-600 font-semibold ml-1">
|
||||
({{ t('subscription.savePercent', { percent: yearlySavingsPercent(plan) }) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 3 KEY STATS (visual highlight) ═══ -->
|
||||
<div class="grid grid-cols-3 gap-2 mb-5">
|
||||
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
|
||||
<div class="text-base font-bold text-slate-900">
|
||||
{{ plan.rules?.allowances?.max_vehicles ?? '—' }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
|
||||
{{ t('subscription.maxVehicles') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
|
||||
<div class="text-base font-bold text-slate-900">
|
||||
{{ plan.rules?.allowances?.max_garages ?? '—' }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
|
||||
{{ t('subscription.maxGarages') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-lg p-2.5 text-center">
|
||||
<div class="text-base font-bold text-slate-900">
|
||||
{{ plan.rules?.allowances?.monthly_free_credits ?? '—' }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5 uppercase tracking-wider">
|
||||
{{ t('subscription.freeCredits') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Action: Current Plan Badge or Details Button -->
|
||||
<div v-if="plan.name === currentPlanName" class="mt-auto">
|
||||
<div class="text-center text-sm text-emerald-600 font-semibold mb-3 bg-emerald-50 py-2 rounded-lg border border-emerald-200">
|
||||
{{ t('subscription.currentPlan') }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
@click="openDetails(plan)"
|
||||
class="w-full py-2.5 rounded-xl bg-blue-800 hover:bg-blue-900 text-white font-semibold transition-colors text-sm mt-auto"
|
||||
>
|
||||
{{ t('subscription.detailsAndPurchase') }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ PLAN DETAILS MODAL ═══ -->
|
||||
<PlanDetailsModal
|
||||
:visible="showModal"
|
||||
:plan="selectedPlan"
|
||||
:current-plan-name="currentPlanName"
|
||||
@close="closeModal"
|
||||
@order="handleOrder"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../api/axios'
|
||||
import BaseCard from '../components/ui/BaseCard.vue'
|
||||
import PlanDetailsModal from '../components/subscription/PlanDetailsModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface PricingZone {
|
||||
monthly_price: number
|
||||
yearly_price: number
|
||||
currency: string
|
||||
credit_price?: number | null
|
||||
}
|
||||
|
||||
interface SubscriptionPlan {
|
||||
id: number
|
||||
name: string
|
||||
rules: Record<string, any> | null
|
||||
is_custom: boolean
|
||||
resolved_pricing?: PricingZone | null
|
||||
}
|
||||
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const allPlans = ref<SubscriptionPlan[]>([])
|
||||
|
||||
// Modal state
|
||||
const showModal = ref(false)
|
||||
const selectedPlan = ref<SubscriptionPlan | null>(null)
|
||||
|
||||
/**
|
||||
* Filter plans based on the user's current mode:
|
||||
* - Individual (no active org): show only `private_` packages
|
||||
* - Corporate (active org): show only `corp_` packages
|
||||
*/
|
||||
const filteredPlans = computed(() => {
|
||||
const isCorporate = authStore.isCorporateMode
|
||||
return allPlans.value.filter((plan) => {
|
||||
if (isCorporate) {
|
||||
return plan.name.startsWith('corp_')
|
||||
}
|
||||
return plan.name.startsWith('private_')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the user's current subscription plan name.
|
||||
*/
|
||||
const currentPlanName = computed(() => {
|
||||
if (authStore.isCorporateMode) {
|
||||
const activeOrg = authStore.myOrganizations.find(
|
||||
(o) => o.organization_id === authStore.user?.active_organization_id
|
||||
)
|
||||
return activeOrg?.subscription_plan || null
|
||||
}
|
||||
return authStore.user?.subscription_plan || null
|
||||
})
|
||||
|
||||
/**
|
||||
* Get resolved monthly price from a plan.
|
||||
*/
|
||||
function resolvedMonthlyPrice(plan: SubscriptionPlan): number {
|
||||
return plan.resolved_pricing?.monthly_price ?? plan.rules?.pricing?.monthly_price ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved yearly price from a plan.
|
||||
*/
|
||||
function resolvedYearlyPrice(plan: SubscriptionPlan): number {
|
||||
return plan.resolved_pricing?.yearly_price ?? plan.rules?.pricing?.yearly_price ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resolved currency from a plan.
|
||||
*/
|
||||
function resolvedCurrency(plan: SubscriptionPlan): string {
|
||||
return plan.resolved_pricing?.currency ?? plan.rules?.pricing?.currency ?? 'EUR'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate yearly savings percentage.
|
||||
*/
|
||||
function yearlySavingsPercent(plan: SubscriptionPlan): number {
|
||||
const monthly = resolvedMonthlyPrice(plan)
|
||||
const yearly = resolvedYearlyPrice(plan)
|
||||
if (monthly <= 0 || yearly <= 0) return 0
|
||||
const monthlyYearly = monthly * 12
|
||||
const savings = monthlyYearly - yearly
|
||||
return Math.round((savings / monthlyYearly) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for display using the resolved currency.
|
||||
*/
|
||||
function formatPrice(price: number, currency?: string): string {
|
||||
if (price === 0) return t('subscription.free')
|
||||
const cur = currency || 'EUR'
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: cur,
|
||||
minimumFractionDigits: cur === 'HUF' ? 0 : 2,
|
||||
maximumFractionDigits: cur === 'HUF' ? 0 : 2,
|
||||
}).format(price)
|
||||
} catch {
|
||||
return `${price} ${cur}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the PlanDetailsModal for the selected plan.
|
||||
*/
|
||||
function openDetails(plan: SubscriptionPlan) {
|
||||
selectedPlan.value = plan
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal.
|
||||
*/
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
selectedPlan.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle order from modal (simulation).
|
||||
*/
|
||||
function handleOrder(plan: SubscriptionPlan, isYearly: boolean) {
|
||||
console.log('[SubscriptionPlansView] Order simulation:', {
|
||||
plan: plan.name,
|
||||
isYearly,
|
||||
price: isYearly ? resolvedYearlyPrice(plan) : resolvedMonthlyPrice(plan),
|
||||
currency: resolvedCurrency(plan),
|
||||
})
|
||||
// TODO: Integrate with actual checkout flow
|
||||
closeModal()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all active subscription plans from the public endpoint.
|
||||
*/
|
||||
async function fetchPlans() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const res = await api.get<SubscriptionPlan[]>('/subscriptions/public')
|
||||
allPlans.value = res.data || []
|
||||
} catch (err: any) {
|
||||
error.value = t('subscription.loadError')
|
||||
console.error('[SubscriptionPlansView] Error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlans()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscription-plans-view {
|
||||
/* Scoped styles if needed */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user