WORKFLOW-APP/components/Toast.tsx
Dave 2fb979b98a Director appointment acceptance — full feature
- DirectorAppointmentTab: table + 'Acceptance Letter' modal with company/director dropdowns
- GET/POST /api/appointments: create record, generate HTML letter, convert to PDF via LibreOffice, email to director via himalaya SMTP
- PATCH /api/appointments/[id]: save signature PNG, embed in signed letter PDF, mail to all company directors
- GET /api/appointments/sign/[token]: public token lookup
- /sign/[token]/page.tsx: public signature canvas (mouse + touch)
- ysterbaard_director_appointments PB collection (requires manual schema setup)
- Section 3 'Director Appointment' now active in ResolutionExplorer
- NEXT_PUBLIC_APP_URL added to .env.local
2026-08-17 18:39:14 +02:00

173 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// components/Toast.tsx
// Tiny in-app toast system. Each toast is a row in a vertical stack
// anchored to the bottom-right. Auto-dismiss after 5s (configurable).
// Includes a 3-second coalesce window — bursty events (e.g. 20 tasks
// flipping to done at once) only show one toast.
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
export type ToastKind = "info" | "success" | "error" | "warn";
export interface Toast {
id: string;
kind: ToastKind;
title: string;
body?: string;
/** Optional click target — e.g. open a task detail panel. */
onClick?: () => void;
/** Auto-dismiss in ms. 0 = sticky. Default 5000. */
duration?: number;
}
interface ToastContextValue {
toast: (t: Omit<Toast, "id">) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
const COALESCE_MS = 3000;
const MAX_VISIBLE = 4;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const lastFiredRef = useRef<{ key: string; at: number } | null>(null);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback((t: Omit<Toast, "id">) => {
const key = `${t.kind}|${t.title}|${t.body ?? ""}`;
const now = Date.now();
if (lastFiredRef.current && lastFiredRef.current.key === key && now - lastFiredRef.current.at < COALESCE_MS) {
return;
}
lastFiredRef.current = { key, at: now };
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => {
const next = [...prev, { ...t, id }];
return next.slice(-MAX_VISIBLE);
});
}, []);
return (
<ToastContext.Provider value={{ toast, dismiss }}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
// Safe no-op so non-provider contexts (e.g. tests) don't crash.
return { toast: () => {}, dismiss: () => {} };
}
return ctx;
}
function ToastViewport({
toasts,
onDismiss,
}: {
toasts: Toast[];
onDismiss: (id: string) => void;
}) {
return (
<div
aria-live="polite"
aria-atomic="true"
className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-[min(360px,calc(100vw-2rem))]"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={() => onDismiss(t.id)} />
))}
</div>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const duration = toast.duration ?? 5000;
useEffect(() => {
if (duration <= 0) return;
const t = setTimeout(onDismiss, duration);
return () => clearTimeout(t);
}, [duration, onDismiss]);
const palette = {
info: "border-blue-300 bg-blue-50 text-blue-900",
success: "border-green-300 bg-green-50 text-green-900",
error: "border-red-300 bg-red-50 text-red-900",
warn: "border-amber-300 bg-amber-50 text-amber-900",
}[toast.kind];
const icon = {
info: "ℹ",
success: "✅",
error: "❌",
warn: "⚠",
}[toast.kind];
const inner = (
<div
role={toast.onClick ? "button" : undefined}
onClick={toast.onClick}
className={`rounded-md border ${palette} px-4 py-3 shadow-sm text-sm flex gap-3 items-start ${toast.onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""}`}
>
<span className="text-base leading-tight">{icon}</span>
<div className="flex-1 min-w-0">
<p className="font-semibold">{toast.title}</p>
{toast.body ? <p className="text-xs mt-0.5 opacity-80">{toast.body}</p> : null}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
aria-label="Dismiss"
className="text-xs opacity-60 hover:opacity-100"
>
✕
</button>
</div>
);
return inner;
}
// ─────────────────────────────────────────────────────────────────
// Browser notifications — request permission on first prompt.
// ─────────────────────────────────────────────────────────────────
export async function maybeRequestNotificationPermission(): Promise<boolean> {
if (typeof window === "undefined") return false;
if (!("Notification" in window)) return false;
if (Notification.permission === "granted") return true;
if (Notification.permission === "denied") return false;
const result = await Notification.requestPermission();
return result === "granted";
}
export function fireBrowserNotification(title: string, options: NotificationOptions = {}) {
if (typeof window === "undefined") return;
if (!("Notification" in window)) return;
if (Notification.permission !== "granted") return;
try {
new Notification(title, options);
} catch {
// Some browsers throw on focus-tab notifications — ignore.
}
}