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:
58
src/components/SubscriptionBanner.vue
Normal file
58
src/components/SubscriptionBanner.vue
Normal 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>
|
||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import AppSidebar from './AppSidebar.vue'
|
import AppSidebar from './AppSidebar.vue'
|
||||||
import AppHeader from './AppHeader.vue'
|
import AppHeader from './AppHeader.vue'
|
||||||
|
import SubscriptionBanner from '@/components/SubscriptionBanner.vue'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -17,6 +18,9 @@ async function handleLogout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Subscription Banner -->
|
||||||
|
<SubscriptionBanner />
|
||||||
|
|
||||||
<!-- Mobile sidebar backdrop -->
|
<!-- Mobile sidebar backdrop -->
|
||||||
<div
|
<div
|
||||||
v-if="sidebarOpen"
|
v-if="sidebarOpen"
|
||||||
|
|||||||
@@ -16,11 +16,17 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/RegisterOrgView.vue'),
|
component: () => import('@/views/RegisterOrgView.vue'),
|
||||||
meta: { guest: true }
|
meta: { guest: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/subscription-expired',
|
||||||
|
name: 'subscription-expired',
|
||||||
|
component: () => import('@/views/SubscriptionExpiredView.vue'),
|
||||||
|
meta: { requiresAuth: true, skipSubscriptionCheck: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
name: 'admin',
|
name: 'admin',
|
||||||
component: () => import('@/views/AdminDashboardView.vue'),
|
component: () => import('@/views/AdminDashboardView.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true, skipSubscriptionCheck: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -143,12 +149,16 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
|
|
||||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||||
const isGuest = to.matched.some(record => record.meta.guest)
|
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
|
const requiredRoles = to.meta.roles as string[] | undefined
|
||||||
|
|
||||||
if (requiresAuth && !authStore.isAuthenticated) {
|
if (requiresAuth && !authStore.isAuthenticated) {
|
||||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||||
} else if (isGuest && authStore.isAuthenticated) {
|
} else if (isGuest && authStore.isAuthenticated) {
|
||||||
next({ name: 'dashboard' })
|
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 || '')) {
|
} else if (requiredRoles && !requiredRoles.includes(authStore.user?.role || '')) {
|
||||||
next({ name: 'dashboard' })
|
next({ name: 'dashboard' })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -19,9 +19,18 @@ export interface Organization {
|
|||||||
settings?: Record<string, unknown>
|
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', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const organization = ref<Organization | null>(null)
|
const organization = ref<Organization | null>(null)
|
||||||
|
const subscription = ref<Subscription | null>(null)
|
||||||
const accessToken = ref<string | null>(localStorage.getItem('accessToken'))
|
const accessToken = ref<string | null>(localStorage.getItem('accessToken'))
|
||||||
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
|
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
|
||||||
const orgSlug = ref<string>(localStorage.getItem('orgSlug') || '')
|
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 fullName = computed(() => user.value ? `${user.value.first_name} ${user.value.last_name}` : '')
|
||||||
const orgName = computed(() => organization.value?.name || '')
|
const orgName = computed(() => organization.value?.name || '')
|
||||||
const orgLogo = computed(() => organization.value?.logo_url || null)
|
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) {
|
function setTokens(access: string, refresh: string) {
|
||||||
accessToken.value = access
|
accessToken.value = access
|
||||||
@@ -52,6 +72,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
function clearAuth() {
|
function clearAuth() {
|
||||||
user.value = null
|
user.value = null
|
||||||
organization.value = null
|
organization.value = null
|
||||||
|
subscription.value = null
|
||||||
accessToken.value = null
|
accessToken.value = null
|
||||||
refreshToken.value = null
|
refreshToken.value = null
|
||||||
localStorage.removeItem('accessToken')
|
localStorage.removeItem('accessToken')
|
||||||
@@ -97,6 +118,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/auth/me')
|
const response = await api.get('/auth/me')
|
||||||
user.value = response.data.user
|
user.value = response.data.user
|
||||||
|
subscription.value = response.data.subscription
|
||||||
// Also fetch organization info
|
// Also fetch organization info
|
||||||
await fetchOrganization()
|
await fetchOrganization()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -137,6 +159,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
organization,
|
organization,
|
||||||
|
subscription,
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
orgSlug,
|
orgSlug,
|
||||||
@@ -150,6 +173,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
fullName,
|
fullName,
|
||||||
orgName,
|
orgName,
|
||||||
orgLogo,
|
orgLogo,
|
||||||
|
isTrialActive,
|
||||||
|
isSubscriptionActive,
|
||||||
|
isSubscriptionExpired,
|
||||||
|
trialDaysRemaining,
|
||||||
|
showTrialWarning,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
63
src/views/SubscriptionExpiredView.vue
Normal file
63
src/views/SubscriptionExpiredView.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user