vkashti / app / quiz / QuizQuestion.tsx
QuizQuestion.tsx
Raw
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { RoundDialog } from './RoundDialog';
import { AnswersHistory } from './AnswersHistory';
import { QuizInput } from './QuizInput';
import {
  FaPaperPlane,
  FaHistory,
  FaHourglassHalf,
  FaBell,
  FaCheck,
  FaUndo
} from 'react-icons/fa';
import { createClient } from '@/utils/supabase/client';
import {
  getCurrentRound,
  questionToRoundQuestion
} from '../../utils/quizHelpers';
import ReactConfetti from 'react-confetti';

type QuizQuestionProps = {
  teamName: string;
  initialQuestionNumber: number;
  quizId?: number | null;
};

export default function QuizQuestion({
  teamName,
  initialQuestionNumber,
  quizId
}: QuizQuestionProps) {
  const [showHistory, setShowHistory] = useState(false);
  const [isButtonDisabled, setIsButtonDisabled] = useState(false);
  const [countdown, setCountdown] = useState(0);
  const [notificationMessage, setNotificationMessage] = useState('');
  const [showNotification, setShowNotification] = useState(false);
  const [isSynced, setIsSynced] = useState(false);
  const [showConfetti, setShowConfetti] = useState(false);
  const [windowDimensions, setWindowDimensions] = useState({ width: 0, height: 0 });
  const submitButtonRef = useRef<HTMLButtonElement>(null);
  const [confettiPosition, setConfettiPosition] = useState({ x: 0, y: 0 });

  // Quiz state
  const [questionNumber, setQuestionNumber] = useState(initialQuestionNumber);
  const [currentRound, setCurrentRound] = useState(getCurrentRound(initialQuestionNumber));
  const [answer, setAnswer] = useState('');
  const [answersHistory, setAnswersHistory] = useState<
    { question: number; round: number; answer: string }[]
  >([]);
  const [adminCurrentQuestion, setAdminCurrentQuestion] = useState<
    number | null
  >(null);
  const [errorMessage, setErrorMessage] = useState('');
  const supabase = createClient();

  // Dialog state
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const [dialogType, setDialogType] = useState<'round' | 'question'>('round');

  // Check if current question has been answered
  const hasAnsweredCurrentQuestion = answersHistory.some(
    (ans) => ans.question === questionNumber
  );

  // Get the answer for the current question if it exists
  const currentQuestionAnswer = answersHistory.find(
    (ans) => ans.question === questionNumber
  );

  // Listen for the admin's current question/slide
  useEffect(() => {
    // Only set up the admin broadcast channel if we have a valid quizId
    if (!quizId) return;

    const channel = supabase.channel('admin-broadcast');
    let syncAttempts = 0;
    const maxSyncAttempts = 5;
    let syncTimer: ReturnType<typeof setTimeout>;
    let hasReceivedInitialSync = false;

    // Handle broadcast messages from admin about slide changes
    channel.on('broadcast', { event: 'slide-change' }, (payload) => {
      // Only process messages for our quiz
      if (
        payload.payload.quizId === quizId &&
        payload.payload.currentQuestionNumber !== null
      ) {
        const newQuestionNumber = payload.payload.currentQuestionNumber;
        
        setAdminCurrentQuestion(newQuestionNumber);
        hasReceivedInitialSync = true; // Mark that we've received sync data
        setIsSynced(true);

        // Always notify about question changes since we're always synced
        if (newQuestionNumber !== questionNumber) {
          const currentRound = getCurrentRound(newQuestionNumber);
          const questionInRound = questionToRoundQuestion(newQuestionNumber);

          setNotificationMessage(
            `Презентаторът премина към Кръг ${currentRound}, Въпрос ${questionInRound}`
          );
          setShowNotification(true);

          // Auto-hide notification after 5 seconds
          setTimeout(() => {
            setShowNotification(false);
          }, 5000);
        }
      }
    });

    // Handle response to our sync request
    channel.on('broadcast', { event: 'slide-current-state' }, (payload) => {
      if (
        payload.payload.quizId === quizId &&
        payload.payload.currentQuestionNumber !== null
      ) {
        console.log("Received slide state:", payload.payload);
        setAdminCurrentQuestion(payload.payload.currentQuestionNumber);
        hasReceivedInitialSync = true; // Mark that we've received sync data
        setIsSynced(true);
        
        // Clear any pending retry timer since we got a response
        if (syncTimer) {
          clearTimeout(syncTimer);
        }
      }
    });

    const requestCurrentSlideState = async () => {
      if (!hasReceivedInitialSync && syncAttempts < maxSyncAttempts) {
        console.log(`Requesting current slide state (attempt ${syncAttempts + 1}/${maxSyncAttempts})...`);
        
        try {
          await channel.send({
            type: 'broadcast',
            event: 'request-slide-state',
            payload: {
              quizId,
              teamName
            }
          });
          
          syncAttempts++;
          
          // Schedule another attempt if we still haven't received a response
          syncTimer = setTimeout(() => {
            if (!hasReceivedInitialSync) {
              requestCurrentSlideState();
            }
          }, 2000); // Retry every 2 seconds
        } catch (error) {
          console.error("Error requesting slide state:", error);
          setIsSynced(false);
          
          // Still retry on error
          syncTimer = setTimeout(() => {
            if (!hasReceivedInitialSync) {
              requestCurrentSlideState();
            }
          }, 2000);
        }
      } else if (syncAttempts >= maxSyncAttempts && !hasReceivedInitialSync) {
        setIsSynced(false);
      }
    };

    channel.subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        // Once subscribed, request the current slide state from admin
        requestCurrentSlideState();
      }
    });

    return () => {
      // Clear any pending timers
      if (syncTimer) {
        clearTimeout(syncTimer);
      }
      supabase.removeChannel(channel);
    };
  }, [quizId, supabase, questionNumber, teamName]);

  // Load saved answers from localStorage
  useEffect(() => {
    const savedAnswers = localStorage.getItem('quizAnswers');

    if (savedAnswers) {
      const answers = JSON.parse(savedAnswers);
      // Only load if it's the same team
      if (answers.length > 0 && answers[0].teamName === teamName) {
        setAnswersHistory(
          answers.map(({ question, round, answer }: any) => ({
            question,
            round,
            answer
          }))
        );
      }
    }
  }, [teamName]);

  // Always sync with admin question whenever it changes
  useEffect(() => {
    if (adminCurrentQuestion !== null) {
      setQuestionNumber(adminCurrentQuestion);
    }
  }, [adminCurrentQuestion]);

  // Update current round whenever question number changes
  useEffect(() => {
    const newRound = getCurrentRound(questionNumber);
    setCurrentRound(newRound);
  }, [questionNumber]);

  // Save progress to localStorage whenever it changes
  useEffect(() => {
    localStorage.setItem(
      'quizTeamData',
      JSON.stringify({
        name: teamName,
        questionNumber,
        quizId
      })
    );
  }, [teamName, questionNumber, quizId]);

  // Save answers to localStorage whenever they change
  useEffect(() => {
    localStorage.setItem(
      'quizAnswers',
      JSON.stringify(
        answersHistory.map((answer) => ({
          ...answer,
          teamName, // Add teamName to identify which team's answers these are
          quizId // Add quizId to identify which quiz these answers belong to
        }))
      )
    );
  }, [answersHistory, teamName, quizId]);

  /**
   * Countdown effect for button disable.
   */
  useEffect(() => {
    let timer: ReturnType<typeof setTimeout>;
    if (isButtonDisabled && countdown > 0) {
      timer = setTimeout(() => setCountdown(countdown - 1), 1000);
    } else if (countdown === 0) {
      setIsButtonDisabled(false);
    }
    return () => clearTimeout(timer);
  }, [isButtonDisabled, countdown]);

  // Handle cleanup when team leaves
  useEffect(() => {
    const channel = supabase.channel('online_teams');
    let isActive = true;

    // Function to update team status
    const updateTeamStatus = async (active: boolean) => {
      if (active) {
        // Set team as active
        await channel.track({
          team: teamName.trim(),
          online_at: new Date().toISOString(),
          status: 'active'
        });
      } else {
        // Set team as inactive
        await channel.track({
          team: teamName.trim(),
          online_at: new Date().toISOString(),
          status: 'inactive'
        });
      }
    };

    // Handle page visibility change
    const handleVisibilityChange = () => {
      isActive = !document.hidden;
      updateTeamStatus(isActive);
    };

    // Handle page unload/close
    const handleBeforeUnload = () => {
      channel.untrack();
      channel.unsubscribe();
    };

    // Initial status update
    updateTeamStatus(true);

    // Add event listeners
    window.addEventListener('beforeunload', handleBeforeUnload);
    document.addEventListener('visibilitychange', handleVisibilityChange);

    return () => {
      window.removeEventListener('beforeunload', handleBeforeUnload);
      document.removeEventListener('visibilitychange', handleVisibilityChange);
      handleBeforeUnload();
    };
  }, [teamName, supabase]);

  // Set window dimensions after mounting
  useEffect(() => {
    setWindowDimensions({
      width: window.innerWidth,
      height: window.innerHeight
    });

    const handleResize = () => {
      setWindowDimensions({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  /**
   * Submit quiz answer.
   */
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (isButtonDisabled || answer.trim() === '') return;

    setIsButtonDisabled(true);
    setCountdown(1);

    // Get the position of the submit button for the confetti origin
    if (submitButtonRef.current) {
      const rect = submitButtonRef.current.getBoundingClientRect();
      setConfettiPosition({
        x: rect.left + rect.width / 2,
        y: rect.top
      });
    }

    try {
      const response = await fetch('/api/quiz', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          teamName,
          answer,
          questionNumber,
          quizId: quizId ? Number(quizId) : null
        })
      });

      if (response.ok) {
        const newAnswer = {
          question: questionNumber,
          round: currentRound,
          answer
        };
        setAnswersHistory((prev) => [...prev, newAnswer]);
        setAnswer('');
        
        // Show confetti on successful submission
        setShowConfetti(true);
        
        // Hide confetti after 3 seconds
        setTimeout(() => {
          setShowConfetti(false);
        }, 3000);
      } else {
        const data = await response.json();
        alert(`Грешка: ${data.message}`);
      }
    } catch (error) {
      alert(
        'Възникна грешка при изпращането на отговора. Моля, опитайте отново.'
      );
    }
  };

  /**
   * Reset team data and redirect to team setup
   */
  const handleReset = () => {
    // Remove team data and answers from localStorage
    localStorage.removeItem('quizTeamData');
    localStorage.removeItem('quizAnswers');
    
    // Redirect to the quiz page
    window.location.href = quizId ? `/quiz/${quizId}` : '/quiz';
  };

  return (
    <div className="relative">
      {/* Confetti effect */}
      {showConfetti && windowDimensions.width > 0 && (
        <ReactConfetti 
          width={windowDimensions.width}
          height={windowDimensions.height}
          recycle={false}
          numberOfPieces={200}
          confettiSource={{
            x: confettiPosition.x,
            y: confettiPosition.y,
            w: 10,
            h: 10
          }}
          initialVelocityY={10}
          gravity={0.3}
        />
      )}
      
      {/* Notification */}
      <AnimatePresence>
        {showNotification && (
          <motion.div
            initial={{ opacity: 0, y: -20 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -20 }}
            className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-green-100 border border-green-300 text-green-800 px-4 py-3 rounded-lg shadow-lg flex items-center gap-2"
          >
            <FaBell className="text-green-600" />
            {notificationMessage}
            <button
              onClick={() => setShowNotification(false)}
              className="ml-3 text-green-700 hover:text-green-900"
            >
              ×
            </button>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Round Dialog */}
      <AnimatePresence>
        {isDialogOpen && (
          <RoundDialog
            dialogType={dialogType}
            currentRound={currentRound}
            setIsDialogOpen={setIsDialogOpen}
            setQuestionNumber={setQuestionNumber}
          />
        )}
      </AnimatePresence>

      {/* History Dialog */}
      <AnimatePresence>
        {showHistory && (
          <AnswersHistory
            answersHistory={answersHistory}
            setShowHistory={setShowHistory}
          />
        )}
      </AnimatePresence>

      <motion.div
        initial={{ opacity: 0, y: 20 }}
        animate={{ opacity: 1, y: 0 }}
        transition={{ duration: 0.5 }}
        className="px-6 py-8 bg-amber-50/70 backdrop-blur-sm rounded-xl shadow-sm border border-amber-100/50 relative overflow-hidden"
      >
        <motion.div
          initial={{ scale: 0.9 }}
          animate={{ scale: 1 }}
          transition={{ delay: 0.2 }}
        >
          {/* Header Bar - All elements on one line */}
          <div className="flex flex-col sm:flex-row items-center justify-between gap-3 mb-6">
            {/* Left side: Team name and question info */}
            <div className="flex flex-wrap items-center gap-3 w-full sm:w-auto">
              <div className="px-4 py-1.5 bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium rounded-lg shadow-sm">
                {teamName}
              </div>
              <div className="text-base sm:text-lg font-bold text-amber-900">
                Кръг {currentRound}, Въпрос{' '}
                {questionToRoundQuestion(questionNumber)}
              </div>
            </div>

            {/* Right side: Sync status and history button */}
            <div className="flex items-center gap-2 w-full sm:w-auto justify-end">
              {/* Subtle pulsating dot for sync status */}
              <div
                className="relative flex items-center"
                title="Автоматично синхронизиран с презентатора"
              >
                <div className={`
                  w-4 h-4 rounded-full 
                  ${isSynced ? 'bg-green-500' : 'bg-red-500'}
                  animate-pulse
                `}></div>
              </div>

              <button
                onClick={() => setShowHistory(true)}
                className="flex items-center gap-1 px-3 py-1.5 bg-amber-100 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-200 transition-colors"
                title="История на отговорите"
              >
                <FaHistory size={14} />
                История
              </button>
            </div>
          </div>

          {/* Quiz Form */}
          <form onSubmit={handleSubmit}>
            <QuizInput
              currentRound={currentRound}
              answer={
                hasAnsweredCurrentQuestion
                  ? currentQuestionAnswer?.answer || ''
                  : answer
              }
              setAnswer={setAnswer}
              disabled={hasAnsweredCurrentQuestion || isButtonDisabled}
              readOnly={hasAnsweredCurrentQuestion}
            />

            <div className="flex items-center justify-center gap-4 mt-6">
              <motion.button
                ref={submitButtonRef}
                whileHover={{ scale: 1.02 }}
                whileTap={{ scale: 0.98 }}
                type="submit"
                disabled={
                  hasAnsweredCurrentQuestion || isButtonDisabled || !answer
                }
                className={`min-w-[140px] px-6 py-3 text-lg font-semibold text-white bg-gradient-to-r from-amber-500 to-orange-500 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 ${
                  hasAnsweredCurrentQuestion || isButtonDisabled || !answer
                    ? 'opacity-50 cursor-not-allowed'
                    : 'hover:from-amber-400 hover:to-orange-400'
                }`}
              >
                {isButtonDisabled ? (
                  <span className="flex items-center gap-2">
                    <FaHourglassHalf className="text-lg animate-pulse" />
                    {countdown}сек
                  </span>
                ) : hasAnsweredCurrentQuestion ? (
                  <span className="flex items-center gap-2">
                    <FaCheck className="text-lg" />
                    Отговорено
                  </span>
                ) : (
                  <span className="flex items-center gap-2">
                    <FaPaperPlane className="text-lg" />
                    Изпрати
                  </span>
                )}
              </motion.button>
            </div>
            
            <div className="flex justify-center mt-6">
              <motion.button
                type="button"
                onClick={handleReset}
                whileHover={{ scale: 1.05 }}
                whileTap={{ scale: 0.95 }}
                className="btn btn-sm px-3 py-2 text-sm text-amber-800 bg-amber-100 border border-amber-200 rounded-lg hover:bg-amber-200 transition-colors duration-200"
              >
                <span className="flex items-center gap-1">
                  <FaUndo className="text-xs" />
                  Рестартирай връзката
                </span>
              </motion.button>
            </div>
          </form>
        </motion.div>
      </motion.div>
    </div>
  );
}