mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-04-19 21:47:50 -05:00
Add report modal for individual hacks
This commit is contained in:
parent
ef55c0a40d
commit
59de8137cc
|
|
@ -3,6 +3,9 @@
|
|||
import { createClient } from "@/utils/supabase/server";
|
||||
import { getMinioClient, PATCHES_BUCKET } from "@/utils/minio/server";
|
||||
import { isInformationalArchiveHack } from "@/utils/hack";
|
||||
import { sendDiscordMessageEmbed } from "@/utils/discord";
|
||||
import { headers } from "next/headers";
|
||||
import { validateEmail } from "@/utils/auth";
|
||||
|
||||
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
||||
const supabase = await createClient();
|
||||
|
|
@ -68,3 +71,125 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url:
|
|||
}
|
||||
}
|
||||
|
||||
export async function submitHackReport(data: {
|
||||
slug: string;
|
||||
reportType: "hateful" | "harassment" | "misleading" | "stolen";
|
||||
details: string | null;
|
||||
email: string | null;
|
||||
isImpersonating: boolean | null;
|
||||
}): Promise<{ error: string | null }> {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Validate hack exists
|
||||
const { data: hack, error: hackError } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, title")
|
||||
.eq("slug", data.slug)
|
||||
.maybeSingle();
|
||||
|
||||
if (hackError || !hack) {
|
||||
return { error: "Hack not found" };
|
||||
}
|
||||
|
||||
// Validate email if provided (for stolen reports)
|
||||
if (data.reportType === "stolen" && data.email) {
|
||||
const emailLower = data.email.trim().toLowerCase();
|
||||
const { error: emailError } = validateEmail(emailLower);
|
||||
if (emailError) {
|
||||
return { error: emailError };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (data.reportType === "misleading" && !data.details?.trim()) {
|
||||
return { error: "Details are required for misleading reports" };
|
||||
}
|
||||
|
||||
if (data.reportType === "stolen") {
|
||||
if (!data.email?.trim()) {
|
||||
return { error: "Email is required for stolen hack reports" };
|
||||
}
|
||||
if (!data.details?.trim()) {
|
||||
return { error: "Details are required for stolen hack reports" };
|
||||
}
|
||||
}
|
||||
|
||||
// Build hack URL
|
||||
const hdrs = await headers();
|
||||
const siteBase = process.env.NEXT_PUBLIC_SITE_URL ? process.env.NEXT_PUBLIC_SITE_URL.replace(/\/$/, "") : "";
|
||||
const proto = siteBase ? "" : (hdrs.get("x-forwarded-proto") || "https");
|
||||
const host = siteBase ? "" : (hdrs.get("host") || "");
|
||||
const baseUrl = siteBase || (proto && host ? `${proto}://${host}` : "");
|
||||
const hackUrl = baseUrl ? `${baseUrl}/hack/${data.slug}` : `/hack/${data.slug}`;
|
||||
|
||||
// Format report type for display
|
||||
const reportTypeLabels: Record<typeof data.reportType, string> = {
|
||||
hateful: "Hateful Content",
|
||||
harassment: "Harassment",
|
||||
misleading: "Misleading",
|
||||
stolen: "My Hack Was Stolen",
|
||||
};
|
||||
|
||||
// Build Discord embed fields
|
||||
const fields: Array<{ name: string; value: string; inline?: boolean }> = [
|
||||
{
|
||||
name: "Report Type",
|
||||
value: reportTypeLabels[data.reportType],
|
||||
inline: false,
|
||||
},
|
||||
{
|
||||
name: "Hack",
|
||||
value: `[${hack.title}](${hackUrl})`,
|
||||
inline: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (data.details) {
|
||||
fields.push({
|
||||
name: "Details",
|
||||
value: data.details.length > 1000 ? data.details.substring(0, 1000) + "..." : data.details,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.reportType === "stolen") {
|
||||
if (data.email) {
|
||||
fields.push({
|
||||
name: "Contact Email",
|
||||
value: data.email.trim().toLowerCase(),
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
if (data.isImpersonating !== null) {
|
||||
fields.push({
|
||||
name: "Is Uploader Impersonating?",
|
||||
value: data.isImpersonating ? "Yes" : "No",
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send Discord webhook
|
||||
if (process.env.DISCORD_WEBHOOK_ADMIN_URL) {
|
||||
try {
|
||||
await sendDiscordMessageEmbed(process.env.DISCORD_WEBHOOK_ADMIN_URL, [
|
||||
{
|
||||
title: "Hack Report",
|
||||
description: `A new report has been submitted for [${hack.title}](${hackUrl})`,
|
||||
color: 0xff6b6b, // Red color for reports
|
||||
fields,
|
||||
footer: {
|
||||
text: `Hack Slug: ${data.slug}`,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error sending Discord webhook:", error);
|
||||
return { error: "Failed to submit report. Please try again later." };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react";
|
||||
import ReportModal from "@/components/Hack/ReportModal";
|
||||
|
||||
interface HackOptionsMenuProps {
|
||||
slug: string;
|
||||
|
|
@ -17,81 +18,88 @@ export default function HackOptionsMenu({
|
|||
canUploadPatch,
|
||||
children,
|
||||
}: HackOptionsMenuProps) {
|
||||
return (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton
|
||||
aria-label="More options"
|
||||
title="Options"
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
|
||||
>
|
||||
<FiMoreVertical size={18} />
|
||||
</MenuButton>
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-40 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const url = window.location.href;
|
||||
const title = document?.title || "Check this out";
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title, url });
|
||||
} else {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert("Link copied to clipboard");
|
||||
return (
|
||||
<>
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton
|
||||
aria-label="More options"
|
||||
title="Options"
|
||||
className="group inline-flex h-8 w-8 items-center justify-center rounded-md ring-1 ring-[var(--border)] bg-[var(--surface-2)] text-foreground/80 hover:bg-[var(--surface-3)] hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--border)]"
|
||||
>
|
||||
<FiMoreVertical size={18} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
className="absolute right-0 z-10 mt-2 w-40 origin-top-right overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] backdrop-blur-lg shadow-lg focus:outline-none transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in"
|
||||
>
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const url = window.location.href;
|
||||
const title = document?.title || "Check this out";
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title, url });
|
||||
} else {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert("Link copied to clipboard");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore if user cancels share; otherwise log
|
||||
if (!(e instanceof Error) || e.name !== "AbortError") {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore if user cancels share; otherwise log
|
||||
if (!(e instanceof Error) || e.name !== "AbortError") {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Share
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
as="button"
|
||||
onClick={() => {
|
||||
// TODO: Implement report
|
||||
}}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Report
|
||||
</MenuItem>
|
||||
{canEdit && <>
|
||||
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${slug}/stats`}
|
||||
}}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Stats
|
||||
Share
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${slug}/edit`}
|
||||
as="button"
|
||||
onClick={() => {
|
||||
setShowReportModal(true);
|
||||
}}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Edit
|
||||
Report
|
||||
</MenuItem>
|
||||
</>}
|
||||
{canUploadPatch && <>
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${slug}/edit/patch`}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Upload new version
|
||||
</MenuItem>
|
||||
</>}
|
||||
{children && <>
|
||||
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
|
||||
{children}
|
||||
</>}
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
{canEdit && <>
|
||||
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${slug}/stats`}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Stats
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${slug}/edit`}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Edit
|
||||
</MenuItem>
|
||||
</>}
|
||||
{canUploadPatch && <>
|
||||
<MenuItem
|
||||
as="a"
|
||||
href={`/hack/${slug}/edit/patch`}
|
||||
className="block w-full px-3 py-2 text-left text-sm data-focus:bg-black/5 dark:data-focus:bg-white/10">
|
||||
Upload new version
|
||||
</MenuItem>
|
||||
</>}
|
||||
{children && <>
|
||||
<MenuSeparator className="my-1 h-px bg-[var(--border)]" />
|
||||
{children}
|
||||
</>}
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
{showReportModal && (
|
||||
<ReportModal slug={slug} onClose={() => setShowReportModal(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
325
src/components/Hack/ReportModal.tsx
Normal file
325
src/components/Hack/ReportModal.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FiX } from "react-icons/fi";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { submitHackReport } from "@/app/hack/[slug]/actions";
|
||||
|
||||
type ReportType = "hateful" | "harassment" | "misleading" | "stolen";
|
||||
|
||||
interface ReportModalProps {
|
||||
slug: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ReportModal: React.FC<ReportModalProps> = ({ slug, onClose }) => {
|
||||
const [currentPage, setCurrentPage] = useState<"select" | "details" | "success">("select");
|
||||
const [reportType, setReportType] = useState<ReportType | null>(null);
|
||||
const [details, setDetails] = useState<string>("");
|
||||
const [email, setEmail] = useState<string>("");
|
||||
const [isImpersonating, setIsImpersonating] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
const previousHtmlOverflow = html.style.overflow;
|
||||
const previousBodyOverflow = body.style.overflow;
|
||||
const previousBodyPaddingRight = body.style.paddingRight;
|
||||
const scrollBarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
html.style.overflow = "hidden";
|
||||
body.style.overflow = "hidden";
|
||||
if (scrollBarWidth > 0) {
|
||||
body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
}
|
||||
|
||||
return () => {
|
||||
html.style.overflow = previousHtmlOverflow;
|
||||
body.style.overflow = previousBodyOverflow;
|
||||
body.style.paddingRight = previousBodyPaddingRight;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const canSubmit = () => {
|
||||
if (!reportType) return false;
|
||||
|
||||
if (reportType === "stolen") {
|
||||
// Stolen requires email and details
|
||||
if (!email.trim() || !details.trim()) return false;
|
||||
// Basic email validation (matches server-side validation pattern)
|
||||
const emailRegex = /^(?!\.)(?!.*\.\.)([a-z0-9_'+\-\.]*)[a-z0-9_'+\-]@([a-z0-9][a-z0-9\-]*\.)+[a-z]{2,}$/;
|
||||
if (!emailRegex.test(email.trim().toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (reportType === "misleading") {
|
||||
// Misleading requires details
|
||||
return details.trim().length > 0;
|
||||
}
|
||||
|
||||
// Hateful and Harassment are optional, so can always submit
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit() || !reportType) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await submitHackReport({
|
||||
slug,
|
||||
reportType,
|
||||
details: details.trim() || null,
|
||||
email: reportType === "stolen" ? email.trim() : null,
|
||||
isImpersonating: reportType === "stolen" ? isImpersonating : null,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
setIsSubmitting(false);
|
||||
} else {
|
||||
setCurrentPage("success");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setError("Failed to submit report. Please try again.");
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectPage = () => (
|
||||
<div className="flex flex-col gap-8 sm:gap-4">
|
||||
<div className="mb-2">
|
||||
<div className="text-xl font-semibold">Report Hack</div>
|
||||
<p className="mt-1 text-sm text-foreground/80">
|
||||
Please select the reason for reporting this hack.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setReportType("hateful");
|
||||
setCurrentPage("details");
|
||||
}}
|
||||
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
Hateful content
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setReportType("harassment");
|
||||
setCurrentPage("details");
|
||||
}}
|
||||
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
Harassment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setReportType("misleading");
|
||||
setCurrentPage("details");
|
||||
}}
|
||||
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
Misleading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setReportType("stolen");
|
||||
setCurrentPage("details");
|
||||
}}
|
||||
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
My hack was stolen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSuccessPage = () => (
|
||||
<div className="flex flex-col gap-6 sm:gap-4">
|
||||
<div className="mb-2">
|
||||
<div className="text-xl font-semibold">Report Submitted</div>
|
||||
<p className="mt-1 text-sm text-foreground/80">
|
||||
Thank you for your report. We will review it and take appropriate action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<p className="text-green-500 font-semibold flex items-center justify-center gap-2">
|
||||
<FaCircleCheck size={18} />
|
||||
Report successfully sent!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center h-14 sm:h-11 w-full text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] enabled:hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailsPage = () => {
|
||||
const isStolen = reportType === "stolen";
|
||||
const isMisleading = reportType === "misleading";
|
||||
const requiresDetails = isStolen || isMisleading;
|
||||
const isOptional = reportType === "hateful" || reportType === "harassment";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-4">
|
||||
<div className="mb-2">
|
||||
<div className="text-xl font-semibold">
|
||||
{reportType === "hateful" && "Hateful Content"}
|
||||
{reportType === "harassment" && "Harassment"}
|
||||
{reportType === "misleading" && "Misleading"}
|
||||
{reportType === "stolen" && "My Hack Was Stolen"}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-foreground/80">
|
||||
{isStolen
|
||||
? "Please provide your contact information and details about the stolen hack."
|
||||
: isOptional
|
||||
? "Please provide additional details (optional)."
|
||||
: "Please provide additional details."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isStolen && (
|
||||
<>
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isImpersonating}
|
||||
onChange={(e) => setIsImpersonating(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm font-semibold">Is the uploader impersonating you?</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold mb-2 block">
|
||||
Contact Email <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold mb-2 block">
|
||||
Additional Details {requiresDetails && <span className="text-red-500">*</span>}
|
||||
{isOptional && <span className="text-foreground/60 text-xs ml-1">(optional)</span>}
|
||||
</label>
|
||||
<textarea
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
placeholder={
|
||||
isStolen
|
||||
? "Please provide important context, proof of ownership, and any other relevant information..."
|
||||
: "Please provide additional context..."
|
||||
}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 rounded-md border border-[var(--border)] bg-[var(--surface-1)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isStolen && (
|
||||
<div className="p-4 rounded-md bg-[var(--surface-2)] border border-[var(--border)]">
|
||||
<p className="text-sm text-foreground/90">
|
||||
<strong>Note:</strong> We will reach out to you via email. Please ensure you have sufficient proof that you are the original creator of this hack.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20">
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit() || isSubmitting}
|
||||
className="inline-flex items-center justify-center h-14 sm:h-11 w-full text-sm font-semibold rounded-md bg-[var(--accent)] text-[var(--accent-foreground)] enabled:hover:bg-[var(--accent-700)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Submit Report"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCurrentPage("select");
|
||||
setReportType(null);
|
||||
setDetails("");
|
||||
setEmail("");
|
||||
setIsImpersonating(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="inline-flex h-14 sm:h-11 w-full items-center justify-center rounded-md px-4 text-sm font-semibold ring-1 ring-[var(--border)] hover:bg-[var(--surface-2)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 right-0 top-0 bottom-0 z-[100] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 dark:bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={
|
||||
currentPage === "select"
|
||||
? "Select report type"
|
||||
: currentPage === "success"
|
||||
? "Report submitted"
|
||||
: "Report details"
|
||||
}
|
||||
className="relative z-[101] mb-16 card backdrop-blur-lg dark:!bg-black/70 p-6 max-w-md max-h-[85vh] overflow-y-auto w-full rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close report modal"
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md text-foreground/60 hover:text-foreground hover:bg-[var(--surface-2)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<FiX size={20} />
|
||||
</button>
|
||||
{currentPage === "select"
|
||||
? renderSelectPage()
|
||||
: currentPage === "success"
|
||||
? renderSuccessPage()
|
||||
: renderDetailsPage()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportModal;
|
||||
Loading…
Reference in New Issue
Block a user