import { DatePipe } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, Component, computed, DestroyRef, effect, ElementRef, inject, Injector, runInInjectionContext, signal, untracked, viewChild, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isObservable, type Observable, take } from 'rxjs'; import { resolveNotificationLinkForMode, ShippingModeService } from '@africanies/africanies-core'; import { AfricaniesIconComponent } from '@africanies/africanies-icons'; import type { PaginationMetaModel } from '@africanies/africanies-models'; import { ButtonComponent } from '../button/button.component'; import { AfricaniesUiI18n } from '../i18n/africanies-ui-i18n'; import { AfricaniesOverlayRef } from '../overlay/africanies-overlay-ref'; import { OVERLAY_DATA } from '../overlay/overlay-data.token'; import type { AfricaniesNotification, NotificationDrawerData, NotificationDrawerResult, NotificationPageResult, } from './notification.types'; /** Pixels before the list end to prefetch the next page. */ const SCROLL_PREFETCH_PX = 120; /** How long the “click again to confirm” arm stays active. */ const MARK_ALL_CONFIRM_MS = 4000; /** * Right-edge drawer listing in-app notifications. * * Opened via {@link NotificationDrawerService}. */ @Component({ selector: 'africanies-notification-drawer', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'block h-full w-full min-w-0 overflow-hidden', }, imports: [AfricaniesIconComponent, ButtonComponent], template: `

{{ inboxLabel() }}

{{ title() }}

@if (hasUnread()) {

{{ unreadLabel() }}

}
@if (hasUnread() && canMarkAllRead()) { }
@if (loadingInitial()) {

{{ loadingLabel() }}

} @else if (loadError()) {

{{ loadError() }}

@if (usesPagination()) { }
} @else if (items().length === 0) {

{{ emptyLabel() }}

} @else { @if (usesPagination()) { } }
`, }) /** * Right-edge drawer listing in-app notifications. */ export class NotificationDrawerPanel { protected readonly ref = inject>(AfricaniesOverlayRef); private readonly data = inject(OVERLAY_DATA); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); private readonly shipping = inject(ShippingModeService); private readonly i18n = inject(AfricaniesUiI18n); private readonly scrollHost = viewChild>('scrollHost'); private readonly loadSentinel = viewChild>('loadSentinel'); private observer: IntersectionObserver | null = null; protected readonly viewLinkClass = computed(() => { const base = 'inline-flex cursor-pointer items-center gap-1 self-start text-caption font-semibold underline underline-offset-[3px] transition-colors'; if (this.shipping.mode() === 'stn') { return `${base} text-import-strong decoration-import-strong/35 hover:decoration-import-strong dark:text-import-light dark:decoration-import-light/40 dark:hover:decoration-import-light`; } return `${base} text-export-strong decoration-export-strong/35 hover:decoration-export-strong dark:text-export-light dark:decoration-export-light/40 dark:hover:decoration-export-light`; }); protected readonly title = computed( () => this.data.title ?? this.i18n.t('africaniesUi.notifications.title', 'Notifications'), ); private readonly seededItems = this.data.notifications ?? []; protected readonly items = signal(this.seededItems); protected readonly markingId = signal(null); protected readonly markingAll = signal(false); protected readonly confirmingMarkAll = signal(false); protected readonly loadingInitial = signal( !!this.data.onLoadPage && this.seededItems.length === 0, ); protected readonly loadingMore = signal(false); protected readonly loadError = signal(null); protected readonly hasMore = signal(false); protected readonly currentPage = signal(0); protected readonly unreadCount = computed( () => (this.items() ?? []).filter((item) => !item.read).length, ); protected readonly hasUnread = computed(() => this.unreadCount() > 0); protected readonly markAllReadLabel = computed(() => { if (this.markingAll()) { return this.i18n.t('africaniesUi.notifications.marking', 'Marking…'); } if (this.confirmingMarkAll()) { return this.i18n.t( 'africaniesUi.notifications.confirmMarkAll', 'Click again to confirm', ); } return this.i18n.t( 'africaniesUi.notifications.markAllRead', 'Mark all as read', ); }); protected readonly markAllReadAria = computed(() => { if (this.confirmingMarkAll()) { return this.i18n.t( 'africaniesUi.notifications.confirmMarkAllAria', 'Click again to confirm mark all as read', ); } return this.i18n.t( 'africaniesUi.notifications.markAllRead', 'Mark all as read', ); }); protected readonly inboxLabel = computed(() => this.i18n.t('africaniesUi.notifications.inbox', 'Inbox'), ); protected readonly unreadLabel = computed(() => this.i18n.tParams( 'africaniesUi.notifications.unread', '{{count}} unread', { count: this.unreadCount() }, ), ); protected readonly closeLabel = computed(() => this.i18n.t('africaniesUi.notifications.close', 'Close notifications'), ); protected readonly loadingLabel = computed(() => this.i18n.t( 'africaniesUi.notifications.loading', 'Loading notifications…', ), ); protected readonly loadingMoreLabel = computed(() => this.i18n.t('africaniesUi.notifications.loadingMore', 'Loading more…'), ); protected readonly tryAgainLabel = computed(() => this.i18n.t('africaniesUi.notifications.tryAgain', 'Try again'), ); protected readonly emptyLabel = computed(() => this.i18n.t( 'africaniesUi.notifications.empty', "You're all caught up.", ), ); protected viewLabel(item: AfricaniesNotification): string { return this.markingId() === item.id ? this.i18n.t('africaniesUi.notifications.opening', 'Opening…') : this.i18n.t('africaniesUi.notifications.view', 'View'); } protected markReadLabel(item: AfricaniesNotification): string { return this.markingId() === item.id ? this.i18n.t('africaniesUi.notifications.marking', 'Marking…') : this.i18n.t('africaniesUi.notifications.markRead', 'Mark read'); } private markAllConfirmTimer: ReturnType | null = null; constructor() { if (this.data.onLoadPage) { if (this.seededItems.length > 0) { this.currentPage.set(1); this.fetchPage(1, 'refresh'); } else { this.fetchPage(1, 'replace'); } } effect(() => { if ( this.loadingInitial() || !this.data.onLoadPage || !this.hasMore() || this.items().length === 0 || !this.loadSentinel() ) { return; } untracked(() => this.scheduleInfiniteScrollSetup()); }); this.destroyRef.onDestroy(() => { this.observer?.disconnect(); this.observer = null; this.clearMarkAllConfirm(); }); } protected usesPagination(): boolean { return typeof this.data.onLoadPage === 'function'; } protected canMarkAllRead(): boolean { return typeof this.data.onMarkAllRead === 'function'; } protected canMarkRead(): boolean { return typeof this.data.onMarkRead === 'function'; } protected retryInitialLoad(): void { if (!this.data.onLoadPage) { return; } this.loadError.set(null); this.fetchPage(1, 'replace'); } protected itemCardClass(item: AfricaniesNotification): string { if (item.read) { return 'border-border bg-transparent dark:border-white/10'; } return 'border-ink-blue/20 bg-background-welcome dark:border-export-light/20 dark:bg-white/[0.04]'; } protected viewHref(item: AfricaniesNotification): string { const link = item.link?.trim(); if (!link) { return ''; } return resolveNotificationLinkForMode(link, this.shipping.mode()); } protected onViewClick(event: MouseEvent, item: AfricaniesNotification): void { if (this.markingId() || this.markingAll()) { event.preventDefault(); return; } const closeDrawer = (): void => { this.ref.close({ selectedId: item.id, markedReadId: item.read ? undefined : item.id, }); }; const navigate = (): void => { const link = this.viewHref(item); if (link) { if (item.externalLink) { window.open(link, '_blank', 'noopener,noreferrer'); } else { window.location.assign(link); } } closeDrawer(); }; if (item.read || !this.data.onMarkRead) { closeDrawer(); return; } event.preventDefault(); this.markingId.set(item.id); this.runHook( () => this.data.onMarkRead?.(item.id), () => { this.patchRead(item.id); this.markingId.set(null); navigate(); }, () => this.markingId.set(null), ); } protected markRead(item: AfricaniesNotification): void { if (item.read || this.markingId() || this.markingAll() || !this.data.onMarkRead) { return; } this.markingId.set(item.id); this.runHook( () => this.data.onMarkRead?.(item.id), () => { this.patchRead(item.id); this.markingId.set(null); }, () => this.markingId.set(null), ); } /** * First click arms a short confirm (Cursor Undo-style). Second click * marks every notification read. */ protected markAllRead(): void { if (this.markingAll() || !this.data.onMarkAllRead) { return; } if (!this.confirmingMarkAll()) { this.armMarkAllConfirm(); return; } this.clearMarkAllConfirm(); this.markingAll.set(true); this.runHook( () => this.data.onMarkAllRead?.(), () => { this.items.update((rows) => (rows ?? []).map((row) => ({ ...row, read: true })), ); this.markingAll.set(false); this.ref.close({ markedAllRead: true }); }, () => this.markingAll.set(false), ); } private armMarkAllConfirm(): void { this.confirmingMarkAll.set(true); this.clearMarkAllConfirmTimer(); this.markAllConfirmTimer = setTimeout(() => { this.confirmingMarkAll.set(false); this.markAllConfirmTimer = null; }, MARK_ALL_CONFIRM_MS); } private clearMarkAllConfirm(): void { this.confirmingMarkAll.set(false); this.clearMarkAllConfirmTimer(); } private clearMarkAllConfirmTimer(): void { if (this.markAllConfirmTimer != null) { clearTimeout(this.markAllConfirmTimer); this.markAllConfirmTimer = null; } } protected formatTimestamp(value: string): string { const parsed = Date.parse(value); if (Number.isNaN(parsed)) { return value; } return new DatePipe('en-US').transform(parsed, 'MMM d, h:mm a') ?? value; } private scheduleInfiniteScrollSetup(): void { runInInjectionContext(this.injector, () => { afterNextRender(() => this.setupInfiniteScroll()); }); } private setupInfiniteScroll(): void { this.observer?.disconnect(); const root = this.scrollHost()?.nativeElement; const sentinel = this.loadSentinel()?.nativeElement; if (!root || !sentinel || !this.data.onLoadPage) { return; } this.observer = new IntersectionObserver( (entries) => { if (!entries.some((entry) => entry.isIntersecting)) { return; } this.loadNextPage(); }, { root, rootMargin: `0px 0px ${SCROLL_PREFETCH_PX}px 0px`, threshold: 0, }, ); this.observer.observe(sentinel); } private resolveHasMore( pagination: PaginationMetaModel | null, itemCount: number, ): boolean { if (itemCount === 0) { return false; } if (pagination == null) { return false; } if (pagination.has_next_page) { return true; } if (pagination.total_pages > 0) { return pagination.current_page < pagination.total_pages; } return false; } private loadNextPage(): void { if ( !this.data.onLoadPage || !this.hasMore() || this.loadingMore() || this.loadingInitial() ) { return; } this.fetchPage(this.currentPage() + 1, 'append'); } private fetchPage( page: number, mode: 'replace' | 'append' | 'refresh', ): void { const loadPage = this.data.onLoadPage; if (!loadPage) { return; } if (mode === 'replace') { this.loadingInitial.set(true); this.loadError.set(null); } else if (mode === 'refresh') { this.loadError.set(null); } else { this.loadingMore.set(true); } this.runPageLoad( () => loadPage(page), (result) => { if (mode === 'replace' || mode === 'refresh') { this.items.set(result.items ?? []); } else { this.appendItems(result.items ?? []); } this.currentPage.set(page); this.hasMore.set( this.resolveHasMore( result.pagination, (result.items ?? []).length, ), ); this.loadingInitial.set(false); this.loadingMore.set(false); }, () => { if (mode === 'replace') { this.loadError.set(this.i18n.t('africaniesUi.notifications.loadError', 'Could not load notifications. Try again.')); this.loadingInitial.set(false); } else if (mode === 'refresh' && this.items().length === 0) { this.loadError.set(this.i18n.t('africaniesUi.notifications.loadError', 'Could not load notifications. Try again.')); } this.loadingMore.set(false); }, ); } private appendItems(incoming: AfricaniesNotification[] | null | undefined): void { const rows = incoming ?? []; if (!rows.length) { return; } this.items.update((existing) => { const seen = new Set((existing ?? []).map((item) => item.id)); const next = rows.filter((item) => !seen.has(item.id)); return next.length ? [...existing, ...next] : existing; }); } private patchRead(id: string): void { this.items.update((rows) => (rows ?? []).map((row) => (row.id === id ? { ...row, read: true } : row)), ); } private runPageLoad( invoke: () => Observable | Promise, succeed: (result: NotificationPageResult) => void, fail: () => void, ): void { let pending: Observable | Promise; try { pending = invoke(); } catch { fail(); return; } if (isObservable(pending)) { pending.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe({ next: (result) => succeed(result), error: () => fail(), }); return; } void Promise.resolve(pending).then(succeed, fail); } private runHook( invoke: () => Observable | Promise | void | undefined, succeed: () => void, fail: () => void, ): void { let pending: Observable | Promise | void; try { pending = invoke(); } catch { fail(); return; } if (pending == null) { succeed(); return; } if (isObservable(pending)) { pending.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe({ next: () => succeed(), error: () => fail(), }); return; } void Promise.resolve(pending).then(succeed, fail); } }