v0.12.47 Add Child alias update
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.46",
|
||||
"version": "0.12.47",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
195
frontend/src/components/AddChildAliasModal.jsx
Normal file
195
frontend/src/components/AddChildAliasModal.jsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function AddChildAliasModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [editingAlias, setEditingAlias] = useState(null); // null = new entry
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingAlias(null);
|
||||
setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const handleSelectAlias = (a) => {
|
||||
if (editingAlias?.id === a.id) { resetForm(); return; }
|
||||
setEditingAlias(a);
|
||||
setForm({
|
||||
firstName: a.first_name || '',
|
||||
lastName: a.last_name || '',
|
||||
dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '',
|
||||
phone: a.phone || '',
|
||||
email: a.email || '',
|
||||
});
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.firstName.trim() || !form.lastName.trim())
|
||||
return toast('First and last name required', 'error');
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingAlias) {
|
||||
await api.updateAlias(editingAlias.id, {
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
dateOfBirth: form.dob || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
});
|
||||
if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile);
|
||||
toast('Child alias updated', 'success');
|
||||
} else {
|
||||
const { alias } = await api.createAlias({
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
dateOfBirth: form.dob || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
});
|
||||
if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile);
|
||||
toast('Child alias added', 'success');
|
||||
}
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (e, aliasId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteAlias(aliasId);
|
||||
setAliases(prev => prev.filter(a => a.id !== aliasId));
|
||||
if (editingAlias?.id === aliasId) resetForm();
|
||||
toast('Child alias removed', 'success');
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
const lbl = (text, required) => (
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Add Child Alias</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Existing aliases list */}
|
||||
{aliases.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Your Children — click to edit
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{aliases.map((a, i) => (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => handleSelectAlias(a)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 12px', cursor: 'pointer',
|
||||
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
|
||||
{a.first_name} {a.last_name}
|
||||
</span>
|
||||
{a.date_of_birth && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{a.date_of_birth.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => handleDelete(e, a.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||
aria-label="Remove"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form section label */}
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
|
||||
{editingAlias
|
||||
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
|
||||
: 'New Child Alias'}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
{lbl('First Name', true)}
|
||||
<input className="input" value={form.firstName} onChange={set('firstName')}
|
||||
autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Last Name', true)}
|
||||
<input className="input" value={form.lastName} onChange={set('lastName')}
|
||||
autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Date of Birth')}
|
||||
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Phone')}
|
||||
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Email (optional)')}
|
||||
<input className="input" type="email" value={form.email} onChange={set('email')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Avatar (optional)')}
|
||||
<input type="file" accept="image/*"
|
||||
onChange={e => setAvatarFile(e.target.files?.[0] || null)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
{editingAlias && (
|
||||
<button className="btn btn-secondary" onClick={resetForm}>Cancel Edit</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,14 @@ const NAV_ICON = {
|
||||
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
||||
};
|
||||
|
||||
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
|
||||
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, onAddChild, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
|
||||
const { user } = useAuth();
|
||||
const drawerRef = useRef(null);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const userGroupIds = features.userGroupMemberships || [];
|
||||
const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
|
||||
const hasUserGroups = userGroupIds.length > 0;
|
||||
const showAddChild = features.loginType === 'guardian_only' && features.inGuardiansGroup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -80,11 +81,16 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
||||
)}
|
||||
|
||||
{/* Tools section */}
|
||||
{canAccessTools && (
|
||||
{(canAccessTools || showAddChild) && (
|
||||
<>
|
||||
<div className="nav-drawer-section-label admin">Tools</div>
|
||||
{item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
|
||||
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
||||
{canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
|
||||
{canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
||||
{showAddChild && item(
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>,
|
||||
'Add Child Aliase',
|
||||
onAddChild
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function ProfileModal({ onClose }) {
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance' | 'add-child'
|
||||
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
|
||||
const [pushTesting, setPushTesting] = useState(false);
|
||||
const [pushResult, setPushResult] = useState(null);
|
||||
const [notifPermission, setNotifPermission] = useState(
|
||||
@@ -36,20 +36,8 @@ export default function ProfileModal({ onClose }) {
|
||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
||||
|
||||
// Minor age protection state
|
||||
const [loginType, setLoginType] = useState('all_ages');
|
||||
const [guardiansGroupId,setGuardiansGroupId] = useState(null);
|
||||
const [showAddChild, setShowAddChild] = useState(false);
|
||||
const [aliases, setAliases] = useState([]);
|
||||
// Add Child form state
|
||||
const [childList, setChildList] = useState([]); // pending aliases to add
|
||||
const [childForm, setChildForm] = useState({ firstName:'', lastName:'', email:'', dob:'', phone:'' });
|
||||
const [childFormAvatar, setChildFormAvatar] = useState(null);
|
||||
const [childSaving, setChildSaving] = useState(false);
|
||||
// Mixed Age: minor user search
|
||||
const [minorSearch, setMinorSearch] = useState('');
|
||||
const [minorResults, setMinorResults] = useState([]);
|
||||
const [selectedMinor, setSelectedMinor] = useState(null);
|
||||
// Minor age protection — DOB/phone display only
|
||||
const [loginType, setLoginType] = useState('all_ages');
|
||||
|
||||
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
|
||||
const [fontScale, setFontScale] = useState(
|
||||
@@ -62,32 +50,13 @@ export default function ProfileModal({ onClose }) {
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Load login type + check if user is in guardians group
|
||||
// Load login type for DOB/phone field visibility
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSettings(), api.getMyUserGroups()]).then(([{ settings: s }, { userGroups }]) => {
|
||||
const lt = s.feature_login_type || 'all_ages';
|
||||
const gid = parseInt(s.feature_guardians_group_id);
|
||||
setLoginType(lt);
|
||||
setGuardiansGroupId(gid || null);
|
||||
if (lt === 'guardian_only') {
|
||||
// In guardian_only mode all authenticated users are guardians — always show Add Child
|
||||
setShowAddChild(true);
|
||||
} else if (lt === 'mixed_age' && gid) {
|
||||
const inGroup = (userGroups || []).some(g => g.id === gid);
|
||||
setShowAddChild(inGroup);
|
||||
}
|
||||
api.getSettings().then(({ settings: s }) => {
|
||||
setLoginType(s.feature_login_type || 'all_ages');
|
||||
}).catch(() => {});
|
||||
api.getAliases().then(({ aliases }) => setAliases(aliases || [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (loginType === 'mixed_age' && minorSearch.length >= 1) {
|
||||
api.searchMinorUsers(minorSearch).then(({ users }) => setMinorResults(users || [])).catch(() => {});
|
||||
} else {
|
||||
setMinorResults([]);
|
||||
}
|
||||
}, [minorSearch, loginType]);
|
||||
|
||||
const applyFontScale = (val) => {
|
||||
setFontScale(val);
|
||||
document.documentElement.style.setProperty('--font-scale', val);
|
||||
@@ -109,46 +78,6 @@ export default function ProfileModal({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChildren = async () => {
|
||||
if (childList.length === 0) return;
|
||||
setChildSaving(true);
|
||||
try {
|
||||
if (loginType === 'mixed_age') {
|
||||
// Link each selected minor
|
||||
for (const minor of childList) {
|
||||
await api.linkMinor(minor.id);
|
||||
}
|
||||
toast('Guardian link request sent — awaiting manager approval', 'success');
|
||||
} else {
|
||||
// Create aliases
|
||||
for (const child of childList) {
|
||||
const { alias } = await api.createAlias({ firstName: child.firstName, lastName: child.lastName, email: child.email, dateOfBirth: child.dob, phone: child.phone });
|
||||
if (child.avatarFile) {
|
||||
await api.uploadAliasAvatar(alias.id, child.avatarFile);
|
||||
}
|
||||
}
|
||||
toast('Children saved', 'success');
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
}
|
||||
setChildList([]);
|
||||
setChildForm({ firstName:'', lastName:'', email:'', dob:'', phone:'' });
|
||||
setSelectedMinor(null);
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setChildSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAlias = async (aliasId) => {
|
||||
try {
|
||||
await api.deleteAlias(aliasId);
|
||||
setAliases(prev => prev.filter(a => a.id !== aliasId));
|
||||
toast('Child removed', 'success');
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -219,7 +148,6 @@ export default function ProfileModal({ onClose }) {
|
||||
<option value="password">Change Password</option>
|
||||
<option value="notifications">Notifications</option>
|
||||
<option value="appearance">Appearance</option>
|
||||
{showAddChild && <option value="add-child">Add Child</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -447,122 +375,6 @@ export default function ProfileModal({ onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'add-child' && (
|
||||
<div className="flex-col gap-3">
|
||||
{/* Existing saved aliases */}
|
||||
{aliases.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>Saved Children</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 12 }}>
|
||||
{aliases.map((a, i) => (
|
||||
<div key={a.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{a.first_name} {a.last_name}</span>
|
||||
{a.date_of_birth && <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{a.date_of_birth.slice(0,10)}</span>}
|
||||
<button onClick={() => handleRemoveAlias(a.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16, lineHeight: 1 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginType === 'guardian_only' ? (
|
||||
<>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Add a Child</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 10 }}>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>First Name *</label>
|
||||
<input className="input" value={childForm.firstName} onChange={e => setChildForm(p=>({...p,firstName:e.target.value}))} autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Last Name *</label>
|
||||
<input className="input" value={childForm.lastName} onChange={e => setChildForm(p=>({...p,lastName:e.target.value}))} autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
|
||||
<input className="input" placeholder="YYYY-MM-DD" value={childForm.dob} onChange={e => setChildForm(p=>({...p,dob:e.target.value}))} autoComplete="off" />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Phone</label>
|
||||
<input className="input" type="tel" value={childForm.phone} onChange={e => setChildForm(p=>({...p,phone:e.target.value}))} autoComplete="off" />
|
||||
</div>
|
||||
<div className="flex-col gap-1" style={{ gridColumn: isMobile ? '1' : '1 / -1' }}>
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Email (optional)</label>
|
||||
<input className="input" type="email" value={childForm.email} onChange={e => setChildForm(p=>({...p,email:e.target.value}))} autoComplete="off" />
|
||||
</div>
|
||||
<div className="flex-col gap-1" style={{ gridColumn: isMobile ? '1' : '1 / -1' }}>
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Avatar (optional)</label>
|
||||
<input type="file" accept="image/*" onChange={e => setChildForm(p=>({...p,avatarFile:e.target.files?.[0]||null}))} />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => {
|
||||
if (!childForm.firstName.trim() || !childForm.lastName.trim()) return toast('First and last name required','error');
|
||||
setChildList(prev => [...prev, { ...childForm }]);
|
||||
setChildForm({ firstName:'', lastName:'', email:'', dob:'', phone:'', avatarFile:null });
|
||||
}}>
|
||||
+ Add
|
||||
</button>
|
||||
{childList.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{childList.map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < childList.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{c.firstName} {c.lastName}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', fontStyle: 'italic' }}>Pending save</span>
|
||||
<button onClick={() => setChildList(prev => prev.filter((_,j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : loginType === 'mixed_age' ? (
|
||||
<>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Link a Child Account</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', margin: 0 }}>Search for a minor user account to link to your guardian profile. The link requires manager approval.</p>
|
||||
<input className="input" placeholder="Search minor users..." value={minorSearch} onChange={e => setMinorSearch(e.target.value)} autoComplete="off" />
|
||||
{minorResults.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', maxHeight: 160, overflowY: 'auto' }}>
|
||||
{minorResults.map(u => (
|
||||
<div key={u.id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: '1px solid var(--border)', cursor: 'pointer' }}
|
||||
onClick={() => { setSelectedMinor(u); setMinorSearch(''); setMinorResults([]); }}>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
|
||||
{u.date_of_birth && <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{u.date_of_birth.slice(0,10)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedMinor && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600 }}>{selectedMinor.first_name} {selectedMinor.last_name}</div>
|
||||
{selectedMinor.date_of_birth && <div style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{selectedMinor.date_of_birth.slice(0,10)}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button className="btn btn-secondary btn-sm"
|
||||
onClick={() => { setChildList(prev => [...prev, selectedMinor]); setSelectedMinor(null); }}>
|
||||
+ Add to list
|
||||
</button>
|
||||
<button className="btn btn-sm" style={{ background: 'none', border: '1px solid var(--border)' }} onClick={() => setSelectedMinor(null)}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{childList.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{childList.map((c, i) => (
|
||||
<div key={c.id || i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px', borderBottom: i < childList.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{c.first_name || c.firstName} {c.last_name || c.lastName}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--warning)', fontStyle: 'italic' }}>Pending approval</span>
|
||||
<button onClick={() => setChildList(prev => prev.filter((_,j) => j !== i))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 16 }}>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<button className="btn btn-primary" onClick={handleSaveChildren} disabled={childSaving || childList.length === 0}>
|
||||
{childSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'appearance' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex-col gap-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import GlobalBar from '../components/GlobalBar.jsx';
|
||||
import AboutModal from '../components/AboutModal.jsx';
|
||||
import HelpModal from '../components/HelpModal.jsx';
|
||||
import NavDrawer from '../components/NavDrawer.jsx';
|
||||
import AddChildAliasModal from '../components/AddChildAliasModal.jsx';
|
||||
import SchedulePage from '../components/SchedulePage.jsx';
|
||||
import MobileGroupManager from '../components/MobileGroupManager.jsx';
|
||||
import './Chat.css';
|
||||
@@ -48,6 +49,9 @@ export default function Chat() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true });
|
||||
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
|
||||
const [addChildPending, setAddChildPending] = useState(false); // defer add-child popup until help closes
|
||||
const addChildCheckedRef = useRef(false); // only auto-check aliases once per session
|
||||
const modalRef = useRef(null); // always reflects current modal value in async callbacks
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [showSidebar, setShowSidebar] = useState(true);
|
||||
|
||||
@@ -80,28 +84,31 @@ export default function Chat() {
|
||||
// Keep groupsRef in sync so visibility/reconnect handlers can read current groups
|
||||
useEffect(() => { groupsRef.current = groups; }, [groups]);
|
||||
|
||||
// Load feature flags + current user's group memberships on mount
|
||||
// Load feature flags + current user's group memberships on mount (combined for consistent inGuardiansGroup)
|
||||
const loadFeatures = useCallback(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setFeatures(prev => ({
|
||||
...prev,
|
||||
branding: settings.feature_branding === 'true',
|
||||
groupManager: settings.feature_group_manager === 'true',
|
||||
scheduleManager: settings.feature_schedule_manager === 'true',
|
||||
appType: settings.app_type || 'RosterChirp-Chat',
|
||||
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
|
||||
isHostDomain: settings.is_host_domain === 'true',
|
||||
msgPublic: settings.feature_msg_public !== 'false',
|
||||
msgGroup: settings.feature_msg_group !== 'false',
|
||||
msgPrivateGroup: settings.feature_msg_private_group !== 'false',
|
||||
msgU2U: settings.feature_msg_u2u !== 'false',
|
||||
loginType: settings.feature_login_type || 'all_ages',
|
||||
playersGroupId: settings.feature_players_group_id ? parseInt(settings.feature_players_group_id) : null,
|
||||
}));
|
||||
}).catch(() => {});
|
||||
api.getMyUserGroups().then(({ userGroups }) => {
|
||||
setFeatures(prev => ({ ...prev, userGroupMemberships: (userGroups || []).map(g => g.id) }));
|
||||
}).catch(() => {});
|
||||
Promise.all([api.getSettings(), api.getMyUserGroups()])
|
||||
.then(([{ settings: s }, { userGroups }]) => {
|
||||
const memberships = (userGroups || []).map(g => g.id);
|
||||
const guardiansGroupId = s.feature_guardians_group_id ? parseInt(s.feature_guardians_group_id) : null;
|
||||
setFeatures(prev => ({
|
||||
...prev,
|
||||
branding: s.feature_branding === 'true',
|
||||
groupManager: s.feature_group_manager === 'true',
|
||||
scheduleManager: s.feature_schedule_manager === 'true',
|
||||
appType: s.app_type || 'RosterChirp-Chat',
|
||||
teamToolManagers: JSON.parse(s.team_tool_managers || s.team_group_managers || '[]'),
|
||||
isHostDomain: s.is_host_domain === 'true',
|
||||
msgPublic: s.feature_msg_public !== 'false',
|
||||
msgGroup: s.feature_msg_group !== 'false',
|
||||
msgPrivateGroup: s.feature_msg_private_group !== 'false',
|
||||
msgU2U: s.feature_msg_u2u !== 'false',
|
||||
loginType: s.feature_login_type || 'all_ages',
|
||||
playersGroupId: s.feature_players_group_id ? parseInt(s.feature_players_group_id) : null,
|
||||
guardiansGroupId,
|
||||
userGroupMemberships: memberships,
|
||||
inGuardiansGroup: guardiansGroupId ? memberships.includes(guardiansGroupId) : false,
|
||||
}));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -110,6 +117,35 @@ export default function Chat() {
|
||||
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
|
||||
}, [loadFeatures]);
|
||||
|
||||
// Keep modalRef in sync so async callbacks can read current modal without stale closure
|
||||
useEffect(() => { modalRef.current = modal; }, [modal]);
|
||||
|
||||
// Auto-popup Add Child Alias modal when guardian_only user has no aliases yet
|
||||
useEffect(() => {
|
||||
if (addChildCheckedRef.current) return;
|
||||
if (features.loginType !== 'guardian_only' || !features.inGuardiansGroup) return;
|
||||
addChildCheckedRef.current = true;
|
||||
api.getAliases().then(({ aliases }) => {
|
||||
if (!(aliases || []).length) {
|
||||
if (modalRef.current === 'help') {
|
||||
setAddChildPending(true); // defer until help closes
|
||||
} else if (!modalRef.current) {
|
||||
setModal('addchild');
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [features.loginType, features.inGuardiansGroup]);
|
||||
|
||||
// Close help — open deferred add-child popup if pending
|
||||
const handleHelpClose = useCallback(() => {
|
||||
if (addChildPending) {
|
||||
setAddChildPending(false);
|
||||
setModal('addchild');
|
||||
} else {
|
||||
setModal(null);
|
||||
}
|
||||
}, [addChildPending]);
|
||||
|
||||
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
@@ -601,12 +637,14 @@ export default function Chat() {
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||
features={features} currentPage={page} isMobile={isMobile}
|
||||
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
|
||||
</div>
|
||||
@@ -630,12 +668,14 @@ export default function Chat() {
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||
features={features} currentPage={page} isMobile={isMobile}
|
||||
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
|
||||
</div>
|
||||
@@ -689,12 +729,14 @@ export default function Chat() {
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||
features={features} currentPage={page} isMobile={isMobile}
|
||||
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
|
||||
|
||||
@@ -721,6 +763,7 @@ export default function Chat() {
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||
features={features}
|
||||
currentPage={page}
|
||||
isMobile={isMobile}
|
||||
@@ -728,7 +771,8 @@ export default function Chat() {
|
||||
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
|
||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
|
||||
</div>
|
||||
@@ -760,6 +804,7 @@ export default function Chat() {
|
||||
onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
|
||||
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
|
||||
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
|
||||
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
|
||||
features={features}
|
||||
currentPage={page}
|
||||
isMobile={isMobile}
|
||||
@@ -774,7 +819,8 @@ export default function Chat() {
|
||||
</div>
|
||||
)}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||
|
||||
</div>
|
||||
);
|
||||
@@ -842,7 +888,8 @@ export default function Chat() {
|
||||
|
||||
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />}
|
||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user