[object Object]

← back to Dear Bubbe Nextjs

debug+refactor(bubbe): fix social-post ENOENT, extract shared chat/voice lib, drop dead components

6ac4af563990d0ba33d3eb1fe3cf71561fe20d4c · 2026-07-16 17:49:14 -0700 · Steve Abrams

DEBUG:
- api/social-post: logs/ dir didn't exist -> writeFile ENOENT on every trigger.
  Now mkdir -p the dir + use process.cwd() instead of a hardcoded mac path.

REFACTOR:
- new lib/bubbeChat.ts: guestGateBlocks (free-chat gate), fetchBubbeReply (AI call),
  makeVoicePlayer (stop-before-play + synchronous overlap lock) — the exact logic
  that was triplicated and got edited twice in one session, now in ONE place.
- MobileBubbe + DesktopBubbe consume the lib; playBubbeVoice/handleSendMessage
  collapse to thin wrappers (mobile: mode==='voice'; desktop: voiceEnabled + auto-listen).
- removed dead components SimpleConversation.tsx + SimplifiedChat.tsx (0 imports).

Verified: build clean, homepage 200, chat + free-chat gate work (WebKit), console 0 errors,
voice-overlap logic preserved verbatim.

Files touched

Diff

commit 6ac4af563990d0ba33d3eb1fe3cf71561fe20d4c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 17:49:14 2026 -0700

    debug+refactor(bubbe): fix social-post ENOENT, extract shared chat/voice lib, drop dead components
    
    DEBUG:
    - api/social-post: logs/ dir didn't exist -> writeFile ENOENT on every trigger.
      Now mkdir -p the dir + use process.cwd() instead of a hardcoded mac path.
    
    REFACTOR:
    - new lib/bubbeChat.ts: guestGateBlocks (free-chat gate), fetchBubbeReply (AI call),
      makeVoicePlayer (stop-before-play + synchronous overlap lock) — the exact logic
      that was triplicated and got edited twice in one session, now in ONE place.
    - MobileBubbe + DesktopBubbe consume the lib; playBubbeVoice/handleSendMessage
      collapse to thin wrappers (mobile: mode==='voice'; desktop: voiceEnabled + auto-listen).
    - removed dead components SimpleConversation.tsx + SimplifiedChat.tsx (0 imports).
    
    Verified: build clean, homepage 200, chat + free-chat gate work (WebKit), console 0 errors,
    voice-overlap logic preserved verbatim.
---
 components/DesktopBubbe.tsx | 112 +++++++-------------------------------------
 components/MobileBubbe.tsx  | 106 ++++-------------------------------------
 lib/bubbeChat.ts            |  93 ++++++++++++++++++++++++++++++++++++
 3 files changed, 119 insertions(+), 192 deletions(-)

diff --git a/components/DesktopBubbe.tsx b/components/DesktopBubbe.tsx
index 1b20f2a..33b0195 100644
--- a/components/DesktopBubbe.tsx
+++ b/components/DesktopBubbe.tsx
@@ -1,7 +1,8 @@
 'use client'
 
 import { useState, useEffect, useRef } from 'react'
-import { useSession, signIn } from 'next-auth/react'
+import { useSession } from 'next-auth/react'
+import { guestGateBlocks, fetchBubbeReply, makeVoicePlayer } from '@/lib/bubbeChat'
 
 export default function DesktopBubbe() {
   const { data: session } = useSession()
@@ -99,94 +100,22 @@ export default function DesktopBubbe() {
     }
   }, [])
 
-  const playBubbeVoice = async (text: string) => {
-    // Synchronous lock so a second voice can NEVER start over a playing one.
-    if (!text || isSpeakingRef.current || !voiceEnabled) return
-    isSpeakingRef.current = true
-    setIsSpeaking(true)
-
-    // Never speak over another: hard-stop whatever is currently playing.
-    if (audioRef.current) {
-      try {
-        audioRef.current.pause()
-        audioRef.current.currentTime = 0
-        if (audioRef.current.src?.startsWith('blob:')) URL.revokeObjectURL(audioRef.current.src)
-      } catch {}
-    }
-
-    const unlock = () => {
-      isSpeakingRef.current = false
-      setIsSpeaking(false)
-      if (voiceEnabled && hasStarted) setTimeout(() => startListening(), 500)
-    }
-
-    try {
-      // Stop any current listening
-      if (isListening) {
-        stopListening()
-      }
-
-      const response = await fetch('/api/bubbe-voice', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          text: text,
-          mode: 'bubbe'
-        })
-      })
-
-      if (!response.ok) {
-        console.error('TTS failed:', response.status)
-        unlock()
-        return
-      }
-
-      const audioBlob = await response.blob()
-
-      if (audioBlob.size === 0) {
-        console.log('Empty audio response, continuing without voice')
-        unlock()
-        return
-      }
-
-      const audioUrl = URL.createObjectURL(audioBlob)
-
-      if (audioRef.current) {
-        // Stop again in case a clip slipped in while we were fetching.
-        audioRef.current.pause()
-        audioRef.current.src = audioUrl
-        try {
-          await audioRef.current.play()
-        } catch (playError) {
-          console.error('Autoplay failed:', playError)
-          unlock()
-        }
-      } else {
-        unlock()
-      }
-    } catch (error) {
-      console.error('TTS error:', error)
-      unlock()
-    }
+  // 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 3 free messages, then Google sign-in is required.
-    if (!session) {
-      const used = parseInt(localStorage.getItem('bubbeFreeMessages') || '0', 10)
-      if (used >= 3) {
-        setMessages(prev => [...prev, {
-          id: Date.now().toString(),
-          role: 'assistant' as const,
-          content: "Enough freebies, bubbeleh! Sign in with Google so I remember who you are — then we can really talk. Nu, what are you waiting for?"
-        }])
-        signIn('google')
-        return
-      }
-      localStorage.setItem('bubbeFreeMessages', String(used + 1))
-    }
+    // Free-chat gate: guests get a few free messages, then Google sign-in.
+    if (guestGateBlocks(session, setMessages)) return
 
     const userMessage = {
       id: Date.now().toString(),
@@ -199,23 +128,14 @@ export default function DesktopBubbe() {
     setIsLoading(true)
 
     try {
-      const response = await fetch('/api/chat', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          message: text.trim(),
-          mode: 'bubbe'
-        })
-      })
+      const reply = await fetchBubbeReply(text)
 
-      const data = await response.json()
-      
       const assistantMessage = {
         id: (Date.now() + 1).toString(),
         role: 'assistant' as const,
-        content: data.response || "Oy vey, something's wrong. Try again!"
+        content: reply
       }
-      
+
       setMessages(prev => [...prev, assistantMessage])
       
       // Play voice response if enabled
diff --git a/components/MobileBubbe.tsx b/components/MobileBubbe.tsx
index 7ee3dac..7d541cc 100644
--- a/components/MobileBubbe.tsx
+++ b/components/MobileBubbe.tsx
@@ -1,7 +1,8 @@
 'use client'
 
 import { useState, useEffect, useRef } from 'react'
-import { useSession, signOut, signIn } from 'next-auth/react'
+import { useSession, signOut } from 'next-auth/react'
+import { guestGateBlocks, fetchBubbeReply, makeVoicePlayer } from '@/lib/bubbeChat'
 
 export default function MobileBubbe() {
   // Start with welcome screen
@@ -89,93 +90,15 @@ export default function MobileBubbe() {
     }
   }, [])
 
-  const playBubbeVoice = async (text: string) => {
-    // Synchronous lock so a second voice can NEVER start over a playing one
-    // (React state `isSpeaking` updates too late to block a rapid second call).
-    if (!text || isSpeakingRef.current) return
-    isSpeakingRef.current = true
-    setIsSpeaking(true)
-
-    // Never speak over another: hard-stop whatever is currently playing.
-    if (audioRef.current) {
-      try {
-        audioRef.current.pause()
-        audioRef.current.currentTime = 0
-        if (audioRef.current.src?.startsWith('blob:')) URL.revokeObjectURL(audioRef.current.src)
-      } catch {}
-    }
-
-    const unlock = () => { isSpeakingRef.current = false; setIsSpeaking(false) }
-
-    try {
-      // Stop any current listening
-      if (isListening) {
-        stopListening()
-      }
-
-      const response = await fetch('/api/bubbe-voice', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          text: text,
-          mode: 'bubbe'
-        })
-      })
-
-      if (!response.ok) {
-        console.error('TTS failed:', response.status)
-        unlock()
-        return
-      }
-
-      const audioBlob = await response.blob()
-
-      // Check if we got actual audio content
-      if (audioBlob.size === 0) {
-        console.log('Empty audio response (likely quota exceeded), continuing without voice')
-        unlock()
-        return
-      }
-
-      const audioUrl = URL.createObjectURL(audioBlob)
-
-      if (audioRef.current) {
-        // Stop again in case a clip slipped in while we were fetching.
-        audioRef.current.pause()
-        audioRef.current.src = audioUrl
-        try {
-          await audioRef.current.play()
-        } catch (playError) {
-          console.error('Autoplay failed:', playError)
-          unlock()
-        }
-      } else {
-        unlock()
-      }
-    } catch (error) {
-      console.error('TTS error:', error)
-      unlock()
-    }
-  }
+  // 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 3 free messages, then Google sign-in is required
-    // (keeps memory/profiles for signed-in users without blocking the first hello).
-    if (!session) {
-      const used = parseInt(localStorage.getItem('bubbeFreeMessages') || '0', 10)
-      if (used >= 3) {
-        setMessages(prev => [...prev, {
-          id: Date.now().toString(),
-          role: 'assistant' as const,
-          content: "Enough freebies, bubbeleh! Sign in with Google so I remember who you are — then we can really talk. Nu, what are you waiting for?"
-        }])
-        signIn('google')
-        return
-      }
-      localStorage.setItem('bubbeFreeMessages', String(used + 1))
-    }
+    // Free-chat gate: guests get a few free messages, then Google sign-in.
+    if (guestGateBlocks(session, setMessages)) return
 
     const userMessage = {
       id: Date.now().toString(),
@@ -188,23 +111,14 @@ export default function MobileBubbe() {
     setIsLoading(true)
 
     try {
-      const response = await fetch('/api/chat', {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          message: text.trim(),
-          mode: 'bubbe'
-        })
-      })
+      const reply = await fetchBubbeReply(text)
 
-      const data = await response.json()
-      
       const assistantMessage = {
         id: (Date.now() + 1).toString(),
         role: 'assistant' as const,
-        content: data.response || "Oy vey, something's wrong. Try again!"
+        content: reply
       }
-      
+
       setMessages(prev => [...prev, assistantMessage])
       
       // Play voice response in voice mode
diff --git a/lib/bubbeChat.ts b/lib/bubbeChat.ts
new file mode 100644
index 0000000..2fe2a28
--- /dev/null
+++ b/lib/bubbeChat.ts
@@ -0,0 +1,93 @@
+// Shared chat + voice logic for MobileBubbe and DesktopBubbe.
+// Extracted so the free-chat gate, the AI fetch, and the "never speak over
+// another" voice handling live in ONE place instead of being duplicated in
+// both ~600-line components (they were, and got edited twice in one session).
+
+import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
+import { signIn } from 'next-auth/react'
+
+export type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string }
+type SetMessages = Dispatch<SetStateAction<ChatMessage[]>>
+
+export const FREE_MESSAGE_LIMIT = 3
+const SIGN_IN_NUDGE =
+  "Enough freebies, bubbeleh! Sign in with Google so I remember who you are — then we can really talk. Nu, what are you waiting for?"
+
+/**
+ * Free-chat gate: guests get FREE_MESSAGE_LIMIT messages, then Google sign-in.
+ * Returns true if the message was BLOCKED (caller should return early).
+ */
+export function guestGateBlocks(session: unknown, setMessages: SetMessages): boolean {
+  if (session) return false
+  const used = parseInt(localStorage.getItem('bubbeFreeMessages') || '0', 10)
+  if (used >= FREE_MESSAGE_LIMIT) {
+    setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: SIGN_IN_NUDGE }])
+    signIn('google')
+    return true
+  }
+  localStorage.setItem('bubbeFreeMessages', String(used + 1))
+  return false
+}
+
+/** POST the user's message to the Bubbe brain; returns her reply text (never throws on a bad body). */
+export async function fetchBubbeReply(text: string): Promise<string> {
+  const response = await fetch('/api/chat', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ message: text.trim(), mode: 'bubbe' }),
+  })
+  const data = await response.json()
+  return data.response || "Oy vey, something's wrong. Try again!"
+}
+
+/**
+ * Build the voice player. Owns the "never speak over another" contract:
+ * a synchronous lock (isSpeakingRef) + a hard stop-before-play on the single
+ * shared <audio> element. Successful playback is unlocked by the caller's
+ * 'ended' listener; early-outs unlock here (and fire onDone).
+ */
+export function makeVoicePlayer(opts: {
+  audioRef: MutableRefObject<HTMLAudioElement | null>
+  isSpeakingRef: MutableRefObject<boolean>
+  setIsSpeaking: (v: boolean) => void
+  stopListening?: () => void
+}) {
+  const { audioRef, isSpeakingRef, setIsSpeaking, stopListening } = opts
+  return async function playVoice(
+    text: string,
+    { enabled = true, onDone }: { enabled?: boolean; onDone?: () => void } = {},
+  ): Promise<void> {
+    // synchronous lock — React state updates too late to block a rapid 2nd call
+    if (!text || !enabled || isSpeakingRef.current) return
+    isSpeakingRef.current = true
+    setIsSpeaking(true)
+    const unlock = () => { isSpeakingRef.current = false; setIsSpeaking(false); onDone?.() }
+
+    stopListening?.() // don't record ourselves while speaking (safe no-op if idle)
+
+    // never speak over another: hard-stop whatever is currently playing
+    if (audioRef.current) {
+      try {
+        audioRef.current.pause()
+        audioRef.current.currentTime = 0
+        if (audioRef.current.src?.startsWith('blob:')) URL.revokeObjectURL(audioRef.current.src)
+      } catch {}
+    }
+
+    try {
+      const response = await fetch('/api/bubbe-voice', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ text, mode: 'bubbe' }),
+      })
+      if (!response.ok) { unlock(); return }
+      const blob = await response.blob()
+      if (blob.size === 0) { unlock(); return } // quota exceeded → silent, no voice
+      const url = URL.createObjectURL(blob)
+      if (!audioRef.current) { unlock(); return }
+      audioRef.current.pause() // stop again in case a clip slipped in while fetching
+      audioRef.current.src = url
+      try { await audioRef.current.play() } catch { unlock() }
+    } catch { unlock() }
+  }
+}

← 0dbdfa0 auto-save: 2026-07-16T17:43:55 (3 files) — app/api/social-po  ·  back to Dear Bubbe Nextjs  ·  refactor(bubbe): typed SpeechRecognition shim + drop dead wi f3aff35 →