Files
accounted/app/global-error.tsx
T
8f1b1fb5cd feat(ui): company monogram in user menu + mobile web touch polish (#1531)
* feat(ui): replace generic building icon with company monogram in user menu

The company row and switcher flyout in the sidebar user menu showed
lucide Building2 for every company. Render the company's initial in a
small rounded square instead (square = company, circle = person), so
each company gets a mark of its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mobile): touch behavior polish for the mobile web experience

- kill -webkit-tap-highlight-color flash; touch-action: manipulation on
  interactive elements (no double-tap-to-zoom wait); user-select: none
  on buttons (long-press no longer enters text selection)
- overscroll-behavior-y: contain on html/body: pull-to-refresh no longer
  hijacks list scrolling, inner scrollers stop chaining to the document
  (contain, not none, so iOS rubber-banding survives)
- 16px font-size floor for form fields on coarse pointers: iOS Safari
  stops zooming into focused inputs; desktop keeps text-sm
- min-h-screen -> min-h-dvh everywhere: correct height under collapsing
  mobile browser chrome, identical on desktop
- active: variants mirror hover: on Button: Tailwind 4 gates hover:
  behind (hover: hover), so touch devices previously got zero pointer
  feedback
- theme-color now tracks the app: default was a leftover blue #304D83;
  SSR emits white and ThemeColorSync mirrors the computed --background
  into the meta tag across dark mode and palette switches

Hover-stuck-after-tap and viewport-fit/safe-area were already covered
(Tailwind 4 hover gating; existing viewportFit: cover + safe-area
utilities).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log monogram and overscroll decisions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 10:41:45 +02:00

92 lines
3.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
// Last-resort boundary: only fires when the root layout itself throws, so it
// renders its own <html> and must stay free of app providers/components
// (NextIntlClientProvider, CompanyContext, etc. are not mounted here). It is
// also server-rendered when the root layout throws during SSR, so the lazy
// initializer must never touch window without a guard.
//
// Like app/error.tsx, transient failures here (e.g. a token race in the first
// request after login) heal on a fresh request, so auto-reload ONCE before
// showing the manual fallback. A per-path, per-tab-session flag (a monotonic
// one-shot, not a time window that could still loop on a slow failing render)
// keeps the single reload from becoming a loop on a genuinely broken root layout.
const RELOAD_FLAG_PREFIX = "accounted:global-error-reloaded:";
function reloadKey(): string {
return (
RELOAD_FLAG_PREFIX +
(typeof window !== "undefined" ? window.location.pathname : "")
);
}
// support@gnubok.se is hardcoded on purpose: this boundary renders when the root
// layout failed, so the branding service and any provider are unavailable here.
const SUPPORT_EMAIL = "support@gnubok.se";
function decideInitialPhase(): "reloading" | "fallback" {
if (typeof window === "undefined") return "reloading";
try {
if (window.sessionStorage.getItem(reloadKey())) return "fallback";
// Claim the one-shot and reload only if it persisted: writing here (not in
// the effect) keeps "mark reloaded" and "decide to reload" atomic, so a
// failed write (quota full / blocked) falls through to 'fallback' instead
// of reloading forever without ever recording it.
window.sessionStorage.setItem(reloadKey(), "1");
return "reloading";
} catch {
return "fallback";
}
}
export default function GlobalError({
error,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [phase] = useState<"reloading" | "fallback">(decideInitialPhase);
useEffect(() => {
console.error("[global] Unhandled error:", error);
// The one-shot flag is already claimed in decideInitialPhase, so reaching
// 'reloading' guarantees it persisted: reload exactly once.
if (phase === "reloading") window.location.reload();
}, [phase, error]);
return (
<html lang="sv" translate="no">
<head>
<meta name="google" content="notranslate" />
</head>
<body>
{phase === "fallback" ? (
<div className="flex min-h-dvh items-center justify-center p-8">
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold">Något gick fel</h2>
<p className="text-muted-foreground">
Ett oväntat fel inträffade. Försök igen eller{" "}
<a
href={`mailto:${SUPPORT_EMAIL}?subject=Oväntat fel`}
className="underline underline-offset-4 hover:text-foreground"
>
kontakta support
</a>{" "}
om problemet kvarstår.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
>
Försök igen
</button>
</div>
</div>
) : null}
</body>
</html>
);
}