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:
163
website/resources/ts/Components/NotificationBell.vue
Normal file
163
website/resources/ts/Components/NotificationBell.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user