사실 요건 작년 여름에 했던 건데.. .그땐 무작정 따라치기 바빠서 개념도 제대로 모르고 했었다.
그래서 다시 영상을 보며 모르는 부분을 체크하고 전반적인 흐름을 블로그에 작성해보려한다.
참고 영상:
https://youtu.be/pUNSHPyVryU?si=Yihi2edO2Ym-A-Jk
아쉬운 건 API로 이미지 불러와야하는데 쓰려면 뭐 허가를 받아야하고..허가를 받으려면 통화를 해서 어떻게 쓸 건지 외국인과 통화해야했던 걸로 기억한다.. (작년 여름에 학원 사람들이랑 하면서..)
기술 스택
Next.js, Typescript, tailwind css, Post css
사용한 API
https://rapidapi.com/apininjas/api/cars-by-api-ninjas/
일단 리액트 말고 Next.js로 작업한 이유가 무엇이냐!
🪄 Next.js의 장점
- 서버 사이드 렌더링(SSR) 및 정적 사이트 생성(SSG) 지원
- Next.js는 서버 사이드 렌더링(SSR)과 정적 사이트 생성(SSG)을 쉽게 구현할 수 있는 기능을 제공한다. 이는 초기 로딩 속도를 향상시키고 SEO를 개선하는 데 도움된다! 반면 리액트는 기본적으로 클라이언트 사이드 렌더링을 지원하므로 초기 로딩 속도가 느릴 수 있다.
- 설정의 간소화와 내장 기능
- Next.js는 프로젝트 구성을 간소화하기 위한 내장 기능들을 많이 제공한다. 예를 들어, 자동으로 라우팅되는 파일 시스템 기반의 페이지 라우팅, CSS 모듈 지원, 이미지 최적화, 환경 변수 관리 등이 내장되어 있다!
- SEO 지원
- SEO(검색 엔진 최적화)에 대한 특별한 지원을 제공한다! 일반적인 단일 페이지 어플리케이션(SPA)이나 클라이언트 사이드 라우팅을 사용하는 경우, 검색 엔진은 페이지의 콘텐츠를 인식하기 어려울 수 있지만 Next.js는 서버 사이드 렌더링(SSR)을 지원하여 페이지를 서버에서 사전에 렌더링할 수 있다.
app/layout.tsx
import "./globals.css";
import { Footer, Navbar } from "../components";
export const metadata = {
title: "Car Hub",
description: "Discover world's best car in the world.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body className="relative">
<Navbar />
{children}
<Footer />
</body>
</html>
);
}
Next.js에서 사용되는 커스텀 레이아웃(일반적으로 공통적인 페이지 요소를 갖고 있는 레이아웃)을 정의하는 부분이다.
이 레이아웃은 전체 페이지에 대한 공통적인 구조를 설정하는 부분!
Navbar와 Footer 컴포넌트를 따로 만들고(이부분은 굳이 코드를 쓰지 않겠음), 그 안에 요소들은 children으로 실제 페이지들이 렌더링 되는 것을 동적으로 표시해준다.
또한 metadata 객체를 통해 페이지의 메타데이터(타이틀, 설명 등)를 정의해준다! 이 정보는 페이지의 SEO에 활용될 수 있기 때문에!!
app/page.tsx
page.tsx에서 컴포넌트들을 불러올 때 코드가독성을 높이기 위해 index.ts에 따로 모아놓은 걸 확인할 수 있다.
저 컴포넌트들은 아래의 사진처럼 components파일 안 index.ts에 export되고있다.
page.tsx에서는 최대한 깔끔하게 코드가 나와야 좋기때문에 컴포넌트를 불러올 때도 이런식으로 활용한 듯 싶다.
components/Hero.tsx
먼저 작업할 부분은 맨 처음 보여지는 Hero이미지 부분!
외국 유튜버들이 진행하는 프로젝트에서 항상 Hero 배너부분이 있어서 대체 뭘까..하고 서치해봤는데
히어로 이미지는 웹 디자인에서 사용되는 하나의 용어로 웹페이지 상단에 위치한 큰 배너를 뜻합니다.
라네... 글쿤!
"use client";
import Image from "next/image";
import { CustomButton } from ".";
const Hero = () => {
// discover 구역으로 넘어가는 함수(스무스 효과)
const handleScroll = () => {
const nextSection = document.getElementById("discover");
if (nextSection) {
nextSection.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div className="hero">
<div className="flex-1 pt-36 padding-x">
<h1 className="hero__title">
Find, book, or rent a car -- quickly and easily!
</h1>
<p className="hero__subtitle">
Streamline your car rental experience with our effortless booking
process.
</p>
<CustomButton
title="Explore Cars"
containerStyles="bg-primary-blue text-white rounded-full mt-10"
handleClick={handleScroll}
/>
</div>
<div className="hero__image-container">
<div className="hero__image">
<Image src="/hero.png" alt="hero" fill className="object-contain" />
<div className="hero__image-overlay" />
</div>
</div>
</div>
);
};
export default Hero;
여기서 중요한 부분은 CustomButton부분이다.
CustomButton에 대한 속성들을 타입스크립트로 타입을 정하고 입맛에 맞게 커스텀하며 공통요소로 쓰일 것이다!
components/CustomButton.tsx
"use client";
import { CustomButtonProps } from "@/types";
import Image from "next/image";
const CustomButton = ({
title,
containerStyles,
handleClick,
btnType,
textStyles,
rightIcon,
}: CustomButtonProps) => {
return (
<button
disabled={false}
type={btnType || "button"}
className={`custom-btn ${containerStyles}`}
onClick={handleClick}
>
<span className={`flex-1 ${textStyles}`}>{title}</span>
{rightIcon && (
<div className="relative w-6 h-6">
<Image
src={rightIcon}
alt="arrow_left"
fill
className="object-contain"
/>
</div>
)}
</button>
);
};
export default CustomButton;
- 버튼은 <button> 엘리먼트로 구성되어 있으며, 버튼을 클릭했을 때 handleClick 함수가 실행되도록 설정
- 버튼에는 제목을 나타내는 텍스트와 옵션으로 제공된 경우에만 오른쪽에 아이콘을 표시할 수 있는 기능이 있다.
- 버튼의 스타일은 주어진 prop들을 사용하여 동적으로 조정된다.(공통요소이기 때문이다.)
CustomButton 컴포넌트는 title, containerStyles, handleClick, btnType, textStyles, rightIcon과 같은 여러 prop을 받는다.
이 속성들은 어디서 받느냐?
바로 typescript를 통해 타입들을 정해주는 것이다.
types/index.ts 중 CustomButtonProps
이런식으로 CustomButton의 속성들의 타입들을 정해준다.
- title: 버튼에 표시될 텍스트의 타입은 문자열(string)이다.
- containerStyles: 버튼을 감싸는 컨테이너의 스타일을 지정하는데 사용되는 문자열이다. 이 값은 선택적(optional)이며, 주어지지 않으면 기본값이 사용됩니다.
- handleClick: 버튼 클릭 이벤트를 처리하는 함수의 타입이다. MouseEventHandler<HTMLButtonElement>를 사용하여 버튼 엘리먼트에 대한 마우스 이벤트를 처리하는데, 이 값 역시 선택적(optional)
- btnType: 버튼의 타입을 나타내는 문자열이다. 가능한 값은 "button" 또는 "submit" 중 하나로. 기본값은 "button"입니다.
- isDisabled: 버튼이 비활성화(disabled)되어야 하는지 여부를 나타내는 불리언 값으로. 기본값은 false입니다.
- textStyles: 버튼 텍스트에 적용되는 스타일을 지정하는 문자열이다. 이 값은 선택적(optional)이며, 주어지지 않으면 기본값이 사용된다.
- rightIcon: 버튼 우측에 표시되는 아이콘 이미지의 경로를 나타내는 문자열입니다. 이 값 역시 선택적(optional)이며, 주어지지 않으면 아이콘이 표시되지 않습니다.
예전에 무작정 따라 쓸 땐 무슨 개소리인가 싶었는데.. 천천히 코드를 뜯어보니 이해가 아주 잘 간다.
components/SearchBar.tsx
다음으로 만들 컴포넌트는 서치바!
이부분이다.
제조사와 모델을 검색할 수 있는 기능이다.
"use client";
import Image from "next/image";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import SearchManufacturer from "./SearchManufacturer";
const SearchButton = ({ otherClasses }: { otherClasses: string }) => (
<button type="submit" className={`-ml-3 z-10 ${otherClasses}`}>
<Image
src={"/magnifying-glass.svg"}
alt={"magnifying glass"}
width={40}
height={40}
className="object-contain"
/>
</button>
);
const SearchBar = () => {
const [manufacturer, setManuFacturer] = useState("");
const [model, setModel] = useState("");
const router = useRouter();
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (manufacturer.trim() === "" && model.trim() === "") {
return alert("Please provide some input");
}
updateSearchParams(model.toLowerCase(), manufacturer.toLowerCase());
};
const updateSearchParams = (model: string, manufacturer: string) => {
const searchParams = new URLSearchParams(window.location.search);
if (model) {
searchParams.set("model", model);
} else {
searchParams.delete("model");
}
if (manufacturer) {
searchParams.set("manufacturer", manufacturer);
} else {
searchParams.delete("manufacturer");
}
const newPathname = `${
window.location.pathname
}?${searchParams.toString()}`;
router.push(newPathname);
};
return (
<form className="searchbar" onSubmit={handleSearch}>
<div className="searchbar__item">
<SearchManufacturer
manufacturer={manufacturer}
setManuFacturer={setManuFacturer}
/>
<SearchButton otherClasses="sm:hidden" />
</div>
<div className="searchbar__item">
<Image
src="/model-icon.png"
width={25}
height={25}
className="absolute w-[20px] h-[20px] ml-4"
alt="car model"
/>
<input
type="text"
name="model"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Tiguan..."
className="searchbar__input"
/>
<SearchButton otherClasses="sm:hidden" />
</div>
<SearchButton otherClasses="max-sm:hidden" />
</form>
);
};
export default SearchBar;
여기서 제조사 검색 부분은 SearchManufacturer.tsx를 통해 검색하고
모델 검색은 그냥 input요소를 통해 값을 전달하는 걸 알 수 있다.
components/SearchManufacturer.tsx
"use client";
import { useState, Fragment } from "react";
import Image from "next/image";
import { Combobox, Transition } from "@headlessui/react";
import { manufacturers } from "@/constants";
import { SearchManuFacturerProps } from "@/types";
interface SearchManufacturerProps {
selected: string;
setSelected: (value: string) => void;
}
const SearchManufacturer: React.FC<SearchManufacturerProps> = ({
selected,
setSelected,
}) => {
const [query, setQuery] = useState("");
const filteredManufacturers =
query === ""
? manufacturers
: manufacturers.filter((item) =>
item
.toLowerCase()
.replace(/\s+/g, "")
.includes(query.toLowerCase().replace(/\s+/g, ""))
);
return (
<div className="search-manufacturer">
<Combobox value={selected} onChange={setSelected}>
<div className="relative w-full">
<Combobox.Button className="absolute top-[14px]">
<Image
src="/car-logo.svg"
width={20}
height={20}
className="ml-4"
alt="car logo"
/>
</Combobox.Button>
<Combobox.Input
className="search-manufacturer__input"
placeholder="Volkswagen"
displayValue={(manufaacturer: string) => manufaacturer}
onChange={(e) => setQuery(e.target.value)}
/>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => setQuery("")}
>
<Combobox.Options
className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
static
>
{filteredManufacturers.length === 0 && query !== "" ? (
<Combobox.Option
value={query}
className="search-manufacturer__option"
>
Create "{query}"
</Combobox.Option>
) : (
filteredManufacturers.map((item) => (
<Combobox.Option
key={item}
className={({ active }) =>
`relative search-manufacturer__option ${
active ? "bg-primary-blue text-white" : "text-gray-900"
}`
}
value={item}
>
{({ selected, active }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{item}
</span>
{selected ? (
<span
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${
active
? "text-white"
: "text-pribg-primary-purple"
}`}
></span>
) : null}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</div>
);
};
export default SearchManufacturer;
여기서도 보면 SearchManufacturer의 타입을 정해주는 걸 볼 수 있다.
types/index.ts 중 SearchManufacturerProps
// 제조사 검색 속성
export interface SearchManuFacturerProps {
manufacturer: string;
setManuFacturer: (manufacturer: string) => void;
}
- manufacturer 속성: 현재 선택된 자동차 제조사의 이름을 나타내는 문자열이다.
- setManuFacturer 속성: 이는 부모 컴포넌트에서 선택된 자동차 제조사를 업데이트하기 위한 콜백 함수이다. 함수 시그니처에 따라 해당 함수는 문자열 형태의 manufacturer 값을 받아 부모 컴포넌트의 상태를 업데이트한다.
그리고 SearchManufacturer.tsx에서 쓰인 UI 라이브러리가 있는 듯 하다. ComboBox와 Transition은 headlessui에서 가져왔으니 공식 문서를 참고해보도록 하자!
🪄 Combobox Input
<Combobox.Input
className="search-manufacturer__input"
placeholder="Volkswagen"
displayValue={(manufacturer: string) => manufacturer}
onChange={(e) => setQuery(e.target.value)}
/>
- displayValue={(manufacturer: string) => manufacturer}
- displayValue는 선택된 값을 입력창에 어떻게 표시할지를 정의하는 속성
- 여기에서는 선택된 자동차 제조사의 이름(manufacturer)을 그대로 표시하도록 설정했다.
- 이 부분을 수정하면 선택된 값이 입력창에 어떻게 표시되는지 조절할 수 있습니다.
- onChange={(e) => setQuery(e.target.value)}
- 사용자가 입력창에 값을 입력하거나 수정할 때 호출되는 이벤트 핸들러
- 사용자의 입력이 변경될 때마다 setQuery 함수를 호출하여 상태를 업데이트한다. 이로써 사용자의 검색 쿼리를 추적하고, 필요한 경우에 따라 자동차 제조사 목록을 필터링할 수 있다.
🪄 필터링 함수 부분
const [query, setQuery] = useState("");
const filteredManufacturers =
query === ""
? manufacturers
: manufacturers.filter((item) =>
item
.toLowerCase()
.replace(/\s+/g, "")
.includes(query.toLowerCase().replace(/\s+/g, ""))
);
- const [query, setQuery] = useState("");:
- query는 현재 사용자가 입력한 검색 쿼리를 나타내는 상태 변수
- setQuery는 query 상태를 업데이트하기 위한 함수로, 사용자가 입력한 값을 기반으로 상태를 변경한다.
- query === "" ? manufacturers : ... (삼항연산자)
- 삼항 연산자를 사용하여, 만약 query가 비어 있다면 (query === ""), 모든 자동차 제조사를 포함한 manufacturers 배열을 그대로 반환한다.
- 그렇지 않다면 (query !== ""), manufacturers 배열을 사용하여 filter 함수를 호출하여 자동차 제조사를 필터링한다.
- manufacturers.filter((item) => ...):
- filter 함수는 배열의 각 요소에 대해 주어진 함수를 호출하고, 함수가 true를 반환하는 요소만을 새로운 배열에 포함시킨다.
- 여기서는 manufacturers 배열을 기반으로 각 자동차 제조사를 필터링!
- item.toLowerCase().replace(/\s+/g, ""):
- 각 자동차 제조사의 이름을 소문자로 변환하고, 공백을 모두 제거한다.(replace 메서드 사용)
- 이는 검색 쿼리 및 제조사 목록의 항목들을 모두 소문자로 만들어서 대소문자 구분 없이 검색할 수 있도록 한다.(toLowerCase 메서드 사용)
- .includes(query.toLowerCase().replace(/\s+/g, "")):
- 변환된 검색 쿼리가 현재 제조사의 이름에 포함되어 있는지 확인한다.(includes 메서드 사용)
- 만약 포함되어 있다면, 해당 자동차 제조사는 검색 결과에 포함된다.
🪄 ComboBox.options
서치할때 보여지는 옵션들을 설정한 구간이다.
<Combobox.Options
className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
static
>
{filteredManufacturers.length === 0 && query !== "" ? (
<Combobox.Option
value={query}
className="search-manufacturer__option"
>
Create "{query}"
</Combobox.Option>
) : (
filteredManufacturers.map((item) => (
<Combobox.Option
key={item}
className={({ active }) =>
`relative search-manufacturer__option ${
active ? "bg-primary-blue text-white" : "text-gray-900"
}`
}
value={item}
>
{({ selected, active }) => (
<>
<span
className={`block truncate ${
selected ? "font-medium" : "font-normal"
}`}
>
{item}
</span>
{selected ? (
<span
className={`absolute inset-y-0 left-0 flex items-center pl-3 ${
active
? "text-white"
: "text-pribg-primary-purple"
}`}
></span>
) : null}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
- filteredManufacturers.length === 0 && query !== "" ? ... : ...:
- 조건부 렌더링을 통해, filteredManufacturers 배열의 길이가 0이면서 검색 쿼리(query)가 비어 있지 않은 경우에는 "Create {query}"라는 옵션을 표시한다.
- filteredManufacturers.map((item) => ...):
- 자동차 제조사 목록을 순회하면서(map) 각각의 옵션을 생성한다.
- 각 옵션은 Combobox.Option으로 표현되며, key 속성으로 고유한 식별자를 제공한다.
- {({ active }) => ...}:
- Combobox.Option 내부에서 active 속성을 사용하여 해당 옵션이 활성화되었는지 여부를 확인한다.
- active가 true이면 옵션이 현재 선택된 상태이므로 배경 색상과 텍스트 색상을 변경
- <span> 및 {selected ? ... : null} 부분:
- 각 옵션의 내용을 표시하고, 선택된 옵션이라면 선택된 상태에 따라 스타일을 변경한다.
각 manufacturers는 constants/index.ts에 배열 형태로 저장시켜 export를 했다.
다시 SearchBar.tsx로 돌아와서 살펴보면 form에 Onsubmit={handleSearch}를 확인할 수 있다.
handleSearch를 살펴보면
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (searchManufacturer.trim() === "" && searchModel.trim() === "") {
return alert("Please provide some input");
}
setModel(searchModel);
setManufacturer(searchManufacturer);
};
- const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {:
- handleSearch 함수는 React에서 제공하는 FormEvent를 통해 폼 제출 이벤트를 처리하는 함수
- 폼이 제출되면 이 함수가 호출되어 검색 작업을 수행한다.
- e.preventDefault();:
- e.preventDefault()는 이벤트의 기본 동작을 취소하는 역할
- if (searchManufacturer.trim() === "" && searchModel.trim() === "") { (조건문!)
- 검색 바에서 입력한 제조사(searchManufacturer)와 모델(searchModel)이 모두 공백으로 이루어져 있는지 확인한다.
- 만약 둘 다 비어 있다면, alert("Please provide some input")을 통해 경고창을 띄워 사용자에게 입력을 제공하라는 메시지를 보여준다.
그리고 검색한 제조사와 모델을 각각 변수(set 어쩌고)에 넣어 저장한다.
근데 여기서...!!!!
유튜브에선 언급되지 않았지만 따로 저사람의 코드를 살펴보면... 매개변수로 전달된 model과 manufacturer 값을 사용하여 현재 페이지의 URL 검색 매개변수를 업데이트하고, 그 결과로 생성된 새로운 URL을 사용하여 페이지를 다시 라우팅하는 부분이 있다.
즉, 내가 찾는 제조사와 모델을 검색하면 해당 매개변수를 주소 뒤 파라미터로 전달해 페이지 새로고침 없이 바로 필터링 해준다는 것이다!
라잌디스..
해당 함수 부분을 살펴보자.
// 매개변수로 받은 값을 파라미터로 전달해주는 함수
const updateSearchParams = (model: string, manufacturer: string) => {
const searchParams = new URLSearchParams(window.location.search);
if (model) {
searchParams.set("model", model);
} else {
searchParams.delete("model");
}
if (manufacturer) {
searchParams.set("manufacturer", manufacturer);
} else {
searchParams.delete("manufacturer");
}
const newPathname = `${
window.location.pathname
}?${searchParams.toString()}`;
router.push(newPathname);
};
- const searchParams = new URLSearchParams(window.location.search);:
- window.location.search는 현재 URL의 검색 부분을 나타낸다. 이 부분을 URLSearchParams 객체로 파싱하여 검색 매개변수를 조작할 수 있게 된다!
- if문으로 해당 값 업데이트 하거나 삭제하기
- 값이 존재하는 경우를 확인하고, 있다면 searchParams.set("model", model), searchParams.set("manufacturer", manufacturer ) 를 사용하여 검색 매개변수를 설정한다.
- 모델 값이 없는 경우(else 블록), 검색 매개변수를 삭제한다.
- // Update or delete the 'manufacturer' search parameter based on the 'manufacturer' value:
- 제조사(manufacturer) 값에 따라 'manufacturer' 검색 매개변수를 업데이트하거나 삭제합니다.
- if (manufacturer)은 제조사 값이 존재하는 경우를 확인하고, 있다면 searchParams.set("manufacturer", manufacturer);를 사용하여 'manufacturer' 검색 매개변수를 설정합니다.
- 제조사 값이 없는 경우(else 블록), searchParams.delete("manufacturer");를 사용하여 'manufacturer' 검색 매개변수를 삭제합니다.
- const newPathname = ${window.location.pathname}?${searchParams.toString()};:
- 업데이트된 검색 매개변수를 포함한 새로운 URL을 생성한다.
- window.location.pathname은 현재 페이지의 경로 부분을 나타낸다.
- searchParams.toString()은 업데이트된 검색 매개변수를 문자열로 반환한다.
- 이 두 부분을 조합하여 새로운 URL(newPathname)을 생성한다.
- router.push(newPathname);:
- router.push를 사용하여 페이지를 새로운 URL로 이동시킨다.
- 이로써 새로운 URL로 라우팅되면서 페이지가 새로 렌더링된다.
서치 부분까지 작성하고 다음 글에서는 api연결과 자동차 세부설명 카드 부분을 정리해볼 예정이다!
💛코드 보기💛
https://github.com/YeoDaSeul4355/car_showcase