Appearance
Notifications & Announcements ​
Overview ​
DamNotification.vue— the bell icon component in the sidebar nav; renders a two-tab dropdown (Notifications + Announcements) with real-time badge counts.useDamNotification— the composable that drivesDamNotification.vue; manages all state, pagination, tab switching, read/unread toggling, and real-time event wiring.useNotificationApi— wraps the user-level and org-level notification preference endpoints; separate from the composable that fetches notification list data.useAnnouncementsApi— wraps announcement CRUD endpoints used by the Announcements management page.echo.client.ts— initializes Laravel Echo over Pusher (client-only); subscribed channels per user drive real-time badge updates and subscription plan changes.snackbar.client.ts— a client-only plugin that mounts DOM-injected toast notifications; exposed as$snackbarand consumed via theuseSnackbarcomposable facade.
Architecture ​
The notification system has two distinct surfaces. The in-app notification panel (DamNotification.vue) is driven entirely by useDamNotification, which handles both the System Notifications tab and the Announcements tab through separate paginated API calls. Real-time badge count updates arrive via a Pusher private channel (user.{id}.projectBatchNotification) rather than polling, so the badge number stays live without any interval timer.
The snackbar system is independent — it is not connected to the notifications panel. snackbar.client.ts creates styled DOM elements appended to document.body with slide-in/slide-out animations; it never uses Vue's rendering pipeline. useSnackbar provides an SSR-safe facade that reads $snackbar from the Nuxt app at call time (after hydration) so composables can call it safely on the server without errors.
The System Notifications tab is subscription-gated: when subscription_features.system_notifications.enable is false, the tab is disabled and the composable defaults to the Announcements tab.
File Structure ​
Vue Component Files ​
app/components/global/DamNotification.vue— Bell icon sidebar nav item;v-menudropdown with twov-tabpanels (Notifications, Announcements); inline announcement detail dialogapp/components/dialogs/AnnouncementDialog.vue— Rich-text announcement composer dialog for admin users; used on the Announcements management pageapp/components/org-settings/NotificationSettingsTab.vue— Per-workspace org-level notification preferences UI; admin-only
Composable Files ​
app/composables/core/components/useDamNotification.ts— Primary notification composable; manages system notification list, announcement list, badge counts, tab state, pagination, read/unread operations, real-time Echo subscriptions, and Amplitude trackingapp/composables/api/useNotificationApi.ts— User-level (getUserNotifications,updateUserNotification) and org-level (getOrgNotifications,updateOrgNotification,pauseWorkspaceNotification) notification preference endpointsapp/composables/api/useAnnouncementsApi.ts— Announcement CRUD: list, create, read/unread, mark-all-read, deleteapp/composables/core/common/useSnackbar.ts— SSR-safe facade over$snackbar; exposessuccess,error,warning,infomethodsapp/composables/queries/useNotificationsQuery.ts— TanStack Query cache-aside for notification settings (org-level)app/composables/queries/useNotificationsMutations.ts— TanStack Query mutations for updating notification settingsapp/composables/core/pages/useNotificationSettings.ts— Page composable for the org Notification Settings page
Plugin Files ​
app/plugins/echo.client.ts— Initializes Laravel Echo with Pusher (broadcaster: 'pusher'); lazy-loads on first use, skips auth pages, returns a safe mock on SSR; exposes$echovianuxtApp.provideapp/plugins/snackbar.client.ts— DOM-injected toast notification system; provides four severity variants (success, error, warning, info) with slide-in animation and auto-dismiss
Page Files ​
app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/announcements/list.vue— Announcements management page; admin-only; usesAnnouncementDialoganduseAnnouncementsApiapp/pages/[workspace_id]/workspace-settings/dam/[instance_id]/notifications/index.vue— Notification Settings page; usesNotificationSettingsTabanduseNotificationSettings
Page Details ​
Announcements List (workspace-settings/dam/[instance_id]/announcements/list.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/workspace-settings/dam/:instance_id/announcements/list |
| Layout | general-settings-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | useAnnouncementsPage() |
Filterable by type and text-searchable (debounced) with infinite scroll. Status chips: Draft, Scheduled, Published. "Publish for" column renders an avatar stack (up to 3 visible with overflow).
List columns: Announcement Name (sortable), Announcement By, Status (chip), Publish Date, Publish For (avatar stack)
Row menu: Edit (if editable) or View; always Delete
Dialogs: AnnouncementDialog (add/edit/preview, flag-driven header), ConfirmationDialog (delete)
Notification Settings (workspace-settings/dam/[instance_id]/notifications/index.vue) ​
| Field | Value |
|---|---|
| Route | /:workspace_id/workspace-settings/dam/:instance_id/notifications |
| Layout | general-settings-layout |
| Middleware | auth-check, check-workspace, check-workspace-access, can-access-dam-module, check-if-suspended |
| Primary composable | useNotificationSettings() |
Global on/off toggle calls pauseWorkspaceNotification. Per-event System and Email switches are grouped by group_name; rows with is_hidden are skipped. When notificationsAllowed is false (subscription gate), the settings are replaced with StaticPlanNotificationSettings and an UpgradePlanSection. systemNotificationsAllowed and emailNotificationsAllowed individually gate each switch column.
DamNotification Component ​
File: app/components/global/DamNotification.vue
The notification bell rendered in the sidebar nav. Opens a 455×550px v-menu card with two tabs.
Features ​
- Badge count (
badgesCount) shown on bell icon and in tab title; displays99+when count exceedsMAX_BADGE_DISPLAY - System Notifications tab disabled with tooltip ("upgrade your plan") when
damSystemNotificationAllowedis false - Infinite scroll:
onScrolltriggersloadMoreNotificationsorloadMoreAnnouncementswhen the list reaches the bottom - Per-notification read/unread toggle; "Mark all as read" button appears when there are unread items
- Clicking a system notification opens its
notification_urlin a new tab and marks it read - Clicking an announcement opens an inline
v-dialogwith sanitized HTML description (sanitizeHtmlfrom~/utils/htmlSanitizer) - "Notification Settings" link at the bottom routes to
profile?activeTab=notification
Props ​
javascript
{
navCollapsed: {
type: Boolean,
default: false // controls whether to show tooltip on the bell icon
}
}Usage ​
vue
<template>
<DamNotification :nav-collapsed="isSidebarCollapsed" />
</template>useDamNotification ​
File: app/composables/core/components/useDamNotification.ts
The composable that owns all notification state and operations. Consumed directly by DamNotification.vue.
State ​
javascript
{
// System notifications tab
activeTab: Ref<number>, // 0 = system, 1 = announcements
systemNotifications: Ref<Notification[]>,
unreadNotifications: Ref<number>,
page: Ref<number>,
lastPage: Ref<number>,
initialLoading: Ref<boolean>,
loadMoreLoading: Ref<boolean>,
showOnlyUnreadNotifications: Ref<boolean>,
// Announcements tab
announcements: Ref<AnnouncementNotificationItem[]>,
unreadAnnouncements: Ref<number>,
page_: Ref<number>,
lastPage_: Ref<number>,
initialLoading_: Ref<boolean>,
loadMoreLoading_: Ref<boolean>,
showOnlyUnreadAnnouncements: Ref<boolean>,
// Shared
badgesCount: Ref<number>,
isMenuOpen: Ref<boolean>,
dialog: Ref<boolean>, // announcement detail dialog
selectedAnnouncement: Ref<AnnouncementNotificationItem | null>,
errorState: Ref<ErrorState>,
// Computed
damSystemNotificationAllowed: ComputedRef<boolean>,
workspaceModule: ComputedRef<boolean>,
displayBatchCount: ComputedRef<{ show: boolean; count_: string }>,
showMarkAllAsReadNotification: ComputedRef<boolean>,
showMarkAllAsReadAnnouncement: ComputedRef<boolean>,
announcementComputed: ComputedRef<AnnouncementNotificationItem[]>,
}Methods ​
javascript
{
// Tab and panel control
changeTab(tabIndex: number): void
openList(): void
closeList(): void
openModal(announcement: AnnouncementNotificationItem): void
closeDialog(): void
// Data loading
getBadgesCount(): Promise<void>
initialLoadNotifications(): Promise<void>
initialLoadAnnouncements(): Promise<void>
loadMoreNotifications(): Promise<void>
loadMoreAnnouncements(): Promise<void>
// Read/unread operations
readUnreadNotification(nId: number, redirect?: boolean): Promise<void>
readAllNotifications(): Promise<void>
readAnnouncement(id: number): Promise<void>
readUnreadAnnouncement(aId: number): Promise<void>
readAllAnnouncements(): Promise<void>
// Scroll handler
onScroll(event: Event): void
// Utility
getFirstCharClass(str: string): string
}Real-Time Subscriptions ​
Set up in onMounted, torn down in onBeforeUnmount:
javascript
// Badge count updates from any DAM notification
echo.private(`user.${user.id}.projectBatchNotification`)
.listen('.ProjectUserNotificationBadgesEvent', (e) => {
badgesCount.value = damSystemNotificationAllowed.value
? e.data.dam_badges_count
: 0
})
// Subscription plan change events
echo.private(`user.${user.id}.getUserSubscriptionChannel`)
.listen('.GetUserSubscriptionEvent', async (e) => {
if (e.data.plan_assign) {
await fetchUser()
// refreshes subscription_features so damSystemNotificationAllowed recomputes
}
})useNotificationApi ​
File: app/composables/api/useNotificationApi.ts
Wraps notification preference endpoints. Separate from the notification list fetching (which uses useCommonApis internally via useDamNotification).
Methods ​
javascript
{
// Fetch the current user's notification preferences
getUserNotifications(params: NotificationParams): Promise<ApiResponse<unknown[]>>
// Update the current user's per-event notification preferences
updateUserNotification(payload: NotificationUpdatePayload): Promise<ApiResponse>
// Fetch org-level (workspace-wide) notification settings
getOrgNotifications(params: { workspace_id: number | string }): Promise<ApiResponse<unknown>>
// Update org-level notification settings
updateOrgNotification(payload: OrgNotificationUpdatePayload): Promise<ApiResponse>
// Pause all notifications for the workspace for a set duration
pauseWorkspaceNotification(payload: PauseWorkspaceNotificationPayload): Promise<ApiResponse>
}Usage ​
vue
<script setup lang="ts">
const { getUserNotifications, updateUserNotification, isLoading } = useNotificationApi()
const { workspaceId } = useWorkspaceId()
const preferences = ref(null)
onMounted(async () => {
const res = await getUserNotifications({ workspace_id: workspaceId.value })
preferences.value = res.data
})
async function toggleEvent(eventKey: string, enabled: boolean) {
await updateUserNotification({
workspace_id: workspaceId.value,
[eventKey]: enabled ? 1 : 0,
})
}
</script>snackbar.client.ts Plugin ​
File: app/plugins/snackbar.client.ts
DOM-injected toast notification system. Does not use Vue's rendering pipeline — elements are created, styled, and appended to document.body directly. Exposed as $snackbar via nuxtApp.provide.
Features ​
- Four variants:
success,error,warning,info - Slide-in animation from the right (
translateX(100%)→translateX(0)) - Auto-dismiss with configurable timeout (default 3000ms)
- Themed border, background, and SVG icon per variant
hideSnackbarslide-out + DOM removal after 300ms transition
Methods ​
javascript
{
// Show a custom message with any variant
show(message: string, icon: Component | null, color: SnackbarColor, timeout: number): void
// Convenience variants — default timeout 3000ms
success(message: string, timeout?: number): void
error(message: string, timeout?: number): void
warning(message: string, timeout?: number): void
info(message: string, timeout?: number): void
}useSnackbar ​
File: app/composables/core/common/useSnackbar.ts
SSR-safe composable facade over $snackbar. Reads nuxtApp.$snackbar at call time rather than at module scope so it is safe to call in composables that run during SSR (the $snackbar plugin is client-only and will be undefined on the server; useSnackbar handles this gracefully).
Usage ​
vue
<script setup lang="ts">
const snackbar = useSnackbar()
async function save() {
try {
await api.post(payload)
snackbar.success('Saved successfully.')
} catch (e) {
snackbar.error('Save failed. Please try again.')
}
}
</script>echo.client.ts Plugin ​
File: app/plugins/echo.client.ts
Initializes Laravel Echo backed by Pusher. Client-only; SSR receives a safe mock object.
Features ​
- Lazy initialization: the singleton
echoInstanceis created on first call toinitializeEcho()and reused for the session lifetime - Skips initialization on auth pages (
index,forgot-password, etc.) and returns a no-op mock - Returns a no-op mock when
auth_tokencookie is absent - Configured from
runtimeConfig.public:pusherKey,pusherCluster,pusherAuthEndpoint - Authorizes private channels via
Authorization: Bearer {token}header on the auth endpoint - Tears down on
app:unmountedviadestroyEcho()to prevent HMR channel stacking
Configuration ​
javascript
// Runtime config keys required
{
pusherKey: string, // PUSHER_APP_KEY
pusherCluster: string, // PUSHER_APP_CLUSTER
pusherAuthEndpoint: string, // Backend private channel auth URL
}API Integration ​
Notification Endpoints ​
| Method | Endpoint | Description |
|---|---|---|
| GET | digital-assets/notification/user-notification-list | User notification preferences |
| POST | digital-assets/notification/set-user-profile-notification | Update user preferences |
| POST | digital-assets/notification/set-organization-profile-notification | Update org preferences |
| POST | digital-assets/notification/pause-workspace-notification | Pause workspace notifications |
Common API Endpoints (via useCommonApis) ​
| Method | Endpoint | Description |
|---|---|---|
| GET | digital-assets/notification/get-badge-count | Current user badge count |
| GET | digital-assets/notification/system-list | Paginated system notification list |
| GET | digital-assets/notification/announcement-list | Paginated announcement list |
| POST | digital-assets/notification/read-announcement | Mark announcement read |
| POST | digital-assets/notification/read-unread-system-notification | Toggle system notification read/unread |
| POST | digital-assets/notification/read-all-notification | Mark all system notifications read |
| POST | digital-assets/notification/read-unread-announcement | Toggle announcement read/unread |
| POST | digital-assets/notification/read-all-announcement | Mark all announcements read |
Request/Response Examples ​
json
// GET digital-assets/notification/system-list
// Query: workspace_id=42&module_id=1&page=1&show_unread=0
{
"data": {
"data": [
{
"id": 1045,
"notification_text": "Alex uploaded 3 new assets to Campaign Assets.",
"notification_url": "https://app.collage.inc/42/dam/files/8822",
"read_at": null,
"created_date": "2 hours ago",
"sender_user_detail": {
"name": "Alex Chen",
"profile_image": null,
"display_profile_image": null
}
}
],
"total_unread_notification": 7,
"total_unread_announcement": 2,
"last_page": 3
}
}json
// GET digital-assets/notification/announcement-list
// Query: workspace_id=42&module_id=1&page=1
{
"data": {
"data": [
{
"id": 88,
"read_at": null,
"created_date": "Yesterday",
"announcements_detail": {
"title": "New Brand Guidelines Released",
"description": "<p>Please review the updated guidelines...</p>",
"publisher": {
"name": "Jordan Smith",
"profile_image": null,
"display_profile_image": null
}
}
}
],
"total_unread_announcement": 2,
"last_page": 1
}
}Component Integration ​
Notification Settings page (app/pages/[workspace_id]/workspace-settings/dam/[instance_id]/notifications/index.vue) ​
The page delegates entirely to useNotificationSettings(). When the subscription plan doesn't include notification management (notificationsAllowed is false) the page swaps to static upgrade-prompt components instead of the settings form.
vue
<script setup lang="ts">
definePageMeta({
layout: 'general-settings-layout',
middleware: ['auth-check', 'check-workspace', 'check-workspace-access', 'can-access-dam-module', 'check-if-suspended'],
})
const {
contentLoading,
notificationsEnabled,
updatingGlobal,
notifications,
notificationsAllowed,
systemNotificationsAllowed,
emailNotificationsAllowed,
updateGlobalNotification,
updateNotification,
} = useNotificationSettings()
</script>
<template>
<div class="collage-body account-settings">
<AccountSettingsLeftMenu />
<v-card class="boxview w-100">
<!-- Subscription gate: no access → show upgrade prompt instead of settings -->
<template v-if="!notificationsAllowed && !contentLoading">
<StaticPlanNotificationSettings />
<UpgradePlanSection />
</template>
<template v-else>
<v-card-title>
<h4>Notification Settings</h4>
</v-card-title>
<v-card-text>
<!-- Global on/off switch — calls pauseWorkspaceNotification mutation -->
<v-switch
v-model="notificationsEnabled"
inset
:disabled="updatingGlobal"
@update:model-value="(val) => updateGlobalNotification(!!val)"
/>
<!-- Per-event rows: each notification has separate System and Email switches -->
<template v-for="(category, index) in notifications" :key="category.id">
<h5>{{ category.group_name }}</h5>
<template v-for="notification in category.notification" :key="notification.id">
<div class="notification-setting-list-body-list">
<p>{{ notification.notification_name }}</p>
<v-switch
v-if="!notification.is_hidden"
v-model="notification.is_system"
inset
:disabled="!notificationsEnabled || !systemNotificationsAllowed"
@update:model-value="(val) => updateNotification(notification.id, 'system', val ? 1 : 0)"
/>
<v-switch
v-if="!notification.is_hidden"
v-model="notification.is_email"
inset
:disabled="!notificationsEnabled || !emailNotificationsAllowed"
@update:model-value="(val) => updateNotification(notification.id, 'email', val ? 1 : 0)"
/>
</div>
</template>
<v-divider v-if="notifications.length - 1 !== index" />
</template>
</v-card-text>
</template>
</v-card>
</div>
</template>Bell icon in the sidebar (app/components/global/DamNotification.vue) ​
DamNotification receives a single prop from whatever layout renders the sidebar. It is self-contained — the layout only needs to pass the collapsed state.
vue
<template>
<DamNotification :nav-collapsed="isSidebarCollapsed" />
</template>Workflows ​
Notification Panel Open Flow ​
1. User clicks bell icon in sidebar
Component: DamNotification.vue
→ isMenuOpen set to true by v-model on v-menu
↓
2. watch(isMenuOpen) fires in useDamNotification
→ If system tab active and no notifications loaded
→ openList() → initialLoadNotifications()
→ If announcements tab active and none loaded
→ initialLoadAnnouncements()
↓
3. initialLoadNotifications()
→ Checks damSystemNotificationAllowed (subscription gate)
→ badgesCount reset to 0
→ getSystemNotificationList({ workspace_id, show_unread, page, module_id: 1 })
→ Populates systemNotifications, unreadNotifications, lastPage
↓
4. User scrolls to bottom of list
→ onScroll() detects scrollHeight − scrollTop === clientHeight
→ loadMoreNotifications() increments page, appends to systemNotifications
↓
5. User closes panel
→ isMenuOpen set to false
→ closeList() resets all list state and page countersNotification Read/Redirect Flow ​
1. User clicks a system notification item
→ readUnreadNotification(notification.id, redirect: true)
↓
2. If unread and notification_url present
→ trackNotificationViewed() fires Amplitude event
(collage viewed, folder viewed, or asset viewed with source: 'notification')
→ window.open(notification_url, '_blank')
→ Returns without toggling read state (redirect takes priority)
↓
3. If read and user clicks again (toggle to unread)
→ notification.read_at set to null optimistically
→ unreadNotifications counter incremented
→ readUnreadSystemNotification API called
↓
4. Mark All as Read
→ readAllNotifications()
→ All notifications mapped to { ...n, read_at: 'read' } (immutable)
→ unreadNotifications set to 0
→ markAllReadUnreadNotification API calledReal-Time Badge Update Flow ​
1. Another user uploads assets or takes an action
→ Laravel backend fires ProjectUserNotificationBadgesEvent
→ Pusher broadcasts to private channel:
user.{userId}.projectBatchNotification
↓
2. echo plugin receives event in DamNotification mount
→ .ProjectUserNotificationBadgesEvent handler fires
→ If damSystemNotificationAllowed → badgesCount = e.data.dam_badges_count
→ If not allowed → badgesCount = 0 (subscription gate)
↓
3. displayBatchCount computed updates
→ { show: true, count_: '7' } or { show: true, count_: '99+' }
→ Badge renders on bell icon and in tab titleSnackbar Display Flow ​
1. Composable calls snackbar.error('Save failed.')
→ useSnackbar reads $snackbar from nuxtApp at call time
↓
2. snackbar.show() invoked (client-only)
→ Creates div element, applies border-left accent style
→ Appends to document.body at top-right (z-index: 9999)
→ requestAnimationFrame transitions translateX(100%) → translateX(0)
↓
3. After timeout (default 3000ms)
→ hideSnackbar() transitions to translateX(100%), opacity 0
→ After 300ms: element removed from DOMRelated Documentation ​
- Real-time Updates — Pusher channel setup and event reference
- Workspace — Notification settings page location
- Permissions —
canManageNotificationsandcanViewAnnouncementsgates - Subscription —
system_notifications.enablesubscription feature flag