Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/web/src/app/dashboard/[guildId]/forms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,13 @@ export default async function GuildFormsPage({
<DeleteFormButton
guildId={guildId}
formId={form.id}
t={{ delete: t.deleteForm, confirm: t.deleteFormConfirm, failed: t.deleteFormFailed }}
t={{
delete: t.deleteForm,
title: t.deleteFormTitle,
confirm: t.deleteFormConfirm,
cancel: t.cancel,
failed: t.deleteFormFailed,
}}
/>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/s/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export default async function SubmissionStatusPage({
deleteConfirm: t.deleteConfirm,
deleted: t.deleted,
actionFailed: t.actionFailed,
cancel: t.cancel,
}}
/>
</section>
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/components/confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={busy ? undefined : onCancel}
/>
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl">
<h2 className="font-heading text-lg font-semibold text-foreground">{title}</h2>
<p className="mt-2 text-sm text-muted-foreground">{message}</p>
{error && <p className="mt-3 text-sm text-destructive">{error}</p>}
<div className="mt-6 flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<button
type="button"
onClick={onConfirm}
disabled={busy}
className={
danger
? "inline-flex items-center justify-center rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:opacity-50"
: "inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
43 changes: 31 additions & 12 deletions apps/web/src/components/dashboard/delete-form-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,58 @@
import { useRouter } from "next/navigation";
import { useState } from "react";

import { ConfirmDialog } from "@/components/confirm-dialog";

export function DeleteFormButton({
guildId,
formId,
t,
}: {
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<string | null>(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 (
<button
type="button"
onClick={onDelete}
disabled={busy}
className="rounded-md border border-destructive/40 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
>
{t.delete}
</button>
<>
<button
type="button"
onClick={() => {
setError(null);
setOpen(true);
}}
className="rounded-md border border-destructive/40 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10"
>
{t.delete}
</button>
<ConfirmDialog
open={open}
title={t.title}
message={t.confirm}
confirmLabel={t.delete}
cancelLabel={t.cancel}
busy={busy}
error={error}
onConfirm={onConfirm}
onCancel={() => setOpen(false)}
/>
</>
);
}
47 changes: 42 additions & 5 deletions apps/web/src/components/submission/submission-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,6 +15,7 @@ export interface SubmissionActionLabels {
deleteConfirm: string;
deleted: string;
actionFailed: string;
cancel: string;
}

/**
Expand All @@ -32,9 +35,10 @@ export function SubmissionActions({
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleted, setDeleted] = useState(false);
// Which destructive action is awaiting confirmation, if any.
const [confirming, setConfirming] = useState<null | "withdraw" | "delete">(null);

async function withdraw() {
if (!window.confirm(t.withdrawConfirm)) return;
setError(null);
setBusy(true);
try {
Expand All @@ -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);
Expand All @@ -52,7 +57,6 @@ export function SubmissionActions({
}

async function remove() {
if (!window.confirm(t.deleteConfirm)) return;
setError(null);
setBusy(true);
try {
Expand All @@ -77,7 +81,14 @@ export function SubmissionActions({
</h2>
<div className="flex flex-wrap gap-3">
{canWithdraw && (
<Button variant="ghost" onClick={withdraw} disabled={busy}>
<Button
variant="ghost"
onClick={() => {
setError(null);
setConfirming("withdraw");
}}
disabled={busy}
>
{t.withdraw}
</Button>
)}
Expand All @@ -89,14 +100,40 @@ export function SubmissionActions({
</a>
<Button
variant="ghost"
onClick={remove}
onClick={() => {
setError(null);
setConfirming("delete");
}}
disabled={busy}
className="text-destructive hover:text-destructive"
>
{t.deleteData}
</Button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{error && !confirming && <p className="text-sm text-destructive">{error}</p>}

<ConfirmDialog
open={confirming === "withdraw"}
title={t.withdraw}
message={t.withdrawConfirm}
confirmLabel={t.withdraw}
cancelLabel={t.cancel}
busy={busy}
error={confirming === "withdraw" ? error : null}
onConfirm={withdraw}
onCancel={() => setConfirming(null)}
/>
<ConfirmDialog
open={confirming === "delete"}
title={t.deleteData}
message={t.deleteConfirm}
confirmLabel={t.deleteData}
cancelLabel={t.cancel}
busy={busy}
error={confirming === "delete" ? error : null}
onConfirm={remove}
onCancel={() => setConfirming(null)}
/>
</div>
);
}
6 changes: 4 additions & 2 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down