Creating a Live Estimation App with ChatGPT - Part 2

If you haven't yet please read Creating a Live Estimation App with ChatGPT - Part 1

Part 2

Since the last post this I've created a Next.js app with Typescript, Tailwind, Pages directory, and started making some of the files suggested.

The sql query it gave my for the create table room had a non-null requirement on lead_name, but the room would be generated before the lead_name was known. I changed the query to be nullable here:

ALTER TABLE rooms ALTER COLUMN lead_name DROP NOT NULL;

Here is my /lib/supabase.ts file, since I'm using TypeScript:

import { createClient } from '@supabase/supabase-js';

const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;

if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
  throw new Error('Missing Supabase URL or anonymous key');
}

export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

Here's my /api/join-room.ts file:

import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@deps/lib/supabase';

interface JoinRoomResponse {
    success?: boolean;
    error?: string;
    is_lead?: boolean;
}

export default async function handler(req: NextApiRequest, res: NextApiResponse<JoinRoomResponse>) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed.' });
    }

    const { name, room_id } = req.body;

    try {
        // Check if user already exists
        const { data: existingUser, error: existingUserError } = await supabase
            .from('users')
            .select('name')
            .eq('room_id', room_id)
            .eq('name', name);

        if (existingUserError) {
            throw existingUserError;
        }

        // If user does not exist, create a new one
        if (!existingUser?.length) {
            const { error: insertError } = await supabase.from('users').insert([{ name, room_id }]);

            if (insertError) {
                throw insertError;
            }
        }

        const { data: room, error: roomError } = await supabase.from('rooms').select('lead_name').eq('id', room_id);

        if (roomError) {
            throw roomError;
        }

        const is_lead = room?.[0]?.lead_name === name;

        return res.status(200).json({ success: true, is_lead });
    } catch (error) {
        if (error instanceof Error) {
            // Handle error
            return res.status(500).json({ error: error.message });
        }
        return res.status(500).json({ error: 'An error occurred when joining the room.' });
    }
}

I added logic to return if you are the lead or not. I also updated the getServerSideProps:

export async function getServerSideProps(context: GetServerSidePropsContext) {
    const { join_code } = context.params!; // Use "!" to assert that context.params is not undefined

    if (!join_code) {
        return {
            props: {
                error: 'Sorry this join code is invalid',
            },
        };
    }

    // Get current time and time 24 hours ago
    const now = new Date();
    const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);

    const { data: rooms } = await supabase.from('room').select('*').eq('join_code', join_code).gte('created_at', oneDayAgo);

    if (!rooms || rooms.length === 0) {
        return {
            props: {
                error: 'Sorry this join code is invalid',
            },
        };
    }

    const room = rooms[0];

    const { data: users } = await supabase.from('users').select('*').eq('room_id', room.id);

    return {
        props: { room, initialUsers: users },
    };
}

Next, I wanted to create a new API endpoint to create a room. Notice how I prompt ChatGPT with an example of the previous code.

...

User: Let's make a create-room api call.

import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@deps/lib/supabase';

interface JoinRoomResponse {
    success?: boolean;
    error?: string;
    is_lead?: boolean;
}

export default async function handler(req: NextApiRequest, res: NextApiResponse<JoinRoomResponse>) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed.' });
    }

    const { name, room_id } = req.body;

    try {
        // Check if user already exists
        const { data: existingUser, error: existingUserError } = await supabase
            .from('users')
            .select('name')
            .eq('room_id', room_id)
            .eq('name', name);

        if (existingUserError) {
            throw existingUserError;
        }

        // If user does not exist, create a new one
        if (!existingUser?.length) {
            const { error: insertError } = await supabase.from('users').insert([{ name, room_id }]);

            if (insertError) {
                throw insertError;
            }
        }

        const { data: room, error: roomError } = await supabase.from('rooms').select('lead_name').eq('id', room_id);

        if (roomError) {
            throw roomError;
        }

        const is_lead = room?.[0]?.lead_name === name;

        return res.status(200).json({ success: true, is_lead });
    } catch (error) {
        if (error instanceof Error) {
            // Handle error
            return res.status(500).json({ error: error.message });
        }
        return res.status(500).json({ error: 'An error occurred when joining the room.' });
    }
}

...

System: The create-room API call is responsible for creating a new room, and possibly the lead user for that room. We need to modify your existing join-room API route to match the requirements for the create-room route. We will also generate a unique join code for the room.

Here is the updated code:

import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@deps/lib/supabase';

interface CreateRoomResponse {
    success?: boolean;
    room?: {
        id: number;
        join_code: string;
    };
    error?: string;
}

// Function to generate a random 6-digit alphanumeric string
const generateJoinCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();

export default async function handler(req: NextApiRequest, res: NextApiResponse<CreateRoomResponse>) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed.' });
    }

    const { lead_name } = req.body;

    try {
        const join_code = generateJoinCode();

        // Create a new room
        const { data: room, error: roomError } = await supabase
            .from('rooms')
            .insert([{ join_code, lead_name }])
            .single();

        if (roomError) {
            throw roomError;
        }

        return res.status(200).json({ success: true, room });
    } catch (error) {
        if (error instanceof Error) {
            // Handle error
            return res.status(500).json({ error: error.message });
        }
        return res.status(500).json({ error: 'An error occurred when creating the room.' });
    }
}

Note from Matt: ChatGPT did an ok job but did not handle the edge case of creating a unique join code for a 24 hour period. I ask it to fix this.

...

User: We need to check before creating that room, that the join code has been used in the last 24 hours.

Also we can return the "Room" interface instead of the id and join_code

export interface Room {
    id: number;
    join_code: string;
    lead_name: string | null;
    created_at: string;
}

...

System: Sure, here's the updated code that checks if the join code has been used in the last 24 hours:

import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@deps/lib/supabase';
import { Room } from '@deps/types';

interface CreateRoomResponse {
    success?: boolean;
    room?: Room;
    error?: string;
}

// Function to generate a random 6-digit alphanumeric string
const generateJoinCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();

export default async function handler(req: NextApiRequest, res: NextApiResponse<CreateRoomResponse>) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed.' });
    }

    const { lead_name } = req.body;

    try {
        const join_code = generateJoinCode();

        // Get current time and time 24 hours ago
        const now = new Date();
        const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);

        // Check if the join code has been used in the last 24 hours
        const { data: existingRooms } = await supabase
            .from<Room>('rooms')
            .select('*')
            .eq('join_code', join_code)
            .gte('created_at', oneDayAgo);

        if (existingRooms && existingRooms.length > 0) {
            return res.status(400).json({ error: 'Join code has been used recently. Please try again.' });
        }

        // Create a new room
        const { data: room, error: roomError } = await supabase
            .from<Room>('rooms')
            .insert([{ join_code, lead_name }])
            .single();

        if (roomError) {
            throw roomError;
        }

        return res.status(200).json({ success: true, room });
    } catch (error) {
        if (error instanceof Error) {
            // Handle error
            return res.status(500).json({ error: error.message });
        }
        return res.status(500).json({ error: 'An error occurred when creating the room.' });
    }
}

This code tries to generate a unique join code and checks if it has been used in the last 24 hours. If it has, it returns an error. If it hasn't, it creates the room and returns the room details.

import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@deps/lib/supabase';
import { Room } from '@deps/types';

interface CreateRoomResponse {
    success?: boolean;
    room?: Room;
    error?: string;
}

// Function to generate a random 6-digit alphanumeric string
const generateJoinCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();

export default async function handler(req: NextApiRequest, res: NextApiResponse<CreateRoomResponse>) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed.' });
    }

    const { lead_name } = req.body;

    try {
        const join_code = generateJoinCode();

        // Get current time and time 24 hours ago
        const now = new Date();
        const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);

        // Check if the join code has been used in the last 24 hours
        const { data: existingRooms } = await supabase
            .from<Room>('rooms')
            .select('*')
            .eq('join_code', join_code)
            .gte('created_at', oneDayAgo);

        if (existingRooms && existingRooms.length > 0) {
            return res.status(400).json({ error: 'Join code has been used recently. Please try again.' });
        }

        // Create a new room
        const { data: room, error: roomError } = await supabase
            .from<Room>('rooms')
            .insert([{ join_code, lead_name }])
            .single();

        if (roomError) {
            throw roomError;
        }

        return res.status(200).json({ success: true, room });
    } catch (error) {
        if (error instanceof Error) {
            // Handle error
            return res.status(500).json({ error: error.message });
        }
        return res.status(500).json({ error: 'An error occurred when creating the room.' });
    }
}

This code tries to generate a unique join code and checks if it has been used in the last 24 hours. If it has, it returns an error. If it hasn't, it creates the room and returns the room details.

Note from Matt: This was better, but it still didn't handle if the second code wasn't unique and so on.

...

User: Instead of returning an error if the code has been used, have it create a new code, and check it again.

We could use recursion, that would be fun!

...

System: Recursion is a great idea! We can create a new function getJoinCode that generates a join code and checks if it has been used in the last 24 hours. If it has, it will call itself again recursively to get a new code.

Here's how you can update your function:

import type { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from '@deps/lib/supabase';
import { Room } from '@deps/types';

interface CreateRoomResponse {
    success?: boolean;
    room?: Room;
    error?: string;
}

// Function to generate a random 6-digit alphanumeric string
const generateJoinCode = () => Math.random().toString(36).substring(2, 8).toUpperCase();

const getJoinCode = async (): Promise<string> => {
    const join_code = generateJoinCode();

    // Get current time and time 24 hours ago
    const now = new Date();
    const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);

    // Check if the join code has been used in the last 24 hours
    const { data: existingRooms } = await supabase
        .from<Room>('rooms')
        .select('*')
        .eq('join_code', join_code)
        .gte('created_at', oneDayAgo);

    if (existingRooms && existingRooms.length > 0) {
        // If join code has been used, generate a new one
        return getJoinCode();
    } else {
        // If join code has not been used, return it
        return join_code;
    }
}

export default async function handler(req: NextApiRequest, res: NextApiResponse<CreateRoomResponse>) {
    if (req.method !== 'POST') {
        return res.status(405).json({ error: 'Method not allowed.' });
    }

    const { lead_name } = req.body;

    try {
        const join_code = await getJoinCode();

        // Create a new room
        const { data: room, error: roomError } = await supabase
            .from<Room>('rooms')
            .insert([{ join_code, lead_name }])
            .single();

        if (roomError) {
            throw roomError;
        }

        return res.status(200).json({ success: true, room });
    } catch (error) {
        if (error instanceof Error) {
            // Handle error
            return res.status(500).json({ error: error.message });
        }
        return res.status(500).json({ error: 'An error occurred when creating the room.' });
    }
}

...

That's much better! It took some convincing. This is a great example of why you can't just create an application with ChatGPT with no experience as a developer. You need the expertise to understand when its wrong and why.

The only thing I'm going to change is that we are not going to send a lead_name in the body.

Now let's add a homepage. I'm going to send it my existing Next.js default index page so it gets a better idea of what I want.

...

User: Let's make this homepage branded like "Estimation Party" and have a button to "Create a new room" which calls the /api/create-room endpoint and then redirects to that room.

It should have have a "Got a join code?" input that lets you here a 6 char alphanumeric join code (.toUppercase) and if valid submitting should take you to /room/<join_code> with router.push

import Image from 'next/image'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export default function Home() {
  return (
    <main
      className={`flex min-h-screen flex-col items-center justify-between p-24 ${inter.className}`}
    >
      <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
        <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto  lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
          Get started by editing&nbsp;
          <code className="font-mono font-bold">src/pages/index.tsx</code>
        </p>
        <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none">
          <a
            className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0"
            href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
            target="_blank"
            rel="noopener noreferrer"
          >
            By{' '}
            <Image
              src="/vercel.svg"
              alt="Vercel Logo"
              className="dark:invert"
              width={100}
              height={24}
              priority
            />
          </a>
        </div>
      </div>

      <div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700/10 after:dark:from-sky-900 after:dark:via-[#0141ff]/40 before:lg:h-[360px]">
        <Image
          className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
          src="/next.svg"
          alt="Next.js Logo"
          width={180}
          height={37}
          priority
        />
      </div>

      <div className="mb-32 grid text-center lg:mb-0 lg:grid-cols-4 lg:text-left">
        <a
          href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
          className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
          target="_blank"
          rel="noopener noreferrer"
        >
          <h2 className={`mb-3 text-2xl font-semibold`}>
            Docs{' '}
            <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
              -&gt;
            </span>
          </h2>
          <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
            Find in-depth information about Next.js features and API.
          </p>
        </a>

        <a
          href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
          className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
          target="_blank"
          rel="noopener noreferrer"
        >
          <h2 className={`mb-3 text-2xl font-semibold`}>
            Learn{' '}
            <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
              -&gt;
            </span>
          </h2>
          <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
            Learn about Next.js in an interactive course with&nbsp;quizzes!
          </p>
        </a>

        <a
          href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
          className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
          target="_blank"
          rel="noopener noreferrer"
        >
          <h2 className={`mb-3 text-2xl font-semibold`}>
            Templates{' '}
            <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
              -&gt;
            </span>
          </h2>
          <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
            Discover and deploy boilerplate example Next.js&nbsp;projects.
          </p>
        </a>

        <a
          href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
          className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30"
          target="_blank"
          rel="noopener noreferrer"
        >
          <h2 className={`mb-3 text-2xl font-semibold`}>
            Deploy{' '}
            <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none">
              -&gt;
            </span>
          </h2>
          <p className={`m-0 max-w-[30ch] text-sm opacity-50`}>
            Instantly deploy your Next.js site to a shareable URL with Vercel.
          </p>
        </a>
      </div>
    </main>
  )
}

...

System: You'll want to use the Next.js useRouter hook to handle the redirect, and fetch to make the API call. For the join code input, you can create a state variable to hold its value and update it on input change. Then, you can use the useRouter hook again to handle the redirect when the form is submitted.

Here's how you can do it:

import { useState } from 'react';
import { useRouter } from 'next/router';
import Image from 'next/image'

export default function Home() {
  const router = useRouter();
  const [joinCode, setJoinCode] = useState('');

  const handleCreateRoom = async () => {
    const res = await fetch('/api/create-room', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ lead_name: 'Your Lead Name' }) // You may want to handle lead_name differently
    });

    const data = await res.json();

    if (data.success) {
      router.push(`/room/${data.room.join_code}`);
    } else {
      // Handle error
      console.error(data.error);
    }
  };

  const handleJoinRoom = (event) => {
    event.preventDefault();
    router.push(`/room/${joinCode.toUpperCase()}`);
  };

  return (
    <main>
      <h1>Estimation Party</h1>

      <button onClick={handleCreateRoom}>
        Create a new room
      </button>

      <form onSubmit={handleJoinRoom}>
        <input
          type="text"
          value={joinCode}
          onChange={(event) => setJoinCode(event.target.value)}
          maxLength={6}
          placeholder="Enter join code"
        />
        <button type="submit">
          Join a room
        </button>
      </form>

      {/* Your existing code... */}
    </main>
  )
}

This sets up a basic input and button for entering a join code and navigating to the corresponding room, as well as a button for creating a new room. Remember to replace 'Your Lead Name' with appropriate logic for setting the lead's name when creating a room.

Please note that this is a simplistic implementation, and you might want to add more checks or error handling. For example, you might want to check whether a room with the entered join code actually exists before trying to navigate to it, and show an error message if it doesn't. Also, you may want to validate the join code input to ensure it's in the correct format.

...

We struck out on the example. It didn't even use tailwind and suggested I use the existing code. I should have been more clear.

User: Can you please follow Tailwind css class styles?

...

System: Sure, here's how you can use Tailwind CSS to style the components:

import { useState } from 'react';
import { useRouter } from 'next/router';

export default function Home() {
  const router = useRouter();
  const [joinCode, setJoinCode] = useState('');

  const handleCreateRoom = async () => {
    const res = await fetch('/api/create-room', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ lead_name: 'Your Lead Name' }) // You may want to handle lead_name differently
    });

    const data = await res.json();

    if (data.success) {
      router.push(`/room/${data.room.join_code}`);
    } else {
      // Handle error
      console.error(data.error);
    }
  };

  const handleJoinRoom = (event) => {
    event.preventDefault();
    router.push(`/room/${joinCode.toUpperCase()}`);
  };

  return (
    <main className="flex flex-col items-center justify-center min-h-screen py-2">
      <h1 className="text-4xl font-bold mb-8">Estimation Party</h1>

      <button
        onClick={handleCreateRoom}
        className="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700"
      >
        Create a new room
      </button>

      <form onSubmit={handleJoinRoom} className="mt-8">
        <input
          type="text"
          value={joinCode}
          onChange={(event) => setJoinCode(event.target.value)}
          maxLength={6}
          placeholder="Enter join code"
          className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
        />
        <button
          type="submit"
          className="mt-4 px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700 w-full"
        >
          Join a room
        </button>
      </form>
    </main>
  )
}

...

So it gave us Tailwind classnames. Let's remove the body of the POST since we aren't putting a lead_name in the home page but waiting until they join a room.

It doesn't look too bad at all.

Estimation Party Home

I got the application code pushed to (GitHub)[https://github.com/mattlgroff/estimation-party] deployed to Vercel @ (estimationparty.com)[https://estimationparty.com].

That's it for Part 2! In Part 3, we'll add the ability to join a room and continue working on the room page.