diff --git a/apps/customer-portal/webapp/package.json b/apps/customer-portal/webapp/package.json index 30c1f50186..44399ef3f2 100644 --- a/apps/customer-portal/webapp/package.json +++ b/apps/customer-portal/webapp/package.json @@ -36,6 +36,7 @@ "react-dom": "^19.2.0", "react-idle-timer": "^5.7.2", "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "react-phone-number-input": "^3.4.16", "react-router": "^7.1.5", "zod": "^4.3.6" diff --git a/apps/customer-portal/webapp/pnpm-lock.yaml b/apps/customer-portal/webapp/pnpm-lock.yaml index 07dbf38635..02a651b217 100644 --- a/apps/customer-portal/webapp/pnpm-lock.yaml +++ b/apps/customer-portal/webapp/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: react-router: specifier: ^7.1.5 version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 zod: specifier: ^4.3.6 version: 4.3.6 @@ -1749,6 +1752,10 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + eslint-plugin-react-hooks@7.0.1: resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} engines: {node: '>=18'} @@ -2211,6 +2218,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2218,9 +2228,30 @@ packages: md5.js@1.3.5: resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + mdast-util-from-markdown@2.0.3: resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -2248,6 +2279,27 @@ packages: micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} @@ -2588,12 +2640,18 @@ packages: regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4803,6 +4861,8 @@ snapshots: escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2): dependencies: '@babel/core': 7.29.0 @@ -5283,6 +5343,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} md5.js@1.3.5: @@ -5291,6 +5353,13 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 @@ -5308,6 +5377,63 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -5401,6 +5527,64 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -5823,6 +6007,17 @@ snapshots: regenerator-runtime@0.13.11: optional: true + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -5840,6 +6035,12 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + require-from-string@2.0.2: {} reselect@5.1.1: {} diff --git a/apps/customer-portal/webapp/src/components/error/ApiErrorState.tsx b/apps/customer-portal/webapp/src/components/error/ApiErrorState.tsx new file mode 100644 index 0000000000..45c99985e7 --- /dev/null +++ b/apps/customer-portal/webapp/src/components/error/ApiErrorState.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2026 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { Box, Stack, Typography, type SxProps, type Theme } from "@wso2/oxygen-ui"; +import type { JSX } from "react"; +import Error401Page from "@components/error/Error401Page"; +import Error403Page from "@components/error/Error403Page"; +import Error500Page from "@components/error/Error500Page"; +import { + getApiErrorMessage, + isForbiddenError, + isUnauthorizedError, +} from "@utils/ApiError"; + +export interface ApiErrorStateProps { + error?: unknown; + fallbackMessage?: string; + containerSx?: SxProps; +} + +/** + * Renders a consistent API error state for support pages/panels. + * + * @param {ApiErrorStateProps} props - Error payload and optional fallback copy. + * @returns {JSX.Element} Error401/Error403/Error500 UI. + */ +export default function ApiErrorState({ + error, + fallbackMessage = "Something went wrong while loading data.", + containerSx, +}: ApiErrorStateProps): JSX.Element { + const baseContainerSx: SxProps = { + py: 4, + alignItems: "center", + justifyContent: "center", + }; + const mergedContainerSx = ( + containerSx ? [baseContainerSx, containerSx] : baseContainerSx + ) as SxProps; + + if (isUnauthorizedError(error)) { + return ( + + + + ); + } + + if (isForbiddenError(error)) { + return ( + + + + ); + } + + return ( + + + + + + {fallbackMessage} + + + ); +} + diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CaseDetailsActivityPanel.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CaseDetailsActivityPanel.tsx index fb1553e1a2..10cd17b861 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CaseDetailsActivityPanel.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CaseDetailsActivityPanel.tsx @@ -24,6 +24,7 @@ import { } from "@wso2/oxygen-ui"; import { useMemo, useEffect, useRef, useState, type JSX } from "react"; import useGetCaseComments from "@features/support/api/useGetCaseComments"; +import { useGetConversationMessages } from "@features/support/api/useGetConversationMessages"; import useGetUserDetails from "@features/settings/api/useGetUserDetails"; import EmptyIcon from "@components/empty-state/EmptyIcon"; import { compareByCreatedOnThenId, formatCommentDate } from "@features/support/utils/support"; @@ -35,6 +36,8 @@ import type { ActivityContentProps, CaseDetailsActivityPanelProps, } from "@features/support/types/supportComponents"; +import type { CaseComment } from "@features/support/types/cases"; +import ApiErrorState from "@components/error/ApiErrorState"; // TODO : DUE TO URGENCY THIS COMPONENT BREAKS THE BEST PRACTICES , NEED FULL REFACTOR /** @@ -46,6 +49,7 @@ import type { export default function CaseDetailsActivityPanel({ projectId, caseId, + conversationId, caseCreatedOn, caseStatus, }: CaseDetailsActivityPanelProps): JSX.Element { @@ -53,20 +57,34 @@ export default function CaseDetailsActivityPanel({ const { data: userDetails } = useGetUserDetails(); const { data: commentsData, - isLoading, - isError, + isLoading: isCommentsLoading, + isError: isCommentsError, + error: commentsError, } = useGetCaseComments(projectId, caseId, { offset: 0, limit: 50 }); + const { + data: conversationData, + isLoading: isConversationLoading, + isError: isConversationError, + error: conversationError, + } = useGetConversationMessages(conversationId ?? "", { pageSize: 50 }); const currentUserEmail = userDetails?.email?.toLowerCase() ?? ""; - const commentsSorted = useMemo(() => { - const list = commentsData?.comments ?? []; - return [...list].sort(compareByCreatedOnThenId); - }, [commentsData?.comments]); + const mergedTimeline = useMemo(() => { + const caseComments = commentsData?.comments ?? []; + const conversationComments: CaseComment[] = ( + conversationData?.pages?.flatMap((page) => page.comments) ?? [] + ).map((comment) => ({ + ...comment, + type: comment.type ?? "comments", + isEscalated: comment.isEscalated ?? false, + })); + return [...caseComments, ...conversationComments].sort(compareByCreatedOnThenId); + }, [commentsData?.comments, conversationData?.pages]); const commentsToShow = useMemo( - () => commentsSorted.filter(hasDisplayableContent), - [commentsSorted], + () => mergedTimeline.filter(hasDisplayableContent), + [mergedTimeline], ); const primaryLight = theme.palette.primary?.light ?? "#fa7b3f"; @@ -75,6 +93,10 @@ export default function CaseDetailsActivityPanel({ // Render content based on state let content: JSX.Element; + const isLoading = isCommentsLoading || (!!conversationId && isConversationLoading); + const isError = isCommentsError || (!!conversationId && isConversationError); + const resolvedError = commentsError ?? conversationError; + if (isLoading) { content = ( @@ -99,9 +121,10 @@ export default function CaseDetailsActivityPanel({ } else if (isError || !commentsData) { content = ( - - Unable to load activity. - + ); } else { diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/ChatMessageCard.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/ChatMessageCard.tsx index c90b9e5853..95febbe408 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/ChatMessageCard.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/ChatMessageCard.tsx @@ -18,6 +18,96 @@ import type { ChatMessageCardProps } from "@features/support/types/supportCompon import { Box, Paper } from "@wso2/oxygen-ui"; import { useCallback, useEffect, useRef } from "react"; import type { JSX } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +const SAFE_PROTOCOLS = ["http:", "https:"]; + +function isSafeHref(href: string | undefined): href is string { + if (!href || typeof href !== "string") return false; + try { + const parsed = new URL(href, "https://invalid.invalid"); + return SAFE_PROTOCOLS.includes(parsed.protocol); + } catch { + return false; + } +} + +const markdownComponents: React.ComponentProps< + typeof ReactMarkdown +>["components"] = { + a: ({ href, children }) => + isSafeHref(href) ? ( + + {children} + + ) : ( + {children} + ), + table: ({ children }) => ( + + + {children} + + + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + img: ({ src = "", alt = "" }) => ( + + ), +}; /** * Card-style chat message using Paper without border or border radius. @@ -28,12 +118,23 @@ import type { JSX } from "react"; */ export default function ChatMessageCard({ htmlContent, + markdownContent, + renderAsMarkdown = false, isCurrentUser, primaryBg, onImageClick, }: ChatMessageCardProps): JSX.Element { const contentRef = useRef(null); + const setImageA11yAttributes = useCallback((root: HTMLDivElement) => { + const images = root.querySelectorAll("img"); + images.forEach((image) => { + image.setAttribute("tabindex", "0"); + image.setAttribute("role", "button"); + image.setAttribute("aria-label", "Open image preview"); + }); + }, []); + const handleClick = useCallback( (e: MouseEvent) => { const target = e.target as HTMLElement; @@ -48,14 +149,41 @@ export default function ChatMessageCard({ [onImageClick], ); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target instanceof HTMLImageElement && + (e.key === "Enter" || e.key === " ") + ) { + const src = target.src || target.getAttribute("src"); + if (src && onImageClick) { + e.preventDefault(); + onImageClick(src); + } + } + }, + [onImageClick], + ); + useEffect(() => { const el = contentRef.current; if (!el) return; + setImageA11yAttributes(el); el.addEventListener("click", handleClick); + el.addEventListener("keydown", handleKeyDown); return () => { el.removeEventListener("click", handleClick); + el.removeEventListener("keydown", handleKeyDown); }; - }, [handleClick]); + }, [ + handleClick, + handleKeyDown, + setImageA11yAttributes, + htmlContent, + markdownContent, + renderAsMarkdown, + ]); return ( + > + {renderAsMarkdown ? ( + + {markdownContent ?? ""} + + ) : ( + + )} + ); } diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CommentBubble.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CommentBubble.tsx index a17152e0f5..a064e3b2c6 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CommentBubble.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/activity-tab/CommentBubble.tsx @@ -44,6 +44,13 @@ function commentAuthorDisplayName(comment: CaseComment): string { return comment.createdBy?.trim() || "Unknown"; } +function shouldRenderCommentAsMarkdown(comment: CaseComment): boolean { + return ( + comment.type?.toLowerCase() === "bot" || + comment.createdBy?.trim().toLowerCase() === "novera" + ); +} + /** * Single comment bubble: avatar, display name, date, and ChatMessageCard. * @@ -59,6 +66,7 @@ export default function CommentBubble({ }: CommentBubbleProps): import("react").JSX.Element { const theme = useTheme(); const rawContent = comment.content ?? ""; + const renderAsMarkdown = shouldRenderCommentAsMarkdown(comment); const isFullCodeWrap = hasSingleCodeWrapper(rawContent); const codeBlockCount = rawContent.match(/\[code\]/gi)?.length ?? 0; const afterCode = isFullCodeWrap @@ -165,6 +173,8 @@ export default function CommentBubble({ ) : isError ? ( - - Failed to load attachments. - + ) : allAttachments.length === 0 ? ( - - - - - Error loading call requests. - - + ); } diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/calls-tab/CallsPanel.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/calls-tab/CallsPanel.tsx index 6208190b40..89ad634164 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/calls-tab/CallsPanel.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/calls-tab/CallsPanel.tsx @@ -170,6 +170,7 @@ export default function CallsPanel({ data, isPending, isError, + error, refetch, fetchNextPage, hasNextPage, @@ -383,7 +384,7 @@ export default function CallsPanel({ {isPending ? ( ) : isError ? ( - + ) : allCallRequests.length === 0 ? ( ) : ( diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsContent.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsContent.tsx index 3f6851153e..9ededd0165 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsContent.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsContent.tsx @@ -41,6 +41,7 @@ import { type CaseStatus, } from "@features/support/constants/supportConstants"; import ErrorIndicator from "@components/error-indicator/ErrorIndicator"; +import ApiErrorState from "@components/error/ApiErrorState"; import CaseDetailsBackButton from "@case-details/CaseDetailsBackButton"; import CaseDetailsHeader from "@case-details/CaseDetailsHeader"; import CaseDetailsActionRow from "@case-details/CaseDetailsActionRow"; @@ -58,6 +59,7 @@ export default function CaseDetailsContent({ data, isLoading, isError, + error, caseId, onBack, onOpenRelatedCase, @@ -219,6 +221,19 @@ export default function CaseDetailsContent({ setHideForDetailsActivityTab, ]); + if (isError && error) { + return ( + + ); + } + if (isLoading) { return ( @@ -359,6 +374,7 @@ export default function CaseDetailsContent({ caseId={caseId} data={data} isError={isError} + error={error} projectId={projectId} focusMode={focusMode} isEngagement={isEngagementRoute} diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsDetailsPanel.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsDetailsPanel.tsx index 175a13a3b1..b19367bb3c 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsDetailsPanel.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/details-tab/CaseDetailsDetailsPanel.tsx @@ -42,7 +42,7 @@ import DOMPurify from "dompurify"; import { getSeverityLegendColor } from "@features/dashboard/utils/dashboard"; import AssignedEngineerDisplay from "@case-details-details/AssignedEngineerDisplay"; import CaseDetailsCard from "@case-details-details/CaseDetailsCard"; -import Error500Page from "@components/error/Error500Page"; +import ApiErrorState from "@components/error/ApiErrorState"; import { formatValue, formatSlaResponseTime, @@ -67,6 +67,7 @@ import { export default function CaseDetailsDetailsPanel({ data, isError, + error, isEngagement = false, isServiceRequest = false, }: CaseDetailsDetailsPanelProps): JSX.Element { @@ -100,19 +101,11 @@ export default function CaseDetailsDetailsPanel({ if (isError) { return ( - - - Something Went Wrong - + ); } diff --git a/apps/customer-portal/webapp/src/features/support/components/case-details/header/CaseDetailsTabPanels.tsx b/apps/customer-portal/webapp/src/features/support/components/case-details/header/CaseDetailsTabPanels.tsx index 6582ad2536..82647ba667 100644 --- a/apps/customer-portal/webapp/src/features/support/components/case-details/header/CaseDetailsTabPanels.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/case-details/header/CaseDetailsTabPanels.tsx @@ -35,6 +35,7 @@ export default function CaseDetailsTabPanels({ caseId, data, isError = false, + error, projectId = "", focusMode = false, isEngagement = false, @@ -63,6 +64,7 @@ export default function CaseDetailsTabPanels({ @@ -99,6 +102,7 @@ export default function CaseDetailsTabPanels({ ) : isError ? ( - - Could not load knowledge base recommendations. Please try again later. - + ) : items.length === 0 ? ( No matching knowledge base articles were found for this conversation. diff --git a/apps/customer-portal/webapp/src/features/support/components/novera-ai-assistant/novera-chat-page/ChatMessageBubble.tsx b/apps/customer-portal/webapp/src/features/support/components/novera-ai-assistant/novera-chat-page/ChatMessageBubble.tsx index 876683afa4..24cb5bd471 100644 --- a/apps/customer-portal/webapp/src/features/support/components/novera-ai-assistant/novera-chat-page/ChatMessageBubble.tsx +++ b/apps/customer-portal/webapp/src/features/support/components/novera-ai-assistant/novera-chat-page/ChatMessageBubble.tsx @@ -18,6 +18,7 @@ import type { ChatMessageBubbleProps } from "@features/support/types/supportComp import { Box, Paper, Typography, IconButton, Button, alpha } from "@wso2/oxygen-ui"; import { Bot, User, ThumbsUp, ThumbsDown, CheckCircle } from "@wso2/oxygen-ui-icons-react"; import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; import { type JSX, useEffect, useRef, useState } from "react"; import { useDarkMode } from "@utils/useDarkMode"; import { ChatSender, NoveraActionType } from "@features/support/types/conversations"; @@ -149,6 +150,53 @@ const markdownComponents: React.ComponentProps< {children} ), + table: ({ children }) => ( + + + {children} + + + ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), }; /** @@ -402,7 +450,10 @@ export default function ChatMessageBubble({ }} className="prose prose-sm max-w-none text-gray-800" > - + {displayText} {message.thinkingSteps && message.thinkingSteps.length > 0 && ( diff --git a/apps/customer-portal/webapp/src/features/support/pages/CaseDetailsPage.tsx b/apps/customer-portal/webapp/src/features/support/pages/CaseDetailsPage.tsx index 83b47af755..f79eed84af 100644 --- a/apps/customer-portal/webapp/src/features/support/pages/CaseDetailsPage.tsx +++ b/apps/customer-portal/webapp/src/features/support/pages/CaseDetailsPage.tsx @@ -39,7 +39,7 @@ export default function CaseDetailsPage(): JSX.Element { const { showLoader, hideLoader } = useLoader(); const { showError } = useErrorBanner(); - const { data, isLoading, isError } = useGetCaseDetails( + const { data, isLoading, isError, error } = useGetCaseDetails( projectId || "", caseId || "", ); @@ -73,11 +73,15 @@ export default function CaseDetailsPage(): JSX.Element { hasShownErrorRef.current = false; return; } + if (error) { + // In-page ApiErrorState handles this path; avoid duplicate banner noise. + return; + } if (!hasShownErrorRef.current) { hasShownErrorRef.current = true; showError("Could not load case details."); } - }, [isError, showError]); + }, [isError, error, showError]); const handleBack = () => { const returnTo = (location.state as { returnTo?: string } | null)?.returnTo; @@ -131,6 +135,7 @@ export default function CaseDetailsPage(): JSX.Element { data={data} isLoading={showSkeletons} isError={isError} + error={error} caseId={caseId || ""} projectId={projectId} onBack={handleBack} diff --git a/apps/customer-portal/webapp/src/features/support/pages/ConversationDetailsPage.tsx b/apps/customer-portal/webapp/src/features/support/pages/ConversationDetailsPage.tsx index f6fe73637d..d05f078233 100644 --- a/apps/customer-portal/webapp/src/features/support/pages/ConversationDetailsPage.tsx +++ b/apps/customer-portal/webapp/src/features/support/pages/ConversationDetailsPage.tsx @@ -40,7 +40,7 @@ import type { ChatHistoryItem, } from "@features/support/types/conversations"; import { ChatSender } from "@features/support/types/conversations"; -import Error500Page from "@components/error/Error500Page"; +import ApiErrorState from "@components/error/ApiErrorState"; import type { Message } from "@features/support/types/conversations"; import ChatMessageBubble from "@features/support/components/novera-ai-assistant/novera-chat-page/ChatMessageBubble"; import ConversationKnowledgeRecommendations from "@features/support/components/knowledge-base/ConversationKnowledgeRecommendations"; @@ -74,6 +74,7 @@ export default function ConversationDetailsPage(): JSX.Element { data, isLoading, isError, + error, fetchNextPage, hasNextPage, isFetchingNextPage, @@ -277,12 +278,10 @@ export default function ConversationDetailsPage(): JSX.Element { ))} ) : isError ? ( - - - - Could not load conversation messages. - - + ) : messages.length === 0 ? ( No messages found for this conversation. diff --git a/apps/customer-portal/webapp/src/features/support/pages/SupportPage.tsx b/apps/customer-portal/webapp/src/features/support/pages/SupportPage.tsx index 1042dc4d50..eb63a1f6c6 100644 --- a/apps/customer-portal/webapp/src/features/support/pages/SupportPage.tsx +++ b/apps/customer-portal/webapp/src/features/support/pages/SupportPage.tsx @@ -59,6 +59,7 @@ export default function SupportPage(): JSX.Element { data: stats, isLoading, isError, + error: statsError, } = useGetProjectSupportStats(projectId || "", { caseTypes: [CaseType.DEFAULT_CASE], }); @@ -67,6 +68,7 @@ export default function SupportPage(): JSX.Element { data, isLoading: isCasesLoading, isError: isCasesError, + error: casesError, } = useGetProjectCases( projectId || "", { @@ -84,6 +86,7 @@ export default function SupportPage(): JSX.Element { data: conversationsData, isLoading: isChatLoading, isError: isChatError, + error: chatError, } = useSearchConversations(projectId || "", { pagination: { limit: SUPPORT_OVERVIEW_CHAT_LIMIT, offset: 0 }, sortBy: { field: "updatedOn", order: SortOrder.DESC }, diff --git a/apps/customer-portal/webapp/src/features/support/types/cases.ts b/apps/customer-portal/webapp/src/features/support/types/cases.ts index 0c857920e3..41a860664f 100644 --- a/apps/customer-portal/webapp/src/features/support/types/cases.ts +++ b/apps/customer-portal/webapp/src/features/support/types/cases.ts @@ -216,7 +216,7 @@ export type CaseDetails = AuditMetadata & { type: IdLabelRef | null; deployedProduct: CaseDetailsDeployedProduct | null; relatedCase: IdLabelRef | null; - conversation: unknown; + conversation: IdLabelRef | null; issueType: IdLabelRef | null; catalog?: IdLabelRef | null; catalogItem?: IdLabelRef | null; diff --git a/apps/customer-portal/webapp/src/features/support/types/supportComponents.ts b/apps/customer-portal/webapp/src/features/support/types/supportComponents.ts index 6e9fa0b5f7..2727808279 100644 --- a/apps/customer-portal/webapp/src/features/support/types/supportComponents.ts +++ b/apps/customer-portal/webapp/src/features/support/types/supportComponents.ts @@ -62,6 +62,7 @@ export type OutstandingCasesListProps = { export type CaseDetailsDetailsPanelProps = { data: CaseDetails | undefined; isError: boolean; + error?: unknown; isEngagement?: boolean; isServiceRequest?: boolean; }; @@ -94,6 +95,8 @@ export type CaseDetailsSectionProps = { export type ChatMessageCardProps = { htmlContent: string; + markdownContent?: string; + renderAsMarkdown?: boolean; isCurrentUser: boolean; primaryBg: string; onImageClick?: (src: string) => void; @@ -188,6 +191,7 @@ export type CommentBubbleProps = { export type CaseDetailsActivityPanelProps = { projectId: string; caseId: string; + conversationId?: string | null; caseCreatedOn?: string | null; focusMode?: boolean; caseStatus?: string | null; @@ -278,6 +282,7 @@ export type DeleteCallRequestModalProps = { export type CallsPanelProps = { projectId: string; caseId: string; + error?: unknown; isCaseClosed?: boolean; caseStatusLabel?: string; caseSeverityId?: string | null; @@ -351,6 +356,7 @@ export type CaseDetailsContentProps = { data: CaseDetails | undefined; isLoading: boolean; isError: boolean; + error?: unknown; caseId: string; onBack: () => void; onOpenRelatedCase?: () => void; @@ -389,6 +395,7 @@ export type CaseDetailsTabPanelsProps = { caseId: string; data?: CaseDetails; isError?: boolean; + error?: unknown; projectId?: string; focusMode?: boolean; isEngagement?: boolean;