Add notification system, notification bell, admin/account tests, and footer legal links

- 6 notification classes: PaymentSucceeded, PaymentFailed, SubscriptionCreated,
  SubscriptionCancelled, ServiceProvisioned, InvoiceGenerated (mail + database)
- Wire notifications to existing event listeners + new subscription listeners
- NotificationBell component in Account and Admin layouts
- NotificationController with index, markAsRead, markAllAsRead endpoints
- 62 new Pest tests: AdminPanelTest (admin CRUD) + CustomerAccountTest (account features)
- Add Legal links column to marketing footer
- 114 tests passing (623 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 13:45:10 -05:00
parent 813fde30c2
commit 89fac519c3
19 changed files with 1603 additions and 6 deletions

View File

@@ -0,0 +1,163 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
interface NotificationItem {
id: string
type: string
message: string
data: Record<string, unknown>
read: boolean
created_at: string
}
const notifications = ref<NotificationItem[]>([])
const unreadCount = ref<number>(0)
const loading = ref<boolean>(false)
const menu = ref<boolean>(false)
function resolveIcon(type: string): string {
const icons: Record<string, string> = {
payment_succeeded: 'tabler-credit-card',
payment_failed: 'tabler-credit-card-off',
subscription_created: 'tabler-rosette-discount-check',
subscription_cancelled: 'tabler-circle-x',
service_provisioned: 'tabler-server',
invoice_generated: 'tabler-file-invoice',
}
return icons[type] ?? 'tabler-bell'
}
function resolveColor(type: string): string {
const colors: Record<string, string> = {
payment_succeeded: 'success',
payment_failed: 'error',
subscription_created: 'primary',
subscription_cancelled: 'warning',
service_provisioned: 'info',
invoice_generated: 'secondary',
}
return colors[type] ?? 'default'
}
async function fetchNotifications(): Promise<void> {
loading.value = true
try {
const response = await axios.get('/notifications')
notifications.value = response.data.notifications
unreadCount.value = response.data.unread_count
}
catch {
// Silently fail for notification fetch
}
finally {
loading.value = false
}
}
async function markAsRead(id: string): Promise<void> {
try {
await axios.post(`/notifications/${id}/read`)
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
}
catch {
// Silently fail
}
}
async function markAllAsRead(): Promise<void> {
try {
await axios.post('/notifications/read-all')
notifications.value.forEach(n => { n.read = true })
unreadCount.value = 0
}
catch {
// Silently fail
}
}
onMounted(() => {
fetchNotifications()
})
</script>
<template>
<VMenu
v-model="menu"
:close-on-content-click="false"
offset="14px"
@update:model-value="(val: boolean) => { if (val) fetchNotifications() }"
>
<template #activator="{ props: menuProps }">
<VBadge
:content="unreadCount"
:model-value="unreadCount > 0"
color="error"
overlap
>
<VBtn
icon="tabler-bell"
variant="text"
size="small"
v-bind="menuProps"
/>
</VBadge>
</template>
<VCard width="380" max-height="500">
<VCardTitle class="d-flex align-center justify-space-between pa-4">
<span class="text-body-1 font-weight-bold">Notifications</span>
<VBtn
v-if="unreadCount > 0"
variant="text"
size="small"
color="primary"
@click="markAllAsRead"
>
Mark all read
</VBtn>
</VCardTitle>
<VDivider />
<VList v-if="notifications.length > 0" density="compact" class="py-0">
<VListItem
v-for="notification in notifications"
:key="notification.id"
:class="{ 'bg-surface-variant': !notification.read }"
@click="markAsRead(notification.id)"
>
<template #prepend>
<VAvatar
size="36"
:color="resolveColor(notification.type)"
variant="tonal"
>
<VIcon :icon="resolveIcon(notification.type)" size="20" />
</VAvatar>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ notification.message }}
</VListItemTitle>
<VListItemSubtitle class="text-caption">
{{ notification.created_at }}
</VListItemSubtitle>
</VListItem>
</VList>
<div v-else class="text-center pa-8">
<VIcon icon="tabler-bell-off" size="32" color="disabled" class="mb-2" />
<div class="text-body-2 text-medium-emphasis">
No notifications
</div>
</div>
</VCard>
</VMenu>
</template>

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { accountNavItems } from '@/navigation/account'
import FlashMessages from '@/Components/FlashMessages.vue'
import NotificationBell from '@/Components/NotificationBell.vue'
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
import logoWhite from '@images/ezscale_logo_white.png'
@@ -66,6 +67,7 @@ function isActive(matchPrefix: string): boolean {
<VSpacer />
<div class="d-flex align-center ga-2">
<NotificationBell />
<ThemeSwitcher />
<span v-if="user" class="text-body-2">

View File

@@ -4,6 +4,7 @@ import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { adminNavItems } from '@/navigation/admin'
import FlashMessages from '@/Components/FlashMessages.vue'
import NotificationBell from '@/Components/NotificationBell.vue'
import ThemeSwitcher from '@/Components/ThemeSwitcher.vue'
import logoWhite from '@images/ezscale_logo_white.png'
@@ -82,6 +83,7 @@ function isActive(matchPrefix: string): boolean {
</VBtn>
</a>
<NotificationBell />
<ThemeSwitcher />
<span v-if="user" class="text-body-2">

View File

@@ -31,10 +31,15 @@ const footerLinks = {
{ title: 'Blog', href: '/blog' },
],
support: [
{ title: 'Help Center', href: '/support' },
{ title: 'Documentation', href: '/docs' },
{ title: 'API Reference', href: '/api' },
{ title: 'Status Page', href: '/status' },
{ title: 'Help Center', href: 'https://ezscale.support' },
{ title: 'Knowledge Base', href: 'https://ezscale.support/en/knowledgebase' },
{ title: 'Status Page', href: 'https://status.ezscale.cloud' },
],
legal: [
{ title: 'Terms of Service', href: '/terms-of-service' },
{ title: 'Privacy Policy', href: '/privacy-policy' },
{ title: 'Acceptable Use', href: '/acceptable-use' },
{ title: 'SLA', href: '/sla' },
],
}
@@ -126,7 +131,7 @@ const socialLinks = [
<VContainer>
<VRow>
<!-- Logo + Description + Newsletter -->
<VCol cols="12" md="5">
<VCol cols="12" md="4">
<div
class="mb-4"
:class="$vuetify.display.smAndDown ? 'w-100' : 'w-75'"
@@ -205,7 +210,7 @@ const socialLinks = [
</VCol>
<!-- Support -->
<VCol cols="12" md="3" sm="4">
<VCol md="2" sm="4" xs="6">
<div class="footer-links">
<h6 class="footer-title text-h6 mb-6">
Support
@@ -226,6 +231,29 @@ const socialLinks = [
</ul>
</div>
</VCol>
<!-- Legal -->
<VCol md="2" sm="4" xs="6">
<div class="footer-links">
<h6 class="footer-title text-h6 mb-6">
Legal
</h6>
<ul style="list-style: none; padding: 0;">
<li
v-for="link in footerLinks.legal"
:key="link.href"
class="mb-4"
>
<a
:href="link.href"
class="text-white-variant"
>
{{ link.title }}
</a>
</li>
</ul>
</div>
</VCol>
</VRow>
</VContainer>
</div>