From 7b7694e1f3fcc23ef732a14b6060b727e78e7c0e Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 21 Jun 2026 20:59:08 +0200 Subject: [PATCH] feat(web): in-app confirmation modal instead of native window.confirm Destructive actions used the browser's native confirm/alert dialogs, which look unbranded and unprofessional. Add a reusable `ConfirmDialog` (overlay + card, Escape/overlay-click to cancel, busy state, inline error) and use it for: - deleting a form (dashboard forms list) - withdrawing / deleting a submission (public status page) i18n: added `cancel` (status + dashboard) and `deleteFormTitle`, DE/EN. --- .../app/dashboard/[guildId]/forms/page.tsx | 8 +- apps/web/src/app/s/[id]/page.tsx | 1 + apps/web/src/components/confirm-dialog.tsx | 81 +++++++++++++++++++ .../dashboard/delete-form-button.tsx | 43 +++++++--- .../submission/submission-actions.tsx | 47 +++++++++-- apps/web/src/i18n/dictionaries.ts | 6 +- 6 files changed, 166 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/confirm-dialog.tsx diff --git a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx index 3a9920d..c29c7bc 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx @@ -120,7 +120,13 @@ export default async function GuildFormsPage({ )} diff --git a/apps/web/src/app/s/[id]/page.tsx b/apps/web/src/app/s/[id]/page.tsx index 2876fee..4014871 100644 --- a/apps/web/src/app/s/[id]/page.tsx +++ b/apps/web/src/app/s/[id]/page.tsx @@ -96,6 +96,7 @@ export default async function SubmissionStatusPage({ deleteConfirm: t.deleteConfirm, deleted: t.deleted, actionFailed: t.actionFailed, + cancel: t.cancel, }} /> diff --git a/apps/web/src/components/confirm-dialog.tsx b/apps/web/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..511f76f --- /dev/null +++ b/apps/web/src/components/confirm-dialog.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Button } from "@msk-forms/ui"; +import { useEffect } from "react"; + +/** + * In-app confirmation modal — replaces the native window.confirm so destructive + * actions get a styled, on-brand dialog. Controlled via `open`. Overlay click + * and Escape cancel (unless busy); the confirm button can show a busy state and + * inline error. + */ +export function ConfirmDialog({ + open, + title, + message, + confirmLabel, + cancelLabel, + busy = false, + error, + danger = true, + onConfirm, + onCancel, +}: { + open: boolean; + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + busy?: boolean; + error?: string | null; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !busy) onCancel(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, busy, onCancel]); + + if (!open) return null; + + return ( +
+
+
+

{title}

+

{message}

+ {error &&

{error}

} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/dashboard/delete-form-button.tsx b/apps/web/src/components/dashboard/delete-form-button.tsx index 8465971..7f99ad8 100644 --- a/apps/web/src/components/dashboard/delete-form-button.tsx +++ b/apps/web/src/components/dashboard/delete-form-button.tsx @@ -3,6 +3,8 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; +import { ConfirmDialog } from "@/components/confirm-dialog"; + export function DeleteFormButton({ guildId, formId, @@ -10,32 +12,49 @@ export function DeleteFormButton({ }: { guildId: string; formId: string; - t: { delete: string; confirm: string; failed: string }; + t: { delete: string; title: string; confirm: string; cancel: string; failed: string }; }) { const router = useRouter(); + const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); - async function onDelete() { - if (!window.confirm(t.confirm)) return; + async function onConfirm() { setBusy(true); + setError(null); try { const res = await fetch(`/api/guilds/${guildId}/forms/${formId}`, { method: "DELETE" }); if (!res.ok) throw new Error(); router.refresh(); } catch { - window.alert(t.failed); + setError(t.failed); setBusy(false); } } return ( - + <> + + setOpen(false)} + /> + ); } diff --git a/apps/web/src/components/submission/submission-actions.tsx b/apps/web/src/components/submission/submission-actions.tsx index 38a8bc3..b6d78e7 100644 --- a/apps/web/src/components/submission/submission-actions.tsx +++ b/apps/web/src/components/submission/submission-actions.tsx @@ -4,6 +4,8 @@ import { Button } from "@msk-forms/ui"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { ConfirmDialog } from "@/components/confirm-dialog"; + export interface SubmissionActionLabels { yourData: string; withdraw: string; @@ -13,6 +15,7 @@ export interface SubmissionActionLabels { deleteConfirm: string; deleted: string; actionFailed: string; + cancel: string; } /** @@ -32,9 +35,10 @@ export function SubmissionActions({ const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [deleted, setDeleted] = useState(false); + // Which destructive action is awaiting confirmation, if any. + const [confirming, setConfirming] = useState(null); async function withdraw() { - if (!window.confirm(t.withdrawConfirm)) return; setError(null); setBusy(true); try { @@ -43,6 +47,7 @@ export function SubmissionActions({ const data = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(data?.error ?? t.actionFailed); } + setConfirming(null); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : t.actionFailed); @@ -52,7 +57,6 @@ export function SubmissionActions({ } async function remove() { - if (!window.confirm(t.deleteConfirm)) return; setError(null); setBusy(true); try { @@ -77,7 +81,14 @@ export function SubmissionActions({
{canWithdraw && ( - )} @@ -89,14 +100,40 @@ export function SubmissionActions({
- {error &&

{error}

} + {error && !confirming &&

{error}

} + + setConfirming(null)} + /> + setConfirming(null)} + />
); } diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index 5aecd07..fb9c04c 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -63,6 +63,7 @@ const en = { deleteConfirm: "Permanently delete this submission and its uploaded files? This cannot be undone.", deleted: "Your submission has been deleted.", actionFailed: "Action failed. Please try again.", + cancel: "Cancel", }, preview: { submission: "Your submission", title: "Whitelist application", inReview: "In review", @@ -97,7 +98,7 @@ const en = { "Connecting a guild happens through the Discord bot invite. That flow lands in a later slice. For now, an admin can add you to a guild.", countForm: "form", countForms: "forms", countSubmissions: "submissions", newForm: "New form", noForms: "No forms yet. Create your first one.", edit: "Edit", exportCsv: "Export CSV", - deleteForm: "Delete", deleteFormConfirm: "Delete this form? All its submissions, files and activity will be permanently removed. This cannot be undone.", deleteFormFailed: "Could not delete the form.", + deleteForm: "Delete", deleteFormTitle: "Delete form?", deleteFormConfirm: "Delete this form? All its submissions, files and activity will be permanently removed. This cannot be undone.", deleteFormFailed: "Could not delete the form.", cancel: "Cancel", colApplicant: "Applicant", colForm: "Form", colDate: "Date", colStatus: "Status", anonymous: "Anonymous", noSubmissions: "No submissions yet.", open: "Open", mySubmissionsTitle: "My submissions", noMySubmissions: "You haven’t submitted any forms yet.", @@ -253,6 +254,7 @@ const de: Dictionary = { deleteConfirm: "Diese Einreichung und alle hochgeladenen Dateien endgültig löschen? Das kann nicht rückgängig gemacht werden.", deleted: "Deine Einreichung wurde gelöscht.", actionFailed: "Aktion fehlgeschlagen. Bitte versuche es erneut.", + cancel: "Abbrechen", }, preview: { submission: "Deine Einreichung", title: "Whitelist-Bewerbung", inReview: "In Prüfung", @@ -287,7 +289,7 @@ const de: Dictionary = { "Eine Guild wird über die Discord-Bot-Einladung verbunden. Dieser Ablauf kommt in einem späteren Slice. Bis dahin kann dich ein Admin zu einer Guild hinzufügen.", countForm: "Formular", countForms: "Formulare", countSubmissions: "Einreichungen", newForm: "Neues Formular", noForms: "Noch keine Formulare. Erstelle dein erstes.", edit: "Bearbeiten", exportCsv: "CSV-Export", - deleteForm: "Löschen", deleteFormConfirm: "Dieses Formular löschen? Alle Einreichungen, Dateien und Aktivitäten werden unwiderruflich entfernt. Das kann nicht rückgängig gemacht werden.", deleteFormFailed: "Formular konnte nicht gelöscht werden.", + deleteForm: "Löschen", deleteFormTitle: "Formular löschen?", deleteFormConfirm: "Dieses Formular löschen? Alle Einreichungen, Dateien und Aktivitäten werden unwiderruflich entfernt. Das kann nicht rückgängig gemacht werden.", deleteFormFailed: "Formular konnte nicht gelöscht werden.", cancel: "Abbrechen", colApplicant: "Bewerber", colForm: "Formular", colDate: "Datum", colStatus: "Status", anonymous: "Anonym", noSubmissions: "Noch keine Einreichungen.", open: "Öffnen", mySubmissionsTitle: "Meine Einreichungen", noMySubmissions: "Du hast noch keine Formulare abgeschickt.",