summaryrefslogtreecommitdiff
path: root/webapp/components/checklist-and-config.tsx
diff options
context:
space:
mode:
authorIlan Bigio <ilan@openai.com>2024-12-16 13:06:08 -0800
committerIlan Bigio <ilan@openai.com>2024-12-19 16:08:22 -0500
commit20009aed53d8864c9204d43a17895168a777d2cc (patch)
tree754dded819869bc34a8a2a02c66ea72dac1ccd24 /webapp/components/checklist-and-config.tsx
Initial commit
Diffstat (limited to 'webapp/components/checklist-and-config.tsx')
-rw-r--r--webapp/components/checklist-and-config.tsx352
1 files changed, 352 insertions, 0 deletions
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<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>
+ );
+}