bookwiz.io / components / FloatingToast.tsx
FloatingToast.tsx
Raw
'use client'

import { useState, useEffect } from 'react'
import { IoCheckmarkCircleOutline, IoArrowBackOutline, IoEyeOutline, IoCloseOutline } from 'react-icons/io5'

interface FloatingToastProps {
  show: boolean
  type?: 'ai-changes' | 'external-changes' | 'success' | 'error'
  message?: string
  actions?: Array<{
    label: string
    onClick: () => void
    variant?: 'primary' | 'secondary' | 'danger'
    loading?: boolean
    icon?: React.ReactNode
  }>
  onDismiss?: () => void
  autoHide?: number // milliseconds
  position?: 'top-right' | 'bottom-right' | 'top-center' | 'bottom-center'
  offset?: number // For stacking multiple toasts
}

export default function FloatingToast({
  show,
  type = 'ai-changes',
  message,
  actions = [],
  onDismiss,
  autoHide,
  position = 'top-right',
  offset = 0
}: FloatingToastProps) {
  const [isVisible, setIsVisible] = useState(false)
  const [isAnimating, setIsAnimating] = useState(false)

  useEffect(() => {
    if (show) {
      setIsVisible(true)
      setTimeout(() => setIsAnimating(true), 10) // Small delay for smooth entrance
    } else {
      setIsAnimating(false)
      setTimeout(() => setIsVisible(false), 300) // Wait for exit animation
    }
  }, [show])

  useEffect(() => {
    if (autoHide && show) {
      const timer = setTimeout(() => {
        onDismiss?.()
      }, autoHide)
      return () => clearTimeout(timer)
    }
  }, [autoHide, show, onDismiss])

  if (!isVisible) return null

  const getPositionStyles = () => {
    const baseOffset = offset * 70 // 70px spacing between toasts
    switch (position) {
      case 'top-right':
        return { top: `${16 + baseOffset}px`, right: '16px' }
      case 'bottom-right':
        return { bottom: `${16 + baseOffset}px`, right: '16px' }
      case 'top-center':
        return { top: `${16 + baseOffset}px`, left: '50%', transform: 'translateX(-50%)' }
      case 'bottom-center':
        return { bottom: `${16 + baseOffset}px`, left: '50%', transform: 'translateX(-50%)' }
      default:
        return { top: `${16 + baseOffset}px`, right: '16px' }
    }
  }

  const getTypeStyles = () => {
    switch (type) {
      case 'ai-changes':
        return {
          bg: 'bg-slate-800/95',
          accent: 'border-teal-500/30',
          icon: '✨',
          iconColor: 'text-teal-400'
        }
      case 'external-changes':
        return {
          bg: 'bg-slate-800/95',
          accent: 'border-blue-500/30',
          icon: '🔄',
          iconColor: 'text-blue-400'
        }
      case 'success':
        return {
          bg: 'bg-slate-800/95',
          accent: 'border-emerald-500/30',
          icon: '✓',
          iconColor: 'text-emerald-400'
        }
      case 'error':
        return {
          bg: 'bg-slate-800/95',
          accent: 'border-red-500/30',
          icon: '×',
          iconColor: 'text-red-400'
        }
      default:
        return {
          bg: 'bg-slate-800/95',
          accent: 'border-slate-500/30',
          icon: 'ℹ',
          iconColor: 'text-slate-400'
        }
    }
  }

  const typeStyles = getTypeStyles()

  const getButtonVariantStyles = (variant?: string) => {
    switch (variant) {
      case 'primary':
        return 'bg-slate-700 hover:bg-slate-600 text-slate-200 border border-slate-600'
      case 'danger':
        return 'bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/30'
      case 'secondary':
      default:
        return 'bg-slate-700/50 hover:bg-slate-700 text-slate-300 border border-slate-600/50'
    }
  }

  return (
    <div
      className={`
        fixed z-50
        transition-all duration-200 ease-out
        ${isAnimating 
          ? 'opacity-100 translate-y-0' 
          : 'opacity-0 translate-y-1'
        }
      `}
      style={getPositionStyles()}
    >
      <div className={`
        ${typeStyles.bg} backdrop-blur-md
        rounded-lg shadow-lg border ${typeStyles.accent}
        p-3 min-w-[260px] max-w-[320px]
        text-slate-100 text-xs
      `}>
        <div className="flex items-center justify-between gap-3">
          {/* Icon and Message */}
          <div className="flex items-center gap-2.5 flex-1 min-w-0">
            <span className={`text-sm flex-shrink-0 font-medium ${typeStyles.iconColor}`}>
              {typeStyles.icon}
            </span>
            <span className="font-medium truncate text-slate-200">
              {message || 'Notification'}
            </span>
          </div>

          {/* Dismiss button */}
          {onDismiss && (
            <button
              onClick={onDismiss}
              className="flex-shrink-0 p-1 rounded-md hover:bg-slate-700/50 transition-colors text-slate-400 hover:text-slate-300"
            >
              <IoCloseOutline className="w-3.5 h-3.5" />
            </button>
          )}
        </div>

        {/* Actions */}
        {actions.length > 0 && (
          <div className="flex items-center gap-2 mt-2.5 pt-2 border-t border-slate-700/50">
            {actions.map((action, index) => (
              <button
                key={index}
                onClick={action.onClick}
                disabled={action.loading}
                className={`
                  ${getButtonVariantStyles(action.variant)}
                  px-2.5 py-1 rounded-md text-xs font-medium
                  transition-all duration-150
                  flex items-center gap-1.5
                  disabled:opacity-50 disabled:cursor-not-allowed
                `}
              >
                {action.loading ? (
                  <div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin opacity-70" />
                ) : action.icon ? (
                  action.icon
                ) : null}
                <span>{action.label}</span>
              </button>
            ))}
          </div>
        )}
      </div>
    </div>
  )
}