@campfire-interactive/shell-header 0.1.5 → 0.3.3

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/dist/index.d.ts CHANGED
@@ -16,6 +16,26 @@ interface ShellUser {
16
16
  email: string;
17
17
  avatarUrl?: string;
18
18
  }
19
+ interface LocaleOption {
20
+ /** BCP-47-ish language code, e.g. "en", "de". */
21
+ code: string;
22
+ /** Human-readable label shown in the dropdown, e.g. "English", "Deutsch". */
23
+ label: string;
24
+ }
25
+ /**
26
+ * Subset of the platform-notifications `Notification` shape that the bell UI
27
+ * actually renders. Hosts typically map their fetched items (e.g., from
28
+ * `useNotifications` in @campfire-interactive/notifications-client/react)
29
+ * into this shape.
30
+ */
31
+ interface NotificationItem {
32
+ id: string;
33
+ title: string;
34
+ body: string;
35
+ link: string | null;
36
+ readAt: string | null;
37
+ createdAt: string;
38
+ }
19
39
  interface ShellHeaderProps {
20
40
  /** Current app ID — used to highlight the active app in the switcher */
21
41
  appId: string;
@@ -23,19 +43,59 @@ interface ShellHeaderProps {
23
43
  user: ShellUser;
24
44
  /** App IDs the user is authorized to access in the current tenant */
25
45
  authorizedApps: string[];
26
- /** Unread notification count */
46
+ /** Unread count for the bell badge. */
27
47
  notificationCount?: number;
28
- /** Called when the notification bell is clicked */
48
+ /**
49
+ * Legacy: fires when the bell icon is clicked. Only used when `notifications`
50
+ * is NOT provided (icon-only mode). When `notifications` is an array, the
51
+ * bell opens a dropdown instead.
52
+ */
29
53
  onNotificationClick?: () => void;
54
+ /**
55
+ * If provided, the bell renders a popover dropdown with these items. Pass
56
+ * an empty array for the "no notifications yet" empty state.
57
+ */
58
+ notifications?: NotificationItem[];
59
+ /** Optimistically mark a single notification read. */
60
+ onMarkRead?: (id: string) => void;
61
+ /** Mark every unread notification in scope read. */
62
+ onMarkAllRead?: () => void;
63
+ /**
64
+ * Fires when a notification row in the dropdown is clicked (after the
65
+ * built-in auto-mark-read). Hosts handle navigation here using `item.link`.
66
+ */
67
+ onNotificationItemClick?: (item: NotificationItem) => void;
30
68
  /** Called when logout is clicked */
31
69
  onLogout?: () => void;
70
+ /**
71
+ * Current user locale (e.g. "en", "de"). When provided alongside
72
+ * `supportedLocales` and `onLocaleChange`, a locale dropdown renders
73
+ * in the right-side cluster. Omitting any of the three hides it.
74
+ */
75
+ locale?: string;
76
+ /** Locales the user can pick from. */
77
+ supportedLocales?: ReadonlyArray<LocaleOption>;
78
+ /**
79
+ * Called when the user picks a different locale. Consumer is responsible
80
+ * for persisting (e.g. via identity-client.me.updatePreferences) and for
81
+ * refreshing the JWT / reloading the page so other parts of the UI
82
+ * pick up the change.
83
+ */
84
+ onLocaleChange?: (locale: string) => void | Promise<void>;
32
85
  /** Fixed width for the app brand area (e.g., to align with a sidebar below). */
33
86
  brandWidth?: number;
34
87
  /** Optional content in the left/center area (filters, search, breadcrumbs, etc.) */
35
88
  children?: React.ReactNode;
36
89
  }
37
90
 
38
- declare function ShellHeader({ appId, user, authorizedApps, notificationCount, onNotificationClick, onLogout, brandWidth, children, }: ShellHeaderProps): react_jsx_runtime.JSX.Element;
91
+ declare function ShellHeader({ appId, user, authorizedApps, notificationCount, onNotificationClick, notifications, onMarkRead, onMarkAllRead, onNotificationItemClick, onLogout, locale, supportedLocales, onLocaleChange, brandWidth, children, }: ShellHeaderProps): react_jsx_runtime.JSX.Element;
92
+
93
+ interface LocaleSwitcherProps {
94
+ currentLocale: string;
95
+ supportedLocales: ReadonlyArray<LocaleOption>;
96
+ onLocaleChange: (locale: string) => void | Promise<void>;
97
+ }
98
+ declare function LocaleSwitcher({ currentLocale, supportedLocales, onLocaleChange }: LocaleSwitcherProps): react_jsx_runtime.JSX.Element;
39
99
 
40
100
  declare const appCatalog: AppDefinition[];
41
101
  /**
@@ -46,4 +106,4 @@ declare const appCatalog: AppDefinition[];
46
106
  */
47
107
  declare function getAppUrl(app: AppDefinition): string;
48
108
 
49
- export { type AppDefinition, ShellHeader, type ShellHeaderProps, type ShellUser, appCatalog, getAppUrl };
109
+ export { type AppDefinition, type LocaleOption, LocaleSwitcher, type NotificationItem, ShellHeader, type ShellHeaderProps, type ShellUser, appCatalog, getAppUrl };
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ function styleInject(css, { insertAt } = {}) {
24
24
  }
25
25
 
26
26
  // src/styles.css
27
- styleInject(".cfi-sh-header {\n display: flex;\n align-items: center;\n height: var(--cfi-shell-header-height, 48px);\n padding: 0 var(--cfi-spacing-md, 1rem);\n background: white;\n color: var(--cfi-color-gray-900, #111827);\n font-family: var(--cfi-font-family, system-ui, sans-serif);\n font-size: var(--cfi-font-size-sm, 0.875rem);\n border-bottom: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n position: relative;\n z-index: 1000;\n}\n.cfi-sh-left {\n display: flex;\n align-items: center;\n flex: 1;\n gap: var(--cfi-spacing-md, 1rem);\n overflow: hidden;\n}\n.cfi-sh-app-brand {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-sm, 0.5rem);\n flex-shrink: 0;\n}\n.cfi-sh-app-badge {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n color: white;\n font-size: 14px;\n font-weight: 700;\n font-family: inherit;\n border-radius: var(--cfi-radius-md, 0.375rem);\n flex-shrink: 0;\n}\n.cfi-sh-app-title {\n font-weight: 600;\n font-size: var(--cfi-font-size-base, 1rem);\n color: var(--cfi-color-gray-900, #111827);\n white-space: nowrap;\n}\n.cfi-sh-right {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-xs, 0.25rem);\n flex-shrink: 0;\n}\n.cfi-sh-icon-btn {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 36px;\n height: 36px;\n background: transparent;\n border: none;\n border-radius: 50%;\n color: var(--cfi-color-gray-600, #4b5563);\n cursor: pointer;\n transition: background 150ms;\n}\n.cfi-sh-icon-btn:hover {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-badge {\n position: absolute;\n top: 2px;\n right: 2px;\n min-width: 16px;\n height: 16px;\n padding: 0 4px;\n background: var(--cfi-color-error, #ef4444);\n color: white;\n font-size: 10px;\n font-weight: 600;\n line-height: 16px;\n text-align: center;\n border-radius: 99px;\n}\n.cfi-sh-app-switcher {\n position: relative;\n}\n.cfi-sh-app-grid {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: var(--cfi-spacing-xs, 0.25rem);\n padding: var(--cfi-spacing-sm, 0.5rem);\n background: white;\n border-radius: var(--cfi-radius-lg, 0.5rem);\n box-shadow: var(--cfi-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n z-index: 1001;\n min-width: 280px;\n}\n.cfi-sh-app-grid-item {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: var(--cfi-spacing-xs, 0.25rem);\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-xs, 0.25rem);\n border-radius: var(--cfi-radius-md, 0.375rem);\n color: var(--cfi-color-gray-700, #374151);\n text-decoration: none;\n cursor: pointer;\n transition: background 100ms;\n font-family: inherit;\n}\n.cfi-sh-app-grid-item:hover {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-app-grid-item-active {\n background: var(--cfi-color-primary-50, #fff7ed);\n color: var(--cfi-color-primary-700, #c2410c);\n}\n.cfi-sh-app-grid-item-active:hover {\n background: var(--cfi-color-primary-100, #ffedd5);\n}\n.cfi-sh-app-grid-icon {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n border-radius: 50%;\n}\n.cfi-sh-app-grid-label {\n font-size: var(--cfi-font-size-xs, 0.75rem);\n text-align: center;\n line-height: 1.2;\n max-width: 80px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.cfi-sh-user-menu {\n position: relative;\n}\n.cfi-sh-avatar-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n padding: 0;\n background: var(--cfi-color-primary-600, #ea580c);\n border: none;\n border-radius: 50%;\n cursor: pointer;\n transition: opacity 150ms;\n}\n.cfi-sh-avatar-btn:hover {\n opacity: 0.85;\n}\n.cfi-sh-avatar-img {\n width: 32px;\n height: 32px;\n border-radius: 50%;\n object-fit: cover;\n}\n.cfi-sh-avatar-initials {\n color: white;\n font-size: var(--cfi-font-size-xs, 0.75rem);\n font-weight: 600;\n font-family: inherit;\n}\n.cfi-sh-user-dropdown {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n min-width: 200px;\n background: white;\n border-radius: var(--cfi-radius-lg, 0.5rem);\n box-shadow: var(--cfi-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n z-index: 1001;\n}\n.cfi-sh-user-header {\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-md, 1rem);\n display: flex;\n flex-direction: column;\n}\n.cfi-sh-user-name {\n font-weight: 500;\n color: var(--cfi-color-gray-900, #111827);\n font-size: var(--cfi-font-size-sm, 0.875rem);\n}\n.cfi-sh-user-email {\n color: var(--cfi-color-gray-500, #6b7280);\n font-size: var(--cfi-font-size-xs, 0.75rem);\n}\n.cfi-sh-divider {\n height: 1px;\n background: var(--cfi-color-gray-200, #e5e7eb);\n}\n.cfi-sh-menu-item {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-sm, 0.5rem);\n width: 100%;\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-md, 1rem);\n background: none;\n border: none;\n color: var(--cfi-color-gray-700, #374151);\n cursor: pointer;\n font-size: var(--cfi-font-size-sm, 0.875rem);\n font-family: inherit;\n transition: background 100ms;\n}\n.cfi-sh-menu-item:hover {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n");
27
+ styleInject(".cfi-sh-header {\n display: flex;\n align-items: center;\n height: var(--cfi-shell-header-height, 48px);\n padding: 0 var(--cfi-spacing-md, 1rem);\n background: white;\n color: var(--cfi-color-gray-900, #111827);\n font-family: var(--cfi-font-family, system-ui, sans-serif);\n font-size: var(--cfi-font-size-sm, 0.875rem);\n border-bottom: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n position: relative;\n z-index: 1000;\n}\n.cfi-sh-left {\n display: flex;\n align-items: center;\n flex: 1;\n gap: var(--cfi-spacing-md, 1rem);\n overflow: hidden;\n}\n.cfi-sh-app-brand {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-sm, 0.5rem);\n flex-shrink: 0;\n}\n.cfi-sh-app-badge {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n color: white;\n font-size: 14px;\n font-weight: 700;\n font-family: inherit;\n border-radius: var(--cfi-radius-md, 0.375rem);\n flex-shrink: 0;\n}\n.cfi-sh-app-title {\n font-weight: 600;\n font-size: var(--cfi-font-size-base, 1rem);\n color: var(--cfi-color-gray-900, #111827);\n white-space: nowrap;\n}\n.cfi-sh-right {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-xs, 0.25rem);\n flex-shrink: 0;\n}\n.cfi-sh-icon-btn {\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 36px;\n height: 36px;\n background: transparent;\n border: none;\n border-radius: 50%;\n color: var(--cfi-color-gray-600, #4b5563);\n cursor: pointer;\n transition: background 150ms;\n}\n.cfi-sh-icon-btn:hover {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-badge {\n position: absolute;\n top: 2px;\n right: 2px;\n min-width: 16px;\n height: 16px;\n padding: 0 4px;\n background: var(--cfi-color-error, #ef4444);\n color: white;\n font-size: 10px;\n font-weight: 600;\n line-height: 16px;\n text-align: center;\n border-radius: 99px;\n}\n.cfi-sh-app-switcher {\n position: relative;\n}\n.cfi-sh-app-grid {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: var(--cfi-spacing-xs, 0.25rem);\n padding: var(--cfi-spacing-sm, 0.5rem);\n background: white;\n border-radius: var(--cfi-radius-lg, 0.5rem);\n box-shadow: var(--cfi-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n z-index: 1001;\n min-width: 280px;\n}\n.cfi-sh-app-grid-item {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: var(--cfi-spacing-xs, 0.25rem);\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-xs, 0.25rem);\n border-radius: var(--cfi-radius-md, 0.375rem);\n color: var(--cfi-color-gray-700, #374151);\n text-decoration: none;\n cursor: pointer;\n transition: background 100ms;\n font-family: inherit;\n}\n.cfi-sh-app-grid-item:hover {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-app-grid-item-active {\n background: var(--cfi-color-primary-50, #fff7ed);\n color: var(--cfi-color-primary-700, #c2410c);\n}\n.cfi-sh-app-grid-item-active:hover {\n background: var(--cfi-color-primary-100, #ffedd5);\n}\n.cfi-sh-app-grid-icon {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n border-radius: 50%;\n}\n.cfi-sh-app-grid-label {\n font-size: var(--cfi-font-size-xs, 0.75rem);\n text-align: center;\n line-height: 1.2;\n max-width: 80px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.cfi-sh-user-menu {\n position: relative;\n}\n.cfi-sh-avatar-btn {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n padding: 0;\n background: var(--cfi-color-primary-600, #ea580c);\n border: none;\n border-radius: 50%;\n cursor: pointer;\n transition: opacity 150ms;\n}\n.cfi-sh-avatar-btn:hover {\n opacity: 0.85;\n}\n.cfi-sh-avatar-img {\n width: 32px;\n height: 32px;\n border-radius: 50%;\n object-fit: cover;\n}\n.cfi-sh-avatar-initials {\n color: white;\n font-size: var(--cfi-font-size-xs, 0.75rem);\n font-weight: 600;\n font-family: inherit;\n}\n.cfi-sh-user-dropdown {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n min-width: 200px;\n background: white;\n border-radius: var(--cfi-radius-lg, 0.5rem);\n box-shadow: var(--cfi-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n z-index: 1001;\n}\n.cfi-sh-user-header {\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-md, 1rem);\n display: flex;\n flex-direction: column;\n}\n.cfi-sh-user-name {\n font-weight: 500;\n color: var(--cfi-color-gray-900, #111827);\n font-size: var(--cfi-font-size-sm, 0.875rem);\n}\n.cfi-sh-user-email {\n color: var(--cfi-color-gray-500, #6b7280);\n font-size: var(--cfi-font-size-xs, 0.75rem);\n}\n.cfi-sh-divider {\n height: 1px;\n background: var(--cfi-color-gray-200, #e5e7eb);\n}\n.cfi-sh-menu-item {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-sm, 0.5rem);\n width: 100%;\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-md, 1rem);\n background: none;\n border: none;\n color: var(--cfi-color-gray-700, #374151);\n cursor: pointer;\n font-size: var(--cfi-font-size-sm, 0.875rem);\n font-family: inherit;\n transition: background 100ms;\n}\n.cfi-sh-menu-item:hover {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-menu-item:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n.cfi-sh-locale-switcher {\n position: relative;\n}\n.cfi-sh-locale-btn {\n display: flex;\n align-items: center;\n gap: var(--cfi-spacing-xs, 0.25rem);\n height: 32px;\n padding: 0 var(--cfi-spacing-sm, 0.5rem);\n background: transparent;\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n border-radius: var(--cfi-radius-md, 0.375rem);\n color: var(--cfi-color-gray-700, #374151);\n cursor: pointer;\n font-size: var(--cfi-font-size-xs, 0.75rem);\n font-weight: 600;\n font-family: inherit;\n transition: background 100ms;\n}\n.cfi-sh-locale-btn:hover:not(:disabled) {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-locale-btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n.cfi-sh-locale-label {\n letter-spacing: 0.02em;\n}\n.cfi-sh-locale-dropdown {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n min-width: 180px;\n background: white;\n border-radius: var(--cfi-radius-lg, 0.5rem);\n box-shadow: var(--cfi-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n z-index: 1001;\n padding: var(--cfi-spacing-xs, 0.25rem) 0;\n}\n.cfi-sh-locale-dropdown .cfi-sh-menu-item {\n justify-content: space-between;\n}\n.cfi-sh-locale-item-active {\n background: var(--cfi-color-gray-100, #f3f4f6);\n}\n.cfi-sh-locale-item-label {\n flex: 1;\n text-align: left;\n}\n.cfi-sh-locale-item-code {\n color: var(--cfi-color-gray-400, #9ca3af);\n font-size: var(--cfi-font-size-xs, 0.75rem);\n font-weight: 600;\n letter-spacing: 0.02em;\n}\n.cfi-sh-notif {\n position: relative;\n}\n.cfi-sh-notif-dropdown {\n position: absolute;\n top: calc(100% + 8px);\n right: 0;\n width: 360px;\n max-height: 480px;\n display: flex;\n flex-direction: column;\n background: white;\n border-radius: var(--cfi-radius-lg, 0.5rem);\n box-shadow: var(--cfi-shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));\n border: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n z-index: 1001;\n overflow: hidden;\n}\n.cfi-sh-notif-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-md, 1rem);\n border-bottom: 1px solid var(--cfi-color-gray-200, #e5e7eb);\n flex-shrink: 0;\n}\n.cfi-sh-notif-title {\n font-weight: 600;\n color: var(--cfi-color-gray-900, #111827);\n}\n.cfi-sh-notif-mark-all {\n background: none;\n border: none;\n color: var(--cfi-color-primary-600, #ea580c);\n cursor: pointer;\n font-size: var(--cfi-font-size-xs, 0.75rem);\n font-family: inherit;\n padding: 0;\n}\n.cfi-sh-notif-mark-all:hover {\n text-decoration: underline;\n}\n.cfi-sh-notif-list {\n overflow-y: auto;\n flex: 1;\n}\n.cfi-sh-notif-empty {\n padding: var(--cfi-spacing-lg, 1.5rem) var(--cfi-spacing-md, 1rem);\n color: var(--cfi-color-gray-500, #6b7280);\n text-align: center;\n font-size: var(--cfi-font-size-sm, 0.875rem);\n}\n.cfi-sh-notif-item {\n display: flex;\n align-items: flex-start;\n gap: var(--cfi-spacing-sm, 0.5rem);\n width: 100%;\n padding: var(--cfi-spacing-sm, 0.5rem) var(--cfi-spacing-md, 1rem);\n background: none;\n border: none;\n border-bottom: 1px solid var(--cfi-color-gray-100, #f3f4f6);\n text-align: left;\n cursor: pointer;\n font-family: inherit;\n transition: background 100ms;\n}\n.cfi-sh-notif-item:hover {\n background: var(--cfi-color-gray-50, #f9fafb);\n}\n.cfi-sh-notif-item:last-child {\n border-bottom: none;\n}\n.cfi-sh-notif-dot {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n margin-top: 6px;\n background: var(--cfi-color-primary-600, #ea580c);\n border-radius: 50%;\n}\n.cfi-sh-notif-dot-placeholder {\n flex-shrink: 0;\n width: 8px;\n height: 8px;\n margin-top: 6px;\n}\n.cfi-sh-notif-content {\n flex: 1;\n min-width: 0;\n}\n.cfi-sh-notif-item-title {\n font-weight: 500;\n color: var(--cfi-color-gray-900, #111827);\n font-size: var(--cfi-font-size-sm, 0.875rem);\n margin-bottom: 2px;\n}\n.cfi-sh-notif-item-unread .cfi-sh-notif-item-title {\n font-weight: 600;\n}\n.cfi-sh-notif-item-body {\n color: var(--cfi-color-gray-600, #4b5563);\n font-size: var(--cfi-font-size-xs, 0.75rem);\n line-height: 1.4;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n.cfi-sh-notif-item-time {\n color: var(--cfi-color-gray-400, #9ca3af);\n font-size: 11px;\n margin-top: 4px;\n}\n");
28
28
 
29
29
  // src/appCatalog.ts
30
30
  var appCatalog = [
@@ -34,7 +34,8 @@ var appCatalog = [
34
34
  { id: "pim", name: "Price Index", icon: "BarChart3", letter: "P", color: "#f97316", localPort: 3300 },
35
35
  { id: "cpq", name: "CPQ", icon: "Calculator", letter: "C", color: "#4f46e5", localPort: 3400 },
36
36
  { id: "omsf", name: "OMSF", icon: "FileText", letter: "O", color: "#0ea5e9", localPort: 3500 },
37
- { id: "identity", name: "Identity", icon: "Shield", letter: "I", color: "#8b5cf6", localPort: 3600 }
37
+ { id: "identity", name: "Identity", icon: "Shield", letter: "I", color: "#8b5cf6", localPort: 3600 },
38
+ { id: "bom", name: "BOM", icon: "Network", letter: "B", color: "#0d9488", localPort: 3700 }
38
39
  ];
39
40
  function getAppUrl(app) {
40
41
  const host = typeof window !== "undefined" ? window.location.hostname : "";
@@ -62,7 +63,8 @@ import {
62
63
  Shield,
63
64
  GripHorizontal,
64
65
  Bell,
65
- LogOut
66
+ LogOut,
67
+ Globe
66
68
  } from "lucide-react";
67
69
  import { jsx, jsxs } from "react/jsx-runtime";
68
70
  var iconMap = {
@@ -141,22 +143,182 @@ function AppSwitcher({ currentAppId, authorizedApps }) {
141
143
  ] });
142
144
  }
143
145
 
144
- // src/NotificationBell.tsx
146
+ // src/LocaleSwitcher.tsx
147
+ import { useState as useState2, useRef as useRef2, useEffect as useEffect2 } from "react";
145
148
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
146
- function NotificationBell({ count = 0, onClick }) {
147
- return /* @__PURE__ */ jsxs3("button", { className: "cfi-sh-icon-btn", onClick, "aria-label": "Notifications", children: [
148
- /* @__PURE__ */ jsx3(Bell, { size: 18 }),
149
- count > 0 && /* @__PURE__ */ jsx3("span", { className: "cfi-sh-badge", children: count > 99 ? "99+" : count })
149
+ function LocaleSwitcher({ currentLocale, supportedLocales, onLocaleChange }) {
150
+ const [open, setOpen] = useState2(false);
151
+ const [updating, setUpdating] = useState2(false);
152
+ const ref = useRef2(null);
153
+ useEffect2(() => {
154
+ function handleClickOutside(e) {
155
+ if (ref.current && !ref.current.contains(e.target)) {
156
+ setOpen(false);
157
+ }
158
+ }
159
+ if (open) document.addEventListener("mousedown", handleClickOutside);
160
+ return () => document.removeEventListener("mousedown", handleClickOutside);
161
+ }, [open]);
162
+ const current = supportedLocales.find((l) => l.code === currentLocale);
163
+ const buttonLabel = (current?.code ?? currentLocale).toUpperCase();
164
+ async function handleSelect(code) {
165
+ if (code === currentLocale) {
166
+ setOpen(false);
167
+ return;
168
+ }
169
+ setUpdating(true);
170
+ try {
171
+ await onLocaleChange(code);
172
+ } finally {
173
+ setUpdating(false);
174
+ setOpen(false);
175
+ }
176
+ }
177
+ return /* @__PURE__ */ jsxs3("div", { className: "cfi-sh-locale-switcher", ref, children: [
178
+ /* @__PURE__ */ jsxs3(
179
+ "button",
180
+ {
181
+ className: "cfi-sh-locale-btn",
182
+ onClick: () => setOpen(!open),
183
+ "aria-expanded": open,
184
+ "aria-haspopup": "true",
185
+ "aria-label": `Language: ${current?.label ?? currentLocale}`,
186
+ disabled: updating,
187
+ title: current?.label ?? currentLocale,
188
+ children: [
189
+ /* @__PURE__ */ jsx3(Globe, { size: 16 }),
190
+ /* @__PURE__ */ jsx3("span", { className: "cfi-sh-locale-label", children: buttonLabel })
191
+ ]
192
+ }
193
+ ),
194
+ open && /* @__PURE__ */ jsx3("div", { className: "cfi-sh-locale-dropdown", children: supportedLocales.map((loc) => /* @__PURE__ */ jsxs3(
195
+ "button",
196
+ {
197
+ className: loc.code === currentLocale ? "cfi-sh-menu-item cfi-sh-locale-item-active" : "cfi-sh-menu-item",
198
+ onClick: () => handleSelect(loc.code),
199
+ disabled: updating,
200
+ children: [
201
+ /* @__PURE__ */ jsx3("span", { className: "cfi-sh-locale-item-label", children: loc.label }),
202
+ /* @__PURE__ */ jsx3("span", { className: "cfi-sh-locale-item-code", children: loc.code.toUpperCase() })
203
+ ]
204
+ },
205
+ loc.code
206
+ )) })
150
207
  ] });
151
208
  }
152
209
 
153
- // src/UserMenu.tsx
154
- import { useState as useState2, useRef as useRef2, useEffect as useEffect2 } from "react";
210
+ // src/NotificationBell.tsx
211
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
155
212
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
213
+ function NotificationBell({
214
+ count = 0,
215
+ onClick,
216
+ items,
217
+ onMarkRead,
218
+ onMarkAllRead,
219
+ onItemClick
220
+ }) {
221
+ const hasDropdown = items !== void 0;
222
+ const [open, setOpen] = useState3(false);
223
+ const ref = useRef3(null);
224
+ useEffect3(() => {
225
+ function handleClickOutside(e) {
226
+ if (ref.current && !ref.current.contains(e.target)) {
227
+ setOpen(false);
228
+ }
229
+ }
230
+ if (open) document.addEventListener("mousedown", handleClickOutside);
231
+ return () => document.removeEventListener("mousedown", handleClickOutside);
232
+ }, [open]);
233
+ useEffect3(() => {
234
+ function handleEsc(e) {
235
+ if (e.key === "Escape") setOpen(false);
236
+ }
237
+ if (open) document.addEventListener("keydown", handleEsc);
238
+ return () => document.removeEventListener("keydown", handleEsc);
239
+ }, [open]);
240
+ const handleBellClick = () => {
241
+ if (hasDropdown) {
242
+ setOpen((prev) => !prev);
243
+ } else if (onClick) {
244
+ onClick();
245
+ }
246
+ };
247
+ const handleItemClick = (item) => {
248
+ if (item.readAt === null && onMarkRead) onMarkRead(item.id);
249
+ if (onItemClick) onItemClick(item);
250
+ setOpen(false);
251
+ };
252
+ const hasUnread = items?.some((i) => i.readAt === null) ?? false;
253
+ return /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-notif", ref, children: [
254
+ /* @__PURE__ */ jsxs4(
255
+ "button",
256
+ {
257
+ className: "cfi-sh-icon-btn",
258
+ onClick: handleBellClick,
259
+ "aria-expanded": hasDropdown ? open : void 0,
260
+ "aria-haspopup": hasDropdown ? "true" : void 0,
261
+ "aria-label": "Notifications",
262
+ children: [
263
+ /* @__PURE__ */ jsx4(Bell, { size: 18 }),
264
+ count > 0 && /* @__PURE__ */ jsx4("span", { className: "cfi-sh-badge", children: count > 99 ? "99+" : count })
265
+ ]
266
+ }
267
+ ),
268
+ hasDropdown && open && /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-notif-dropdown", role: "dialog", "aria-label": "Notifications", children: [
269
+ /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-notif-header", children: [
270
+ /* @__PURE__ */ jsx4("span", { className: "cfi-sh-notif-title", children: "Notifications" }),
271
+ hasUnread && onMarkAllRead && /* @__PURE__ */ jsx4(
272
+ "button",
273
+ {
274
+ className: "cfi-sh-notif-mark-all",
275
+ onClick: () => onMarkAllRead(),
276
+ type: "button",
277
+ children: "Mark all read"
278
+ }
279
+ )
280
+ ] }),
281
+ /* @__PURE__ */ jsx4("div", { className: "cfi-sh-notif-list", children: items.length === 0 ? /* @__PURE__ */ jsx4("div", { className: "cfi-sh-notif-empty", children: "No notifications yet." }) : items.map((n) => /* @__PURE__ */ jsxs4(
282
+ "button",
283
+ {
284
+ className: `cfi-sh-notif-item ${n.readAt === null ? "cfi-sh-notif-item-unread" : ""}`,
285
+ onClick: () => handleItemClick(n),
286
+ type: "button",
287
+ children: [
288
+ n.readAt === null ? /* @__PURE__ */ jsx4("span", { className: "cfi-sh-notif-dot", "aria-hidden": "true" }) : /* @__PURE__ */ jsx4("span", { className: "cfi-sh-notif-dot-placeholder", "aria-hidden": "true" }),
289
+ /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-notif-content", children: [
290
+ /* @__PURE__ */ jsx4("div", { className: "cfi-sh-notif-item-title", children: n.title }),
291
+ /* @__PURE__ */ jsx4("div", { className: "cfi-sh-notif-item-body", children: n.body }),
292
+ /* @__PURE__ */ jsx4("div", { className: "cfi-sh-notif-item-time", children: formatRelative(n.createdAt) })
293
+ ] })
294
+ ]
295
+ },
296
+ n.id
297
+ )) })
298
+ ] })
299
+ ] });
300
+ }
301
+ function formatRelative(iso) {
302
+ const t = Date.parse(iso);
303
+ if (Number.isNaN(t)) return "";
304
+ const diff = Math.max(0, Date.now() - t);
305
+ const min = Math.floor(diff / 6e4);
306
+ if (min < 1) return "just now";
307
+ if (min < 60) return `${min}m`;
308
+ const hr = Math.floor(min / 60);
309
+ if (hr < 24) return `${hr}h`;
310
+ const days = Math.floor(hr / 24);
311
+ if (days < 7) return `${days}d`;
312
+ return new Date(t).toLocaleDateString("en-US", { month: "short", day: "numeric" });
313
+ }
314
+
315
+ // src/UserMenu.tsx
316
+ import { useState as useState4, useRef as useRef4, useEffect as useEffect4 } from "react";
317
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
156
318
  function UserMenu({ user, onLogout, color }) {
157
- const [open, setOpen] = useState2(false);
158
- const ref = useRef2(null);
159
- useEffect2(() => {
319
+ const [open, setOpen] = useState4(false);
320
+ const ref = useRef4(null);
321
+ useEffect4(() => {
160
322
  function handleClickOutside(e) {
161
323
  if (ref.current && !ref.current.contains(e.target)) {
162
324
  setOpen(false);
@@ -166,8 +328,8 @@ function UserMenu({ user, onLogout, color }) {
166
328
  return () => document.removeEventListener("mousedown", handleClickOutside);
167
329
  }, [open]);
168
330
  const initials = user.name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2);
169
- return /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-user-menu", ref, children: [
170
- /* @__PURE__ */ jsx4(
331
+ return /* @__PURE__ */ jsxs5("div", { className: "cfi-sh-user-menu", ref, children: [
332
+ /* @__PURE__ */ jsx5(
171
333
  "button",
172
334
  {
173
335
  className: "cfi-sh-avatar-btn",
@@ -175,16 +337,16 @@ function UserMenu({ user, onLogout, color }) {
175
337
  onClick: () => setOpen(!open),
176
338
  "aria-expanded": open,
177
339
  "aria-haspopup": "true",
178
- children: user.avatarUrl ? /* @__PURE__ */ jsx4("img", { src: user.avatarUrl, alt: user.name, className: "cfi-sh-avatar-img" }) : /* @__PURE__ */ jsx4("span", { className: "cfi-sh-avatar-initials", children: initials })
340
+ children: user.avatarUrl ? /* @__PURE__ */ jsx5("img", { src: user.avatarUrl, alt: user.name, className: "cfi-sh-avatar-img" }) : /* @__PURE__ */ jsx5("span", { className: "cfi-sh-avatar-initials", children: initials })
179
341
  }
180
342
  ),
181
- open && /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-user-dropdown", children: [
182
- /* @__PURE__ */ jsxs4("div", { className: "cfi-sh-user-header", children: [
183
- /* @__PURE__ */ jsx4("span", { className: "cfi-sh-user-name", children: user.name }),
184
- /* @__PURE__ */ jsx4("span", { className: "cfi-sh-user-email", children: user.email })
343
+ open && /* @__PURE__ */ jsxs5("div", { className: "cfi-sh-user-dropdown", children: [
344
+ /* @__PURE__ */ jsxs5("div", { className: "cfi-sh-user-header", children: [
345
+ /* @__PURE__ */ jsx5("span", { className: "cfi-sh-user-name", children: user.name }),
346
+ /* @__PURE__ */ jsx5("span", { className: "cfi-sh-user-email", children: user.email })
185
347
  ] }),
186
- /* @__PURE__ */ jsx4("div", { className: "cfi-sh-divider" }),
187
- /* @__PURE__ */ jsxs4(
348
+ /* @__PURE__ */ jsx5("div", { className: "cfi-sh-divider" }),
349
+ /* @__PURE__ */ jsxs5(
188
350
  "button",
189
351
  {
190
352
  className: "cfi-sh-menu-item",
@@ -193,8 +355,8 @@ function UserMenu({ user, onLogout, color }) {
193
355
  onLogout?.();
194
356
  },
195
357
  children: [
196
- /* @__PURE__ */ jsx4(LogOut, { size: 14 }),
197
- /* @__PURE__ */ jsx4("span", { children: "Sign out" })
358
+ /* @__PURE__ */ jsx5(LogOut, { size: 14 }),
359
+ /* @__PURE__ */ jsx5("span", { children: "Sign out" })
198
360
  ]
199
361
  }
200
362
  )
@@ -203,35 +365,62 @@ function UserMenu({ user, onLogout, color }) {
203
365
  }
204
366
 
205
367
  // src/ShellHeader.tsx
206
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
368
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
207
369
  function ShellHeader({
208
370
  appId,
209
371
  user,
210
372
  authorizedApps,
211
373
  notificationCount,
212
374
  onNotificationClick,
375
+ notifications,
376
+ onMarkRead,
377
+ onMarkAllRead,
378
+ onNotificationItemClick,
213
379
  onLogout,
380
+ locale,
381
+ supportedLocales,
382
+ onLocaleChange,
214
383
  brandWidth,
215
384
  children
216
385
  }) {
217
386
  const currentApp = appCatalog.find((a) => a.id === appId);
218
387
  const brandStyle = brandWidth ? { width: `${brandWidth}px`, flexShrink: 0, boxSizing: "border-box" } : void 0;
219
- return /* @__PURE__ */ jsxs5("header", { className: "cfi-sh-header", children: [
220
- /* @__PURE__ */ jsxs5("div", { className: "cfi-sh-left", children: [
221
- /* @__PURE__ */ jsxs5("div", { className: "cfi-sh-app-brand", style: brandStyle, children: [
222
- currentApp && /* @__PURE__ */ jsx5("span", { className: "cfi-sh-app-badge", style: { background: currentApp.color }, children: currentApp.letter }),
223
- /* @__PURE__ */ jsx5("span", { className: "cfi-sh-app-title", children: currentApp?.name || appId })
388
+ const showLocaleSwitcher = locale !== void 0 && supportedLocales !== void 0 && supportedLocales.length > 0 && onLocaleChange !== void 0;
389
+ return /* @__PURE__ */ jsxs6("header", { className: "cfi-sh-header", children: [
390
+ /* @__PURE__ */ jsxs6("div", { className: "cfi-sh-left", children: [
391
+ /* @__PURE__ */ jsxs6("div", { className: "cfi-sh-app-brand", style: brandStyle, children: [
392
+ currentApp && /* @__PURE__ */ jsx6("span", { className: "cfi-sh-app-badge", style: { background: currentApp.color }, children: currentApp.letter }),
393
+ /* @__PURE__ */ jsx6("span", { className: "cfi-sh-app-title", children: currentApp?.name || appId })
224
394
  ] }),
225
395
  children
226
396
  ] }),
227
- /* @__PURE__ */ jsxs5("div", { className: "cfi-sh-right", children: [
228
- /* @__PURE__ */ jsx5(NotificationBell, { count: notificationCount, onClick: onNotificationClick }),
229
- /* @__PURE__ */ jsx5(AppSwitcher, { currentAppId: appId, authorizedApps }),
230
- /* @__PURE__ */ jsx5(UserMenu, { user, onLogout, color: currentApp?.color })
397
+ /* @__PURE__ */ jsxs6("div", { className: "cfi-sh-right", children: [
398
+ showLocaleSwitcher && /* @__PURE__ */ jsx6(
399
+ LocaleSwitcher,
400
+ {
401
+ currentLocale: locale,
402
+ supportedLocales,
403
+ onLocaleChange
404
+ }
405
+ ),
406
+ /* @__PURE__ */ jsx6(
407
+ NotificationBell,
408
+ {
409
+ count: notificationCount,
410
+ onClick: onNotificationClick,
411
+ items: notifications,
412
+ onMarkRead,
413
+ onMarkAllRead,
414
+ onItemClick: onNotificationItemClick
415
+ }
416
+ ),
417
+ /* @__PURE__ */ jsx6(AppSwitcher, { currentAppId: appId, authorizedApps }),
418
+ /* @__PURE__ */ jsx6(UserMenu, { user, onLogout, color: currentApp?.color })
231
419
  ] })
232
420
  ] });
233
421
  }
234
422
  export {
423
+ LocaleSwitcher,
235
424
  ShellHeader,
236
425
  appCatalog,
237
426
  getAppUrl
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@campfire-interactive/shell-header",
3
- "version": "0.1.5",
3
+ "version": "0.3.3",
4
4
  "description": "Shared shell header with app switcher for Campfire Suite",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",