@luxexchange/notifications 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.depcheckrc +14 -0
  2. package/.eslintrc.js +20 -0
  3. package/README.md +548 -0
  4. package/package.json +42 -0
  5. package/project.json +30 -0
  6. package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
  7. package/src/global.d.ts +2 -0
  8. package/src/index.ts +41 -0
  9. package/src/notification-data-source/NotificationDataSource.ts +8 -0
  10. package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
  11. package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +73 -0
  12. package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
  13. package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
  14. package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
  15. package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
  16. package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
  17. package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
  18. package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
  19. package/src/notification-processor/NotificationProcessor.ts +26 -0
  20. package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
  21. package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +239 -0
  22. package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
  23. package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
  24. package/src/notification-renderer/NotificationRenderer.ts +8 -0
  25. package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
  26. package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
  27. package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
  28. package/src/notification-renderer/utils/iconUtils.ts +103 -0
  29. package/src/notification-service/NotificationService.ts +47 -0
  30. package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
  31. package/src/notification-service/implementations/createNotificationService.ts +364 -0
  32. package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
  33. package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
  34. package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
  35. package/src/notification-tracker/NotificationTracker.ts +14 -0
  36. package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
  37. package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
  38. package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
  39. package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
  40. package/src/utils/formatNotificationType.test.ts +25 -0
  41. package/src/utils/formatNotificationType.ts +25 -0
  42. package/tsconfig.json +24 -0
  43. package/tsconfig.lint.json +8 -0
  44. package/vitest-setup.ts +1 -0
  45. package/vitest.config.ts +14 -0
@@ -0,0 +1,239 @@
1
+ import { ContentStyle, type InAppNotification, OnClickAction } from '@luxexchange/api'
2
+ import { createNotificationProcessor } from '@luxexchange/notifications/src/notification-processor/implementations/createNotificationProcessor'
3
+ import {
4
+ type NotificationProcessor,
5
+ type NotificationProcessorResult,
6
+ } from '@luxexchange/notifications/src/notification-processor/NotificationProcessor'
7
+ import { type NotificationTracker } from '@luxexchange/notifications/src/notification-tracker/NotificationTracker'
8
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
9
+
10
+ /**
11
+ * Creates a base notification processor that implements style-based deduplication and limiting,
12
+ * as well as separating primary and chained notifications using topological sorting.
13
+ *
14
+ * Processing rules:
15
+ * 1. Builds a dependency graph of notifications based on POPUP actions
16
+ * 2. Uses topological sort to identify root notifications (those with no incoming edges)
17
+ * 3. Filters out notifications that have already been processed (tracked)
18
+ * 4. Limits the number of primary notifications per content style (LOWER_LEFT_BANNER: 3, others: 1)
19
+ * 5. Returns primary notifications for immediate rendering and chained notifications for later
20
+ *
21
+ * This properly handles notification chains of any length (A → B → C → D → ...).
22
+ *
23
+ * @param tracker - The NotificationTracker to check which notifications have been processed
24
+ * @returns A NotificationProcessor that applies these rules
25
+ */
26
+ export function createBaseNotificationProcessor(tracker: NotificationTracker): NotificationProcessor {
27
+ return createNotificationProcessor({
28
+ process: async (notifications: InAppNotification[]): Promise<NotificationProcessorResult> => {
29
+ const processedIds = await tracker.getProcessedIds()
30
+
31
+ // Step 1: Build dependency graph and identify roots using topological analysis
32
+ const { roots, nonRoots } = identifyRootsAndChained(notifications)
33
+
34
+ // Step 2: Separate primary (roots) and chained (non-roots) notifications
35
+ const primaryNotifications: InAppNotification[] = []
36
+ const chainedNotifications: InAppNotification[] = []
37
+
38
+ for (const notification of notifications) {
39
+ if (roots.has(notification.id)) {
40
+ primaryNotifications.push(notification)
41
+ } else if (nonRoots.has(notification.id)) {
42
+ chainedNotifications.push(notification)
43
+ }
44
+ }
45
+
46
+ // Step 3: Filter out notifications that are locally tracked or don't have DISMISS action
47
+ const filteredPrimary = primaryNotifications.filter((notification) => {
48
+ if (processedIds.has(notification.id)) {
49
+ return false
50
+ }
51
+ if (!hasDismissAction(notification)) {
52
+ getLogger().warn(
53
+ 'createBaseNotificationProcessor',
54
+ 'process',
55
+ `Filtering out notification ${notification.id} - no DISMISS action found in any click configuration`,
56
+ { notification },
57
+ )
58
+ return false
59
+ }
60
+ return true
61
+ })
62
+
63
+ const filteredChained = chainedNotifications.filter((notification) => {
64
+ if (processedIds.has(notification.id)) {
65
+ return false
66
+ }
67
+ if (!hasDismissAction(notification)) {
68
+ getLogger().warn(
69
+ 'createBaseNotificationProcessor',
70
+ 'process',
71
+ `Filtering out notification ${notification.id} - no DISMISS action found in any click configuration`,
72
+ { notification },
73
+ )
74
+ return false
75
+ }
76
+ return true
77
+ })
78
+
79
+ // Step 4: Limit the number of primary notifications per content style
80
+ const limitedPrimary = limitNotifications(filteredPrimary)
81
+
82
+ // Step 5: Convert chained notifications to a Map for fast lookup
83
+ const chainedMap = new Map<string, InAppNotification>()
84
+ for (const notification of filteredChained) {
85
+ chainedMap.set(notification.id, notification)
86
+ }
87
+
88
+ return {
89
+ primary: limitedPrimary,
90
+ chained: chainedMap,
91
+ }
92
+ },
93
+ })
94
+ }
95
+
96
+ /**
97
+ * Checks if a notification has at least one DISMISS action in any of its click configurations.
98
+ * Every notification must have a way to be dismissed.
99
+ */
100
+ function hasDismissAction(notification: InAppNotification): boolean {
101
+ // Check all buttons for DISMISS action
102
+ const buttons = notification.content?.buttons ?? []
103
+ for (const button of buttons) {
104
+ if (button.onClick?.onClick.includes(OnClickAction.DISMISS)) {
105
+ return true
106
+ }
107
+ }
108
+
109
+ // Check background click for DISMISS action
110
+ const backgroundOnClick = notification.content?.background?.backgroundOnClick
111
+ if (backgroundOnClick?.onClick.includes(OnClickAction.DISMISS)) {
112
+ return true
113
+ }
114
+
115
+ // Check onDismissClick for DISMISS action (close button)
116
+ const onDismissClick = notification.content?.onDismissClick
117
+ if (onDismissClick?.onClick.includes(OnClickAction.DISMISS)) {
118
+ return true
119
+ }
120
+
121
+ return false
122
+ }
123
+
124
+ /**
125
+ * Identifies root notifications and chained notifications using graph analysis.
126
+ *
127
+ * Builds a dependency graph where:
128
+ * - Nodes are notifications
129
+ * - Edges represent POPUP actions (A → B means A has a button/background that pops up B)
130
+ *
131
+ * Root notifications are those with no incoming edges (not referenced by any POPUP action).
132
+ * Chained notifications are those with at least one incoming edge.
133
+ *
134
+ * This properly handles chains of any length: A → B → C → D
135
+ * - A is a root (no incoming edges)
136
+ * - B, C, D are chained (have incoming edges)
137
+ *
138
+ * @returns Object with two sets: roots (primary notifications) and nonRoots (chained notifications)
139
+ */
140
+ function identifyRootsAndChained(notifications: InAppNotification[]): { roots: Set<string>; nonRoots: Set<string> } {
141
+ const allIds = new Set<string>(notifications.map((n) => n.id))
142
+ const hasIncomingEdge = new Set<string>()
143
+
144
+ for (const notification of notifications) {
145
+ const popupTargets = extractPopupTargets(notification)
146
+
147
+ for (const targetId of popupTargets) {
148
+ if (allIds.has(targetId)) {
149
+ hasIncomingEdge.add(targetId)
150
+ }
151
+ }
152
+ }
153
+
154
+ const roots = new Set<string>()
155
+ const nonRoots = new Set<string>()
156
+
157
+ for (const notification of notifications) {
158
+ if (hasIncomingEdge.has(notification.id)) {
159
+ nonRoots.add(notification.id)
160
+ } else {
161
+ roots.add(notification.id)
162
+ }
163
+ }
164
+
165
+ return { roots, nonRoots }
166
+ }
167
+
168
+ /**
169
+ * Extracts all notification IDs that are targeted by POPUP actions in this notification.
170
+ * Checks both button clicks and background clicks.
171
+ */
172
+ function extractPopupTargets(notification: InAppNotification): string[] {
173
+ const targets: string[] = []
174
+
175
+ const buttons = notification.content?.buttons ?? []
176
+ for (const button of buttons) {
177
+ const target = getPopupTarget(button.onClick)
178
+ if (target) {
179
+ targets.push(target)
180
+ }
181
+ }
182
+
183
+ const backgroundTarget = getPopupTarget(notification.content?.background?.backgroundOnClick)
184
+ if (backgroundTarget) {
185
+ targets.push(backgroundTarget)
186
+ }
187
+
188
+ return targets
189
+ }
190
+
191
+ /**
192
+ * Gets the target notification ID if this onClick action includes POPUP.
193
+ */
194
+ function getPopupTarget(onClick: { onClick: OnClickAction[]; onClickLink?: string } | undefined): string | undefined {
195
+ if (!onClick?.onClick.includes(OnClickAction.POPUP)) {
196
+ return undefined
197
+ }
198
+ return onClick.onClickLink
199
+ }
200
+
201
+ /**
202
+ * Limits the number of notifications per content style.
203
+ * - LOWER_LEFT_BANNER: up to 3 notifications
204
+ * - SYSTEM_BANNER: 1 notification (sticky system alerts)
205
+ * - All other styles: 1 notification each
206
+ */
207
+ function limitNotifications(notifications: InAppNotification[]): InAppNotification[] {
208
+ const groupedByStyle = new Map<number, InAppNotification[]>()
209
+
210
+ for (const notification of notifications) {
211
+ const style = notification.content?.style ?? ContentStyle.UNSPECIFIED
212
+ const group = groupedByStyle.get(style) ?? []
213
+ group.push(notification)
214
+ groupedByStyle.set(style, group)
215
+ }
216
+
217
+ const limited: InAppNotification[] = []
218
+
219
+ for (const [style, group] of groupedByStyle.entries()) {
220
+ const limit = getStyleLimit(style)
221
+ limited.push(...group.slice(0, limit))
222
+ }
223
+
224
+ return limited
225
+ }
226
+
227
+ /**
228
+ * Returns the maximum number of concurrent notifications for a given content style.
229
+ */
230
+ function getStyleLimit(style: number): number {
231
+ if (style === ContentStyle.LOWER_LEFT_BANNER) {
232
+ return 3
233
+ }
234
+ if (style === ContentStyle.SYSTEM_BANNER) {
235
+ return 1
236
+ }
237
+ // Default: 1 for MODAL, UNSPECIFIED, and any future styles
238
+ return 1
239
+ }
@@ -0,0 +1,130 @@
1
+ import type { InAppNotification } from '@luxfi/api'
2
+ import { createNotificationProcessor } from '@luxfi/notifications/src/notification-processor/implementations/createNotificationProcessor'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ describe('createNotificationProcessor', () => {
6
+ it('creates a notification processor with process method', () => {
7
+ const mockProcess = vi.fn()
8
+ const processor = createNotificationProcessor({
9
+ process: mockProcess,
10
+ })
11
+
12
+ expect(processor).toBeDefined()
13
+ expect(typeof processor.process).toBe('function')
14
+ })
15
+
16
+ it('delegates process call to injected process function', async () => {
17
+ const mockNotifications: InAppNotification[] = [
18
+ {
19
+ id: 'test-notif-1-id',
20
+ metaData: {},
21
+ notificationName: 'test-notif-1',
22
+ timestamp: 1000,
23
+ content: { style: 'CONTENT_STYLE_MODAL', title: 'test-notif-1-title' },
24
+ userId: 'user-1',
25
+ } as InAppNotification,
26
+ ]
27
+ const mockResult: InAppNotification[] = [
28
+ {
29
+ id: 'result-notif-id',
30
+ metaData: {},
31
+ notificationName: 'result-notif',
32
+ timestamp: 2000,
33
+ content: { style: 'CONTENT_STYLE_MODAL', title: 'result-notif-title' },
34
+ userId: 'user-1',
35
+ } as InAppNotification,
36
+ ]
37
+
38
+ const mockProcess = vi.fn().mockResolvedValue(mockResult)
39
+ const processor = createNotificationProcessor({
40
+ process: mockProcess,
41
+ })
42
+
43
+ const result = await processor.process(mockNotifications)
44
+
45
+ expect(mockProcess).toHaveBeenCalledWith(mockNotifications)
46
+ expect(result).toBe(mockResult)
47
+ })
48
+
49
+ it('preserves the exact arguments passed to process method', async () => {
50
+ const notifications: InAppNotification[] = [
51
+ {
52
+ id: 'notif-1-id',
53
+ metaData: {},
54
+ notificationName: 'notif-1',
55
+ timestamp: 1000,
56
+ content: { style: 'CONTENT_STYLE_MODAL', title: 'notif-1-title' },
57
+ userId: 'user-1',
58
+ } as InAppNotification,
59
+ {
60
+ id: 'notif-2-id',
61
+ metaData: {},
62
+ notificationName: 'notif-2',
63
+ timestamp: 2000,
64
+ content: { style: 'CONTENT_STYLE_BANNER', title: 'notif-2-title' },
65
+ userId: 'user-1',
66
+ } as InAppNotification,
67
+ ]
68
+
69
+ let capturedNotifications: InAppNotification[] | undefined
70
+
71
+ const mockProcess = vi.fn(async (notifs) => {
72
+ capturedNotifications = notifs
73
+ return []
74
+ })
75
+
76
+ const processor = createNotificationProcessor({
77
+ process: mockProcess,
78
+ })
79
+
80
+ await processor.process(notifications)
81
+
82
+ expect(capturedNotifications).toBe(notifications)
83
+ })
84
+
85
+ it('returns empty array when injected process returns empty array', async () => {
86
+ const mockProcess = vi.fn().mockResolvedValue([])
87
+ const processor = createNotificationProcessor({
88
+ process: mockProcess,
89
+ })
90
+
91
+ const result = await processor.process([])
92
+
93
+ expect(result).toEqual([])
94
+ })
95
+
96
+ it('handles multiple calls with different arguments', async () => {
97
+ const mockProcess = vi.fn(async (notifications) => notifications)
98
+ const processor = createNotificationProcessor({
99
+ process: mockProcess,
100
+ })
101
+
102
+ const notifs1: InAppNotification[] = [
103
+ {
104
+ id: 'notif-1-id',
105
+ metaData: {},
106
+ notificationName: 'notif-1',
107
+ timestamp: 1000,
108
+ content: { style: 'CONTENT_STYLE_MODAL', title: 'notif-1-title' },
109
+ userId: 'user-1',
110
+ } as InAppNotification,
111
+ ]
112
+ const notifs2: InAppNotification[] = [
113
+ {
114
+ id: 'notif-2-id',
115
+ metaData: {},
116
+ notificationName: 'notif-2',
117
+ timestamp: 2000,
118
+ content: { style: 'CONTENT_STYLE_BANNER', title: 'notif-2-title' },
119
+ userId: 'user-1',
120
+ } as InAppNotification,
121
+ ]
122
+
123
+ const result1 = await processor.process(notifs1)
124
+ const result2 = await processor.process(notifs2)
125
+
126
+ expect(mockProcess).toHaveBeenCalledTimes(2)
127
+ expect(result1).toEqual(notifs1)
128
+ expect(result2).toEqual(notifs2)
129
+ })
130
+ })
@@ -0,0 +1,15 @@
1
+ import { type InAppNotification } from '@luxfi/api'
2
+ import {
3
+ type NotificationProcessor,
4
+ type NotificationProcessorResult,
5
+ } from '@luxfi/notifications/src/notification-processor/NotificationProcessor'
6
+
7
+ export function createNotificationProcessor(ctx: {
8
+ process: (notifications: InAppNotification[]) => Promise<NotificationProcessorResult>
9
+ }): NotificationProcessor {
10
+ return {
11
+ process: async (notifications: InAppNotification[]): Promise<NotificationProcessorResult> => {
12
+ return ctx.process(notifications)
13
+ },
14
+ }
15
+ }
@@ -0,0 +1,8 @@
1
+ import { type InAppNotification } from '@luxfi/api'
2
+
3
+ export interface NotificationRenderer {
4
+ // Render a notification and return cleanup function
5
+ render(notification: InAppNotification): () => void
6
+ // Check if a notification type can be rendered (e.g., only one modal at a time)
7
+ canRender(notification: InAppNotification): boolean
8
+ }
@@ -0,0 +1,188 @@
1
+ import { ReactNode } from 'react'
2
+ import { Button, Flex, IconButton, styled, Text, useIsDarkMode } from '@luxfi/ui/src'
3
+ import { X } from '@luxfi/ui/src/components/icons/X'
4
+ import { zIndexes } from '@luxfi/ui/src/theme'
5
+
6
+ const BANNER_WIDTH = 260
7
+ const BANNER_HEIGHT = 150
8
+ const GRADIENT_BACKGROUND_HEIGHT = 64 // Vertical midpoint of the thumbnail
9
+ const ICON_SIZE = 40
10
+
11
+ const BannerContainer = styled(Flex, {
12
+ borderRadius: '$rounded16',
13
+ minHeight: BANNER_HEIGHT,
14
+ shadowColor: '$shadowColor',
15
+ shadowOffset: { width: 0, height: 4 },
16
+ shadowOpacity: 0.4,
17
+ shadowRadius: 10,
18
+ overflow: 'hidden',
19
+ padding: '$spacing16',
20
+ backgroundColor: '$surface1',
21
+ borderWidth: 1,
22
+ borderColor: '$surface3',
23
+ variants: {
24
+ clickable: {
25
+ true: {
26
+ cursor: 'pointer',
27
+ },
28
+ false: {
29
+ cursor: 'default',
30
+ },
31
+ },
32
+ } as const,
33
+ })
34
+
35
+ const GradientBackground = styled(Flex, {
36
+ position: 'absolute',
37
+ top: 0,
38
+ left: 0,
39
+ right: 0,
40
+ width: '100%',
41
+ height: GRADIENT_BACKGROUND_HEIGHT,
42
+ backgroundSize: 'cover',
43
+ backgroundPosition: 'center',
44
+ backgroundRepeat: 'no-repeat',
45
+ mask: 'linear-gradient(180deg, rgba(0,0,0,0.48) 0%, rgba(0,0,0,0) 100%)',
46
+ })
47
+
48
+ const IconContainer = styled(Flex, {
49
+ width: ICON_SIZE,
50
+ height: ICON_SIZE,
51
+ backgroundSize: 'cover',
52
+ backgroundPosition: 'center',
53
+ backgroundRepeat: 'no-repeat',
54
+ borderRadius: '$rounded6',
55
+ })
56
+
57
+ const ContentWrapper = styled(Flex, {
58
+ flex: 1,
59
+ justifyContent: 'space-between',
60
+ paddingTop: 16,
61
+ })
62
+
63
+ function BannerXButton({ handleClose }: { handleClose: () => void }): JSX.Element {
64
+ return (
65
+ <Flex row centered position="absolute" right={8} top={8} zIndex={zIndexes.mask}>
66
+ <IconButton
67
+ size="xxsmall"
68
+ emphasis="secondary"
69
+ icon={<X />}
70
+ onPress={(e) => {
71
+ e.stopPropagation()
72
+ handleClose()
73
+ }}
74
+ />
75
+ </Flex>
76
+ )
77
+ }
78
+
79
+ export interface BannerTemplateButton {
80
+ text: string
81
+ onPress: () => void
82
+ isPrimary?: boolean
83
+ }
84
+
85
+ interface BannerTemplateProps {
86
+ backgroundImageUrl?: string
87
+ /** Optional dark mode variant for backgroundImageUrl. Falls back to backgroundImageUrl if not provided. */
88
+ darkModeBackgroundImageUrl?: string
89
+ /** Pre-rendered icon element. When provided, takes priority over iconUrl. */
90
+ icon?: ReactNode
91
+ iconUrl?: string
92
+ /** Optional dark mode variant for iconUrl. Falls back to iconUrl if not provided. */
93
+ darkModeIconUrl?: string
94
+ title: string
95
+ subtitle?: string
96
+ onClose: () => void
97
+ onPress?: () => void
98
+ children?: ReactNode
99
+ /** Override the default banner width. Use '100%' for full-width. */
100
+ width?: number | string
101
+ /** Optional button to display below the content */
102
+ button?: BannerTemplateButton
103
+ }
104
+
105
+ /**
106
+ * BannerTemplate component
107
+ *
108
+ * A reusable template for rendering lower-banner notifications.
109
+ * Extracted from SolanaPromoBanner to be shared across the notification system.
110
+ *
111
+ * Features:
112
+ * - Fixed position in lower-left corner
113
+ * - Optional background image with gradient overlay
114
+ * - Optional icon
115
+ * - Title and subtitle text (or custom children)
116
+ * - Dismiss button
117
+ * - Click handler support
118
+ */
119
+ export function BannerTemplate({
120
+ backgroundImageUrl,
121
+ darkModeBackgroundImageUrl,
122
+ icon,
123
+ iconUrl,
124
+ darkModeIconUrl,
125
+ title,
126
+ subtitle,
127
+ onClose,
128
+ onPress,
129
+ children,
130
+ width,
131
+ button,
132
+ }: BannerTemplateProps): JSX.Element {
133
+ const isDarkMode = useIsDarkMode()
134
+ const effectiveBackgroundUrl =
135
+ isDarkMode && darkModeBackgroundImageUrl ? darkModeBackgroundImageUrl : backgroundImageUrl
136
+ const effectiveIconUrl = isDarkMode && darkModeIconUrl ? darkModeIconUrl : iconUrl
137
+
138
+ return (
139
+ <BannerContainer pointerEvents="auto" width={width ?? BANNER_WIDTH} clickable={!!onPress} onPress={onPress}>
140
+ <BannerXButton handleClose={onClose} />
141
+
142
+ {effectiveBackgroundUrl && <GradientBackground backgroundImage={`url(${effectiveBackgroundUrl})`} />}
143
+
144
+ <ContentWrapper>
145
+ <Flex gap="$spacing8">
146
+ {icon ? (
147
+ <Flex centered width={ICON_SIZE} height={ICON_SIZE}>
148
+ {icon}
149
+ </Flex>
150
+ ) : effectiveIconUrl ? (
151
+ <IconContainer backgroundImage={`url(${effectiveIconUrl})`} />
152
+ ) : (
153
+ <Flex width={ICON_SIZE} height={ICON_SIZE} backgroundColor="transparent" />
154
+ )}
155
+
156
+ {children || (
157
+ <Flex gap="$spacing4">
158
+ <Text variant="body3" color="$neutral1">
159
+ {title}
160
+ </Text>
161
+ {subtitle && (
162
+ <Text variant="body4" color="$neutral2">
163
+ {subtitle}
164
+ </Text>
165
+ )}
166
+ </Flex>
167
+ )}
168
+ </Flex>
169
+
170
+ {button && (
171
+ <Flex marginTop="$spacing12">
172
+ <Button
173
+ size="medium"
174
+ emphasis={button.isPrimary ? 'primary' : 'secondary'}
175
+ minHeight="$spacing36"
176
+ onPress={(e) => {
177
+ e.stopPropagation()
178
+ button.onPress()
179
+ }}
180
+ >
181
+ {button.text}
182
+ </Button>
183
+ </Flex>
184
+ )}
185
+ </ContentWrapper>
186
+ </BannerContainer>
187
+ )
188
+ }
@@ -0,0 +1,123 @@
1
+ import { BackgroundType } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb'
2
+ import type { InAppNotification } from '@luxexchange/api'
3
+ import {
4
+ BannerTemplate,
5
+ type BannerTemplateButton,
6
+ } from '@luxexchange/notifications/src/notification-renderer/components/BannerTemplate'
7
+ import { parseCustomIconLink } from '@luxexchange/notifications/src/notification-renderer/utils/iconUtils'
8
+ import { type NotificationClickTarget } from '@luxexchange/notifications/src/notification-service/NotificationService'
9
+ import { memo, useMemo } from 'react'
10
+ import { useTranslation } from 'react-i18next'
11
+
12
+ const BANNER_ICON_SIZE = 40
13
+
14
+ interface InlineBannerNotificationProps {
15
+ notification: InAppNotification
16
+ onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void
17
+ /** Override the default banner width. Use '100%' for full-width. */
18
+ width?: number | string
19
+ /** Enable button rendering. Defaults to false (web only). */
20
+ renderButton?: boolean
21
+ }
22
+
23
+ /**
24
+ * InlineBannerNotification component
25
+ *
26
+ * A wrapper around BannerTemplate for rendering notification API-driven banners.
27
+ * Can be used inline (extension/mobile) or in fixed position (web).
28
+ * Delegates click handling to the NotificationService.
29
+ *
30
+ * Features:
31
+ * - Maps notification API types to BannerTemplate props
32
+ * - Delegates click actions to NotificationService via onNotificationClick
33
+ * - Extracts background images and icons from notification content
34
+ *
35
+ * Notification API Type Mapping:
36
+ * - content.title → Banner title
37
+ * - content.subtitle → Banner description
38
+ * - content.background.link → Background image URL (when backgroundType is IMAGE)
39
+ * - content.background.backgroundOnClick → Handled by NotificationService
40
+ */
41
+ export const InlineBannerNotification = memo(function InlineBannerNotification({
42
+ notification,
43
+ onNotificationClick,
44
+ width,
45
+ renderButton = false,
46
+ }: InlineBannerNotificationProps) {
47
+ const { t, i18n } = useTranslation()
48
+ const content = notification.content
49
+
50
+ const handleClose = (): void => {
51
+ onNotificationClick?.(notification.id, { type: 'dismiss' })
52
+ }
53
+
54
+ const hasBackgroundClick = !!content?.background?.backgroundOnClick
55
+
56
+ const handleBannerPress = (): void => {
57
+ onNotificationClick?.(notification.id, { type: 'background' })
58
+ }
59
+
60
+ const backgroundImageUrl = useMemo(() => {
61
+ const background = content?.background
62
+ if (background && background.backgroundType === BackgroundType.IMAGE && background.link) {
63
+ return background.link
64
+ }
65
+ return undefined
66
+ }, [content?.background])
67
+
68
+ const darkModeBackgroundImageUrl = useMemo(() => {
69
+ const background = content?.background
70
+ if (background && background.backgroundType === BackgroundType.IMAGE && background.darkModeLink) {
71
+ return background.darkModeLink
72
+ }
73
+ return undefined
74
+ }, [content?.background])
75
+
76
+ const customIcon = useMemo(() => {
77
+ const parsed = parseCustomIconLink(content?.iconLink)
78
+ if (!parsed.IconComponent) {
79
+ return undefined
80
+ }
81
+ const Icon = parsed.IconComponent
82
+ return <Icon size={BANNER_ICON_SIZE} color={parsed.colorToken ?? '$neutral1'} />
83
+ }, [content?.iconLink])
84
+
85
+ const button = useMemo((): BannerTemplateButton | undefined => {
86
+ if (!renderButton) {
87
+ return undefined
88
+ }
89
+
90
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
91
+ const firstButton = content?.buttons?.[0]
92
+ if (!firstButton) {
93
+ return undefined
94
+ }
95
+
96
+ // Translate known i18n keys, otherwise use raw text
97
+ const buttonText = i18n.exists(firstButton.text) ? t(firstButton.text) : firstButton.text
98
+
99
+ return {
100
+ text: buttonText,
101
+ isPrimary: firstButton.isPrimary ?? false,
102
+ onPress: (): void => {
103
+ onNotificationClick?.(notification.id, { type: 'button', index: 0 })
104
+ },
105
+ }
106
+ }, [renderButton, content?.buttons, notification.id, onNotificationClick, t, i18n])
107
+
108
+ return (
109
+ <BannerTemplate
110
+ backgroundImageUrl={backgroundImageUrl}
111
+ darkModeBackgroundImageUrl={darkModeBackgroundImageUrl}
112
+ icon={customIcon}
113
+ iconUrl={customIcon ? undefined : content?.iconLink}
114
+ darkModeIconUrl={customIcon ? undefined : content?.darkModeIconLink}
115
+ title={content?.title ?? ''}
116
+ subtitle={content?.subtitle ?? ''}
117
+ width={width}
118
+ button={button}
119
+ onClose={handleClose}
120
+ onPress={hasBackgroundClick ? handleBannerPress : undefined}
121
+ />
122
+ )
123
+ })