@xenide-io/the-old-ui-theme 0.9.13 → 0.9.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xenide-io/the-old-ui-theme",
3
- "version": "0.9.13",
3
+ "version": "0.9.14",
4
4
  "description": "Props-driven React components and theme tokens inspired by classic interface design — optimized for Next.js apps and internal tools.",
5
5
  "author": "Xenide",
6
6
  "license": "MIT",
@@ -46,6 +46,39 @@ describe("SuiteIntegrationPicker", () => {
46
46
  expect(screen.queryByText("Linear")).not.toBeInTheDocument();
47
47
  });
48
48
 
49
+ it("filters to bridge-capable apps when Shelly Bridge only is on", async () => {
50
+ const user = userEvent.setup();
51
+
52
+ render(
53
+ <SuiteIntegrationPicker
54
+ open
55
+ items={[
56
+ {
57
+ id: "bridged",
58
+ label: "Bridged app",
59
+ description: "Can bridge into Tides",
60
+ bridgeTargets: ["tides"],
61
+ },
62
+ {
63
+ id: "shelly-only",
64
+ label: "Shelly only app",
65
+ description: "AI only",
66
+ },
67
+ ]}
68
+ onClose={vi.fn()}
69
+ onConnect={vi.fn()}
70
+ />,
71
+ );
72
+
73
+ expect(screen.getByText("Bridged app")).toBeInTheDocument();
74
+ expect(screen.getByText("Shelly only app")).toBeInTheDocument();
75
+
76
+ await user.click(screen.getByLabelText("Shelly Bridge only"));
77
+
78
+ expect(screen.getByText("Bridged app")).toBeInTheDocument();
79
+ expect(screen.queryByText("Shelly only app")).not.toBeInTheDocument();
80
+ });
81
+
49
82
  it("shows auth tags and passes the selected method to the connector", async () => {
50
83
  const user = userEvent.setup();
51
84
  const onConnect = vi.fn();
@@ -59,6 +92,7 @@ describe("SuiteIntegrationPicker", () => {
59
92
  label: "Mixed provider",
60
93
  description: "Supports multiple connection methods",
61
94
  categories: ["calendar", "email"],
95
+ bridgeTargets: ["turtletime", "tides"],
62
96
  authSchemes: ["OAUTH2", "API_KEY"],
63
97
  },
64
98
  ]}
@@ -69,8 +103,8 @@ describe("SuiteIntegrationPicker", () => {
69
103
 
70
104
  expect(screen.getByText("OAuth 2.0")).toBeInTheDocument();
71
105
  expect(screen.getByText("API key")).toBeInTheDocument();
72
- expect(screen.getByLabelText("Works with TurtleTime")).toBeInTheDocument();
73
- expect(screen.getByLabelText("Works with Tides")).toBeInTheDocument();
106
+ expect(screen.getByTitle("Bridge in TurtleTime")).toBeInTheDocument();
107
+ expect(screen.getByTitle("Bridge in Tides")).toBeInTheDocument();
74
108
 
75
109
  await user.click(screen.getByRole("button", { name: "Connect" }));
76
110
  await user.click(screen.getByRole("button", { name: /API key Select/ }));
@@ -10,6 +10,7 @@ import {
10
10
  type ReactNode,
11
11
  } from "react";
12
12
  import {
13
+ Bridge3d,
13
14
  EvPlug as Plug,
14
15
  Filter,
15
16
  NavArrowDown,
@@ -18,8 +19,6 @@ import {
18
19
  Xmark,
19
20
  } from "iconoir-react";
20
21
  import { Button, Input, Modal } from "@/components/ui";
21
- import { SUITE_APPS, SUITE_APP_MAP, type SuiteAppSlug } from "@/suite/lib/apps";
22
- import { SuiteAppIcon } from "@/suite/icons";
23
22
 
24
23
  export interface SuiteIntegrationPickerItem {
25
24
  id: string;
@@ -27,6 +26,8 @@ export interface SuiteIntegrationPickerItem {
27
26
  description: string;
28
27
  icon?: ReactNode;
29
28
  categories?: string[];
29
+ /** Product apps this toolkit can actually bridge. Empty means Shelly AI only. */
30
+ bridgeTargets?: string[];
30
31
  authSchemes?: string[];
31
32
  connecting?: boolean;
32
33
  unavailableReason?: string;
@@ -63,6 +64,13 @@ export interface SuiteIntegrationPickerProps {
63
64
  category?: string;
64
65
  categories?: string[];
65
66
  onCategoryChange?: (value: string) => void;
67
+ /**
68
+ * When provided, the "Shelly Bridge only" filter is controlled by the caller
69
+ * (so it can refetch a bridge-filtered catalogue server-side). When omitted,
70
+ * the picker filters the loaded items locally.
71
+ */
72
+ bridgeOnly?: boolean;
73
+ onBridgeOnlyChange?: (value: boolean) => void;
66
74
  onClose: () => void;
67
75
  onConnect: (
68
76
  item: SuiteIntegrationPickerItem,
@@ -87,7 +95,7 @@ function IntegrationSkeletonCard() {
87
95
  aria-hidden="true"
88
96
  >
89
97
  <div className="flex items-start gap-3">
90
- <div className="h-10 w-10 shrink-0 rounded-lg bg-ph-muted" />
98
+ <div className="h-8 w-8 shrink-0 rounded-md bg-ph-muted" />
91
99
  <div className="min-w-0 flex-1 space-y-2 pt-1">
92
100
  <div className="h-4 w-3/5 rounded bg-ph-muted" />
93
101
  <div className="h-3 w-full rounded bg-ph-muted" />
@@ -128,46 +136,28 @@ function authSchemesFor(item: SuiteIntegrationPickerItem) {
128
136
  );
129
137
  }
130
138
 
131
- /**
132
- * Which ShellStack apps put a connected integration to work, per the deep
133
- * integration plan: calendar/mail feed TurtleTime time entries and Tides
134
- * tasks, code/chat/ticketing feed Tides tasks and Kraken runbooks, docs and
135
- * knowledge sources feed Kraken, CRM/finance feed the Portal. Unmapped
136
- * categories default to Kraken's authorised-source search.
137
- */
138
- const CATEGORY_APP_AFFINITY: Record<string, readonly SuiteAppSlug[]> = {
139
- calendar: ["turtletime", "tides"],
140
- email: ["turtletime", "tides"],
141
- code: ["tides", "kraken"],
142
- chat: ["tides", "kraken"],
143
- documents: ["kraken"],
144
- notes: ["kraken"],
145
- knowledge: ["kraken"],
146
- file_storage: ["kraken"],
147
- crm: ["shellstack", "tides"],
148
- finance: ["shellstack", "tides"],
149
- payment: ["shellstack", "tides"],
150
- monitoring: ["tides", "kraken"],
151
- design: ["tides", "kraken"],
152
- support: ["tides", "shellstack"],
153
- ticketing: ["tides", "kraken"],
139
+ const BRIDGE_MARK: Record<string, { src: string; name: string }> = {
140
+ turtletime: { src: "/turtletime-icon.svg", name: "TurtleTime" },
141
+ tides: { src: "/tides-icon.svg", name: "Tides" },
142
+ kraken: { src: "/kraken-icon.svg", name: "Kraken" },
154
143
  };
155
144
 
156
- const DEFAULT_APP_AFFINITY: readonly SuiteAppSlug[] = ["tides", "kraken"];
157
-
158
145
  export function suiteAppsForIntegration(
159
- categories: readonly string[] | undefined,
160
- ): SuiteAppSlug[] {
161
- const apps = new Set<SuiteAppSlug>();
162
- for (const category of categories ?? []) {
163
- for (const app of CATEGORY_APP_AFFINITY[category.trim().toLowerCase()] ??
164
- []) {
165
- apps.add(app);
166
- }
146
+ itemOrCategories:
147
+ | { bridgeTargets?: readonly string[] }
148
+ | readonly string[]
149
+ | undefined,
150
+ ): string[] {
151
+ if (
152
+ !itemOrCategories ||
153
+ Array.isArray(itemOrCategories) ||
154
+ !("bridgeTargets" in itemOrCategories)
155
+ ) {
156
+ return [];
167
157
  }
168
- return apps.size > 0
169
- ? SUITE_APPS.filter((app) => apps.has(app.slug)).map((app) => app.slug)
170
- : [...DEFAULT_APP_AFFINITY];
158
+ const explicit = itemOrCategories.bridgeTargets;
159
+ if (!Array.isArray(explicit)) return [];
160
+ return explicit.filter((app): app is string => app in BRIDGE_MARK);
171
161
  }
172
162
 
173
163
  export function SuiteIntegrationPicker({
@@ -184,6 +174,8 @@ export function SuiteIntegrationPicker({
184
174
  category: controlledCategory,
185
175
  categories: controlledCategories,
186
176
  onCategoryChange,
177
+ bridgeOnly: controlledBridgeOnly,
178
+ onBridgeOnlyChange,
187
179
  onClose,
188
180
  onConnect,
189
181
  onFetchAuthFields,
@@ -197,6 +189,7 @@ export function SuiteIntegrationPicker({
197
189
  const [categoryQuery, setCategoryQuery] = useState("");
198
190
  const [categoryMenuOpen, setCategoryMenuOpen] = useState(false);
199
191
  const [expanded, setExpanded] = useState<string | null>(null);
192
+ const [bridgeOnlyInternal, setBridgeOnlyInternal] = useState(false);
200
193
  const categoryMenuRef = useRef<HTMLDivElement>(null);
201
194
  const categoryTriggerRef = useRef<HTMLButtonElement>(null);
202
195
  const categorySearchRef = useRef<HTMLInputElement>(null);
@@ -207,6 +200,7 @@ export function SuiteIntegrationPicker({
207
200
  const scrollViewportRef = useRef<HTMLDivElement>(null);
208
201
  const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
209
202
  const loadMoreInFlightRef = useRef(false);
203
+ const sentinelVisibleRef = useRef(false);
210
204
  const authRequestRef = useRef(0);
211
205
  const [methodItem, setMethodItem] =
212
206
  useState<SuiteIntegrationPickerItem | null>(null);
@@ -234,11 +228,16 @@ export function SuiteIntegrationPicker({
234
228
  const categories = controlledCategories ?? itemCategories;
235
229
  const activeSearch = controlledSearch ?? query;
236
230
  const activeCategory = controlledCategory ?? category;
231
+ const bridgeOnly = controlledBridgeOnly ?? bridgeOnlyInternal;
232
+ const setBridgeOnly = (value: boolean) => {
233
+ setBridgeOnlyInternal(value);
234
+ onBridgeOnlyChange?.(value);
235
+ };
237
236
  const categoryLabel = (value: string) =>
238
237
  value
239
238
  .replace(/[-_]/g, " ")
240
239
  .replace(/\b\w/g, (letter) => letter.toUpperCase());
241
- const filtered =
240
+ const catalogItems =
242
241
  onSearchChange || onCategoryChange
243
242
  ? items
244
243
  : items.filter((item) => {
@@ -250,6 +249,10 @@ export function SuiteIntegrationPicker({
250
249
  (item.categories ?? []).includes(activeCategory))
251
250
  );
252
251
  });
252
+ const filtered =
253
+ bridgeOnly && controlledBridgeOnly === undefined
254
+ ? catalogItems.filter((item) => (item.bridgeTargets ?? []).length > 0)
255
+ : catalogItems;
253
256
  const visibleCategories = categories.filter((value) =>
254
257
  categoryLabel(value)
255
258
  .toLowerCase()
@@ -480,27 +483,37 @@ export function SuiteIntegrationPicker({
480
483
  useEffect(() => {
481
484
  const root = scrollViewportRef.current;
482
485
  const sentinel = loadMoreSentinelRef.current;
483
- if (
484
- !open ||
485
- !root ||
486
- !sentinel ||
487
- !hasMore ||
488
- !onEndReached ||
489
- loading ||
490
- catalogLoading ||
491
- loadingMore
492
- ) {
486
+ if (!open || !root || !sentinel) {
487
+ sentinelVisibleRef.current = false;
493
488
  return;
494
489
  }
495
490
 
496
491
  const observer = new IntersectionObserver(
497
492
  ([entry]) => {
498
- if (!entry.isIntersecting || loadMoreInFlightRef.current) return;
493
+ // Only auto-load on the rising edge of the sentinel becoming visible.
494
+ // Without this, shrinking the list (search / category / Shelly Bridge
495
+ // only) leaves the sentinel pinned in view and each re-armed observer
496
+ // fires again, chain-loading the whole catalog page after page.
497
+ if (!entry.isIntersecting) {
498
+ sentinelVisibleRef.current = false;
499
+ return;
500
+ }
501
+ if (sentinelVisibleRef.current) return;
502
+ sentinelVisibleRef.current = true;
503
+ if (
504
+ loadMoreInFlightRef.current ||
505
+ !hasMore ||
506
+ !onEndReached ||
507
+ loading ||
508
+ catalogLoading ||
509
+ loadingMore
510
+ ) {
511
+ return;
512
+ }
499
513
  loadMoreInFlightRef.current = true;
500
- observer.unobserve(entry.target);
501
514
  onEndReached();
502
515
  },
503
- { root, rootMargin: "0px 0px 80px", threshold: 0 },
516
+ { root, rootMargin: "0px 0px 120px", threshold: 0 },
504
517
  );
505
518
 
506
519
  observer.observe(sentinel);
@@ -524,6 +537,24 @@ export function SuiteIntegrationPicker({
524
537
  {connectionError}
525
538
  </p>
526
539
  ) : null}
540
+ <label
541
+ className="inline-flex w-fit cursor-pointer items-center gap-2 text-sm text-ph-ink"
542
+ data-test={`${dataTest}-bridge-only`}
543
+ >
544
+ <input
545
+ type="checkbox"
546
+ role="switch"
547
+ className="ph-toggle"
548
+ checked={bridgeOnly}
549
+ onChange={(event) => setBridgeOnly(event.target.checked)}
550
+ aria-label="Shelly Bridge only"
551
+ />
552
+ <Bridge3d
553
+ className="h-4 w-4 shrink-0 text-ph-subtle"
554
+ aria-hidden
555
+ />
556
+ <span>Shelly Bridge only</span>
557
+ </label>
527
558
  <div className="grid gap-4 md:grid-cols-2">
528
559
  <div className="relative">
529
560
  <Search
@@ -652,6 +683,8 @@ export function SuiteIntegrationPicker({
652
683
  const isExpanded = expanded === item.id;
653
684
  const longDescription =
654
685
  item.description.length > DESCRIPTION_LIMIT;
686
+ const bridgeApps = suiteAppsForIntegration(item);
687
+ const schemes = authSchemesFor(item);
655
688
  const text =
656
689
  isExpanded || !longDescription
657
690
  ? item.description
@@ -671,9 +704,9 @@ export function SuiteIntegrationPicker({
671
704
  aria-expanded={isExpanded}
672
705
  >
673
706
  <span className="flex items-start gap-3">
674
- <span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ph-muted text-ph-ink">
707
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-ph-muted text-ph-ink">
675
708
  {item.icon ?? (
676
- <Plug className="h-5 w-5" aria-hidden />
709
+ <Plug className="h-4 w-4" aria-hidden />
677
710
  )}
678
711
  </span>
679
712
  <span className="min-w-0">
@@ -696,34 +729,33 @@ export function SuiteIntegrationPicker({
696
729
  </span>
697
730
  </span>
698
731
  </button>
699
- {suiteAppsForIntegration(item.categories).length > 0 ? (
732
+ {bridgeApps.length > 0 ? (
700
733
  <div
701
734
  className="mt-3 flex flex-wrap items-center gap-1.5"
702
- aria-label="Works with ShellStack apps"
735
+ aria-label="Bridges into ShellStack apps"
703
736
  >
704
- {suiteAppsForIntegration(item.categories).map(
705
- (app) => (
706
- <span
737
+ {bridgeApps.map((app) => {
738
+ const mark = BRIDGE_MARK[app];
739
+ if (!mark) return null;
740
+ return (
741
+ <img
707
742
  key={app}
743
+ src={mark.src}
744
+ alt=""
745
+ title={`Bridge in ${mark.name}`}
746
+ className="h-5 w-5"
708
747
  data-test={`${dataTest}-${item.id}-app-${app}`}
709
- >
710
- <SuiteAppIcon
711
- app={app}
712
- size={16}
713
- label={`Works with ${SUITE_APP_MAP[app].name}`}
714
- className="rounded-[4px]"
715
- />
716
- </span>
717
- ),
718
- )}
748
+ />
749
+ );
750
+ })}
719
751
  </div>
720
752
  ) : null}
721
- {authSchemesFor(item).length > 0 ? (
753
+ {schemes.length > 0 ? (
722
754
  <div
723
755
  className="mt-3 flex flex-wrap gap-1.5"
724
756
  aria-label="Connection methods"
725
757
  >
726
- {authSchemesFor(item).map((scheme) => (
758
+ {schemes.map((scheme) => (
727
759
  <span
728
760
  key={scheme}
729
761
  className="rounded-full border border-ph-border bg-ph-muted px-2 py-1 text-[0.6875rem] font-medium text-ph-subtle"
@@ -5,6 +5,7 @@ vi.mock("next/navigation", () => ({
5
5
  useRouter: () => ({ push: vi.fn() }),
6
6
  }));
7
7
 
8
+ import { SuiteNotificationsProvider } from "../lib/suite-notifications";
8
9
  import { SuiteNotificationBell } from "./suite-notification-bell";
9
10
 
10
11
  afterEach(() => {
@@ -14,21 +15,24 @@ afterEach(() => {
14
15
  describe("SuiteNotificationBell", () => {
15
16
  it("anchors the unread badge close to the bell glyph", async () => {
16
17
  render(
17
- <SuiteNotificationBell
18
+ <SuiteNotificationsProvider
18
19
  fetchNotifications={async () => ({
19
20
  notifications: [],
20
21
  unread_count: 12,
21
22
  })}
22
23
  markRead={async () => undefined}
23
24
  markAllRead={async () => undefined}
24
- dropdownMenu={({ trigger, "aria-label": label, children }) => (
25
- <div aria-label={label}>
26
- {trigger}
27
- {children}
28
- </div>
29
- )}
30
25
  cacheKey={null}
31
- />,
26
+ >
27
+ <SuiteNotificationBell
28
+ dropdownMenu={({ trigger, "aria-label": label, children }) => (
29
+ <div aria-label={label}>
30
+ {trigger}
31
+ {children}
32
+ </div>
33
+ )}
34
+ />
35
+ </SuiteNotificationsProvider>,
32
36
  );
33
37
 
34
38
  const badge = await waitFor(() => {
@@ -1,36 +1,14 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useState } from "react";
4
- import { useRouter } from "next/navigation";
5
3
  import { Bell } from "iconoir-react";
6
4
 
7
5
  import type { SuiteDropdownMenuComponent } from "../lib/injected";
8
- import {
9
- resolveSuiteNotificationHref,
10
- suiteAppBaseUrl,
11
- suiteAppSlugForNotificationSource,
12
- } from "../lib/apps";
6
+ import { useSuiteNotifications } from "../lib/suite-notifications";
13
7
 
14
- const POLL_MS = 60_000;
15
-
16
- /** Cross-app (suite) notification from `/api/notifications/` — shared across ShellStack apps. */
17
- export interface SuiteNotification {
18
- id: string;
19
- title: string;
20
- body: string;
21
- href: string;
22
- kind: string;
23
- source_app: string;
24
- read_at: string | null;
25
- created_at: string;
26
- workspace: string | null;
27
- organisation: string | null;
28
- }
29
-
30
- export interface SuiteNotificationsResponse {
31
- notifications: SuiteNotification[];
32
- unread_count: number;
33
- }
8
+ export type {
9
+ SuiteNotification,
10
+ SuiteNotificationsResponse,
11
+ } from "../lib/suite-notifications";
34
12
 
35
13
  function timeAgo(iso: string): string {
36
14
  const then = new Date(iso).getTime();
@@ -44,155 +22,30 @@ function timeAgo(iso: string): string {
44
22
  return `${Math.round(hrs / 24)}d ago`;
45
23
  }
46
24
 
25
+ /**
26
+ * Presentational notification bell. State, polling, caching and navigation
27
+ * live in SuiteNotificationsProvider — this just renders the shared context
28
+ * and forwards user intent back to it.
29
+ */
47
30
  export function SuiteNotificationBell({
48
- fetchNotifications,
49
- markRead,
50
- markAllRead,
51
31
  dropdownMenu: DropdownMenu,
52
- cacheKey = "suite-notifications-cache",
53
32
  dataTest = "suite-notifications",
54
33
  triggerId,
55
34
  triggerDataTest,
56
35
  }: {
57
- fetchNotifications: () => Promise<SuiteNotificationsResponse>;
58
- markRead: (id: string) => Promise<unknown>;
59
- markAllRead: () => Promise<unknown>;
60
36
  dropdownMenu: SuiteDropdownMenuComponent;
61
- /** sessionStorage key for stale-while-revalidate; `null` disables caching. */
62
- cacheKey?: string | null;
63
37
  dataTest?: string;
64
38
  triggerId?: string;
65
39
  triggerDataTest?: string;
66
40
  }) {
67
- const router = useRouter();
68
- const [open, setOpen] = useState(false);
69
- const [items, setItems] = useState<SuiteNotification[]>([]);
70
- const [unread, setUnread] = useState(0);
71
-
72
- const load = useCallback(async () => {
73
- try {
74
- const data = await fetchNotifications();
75
- setItems(data.notifications);
76
- setUnread(data.unread_count);
77
- // Stale-while-revalidate: keep last inbox for instant paint next visit.
78
- if (cacheKey) {
79
- try {
80
- sessionStorage.setItem(
81
- cacheKey,
82
- JSON.stringify({
83
- notifications: data.notifications,
84
- unread_count: data.unread_count,
85
- }),
86
- );
87
- } catch {
88
- // ignore quota / private mode
89
- }
90
- }
91
- } catch {
92
- // Non-critical chrome — never surface bell errors to the user.
93
- }
94
- }, [fetchNotifications, cacheKey]);
95
-
96
- useEffect(() => {
97
- if (cacheKey) {
98
- queueMicrotask(() => {
99
- try {
100
- const raw = sessionStorage.getItem(cacheKey);
101
- if (raw) {
102
- const cached = JSON.parse(raw) as {
103
- notifications?: SuiteNotification[];
104
- unread_count?: number;
105
- };
106
- if (Array.isArray(cached.notifications)) {
107
- setItems(cached.notifications);
108
- setUnread(cached.unread_count ?? 0);
109
- }
110
- }
111
- } catch {
112
- // ignore
113
- }
114
- });
115
- }
116
- const kick = window.setTimeout(() => void load(), 0);
117
- const id = window.setInterval(() => void load(), POLL_MS);
118
- return () => {
119
- window.clearTimeout(kick);
120
- window.clearInterval(id);
121
- };
122
- }, [load, cacheKey]);
123
-
124
- const onOpenChange = useCallback(
125
- (next: boolean) => {
126
- setOpen(next);
127
- if (next) void load();
128
- },
129
- [load],
130
- );
131
-
132
- const openItem = useCallback(
133
- async (n: SuiteNotification) => {
134
- setOpen(false);
135
- if (!n.read_at) {
136
- setItems((prev) =>
137
- prev.map((i) =>
138
- i.id === n.id ? { ...i, read_at: new Date().toISOString() } : i,
139
- ),
140
- );
141
- setUnread((u) => Math.max(0, u - 1));
142
- try {
143
- await markRead(n.id);
144
- } catch {
145
- // ignore
146
- }
147
- }
148
- const target = resolveSuiteNotificationHref(n.href, n.source_app);
149
- if (!target) return;
150
- if (/^https?:\/\//i.test(target)) {
151
- try {
152
- const url = new URL(target);
153
- const sourceApp = suiteAppSlugForNotificationSource(n.source_app);
154
- const path = `${url.pathname}${url.search}${url.hash}`;
155
- if (url.origin === window.location.origin) {
156
- router.push(path);
157
- return;
158
- }
159
- if (sourceApp && sourceApp !== "shellstack") {
160
- const sourceOrigin = new URL(suiteAppBaseUrl(sourceApp)).origin;
161
- if (url.origin === sourceOrigin) {
162
- const launchUrl = new URL(
163
- `/launch/${sourceApp}`,
164
- suiteAppBaseUrl("shellstack"),
165
- );
166
- launchUrl.searchParams.set("next", path);
167
- window.location.assign(launchUrl.toString());
168
- return;
169
- }
170
- }
171
- } catch {
172
- // Fall through to the original external link.
173
- }
174
- window.location.assign(target);
175
- return;
176
- }
177
- router.push(target);
178
- },
179
- [router, markRead],
180
- );
181
-
182
- const markAll = useCallback(async () => {
183
- setItems((prev) =>
184
- prev.map((i) => ({
185
- ...i,
186
- read_at: i.read_at ?? new Date().toISOString(),
187
- })),
188
- );
189
- setUnread(0);
190
- try {
191
- await markAllRead();
192
- } catch {
193
- // ignore
194
- }
195
- }, [markAllRead]);
41
+ const {
42
+ notifications,
43
+ unreadCount: unread,
44
+ open,
45
+ setOpen,
46
+ select,
47
+ markAllRead,
48
+ } = useSuiteNotifications();
196
49
 
197
50
  return (
198
51
  <DropdownMenu
@@ -204,7 +57,7 @@ export function SuiteNotificationBell({
204
57
  align="end"
205
58
  panelClassName="w-80 overflow-hidden p-0"
206
59
  open={open}
207
- onOpenChange={onOpenChange}
60
+ onOpenChange={setOpen}
208
61
  trigger={
209
62
  <span className="relative inline-flex h-11 w-11 items-center justify-center overflow-visible rounded-full text-ph-mutedtext transition hover:bg-ph-muted hover:text-ph-ink">
210
63
  <span className="relative inline-flex h-5 w-5">
@@ -225,7 +78,7 @@ export function SuiteNotificationBell({
225
78
  type="button"
226
79
  id={`${dataTest}-mark-all`}
227
80
  data-test={`${dataTest}-mark-all`}
228
- onClick={markAll}
81
+ onClick={markAllRead}
229
82
  className="text-xs font-medium text-ph-brand hover:underline"
230
83
  >
231
84
  Mark all read
@@ -233,17 +86,17 @@ export function SuiteNotificationBell({
233
86
  ) : null}
234
87
  </div>
235
88
  <div className="max-h-80 overflow-y-auto py-1">
236
- {items.length === 0 ? (
89
+ {notifications.length === 0 ? (
237
90
  <p className="px-3 py-6 text-center text-sm text-ph-mutedtext">
238
91
  You&apos;re all caught up.
239
92
  </p>
240
93
  ) : (
241
- items.map((n) => (
94
+ notifications.map((n) => (
242
95
  <button
243
96
  key={n.id}
244
97
  id={`${dataTest}-item-${n.id}`}
245
98
  data-test={`${dataTest}-item-${n.id}`}
246
- onClick={() => void openItem(n)}
99
+ onClick={() => select(n)}
247
100
  className="flex w-full flex-col gap-0.5 px-3 py-2 text-left transition hover:bg-ph-muted"
248
101
  >
249
102
  <span className="flex items-center gap-2">