Workflow-App/Workflow/components/Toast.tsx
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +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.
}
}