@redacto.io/consent-sdk-react 3.6.0 → 5.0.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +4 -4
  3. package/dist/index.d.ts +4 -4
  4. package/dist/index.js +36 -7
  5. package/dist/index.mjs +36 -7
  6. package/dist/privacy-center.d.mts +134 -51
  7. package/dist/privacy-center.d.ts +134 -51
  8. package/dist/privacy-center.js +6391 -2741
  9. package/dist/privacy-center.mjs +6135 -2482
  10. package/package.json +1 -1
  11. package/src/RedactoNoticeConsent/RedactoNoticeConsent.tsx +5 -5
  12. package/src/RedactoNoticeConsent/language-codes.ts +37 -1
  13. package/src/RedactoNoticeConsentInline/RedactoNoticeConsentInline.tsx +0 -3
  14. package/src/RedactoNoticeConsentInline/api/index.ts +0 -1
  15. package/src/RedactoPrivacyCenter/PrivacyCenterCaseDetailsPage.tsx +154 -0
  16. package/src/RedactoPrivacyCenter/PrivacyCenterHomePage.tsx +3 -0
  17. package/src/RedactoPrivacyCenter/PrivacyCenterLayout.tsx +81 -50
  18. package/src/RedactoPrivacyCenter/RedactoPrivacyCenter.tsx +4 -0
  19. package/src/RedactoPrivacyCenter/api/actions.ts +68 -36
  20. package/src/RedactoPrivacyCenter/api/client.ts +125 -14
  21. package/src/RedactoPrivacyCenter/components/CaseDetails/AttachmentCard.tsx +38 -0
  22. package/src/RedactoPrivacyCenter/components/CaseDetails/ChatBubble.tsx +31 -0
  23. package/src/RedactoPrivacyCenter/components/CaseDetails/DocumentRequestCard.tsx +73 -0
  24. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageComposer.tsx +87 -0
  25. package/src/RedactoPrivacyCenter/components/CaseDetails/MessageItem.tsx +95 -0
  26. package/src/RedactoPrivacyCenter/components/CaseDetails/MessagesTab.tsx +147 -0
  27. package/src/RedactoPrivacyCenter/components/CaseDetails/RequestDetailsTab.tsx +171 -0
  28. package/src/RedactoPrivacyCenter/components/CaseDetails/StatusEvent.tsx +23 -0
  29. package/src/RedactoPrivacyCenter/components/CaseDetails/useFileUpload.ts +116 -0
  30. package/src/RedactoPrivacyCenter/components/CaseDetails/useMessagePolling.ts +36 -0
  31. package/src/RedactoPrivacyCenter/components/CaseDetails/utils.ts +56 -0
  32. package/src/RedactoPrivacyCenter/components/CaseDetailsModal.tsx +1 -0
  33. package/src/RedactoPrivacyCenter/components/CaseHistory.tsx +6 -13
  34. package/src/RedactoPrivacyCenter/components/ConsentManager.tsx +6 -0
  35. package/src/RedactoPrivacyCenter/components/ConsentManagerColumns.tsx +0 -1
  36. package/src/RedactoPrivacyCenter/components/DataRequest/CorrectionDataSection.tsx +2 -2
  37. package/src/RedactoPrivacyCenter/components/DataRequest/PurposeSelectionSection.tsx +2 -3
  38. package/src/RedactoPrivacyCenter/components/Form.tsx +152 -11
  39. package/src/RedactoPrivacyCenter/components/GrievanceTable.tsx +4 -6
  40. package/src/RedactoPrivacyCenter/components/GrievanceTablePagination.tsx +9 -2
  41. package/src/RedactoPrivacyCenter/components/ModifyConsentModal.tsx +1 -0
  42. package/src/RedactoPrivacyCenter/components/Pagination.tsx +12 -12
  43. package/src/RedactoPrivacyCenter/components/PrivacyCenterLayout.tsx +3 -1
  44. package/src/RedactoPrivacyCenter/components/RequestSubmit.tsx +15 -1
  45. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideBarPC.tsx +8 -8
  46. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/SideMenuPC.tsx +7 -11
  47. package/src/RedactoPrivacyCenter/components/SideMenuBarPC/types.ts +1 -1
  48. package/src/RedactoPrivacyCenter/components/activitiesColumns.tsx +1 -1
  49. package/src/RedactoPrivacyCenter/components/caseHistoryColumns.tsx +4 -24
  50. package/src/RedactoPrivacyCenter/components/constant.ts +3 -7
  51. package/src/RedactoPrivacyCenter/components/types.ts +12 -4
  52. package/src/RedactoPrivacyCenter/context/AuthContext.tsx +30 -16
  53. package/src/RedactoPrivacyCenter/context/BaseUrlContext.tsx +3 -6
  54. package/src/RedactoPrivacyCenter/context/NavigationContext.tsx +35 -3
  55. package/src/RedactoPrivacyCenter/context/OrganizationContext.tsx +1 -1
  56. package/src/RedactoPrivacyCenter/context/types.ts +14 -0
  57. package/src/RedactoPrivacyCenter/lib/api.ts +101 -3
  58. package/src/RedactoPrivacyCenter/lib/constants.ts +46 -1
  59. package/src/RedactoPrivacyCenter/lib/i18n.ts +1 -1
  60. package/src/RedactoPrivacyCenter/lib/queries.ts +106 -2
  61. package/src/RedactoPrivacyCenter/lib/types.ts +186 -1
  62. package/src/RedactoPrivacyCenter/lib/utils.ts +1 -0
  63. package/src/RedactoPrivacyCenter/locales/as/translation.json +93 -1
  64. package/src/RedactoPrivacyCenter/locales/bho/translation.json +93 -1
  65. package/src/RedactoPrivacyCenter/locales/bn/translation.json +93 -1
  66. package/src/RedactoPrivacyCenter/locales/brx/translation.json +93 -1
  67. package/src/RedactoPrivacyCenter/locales/doi/translation.json +93 -1
  68. package/src/RedactoPrivacyCenter/locales/en/translation.json +69 -2
  69. package/src/RedactoPrivacyCenter/locales/gom/translation.json +93 -1
  70. package/src/RedactoPrivacyCenter/locales/gu/translation.json +93 -1
  71. package/src/RedactoPrivacyCenter/locales/hi/translation.json +93 -1
  72. package/src/RedactoPrivacyCenter/locales/kn/translation.json +93 -1
  73. package/src/RedactoPrivacyCenter/locales/ks/translation.json +93 -1
  74. package/src/RedactoPrivacyCenter/locales/mai/translation.json +93 -1
  75. package/src/RedactoPrivacyCenter/locales/ml/translation.json +93 -1
  76. package/src/RedactoPrivacyCenter/locales/mni-Mtei/translation.json +93 -1
  77. package/src/RedactoPrivacyCenter/locales/mr/translation.json +93 -1
  78. package/src/RedactoPrivacyCenter/locales/ne/translation.json +93 -1
  79. package/src/RedactoPrivacyCenter/locales/or/translation.json +93 -1
  80. package/src/RedactoPrivacyCenter/locales/pa/translation.json +93 -1
  81. package/src/RedactoPrivacyCenter/locales/sa/translation.json +93 -1
  82. package/src/RedactoPrivacyCenter/locales/sat/translation.json +93 -1
  83. package/src/RedactoPrivacyCenter/locales/sd/translation.json +93 -1
  84. package/src/RedactoPrivacyCenter/locales/ta/translation.json +93 -1
  85. package/src/RedactoPrivacyCenter/locales/te/translation.json +93 -1
  86. package/src/RedactoPrivacyCenter/locales/ur/translation.json +93 -1
  87. package/src/RedactoPrivacyCenter/styles/injectStyles.ts +725 -0
  88. package/src/RedactoPrivacyCenter/styles/pcStyles.ts +0 -6
  89. package/src/RedactoPrivacyCenter/table/AdvancedTable.tsx +5 -3
  90. package/src/RedactoPrivacyCenter/table/AdvancedTablePagination.tsx +9 -12
  91. package/src/RedactoPrivacyCenter/ui/Footer.tsx +4 -2
  92. package/src/RedactoPrivacyCenter/ui/PCAccordion.tsx +24 -18
  93. package/src/RedactoPrivacyCenter/ui/PCCollapse.tsx +30 -20
  94. package/src/RedactoPrivacyCenter/ui/PCInput.tsx +17 -13
  95. package/src/RedactoPrivacyCenter/ui/PCLoader.tsx +3 -2
  96. package/src/RedactoPrivacyCenter/ui/PCModal.tsx +3 -1
  97. package/src/RedactoPrivacyCenter/ui/PCPopover.tsx +0 -2
  98. package/src/RedactoPrivacyCenter/ui/PCSelect.tsx +10 -4
  99. package/src/RedactoPrivacyCenter/ui/PCTable.tsx +44 -32
  100. package/src/RedactoPrivacyCenter/ui/PCText.tsx +5 -3
  101. package/src/RedactoPrivacyCenter/ui/StatusBadge.tsx +0 -2
  102. package/src/RedactoPrivacyCenter/ui/ThemeToggle.tsx +4 -2
  103. package/src/privacy-center.ts +1 -0
@@ -0,0 +1,147 @@
1
+ import { useState, useMemo, useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { IoDocumentText, IoRefresh } from 'react-icons/io5';
4
+ import { PCText, PCLoader } from '../../ui';
5
+ import { useOrganization } from '../../context/OrganizationContext';
6
+ import { useAuth } from '../../context/AuthContext';
7
+ import {
8
+ useCaseMessages,
9
+ useSendCaseMessage,
10
+ useUploadCaseDocument,
11
+ useSubmitDocumentForRequest,
12
+ } from '../../lib/queries';
13
+ import { STATUS_COLORS } from '../../lib/constants';
14
+ import type { MessagesTabProps } from '../../lib/types';
15
+ import { MessageItem } from './MessageItem';
16
+ import { MessageComposer } from './MessageComposer';
17
+ import { useMessagePolling } from './useMessagePolling';
18
+ import { useFileUpload } from './useFileUpload';
19
+
20
+ export const MessagesTab = ({ caseUuid }: MessagesTabProps) => {
21
+ const { t } = useTranslation();
22
+ const { organization } = useOrganization();
23
+ const { accessToken, refreshToken, onError, updateTokens } = useAuth();
24
+ const authContext = useMemo(
25
+ () => ({ accessToken, refreshToken, onError, updateTokens }),
26
+ [accessToken, refreshToken, onError, updateTokens]
27
+ );
28
+
29
+ const organizationId = organization?.uuid;
30
+ const workspaceId = organization?.workspaces[0]?.uuid;
31
+
32
+ const { data: messages = [], isLoading, isError, isFetching, refetch } = useCaseMessages(
33
+ organizationId, workspaceId, caseUuid, authContext
34
+ );
35
+
36
+ const sendMutation = useSendCaseMessage(organizationId, workspaceId, caseUuid, authContext);
37
+ const uploadMutation = useUploadCaseDocument(organizationId, workspaceId, caseUuid, authContext);
38
+ const submitMutation = useSubmitDocumentForRequest(organizationId, workspaceId, caseUuid, authContext);
39
+
40
+ const { scrollContainerRef, handleScroll, scrollToBottom } = useMessagePolling(refetch, messages);
41
+
42
+ const {
43
+ pendingUpload, setPendingUpload, submittingForRequest,
44
+ attachInputRef, submitInputRef,
45
+ handleAttachFile, handleSubmitInputChange, triggerUploadForRequest,
46
+ } = useFileUpload(uploadMutation, submitMutation, refetch, scrollToBottom);
47
+
48
+ const [messageText, setMessageText] = useState('');
49
+
50
+ const handleSend = useCallback(() => {
51
+ if (sendMutation.isPending || uploadMutation.isPending) return;
52
+ const trimmedBody = messageText.trim();
53
+ if (!trimmedBody && !pendingUpload) return;
54
+ sendMutation.mutate(
55
+ { body: trimmedBody, documentUuids: pendingUpload ? [pendingUpload.uuid] : [] },
56
+ {
57
+ onSuccess: () => {
58
+ setMessageText('');
59
+ setPendingUpload(null);
60
+ scrollToBottom();
61
+ refetch();
62
+ },
63
+ }
64
+ );
65
+ }, [messageText, pendingUpload, sendMutation, uploadMutation, refetch, setPendingUpload, scrollToBottom]);
66
+
67
+ const isSending = sendMutation.isPending;
68
+ const isUploading = uploadMutation.isPending;
69
+
70
+ return (
71
+ <div className="pc-messages-container">
72
+ {/* Hidden file inputs */}
73
+ <input ref={attachInputRef} type="file" className="pc-hidden" onChange={handleAttachFile} />
74
+ <input ref={submitInputRef} type="file" className="pc-hidden" onChange={handleSubmitInputChange} />
75
+
76
+ {/* Secure notice */}
77
+ <div
78
+ className="pc-messages-notice"
79
+ style={{
80
+ backgroundColor: STATUS_COLORS.infoBanner.bg,
81
+ borderBottom: `1px solid ${STATUS_COLORS.infoBanner.border}`,
82
+ color: STATUS_COLORS.infoBanner.color,
83
+ }}
84
+ >
85
+ <span>🔒</span>
86
+ <span className="pc-messages-notice-text">{t('secureMessagesNotice')}</span>
87
+ <button
88
+ type="button"
89
+ onClick={() => refetch()}
90
+ disabled={isFetching}
91
+ aria-label={t('refreshMessages', 'Refresh messages')}
92
+ title={t('refreshMessages', 'Refresh messages')}
93
+ className="pc-messages-refresh-btn"
94
+ style={{ color: STATUS_COLORS.infoBanner.color }}
95
+ >
96
+ <IoRefresh
97
+ size={16}
98
+ style={{ animation: isFetching ? 'pc-spin 1s linear infinite' : 'none' }}
99
+ />
100
+ </button>
101
+ </div>
102
+
103
+ {/* Messages list */}
104
+ <div
105
+ ref={scrollContainerRef}
106
+ onScroll={handleScroll}
107
+ className="pc-messages-list"
108
+ >
109
+ {isLoading && (
110
+ <div className="pc-messages-center">
111
+ <PCLoader variant="spinner" size="md" />
112
+ </div>
113
+ )}
114
+ {isError && (
115
+ <div className="pc-messages-center-text">
116
+ <PCText size="sm" color="secondary">{t('errorLoadingMessages')}</PCText>
117
+ </div>
118
+ )}
119
+ {!isLoading && !isError && messages.length === 0 && (
120
+ <div className="pc-messages-empty">
121
+ <IoDocumentText size={40} className="pc-messages-empty-icon" />
122
+ <PCText size="sm" color="secondary">{t('noMessagesYet')}</PCText>
123
+ </div>
124
+ )}
125
+ {messages.map((msg) => (
126
+ <MessageItem
127
+ key={msg.uuid}
128
+ message={msg}
129
+ onUploadForRequest={triggerUploadForRequest}
130
+ isSubmittingForRequest={submittingForRequest === msg.uuid}
131
+ />
132
+ ))}
133
+ </div>
134
+
135
+ <MessageComposer
136
+ messageText={messageText}
137
+ onMessageTextChange={setMessageText}
138
+ pendingUpload={pendingUpload}
139
+ onRemovePendingUpload={() => setPendingUpload(null)}
140
+ onAttachClick={() => attachInputRef.current?.click()}
141
+ onSend={handleSend}
142
+ isSending={isSending}
143
+ isUploading={isUploading}
144
+ />
145
+ </div>
146
+ );
147
+ };
@@ -0,0 +1,171 @@
1
+ import { useTranslation } from 'react-i18next';
2
+ import { PCText, PCBadge, PCTable } from '../../ui';
3
+ import { formatDate } from '../utils';
4
+ import type { RequestDetailsTabProps, RawPurposeWrapper, DataElement, UpdateCorrectionDataItem, GrievanceTypeElement } from '../../lib/types';
5
+ import { getCaseStatusVariant, formatCaseStatus } from './utils';
6
+
7
+ export const RequestDetailsTab = ({
8
+ selectedCase,
9
+ request_details,
10
+ requestTypeLabel,
11
+ }: RequestDetailsTabProps) => {
12
+ const { t } = useTranslation();
13
+ return (
14
+ <div className="pc-request-details">
15
+ {/* Summary card */}
16
+ <div className="pc-request-summary-card">
17
+ <div className="pc-case-details-grid pc-request-summary-grid">
18
+ <div>
19
+ <PCText size="xs" weight="medium" color="secondary" className="pc-request-field-label">
20
+ {t('requestType')}
21
+ </PCText>
22
+ <PCText size="sm" weight="semibold" className="pc-request-field-value--capitalize">
23
+ {requestTypeLabel}
24
+ </PCText>
25
+ </div>
26
+ <div>
27
+ <PCText size="xs" weight="medium" color="secondary" className="pc-request-field-label">
28
+ {t('status')}
29
+ </PCText>
30
+ <PCBadge variant={getCaseStatusVariant(selectedCase.status)} size="sm" className="pc-status-badge">
31
+ {selectedCase.status_display || formatCaseStatus(selectedCase.status)}
32
+ </PCBadge>
33
+ </div>
34
+ <div>
35
+ <PCText size="xs" weight="medium" color="secondary" className="pc-request-field-label">
36
+ {t('createdAt')}
37
+ </PCText>
38
+ <PCText size="sm" weight="medium" className="pc-request-field-value">
39
+ {formatDate(selectedCase.created_at)}
40
+ </PCText>
41
+ </div>
42
+ {selectedCase.completed_at && (
43
+ <div>
44
+ <PCText size="xs" weight="medium" color="secondary" className="pc-request-field-label">
45
+ {t('completedAt')}
46
+ </PCText>
47
+ <PCText size="sm" weight="medium" className="pc-request-field-value">
48
+ {formatDate(selectedCase.completed_at)}
49
+ </PCText>
50
+ </div>
51
+ )}
52
+ </div>
53
+ </div>
54
+
55
+ {/* Description */}
56
+ {(selectedCase.description_display || selectedCase.description) && (
57
+ <div className="pc-request-description">
58
+ <PCText size="sm" weight="semibold" className="pc-request-description-title">
59
+ {t('descriptionLabel')}
60
+ </PCText>
61
+ <PCText size="sm" color="secondary" className="pc-request-description-text">
62
+ {selectedCase.description_display || selectedCase.description}
63
+ </PCText>
64
+ </div>
65
+ )}
66
+
67
+ {/* Request specifics */}
68
+ {request_details && (
69
+ <div className="pc-request-specifics">
70
+ <div className="pc-request-section-divider">
71
+ <span className="pc-request-section-title">{t('requestSpecifics')}</span>
72
+ </div>
73
+
74
+ {request_details.purposes && request_details.purposes.length > 0 && (
75
+ <div>
76
+ <PCText size="sm" weight="semibold" className="pc-request-section-heading">
77
+ {t('purposesInvolved')}
78
+ </PCText>
79
+ <ul className="pc-request-purpose-list">
80
+ {request_details.purposes.map((p: RawPurposeWrapper) => (
81
+ <li key={p.purpose?.uuid} className="pc-request-purpose-item">
82
+ <div className="pc-request-purpose-name">
83
+ {p.purpose?.name}
84
+ </div>
85
+ {p.purpose?.description && (
86
+ <div className="pc-request-purpose-desc">
87
+ {p.purpose.description}
88
+ </div>
89
+ )}
90
+ {p.purpose?.data_elements && p.purpose.data_elements.some((de: DataElement) => de.selected) && (
91
+ <div className="pc-request-data-elements">
92
+ {p.purpose.data_elements.filter((de: DataElement) => de.selected).map((de: DataElement) => (
93
+ <PCBadge key={de.uuid} variant="secondary" size="sm" className="pc-status-badge">{de.name}</PCBadge>
94
+ ))}
95
+ </div>
96
+ )}
97
+ </li>
98
+ ))}
99
+ </ul>
100
+ </div>
101
+ )}
102
+
103
+ {request_details.correction_data && request_details.correction_data.length > 0 && (
104
+ <div>
105
+ <PCText size="sm" weight="semibold" className="pc-request-section-heading">
106
+ {t('dataToCorrect')}
107
+ </PCText>
108
+ <div className="pcOverflowXAuto">
109
+ <div className="pc-request-table-wrap">
110
+ <PCTable className="pc-request-table">
111
+ <PCTable.Header className="pc-request-table-header">
112
+ <PCTable.Row>
113
+ {[t('field'), t('currentValue'), t('newValue')].map((h) => (
114
+ <PCTable.Head key={h} className="pc-request-table-head">
115
+ {h}
116
+ </PCTable.Head>
117
+ ))}
118
+ </PCTable.Row>
119
+ </PCTable.Header>
120
+ <PCTable.Body>
121
+ {request_details.correction_data.map((item: UpdateCorrectionDataItem) => (
122
+ <PCTable.Row key={item.name} className="pc-request-table-row">
123
+ <PCTable.Cell className="pc-request-table-cell pc-request-table-cell--name">{item.name}</PCTable.Cell>
124
+ <PCTable.Cell className="pc-request-table-cell pc-request-table-cell--value">{item.curr_value || '-'}</PCTable.Cell>
125
+ <PCTable.Cell className="pc-request-table-cell pc-request-table-cell--value">{item.new_value || '-'}</PCTable.Cell>
126
+ </PCTable.Row>
127
+ ))}
128
+ </PCTable.Body>
129
+ </PCTable>
130
+ </div>
131
+ </div>
132
+ </div>
133
+ )}
134
+
135
+ {request_details.nomination_data && (
136
+ <div>
137
+ <PCText size="sm" weight="semibold" className="pc-request-section-heading">
138
+ {t('nomination')}
139
+ </PCText>
140
+ <div className="pc-request-nomination-card">
141
+ <div className="pc-request-nomination-field">
142
+ <PCText size="xs" weight="medium" color="secondary">{t('nomineeEmail')}</PCText>
143
+ <PCText size="sm" weight="medium" className="pc-request-field-value">{request_details.nomination_data.nominee_email || '-'}</PCText>
144
+ </div>
145
+ <div className="pc-request-nomination-field">
146
+ <PCText size="xs" weight="medium" color="secondary">{t('nomineeMobile')}</PCText>
147
+ <PCText size="sm" weight="medium" className="pc-request-field-value">{request_details.nomination_data.nominee_mobile || '-'}</PCText>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ )}
152
+
153
+ {request_details.grievance_types && request_details.grievance_types.length > 0 && (
154
+ <div>
155
+ <PCText size="sm" weight="semibold" className="pc-request-section-heading">
156
+ {t('grievanceTypes')}
157
+ </PCText>
158
+ <div className="pc-request-grievance-wrap">
159
+ {request_details.grievance_types.map((g: GrievanceTypeElement) => (
160
+ <PCBadge key={g.grievance} variant="secondary" size="sm" className="pc-status-badge">
161
+ {g.grievance?.replace('_', ' ')}
162
+ </PCBadge>
163
+ ))}
164
+ </div>
165
+ </div>
166
+ )}
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ };
@@ -0,0 +1,23 @@
1
+ import type { StatusEventProps } from '../../lib/types';
2
+
3
+ export const StatusEvent = ({
4
+ icon, color, bgColor, borderColor, text, subText, timestamp,
5
+ }: StatusEventProps) => {
6
+ return (
7
+ <div className="pc-status-event">
8
+ <div
9
+ className="pc-status-event-pill"
10
+ style={{ backgroundColor: bgColor, border: `1px solid ${borderColor}`, color }}
11
+ >
12
+ {icon}
13
+ <span>{text}</span>
14
+ </div>
15
+ {subText && (
16
+ <div className="pc-status-event-sub" style={{ color }}>{subText}</div>
17
+ )}
18
+ <span className="pc-status-event-time">
19
+ {timestamp}
20
+ </span>
21
+ </div>
22
+ );
23
+ };
@@ -0,0 +1,116 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import type { PendingUpload } from '../../lib/types';
3
+ import { toast } from '../../utils/toast';
4
+ import { validateUploadFile, getUploadErrorMessage } from './utils';
5
+
6
+ type UploadMutation = {
7
+ mutate: (file: File, opts: {
8
+ onSuccess: (result: { uuid: string; file_name: string; file_size: number } | null) => void;
9
+ onError: (error: unknown) => void;
10
+ }) => void;
11
+ isPending: boolean;
12
+ };
13
+
14
+ type SubmitMutation = {
15
+ mutate: (params: { requestEventUuid: string; documentUuid: string; body: string }, opts: {
16
+ onSuccess: () => void;
17
+ onError: () => void;
18
+ }) => void;
19
+ };
20
+
21
+ export function useFileUpload(
22
+ uploadMutation: UploadMutation,
23
+ submitMutation: SubmitMutation,
24
+ refetch: () => void,
25
+ scrollToBottom: () => void,
26
+ ) {
27
+ const [pendingUpload, setPendingUpload] = useState<PendingUpload | null>(null);
28
+ const [submittingForRequest, setSubmittingForRequest] = useState<string | null>(null);
29
+
30
+ const attachInputRef = useRef<HTMLInputElement>(null);
31
+ const submitInputRef = useRef<HTMLInputElement>(null);
32
+ const pendingRequestRef = useRef<string | null>(null);
33
+
34
+ const handleAttachFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
35
+ const file = e.target.files?.[0];
36
+ if (!file) return;
37
+ e.target.value = '';
38
+ const validationError = validateUploadFile(file);
39
+ if (validationError) {
40
+ toast.error(validationError);
41
+ return;
42
+ }
43
+ uploadMutation.mutate(file, {
44
+ onSuccess: (result) => {
45
+ if (result) {
46
+ setPendingUpload({ uuid: result.uuid, file_name: result.file_name, file_size: result.file_size });
47
+ toast.success(`"${result.file_name}" uploaded successfully`);
48
+ } else {
49
+ toast.error('Document upload failed. Please try again.');
50
+ }
51
+ },
52
+ onError: (error) => {
53
+ toast.error(getUploadErrorMessage(error));
54
+ },
55
+ });
56
+ }, [uploadMutation]);
57
+
58
+ const handleSubmitForRequest = useCallback((requestEventUuid: string, file: File) => {
59
+ const validationError = validateUploadFile(file);
60
+ if (validationError) {
61
+ toast.error(validationError);
62
+ return;
63
+ }
64
+ setSubmittingForRequest(requestEventUuid);
65
+ uploadMutation.mutate(file, {
66
+ onSuccess: (uploaded) => {
67
+ if (!uploaded) {
68
+ setSubmittingForRequest(null);
69
+ toast.error('Document upload failed. Please try again.');
70
+ return;
71
+ }
72
+ toast.success(`"${uploaded.file_name}" uploaded successfully`);
73
+ submitMutation.mutate(
74
+ { requestEventUuid, documentUuid: uploaded.uuid, body: '' },
75
+ {
76
+ onSuccess: () => { setSubmittingForRequest(null); scrollToBottom(); refetch(); },
77
+ onError: () => {
78
+ setSubmittingForRequest(null);
79
+ toast.error('Failed to submit document. Please try again.');
80
+ },
81
+ }
82
+ );
83
+ },
84
+ onError: (error) => {
85
+ setSubmittingForRequest(null);
86
+ toast.error(getUploadErrorMessage(error));
87
+ },
88
+ });
89
+ }, [uploadMutation, submitMutation, refetch, scrollToBottom]);
90
+
91
+ const handleSubmitInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
92
+ const file = e.target.files?.[0];
93
+ e.target.value = '';
94
+ const requestUuid = pendingRequestRef.current;
95
+ pendingRequestRef.current = null;
96
+ if (file && requestUuid) {
97
+ handleSubmitForRequest(requestUuid, file);
98
+ }
99
+ }, [handleSubmitForRequest]);
100
+
101
+ const triggerUploadForRequest = useCallback((requestEventUuid: string) => {
102
+ pendingRequestRef.current = requestEventUuid;
103
+ submitInputRef.current?.click();
104
+ }, []);
105
+
106
+ return {
107
+ pendingUpload,
108
+ setPendingUpload,
109
+ submittingForRequest,
110
+ attachInputRef,
111
+ submitInputRef,
112
+ handleAttachFile,
113
+ handleSubmitInputChange,
114
+ triggerUploadForRequest,
115
+ };
116
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useLayoutEffect, useRef, useCallback } from 'react';
2
+ import type { CaseMessage } from '../../lib/types';
3
+
4
+ export function useMessagePolling(
5
+ refetch: () => void,
6
+ messages: CaseMessage[],
7
+ ) {
8
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
9
+ const isNearBottomRef = useRef(true);
10
+ const shouldAutoScrollRef = useRef(true);
11
+
12
+ useEffect(() => {
13
+ const interval = setInterval(() => { refetch(); }, 30_000);
14
+ return () => clearInterval(interval);
15
+ }, [refetch]);
16
+
17
+ const handleScroll = useCallback(() => {
18
+ const el = scrollContainerRef.current;
19
+ if (!el) return;
20
+ isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
21
+ }, []);
22
+
23
+ useLayoutEffect(() => {
24
+ const el = scrollContainerRef.current;
25
+ if (el && (isNearBottomRef.current || shouldAutoScrollRef.current)) {
26
+ el.scrollTop = el.scrollHeight;
27
+ shouldAutoScrollRef.current = false;
28
+ }
29
+ }, [messages]);
30
+
31
+ const scrollToBottom = useCallback(() => {
32
+ shouldAutoScrollRef.current = true;
33
+ }, []);
34
+
35
+ return { scrollContainerRef, handleScroll, scrollToBottom };
36
+ }
@@ -0,0 +1,56 @@
1
+ import { MAX_FILE_SIZE, ALLOWED_MIME_TYPES } from '../../lib/constants';
2
+ import type { CaseStatusVariant } from '../../lib/types';
3
+
4
+ export const getVar = (v: string) => `var(${v})`;
5
+
6
+ export const getCaseStatusVariant = (status: string): CaseStatusVariant => {
7
+ const n = status.toLowerCase().replace(/_/g, '');
8
+ if (n === 'completed') return 'success';
9
+ if (n === 'rejected' || n === 'declined') return 'error';
10
+ if (n === 'processing' || n === 'inprogress' || n === 'expired') return 'warning';
11
+ if (n === 'initiated') return 'info';
12
+ return 'secondary';
13
+ };
14
+
15
+ export const formatCaseStatus = (s: string) =>
16
+ s.split('_').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ');
17
+
18
+ export const getDownloadUrl = (fileUrl: { url?: string; download_url?: string } | null | undefined): string | null =>
19
+ fileUrl ? (fileUrl.url || fileUrl.download_url || null) : null;
20
+
21
+ /** Formats a UTC ISO string to local timezone display, e.g. "Mar 24, 2:14 PM" */
22
+ export function formatMessageTime(isoString: string): string {
23
+ const d = new Date(isoString);
24
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
25
+ + ', '
26
+ + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
27
+ }
28
+
29
+ export function formatFileSize(bytes: number): string {
30
+ if (bytes < 1024) return `${bytes} B`;
31
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
32
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
33
+ }
34
+
35
+ export function validateUploadFile(file: File): string | null {
36
+ if (file.size === 0) return 'File is empty. Please select a valid file.';
37
+ if (file.size > MAX_FILE_SIZE) return 'File size must not exceed 10 MB.';
38
+ if (!file.type || file.type.trim() === '') return 'Could not determine file type. Please try a different file.';
39
+ if (!ALLOWED_MIME_TYPES.includes(file.type))
40
+ return 'File type not supported. Allowed: PDF, JPG, PNG, WEBP, DOC, DOCX, XLS, XLSX, TXT, CSV.';
41
+ return null;
42
+ }
43
+
44
+ export function getUploadErrorMessage(error: unknown): string {
45
+ if (!(error instanceof Error)) return 'Something went wrong. Please try again.';
46
+ const msg = error.message;
47
+ if (msg.includes('[409]') || msg.includes('document_already_exists'))
48
+ return 'This document has already been uploaded.';
49
+ if (msg.includes('[422]'))
50
+ return 'File format not accepted. Please upload a PDF, image, or Office document.';
51
+ if (msg.includes('[400]') || msg.includes('empty_file'))
52
+ return 'File is empty or the request is invalid.';
53
+ if (msg.includes('[500]'))
54
+ return 'Something went wrong on the server. Please try again.';
55
+ return 'Document upload failed. Please try again.';
56
+ }
@@ -31,6 +31,7 @@ export default function CaseDetailsModal({
31
31
  <PCModal
32
32
  isOpen={isOpen}
33
33
  onClose={onClose}
34
+ closeAriaLabel={t('closeModal')}
34
35
  size="lg"
35
36
  title={
36
37
  <div style={{ minWidth: 0, flex: 1, paddingRight: getVar('--pc-spacing-sm') }}>
@@ -18,7 +18,7 @@ import { useAuth } from '../context/AuthContext';
18
18
  import { useRequestTypeLabels, useGrievanceTabs } from '../lib/constants';
19
19
  import type { CaseHistoryProps } from './types';
20
20
  import { GRIEVANCE_TAB_IDS } from './constant';
21
- import CaseDetailsModal from './CaseDetailsModal';
21
+ import { useInternalNavigation } from '../context/NavigationContext';
22
22
  import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
23
23
  import { useCaseHistoryColumns } from './caseHistoryColumns';
24
24
  import { PCPopover, PCCheckbox } from '../ui';
@@ -27,6 +27,7 @@ import GrievanceTablePagination from './GrievanceTablePagination';
27
27
 
28
28
  export default function CaseHistory({ onCreateRequest }: CaseHistoryProps) {
29
29
  const { t } = useTranslation();
30
+ const { navigateTo, setSelectedCase } = useInternalNavigation();
30
31
  const grievanceTabs = useGrievanceTabs();
31
32
  const requestTypeLabels = useRequestTypeLabels();
32
33
  const [activeTab, setActiveTab] = useState<string>(GRIEVANCE_TAB_IDS.ALL);
@@ -34,8 +35,6 @@ export default function CaseHistory({ onCreateRequest }: CaseHistoryProps) {
34
35
  const [selectedRequestTypes, setSelectedRequestTypes] = useState<string[]>(
35
36
  Object.values(RequestTypeEnum)
36
37
  );
37
- const [selectedCase, setSelectedCase] = useState<CaseRequest | null>(null);
38
- const [isModalOpen, setIsModalOpen] = useState(false);
39
38
  const [pageIndex, setPageIndex] = useState(0);
40
39
  const [pageSize, setPageSize] = useState(10);
41
40
  const { organization } = useOrganization();
@@ -116,12 +115,10 @@ export default function CaseHistory({ onCreateRequest }: CaseHistoryProps) {
116
115
 
117
116
  const handleViewDetails = useCallback((caseRequest: CaseRequest) => {
118
117
  setSelectedCase(caseRequest);
119
- setIsModalOpen(true);
120
- }, []);
118
+ navigateTo('case-details');
119
+ }, [navigateTo, setSelectedCase]);
121
120
 
122
- const columns = useCaseHistoryColumns({
123
- onViewDetails: handleViewDetails,
124
- });
121
+ const columns = useCaseHistoryColumns();
125
122
 
126
123
  // Reset to first page when filters change (but not when pagination changes)
127
124
  useEffect(() => {
@@ -265,6 +262,7 @@ export default function CaseHistory({ onCreateRequest }: CaseHistoryProps) {
265
262
  <PCInput
266
263
  type="search"
267
264
  clearable
265
+ clearAriaLabel={t('clearInput')}
268
266
  placeholder={t('searchCasesByID')}
269
267
  value={searchQuery}
270
268
  onClear={() => setSearchQuery('')}
@@ -423,11 +421,6 @@ export default function CaseHistory({ onCreateRequest }: CaseHistoryProps) {
423
421
  setPageIndex(0);
424
422
  }}
425
423
  />
426
- <CaseDetailsModal
427
- isOpen={isModalOpen}
428
- onClose={() => setIsModalOpen(false)}
429
- selectedCase={selectedCase}
430
- />
431
424
  </div>
432
425
  );
433
426
  }
@@ -592,6 +592,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
592
592
  <PCInput
593
593
  type="search"
594
594
  clearable
595
+ clearAriaLabel={t('clearInput')}
595
596
  placeholder={t('search')}
596
597
  value={searchQuery}
597
598
  onClear={() => {
@@ -623,6 +624,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
623
624
  onChange={handleStatusFilterChange}
624
625
  onClear={() => setStatusFilter(null)}
625
626
  clearable={statusFilter !== null}
627
+ clearAriaLabel={t('clearSelection')}
626
628
  dropdownMatchTriggerWidth
627
629
  style={{
628
630
  width: '100%',
@@ -632,11 +634,14 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
632
634
  className="pc-filter-select"
633
635
  placeholder={t('product')}
634
636
  searchable
637
+ searchPlaceholder={t('searchPlaceholder')}
638
+ noOptionsMessage={t('noOptionsFound')}
635
639
  options={productOptions}
636
640
  value={productFilter}
637
641
  onChange={handleProductFilterChange}
638
642
  onClear={() => setProductFilter(null)}
639
643
  clearable={productFilter !== null}
644
+ clearAriaLabel={t('clearSelection')}
640
645
  dropdownMatchTriggerWidth
641
646
  style={{
642
647
  width: '100%',
@@ -665,6 +670,7 @@ export default function ConsentManager({ variant }: ConsentManagerProps) {
665
670
  isOpen={exportModal}
666
671
  onClose={() => setExportModal(false)}
667
672
  size="sm"
673
+ closeAriaLabel={t('closeModal')}
668
674
  title={
669
675
  <div style={{ minWidth: 0, flex: 1, paddingRight: getVar('--pc-spacing-sm') }}>
670
676
  <PCText size="base" weight="semibold" style={{ margin: 0, color: getVar('--pc-text-primary') }}>
@@ -1,7 +1,6 @@
1
1
  import { useMemo } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import { PCText, PCTooltip, PCBadge } from '../ui';
4
- import { cn } from '../utils/cn';
5
4
  import { pcStyles } from '../styles/pcStyles';
6
5
  import { Smartphone, LaptopMinimal } from 'lucide-react';
7
6
  import { createColumnHelper } from '@tanstack/react-table';