This commit is contained in:
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { formatDateTime, formatRelative } from "@/lib/format";
|
||||
|
||||
@@ -250,43 +251,38 @@ export function ProvisioningStatus({ requestId, canAct }: Props) {
|
||||
</div>
|
||||
|
||||
{confirmCancel && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setConfirmCancel(false);
|
||||
}}
|
||||
<Modal
|
||||
open={confirmCancel}
|
||||
onClose={() => setConfirmCancel(false)}
|
||||
ariaLabel={t("cancelConfirmRequestTitle")}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmRequestTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("cancelConfirmRequestDescription")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending
|
||||
? tCommon("loading")
|
||||
: t("cancelRequestConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmRequestTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-5">
|
||||
{t("cancelConfirmRequestDescription")}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={actionPending}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{actionPending
|
||||
? tCommon("loading")
|
||||
: t("cancelRequestConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
interface Props {
|
||||
tenantName: string;
|
||||
@@ -102,55 +103,50 @@ export function SubscriptionToggle({ tenantName, suspended }: Props) {
|
||||
)}
|
||||
|
||||
{confirmOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setConfirmOpen(false);
|
||||
}}
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
ariaLabel={t("cancelConfirmTitle")}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full">
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{t("cancelConfirmDescription")}
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
|
||||
<li>{t("cancelConfirmBullet1")}</li>
|
||||
<li>{t("cancelConfirmBullet2")}</li>
|
||||
<li>{t("cancelConfirmBullet3")}</li>
|
||||
</ul>
|
||||
<h3 className="font-display text-lg font-semibold text-text-primary mb-2">
|
||||
{t("cancelConfirmTitle")}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{t("cancelConfirmDescription")}
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted list-disc list-inside space-y-1 mb-5">
|
||||
<li>{t("cancelConfirmBullet1")}</li>
|
||||
<li>{t("cancelConfirmBullet2")}</li>
|
||||
<li>{t("cancelConfirmBullet3")}</li>
|
||||
</ul>
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSuspend(true)}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
: t("cancelSubscriptionConfirm")}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded-lg px-3 py-2 mb-3">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg border border-border text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSuspend(true)}
|
||||
disabled={submitting}
|
||||
className="text-sm px-4 py-2 rounded-lg bg-amber-500 text-white hover:bg-amber-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{submitting
|
||||
? tCommon("loading")
|
||||
: t("cancelSubscriptionConfirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
89
src/components/ui/modal.tsx
Normal file
89
src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Called when user clicks the backdrop or presses Escape. */
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* ARIA label fallback when no labelled element exists inside.
|
||||
* Optional; if you have a heading inside the modal with id, set
|
||||
* `aria-labelledby` on a wrapper instead.
|
||||
*/
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal-based modal.
|
||||
*
|
||||
* Why a portal
|
||||
* ------------
|
||||
* `position: fixed` becomes positioned relative to a transformed
|
||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||
* dashboard/tenant-detail containers (because of the fade-up
|
||||
* animation, which uses `animation-fill-mode: both` to keep the
|
||||
* transform on after the animation finishes). That broke modals
|
||||
* rendered as in-place children — they centred to the panel they
|
||||
* lived in, not to the page.
|
||||
*
|
||||
* Rendering at `document.body` via `createPortal` escapes every
|
||||
* containing-block ancestor and gives us true viewport coordinates.
|
||||
*
|
||||
* UX details
|
||||
* ----------
|
||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
||||
* when the click target IS the backdrop, not the panel inside.)
|
||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
||||
* - `body` overflow is locked while open so background content
|
||||
* doesn't scroll behind the modal.
|
||||
* - Renders nothing on first paint server-side, then mounts on
|
||||
* client. `useEffect` gating ensures `document.body` is available;
|
||||
* without it Next.js SSR would throw on `document` reference.
|
||||
*/
|
||||
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||
const closeRef = useRef(onClose);
|
||||
closeRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
// Lock background scroll. Restore on unmount/close.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user