@open-mercato/core 0.5.1-develop.2800.bfe2178a4f → 0.5.1-develop.2802.9223828f7f

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.
@@ -0,0 +1,324 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useQueryClient } from '@tanstack/react-query'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import type { BulkAction } from '@open-mercato/ui/backend/DataTable'
7
+ import type { FilterValues } from '@open-mercato/ui/backend/FilterBar'
8
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
9
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
11
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
12
+ import { toErrorMessage } from './message-detail/utils'
13
+
14
+ export type MessageFolder = 'inbox' | 'sent' | 'drafts' | 'archived' | 'all'
15
+
16
+ type MessageBulkActionId = 'markRead' | 'markUnread' | 'archive' | 'delete'
17
+
18
+ type BulkExecutionSummary = {
19
+ action: MessageBulkActionId
20
+ total: number
21
+ succeeded: number
22
+ failed: number
23
+ }
24
+
25
+ type MessageBulkRequestConfig = {
26
+ method: 'PUT' | 'DELETE'
27
+ buildUrl: (messageId: string) => string
28
+ successKey: string
29
+ successFallback: string
30
+ errorKey: string
31
+ errorFallback: string
32
+ }
33
+
34
+ type MessageInboxBulkMutationContext = {
35
+ actionId: MessageBulkActionId
36
+ messageIds: string[]
37
+ folder: MessageFolder
38
+ page: number
39
+ search: string
40
+ filters: FilterValues
41
+ retryLastMutation: () => Promise<boolean>
42
+ }
43
+
44
+ type UseMessagesInboxBulkActionsInput = {
45
+ folder: MessageFolder
46
+ page: number
47
+ search: string
48
+ filterValues: FilterValues
49
+ }
50
+
51
+ type MessageInboxBulkRow = {
52
+ id: string
53
+ }
54
+
55
+ const MESSAGE_BULK_REQUESTS: Record<MessageBulkActionId, MessageBulkRequestConfig> = {
56
+ markRead: {
57
+ method: 'PUT',
58
+ buildUrl: (messageId) => `/api/messages/${encodeURIComponent(messageId)}/read`,
59
+ successKey: 'messages.bulk.flash.markReadSuccess',
60
+ successFallback: '{count} messages marked as read.',
61
+ errorKey: 'messages.errors.stateChangeFailed',
62
+ errorFallback: 'Failed to update message state.',
63
+ },
64
+ markUnread: {
65
+ method: 'DELETE',
66
+ buildUrl: (messageId) => `/api/messages/${encodeURIComponent(messageId)}/read`,
67
+ successKey: 'messages.bulk.flash.markUnreadSuccess',
68
+ successFallback: '{count} messages marked as unread.',
69
+ errorKey: 'messages.errors.stateChangeFailed',
70
+ errorFallback: 'Failed to update message state.',
71
+ },
72
+ archive: {
73
+ method: 'PUT',
74
+ buildUrl: (messageId) => `/api/messages/${encodeURIComponent(messageId)}/archive`,
75
+ successKey: 'messages.bulk.flash.archiveSuccess',
76
+ successFallback: '{count} messages archived.',
77
+ errorKey: 'messages.errors.stateChangeFailed',
78
+ errorFallback: 'Failed to update message state.',
79
+ },
80
+ delete: {
81
+ method: 'DELETE',
82
+ buildUrl: (messageId) => `/api/messages/${encodeURIComponent(messageId)}`,
83
+ successKey: 'messages.bulk.flash.deleteSuccess',
84
+ successFallback: '{count} messages deleted.',
85
+ errorKey: 'messages.errors.deleteFailed',
86
+ errorFallback: 'Failed to delete message.',
87
+ },
88
+ }
89
+
90
+ function normalizeSelectionScopeValue(value: unknown): unknown {
91
+ if (value == null) return undefined
92
+ if (typeof value === 'string') {
93
+ const trimmed = value.trim()
94
+ return trimmed.length > 0 ? trimmed : undefined
95
+ }
96
+ if (Array.isArray(value)) {
97
+ const normalized = value
98
+ .map((item) => normalizeSelectionScopeValue(item))
99
+ .filter((item) => item !== undefined)
100
+ return normalized.length > 0 ? normalized : undefined
101
+ }
102
+ if (typeof value === 'object') {
103
+ const normalizedEntries = Object.entries(value)
104
+ .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
105
+ .map(([key, nestedValue]) => [key, normalizeSelectionScopeValue(nestedValue)] as const)
106
+ .filter(([, nestedValue]) => nestedValue !== undefined)
107
+ if (normalizedEntries.length === 0) return undefined
108
+ return Object.fromEntries(normalizedEntries)
109
+ }
110
+ return value
111
+ }
112
+
113
+ function buildMessageSelectionScopeKey(
114
+ folder: MessageFolder,
115
+ page: number,
116
+ search: string,
117
+ filterValues: FilterValues,
118
+ ): string {
119
+ return JSON.stringify({
120
+ folder,
121
+ page,
122
+ search: search.trim(),
123
+ filters: normalizeSelectionScopeValue(filterValues) ?? {},
124
+ })
125
+ }
126
+
127
+ async function runWithConcurrency<TItem>(
128
+ items: TItem[],
129
+ limit: number,
130
+ worker: (item: TItem) => Promise<void>,
131
+ ): Promise<PromiseSettledResult<void>[]> {
132
+ if (items.length === 0) return []
133
+
134
+ const results: PromiseSettledResult<void>[] = new Array(items.length)
135
+ let nextIndex = 0
136
+
137
+ const runWorker = async () => {
138
+ while (nextIndex < items.length) {
139
+ const currentIndex = nextIndex
140
+ nextIndex += 1
141
+ try {
142
+ await worker(items[currentIndex])
143
+ results[currentIndex] = { status: 'fulfilled', value: undefined }
144
+ } catch (error) {
145
+ results[currentIndex] = { status: 'rejected', reason: error }
146
+ }
147
+ }
148
+ }
149
+
150
+ await Promise.all(
151
+ Array.from({ length: Math.min(limit, items.length) }, () => runWorker()),
152
+ )
153
+
154
+ return results
155
+ }
156
+
157
+ export function useMessagesInboxBulkActions<T extends MessageInboxBulkRow>({
158
+ folder,
159
+ page,
160
+ search,
161
+ filterValues,
162
+ }: UseMessagesInboxBulkActionsInput): {
163
+ bulkActions: BulkAction<T>[] | undefined
164
+ selectionScopeKey: string
165
+ injectionContext: Record<string, unknown>
166
+ ConfirmDialogElement: React.ReactNode
167
+ } {
168
+ const t = useT()
169
+ const queryClient = useQueryClient()
170
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
171
+ const { runMutation, retryLastMutation } = useGuardedMutation<MessageInboxBulkMutationContext>({
172
+ contextId: 'messages-inbox-bulk-actions',
173
+ })
174
+
175
+ const selectionScopeKey = React.useMemo(
176
+ () => buildMessageSelectionScopeKey(folder, page, search, filterValues),
177
+ [filterValues, folder, page, search],
178
+ )
179
+ const injectionContext = React.useMemo<Record<string, unknown>>(
180
+ () => ({
181
+ folder,
182
+ page,
183
+ search: search.trim(),
184
+ filters: filterValues,
185
+ retryLastMutation,
186
+ }),
187
+ [filterValues, folder, page, retryLastMutation, search],
188
+ )
189
+
190
+ const executeBulkAction = React.useCallback(async (
191
+ actionId: MessageBulkActionId,
192
+ selectedRows: T[],
193
+ ): Promise<boolean> => {
194
+ const messageIds = selectedRows.map((row) => row.id).filter((id) => id.trim().length > 0)
195
+ if (messageIds.length === 0) return false
196
+
197
+ if (actionId === 'delete') {
198
+ const confirmed = await confirm({
199
+ title: t('messages.bulk.delete.title', 'Delete {count} messages?', { count: messageIds.length }),
200
+ description: t('messages.bulk.delete.description', 'This removes the selected messages from your view.'),
201
+ confirmText: t('messages.actions.delete', 'Delete'),
202
+ variant: 'destructive',
203
+ })
204
+ if (!confirmed) return false
205
+ }
206
+
207
+ const requestConfig = MESSAGE_BULK_REQUESTS[actionId]
208
+
209
+ try {
210
+ const summary = await runMutation<BulkExecutionSummary>({
211
+ operation: async () => {
212
+ const results = await runWithConcurrency(messageIds, 5, async (messageId) => {
213
+ const call = await apiCall<{ ok?: boolean }>(requestConfig.buildUrl(messageId), {
214
+ method: requestConfig.method,
215
+ })
216
+ if (!call.ok) {
217
+ throw new Error(
218
+ toErrorMessage(call.result)
219
+ ?? t(requestConfig.errorKey, requestConfig.errorFallback),
220
+ )
221
+ }
222
+ })
223
+
224
+ const failed = results.filter((result) => result.status === 'rejected').length
225
+ const succeeded = results.length - failed
226
+
227
+ if (succeeded > 0) {
228
+ await queryClient.invalidateQueries({ queryKey: ['messages', 'list'] })
229
+ }
230
+
231
+ return {
232
+ action: actionId,
233
+ total: messageIds.length,
234
+ succeeded,
235
+ failed,
236
+ }
237
+ },
238
+ context: {
239
+ actionId,
240
+ messageIds,
241
+ folder,
242
+ page,
243
+ search: search.trim(),
244
+ filters: filterValues,
245
+ retryLastMutation,
246
+ },
247
+ mutationPayload: {
248
+ actionId,
249
+ messageIds,
250
+ },
251
+ })
252
+
253
+ if (summary.succeeded === 0) {
254
+ flash(
255
+ t('messages.bulk.flash.failed', 'Failed to process {count} messages.', { count: summary.failed }),
256
+ 'error',
257
+ )
258
+ return false
259
+ }
260
+
261
+ if (summary.failed > 0) {
262
+ flash(
263
+ t('messages.bulk.flash.partial', '{succeeded} of {total} messages processed; {failed} failed.', {
264
+ succeeded: summary.succeeded,
265
+ total: summary.total,
266
+ failed: summary.failed,
267
+ }),
268
+ 'warning',
269
+ )
270
+ return true
271
+ }
272
+
273
+ flash(
274
+ t(requestConfig.successKey, requestConfig.successFallback, { count: summary.succeeded }),
275
+ 'success',
276
+ )
277
+ return true
278
+ } catch (error) {
279
+ flash(
280
+ error instanceof Error
281
+ ? error.message
282
+ : t(requestConfig.errorKey, requestConfig.errorFallback),
283
+ 'error',
284
+ )
285
+ return false
286
+ }
287
+ }, [confirm, filterValues, folder, page, queryClient, retryLastMutation, runMutation, search, t])
288
+
289
+ const bulkActions = React.useMemo<BulkAction<T>[] | undefined>(
290
+ () => folder === 'inbox'
291
+ ? [
292
+ {
293
+ id: 'messages-mark-read',
294
+ label: t('messages.actions.markRead', 'Mark read'),
295
+ onExecute: (selectedRows: T[]) => executeBulkAction('markRead', selectedRows),
296
+ },
297
+ {
298
+ id: 'messages-mark-unread',
299
+ label: t('messages.actions.markUnread', 'Mark unread'),
300
+ onExecute: (selectedRows: T[]) => executeBulkAction('markUnread', selectedRows),
301
+ },
302
+ {
303
+ id: 'messages-archive',
304
+ label: t('messages.actions.archive', 'Archive'),
305
+ onExecute: (selectedRows: T[]) => executeBulkAction('archive', selectedRows),
306
+ },
307
+ {
308
+ id: 'messages-delete',
309
+ label: t('messages.actions.delete', 'Delete'),
310
+ destructive: true,
311
+ onExecute: (selectedRows: T[]) => executeBulkAction('delete', selectedRows),
312
+ },
313
+ ]
314
+ : undefined,
315
+ [executeBulkAction, folder, t],
316
+ )
317
+
318
+ return {
319
+ bulkActions,
320
+ selectionScopeKey,
321
+ injectionContext,
322
+ ConfirmDialogElement,
323
+ }
324
+ }
@@ -24,6 +24,14 @@
24
24
  "messages.badge.unread": "{count} ungelesene Nachrichten",
25
25
  "messages.body": "Nachricht",
26
26
  "messages.bodyFormat.toggle": "Markdown umschalten",
27
+ "messages.bulk.delete.description": "Dadurch werden die ausgewählten Nachrichten aus Ihrer Ansicht entfernt.",
28
+ "messages.bulk.delete.title": "{count} Nachrichten löschen?",
29
+ "messages.bulk.flash.archiveSuccess": "{count} Nachrichten archiviert.",
30
+ "messages.bulk.flash.deleteSuccess": "{count} Nachrichten gelöscht.",
31
+ "messages.bulk.flash.failed": "{count} Nachrichten konnten nicht verarbeitet werden.",
32
+ "messages.bulk.flash.markReadSuccess": "{count} Nachrichten als gelesen markiert.",
33
+ "messages.bulk.flash.markUnreadSuccess": "{count} Nachrichten als ungelesen markiert.",
34
+ "messages.bulk.flash.partial": "{succeeded} von {total} Nachrichten verarbeitet; {failed} fehlgeschlagen.",
27
35
  "messages.compose": "Nachricht verfassen",
28
36
  "messages.composeHint": "Erstelle eine Nachricht, hänge Objekte an und sende sie an ausgewählte Empfänger.",
29
37
  "messages.composer.attachmentPicker.confirm": "Auswahl anhängen",
@@ -24,6 +24,14 @@
24
24
  "messages.badge.unread": "{count} unread messages",
25
25
  "messages.body": "Message",
26
26
  "messages.bodyFormat.toggle": "Toggle markdown",
27
+ "messages.bulk.delete.description": "This removes the selected messages from your view.",
28
+ "messages.bulk.delete.title": "Delete {count} messages?",
29
+ "messages.bulk.flash.archiveSuccess": "{count} messages archived.",
30
+ "messages.bulk.flash.deleteSuccess": "{count} messages deleted.",
31
+ "messages.bulk.flash.failed": "Failed to process {count} messages.",
32
+ "messages.bulk.flash.markReadSuccess": "{count} messages marked as read.",
33
+ "messages.bulk.flash.markUnreadSuccess": "{count} messages marked as unread.",
34
+ "messages.bulk.flash.partial": "{succeeded} of {total} messages processed; {failed} failed.",
27
35
  "messages.compose": "Compose message",
28
36
  "messages.composeHint": "Create a message, attach objects, and send it to selected recipients.",
29
37
  "messages.composer.attachmentPicker.confirm": "Attach selected",
@@ -24,6 +24,14 @@
24
24
  "messages.badge.unread": "{count} mensajes sin leer",
25
25
  "messages.body": "Mensaje",
26
26
  "messages.bodyFormat.toggle": "Alternar Markdown",
27
+ "messages.bulk.delete.description": "Esto elimina los mensajes seleccionados de tu vista.",
28
+ "messages.bulk.delete.title": "¿Eliminar {count} mensajes?",
29
+ "messages.bulk.flash.archiveSuccess": "{count} mensajes archivados.",
30
+ "messages.bulk.flash.deleteSuccess": "{count} mensajes eliminados.",
31
+ "messages.bulk.flash.failed": "No se pudieron procesar {count} mensajes.",
32
+ "messages.bulk.flash.markReadSuccess": "{count} mensajes marcados como leídos.",
33
+ "messages.bulk.flash.markUnreadSuccess": "{count} mensajes marcados como no leídos.",
34
+ "messages.bulk.flash.partial": "Se procesaron {succeeded} de {total} mensajes; {failed} fallaron.",
27
35
  "messages.compose": "Redactar mensaje",
28
36
  "messages.composeHint": "Crea un mensaje, adjunta objetos y envíalo a los destinatarios seleccionados.",
29
37
  "messages.composer.attachmentPicker.confirm": "Adjuntar seleccionados",
@@ -24,6 +24,14 @@
24
24
  "messages.badge.unread": "{count} nieprzeczytanych wiadomości",
25
25
  "messages.body": "Wiadomość",
26
26
  "messages.bodyFormat.toggle": "Przełącz Markdown",
27
+ "messages.bulk.delete.description": "To usuwa wybrane wiadomości z Twojego widoku.",
28
+ "messages.bulk.delete.title": "Usunąć {count} wiadomości?",
29
+ "messages.bulk.flash.archiveSuccess": "Zarchiwizowano {count} wiadomości.",
30
+ "messages.bulk.flash.deleteSuccess": "Usunięto {count} wiadomości.",
31
+ "messages.bulk.flash.failed": "Nie udało się przetworzyć {count} wiadomości.",
32
+ "messages.bulk.flash.markReadSuccess": "Oznaczono {count} wiadomości jako przeczytane.",
33
+ "messages.bulk.flash.markUnreadSuccess": "Oznaczono {count} wiadomości jako nieprzeczytane.",
34
+ "messages.bulk.flash.partial": "Przetworzono {succeeded} z {total} wiadomości; niepowodzeń: {failed}.",
27
35
  "messages.compose": "Napisz wiadomość",
28
36
  "messages.composeHint": "Utwórz wiadomość, dołącz obiekty i wyślij do wybranych odbiorców.",
29
37
  "messages.composer.attachmentPicker.confirm": "Dołącz wybrane",