@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/messages/useMessageComposeOperations.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type {\n MessageComposerContextObject,\n MessageComposerProps,\n MessageTypeItem,\n} from './message-composer.types'\nimport type { MessagePriority } from './message-priority'\n\ntype Translator = ReturnType<typeof useT>\n\ntype MessageRecipient = {\n userId: string\n type: 'to'\n}\n\ntype SubmitRequest = {\n endpoint: string\n method: 'POST' | 'PATCH'\n payload: Record<string, unknown>\n}\n\ntype BaseOperation = {\n validate: () => string | null\n buildRequest: (input: { attachmentIds: string[] }) => SubmitRequest\n successMessage: string\n requiresAttachmentRefresh: boolean\n}\n\ntype ComposeOperationParams = {\n t: Translator\n messageType: string\n createableMessageTypes: MessageTypeItem[]\n priority: MessagePriority\n visibility: 'public' | 'internal'\n externalEmail: string\n recipientIds: string[]\n subject: string\n body: string\n bodyFormat: 'text' | 'markdown'\n sendViaEmail: boolean\n contextObject: MessageComposerContextObject | null\n defaultValues: MessageComposerProps['defaultValues']\n contextActionOptions: Array<{ id: string; label: string }>\n normalizedRequiredActionMode: 'none' | 'optional' | 'required'\n shouldShowContextActions: boolean\n contextActionRequired: boolean\n contextActionType: string\n}\n\ntype ReplyOperationParams = {\n t: Translator\n messageId?: string\n body: string\n bodyFormat: 'text' | 'markdown'\n replyAll: boolean\n recipientIds: string[]\n sendViaEmail: boolean\n}\n\ntype ForwardOperationParams = {\n t: Translator\n messageId?: string\n recipientIds: string[]\n body: string\n includeAttachments: boolean\n sendViaEmail: boolean\n}\n\ntype DraftOperationParams = Omit<ComposeOperationParams, 'createableMessageTypes'>\n & { messageId?: string }\n\nfunction isValidEmailAddress(value: string): boolean {\n const email = value.trim()\n if (!email || email.length > 254) return false\n\n const atIndex = email.indexOf('@')\n if (atIndex <= 0 || atIndex !== email.lastIndexOf('@') || atIndex === email.length - 1) return false\n\n for (const char of email) {\n if (char === ' ' || char === '\\t' || char === '\\n' || char === '\\r') return false\n }\n\n const localPart = email.slice(0, atIndex)\n const domainPart = email.slice(atIndex + 1)\n if (!localPart || !domainPart || localPart.length > 64) return false\n if (domainPart.length > 253 || !domainPart.includes('.')) return false\n if (domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..')) return false\n\n const domainLabels = domainPart.split('.')\n for (const label of domainLabels) {\n if (!label) return false\n if (label.startsWith('-') || label.endsWith('-')) return false\n }\n\n return true\n}\n\nfunction mapRecipients(recipientIds: string[]): MessageRecipient[] {\n return recipientIds.map((userId) => ({ userId, type: 'to' }))\n}\n\nfunction buildComposePayload(\n params: ComposeOperationParams | DraftOperationParams,\n input: { attachmentIds: string[]; isDraft: boolean },\n): Record<string, unknown> {\n const publicMessage = params.visibility === 'public'\n const sourceEntityType = params.contextObject?.sourceEntityType ?? params.defaultValues?.sourceEntityType\n const sourceEntityId = params.contextObject?.sourceEntityId ?? params.defaultValues?.sourceEntityId\n const selectedContextActionOption = params.contextActionOptions.find((option) => option.id === params.contextActionType)\n const resolvedContextActionRequired = params.shouldShowContextActions\n ? (params.normalizedRequiredActionMode === 'required' ? true : params.contextActionRequired)\n : (params.contextObject?.actionRequired ?? false)\n const resolvedContextActionType = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? params.contextActionType : params.contextObject?.actionType)\n : undefined\n const resolvedContextActionLabel = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? selectedContextActionOption?.label : params.contextObject?.actionLabel)\n : undefined\n const contextObjects = params.contextObject\n ? [{\n entityModule: params.contextObject.entityModule,\n entityType: params.contextObject.entityType,\n entityId: params.contextObject.entityId,\n actionRequired: resolvedContextActionRequired,\n actionType: resolvedContextActionType,\n actionLabel: resolvedContextActionLabel,\n }]\n : undefined\n\n return {\n type: params.messageType,\n priority: params.priority,\n visibility: params.visibility,\n externalEmail: publicMessage ? params.externalEmail.trim() : undefined,\n externalName: undefined,\n recipients: publicMessage ? [] : mapRecipients(params.recipientIds),\n subject: params.subject.trim(),\n body: params.body,\n bodyFormat: params.bodyFormat,\n sourceEntityType: sourceEntityType ?? undefined,\n sourceEntityId: sourceEntityId ?? undefined,\n objects: contextObjects,\n attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n isDraft: input.isDraft,\n }\n}\n\nexport function useComposeSendOperation(params: ComposeOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.createableMessageTypes.some((item) => item.type === params.messageType)) {\n return params.t('messages.errors.sendFailed', 'Failed to send message.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'required'\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'optional'\n && params.contextActionRequired\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: '/api/messages',\n method: 'POST',\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useComposeDraftOperation(params: DraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const payload = buildComposePayload(params, { attachmentIds, isDraft: true })\n if (!params.messageId) {\n return {\n endpoint: '/api/messages',\n method: 'POST',\n payload,\n }\n }\n\n const draftUpdatePayload = { ...payload }\n delete draftUpdatePayload.isDraft\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH',\n payload: draftUpdatePayload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useReplySubmitOperation(params: ReplyOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/reply`,\n method: 'POST',\n payload: {\n body: params.body,\n bodyFormat: params.bodyFormat,\n replyAll: params.replyAll,\n recipients: params.recipientIds.length > 0 ? mapRecipients(params.recipientIds) : undefined,\n attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.replySuccess', 'Reply sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useForwardSubmitOperation(params: ForwardOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: () => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/forward`,\n method: 'POST',\n payload: {\n recipients: mapRecipients(params.recipientIds),\n body: params.body,\n includeAttachments: params.includeAttachments,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.forwardSuccess', 'Message forwarded.'),\n requiresAttachmentRefresh: false,\n }), [params])\n}\n\ntype SendDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useSendDraftOperation(params: SendDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\ntype UpdateDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useUpdateDraftOperation(params: UpdateDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const { isDraft: _omit, ...payload } = buildComposePayload(params, { attachmentIds, isDraft: true })\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport type MessageComposeSubmitOperation = BaseOperation\n"],
|
|
5
|
-
"mappings": "AAAA,YAAY,WAAW;
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type {\n MessageComposerContextObject,\n MessageComposerProps,\n MessageTypeItem,\n} from './message-composer.types'\nimport type { MessagePriority } from './message-priority'\n\ntype Translator = ReturnType<typeof useT>\n\ntype MessageRecipient = {\n userId: string\n type: 'to'\n}\n\ntype SubmitRequest = {\n endpoint: string\n method: 'POST' | 'PATCH'\n payload: Record<string, unknown>\n}\n\ntype BaseOperation = {\n validate: () => string | null\n buildRequest: (input: { attachmentIds: string[] }) => SubmitRequest\n successMessage: string\n requiresAttachmentRefresh: boolean\n}\n\ntype ComposeOperationParams = {\n t: Translator\n messageType: string\n createableMessageTypes: MessageTypeItem[]\n priority: MessagePriority\n visibility: 'public' | 'internal'\n externalEmail: string\n recipientIds: string[]\n subject: string\n body: string\n bodyFormat: 'text' | 'markdown'\n sendViaEmail: boolean\n /**\n * Chosen alternative sender, or empty for the platform sender. Sent only on a\n * real send: a draft is not delivered, so routing it is meaningless.\n */\n senderChannelId: string\n contextObject: MessageComposerContextObject | null\n defaultValues: MessageComposerProps['defaultValues']\n contextActionOptions: Array<{ id: string; label: string }>\n normalizedRequiredActionMode: 'none' | 'optional' | 'required'\n shouldShowContextActions: boolean\n contextActionRequired: boolean\n contextActionType: string\n}\n\ntype ReplyOperationParams = {\n t: Translator\n messageId?: string\n body: string\n bodyFormat: 'text' | 'markdown'\n replyAll: boolean\n recipientIds: string[]\n sendViaEmail: boolean\n}\n\ntype ForwardOperationParams = {\n t: Translator\n messageId?: string\n recipientIds: string[]\n body: string\n includeAttachments: boolean\n sendViaEmail: boolean\n}\n\ntype DraftOperationParams = Omit<ComposeOperationParams, 'createableMessageTypes'>\n & { messageId?: string }\n\nfunction isValidEmailAddress(value: string): boolean {\n const email = value.trim()\n if (!email || email.length > 254) return false\n\n const atIndex = email.indexOf('@')\n if (atIndex <= 0 || atIndex !== email.lastIndexOf('@') || atIndex === email.length - 1) return false\n\n for (const char of email) {\n if (char === ' ' || char === '\\t' || char === '\\n' || char === '\\r') return false\n }\n\n const localPart = email.slice(0, atIndex)\n const domainPart = email.slice(atIndex + 1)\n if (!localPart || !domainPart || localPart.length > 64) return false\n if (domainPart.length > 253 || !domainPart.includes('.')) return false\n if (domainPart.startsWith('.') || domainPart.endsWith('.') || domainPart.includes('..')) return false\n\n const domainLabels = domainPart.split('.')\n for (const label of domainLabels) {\n if (!label) return false\n if (label.startsWith('-') || label.endsWith('-')) return false\n }\n\n return true\n}\n\nfunction mapRecipients(recipientIds: string[]): MessageRecipient[] {\n return recipientIds.map((userId) => ({ userId, type: 'to' }))\n}\n\nfunction buildComposePayload(\n params: ComposeOperationParams | DraftOperationParams,\n input: { attachmentIds: string[]; isDraft: boolean },\n): Record<string, unknown> {\n const publicMessage = params.visibility === 'public'\n const sourceEntityType = params.contextObject?.sourceEntityType ?? params.defaultValues?.sourceEntityType\n const sourceEntityId = params.contextObject?.sourceEntityId ?? params.defaultValues?.sourceEntityId\n const selectedContextActionOption = params.contextActionOptions.find((option) => option.id === params.contextActionType)\n const resolvedContextActionRequired = params.shouldShowContextActions\n ? (params.normalizedRequiredActionMode === 'required' ? true : params.contextActionRequired)\n : (params.contextObject?.actionRequired ?? false)\n const resolvedContextActionType = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? params.contextActionType : params.contextObject?.actionType)\n : undefined\n const resolvedContextActionLabel = resolvedContextActionRequired\n ? (params.shouldShowContextActions ? selectedContextActionOption?.label : params.contextObject?.actionLabel)\n : undefined\n const contextObjects = params.contextObject\n ? [{\n entityModule: params.contextObject.entityModule,\n entityType: params.contextObject.entityType,\n entityId: params.contextObject.entityId,\n actionRequired: resolvedContextActionRequired,\n actionType: resolvedContextActionType,\n actionLabel: resolvedContextActionLabel,\n }]\n : undefined\n\n return {\n type: params.messageType,\n priority: params.priority,\n visibility: params.visibility,\n externalEmail: publicMessage ? params.externalEmail.trim() : undefined,\n externalName: undefined,\n recipients: publicMessage ? [] : mapRecipients(params.recipientIds),\n subject: params.subject.trim(),\n body: params.body,\n bodyFormat: params.bodyFormat,\n sourceEntityType: sourceEntityType ?? undefined,\n sourceEntityId: sourceEntityId ?? undefined,\n objects: contextObjects,\n attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n senderChannelId: !input.isDraft && publicMessage && params.senderChannelId\n ? params.senderChannelId\n : undefined,\n isDraft: input.isDraft,\n }\n}\n\nexport function useComposeSendOperation(params: ComposeOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.createableMessageTypes.some((item) => item.type === params.messageType)) {\n return params.t('messages.errors.sendFailed', 'Failed to send message.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'required'\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n if (\n params.shouldShowContextActions\n && params.normalizedRequiredActionMode === 'optional'\n && params.contextActionRequired\n && !params.contextActionType\n ) {\n return params.t('messages.composer.objectPicker.errors.actionRequired', 'Select an action.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: '/api/messages',\n method: 'POST',\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useComposeDraftOperation(params: DraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const payload = buildComposePayload(params, { attachmentIds, isDraft: true })\n if (!params.messageId) {\n return {\n endpoint: '/api/messages',\n method: 'POST',\n payload,\n }\n }\n\n const draftUpdatePayload = { ...payload }\n delete draftUpdatePayload.isDraft\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH',\n payload: draftUpdatePayload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useReplySubmitOperation(params: ReplyOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/reply`,\n method: 'POST',\n payload: {\n body: params.body,\n bodyFormat: params.bodyFormat,\n replyAll: params.replyAll,\n recipients: params.recipientIds.length > 0 ? mapRecipients(params.recipientIds) : undefined,\n attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.replySuccess', 'Reply sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport function useForwardSubmitOperation(params: ForwardOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (!params.messageId) {\n return params.t('messages.errors.invalidContext', 'Message context is missing.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: () => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId ?? '')}/forward`,\n method: 'POST',\n payload: {\n recipients: mapRecipients(params.recipientIds),\n body: params.body,\n includeAttachments: params.includeAttachments,\n sendViaEmail: params.sendViaEmail,\n },\n }),\n successMessage: params.t('messages.flash.forwardSuccess', 'Message forwarded.'),\n requiresAttachmentRefresh: false,\n }), [params])\n}\n\ntype SendDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useSendDraftOperation(params: SendDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => {\n if (params.visibility !== 'public' && params.recipientIds.length === 0) {\n return params.t('messages.errors.noRecipients', 'Please add at least one recipient.')\n }\n if (params.visibility === 'public' && !isValidEmailAddress(params.externalEmail.trim())) {\n return params.t('messages.errors.noExternalEmail', 'Please enter a valid external email.')\n }\n if (!params.subject.trim()) {\n return params.t('messages.errors.noSubject', 'Please enter a subject.')\n }\n if (!params.body.trim()) {\n return params.t('messages.errors.noBody', 'Please enter a message.')\n }\n return null\n },\n buildRequest: ({ attachmentIds }) => ({\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload: buildComposePayload(params, { attachmentIds, isDraft: false }),\n }),\n successMessage: params.t('messages.flash.sentSuccess', 'Message sent.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\ntype UpdateDraftOperationParams = DraftOperationParams & { messageId: string }\n\nexport function useUpdateDraftOperation(params: UpdateDraftOperationParams): BaseOperation {\n return React.useMemo(() => ({\n validate: () => null,\n buildRequest: ({ attachmentIds }) => {\n const { isDraft: _omit, ...payload } = buildComposePayload(params, { attachmentIds, isDraft: true })\n return {\n endpoint: `/api/messages/${encodeURIComponent(params.messageId)}`,\n method: 'PATCH' as const,\n payload,\n }\n },\n successMessage: params.t('messages.flash.draftSaved', 'Draft saved.'),\n requiresAttachmentRefresh: true,\n }), [params])\n}\n\nexport type MessageComposeSubmitOperation = BaseOperation\n"],
|
|
5
|
+
"mappings": "AAAA,YAAY,WAAW;AA6EvB,SAAS,oBAAoB,OAAwB;AACnD,QAAM,QAAQ,MAAM,KAAK;AACzB,MAAI,CAAC,SAAS,MAAM,SAAS,IAAK,QAAO;AAEzC,QAAM,UAAU,MAAM,QAAQ,GAAG;AACjC,MAAI,WAAW,KAAK,YAAY,MAAM,YAAY,GAAG,KAAK,YAAY,MAAM,SAAS,EAAG,QAAO;AAE/F,aAAW,QAAQ,OAAO;AACxB,QAAI,SAAS,OAAO,SAAS,OAAQ,SAAS,QAAQ,SAAS,KAAM,QAAO;AAAA,EAC9E;AAEA,QAAM,YAAY,MAAM,MAAM,GAAG,OAAO;AACxC,QAAM,aAAa,MAAM,MAAM,UAAU,CAAC;AAC1C,MAAI,CAAC,aAAa,CAAC,cAAc,UAAU,SAAS,GAAI,QAAO;AAC/D,MAAI,WAAW,SAAS,OAAO,CAAC,WAAW,SAAS,GAAG,EAAG,QAAO;AACjE,MAAI,WAAW,WAAW,GAAG,KAAK,WAAW,SAAS,GAAG,KAAK,WAAW,SAAS,IAAI,EAAG,QAAO;AAEhG,QAAM,eAAe,WAAW,MAAM,GAAG;AACzC,aAAW,SAAS,cAAc;AAChC,QAAI,CAAC,MAAO,QAAO;AACnB,QAAI,MAAM,WAAW,GAAG,KAAK,MAAM,SAAS,GAAG,EAAG,QAAO;AAAA,EAC3D;AAEA,SAAO;AACT;AAEA,SAAS,cAAc,cAA4C;AACjE,SAAO,aAAa,IAAI,CAAC,YAAY,EAAE,QAAQ,MAAM,KAAK,EAAE;AAC9D;AAEA,SAAS,oBACP,QACA,OACyB;AACzB,QAAM,gBAAgB,OAAO,eAAe;AAC5C,QAAM,mBAAmB,OAAO,eAAe,oBAAoB,OAAO,eAAe;AACzF,QAAM,iBAAiB,OAAO,eAAe,kBAAkB,OAAO,eAAe;AACrF,QAAM,8BAA8B,OAAO,qBAAqB,KAAK,CAAC,WAAW,OAAO,OAAO,OAAO,iBAAiB;AACvH,QAAM,gCAAgC,OAAO,2BACxC,OAAO,iCAAiC,aAAa,OAAO,OAAO,wBACnE,OAAO,eAAe,kBAAkB;AAC7C,QAAM,4BAA4B,gCAC7B,OAAO,2BAA2B,OAAO,oBAAoB,OAAO,eAAe,aACpF;AACJ,QAAM,6BAA6B,gCAC9B,OAAO,2BAA2B,6BAA6B,QAAQ,OAAO,eAAe,cAC9F;AACJ,QAAM,iBAAiB,OAAO,gBAC1B,CAAC;AAAA,IACD,cAAc,OAAO,cAAc;AAAA,IACnC,YAAY,OAAO,cAAc;AAAA,IACjC,UAAU,OAAO,cAAc;AAAA,IAC/B,gBAAgB;AAAA,IAChB,YAAY;AAAA,IACZ,aAAa;AAAA,EACf,CAAC,IACC;AAEJ,SAAO;AAAA,IACL,MAAM,OAAO;AAAA,IACb,UAAU,OAAO;AAAA,IACjB,YAAY,OAAO;AAAA,IACnB,eAAe,gBAAgB,OAAO,cAAc,KAAK,IAAI;AAAA,IAC7D,cAAc;AAAA,IACd,YAAY,gBAAgB,CAAC,IAAI,cAAc,OAAO,YAAY;AAAA,IAClE,SAAS,OAAO,QAAQ,KAAK;AAAA,IAC7B,MAAM,OAAO;AAAA,IACb,YAAY,OAAO;AAAA,IACnB,kBAAkB,oBAAoB;AAAA,IACtC,gBAAgB,kBAAkB;AAAA,IAClC,SAAS;AAAA,IACT,eAAe,MAAM,cAAc,SAAS,IAAI,MAAM,gBAAgB;AAAA,IACtE,cAAc,OAAO;AAAA,IACrB,iBAAiB,CAAC,MAAM,WAAW,iBAAiB,OAAO,kBACvD,OAAO,kBACP;AAAA,IACJ,SAAS,MAAM;AAAA,EACjB;AACF;AAEO,SAAS,wBAAwB,QAA+C;AACrF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,OAAO,eAAe,YAAY,OAAO,aAAa,WAAW,GAAG;AACtE,eAAO,OAAO,EAAE,gCAAgC,oCAAoC;AAAA,MACtF;AACA,UAAI,OAAO,eAAe,YAAY,CAAC,oBAAoB,OAAO,cAAc,KAAK,CAAC,GAAG;AACvF,eAAO,OAAO,EAAE,mCAAmC,sCAAsC;AAAA,MAC3F;AACA,UAAI,CAAC,OAAO,QAAQ,KAAK,GAAG;AAC1B,eAAO,OAAO,EAAE,6BAA6B,yBAAyB;AAAA,MACxE;AACA,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,UAAI,CAAC,OAAO,uBAAuB,KAAK,CAAC,SAAS,KAAK,SAAS,OAAO,WAAW,GAAG;AACnF,eAAO,OAAO,EAAE,8BAA8B,yBAAyB;AAAA,MACzE;AACA,UACE,OAAO,4BACJ,OAAO,iCAAiC,cACxC,CAAC,OAAO,mBACX;AACA,eAAO,OAAO,EAAE,wDAAwD,mBAAmB;AAAA,MAC7F;AACA,UACE,OAAO,4BACJ,OAAO,iCAAiC,cACxC,OAAO,yBACP,CAAC,OAAO,mBACX;AACA,eAAO,OAAO,EAAE,wDAAwD,mBAAmB;AAAA,MAC7F;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,CAAC,EAAE,cAAc,OAAO;AAAA,MACpC,UAAU;AAAA,MACV,QAAQ;AAAA,MACR,SAAS,oBAAoB,QAAQ,EAAE,eAAe,SAAS,MAAM,CAAC;AAAA,IACxE;AAAA,IACA,gBAAgB,OAAO,EAAE,8BAA8B,eAAe;AAAA,IACtE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAEO,SAAS,yBAAyB,QAA6C;AACpF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AAAA,IAChB,cAAc,CAAC,EAAE,cAAc,MAAM;AACnC,YAAM,UAAU,oBAAoB,QAAQ,EAAE,eAAe,SAAS,KAAK,CAAC;AAC5E,UAAI,CAAC,OAAO,WAAW;AACrB,eAAO;AAAA,UACL,UAAU;AAAA,UACV,QAAQ;AAAA,UACR;AAAA,QACF;AAAA,MACF;AAEA,YAAM,qBAAqB,EAAE,GAAG,QAAQ;AACxC,aAAO,mBAAmB;AAC1B,aAAO;AAAA,QACL,UAAU,iBAAiB,mBAAmB,OAAO,SAAS,CAAC;AAAA,QAC/D,QAAQ;AAAA,QACR,SAAS;AAAA,MACX;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,6BAA6B,cAAc;AAAA,IACpE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAEO,SAAS,wBAAwB,QAA6C;AACnF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,UAAI,CAAC,OAAO,WAAW;AACrB,eAAO,OAAO,EAAE,kCAAkC,6BAA6B;AAAA,MACjF;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,CAAC,EAAE,cAAc,OAAO;AAAA,MACpC,UAAU,iBAAiB,mBAAmB,OAAO,aAAa,EAAE,CAAC;AAAA,MACrE,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,MAAM,OAAO;AAAA,QACb,YAAY,OAAO;AAAA,QACnB,UAAU,OAAO;AAAA,QACjB,YAAY,OAAO,aAAa,SAAS,IAAI,cAAc,OAAO,YAAY,IAAI;AAAA,QAClF,eAAe,cAAc,SAAS,IAAI,gBAAgB;AAAA,QAC1D,cAAc,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,+BAA+B,aAAa;AAAA,IACrE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAEO,SAAS,0BAA0B,QAA+C;AACvF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,OAAO,aAAa,WAAW,GAAG;AACpC,eAAO,OAAO,EAAE,gCAAgC,oCAAoC;AAAA,MACtF;AACA,UAAI,CAAC,OAAO,WAAW;AACrB,eAAO,OAAO,EAAE,kCAAkC,6BAA6B;AAAA,MACjF;AACA,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,OAAO;AAAA,MACnB,UAAU,iBAAiB,mBAAmB,OAAO,aAAa,EAAE,CAAC;AAAA,MACrE,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,YAAY,cAAc,OAAO,YAAY;AAAA,QAC7C,MAAM,OAAO;AAAA,QACb,oBAAoB,OAAO;AAAA,QAC3B,cAAc,OAAO;AAAA,MACvB;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,iCAAiC,oBAAoB;AAAA,IAC9E,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAIO,SAAS,sBAAsB,QAAiD;AACrF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AACd,UAAI,OAAO,eAAe,YAAY,OAAO,aAAa,WAAW,GAAG;AACtE,eAAO,OAAO,EAAE,gCAAgC,oCAAoC;AAAA,MACtF;AACA,UAAI,OAAO,eAAe,YAAY,CAAC,oBAAoB,OAAO,cAAc,KAAK,CAAC,GAAG;AACvF,eAAO,OAAO,EAAE,mCAAmC,sCAAsC;AAAA,MAC3F;AACA,UAAI,CAAC,OAAO,QAAQ,KAAK,GAAG;AAC1B,eAAO,OAAO,EAAE,6BAA6B,yBAAyB;AAAA,MACxE;AACA,UAAI,CAAC,OAAO,KAAK,KAAK,GAAG;AACvB,eAAO,OAAO,EAAE,0BAA0B,yBAAyB;AAAA,MACrE;AACA,aAAO;AAAA,IACT;AAAA,IACA,cAAc,CAAC,EAAE,cAAc,OAAO;AAAA,MACpC,UAAU,iBAAiB,mBAAmB,OAAO,SAAS,CAAC;AAAA,MAC/D,QAAQ;AAAA,MACR,SAAS,oBAAoB,QAAQ,EAAE,eAAe,SAAS,MAAM,CAAC;AAAA,IACxE;AAAA,IACA,gBAAgB,OAAO,EAAE,8BAA8B,eAAe;AAAA,IACtE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;AAIO,SAAS,wBAAwB,QAAmD;AACzF,SAAO,MAAM,QAAQ,OAAO;AAAA,IAC1B,UAAU,MAAM;AAAA,IAChB,cAAc,CAAC,EAAE,cAAc,MAAM;AACnC,YAAM,EAAE,SAAS,OAAO,GAAG,QAAQ,IAAI,oBAAoB,QAAQ,EAAE,eAAe,SAAS,KAAK,CAAC;AACnG,aAAO;AAAA,QACL,UAAU,iBAAiB,mBAAmB,OAAO,SAAS,CAAC;AAAA,QAC/D,QAAQ;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IACA,gBAAgB,OAAO,EAAE,6BAA6B,cAAc;AAAA,IACpE,2BAA2B;AAAA,EAC7B,IAAI,CAAC,MAAM,CAAC;AACd;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -77,6 +77,12 @@ function NotificationItem({
|
|
|
77
77
|
};
|
|
78
78
|
const handleAction = async (actionId, event) => {
|
|
79
79
|
event?.stopPropagation();
|
|
80
|
+
if (notification.status === "actioned") {
|
|
81
|
+
const actionHref = notification.actions.find((action) => action.id === actionId)?.href;
|
|
82
|
+
const fallbackHref = actionHref ?? notification.linkHref;
|
|
83
|
+
if (fallbackHref) router.push(fallbackHref);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
80
86
|
setExecuting(actionId);
|
|
81
87
|
try {
|
|
82
88
|
const result = await onExecuteAction(actionId);
|
|
@@ -93,7 +99,8 @@ function NotificationItem({
|
|
|
93
99
|
id: action.id,
|
|
94
100
|
labelKey: action.labelKey ?? action.label,
|
|
95
101
|
variant: action.variant,
|
|
96
|
-
icon: action.icon
|
|
102
|
+
icon: action.icon,
|
|
103
|
+
href: action.href
|
|
97
104
|
}));
|
|
98
105
|
if (CustomRenderer) {
|
|
99
106
|
return /* @__PURE__ */ jsx(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/notifications/NotificationItem.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport { Bell, AlertTriangle, CheckCircle2, XCircle, Info, Loader2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { formatRelativeTime } from '@open-mercato/shared/lib/time'\nimport type { NotificationDto, NotificationRendererProps, NotificationTypeAction } from '@open-mercato/shared/modules/notifications/types'\nimport { useOptionalLocale } from '@open-mercato/shared/lib/i18n/context'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\nexport type NotificationItemProps = {\n notification: NotificationDto\n onMarkAsRead: () => Promise<void>\n onExecuteAction: (actionId: string) => Promise<{ href?: string }>\n onDismiss: () => Promise<void>\n t: TranslateFn\n customRenderer?: ComponentType<NotificationRendererProps>\n}\n\nfunction resolveNotificationText(params: {\n key?: string | null\n fallback?: string | null\n variables?: Record<string, string> | null\n t: TranslateFn\n}): string {\n const { key, fallback, variables, t } = params\n if (key) {\n return t(key, fallback ?? key, variables ?? undefined)\n }\n if (fallback) {\n return t(fallback, variables ?? undefined)\n }\n return ''\n}\n\nconst severityIcons = {\n info: Info,\n warning: AlertTriangle,\n success: CheckCircle2,\n error: XCircle,\n}\n\n// Avatar background \u2014 soft tints matching the Figma example item palette\nconst severityAvatarBg = {\n info: 'bg-status-info-bg',\n warning: 'bg-status-warning-bg',\n success: 'bg-status-success-bg',\n error: 'bg-status-error-bg',\n}\n\nconst severityAvatarText = {\n info: 'text-status-info-icon',\n warning: 'text-status-warning-icon',\n success: 'text-status-success-icon',\n error: 'text-status-error-icon',\n}\n\nfunction humanizeModuleId(moduleId?: string | null): string | null {\n if (!moduleId) return null\n return moduleId\n .split(/[_:.-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nexport function NotificationItem({\n notification,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n t,\n customRenderer: CustomRenderer,\n}: NotificationItemProps) {\n const router = useRouter()\n const locale = useOptionalLocale()\n const [executing, setExecuting] = React.useState<string | null>(null)\n\n const isUnread = notification.status === 'unread'\n const hasActions = notification.actions && notification.actions.length > 0\n const severity = (notification.severity as keyof typeof severityIcons) ?? 'info'\n const IconComponent = severityIcons[severity] ?? Bell\n const avatarBg = severityAvatarBg[severity] ?? 'bg-muted'\n const avatarText = severityAvatarText[severity] ?? 'text-muted-foreground'\n\n const titleText = resolveNotificationText({\n key: notification.titleKey,\n fallback: notification.title,\n variables: notification.titleVariables ?? undefined,\n t,\n })\n const bodyText = resolveNotificationText({\n key: notification.bodyKey,\n fallback: notification.body ?? undefined,\n variables: notification.bodyVariables ?? undefined,\n t,\n })\n const timeAgo = formatRelativeTime(notification.createdAt, { locale, translate: t }) ?? ''\n const sourceLabel = humanizeModuleId(notification.sourceModule)\n\n const handleClick = async () => {\n if (isUnread) await onMarkAsRead()\n if (notification.linkHref) router.push(notification.linkHref)\n }\n\n const handleAction = async (actionId: string, event?: React.MouseEvent) => {\n event?.stopPropagation()\n setExecuting(actionId)\n try {\n const result = await onExecuteAction(actionId)\n if (result.href) router.push(result.href)\n } finally {\n setExecuting(null)\n }\n }\n\n const handleDismiss = async (event?: React.MouseEvent) => {\n event?.stopPropagation()\n await onDismiss()\n }\n\n const rendererActions: NotificationTypeAction[] = notification.actions.map((action) => ({\n id: action.id,\n labelKey: action.labelKey ?? action.label,\n variant: action.variant as NotificationTypeAction['variant'],\n icon: action.icon,\n }))\n\n if (CustomRenderer) {\n return (\n <CustomRenderer\n notification={{\n id: notification.id,\n type: notification.type,\n title: titleText,\n body: bodyText || null,\n titleKey: notification.titleKey,\n bodyKey: notification.bodyKey,\n titleVariables: notification.titleVariables ?? null,\n bodyVariables: notification.bodyVariables ?? null,\n icon: notification.icon,\n severity: notification.severity,\n status: notification.status,\n sourceModule: notification.sourceModule,\n sourceEntityType: notification.sourceEntityType,\n sourceEntityId: notification.sourceEntityId,\n linkHref: notification.linkHref ?? null,\n createdAt: notification.createdAt,\n }}\n onAction={handleAction}\n onDismiss={handleDismiss}\n actions={rendererActions}\n />\n )\n }\n\n const showActions = hasActions && notification.status !== 'actioned'\n const showBodyBubble = !!bodyText && bodyText.length > 0\n\n return (\n <div\n className={cn(\n 'group relative flex gap-4 items-start rounded-xl p-3 transition-colors hover:bg-muted/40 cursor-pointer',\n isUnread && 'bg-muted/20'\n )}\n onClick={handleClick}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n handleClick()\n }\n }}\n role=\"button\"\n tabIndex={0}\n >\n {/* Avatar: severity-tinted circle with severity icon */}\n <div className={cn('relative shrink-0 flex size-10 items-center justify-center rounded-full', avatarBg)}>\n <IconComponent className={cn('size-5', avatarText)} aria-hidden=\"true\" />\n {isUnread ? (\n <span\n className=\"absolute -right-1 -top-1 size-3 rounded-full bg-accent-indigo ring-2 ring-background\"\n aria-hidden=\"true\"\n />\n ) : null}\n </div>\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {/* Title row */}\n <p className=\"text-sm font-medium leading-5 tracking-tight text-foreground\">\n {titleText}\n </p>\n\n {/* Description row: time \u00B7 source */}\n <div className=\"flex flex-wrap items-center gap-1 text-xs leading-4 text-muted-foreground\">\n {timeAgo ? <span className=\"whitespace-nowrap\">{timeAgo}</span> : null}\n {timeAgo && sourceLabel ? (\n <span aria-hidden=\"true\" className=\"text-text-disabled\">\u00B7</span>\n ) : null}\n {sourceLabel ? <span className=\"truncate\">{sourceLabel}</span> : null}\n </div>\n\n {/* Optional body bubble */}\n {showBodyBubble ? (\n <div className=\"mt-2 inline-flex max-w-full self-start rounded-tr-lg rounded-br-lg rounded-bl-lg rounded-tl-sm border bg-background px-3 py-2 text-sm leading-5 tracking-tight text-muted-foreground shadow-xs\">\n <p className=\"whitespace-pre-line break-words\">{bodyText}</p>\n </div>\n ) : null}\n\n {/* Optional actions row */}\n {showActions ? (\n <div className=\"mt-2 flex flex-wrap items-center gap-2\">\n {notification.actions.map((action, idx) => {\n const isPrimary = idx === notification.actions.length - 1 || action.variant === 'default'\n return (\n <Button\n key={action.id}\n type=\"button\"\n variant={isPrimary ? 'default' : 'outline'}\n size=\"sm\"\n className={cn(\n 'h-8 rounded-md px-2.5 py-1.5 text-sm font-medium',\n isPrimary && 'bg-accent-indigo text-accent-indigo-foreground hover:bg-accent-indigo/90',\n )}\n onClick={(event) => handleAction(action.id, event)}\n disabled={executing !== null}\n >\n {action.labelKey ? t(action.labelKey, action.label) : t(action.label, action.label)}\n {executing === action.id ? <Loader2 className=\"ml-1 size-3 animate-spin\" /> : null}\n </Button>\n )\n })}\n </div>\n ) : null}\n\n {notification.status === 'actioned' && notification.actionTaken ? (\n <p className=\"mt-1 text-xs italic text-muted-foreground\">\n {t('notifications.actionTaken', 'Action taken: {action}', { action: notification.actionTaken })}\n </p>\n ) : null}\n </div>\n\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n className=\"absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100\"\n onClick={handleDismiss}\n aria-label={t('notifications.actions.dismiss', 'Dismiss')}\n >\n <X className=\"size-3.5\" />\n </IconButton>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { useRouter } from 'next/navigation'\nimport { Bell, AlertTriangle, CheckCircle2, XCircle, Info, Loader2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { formatRelativeTime } from '@open-mercato/shared/lib/time'\nimport type { NotificationDto, NotificationRendererProps, NotificationTypeAction } from '@open-mercato/shared/modules/notifications/types'\nimport { useOptionalLocale } from '@open-mercato/shared/lib/i18n/context'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\nexport type NotificationItemProps = {\n notification: NotificationDto\n onMarkAsRead: () => Promise<void>\n onExecuteAction: (actionId: string) => Promise<{ href?: string }>\n onDismiss: () => Promise<void>\n t: TranslateFn\n customRenderer?: ComponentType<NotificationRendererProps>\n}\n\nfunction resolveNotificationText(params: {\n key?: string | null\n fallback?: string | null\n variables?: Record<string, string> | null\n t: TranslateFn\n}): string {\n const { key, fallback, variables, t } = params\n if (key) {\n return t(key, fallback ?? key, variables ?? undefined)\n }\n if (fallback) {\n return t(fallback, variables ?? undefined)\n }\n return ''\n}\n\nconst severityIcons = {\n info: Info,\n warning: AlertTriangle,\n success: CheckCircle2,\n error: XCircle,\n}\n\n// Avatar background \u2014 soft tints matching the Figma example item palette\nconst severityAvatarBg = {\n info: 'bg-status-info-bg',\n warning: 'bg-status-warning-bg',\n success: 'bg-status-success-bg',\n error: 'bg-status-error-bg',\n}\n\nconst severityAvatarText = {\n info: 'text-status-info-icon',\n warning: 'text-status-warning-icon',\n success: 'text-status-success-icon',\n error: 'text-status-error-icon',\n}\n\nfunction humanizeModuleId(moduleId?: string | null): string | null {\n if (!moduleId) return null\n return moduleId\n .split(/[_:.-]+/)\n .filter(Boolean)\n .map((part) => part.charAt(0).toUpperCase() + part.slice(1))\n .join(' ')\n}\n\nexport function NotificationItem({\n notification,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n t,\n customRenderer: CustomRenderer,\n}: NotificationItemProps) {\n const router = useRouter()\n const locale = useOptionalLocale()\n const [executing, setExecuting] = React.useState<string | null>(null)\n\n const isUnread = notification.status === 'unread'\n const hasActions = notification.actions && notification.actions.length > 0\n const severity = (notification.severity as keyof typeof severityIcons) ?? 'info'\n const IconComponent = severityIcons[severity] ?? Bell\n const avatarBg = severityAvatarBg[severity] ?? 'bg-muted'\n const avatarText = severityAvatarText[severity] ?? 'text-muted-foreground'\n\n const titleText = resolveNotificationText({\n key: notification.titleKey,\n fallback: notification.title,\n variables: notification.titleVariables ?? undefined,\n t,\n })\n const bodyText = resolveNotificationText({\n key: notification.bodyKey,\n fallback: notification.body ?? undefined,\n variables: notification.bodyVariables ?? undefined,\n t,\n })\n const timeAgo = formatRelativeTime(notification.createdAt, { locale, translate: t }) ?? ''\n const sourceLabel = humanizeModuleId(notification.sourceModule)\n\n const handleClick = async () => {\n if (isUnread) await onMarkAsRead()\n if (notification.linkHref) router.push(notification.linkHref)\n }\n\n const handleAction = async (actionId: string, event?: React.MouseEvent) => {\n event?.stopPropagation()\n if (notification.status === 'actioned') {\n const actionHref = notification.actions.find((action) => action.id === actionId)?.href\n const fallbackHref = actionHref ?? notification.linkHref\n if (fallbackHref) router.push(fallbackHref)\n return\n }\n setExecuting(actionId)\n try {\n const result = await onExecuteAction(actionId)\n if (result.href) router.push(result.href)\n } finally {\n setExecuting(null)\n }\n }\n\n const handleDismiss = async (event?: React.MouseEvent) => {\n event?.stopPropagation()\n await onDismiss()\n }\n\n const rendererActions: NotificationTypeAction[] = notification.actions.map((action) => ({\n id: action.id,\n labelKey: action.labelKey ?? action.label,\n variant: action.variant as NotificationTypeAction['variant'],\n icon: action.icon,\n href: action.href,\n }))\n\n if (CustomRenderer) {\n return (\n <CustomRenderer\n notification={{\n id: notification.id,\n type: notification.type,\n title: titleText,\n body: bodyText || null,\n titleKey: notification.titleKey,\n bodyKey: notification.bodyKey,\n titleVariables: notification.titleVariables ?? null,\n bodyVariables: notification.bodyVariables ?? null,\n icon: notification.icon,\n severity: notification.severity,\n status: notification.status,\n sourceModule: notification.sourceModule,\n sourceEntityType: notification.sourceEntityType,\n sourceEntityId: notification.sourceEntityId,\n linkHref: notification.linkHref ?? null,\n createdAt: notification.createdAt,\n }}\n onAction={handleAction}\n onDismiss={handleDismiss}\n actions={rendererActions}\n />\n )\n }\n\n const showActions = hasActions && notification.status !== 'actioned'\n const showBodyBubble = !!bodyText && bodyText.length > 0\n\n return (\n <div\n className={cn(\n 'group relative flex gap-4 items-start rounded-xl p-3 transition-colors hover:bg-muted/40 cursor-pointer',\n isUnread && 'bg-muted/20'\n )}\n onClick={handleClick}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n handleClick()\n }\n }}\n role=\"button\"\n tabIndex={0}\n >\n {/* Avatar: severity-tinted circle with severity icon */}\n <div className={cn('relative shrink-0 flex size-10 items-center justify-center rounded-full', avatarBg)}>\n <IconComponent className={cn('size-5', avatarText)} aria-hidden=\"true\" />\n {isUnread ? (\n <span\n className=\"absolute -right-1 -top-1 size-3 rounded-full bg-accent-indigo ring-2 ring-background\"\n aria-hidden=\"true\"\n />\n ) : null}\n </div>\n\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n {/* Title row */}\n <p className=\"text-sm font-medium leading-5 tracking-tight text-foreground\">\n {titleText}\n </p>\n\n {/* Description row: time \u00B7 source */}\n <div className=\"flex flex-wrap items-center gap-1 text-xs leading-4 text-muted-foreground\">\n {timeAgo ? <span className=\"whitespace-nowrap\">{timeAgo}</span> : null}\n {timeAgo && sourceLabel ? (\n <span aria-hidden=\"true\" className=\"text-text-disabled\">\u00B7</span>\n ) : null}\n {sourceLabel ? <span className=\"truncate\">{sourceLabel}</span> : null}\n </div>\n\n {/* Optional body bubble */}\n {showBodyBubble ? (\n <div className=\"mt-2 inline-flex max-w-full self-start rounded-tr-lg rounded-br-lg rounded-bl-lg rounded-tl-sm border bg-background px-3 py-2 text-sm leading-5 tracking-tight text-muted-foreground shadow-xs\">\n <p className=\"whitespace-pre-line break-words\">{bodyText}</p>\n </div>\n ) : null}\n\n {/* Optional actions row */}\n {showActions ? (\n <div className=\"mt-2 flex flex-wrap items-center gap-2\">\n {notification.actions.map((action, idx) => {\n const isPrimary = idx === notification.actions.length - 1 || action.variant === 'default'\n return (\n <Button\n key={action.id}\n type=\"button\"\n variant={isPrimary ? 'default' : 'outline'}\n size=\"sm\"\n className={cn(\n 'h-8 rounded-md px-2.5 py-1.5 text-sm font-medium',\n isPrimary && 'bg-accent-indigo text-accent-indigo-foreground hover:bg-accent-indigo/90',\n )}\n onClick={(event) => handleAction(action.id, event)}\n disabled={executing !== null}\n >\n {action.labelKey ? t(action.labelKey, action.label) : t(action.label, action.label)}\n {executing === action.id ? <Loader2 className=\"ml-1 size-3 animate-spin\" /> : null}\n </Button>\n )\n })}\n </div>\n ) : null}\n\n {notification.status === 'actioned' && notification.actionTaken ? (\n <p className=\"mt-1 text-xs italic text-muted-foreground\">\n {t('notifications.actionTaken', 'Action taken: {action}', { action: notification.actionTaken })}\n </p>\n ) : null}\n </div>\n\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"xs\"\n className=\"absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100\"\n onClick={handleDismiss}\n aria-label={t('notifications.actions.dismiss', 'Dismiss')}\n >\n <X className=\"size-3.5\" />\n </IconButton>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -170,7 +170,7 @@ function PortalShell({
|
|
|
170
170
|
if (!authenticated) {
|
|
171
171
|
return /* @__PURE__ */ jsxs("div", { className: "flex min-h-svh flex-col bg-background", "data-portal-handle": PORTAL_SHELL_HANDLE, children: [
|
|
172
172
|
/* @__PURE__ */ jsx("header", { className: "sticky top-0 z-sticky border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80", "data-portal-handle": PORTAL_HEADER_HANDLE, children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6", children: [
|
|
173
|
-
/* @__PURE__ */ jsxs(
|
|
173
|
+
/* @__PURE__ */ jsxs("a", { href: portalHome, className: "flex items-center gap-2.5 text-foreground transition hover:opacity-80", "aria-label": headerTitle, children: [
|
|
174
174
|
/* @__PURE__ */ jsx(Image, { src: logo?.src ?? "/open-mercato.svg", alt: logo?.alt ?? "", width: 28, height: 28, className: "", priority: true }),
|
|
175
175
|
/* @__PURE__ */ jsx("span", { className: "text-base font-semibold tracking-tight", children: headerTitle })
|
|
176
176
|
] }),
|
|
@@ -181,7 +181,7 @@ function PortalShell({
|
|
|
181
181
|
] }) }),
|
|
182
182
|
/* @__PURE__ */ jsx("main", { className: "flex-1", children: /* @__PURE__ */ jsx("div", { className: "mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20", children }) }),
|
|
183
183
|
/* @__PURE__ */ jsx("footer", { className: "border-t", "data-portal-handle": PORTAL_FOOTER_HANDLE, children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6", children: [
|
|
184
|
-
/* @__PURE__ */ jsxs(
|
|
184
|
+
/* @__PURE__ */ jsxs("a", { href: portalHome, className: "flex items-center gap-2 text-muted-foreground transition hover:text-foreground", children: [
|
|
185
185
|
/* @__PURE__ */ jsx(Image, { src: logo?.src ?? "/open-mercato.svg", alt: logo?.alt ?? "", width: 20, height: 20, className: "" }),
|
|
186
186
|
/* @__PURE__ */ jsx("span", { className: "text-sm font-medium text-foreground", children: headerTitle })
|
|
187
187
|
] }),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/portal/PortalShell.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport { type ReactNode, useEffect, useState, useCallback, useMemo } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { Skeleton } from '../primitives/skeleton'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\nimport { apiCall } from '../backend/utils/apiCall'\nimport type { PortalNavGroup } from './utils/nav'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type ShellLogo = {\n src: string\n alt?: string\n}\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Override the brand logo rendered in the header, footer, and sidebar. */\n logo?: ShellLogo\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\nfunction SidebarNavSkeleton() {\n return (\n <div className=\"flex flex-col gap-2 px-3 py-1\" data-testid=\"portal-nav-loading\">\n <Skeleton className=\"h-9 w-full rounded-lg\" />\n <Skeleton className=\"h-9 w-11/12 rounded-lg\" />\n <Skeleton className=\"h-9 w-5/6 rounded-lg\" />\n <Skeleton className=\"h-9 w-10/12 rounded-lg\" />\n </div>\n )\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-overline font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n logo,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n // A context user wins over the prop. The layout that supplies `authenticated`\n // sits above the [...slug] segment, so a client-side navigation never re-runs\n // it and the prop can describe the route the visitor arrived on rather than\n // the one being rendered. Trusting a stale `false` there painted the\n // logged-out header over authenticated content (#5678).\n const authenticated = !!user || (authenticatedProp ?? false)\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])\n const [isNavLoading, setIsNavLoading] = useState(authenticated)\n useEffect(() => {\n if (!authenticated) {\n setAutoNavGroups([])\n setIsNavLoading(false)\n return\n }\n let cancelled = false\n setIsNavLoading(true)\n const load = async () => {\n try {\n const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(\n '/api/customer_accounts/portal/nav',\n )\n if (cancelled || !ok || !result?.ok) return\n setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])\n } catch {\n if (!cancelled) setAutoNavGroups([])\n } finally {\n if (!cancelled) setIsNavLoading(false)\n }\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [authenticated])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, autoNavGroups, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, autoNavGroups, injectedAccountItems])\n\n const shouldRenderMainNav = isNavLoading || mergedNavItems.length > 0\n const shouldRenderAccountNav = mergedAccountItems.length > 0\n const shouldRenderNav = shouldRenderMainNav || shouldRenderAccountNav\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-sticky border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={28} height={28} className=\"\" priority />\n <span className=\"text-base font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-sm\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-sm\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={22} height={22} className=\"\" />\n <span className=\"text-sm font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <div\n className=\"hidden\"\n data-testid=\"portal-nav-ready\"\n data-ready={isNavLoading ? 'false' : 'true'}\n aria-hidden=\"true\"\n />\n\n {shouldRenderNav ? (\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n {shouldRenderMainNav ? (\n <>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n {isNavLoading ? (\n <SidebarNavSkeleton />\n ) : (\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n )}\n </>\n ) : null}\n\n {shouldRenderAccountNav ? (\n <div className={shouldRenderMainNav ? 'mt-8' : ''}>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n ) : null}\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-sm font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-overline text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-modal lg:hidden\">\n <div className=\"absolute inset-0 bg-black/20 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-overline text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
|
|
5
|
-
"mappings": ";AA0DI,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport { type ReactNode, useEffect, useState, useCallback, useMemo } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { Skeleton } from '../primitives/skeleton'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\nimport { apiCall } from '../backend/utils/apiCall'\nimport type { PortalNavGroup } from './utils/nav'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type ShellLogo = {\n src: string\n alt?: string\n}\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Override the brand logo rendered in the header, footer, and sidebar. */\n logo?: ShellLogo\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\nfunction SidebarNavSkeleton() {\n return (\n <div className=\"flex flex-col gap-2 px-3 py-1\" data-testid=\"portal-nav-loading\">\n <Skeleton className=\"h-9 w-full rounded-lg\" />\n <Skeleton className=\"h-9 w-11/12 rounded-lg\" />\n <Skeleton className=\"h-9 w-5/6 rounded-lg\" />\n <Skeleton className=\"h-9 w-10/12 rounded-lg\" />\n </div>\n )\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-overline font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n logo,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n // A context user wins over the prop. The layout that supplies `authenticated`\n // sits above the [...slug] segment, so a client-side navigation never re-runs\n // it and the prop can describe the route the visitor arrived on rather than\n // the one being rendered. Trusting a stale `false` there painted the\n // logged-out header over authenticated content (#5678).\n const authenticated = !!user || (authenticatedProp ?? false)\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])\n const [isNavLoading, setIsNavLoading] = useState(authenticated)\n useEffect(() => {\n if (!authenticated) {\n setAutoNavGroups([])\n setIsNavLoading(false)\n return\n }\n let cancelled = false\n setIsNavLoading(true)\n const load = async () => {\n try {\n const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(\n '/api/customer_accounts/portal/nav',\n )\n if (cancelled || !ok || !result?.ok) return\n setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])\n } catch {\n if (!cancelled) setAutoNavGroups([])\n } finally {\n if (!cancelled) setIsNavLoading(false)\n }\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [authenticated])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, autoNavGroups, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, autoNavGroups, injectedAccountItems])\n\n const shouldRenderMainNav = isNavLoading || mergedNavItems.length > 0\n const shouldRenderAccountNav = mergedAccountItems.length > 0\n const shouldRenderNav = shouldRenderMainNav || shouldRenderAccountNav\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-sticky border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n {/* A plain anchor, not a client-side Link: for a signed-in customer on a public\n auth route the root is across the public/authenticated boundary, and only a\n full page load re-runs the layout that hands the session back (#6362). */}\n <a href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={28} height={28} className=\"\" priority />\n <span className=\"text-base font-semibold tracking-tight\">{headerTitle}</span>\n </a>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-sm\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-sm\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <a href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </a>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src={logo?.src ?? \"/open-mercato.svg\"} alt={logo?.alt ?? \"\"} width={22} height={22} className=\"\" />\n <span className=\"text-sm font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <div\n className=\"hidden\"\n data-testid=\"portal-nav-ready\"\n data-ready={isNavLoading ? 'false' : 'true'}\n aria-hidden=\"true\"\n />\n\n {shouldRenderNav ? (\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n {shouldRenderMainNav ? (\n <>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n {isNavLoading ? (\n <SidebarNavSkeleton />\n ) : (\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n )}\n </>\n ) : null}\n\n {shouldRenderAccountNav ? (\n <div className={shouldRenderMainNav ? 'mt-8' : ''}>\n <p className=\"mb-2 px-3 text-overline font-semibold uppercase tracking-widest text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n ) : null}\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-sm font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-overline text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-modal lg:hidden\">\n <div className=\"absolute inset-0 bg-black/20 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-overline text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -5,7 +5,8 @@ import { DayPicker, useDayPicker } from "react-day-picker";
|
|
|
5
5
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
6
|
import { format } from "date-fns/format";
|
|
7
7
|
import { cn } from "@open-mercato/shared/lib/utils";
|
|
8
|
-
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
8
|
+
import { useOptionalT, useT } from "@open-mercato/shared/lib/i18n/context";
|
|
9
|
+
import { createTranslator } from "@open-mercato/shared/lib/i18n/translate";
|
|
9
10
|
import { CompactButton } from "./compact-button.js";
|
|
10
11
|
function CalendarMonthSelector({
|
|
11
12
|
month,
|
|
@@ -26,6 +27,11 @@ function CalendarMonthSelector({
|
|
|
26
27
|
}
|
|
27
28
|
const STANDALONE_MONTH_YEAR = "LLLL yyyy";
|
|
28
29
|
const STANDALONE_MONTH_SHORT = "LLL";
|
|
30
|
+
const fallbackTranslate = createTranslator({});
|
|
31
|
+
function useCalendarT() {
|
|
32
|
+
return useOptionalT() ?? fallbackTranslate;
|
|
33
|
+
}
|
|
34
|
+
const CalendarLabelsContext = React.createContext(void 0);
|
|
29
35
|
const navButtonClassName = cn(
|
|
30
36
|
"h-9 w-9 inline-flex items-center justify-center rounded-md shrink-0",
|
|
31
37
|
"border border-border bg-background text-muted-foreground transition-colors",
|
|
@@ -37,11 +43,14 @@ function MonthNavButton({
|
|
|
37
43
|
direction,
|
|
38
44
|
locale
|
|
39
45
|
}) {
|
|
46
|
+
const t = useCalendarT();
|
|
47
|
+
const labels = React.useContext(CalendarLabelsContext);
|
|
40
48
|
const dayPicker = useDayPicker();
|
|
41
49
|
const target = direction === "prev" ? dayPicker.previousMonth : dayPicker.nextMonth;
|
|
42
50
|
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
|
|
43
51
|
const targetLabel = format(target ?? /* @__PURE__ */ new Date(), STANDALONE_MONTH_YEAR, locale ? { locale } : void 0);
|
|
44
|
-
const
|
|
52
|
+
const customLabel = direction === "prev" ? labels?.labelPrevious : labels?.labelNext;
|
|
53
|
+
const ariaLabel = customLabel ? customLabel(target) : direction === "prev" ? t("ui.calendar.goToPreviousMonth", "Go to previous month: {month}", { month: targetLabel }) : t("ui.calendar.goToNextMonth", "Go to next month: {month}", { month: targetLabel });
|
|
45
54
|
return /* @__PURE__ */ jsx(
|
|
46
55
|
"button",
|
|
47
56
|
{
|
|
@@ -56,11 +65,12 @@ function MonthNavButton({
|
|
|
56
65
|
}
|
|
57
66
|
);
|
|
58
67
|
}
|
|
59
|
-
function buildMonthCaption(locale, totalMonths, onOpenMonthGrid) {
|
|
68
|
+
function buildMonthCaption(locale, totalMonths, onOpenMonthGrid, monthGridTriggerRef) {
|
|
60
69
|
return function MonthCaption({
|
|
61
70
|
calendarMonth,
|
|
62
71
|
displayIndex
|
|
63
72
|
}) {
|
|
73
|
+
const t = useCalendarT();
|
|
64
74
|
const label = format(calendarMonth.date, STANDALONE_MONTH_YEAR, locale ? { locale } : void 0);
|
|
65
75
|
const index = typeof displayIndex === "number" ? displayIndex : 0;
|
|
66
76
|
const showPrev = index === 0;
|
|
@@ -71,9 +81,10 @@ function buildMonthCaption(locale, totalMonths, onOpenMonthGrid) {
|
|
|
71
81
|
labelInteractive ? /* @__PURE__ */ jsx(
|
|
72
82
|
"button",
|
|
73
83
|
{
|
|
84
|
+
ref: monthGridTriggerRef,
|
|
74
85
|
type: "button",
|
|
75
86
|
onClick: onOpenMonthGrid ?? void 0,
|
|
76
|
-
"aria-label":
|
|
87
|
+
"aria-label": t("ui.calendar.openMonthYearNavigation", "{month} \u2013 open month and year navigation", { month: label }),
|
|
77
88
|
className: cn(
|
|
78
89
|
"flex-1 flex items-center justify-center h-9 rounded-md bg-muted px-3 text-sm font-medium",
|
|
79
90
|
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
@@ -98,9 +109,40 @@ function MonthGrid({
|
|
|
98
109
|
selectedMonth,
|
|
99
110
|
locale,
|
|
100
111
|
onSelectMonth,
|
|
101
|
-
onClose
|
|
112
|
+
onClose,
|
|
113
|
+
onRestoreFocus
|
|
102
114
|
}) {
|
|
115
|
+
const t = useCalendarT();
|
|
103
116
|
const [year, setYear] = React.useState(initialYear);
|
|
117
|
+
const gridRef = React.useRef(null);
|
|
118
|
+
const selectedMonthRef = React.useRef(null);
|
|
119
|
+
React.useEffect(() => {
|
|
120
|
+
selectedMonthRef.current?.focus();
|
|
121
|
+
return onRestoreFocus;
|
|
122
|
+
}, [onRestoreFocus]);
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
const handleEscape = (event) => {
|
|
125
|
+
if (event.key !== "Escape" || !(event.target instanceof Node) || !gridRef.current?.contains(event.target)) return;
|
|
126
|
+
event.preventDefault();
|
|
127
|
+
event.stopPropagation();
|
|
128
|
+
onClose();
|
|
129
|
+
};
|
|
130
|
+
window.addEventListener("keydown", handleEscape, true);
|
|
131
|
+
return () => window.removeEventListener("keydown", handleEscape, true);
|
|
132
|
+
}, [onClose]);
|
|
133
|
+
const handleKeyDown = (event) => {
|
|
134
|
+
if (event.key !== "Tab") return;
|
|
135
|
+
const buttons = event.currentTarget.querySelectorAll("button:not(:disabled)");
|
|
136
|
+
const firstButton = buttons[0];
|
|
137
|
+
const lastButton = buttons[buttons.length - 1];
|
|
138
|
+
if (event.shiftKey && document.activeElement === firstButton) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
lastButton?.focus();
|
|
141
|
+
} else if (!event.shiftKey && document.activeElement === lastButton) {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
firstButton?.focus();
|
|
144
|
+
}
|
|
145
|
+
};
|
|
104
146
|
const today = /* @__PURE__ */ new Date();
|
|
105
147
|
const monthLabels = React.useMemo(
|
|
106
148
|
() => Array.from(
|
|
@@ -113,16 +155,18 @@ function MonthGrid({
|
|
|
113
155
|
return /* @__PURE__ */ jsxs(
|
|
114
156
|
"div",
|
|
115
157
|
{
|
|
158
|
+
ref: gridRef,
|
|
116
159
|
className: "absolute inset-0 z-10 flex flex-col rounded-md bg-popover p-3",
|
|
117
160
|
role: "dialog",
|
|
118
|
-
"aria-label": "Select month and year",
|
|
161
|
+
"aria-label": t("ui.calendar.selectMonthAndYear", "Select month and year"),
|
|
162
|
+
onKeyDown: handleKeyDown,
|
|
119
163
|
children: [
|
|
120
164
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 mb-3", children: [
|
|
121
165
|
/* @__PURE__ */ jsx(
|
|
122
166
|
"button",
|
|
123
167
|
{
|
|
124
168
|
type: "button",
|
|
125
|
-
"aria-label":
|
|
169
|
+
"aria-label": t("ui.calendar.goToPreviousYear", "Go to previous year: {year}", { year: year - 1 }),
|
|
126
170
|
onClick: () => setYear((current) => current - 1),
|
|
127
171
|
className: navButtonClassName,
|
|
128
172
|
children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-4 w-4", "aria-hidden": "true" })
|
|
@@ -133,7 +177,7 @@ function MonthGrid({
|
|
|
133
177
|
{
|
|
134
178
|
type: "button",
|
|
135
179
|
onClick: onClose,
|
|
136
|
-
"aria-label":
|
|
180
|
+
"aria-label": t("ui.calendar.backToDaySelection", "{year} \u2013 back to day selection", { year: yearLabel }),
|
|
137
181
|
className: cn(
|
|
138
182
|
"flex-1 flex items-center justify-center h-9 rounded-md bg-muted px-3 text-sm font-medium",
|
|
139
183
|
"transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
@@ -146,7 +190,7 @@ function MonthGrid({
|
|
|
146
190
|
"button",
|
|
147
191
|
{
|
|
148
192
|
type: "button",
|
|
149
|
-
"aria-label":
|
|
193
|
+
"aria-label": t("ui.calendar.goToNextYear", "Go to next year: {year}", { year: year + 1 }),
|
|
150
194
|
onClick: () => setYear((current) => current + 1),
|
|
151
195
|
className: navButtonClassName,
|
|
152
196
|
children: /* @__PURE__ */ jsx(ChevronRight, { className: "h-4 w-4", "aria-hidden": "true" })
|
|
@@ -159,6 +203,7 @@ function MonthGrid({
|
|
|
159
203
|
return /* @__PURE__ */ jsx(
|
|
160
204
|
"button",
|
|
161
205
|
{
|
|
206
|
+
ref: monthIndex === selectedMonth.getMonth() ? selectedMonthRef : void 0,
|
|
162
207
|
type: "button",
|
|
163
208
|
"aria-pressed": isSelected,
|
|
164
209
|
onClick: () => onSelectMonth(new Date(year, monthIndex, 1)),
|
|
@@ -192,6 +237,7 @@ function Calendar({
|
|
|
192
237
|
month,
|
|
193
238
|
defaultMonth,
|
|
194
239
|
onMonthChange,
|
|
240
|
+
labels,
|
|
195
241
|
...props
|
|
196
242
|
}) {
|
|
197
243
|
const monthGridEnabled = numberOfMonths === 1;
|
|
@@ -199,6 +245,10 @@ function Calendar({
|
|
|
199
245
|
() => month ?? defaultMonth ?? /* @__PURE__ */ new Date()
|
|
200
246
|
);
|
|
201
247
|
const [showMonthGrid, setShowMonthGrid] = React.useState(false);
|
|
248
|
+
const monthGridTriggerRef = React.useRef(null);
|
|
249
|
+
const restoreDayViewFocus = React.useCallback(() => {
|
|
250
|
+
monthGridTriggerRef.current?.focus();
|
|
251
|
+
}, []);
|
|
202
252
|
React.useEffect(() => {
|
|
203
253
|
if (month) setDisplayMonth(month);
|
|
204
254
|
}, [month]);
|
|
@@ -220,23 +270,27 @@ function Calendar({
|
|
|
220
270
|
() => buildMonthCaption(
|
|
221
271
|
locale,
|
|
222
272
|
numberOfMonths,
|
|
223
|
-
monthGridEnabled ? () => setShowMonthGrid(true) : null
|
|
273
|
+
monthGridEnabled ? () => setShowMonthGrid(true) : null,
|
|
274
|
+
monthGridTriggerRef
|
|
224
275
|
),
|
|
225
276
|
[locale, numberOfMonths, monthGridEnabled]
|
|
226
277
|
);
|
|
227
|
-
return /* @__PURE__ */ jsxs("div", { className: monthGridEnabled ? "relative" : "contents", children: [
|
|
278
|
+
return /* @__PURE__ */ jsx(CalendarLabelsContext.Provider, { value: labels, children: /* @__PURE__ */ jsxs("div", { className: monthGridEnabled ? "relative" : "contents", children: [
|
|
228
279
|
/* @__PURE__ */ jsx(
|
|
229
280
|
"div",
|
|
230
281
|
{
|
|
231
|
-
className: monthGridEnabled && showMonthGrid ? "pointer-events-none" : "contents",
|
|
282
|
+
className: monthGridEnabled && showMonthGrid ? "invisible pointer-events-none" : "contents",
|
|
232
283
|
"aria-hidden": monthGridEnabled && showMonthGrid ? true : void 0,
|
|
284
|
+
inert: monthGridEnabled && showMonthGrid ? true : void 0,
|
|
233
285
|
children: /* @__PURE__ */ jsx(
|
|
234
286
|
DayPicker,
|
|
235
287
|
{
|
|
236
288
|
showOutsideDays,
|
|
237
289
|
fixedWeeks,
|
|
238
290
|
pagedNavigation,
|
|
291
|
+
hideNavigation: true,
|
|
239
292
|
locale,
|
|
293
|
+
labels,
|
|
240
294
|
numberOfMonths,
|
|
241
295
|
month: displayMonth,
|
|
242
296
|
onMonthChange: handleMonthChange,
|
|
@@ -315,10 +369,11 @@ function Calendar({
|
|
|
315
369
|
selectedMonth: displayMonth,
|
|
316
370
|
locale,
|
|
317
371
|
onSelectMonth: handleSelectMonth,
|
|
318
|
-
onClose: () => setShowMonthGrid(false)
|
|
372
|
+
onClose: () => setShowMonthGrid(false),
|
|
373
|
+
onRestoreFocus: restoreDayViewFocus
|
|
319
374
|
}
|
|
320
375
|
) : null
|
|
321
|
-
] });
|
|
376
|
+
] }) });
|
|
322
377
|
}
|
|
323
378
|
export {
|
|
324
379
|
Calendar,
|