332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
'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>
|
||
)
|
||
}
|