@open-mercato/ui 0.9.1-develop.7542.1.61a5e7dcbb → 0.9.1-develop.7543.1.4d3d24cb1e

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.
@@ -3,6 +3,9 @@ import * as React from "react";
3
3
  import { apiCall, apiCallOrThrow } from "../utils/apiCall.js";
4
4
  import { useGuardedMutation } from "../injection/useGuardedMutation.js";
5
5
  import { useT } from "@open-mercato/shared/lib/i18n/context";
6
+ import {
7
+ NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE
8
+ } from "@open-mercato/shared/modules/notifications/types";
6
9
  const NOTIFICATION_ACTIONS_CONTEXT_ID = "notifications-actions";
7
10
  function useNotificationActions(notifications, setNotifications, setUnreadCount) {
8
11
  const markAsReadRef = React.useRef(async () => {
@@ -52,9 +55,17 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
52
55
  )
53
56
  );
54
57
  setUnreadCount((prev) => Math.max(0, prev - 1));
58
+ } else if (result.status === 409 && result.result?.code === NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE) {
59
+ const notification = notifications.find((n) => n.id === id);
60
+ setNotifications(
61
+ (prev) => prev.map((n) => n.id === id ? { ...n, status: "actioned" } : n)
62
+ );
63
+ if (notification?.status === "unread") {
64
+ setUnreadCount((prev) => Math.max(0, prev - 1));
65
+ }
55
66
  }
56
67
  return { href: result.result?.href };
57
- }, [runMutation, retryLastMutation, setNotifications, setUnreadCount]);
68
+ }, [notifications, runMutation, retryLastMutation, setNotifications, setUnreadCount]);
58
69
  const dismiss = React.useCallback(
59
70
  async (id) => {
60
71
  await runMutation({
@@ -98,7 +109,7 @@ function useNotificationActions(notifications, setNotifications, setUnreadCount)
98
109
  const next = [
99
110
  {
100
111
  ...dismissUndo.notification,
101
- status: dismissUndo.previousStatus,
112
+ status: dismissUndo.notification.status === "actioned" ? "actioned" : dismissUndo.previousStatus,
102
113
  readAt: dismissUndo.previousStatus === "unread" ? null : dismissUndo.notification.readAt ?? (/* @__PURE__ */ new Date()).toISOString()
103
114
  },
104
115
  ...prev.filter((n) => n.id !== dismissUndo.notification.id)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/notifications/useNotificationActions.ts"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall, apiCallOrThrow } from '../utils/apiCall'\nimport { useGuardedMutation } from '../injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\n\nconst NOTIFICATION_ACTIONS_CONTEXT_ID = 'notifications-actions'\n\ntype NotificationMutationContext = {\n formId: string\n resourceKind: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport type NotificationDismissUndoState = {\n notification: NotificationDto\n previousStatus: 'read' | 'unread'\n} | null\n\ntype SetUnreadCount = React.Dispatch<React.SetStateAction<number>>\n\nexport type NotificationActionsResult = {\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: NotificationDismissUndoState\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n markAsReadRef: React.MutableRefObject<(id: string) => Promise<void>>\n dismissRef: React.MutableRefObject<(id: string) => Promise<void>>\n}\n\nexport function useNotificationActions(\n notifications: NotificationDto[],\n setNotifications: React.Dispatch<React.SetStateAction<NotificationDto[]>>,\n setUnreadCount: SetUnreadCount,\n): NotificationActionsResult {\n const markAsReadRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const dismissRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const [dismissUndo, setDismissUndo] = React.useState<NotificationDismissUndoState>(null)\n const dismissUndoTimerRef = React.useRef<number | null>(null)\n\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<NotificationMutationContext>({\n contextId: NOTIFICATION_ACTIONS_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const markAsRead = React.useCallback(async (id: string) => {\n await runMutation({\n operation: () => apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id },\n })\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n markAsReadRef.current = markAsRead\n }, [markAsRead])\n\n const executeAction = React.useCallback(async (id: string, actionId: string) => {\n const result = await runMutation({\n operation: () =>\n apiCall<{ ok: boolean; href?: string }>(\n `/api/notifications/${id}/action`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ actionId }),\n },\n ),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id, actionId },\n })\n\n if (result.ok) {\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'actioned', actionTaken: actionId } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n\n return { href: result.result?.href }\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n const dismiss = React.useCallback(\n async (id: string) => {\n await runMutation({\n operation: () => apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id },\n })\n const notification = notifications.find((n) => n.id === id)\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n if (notification?.status === 'unread') {\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n if (notification) {\n const previousStatus = notification.status === 'unread' ? 'unread' : 'read'\n setDismissUndo({ notification, previousStatus })\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n dismissUndoTimerRef.current = window.setTimeout(() => {\n setDismissUndo(null)\n }, 6000)\n }\n },\n [notifications, setNotifications, setUnreadCount, runMutation, retryLastMutation],\n )\n\n React.useEffect(() => {\n dismissRef.current = dismiss\n }, [dismiss])\n\n const undoDismiss = React.useCallback(async () => {\n if (!dismissUndo) return\n await runMutation({\n operation: () =>\n apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ status: dismissUndo.previousStatus }),\n }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id: dismissUndo.notification.id, status: dismissUndo.previousStatus },\n })\n\n setNotifications((prev) => {\n const next = [\n {\n ...dismissUndo.notification,\n status: dismissUndo.previousStatus,\n readAt:\n dismissUndo.previousStatus === 'unread'\n ? null\n : dismissUndo.notification.readAt ?? new Date().toISOString(),\n },\n ...prev.filter((n) => n.id !== dismissUndo.notification.id),\n ]\n return next.sort(\n (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n )\n })\n\n if (dismissUndo.previousStatus === 'unread') {\n setUnreadCount((prev) => prev + 1)\n }\n\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n setDismissUndo(null)\n }, [dismissUndo, setNotifications, setUnreadCount, runMutation, retryLastMutation])\n\n const markAllRead = React.useCallback(async () => {\n await runMutation({\n operation: () => apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: {},\n })\n setNotifications((prev) =>\n prev.map((n) =>\n n.status === 'unread'\n ? { ...n, status: 'read', readAt: new Date().toISOString() }\n : n,\n ),\n )\n setUnreadCount(0)\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n return () => {\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n }\n }, [])\n\n return {\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n markAsReadRef,\n dismissRef,\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { apiCall, apiCallOrThrow } from '../utils/apiCall'\nimport { useGuardedMutation } from '../injection/useGuardedMutation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,\n type NotificationDto,\n} from '@open-mercato/shared/modules/notifications/types'\n\nconst NOTIFICATION_ACTIONS_CONTEXT_ID = 'notifications-actions'\n\ntype NotificationMutationContext = {\n formId: string\n resourceKind: string\n retryLastMutation: () => Promise<boolean>\n}\n\nexport type NotificationDismissUndoState = {\n notification: NotificationDto\n previousStatus: 'read' | 'unread'\n} | null\n\ntype SetUnreadCount = React.Dispatch<React.SetStateAction<number>>\n\nexport type NotificationActionsResult = {\n markAsRead: (id: string) => Promise<void>\n executeAction: (id: string, actionId: string) => Promise<{ href?: string }>\n dismiss: (id: string) => Promise<void>\n dismissUndo: NotificationDismissUndoState\n undoDismiss: () => Promise<void>\n markAllRead: () => Promise<void>\n markAsReadRef: React.MutableRefObject<(id: string) => Promise<void>>\n dismissRef: React.MutableRefObject<(id: string) => Promise<void>>\n}\n\nexport function useNotificationActions(\n notifications: NotificationDto[],\n setNotifications: React.Dispatch<React.SetStateAction<NotificationDto[]>>,\n setUnreadCount: SetUnreadCount,\n): NotificationActionsResult {\n const markAsReadRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const dismissRef = React.useRef<(id: string) => Promise<void>>(async () => {})\n const [dismissUndo, setDismissUndo] = React.useState<NotificationDismissUndoState>(null)\n const dismissUndoTimerRef = React.useRef<number | null>(null)\n\n const t = useT()\n const { runMutation, retryLastMutation } = useGuardedMutation<NotificationMutationContext>({\n contextId: NOTIFICATION_ACTIONS_CONTEXT_ID,\n blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),\n })\n\n const markAsRead = React.useCallback(async (id: string) => {\n await runMutation({\n operation: () => apiCallOrThrow(`/api/notifications/${id}/read`, { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id },\n })\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n markAsReadRef.current = markAsRead\n }, [markAsRead])\n\n const executeAction = React.useCallback(async (id: string, actionId: string) => {\n const result = await runMutation({\n operation: () =>\n apiCall<{ ok?: boolean; href?: string; code?: string }>(\n `/api/notifications/${id}/action`,\n {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ actionId }),\n },\n ),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id, actionId },\n })\n\n if (result.ok) {\n setNotifications((prev) =>\n prev.map((n) =>\n n.id === id ? { ...n, status: 'actioned', actionTaken: actionId } : n,\n ),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n } else if (\n result.status === 409 &&\n result.result?.code === NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE\n ) {\n const notification = notifications.find((n) => n.id === id)\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, status: 'actioned' } : n)),\n )\n if (notification?.status === 'unread') {\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n }\n\n return { href: result.result?.href }\n }, [notifications, runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n const dismiss = React.useCallback(\n async (id: string) => {\n await runMutation({\n operation: () => apiCallOrThrow(`/api/notifications/${id}/dismiss`, { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id },\n })\n const notification = notifications.find((n) => n.id === id)\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n if (notification?.status === 'unread') {\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }\n if (notification) {\n const previousStatus = notification.status === 'unread' ? 'unread' : 'read'\n setDismissUndo({ notification, previousStatus })\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n dismissUndoTimerRef.current = window.setTimeout(() => {\n setDismissUndo(null)\n }, 6000)\n }\n },\n [notifications, setNotifications, setUnreadCount, runMutation, retryLastMutation],\n )\n\n React.useEffect(() => {\n dismissRef.current = dismiss\n }, [dismiss])\n\n const undoDismiss = React.useCallback(async () => {\n if (!dismissUndo) return\n await runMutation({\n operation: () =>\n apiCallOrThrow(`/api/notifications/${dismissUndo.notification.id}/restore`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ status: dismissUndo.previousStatus }),\n }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: { id: dismissUndo.notification.id, status: dismissUndo.previousStatus },\n })\n\n setNotifications((prev) => {\n const next = [\n {\n ...dismissUndo.notification,\n status:\n dismissUndo.notification.status === 'actioned' ? 'actioned' : dismissUndo.previousStatus,\n readAt:\n dismissUndo.previousStatus === 'unread'\n ? null\n : dismissUndo.notification.readAt ?? new Date().toISOString(),\n },\n ...prev.filter((n) => n.id !== dismissUndo.notification.id),\n ]\n return next.sort(\n (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),\n )\n })\n\n if (dismissUndo.previousStatus === 'unread') {\n setUnreadCount((prev) => prev + 1)\n }\n\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n setDismissUndo(null)\n }, [dismissUndo, setNotifications, setUnreadCount, runMutation, retryLastMutation])\n\n const markAllRead = React.useCallback(async () => {\n await runMutation({\n operation: () => apiCallOrThrow('/api/notifications/mark-all-read', { method: 'PUT' }),\n context: { formId: NOTIFICATION_ACTIONS_CONTEXT_ID, resourceKind: 'notification', retryLastMutation },\n mutationPayload: {},\n })\n setNotifications((prev) =>\n prev.map((n) =>\n n.status === 'unread'\n ? { ...n, status: 'read', readAt: new Date().toISOString() }\n : n,\n ),\n )\n setUnreadCount(0)\n }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])\n\n React.useEffect(() => {\n return () => {\n if (dismissUndoTimerRef.current) {\n window.clearTimeout(dismissUndoTimerRef.current)\n }\n }\n }, [])\n\n return {\n markAsRead,\n executeAction,\n dismiss,\n dismissUndo,\n undoDismiss,\n markAllRead,\n markAsReadRef,\n dismissRef,\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.9.1-develop.7542.1.61a5e7dcbb",
3
+ "version": "0.9.1-develop.7543.1.4d3d24cb1e",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,14 +155,14 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.9.1-develop.7542.1.61a5e7dcbb",
158
+ "@open-mercato/shared": "0.9.1-develop.7543.1.4d3d24cb1e",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.9.1-develop.7542.1.61a5e7dcbb",
165
+ "@open-mercato/shared": "0.9.1-develop.7543.1.4d3d24cb1e",
166
166
  "@testing-library/dom": "^10.4.1",
167
167
  "@testing-library/jest-dom": "^7.0.1",
168
168
  "@testing-library/react": "^16.3.3",
@@ -32,7 +32,7 @@ type RunMutationInput = {
32
32
  mutationPayload: Record<string, unknown>
33
33
  }
34
34
 
35
- function makeNotification(id: string, status: 'unread' | 'read' = 'unread'): NotificationDto {
35
+ function makeNotification(id: string, status: NotificationDto['status'] = 'unread'): NotificationDto {
36
36
  return {
37
37
  id,
38
38
  type: 'example',
@@ -134,6 +134,91 @@ describe('useNotificationActions guarded mutations', () => {
134
134
  )
135
135
  })
136
136
 
137
+ it.each([
138
+ ['actioned', 'actioned'],
139
+ ['read', 'read'],
140
+ ['unread', 'unread'],
141
+ ] as const)('puts an undone %s notification back as %s', async (status, restoredStatus) => {
142
+ const setNotifications = jest.fn()
143
+ const setUnreadCount = jest.fn()
144
+ const notification = { ...makeNotification('n1', status), readAt: status === 'unread' ? null : '2026-01-01T10:00:00.000Z' }
145
+ const { result } = renderHook(() =>
146
+ useNotificationActions([notification], setNotifications, setUnreadCount),
147
+ )
148
+ await act(async () => {
149
+ await result.current.dismiss('n1')
150
+ })
151
+ setNotifications.mockClear()
152
+ await act(async () => {
153
+ await result.current.undoDismiss()
154
+ })
155
+
156
+ expect(setNotifications).toHaveBeenCalledTimes(1)
157
+ const restore = setNotifications.mock.calls[0][0] as (prev: NotificationDto[]) => NotificationDto[]
158
+ const [restored] = restore([])
159
+ expect(restored.id).toBe('n1')
160
+ expect(restored.status).toBe(restoredStatus)
161
+ expect(restored.readAt ?? null).toBe(notification.readAt)
162
+ })
163
+
164
+ it('marks the local notification actioned when the server reports the action already ran', async () => {
165
+ apiCallMock.mockResolvedValue({
166
+ ok: false,
167
+ status: 409,
168
+ result: { error: 'Notification action already executed', code: 'notification_action_already_executed' },
169
+ })
170
+ const setNotifications = jest.fn()
171
+ const setUnreadCount = jest.fn()
172
+ const notification = makeNotification('n1', 'read')
173
+ const { result } = renderHook(() =>
174
+ useNotificationActions([notification], setNotifications, setUnreadCount),
175
+ )
176
+ await act(async () => {
177
+ await result.current.executeAction('n1', 'approve')
178
+ })
179
+
180
+ expect(setNotifications).toHaveBeenCalledTimes(1)
181
+ const update = setNotifications.mock.calls[0][0] as (prev: NotificationDto[]) => NotificationDto[]
182
+ expect(update([notification, makeNotification('n2', 'read')]).map((n) => n.status)).toEqual(['actioned', 'read'])
183
+ expect(setUnreadCount).not.toHaveBeenCalled()
184
+ })
185
+
186
+ it('decrements the unread count when the server reports an unread notification\'s action already ran', async () => {
187
+ apiCallMock.mockResolvedValue({
188
+ ok: false,
189
+ status: 409,
190
+ result: { error: 'Notification action already executed', code: 'notification_action_already_executed' },
191
+ })
192
+ const setNotifications = jest.fn()
193
+ const setUnreadCount = jest.fn()
194
+ const { result } = renderHook(() =>
195
+ useNotificationActions([makeNotification('n1', 'unread')], setNotifications, setUnreadCount),
196
+ )
197
+ await act(async () => {
198
+ await result.current.executeAction('n1', 'approve')
199
+ })
200
+
201
+ expect(setUnreadCount).toHaveBeenCalledTimes(1)
202
+ const decrement = setUnreadCount.mock.calls[0][0] as (prev: number) => number
203
+ expect(decrement(3)).toBe(2)
204
+ expect(decrement(0)).toBe(0)
205
+ })
206
+
207
+ it('leaves the local notification alone when the action command itself answers 409', async () => {
208
+ apiCallMock.mockResolvedValue({ ok: false, status: 409, result: { error: 'Order was changed by someone else' } })
209
+ const setNotifications = jest.fn()
210
+ const setUnreadCount = jest.fn()
211
+ const { result } = renderHook(() =>
212
+ useNotificationActions([makeNotification('n1', 'read')], setNotifications, setUnreadCount),
213
+ )
214
+ await act(async () => {
215
+ await result.current.executeAction('n1', 'approve')
216
+ })
217
+
218
+ expect(setNotifications).not.toHaveBeenCalled()
219
+ expect(setUnreadCount).not.toHaveBeenCalled()
220
+ })
221
+
137
222
  it('routes markAllRead through the guarded mutation path', async () => {
138
223
  const { result } = renderActions([makeNotification('n1')])
139
224
  await act(async () => {
@@ -3,7 +3,10 @@ import * as React from 'react'
3
3
  import { apiCall, apiCallOrThrow } from '../utils/apiCall'
4
4
  import { useGuardedMutation } from '../injection/useGuardedMutation'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
- import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
6
+ import {
7
+ NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE,
8
+ type NotificationDto,
9
+ } from '@open-mercato/shared/modules/notifications/types'
7
10
 
8
11
  const NOTIFICATION_ACTIONS_CONTEXT_ID = 'notifications-actions'
9
12
 
@@ -68,7 +71,7 @@ export function useNotificationActions(
68
71
  const executeAction = React.useCallback(async (id: string, actionId: string) => {
69
72
  const result = await runMutation({
70
73
  operation: () =>
71
- apiCall<{ ok: boolean; href?: string }>(
74
+ apiCall<{ ok?: boolean; href?: string; code?: string }>(
72
75
  `/api/notifications/${id}/action`,
73
76
  {
74
77
  method: 'POST',
@@ -87,10 +90,21 @@ export function useNotificationActions(
87
90
  ),
88
91
  )
89
92
  setUnreadCount((prev) => Math.max(0, prev - 1))
93
+ } else if (
94
+ result.status === 409 &&
95
+ result.result?.code === NOTIFICATION_ACTION_ALREADY_EXECUTED_ERROR_CODE
96
+ ) {
97
+ const notification = notifications.find((n) => n.id === id)
98
+ setNotifications((prev) =>
99
+ prev.map((n) => (n.id === id ? { ...n, status: 'actioned' } : n)),
100
+ )
101
+ if (notification?.status === 'unread') {
102
+ setUnreadCount((prev) => Math.max(0, prev - 1))
103
+ }
90
104
  }
91
105
 
92
106
  return { href: result.result?.href }
93
- }, [runMutation, retryLastMutation, setNotifications, setUnreadCount])
107
+ }, [notifications, runMutation, retryLastMutation, setNotifications, setUnreadCount])
94
108
 
95
109
  const dismiss = React.useCallback(
96
110
  async (id: string) => {
@@ -139,7 +153,8 @@ export function useNotificationActions(
139
153
  const next = [
140
154
  {
141
155
  ...dismissUndo.notification,
142
- status: dismissUndo.previousStatus,
156
+ status:
157
+ dismissUndo.notification.status === 'actioned' ? 'actioned' : dismissUndo.previousStatus,
143
158
  readAt:
144
159
  dismissUndo.previousStatus === 'unread'
145
160
  ? null