npx create-next-app@latest appName
npm install @clerk/[email protected] @prisma/[email protected] @tanstack/[email protected] @tanstack/[email protected] [email protected] [email protected] [email protected] [email protected]
npm install -D @tailwindcss/[email protected] [email protected] [email protected]
- remove default code from globals.css tailwind.config.js
{
plugins: [require('@tailwindcss/typography'), require('daisyui')],
}
- create following pages - chat, profile, tours
- group them together with route group (dashboard)
- setup one layout file for all three pages
- setup title and description in the layout
- code home page (DaisyUI Hero Component)
app/layout.js
export const metadata = {
title: 'GPTGenius',
description:
'GPTGenius: Your AI language companion. Powered by OpenAI, it enhances your conversations, content creation, and more!',
};
app/page.js
import Link from 'next/link';
const HomePage = () => {
return (
<div className='hero min-h-screen bg-base-200'>
<div className='hero-content text-center'>
<div className='max-w-md'>
<h1 className='text-6xl font-bold text-primary'>GPTGenius</h1>
<p className='py-6 text-lg leading-loose'>
GPTGenius: Your AI language companion. Powered by OpenAI, it
enhances your conversations, content creation, and more!
</p>
<Link href='/chat' className='btn btn-secondary '>
Get Started
</Link>
</div>
</div>
</div>
);
};
export default HomePage;
(Clerk Docs)[https://clerk.com/]
- create account
- create new application
- complete Next.js setup
npm install @clerk/nextjs
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = your_publishable_key;
CLERK_SECRET_KEY = your_secret_key;
Environment variables with this NEXT_PUBLIC_
prefix are exposed to client-side JavaScript code, while those without the prefix are only accessible on the server-side and are not exposed to the client-side code.
NEXT_PUBLIC_
const apiKey = process.env.NEXT_PUBLIC_API_KEY;
layout.js
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }) {
return (
<ClerkProvider>
<html lang='en'>
<body>{children}</body>
</html>
</ClerkProvider>
);
}
middleware.ts
import { authMiddleware } from '@clerk/nextjs';
// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
publicRoutes: ['/'],
});
export const config = {
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
};
- follow the docs and setup custom pages
- use clerk's component
app/sign-up/[[...sign-up]]/page.js
import { SignUp } from '@clerk/nextjs';
const SignUpPage = () => {
return (
<div className='min-h-screen flex justify-center items-center'>
<SignUp />
</div>
);
};
export default SignUpPage;
app/sign-in/[[...sign-in]]/page.js
import { SignIn } from '@clerk/nextjs';
const SignInPage = () => {
return (
<div className='min-h-screen flex justify-center items-center'>
<SignIn />
</div>
);
};
export default SignInPage;
.env.local
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/chat
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/chat
(React Icons )[https://react-icons.github.io/react-icons/]
npm install react-icons --save
import { FaBeer } from 'react-icons/fa';
<FaBeer>
- setup layout for for all pages
- DaisyUI Drawer Component DaisyUI
- create components/sidebar
layout.js
import { FaBarsStaggered } from 'react-icons/fa6';
import Sidebar from '@/components/Sidebar';
const layout = ({ children }) => {
return (
<div className='drawer lg:drawer-open'>
<input id='my-drawer-2' type='checkbox' className='drawer-toggle' />
<div className='drawer-content'>
{/* Page content here */}
<label
htmlFor='my-drawer-2'
className='drawer-button lg:hidden fixed top-6 right-6'
>
<FaBarsStaggered className='w-8 h-8 text-primary' />
</label>
<div className='bg-base-200 px-8 py-12 min-h-screen'>{children}</div>
</div>
<div className='drawer-side'>
<label
htmlFor='my-drawer-2'
aria-label='close sidebar'
className='drawer-overlay'
></label>
<Sidebar />
</div>
</div>
);
};
export default layout;
- create SidebarHeader, NavLinks, MemberProfile
Sidebar.jsx
import SidebarHeader from './SidebarHeader';
import NavLinks from './NavLinks';
import MemberProfile from './MemberProfile';
const Sidebar = () => {
return (
<div className='px-4 w-80 min-h-full bg-base-300 py-12 grid grid-rows-[auto,1fr,auto] '>
{/* first row */}
<SidebarHeader />
{/* second row */}
<NavLinks />
{/* third row */}
<MemberProfile />
</div>
);
};
export default Sidebar;
- create ThemeToggle
import ThemeToggle from './ThemeToggle';
import { SiOpenaigym } from 'react-icons/si';
const SidebarHeader = () => {
return (
<div className='flex items-center mb-4 gap-4 px-4'>
<SiOpenaigym className='w-10 h-10 text-primary' />
<h2 className='text-xl font-extrabold text-primary mr-auto'>GPTGenius</h2>
<ThemeToggle />
</div>
);
};
export default SidebarHeader;
-
Import Dependencies:
- Import the
Link
component fromnext/link
.
- Import the
-
Define Navigation Links:
- Create an array named
links
that contains objects representing navigation links. Each object should have ahref
property specifying the link's destination and alabel
property for the link's text label.
- Create an array named
-
Create the NavLinks Component:
- Define a functional component named
NavLinks
.
- Define a functional component named
-
Render Navigation Links:
-
Within the component, render an unordered list (
<ul>
) with a class of'menu text-base-content'
. -
Use the
map
function to iterate through thelinks
array and generate list items (<li>
) for each link. -
For each link object in the
links
array, create aLink
component with thehref
attribute set to the link'shref
property. -
Display the link's label (
link.label
) as the text content of theLink
component.
-
This component is responsible for rendering navigation links based on the links
array. It uses the next/link
package to create client-side navigation links in a Next.js application. The navigation links are generated dynamically based on the links
array.
import Link from 'next/link';
const links = [
{ href: '/chat', label: 'chat' },
{ href: '/tours', label: 'tours' },
{ href: '/tours/new-tour', label: 'new tour' },
{ href: '/profile', label: 'profile' },
];
const NavLinks = () => {
return (
<ul className='menu text-base-content'>
{links.map((link) => {
return (
<li key={link.href}>
<Link href={link.href} className='capitalize'>
{link.label}
</Link>
</li>
);
})}
</ul>
);
};
export default NavLinks;
-
Import Dependencies:
- Import the necessary dependencies at the top of the file:
UserButton
,currentUser
, andauth
from@clerk/nextjs
.
- Import the necessary dependencies at the top of the file:
-
Create the MemberProfile Component:
- Define an asynchronous functional component named
MemberProfile
.
- Define an asynchronous functional component named
-
Fetch Current User:
- Inside the component, use the
currentUser()
function to asynchronously fetch the currently authenticated user and store it in theuser
variable.
- Inside the component, use the
-
Get User ID:
- Use the
auth()
function to extract theuserId
from the authentication context.
- Use the
-
Render User Profile:
- Render a
div
element containing the user's profile information. - Include a
UserButton
component, which provides a button for signing out and redirects to the specified URL ('/'
in this case) after sign-out. - Display the user's email address using
user.emailAddresses[0].emailAddress
.
- Render a
This component fetches the currently authenticated user and displays their email address along with a sign-out button. It uses the @clerk/nextjs
library for authentication and user management in a Next.js application.
import { UserButton, currentUser, auth } from '@clerk/nextjs';
const MemberProfile = async () => {
const user = await currentUser();
const { userId } = auth();
return (
<div className='px-4 flex items-center gap-2'>
<UserButton afterSignOutUrl='/' />
<p>{user.emailAddresses[0].emailAddress}</p>
</div>
);
};
export default MemberProfile;
- setup themes in tailwind.config.js
-
Import Dependencies:
- Import the necessary dependencies at the top of the file:
BsMoonFill
andBsSunFill
from 'react-icons/bs'.useState
from 'react'.
- Import the necessary dependencies at the top of the file:
-
Define Theme Options:
- Create an object named
themes
to hold theme options. In this example, there are two themes: 'winter' and 'dracula'.
- Create an object named
-
Initialize Theme State:
- Use the
useState
hook to initialize thetheme
state variable with the default theme, such asthemes.winter
.
- Use the
-
Toggle Theme Function:
- Define a function named
toggleTheme
to handle theme toggling. - Inside the function, check the current theme (
theme
) and switch it to the opposite theme (themes.dracula
if it's 'winter', orthemes.winter
if it's 'dracula'). - Update the document's root element (
document.documentElement
) with the new theme by setting the 'data-theme' attribute.
- Define a function named
-
Button Rendering:
- Render a button element with an
onClick
event handler that triggers thetoggleTheme
function.
- Render a button element with an
-
Conditional Icon Rendering:
- Inside the button, conditionally render icons based on the current theme.
- If the theme is 'winter', render a moon icon (e.g.,
<BsMoonFill />
). - If the theme is 'dracula', render a sun icon (e.g.,
<BsSunFill />
).
This component allows users to toggle between two themes (e.g., light and dark) by clicking the button, which updates the data-theme
attribute on the document's root element and changes the displayed icon accordingly.
tailwind.config.js
{
daisyui: {
themes: ['winter', 'dracula'],
},
}
'use client';
import { BsMoonFill, BsSunFill } from 'react-icons/bs';
import { useState } from 'react';
const themes = {
winter: 'winter',
dracula: 'dracula',
};
const ThemeToggle = () => {
const [theme, setTheme] = useState(themes.winter);
const toggleTheme = () => {
const newTheme = theme === themes.winter ? themes.dracula : themes.winter;
document.documentElement.setAttribute('data-theme', newTheme);
setTheme(newTheme);
};
return (
<button onClick={toggleTheme} className='btn btn-sm btn-outline'>
{theme === 'winter' ? (
<BsMoonFill className='h-4 w-4 ' />
) : (
<BsSunFill className='h-4 w-4' />
)}
</button>
);
};
export default ThemeToggle;
-
Import Dependencies:
- Import the
UserProfile
component from'@clerk/nextjs'
.
- Import the
-
Render the UserProfile Component:
- Within the component's render function, return the
UserProfile
component.
- Within the component's render function, return the
This component serves as a page for displaying the user's profile information. It utilizes the UserProfile
component from the '@clerk/nextjs'
package to render the user's profile details. This is a common pattern in Next.js applications for handling user authentication and profile management.
import { UserProfile } from '@clerk/nextjs';
const UserProfilePage = () => {
return <UserProfile />;
};
export default UserProfilePage;
- setup app/providers.js
- import/add Toaster component
- wrap {children} in layout.js
app/providers.jsx
'use client';
import { Toaster } from 'react-hot-toast';
export default function Providers({ children }) {
return (
<>
<Toaster position='top-center' />
{children}
</>
);
}
app/layout.js
<Providers>{children}</Providers>
-
Import Dependencies:
- Import the necessary dependencies, including
useState
from 'react' andtoast
from 'react-hot-toast'.
- Import the necessary dependencies, including
-
State Management:
- Initialize state variables using the
useState
hook:text
: to manage the text input for composing messages.messages
: to manage the list of messages.
- Initialize state variables using the
-
Handle Form Submission:
- Implement a
handleSubmit
function to handle form submissions when sending messages. It should prevent the default form behavior.
- Implement a
-
Render UI Elements:
- Render the chat interface with the following components and elements:
- A 'messages' header using an
<h2>
element. - A
<form>
element for composing and sending messages. - Inside the form:
- An
<input>
element for entering messages, with event handling to update thetext
state. - A 'Send' button to submit messages.
- An
- A 'messages' header using an
- Render the chat interface with the following components and elements:
This component represents a chat interface where users can send messages. It uses React state to manage the input text and a list of messages. When a message is submitted, it prevents the default form behavior (form submission) and handles message composition.
- setup components/Chat.jsx
- import in app/(dashboard)/chat/page.js
'use client';
import { useState } from 'react';
import toast from 'react-hot-toast';
const Chat = () => {
const [text, setText] = useState('');
const [messages, setMessages] = useState([]);
const handleSubmit = (e) => {
e.preventDefault();
};
return (
<div className='min-h-[calc(100vh-6rem)] grid grid-rows-[1fr,auto]'>
<div>
<h2 className='text-5xl'>messages</h2>
</div>
<form onSubmit={handleSubmit} className='max-w-4xl pt-12'>
<div className='join w-full'>
<input
type='text'
placeholder='Message GeniusGPT'
className='input input-bordered join-item w-full'
value={text}
required
onChange={(e) => setText(e.target.value)}
/>
<button className='btn btn-primary join-item' type='submit'>
ask question
</button>
</div>
</form>
</div>
);
};
export default Chat;
npm i @tanstack/react-query @tanstack/react-query-devtools
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// the data will be considered fresh for 1 minute
staleTime: 60 * 1000,
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
const Items = () => {
const { isPending, isError, data } = useQuery({
queryKey: ['tasks'],
// A query function can be literally any function that returns a promise.
queryFn: () => axios.get('/someUrl'),
});
if (isPending) {
return <p>Loading...</p>;
}
if (isError) {
return <p>Error...</p>;
}
return (
<div className='items'>
{data.taskList.map((item) => {
return <SingleItem key={item.id} item={item} />;
})}
</div>
);
};
export default Items;
const { mutate, isPending, data } = useMutation({
mutationFn: (taskTitle) => axios.post('/', { title: taskTitle }),
onSuccess: () => {
// do something
},
onError: () => {
// do something
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutate(newItemName);
};
-
WE CAN USE SERVER ACTIONS ๐๐๐๐๐๐
app/providers.jsx
// In Next.js, this file would be called: app/providers.jsx
'use client';
// We can not useState or useRef in a server component, which is why we are
// extracting this part out into it's own file with 'use client' on top
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { Toaster } from 'react-hot-toast';
export default function Providers({ children }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<Toaster position='top-center' />
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
- WRAP EACH PAGE
chat/page.js
import Chat from '@/components/Chat';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
export default async function ChatPage() {
const queryClient = new QueryClient();
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Chat />
</HydrationBoundary>
);
}
utils/actions.js
'use server';
export const generateChatResponse = async (chatMessage) => {
console.log(chatMessage);
return 'awesome';
};
components/Chat.jsx
'use client';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useMutation } from '@tanstack/react-query';
import { generateChatResponse } from '@/utils/actions';
const Chat = () => {
const [text, setText] = useState('');
const [messages, setMessages] = useState([]);
const { mutate } = useMutation({
mutationFn: (message) => generateChatResponse(message),
});
const handleSubmit = (e) => {
e.preventDefault();
mutate(text);
};
};
npm i openai
- create API KEY
- save in .env.local
OPENAI_API_KEY=....
utils/actions.js
import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export const generateChatResponse = async (message) => {
const response = await openai.chat.completions.create({
messages: [
{ role: 'system', content: 'you are a helpful assistant' },
{ role: 'user', content: message };
],
model: 'gpt-3.5-turbo',
temperature: 0,
});
console.log(response.choices[0].message)
console.log(response);
return 'awesome';
};
utils/actions
export const generateChatResponse = async (chatMessages) => {
try {
const response = await openai.chat.completions.create({
messages: [
{ role: 'system', content: 'you are a helpful assistant' },
...chatMessages,
],
model: 'gpt-3.5-turbo',
temperature: 0,
});
return response.choices[0].message;
} catch (error) {
return null;
}
};
Chat.jsx
const Chat = () => {
const [text, setText] = useState('');
const [messages, setMessages] = useState([]);
const { mutate, isPending, data } = useMutation({
mutationFn: (query) => generateChatResponse([...messages, query]),
onSuccess: (data) => {
if (!data) {
toast.error('Something went wrong...');
return;
}
setMessages((prev) => [...prev, data]);
},
onError: (error) => {
toast.error('Something went wrong...');
},
});
const handleSubmit = (e) => {
e.preventDefault();
const query = { role: 'user', content: text };
mutate(query);
setMessages((prev) => [...prev, query]);
setText('');
};
};
Chat.jsx
return (
<div>
{messages.map(({ role, content }, index) => {
const avatar = role == 'user' ? '๐ค' : '๐ค';
const bcg = role == 'user' ? 'bg-base-200' : 'bg-base-100';
return (
<div
key={index}
className={` ${bcg} flex py-6 -mx-8 px-8
text-xl leading-loose border-b border-base-300`}
>
<span className='mr-4 '>{avatar}</span>
<p className='max-w-3xl'>{content}</p>
</div>
);
})}
{isPending && <span className='loading'></span>}
</div>
);
return (
<button
className='btn btn-primary join-item'
type='submit'
disabled={isPending}
>
{isPending ? 'please wait' : 'ask question'}
</button>
);
- create NewTour and TourInfo components
- create New Tour page : app/(dashboard)/tours/new-tour/page.js
- add react query boilerplate
- render NewTour component
- setup form with two inputs city and country
tours/new-tour/page.js
import NewTour from '@/components/NewTour';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
export default async function ChatPage() {
const queryClient = new QueryClient();
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<NewTour />
</HydrationBoundary>
);
}
'use client';
import toast from 'react-hot-toast';
import TourInfo from '@/components/TourInfo';
const NewTour = () => {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const destination = Object.fromEntries(formData.entries());
};
return (
<>
<form onSubmit={handleSubmit} className='max-w-2xl'>
<h2 className=' mb-4'>Select your dream destination</h2>
<div className='join w-full'>
<input
type='text'
className='input input-bordered join-item w-full'
placeholder='city'
name='city'
required
/>
<input
type='text'
className='input input-bordered join-item w-full'
placeholder='country'
name='country'
required
/>
<button className='btn btn-primary join-item' type='submit'>
generate tour
</button>
</div>
</form>
<div className='mt-16'>
<TourInfo />
</div>
</>
);
};
export default NewTour;
actions.js
export const getExistingTour = async ({ city, country }) => {
return null;
};
export const generateTourResponse = async ({ city, country }) => {
return null;
};
export const createNewTour = async (tour) => {
return null;
};
NewTour.jsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
createNewTour,
generateTourResponse,
getExistingTour,
} from '@/utils/actions';
import toast from 'react-hot-toast';
import TourInfo from '@/components/TourInfo';
const NewTour = () => {
const {
mutate,
isPending,
data: tour,
} = useMutation({
mutationFn: async (destination) => {
const newTour = await generateTourResponse(destination);
if (newTour) {
return newTour;
}
toast.error('No matching city found...');
return null;
},
});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const destination = Object.fromEntries(formData.entries());
mutate(destination);
};
if (isPending) {
return <span className='loading loading-lg'></span>;
}
return (
<>
<form onSubmit={handleSubmit} className='max-w-2xl'>
<h2 className=' mb-4'>Select your dream destination</h2>
<div className='join w-full'>
<input
type='text'
className='input input-bordered join-item w-full'
placeholder='city'
name='city'
required
/>
<input
type='text'
className='input input-bordered join-item w-full'
placeholder='country'
name='country'
required
/>
<button
className='btn btn-primary join-item'
type='submit'
disabled={isPending}
>
{isPending ? 'please wait...' : 'generate tour'}
</button>
</div>
</form>
<div className='mt-16'>
<div className='mt-16'>{tour ? <TourInfo tour={tour} /> : null}</div>
</div>
</>
);
};
export default NewTour;
Later we will use shorter prompt
{
"tour": {
...
"stops": ["stop name ", "stop name","stop name"]
}
}
const query = `Find a ${city} in this ${country}.
If ${city} in this ${country} exists, create a list of things families can do in this ${city},${country}.
Once you have a list, create a one-day tour. Response should be in the following JSON format:
{
"tour": {
"city": "${city}",
"country": "${country}",
"title": "title of the tour",
"description": "description of the city and tour",
"stops": ["short paragraph on the stop 1 ", "short paragraph on the stop 2","short paragraph on the stop 3"]
}
}
If you can't find info on exact ${city}, or ${city} does not exist, or it's population is less than 1, or it is not located in the following ${country} return { "tour": null }, with no additional characters.`;
export const generateTourResponse = async ({ city, country }) => {
const query = `Find a ${city} in this ${country}.
If ${city} in this ${country} exists, create a list of things families can do in this ${city},${country}.
Once you have a list, create a one-day tour. Response should be in the following JSON format:
{
"tour": {
"city": "${city}",
"country": "${country}",
"title": "title of the tour",
"description": "description of the city and tour",
"stops": ["short paragraph on the stop 1 ", "short paragraph on the stop 2","short paragraph on the stop 3"]
}
}
If you can't find info on exact ${city}, or ${city} does not exist, or it's population is less than 1, or it is not located in the following ${country} return { "tour": null }, with no additional characters.`;
try {
const response = await openai.chat.completions.create({
messages: [
{ role: 'system', content: 'you are a tour guide' },
{ role: 'user', content: query },
],
model: 'gpt-3.5-turbo',
temperature: 0,
});
// potentially returns a text with error message
const tourData = JSON.parse(response.choices[0].message.content);
if (!tourData.tour) {
return null;
}
return tourData.tour;
} catch (error) {
console.log(error);
return null;
}
};
{
"tour": {
...
"stops": ["stop name ", "stop name","stop name"]
}
}
TourInfo.jsx
const TourInfo = ({ tour }) => {
const { title, description, stops } = tour;
return (
<div className='max-w-2xl'>
<h1 className='text-4xl font-semibold mb-4'>{title}</h1>
<p className='leading-loose mb-6'>{description}</p>
<ul>
{stops.map((stop) => {
return (
<li key={stop} className='mb-4 bg-base-100 p-4 rounded-xl'>
<p className='text'>{stop}</p>
</li>
);
})}
</ul>
</div>
);
};
export default TourInfo;
npm install prisma --save-dev
npm install @prisma/client
npx prisma init
- ADD .ENV TO .GITIGNORE !!!!
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
generator client {
provider = "prisma-client-js"
}
model Tour {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
city String
country String
title String
description String @db.Text
image String? @db.Text
stops Json
@@unique([city, country])
}
npx prisma db push
npx prisma studio
@db.Text: This attribute is used to specify the type of the column in the underlying database. When you use @db.Text, you're telling Prisma that the particular field should be stored as a text column in the database. Text columns can store large amounts of string data, typically used for long-form text that exceeds the length limits of standard string columns. This is often used for descriptions, comments, JSON-formatted strings, etc.
@@unique: This attribute is used at the model level to enforce the uniqueness of a specific combination of fields within the database. In this case, @@unique([city, country]) ensures that no two rows in the table have the same combination of city and country. This means you can have multiple tours in the same city or country, but not multiple tours with the same city and country combination. It essentially acts as a composite unique constraint on the two fields.
utils/db.ts
import { PrismaClient } from '@prisma/client';
const prismaClientSingleton = () => {
return new PrismaClient();
};
type PrismaClientSingleton = ReturnType<typeof prismaClientSingleton>;
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClientSingleton | undefined;
};
const prisma = globalForPrisma.prisma ?? prismaClientSingleton();
export default prisma;
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
actions.js
export const getExistingTour = async ({ city, country }) => {
return prisma.tour.findUnique({
where: {
city_country: {
city,
country,
},
},
});
};
export const createNewTour = async (tour) => {
return prisma.tour.create({
data: tour,
});
};
NewTour.jsx
const queryClient = useQueryClient();
{
mutationFn: async (destination) => {
const existingTour = await getExistingTour(destination);
if (existingTour) return existingTour;
const newTour = await generateTourResponse(destination);
if (newTour) {
await createNewTour(newTour);
queryClient.invalidateQueries({ queryKey: ['tours'] });
return newTour;
}
toast.error('No matching city found...');
return null;
},
}
actions.js
export const getAllTours = async (searchTerm) => {
if (!searchTerm) {
const tours = await prisma.tour.findMany({
orderBy: {
city: 'asc',
},
});
return tours;
}
const tours = await prisma.tour.findMany({
where: {
OR: [
{
city: {
contains: searchTerm,
},
},
{
country: {
contains: searchTerm,
},
},
],
},
orderBy: {
city: 'asc',
},
});
return tours;
};
- create ToursPage ToursList and TourCard components
- create loading.js in app/tours
import ToursPage from '@/components/ToursPage';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { getAllTours } from '@/utils/actions';
export default async function AllToursPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['tours'],
queryFn: () => getAllTours(),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ToursPage />
</HydrationBoundary>
);
}
'use client';
import { getAllTours } from '@/utils/actions';
import { useQuery } from '@tanstack/react-query';
import ToursList from './ToursList';
const ToursPage = () => {
const { data, isPending } = useQuery({
queryKey: ['tours'],
queryFn: () => getAllTours(),
});
return (
<>
{isPending ? (
<span className=' loading'></span>
) : (
<ToursList data={data} />
)}
</>
);
};
export default ToursPage;
import TourCard from './TourCard';
const ToursList = ({ data }) => {
if (data.length === 0) return <h4 className='text-lg '>No tours found...</h4>;
return (
<div className='grid sm:grid-cols-2 lg:grid-cols-4 gap-8'>
{data.map((tour) => {
return <TourCard key={tour.id} tour={tour} />;
})}
</div>
);
};
export default ToursList;
import Link from 'next/link';
const TourCard = ({ tour }) => {
const { city, title, id, country } = tour;
return (
<Link
href={`/tours/${id}`}
className='card card-compact rounded-xl bg-base-100'
>
<div className='card-body items-center text-center'>
<h2 className='card-title text-center'>
{city}, {country}
</h2>
</div>
</Link>
);
};
export default TourCard;
'use client';
import { getAllTours } from '@/utils/actions';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import ToursList from './ToursList';
const ToursPage = () => {
const [searchValue, setSearchValue] = useState('');
const { data, isPending } = useQuery({
queryKey: ['tours', searchValue],
queryFn: () => getAllTours(searchValue),
});
return (
<>
<form className='max-w-lg mb-12'>
<div className='join w-full'>
<input
type='text'
placeholder='enter city or country here..'
className='input input-bordered join-item w-full'
name='search'
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
required
/>
<button
className='btn btn-primary join-item'
type='button'
disabled={isPending}
onClick={() => setSearchValue('')}
>
{isPending ? 'please wait' : 'reset'}
</button>
</div>
</form>
{isPending ? (
<span className=' loading'></span>
) : (
<ToursList data={data} />
)}
</>
);
};
export default ToursPage;
- setup page and get info on specific tour
- create app/tours/[id]/page.js
actions.js
export const getSingleTour = async (id) => {
return prisma.tour.findUnique({
where: {
id,
},
});
};
import TourInfo from '@/components/TourInfo';
import { getSingleTour } from '@/utils/actions';
import Link from 'next/link';
import { redirect } from 'next/navigation';
const SingleTourPage = async ({ params }) => {
const tour = await getSingleTour(params.id);
if (!tour) {
redirect('/tours');
}
return (
<div>
<Link href='/tours' className='btn btn-secondary mb-12'>
back to tours
</Link>
<TourInfo tour={tour} />
</div>
);
};
export default SingleTourPage;
- url are valid for 2 hours
- way more expensive than chat
actions.js
export const generateTourImage = async ({ city, country }) => {
try {
const tourImage = await openai.images.generate({
prompt: `a panoramic view of the ${city} ${country}`,
n: 1,
size: '512x512',
});
return tourImage?.data[0]?.url;
} catch (error) {
return null;
}
};
app/tours/[id]/page.js
import TourInfo from '@/components/TourInfo';
import { generateTourImage } from '@/utils/actions';
import prisma from '@/utils/prisma';
import Link from 'next/link';
import Image from 'next/image';
const SingleTourPage = async ({ params }) => {
const tour = await prisma.tour.findUnique({
where: {
id: params.id,
},
});
const tourImage = await generateTourImage({
city: tour.city,
country: tour.country,
});
return (
<div>
<Link href='/tours' className='btn btn-secondary mb-12'>
back to tours
</Link>
{tourImage ? (
<div>
<Image
src={tourImage}
width={300}
height={300}
className='rounded-xl shadow-xl mb-16 h-96 w-96 object-cover'
alt={tour.title}
priority
/>
</div>
) : null}
<TourInfo tour={tour} />
</div>
);
};
export default SingleTourPage;
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'oaidalleapiprodscus.blob.core.windows.net',
port: '',
pathname: '/private/**',
},
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
],
},
};
module.exports = nextConfig;
npm i axios
.env.local
UNSPLASH_API_KEY=7pmB29Xi9rOWHhYpvtuc4edchzh1w0eawUjJwNAqngA
import TourInfo from '@/components/TourInfo';
import { generateTourImage } from '@/utils/actions';
import prisma from '@/utils/prisma';
import Link from 'next/link';
import Image from 'next/image';
import axios from 'axios';
const url = `https://api.unsplash.com/search/photos?client_id=${process.env.UNSPLASH_API_KEY}&query=`;
const SingleTourPage = async ({ params }) => {
const tour = await prisma.tour.findUnique({
where: {
id: params.id,
},
});
const { data } = await axios(`${url}${tour.city}`);
const tourImage = data?.results[0]?.urls?.raw;
// const tourImage = await generateTourImage({
// city: tour.city,
// country: tour.country,
// });
return (
<div>
<Link href='/tours' className='btn btn-secondary mb-12'>
back to tours
</Link>
{tourImage ? (
<div>
<Image
src={tourImage}
width={300}
height={300}
className='rounded-xl shadow-xl mb-16 h-96 w-96 object-cover'
alt={tour.title}
priority
/>
</div>
) : null}
<TourInfo tour={tour} />
</div>
);
};
export default SingleTourPage;
- delete folders
- remove env variables
OPTIONAL !!!
INVOLVES REFACTORING !!!!
- set max_tokens in chat
- create Token model
- assign some token amount to user
- check token amount before request
- subtract after successful request
actions.js
export const generateChatResponse = async (chatMessages) => {
try {
const response = await openai.chat.completions.create({
max_tokens: 100,
});
return response.choices[0].message;
} catch (error) {
console.log(error);
return null;
}
};
-
remove ability to delete account
- Email, Phone, Username
- Allow users to delete their accounts (false)
- Email, Phone, Username
-
block disposable emails
- Restrictions
- Block sign-ups that use disposable email addresses
- Restrictions
-
remove all users
model Token {
clerkId String @id
tokens Int @default (1000)
}
- migrate
actions.js
export const fetchUserTokensById = async (clerkId) => {
const result = await prisma.token.findUnique({
where: {
clerkId,
},
});
return result?.tokens;
};
export const generateUserTokensForId = async (clerkId) => {
const result = await prisma.token.create({
data: {
clerkId,
},
});
return result?.tokens;
};
export const fetchOrGenerateTokens = async (clerkId) => {
const result = await fetchUserTokensById(clerkId);
if (result) {
return result.tokens;
}
return (await generateUserTokensForId(clerkId)).tokens;
};
export const subtractTokens = async (clerkId, tokens) => {
const result = await prisma.token.update({
where: {
clerkId,
},
data: {
tokens: {
decrement: tokens,
},
},
});
revalidatePath('/profile');
// Return the new token value
return result.tokens;
};
components/MemberProfile.jsx
import { fetchOrGenerateTokens } from '@/utils/actions';
import { UserButton, auth, currentUser } from '@clerk/nextjs';
const MemberProfile = async () => {
const user = await currentUser();
const { userId } = auth();
await fetchOrGenerateTokens(userId);
return (
<div className='px-4 flex items-center gap-2'>
<UserButton afterSignOutUrl='/' />
<p>{user.emailAddresses[0].emailAddress}</p>
</div>
);
};
export default MemberProfile;
profile/page.js
import { fetchUserTokensById } from '@/utils/actions';
import { UserProfile, auth } from '@clerk/nextjs';
export const dynamic = 'force-dynamic';
const ProfilePage = async () => {
const { userId } = auth();
const currentTokens = await fetchUserTokensById(userId);
return (
<div>
<h2 className='mb-8 ml-8 text-xl font-extrabold'>
Token Amount : {currentTokens}
</h2>
<UserProfile />
</div>
);
};
export default ProfilePage;
actions.js
export const generateTourResponse = () => {
return { tour: tourData.tour, tokens: response.usage.total_tokens };
};
components/NewTour.jsx
'use client';
import {
fetchUserTokensById,
subtractTokens,
} from '@/utils/actions';
import { useAuth } from '@clerk/nextjs';
const NewTour = () => {
const { userId } = useAuth();
const {
mutate,
isPending,
data: tour,
} = useMutation({
mutationFn: async (destination) => {
const existingTour = await getExistingTour(destination);
if (existingTour) return existingTour;
const currentTokens = await fetchUserTokensById(userId);
if (currentTokens < 300) {
toast.error('Token balance too low....');
return;
}
const newTour = await generateTourResponse(destination);
if (!newTour) {
toast.error('No matching city found...');
return null;
}
const response = await createNewTour(newTour.tour);
queryClient.invalidateQueries({ queryKey: ['tours'] });
const newTokens = await subtractTokens(userId, newTour.tokens);
toast.success(`${newTokens} tokens remaining...`);
return newTour.tour;
},
});
....
}
actions.js
const generateChatResponse = () => {
return {
message: response.choices[0].message,
tokens: response.usage.total_tokens,
};
};
components/Chat.jsx
'use client';
import { fetchUserTokensById, subtractTokens } from '@/utils/actions';
import { useAuth } from '@clerk/nextjs';
const Chat = () => {
const { userId } = useAuth();
const { mutate, isPending } = useMutation({
mutationFn: async (query) => {
const currentTokens = await fetchUserTokensById(userId);
if (currentTokens < 100) {
toast.error('Token balance too low....');
return;
}
const response = await generateChatResponse([...messages, query]);
if (!response) {
toast.error('Something went wrong...');
return;
}
setMessages((prev) => [...prev, response.message]);
const newTokens = await subtractTokens(userId, response.tokens);
toast.success(`${newTokens} tokens remaining...`);
},
});
...
};
export default Chat;
package.json
"scripts": {
"build": "npx prisma generate && next build",
},
-
shorter prompt "stops":["stop 1","stop 2", "stop 3"]
-
planetscale
-
github repo
-
vercel
middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtectedRoute = createRouteMatcher([
'/chat(.*)',
'/profile(.*)',
'/chat(.*)',
'/tours(.*)',
]);
export default clerkMiddleware((auth, req) => {
if (isProtectedRoute(req)) auth().protect();
});
export const config = {
matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
MemberProfile.jsx
import { fetchOrGenerateTokens } from '@/utils/actions';
import { UserButton } from '@clerk/nextjs';
import { auth, currentUser } from '@clerk/nextjs/server';
const MemberProfile = async () => {
const user = await currentUser();
const { userId } = auth();
await fetchOrGenerateTokens(userId);
return (
<div className='px-4 flex items-center gap-2'>
<UserButton afterSignOutUrl='/' />
<p>{user.emailAddresses[0].emailAddress}</p>
</div>
);
};
export default MemberProfile;
import { fetchUserTokensById } from '@/utils/actions';
import { UserProfile } from '@clerk/nextjs';
import { auth } from '@clerk/nextjs/server';
const ProfilePage = async () => {
const { userId } = auth();
const currentTokens = await fetchUserTokensById(userId);
return (
<div>
<h2 className='mb-8 ml-8 text-xl font-extrabold'>
Token Amount : {currentTokens}
</h2>
<UserProfile routing='hash' />
</div>
);
};
export default ProfilePage;