refactor(admin): share the dev notification tester between desktop and phone

Both panels loaded the same trips and users, built the same twelve test payloads and sent them the same way. That now lives in useDevNotifications; each panel keeps its own markup and the phone keeps its own wording of the failure toast.
This commit is contained in:
Maurice
2026-10-09 09:57:41 +02:00
parent 5abe5f6519
commit 67f20829f7
4 changed files with 396 additions and 266 deletions
@@ -1,27 +1,13 @@
import React, { useState, useEffect, useId } from 'react' import React, { useId } from 'react'
import { adminApi, tripsApi } from '../../api/client'
import { getApiErrorMessage } from '../../utils/apiError'
import { useAuthStore } from '../../store/authStore'
import { useToast } from '../shared/Toast'
import { fs } from '../shared/DialogShell' import { fs } from '../shared/DialogShell'
import { EditorField, INPUT } from '../shared/dialogParts' import { EditorField, INPUT } from '../shared/dialogParts'
import { SettingsCard, StatusPill } from '../Settings/settingsKit' import { SettingsCard, StatusPill } from '../Settings/settingsKit'
import { useDevNotifications } from './useDevNotifications'
import { import {
Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus, Bell, Zap, CheckCircle, Navigation, User, Calendar, Clock, Image, MessageSquare, Tag, UserPlus,
Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck, Download, MapPin, ChevronDown, FlaskConical, Loader2, Map as MapIcon, ShieldCheck,
} from 'lucide-react' } from 'lucide-react'
interface Trip {
id: number
title: string
}
interface AppUser {
id: number
username: string
email: string
}
/** A native select in the editors' box look, with the chevron the CustomSelect draws. */ /** A native select in the editors' box look, with the chevron the CustomSelect draws. */
const SELECT = `${INPUT} appearance-none pe-9` const SELECT = `${INPUT} appearance-none pe-9`
@@ -40,44 +26,11 @@ function ButtonGrid({ children }: { children: React.ReactNode }): React.ReactEle
} }
export default function DevNotificationsPanel(): React.ReactElement { export default function DevNotificationsPanel(): React.ReactElement {
const toast = useToast()
const ids = useId() const ids = useId()
const user = useAuthStore(s => s.user) const {
const [sending, setSending] = useState<string | null>(null) sending, trips, selectedTripId, setSelectedTripId, users, selectedUserId, setSelectedUserId,
const [trips, setTrips] = useState<Trip[]>([]) tripInviteId, vacayInviteId, send,
const [selectedTripId, setSelectedTripId] = useState<number | null>(null) } = useDevNotifications()
const [users, setUsers] = useState<AppUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
useEffect(() => {
tripsApi.list().then(data => {
const list = (data.trips || data || []) as Trip[]
setTrips(list)
if (list.length > 0) setSelectedTripId(list[0].id)
}).catch(() => {})
adminApi.users().then(data => {
const list = (data.users || data || []) as AppUser[]
setUsers(list)
if (list.length > 0) setSelectedUserId(list[0].id)
}).catch(() => {})
}, [])
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label)
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, 'Failed'))
} finally {
setSending(null)
}
}
const selectedTrip = trips.find(t => t.id === selectedTripId)
const selectedUser = users.find(u => u.id === selectedUserId)
const username = user?.username || 'Admin'
const tripTitle = selectedTrip?.title || 'Test Trip'
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
@@ -114,41 +67,16 @@ export default function DevNotificationsPanel(): React.ReactElement {
> >
<ButtonGrid> <ButtonGrid>
<Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} <Btn id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell}
onClick={() => fire('simple-me', { onClick={send.simpleMe}
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})}
/> />
<Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} <Btn id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle}
onClick={() => fire('boolean-me', { onClick={send.booleanMe}
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})}
/> />
<Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} <Btn id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation}
onClick={() => fire('navigate-me', { onClick={send.navigateMe}
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})}
/> />
<Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} <Btn id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap}
onClick={() => fire('simple-admins', { onClick={send.simpleAdmins}
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})}
/> />
</ButtonGrid> </ButtonGrid>
</SettingsCard> </SettingsCard>
@@ -167,46 +95,19 @@ export default function DevNotificationsPanel(): React.ReactElement {
</EditorField> </EditorField>
<ButtonGrid> <ButtonGrid>
<Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} <Btn id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar}
onClick={() => selectedTripId && fire('booking_change', { onClick={send.bookingChange}
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
})}
/> />
<Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} <Btn id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock}
onClick={() => selectedTripId && fire('trip_reminder', { onClick={send.tripReminder}
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})}
/> />
<Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} <Btn id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image}
onClick={() => selectedTripId && fire('photos_shared', { onClick={send.photosShared}
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
// A string on purpose: notification params are Record<string, string> end to end, the server
// sends count: String(added), and resolveTemplate reads a numeric string as the plural count.
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})}
/> />
<Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} <Btn id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare}
onClick={() => selectedTripId && fire('collab_message', { onClick={send.collabMessage}
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
})}
/> />
<Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} <Btn id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag}
onClick={() => selectedTripId && fire('packing_tagged', { onClick={send.packingTagged}
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})}
/> />
</ButtonGrid> </ButtonGrid>
</SettingsCard> </SettingsCard>
@@ -226,28 +127,18 @@ export default function DevNotificationsPanel(): React.ReactElement {
</EditorField> </EditorField>
<ButtonGrid> <ButtonGrid>
<Btn <Btn
id={`trip_invite-${selectedUserId}`} id={tripInviteId}
label="trip_invite" label="trip_invite"
sub="navigate · user" sub="navigate · user"
icon={UserPlus} icon={UserPlus}
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, { onClick={send.tripInvite}
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
})}
/> />
<Btn <Btn
id={`vacay_invite-${selectedUserId}`} id={vacayInviteId}
label="vacay_invite" label="vacay_invite"
sub="navigate · user" sub="navigate · user"
icon={MapPin} icon={MapPin}
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, { onClick={send.vacayInvite}
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})}
/> />
</ButtonGrid> </ButtonGrid>
</SettingsCard> </SettingsCard>
@@ -257,12 +148,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
<SettingsCard icon={ShieldCheck} title="Admin-Scoped Events" hint="Fires to all admin users."> <SettingsCard icon={ShieldCheck} title="Admin-Scoped Events" hint="Fires to all admin users.">
<ButtonGrid> <ButtonGrid>
<Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download} <Btn id="version_available" label="version_available" sub="navigate · admin" icon={Download}
onClick={() => fire('version_available', { onClick={send.versionAvailable}
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})}
/> />
</ButtonGrid> </ButtonGrid>
</SettingsCard> </SettingsCard>
@@ -0,0 +1,144 @@
// FE-COMP-DEVNOTIF-HOOK-001 to -008: the shared dev notification tester behind both admin shells.
import { act, renderHook, waitFor } from '@testing-library/react';
import { buildUser } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { adminApi, tripsApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { useDevNotifications } from './useDevNotifications';
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
const ME = buildUser({ id: 7, username: 'tester' });
const TRIPS = [
{ id: 11, title: 'Paris' },
{ id: 12, title: 'Tokyo' },
];
const USERS = [
{ id: 21, username: 'alice', email: 'alice@example.com' },
{ id: 22, username: 'bob', email: 'bob@example.com' },
];
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: ME, isAuthenticated: true });
vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS });
vi.spyOn(adminApi, 'users').mockResolvedValue({ users: USERS });
vi.spyOn(adminApi, 'sendTestNotification').mockResolvedValue({});
toast.success.mockReset();
toast.error.mockReset();
});
afterEach(() => vi.restoreAllMocks());
async function loaded(options?: Parameters<typeof useDevNotifications>[0]) {
const hook = renderHook(() => useDevNotifications(options));
await waitFor(() => expect(hook.result.current.selectedUserId).toBe(21));
await waitFor(() => expect(hook.result.current.selectedTripId).toBe(11));
return hook;
}
describe('useDevNotifications', () => {
it('FE-COMP-DEVNOTIF-HOOK-001: loads trips and users and preselects the first of each', async () => {
const { result } = await loaded();
expect(result.current.trips).toEqual(TRIPS);
expect(result.current.users).toEqual(USERS);
expect(result.current.tripInviteId).toBe('trip_invite-21');
expect(result.current.vacayInviteId).toBe('vacay_invite-21');
});
it('FE-COMP-DEVNOTIF-HOOK-002: a failed load leaves the lists empty and nothing selected', async () => {
vi.mocked(tripsApi.list).mockRejectedValue(new Error('down'));
vi.mocked(adminApi.users).mockRejectedValue(new Error('down'));
const { result } = renderHook(() => useDevNotifications());
await waitFor(() => expect(adminApi.users).toHaveBeenCalled());
expect(result.current.trips).toEqual([]);
expect(result.current.selectedTripId).toBeNull();
expect(result.current.selectedUserId).toBeNull();
});
it('FE-COMP-DEVNOTIF-HOOK-003: a self-addressed send targets the signed-in user and toasts the label', async () => {
const { result } = await loaded();
await act(async () => {
await result.current.send.booleanMe();
});
expect(adminApi.sendTestNotification).toHaveBeenCalledWith({
event: 'test_boolean',
scope: 'user',
targetId: 7,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
});
expect(toast.success).toHaveBeenCalledWith('Sent: boolean-me');
expect(result.current.sending).toBeNull();
});
it('FE-COMP-DEVNOTIF-HOOK-004: trip events carry the selected trip, its title and the actor', async () => {
const { result } = await loaded();
act(() => result.current.setSelectedTripId(12));
await act(async () => {
await result.current.send.photosShared();
});
expect(adminApi.sendTestNotification).toHaveBeenCalledWith({
event: 'photos_shared',
scope: 'trip',
targetId: 12,
params: { actor: 'tester', trip: 'Tokyo', count: '5', tripId: '12' },
});
});
it('FE-COMP-DEVNOTIF-HOOK-005: user events go to the selected recipient under an id naming them', async () => {
const { result } = await loaded();
act(() => result.current.setSelectedUserId(22));
await act(async () => {
await result.current.send.tripInvite();
});
expect(adminApi.sendTestNotification).toHaveBeenCalledWith({
event: 'trip_invite',
scope: 'user',
targetId: 22,
params: { actor: 'tester', trip: 'Paris', invitee: 'bob@example.com', tripId: '11' },
});
expect(toast.success).toHaveBeenCalledWith('Sent: trip_invite-22');
});
it('FE-COMP-DEVNOTIF-HOOK-006: trip and user events do nothing while nothing is selected', async () => {
vi.mocked(tripsApi.list).mockResolvedValue({ trips: [] });
vi.mocked(adminApi.users).mockResolvedValue({ users: [] });
const { result } = renderHook(() => useDevNotifications());
await waitFor(() => expect(adminApi.users).toHaveBeenCalled());
await act(async () => {
await result.current.send.bookingChange();
await result.current.send.vacayInvite();
});
expect(adminApi.sendTestNotification).not.toHaveBeenCalled();
});
it('FE-COMP-DEVNOTIF-HOOK-007: by default a failure toasts the server error field, else Failed', async () => {
const { result } = await loaded();
vi.mocked(adminApi.sendTestNotification).mockRejectedValueOnce({ response: { data: { error: 'No channel' } } });
await act(async () => {
await result.current.send.versionAvailable();
});
expect(toast.error).toHaveBeenLastCalledWith('No channel');
vi.mocked(adminApi.sendTestNotification).mockRejectedValueOnce(new Error('boom'));
await act(async () => {
await result.current.send.versionAvailable();
});
expect(toast.error).toHaveBeenLastCalledWith('Failed');
expect(result.current.sending).toBeNull();
});
it('FE-COMP-DEVNOTIF-HOOK-008: a shell can word the failure toast itself', async () => {
const { result } = await loaded({ errorMessage: (err) => (err instanceof Error ? err.message : 'Failed') });
vi.mocked(adminApi.sendTestNotification).mockRejectedValueOnce(new Error('boom'));
await act(async () => {
await result.current.send.simpleAdmins();
});
expect(toast.error).toHaveBeenCalledWith('boom');
});
});
@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react';
import { adminApi, tripsApi } from '../../api/client';
import { useAuthStore } from '../../store/authStore';
import { getApiErrorMessage } from '../../utils/apiError';
import { useToast } from '../shared/Toast';
interface DevTrip {
id: number;
title: string;
}
interface DevAppUser {
id: number;
username: string;
email: string;
}
interface UseDevNotificationsOptions {
/** The error toast text for a failed send. Defaults to the server's `error` field, else 'Failed'. */
errorMessage?: (err: unknown) => string;
}
const serverErrorMessage = (err: unknown) => getApiErrorMessage(err, 'Failed');
/**
* The dev-only notification tester behind both admin shells (the desktop panel and the
* phone panel render their own buttons over it). Loads the trips and users to pick a
* target from, and builds and sends each test event. Every handler is named after the
* button that calls it; `sending` holds the id of the send in flight.
*/
export function useDevNotifications({ errorMessage = serverErrorMessage }: UseDevNotificationsOptions = {}) {
const toast = useToast();
const user = useAuthStore((s) => s.user);
const [sending, setSending] = useState<string | null>(null);
const [trips, setTrips] = useState<DevTrip[]>([]);
const [selectedTripId, setSelectedTripId] = useState<number | null>(null);
const [users, setUsers] = useState<DevAppUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
useEffect(() => {
tripsApi
.list()
.then((data) => {
const list = (data.trips || data || []) as DevTrip[];
setTrips(list);
if (list.length > 0) setSelectedTripId(list[0].id);
})
.catch(() => {});
adminApi
.users()
.then((data) => {
const list = (data.users || data || []) as DevAppUser[];
setUsers(list);
if (list.length > 0) setSelectedUserId(list[0].id);
})
.catch(() => {});
}, []);
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label);
try {
await adminApi.sendTestNotification(payload);
toast.success(`Sent: ${label}`);
} catch (err: unknown) {
toast.error(errorMessage(err));
} finally {
setSending(null);
}
};
const selectedTrip = trips.find((t) => t.id === selectedTripId);
const selectedUser = users.find((u) => u.id === selectedUserId);
const username = user?.username || 'Admin';
const tripTitle = selectedTrip?.title || 'Test Trip';
const tripInviteId = `trip_invite-${selectedUserId}`;
const vacayInviteId = `vacay_invite-${selectedUserId}`;
const send = {
simpleMe: () =>
fire('simple-me', {
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
}),
booleanMe: () =>
fire('boolean-me', {
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
}),
navigateMe: () =>
fire('navigate-me', {
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
}),
simpleAdmins: () =>
fire('simple-admins', {
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
}),
bookingChange: () =>
selectedTripId &&
fire('booking_change', {
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: {
actor: username,
trip: tripTitle,
booking: 'Test Hotel',
type: 'hotel',
tripId: String(selectedTripId),
},
}),
tripReminder: () =>
selectedTripId &&
fire('trip_reminder', {
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
}),
photosShared: () =>
selectedTripId &&
fire('photos_shared', {
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
// A string on purpose: notification params are Record<string, string> end to end, the server
// sends count: String(added), and resolveTemplate reads a numeric string as the plural count.
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
}),
collabMessage: () =>
selectedTripId &&
fire('collab_message', {
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: {
actor: username,
trip: tripTitle,
preview: 'This is a test message preview.',
tripId: String(selectedTripId),
},
}),
packingTagged: () =>
selectedTripId &&
fire('packing_tagged', {
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
}),
tripInvite: () =>
selectedUserId &&
fire(tripInviteId, {
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: {
actor: username,
trip: tripTitle,
invitee: selectedUser?.email || '',
tripId: String(selectedTripId ?? 0),
},
}),
vacayInvite: () =>
selectedUserId &&
fire(vacayInviteId, {
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
}),
versionAvailable: () =>
fire('version_available', {
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
}),
};
return {
sending,
trips,
selectedTripId,
setSelectedTripId,
users,
selectedUserId,
setSelectedUserId,
tripInviteId,
vacayInviteId,
send,
};
}
@@ -1,24 +1,14 @@
import React, { useState, useEffect } from 'react' import React from 'react'
import { import {
Bell, Zap, CheckCircle, Navigation, Calendar, Clock, Image, Bell, Zap, CheckCircle, Navigation, Calendar, Clock, Image,
MessageSquare, Tag, UserPlus, Download, MapPin, Loader2, MessageSquare, Tag, UserPlus, Download, MapPin, Loader2,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { adminApi, tripsApi } from '../../../api/client' import { useDevNotifications } from '../../../components/Admin/useDevNotifications'
import { useAuthStore } from '../../../store/authStore'
import { useToast } from '../../../components/shared/Toast'
import { MAdminCard, MAdminCardHead } from './MAdminUi' import { MAdminCard, MAdminCardHead } from './MAdminUi'
interface Trip { /** The phone panel toasts the thrown error's own message rather than the server's error field. */
id: number const errorMessage = (err: unknown) => (err instanceof Error ? err.message : 'Failed')
title: string
}
interface AppUser {
id: number
username: string
email: string
}
const SELECT_CLASS = const SELECT_CLASS =
'mb-2 h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]' 'mb-2 h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none focus:border-[color:var(--m-faint)]'
@@ -57,46 +47,13 @@ function Btn({
} }
// Dev-only notification testing panel, re-skinned to the mobile admin system. // Dev-only notification testing panel, re-skinned to the mobile admin system.
// All state, fetches and fire() payloads are ported verbatim from the desktop // All state, fetches and payloads come from useDevNotifications, shared
// DevNotificationsPanel — only the presentation layer changes. // with the desktop DevNotificationsPanel; only the presentation layer differs.
export default function MAdminDevNotificationsPanel(): React.ReactElement { export default function MAdminDevNotificationsPanel(): React.ReactElement {
const toast = useToast() const {
const user = useAuthStore(s => s.user) sending, trips, selectedTripId, setSelectedTripId, users, selectedUserId, setSelectedUserId,
const [sending, setSending] = useState<string | null>(null) tripInviteId, vacayInviteId, send,
const [trips, setTrips] = useState<Trip[]>([]) } = useDevNotifications({ errorMessage })
const [selectedTripId, setSelectedTripId] = useState<number | null>(null)
const [users, setUsers] = useState<AppUser[]>([])
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
useEffect(() => {
tripsApi.list().then(data => {
const list = (data.trips || data || []) as Trip[]
setTrips(list)
if (list.length > 0) setSelectedTripId(list[0].id)
}).catch(() => {})
adminApi.users().then(data => {
const list = (data.users || data || []) as AppUser[]
setUsers(list)
if (list.length > 0) setSelectedUserId(list[0].id)
}).catch(() => {})
}, [])
const fire = async (label: string, payload: Record<string, unknown>) => {
setSending(label)
try {
await adminApi.sendTestNotification(payload)
toast.success(`Sent: ${label}`)
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Failed')
} finally {
setSending(null)
}
}
const selectedTrip = trips.find(t => t.id === selectedTripId)
const selectedUser = users.find(u => u.id === selectedUserId)
const username = user?.username || 'Admin'
const tripTitle = selectedTrip?.title || 'Test Trip'
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@@ -118,41 +75,16 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement {
/> />
<div className="mt-2 grid grid-cols-1 gap-2"> <div className="mt-2 grid grid-cols-1 gap-2">
<Btn sending={sending} id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1" <Btn sending={sending} id="simple-me" label="Simple → Me" sub="test_simple · user" icon={Bell} color="#6366f1"
onClick={() => fire('simple-me', { onClick={send.simpleMe}
event: 'test_simple',
scope: 'user',
targetId: user?.id,
params: {},
})}
/> />
<Btn sending={sending} id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981" <Btn sending={sending} id="boolean-me" label="Boolean → Me" sub="test_boolean · user" icon={CheckCircle} color="#10b981"
onClick={() => fire('boolean-me', { onClick={send.booleanMe}
event: 'test_boolean',
scope: 'user',
targetId: user?.id,
params: {},
inApp: {
type: 'boolean',
positiveCallback: { action: 'test_approve', payload: {} },
negativeCallback: { action: 'test_deny', payload: {} },
},
})}
/> />
<Btn sending={sending} id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b" <Btn sending={sending} id="navigate-me" label="Navigate → Me" sub="test_navigate · user" icon={Navigation} color="#f59e0b"
onClick={() => fire('navigate-me', { onClick={send.navigateMe}
event: 'test_navigate',
scope: 'user',
targetId: user?.id,
params: {},
})}
/> />
<Btn sending={sending} id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444" <Btn sending={sending} id="simple-admins" label="Simple → All Admins" sub="test_simple · admin" icon={Zap} color="#ef4444"
onClick={() => fire('simple-admins', { onClick={send.simpleAdmins}
event: 'test_simple',
scope: 'admin',
targetId: 0,
params: {},
})}
/> />
</div> </div>
</MAdminCard> </MAdminCard>
@@ -174,44 +106,19 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement {
</select> </select>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<Btn sending={sending} id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1" <Btn sending={sending} id="booking_change" label="booking_change" sub="navigate · trip" icon={Calendar} color="#6366f1"
onClick={() => selectedTripId && fire('booking_change', { onClick={send.bookingChange}
event: 'booking_change',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, booking: 'Test Hotel', type: 'hotel', tripId: String(selectedTripId) },
})}
/> />
<Btn sending={sending} id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981" <Btn sending={sending} id="trip_reminder" label="trip_reminder" sub="navigate · trip" icon={Clock} color="#10b981"
onClick={() => selectedTripId && fire('trip_reminder', { onClick={send.tripReminder}
event: 'trip_reminder',
scope: 'trip',
targetId: selectedTripId,
params: { trip: tripTitle, tripId: String(selectedTripId) },
})}
/> />
<Btn sending={sending} id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b" <Btn sending={sending} id="photos_shared" label="photos_shared" sub="navigate · trip" icon={Image} color="#f59e0b"
onClick={() => selectedTripId && fire('photos_shared', { onClick={send.photosShared}
event: 'photos_shared',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, count: '5', tripId: String(selectedTripId) },
})}
/> />
<Btn sending={sending} id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6" <Btn sending={sending} id="collab_message" label="collab_message" sub="navigate · trip" icon={MessageSquare} color="#8b5cf6"
onClick={() => selectedTripId && fire('collab_message', { onClick={send.collabMessage}
event: 'collab_message',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, preview: 'This is a test message preview.', tripId: String(selectedTripId) },
})}
/> />
<Btn sending={sending} id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899" <Btn sending={sending} id="packing_tagged" label="packing_tagged" sub="navigate · trip" icon={Tag} color="#ec4899"
onClick={() => selectedTripId && fire('packing_tagged', { onClick={send.packingTagged}
event: 'packing_tagged',
scope: 'trip',
targetId: selectedTripId,
params: { actor: username, trip: tripTitle, category: 'Clothing', tripId: String(selectedTripId) },
})}
/> />
</div> </div>
</div> </div>
@@ -236,31 +143,21 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement {
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<Btn <Btn
sending={sending} sending={sending}
id={`trip_invite-${selectedUserId}`} id={tripInviteId}
label="trip_invite" label="trip_invite"
sub="navigate · user" sub="navigate · user"
icon={UserPlus} icon={UserPlus}
color="#06b6d4" color="#06b6d4"
onClick={() => selectedUserId && fire(`trip_invite-${selectedUserId}`, { onClick={send.tripInvite}
event: 'trip_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, trip: tripTitle, invitee: selectedUser?.email || '', tripId: String(selectedTripId ?? 0) },
})}
/> />
<Btn <Btn
sending={sending} sending={sending}
id={`vacay_invite-${selectedUserId}`} id={vacayInviteId}
label="vacay_invite" label="vacay_invite"
sub="navigate · user" sub="navigate · user"
icon={MapPin} icon={MapPin}
color="#f97316" color="#f97316"
onClick={() => selectedUserId && fire(`vacay_invite-${selectedUserId}`, { onClick={send.vacayInvite}
event: 'vacay_invite',
scope: 'user',
targetId: selectedUserId,
params: { actor: username, planId: '1' },
})}
/> />
</div> </div>
</div> </div>
@@ -275,12 +172,7 @@ export default function MAdminDevNotificationsPanel(): React.ReactElement {
/> />
<div className="mt-2 grid grid-cols-1 gap-2"> <div className="mt-2 grid grid-cols-1 gap-2">
<Btn sending={sending} id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b" <Btn sending={sending} id="version_available" label="version_available" sub="navigate · admin" icon={Download} color="#64748b"
onClick={() => fire('version_available', { onClick={send.versionAvailable}
event: 'version_available',
scope: 'admin',
targetId: 0,
params: { version: '9.9.9-test' },
})}
/> />
</div> </div>
</MAdminCard> </MAdminCard>