All files / pages/settings CommunicationsPage.tsx

84.61% Statements 33/39
90.9% Branches 20/22
80% Functions 12/15
86.11% Lines 31/36

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>
	);
}