updating frontend and removing superfluous files

This commit is contained in:
akdeb 2025-04-18 15:32:47 +01:00
parent 9bfd91d39f
commit 36762af913
21 changed files with 263 additions and 943 deletions

View file

@ -23,6 +23,7 @@ supabase start # Starts your local Supabase server with the default migrations a
npm install
npm run dev
```
> **Login creds:** Email: admin@elatoai.com, Password: admin
3. Add your ESP32-S3 Device MAC Address to the Settings page in the NextJS Frontend. (Remove colons and convert to lowercase, useful for adding friendly user codes when registering multiple devices)
```bash
@ -40,27 +41,30 @@ OPENAI_API_KEY=your_openai_api_key
5. Set up your ESP32 Arduino Client. On PlatformIO, first `Build` the project, then `Upload` the project to your ESP32.
## 🌟 Features
- **Realtime Speech-to-Speech**: Instant speech conversion powered by OpenAI's Realtime APIs.
- **Create Custom AI Agents**: Create custom agents with different personalities and voices.
- **Secure WebSockets**: Reliable, encrypted WebSocket communication.
- **Server Turn Detection**: Intelligent conversation flow handling for smooth interactions.
- **Opus Audio Compression**: High-quality audio streaming with minimal bandwidth.
- **Global Edge Performance**: Low latency Deno Edge Functions ensuring seamless global conversations.
- **ESP32 Arduino Framework**: Optimized and easy-to-use hardware integration.
## 📌 Project Architecture
ElatoAI consists of three main components:
1. **Frontend Client** (`Next.js` hosted on Vercel)
2. **Edge Server Functions** (`Deno` running on Deno/Supabase Edge)
3. **ESP32 IoT Client** (`PlatformIO/Arduino`)
1. **Frontend Client** (`Next.js` hosted on Vercel) - to create and talk to your AI agents and 'send' it to your ESP32 device
2. **Edge Server Functions** (`Deno` running on Deno/Supabase Edge) - to handle the websocket connections from the ESP32 device and the OpenAI API calls
3. **ESP32 IoT Client** (`PlatformIO/Arduino`) - to receive the websocket connections from the Edge Server Functions and send audio to the OpenAI API via the Deno edge server.
## 🌟 Features
1. **Realtime Speech-to-Speech**: Instant speech conversion powered by OpenAI's Realtime APIs.
2. **Create Custom AI Agents**: Create custom agents with different personalities and voices.
3. **Customizable Voices**: Choose from a variety of voices and personalities.
4. **Secure WebSockets**: Reliable, encrypted WebSocket communication.
5. **Server Turn Detection**: Intelligent conversation flow handling for smooth interactions.
6. **Opus Audio Compression**: High-quality audio streaming with minimal bandwidth.
7. **Global Edge Performance**: Low latency Deno Edge Functions ensuring seamless global conversations.
8. **ESP32 Arduino Framework**: Optimized and easy-to-use hardware integration.
9. **Conversation History**: View your conversation history.
10. **Device Management**: Register and manage your devices.
11. **User Authentication**: Secure user authentication and authorization.
12. **Conversations with WebRTC and Websockets**: Talk to your AI with WebRTC on the NextJS webapp and with websockets on the ESP32.
## 🛠 Tech Stack
| Component | Technology Used |

View file

@ -4,13 +4,16 @@ import { encodedRedirect } from "@/utils/utils";
import { createClient } from "@/utils/supabase/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { encryptSecret, getMacAddressFromDeviceCode, isValidMacAddress } from "@/lib/utils";
import { getMacAddressFromDeviceCode, isValidMacAddress } from "@/lib/utils";
import { addUserToDevice, dbCheckUserCode } from "@/db/devices";
import { getSimpleUserById, updateUser } from "@/db/users";
export async function deleteUserApiKey(userId: string) {
const supabase = createClient();
const { error } = await supabase.from('api_keys').delete().eq('user_id', userId);
const { error } = await supabase.from("api_keys").delete().eq(
"user_id",
userId,
);
return error;
}
@ -41,12 +44,13 @@ export const forgotPasswordAction = async (formData: FormData) => {
return encodedRedirect(
"error",
"/forgot-password",
"Email is required"
"Email is required",
);
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`,
redirectTo:
`${origin}/auth/callback?redirect_to=/protected/reset-password`,
});
if (error) {
@ -54,7 +58,7 @@ export const forgotPasswordAction = async (formData: FormData) => {
return encodedRedirect(
"error",
"/forgot-password",
"Could not reset password"
"Could not reset password",
);
}
@ -65,7 +69,7 @@ export const forgotPasswordAction = async (formData: FormData) => {
return encodedRedirect(
"success",
"/forgot-password",
"Check your email for a link to reset your password."
"Check your email for a link to reset your password.",
);
};
@ -79,7 +83,7 @@ export const resetPasswordAction = async (formData: FormData) => {
encodedRedirect(
"error",
"/protected/reset-password",
"Password and confirm password are required"
"Password and confirm password are required",
);
}
@ -87,7 +91,7 @@ export const resetPasswordAction = async (formData: FormData) => {
encodedRedirect(
"error",
"/protected/reset-password",
"Passwords do not match"
"Passwords do not match",
);
}
@ -99,7 +103,7 @@ export const resetPasswordAction = async (formData: FormData) => {
encodedRedirect(
"error",
"/protected/reset-password",
"Password update failed"
"Password update failed",
);
}
@ -118,7 +122,7 @@ export const checkDoctorAction = async (authCode: string) => {
export const connectUserToDevice = async (
userId: string,
userDeviceCode: string
userDeviceCode: string,
) => {
const supabase = createClient();
@ -131,7 +135,7 @@ export const connectUserToDevice = async (
const successfullyAdded = await addUserToDevice(
supabase,
userDeviceCode,
userId
userId,
);
return successfullyAdded;
};
@ -171,69 +175,23 @@ export const isPremiumUser = async (userId: string) => {
return dbUser?.is_premium;
};
export const setDeviceReset = async (userId: string) => {
const supabase = createClient();
await supabase
.from("users")
.update({ is_reset: true })
.eq("user_id", userId);
};
export const setDeviceOta = async (userId: string) => {
const supabase = createClient();
await supabase.from("users").update({ is_ota: true }).eq("user_id", userId);
};
export async function storeUserApiKey(userId: string, rawApiKey: string) {
const supabase = createClient();
const { iv, encryptedData } = encryptSecret(rawApiKey, process.env.ENCRYPTION_KEY!);
const { error } = await supabase
.from('api_keys')
.upsert({
user_id: userId,
encrypted_key: encryptedData,
iv: iv,
});
if (error) {
console.error('Error inserting or updating user secret:', error);
throw error;
}
console.log(`Encrypted API key for user ${userId} stored successfully.`);
}
export async function checkIfUserHasApiKey(userId: string): Promise<boolean> {
const supabase = createClient();
const { data, error } = await supabase
.from('api_keys')
.select('*', { count: 'exact' })
.eq('user_id', userId);
if (error) {
console.error('Error checking if user has API key:', error);
throw error;
}
return data.length > 0;
}
export async function registerDevice(userId: string, deviceCode: string) {
export async function registerDevice(userId: string, deviceCode: string) {
// check if deviceCode is valid mac address
if (!isValidMacAddress(deviceCode)) {
return { error: "Invalid device code" };
return { error: "Invalid device code" };
}
const supabase = createClient();
const { data, error } = await supabase
.from('devices')
.insert({ user_id: userId, user_code: deviceCode.toLowerCase(), mac_address: getMacAddressFromDeviceCode(deviceCode).toUpperCase() }).select();
.from("devices")
.insert({
user_id: userId,
user_code: deviceCode, // this is the device code that the user will use to register their device (friendly code preferred)
mac_address: deviceCode,
}).select();
if (error) {
console.log(error)
console.log(error);
return { error: "Error registering device" };
}
@ -242,21 +200,4 @@ export async function storeUserApiKey(userId: string, rawApiKey: string) {
}
return { error: null };
}
export const createPersonality = async (userId: string, personality: IPersonality): Promise<IPersonality | null> => {
const supabase = createClient();
const { data, error } = await supabase
.from('personalities')
.insert({
...personality,
creator_id: userId
}).select();
if (error) {
console.error('Error creating personality:', error);
throw error;
}
return data ? data[0] : null;
}
}

View file

@ -10,16 +10,6 @@ interface IPayload {
timestamp: string;
}
const getDoctorGuidanceHistory = async (
supabase: SupabaseClient,
userId: string,
): Promise<string> => {
const { data, error } = await supabase.from('conversations').select('*').eq('user_id', userId).eq('role', 'doctor').order('created_at', { ascending: false }).limit(10);
return data?.map((chat: IConversation) => {
const timestamp = chat.created_at ? new Date(chat.created_at).toLocaleString() : "";
return `${chat.role} [${timestamp}]: ${chat.content}`;
}).join("") ?? "";}
const getChatHistory = async (
supabase: SupabaseClient,
userId: string,
@ -27,25 +17,27 @@ const getChatHistory = async (
): Promise<string> => {
try {
let query = supabase
.from('conversations')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(10);
.from("conversations")
.select("*")
.eq("user_id", userId)
.order("created_at", { ascending: false })
.limit(10);
if (personalityKey) {
query = query.eq('personality_key', personalityKey);
}
if (personalityKey) {
query = query.eq("personality_key", personalityKey);
}
const { data, error } = await query;
if (error) throw error;
const messages = data.map((chat: IConversation) => `${chat.role}: ${chat.content}`)
.join('\n');
const messages = data.map((chat: IConversation) =>
`${chat.role}: ${chat.content}`
)
.join("\n");
return messages;
return messages;
} catch (error: any) {
throw new Error(`Failed to get chat history: ${error.message}`);
throw new Error(`Failed to get chat history: ${error.message}`);
}
};
@ -57,23 +49,11 @@ Your physical form is in the form of a physical object or a toy.
A person interacts with you by pressing a button, sends you instructions and you must respond in a concise conversational style.
`;
const DoctorPromptTemplate = (user: IUser) => {
const userMetadata = user.user_info.user_metadata as IDoctorMetadata;
const doctorName = userMetadata.doctor_name || 'Doctor';
const hospitalName = userMetadata.hospital_name || 'An amazing hospital';
const specialization = userMetadata.specialization || 'general medicine';
const favoritePhrases = userMetadata.favorite_phrases || "You're doing an amazing job";
return `
YOU ARE TALKING TO a patient under the care of doctor ${doctorName} from hospital or clinic ${hospitalName}. The child may be undergoing procedures such as ${specialization}.
YOU ARE: A friendly, compassionate toy designed to offer comfort and care. You specialize in calming children and answering their questions with simple, concise and soothing explanations.
YOUR FAVORITE PHRASES ARE: ${favoritePhrases}
`;
};
const getCommonPromptTemplate = (chatHistory: string, user: IUser, timestamp: string) => `
const getCommonPromptTemplate = (
chatHistory: string,
user: IUser,
timestamp: string,
) => `
YOUR VOICE IS: ${user.personality?.voice_prompt}
YOUR CHARACTER PROMPT IS: ${user.personality?.character_prompt}
@ -83,7 +63,9 @@ ${chatHistory}
USER'S CURRENT TIME IS: ${timestamp}
LANGUAGE:
You may talk in any language the user would like, but the default language is ${user?.language?.name ?? 'English'}.
You may talk in any language the user would like, but the default language is ${
user?.language?.name ?? "English"
}.
`;
const getStoryPromptTemplate = (user: IUser, chatHistory: string) => {
@ -122,37 +104,15 @@ Let's begin the adventure now!
`;
};
const getDoctorGuidanceTemplate = async ({user, supabase, timestamp}: IPayload) => {
const chatHistory = await getDoctorGuidanceHistory(supabase, user.user_id);
const userMetadata = user.user_info.user_metadata as IDoctorMetadata;
const doctorName = userMetadata.doctor_name || 'Doctor';
const hospitalName = userMetadata.hospital_name || 'An amazing hospital';
const specialization = userMetadata.specialization || 'general medicine';
return `
- You are talking to the doctor. Your physical form is actually a medical wellness toy for children.
- The doctor will either ask you questions or give you instructions on how to help this child.
- You must respond in a concise conversational style.
Your voice:
- Talk in a serious, sincere and professional tone.
- Do not add exclamations or excited words.
Current time:
${new Date(timestamp).toLocaleString()}.
Chat history with the doctor:
${chatHistory}
Doctor background:
The doctor's name is ${doctorName} and the hospital is ${hospitalName}. The doctor is a specialist in ${specialization}.
`
};
const createSystemPrompt = async (
payload: IPayload,
): Promise<string> => {
const { user, supabase, timestamp } = payload;
const chatHistory = await getChatHistory(supabase, user.user_id, user.personality?.key ?? null);
const chatHistory = await getChatHistory(
supabase,
user.user_id,
user.personality?.key ?? null,
);
const commonPrompt = getCommonPromptTemplate(chatHistory, user, timestamp);
const isStory = user.personality?.is_story;
@ -163,14 +123,11 @@ const createSystemPrompt = async (
let systemPrompt;
switch (user.user_info.user_type) {
case 'user':
systemPrompt = UserPromptTemplate(user);
break;
case 'doctor':
systemPrompt = DoctorPromptTemplate(user);
break;
default:
throw new Error('Invalid user type');
case "user":
systemPrompt = UserPromptTemplate(user);
break;
default:
throw new Error("Invalid user type");
}
return systemPrompt + commonPrompt;
};
@ -184,46 +141,27 @@ const createSystemPrompt = async (
*/
function decryptSecret(encryptedData: string, iv: string, masterKey: string) {
// Decode the base64 master key
const decodedKey = Buffer.from(masterKey, 'base64');
const decodedKey = Buffer.from(masterKey, "base64");
if (decodedKey.length !== 32) {
throw new Error('ENCRYPTION_KEY must be 32 bytes when decoded from base64.');
throw new Error(
"ENCRYPTION_KEY must be 32 bytes when decoded from base64.",
);
}
const decipher = crypto.createDecipheriv(
'aes-256-cbc' as any,
Buffer.from(masterKey, 'base64') as any,
Buffer.from(iv, 'base64') as any
"aes-256-cbc" as any,
Buffer.from(masterKey, "base64") as any,
Buffer.from(iv, "base64") as any,
);
let decrypted = decipher.update(encryptedData, 'base64', 'utf8');
decrypted += decipher.final('utf8');
let decrypted = decipher.update(encryptedData, "base64", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
const getOpenAiApiKey = async (
supabase: SupabaseClient,
userId: string,
): Promise<string> => {
const { data, error } = await supabase
.from('api_keys')
.select('encrypted_key, iv')
.eq('user_id', userId)
.single();
if (error) throw error;
const { encrypted_key, iv } = data;
const masterKey = process.env.ENCRYPTION_KEY!;
const decryptedKey = decryptSecret(encrypted_key, iv, masterKey);
return decryptedKey;
};
export async function GET(request: NextRequest) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@ -234,9 +172,12 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
const isDoctor = dbUser.user_info.user_type === "doctor";
const openAiApiKey = await getOpenAiApiKey(supabase, user.id);
const systemPrompt = isDoctor ? await getDoctorGuidanceTemplate({ user: dbUser, supabase, timestamp: new Date().toISOString() }) : await createSystemPrompt({ user: dbUser, supabase, timestamp: new Date().toISOString() });
const openAiApiKey = process.env.OPENAI_API_KEY;
const systemPrompt = await createSystemPrompt({
user: dbUser,
supabase,
timestamp: new Date().toISOString(),
});
try {
const response = await fetch(
@ -248,11 +189,11 @@ export async function GET(request: NextRequest) {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: 'gpt-4o-mini-realtime-preview-2024-12-17',
model: "gpt-4o-mini-realtime-preview-2024-12-17",
instructions: systemPrompt,
voice: isDoctor ? 'ballad' : dbUser.personality?.oai_voice ?? 'ballad',
voice: dbUser.personality?.oai_voice ?? "ballad",
}),
}
},
);
const data = await response.json();
return NextResponse.json(data);
@ -260,7 +201,7 @@ export async function GET(request: NextRequest) {
console.error("Error in /session:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
{ status: 500 },
);
}
}

View file

@ -1,92 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Check, Key, Trash } from "lucide-react";
import { checkIfUserHasApiKey, storeUserApiKey, deleteUserApiKey } from "../actions";
import { useState } from "react";
import { useToast } from "@/components/ui/use-toast";
interface AuthTokenModalProps {
user: IUser;
userHasApiKey: () => void;
hasApiKey: boolean;
setHasApiKey: (hasApiKey: boolean) => void;
}
const AuthTokenModal: React.FC<AuthTokenModalProps> = ({ user, userHasApiKey, hasApiKey, setHasApiKey }) => {
const { toast } = useToast();
const [apiKey, setApiKey] = useState<string>("");
return (
<Dialog>
<DialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="flex flex-row items-center gap-2"
onClick={async () => {
userHasApiKey();
}}
>
<Key size={16} />
<span>Set your key</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Set your OpenAI API Key</DialogTitle>
<DialogDescription>
This key is kept encrypted and never stored on our servers as plain text.
</DialogDescription>
</DialogHeader>
<div className="flex flex-row gap-2 py-4">
<Input
id="api_key"
value={apiKey}
disabled={hasApiKey}
placeholder={hasApiKey ? "sk-... (OpenAI API Key already set)" : "sk-..."}
onChange={(e) => {
setApiKey(e.target.value);
}}
/>
<Button
size="icon"
variant="ghost"
// disabled={!apiKey || hasApiKey}
onClick={async () => {
if (!hasApiKey) {
await storeUserApiKey(user.user_id, apiKey);
setHasApiKey(true); // Set this immediately
userHasApiKey();
toast({
description: "OpenAI API Key added",
});
setApiKey("********************");
} else {
await deleteUserApiKey(user.user_id);
setHasApiKey(false);
userHasApiKey();
toast({
description: "OpenAI API Key removed",
});
}
}}
className="flex-shrink-0"
>
{!hasApiKey ?<Check className="flex-shrink-0" size={18} /> : <Trash className="flex-shrink-0" size={18} />}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default AuthTokenModal;

View file

@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ArrowLeft, ArrowRight, Check, Mic, Volume2 } from "lucide-react";
import Twemoji from "react-twemoji";
import { createPersonality } from "../../actions";
import { createPersonality } from "@/db/personalities";
import { v4 as uuidv4 } from 'uuid';
import { toast } from "@/components/ui/use-toast";
import { useRouter } from "next/navigation";
@ -163,7 +163,7 @@ const SettingsDashboard: React.FC<SettingsDashboardProps> = ({
}
try {
const personality = await createPersonality(selectedUser.user_id, {
const personality = await createPersonality(supabase, selectedUser.user_id, {
title: formData.title,
subtitle: "",
character_prompt: formData.prompt,

View file

@ -1,44 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { getCreditsRemaining } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import AddCreditsModal from "./Upsell/AddCreditsModal";
const CreditsRemaining: React.FC<{
user: IUser;
languageCode: string;
}> = ({ user, languageCode }) => {
const creditsRemaining = getCreditsRemaining(user);
const hasNoCredits = creditsRemaining <= 0;
if (user.is_premium) {
return null;
}
return (
<div className="flex flex-row items-center gap-4">
<p
className={`text-sm ${hasNoCredits ? "text-gray-400" : "text-gray-400"}`}
>
{creditsRemaining} {"credits remaining"}
</p>
{creditsRemaining <= 50 && (
<AddCreditsModal>
<Button
variant="upsell_link"
className="flex flex-row items-center gap-2 pl-0"
size="sm"
>
<Sparkles size={16} />
{hasNoCredits
? "Upgrade to continue"
: "Get unlimited access"}
</Button>
</AddCreditsModal>
)}
</div>
);
};
export default CreditsRemaining;

View file

@ -1,4 +1,3 @@
import CreditsRemaining from "./CreditsRemaining";
interface HomePageSubtitlesProps {
user: IUser;
page: "home" | "settings" | "create";

View file

@ -1,59 +1,71 @@
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { motion } from 'framer-motion';
import SheetWrapper from '../Playground/SheetWrapper';
import Image from "next/image";
import { cn, getPersonalityImageSrc } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmojiComponent } from "../Playground/EmojiImage";
// Example character data - replace with your actual data
const characters = [
{ id: 1, name: 'Assistant', description: 'Helpful AI assistant', image: '/characters/assistant.png' },
{ id: 2, name: 'Storyteller', description: 'Creative storytelling companion', image: '/characters/storyteller.png' },
{ id: 3, name: 'Tutor', description: 'Patient educational guide', image: '/characters/tutor.png' },
{ id: 4, name: 'Comedian', description: 'Witty joke-telling friend', image: '/characters/comedian.png' },
{ id: 5, name: 'Chef', description: 'Culinary expert and recipe advisor', image: '/characters/chef.png' },
{ id: 6, name: 'Fitness Coach', description: 'Motivational exercise companion', image: '/characters/fitness.png' },
// Add more characters as needed
];
interface CharacterShowcaseProps {
allPersonalities: IPersonality[];
}
export const CharacterShowcase = ({ allPersonalities }: CharacterShowcaseProps) => {
const [hoveredCharacter, setHoveredCharacter] = useState<number | null>(null);
return (
<section className="py-16 bg-gradient-to-b from-gray-50 to-white">
<div className="container mx-auto px-4 max-w-screen-lg">
<div className="flex flex-col lg:flex-row items-center gap-12">
{/* Character List - On left for desktop, bottom for mobile */}
<div className="order-2 lg:order-1 w-full lg:w-3/5 sm:max-w-[400px] mx-auto">
<div className="relative">
{/* Top scroll indicator/halo */}
<div className="absolute top-0 left-0 right-0 h-8 bg-gradient-to-b from-gray-50 to-transparent z-10 pointer-events-none rounded-t-lg"></div>
<div className="h-[500px] mx-auto overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 rounded-lg">
<div className="grid grid-cols-2 gap-4 md:gap-6 p-4">
{allPersonalities.map((personality, index) => (
<SheetWrapper
languageState={'en-US'}
key={index + personality.personality_id!}
personality={personality}
personalityIdState={''}
onPersonalityPicked={() => {}}
disableButtons={true}
/>
<Card
className={cn(
"p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col hover:border-primary/50",
)}
>
<CardContent className="flex-shrink-0 p-0 h-[160px] sm:h-[180px] relative">
{personality.creator_id === null ? (
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={180}
className="rounded-3xl rounded-br-none rounded-bl-none w-full h-full object-cover"
/>
) : (
<div className="flex flex-row items-center justify-center h-full">
<EmojiComponent personality={personality} />
</div>
)}
</CardContent>
<CardHeader className="flex-shrink-0 gap-0 px-4 py-2">
<CardTitle className="font-semibold text-md flex flex-row items-center gap-2">
{personality.title}
</CardTitle>
<CardDescription className="text-sm font-normal">
{personality.subtitle}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
{/* Bottom scroll indicator/halo */}
<div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-gray-50 to-transparent z-10 pointer-events-none rounded-b-lg"></div>
</div>
</div>
{/* Text Content - On right for desktop, top for mobile */}
<div className="order-1 lg:order-2 w-full lg:w-2/5">
<h2 className="text-3xl md:text-4xl font-bold mb-6 text-gray-800">
Meet Our AI Characters

View file

@ -1,5 +1,16 @@
import { EnglishCopy } from "@/utils/i18n";
import SheetWrapper from "./SheetWrapper";
import ModifyCharacterSheet from "./ModifyCharacterSheet";
import Image from "next/image";
import { cn, getPersonalityImageSrc } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Check, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmojiComponent } from "./EmojiImage";
interface CharacterSectionProps {
allPersonalities: IPersonality[];
@ -20,6 +31,7 @@ const CharacterSection = ({
disableButtons,
selectedFilters,
}: CharacterSectionProps) => {
const filteredPersonalities = allPersonalities.filter((personality) => {
return selectedFilters.every((filter) => {
return personality[filter] === true;
@ -37,16 +49,66 @@ const CharacterSection = ({
</p>
<div className="w-full">
<div className="grid grid-cols-2 lg:grid-cols-4 md:gap-6 gap-4">
{filteredPersonalities.map((personality, index) => (
<SheetWrapper
languageState={languageState}
key={index + personality.personality_id!}
personality={personality}
personalityIdState={personalityIdState}
onPersonalityPicked={onPersonalityPicked}
disableButtons={disableButtons}
/>
))}
{filteredPersonalities.map((personality, index) => {
const isCurrentPersonality = personalityIdState === personality.personality_id;
return (
<ModifyCharacterSheet
key={personality.personality_id}
openPersonality={personality}
languageState={languageState}
isCurrentPersonality={isCurrentPersonality}
onPersonalityPicked={onPersonalityPicked}
disableButtons={disableButtons}
>
<Card
className={cn(
"p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col",
personalityIdState === personality.personality_id
? "border-primary border-2"
: "hover:border-primary/50"
)}
// onClick={() => onPersonalityPicked(personality)}
>
<CardContent className="flex-shrink-0 p-0 h-[160px] sm:h-[180px] relative">
{personality.creator_id === null ? (
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={180}
className="rounded-3xl rounded-br-none rounded-bl-none w-full h-full object-cover"
/>
) : (
<div className="flex flex-row items-center justify-center h-full">
<EmojiComponent personality={personality} />
</div>
)}
<Button
size="sm"
variant={isCurrentPersonality ? "default" : "outline"}
className={`absolute shadow-lg top-2 right-2 rounded-full h-9 w-9 p-0 ${isCurrentPersonality ? "bg-primary text-primary-foreground" : ""}`}
onClick={() => onPersonalityPicked(personality.personality_id!)}
aria-label={isCurrentPersonality ? "Deselect feature" : "Select feature"}
>
{true ? (
<Check className="h-4 w-4" strokeWidth={3} />
) : (
<CheckCircle className="h-4 w-4" strokeWidth={3} />
)}
</Button>
</CardContent>
<CardHeader className="flex-shrink-0 gap-0 px-4 py-2">
<CardTitle className="font-semibold text-md flex flex-row items-center gap-2">
{personality.title}
</CardTitle>
<CardDescription className="text-sm font-normal">
{personality.subtitle}
</CardDescription>
</CardHeader>
</Card>
</ModifyCharacterSheet>
);
})}
</div>
</div>
</div>

View file

@ -41,8 +41,6 @@ const ModifyCharacterSheet: React.FC<ModifyCharacterSheetProps> = ({
return (
<div className="flex flex-row gap-4 p-4 ">
<Button
// tabIndex={-1}
// autoFocus={false}
size="lg"
className={`w-full rounded-full text-sm md:text-lg flex flex-row items-center gap-1 md:gap-2 transition-colors duration-300 ${
isSent || isCurrentPersonality
@ -60,7 +58,7 @@ const ModifyCharacterSheet: React.FC<ModifyCharacterSheetProps> = ({
<Check className="flex-shrink-0 h-5 w-5 md:h-6 md:w-6" />
{isSent || isCurrentPersonality
? "Live character"
: "Chat on Elato"}
: "Click to chat"}
</Button>
</div>
);

View file

@ -1,52 +0,0 @@
import DoctorPersonalities from "./DoctorPersonalities";
import UserPersonalities from "./UserPersonalities";
interface PickPersonalityProps {
onPersonalityPicked: (personalityIdPicked: string) => void;
allPersonalities: IPersonality[];
personalityIdState: string;
currentUser?: IUser;
languageState: string;
disableButtons: boolean;
selectedFilters: PersonalityFilter[];
myPersonalities: IPersonality[];
}
const PickPersonality: React.FC<PickPersonalityProps> = ({
onPersonalityPicked,
allPersonalities,
personalityIdState,
currentUser,
languageState,
disableButtons,
selectedFilters,
myPersonalities,
}) => {
if (currentUser?.user_info?.user_type === "doctor") {
return (
<DoctorPersonalities
onPersonalityPicked={onPersonalityPicked}
allPersonalities={allPersonalities}
personalityIdState={personalityIdState}
languageState={languageState}
disableButtons={disableButtons}
selectedFilters={selectedFilters}
myPersonalities={myPersonalities}
/>
);
}
return (
<UserPersonalities
myPersonalities={myPersonalities}
onPersonalityPicked={onPersonalityPicked}
allPersonalities={allPersonalities}
personalityIdState={personalityIdState}
languageState={languageState}
disableButtons={disableButtons}
selectedFilters={selectedFilters}
/>
);
};
export default PickPersonality;

View file

@ -1,9 +1,7 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import React, { useState } from "react";
import { createClient } from "@/utils/supabase/client";
import { getCreditsRemaining } from "@/lib/utils";
import PickPersonality from "./PickPersonality";
import { updateUser } from "@/db/users";
import _ from "lodash";
import HomePageSubtitles from "../HomePageSubtitles";
@ -11,8 +9,8 @@ import PersonalityFilters from "./PersonalityFilters";
import { TranscriptProvider } from "../Realtime/contexts/TranscriptContext";
import { EventProvider } from "../Realtime/contexts/EventContext";
import App from "../Realtime/App";
import { checkIfUserHasApiKey } from "@/app/actions";
import { defaultPersonalityId } from "@/lib/data";
import UserPersonalities from "./UserPersonalities";
interface PlaygroundProps {
currentUser: IUser;
@ -25,20 +23,10 @@ const Playground: React.FC<PlaygroundProps> = ({
allPersonalities,
myPersonalities,
}) => {
const [hasApiKey, setHasApiKey] = useState<boolean>(false);
const isDoctor = currentUser.user_info.user_type === "doctor";
useEffect(() => {
const checkApiKey = async () => {
const hasApiKey = await checkIfUserHasApiKey(currentUser.user_id);
setHasApiKey(hasApiKey);
};
checkApiKey();
}, [currentUser.user_id]);
const supabase = createClient();
// Remove userState entirely and just use personalityState
const [personalityIdState, setPersonalityIdState] = useState<string>(
currentUser.personality!.personality_id ?? defaultPersonalityId // Initial value from props
);
@ -47,12 +35,7 @@ const Playground: React.FC<PlaygroundProps> = ({
[]
);
const creditsRemaining = getCreditsRemaining(currentUser);
const outOfCredits = creditsRemaining <= 0 && !currentUser.is_premium;
// const ref: any = useRef<ComponentRef<typeof Messages> | null>(null);
const onPersonalityPicked = async (personalityIdPicked: string) => {
// Instantaneously update the state variable
setPersonalityIdState(personalityIdPicked);
await updateUser(
supabase,
@ -74,7 +57,7 @@ const Playground: React.FC<PlaygroundProps> = ({
<div className="flex flex-col gap-8 items-center justify-center">
<TranscriptProvider>
<EventProvider>
<App hasApiKey={hasApiKey} personalityIdState={personalityIdState} isDoctor={isDoctor} userId={currentUser.user_id} />
<App personalityIdState={personalityIdState} isDoctor={isDoctor} userId={currentUser.user_id} />
</EventProvider>
</TranscriptProvider>
</div>
@ -93,11 +76,10 @@ const Playground: React.FC<PlaygroundProps> = ({
languageState={'en-US'}
currentUser={currentUser}
/>
<PickPersonality
<UserPersonalities
selectedFilters={selectedFilters}
onPersonalityPicked={onPersonalityPicked}
personalityIdState={personalityIdState}
currentUser={currentUser}
languageState={'en-US'}
disableButtons={false}
allPersonalities={isDoctor
@ -107,16 +89,6 @@ const Playground: React.FC<PlaygroundProps> = ({
/>
</div>
</div>
{/* <ControlPanel
connectionStatus={connectionStatus}
isMuted={isMuted}
muteMicrophone={muteMicrophone}
unmuteMicrophone={unmuteMicrophone}
handleClickInterrupt={handleClickInterrupt}
handleClickCloseConnection={handleClickCloseConnection}
microphoneStream={microphoneStream}
audioBuffer={audioBuffer}
/> */}
</div>
);
};

View file

@ -1,90 +0,0 @@
import ModifyCharacterSheet from "./ModifyCharacterSheet";
import Image from "next/image";
import { cn, getPersonalityImageSrc } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Check, CheckCircle, CircleCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmojiComponent } from "./EmojiImage";
interface SheetWrapperProps {
personality: IPersonality;
personalityIdState: string;
onPersonalityPicked: (personalityId: string) => void;
languageState: string;
disableButtons: boolean;
}
const SheetWrapper: React.FC<SheetWrapperProps> = ({
personality,
personalityIdState,
onPersonalityPicked,
languageState,
disableButtons,
}) => {
const isCurrentPersonality = personalityIdState === personality.personality_id;
return (
<ModifyCharacterSheet
key={personality.personality_id}
openPersonality={personality}
languageState={languageState}
isCurrentPersonality={isCurrentPersonality}
onPersonalityPicked={onPersonalityPicked}
disableButtons={disableButtons}
>
<Card
className={cn(
"p-0 rounded-3xl cursor-pointer shadow-lg transition-all hover:scale-103 flex flex-col",
personalityIdState === personality.personality_id
? "border-primary border-2"
: "hover:border-primary/50"
)}
// onClick={() => onPersonalityPicked(personality)}
>
<CardContent className="flex-shrink-0 p-0 h-[160px] sm:h-[180px] relative">
{personality.creator_id === null ? (
<Image
src={getPersonalityImageSrc(personality.key)}
alt={personality.key}
width={100}
height={180}
className="rounded-3xl rounded-br-none rounded-bl-none w-full h-full object-cover"
/>
) : (
<div className="flex flex-row items-center justify-center h-full">
<EmojiComponent personality={personality} />
</div>
)}
<Button
size="sm"
variant={isCurrentPersonality ? "default" : "outline"}
className={`absolute shadow-lg top-2 right-2 rounded-full h-9 w-9 p-0 ${isCurrentPersonality ? "bg-primary text-primary-foreground" : ""}`}
onClick={() => onPersonalityPicked(personality.personality_id!)}
aria-label={isCurrentPersonality ? "Deselect feature" : "Select feature"}
>
{true ? (
<Check className="h-4 w-4" strokeWidth={3} />
) : (
<CheckCircle className="h-4 w-4" strokeWidth={3} />
)}
</Button>
</CardContent>
<CardHeader className="flex-shrink-0 gap-0 px-4 py-2">
<CardTitle className="font-semibold text-md flex flex-row items-center gap-2">
{personality.title}
</CardTitle>
<CardDescription className="text-sm font-normal">
{personality.subtitle}
</CardDescription>
</CardHeader>
</Card>
</ModifyCharacterSheet>
);
};
export default SheetWrapper;

View file

@ -32,7 +32,7 @@ const UserPersonalities: React.FC<UserPersonalitiesProps> = ({
disableButtons={disableButtons}
/>
)}
<CharacterSection
<CharacterSection
allPersonalities={allPersonalities}
languageState={languageState}
personalityIdState={personalityIdState}
@ -42,7 +42,7 @@ const UserPersonalities: React.FC<UserPersonalitiesProps> = ({
selectedFilters={selectedFilters}
/>
</div>
);
};

View file

@ -25,17 +25,14 @@ import { getPersonalityById } from "@/db/personalities";
import { createClient } from "@/utils/supabase/client";
interface AppProps {
hasApiKey: boolean;
personalityIdState: string;
isDoctor: boolean;
userId: string;
}
function App({ hasApiKey, personalityIdState, isDoctor, userId }: AppProps) {
function App({ personalityIdState, isDoctor, userId }: AppProps) {
const supabase = createClient();
const searchParams = useSearchParams();
const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } =
useTranscript();
const { logClientEvent, logServerEvent } = useEvent();
@ -376,8 +373,6 @@ function App({ hasApiKey, personalityIdState, isDoctor, userId }: AppProps) {
<BottomToolbar
sessionStatus={sessionStatus}
onToggleConnection={onToggleConnection}
hasApiKey={hasApiKey}
personality={personality}
isDoctor={isDoctor}
/>
</div>

View file

@ -3,24 +3,16 @@ import { SessionStatus } from "@/app/components/Realtime/types";
import { Paperclip, PhoneCall, Play, Stethoscope } from "lucide-react";
import { Loader2, X } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { getPersonalityById } from "@/db/personalities";
import { createClient } from "@/utils/supabase/client";
import Image from "next/image";
import { EmojiComponent } from "../../Playground/EmojiImage";
interface BottomToolbarProps {
sessionStatus: SessionStatus;
onToggleConnection: () => void;
hasApiKey: boolean;
personality: IPersonality;
isDoctor: boolean;
}
function BottomToolbar({
sessionStatus,
onToggleConnection,
hasApiKey,
personality,
isDoctor,
}: BottomToolbarProps) {
const isConnected = sessionStatus === "CONNECTED";
@ -44,7 +36,7 @@ function BottomToolbar({
return "Doctor chat";
}
const isDisabled = isConnecting || !hasApiKey;
const isDisabled = isConnecting;
function getConnectionButtonClasses() {
const baseClasses = "text-white text-base p-2 w-fit rounded-full shadow-lg flex flex-row items-center justify-center gap-2 px-4";
@ -69,27 +61,13 @@ function BottomToolbar({
<TooltipTrigger asChild>
<button
onClick={() => {
if (hasApiKey) {
if (process.env.OPENAI_API_KEY) {
onToggleConnection();
}
}}
className={getConnectionButtonClasses()}
disabled={isDisabled}
>
{/* {personality?.creator_id == null ? (
<Image
src={`/personality/${personality?.key}.jpeg`}
alt={personality?.title || "Personality avatar"}
className="w-10 h-10 rounded-tl-full rounded-bl-full mr-2 object-cover"
width={40}
height={40}
quality={100}
onError={(e) => {
// Fallback if image fails to load
e.currentTarget.style.display = 'none';
}}
/>
) : <EmojiComponent personality={personality} size={28} />} */}
{getConnectionButtonIcon()}
{isDoctor ? getConnectionButtonLabelForDoctor() : getConnectionButtonLabel()}
</button>

View file

@ -1,4 +1,4 @@
import { checkIfUserHasApiKey, registerDevice, signOutAction } from "@/app/actions";
import { registerDevice, signOutAction } from "@/app/actions";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@ -35,15 +35,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
const userFormRef = React.useRef<{ submitForm: () => void } | null>(null);
const [deviceCode, setDeviceCode] = React.useState("");
const [error, setError] = React.useState("");
const [hasApiKey, setHasApiKey] = React.useState<boolean>(false);
const userHasApiKey = useCallback(async () => {
const hasApiKey = await checkIfUserHasApiKey(selectedUser.user_id);
setHasApiKey(hasApiKey);
}, [selectedUser.user_id]);
// ... existing code ...
const handleSave = () => {
if (selectedUser.user_info.user_type === "doctor") {
doctorFormRef.current?.submitForm();
@ -58,16 +50,9 @@ const AppSettings: React.FC<AppSettingsProps> = ({
);
}, [selectedUser.user_id, supabase]);
React.useEffect(() => {
checkIfUserHasDevice();
userHasApiKey();
}, [checkIfUserHasDevice, userHasApiKey]);
const [volume, setVolume] = React.useState([
selectedUser.device?.volume ?? 50,
]);
const [isReset, setIsReset] = React.useState(selectedUser.device?.is_reset ?? false);
const [isOta, setIsOta] = React.useState(selectedUser.device?.is_ota ?? false);
const debouncedUpdateVolume = _.debounce(async () => {
if (selectedUser.device?.device_id) {
@ -137,24 +122,6 @@ const AppSettings: React.FC<AppSettingsProps> = ({
Device settings
</h2>
<div className="flex flex-col gap-6">
{/* <div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
<Label className="text-sm font-medium text-gray-700">
Set your OpenAI API Key
</Label>
<div
className={`rounded-full flex-shrink-0 h-2 w-2 ${
hasApiKey ? 'bg-green-500' : 'bg-amber-500'
}`}
/>
</div>
<div className="flex flex-row items-center gap-2 mt-2">
<AuthTokenModal user={selectedUser} userHasApiKey={userHasApiKey} hasApiKey={hasApiKey} setHasApiKey={setHasApiKey} />
</div>
<p className="text-xs text-gray-400">
Your keys are E2E encrypted and never stored on our servers as plain text.
</p>
</div> */}
<div className="flex flex-col gap-2">
<div className="flex flex-row items-center gap-2">
<Label className="text-sm font-medium text-gray-700">
@ -166,7 +133,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
?
</TooltipTrigger>
<TooltipContent>
<p>For simplicity, you can register your ESP32 MAC address here without colons</p>
<p>For simplicity, you can register your ESP32 MAC address here. <br /> Ideally you want this to be a friendly code for your device.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -183,7 +150,7 @@ const AppSettings: React.FC<AppSettingsProps> = ({
value={deviceCode}
disabled={isConnected}
onChange={(e) => setDeviceCode(e.target.value)}
placeholder={isConnected ? "**********" : "Enter your device code"}
placeholder={isConnected ? "**********" : "Enter your ESP32-S3 MAC address"}
/>
<Button
size="sm"
@ -203,81 +170,10 @@ const AppSettings: React.FC<AppSettingsProps> = ({
<p className="text-xs text-gray-400">
{isConnected ? <span className="font-medium text-gray-800">Registered!</span> :
error ? <span className="text-red-500">{error}.</span> :
"Add your device code to your account to register it."
"Add your ESP32-S3 MAC address (e.g. 12:34:56:78:9A:BC) to your account to register it."
}
</p>
</div>
{/* <div className="flex flex-col gap-4 flex-nowrap">
<Label className="text-sm font-medium text-gray-700">
Over-the-air (OTA) updates
</Label>
<div className="flex flex-col gap-2">
<Button
size="sm"
variant="outline"
className="font-normal flex flex-row items-center gap-2 w-fit"
onClick={async () => {
setIsOta(true);
await setDeviceOta(
selectedUser.user_id
);
}}
disabled={isOta}
>
<RefreshCw size={16} />
<span>Update</span>
</Button>
{isOta ? (
<p className="text-xs text-gray-400 inline">
<Check
size={16}
className="inline-block mr-1"
/>
Your device will be updated on next
start
</p>
) : (
<p className="text-xs text-gray-400">
This will update your device software to
the latest version.
</p>
)}
</div>
<Label className="text-sm font-medium text-gray-700">
Factory reset
</Label>
<div className="flex flex-col gap-2">
<Button
size="sm"
variant="outline"
className="font-normal flex flex-row items-center gap-2 w-fit"
onClick={async () => {
setIsReset(true);
await setDeviceReset(
selectedUser.user_id
);
}}
disabled={isReset}
>
<Cog size={16} />
<span>Factory reset</span>
</Button>
{isReset ? (
<p className="text-xs text-gray-400 inline">
<Check
size={16}
className="inline-block mr-1"
/>
Your device will be factory reset on next start
</p>
) : (
<p className="text-xs text-gray-400">
Caution: This will reset your wifi and authentication details on your device.
</p>
)}
</div>
</div> */}
<div className="flex flex-col gap-2 mt-2">
<Label className="text-sm font-medium text-gray-700">
Logged in as

View file

@ -1,90 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import StripePricingTable from "../PricingTable";
interface AddCreditsModalProps {
children: React.ReactNode;
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
const buttonText = "Proceed to Payment";
return (
<form className={cn("grid items-start gap-4", className)}>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
defaultValue="shadcn@example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@shadcn" />
</div>
<Button type="submit" variant="upsell_primary">
{buttonText}
</Button>
</form>
);
}
const AddCreditsModal: React.FC<AddCreditsModalProps> = ({ children }) => {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const title = "Explore Elato's Voice Subscription Plans";
const subtitle =
"Unlock more features and get more done with Elato Premium Plans.";
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[670px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{subtitle}</DialogDescription>
</DialogHeader>
<StripePricingTable />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{subtitle}</DrawerDescription>
</DrawerHeader>
<StripePricingTable />
</DrawerContent>
</Drawer>
);
};
export default AddCreditsModal;

View file

@ -1,95 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface AddCreditsModalProps {
children: React.ReactNode;
}
function ProfileForm({ className }: React.ComponentProps<"form">) {
const buttonText = "Proceed to your Elato Kit - $59";
return (
<form className={cn("grid items-start gap-4", className)}>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
type="email"
id="email"
defaultValue="shadcn@example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@shadcn" />
</div>
<Button type="submit" variant="upsell_primary">
{buttonText}
</Button>
</form>
);
}
const PreorderModal: React.FC<AddCreditsModalProps> = ({ children }) => {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const title = "Preorder a Elato Kit";
const subtitle = "Get early access to our latest product.";
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{subtitle}</DialogDescription>
</DialogHeader>
<ProfileForm />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>{title}</DrawerTitle>
<DrawerDescription>{subtitle}</DrawerDescription>
</DrawerHeader>
<ProfileForm className="px-4" />
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
};
export default PreorderModal;

View file

@ -1,6 +1,29 @@
import { SupabaseClient } from "@supabase/supabase-js";
export const getPersonalityById = async (supabase: SupabaseClient, personalityId: string) => {
export const createPersonality = async (
supabase: SupabaseClient,
userId: string,
personality: IPersonality,
): Promise<IPersonality | null> => {
const { data, error } = await supabase
.from("personalities")
.insert({
...personality,
creator_id: userId,
}).select();
if (error) {
console.error("Error creating personality:", error);
throw error;
}
return data ? data[0] : null;
};
export const getPersonalityById = async (
supabase: SupabaseClient,
personalityId: string,
) => {
const { data, error } = await supabase
.from("personalities")
.select(`*`)
@ -12,7 +35,7 @@ export const getPersonalityById = async (supabase: SupabaseClient, personalityId
}
return data[0] as IPersonality;
}
};
export const getAllPersonalities = async (supabase: SupabaseClient) => {
const { data, error } = await supabase
@ -20,7 +43,7 @@ export const getAllPersonalities = async (supabase: SupabaseClient) => {
.select(`*`)
.is("creator_id", null)
.order("created_at", { ascending: false });
if (error) {
console.log("error getAllPersonalities", error);
return [];
@ -29,12 +52,15 @@ export const getAllPersonalities = async (supabase: SupabaseClient) => {
return data as IPersonality[];
};
export const getMyPersonalities = async (supabase: SupabaseClient, userId: string) => {
export const getMyPersonalities = async (
supabase: SupabaseClient,
userId: string,
) => {
const { data, error } = await supabase
.from("personalities")
.select(`*`)
.eq("creator_id", userId);
if (error) {
console.log("error getMyPersonalities", error);
return [];

View file

@ -1,13 +1,7 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import {
defaultPersonalityId,
defaultToyId,
INITIAL_CREDITS,
SECONDS_PER_CREDIT,
} from "./data";
import crypto from "crypto";
import { createClient } from "@/utils/supabase/client";
import { defaultPersonalityId } from "./data";
export const getOpenGraphMetadata = (title: string) => {
return {
openGraph: {
@ -18,14 +12,16 @@ export const getOpenGraphMetadata = (title: string) => {
// code in the form: aabbccddeeff
export const isValidMacAddress = (macAddress: string): boolean => {
// Check if macAddress is a valid MAC address without separators
const macRegex = /^[0-9A-Fa-f]{12}$/;
// Check if macAddress is a valid MAC address with colon separators
const macRegex = /^([0-9A-Fa-f]{2}:){5}([0-9A-Fa-f]{2})$/;
return macRegex.test(macAddress);
};
export const getMacAddressFromDeviceCode = (deviceCode: string): string => {
// add colons to the device code
return deviceCode.substring(0, 2) + ":" + deviceCode.substring(2, 4) + ":" + deviceCode.substring(4, 6) + ":" + deviceCode.substring(6, 8) + ":" + deviceCode.substring(8, 10) + ":" + deviceCode.substring(10, 12);
return deviceCode.substring(0, 2) + ":" + deviceCode.substring(2, 4) + ":" +
deviceCode.substring(4, 6) + ":" + deviceCode.substring(6, 8) + ":" +
deviceCode.substring(8, 10) + ":" + deviceCode.substring(10, 12);
};
export const getPersonalityImageSrc = (title: string) => {
@ -53,46 +49,9 @@ export const getBaseUrl = () => {
};
export const getUserAvatar = (avatar_url: string) => {
// return `/kidAvatar_boy_1.png`;
return avatar_url;
// get random number between 0 and 9
// const randomNum = Math.floor(Math.random() * 10);
// return `/user_avatar/user_avatar_${randomNum}.png`;
};
export const getAssistantAvatar = (imageSrc: string) => {
return "/" + imageSrc + ".png";
};
export const getCreditsRemaining = (user: IUser): number => {
const usedCredits = user.session_time / SECONDS_PER_CREDIT;
const remainingCredits = Math.round(INITIAL_CREDITS - usedCredits);
return Math.max(0, remainingCredits); // Ensure credits don't go below 0
};
export function encryptSecret(secret: string, masterKey: string) {
// Decode the base64 master key
const decodedKey = Buffer.from(masterKey, 'base64');
if (decodedKey.length !== 32) {
throw new Error('ENCRYPTION_KEY must be 32 bytes when decoded from base64.');
}
// Generate a 16-byte IV
const ivBuf = crypto.randomBytes(16);
// Use type assertion to string for algorithm and force the types for key and iv
const cipher = crypto.createCipheriv(
'aes-256-cbc' as string,
decodedKey as unknown as crypto.CipherKey,
ivBuf as unknown as crypto.BinaryLike
);
let encrypted = cipher.update(secret, 'utf8', 'base64');
encrypted += cipher.final('base64');
return {
iv: ivBuf.toString('base64'),
encryptedData: encrypted,
};
}