← back to Watches

src/components/RefreshButton.jsx

179 lines

import React, { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

/**
 * RefreshButton - Manual refresh button with loading state
 * @param {function} onRefresh - Callback function to trigger refresh (should return a Promise)
 * @param {boolean} disabled - External disabled state
 * @param {string} className - Additional CSS classes
 * @param {string} size - Button size: 'sm', 'md', 'lg'
 */
export default function RefreshButton({
  onRefresh,
  disabled = false,
  className = '',
  size = 'md'
}) {
  const [isLoading, setIsLoading] = useState(false);
  const [showSuccess, setShowSuccess] = useState(false);
  const [showError, setShowError] = useState(false);

  const sizeClasses = {
    sm: 'w-6 h-6 p-1',
    md: 'w-8 h-8 p-1.5',
    lg: 'w-10 h-10 p-2'
  };

  const iconSizes = {
    sm: 'w-4 h-4',
    md: 'w-5 h-5',
    lg: 'w-6 h-6'
  };

  const handleClick = useCallback(async () => {
    if (isLoading || disabled) return;

    setIsLoading(true);
    setShowSuccess(false);
    setShowError(false);

    try {
      if (onRefresh) {
        await onRefresh();
      }
      setShowSuccess(true);
      setTimeout(() => setShowSuccess(false), 2000);
    } catch (error) {
      console.error('Refresh failed:', error);
      setShowError(true);
      setTimeout(() => setShowError(false), 3000);
    } finally {
      setIsLoading(false);
    }
  }, [onRefresh, isLoading, disabled]);

  const isDisabled = isLoading || disabled;

  return (
    <div className={`relative inline-flex items-center ${className}`}>
      <motion.button
        onClick={handleClick}
        disabled={isDisabled}
        className={`
          ${sizeClasses[size]}
          rounded-full
          bg-gray-800/50 hover:bg-gray-700/50
          border border-gray-600/50 hover:border-amber-500/50
          text-gray-300 hover:text-amber-400
          transition-all duration-200
          disabled:opacity-50 disabled:cursor-not-allowed
          focus:outline-none focus:ring-2 focus:ring-amber-500/50
          backdrop-blur-sm
        `}
        whileHover={{ scale: isDisabled ? 1 : 1.05 }}
        whileTap={{ scale: isDisabled ? 1 : 0.95 }}
        title="Refresh market data"
      >
        <AnimatePresence mode="wait">
          {isLoading ? (
            <motion.svg
              key="loading"
              className={`${iconSizes[size]} animate-spin`}
              initial={{ opacity: 0, rotate: 0 }}
              animate={{ opacity: 1, rotate: 360 }}
              exit={{ opacity: 0 }}
              transition={{ rotate: { repeat: Infinity, duration: 1, ease: 'linear' } }}
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
              />
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
              />
            </motion.svg>
          ) : showSuccess ? (
            <motion.svg
              key="success"
              className={`${iconSizes[size]} text-green-400`}
              initial={{ opacity: 0, scale: 0.5 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2}
            >
              <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
            </motion.svg>
          ) : showError ? (
            <motion.svg
              key="error"
              className={`${iconSizes[size]} text-red-400`}
              initial={{ opacity: 0, scale: 0.5 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2}
            >
              <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
            </motion.svg>
          ) : (
            <motion.svg
              key="refresh"
              className={iconSizes[size]}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
              strokeWidth={2}
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
              />
            </motion.svg>
          )}
        </AnimatePresence>
      </motion.button>

      {/* Toast notifications */}
      <AnimatePresence>
        {showSuccess && (
          <motion.div
            initial={{ opacity: 0, x: 10 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0, x: 10 }}
            className="absolute left-full ml-2 px-2 py-1 bg-green-900/80 text-green-300 text-xs rounded whitespace-nowrap"
          >
            Updated!
          </motion.div>
        )}
        {showError && (
          <motion.div
            initial={{ opacity: 0, x: 10 }}
            animate={{ opacity: 1, x: 0 }}
            exit={{ opacity: 0, x: 10 }}
            className="absolute left-full ml-2 px-2 py-1 bg-red-900/80 text-red-300 text-xs rounded whitespace-nowrap"
          >
            Refresh failed
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}