Project/Airbnb Clone

[Airbnb 클론코딩] Listing creation

hu6r1s 2023. 7. 11. 13:36

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='&copy; <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를 통해 달러 이모지가 나올 수 있도록 해주면 된다.

마무리

지도 매핑하는 것을 처음 해봤는데 많은 것을 배운 영상이었다.