Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 | 1x 9x 9x 9x 9x 9x 15x 15x 9x 15x 1x 2x 1x 1x 1x 9x 1x 3x 2x 1x 1x 1x 9x 9x 4x 5x 5x 15x 5x 15x 15x | import {type ReactElement, useState} from 'react';
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Container,
Divider,
Paper,
Snackbar,
Switch,
Typography
} from '@mui/material';
import {Save as SaveIcon} from '@mui/icons-material';
import {
type ConsentState,
type ConsentType,
type ConsentUpdate,
useGetMyConsentsQuery,
useUpdateMyConsentsMutation
} from '../../services/consentsApi';
// ============================================================================
// Display catalog
// ============================================================================
/**
* Frontend labels for the backend consent type identifiers. The backend owns
* the authoritative legal wording (recorded with each consent); this catalog
* is only the human-readable summary shown on the settings page.
*/
const CONSENT_CATALOG: Record<ConsentType, {label: string; description: string}> = {
transactional: {
label: 'Transactional communications',
description:
'Service, security, and account-activity messages. Required to operate your account.'
},
sms: {
label: 'SMS / text messages',
description:
'Alerts, account information, and marketing by text. Message and data rates may apply. Reply STOP to opt out.'
},
automated_calls: {
label: 'Automated calls & prerecorded messages',
description: 'Marketing and informational calls placed with an autodialer or prerecorded voice.'
}
};
// ============================================================================
// Types
// ============================================================================
interface SnackbarState {
open: boolean;
message: string;
severity: 'success' | 'error';
}
// ============================================================================
// Component
// ============================================================================
export default function CommunicationsPage(): ReactElement {
const {data: serverConsents, isLoading, error} = useGetMyConsentsQuery();
const [updateConsents, {isLoading: isSaving}] = useUpdateMyConsentsMutation();
// Track only what the user toggled away from the server value, so the
// rendered switch state is derived (server data + overrides) without an
// effect syncing local and server state.
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
const [snackbar, setSnackbar] = useState<SnackbarState>({
open: false,
message: '',
severity: 'success'
});
const merged: ConsentState[] = (serverConsents ?? []).map((consent) => {
const override = overrides[consent.consentType];
return override === undefined ? consent : {...consent, granted: override};
});
const isDirty = Object.keys(overrides).length > 0;
const handleToggle = (consent: ConsentState) => (): void => {
const next = !consent.granted; // consent is merged, so this flips the displayed value
const serverValue = serverConsents?.find((c) => c.consentType === consent.consentType)?.granted;
setOverrides((prev) => {
// Drop the override if it matches the stored value again, so the page
// correctly reports clean.
Iif (next === serverValue) {
const {[consent.consentType]: _removed, ...rest} = prev;
return rest;
}
return {...prev, [consent.consentType]: next};
});
};
const handleSave = (): void => {
// Only optional consents are editable; the required transactional consent
// is never submitted (the backend rejects it anyway).
const updates: ConsentUpdate[] = merged
.filter((c) => !c.required)
.map((c) => ({consentType: c.consentType, granted: c.granted}));
updateConsents(updates)
.unwrap()
.then((): void => {
setOverrides({});
setSnackbar({open: true, message: 'Communication preferences saved.', severity: 'success'});
})
.catch((): void => {
setSnackbar({
open: true,
message: 'Failed to save preferences. Please try again.',
severity: 'error'
});
});
};
const handleCloseSnackbar = (): void => {
setSnackbar((prev) => ({...prev, open: false}));
};
// -------------------------------------------------------------------------
// Loading / error
// -------------------------------------------------------------------------
if (isLoading) {
return (
<Box sx={{display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '60vh'}}>
<CircularProgress/>
</Box>
);
}
Iif (error !== undefined) {
return (
<Container maxWidth="md" sx={{mt: 4}}>
<Alert severity="error">
Failed to load communication preferences. Please refresh and try again.
</Alert>
</Container>
);
}
const formatUpdated = (updatedAt: string | null): string =>
updatedAt === null ? 'Not set' : `Last updated ${new Date(updatedAt).toLocaleDateString()}`;
return (
<Box sx={{backgroundColor: 'background.default', minHeight: '100vh', py: 4}}>
<Container maxWidth="md">
<Box sx={{mb: 4}}>
<Typography variant="h4" gutterBottom sx={{fontWeight: 'bold'}}>
Communication consent
</Typography>
<Typography variant="body1" sx={{color: 'text.secondary'}}>
Control how FlowState Capital may contact you. You can change your optional
consents at any time; they take effect immediately.
</Typography>
</Box>
<Paper sx={{p: 3, mb: 3}}>
{merged.map((consent, i) => {
const display = CONSENT_CATALOG[consent.consentType];
return (
<Box key={consent.consentType}>
{i > 0 && <Divider sx={{my: 2}}/>}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 2
}}
>
<Box sx={{flex: 1}}>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<Typography variant="body1" sx={{fontWeight: 'medium'}}>
{display.label}
</Typography>
{consent.required && (
<Chip label="Required" size="small" color="default"/>
)}
</Box>
<Typography variant="body2" sx={{color: 'text.secondary'}}>
{display.description}
</Typography>
<Typography variant="caption" sx={{color: 'text.disabled'}}>
{formatUpdated(consent.updatedAt)}
</Typography>
</Box>
<Switch
checked={consent.granted}
disabled={consent.required}
onChange={handleToggle(consent)}
data-testid={`consent-switch-${consent.consentType}`}
slotProps={{input: {'aria-label': display.label}}}
/>
</Box>
</Box>
);
})}
<Box sx={{display: 'flex', justifyContent: 'flex-end', mt: 3}}>
<Button
variant="contained"
startIcon={isSaving ? <CircularProgress size={20}/> : <SaveIcon/>}
onClick={handleSave}
disabled={!isDirty || isSaving}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</Box>
</Paper>
<Snackbar
open={snackbar.open}
autoHideDuration={4000}
onClose={handleCloseSnackbar}
anchorOrigin={{vertical: 'bottom', horizontal: 'center'}}
>
<Alert onClose={handleCloseSnackbar} severity={snackbar.severity} sx={{width: '100%'}}>
{snackbar.message}
</Alert>
</Snackbar>
</Container>
</Box>
);
}
|