webapp/components/checklist-and-config.tsx (317 lines of code) (raw):

"use client"; import React, { useEffect, useState, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Circle, CheckCircle, Loader2 } from "lucide-react"; import { PhoneNumber } from "@/components/types"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; export default function ChecklistAndConfig({ ready, setReady, selectedPhoneNumber, setSelectedPhoneNumber, }: { ready: boolean; setReady: (val: boolean) => void; selectedPhoneNumber: string; setSelectedPhoneNumber: (val: string) => void; }) { const [hasCredentials, setHasCredentials] = useState(false); const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumber[]>([]); const [currentNumberSid, setCurrentNumberSid] = useState(""); const [currentVoiceUrl, setCurrentVoiceUrl] = useState(""); const [publicUrl, setPublicUrl] = useState(""); const [localServerUp, setLocalServerUp] = useState(false); const [publicUrlAccessible, setPublicUrlAccessible] = useState(false); const [allChecksPassed, setAllChecksPassed] = useState(false); const [webhookLoading, setWebhookLoading] = useState(false); const [ngrokLoading, setNgrokLoading] = useState(false); const appendedTwimlUrl = publicUrl ? `${publicUrl}/twiml` : ""; const isWebhookMismatch = appendedTwimlUrl && currentVoiceUrl && appendedTwimlUrl !== currentVoiceUrl; useEffect(() => { let polling = true; const pollChecks = async () => { try { // 1. Check credentials let res = await fetch("/api/twilio"); if (!res.ok) throw new Error("Failed credentials check"); const credData = await res.json(); setHasCredentials(!!credData?.credentialsSet); // 2. Fetch numbers res = await fetch("/api/twilio/numbers"); if (!res.ok) throw new Error("Failed to fetch phone numbers"); const numbersData = await res.json(); if (Array.isArray(numbersData) && numbersData.length > 0) { setPhoneNumbers(numbersData); // If currentNumberSid not set or not in the list, use first const selected = numbersData.find((p: PhoneNumber) => p.sid === currentNumberSid) || numbersData[0]; setCurrentNumberSid(selected.sid); setCurrentVoiceUrl(selected.voiceUrl || ""); setSelectedPhoneNumber(selected.friendlyName || ""); } // 3. Check local server & public URL let foundPublicUrl = ""; try { const resLocal = await fetch("http://localhost:8081/public-url"); if (resLocal.ok) { const pubData = await resLocal.json(); foundPublicUrl = pubData?.publicUrl || ""; setLocalServerUp(true); setPublicUrl(foundPublicUrl); } else { throw new Error("Local server not responding"); } } catch { setLocalServerUp(false); setPublicUrl(""); } } catch (err) { console.error(err); } }; pollChecks(); const intervalId = setInterval(() => polling && pollChecks(), 1000); return () => { polling = false; clearInterval(intervalId); }; }, [currentNumberSid, setSelectedPhoneNumber]); const updateWebhook = async () => { if (!currentNumberSid || !appendedTwimlUrl) return; try { setWebhookLoading(true); const res = await fetch("/api/twilio/numbers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phoneNumberSid: currentNumberSid, voiceUrl: appendedTwimlUrl, }), }); if (!res.ok) throw new Error("Failed to update webhook"); setCurrentVoiceUrl(appendedTwimlUrl); } catch (err) { console.error(err); } finally { setWebhookLoading(false); } }; const checkNgrok = async () => { if (!localServerUp || !publicUrl) return; setNgrokLoading(true); let success = false; for (let i = 0; i < 5; i++) { try { const resTest = await fetch(publicUrl + "/public-url"); if (resTest.ok) { setPublicUrlAccessible(true); success = true; break; } } catch { // retry } if (i < 4) { await new Promise((r) => setTimeout(r, 3000)); } } if (!success) { setPublicUrlAccessible(false); } setNgrokLoading(false); }; const checklist = useMemo(() => { return [ { label: "Set up Twilio account", done: hasCredentials, description: "Then update account details in webapp/.env", field: ( <Button className="w-full" onClick={() => window.open("https://console.twilio.com/", "_blank")} > Open Twilio Console </Button> ), }, { label: "Set up Twilio phone number", done: phoneNumbers.length > 0, description: "Costs around $1.15/month", field: phoneNumbers.length > 0 ? ( phoneNumbers.length === 1 ? ( <Input value={phoneNumbers[0].friendlyName || ""} disabled /> ) : ( <Select onValueChange={(value) => { setCurrentNumberSid(value); const selected = phoneNumbers.find((p) => p.sid === value); if (selected) { setSelectedPhoneNumber(selected.friendlyName || ""); setCurrentVoiceUrl(selected.voiceUrl || ""); } }} value={currentNumberSid} > <SelectTrigger className="w-full"> <SelectValue placeholder="Select a phone number" /> </SelectTrigger> <SelectContent> {phoneNumbers.map((phone) => ( <SelectItem key={phone.sid} value={phone.sid}> {phone.friendlyName} </SelectItem> ))} </SelectContent> </Select> ) ) : ( <Button className="w-full" onClick={() => window.open( "https://console.twilio.com/us1/develop/phone-numbers/manage/incoming", "_blank" ) } > Set up Twilio phone number </Button> ), }, { label: "Start local WebSocket server", done: localServerUp, description: "cd websocket-server && npm run dev", field: null, }, { label: "Start ngrok", done: publicUrlAccessible, description: "Then set ngrok URL in websocket-server/.env", field: ( <div className="flex items-center gap-2 w-full"> <div className="flex-1"> <Input value={publicUrl} disabled /> </div> <div className="flex-1"> <Button variant="outline" onClick={checkNgrok} disabled={ngrokLoading || !localServerUp || !publicUrl} className="w-full" > {ngrokLoading ? ( <Loader2 className="mr-2 h-4 animate-spin" /> ) : ( "Check ngrok" )} </Button> </div> </div> ), }, { label: "Update Twilio webhook URL", done: !!publicUrl && !isWebhookMismatch, description: "Can also be done manually in Twilio console", field: ( <div className="flex items-center gap-2 w-full"> <div className="flex-1"> <Input value={currentVoiceUrl} disabled className="w-full" /> </div> <div className="flex-1"> <Button onClick={updateWebhook} disabled={webhookLoading} className="w-full" > {webhookLoading ? ( <Loader2 className="mr-2 h-4 animate-spin" /> ) : ( "Update Webhook" )} </Button> </div> </div> ), }, ]; }, [ hasCredentials, phoneNumbers, currentNumberSid, localServerUp, publicUrl, publicUrlAccessible, currentVoiceUrl, isWebhookMismatch, appendedTwimlUrl, webhookLoading, ngrokLoading, setSelectedPhoneNumber, ]); useEffect(() => { setAllChecksPassed(checklist.every((item) => item.done)); }, [checklist]); useEffect(() => { if (!ready) { checkNgrok(); } }, [localServerUp, ready]); useEffect(() => { if (!allChecksPassed) { setReady(false); } }, [allChecksPassed, setReady]); const handleDone = () => setReady(true); return ( <Dialog open={!ready}> <DialogContent className="w-full max-w-[800px]"> <DialogHeader> <DialogTitle>Setup Checklist</DialogTitle> <DialogDescription> This sample app requires a few steps before you get started </DialogDescription> </DialogHeader> <div className="mt-4 space-y-0"> {checklist.map((item, i) => ( <div key={i} className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 py-2" > <div className="flex flex-col"> <div className="flex items-center gap-2 mb-1"> {item.done ? ( <CheckCircle className="text-green-500" /> ) : ( <Circle className="text-gray-400" /> )} <span className="font-medium">{item.label}</span> </div> {item.description && ( <p className="text-sm text-gray-500 ml-8"> {item.description} </p> )} </div> <div className="flex items-center mt-2 sm:mt-0">{item.field}</div> </div> ))} </div> <div className="mt-6 flex flex-col sm:flex-row sm:justify-end"> <Button variant="outline" onClick={handleDone} disabled={!allChecksPassed} > Let's go! </Button> </div> </DialogContent> </Dialog> ); }