Skip to content

Commit

Permalink
chore: implement generation
Browse files Browse the repository at this point in the history
  • Loading branch information
alexmarqs committed Feb 23, 2023
1 parent cafa120 commit 810d967
Show file tree
Hide file tree
Showing 37 changed files with 732 additions and 63 deletions.
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,22 @@ This project generates a description of an house using [OpenAI GPT-3 API](https:
- [Vercel Edge functions](https://vercel.com/features/edge-functions) (serverless functions)
- [OpenAI GPT-3 API](https://openai.com/api/) (`text-davinci-003`)
- [Framer Motion](https://www.framer.com/motion/) (animations)
- [Heroicons](https://heroicons.com/) (SVG icons)
- [Headless UI](https://headlessui.dev/) (React components)
- [Headless UI](https://headlessui.dev/) (React components using tailwind css)
- [ESLint](https://eslint.org/) (linter)
- [Prettier](https://prettier.io/) (code formatter)
- [Draw.io](https://draw.io/) (for diagrams)
- [React Hot Toast](https://react-hot-toast.com/) (toasts)
- [Zod](https://zod.dev/) (schema validation)
- Multiple Layouts with [Next.js](https://nextjs.org/docs/basic-features/layouts)
- SEO with [Next.js](https://nextjs.org/docs/api-reference/next/head)

## Architecture 🏗

![](./docs/diagram.drawio.svg)

## Technical Notes 📝

I'm using custom hooks for the generation / API call. However you could use a library like [react-query](https://react-query.tanstack.com/) (`mutateAsync`) to trigger the API call and take advantage of all the features it provides out of the box.

Regarding the form, I'm just using `useRef` to get the values (instead of `useState` for performance reasons and to avoid re-renders). However you could use a library like [react-hook-form](https://react-hook-form.com/) to handle the form state and validation or just use the native `HTMLFormElement` API.
I'm using custom hooks for the generation / API call. However you could use a library like [react-query](https://react-query.tanstack.com/) (mutations) to trigger the API call and take advantage of all the features it provides out of the box (loading, retry, etc.)

## Running locally 🏃‍♂️

Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"format": "prettier --write ."
},
"dependencies": {
"@headlessui/react": "^1.7.10",
"@headlessui/tailwindcss": "^0.1.2",
"@heroicons/react": "^2.0.16",
"@next/font": "latest",
"@types/node": "18.11.18",
"@types/react": "18.0.26",
Expand All @@ -21,12 +24,15 @@
"eslint-config-next": "13.1.2",
"next": "latest",
"react": "18.2.0",
"react-countup": "^6.4.1",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.1",
"react-hot-toast": "^2.4.0",
"typescript": "4.9.4",
"zod": "^3.20.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"autoprefixer": "10.4.13",
"eslint-config-prettier": "8.6.0",
"postcss": "8.4.21",
Expand Down
1 change: 0 additions & 1 deletion public/next.svg

This file was deleted.

1 change: 0 additions & 1 deletion public/thirteen.svg

This file was deleted.

1 change: 0 additions & 1 deletion public/vercel.svg

This file was deleted.

Binary file added public/vercelLogo.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions src/components/CircleBadge/CircleBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { cls } from '@/utils/classes';

type CircleBadgeProps = {
value: string;
className?: string;
};

export const CircleBadge = ({ value, className }: CircleBadgeProps) => {
return (
<div
className={cls(
'relative w-3 h-3 bg-black rounded-full flex justify-center items-center text-center p-4 text-white',
className
)}
>
<span className="absolute left-0 top-0"></span>
{value}
</div>
);
};
1 change: 1 addition & 0 deletions src/components/CircleBadge/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CircleBadge } from './CircleBadge';
67 changes: 67 additions & 0 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { cls } from '@/utils/classes';
import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/20/solid';
import { Fragment } from 'react';

type DropdownProps = {
options: string[];
value: string;
onChange: (value: string) => void;
};

export const Dropdown = ({ value: selected, options, onChange }: DropdownProps) => {
return (
<Menu as="div" className="relative block text-left w-full">
<div>
<Menu.Button className="inline-flex w-full justify-between items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black">
{selected}
<ChevronUpIcon
className="-mr-1 ml-2 h-5 w-5 ui-open:hidden"
aria-hidden="true"
/>
<ChevronDownIcon
className="-mr-1 ml-2 h-5 w-5 hidden ui-open:block"
aria-hidden="true"
/>
</Menu.Button>
</div>

<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute left-0 z-10 mt-2 w-full origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
key={selected}
>
<div className="">
{options.map((item) => (
<Menu.Item key={item}>
{({ active }) => (
<button
onClick={() => onChange(item)}
className={cls(
active ? 'bg-gray-100 text-gray-900' : 'text-gray-700',
selected === item ? 'bg-gray-200' : '',
'px-4 py-2 text-sm w-full text-left flex items-center space-x-2 justify-between'
)}
>
<span>{item}</span>
{selected === item ? (
<CheckIcon className="w-4 h-4 text-bold" />
) : null}
</button>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
);
};
1 change: 1 addition & 0 deletions src/components/Dropdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Dropdown } from './Dropdown';
40 changes: 40 additions & 0 deletions src/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Icon } from '../Svgs';

export const Footer = () => {
return (
<footer className="w-full border-t border-gray-200 h-18 sm:h-12">
<div className="flex sm:flex-row flex-col justify-between mx-auto max-w-7xl h-18 sm:h-12 items-center sm:py-0 py-2 sm:px-2">
<div className="text-sm text-center">
Powered by{' '}
<a
href="https://openai.com/"
target="_blank"
rel="noreferrer"
className="font-bold hover:underline transition underline-offset-2"
>
OpenAI{' '}
</a>
and{' '}
<a
href="https://vercel.com/"
target="_blank"
rel="noreferrer"
className="font-bold hover:underline transition underline-offset-2"
>
Vercel Edge Functions.
</a>
</div>
{/**<p className="text-sm font-medium">Made with ❤️</p>*/}
<div className="flex space-x-2">
<a
href="https://github.com/alexmarqs/real-estate-ai-app"
target="_blank"
rel="noreferrer"
>
<Icon name="github" className="h-6 w-6 fill-slate-500 hover:fill-slate-700" />
</a>
</div>
</div>
</footer>
);
};
1 change: 1 addition & 0 deletions src/components/Footer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Footer } from './Footer';
166 changes: 166 additions & 0 deletions src/components/GenerationForm/GenerationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { useGenerationForm } from '@/hooks/useGenerationForm';
import { cls } from '@/utils/classes';
import { AUDIENCES, MOODS, PROPERTY_TYPES } from '@/utils/options';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { CircleBadge } from '../CircleBadge';
import { Dropdown } from '../Dropdown';
import { Icon } from '../Svgs';

type GenerationFormProps = {
className?: string;
};

type FormData = {
propertyType: string;
description: string;
audience: string;
mood: string;
};

const INITIAL_FORM_DATA: FormData = {
propertyType: PROPERTY_TYPES[0],
description: '',
audience: AUDIENCES[0],
mood: MOODS[0],
};

export const GenerationForm = ({ className }: GenerationFormProps) => {
const {
//reset,
register,
handleSubmit,
formState: { errors },
control,
} = useForm<FormData>({
defaultValues: INITIAL_FORM_DATA,
});

const { description, isLoading, generate } = useGenerationForm();

const onSubmit = (data: FormData) => {
generate({
propertyType: data.propertyType as any,
description: data.description,
targetAudience: data.audience as any,
mood: data.mood as any,
});
};

useEffect(() => {
if (!description) {
return;
}
// scroll to the bottom of the page
window.scrollTo(0, document.body.scrollHeight);
}, [description]);

return (
<div className={cls('max-w-2xl w-full', className)}>
<form className="flex flex-col space-y-8" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-2">
<CircleBadge value="1" />
<label className="text-left font-medium" htmlFor="propertyType">
Property Type
</label>
</div>
<Controller
name="propertyType"
control={control}
render={({ field }) => (
<Dropdown
options={Array.from(PROPERTY_TYPES)}
value={field.value}
onChange={(item: string) => field.onChange(item)}
/>
)}
/>
</div>
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-2">
<CircleBadge value="2" />
<label className="text-left font-medium" htmlFor="description">
Description
</label>
</div>
<div>
<textarea
{...register('description', { required: true })}
id="description"
name="description"
rows={4}
className={cls(
'w-full rounded-md border-gray-300 shadow-sm focus:border-black focus:ring-black',
errors.description?.type === 'required'
? ' focus:border-red-500 focus:ring-red-500 border-red-300'
: undefined
)}
placeholder={
'e.g. An apartment with beautiful views of the city, modern interior, and a spacious living room.'
}
/>
{errors.description?.type === 'required' && (
<p className="text-red-500 text-sm text-left">This field is required</p>
)}
</div>
</div>
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-2">
<CircleBadge value="3" />
<label className="text-left font-medium" htmlFor="audience">
Target Audience
</label>
</div>
<Controller
name="audience"
control={control}
render={({ field }) => (
<Dropdown
options={Array.from(AUDIENCES)}
value={field.value}
onChange={(item: string) => field.onChange(item)}
/>
)}
/>
</div>
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-2">
<CircleBadge value="4" />
<label className="text-left font-medium" htmlFor="mood">
Mood
</label>
</div>
<Controller
name="mood"
control={control}
render={({ field }) => (
<Dropdown
options={Array.from(MOODS)}
value={field.value}
onChange={(item: string) => field.onChange(item)}
/>
)}
/>
</div>
<button
type="submit"
disabled={isLoading}
className={cls(
'bg-black text-white font-medium py-2 px-4 rounded-lg hover:bg-gray-800',
isLoading ? 'cursor-not-allowed bg-gray-500 hover:bg-gray-500' : undefined
)}
>
{isLoading && <Icon name="spinner" />}
Generate now ⚡︎
</button>
</form>
{!isLoading && description && (
<div className="mt-8">
<h2 className="text-3xl font-bold">Generated Description 🎉</h2>
<p className="mt-4 text-left">{description}</p>
</div>
)}
</div>
);
};
1 change: 1 addition & 0 deletions src/components/GenerationForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GenerationForm } from './GenerationForm';
22 changes: 22 additions & 0 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Image from 'next/image';
import vercelLogo from '../../../public/vercelLogo.png';

export const Header = () => {
return (
<header className="w-full border-b border-gray-200 h-14">
<div className="flex justify-between mx-auto max-w-7xl h-14 items-center px-4 md:px-2">
<h1 className=" text-xl sm:text-2xl font-bold tracking-tight text-center">
Real Estate AI Generator
</h1>

<a
href={'https://vercel.com/templates/next.js/twitter-bio'}
target="_blank"
rel="noreferrer"
>
<Image src={vercelLogo} alt="Logo" className="w-8 h-[28px]" />
</a>
</div>
</header>
);
};
1 change: 1 addition & 0 deletions src/components/Header/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Header } from './Header';

0 comments on commit 810d967

Please sign in to comment.