@open-mercato/ui 0.6.2-develop.3372.1.de9a169973 → 0.6.2-develop.3380.1.bb14509041
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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/ai/AiIcon.js +1 -1
- package/dist/ai/AiIcon.js.map +2 -2
- package/dist/backend/AppShell.js +223 -153
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/ProfileDropdown.js +101 -79
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/notifications/NotificationCountBadge.js +1 -1
- package/dist/backend/notifications/NotificationCountBadge.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +67 -50
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/backend/notifications/NotificationPanel.js +108 -85
- package/dist/backend/notifications/NotificationPanel.js.map +2 -2
- package/dist/primitives/breadcrumb.js +161 -0
- package/dist/primitives/breadcrumb.js.map +7 -0
- package/dist/primitives/sheet.js +114 -0
- package/dist/primitives/sheet.js.map +7 -0
- package/package.json +3 -3
- package/src/ai/AiIcon.tsx +1 -1
- package/src/backend/AppShell.tsx +151 -49
- package/src/backend/ProfileDropdown.tsx +115 -88
- package/src/backend/__tests__/AppShell.test.tsx +8 -0
- package/src/backend/notifications/NotificationCountBadge.tsx +1 -1
- package/src/backend/notifications/NotificationItem.tsx +97 -104
- package/src/backend/notifications/NotificationPanel.tsx +126 -102
- package/src/primitives/__tests__/breadcrumb.test.tsx +298 -0
- package/src/primitives/__tests__/sheet.test.tsx +152 -0
- package/src/primitives/breadcrumb.tsx +185 -0
- package/src/primitives/sheet.tsx +165 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import Link from 'next/link'
|
|
4
|
-
import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check } from 'lucide-react'
|
|
4
|
+
import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'
|
|
5
5
|
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
7
7
|
import { useTheme } from '@open-mercato/ui/theme'
|
|
8
|
-
import {
|
|
8
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
9
9
|
import { IconButton } from '../primitives/icon-button'
|
|
10
|
+
import { Switch } from '../primitives/switch'
|
|
10
11
|
import { useInjectedMenuItems } from './injection/useInjectedMenuItems'
|
|
11
12
|
import { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'
|
|
12
13
|
import { resolveInjectedIcon } from './injection/resolveInjectedIcon'
|
|
@@ -99,8 +100,14 @@ export function ProfileDropdown({
|
|
|
99
100
|
} catch {}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
|
|
103
|
-
|
|
103
|
+
// Unified row class — every menu item uses this for perfectly aligned layout.
|
|
104
|
+
// h-9 keeps all rows the same height regardless of whether they have a trailing element.
|
|
105
|
+
const menuItemClass = cn(
|
|
106
|
+
'group flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground',
|
|
107
|
+
'transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',
|
|
108
|
+
'cursor-pointer',
|
|
109
|
+
)
|
|
110
|
+
const menuIconClass = 'size-4 shrink-0 text-muted-foreground group-hover:text-foreground'
|
|
104
111
|
|
|
105
112
|
const resolveMenuLabel = React.useCallback(
|
|
106
113
|
(item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {
|
|
@@ -139,6 +146,12 @@ export function ProfileDropdown({
|
|
|
139
146
|
(item: MergedMenuItem) => {
|
|
140
147
|
const label = resolveMenuLabel(item)
|
|
141
148
|
const icon = resolveInjectedIcon(item.icon)
|
|
149
|
+
const inner = (
|
|
150
|
+
<>
|
|
151
|
+
<span className={menuIconClass}>{icon}</span>
|
|
152
|
+
<span className="flex-1 truncate">{label}</span>
|
|
153
|
+
</>
|
|
154
|
+
)
|
|
142
155
|
if (item.href) {
|
|
143
156
|
return (
|
|
144
157
|
<Link
|
|
@@ -149,18 +162,15 @@ export function ProfileDropdown({
|
|
|
149
162
|
data-menu-item-id={item.id}
|
|
150
163
|
onClick={() => setOpen(false)}
|
|
151
164
|
>
|
|
152
|
-
{
|
|
153
|
-
<span>{label}</span>
|
|
165
|
+
{inner}
|
|
154
166
|
</Link>
|
|
155
167
|
)
|
|
156
168
|
}
|
|
157
169
|
return (
|
|
158
|
-
<
|
|
170
|
+
<button
|
|
159
171
|
key={item.id}
|
|
160
172
|
type="button"
|
|
161
|
-
|
|
162
|
-
size="sm"
|
|
163
|
-
className="w-full justify-start"
|
|
173
|
+
className={menuItemClass}
|
|
164
174
|
role="menuitem"
|
|
165
175
|
data-menu-item-id={item.id}
|
|
166
176
|
onClick={() => {
|
|
@@ -168,12 +178,11 @@ export function ProfileDropdown({
|
|
|
168
178
|
setOpen(false)
|
|
169
179
|
}}
|
|
170
180
|
>
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
</Button>
|
|
181
|
+
{inner}
|
|
182
|
+
</button>
|
|
174
183
|
)
|
|
175
184
|
},
|
|
176
|
-
[menuItemClass, resolveMenuLabel],
|
|
185
|
+
[menuItemClass, menuIconClass, resolveMenuLabel],
|
|
177
186
|
)
|
|
178
187
|
|
|
179
188
|
const renderBuiltInItem = React.useCallback(
|
|
@@ -187,8 +196,8 @@ export function ProfileDropdown({
|
|
|
187
196
|
role="menuitem"
|
|
188
197
|
onClick={() => setOpen(false)}
|
|
189
198
|
>
|
|
190
|
-
<Key className=
|
|
191
|
-
<span>{t('ui.profileMenu.changePassword', 'Change Password')}</span>
|
|
199
|
+
<Key className={menuIconClass} />
|
|
200
|
+
<span className="flex-1 truncate">{t('ui.profileMenu.changePassword', 'Change Password')}</span>
|
|
192
201
|
</Link>
|
|
193
202
|
)
|
|
194
203
|
}
|
|
@@ -202,68 +211,78 @@ export function ProfileDropdown({
|
|
|
202
211
|
role="menuitem"
|
|
203
212
|
onClick={() => setOpen(false)}
|
|
204
213
|
>
|
|
205
|
-
<Bell className=
|
|
206
|
-
<span>{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
|
|
214
|
+
<Bell className={menuIconClass} />
|
|
215
|
+
<span className="flex-1 truncate">{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>
|
|
207
216
|
</Link>
|
|
208
217
|
)
|
|
209
218
|
}
|
|
210
219
|
|
|
211
220
|
if (id === 'theme-toggle') {
|
|
212
|
-
|
|
213
|
-
|
|
221
|
+
if (!mounted) return null
|
|
222
|
+
return (
|
|
223
|
+
<div
|
|
214
224
|
key={id}
|
|
215
|
-
|
|
216
|
-
variant="ghost"
|
|
217
|
-
size="sm"
|
|
218
|
-
className="w-full justify-between"
|
|
225
|
+
className={cn(menuItemClass, 'justify-between')}
|
|
219
226
|
role="menuitem"
|
|
220
227
|
onClick={handleThemeToggle}
|
|
228
|
+
onKeyDown={(e) => {
|
|
229
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
230
|
+
e.preventDefault()
|
|
231
|
+
handleThemeToggle()
|
|
232
|
+
}
|
|
233
|
+
}}
|
|
234
|
+
tabIndex={0}
|
|
221
235
|
>
|
|
222
|
-
<span className="
|
|
223
|
-
{isDark ? <Moon className=
|
|
224
|
-
<span>{t('ui.profileMenu.theme', '
|
|
236
|
+
<span className="flex flex-1 items-center gap-3">
|
|
237
|
+
{isDark ? <Moon className={menuIconClass} /> : <Sun className={menuIconClass} />}
|
|
238
|
+
<span className="flex-1 truncate">{t('ui.profileMenu.theme', 'Theme')}</span>
|
|
225
239
|
</span>
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
<Switch
|
|
241
|
+
checked={isDark}
|
|
242
|
+
onCheckedChange={handleThemeToggle}
|
|
243
|
+
aria-label={t('ui.profileMenu.theme', 'Theme')}
|
|
244
|
+
onClick={(e) => e.stopPropagation()}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
231
248
|
}
|
|
232
249
|
|
|
233
250
|
if (id === 'language') {
|
|
234
251
|
return (
|
|
235
|
-
<div key={id} className="
|
|
236
|
-
<
|
|
252
|
+
<div key={id} className="contents">
|
|
253
|
+
<button
|
|
237
254
|
type="button"
|
|
238
|
-
|
|
239
|
-
size="sm"
|
|
240
|
-
className="w-full justify-between"
|
|
255
|
+
className={menuItemClass}
|
|
241
256
|
role="menuitem"
|
|
242
257
|
onClick={() => setLanguageOpen(!languageOpen)}
|
|
243
258
|
aria-expanded={languageOpen}
|
|
244
259
|
>
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
260
|
+
<Globe className={menuIconClass} />
|
|
261
|
+
<span className="truncate">{t('ui.profileMenu.language', 'Language')}</span>
|
|
262
|
+
<span className="text-xs text-muted-foreground">{localeLabels[currentLocale]}</span>
|
|
263
|
+
<ChevronRight
|
|
264
|
+
className={cn(
|
|
265
|
+
'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform',
|
|
266
|
+
languageOpen && 'rotate-90',
|
|
267
|
+
)}
|
|
268
|
+
aria-hidden="true"
|
|
269
|
+
/>
|
|
270
|
+
</button>
|
|
253
271
|
{languageOpen && (
|
|
254
|
-
<div className="
|
|
272
|
+
<div className="ml-7 mr-1 flex flex-col gap-0.5 border-l pl-2 py-1">
|
|
255
273
|
{locales.map((locale) => (
|
|
256
|
-
<
|
|
274
|
+
<button
|
|
257
275
|
key={locale}
|
|
258
276
|
type="button"
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
277
|
+
className={cn(
|
|
278
|
+
'flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-sm transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',
|
|
279
|
+
locale === currentLocale ? 'font-medium text-foreground' : 'text-muted-foreground',
|
|
280
|
+
)}
|
|
262
281
|
onClick={() => handleLocaleChange(locale)}
|
|
263
282
|
>
|
|
264
|
-
<span>{localeLabels[locale]}</span>
|
|
265
|
-
{locale === currentLocale && <Check className="size-3.5" />}
|
|
266
|
-
</
|
|
283
|
+
<span className="truncate">{localeLabels[locale]}</span>
|
|
284
|
+
{locale === currentLocale && <Check className="size-3.5 shrink-0 text-accent-indigo" aria-hidden="true" />}
|
|
285
|
+
</button>
|
|
267
286
|
))}
|
|
268
287
|
</div>
|
|
269
288
|
)}
|
|
@@ -274,16 +293,14 @@ export function ProfileDropdown({
|
|
|
274
293
|
if (id === 'sign-out') {
|
|
275
294
|
return (
|
|
276
295
|
<form key={id} action="/api/auth/logout" method="POST">
|
|
277
|
-
<
|
|
278
|
-
variant="ghost"
|
|
279
|
-
size="sm"
|
|
280
|
-
className="w-full justify-start"
|
|
296
|
+
<button
|
|
281
297
|
type="submit"
|
|
298
|
+
className={menuItemClass}
|
|
282
299
|
role="menuitem"
|
|
283
300
|
>
|
|
284
|
-
<LogOut className=
|
|
285
|
-
<span>{t('ui.userMenu.logout', 'Sign Out')}</span>
|
|
286
|
-
</
|
|
301
|
+
<LogOut className={menuIconClass} />
|
|
302
|
+
<span className="flex-1 truncate text-left">{t('ui.userMenu.logout', 'Sign Out')}</span>
|
|
303
|
+
</button>
|
|
287
304
|
</form>
|
|
288
305
|
)
|
|
289
306
|
}
|
|
@@ -297,6 +314,7 @@ export function ProfileDropdown({
|
|
|
297
314
|
isDark,
|
|
298
315
|
languageOpen,
|
|
299
316
|
menuItemClass,
|
|
317
|
+
menuIconClass,
|
|
300
318
|
mounted,
|
|
301
319
|
notificationsHref,
|
|
302
320
|
t,
|
|
@@ -321,42 +339,51 @@ export function ProfileDropdown({
|
|
|
321
339
|
{open && (
|
|
322
340
|
<div
|
|
323
341
|
ref={menuRef}
|
|
324
|
-
className="absolute right-0 top-full mt-
|
|
342
|
+
className="absolute right-0 top-full z-popover mt-2 w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg"
|
|
325
343
|
role="menu"
|
|
326
344
|
data-testid="profile-dropdown"
|
|
327
345
|
>
|
|
328
346
|
{/* User info header */}
|
|
329
347
|
{(displayName || email) && (
|
|
330
|
-
<div className="
|
|
331
|
-
|
|
332
|
-
<
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
348
|
+
<div className="flex items-center gap-3 border-b bg-muted/30 px-3 py-3">
|
|
349
|
+
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo">
|
|
350
|
+
<User className="size-4" aria-hidden="true" />
|
|
351
|
+
</div>
|
|
352
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
353
|
+
{displayName ? (
|
|
354
|
+
<span className="truncate text-sm font-medium leading-5 text-foreground">{displayName}</span>
|
|
355
|
+
) : null}
|
|
356
|
+
{email ? (
|
|
357
|
+
<span className={cn(
|
|
358
|
+
'truncate text-xs leading-4',
|
|
359
|
+
displayName ? 'text-muted-foreground' : 'text-foreground font-medium',
|
|
360
|
+
)}>
|
|
361
|
+
{email}
|
|
362
|
+
</span>
|
|
363
|
+
) : null}
|
|
364
|
+
{!displayName && email ? (
|
|
365
|
+
<span className="truncate text-overline uppercase tracking-wider text-muted-foreground/80">
|
|
366
|
+
{t('ui.userMenu.loggedInAs', 'Logged in')}
|
|
367
|
+
</span>
|
|
368
|
+
) : null}
|
|
369
|
+
</div>
|
|
345
370
|
</div>
|
|
346
371
|
)}
|
|
347
372
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
{item.
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
373
|
+
<div className="flex flex-col p-1.5">
|
|
374
|
+
{mergedMenuItems.map((item) => (
|
|
375
|
+
<React.Fragment key={item.id}>
|
|
376
|
+
{item.separator ? <div className="my-1 h-px bg-border" aria-hidden="true" /> : null}
|
|
377
|
+
{item.source === 'injected'
|
|
378
|
+
? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)
|
|
379
|
+
: renderBuiltInItem(item.id)}
|
|
380
|
+
</React.Fragment>
|
|
381
|
+
))}
|
|
382
|
+
<InjectionSpot
|
|
383
|
+
spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}
|
|
384
|
+
context={injectionContext}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
360
387
|
</div>
|
|
361
388
|
)}
|
|
362
389
|
</div>
|
|
@@ -173,6 +173,14 @@ describe('AppShell', () => {
|
|
|
173
173
|
expect(screen.getByTestId('injection-spot:backend:sidebar:footer')).toBeInTheDocument()
|
|
174
174
|
expect(screen.getByTestId('injection-spot:backend-mutation:global')).toBeInTheDocument()
|
|
175
175
|
expect(screen.getByText('Child content')).toBeInTheDocument()
|
|
176
|
+
|
|
177
|
+
const breadcrumbNav = screen.getByRole('navigation', { name: 'Breadcrumb' })
|
|
178
|
+
expect(breadcrumbNav).toHaveAttribute('data-slot', 'breadcrumb')
|
|
179
|
+
expect(breadcrumbNav).toHaveAttribute('data-divider', 'arrow')
|
|
180
|
+
const dashboardHome = within(breadcrumbNav).getByRole('link', { name: 'Dashboard' })
|
|
181
|
+
expect(dashboardHome).toHaveAttribute('href', '/backend')
|
|
182
|
+
const activePage = within(breadcrumbNav).getByText((_, el) => el?.getAttribute('data-slot') === 'breadcrumb-page')
|
|
183
|
+
expect(activePage).toHaveAttribute('aria-current', 'page')
|
|
176
184
|
expect(mockInjectionSpot).toHaveBeenCalledWith(
|
|
177
185
|
expect.objectContaining({
|
|
178
186
|
spotId: 'backend-mutation:global',
|
|
@@ -7,7 +7,7 @@ export type NotificationCountBadgeProps = {
|
|
|
7
7
|
export function NotificationCountBadge({ count }: NotificationCountBadgeProps) {
|
|
8
8
|
if (count <= 0) return null
|
|
9
9
|
return (
|
|
10
|
-
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-
|
|
10
|
+
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground ring-2 ring-background">
|
|
11
11
|
{count > 99 ? '99+' : count}
|
|
12
12
|
</span>
|
|
13
13
|
)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { useRouter } from 'next/navigation'
|
|
4
|
-
import {
|
|
4
|
+
import { Bell, AlertTriangle, CheckCircle2, XCircle, Info, Loader2, X } from 'lucide-react'
|
|
5
5
|
import { Button } from '../../primitives/button'
|
|
6
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
6
7
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
8
|
import { formatRelativeTime } from '@open-mercato/shared/lib/time'
|
|
8
9
|
import type { NotificationDto, NotificationRendererProps, NotificationTypeAction } from '@open-mercato/shared/modules/notifications/types'
|
|
@@ -15,37 +16,9 @@ export type NotificationItemProps = {
|
|
|
15
16
|
onExecuteAction: (actionId: string) => Promise<{ href?: string }>
|
|
16
17
|
onDismiss: () => Promise<void>
|
|
17
18
|
t: TranslateFn
|
|
18
|
-
/**
|
|
19
|
-
* Optional custom renderer component for this notification type.
|
|
20
|
-
* When provided, this component will be used instead of the default rendering.
|
|
21
|
-
*
|
|
22
|
-
* Custom renderers receive full control over the notification's appearance while
|
|
23
|
-
* still having access to action handlers and notification data.
|
|
24
|
-
*
|
|
25
|
-
* @example
|
|
26
|
-
* ```tsx
|
|
27
|
-
* // In your module's notifications.client.ts
|
|
28
|
-
* export const salesNotificationTypes = [
|
|
29
|
-
* {
|
|
30
|
-
* type: 'sales.order.created',
|
|
31
|
-
* Renderer: SalesOrderCreatedRenderer,
|
|
32
|
-
* // ...other fields
|
|
33
|
-
* }
|
|
34
|
-
* ]
|
|
35
|
-
*
|
|
36
|
-
* // Usage in NotificationPanel
|
|
37
|
-
* const renderer = salesNotificationTypes.find(t => t.type === notification.type)?.Renderer
|
|
38
|
-
* <NotificationItem
|
|
39
|
-
* notification={notification}
|
|
40
|
-
* customRenderer={renderer}
|
|
41
|
-
* ...
|
|
42
|
-
* />
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
19
|
customRenderer?: ComponentType<NotificationRendererProps>
|
|
46
20
|
}
|
|
47
21
|
|
|
48
|
-
|
|
49
22
|
function resolveNotificationText(params: {
|
|
50
23
|
key?: string | null
|
|
51
24
|
fallback?: string | null
|
|
@@ -69,13 +42,30 @@ const severityIcons = {
|
|
|
69
42
|
error: XCircle,
|
|
70
43
|
}
|
|
71
44
|
|
|
72
|
-
|
|
45
|
+
// Avatar background — soft tints matching the Figma example item palette
|
|
46
|
+
const severityAvatarBg = {
|
|
47
|
+
info: 'bg-status-info-bg',
|
|
48
|
+
warning: 'bg-status-warning-bg',
|
|
49
|
+
success: 'bg-status-success-bg',
|
|
50
|
+
error: 'bg-status-error-bg',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const severityAvatarText = {
|
|
73
54
|
info: 'text-status-info-icon',
|
|
74
55
|
warning: 'text-status-warning-icon',
|
|
75
56
|
success: 'text-status-success-icon',
|
|
76
57
|
error: 'text-status-error-icon',
|
|
77
58
|
}
|
|
78
59
|
|
|
60
|
+
function humanizeModuleId(moduleId?: string | null): string | null {
|
|
61
|
+
if (!moduleId) return null
|
|
62
|
+
return moduleId
|
|
63
|
+
.split(/[_:.-]+/)
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
66
|
+
.join(' ')
|
|
67
|
+
}
|
|
68
|
+
|
|
79
69
|
export function NotificationItem({
|
|
80
70
|
notification,
|
|
81
71
|
onMarkAsRead,
|
|
@@ -89,8 +79,11 @@ export function NotificationItem({
|
|
|
89
79
|
|
|
90
80
|
const isUnread = notification.status === 'unread'
|
|
91
81
|
const hasActions = notification.actions && notification.actions.length > 0
|
|
92
|
-
const severity = notification.severity as keyof typeof severityIcons
|
|
82
|
+
const severity = (notification.severity as keyof typeof severityIcons) ?? 'info'
|
|
93
83
|
const IconComponent = severityIcons[severity] ?? Bell
|
|
84
|
+
const avatarBg = severityAvatarBg[severity] ?? 'bg-muted'
|
|
85
|
+
const avatarText = severityAvatarText[severity] ?? 'text-muted-foreground'
|
|
86
|
+
|
|
94
87
|
const titleText = resolveNotificationText({
|
|
95
88
|
key: notification.titleKey,
|
|
96
89
|
fallback: notification.title,
|
|
@@ -103,14 +96,12 @@ export function NotificationItem({
|
|
|
103
96
|
variables: notification.bodyVariables ?? undefined,
|
|
104
97
|
t,
|
|
105
98
|
})
|
|
99
|
+
const timeAgo = formatRelativeTime(notification.createdAt, { translate: t }) ?? ''
|
|
100
|
+
const sourceLabel = humanizeModuleId(notification.sourceModule)
|
|
106
101
|
|
|
107
102
|
const handleClick = async () => {
|
|
108
|
-
if (isUnread)
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
if (notification.linkHref) {
|
|
112
|
-
router.push(notification.linkHref)
|
|
113
|
-
}
|
|
103
|
+
if (isUnread) await onMarkAsRead()
|
|
104
|
+
if (notification.linkHref) router.push(notification.linkHref)
|
|
114
105
|
}
|
|
115
106
|
|
|
116
107
|
const handleAction = async (actionId: string, event?: React.MouseEvent) => {
|
|
@@ -118,9 +109,7 @@ export function NotificationItem({
|
|
|
118
109
|
setExecuting(actionId)
|
|
119
110
|
try {
|
|
120
111
|
const result = await onExecuteAction(actionId)
|
|
121
|
-
if (result.href)
|
|
122
|
-
router.push(result.href)
|
|
123
|
-
}
|
|
112
|
+
if (result.href) router.push(result.href)
|
|
124
113
|
} finally {
|
|
125
114
|
setExecuting(null)
|
|
126
115
|
}
|
|
@@ -131,7 +120,6 @@ export function NotificationItem({
|
|
|
131
120
|
await onDismiss()
|
|
132
121
|
}
|
|
133
122
|
|
|
134
|
-
// Convert notification actions to the format expected by custom renderers
|
|
135
123
|
const rendererActions: NotificationTypeAction[] = notification.actions.map((action) => ({
|
|
136
124
|
id: action.id,
|
|
137
125
|
labelKey: action.labelKey ?? action.label,
|
|
@@ -139,7 +127,6 @@ export function NotificationItem({
|
|
|
139
127
|
icon: action.icon,
|
|
140
128
|
}))
|
|
141
129
|
|
|
142
|
-
// Use custom renderer if provided
|
|
143
130
|
if (CustomRenderer) {
|
|
144
131
|
return (
|
|
145
132
|
<CustomRenderer
|
|
@@ -168,11 +155,14 @@ export function NotificationItem({
|
|
|
168
155
|
)
|
|
169
156
|
}
|
|
170
157
|
|
|
158
|
+
const showActions = hasActions && notification.status !== 'actioned'
|
|
159
|
+
const showBodyBubble = !!bodyText && bodyText.length > 0
|
|
160
|
+
|
|
171
161
|
return (
|
|
172
162
|
<div
|
|
173
163
|
className={cn(
|
|
174
|
-
'group relative
|
|
175
|
-
isUnread && 'bg-muted/
|
|
164
|
+
'group relative flex gap-4 items-start rounded-xl p-3 transition-colors hover:bg-muted/40 cursor-pointer',
|
|
165
|
+
isUnread && 'bg-muted/20'
|
|
176
166
|
)}
|
|
177
167
|
onClick={handleClick}
|
|
178
168
|
onKeyDown={(e) => {
|
|
@@ -184,79 +174,82 @@ export function NotificationItem({
|
|
|
184
174
|
role="button"
|
|
185
175
|
tabIndex={0}
|
|
186
176
|
>
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
177
|
+
{/* Avatar: severity-tinted circle with severity icon */}
|
|
178
|
+
<div className={cn('relative shrink-0 flex size-10 items-center justify-center rounded-full', avatarBg)}>
|
|
179
|
+
<IconComponent className={cn('size-5', avatarText)} aria-hidden="true" />
|
|
180
|
+
{isUnread ? (
|
|
181
|
+
<span
|
|
182
|
+
className="absolute -right-1 -top-1 size-3 rounded-full bg-accent-indigo ring-2 ring-background"
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
/>
|
|
185
|
+
) : null}
|
|
186
|
+
</div>
|
|
190
187
|
|
|
191
|
-
<div className="flex gap-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
188
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
189
|
+
{/* Title row */}
|
|
190
|
+
<p className="text-sm font-medium leading-5 tracking-tight text-foreground">
|
|
191
|
+
{titleText}
|
|
192
|
+
</p>
|
|
193
|
+
|
|
194
|
+
{/* Description row: time · source */}
|
|
195
|
+
<div className="flex flex-wrap items-center gap-1 text-xs leading-4 text-muted-foreground">
|
|
196
|
+
{timeAgo ? <span className="whitespace-nowrap">{timeAgo}</span> : null}
|
|
197
|
+
{timeAgo && sourceLabel ? (
|
|
198
|
+
<span aria-hidden="true" className="text-text-disabled">·</span>
|
|
199
|
+
) : null}
|
|
200
|
+
{sourceLabel ? <span className="truncate">{sourceLabel}</span> : null}
|
|
199
201
|
</div>
|
|
200
202
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
</h4>
|
|
206
|
-
<span className="flex-shrink-0 text-xs text-muted-foreground">
|
|
207
|
-
{formatRelativeTime(notification.createdAt, { translate: t }) ?? ''}
|
|
208
|
-
</span>
|
|
203
|
+
{/* Optional body bubble */}
|
|
204
|
+
{showBodyBubble ? (
|
|
205
|
+
<div className="mt-2 inline-flex max-w-full self-start rounded-tr-lg rounded-br-lg rounded-bl-lg rounded-tl-sm border bg-background px-3 py-2 text-sm leading-5 tracking-tight text-muted-foreground shadow-xs">
|
|
206
|
+
<p className="whitespace-pre-line break-words">{bodyText}</p>
|
|
209
207
|
</div>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
<div className="mt-2 flex flex-wrap items-start gap-2">
|
|
219
|
-
{notification.actions.map((action) => (
|
|
208
|
+
) : null}
|
|
209
|
+
|
|
210
|
+
{/* Optional actions row */}
|
|
211
|
+
{showActions ? (
|
|
212
|
+
<div className="mt-2 flex flex-wrap items-center gap-2">
|
|
213
|
+
{notification.actions.map((action, idx) => {
|
|
214
|
+
const isPrimary = idx === notification.actions.length - 1 || action.variant === 'default'
|
|
215
|
+
return (
|
|
220
216
|
<Button
|
|
221
217
|
key={action.id}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
'outline'
|
|
225
|
-
}
|
|
218
|
+
type="button"
|
|
219
|
+
variant={isPrimary ? 'default' : 'outline'}
|
|
226
220
|
size="sm"
|
|
227
|
-
className=
|
|
221
|
+
className={cn(
|
|
222
|
+
'h-8 rounded-md px-2.5 py-1.5 text-sm font-medium',
|
|
223
|
+
isPrimary && 'bg-accent-indigo text-accent-indigo-foreground hover:bg-accent-indigo/90',
|
|
224
|
+
)}
|
|
228
225
|
onClick={(event) => handleAction(action.id, event)}
|
|
229
226
|
disabled={executing !== null}
|
|
230
227
|
>
|
|
231
|
-
{action.labelKey
|
|
232
|
-
|
|
233
|
-
: t(action.label, action.label)}
|
|
234
|
-
{executing === action.id && (
|
|
235
|
-
<Loader2 className="ml-1 h-3 w-3 animate-spin" />
|
|
236
|
-
)}
|
|
228
|
+
{action.labelKey ? t(action.labelKey, action.label) : t(action.label, action.label)}
|
|
229
|
+
{executing === action.id ? <Loader2 className="ml-1 size-3 animate-spin" /> : null}
|
|
237
230
|
</Button>
|
|
238
|
-
)
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
{notification.status === 'actioned' && notification.actionTaken && (
|
|
243
|
-
<p className="mt-1 text-xs text-muted-foreground italic">
|
|
244
|
-
{t('notifications.actionTaken', 'Action taken: {action}', {
|
|
245
|
-
action: notification.actionTaken,
|
|
246
|
-
})}
|
|
247
|
-
</p>
|
|
248
|
-
)}
|
|
249
|
-
</div>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</div>
|
|
234
|
+
) : null}
|
|
250
235
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
>
|
|
257
|
-
<X className="h-3 w-3" />
|
|
258
|
-
</Button>
|
|
236
|
+
{notification.status === 'actioned' && notification.actionTaken ? (
|
|
237
|
+
<p className="mt-1 text-xs italic text-muted-foreground">
|
|
238
|
+
{t('notifications.actionTaken', 'Action taken: {action}', { action: notification.actionTaken })}
|
|
239
|
+
</p>
|
|
240
|
+
) : null}
|
|
259
241
|
</div>
|
|
242
|
+
|
|
243
|
+
<IconButton
|
|
244
|
+
type="button"
|
|
245
|
+
variant="ghost"
|
|
246
|
+
size="xs"
|
|
247
|
+
className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
|
248
|
+
onClick={handleDismiss}
|
|
249
|
+
aria-label={t('notifications.actions.dismiss', 'Dismiss')}
|
|
250
|
+
>
|
|
251
|
+
<X className="size-3.5" />
|
|
252
|
+
</IconButton>
|
|
260
253
|
</div>
|
|
261
254
|
)
|
|
262
255
|
}
|