Add member profile questions, admin tooling, legal pages, and fast tests
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
- Add admin UI for managing profile questions and member profile answers
- Add volunteer level/profile data support across backend schemas, models, API, and migration
- Update dashboard/profile UI, super admin menu, membership service types, and related styling
- Add privacy policy, terms of service, cookie notice, and footer links
- Add frontend Vitest coverage for profile question logic
- Add backend pytest coverage for profile answer normalization and validation
- Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
- Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
- Protect feature flag reload behind super-admin access
- Restrict admin-triggered password resets so admins can only reset member accounts
- Replace email template HTML preview rendering with escaped text preview
- Update docs for feature flag reload access, password reset scope, and email template preview safety
-- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
This commit is contained in:
@@ -17,10 +17,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"vite": "^5.0.5"
|
||||
"vite": "^5.0.5",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
+779
-114
File diff suppressed because it is too large
Load Diff
+47
-14
@@ -6,26 +6,59 @@ import Login from './pages/Login';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import EmailTemplates from './pages/EmailTemplates';
|
||||
import MembershipTiers from './pages/MembershipTiers';
|
||||
import BounceManagement from './pages/BounceManagement';
|
||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||
import TermsOfService from './pages/TermsOfService';
|
||||
import './App.css';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [cookieDismissed, setCookieDismissed] = useState(
|
||||
() => localStorage.getItem('cookie_notice_dismissed') === 'true'
|
||||
);
|
||||
|
||||
const dismissCookies = () => {
|
||||
localStorage.setItem('cookie_notice_dismissed', 'true');
|
||||
setCookieDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/email-templates" element={<EmailTemplates />} />
|
||||
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
||||
<Route path="/bounce-management" element={<BounceManagement />} />
|
||||
</Routes>
|
||||
<div className="app-shell">
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/email-templates" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/membership-tiers" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/bounce-management" element={<Navigate to="/dashboard" />} />
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
<Route path="/terms-of-service" element={<TermsOfService />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
<Link to="/privacy-policy">Privacy Policy</Link>
|
||||
<Link to="/terms-of-service">Terms of Service</Link>
|
||||
</div>
|
||||
<div style={{ marginTop: '8px' }}>SASA Portal</div>
|
||||
</footer>
|
||||
{!cookieDismissed && (
|
||||
<div className="cookie-banner">
|
||||
<div>
|
||||
We use cookies for session authentication, security, and basic site functionality.
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ padding: '6px 12px' }} onClick={dismissCookies}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</FeatureFlagProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ProfileQuestion,
|
||||
ProfileQuestionInputType,
|
||||
ProfileQuestionOption,
|
||||
ProfileQuestionUpsertData,
|
||||
userService
|
||||
} from '../services/membershipService';
|
||||
|
||||
interface AdminProfileQuestionManagerProps {
|
||||
onQuestionsChanged?: () => void;
|
||||
}
|
||||
|
||||
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
|
||||
|
||||
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
|
||||
if (!options || options.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return options.map((option) => `${option.label}|${option.value}`).join('\n');
|
||||
};
|
||||
|
||||
const textToOptions = (value: string): ProfileQuestionOption[] => {
|
||||
return value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [labelPart, valuePart] = line.split('|');
|
||||
const label = (labelPart || '').trim();
|
||||
const optionValue = (valuePart || labelPart || '').trim();
|
||||
return { label, value: optionValue };
|
||||
})
|
||||
.filter((option) => option.label.length > 0 && option.value.length > 0);
|
||||
};
|
||||
|
||||
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => {
|
||||
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
|
||||
const [listSearch, setListSearch] = useState('');
|
||||
|
||||
const emptyForm: ProfileQuestionUpsertData = {
|
||||
key: '',
|
||||
label: '',
|
||||
help_text: '',
|
||||
input_type: 'text',
|
||||
placeholder: '',
|
||||
options: null,
|
||||
is_required: false,
|
||||
is_active: true,
|
||||
admin_only_edit: false,
|
||||
display_order: 0,
|
||||
depends_on_question_id: null,
|
||||
depends_on_value: null
|
||||
};
|
||||
|
||||
const [formData, setFormData] = useState<ProfileQuestionUpsertData>(emptyForm);
|
||||
const [optionsText, setOptionsText] = useState('');
|
||||
|
||||
const loadQuestions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await userService.getAdminProfileQuestions(true);
|
||||
setQuestions(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load questions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadQuestions();
|
||||
}, []);
|
||||
|
||||
const dependencyCandidates = useMemo(() => {
|
||||
return questions.filter((question) => question.id !== editingQuestionId);
|
||||
}, [questions, editingQuestionId]);
|
||||
|
||||
const selectedDependencyQuestion = useMemo(() => {
|
||||
if (!formData.depends_on_question_id) {
|
||||
return null;
|
||||
}
|
||||
return questions.find((question) => question.id === formData.depends_on_question_id) || null;
|
||||
}, [questions, formData.depends_on_question_id]);
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const term = listSearch.trim().toLowerCase();
|
||||
if (!term) {
|
||||
return questions;
|
||||
}
|
||||
return questions.filter((question) =>
|
||||
question.label.toLowerCase().includes(term) ||
|
||||
question.key.toLowerCase().includes(term)
|
||||
);
|
||||
}, [questions, listSearch]);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(emptyForm);
|
||||
setOptionsText('');
|
||||
setEditingQuestionId(null);
|
||||
};
|
||||
|
||||
const handleEdit = (question: ProfileQuestion) => {
|
||||
setEditingQuestionId(question.id);
|
||||
setFormData({
|
||||
key: question.key,
|
||||
label: question.label,
|
||||
help_text: question.help_text,
|
||||
input_type: question.input_type,
|
||||
placeholder: question.placeholder,
|
||||
options: question.options,
|
||||
is_required: question.is_required,
|
||||
is_active: question.is_active,
|
||||
admin_only_edit: question.admin_only_edit,
|
||||
display_order: question.display_order,
|
||||
depends_on_question_id: question.depends_on_question_id,
|
||||
depends_on_value: question.depends_on_value
|
||||
});
|
||||
setOptionsText(optionsToText(question.options));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload: ProfileQuestionUpsertData = {
|
||||
...formData,
|
||||
key: formData.key.trim(),
|
||||
label: formData.label.trim(),
|
||||
help_text: formData.help_text?.trim() || null,
|
||||
placeholder: formData.placeholder?.trim() || null,
|
||||
depends_on_value: formData.depends_on_value?.trim() || null,
|
||||
options: formData.input_type === 'select' ? textToOptions(optionsText) : null,
|
||||
};
|
||||
|
||||
if (editingQuestionId) {
|
||||
await userService.updateAdminProfileQuestion(editingQuestionId, payload);
|
||||
} else {
|
||||
await userService.createAdminProfileQuestion(payload);
|
||||
}
|
||||
|
||||
await loadQuestions();
|
||||
resetForm();
|
||||
onQuestionsChanged?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to save question');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (questionId: number) => {
|
||||
if (!window.confirm('Deactivate this question? Existing answers are kept.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userService.deactivateAdminProfileQuestion(questionId);
|
||||
await loadQuestions();
|
||||
onQuestionsChanged?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to deactivate question');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3>
|
||||
<p style={{ marginBottom: '16px', color: '#555' }}>
|
||||
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies.
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
|
||||
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Question key (e.g. pilot_license_type)"
|
||||
value={formData.key}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Question label"
|
||||
value={formData.label}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Help text (optional)"
|
||||
value={formData.help_text || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
|
||||
rows={2}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
||||
<select
|
||||
value={formData.input_type}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
{INPUT_TYPES.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Display order"
|
||||
value={formData.display_order ?? 0}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Placeholder"
|
||||
value={formData.placeholder || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
|
||||
<select
|
||||
value={formData.depends_on_question_id ?? ''}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
depends_on_question_id: nextValue ? Number(nextValue) : null,
|
||||
depends_on_value: null
|
||||
}));
|
||||
}}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">No dependency</option>
|
||||
{dependencyCandidates.map((question) => (
|
||||
<option key={question.id} value={question.id}>{question.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{!selectedDependencyQuestion && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Choose a dependency question first"
|
||||
value=""
|
||||
disabled
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'select' && (
|
||||
<select
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
{selectedDependencyQuestion.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion?.input_type === 'boolean' && (
|
||||
<select
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
>
|
||||
<option value="">Any answered value</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
|
||||
<input
|
||||
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
|
||||
placeholder="Show when parent answer equals..."
|
||||
value={formData.depends_on_value || ''}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.input_type === 'select' && (
|
||||
<textarea
|
||||
value={optionsText}
|
||||
onChange={(event) => setOptionsText(event.target.value)}
|
||||
rows={4}
|
||||
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
|
||||
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.is_required)}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.admin_only_edit)}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
Admin-only edits
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.is_active)}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
|
||||
style={{ marginRight: '6px' }}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
|
||||
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
|
||||
</button>
|
||||
{editingQuestionId && (
|
||||
<button className="btn btn-secondary" onClick={resetForm}>
|
||||
Cancel Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by label or key..."
|
||||
value={listSearch}
|
||||
onChange={(event) => setListSearch(event.target.value)}
|
||||
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
|
||||
/>
|
||||
{loading ? (
|
||||
<p>Loading questions...</p>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
|
||||
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredQuestions.map((question) => (
|
||||
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '8px' }}>{question.display_order}</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
{question.label}
|
||||
{question.admin_only_edit && (
|
||||
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
||||
Admin Managed
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px' }}>{question.input_type}</td>
|
||||
<td style={{ padding: '8px' }}>{question.key}</td>
|
||||
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
|
||||
<td style={{ padding: '8px' }}>
|
||||
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
|
||||
Edit
|
||||
</button>
|
||||
{question.is_active && (
|
||||
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}>
|
||||
Deactivate
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{filteredQuestions.length === 0 && (
|
||||
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProfileQuestionManager;
|
||||
@@ -159,10 +159,14 @@ const EmailTemplateManagement: React.FC = () => {
|
||||
overflow: 'auto',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.4',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: '#333'
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }}
|
||||
/>
|
||||
>
|
||||
{template.html_body.substring(0, 300)}
|
||||
{template.html_body.length > 300 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -398,4 +402,4 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailTemplateManagement;
|
||||
export default EmailTemplateManagement;
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ProfileMenuProps {
|
||||
onEditProfile?: () => void;
|
||||
}
|
||||
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
|
||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -135,42 +135,11 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onE
|
||||
)}
|
||||
|
||||
{/* Menu Items */}
|
||||
{userRole === 'super_admin' && (
|
||||
<>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
|
||||
onClick={() => {
|
||||
navigate('/membership-tiers');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Membership Tiers
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
|
||||
onClick={() => {
|
||||
navigate('/email-templates');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Email Templates
|
||||
</button>
|
||||
<button
|
||||
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
|
||||
onClick={() => {
|
||||
navigate('/bounce-management');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Bounce Management
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
style={{
|
||||
...menuItemStyle,
|
||||
borderRadius: '0',
|
||||
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
|
||||
borderRadius: user ? '0' : '4px 4px 0 0',
|
||||
borderTop: user ? '1px solid #eee' : 'none'
|
||||
}}
|
||||
onClick={handleChangePassword}
|
||||
>
|
||||
@@ -322,4 +291,4 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileMenu;
|
||||
export default ProfileMenu;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
|
||||
import {
|
||||
answerToComparable,
|
||||
canEditProfileQuestion,
|
||||
isProfileQuestionVisible,
|
||||
ProfileQuestionAnswerValue
|
||||
} from '../utils/profileQuestionLogic';
|
||||
|
||||
interface ProfileQuestionsFormProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
questions: ProfileQuestionForUser[];
|
||||
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
||||
saveLabel?: string;
|
||||
allowAdminManagedEdit?: boolean;
|
||||
}
|
||||
|
||||
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'Not set';
|
||||
}
|
||||
|
||||
if (question.input_type === 'boolean') {
|
||||
return value === true || value === 'true' ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
if (question.input_type === 'select') {
|
||||
const matchingOption = question.options.find((option) => option.value === String(value));
|
||||
return matchingOption?.label || String(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
|
||||
title,
|
||||
description,
|
||||
questions,
|
||||
onSave,
|
||||
saveLabel = 'Save Answers',
|
||||
allowAdminManagedEdit = false
|
||||
}) => {
|
||||
const initialAnswers = useMemo(() => {
|
||||
const values: Record<number, ProfileQuestionAnswerValue> = {};
|
||||
questions.forEach((question) => {
|
||||
values[question.id] = question.answer ?? null;
|
||||
});
|
||||
return values;
|
||||
}, [questions]);
|
||||
|
||||
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
setAnswers(initialAnswers);
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
}, [initialAnswers]);
|
||||
|
||||
const visibleQuestions = useMemo(() => {
|
||||
const byId = new Map<number, ProfileQuestionForUser>();
|
||||
questions.forEach((question) => byId.set(question.id, question));
|
||||
|
||||
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
|
||||
}, [questions, answers]);
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
return visibleQuestions
|
||||
.filter((question) => {
|
||||
if (!searchTerm) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
question.label.toLowerCase().includes(searchTerm) ||
|
||||
question.key.toLowerCase().includes(searchTerm) ||
|
||||
(question.help_text || '').toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
}, [visibleQuestions, search]);
|
||||
|
||||
const paginatedQuestions = useMemo(() => {
|
||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
return filteredQuestions.slice(start, start + pageSize);
|
||||
}, [filteredQuestions, page]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
|
||||
|
||||
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
|
||||
setAnswers((prev) => ({
|
||||
...prev,
|
||||
[questionId]: value
|
||||
}));
|
||||
setSuccessMessage(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
try {
|
||||
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
|
||||
const changedAnswers: ProfileAnswerInput[] = questions
|
||||
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
|
||||
.filter((question) => {
|
||||
const current = answerToComparable(answers[question.id] ?? null);
|
||||
const initial = answerToComparable(initialAnswers[question.id] ?? null);
|
||||
return current !== initial;
|
||||
})
|
||||
.map((question) => ({
|
||||
question_id: question.id,
|
||||
value: answers[question.id] ?? null
|
||||
}));
|
||||
|
||||
await onSave(changedAnswers);
|
||||
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (question: ProfileQuestionForUser) => {
|
||||
const value = answers[question.id] ?? null;
|
||||
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
|
||||
|
||||
if (disabled && !saving) {
|
||||
return (
|
||||
<div className="profile-question-readonly">
|
||||
{formatAnswerForDisplay(question, value)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'boolean') {
|
||||
return (
|
||||
<select
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
if (nextValue === '') {
|
||||
setAnswerValue(question.id, null);
|
||||
} else {
|
||||
setAnswerValue(question.id, nextValue === 'true');
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
>
|
||||
<option value="">Prefer not to say</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'select') {
|
||||
return (
|
||||
<select
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
>
|
||||
<option value="">Select an option</option>
|
||||
{question.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'date') {
|
||||
return (
|
||||
<input
|
||||
type="date"
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.input_type === 'number') {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
|
||||
disabled={disabled}
|
||||
placeholder={question.placeholder || ''}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={value === null ? '' : String(value)}
|
||||
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
|
||||
disabled={disabled}
|
||||
placeholder={question.placeholder || ''}
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginTop: '20px' }}>
|
||||
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
|
||||
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
|
||||
|
||||
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="alert alert-success">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<p style={{ color: '#666' }}>No questions available.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
{paginatedQuestions.map((question) => (
|
||||
<div key={question.id} className="profile-question-row">
|
||||
<div className="profile-question-meta">
|
||||
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
|
||||
{question.label}
|
||||
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
|
||||
{question.admin_only_edit && (
|
||||
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
|
||||
Admin Managed
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{question.help_text && (
|
||||
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-question-answer">{renderField(question)}</div>
|
||||
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
|
||||
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
|
||||
This field can only be changed by an admin.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredQuestions.length > pageSize && (
|
||||
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
|
||||
<span style={{ fontSize: '13px', color: '#525a66' }}>
|
||||
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
|
||||
Previous
|
||||
</button>
|
||||
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileQuestionsForm;
|
||||
@@ -163,7 +163,7 @@ interface TierManagementProps {
|
||||
onCancelEdit: () => void;
|
||||
}
|
||||
|
||||
const TierManagement: React.FC<TierManagementProps> = ({
|
||||
export const TierManagement: React.FC<TierManagementProps> = ({
|
||||
tiers,
|
||||
loading,
|
||||
showCreateForm,
|
||||
@@ -384,4 +384,4 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
|
||||
);
|
||||
};
|
||||
|
||||
export default SuperAdminMenu;
|
||||
export default SuperAdminMenu;
|
||||
|
||||
+723
-422
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const PrivacyPolicy: React.FC = () => {
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
|
||||
<div className="card">
|
||||
<h2 style={{ marginBottom: '12px' }}>Privacy Policy</h2>
|
||||
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
|
||||
Privacy policy content will be added here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicy;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const TermsOfService: React.FC = () => {
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
|
||||
<div className="card">
|
||||
<h2 style={{ marginBottom: '12px' }}>Terms of Service</h2>
|
||||
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
|
||||
Terms of service content will be added here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TermsOfService;
|
||||
@@ -26,11 +26,62 @@ export interface User {
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
role: string;
|
||||
volunteer_level: string | null;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export type ProfileQuestionInputType = 'text' | 'number' | 'boolean' | 'date' | 'select';
|
||||
|
||||
export interface ProfileQuestionOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ProfileQuestion {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
help_text: string | null;
|
||||
input_type: ProfileQuestionInputType;
|
||||
placeholder: string | null;
|
||||
options: ProfileQuestionOption[];
|
||||
is_required: boolean;
|
||||
is_active: boolean;
|
||||
admin_only_edit: boolean;
|
||||
display_order: number;
|
||||
depends_on_question_id: number | null;
|
||||
depends_on_value: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProfileQuestionForUser extends ProfileQuestion {
|
||||
answer: string | number | boolean | null;
|
||||
can_edit: boolean;
|
||||
}
|
||||
|
||||
export interface ProfileQuestionUpsertData {
|
||||
key: string;
|
||||
label: string;
|
||||
help_text?: string | null;
|
||||
input_type: ProfileQuestionInputType;
|
||||
placeholder?: string | null;
|
||||
options?: ProfileQuestionOption[] | null;
|
||||
is_required?: boolean;
|
||||
is_active?: boolean;
|
||||
admin_only_edit?: boolean;
|
||||
display_order?: number;
|
||||
depends_on_question_id?: number | null;
|
||||
depends_on_value?: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileAnswerInput {
|
||||
question_id: number;
|
||||
value: string | number | boolean | null;
|
||||
}
|
||||
|
||||
export interface MembershipTier {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -230,6 +281,51 @@ export const userService = {
|
||||
const response = await api.delete(`/users/${userId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getMyProfileQuestions(): Promise<ProfileQuestionForUser[]> {
|
||||
const response = await api.get('/users/me/profile-questions');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateMyProfileAnswers(answers: ProfileAnswerInput[]): Promise<{ message: string }> {
|
||||
const response = await api.put('/users/me/profile-answers', { answers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getAdminProfileQuestions(includeInactive: boolean = true): Promise<ProfileQuestion[]> {
|
||||
const response = await api.get(`/users/admin/profile-questions?include_inactive=${includeInactive}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createAdminProfileQuestion(data: ProfileQuestionUpsertData): Promise<ProfileQuestion> {
|
||||
const response = await api.post('/users/admin/profile-questions', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateAdminProfileQuestion(questionId: number, data: Partial<ProfileQuestionUpsertData>): Promise<ProfileQuestion> {
|
||||
const response = await api.put(`/users/admin/profile-questions/${questionId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deactivateAdminProfileQuestion(questionId: number): Promise<{ message: string }> {
|
||||
const response = await api.delete(`/users/admin/profile-questions/${questionId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getUserProfileAnswers(userId: number): Promise<ProfileQuestionForUser[]> {
|
||||
const response = await api.get(`/users/admin/users/${userId}/profile-answers`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateUserProfileAnswers(userId: number, answers: ProfileAnswerInput[]): Promise<{ message: string }> {
|
||||
const response = await api.put(`/users/admin/users/${userId}/profile-answers`, { answers });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async sendUserPasswordReset(userId: number): Promise<{ message: string }> {
|
||||
const response = await api.post(`/users/${userId}/send-password-reset`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export const membershipService = {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canEditProfileQuestion,
|
||||
DependentProfileQuestion,
|
||||
isProfileQuestionVisible,
|
||||
ProfileQuestionAnswerValue
|
||||
} from './profileQuestionLogic';
|
||||
|
||||
describe('profile question logic', () => {
|
||||
it('keeps admin-managed questions read-only outside admin editing mode', () => {
|
||||
const question = {
|
||||
id: 1,
|
||||
admin_only_edit: true,
|
||||
can_edit: true
|
||||
};
|
||||
|
||||
expect(canEditProfileQuestion(question, false)).toBe(false);
|
||||
expect(canEditProfileQuestion(question, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not allow editing when the API marks a question read-only', () => {
|
||||
expect(canEditProfileQuestion({ id: 1, admin_only_edit: false, can_edit: false }, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('shows dependent questions when boolean answers match', () => {
|
||||
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
|
||||
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'true' };
|
||||
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
|
||||
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: true };
|
||||
|
||||
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides dependent questions when select answers do not match', () => {
|
||||
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
|
||||
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'completed' };
|
||||
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
|
||||
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: 'pending' };
|
||||
|
||||
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export type ProfileQuestionAnswerValue = string | number | boolean | null;
|
||||
|
||||
export interface EditableProfileQuestion {
|
||||
id: number;
|
||||
admin_only_edit: boolean;
|
||||
can_edit: boolean;
|
||||
}
|
||||
|
||||
export interface DependentProfileQuestion {
|
||||
id: number;
|
||||
depends_on_question_id: number | null;
|
||||
depends_on_value: string | null;
|
||||
}
|
||||
|
||||
export const answerToComparable = (value: ProfileQuestionAnswerValue): string | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const canEditProfileQuestion = (
|
||||
question: EditableProfileQuestion,
|
||||
allowAdminManagedEdit = false
|
||||
): boolean => {
|
||||
if (!question.can_edit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (question.admin_only_edit && !allowAdminManagedEdit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isProfileQuestionVisible = <TQuestion extends DependentProfileQuestion>(
|
||||
question: TQuestion,
|
||||
questionsById: Map<number, TQuestion>,
|
||||
answers: Record<number, ProfileQuestionAnswerValue>
|
||||
): boolean => {
|
||||
if (!question.depends_on_question_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentQuestion = questionsById.get(question.depends_on_question_id);
|
||||
if (!parentQuestion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentAnswer = answerToComparable(answers[parentQuestion.id] ?? null);
|
||||
if (question.depends_on_value === null || question.depends_on_value === undefined) {
|
||||
return parentAnswer !== null && parentAnswer !== '';
|
||||
}
|
||||
|
||||
return parentAnswer === question.depends_on_value;
|
||||
};
|
||||
Reference in New Issue
Block a user