profit-planet-frontend/src/app/components/toast/toastComponent.tsx
DeathKaioken e769132f84 fml
Co-authored-by: Copilot <copilot@github.com>
2026-05-04 04:52:11 +02:00

332 lines
10 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.

'use client'
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode
} from 'react'
import { createRoot } from 'react-dom/client'
import { isPageTransitioning, onPageTransitionEnd } from '../animation/pageTransitionEffect'
type ToastVariant = 'success' | 'error' | 'info' | 'warning'
export interface ToastOptions {
id?: string
title?: string
message: string
variant?: ToastVariant
duration?: number // ms, default 4000
}
// add optional closing flag for exit animation
interface ToastInternal extends ToastOptions {
id: string
closing?: boolean
}
interface ToastContextValue {
showToast: (options: ToastOptions) => void
}
// increase fade duration
const TOAST_ANIMATION_MS = 400 // fade-out duration in ms
// --- global toast store so toasts survive route changes ---
let globalToasts: ToastInternal[] = []
type ToastListener = (toasts: ToastInternal[]) => void
const toastListeners = new Set<ToastListener>()
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
// NEW: portal state (single global React root)
let toastPortalContainer: HTMLDivElement | null = null
let toastPortalRoot: ReturnType<typeof createRoot> | null = null
let toastPortalMounted = false
function notifyToastListeners() {
for (const listener of toastListeners) {
listener(globalToasts)
}
}
function removeToastInternal(id: string) {
// clear auto-dismiss timer
const timeout = toastTimeouts.get(id)
if (timeout) {
clearTimeout(timeout)
toastTimeouts.delete(id)
}
const existing = globalToasts.find(t => t.id === id)
if (!existing) return
if (existing.closing) {
// already closing: hard-remove immediately
globalToasts = globalToasts.filter(t => t.id !== id)
notifyToastListeners()
return
}
// mark as closing to trigger fade-out
globalToasts = globalToasts.map(t =>
t.id === id ? { ...t, closing: true } : t
)
notifyToastListeners()
// remove after animation finishes
setTimeout(() => {
globalToasts = globalToasts.filter(t => t.id !== id)
notifyToastListeners()
}, TOAST_ANIMATION_MS)
}
function addToast(options: ToastOptions) {
// Defer toast until page transition overlay has fully exited
if (typeof window !== 'undefined' && isPageTransitioning) {
onPageTransitionEnd(() => addToast(options))
return
}
const id = options.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
const toast: ToastInternal = {
id,
variant: options.variant ?? 'info',
duration: options.duration ?? 4000,
...options
}
globalToasts = [...globalToasts, toast]
notifyToastListeners()
if (toast.duration && toast.duration > 0) {
const timeout = setTimeout(() => {
removeToastInternal(id)
}, toast.duration)
toastTimeouts.set(id, timeout)
}
}
// NEW: mount a global portal once per browser session
function ensureToastPortalMounted() {
if (toastPortalMounted) return
if (typeof document === 'undefined') return
toastPortalMounted = true
toastPortalContainer = document.createElement('div')
document.body.appendChild(toastPortalContainer)
toastPortalRoot = createRoot(toastPortalContainer)
// Defer actual render to avoid triggering nested updates during React render
setTimeout(() => {
if (!toastPortalRoot) return
toastPortalRoot.render(<ToastViewport />)
}, 0)
}
// --- context & provider ---
const ToastContext = createContext<ToastContextValue | undefined>(undefined)
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (ctx) {
// Normal path: inside <ToastProvider>
return ctx
}
// Fallback path: no provider mounted, still use global store/portal
if (typeof window !== 'undefined') {
ensureToastPortalMounted()
}
return {
showToast: (options: ToastOptions) => {
addToast(options)
}
}
}
interface ToastProviderProps {
children: ReactNode
}
export function ToastProvider({ children }: ToastProviderProps) {
// ensure the global portal is mounted when any provider appears
useEffect(() => {
ensureToastPortalMounted()
}, [])
const showToast = useCallback((options: ToastOptions) => {
addToast(options)
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
</ToastContext.Provider>
)
}
// NEW: global viewport rendered via portal, independent of pages/providers
function ToastViewport() {
const [toasts, setToasts] = useState<ToastInternal[]>(globalToasts)
useEffect(() => {
const listener: ToastListener = (next) => setToasts(next)
toastListeners.add(listener)
setToasts(globalToasts)
return () => {
toastListeners.delete(listener)
}
}, [])
const handleClose = useCallback((id: string) => {
removeToastInternal(id)
}, [])
if (!toasts.length) return null
return (
<>
<div
className="pp-toast-viewport pointer-events-none fixed inset-x-4 z-50 flex justify-end sm:inset-x-auto sm:right-4 sm:w-auto"
style={{ bottom: 'calc(env(safe-area-inset-bottom, 0px) + var(--pp-toast-bottom, 5rem))' }}
>
<div className="flex max-h-[80vh] w-full flex-col gap-3 overflow-hidden sm:w-80">
{toasts.map(t => (
<ToastItem key={t.id} toast={t} onClose={() => handleClose(t.id)} />
))}
</div>
</div>
<style jsx global>{`
.pp-toast-viewport {
--pp-toast-bottom: 5rem; /* desktop/tablet */
}
@media (max-width: 640px) {
.pp-toast-viewport {
--pp-toast-bottom: 7rem; /* mobile: lift higher above footer */
}
}
`}</style>
</>
)
}
interface ToastItemProps {
toast: ToastInternal
onClose: () => void
}
const TOAST_ICONS: Record<ToastVariant, React.ReactElement> = {
success: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
),
error: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clipRule="evenodd" />
</svg>
),
info: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
),
warning: (
<svg viewBox="0 0 20 20" fill="currentColor" className="h-4 w-4">
<path fillRule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
),
}
const TOAST_VARIANT_STYLES: Record<ToastVariant, { card: string; iconWrap: string; title: string; message: string; close: string }> = {
success: {
card: 'border border-emerald-200 border-l-4 border-l-emerald-400 bg-emerald-50/90 shadow-[0_8px_32px_-8px_rgba(5,150,105,0.18)]',
iconWrap: 'bg-emerald-100 text-emerald-600',
title: 'text-emerald-800',
message: 'text-emerald-900/80',
close: 'text-emerald-400 hover:bg-emerald-100 hover:text-emerald-700',
},
error: {
card: 'border border-red-200 border-l-4 border-l-red-400 bg-red-50/90 shadow-[0_8px_32px_-8px_rgba(220,38,38,0.18)]',
iconWrap: 'bg-red-100 text-red-600',
title: 'text-red-800',
message: 'text-red-900/80',
close: 'text-red-300 hover:bg-red-100 hover:text-red-700',
},
info: {
card: 'border border-sky-200 border-l-4 border-l-sky-400 bg-sky-50/90 shadow-[0_8px_32px_-8px_rgba(14,165,233,0.18)]',
iconWrap: 'bg-sky-100 text-sky-600',
title: 'text-sky-800',
message: 'text-sky-900/80',
close: 'text-sky-300 hover:bg-sky-100 hover:text-sky-700',
},
warning: {
card: 'border border-amber-200 border-l-4 border-l-amber-400 bg-amber-50/90 shadow-[0_8px_32px_-8px_rgba(217,119,6,0.18)]',
iconWrap: 'bg-amber-100 text-amber-600',
title: 'text-amber-800',
message: 'text-amber-900/80',
close: 'text-amber-300 hover:bg-amber-100 hover:text-amber-700',
},
}
function ToastItem({ toast, onClose }: ToastItemProps) {
const { title, message, variant } = toast
const v = variant ?? 'info'
const styles = TOAST_VARIANT_STYLES[v]
// local visible state for entry animation
const [visible, setVisible] = useState(false)
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true))
return () => cancelAnimationFrame(frame)
}, [])
const isClosing = !!toast.closing
const motionClasses = isClosing
? 'opacity-0 translate-y-2 scale-95'
: visible
? 'opacity-100 translate-y-0 scale-100'
: 'opacity-0 translate-y-2 scale-95'
return (
<div
className={`
pointer-events-auto flex w-full items-start gap-3 rounded-2xl
px-4 py-3.5 backdrop-blur-md transform transition-all duration-400
${motionClasses}
${styles.card}
`}
>
<div className={`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${styles.iconWrap}`}>
{TOAST_ICONS[v]}
</div>
<div className="flex-1 min-w-0">
{title && (
<div className={`mb-0.5 text-[11px] font-bold uppercase tracking-widest ${styles.title}`}>
{title}
</div>
)}
<div className={`text-[13px] leading-snug ${styles.message}`}>{message}</div>
</div>
<button
type="button"
onClick={onClose}
className={`ml-1 mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-slate-300 ${styles.close}`}
aria-label="Close notification"
>
×
</button>
</div>
)
}