feat: Add subscription UI components

- Add Subscription interface to auth store
- Add subscription computed properties (isTrialActive, isExpired, etc.)
- Add SubscriptionBanner component (trial warning, expired notice)
- Add SubscriptionExpiredView (blocked access screen)
- Update AppLayout to show banner
- Update router to redirect expired subscriptions
This commit is contained in:
2026-03-13 05:58:06 +00:00
parent ab3bc857a3
commit 9ec7613ee5
5 changed files with 164 additions and 1 deletions

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const bannerType = computed(() => {
if (authStore.isSubscriptionExpired) return 'expired'
if (authStore.showTrialWarning) return 'trial-warning'
if (authStore.isTrialActive) return 'trial'
return null
})
const bannerClass = computed(() => {
switch (bannerType.value) {
case 'expired': return 'bg-red-600 text-white'
case 'trial-warning': return 'bg-yellow-500 text-yellow-900'
case 'trial': return 'bg-blue-500 text-white'
default: return ''
}
})
const message = computed(() => {
const days = authStore.trialDaysRemaining
switch (bannerType.value) {
case 'expired':
return authStore.subscription?.status === 'paused'
? '⚠️ Ihr Account wurde pausiert. Bitte kontaktieren Sie den Support.'
: '⚠️ Ihre Testphase ist abgelaufen. Bitte wählen Sie ein Abonnement.'
case 'trial-warning':
return `⏰ Nur noch ${days} Tag${days === 1 ? '' : 'e'} in der Testphase! Jetzt Abo wählen.`
case 'trial':
return `🎉 Testphase: Noch ${days} Tag${days === 1 ? '' : 'e'} kostenlos testen`
default:
return ''
}
})
const showBanner = computed(() => bannerType.value !== null)
</script>
<template>
<div
v-if="showBanner"
:class="['px-4 py-2 text-center text-sm font-medium', bannerClass]"
>
{{ message }}
<a
v-if="bannerType === 'expired' || bannerType === 'trial-warning'"
href="https://secu.kronos-soulution.de/#preise"
target="_blank"
class="ml-2 underline font-bold hover:no-underline"
>
Preise ansehen
</a>
</div>
</template>

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
import SubscriptionBanner from '@/components/SubscriptionBanner.vue'
const authStore = useAuthStore()
const router = useRouter()
@@ -17,6 +18,9 @@ async function handleLogout() {
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<!-- Subscription Banner -->
<SubscriptionBanner />
<!-- Mobile sidebar backdrop -->
<div
v-if="sidebarOpen"

View File

@@ -16,11 +16,17 @@ const router = createRouter({
component: () => import('@/views/RegisterOrgView.vue'),
meta: { guest: true }
},
{
path: '/subscription-expired',
name: 'subscription-expired',
component: () => import('@/views/SubscriptionExpiredView.vue'),
meta: { requiresAuth: true, skipSubscriptionCheck: true }
},
{
path: '/admin',
name: 'admin',
component: () => import('@/views/AdminDashboardView.vue'),
meta: { requiresAuth: true }
meta: { requiresAuth: true, skipSubscriptionCheck: true }
},
{
path: '/',
@@ -143,12 +149,16 @@ router.beforeEach(async (to, _from, next) => {
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
const isGuest = to.matched.some(record => record.meta.guest)
const skipSubscriptionCheck = to.matched.some(record => record.meta.skipSubscriptionCheck)
const requiredRoles = to.meta.roles as string[] | undefined
if (requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } })
} else if (isGuest && authStore.isAuthenticated) {
next({ name: 'dashboard' })
} else if (requiresAuth && authStore.isSubscriptionExpired && !skipSubscriptionCheck) {
// Redirect to subscription expired page if subscription is not active
next({ name: 'subscription-expired' })
} else if (requiredRoles && !requiredRoles.includes(authStore.user?.role || '')) {
next({ name: 'dashboard' })
} else {

View File

@@ -19,9 +19,18 @@ export interface Organization {
settings?: Record<string, unknown>
}
export interface Subscription {
status: 'trial' | 'active' | 'paused' | 'expired' | 'cancelled'
plan: 'starter' | 'business' | 'enterprise'
trialEndsAt: string | null
subscriptionEndsAt: string | null
daysRemaining: number | null
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const organization = ref<Organization | null>(null)
const subscription = ref<Subscription | null>(null)
const accessToken = ref<string | null>(localStorage.getItem('accessToken'))
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
const orgSlug = ref<string>(localStorage.getItem('orgSlug') || '')
@@ -36,6 +45,17 @@ export const useAuthStore = defineStore('auth', () => {
const fullName = computed(() => user.value ? `${user.value.first_name} ${user.value.last_name}` : '')
const orgName = computed(() => organization.value?.name || '')
const orgLogo = computed(() => organization.value?.logo_url || null)
// Subscription computed
const isTrialActive = computed(() => subscription.value?.status === 'trial' && (subscription.value?.daysRemaining ?? 0) > 0)
const isSubscriptionActive = computed(() => subscription.value?.status === 'active' || isTrialActive.value)
const isSubscriptionExpired = computed(() =>
subscription.value?.status === 'expired' ||
subscription.value?.status === 'paused' ||
(subscription.value?.status === 'trial' && (subscription.value?.daysRemaining ?? 0) <= 0)
)
const trialDaysRemaining = computed(() => subscription.value?.daysRemaining ?? null)
const showTrialWarning = computed(() => isTrialActive.value && (trialDaysRemaining.value ?? 0) <= 7)
function setTokens(access: string, refresh: string) {
accessToken.value = access
@@ -52,6 +72,7 @@ export const useAuthStore = defineStore('auth', () => {
function clearAuth() {
user.value = null
organization.value = null
subscription.value = null
accessToken.value = null
refreshToken.value = null
localStorage.removeItem('accessToken')
@@ -97,6 +118,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
const response = await api.get('/auth/me')
user.value = response.data.user
subscription.value = response.data.subscription
// Also fetch organization info
await fetchOrganization()
} catch {
@@ -137,6 +159,7 @@ export const useAuthStore = defineStore('auth', () => {
return {
user,
organization,
subscription,
accessToken,
refreshToken,
orgSlug,
@@ -150,6 +173,11 @@ export const useAuthStore = defineStore('auth', () => {
fullName,
orgName,
orgLogo,
isTrialActive,
isSubscriptionActive,
isSubscriptionExpired,
trialDaysRemaining,
showTrialWarning,
login,
register,
logout,

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
const isPaused = authStore.subscription?.status === 'paused'
async function logout() {
await authStore.logout()
router.push('/login')
}
</script>
<template>
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div class="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div class="text-6xl mb-6">{{ isPaused ? '⏸️' : '⏰' }}</div>
<h1 class="text-2xl font-bold text-gray-900 mb-4">
{{ isPaused ? 'Account pausiert' : 'Testphase beendet' }}
</h1>
<p class="text-gray-600 mb-6">
<template v-if="isPaused">
Ihr Account wurde vorübergehend pausiert. Dies kann an einer ausstehenden Zahlung liegen.
Bitte kontaktieren Sie unseren Support.
</template>
<template v-else>
Ihre 14-tägige Testphase ist leider abgelaufen.
Um SeCu weiter zu nutzen, wählen Sie bitte ein passendes Abonnement.
</template>
</p>
<div class="space-y-3">
<a
href="https://secu.kronos-soulution.de/#preise"
target="_blank"
class="block w-full bg-purple-600 text-white py-3 px-6 rounded-lg font-semibold hover:bg-purple-700 transition"
>
{{ isPaused ? '📞 Support kontaktieren' : '💳 Abonnement wählen' }}
</a>
<button
@click="logout"
class="block w-full bg-gray-200 text-gray-700 py-3 px-6 rounded-lg font-semibold hover:bg-gray-300 transition"
>
🚪 Ausloggen
</button>
</div>
<div class="mt-8 pt-6 border-t border-gray-200">
<p class="text-sm text-gray-500">
Fragen? Kontaktieren Sie uns:
</p>
<a href="mailto:support@kronos-soulution.de" class="text-purple-600 font-medium hover:underline">
support@kronos-soulution.de
</a>
</div>
</div>
</div>
</template>