Compare commits
2 Commits
v0.1.81
...
7fac3c3aa8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fac3c3aa8 | |||
| bff3aad1ca |
@@ -98,6 +98,7 @@ export default async function AdminBillingPage() {
|
|||||||
<div className="animate-in animate-in-delay-3">
|
<div className="animate-in animate-in-delay-3">
|
||||||
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
<h2 className="text-lg font-semibold mb-3">{t("balancesTitle")}</h2>
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -126,6 +127,7 @@ export default async function AdminBillingPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import { listTenants } from "@/lib/k8s";
|
|||||||
import { countPendingSkillActivationRequests } from "@/lib/db";
|
import { countPendingSkillActivationRequests } from "@/lib/db";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("admin") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import { RunningTotalWidget } from "@/components/billing/running-total-widget";
|
|||||||
* Anyone signed in can view this. The data is org-scoped; even
|
* Anyone signed in can view this. The data is org-scoped; even
|
||||||
* non-owner team members see the same view.
|
* non-owner team members see the same view.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("billing") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CustomerBillingPage() {
|
export default async function CustomerBillingPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ import { ProvisioningStatus } from "@/components/onboarding/provisioning-status"
|
|||||||
import { formatDateTime } from "@/lib/format";
|
import { formatDateTime } from "@/lib/format";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("dashboard") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
72
src/app/[locale]/error.tsx
Normal file
72
src/app/[locale]/error.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary for the [locale] segment. Catches render/data errors
|
||||||
|
* thrown by any page below the locale layout (which is where K8s, DB,
|
||||||
|
* LiteLLM and Stripe calls happen). Renders inside NextIntlClientProvider,
|
||||||
|
* so translations are available. Root-layout failures fall through to
|
||||||
|
* global-error.tsx instead.
|
||||||
|
*/
|
||||||
|
export default function LocaleError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("errors");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Surface the error for log scraping; the digest correlates with
|
||||||
|
// the server-side stack in production.
|
||||||
|
console.error("Portal error boundary:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-xl bg-error/10">
|
||||||
|
<svg
|
||||||
|
className="h-7 w-7 text-error"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 9v4M12 17h.01M10.3 3.86l-8.5 14.7A1.5 1.5 0 003.1 21h17.8a1.5 1.5 0 001.3-2.44l-8.5-14.7a1.5 1.5 0 00-2.6 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||||
|
{t("title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mb-6">{t("description")}</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p className="text-[11px] font-mono text-text-muted mb-6">
|
||||||
|
{error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{t("retry")}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="py-2 px-4 rounded-lg border border-border text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
{t("backToDashboard")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,36 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages, getTranslations } from "next-intl/server";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import { NavShell } from "@/components/layout/nav-shell";
|
import { NavShell } from "@/components/layout/nav-shell";
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return routing.locales.map((locale) => ({ locale }));
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Metadata API (Next 15) instead of a hand-rolled <head>. The title
|
||||||
|
// template lets each page export a short `title` (e.g. "Dashboard")
|
||||||
|
// that renders as "Dashboard · PieCed". Pages that export no metadata
|
||||||
|
// fall back to the default below.
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
const appName = t("appName");
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
default: `${appName} Portal`,
|
||||||
|
template: `%s · ${appName}`,
|
||||||
|
},
|
||||||
|
description: "PieCed IT — Multi-tenant AI assistant platform",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
export default async function LocaleLayout({
|
export default async function LocaleLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
@@ -22,20 +45,13 @@ export default async function LocaleLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} className="dark">
|
<html lang={locale} className="dark">
|
||||||
<head>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>PieCed Portal</title>
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="PieCed IT — Multi-tenant AI assistant platform"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
<body className="min-h-screen bg-surface-0 text-text-primary antialiased">
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
<NavShell>{children}</NavShell>
|
<NavShell session={session}>{children}</NavShell>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
25
src/app/[locale]/loading.tsx
Normal file
25
src/app/[locale]/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Loading skeleton for the [locale] segment. Shown during navigation
|
||||||
|
* while a server component fetches (the dashboard, for instance, does
|
||||||
|
* listTenants() + one K8s GET per provisioning row). Textless on
|
||||||
|
* purpose so it needs no translations and adds no layout shift.
|
||||||
|
*/
|
||||||
|
export default function LocaleLoading() {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse" aria-hidden="true">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="h-7 w-48 rounded-md bg-surface-2" />
|
||||||
|
<div className="mt-4 h-4 w-72 rounded bg-surface-1" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-28 rounded-xl border border-border bg-surface-1"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="sr-only">Loading…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/[locale]/not-found.tsx
Normal file
34
src/app/[locale]/not-found.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Link } from "@/i18n/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 for the [locale] segment. Triggered by notFound() calls in pages
|
||||||
|
* below the locale layout. (A notFound() thrown by the locale layout
|
||||||
|
* itself — e.g. an unknown locale — resolves to the framework default,
|
||||||
|
* which is acceptable for that narrow case.)
|
||||||
|
*/
|
||||||
|
export default async function LocaleNotFound() {
|
||||||
|
const t = await getTranslations("errors");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] items-center justify-center px-5">
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<div className="font-display text-5xl font-semibold text-accent mb-4 tabular-nums">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-xl font-semibold text-text-primary mb-2">
|
||||||
|
{t("notFoundTitle")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-text-secondary mb-6">
|
||||||
|
{t("notFoundDescription")}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="inline-flex py-2 px-4 rounded-lg bg-accent text-surface-0 text-sm font-medium hover:bg-accent-dim transition-colors"
|
||||||
|
>
|
||||||
|
{t("backToDashboard")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, forwardRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter, Link } from "@/i18n/navigation";
|
import { useRouter, Link } from "@/i18n/navigation";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@@ -50,6 +50,30 @@ export default function RegisterPage() {
|
|||||||
const [state, setState] = useState<FormState>("idle");
|
const [state, setState] = useState<FormState>("idle");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
// Radiogroup keyboard support. `role="radio"` requires roving
|
||||||
|
// tabindex (one tab stop) + arrow-key navigation between options —
|
||||||
|
// native buttons don't move focus on arrows. The selected card is
|
||||||
|
// the tab stop; when nothing is selected yet the first card is
|
||||||
|
// focusable so keyboard users can enter the group.
|
||||||
|
const TYPES: AccountType[] = ["personal", "company"];
|
||||||
|
const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||||
|
|
||||||
|
const rovingTabIndex = (type: AccountType, index: number) =>
|
||||||
|
accountType === type || (accountType === null && index === 0) ? 0 : -1;
|
||||||
|
|
||||||
|
const handleCardKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||||
|
let next: number | null = null;
|
||||||
|
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||||
|
next = (index + 1) % TYPES.length;
|
||||||
|
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||||
|
next = (index - 1 + TYPES.length) % TYPES.length;
|
||||||
|
}
|
||||||
|
if (next === null) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setAccountType(TYPES[next]);
|
||||||
|
cardRefs.current[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
const isPersonal = accountType === "personal";
|
const isPersonal = accountType === "personal";
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -146,8 +170,13 @@ export default function RegisterPage() {
|
|||||||
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
className="grid grid-cols-2 gap-3 mb-6 animate-in animate-in-delay-1"
|
||||||
>
|
>
|
||||||
<AccountTypeCard
|
<AccountTypeCard
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[0] = el;
|
||||||
|
}}
|
||||||
selected={accountType === "personal"}
|
selected={accountType === "personal"}
|
||||||
onClick={() => setAccountType("personal")}
|
onClick={() => setAccountType("personal")}
|
||||||
|
tabIndex={rovingTabIndex("personal", 0)}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(e, 0)}
|
||||||
label={t("personalCardTitle")}
|
label={t("personalCardTitle")}
|
||||||
description={t("personalCardDescription")}
|
description={t("personalCardDescription")}
|
||||||
icon={
|
icon={
|
||||||
@@ -168,8 +197,13 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AccountTypeCard
|
<AccountTypeCard
|
||||||
|
ref={(el) => {
|
||||||
|
cardRefs.current[1] = el;
|
||||||
|
}}
|
||||||
selected={accountType === "company"}
|
selected={accountType === "company"}
|
||||||
onClick={() => setAccountType("company")}
|
onClick={() => setAccountType("company")}
|
||||||
|
tabIndex={rovingTabIndex("company", 1)}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(e, 1)}
|
||||||
label={t("companyCardTitle")}
|
label={t("companyCardTitle")}
|
||||||
description={t("companyCardDescription")}
|
description={t("companyCardDescription")}
|
||||||
icon={
|
icon={
|
||||||
@@ -305,41 +339,42 @@ export default function RegisterPage() {
|
|||||||
* and text colours intensify when selected to give a clear "this one
|
* and text colours intensify when selected to give a clear "this one
|
||||||
* is on" signal beyond just the border colour.
|
* is on" signal beyond just the border colour.
|
||||||
*/
|
*/
|
||||||
function AccountTypeCard({
|
const AccountTypeCard = forwardRef<
|
||||||
selected,
|
HTMLButtonElement,
|
||||||
onClick,
|
{
|
||||||
label,
|
selected: boolean;
|
||||||
description,
|
onClick: () => void;
|
||||||
icon,
|
label: string;
|
||||||
}: {
|
description: string;
|
||||||
selected: boolean;
|
icon: React.ReactNode;
|
||||||
onClick: () => void;
|
tabIndex: number;
|
||||||
label: string;
|
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||||
description: string;
|
}
|
||||||
icon: React.ReactNode;
|
>(function AccountTypeCard(
|
||||||
}) {
|
{ selected, onClick, label, description, icon, tabIndex, onKeyDown },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
|
tabIndex={tabIndex}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
className={`text-left rounded-xl border p-4 transition-colors cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/40 ${
|
||||||
selected
|
selected
|
||||||
? "border-accent bg-accent/10"
|
? "border-accent bg-accent/10"
|
||||||
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
: "border-border bg-surface-2 hover:border-accent/40 hover:bg-surface-3/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className={`mb-2 ${selected ? "text-accent" : "text-text-muted"}`}>
|
||||||
className={`mb-2 ${
|
|
||||||
selected ? "text-accent" : "text-text-muted"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`text-sm font-semibold mb-0.5 ${
|
className={`text-sm font-semibold mb-0.5 ${
|
||||||
selected ? "text-text-primary" : "text-text-primary"
|
selected ? "text-text-primary" : "text-text-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -347,4 +382,4 @@ function AccountTypeCard({
|
|||||||
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
<div className="text-xs text-text-muted leading-snug">{description}</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import { Card } from "@/components/ui/card";
|
|||||||
* Access: any authenticated user (the cards themselves gate further;
|
* Access: any authenticated user (the cards themselves gate further;
|
||||||
* non-owner users would not see "Billing" as actionable, etc.).
|
* non-owner users would not see "Billing" as actionable, etc.).
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("settings") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import { TicketCategoryLabel } from "@/components/support/ticket-category-label"
|
|||||||
* having recent activity, but we don't sort by status; that's a
|
* having recent activity, but we don't sort by status; that's a
|
||||||
* filter the admin can add later if the queue grows.
|
* filter the admin can add later if the queue grows.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("support") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function SupportListPage() {
|
export default async function SupportListPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import { InviteForm } from "@/components/team/invite-form";
|
|||||||
* `<TeamList>` and `<InviteForm>` client components handle live
|
* `<TeamList>` and `<InviteForm>` client components handle live
|
||||||
* updates after invites and refreshes.
|
* updates after invites and refreshes.
|
||||||
*/
|
*/
|
||||||
|
export async function generateMetadata() {
|
||||||
|
const t = await getTranslations("common");
|
||||||
|
return { title: t("team") };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function TeamPage() {
|
export default async function TeamPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) redirect("/login");
|
if (!user) redirect("/login");
|
||||||
|
|||||||
78
src/app/global-error.tsx
Normal file
78
src/app/global-error.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort boundary for errors thrown in the root layout itself
|
||||||
|
* (before the locale layout / intl provider mount). It replaces the
|
||||||
|
* entire document, so it must render its own <html>/<body> and cannot
|
||||||
|
* use translations or rely on the app stylesheet being applied — styles
|
||||||
|
* are inlined with the palette's hex values so it renders correctly in
|
||||||
|
* isolation. Everything below the locale layout is handled by
|
||||||
|
* [locale]/error.tsx instead; this should almost never be seen.
|
||||||
|
*/
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error("Portal global error:", error);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
minHeight: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "#0a0c10",
|
||||||
|
color: "#e8ecf4",
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
padding: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: "28rem", textAlign: "center" }}>
|
||||||
|
<h1 style={{ fontSize: "1.25rem", fontWeight: 600, margin: "0 0 0.5rem" }}>
|
||||||
|
Something went wrong
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: "0.875rem", color: "#8892a4", margin: "0 0 1.5rem" }}>
|
||||||
|
An unexpected error occurred. Please try again.
|
||||||
|
</p>
|
||||||
|
{error?.digest && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "11px",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
color: "#565e6e",
|
||||||
|
margin: "0 0 1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error.digest}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
border: "none",
|
||||||
|
background: "#00d4aa",
|
||||||
|
color: "#0a0c10",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -336,6 +336,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
<CardHeader>{t("editorLinesHeading")}</CardHeader>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -420,6 +421,7 @@ export function CustomInvoiceEditor({ draft, orgBilling }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2 mt-3">
|
<div className="flex gap-2 mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={addLine}
|
onClick={addLine}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
|||||||
{t("newInvoiceBtn")}
|
{t("newInvoiceBtn")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -140,6 +141,7 @@ export function DraftList({ drafts, orgNameMap }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -323,6 +324,7 @@ function DraftPreview({ draft }: { draft: InvoiceDraft }) {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
{creditNotes.length > 0 && (
|
{creditNotes.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
<CardHeader>{t("creditNotesPanelTitle")}</CardHeader>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -518,12 +519,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lines */}
|
{/* Lines */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
<CardHeader>{t("lineItemsTitle")}</CardHeader>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -572,6 +575,7 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
<div className="mt-4 pt-3 border-t border-border space-y-1 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-text-muted">{t("subtotal")}</span>
|
<span className="text-text-muted">{t("subtotal")}</span>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
{t("noInvoicesFound")}
|
{t("noInvoicesFound")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -178,6 +179,7 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -153,6 +154,7 @@ export function OrgPaymentModeList({ orgs }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export function PricingEditor({
|
|||||||
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
<p className="text-sm text-text-muted mb-4">{t("skillPricingDesc")}</p>
|
||||||
|
|
||||||
{initialSkillPricing.length > 0 ? (
|
{initialSkillPricing.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm mb-6">
|
<table className="w-full text-sm mb-6">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -319,6 +320,7 @@ export function PricingEditor({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
<p className="text-sm text-text-muted italic mb-4">{t("noSkillsPriced")}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|||||||
{t("noRunsYet")}
|
{t("noRunsYet")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -241,6 +242,7 @@ export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -199,6 +200,7 @@ export function PendingSkillRequests({ initialRows }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -96,6 +97,7 @@ export function CustomerCreditNoteList({ creditNotes }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -160,6 +161,7 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-xs text-text-muted text-left">
|
<thead className="text-xs text-text-muted text-left">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -104,6 +105,7 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
|||||||
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
||||||
{t("breakdownToggle", { count: draft.lines.length })}
|
{t("breakdownToggle", { count: draft.lines.length })}
|
||||||
</summary>
|
</summary>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full mt-2 text-xs">
|
<table className="w-full mt-2 text-xs">
|
||||||
<tbody>
|
<tbody>
|
||||||
{draft.lines.map((ln, i) => (
|
{draft.lines.map((ln, i) => (
|
||||||
@@ -188,6 +189,7 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { signOut, useSession } from "next-auth/react";
|
|||||||
import { usePathname } from "@/i18n/navigation";
|
import { usePathname } from "@/i18n/navigation";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
import { LanguageSwitcher } from "@/components/ui/language-switcher";
|
||||||
|
|
||||||
function NavBar() {
|
function NavBar() {
|
||||||
@@ -211,9 +212,19 @@ function NavLink({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavShell({ children }: { children: React.ReactNode }) {
|
export function NavShell({
|
||||||
|
children,
|
||||||
|
session,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
// Server-resolved session passed down from the locale layout. Seeding
|
||||||
|
// SessionProvider with it means useSession() is populated on the first
|
||||||
|
// client render, so the nav links render immediately instead of
|
||||||
|
// popping in after the client-side session fetch (CLS / flash).
|
||||||
|
session: Session | null;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider session={session}>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
<main className="mx-auto max-w-6xl px-5 py-8">{children}</main>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ interface Props {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FOCUSABLE =
|
||||||
|
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portal-based modal.
|
* Portal-based modal.
|
||||||
*
|
*
|
||||||
@@ -25,45 +28,86 @@ interface Props {
|
|||||||
* ancestor's containing block, not the viewport, when ANY ancestor
|
* ancestor's containing block, not the viewport, when ANY ancestor
|
||||||
* has a `transform`, `perspective`, or `filter` applied. Our
|
* has a `transform`, `perspective`, or `filter` applied. Our
|
||||||
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
* `animate-in` utility sets `transform: translateY(0)` on a lot of
|
||||||
* dashboard/tenant-detail containers (because of the fade-up
|
* dashboard/tenant-detail containers, which broke modals rendered as
|
||||||
* animation, which uses `animation-fill-mode: both` to keep the
|
* in-place children — they centred to the panel they lived in, not to
|
||||||
* transform on after the animation finishes). That broke modals
|
* the page. Rendering at `document.body` via `createPortal` escapes
|
||||||
* rendered as in-place children — they centred to the panel they
|
* every containing-block ancestor and gives us true viewport coords.
|
||||||
* lived in, not to the page.
|
|
||||||
*
|
*
|
||||||
* Rendering at `document.body` via `createPortal` escapes every
|
* UX / a11y details
|
||||||
* containing-block ancestor and gives us true viewport coordinates.
|
* -----------------
|
||||||
*
|
* - Backdrop click triggers `onClose` (only when the click target IS
|
||||||
* UX details
|
* the backdrop, not the panel inside).
|
||||||
* ----------
|
* - Escape triggers `onClose`.
|
||||||
* - Backdrop click triggers `onClose`. (Bubbling check: only fires
|
* - `body` overflow is locked while open so background content doesn't
|
||||||
* when the click target IS the backdrop, not the panel inside.)
|
* scroll behind the modal.
|
||||||
* - Escape key triggers `onClose`. Standard modal expectation.
|
* - Focus is moved into the panel on open, trapped within it while open
|
||||||
* - `body` overflow is locked while open so background content
|
* (Tab / Shift+Tab cycle), and restored to the previously focused
|
||||||
* doesn't scroll behind the modal.
|
* element on close — so keyboard and screen-reader users can't tab
|
||||||
* - Renders nothing on first paint server-side, then mounts on
|
* out to the inert page behind the dialog.
|
||||||
* 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) {
|
export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
||||||
const closeRef = useRef(onClose);
|
const closeRef = useRef(onClose);
|
||||||
closeRef.current = onClose;
|
closeRef.current = onClose;
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
||||||
// Lock background scroll. Restore on unmount/close.
|
// Remember what had focus so we can restore it on close.
|
||||||
|
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
// Lock background scroll.
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
|
|
||||||
|
// Move focus into the dialog — first focusable element, else the
|
||||||
|
// panel itself (it carries tabIndex={-1}).
|
||||||
|
const panel = panelRef.current;
|
||||||
|
const focusables = panel
|
||||||
|
? Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
|
||||||
|
: [];
|
||||||
|
(focusables[0] ?? panel)?.focus();
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") closeRef.current();
|
if (e.key === "Escape") {
|
||||||
|
closeRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab" || !panel) return;
|
||||||
|
|
||||||
|
// Re-query each time — modal content can change between tabs.
|
||||||
|
const items = Array.from(
|
||||||
|
panel.querySelectorAll<HTMLElement>(FOCUSABLE)
|
||||||
|
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
panel.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (active === first || active === panel) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
}
|
||||||
|
} else if (active === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow;
|
document.body.style.overflow = previousOverflow;
|
||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
|
// Restore focus to the trigger (if it's still in the document).
|
||||||
|
if (previouslyFocused && document.contains(previouslyFocused)) {
|
||||||
|
previouslyFocused.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
@@ -72,15 +116,19 @@ export function Modal({ open, onClose, children, ariaLabel }: Props) {
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<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"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
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">
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="bg-surface-1 border border-border rounded-xl p-6 max-w-md w-full max-h-[90vh] overflow-y-auto focus:outline-none"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -964,5 +964,13 @@
|
|||||||
"saving": "Speichern…",
|
"saving": "Speichern…",
|
||||||
"saved": "Gespeichert.",
|
"saved": "Gespeichert.",
|
||||||
"missingRequired": "Vor- und Nachname sind erforderlich."
|
"missingRequired": "Vor- und Nachname sind erforderlich."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Etwas ist schiefgelaufen",
|
||||||
|
"description": "Beim Laden dieser Seite ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
|
"notFoundTitle": "Seite nicht gefunden",
|
||||||
|
"notFoundDescription": "Die angeforderte Seite existiert nicht oder wurde verschoben."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -964,5 +964,13 @@
|
|||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saved": "Saved.",
|
"saved": "Saved.",
|
||||||
"missingRequired": "First and last name are required."
|
"missingRequired": "First and last name are required."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Something went wrong",
|
||||||
|
"description": "An error occurred while loading this page. Please try again.",
|
||||||
|
"retry": "Try again",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
|
"notFoundTitle": "Page not found",
|
||||||
|
"notFoundDescription": "The page you're looking for doesn't exist or has moved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -964,5 +964,13 @@
|
|||||||
"saving": "Enregistrement…",
|
"saving": "Enregistrement…",
|
||||||
"saved": "Enregistré.",
|
"saved": "Enregistré.",
|
||||||
"missingRequired": "Le prénom et le nom sont obligatoires."
|
"missingRequired": "Le prénom et le nom sont obligatoires."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Une erreur est survenue",
|
||||||
|
"description": "Une erreur s'est produite lors du chargement de cette page. Veuillez réessayer.",
|
||||||
|
"retry": "Réessayer",
|
||||||
|
"backToDashboard": "Retour au tableau de bord",
|
||||||
|
"notFoundTitle": "Page introuvable",
|
||||||
|
"notFoundDescription": "La page que vous recherchez n'existe pas ou a été déplacée."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -964,5 +964,13 @@
|
|||||||
"saving": "Salvataggio…",
|
"saving": "Salvataggio…",
|
||||||
"saved": "Salvato.",
|
"saved": "Salvato.",
|
||||||
"missingRequired": "Nome e cognome sono obbligatori."
|
"missingRequired": "Nome e cognome sono obbligatori."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Si è verificato un errore",
|
||||||
|
"description": "Si è verificato un errore durante il caricamento di questa pagina. Riprova.",
|
||||||
|
"retry": "Riprova",
|
||||||
|
"backToDashboard": "Torna alla dashboard",
|
||||||
|
"notFoundTitle": "Pagina non trovata",
|
||||||
|
"notFoundDescription": "La pagina che stai cercando non esiste o è stata spostata."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user