diff --git a/README.md b/README.md index 04b54aa..d2c75bf 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ To Be Played is split into three repositories: - **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it" marks them seen in Auth; closing the dialog any other way shows them again on the next dashboard visit. `/whats-new` (menu) lists all entries and marks them seen. Admins publish, preview, edit and delete entries on `/admin/news`. +- **Broadcast mail**: admins write a mail in both languages on `/admin/broadcasts`, send it to all active users or to + picked ones, send a test to themselves, confirm with the real recipient count, and see the history with the counts. + Users turn platform mails off in the settings; account mails always come. - **Welcome page**: `/welcome` opens once after the first login (Auth `settings.welcomeSeenAt` is null; only the dashboard redirects, deep links stay). Feature cards with links, two next steps (Steam, import) with checks from `/me` and the library summary. "Let's go" and "Skip" stamp it in Auth and open the dashboard. The settings page diff --git a/public/i18n/de.json b/public/i18n/de.json index 3228a48..8e8e6a3 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -130,7 +130,9 @@ "PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".", "FAVORITE_PLATFORM": "Lieblingsplattform", "FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.", - "FAVORITE_PLATFORM_NONE": "Keine" + "FAVORITE_PLATFORM_NONE": "Keine", + "PLATFORM_MAILS": "Mails von der Plattform", + "PLATFORM_MAILS_HINT": "Neuigkeiten und Ankündigungen per Mail. Mails zu deinem Konto kommen immer." }, "VALIDATION": { "EMAIL_REQUIRED": "Email ist erforderlich.", @@ -304,6 +306,7 @@ "ADMIN_HINTS": "Hinweise", "WHATS_NEW": "Neuigkeiten", "ADMIN_NEWS": "Neuigkeiten verwalten", + "ADMIN_BROADCASTS": "Rundmail", "PENDING_REQUESTS_one": "{{count}} Registrierung wartet", "PENDING_REQUESTS_other": "{{count}} Registrierungen warten" }, @@ -691,6 +694,35 @@ "LOAD_ERROR": "Die Einträge konnten nicht geladen werden.", "NO_ACCESS": "Nur Admins können diese Seite öffnen." }, + "ADMIN_BROADCAST": { + "NEW": "Neue Rundmail", + "SUBJECT_EN": "Betreff (Englisch)", + "SUBJECT_DE": "Betreff (Deutsch)", + "BODY_EN": "Text (Englisch)", + "BODY_DE": "Text (Deutsch)", + "TEXT_HELP": "Nur Text, in beiden Sprachen. Betreff höchstens 150, Text höchstens 5000 Zeichen. Eine Fußzeile mit dem Link zum Abschalten wird angehängt.", + "RECIPIENTS": "Empfänger", + "ALL": "Alle aktiven Nutzer", + "PICKED": "Ausgewählte Nutzer", + "SEARCH": "E-Mail oder Name suchen", + "PICKED_COUNT": "{{count}} ausgewählt", + "REMOVE": "Entfernen", + "TEST": "Test an mich senden", + "TEST_SENT": "Testmail für dich eingereiht.", + "SEND": "Senden...", + "CONFIRM_TITLE": "Diese Mail senden?", + "CONFIRM_TEXT": "An {{count}} Nutzer senden? Das kann nicht rückgängig gemacht werden.", + "CONFIRM": "Senden", + "CANCEL": "Abbrechen", + "NO_RECIPIENTS": "Kein Empfänger: niemand ist aktiv und hat Mails an.", + "SENT": "Mail für {{count}} Nutzer eingereiht.", + "ERROR": "Die Aktion ist fehlgeschlagen.", + "LIST": "Gesendete Mails", + "COUNTS": "{{recipients}} Empfänger, {{sent}} gesendet, {{failed}} fehlgeschlagen", + "EMPTY": "Noch keine Mails gesendet.", + "LOAD_ERROR": "Die Mails konnten nicht geladen werden.", + "NO_ACCESS": "Nur Admins können diese Seite öffnen." + }, "TILES": { "CUSTOMIZE": "Anpassen", "EDIT_HINT": "Verschiebe, vergrößere oder verstecke die Kacheln. Gespeichert wird erst mit \"Fertig\".", diff --git a/public/i18n/en.json b/public/i18n/en.json index cbd771a..b3da899 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -130,7 +130,9 @@ "PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".", "FAVORITE_PLATFORM": "Favorite platform", "FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.", - "FAVORITE_PLATFORM_NONE": "None" + "FAVORITE_PLATFORM_NONE": "None", + "PLATFORM_MAILS": "Mails from the platform", + "PLATFORM_MAILS_HINT": "News and announcements by mail. Mails about your account always come." }, "VALIDATION": { "EMAIL_REQUIRED": "Email is required.", @@ -304,6 +306,7 @@ "ADMIN_HINTS": "Hints", "WHATS_NEW": "What's new", "ADMIN_NEWS": "News", + "ADMIN_BROADCASTS": "Mail", "PENDING_REQUESTS_one": "{{count}} registration waits", "PENDING_REQUESTS_other": "{{count}} registrations wait" }, @@ -691,6 +694,35 @@ "LOAD_ERROR": "The entries could not be loaded.", "NO_ACCESS": "Only admins can open this page." }, + "ADMIN_BROADCAST": { + "NEW": "New mail", + "SUBJECT_EN": "Subject (English)", + "SUBJECT_DE": "Subject (German)", + "BODY_EN": "Text (English)", + "BODY_DE": "Text (German)", + "TEXT_HELP": "Plain text, both languages. Subject max 150, text max 5000 characters. A footer with the opt-out link is added.", + "RECIPIENTS": "Recipients", + "ALL": "All active users", + "PICKED": "Picked users", + "SEARCH": "Search email or name", + "PICKED_COUNT": "{{count}} picked", + "REMOVE": "Remove", + "TEST": "Send test to me", + "TEST_SENT": "Test mail queued for you.", + "SEND": "Send...", + "CONFIRM_TITLE": "Send this mail?", + "CONFIRM_TEXT": "Send to {{count}} users? This cannot be undone.", + "CONFIRM": "Send", + "CANCEL": "Cancel", + "NO_RECIPIENTS": "No recipient: nobody active with mails on.", + "SENT": "Mail queued for {{count}} users.", + "ERROR": "The action failed.", + "LIST": "Sent mails", + "COUNTS": "{{recipients}} recipients, {{sent}} sent, {{failed}} failed", + "EMPTY": "No mails sent yet.", + "LOAD_ERROR": "The mails could not be loaded.", + "NO_ACCESS": "Only admins can open this page." + }, "TILES": { "CUSTOMIZE": "Customize", "EDIT_HINT": "Move, resize or hide the tiles. Nothing is saved before \"Done\".", diff --git a/src/App.jsx b/src/App.jsx index 2453874..44b556c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import Dashboard from "./pages/Dashboard.jsx"; import RegistrationPage from './pages/RegistrationPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx'; import AdminHintsPage from './pages/AdminHintsPage.jsx'; +import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx'; import AdminNewsPage from './pages/AdminNewsPage.jsx'; import WhatsNewPage from './pages/WhatsNewPage.jsx'; import WelcomePage from './pages/WelcomePage.jsx'; @@ -82,6 +83,7 @@ function App() { }/> }/> }/> + }/> }/> }/> diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 311510b..f63f5c6 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom'; import {useTranslation} from 'react-i18next'; -import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react'; +import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Mail, Megaphone, Moon, Newspaper, Settings, Shield, Sun} from 'lucide-react'; import {toast} from 'sonner'; import {Badge} from '@/components/ui/badge'; @@ -169,6 +169,9 @@ export default function AppHeader({isAuthenticated, onLogout}) { {t('NAV.ADMIN_NEWS')} + + {t('NAV.ADMIN_BROADCASTS')} + )} {isAdmin && ( diff --git a/src/components/AppHeader.test.jsx b/src/components/AppHeader.test.jsx index bcb3ac6..f90bfcd 100644 --- a/src/components/AppHeader.test.jsx +++ b/src/components/AppHeader.test.jsx @@ -55,6 +55,7 @@ describe('AppHeader', () => { expect(await screen.findByText('NAV.ADMIN')).toBeTruthy(); expect(screen.getByText('NAV.ADMIN_NEWS')).toBeTruthy(); + expect(screen.getByText('NAV.ADMIN_BROADCASTS')).toBeTruthy(); expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy(); expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull(); expect(screen.queryByLabelText('NAV.PENDING_REQUESTS:0')).toBeNull(); @@ -84,6 +85,7 @@ describe('AppHeader', () => { openMenu(); expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy(); expect(screen.queryByText('NAV.ADMIN')).toBeNull(); + expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); }); it('offers nothing of that to a user', async () => { @@ -96,6 +98,7 @@ describe('AppHeader', () => { expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy(); expect(screen.queryByText('NAV.ADMIN')).toBeNull(); expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull(); + expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull(); expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull(); }); }); diff --git a/src/components/PreferencesCard.jsx b/src/components/PreferencesCard.jsx index e2dfb06..aedce60 100644 --- a/src/components/PreferencesCard.jsx +++ b/src/components/PreferencesCard.jsx @@ -11,7 +11,7 @@ import {usePreferences} from '../context/PreferencesContext.jsx'; import usePersistSettings from '../hooks/usePersistSettings.js'; import useSaveResponse from '../hooks/useSaveResponse.js'; import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js'; -import {LANGUAGES, saveSettings} from '../utils/settings.js'; +import {LANGUAGES, savePlatformMails, saveSettings} from '../utils/settings.js'; // Radix Select has no empty value. const NO_FAVORITE = 'NONE'; @@ -41,6 +41,13 @@ export default function PreferencesCard() { } }; + const handlePlatformMailsChange = async (next) => { + if (await runSave(() => savePlatformMails(next))) { + updateProfile((p) => ({...p, settings: {...p.settings, platformMails: next}})); + toast.success(t('SETTINGS.SAVED')); + } + }; + return ( @@ -83,6 +90,14 @@ export default function PreferencesCard() { +
+
+ +

{t('SETTINGS.PLATFORM_MAILS_HINT')}

+
+ +
); diff --git a/src/pages/AdminBroadcastPage.jsx b/src/pages/AdminBroadcastPage.jsx new file mode 100644 index 0000000..3e4bc43 --- /dev/null +++ b/src/pages/AdminBroadcastPage.jsx @@ -0,0 +1,322 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useTranslation} from 'react-i18next'; +import {Loader2, Search, Send, X} from 'lucide-react'; +import {toast} from 'sonner'; + +import {Button} from '@/components/ui/button'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {Textarea} from '@/components/ui/textarea'; +import PageContainer from '../components/PageContainer.jsx'; +import {fetchUsers} from '../utils/admin.js'; +import {fetchBroadcasts, fetchRecipientCount, sendBroadcast, sendTestBroadcast} from '../utils/broadcasts.js'; +import {formatDateTime} from '../utils/format.js'; +import {MAX_BROADCAST_BODY_LENGTH, MAX_BROADCAST_SUBJECT_LENGTH} from '../utils/limits.js'; + +const emptyForm = () => ({subjectEn: '', subjectDe: '', bodyEn: '', bodyDe: ''}); +const SEARCH_SIZE = 20; + +/** + * PLATFORM_ADMIN only: write a mail in both languages, send it to all active users or to picked ones, and see the + * history with the counts. Auth checks the role. + */ +export default function AdminBroadcastPage() { + const {t, i18n} = useTranslation(); + const [entries, setEntries] = useState([]); + const [status, setStatus] = useState('loading'); + const [form, setForm] = useState(emptyForm); + const [mode, setMode] = useState('all'); + const [picked, setPicked] = useState([]); + const [query, setQuery] = useState(''); + const [found, setFound] = useState([]); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + try { + const response = await fetchBroadcasts(); + if (response.status === 401) return; + if (response.status === 403) { + setStatus('forbidden'); + return; + } + if (!response.ok) { + setStatus('error'); + return; + } + setEntries((await response.json()).results); + setStatus('ready'); + } catch (err) { + console.error('Admin broadcast error:', err); + setStatus('error'); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const trimmed = () => ({ + subjectEn: form.subjectEn.trim(), + subjectDe: form.subjectDe.trim(), + bodyEn: form.bodyEn.trim(), + bodyDe: form.bodyDe.trim(), + }); + + const incomplete = Object.values(trimmed()).some((value) => !value); + const userIds = () => (mode === 'all' ? null : picked.map((user) => user.id)); + const change = (field) => (event) => setForm({...form, [field]: event.target.value}); + + const sendTest = async () => { + setBusy(true); + try { + const response = await sendTestBroadcast(trimmed()); + if (response.status === 401) return; + if (response.ok) toast.success(t('ADMIN_BROADCAST.TEST_SENT')); + else toast.error(t('ADMIN_BROADCAST.ERROR')); + } catch (err) { + console.error('Admin broadcast test error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + const submit = async (event) => { + event.preventDefault(); + setConfirm({count: null}); + try { + const response = await fetchRecipientCount(userIds()); + if (response.status === 401) return; + if (!response.ok) throw new Error(`count ${response.status}`); + const {count} = await response.json(); + setConfirm((current) => (current ? {count} : current)); + } catch (err) { + console.error('Admin broadcast count error:', err); + toast.error(t('ADMIN_BROADCAST.ERROR')); + setConfirm(null); + } + }; + + const send = async () => { + setBusy(true); + try { + const response = await sendBroadcast({...trimmed(), userIds: userIds()}); + if (response.status === 401) return; + if (response.ok) { + const data = await response.json(); + toast.success(t('ADMIN_BROADCAST.SENT', {count: data.recipientCount})); + setConfirm(null); + setForm(emptyForm()); + setPicked([]); + setFound([]); + await load(); + } else { + toast.error(t(response.status === 409 ? 'ADMIN_BROADCAST.NO_RECIPIENTS' : 'ADMIN_BROADCAST.ERROR')); + } + } catch (err) { + console.error('Admin broadcast send error:', err); + toast.error(t('INFO.ERROR.NETWORK_FAILURE')); + } finally { + setBusy(false); + } + }; + + const search = async () => { + try { + const response = await fetchUsers(query.trim(), 'ACTIVE', 0, SEARCH_SIZE); + if (response.status === 401) return; + if (!response.ok) throw new Error(`search ${response.status}`); + const {content} = await response.json(); + setFound(content.filter((user) => !user.blocked && !user.deletedAt)); + } catch (err) { + console.error('Admin broadcast search error:', err); + toast.error(t('ADMIN_BROADCAST.ERROR')); + } + }; + + const toggle = (user) => setPicked((current) => (current.some((p) => p.id === user.id) + ? current.filter((p) => p.id !== user.id) + : [...current, {id: user.id, email: user.email, displayName: user.displayName}])); + + if (status === 'forbidden') { + return ( + +

{t('ADMIN_BROADCAST.NO_ACCESS')}

+
+ ); + } + + return ( + + + + {t('ADMIN_BROADCAST.NEW')} + + +
+
+ + +
+
+ +