← 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
M components/DesktopBubbe.tsxM components/MobileBubbe.tsxA lib/bubbeChat.ts
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 →