2023.07.02 - [Project/Airbnb Clone] - [Airbnb 클론코딩] Category UI
[Airbnb 클론코딩] Category UI
fmf2023.06.25 - [Project/Airbnb Clone] - [Airbnb 클론코딩] Register & Login functionality [Airbnb 클론코딩] Register & Login functionality 2023.06.17 - [Project/Airbnb Clone] - [Airbnb 클론코딩] Auth UI [Airbnb 클론코딩] Auth UI 2023.06.11
hu-bris.tistory.com
Listing creation
이번에 만드는 것은 에어비앤비에 자신의 집을 렌탈해주는 기능이다.
Rent Modal
네이게이션바에 있는 Airbnb your home이라는 문구를 클릭하면 Rent Modal이 나오도록 할 것이다.
이전에 만들었던 useLoginModal이나 useRegisterModal처럼 useRentModal을 만들어준다.
2023.06.17 - [Project/Airbnb Clone] - [Airbnb 클론코딩] Auth UI
이제 Rent Modal 컴포넌트를 만들어주고 이를 UserMenu와 layout에 추가해준다.
집을 rent해주기 위해서 몇가지 단계를 거쳐나갈 수 있도록 enum을 사용하여 분류해준다.
enum STEPS {
CATEGORY = 0,
LOCATION = 1,
INFO = 2,
IMAGES = 3,
DESCRIPTION = 4,
PRICE = 5
}
이제 리스트를 만들고 스탭을 next와 back으로 갈 수 있도록 만들어 준다.
const [step, setStep] = useState(STEPS.CATEGORY)
const onBack = () => {
setStep((value) => value - 1)
}
const onNext = () => {
setStep((value) => value + 1)
}
const actionLabel = useMemo(() => {
if (step === STEPS.PRICE) {
return 'Create'
}
return 'Next'
}, [step])
const secondaryActionLabel = useMemo(() => {
if (step === STEPS.CATEGORY) {
return undefined
}
// ...생략
return (
<Modal
isOpen={rentModal.isOpen}
onClose={rentModal.onClose}
onSubmit={handleSubmit(onSubmit)}
actionLabel={actionLabel}
secondaryActionLabel={secondaryActionLabel}
secondaryAction={step === STEPS.CATEGORY ? undefined : onBack}
title='Airbnb your home!'
body={bodyContent}
/>
);
step이 price가 되면 마지막 단계이므로 Create라는 라벨이 나오게 하며, 그게 아니라면 Next가 나오게 된다.
이제 모달 창의 내용을 채워줘야 한다.
let bodyContent = (
<div className="flex flex-col gap-8">
<Heading
title="Which of these best describes your place?"
subtitle="Pick a category"
/>
<div
className="
grid
grid-cols-1
md:grid-cols-2
gap-3
max-h-[50vh]
overflow-y-auto
"
>
{categories.map((item) => (
<div key={item.label} className="col-span-1">
<CategoryInput
onClick={(category) => setCustomValue('category', category)}
selected={category === item.label}
label={item.label}
icon={item.icon}
/>
</div>
))}
</div>
</div>
)
기본적으로 step이 category로 되어 있으므로 초기 bodyContent는 category에 맞춰 만든다.
categories는 이전에 만들어놓은 import { categories } from '../navbar/Categories';를 통해 불러온다.
그리고 CategoryInput컴포넌트를 app/components/inputs 디렉터리에 만든다.
'use client';
import { IconType } from "react-icons/lib";
interface CategoryInputProps {
icon: IconType;
label: string;
selected?: boolean;
onClick: (value: string) => void;
}
const CategoryInput: React.FC<CategoryInputProps> = ({
icon: Icon,
label,
selected,
onClick
}) => {
return (
<div
onClick={() => onClick(label)}
className={`
rounded-xl
border-2
p-4
flex
flex-col
gap-3
hover:border-black
transition
cursor-pointer
${selected ? 'border-black' : 'border-neutral-200'}
`}
>
<Icon size={30} />
<div className="font-semibold">
{label}
</div>
</div>
);
};
export default CategoryInput;
카테고리를 선택했을때, 선택된 카테고리를 확인할 수 있도록한다.
const {
register,
handleSubmit,
setValue,
watch,
formState: {
errors,
},
reset
} = useForm<FieldValues>({
defaultValues: {
category: '',
location: null,
guestCount: 1,
roomCount: 1,
bathroomCount: 1,
imageSrc: "",
price: 1,
title: "",
description: ""
}
})
const category = watch('category')
const setCustomValue = (id: string, value: any) => {
setValue(id, value, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
})
}
useForm을 사용해서 기본 값들을 설정하고 필요한 객체들을 불러온다.
setValue를 사용하여 id 값을 value로 등록하는 기능을 하며 옵션으로 shouldValidate, shouldDirty, shouldTouch을 사용하여 유효성, 변경점, 클릭이 되었는지 여부를 표시할 수있다.
다음은 스탭을 넘겨 지역을 선택하는 부분이다.
먼저 useCountries라는 훅을 만들어야 한다.
npm install world-countries
해당 커맨드를 통해 세계나라 데이터가 들어있는 라이브러리를 설치한다.
import countries from 'world-countries';
const formattedCountries = countries.map((country) => ({
value: country.cca2,
label: country.name.common,
flag: country.flag,
latlng: country.latlng,
region: country.region,
}));
const useCountries = () => {
const getAll = () => formattedCountries;
const getByValue = (value: string) => {
return formattedCountries.find((item) => item.value === value);
}
return {
getAll,
getByValue
}
};
export default useCountries;
모든 나라들의 데이터를 하나하나 값에 매핑해준다.
'use client';
import useCountries from '@/app/hooks/useCountries';
import Select from 'react-select';
export type CountrySelectValue = {
flag: string;
label: string;
latlng: number[];
region: string;
value: string;
}
interface CountrySelectProps {
value?: CountrySelectValue;
onChange: (value: CountrySelectValue) => void;
}
const CountrySelect: React.FC<CountrySelectProps> = ({
value,
onChange
}) => {
const { getAll } = useCountries()
return (
<div>
<Select
placeholder="Anywhere"
isClearable
options={getAll()}
value={value}
onChange={(value) => onChange(value as CountrySelectValue)}
formatOptionLabel={(option: any) => (
<div className="flex flex-row items-center gap-3">
<div>{option.flag}</div>
<div>
{option.label},
<span className="text-neutral-500 ml-1">
{option.region}
</span>
</div>
</div>
)}
classNames={{
control: () => 'p-3 border-2',
input: () => 'text-lg',
option: () => 'text-lg'
}}
theme={(theme) => ({
...theme,
borderRadius: 6,
colors: {
...theme.colors,
primary: 'black',
primary25: '#ffe4e6'
}
})}
/>
</div>
);
};
export default CountrySelect;
CountrySelect 컴포넌트를 통해 국가를 선택하는 select 폼을 만들어준다.
유튜브 영상에서는 해당 국가의 국기 이모지가 나오는데 해보니 국가 코드로 나오는 것을 확인했다.
알아보니 firefox에서는 정상적으로 국기 이모지가 나오고 chrome에서 국가 코드가 나온다고 한다.
if (step == STEPS.LOCATION) {
bodyContent = (
<div className="flex flex-col gap-8">
<Heading
title="Where is your place located?"
subtitle="Help guests find you!"
/>
<CountrySelect
value={location}
onChange={(value) => setCustomValue('location', value)}
/>
<Map
center={location?.latlng}
/>
</div>
)
}
이제 step이 location일 때 bodyContent를 국가를 선택할 수 있도록 해주면 된다.
Map 컴포넌트를 만들어 해당 국가의 지도를 보여준다.
npm install leaflet
npm install -D @types/leaflet
npm install react-leaflet
먼저 leaflet이라는 라이브러리를 설치한다.
'use client';
import L from "leaflet";
import { MapContainer, Marker, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";
// @ts-ignore
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: markerIcon.src,
iconRetinaUrl: markerIcon2x.src,
shadowUrl: markerShadow.src
})
interface MapProps {
center?: number[];
}
const Map: React.FC<MapProps> = ({
center
}) => {
return (
<MapContainer
center={center as L.LatLngExpression || [51, -0.09]}
zoom={center ? 4 : 2}
scrollWheelZoom={false}
className="h-[35vh] rounded-lg"
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{center && (
<Marker
position={center as L.LatLngExpression}
/>
)}
</MapContainer>
);
};
export default Map;
MapContainer와 TileLayer, Marker를 통해 지도를 만들어주면 된다.
const Map = useMemo(() => dynamic(() => import('../Map'), {
ssr: false
}), [location])
Map을 이렇게 import해주면 location이 변경될 때마다 동적으로 가져올 수 있다.
다음은 손님, 방, 화장실 개수를 적어주는 스탭이다.
'use client';
import { useCallback } from "react";
import { AiOutlineMinus, AiOutlinePlus } from "react-icons/ai";
interface CounterProps {
title: string;
subtitle: string;
value: number;
onChange: (value: number) => void;
}
const Counter: React.FC<CounterProps> = ({
title,
subtitle,
value,
onChange
}) => {
const onAdd = useCallback(() => {
onChange(value + 1)
}, [onChange, value])
const onReduce = useCallback(() => {
if (value === 1) {
return
}
onChange(value - 1)
}, [value, onChange])
return (
<div
className="flex flex-row items-center justify-between"
>
<div className="flex flex-col">
<div className="font-medium">
{title}
</div>
<div className="font-light text-gray-600">
{subtitle}
</div>
</div>
<div className="flex flex-row itmes-center gap-4">
<div
onClick={onReduce}
className="
w-10
h-10
rounded-full
border-[1px]
border-neutral-400
flex
items-center
justify-center
text-neutral-600
cursor-pointer
hover:opacity-80
transiition
"
>
<AiOutlineMinus />
</div>
<div className="font-light text-xl text-neutral-600">
{value}
</div>
<div
onClick={onAdd}
className="
w-10
h-10
rounded-full
border-[1px]
border-neutral-400
flex
items-center
justify-center
text-neutral-600
cursor-pointer
hover:opacity-80
transiition
"
>
<AiOutlinePlus />
</div>
</div>
</div>
);
};
export default Counter;
Counter 컴포넌트로 개수를 늘리고 줄일 수 있는 개체로 만든다.
if (step === STEPS.INFO) {
bodyContent = (
<div className="flex flex-col gap-8">
<Heading
title="Share some bisics about your place"
subtitle="What amenities do you have?"
/>
<Counter
title="Guests"
subtitle="How many guests do you allow?"
value={guestCount}
onChange={(value) => setCustomValue('guestCount', value)}
/>
<hr />
<Counter
title="Rooms"
subtitle="How many rooms do you have?"
value={roomCount}
onChange={(value) => setCustomValue('roomCount', value)}
/>
<hr />
<Counter
title="Bathrooms"
subtitle="How many bathrooms do you have?"
value={bathroomCount}
onChange={(value) => setCustomValue('bathroomCount', value)}
/>
</div>
)
}
그리고 bodyContent를 해당 Counter 컴포넌트를 가져와 값을 보내주는 것으로 한다.
이제 대여할 자신의 집 이미지를 가져오기 위한 기능이다.
먼저 cloudinary라는 서비스를 사용하기 위해 가입을 한다.
npm install next-cloudinary
그리고 해당 커맨드를 통해 서비스를 이용할 수 있도록 한다.
'use client';
import { CldUploadWidget } from "next-cloudinary";
import Image from "next/image";
import { useCallback } from "react";
import { TbPhotoPlus } from "react-icons/tb";
declare global {
var cloudinary: any;
}
interface ImageUploadProps {
onChange: (value: string) => void;
value: string
}
const ImageUpload: React.FC<ImageUploadProps> = ({
onChange,
value
}) => {
const handleUpload = useCallback((result: any) => {
onChange(result.info.secure_url)
}, [onChange])
return (
<CldUploadWidget
onUpload={handleUpload}
uploadPreset="oefkkt2p"
options={{
maxFiles: 1
}}
>
{({ open }) => {
return (
<div
onClick={() => open?.()}
className="
relative
cursor-pointer
hover:opacity-70
transition
border-dashed
border-2
p-20
border-neutral-300
flex
flex-col
justify-center
items-center
gap-4
text-neutral-600
"
>
<TbPhotoPlus size={50} />
<div className="font-semibold text-lg">
Click to upload
</div>
{value && (
<div
className="absolute inset-0 w-full h-full"
>
<Image
alt="Upload"
fill
style={{ objectFit: 'cover' }}
src={value}
/>
</div>
)}
</div>
)
}}
</CldUploadWidget>
);
};
export default ImageUpload;
이거는 공식문서를 참고하도록 하자.
if (step === STEPS.IMAGES) {
bodyContent = (
<div className="flex flex-col gap-8">
<Heading
title="Add a photo of your place"
subtitle="Show guests what your place looks like!"
/>
<ImageUpload
value={imageSrc}
onChange={(value) => setCustomValue('imageSrc', value)}
/>
</div>
)
}
이렇게 ImageUpload 컴포넌트를 불러와 사용하면 된다.
다음은 글을 올릴 제목과 내용을 적는 기능이다.
if (step === STEPS.DESCRIPTION) {
bodyContent = (
<div className="flex flex-col gap-8">
<Heading
title="How would you describe your place?"
subtitle="Short and sweet works best!"
/>
<Input
id="title"
label="Title"
disabled={isLoading}
register={register}
errors={errors}
required
/>
<Input
id="description"
label="Description"
disabled={isLoading}
register={register}
errors={errors}
required
/>
<hr />
</div>
)
}
이건 그냥 저번에 만든 Input 컴포넌트를 사용하면 된다.
if (step === STEPS.PRICE) {
bodyContent = (
<div className="flex flex-col gap-8">
<Heading
title="Now, set your price"
subtitle="How much do you charge per night?"
/>
<Input
id="price"
label="Price"
formatPrice
type="number"
disabled={isLoading}
register={register}
errors={errors}
required
/>
</div>
)
}
마지막으로 가격도 Input 컴포넌트를 사용하고 formatPrice를 통해 달러 이모지가 나올 수 있도록 해주면 된다.
마무리
지도 매핑하는 것을 처음 해봤는데 많은 것을 배운 영상이었다.
'Project > Airbnb Clone' 카테고리의 다른 글
[Airbnb 클론코딩] Fetching listings whith server components (0) | 2023.08.07 |
---|---|
[Airbnb 클론코딩] Category UI (0) | 2023.07.02 |
[Airbnb 클론코딩] Register & Login functionality (0) | 2023.06.25 |
[Airbnb 클론코딩] Auth UI (1) | 2023.06.17 |
[Airbnb 클론코딩] 환경설정 및 Navbar UI(With. Youtube) (0) | 2023.06.11 |