@l.x/notifications 1.0.3 → 1.0.4

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 (47) hide show
  1. package/.depcheckrc +14 -0
  2. package/.eslintrc.js +20 -0
  3. package/README.md +548 -0
  4. package/package.json +42 -1
  5. package/project.json +24 -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 +10 -0
  10. package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
  11. package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +77 -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 +254 -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 +49 -0
  30. package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
  31. package/src/notification-service/implementations/createNotificationService.ts +368 -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 +34 -0
  43. package/tsconfig.lint.json +8 -0
  44. package/vitest-setup.ts +1 -0
  45. package/vitest.config.ts +14 -0
  46. package/index.d.ts +0 -1
  47. package/index.js +0 -1
@@ -0,0 +1,254 @@
1
+ import { ContentStyle, type InAppNotification, OnClickAction } from '@l.x/api'
2
+ import { createNotificationProcessor } from '@l.x/notifications/src/notification-processor/implementations/createNotificationProcessor'
3
+ import {
4
+ type NotificationProcessor,
5
+ type NotificationProcessorResult,
6
+ } from '@l.x/notifications/src/notification-processor/NotificationProcessor'
7
+ import { type NotificationTracker } from '@l.x/notifications/src/notification-tracker/NotificationTracker'
8
+ import { getLogger } from '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
+ // Required cards are exempt from the DISMISS check — they self-dismiss when their
48
+ // underlying data condition resolves (e.g. wallet receives funds).
49
+ const filteredPrimary = primaryNotifications.filter((notification) => {
50
+ if (processedIds.has(notification.id)) {
51
+ return false
52
+ }
53
+ if (!isRequiredCard(notification) && !hasDismissAction(notification)) {
54
+ getLogger().warn(
55
+ 'createBaseNotificationProcessor',
56
+ 'process',
57
+ `Filtering out notification ${notification.id} - no DISMISS action found in any click configuration`,
58
+ { notification },
59
+ )
60
+ return false
61
+ }
62
+ return true
63
+ })
64
+
65
+ const filteredChained = chainedNotifications.filter((notification) => {
66
+ if (processedIds.has(notification.id)) {
67
+ return false
68
+ }
69
+ if (!isRequiredCard(notification) && !hasDismissAction(notification)) {
70
+ getLogger().warn(
71
+ 'createBaseNotificationProcessor',
72
+ 'process',
73
+ `Filtering out notification ${notification.id} - no DISMISS action found in any click configuration`,
74
+ { notification },
75
+ )
76
+ return false
77
+ }
78
+ return true
79
+ })
80
+
81
+ // Step 4: Limit the number of primary notifications per content style
82
+ const limitedPrimary = limitNotifications(filteredPrimary)
83
+
84
+ // Step 5: Convert chained notifications to a Map for fast lookup
85
+ const chainedMap = new Map<string, InAppNotification>()
86
+ for (const notification of filteredChained) {
87
+ chainedMap.set(notification.id, notification)
88
+ }
89
+
90
+ return {
91
+ primary: limitedPrimary,
92
+ chained: chainedMap,
93
+ }
94
+ },
95
+ })
96
+ }
97
+
98
+ /**
99
+ * Returns true if the notification is a required card — one that self-dismisses when its
100
+ * underlying data condition resolves rather than via explicit user action.
101
+ */
102
+ function isRequiredCard(notification: InAppNotification): boolean {
103
+ try {
104
+ const extra = notification.content?.extra ? JSON.parse(notification.content.extra) : {}
105
+ return extra.cardType === 'required'
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Checks if a notification has at least one DISMISS action in any of its click configurations.
113
+ * Every notification must have a way to be dismissed.
114
+ */
115
+ function hasDismissAction(notification: InAppNotification): boolean {
116
+ // Check all buttons for DISMISS action
117
+ const buttons = notification.content?.buttons ?? []
118
+ for (const button of buttons) {
119
+ if (button.onClick?.onClick.includes(OnClickAction.DISMISS)) {
120
+ return true
121
+ }
122
+ }
123
+
124
+ // Check background click for DISMISS action
125
+ const backgroundOnClick = notification.content?.background?.backgroundOnClick
126
+ if (backgroundOnClick?.onClick.includes(OnClickAction.DISMISS)) {
127
+ return true
128
+ }
129
+
130
+ // Check onDismissClick for DISMISS action (close button)
131
+ const onDismissClick = notification.content?.onDismissClick
132
+ if (onDismissClick?.onClick.includes(OnClickAction.DISMISS)) {
133
+ return true
134
+ }
135
+
136
+ return false
137
+ }
138
+
139
+ /**
140
+ * Identifies root notifications and chained notifications using graph analysis.
141
+ *
142
+ * Builds a dependency graph where:
143
+ * - Nodes are notifications
144
+ * - Edges represent POPUP actions (A → B means A has a button/background that pops up B)
145
+ *
146
+ * Root notifications are those with no incoming edges (not referenced by any POPUP action).
147
+ * Chained notifications are those with at least one incoming edge.
148
+ *
149
+ * This properly handles chains of any length: A → B → C → D
150
+ * - A is a root (no incoming edges)
151
+ * - B, C, D are chained (have incoming edges)
152
+ *
153
+ * @returns Object with two sets: roots (primary notifications) and nonRoots (chained notifications)
154
+ */
155
+ function identifyRootsAndChained(notifications: InAppNotification[]): { roots: Set<string>; nonRoots: Set<string> } {
156
+ const allIds = new Set<string>(notifications.map((n) => n.id))
157
+ const hasIncomingEdge = new Set<string>()
158
+
159
+ for (const notification of notifications) {
160
+ const popupTargets = extractPopupTargets(notification)
161
+
162
+ for (const targetId of popupTargets) {
163
+ if (allIds.has(targetId)) {
164
+ hasIncomingEdge.add(targetId)
165
+ }
166
+ }
167
+ }
168
+
169
+ const roots = new Set<string>()
170
+ const nonRoots = new Set<string>()
171
+
172
+ for (const notification of notifications) {
173
+ if (hasIncomingEdge.has(notification.id)) {
174
+ nonRoots.add(notification.id)
175
+ } else {
176
+ roots.add(notification.id)
177
+ }
178
+ }
179
+
180
+ return { roots, nonRoots }
181
+ }
182
+
183
+ /**
184
+ * Extracts all notification IDs that are targeted by POPUP actions in this notification.
185
+ * Checks both button clicks and background clicks.
186
+ */
187
+ function extractPopupTargets(notification: InAppNotification): string[] {
188
+ const targets: string[] = []
189
+
190
+ const buttons = notification.content?.buttons ?? []
191
+ for (const button of buttons) {
192
+ const target = getPopupTarget(button.onClick)
193
+ if (target) {
194
+ targets.push(target)
195
+ }
196
+ }
197
+
198
+ const backgroundTarget = getPopupTarget(notification.content?.background?.backgroundOnClick)
199
+ if (backgroundTarget) {
200
+ targets.push(backgroundTarget)
201
+ }
202
+
203
+ return targets
204
+ }
205
+
206
+ /**
207
+ * Gets the target notification ID if this onClick action includes POPUP.
208
+ */
209
+ function getPopupTarget(onClick: { onClick: OnClickAction[]; onClickLink?: string } | undefined): string | undefined {
210
+ if (!onClick?.onClick.includes(OnClickAction.POPUP)) {
211
+ return undefined
212
+ }
213
+ return onClick.onClickLink
214
+ }
215
+
216
+ /**
217
+ * Limits the number of notifications per content style.
218
+ * - LOWER_LEFT_BANNER: up to 3 notifications
219
+ * - SYSTEM_BANNER: 1 notification (sticky system alerts)
220
+ * - All other styles: 1 notification each
221
+ */
222
+ function limitNotifications(notifications: InAppNotification[]): InAppNotification[] {
223
+ const groupedByStyle = new Map<number, InAppNotification[]>()
224
+
225
+ for (const notification of notifications) {
226
+ const style = notification.content?.style ?? ContentStyle.UNSPECIFIED
227
+ const group = groupedByStyle.get(style) ?? []
228
+ group.push(notification)
229
+ groupedByStyle.set(style, group)
230
+ }
231
+
232
+ const limited: InAppNotification[] = []
233
+
234
+ for (const [style, group] of groupedByStyle.entries()) {
235
+ const limit = getStyleLimit(style)
236
+ limited.push(...group.slice(0, limit))
237
+ }
238
+
239
+ return limited
240
+ }
241
+
242
+ /**
243
+ * Returns the maximum number of concurrent notifications for a given content style.
244
+ */
245
+ function getStyleLimit(style: number): number {
246
+ if (style === ContentStyle.LOWER_LEFT_BANNER) {
247
+ return 3
248
+ }
249
+ if (style === ContentStyle.SYSTEM_BANNER) {
250
+ return 1
251
+ }
252
+ // Default: 1 for MODAL, UNSPECIFIED, and any future styles
253
+ return 1
254
+ }
@@ -0,0 +1,130 @@
1
+ import type { InAppNotification } from '@l.x/api'
2
+ import { createNotificationProcessor } from '@l.x/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 '@l.x/api'
2
+ import {
3
+ type NotificationProcessor,
4
+ type NotificationProcessorResult,
5
+ } from '@l.x/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 '@l.x/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 'ui/src'
3
+ import { X } from 'ui/src/components/icons/X'
4
+ import { zIndexes } from '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
+ }