← back to Dear Bubbe Nextjs

components/MobileBubbe.tsx

548 lines

'use client'

import { useState, useEffect, useRef } from 'react'
import { useSession, signOut } from 'next-auth/react'
import { guestGateBlocks, fetchBubbeReply, makeVoicePlayer } from '@/lib/bubbeChat'

export default function MobileBubbe() {
  // Start with welcome screen
  const { data: session } = useSession()
  const [mode, setMode] = useState<'welcome' | 'text' | 'voice'>('welcome')
  const [messages, setMessages] = useState<Array<{id: string, role: 'user' | 'assistant', content: string}>>([])
  const [textInput, setTextInput] = useState('')
  const [isLoading, setIsLoading] = useState(false)
  const [isListening, setIsListening] = useState(false)
  const [isSpeaking, setIsSpeaking] = useState(false)
  const [hasStarted, setHasStarted] = useState(false)
  const [showListeningUI, setShowListeningUI] = useState(false)
  const [transitionTimer, setTransitionTimer] = useState<NodeJS.Timeout | null>(null)
  const messagesEndRef = useRef<HTMLDivElement>(null)
  const recognitionRef = useRef<WebSpeechRecognition | null>(null)
  const handleSendRef = useRef<(t: string) => void>(() => {}) // latest handleSendMessage for the once-created recognition
  const audioRef = useRef<HTMLAudioElement | null>(null)
  const isSpeakingRef = useRef(false) // synchronous lock so voices never overlap
  const isAutoStartedRef = useRef(false)

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  useEffect(() => {
    // Initialize speech recognition
    if (typeof window !== 'undefined' && ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) {
      const SpeechRecognition = window.webkitSpeechRecognition || window.SpeechRecognition
      if (!SpeechRecognition) return
      // Create the recognition instance ONCE (was recreated on every state
      // change → leaked instances). Handlers read fresh state via handleSendRef.
      if (!recognitionRef.current) try {
        recognitionRef.current = new SpeechRecognition()
        recognitionRef.current.continuous = false
        recognitionRef.current.interimResults = false
        recognitionRef.current.lang = 'en-US'

        recognitionRef.current.onresult = (event: any) => {
          const transcript = event.results[0][0].transcript
          handleSendRef.current(transcript)
          setIsListening(false)
          // Smooth UI transition
          if (transitionTimer) clearTimeout(transitionTimer)
          const timer = setTimeout(() => setShowListeningUI(false), 300)
          setTransitionTimer(timer)
        }

        recognitionRef.current.onerror = (event: any) => {
          console.error('Speech error:', event.error)
          setIsListening(false)
          // Smooth UI transition
          if (transitionTimer) clearTimeout(transitionTimer)
          const timer = setTimeout(() => setShowListeningUI(false), 300)
          setTransitionTimer(timer)
          // Don't auto-restart on error to prevent flashing
          // User can tap to retry
        }

        recognitionRef.current.onend = () => {
          setIsListening(false)
          // Smooth UI transition
          if (transitionTimer) clearTimeout(transitionTimer)
          const timer = setTimeout(() => setShowListeningUI(false), 300)
          setTransitionTimer(timer)
          // Don't auto-restart to prevent flashing
        }
      } catch (e) {
        console.error('Speech recognition setup failed:', e)
      }
    }

    // Initialize the ONE audio element for TTS playback — only once.
    // (Previously this ran on every state change, spawning new Audio elements
    // that played over each other → overlapping voices.)
    if (!audioRef.current) {
      audioRef.current = new Audio()
      audioRef.current.addEventListener('ended', () => {
        isSpeakingRef.current = false
        setIsSpeaking(false)
      })
    }
  }, [mode, hasStarted, isSpeaking, isLoading])

  // Don't auto-start - wait for user interaction

  // Voice playback (stop-before-play + overlap lock) lives in lib/bubbeChat.
  const voicePlayer = makeVoicePlayer({ audioRef, isSpeakingRef, setIsSpeaking })
  const playBubbeVoice = (text: string) => { stopListening(); return voicePlayer(text) }

  const handleSendMessage = async (text: string) => {
    if (!text.trim()) return

    // Free-chat gate: guests get a few free messages, then Google sign-in.
    if (guestGateBlocks(session, setMessages)) return

    const userMessage = {
      id: Date.now().toString(),
      role: 'user' as const,
      content: text.trim()
    }

    setMessages(prev => [...prev, userMessage])
    setTextInput('')
    setIsLoading(true)

    try {
      const reply = await fetchBubbeReply(text)

      const assistantMessage = {
        id: (Date.now() + 1).toString(),
        role: 'assistant' as const,
        content: reply
      }

      setMessages(prev => [...prev, assistantMessage])
      
      // Play voice response in voice mode
      if (mode === 'voice') {
        await playBubbeVoice(assistantMessage.content)
      }
    } catch (error) {
      console.error('Error:', error)
      const errorMessage = {
        id: (Date.now() + 1).toString(),
        role: 'assistant' as const,
        content: "Oy vey, the internet is meshuga! Try again!"
      }
      setMessages(prev => [...prev, errorMessage])
      
      // Play error message in voice mode
      if (mode === 'voice') {
        await playBubbeVoice(errorMessage.content)
      }
    } finally {
      setIsLoading(false)
    }
  }

  // Keep the once-created recognition's onresult pointed at the latest
  // handleSendMessage (fresh session/mode closure), no dep array = every render.
  useEffect(() => { handleSendRef.current = handleSendMessage })

  const startListening = () => {
    if (!recognitionRef.current) {
      alert('Voice not supported on this device. Please type instead.')
      return
    }
    
    // Don't start if already listening or speaking
    if (isListening || isSpeaking) return
    
    try {
      setIsListening(true)
      setShowListeningUI(true) // Show UI immediately
      recognitionRef.current.start()
    } catch (error) {
      console.error('Failed to start listening:', error)
      setIsListening(false)
      // Only show alert on first failure
      if (!hasStarted) {
        alert('Microphone access denied. Please allow microphone access and try again.')
      }
    }
  }

  const stopListening = () => {
    if (recognitionRef.current && isListening) {
      try {
        recognitionRef.current.stop()
      } catch (e) {
        console.error('Error stopping:', e)
      }
    }
    setIsListening(false)
  }

  const startConversation = async () => {
    if (hasStarted) return
    
    setHasStarted(true)
    
    // Start with Bubbe's greeting
    const greeting = "Oy vey, FINALLY someone to talk to! Nu, what's wrong with your life now? And don't tell me 'nothing' - I can see it in your pixels!"
    
    const greetingMessage = {
      id: Date.now().toString(),
      role: 'assistant' as const,
      content: greeting
    }
    
    setMessages([greetingMessage])
    
    // Play the greeting but don't auto-start listening
    await playBubbeVoice(greeting)
  }

  // Welcome screen for authenticated users
  if (mode === 'welcome') {
    const profileData = localStorage.getItem('bubbeProfile')
    const profile = profileData ? JSON.parse(profileData) : {}
    
    return (
      <div style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        padding: '20px',
        fontFamily: 'system-ui, -apple-system, sans-serif',
        color: 'white',
        textAlign: 'center'
      }}>
        <div style={{ fontSize: '80px', marginBottom: '20px' }}>🥯</div>
        <h1 style={{ fontSize: '32px', marginBottom: '10px' }}>
          Welcome back, {profile.name?.split(' ')[0] || 'bubbeleh'}!
        </h1>
        <p style={{ fontSize: '18px', marginBottom: '40px', opacity: 0.9 }}>
          Bubbe's ready to give you some real talk
        </p>
        
        <div style={{ display: 'flex', gap: '20px' }}>
          <button
            onClick={() => {
              setMode('voice')
              startConversation()
            }}
            style={{
              background: 'white',
              color: '#764ba2',
              border: 'none',
              padding: '15px 30px',
              borderRadius: '30px',
              fontSize: '18px',
              fontWeight: 'bold',
              cursor: 'pointer',
              display: 'flex',
              alignItems: 'center',
              gap: '10px'
            }}
          >
            🎤 Voice Chat
          </button>
          
          <button
            onClick={() => {
              setMode('text')
              startConversation()
            }}
            style={{
              background: 'rgba(255,255,255,0.2)',
              color: 'white',
              border: '2px solid white',
              padding: '15px 30px',
              borderRadius: '30px',
              fontSize: '18px',
              fontWeight: 'bold',
              cursor: 'pointer',
              display: 'flex',
              alignItems: 'center',
              gap: '10px'
            }}
          >
            💬 Text Chat
          </button>
        </div>
        
        <p style={{ fontSize: '14px', marginTop: '40px', opacity: 0.7 }}>
          Tap Voice Chat and then tap the microphone to talk
        </p>
        
        {session && (
          <div style={{
            marginTop: '30px',
            padding: '15px',
            background: 'rgba(255,255,255,0.1)',
            borderRadius: '15px',
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            gap: '10px'
          }}>
            <p style={{ fontSize: '12px', opacity: 0.8 }}>Signed in as:</p>
            <p style={{ fontSize: '14px', fontWeight: 'bold' }}>{session.user?.email}</p>
            <button
              onClick={() => signOut({ callbackUrl: '/auth/signin' })}
              style={{
                marginTop: '5px',
                padding: '8px 20px',
                background: 'rgba(255,255,255,0.2)',
                border: '1px solid rgba(255,255,255,0.3)',
                borderRadius: '20px',
                color: 'white',
                fontSize: '12px',
                cursor: 'pointer'
              }}
            >
              Switch Account
            </button>
          </div>
        )}
      </div>
    )
  }

  // Chat interface
  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      display: 'flex',
      flexDirection: 'column',
      fontFamily: 'system-ui, -apple-system, sans-serif'
    }}>
      {/* Header */}
      <div style={{
        background: 'rgba(0,0,0,0.2)',
        padding: '15px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        color: 'white'
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
          <span style={{ fontSize: '24px' }}>🥯</span>
          <div>
            <div style={{ fontWeight: 'bold' }}>Dear Bubbe</div>
            <div style={{ fontSize: '12px', opacity: 0.8 }}>
              {mode === 'voice' ? 'Voice Mode' : 'Text Mode'}
            </div>
          </div>
        </div>
        <button
          onClick={() => {
            // Toggle between text and voice modes
            setMode(mode === 'voice' ? 'text' : 'voice')
          }}
          style={{
            background: 'rgba(255,255,255,0.2)',
            border: 'none',
            color: 'white',
            padding: '8px 15px',
            borderRadius: '20px',
            fontSize: '14px',
            cursor: 'pointer'
          }}
        >
          {mode === 'voice' ? '💬 Text' : '🎤 Voice'}
        </button>
      </div>

      {/* Messages */}
      <div style={{
        flex: 1,
        overflowY: 'auto',
        padding: '15px',
        display: 'flex',
        flexDirection: 'column',
        gap: '10px'
      }}>
        {messages.length === 0 && (
          <div style={{
            textAlign: 'center',
            color: 'rgba(255,255,255,0.8)',
            marginTop: '50px'
          }}>
            <p style={{ fontSize: '18px' }}>
              {mode === 'voice' 
                ? "Bubbe is starting to talk..."
                : "Type your message below, bubbeleh!"}
            </p>
          </div>
        )}
        
        {messages.map((message) => (
          <div
            key={message.id}
            style={{
              alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
              maxWidth: '80%'
            }}
          >
            <div style={{
              background: message.role === 'user' ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.3)',
              color: message.role === 'user' ? '#333' : 'white',
              padding: '12px 16px',
              borderRadius: '18px',
              borderBottomRightRadius: message.role === 'user' ? '4px' : '18px',
              borderBottomLeftRadius: message.role === 'user' ? '18px' : '4px'
            }}>
              {message.content}
            </div>
          </div>
        ))}
        
        {isLoading && (
          <div style={{ alignSelf: 'flex-start', maxWidth: '80%' }}>
            <div style={{
              background: 'rgba(0,0,0,0.3)',
              color: 'white',
              padding: '12px 16px',
              borderRadius: '18px',
              borderBottomLeftRadius: '4px'
            }}>
              <div style={{ display: 'flex', gap: '4px' }}>
                <span style={{ animation: 'bounce 1.4s infinite' }}>●</span>
                <span style={{ animation: 'bounce 1.4s infinite 0.2s' }}>●</span>
                <span style={{ animation: 'bounce 1.4s infinite 0.4s' }}>●</span>
              </div>
            </div>
          </div>
        )}
        
        <div ref={messagesEndRef} />
      </div>

      {/* Input area */}
      <div style={{
        padding: '15px',
        background: 'rgba(0,0,0,0.2)',
        borderTop: '1px solid rgba(255,255,255,0.2)'
      }}>
        {mode === 'text' ? (
          <div style={{ display: 'flex', gap: '10px' }}>
            <input
              type="text"
              value={textInput}
              onChange={(e) => setTextInput(e.target.value)}
              onKeyPress={(e) => {
                if (e.key === 'Enter') {
                  handleSendMessage(textInput)
                }
              }}
              placeholder="Type your message..."
              disabled={isLoading}
              style={{
                flex: 1,
                padding: '12px',
                fontSize: '16px',
                borderRadius: '25px',
                border: 'none',
                background: 'rgba(255,255,255,0.9)',
                outline: 'none'
              }}
            />
            <button
              onClick={() => handleSendMessage(textInput)}
              disabled={!textInput.trim() || isLoading}
              style={{
                padding: '12px 20px',
                background: !textInput.trim() || isLoading ? 'rgba(255,255,255,0.3)' : 'white',
                color: !textInput.trim() || isLoading ? 'rgba(0,0,0,0.3)' : '#667eea',
                border: 'none',
                borderRadius: '25px',
                fontSize: '16px',
                fontWeight: 'bold',
                cursor: !textInput.trim() || isLoading ? 'default' : 'pointer'
              }}
            >
              Send
            </button>
          </div>
        ) : (
          <div style={{ 
            display: 'flex', 
            flexDirection: 'column',
            alignItems: 'center',
            gap: '10px'
          }}>
            <div style={{
              fontSize: '14px',
              color: 'rgba(255,255,255,0.9)',
              textAlign: 'center',
              minHeight: '20px'
            }}>
              {isSpeaking ? '🔊 Bubbe is talking...' : 
               showListeningUI ? '👂 Listening...' : 
               isLoading ? '🤔 Thinking...' :
               hasStarted ? 'Tap to speak' : 'Starting...'}
            </div>
            <button
              onClick={() => {
                if (isSpeaking) {
                  // Stop speaking
                  if (audioRef.current) {
                    audioRef.current.pause()
                    setIsSpeaking(false)
                  }
                } else if (isListening) {
                  stopListening()
                } else if (!isLoading && hasStarted) {
                  startListening()
                } else if (!hasStarted) {
                  startConversation()
                }
              }}
              disabled={isLoading}
              style={{
                width: '80px',
                height: '80px',
                borderRadius: '50%',
                background: showListeningUI ? '#ef4444' : isSpeaking ? '#f59e0b' : 'white',
                border: 'none',
                fontSize: '30px',
                cursor: isLoading ? 'default' : 'pointer',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                transition: 'all 0.3s',
                boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
                WebkitTapHighlightColor: 'transparent',
                animation: isSpeaking ? 'pulse 1.5s infinite' : showListeningUI ? 'pulse 2s infinite' : 'none'
              }}
            >
              {isSpeaking ? '🔊' : showListeningUI ? '⏹️' : isLoading ? '⏳' : '🎤'}
            </button>
          </div>
        )}
      </div>

      <style jsx>{`
        @keyframes bounce {
          0%, 60%, 100% { transform: translateY(0); }
          30% { transform: translateY(-10px); }
        }
        @keyframes pulse {
          0% { transform: scale(1); opacity: 1; }
          50% { transform: scale(1.05); opacity: 0.8; }
          100% { transform: scale(1); opacity: 1; }
        }
      `}</style>
    </div>
  )
}