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.
173 lines
5.2 KiB
TypeScript
173 lines
5.2 KiB
TypeScript
// 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.
|
||
}
|
||
}
|