src/app/products/upload/page.tsx
"use client";
import Button from "@/components/Button";
import Container from "@/components/Container";
import Heading from "@/components/Heading";
import Input from "@/components/Input";
import React, { useState } from "react";
import { FieldValues, SubmitHandler, useForm } from "react-hook-form";
const ProductUploadPage = () => {
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
reset,
} = useForm<FieldValues>({
defaultValues: {
title: "",
description: "",
category: "",
latitude: 33.5563,
longitute: 126.79581,
imageSrc: "",
price: 1,
},
});
const onSubmit: SubmitHandler<FieldValues> = (data) => {
}
return (
<Container>
<div className="max-w-screen-lg mx-auto">
<form className="flex flex-col gap-8" onSubmit={handleSubmit(onSubmit)}>
<Heading
title="상품 등록"
subtitle="상품을 등록하세요. "
/>
<Input
id="title"
label="Title"
disabled={isLoading}
register={register}
errors={errors}
required
/>
<hr />
<Input
id="description"
label="Description"
disabled={isLoading}
register={register}
errors={errors}
required
/>
<hr />
<Input
id="price"
label="Price"
formatPrice={true}
disabled={isLoading}
register={register}
errors={errors}
required
/>
<hr />
{/* category */}
<div
className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[50vh] overflow-w-auto"
>
</div>
<hr />
{/* kakaoMap */}
<Button label="상품 생성하기" />
</form>
</div>
</Container>
);
};
export default ProductUploadPage;
src/components/Container.tsx
import React from 'react'
interface ContainerProps {
children: React.ReactNode
}
const Container = ({ children }: ContainerProps) => {
return <div className="max-w-[2520px] mx-auto xl:px-20 md:px-10 sm:px-2 px-4 py-6">{children}</div>;
};
export default Container
src/compoenents/Heading.tsx
import React from 'react'
interface HeadingProps {
title: string;
subtitle?: string;
center?: boolean;
}
const Heading = ({ title, subtitle, center }: HeadingProps) => {
return (
<div className={center ? 'text-center' : 'text-start'}>
<h1 className="text-2xl font-bold">{title}</h1>
<h4 className="mt-2 font-light text-neutral-500">{subtitle}</h4>
</div>
);
};
export default Heading
model User {
id String @id @default(cuid())
name String?
hashedPassword String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userType UserType @default(User)
favoriteIds String[]
products Product[]
}
model Product {
id String @id @default(cuid())
title String
description String
imageSrc String
category String
userId String
price Int
latitude Float
longitude Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
DB 싱크 맞추기
npx prisma db push
이미지 업로드는 클라우디너리 모듈 사용
설치 npm i next-cloudinary
src/components/ImageUpload.tsx
를 간단생성 후
업로드페이지로 돌아와서 props를 설정한다.
react-hook-form이 아니라서 register를 안하기때문에 업데이트가 안되는데
setValue를 써서 state를 업데이트한다.
imageSrc를 upload/page.tsx에서 ImageUpload컴포넌트로 내려주는 방법
default value가 업데이트될때마다 watch를 이용해서 내려준다.
src/app/products/upload/page.tsx
const imageSrc = watch('imageSrc');
const setCustomValue = (id: string, value: any) => {
setValue(id, value); // react-hook-form 제공. value는 이미지경로
}
...
<ImageUpload onChange={(value) => setCustomValue('imageSrc', value)} value={imageSrc} />
이미지업로드 -> onChange함수 호출 -> setCustomValue 호출 -> setValue로 imageSrc의 value가 이미지경로 됨 -> watch로 imageSrc 변경 -> 다시 ImageUpload에 value가 변경됨 -> 컴포넌트에 실제 값 들어와서 이미지보여주게됨
src/components/ImageUpload.tsx
import { CldUploadWidget } from 'next-cloudinary';
import Image from 'next/image';
import React from 'react'
import { TbPhotoPlus } from 'react-icons/tb';
interface ImageUploadProps {
onChange: (value: string) => void;
value: string;
}
const ImageUpload = ({
onChange,
value
}: ImageUploadProps) => {
const handleUpload = (result: any) => {
console.log('result', result);
onChange(result.info.secure_url); // image 정보 https url 제공
}
return (
<CldUploadWidget
onUpload={handleUpload}
uploadPreset={'ididi'}
options={{
maxFiles: 1
}}
>
{({open}) => {
return (
<div
className='relative flex flex-col items-center justify-center gap-4 p-20 transition border-2 border-dashed cursor-pointer hover:opacity-70 border-neutral-300 text-neutral-300'
onClick={() => open?.()}
>
<TbPhotoPlus
size={50}
/>
{value && (
<div className='absolute inset-0 w-full h-full'>
<Image
fill
style={{ objectFit: 'cover'}}
src={value}
alt=''
/>
</div>
)}
</div>
)
}}
</CldUploadWidget>
)
}
export default ImageUpload
https://next.cloudinary.dev/installation
Cloud name을 지정해야한다.
.env에
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME="<Your Cloud Name>"
을 임의로 넣는다.
cloude name is wrong은 name이 잘못되었다는 얘기인데, cloudinary에 가서 회원가입을 한 후, Dashboard에서 cloud name을 확인할 수 있다.
setting > upload > Upload presets > upload preset name 복사
src/compoenents/ImageUpload.tsx
<CldUploadWidget>
에 uploadPreset={"프리셋이름"}
에 넣는다.
.env
에 변수 등록하여 사용한다.
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET="프리셋이름"
환경변수가 브라우저에 노출되는것은 NEXT_PUBLIC
으로 시작해야한다.
{"error":{"message":"Upload preset must be whitelisted for unsigned uploads"}}
다시 업로드 테스트를 해보면 이런 에러가 나는데,
setting > upload > Upload presets > Edit > Storage and Access > Signing Mode에서 Unsigned로 수정하고 Save
그다음 에러
Unhandled Runtime Error
Error: Invalid src prop (https://res.cloudinary.com/dcuf9roir/image/upload/v1707185727/1_x0hp8o.jpg) on `next/image`, hostname "res.cloudinary.com" is not configured under images in your `next.config.js`
See more info: https://nextjs.org/docs/messages/next-image-unconfigured-host
https://res.cloudinary.com/dcuf9roir/image/upload/v1707185727/1_x0hp8o.jpg
에 이미지가 들어가 있는데, 앱에서 보일수있게 허용 설정을 해야한다는 의미다.
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: [
'res.cloudinary.com'
]
}
}
module.exports = nextConfig
파일이 잘 올라가는것을 확인 할 수있다.
src/compoenents/categories/Categories.tsx
카테고리 목록 배열
import React from 'react'
import { FaSkiing } from 'react-icons/fa';
import { GiBoatFishing, GiIsland, GiWindmill } from 'react-icons/gi'
import { MdOutlineVilla } from 'react-icons/md'
import { TbBeach, TbPool } from 'react-icons/tb'
export const categories = [
{
label: "디지털기기",
path: "digital",
icon: TbBeach,
description: "디지털기기 카테고리 입니다.",
},
{
label: "생활가전",
path: "appliances",
icon: GiWindmill,
description: "생활가전 카테고리 입니다.",
},
{
label: "가구/인테리어",
path: "interior",
icon: MdOutlineVilla,
description: "가구/인테리어 카테고리 입니다.",
},
{
label: "여성의류",
path: "women-clothing",
icon: TbBeach,
description: "여성의류 카테고리 입니다.",
},
{
label: "남성패션/잡화",
path: "men-fashion",
icon: TbPool,
description: "남성패션/잡화 카테고리 입니다.",
},
{
label: "뷰티/미용",
path: "beauty",
icon: GiIsland,
description: "뷰티/미용 카테고리 입니다.",
},
{
label: "스포츠/레저",
path: "sports",
icon: GiBoatFishing,
description: "스포츠/레저 카테고리 입니다.",
},
{
label: "중고차",
path: "used-car",
icon: FaSkiing,
description: "중고차 카테고리 입니다.",
},
];
const Categoreis = () => {
return (
<div>Categoreis</div>
)
}
export default Categoreis
src/app/products/upload/page.tsx
카테고리 선택시
{/* category */}
const category = watch('category');
...
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-[50vh] overflow-w-auto">
{categories.map((item) => (
<div key={item.label} className="col-span-1">
<CategoryInput
onClick={(category) => setCustomValue('category', category)}
selected={category === item.path}
label={item.label}
icon={item.icon}
path={item.path}
/>
</div>
))}
</div>
src/compoenents/categories/CategoryInput.tsx
import React from 'react'
import { IconType } from 'react-icons'
interface CategoryInputProps {
icon: IconType;
label: string;
selected?: boolean;
path: string;
onClick: (value: string) => void;
}
const CategoryInput = ({
icon: Icon,
label,
selected,
onClick,
path,
}: CategoryInputProps) => {
return (
<div
onClick={() => onClick(path)}
className={`rounded-xl border-2 p-4 flex flex-col gap-3 hover:border-orange-500 transition cursor-pointer ${
selected ? "border-orange-500" : "border-neutral-200"
}`}
>
<Icon size={30} className={`${selected && "text-orange-500"}`} />
<div className={`font-semibold ${selected && "text-orange-500"}`}>
{label}
</div>
</div>
);
};
export default CategoryInput
카카오맵 API사용하기위해 키 생성하는 방법을 알아보자.
https://developers.kakao.com/console/app/
앱이름은 nextjs-market-app으로 해두고
사업자명도 동일하게지정하고,
카테고리는 쇼핑으로 했다.
플랫폼 > Web > Web 플랫폼 등록
로컬주소인 http://localhost:3000
으로 지정
요약정보에 앱키를 잘 저장해둔다.
카카오맵 컴포넌트는 dynamic import를 사용해야한다.
dynamic import를 사용하면 모듈은 빌드타임이 아닌 런타임에 불러오도록 한다. 번들파일을 분리하고 퍼포먼스가 향상될 수 있다.
초기 로딩 시 사이즈가 크거나 초기 로딩부터 사용하지 않는 부분, 또한 런타임에만 알 수 있는 정보에 기반해서 모듈을 가져와야할 때 사용한다.
With no SSR
서버사이드렌더링은 보통 true지만..
클라이언트사이드 렌더링을 하기위해서 ssr을 false로 설정한다.
src/products/upload/page.tsx
const latitude = watch("latitude");
const longitude = watch("longitude");
const KakaoMap = dynamic(() => import('../../../components/KakaoMap/KakaoMap'), {
ssr: false
})
...
{/* kakaoMap */}
<KakaoMap
setCustomValue={setCustomValue}
latitude={latitude}
longitude={longitude}
/>
src/components/KakaoMap/KakaoMap.tsx
import React from 'react'
interface KakaoMapProps {
latitude: number;
longitude: number;
setCustomValue?: (id: string, value: number) => void;
detailPage?: boolean;
}
const KakaoMap = ({
latitude,
longitude,
setCustomValue,
detailPage = false, // 상품 상세페이지 분기처리
}: KakaoMapProps) => {
return <div></div>;
};
export default KakaoMap
https://react-kakao-maps-sdk.jaeseokim.dev/
설치
npm i react-kakao-maps-sdk
카카오맵 API 스크립트 추가
app/layout.tsx
import Script from 'next/script';
...
return (
<html lang="en">
<body className={inter.className}>
<Navbar currentUser={currentUser} />
<main>{children}</main>
<Script
type="text/javascript"
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다.&libraries=services,clusterer"
/>
</body>
</html>
);
KakaoMap 컴포넌트에 지도 넣기
import { Map, MapMarker } from 'react-kakao-maps-sdk';
...
return (
<Map
center={{ lat: 33.5563, lng: 126.79581 }}
style={{ width: "100%", height: "360px" }}
>
<MapMarker position={{ lat: 33.55635, lng: 126.795841 }}>
<div style={{color:"#000"}}>Hello World!</div>
</MapMarker>
</Map>
)
Unhandled Runtime Error
TypeError: kakao.maps.LatLng is not a constructor
이런 에러가 난다면,
layout.tsx
에서 src을 &autoload=false
을 추가하여 변경한다.
src="//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 넣으시면 됩니다.&libraries=services,clusterer&autoload=false"
카카오맵 컴포넌트가 잘뜨는데,
페이지 렌더가 끝나고나서 렌더된다.
Map에 onClick 이벤트 설정 mouseEvent를 handleClick에 넘겨주고
handleClick에서 latitude와 longitude를 각 해당 값으로 변경(setCustomValue)
const KakaoMap = ({
latitude,
longitude,
setCustomValue,
detailPage = false, // 상품 상세페이지 분기처리
}: KakaoMapProps) => {
const handleClick = (mouseEvent) => {
// console.log(mouseEvent);
setCustomValue('latitude', mouseEvent.latLng.getLat())
setCustomValue("longitude", mouseEvent.latLng.getLng());
};
return (
<Map
center={{ lat: 33.5563, lng: 126.79581 }}
style={{ width: "100%", height: "360px" }}
onClick={(_, mouseEvent) => handleClick(mouseEvent)}
>
<MapMarker position={{ lat: 33.55635, lng: 126.795841 }}>
{/* <div style={{ color: "#000" }}>Hello World!</div> */}
</MapMarker>
</Map>
);
};
(mouseEvent console.log시)
setCustomValue가 optional하기때문에 에러가 나는 부분이다.
어떤때는 setCustomValue를 내려주고(글작성시)
어떤때는 안내려주니까(글 조회시)
디테일페이지라면 return하고
setCustomValue를 단언한다.
그리고 Map의 center와 MapMarker의 position은 해당 좌표값으로 넣는다.
...
const handleClick = (mouseEvent: kakao.maps.event.MouseEvent) => {
if (detailPage) return; // 간결, 효율적
setCustomValue!('latitude', mouseEvent.latLng.getLat());
setCustomValue!("longitude", mouseEvent.latLng.getLng());
};
return (
<Map
center={{ lat: latitude, lng: longitude }}
style={{ width: "100%", height: "360px" }}
onClick={(_, mouseEvent) => handleClick(mouseEvent)}
>
<MapMarker position={{ lat: latitude, lng: longitude }}>
{/* <div style={{ color: "#000" }}>Hello World!</div> */}
</MapMarker>
</Map>
);
잘 이동이 된다.
axios.post('/api/products/'.data)
로 요청 -> /api/products/rout.ts
의 POST()
에서 처리
src/app/products/upload/page.tsx
import { useRouter } from "next/router";
...
const router = useRouter();
const onSubmit: SubmitHandler<FieldValues> = (data) => {
setIsLoading(true);
axios.post('/api/products', data)
.then(response => {
router.push(`/products/${response.data.id}`);
})
.catch((err) => {
console.error(err);
})
.finally(() => {
setIsLoading(false);
})
}
이라고 작성했는데 에러가 뜬다.
NextRouter was not mounted. https://nextjs.org/docs/messages/next-router-not-mounted
"use client"
를 사용하면 useRouter
는 사용할 수 없다.
useNavigation
을 사용해야한다.
import만 next/navigation
로 해보았다.
import { useRouter } from "next/navigation";
src/app/products/upload/page.tsx
에서 axios.post('/api/products/'.data)
로 요청한것을
/api/products/rout.ts
의 POST()
에서 데이터 저장 처리
getCurrentUser()
로 로그인된 유저인지 확인(비로그인 error return)
내용이 없다면 error return
내용이 있다면 prisma.product.create({data})
product테이블에 데이터 create(각 컬럼별 값)
NextResponse.json(product)
리턴
import getCurrentUser from "@/app/actions/getCurrentUser";
import { NextResponse } from "next/server";
import prisma from '@/helpers/prismadb';
export async function POST(request: Request) {
const currentUser = await getCurrentUser()
if(!currentUser) {
return NextResponse.error();
}
const body = await request.json()
const {
title,
description,
imageSrc,
category,
latitude,
longitude,
price
} = body;
Object.keys(body).forEach((value) => {
if(!body[value]) {
return NextResponse.error();
}
});
const product = await prisma.product.create({
data: {
title,
description,
imageSrc,
category,
latitude,
longitude,
price,
userId: currentUser.id
}
});
return NextResponse.json(product);
}
잘 들어간다.
+ price 값 바꿔서 등록은 왜 안될까
input type도 number로 바꾸고
body[price]
도 number로 바꿔서 넣었는데도
"1000"
이된다.