Product Observe
이제 Product와 관련된 UI를 본격적으로 구축해 보도록 하겠습니다.
먼저 저희는 저번에 prisma Product Model을 생성하고 URL페이지에서 Product들 몇개를 추가해 보았습니다. 그럼 이제 저희가 mock up했던 데이터들을 실제 데이터 들로 치환해 주는 작업을 해주어야 할 것입니다.
그럼 자연스럽게 SWR을 사용하여 전역으로 productData를 관리해야 할것이고, 이 데이터를 가져올 api가 필요합니다.
/pages/api/products/index.ts
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
if (req.method === "GET") {
const products = await client.product.findMany({});
res.json({
ok: true,
products,
});
}
if (req.method === "POST") {
const {
body: { name, price, description },
session: { user },
} = req;
const product = await client.product.create({
data: {
name,
price: +price,
description,
image: "xx",
user: {
connect: {
id: user?.id,
},
},
},
});
res.json({
ok: true,
product,
});
}
};
export default withApiSession(
withHandler({
methods: ["GET", "POST"],
handler,
})
);
우선 다음과 같이 전에 작성한적 없는 형태로 req.method가 GET인 경우와 POST인 경우를 나누어서 처리해 주었습니다. 그 이유는 SWR의 REST API지침에 따르면 GET을 무조건 써주게 되어있습니다. 따라서 GET, POST모두가 필요하게 된 것이고 저희는 withHandler의 형태를 다음과 같이 조금 바꾸어 주었습니다.
/libs/server/withHandler.ts
import type {
NextApiRequest,
NextApiResponse,
NextApiHandler,
} from "next/types";
type method = "GET" | "POST" | "DELETE";
type MethodType = method[];
export interface ResponseType {
ok: boolean;
[key: string]: any;
}
interface ConfigType {
methods: MethodType;
handler: NextApiHandler<ResponseType>;
isPrivate?: boolean;
}
type HandlerType = {
(config: ConfigType): NextApiHandler;
};
const withHandler: HandlerType = ({
methods,
handler,
isPrivate = true,
}) => {
return async function (req, res): Promise<any> {
if (req.method && !methods.includes(req.method as method)) {
res.status(405).end();
}
if (isPrivate && !req.session.user) {
return res
.status(401)
.json({ ok: false, error: "Plz log in." });
}
try {
await handler(req, res);
} catch (error) {
console.error(error);
if (error instanceof Error) {
res.status(500).json({ error: error.message });
} else {
res.status(500).json({ error: "Internal Server Error" });
}
}
};
};
export default withHandler;
다음과 같이 MethodType을 method[]로 선언해 주고 method배열에 들어온 인자중에 req.method와 대응하는게 없으면 바로 405가 발생하도록 처리해 주었습니다.
"GET"일 때는 product의 모든 정보를 제공하도록 설계했습니다.
"POST"일 때는 저번에 봤던것 처럼 session을 활용하여 product의 주인을 설정하고 데이터를 삽입하는 과정을 거치도록 설계했습니다.
이제 SWR을 사용해서 데이터를 렌더링 하는 Home 페이지를 보도록 하겠습니다.
/pages/index.tsx
import type { NextPage } from "next";
import Layout from "@components/layout";
import Item from "@components/item";
import FloatingButton from "@components/FloatingButton";
import useUser from "@libs/client/useUser";
import Head from "next/head";
import useSWR from "swr";
import { Product } from "@prisma/client";
interface ProductsResponse {
ok: boolean;
products: Product[];
}
const Home: NextPage = () => {
const { user, isLoading } = useUser();
const { data } = useSWR<ProductsResponse>("/api/products");
console.log(data);
return (
<Layout title="홈" hasTabBar>
<Head>
<title>Home</title>
</Head>
<div className="p flex flex-col space-y-5 py-2">
{data?.products?.map(({ id, name, price }, i) => (
<Item
key={id}
id={id}
title={name}
price={price}
comments={1}
hearts={1}
/>
))}
<FloatingButton href="/products/upload">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</FloatingButton>
</div>
</Layout>
);
};
export default Home;
다음과 같이 ProductResponse를 설정해 주고, data를 통해 데이터를 받아오고 이와 관련된 데이터를 활용하여 기존된 mock데이터를 지우고 렌더링 했습니다. 또한 data가 초기 mount될 시점에는 데이터가 없을 수도
있으므로, optional chaining을 사용해 주었습니다.
다음과 같은 화면이 나왔다면 성공입니다.
이제 이 상품들을 클릭했을 때 이동할 상세페이지를 구현해 봅시다.
우선 해당 아이템과 관련 상품들을 반환하는 api부터 만들어 보도록 하겠습니다.
/pages/products/[id].tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
// id -> string / string[] cause of dynamic routing
const { id } = req.query;
const product = await client.product.findUnique({
where: { id: +id.toString() },
include: {
user: {
select: {
id: true,
name: true,
},
},
},
});
const terms = product?.name.split(" ").map((word) => ({
name: {
contains: word,
},
}));
const relatedProducts = await client.product.findMany({
where: {
OR: terms,
AND: {
id: {
not: product?.id,
},
},
},
});
console.log(relatedProducts);
res.json({ ok: true, product, relatedProducts });
};
export default withApiSession(
withHandler({
methods: ["GET"],
handler,
})
);
우선 req.query를 통해 [id]에 해당하는 데이터를 받아옵니다. 그리고 findUnique로 id에 해당하는 product를 user의 id, name을 포함하여 찾아 옵니다.
그리고 이제 product의 이름을 활용하여 연관된 상품을 찾는 코드입니다. 예를 들어서 "Galaxy S60"이라면 우선 ["Galaxy", "S60"]으로 나누고, [ { name: {.contains: "Galaxy" } }, {.name: {.contains: "S60" } } ]으로 만들어 주어, 다른 상품중에 Galaxy, S60의 이름을 포함하는 모든 상품들을 가져오게 했습니다.
이렇게 안해도 원래는 머신러닝 기술을 이용해서 관련된 데이터를 추출하는 것이 일반적입니다.
https://www.prisma.io/docs/reference/api-reference/prisma-client-reference
여기에 prism의 filter query reference를 확인하실 수 있을 겁니다.
/pages/products/[id].tsx
import type { NextPage } from "next";
import Layout from "@components/layout";
import Button from "@components/button";
import { useRouter } from "next/router";
import useSWR from "swr";
import Link from "next/link";
import { Product, User } from "@prisma/client";
import { Backdrop, CircularProgress } from "@mui/material";
import { useState } from "react";
interface ProductWithUser extends Product {
user: User;
}
interface ItemDetailResponse {
ok: boolean;
product: ProductWithUser;
relatedProducts: Product[];
}
const ItemDetail: NextPage = () => {
const router = useRouter();
// console.log(router.query);
const { data } = useSWR<ItemDetailResponse>(
router.query.id ? `/api/products/${router.query.id}` : null
);
return (
<Layout canGoBack>
<Backdrop
sx={{
color: "#fff",
zIndex: (theme) => theme.zIndex.drawer + 1,
}}
open={data === undefined}
>
<CircularProgress color="inherit" />
</Backdrop>
<div className="px-4 py-3">
<div className="mb-8">
<div className="h-96 bg-slate-300" />
<div className="mt-1 flex items-center space-x-3 border-t border-b py-3">
<div className="h-12 w-12 rounded-full bg-slate-300" />
<div>
<p className="text-sm font-medium text-gray-700">
{data?.product.user.name}
</p>
<Link
href={`/users/profiles/${data?.product.user.id}`}
>
<a className="cursor-pointer text-xs font-medium text-gray-500">
View profile →
</a>
</Link>
</div>
</div>
<div className="mt-5">
<h1 className="text-3xl font-bold text-gray-900">
{data?.product.name}
</h1>
<span className="mt-3 block text-3xl text-gray-900">
${data?.product.price}
</span>
<p className="my-6 text-base text-gray-700">
{data?.product.description}
</p>
<div className="flex items-center justify-between space-x-2">
<Button text="Talk to seller" large />
<button className="flex items-center justify-center rounded-md p-3 text-gray-400 hover:bg-gray-100 hover:text-gray-500">
<svg
className="h-6 w-6 "
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
</div>
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">
Similar items
</h2>
<div className="mt-6 grid grid-cols-2 gap-4">
{data?.relatedProducts.map(({ id, name, price }) => (
<Link href={`/products/${id}`} key={id}>
<a>
<div className="mb-4 h-56 w-full bg-slate-300" />
<h3 className=" -mb-1 text-gray-700">
{name}
</h3>
<span className="text-sm font-medium text-gray-900">
${price}
</span>
</a>
</Link>
))}
</div>
</div>
</div>
</Layout>
);
};
export default ItemDetail;
여기에서는 router.query.id에 해당하는 값을 활용하여 위에서 만든 /api/products/{id}로 GET 요청을 SWR을 활용해서 보냅니다.
그리고 data가 undefined | ItemDetailResponse인데 undefined일 때는 MUI을 사용하여 로딩창을 보여지도록 했습니다.
마지막으로 받아온product, relatedProduct[]을 활용하여 상세 페이지에 한꺼번에 렌더링 하는 과정을 거칩니다.
다음과 같은 상세 페이지를 확인할 수 있을 겁니다.
Favorite Products
한 사용자가 상품에 대한 좋아요를 설정하고 취소할 수 있도록 구현해 보도록 하겠습니다.
/prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["referentialIntegrity"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
referentialIntegrity = "prisma"
}
model User {
id Int @id @default(autoincrement())
phone String? @unique
email String? @unique
name String
avatar String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tokens Token[]
products Product[]
fav Fav[]
}
model Token {
id Int @id @default(autoincrement())
payload String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Product {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
image String
name String
price Int
description String @db.MediumText
favs Fav[]
}
model Fav {
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Fav Model을 생성해 줍니다. 그리고 User, Product와 관계를 맺어줍니다. User는 여러개의 Fav을 가질 수 있습니다. 또한 Product는 여러개의 Fav를 가진다고 일단 가정해 봅시다.
Fav모델은 유저에 의해 생성되고, product를 가리킵니다. 그렇기 때문에 fav데이터를 product에서도 접근할 수도 있고, user에서도 접근할 수 있습니다. 또한 product에서는 몇명의 user가 favorite으로 추가했는지 쉽게 셀 수 있게 됩니다.
이 의미는 User와 Fav는 1 : N관계에 있고, Product와 Fav도 또한 1 : N관계에 있다고 할 수 있습니다. 이 것의 의미는 한 유저는 여러개의 Product에 좋아요를 누를 수 있다는 뜻이고, 한 개의 Product에 여러 사람이 좋아요를 누를 수도 있다는 의미입니다.
이를 구현하기 위해서 /api/products/[id]/fav와 같은 api router를 작성해 주겠습니다.
/pages/api/products/[id]/fav.ts
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
import { User } from "@prisma/client";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const {
query: { id },
session: { user },
} = req;
const alreadyEx = await client.fav.findFirst({
where: {
productId: +id.toString(),
userId: user?.id,
},
});
if (alreadyEx) {
// delete
await client.fav.delete({
where: {
id: alreadyEx.id,
},
});
} else {
// create
await client.fav.create({
data: {
user: {
connect: {
id: user?.id,
},
},
product: {
connect: {
id: +id.toString(),
},
},
},
});
}
res.json({ ok: true });
};
export default withApiSession(
withHandler({
methods: ["POST"],
handler,
})
);
현재 user를 알아내고 query를 통해 product의 id를 알아냅니다. 그리고 이에 해당하는 fav Record가 있다면, delete하고 없다면 생성하는 과정을 진행해 줘야 합니다. 그리고 여기서 productId, userId는 unique한 값들이 아니므로 findUnique를 사용할 수 없습니다. 또한 delete는 기존 데이터베이스 레코드를 삭제하는데, unique한 속성으로만 삭제가 가능하게 설계되어 있습니다. 따라서 만약에 위에서 unique하지 않은 값을 없애고 싶을 때는 deleteMany를 사용하면 됩니다.
이제 이를 product 상세 페이지에서 mutate해 보겠습니다.
/pages/api/products/[id]/index.ts
import type { NextPage } from "next";
import Layout from "@components/layout";
import Button from "@components/button";
import { useRouter } from "next/router";
import useSWR, { useSWRConfig } from "swr";
import Link from "next/link";
import { Product, User } from "@prisma/client";
import { Backdrop, CircularProgress } from "@mui/material";
import { useState } from "react";
import useMutation from "@libs/client/useMutation";
import { cls } from "@libs/client/utils";
import useUser from "@libs/client/useUser";
interface ProductWithUser extends Product {
user: User;
}
interface ItemDetailResponse {
ok: boolean;
product: ProductWithUser;
relatedProducts: Product[];
isLiked: boolean;
}
const ItemDetail: NextPage = () => {
const { user, isLoading } = useUser();
const router = useRouter();
const { mutate } = useSWRConfig();
const { data, mutate: boundMutate } =
useSWR<ItemDetailResponse>(
router.query.id ? `/api/products/${router.query.id}` : null
);
const [toggleFav] = useMutation(
`/api/products/${router.query.id}/fav`
);
const onFavClick = () => {
toggleFav({});
if (!data) return;
boundMutate(
(prev) => prev && { ...prev, isLiked: !prev.isLiked },
false
);
// mutate(
// "/api/users/me",
// (prev: any) => {
// console.log(prev);
// return { ok: !prev.ok };
// },
// false
// );
};
return (
<Layout canGoBack>
<Backdrop
sx={{
color: "#fff",
zIndex: (theme) => theme.zIndex.drawer + 1,
}}
open={data === undefined}
>
<CircularProgress color="inherit" />
</Backdrop>
<div className="px-4 py-3">
<div className="mb-8">
<div className="h-96 bg-slate-300" />
<div className="mt-1 flex items-center space-x-3 border-t border-b py-3">
<div className="h-12 w-12 rounded-full bg-slate-300" />
<div>
<p className="text-sm font-medium text-gray-700">
{data?.product?.user?.name}
</p>
<Link
href={`/users/profiles/${data?.product?.user?.id}`}
>
<a className="cursor-pointer text-xs font-medium text-gray-500">
View profile →
</a>
</Link>
</div>
</div>
<div className="mt-5">
<h1 className="text-3xl font-bold text-gray-900">
{data?.product?.name}
</h1>
<span className="mt-3 block text-3xl text-gray-900">
${data?.product?.price}
</span>
<p className="my-6 text-base text-gray-700">
{data?.product?.description}
</p>
<div className="flex items-center justify-between space-x-2">
<Button text="Talk to seller" large />
<button
onClick={onFavClick}
className={cls(
"flex items-center justify-center rounded-md p-3 hover:bg-gray-100",
data?.isLiked
? "text-red-400 hover:text-red-500"
: "text-gray-400 hover:text-gray-500"
)}
>
{data?.isLiked ? (
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z"
clipRule="evenodd"
></path>
</svg>
) : (
<svg
className="h-6 w-6 "
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
)}
</button>
</div>
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">
Similar items
</h2>
<div className="mt-6 grid grid-cols-2 gap-4">
{data?.relatedProducts?.map(
({ id, name, price }) => (
<Link href={`/products/${id}`} key={id}>
<a>
<div className="mb-4 h-56 w-full bg-slate-300" />
<h3 className=" -mb-1 text-gray-700">
{name}
</h3>
<span className="text-sm font-medium text-gray-900">
${price}
</span>
</a>
</Link>
)
)}
</div>
</div>
</div>
</Layout>
);
};
export default ItemDetail;
그리고 /api/products/[id]도 조금 수정해 주었습니다.
/pages/products/[id].tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
// id -> string / string[] cause of dynamic routing
const {
query: { id },
session: { user },
} = req;
const product = await client.product.findUnique({
where: { id: +id.toString() },
include: {
user: {
select: {
id: true,
name: true,
},
},
},
});
const terms = product?.name.split(" ").map((word) => ({
name: {
contains: word,
},
}));
const relatedProducts = await client.product.findMany({
where: {
OR: terms,
AND: {
id: {
not: product?.id,
},
},
},
});
const isLiked = Boolean(
await client.fav.findFirst({
// fav의 productId중에 product.id가 있는지 없는지 체크
where: {
productId: product?.id,
userId: user?.id,
},
})
);
res.json({ ok: true, product, isLiked, relatedProducts });
};
export default withApiSession(
withHandler({
methods: ["GET"],
handler,
})
);
다음과 같이 유저가 해당 product를 좋아요 표시 했는지 판단하는 isLiked를 하나 추가해서 내보내 줍니다.
상세 페이지에서 이 isLiked를 활용하여 하트 svg아이콘을 빨간색으로 칠해주었습니다. ( 그리고 ItemDetailResponse 도 또한 조ㅡㄱㅁ 바꾸어 주었습니다. )
그리고 SWR의 UI Optimization기능을 사용해 보도도록 하겠습니다. 이를 활용하기 위해서는 SWR의 mutate함수를 사용해야 합니다. mutate는 2가지 인수를 받는데, 바꿀 객체의 값과, default의 값이 true인 revalidate를 받게 됩니다. 여기서 revalidate는 바꾸고 다시
api endpoint로 검증을 시켜서 한번더 get요청을 보내서 업데이트 하는 과정을 말합니다.
위의 코드에서는 다른 endpoint는 못건들이므로 boundMutation으로 이름을 지었습니다.또한 mutate함수의 첫번쨰 인자로는 함수를 넣을 수도 있는데, prev( 이전 데이터 )를 받기도 합니다. 받아서 바로 !prev.isLiked를 박아서 어짜피 바뀔거 바로 ui상으로 보여주는 과정을 거치게 해 줍니다. 그리고 useMutation을 활용해서 /api/products/{id}/fav로 post요청을 보내면 됩니다.
이 외에도 다른 swr의 endpoint의 값을 바꾸는 방법이 있는데, 이를 Bound Mutation이라고 합니다. 이를 활용해서 /api/users/me의 값을 바꾸어 보았습니다. /api/users/me에서는 로그인 상태를 관리하는데, prev.ok를 부정해서 사용자를 로그아웃 시켜볼 수 있었습니다. (실제로는 로그아웃된 것이 아님)
/pages/api/products/index.ts
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
if (req.method === "GET") {
const products = await client.product.findMany({
include: {
favs: true,
},
});
console.log(products);
res.json({
ok: true,
products,
});
}
if (req.method === "POST") {
const {
body: { name, price, description },
session: { user },
} = req;
const product = await client.product.create({
data: {
name,
price: +price,
description,
image: "xx",
user: {
connect: {
id: user?.id,
},
},
},
});
res.json({
ok: true,
product,
});
}
};
export default withApiSession(
withHandler({
methods: ["GET", "POST"],
handler,
})
);
또한 위에서 작성한 /api/products를 조금 수정하였습니다. 그 이유는 product에 하트 표시한 user의 수를 알아내기 위함입니다. 하트 이모티콘 옆에 하트를 누른 사용자 수를 나타내고 싶었습니다. 그래서 include : {.favs: true }를 해서 모든 product와 연관되어 있는 favs를 가져오도록 했습니다.
대충 이렇게 favs가 배열로 넘어오게 됩니다.
그리고 Home페이지에서 이의 length를 활용하면 간단하게 구현할 수 있습니다.
/pages/index.tsx
import type { NextPage } from "next";
import Layout from "@components/layout";
import Item from "@components/item";
import FloatingButton from "@components/FloatingButton";
import useUser from "@libs/client/useUser";
import Head from "next/head";
import useSWR from "swr";
import { Fav, Product } from "@prisma/client";
interface ProductWithFav extends Product {
favs: Fav[];
}
interface ProductsResponse {
ok: boolean;
products: ProductWithFav[];
}
const Home: NextPage = () => {
const { user, isLoading } = useUser();
const { data } = useSWR<ProductsResponse>("/api/products");
return (
<Layout title="홈" hasTabBar>
<Head>
<title>Home</title>
</Head>
<div className="p flex flex-col space-y-5 py-2">
{data?.products?.map(({ id, name, price, favs }, i) => (
<Item
key={id}
id={id}
title={name}
price={price}
comments={1}
hearts={favs.length}
/>
))}
<FloatingButton href="/products/upload">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</FloatingButton>
</div>
</Layout>
);
};
export default Home;
다음과 같이 Product Response의 interface를 조금 수정해주고 hearts={favs.length}를 통해 나타내었습니다.
다음과 같이 잘 나타나는 것을 보실 수 있습니다. 이에 대한 favs record의 정보는 아래와 같습니다.
다음과 같이 product1은 user1, user2가 좋아요를 눌러서 위의 product1에 하트가 2개가 있는 것을 보실 수 있습니다.
다음과 같이 좋아요도 잘 표현된 것을 보실 수 있습니다.
'Web > CloneCoding' 카테고리의 다른 글
[ Carrot Market ] #12 - Community - 1 (0) | 2022.05.31 |
---|---|
[Carrot Market] #11 PRODUCTS - START (0) | 2022.05.17 |
[Carrot Market] #10 - AUTHORIZATION (0) | 2022.05.13 |
[Carrot Market] #9 - Authentication - 2 (0) | 2022.05.12 |
[Carrot Market] #9 - Authentication - 1 (0) | 2022.05.12 |