v0.12.51 updated "Mixed Age" login type.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-backend",
|
"name": "rosterchirp-backend",
|
||||||
"version": "0.12.50",
|
"version": "0.12.51",
|
||||||
"description": "RosterChirp backend server",
|
"description": "RosterChirp backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -190,10 +190,10 @@ router.post('/', authMiddleware, async (req, res) => {
|
|||||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
|
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
|
||||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
|
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
|
||||||
|
|
||||||
// Mixed Age: if the other user is a minor, auto-add their guardian
|
// Mixed Age: if initiator is not a minor and the other user is a minor, auto-add their guardian
|
||||||
let guardianAdded = false, guardianName = null;
|
let guardianAdded = false, guardianName = null;
|
||||||
const loginType = await getLoginType(req.schema);
|
const loginType = await getLoginType(req.schema);
|
||||||
if (loginType === 'mixed_age') {
|
if (loginType === 'mixed_age' && !req.user.is_minor) {
|
||||||
const otherUserFull = await queryOne(req.schema,
|
const otherUserFull = await queryOne(req.schema,
|
||||||
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]);
|
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]);
|
||||||
if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) {
|
if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) {
|
||||||
@@ -234,6 +234,7 @@ router.post('/', authMiddleware, async (req, res) => {
|
|||||||
[name, type||'private', req.user.id, !!isReadonly]
|
[name, type||'private', req.user.id, !!isReadonly]
|
||||||
);
|
);
|
||||||
const groupId = r.rows[0].id;
|
const groupId = r.rows[0].id;
|
||||||
|
const groupGuardianNames = [];
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||||
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
||||||
@@ -251,9 +252,30 @@ router.post('/', authMiddleware, async (req, res) => {
|
|||||||
if (parseInt(totalCount.cnt) >= 3) {
|
if (parseInt(totalCount.cnt) >= 3) {
|
||||||
await computeAndStoreComposite(req.schema, groupId);
|
await computeAndStoreComposite(req.schema, groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mixed Age: auto-add guardians for any minor members (non-minor initiators only)
|
||||||
|
const groupLoginType = await getLoginType(req.schema);
|
||||||
|
if (groupLoginType === 'mixed_age' && !req.user.is_minor && memberIds?.length > 0) {
|
||||||
|
for (const uid of memberIds) {
|
||||||
|
const memberInfo = await queryOne(req.schema,
|
||||||
|
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [uid]);
|
||||||
|
if (memberInfo?.is_minor && memberInfo.guardian_user_id && memberInfo.guardian_user_id !== req.user.id) {
|
||||||
|
await exec(req.schema,
|
||||||
|
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||||
|
[groupId, memberInfo.guardian_user_id]);
|
||||||
|
const g = await queryOne(req.schema,
|
||||||
|
'SELECT name,display_name FROM users WHERE id=$1', [memberInfo.guardian_user_id]);
|
||||||
|
const gName = g?.display_name || g?.name;
|
||||||
|
if (gName && !groupGuardianNames.includes(gName)) groupGuardianNames.push(gName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await emitGroupNew(req.schema, io, groupId);
|
await emitGroupNew(req.schema, io, groupId);
|
||||||
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
res.json({
|
||||||
|
group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]),
|
||||||
|
...(groupGuardianNames.length ? { guardianAdded: true, guardianName: groupGuardianNames.join(', ') } : {}),
|
||||||
|
});
|
||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,18 +80,18 @@ router.get('/search', authMiddleware, async (req, res) => {
|
|||||||
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
||||||
if (group && (group.type === 'private' || group.is_direct)) {
|
if (group && (group.type === 'private' || group.is_direct)) {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[parseInt(groupId), req.user.id, `%${q}%`]
|
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[req.user.id, `%${q}%`]
|
[req.user.id, `%${q}%`]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
users = await query(req.schema,
|
users = await query(req.schema,
|
||||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||||
[`%${q}%`]
|
[`%${q}%`]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -695,6 +695,64 @@ router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async
|
|||||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List minor players available for this guardian to claim (Mixed Age — Family Manager)
|
||||||
|
// Returns minors in the players group who either have no guardian yet or are already linked to me.
|
||||||
|
router.get('/minor-players', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||||
|
const playersGroupId = parseInt(playersRow?.value);
|
||||||
|
if (!playersGroupId) return res.json({ users: [] });
|
||||||
|
const users = await query(req.schema,
|
||||||
|
`SELECT u.id,u.name,u.first_name,u.last_name,u.date_of_birth,u.avatar,u.status,u.guardian_user_id
|
||||||
|
FROM users u
|
||||||
|
JOIN user_group_members ugm ON ugm.user_id=u.id AND ugm.user_group_id=$1
|
||||||
|
WHERE u.is_minor=TRUE AND u.status!='deleted'
|
||||||
|
AND (u.guardian_user_id IS NULL OR u.guardian_user_id=$2)
|
||||||
|
ORDER BY u.first_name,u.last_name`,
|
||||||
|
[playersGroupId, req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ users });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Claim minor as guardian (Mixed Age — Family Manager direct link, no approval needed)
|
||||||
|
router.post('/me/guardian-children/:minorId', authMiddleware, async (req, res) => {
|
||||||
|
const minorId = parseInt(req.params.minorId);
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, "SELECT * FROM users WHERE id=$1 AND status!='deleted'", [minorId]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (!minor.is_minor) return res.status(400).json({ error: 'User is not a minor' });
|
||||||
|
if (minor.guardian_user_id && minor.guardian_user_id !== req.user.id)
|
||||||
|
return res.status(409).json({ error: 'This minor already has a guardian' });
|
||||||
|
await exec(req.schema,
|
||||||
|
"UPDATE users SET guardian_user_id=$1,guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$2",
|
||||||
|
[req.user.id, minorId]
|
||||||
|
);
|
||||||
|
await addUserToPublicGroups(req.schema, minorId);
|
||||||
|
const user = await queryOne(req.schema,
|
||||||
|
'SELECT id,name,first_name,last_name,date_of_birth,avatar,status,guardian_user_id FROM users WHERE id=$1',
|
||||||
|
[minorId]
|
||||||
|
);
|
||||||
|
res.json({ user });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove minor from guardian's list (Mixed Age — re-suspends the minor)
|
||||||
|
router.delete('/me/guardian-children/:minorId', authMiddleware, async (req, res) => {
|
||||||
|
const minorId = parseInt(req.params.minorId);
|
||||||
|
try {
|
||||||
|
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
|
||||||
|
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (minor.guardian_user_id !== req.user.id)
|
||||||
|
return res.status(403).json({ error: 'You are not the guardian of this user' });
|
||||||
|
await exec(req.schema,
|
||||||
|
"UPDATE users SET guardian_user_id=NULL,status='suspended',updated_at=NOW() WHERE id=$1",
|
||||||
|
[minorId]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
// Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval)
|
// Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval)
|
||||||
router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => {
|
router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => {
|
||||||
const minorId = parseInt(req.params.minorId);
|
const minorId = parseInt(req.params.minorId);
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.12.50}"
|
VERSION="${1:-0.12.51}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="rosterchirp"
|
IMAGE_NAME="rosterchirp"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rosterchirp-frontend",
|
"name": "rosterchirp-frontend",
|
||||||
"version": "0.12.50",
|
"version": "0.12.51",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,16 +3,25 @@ import { useToast } from '../contexts/ToastContext.jsx';
|
|||||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||||
import { api } from '../utils/api.js';
|
import { api } from '../utils/api.js';
|
||||||
|
|
||||||
export default function AddChildAliasModal({ onClose }) {
|
export default function AddChildAliasModal({ features = {}, onClose }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
|
const loginType = features.loginType || 'guardian_only';
|
||||||
|
const isMixedAge = loginType === 'mixed_age';
|
||||||
|
|
||||||
|
// ── Guardian-only state (alias form) ──────────────────────────────────────
|
||||||
const [aliases, setAliases] = useState([]);
|
const [aliases, setAliases] = useState([]);
|
||||||
const [editingAlias, setEditingAlias] = useState(null); // null = new entry
|
const [editingAlias, setEditingAlias] = useState(null);
|
||||||
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||||
const [avatarFile, setAvatarFile] = useState(null);
|
const [avatarFile, setAvatarFile] = useState(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
// Partner state
|
// ── Mixed-age state (real minor users) ────────────────────────────────────
|
||||||
|
const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine
|
||||||
|
const [selectedMinorId, setSelectedMinorId] = useState('');
|
||||||
|
const [addingMinor, setAddingMinor] = useState(false);
|
||||||
|
|
||||||
|
// ── Partner state (shared) ────────────────────────────────────────────────
|
||||||
const [partner, setPartner] = useState(null);
|
const [partner, setPartner] = useState(null);
|
||||||
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
||||||
const [respondSeparately, setRespondSeparately] = useState(false);
|
const [respondSeparately, setRespondSeparately] = useState(false);
|
||||||
@@ -20,20 +29,27 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
const [savingPartner, setSavingPartner] = useState(false);
|
const [savingPartner, setSavingPartner] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
const loads = [api.getPartner(), api.searchUsers('')];
|
||||||
api.getAliases(),
|
if (isMixedAge) {
|
||||||
api.getPartner(),
|
loads.push(api.getMinorPlayers());
|
||||||
api.searchUsers(''),
|
} else {
|
||||||
]).then(([aliasRes, partnerRes, usersRes]) => {
|
loads.push(api.getAliases());
|
||||||
setAliases(aliasRes.aliases || []);
|
}
|
||||||
|
Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => {
|
||||||
const p = partnerRes.partner || null;
|
const p = partnerRes.partner || null;
|
||||||
setPartner(p);
|
setPartner(p);
|
||||||
setSelectedPartnerId(p?.id?.toString() || '');
|
setSelectedPartnerId(p?.id?.toString() || '');
|
||||||
setRespondSeparately(p?.respond_separately || false);
|
setRespondSeparately(p?.respond_separately || false);
|
||||||
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id));
|
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id));
|
||||||
|
if (isMixedAge) {
|
||||||
|
setMinorPlayers(thirdRes.users || []);
|
||||||
|
} else {
|
||||||
|
setAliases(thirdRes.aliases || []);
|
||||||
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, [isMixedAge]);
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -42,6 +58,47 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
setAvatarFile(null);
|
setAvatarFile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Partner handlers ──────────────────────────────────────────────────────
|
||||||
|
const handleSavePartner = async () => {
|
||||||
|
setSavingPartner(true);
|
||||||
|
try {
|
||||||
|
if (!selectedPartnerId) {
|
||||||
|
await api.removePartner();
|
||||||
|
setPartner(null);
|
||||||
|
setRespondSeparately(false);
|
||||||
|
if (!isMixedAge) {
|
||||||
|
const { aliases: fresh } = await api.getAliases();
|
||||||
|
setAliases(fresh || []);
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
const { users: fresh } = await api.getMinorPlayers();
|
||||||
|
setMinorPlayers(fresh || []);
|
||||||
|
}
|
||||||
|
toast('Spouse/Partner/Co-Parent removed', 'success');
|
||||||
|
} else {
|
||||||
|
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
|
||||||
|
setPartner(p);
|
||||||
|
setRespondSeparately(p?.respond_separately || false);
|
||||||
|
if (!isMixedAge) {
|
||||||
|
const { aliases: fresh } = await api.getAliases();
|
||||||
|
setAliases(fresh || []);
|
||||||
|
}
|
||||||
|
toast('Spouse/Partner/Co-Parent saved', 'success');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingPartner(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Guardian-only alias handlers ──────────────────────────────────────────
|
||||||
const handleSelectAlias = (a) => {
|
const handleSelectAlias = (a) => {
|
||||||
if (editingAlias?.id === a.id) { resetForm(); return; }
|
if (editingAlias?.id === a.id) { resetForm(); return; }
|
||||||
setEditingAlias(a);
|
setEditingAlias(a);
|
||||||
@@ -55,33 +112,7 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
setAvatarFile(null);
|
setAvatarFile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePartner = async () => {
|
const handleSaveAlias = async () => {
|
||||||
setSavingPartner(true);
|
|
||||||
try {
|
|
||||||
if (!selectedPartnerId) {
|
|
||||||
await api.removePartner();
|
|
||||||
setPartner(null);
|
|
||||||
setRespondSeparately(false);
|
|
||||||
const { aliases: fresh } = await api.getAliases();
|
|
||||||
setAliases(fresh || []);
|
|
||||||
resetForm();
|
|
||||||
toast('Spouse/Partner/Co-Parent removed', 'success');
|
|
||||||
} else {
|
|
||||||
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
|
|
||||||
setPartner(p);
|
|
||||||
setRespondSeparately(p?.respond_separately || false);
|
|
||||||
const { aliases: fresh } = await api.getAliases();
|
|
||||||
setAliases(fresh || []);
|
|
||||||
toast('Spouse/Partner/Co-Parent saved', 'success');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast(e.message, 'error');
|
|
||||||
} finally {
|
|
||||||
setSavingPartner(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!form.firstName.trim() || !form.lastName.trim())
|
if (!form.firstName.trim() || !form.lastName.trim())
|
||||||
return toast('First and last name required', 'error');
|
return toast('First and last name required', 'error');
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -117,7 +148,7 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (e, aliasId) => {
|
const handleDeleteAlias = async (e, aliasId) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
await api.deleteAlias(aliasId);
|
await api.deleteAlias(aliasId);
|
||||||
@@ -127,11 +158,35 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const lbl = (text, required) => (
|
// ── Mixed-age minor handlers ──────────────────────────────────────────────
|
||||||
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id);
|
||||||
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
|
const availableMinors = minorPlayers.filter(u => !u.guardian_user_id);
|
||||||
</label>
|
|
||||||
);
|
const handleAddMinor = async () => {
|
||||||
|
if (!selectedMinorId) return;
|
||||||
|
setAddingMinor(true);
|
||||||
|
try {
|
||||||
|
await api.addGuardianChild(parseInt(selectedMinorId));
|
||||||
|
const { users: fresh } = await api.getMinorPlayers();
|
||||||
|
setMinorPlayers(fresh || []);
|
||||||
|
setSelectedMinorId('');
|
||||||
|
toast('Child added and account activated', 'success');
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setAddingMinor(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMinor = async (e, minorId) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await api.removeGuardianChild(minorId);
|
||||||
|
const { users: fresh } = await api.getMinorPlayers();
|
||||||
|
setMinorPlayers(fresh || []);
|
||||||
|
toast('Child removed', 'success');
|
||||||
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||||
@@ -187,93 +242,169 @@ export default function AddChildAliasModal({ onClose }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Existing aliases list */}
|
{/* ── Mixed Age: link real minor users ── */}
|
||||||
{aliases.length > 0 && (
|
{isMixedAge && (
|
||||||
<div style={{ marginBottom: 16 }}>
|
<>
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
{/* Current children list */}
|
||||||
Your Children — click to edit
|
{myMinors.length > 0 && (
|
||||||
</div>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
{aliases.map((a, i) => (
|
Your Children
|
||||||
<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 style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||||
|
{myMinors.map((u, i) => (
|
||||||
|
<div
|
||||||
|
key={u.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '9px 12px',
|
||||||
|
borderBottom: i < myMinors.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={e => handleRemoveMinor(e, u.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add minor from players group */}
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
|
Add Child
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={selectedMinorId}
|
||||||
|
onChange={e => setSelectedMinorId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Select a player —</option>
|
||||||
|
{availableMinors.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.first_name} {u.last_name}{u.date_of_birth ? ` (${u.date_of_birth.slice(0, 10)})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleAddMinor}
|
||||||
|
disabled={addingMinor || !selectedMinorId}
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{addingMinor ? 'Adding…' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{availableMinors.length === 0 && myMinors.length === 0 && (
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||||
|
No minor players available to link.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form section label */}
|
{/* ── Guardian Only: alias form ── */}
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
|
{!isMixedAge && (
|
||||||
{editingAlias
|
<>
|
||||||
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
|
{/* Existing aliases list */}
|
||||||
: 'Add Child'}
|
{aliases.length > 0 && (
|
||||||
</div>
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
{/* Form */}
|
Your Children — click to edit
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||||
<div>
|
{aliases.map((a, i) => (
|
||||||
{lbl('First Name', true)}
|
<div
|
||||||
<input className="input" value={form.firstName} onChange={set('firstName')}
|
key={a.id}
|
||||||
autoComplete="off" autoCapitalize="words" />
|
onClick={() => handleSelectAlias(a)}
|
||||||
</div>
|
style={{
|
||||||
<div>
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
{lbl('Last Name', true)}
|
padding: '9px 12px', cursor: 'pointer',
|
||||||
<input className="input" value={form.lastName} onChange={set('lastName')}
|
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
|
||||||
autoComplete="off" autoCapitalize="words" />
|
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
|
||||||
</div>
|
}}
|
||||||
<div>
|
>
|
||||||
{lbl('Date of Birth')}
|
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
|
||||||
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
|
{a.first_name} {a.last_name}
|
||||||
autoComplete="off" />
|
</span>
|
||||||
</div>
|
{a.date_of_birth && (
|
||||||
<div>
|
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||||
{lbl('Phone')}
|
{a.date_of_birth.slice(0, 10)}
|
||||||
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
|
</span>
|
||||||
autoComplete="off" />
|
)}
|
||||||
</div>
|
<button
|
||||||
</div>
|
onClick={e => handleDeleteAlias(e, a.id)}
|
||||||
<div>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||||
{lbl('Email (optional)')}
|
aria-label="Remove"
|
||||||
<input className="input" type="email" value={form.email} onChange={set('email')}
|
>×</button>
|
||||||
autoComplete="off" />
|
</div>
|
||||||
</div>
|
))}
|
||||||
<div>
|
</div>
|
||||||
{lbl('Avatar (optional)')}
|
</div>
|
||||||
<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'}
|
{/* Form section label */}
|
||||||
</button>
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
|
||||||
</div>
|
{editingAlias
|
||||||
</div>
|
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
|
||||||
|
: 'Add Child'}
|
||||||
|
</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={handleSaveAlias} disabled={saving}>
|
||||||
|
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
const msgPublic = features.msgPublic ?? true;
|
const msgPublic = features.msgPublic ?? true;
|
||||||
const msgU2U = features.msgU2U ?? true;
|
const msgU2U = features.msgU2U ?? true;
|
||||||
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
||||||
|
const loginType = features.loginType || 'all_ages';
|
||||||
|
|
||||||
// Default to private if available, otherwise public
|
// Default to private if available, otherwise public
|
||||||
const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public';
|
const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public';
|
||||||
@@ -21,9 +22,8 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
// Mixed Age: guardian confirmation modal
|
// Pre-confirmation for minor members (shown before creating the chat)
|
||||||
const [guardianConfirm, setGuardianConfirm] = useState(null); // { group, guardianName }
|
const [minorConfirm, setMinorConfirm] = useState(null); // { minorNames: [] } — pending create
|
||||||
const loginType = features.loginType || 'all_ages';
|
|
||||||
|
|
||||||
// True when exactly 1 user selected on private tab AND U2U messages are enabled
|
// True when exactly 1 user selected on private tab AND U2U messages are enabled
|
||||||
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
|
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
|
||||||
@@ -48,16 +48,11 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const doCreate = async () => {
|
||||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
|
||||||
if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error');
|
|
||||||
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let payload;
|
let payload;
|
||||||
if (isDirect) {
|
if (isDirect) {
|
||||||
// Direct message: no name, isDirect flag
|
|
||||||
payload = {
|
payload = {
|
||||||
type: 'private',
|
type: 'private',
|
||||||
memberIds: selected.map(u => u.id),
|
memberIds: selected.map(u => u.id),
|
||||||
@@ -72,18 +67,16 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { group, duplicate, guardianAdded, guardianName } = await api.createGroup(payload);
|
const { group, duplicate, guardianAdded } = await api.createGroup(payload);
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
toast('A group with these members already exists — opening it now.', 'info');
|
toast('A group with these members already exists — opening it now.', 'info');
|
||||||
onCreated(group);
|
|
||||||
} else {
|
} else {
|
||||||
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
||||||
if (guardianAdded && guardianName) {
|
if (guardianAdded) {
|
||||||
setGuardianConfirm({ group, guardianName });
|
toast('A guardian has been added to this conversation.', 'info');
|
||||||
} else {
|
|
||||||
onCreated(group);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onCreated(group);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message, 'error');
|
toast(e.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,6 +84,23 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||||
|
if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error');
|
||||||
|
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
||||||
|
|
||||||
|
// Mixed Age: warn if any selected member is a minor (and initiator is not a minor)
|
||||||
|
if (loginType === 'mixed_age' && !user.is_minor) {
|
||||||
|
const minors = selected.filter(u => u.is_minor);
|
||||||
|
if (minors.length > 0) {
|
||||||
|
setMinorConfirm({ minorNames: minors.map(u => u.display_name || u.name) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await doCreate();
|
||||||
|
};
|
||||||
|
|
||||||
// Placeholder for the name field
|
// Placeholder for the name field
|
||||||
const namePlaceholder = isDirect
|
const namePlaceholder = isDirect
|
||||||
? selected[0]?.name || ''
|
? selected[0]?.name || ''
|
||||||
@@ -181,15 +191,25 @@ export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{guardianConfirm && (
|
{/* Pre-confirmation modal: minor member warning */}
|
||||||
|
{minorConfirm && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal" style={{ maxWidth: 360 }}>
|
<div className="modal" style={{ maxWidth: 380 }}>
|
||||||
<h2 className="modal-title" style={{ marginBottom: 12 }}>Guardian Added</h2>
|
<h2 className="modal-title" style={{ marginBottom: 12 }}>Guardian Notice</h2>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 20 }}>
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||||
<strong>{guardianConfirm.guardianName}</strong> has been added to this conversation as the guardian of this minor.
|
The following member{minorConfirm.minorNames.length > 1 ? 's are' : ' is'} a minor:
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end">
|
<ul style={{ marginBottom: 16, paddingLeft: 20 }}>
|
||||||
<button className="btn btn-primary" onClick={() => { setGuardianConfirm(null); onCreated(guardianConfirm.group); }}>OK</button>
|
{minorConfirm.minorNames.map(n => (
|
||||||
|
<li key={n} className="text-sm" style={{ color: 'var(--text-primary)' }}>{n}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 20 }}>
|
||||||
|
Their designated guardian(s) will be automatically added to this conversation. Do you want to proceed?
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setMinorConfirm(null)}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={() => { setMinorConfirm(null); doCreate(); }}>Proceed</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,10 +120,11 @@ export default function Chat() {
|
|||||||
// Keep modalRef in sync so async callbacks can read current modal without stale closure
|
// Keep modalRef in sync so async callbacks can read current modal without stale closure
|
||||||
useEffect(() => { modalRef.current = modal; }, [modal]);
|
useEffect(() => { modalRef.current = modal; }, [modal]);
|
||||||
|
|
||||||
// Auto-popup Add Child Alias modal when guardian_only user has no aliases yet
|
// Auto-popup Add Child Alias modal when guardian user has no children yet
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (addChildCheckedRef.current) return;
|
if (addChildCheckedRef.current) return;
|
||||||
if (features.loginType !== 'guardian_only' || !features.inGuardiansGroup) return;
|
if (!features.inGuardiansGroup) return;
|
||||||
|
if (features.loginType !== 'guardian_only' && features.loginType !== 'mixed_age') return;
|
||||||
addChildCheckedRef.current = true;
|
addChildCheckedRef.current = true;
|
||||||
api.getAliases().then(({ aliases }) => {
|
api.getAliases().then(({ aliases }) => {
|
||||||
if (!(aliases || []).length) {
|
if (!(aliases || []).length) {
|
||||||
@@ -644,7 +645,7 @@ export default function Chat() {
|
|||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -675,7 +676,7 @@ export default function Chat() {
|
|||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -736,7 +737,7 @@ export default function Chat() {
|
|||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal 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'); }} />}
|
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
|
||||||
|
|
||||||
@@ -772,7 +773,7 @@ export default function Chat() {
|
|||||||
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
|
||||||
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -890,7 +891,7 @@ export default function Chat() {
|
|||||||
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
{modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
|
||||||
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
|
||||||
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
{modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
|
||||||
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
|
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const api = {
|
|||||||
return req('POST', '/users/me/avatar', form);
|
return req('POST', '/users/me/avatar', form);
|
||||||
},
|
},
|
||||||
searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`),
|
searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`),
|
||||||
|
getMinorPlayers: () => req('GET', '/users/minor-players'),
|
||||||
|
addGuardianChild: (minorId) => req('POST', `/users/me/guardian-children/${minorId}`),
|
||||||
|
removeGuardianChild: (minorId) => req('DELETE', `/users/me/guardian-children/${minorId}`),
|
||||||
approveGuardian: (id) => req('PATCH', `/users/${id}/approve-guardian`),
|
approveGuardian: (id) => req('PATCH', `/users/${id}/approve-guardian`),
|
||||||
denyGuardian: (id) => req('PATCH', `/users/${id}/deny-guardian`),
|
denyGuardian: (id) => req('PATCH', `/users/${id}/deny-guardian`),
|
||||||
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
|
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user