feat: dark mode toggle, copy-to-clipboard order IDs, accessibility
- Dark mode toggle (●/○) in desktop + mobile header nav - ThemeProvider wired in layout.tsx (class-based, light default) - Header scroll-glass uses CSS color-mix for dark-mode correctness - PortalShell main gets id="main-content" for skip-link target - Portal order table: copy order ID to clipboard with toast confirmation - Admin page: toast success/error on status advance - Skip-to-content link in layout.tsx (sr-only, visible on keyboard focus) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -4,6 +4,7 @@ import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { Toaster } from "sonner";
|
||||
import { LocaleProvider } from "@/components/locale-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
@@ -26,7 +27,9 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<LocaleProvider>{children}</LocaleProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
<LocaleProvider>{children}</LocaleProvider>
|
||||
</ThemeProvider>
|
||||
<Toaster position="bottom-right" toastOptions={{ classNames: { toast: "font-mono text-xs tracking-[0.06em] rounded-none border border-border bg-background text-foreground", title: "font-mono", description: "font-mono text-muted-foreground" } }} />
|
||||
<Analytics />
|
||||
</body>
|
||||
|
||||
+25
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Header } from "@/components/header";
|
||||
import { EmptyState, ErrorState, LoadingSkeleton } from "@/components/states";
|
||||
import { useT } from "@/components/locale-provider";
|
||||
@@ -208,8 +209,9 @@ function Content({
|
||||
href={`/order/${o.order_id}`}
|
||||
className="group grid grid-cols-[1.4fr_1.6fr_1fr_1fr_0.9fr] items-center border-b border-border px-5 py-4 transition-colors last:border-b-0 hover:bg-secondary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
>
|
||||
<span className="font-mono text-[12px] tabular-nums tracking-tight">
|
||||
<span className="flex items-center gap-1.5 font-mono text-[12px] tabular-nums tracking-tight">
|
||||
{shortId(o.order_id)}
|
||||
<CopyId id={o.order_id} />
|
||||
</span>
|
||||
<span className="truncate pr-4 text-[15px] tracking-[-0.01em]">{o.flow}</span>
|
||||
<span className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
@@ -268,6 +270,28 @@ function Stat({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CopyId({ id }: { id: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Copy order ID"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(id).then(() => {
|
||||
setCopied(true);
|
||||
toast.success(`Copied ${id.slice(0, 8)}…`);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}}
|
||||
className="text-foreground/20 transition-colors hover:text-foreground/60"
|
||||
>
|
||||
{copied ? "✓" : "⧉"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Square provenance-coloured state marker — no red here (reserved for the EmptyState CTA). */
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const color = DELIVERED_STATES.has(status)
|
||||
|
||||
+25
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useT } from "@/components/locale-provider";
|
||||
import { LocaleSwitcher } from "@/components/locale-switcher";
|
||||
|
||||
@@ -26,6 +27,24 @@ function Wordmark() {
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return <span className="h-9 w-9" />;
|
||||
const dark = resolvedTheme === "dark";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={dark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
onClick={() => setTheme(dark ? "light" : "dark")}
|
||||
className="flex h-9 w-9 items-center justify-center border border-border font-mono text-[11px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
||||
>
|
||||
{dark ? "○" : "●"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const t = useT();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
@@ -42,7 +61,7 @@ export function Header() {
|
||||
<header
|
||||
className="sticky top-0 z-50 border-b border-border transition-colors"
|
||||
style={{
|
||||
backgroundColor: scrolled ? "rgba(255,255,255,0.82)" : "var(--background)",
|
||||
backgroundColor: scrolled ? "color-mix(in srgb, var(--background) 82%, transparent)" : "var(--background)",
|
||||
backdropFilter: scrolled ? "blur(8px)" : "none",
|
||||
}}
|
||||
>
|
||||
@@ -63,6 +82,7 @@ export function Header() {
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-4 md:flex">
|
||||
<ThemeToggle />
|
||||
<LocaleSwitcher />
|
||||
<Link href="/portal" className="px-3 py-2 font-mono text-[12px] uppercase tracking-[0.12em] text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t({ en: "Sign in", sv: "Logga in" })}
|
||||
@@ -100,7 +120,10 @@ export function Header() {
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<LocaleSwitcher />
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LocaleSwitcher />
|
||||
</div>
|
||||
<Link href="/bestall" onClick={() => setOpen(false)} className="bg-foreground px-5 py-3 font-mono text-[12px] uppercase tracking-[0.12em] text-background">
|
||||
{t({ en: "Order", sv: "Beställ" })} →
|
||||
</Link>
|
||||
|
||||
@@ -109,7 +109,7 @@ export function PortalShell({
|
||||
</aside>
|
||||
|
||||
{/* content */}
|
||||
<main className="min-w-0 flex-1 px-6 py-10">
|
||||
<main id="main-content" className="min-w-0 flex-1 px-6 py-10">
|
||||
<h1 className="text-3xl font-medium tracking-[-0.03em] md:text-4xl">{title}</h1>
|
||||
<div className="mt-8">{children}</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user