← back to Dear Bubbe Nextjs

components/DesktopBubbe.tsx

463 lines

'use client'

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

export default function DesktopBubbe() {
  const { data: session } = useSession()
  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 [voiceEnabled, setVoiceEnabled] = useState(true)
  const messagesEndRef = useRef<HTMLDivElement>(null)
  const recognitionRef = useRef<WebSpeechRecognition | null>(null)
  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
      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
          handleSendMessage(transcript)
          setIsListening(false)
        }

        recognitionRef.current.onerror = (event: any) => {
          console.error('Speech error:', event.error)
          setIsListening(false)
          // Continue conversation on error
          if (hasStarted && voiceEnabled && event.error !== 'aborted') {
            setTimeout(() => {
              if (!isSpeaking && !isLoading) {
                startListening()
              }
            }, 1000)
          }
        }

        recognitionRef.current.onend = () => {
          setIsListening(false)
          // Auto-restart listening if voice enabled
          if (hasStarted && voiceEnabled && !isSpeaking && !isLoading) {
            setTimeout(() => {
              if (!isSpeaking && !isLoading) {
                startListening()
              }
            }, 500)
          }
        }
      } catch (e) {
        console.error('Speech recognition setup failed:', e)
      }
    }

    // Initialize the ONE audio element only once (recreating it on every state
    // change spawned overlapping Audio elements that played over each other).
    if (!audioRef.current) {
      audioRef.current = new Audio()
      audioRef.current.addEventListener('ended', () => {
        isSpeakingRef.current = false
        setIsSpeaking(false)
        // Auto-start listening after speech ends
        if (voiceEnabled && hasStarted) {
          setTimeout(() => {
            startListening()
          }, 500)
        }
      })
    }
  }, [voiceEnabled, hasStarted, isSpeaking, isLoading])

  // Auto-start conversation on page load
  useEffect(() => {
    if (!isAutoStartedRef.current) {
      isAutoStartedRef.current = true
      // Prevent duplicate auto-start with global flag
      if (!(window as any).__bubbeDesktopAutoStarted) {
        (window as any).__bubbeDesktopAutoStarted = true
        // Start conversation after small delay
        setTimeout(() => {
          startConversation()
        }, 1500)
      }
    }
  }, [])

  // Voice playback (stop-before-play + overlap lock) lives in lib/bubbeChat.
  // Desktop gates on voiceEnabled and auto-restarts listening after speech.
  const voicePlayer = makeVoicePlayer({ audioRef, isSpeakingRef, setIsSpeaking })
  const playBubbeVoice = (text: string) => {
    stopListening()
    return voicePlayer(text, {
      enabled: voiceEnabled,
      onDone: () => { if (voiceEnabled && hasStarted) setTimeout(() => startListening(), 500) },
    })
  }

  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 if enabled
      if (voiceEnabled) {
        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])
      
      if (voiceEnabled) {
        await playBubbeVoice(errorMessage.content)
      }
    } finally {
      setIsLoading(false)
    }
  }

  const startListening = () => {
    if (!recognitionRef.current || !voiceEnabled) return
    if (isListening || isSpeaking) return
    
    try {
      setIsListening(true)
      recognitionRef.current.start()
    } catch (error) {
      console.error('Failed to start listening:', error)
      setIsListening(false)
    }
  }

  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, look who finally showed up! Nu, what's the crisis today? And don't lie to me, I can see you're stressed from here!"
    
    const greetingMessage = {
      id: Date.now().toString(),
      role: 'assistant' as const,
      content: greeting
    }
    
    setMessages([greetingMessage])
    
    // Play the greeting and then start listening
    await playBubbeVoice(greeting)
    
    // If voice didn't play, start listening anyway
    if (!isSpeaking && voiceEnabled) {
      setTimeout(() => startListening(), 500)
    }
  }

  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: '20px',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        color: 'white',
        borderBottom: '1px solid rgba(255,255,255,0.1)'
      }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
          <span style={{ fontSize: '36px' }}>🥯</span>
          <div>
            <h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: 0 }}>Dear Bubbe</h1>
            <p style={{ fontSize: '14px', opacity: 0.8, margin: 0 }}>
              Your Brutally Honest AI Jewish Grandmother
            </p>
          </div>
        </div>
        
        <div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
          {/* Voice Status */}
          {(isSpeaking || isListening) && (
            <div style={{
              background: isSpeaking ? '#f59e0b' : '#ef4444',
              padding: '8px 16px',
              borderRadius: '20px',
              fontSize: '14px',
              display: 'flex',
              alignItems: 'center',
              gap: '8px'
            }}>
              {isSpeaking ? '🔊 Speaking' : '🎤 Listening'}
            </div>
          )}
          
          {/* Voice Toggle */}
          <button
            onClick={() => setVoiceEnabled(!voiceEnabled)}
            style={{
              padding: '10px 20px',
              background: voiceEnabled ? '#10b981' : '#6b7280',
              color: 'white',
              border: 'none',
              borderRadius: '20px',
              fontSize: '14px',
              fontWeight: 'bold',
              cursor: 'pointer',
              display: 'flex',
              alignItems: 'center',
              gap: '8px'
            }}
          >
            {voiceEnabled ? '🔊 Voice On' : '🔇 Voice Off'}
          </button>
        </div>
      </div>

      {/* Main Chat Area */}
      <div style={{
        flex: 1,
        display: 'flex',
        maxWidth: '1200px',
        width: '100%',
        margin: '0 auto',
        padding: '20px',
        gap: '20px'
      }}>
        {/* Messages Column */}
        <div style={{
          flex: 1,
          background: 'rgba(255,255,255,0.1)',
          borderRadius: '20px',
          padding: '20px',
          overflowY: 'auto',
          display: 'flex',
          flexDirection: 'column'
        }}>
          {messages.length === 0 && (
            <div style={{
              textAlign: 'center',
              color: 'rgba(255,255,255,0.8)',
              marginTop: '50px'
            }}>
              <p style={{ fontSize: '20px' }}>
                Bubbe is getting ready to kvetch at you...
              </p>
            </div>
          )}
          
          {messages.map((message) => (
            <div
              key={message.id}
              style={{
                alignSelf: message.role === 'user' ? 'flex-end' : 'flex-start',
                maxWidth: '70%',
                marginBottom: '15px'
              }}
            >
              <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: '15px 20px',
                borderRadius: '20px',
                borderBottomRightRadius: message.role === 'user' ? '4px' : '20px',
                borderBottomLeftRadius: message.role === 'user' ? '20px' : '4px',
                fontSize: '16px',
                lineHeight: '1.5'
              }}>
                {message.content}
              </div>
            </div>
          ))}
          
          {isLoading && (
            <div style={{ alignSelf: 'flex-start', maxWidth: '70%' }}>
              <div style={{
                background: 'rgba(0,0,0,0.3)',
                color: 'white',
                padding: '15px 20px',
                borderRadius: '20px',
                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>
      </div>

      {/* Input Area */}
      <div style={{
        padding: '20px',
        background: 'rgba(0,0,0,0.2)',
        borderTop: '1px solid rgba(255,255,255,0.2)'
      }}>
        <div style={{
          maxWidth: '1200px',
          margin: '0 auto',
          display: 'flex',
          gap: '15px',
          alignItems: 'center'
        }}>
          <input
            type="text"
            value={textInput}
            onChange={(e) => setTextInput(e.target.value)}
            onKeyPress={(e) => {
              if (e.key === 'Enter') {
                handleSendMessage(textInput)
              }
            }}
            placeholder={voiceEnabled ? "Type or speak your message..." : "Type your message..."}
            disabled={isLoading}
            style={{
              flex: 1,
              padding: '15px 20px',
              fontSize: '16px',
              borderRadius: '30px',
              border: 'none',
              background: 'rgba(255,255,255,0.95)',
              outline: 'none'
            }}
          />
          
          {voiceEnabled && (
            <button
              onClick={() => {
                if (isSpeaking) {
                  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: '60px',
                height: '60px',
                borderRadius: '50%',
                background: isListening ? '#ef4444' : isSpeaking ? '#f59e0b' : 'white',
                border: 'none',
                fontSize: '24px',
                cursor: isLoading ? 'default' : 'pointer',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
                animation: isSpeaking || isListening ? 'pulse 1.5s infinite' : 'none'
              }}
            >
              {isSpeaking ? '🔊' : isListening ? '⏹️' : isLoading ? '⏳' : '🎤'}
            </button>
          )}
          
          <button
            onClick={() => handleSendMessage(textInput)}
            disabled={!textInput.trim() || isLoading}
            style={{
              padding: '15px 30px',
              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: '30px',
              fontSize: '16px',
              fontWeight: 'bold',
              cursor: !textInput.trim() || isLoading ? 'default' : 'pointer'
            }}
          >
            Send
          </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>
  )
}