From 20009aed53d8864c9204d43a17895168a777d2cc Mon Sep 17 00:00:00 2001 From: Ilan Bigio Date: Mon, 16 Dec 2024 13:06:08 -0800 Subject: Initial commit --- webapp/components/checklist-and-config.tsx | 352 +++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 webapp/components/checklist-and-config.tsx (limited to 'webapp/components/checklist-and-config.tsx') diff --git a/webapp/components/checklist-and-config.tsx b/webapp/components/checklist-and-config.tsx new file mode 100644 index 0000000..21e47d5 --- /dev/null +++ b/webapp/components/checklist-and-config.tsx @@ -0,0 +1,352 @@ +"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([]); + 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: ( + + ), + }, + { + label: "Set up Twilio phone number", + done: phoneNumbers.length > 0, + description: "Costs around $1.15/month", + field: + phoneNumbers.length > 0 ? ( + phoneNumbers.length === 1 ? ( + + ) : ( + + ) + ) : ( + + ), + }, + { + 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: ( +
+
+ +
+
+ +
+
+ ), + }, + { + label: "Update Twilio webhook URL", + done: !!publicUrl && !isWebhookMismatch, + description: "Can also be done manually in Twilio console", + field: ( +
+
+ +
+
+ +
+
+ ), + }, + ]; + }, [ + 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 ( + + + + Setup Checklist + + This sample app requires a few steps before you get started + + + +
+ {checklist.map((item, i) => ( +
+
+
+ {item.done ? ( + + ) : ( + + )} + {item.label} +
+ {item.description && ( +

+ {item.description} +

+ )} +
+
{item.field}
+
+ ))} +
+ +
+ +
+
+
+ ); +} -- cgit v1.2.3