전 글과 이어집니다!
https://duektmf34.tistory.com/194
다음으로 할 작업은 API 연동이다.
사용할 API는 요기!
https://api-ninjas.com/api/cars
API 연동부분은 utils/index.ts에서 작업을 하였다.
utils/index.ts
나중에 삭제, 업데이트 관련 API 부분도 들어가지만 일단 GET방식으로 받아오는 것 먼저 해보자.
export async function fetchCars(filters: FilterProps) {
const { manufacturer, year, model, limit, fuel } = filters;
// HeadersInit은 TypeScript에서 Fetch API를 사용할 때 헤더를 지정하는 데 사용되는 타입!
const headers: HeadersInit = {
"X-RapidAPI-Key": "자신의api key를 넣어주세용!",
"X-RapidAPI-Host": "cars-by-api-ninjas.p.rapidapi.com",
};
// api 요청하는 부분
const response = await fetch(
`https://cars-by-api-ninjas.p.rapidapi.com/v1/cars?make=${manufacturer}&year=${year}&model=${model}&limit=${limit}&fuel_type=${fuel}`,
{
headers: headers,
}
);
// Parse the response as JSON
const result = await response.json();
console.log(JSON.stringify(result));
return result;
}
여기서 중요한 부분은 filters!
전달된 filters 객체에서 필요한 속성들을 추출하여 각각의 변수에 할당한다. 마찬가지로 filterProps 타입을 정해주자.
types/index.ts 중 FilterProps
// API 요청시 파라미터 값 속성
export interface FilterProps {
manufacturer?: string;
year?: number;
model?: string;
limit?: number;
fuel?: string;
}
각각 제조사, 년도, 모델, 갯수제한, 연료를 필터링해서 가져온다.
콘솔에 JSON.stringify를 통해 찍어보면 json형태의 정보들이 그대로 찍히는 것들을 볼 수 있다.아주 잘 나오는군!
개인적으로 axios로 작업하는 걸 좋아하지만 axios깔기 귀찮다며 fetch로 하자는 자스마스터..
API연동을 했으면 화면에 뿌려줘야겠져?
page.tsx에서 작업해보자.
app/page.tsx
const allCars = await fetchCars({
manufacturer: searchParams.manufacturer || "",
year: searchParams.year || 2023,
fuel: searchParams.fuel || "",
limit: searchParams.limit || 10,
model: searchParams.model || "",
});
const isDataEmpty = !Array.isArray(allCars) || allCars.length < 1 || !allCars;
return (
<main className="overflow-hidden">
<div className="mt-12 padding-x padding-y max-width" id="discover">
{!isDataEmpty ? (
<section>
<div className="home__cars-wrapper">
{allCars?.map((car) => (
<CarCard car={car} />
))}
</div>
</section>
) : (
<div className="home__error-container">
<h2 className="text-black text-xl font-bold">Oops, no results</h2>
<p>{allCars?.message}</p>
</div>
)}
</div>
</main>
);
Hero 부분과 Search부분은 제외한 부분이다.
- fetchCars 함수를 호출하여 자동차 검색 결과를 가져오고 있다. 검색 파라미터로는 제조사(manufacturer), 연도(year), 연료 종류(fuel), 검색 결과 개수 제한(limit), 모델(model) 등이 사용되며, 기본값이나 빈 문자열이 지정되어 있다.
- isDataEmpty 변수는 검색 결과가 비어있는지 여부를 판단하는데 사용된다. 검색 결과가 배열이 아니거나 길이가 1보다 작거나 검색 결과 자체가 false인 경우 isDataEmpty는 true가 된다.(조건부 렌더링!)
- <main> 엘리먼트 안에는 검색 결과에 따라 다른 섹션이 표시됩니다. 검색 결과가 비어있지 않다면, 자동차 카드(CarCard)를 매핑하여 표시합니다. 이 부분은 allCars?.map(...)을 통해 각 자동차에 대한 카드를 생성하고 있다.(CarCard는 아래에서 작업)
- 검색 결과가 비어있을 경우, 에러를 나타내는 섹션이 표시.
components/CarCard.tsx
본격적으로 자동차 카드섹션 작업을 해볼 거다.
각 카드 안에 api에서 가져온 정보들을 뿌려줄 것!
"use client";
import { useState } from "react";
import Image from "next/image";
import { CarProps } from "@/types";
import { calculateCarRent, generateCarImageUrl } from "@/utils";
import CustomButton from "./CustomButton";
import CarDetails from "./CarDetails";
interface CarCardProps {
car: CarProps;
}
const CarCard = ({ car }: CarCardProps) => {
const { city_mpg, year, make, model, transmission, drive } = car;
const [isOpen, setIsOpen] = useState(false);
const carRent = calculateCarRent(city_mpg, year);
return (
<div className="car-card group">
<div className="car-card__content">
<h2 className="car-card__content-title">
{/* 제조사와 모델 */}
<span className="text-primary-blue">{make}</span> {model}
</h2>
</div>
<p className="flex mt-6 text-[32px] leading-[38px] font-extrabold">
<span className="self-start text-[14px] leading-[17px] font-semibold">
$
</span>
{/* 렌탈비 */}
{carRent}
<span className="self-end text-[14px] leading-[17px] font-medium">
/day
</span>
</p>
<div className="relative w-full h-40 my-3 object-contain">
{/* 차 이미지 */}
<Image
src={generateCarImageUrl(car)}
alt="car model"
fill
priority
className="object-contain"
/>
</div>
<div className="relative flex w-full mt-2">
<div className="flex group-hover:invisible w-full justify-between text-grey">
<div className="flex flex-col justify-center items-center gap-2">
<Image
src="/steering-wheel.svg"
width={20}
height={20}
alt="steering wheel"
/>
<p className="text-[14px] leading-[17px]">
{/* 오토인지 수동인지? */}
{transmission === "a" ? "Automatic" : "Manual"}
</p>
</div>
<div className="car-card__icon">
<Image src="/tire.svg" width={20} height={20} alt="seat" />
{/* 구동 방식(FWD, RWD, AWD, 4WD) */}
<p className="car-card__icon-text">{drive.toUpperCase()}</p>
</div>
<div className="car-card__icon">
<Image src="/gas.svg" width={20} height={20} alt="seat" />
{/* 연비? */}
<p className="car-card__icon-text">{city_mpg} MPG</p>
</div>
</div>
<div className="car-card__btn-container">
<CustomButton
title="View More"
containerStyles="w-full py-[16px] rounded-full bg-primary-blue"
textStyles="text-white text-[14px] leading-[17px] font-bold"
rightIcon="/right-arrow.svg"
handleClick={() => setIsOpen(true)}
/>
</div>
</div>
<CarDetails
isOpen={isOpen}
closeModal={() => setIsOpen(false)}
car={car}
/>
</div>
);
};
export default CarCard;
- CarCardProps 인터페이스: CarCard 컴포넌트가 받을 car prop의 타입을 정의하는 인터페이스다.
- useState 훅: isOpen이라는 상태 변수를 초기값 false로 세팅하고, 이 상태를 관리하는 setIsOpen 함수를 선언
- calculateCarRent 함수를 사용하여 carRent 상태를 계산한다. 이 함수는 city_mpg와 year를 이용하여 자동차 대여료를 계산하는 것
CarCardProps 타입과 calculateCarRent함수를 살펴보자.
🪄 CarCardProps
// 자동차 속성
export interface CarProps {
city_mpg: number;
class: string;
combination_mpg: number;
cylinders: number;
displacement: number;
drive: string;
fuel_type: string;
highway_mpg: number;
make: string;
model: string;
transmission: string;
year: number;
}
요건 API요청 시 받아오는 데이터 key값을 참조해 적은 것이다.
🪄 calculateCarRent함수 (utils/index.ts에 작성)
// 자동차 렌탈비 계산
export const calculateCarRent = (city_mpg: number, year: number) => {
const basePricePerDay = 50; // 하루 대여 기본 요금으로 50달러를 설정
const mileageFactor = 0.1; // 주행한 마일당 추가 요금을 나타내는 요소로, 0.1로 설정
const ageFactor = 0.05; // 자동차 나이에 따른 추가 요금을 나타내는 요소로, 0.05로 설정
// 주행한 마일에 기반한 추가 요금을 계산. city_mpg에 mileageFactor를 곱한 값을 사용
const mileageRate = city_mpg * mileageFactor;
// 자동차 나이에 기반한 추가 요금을 계산. 현재 연도에서 year를 뺀 후, ageFactor를 곱한 값을 사용
const ageRate = (new Date().getFullYear() - year) * ageFactor;
// 기본 요금과 추가 마일리지, 나이에 대한 요금을 합산하여 하루 대여료를 계산
const rentalRatePerDay = basePricePerDay + mileageRate + ageRate;
// toFixed를 사용해 최종 대여료를 반올림하여 소수점 이하를 제거하고 문자열로 반환
return rentalRatePerDay.toFixed(0);
};
자스마스터분도 gpt한테 물어봐서 작성한 거란다.. (gpt 만능 ㄷㄷ;)
그걸 한국어로 번역해 적어놓았다! 대충 렌탈비 계산하는 함수라 생각하고 가볍게 넘어가면 좋을 듯 싶다.
그리고 여기서 또 쓰인 공통요소!
CustomButton.
<div className="car-card__btn-container">
<CustomButton
title="View More"
containerStyles="w-full py-[16px] rounded-full bg-primary-blue"
textStyles="text-white text-[14px] leading-[17px] font-bold"
rightIcon="/right-arrow.svg"
handleClick={() => setIsOpen(true)}
/>
</div>
세부 정보를 볼 수 있는 버튼이다. (View More)
마찬가지로 명시해둔 타입을 토대로 커스텀해 사용하였다.
handleClick함수는 setIsOpen이 true로 변하게끔 구현.
setIsOpen이 true로 되면
<CarDetails
isOpen={isOpen}
closeModal={() => setIsOpen(false)}
car={car}
/>
CarDetails 컴포넌트가 활성화된다.
components/CarDetails.tsx
"use client";
import { Fragment } from "react";
import Image from "next/image";
import { Dialog, Transition } from "@headlessui/react";
import { generateCarImageUrl } from "@/utils";
import { CarProps } from "@/types";
interface CarDetailsProps {
isOpen: boolean;
closeModal: () => void;
car: CarProps;
}
const CarDetails = ({ isOpen, closeModal, car }: CarDetailsProps) => (
<>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-out duration-300"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto transform rounded-2xl bg-white p-6 text-left shadow-xl transition-all flex flex-col gap-5">
<button
type="button"
className="absolute top-2 right-2 z-10 w-fit p-2 bg-primary-blue-100 rounded-full"
onClick={closeModal}
>
<Image
src="/close.svg"
alt="close"
width={20}
height={20}
className="object-contain"
/>
</button>
<div className="flex-1 flex flex-col gap-3">
<div className="relative w-full h-40 bg-pattern bg-cover bg-center rounded-lg">
<Image
src={generateCarImageUrl(car)}
alt="car model"
fill
priority
className="object-contain"
/>
</div>
<div className="flex gap-3">
<div className="flex-1 relative w-full h-24 bg-primary-blue-100 rounded-lg">
<Image
src={generateCarImageUrl(car, "29")}
alt="car model"
fill
priority
className="object-contain"
/>
</div>
<div className="flex-1 relative w-full h-24 bg-primary-blue-100 rounded-lg">
<Image
src={generateCarImageUrl(car, "33")}
alt="car model"
fill
priority
className="object-contain"
/>
</div>
<div className="flex-1 relative w-full h-24 bg-primary-blue-100 rounded-lg">
<Image
src={generateCarImageUrl(car, "13")}
alt="car model"
fill
priority
className="object-contain"
/>
</div>
</div>
</div>
<div className="flex-1 flex flex-col gap-2">
<h2 className="font-semibold text-xl capitalize">
{car.make} {car.model}
</h2>
<div className="mt-3 flex flex-wrap gap-4">
{Object.entries(car).map(([key, value]) => (
<div
className="flex justify-between gap-5 w-full text-right"
key={key}
>
<h4 className="text-grey capitalize">
{key.split("_").join(" ")}
</h4>
<p className="text-black-100 font-semibold">{value}</p>
</div>
))}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
</>
);
export default CarDetails;
- CarDetailsProps 인터페이스: CarDetails 컴포넌트가 받을 prop들의 타입을 정의하는 인터페이스이다.
- Transition과 Dialog는 headlessui에 포함되어있는 UI요소이다. (Transition: 애니메이션 효과, Dialog: 모달)
- 세부 정보는 Object.entries(car).map(...)를 사용하여 동적으로 생성되며, 세부 정보의 키와 값이 나란히 표시되도록 구현되어 있다.
여기서 generateCarImageUrl 함수를 통해 이미지 API 연결하고, 가져와야하는데...
API 제공 사이트에서 치사하게 막아놨다 ㅜㅜ...
여러가지 시도 해보고 마지막쯤에 영상 설명을 번역해봤더니..
ㅋㅋㅋ 에라이~ ㅜ
다 똑같은 사진이라 너무너무너무너무너무 아쉽다.............ㅜㅜ
componets/CustomFilter.tsx
다음 작업할 부분은 filter부분!
연료와 연도를 필터해서 보여주는 듯 싶다.
"use client";
import { Fragment, useState } from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { Listbox, Transition } from "@headlessui/react";
import { updateSearchParams } from "@/utils";
import { CustomFilterProps } from "@/types";
export default function CustomFilter({ title, options }: CustomFilterProps) {
const router = useRouter();
const [selected, setSelected] = useState(options[0]); // selected 상태를 초기값으로 첫 번째 옵션으로 설정
// 함수: 선택된 옵션에 따라 URL의 검색 매개변수를 업데이트하고, 새 URL로 이동하는 함수
const handleUpdateParams = (e: { title: string; value: string }) => {
const newPathName = updateSearchParams(title, e.value.toLowerCase());
router.push(newPathName);
};
return (
<div className="w-fit">
<Listbox
value={selected}
onChange={(e) => {
setSelected(e); // Update the selected option in state
handleUpdateParams(e); // Update the URL search parameters and navigate to the new URL
}}
>
<div className="relative w-fit z-10">
<Listbox.Button className="custom-filter__btn">
<span className="block truncate">{selected.title}</span>
<Image
src="/chevron-up-down.svg"
width={20}
height={20}
className="ml-4 object-contain"
alt="chevron_up-down"
/>
</Listbox.Button>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options className="custom-filter__options">
{options.map((option) => (
<Listbox.Option
key={option.title}
className={({ active }) =>
`relative cursor-default select-none py-2 px-4 ${
active ? "bg-primary-blue text-white" : "text-gray-900"
}`
}
value={option}
>
{({ selected }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{option.title}
</span>
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
}
- CustomFilterProps 인터페이스: CustomFilter 컴포넌트가 받을 prop들의 타입을 정의하는 인터페이스
- useState 훅: selected 상태를 초기값으로 첫 번째 옵션으로 설정하고, 이 상태를 업데이트할 때 사용되는 setSelected 함수를 선언하고 있다.
- handleUpdateParams 함수: 선택된 옵션에 따라 URL의 검색 매개변수를 업데이트하고, 새 URL로 이동하는 함수이다. 이 함수는 updateSearchParams 유틸리티 함수를 사용하여 새로운 경로를 생성하고, router.push를 통해 페이지를 이동한다.
- <Listbox> 컴포넌트: headlessui 라이브러리의 Listbox를 사용하여 드롭다운 형태의 선택 목록을 구현한다. value prop으로 현재 선택된 옵션을, onChange prop으로 선택이 변경될 때 호출될 함수를 전달한다.
- <Listbox.Button>: 목록 박스의 버튼 역할을 하는 부분으로, 현재 선택된 옵션과 드롭다운 아이콘을 포함하고 있다.
- <Listbox.Options>: 실제 옵션 목록을 나타내며, map 함수를 사용하여 각 옵션을 렌더링한다.
- <Listbox.Option>: 각 옵션을 나타내는 부분으로, 선택 여부에 따라 스타일이 변경되도록 동적으로 클래스를 설정한다.
🪄types/index.ts 중 CustomFilterProps
export interface OptionProps {
title: string;
value: string;
}
export interface CustomFilterProps {
title: string;
options: OptionProps[];
}
- OptionProps 인터페이스:
- title: 옵션의 표시 제목을 나타내는 문자열
- value: 옵션의 값으로 사용되는 문자열
- CustomFilterProps 인터페이스:
- title: 커스텀 필터의 제목을 나타내는 문자열
- options: OptionProps의 배열로, 커스텀 필터에서 사용 가능한 옵션들을 나타낸다.
마지막으로 page.tsx에 적용시켜준다!
<div className="home__filter-container">
<CustomFilter title="fuel" options={fuels} />
<CustomFilter title="year" options={yearsOfProduction} />
</div>
당연히 import 하고 써야겟죠?!
그리고 options는 constants/index.ts파일에 따로 매핑해놨다.
🪄constants/index.ts
export const yearsOfProduction = [
{ title: "Year", value: "" },
{ title: "2015", value: "2015" },
{ title: "2016", value: "2016" },
{ title: "2017", value: "2017" },
{ title: "2018", value: "2018" },
{ title: "2019", value: "2019" },
{ title: "2020", value: "2020" },
{ title: "2021", value: "2021" },
{ title: "2022", value: "2022" },
{ title: "2023", value: "2023" },
];
export const fuels = [
{
title: "Fuel",
value: "",
},
{
title: "Gas",
value: "Gas",
},
{
title: "Electricity",
value: "Electricity",
},
];
components/ShowMore.tsx
마지막으로 작업할 곳은 더보기다!!
우리가 api요청할 때 limit를 10으로 걸어놨기 때문에 더보기 버튼이 필요하다.
"use client";
import { useRouter } from "next/navigation";
import { CustomButton } from "@/components";
import { ShowMoreProps } from "@/types";
import { updateSearchParams } from "@/utils";
const ShowMore = ({ pageNumber, isNext }: ShowMoreProps) => {
const router = useRouter();
const handleNavigation = () => {
// 페이지 번호와 다음/이전 페이지 여부에 따라 새로운 limit 값을 계산하고 URL의 검색 매개변수를 업데이트한 후 새로운 경로로 이동한다.
const newLimit = (pageNumber + 1) * 10;
const newPathname = updateSearchParams("limit", `${newLimit}`);
// url 이동 후 스크롤 상단으로 가지않게 막아주는 옵션 추가
router.push(newPathname, { scroll: false });
};
return (
<div className="w-full flex-center gap-5 mt-10">
{!isNext && (
<CustomButton
btnType="button"
title="Show More"
containerStyles="bg-primary-blue rounded-full text-white"
handleClick={handleNavigation}
/>
)}
</div>
);
};
export default ShowMore;
- ShowMoreProps 인터페이스: ShowMore 컴포넌트가 받을 prop들의 타입을 정의
- handleNavigation 함수: 버튼 클릭 시 호출되며, 페이지 번호와 다음/이전 페이지 여부에 따라 새로운 limit 값을 계산하고 URL의 검색 매개변수를 업데이트한 후 새로운 경로로 이동한다.
- isNext prop이 false인 경우에만 버튼이 표시되며, 버튼 클릭 시 handleNavigation 함수가 실행된다.
여기서 show more를 눌렀을 때 페이지 상단으로 가는 불편함이 있었는데 router.push할 때 scroll 옵션을 false로 바꿔주면 스크롤값이 유지가된다! 공식문서를 참고해보자. (여기에 답이 다 나와있음...ㅜ)
https://nextjs.org/docs/pages/api-reference/functions/use-router
🪄types/index.ts 중 ShowMoreProps
export interface ShowMoreProps {
pageNumber: number;
isNext: boolean;
}
pageNumber는 숫자로, isNext는 불리언 값으로 정의하였다.
page.tsx에 적용하면?
<ShowMore
pageNumber={(searchParams.limit || 10) / 10}
isNext={(searchParams.limit || 10) > allCars.length}
/>
이런 식으로 limit 제한을 걸어 커스텀할 수 있다!
이로서 Car-showcase의 모든 기능을 다 구현하였다.
완성된 사이트
https://car-showcase-jjul.netlify.app/
클론 코딩 후기
처음에 학원서 무작정 따라할 땐 뭔소린지도 모르고 그냥 쳤는데.. 다시 영상보며 한 번 싸악~ 정리하니 머릿속에 잘 들어온다. 프젝 규모가 작아 구조화가 제대로 되지 않아 아쉽지만 다음 큰 프로젝트 클론할 때 구조화에 더 신경쓰는 걸로!
그리고 공통 요소를 어떻게 컴포넌트화 하는가, 커스텀을 어떻게 입맛대로 잘 할 수 있을까에 대해 다시 한 번 생각해보는 계기였당.
프로젝트가 커지면 커스텀 컴포넌트의 활용성이 더욱 높아질 것이고 코드 짤 때도 신중하게 짜야할 것이다!