From 0df925fbe50cf5043601073c3132b5389e658a50 Mon Sep 17 00:00:00 2001 From: Youngmin Song <68019733+y-ngm-n@users.noreply.github.com> Date: Tue, 27 Aug 2024 19:42:52 +0900 Subject: [PATCH 01/28] =?UTF-8?q?[BYOB-165]=20=EC=A3=BC=EB=A5=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EB=B3=80=EA=B2=BD=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=88=98=EC=A0=95=20+=20=EC=A3=BC=EB=A5=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=9C=20debounce=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 주류 검색 시 debounce 적용 * feat: query status가 idle일 때 보여질 UI 추가 * feat: 주류 검색 로딩 UI 수정 * feat: 주류 검색 api 변경에 따른 필드 관련 수정 --- new-types.d.ts | 1 + package-lock.json | 27 ++++++++++++++++++ package.json | 4 +++ src/app/liquors/page.tsx | 60 ++++++++++++++++++++++++++++++++-------- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/new-types.d.ts b/new-types.d.ts index 1cc41ff..cc842b2 100644 --- a/new-types.d.ts +++ b/new-types.d.ts @@ -40,6 +40,7 @@ interface LiquorInfo { id: number; en_name: string; ko_name: string; + ko_name_origin: string; price: string; thumbnail_image_url: string; tasting_notes_Aroma: string; diff --git a/package-lock.json b/package-lock.json index 2c55188..acf648e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,13 @@ "@next/third-parties": "^14.2.5", "@sentry/nextjs": "^8.26.0", "@tanstack/react-query": "^5.51.3", + "@types/lodash": "^4.17.7", + "@types/lodash.debounce": "^4.0.9", "axios": "^1.3.4", "embla-carousel-react": "^8.1.6", "http-proxy-middleware": "^3.0.0", + "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "next": "14.2.4", "react": "^18", "react-copy-to-clipboard": "^5.1.0", @@ -2370,6 +2374,19 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==" + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mysql": { "version": "2.15.22", "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.22.tgz", @@ -5691,6 +5708,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index efe65e2..5c6c121 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,13 @@ "@next/third-parties": "^14.2.5", "@sentry/nextjs": "^8.26.0", "@tanstack/react-query": "^5.51.3", + "@types/lodash": "^4.17.7", + "@types/lodash.debounce": "^4.0.9", "axios": "^1.3.4", "embla-carousel-react": "^8.1.6", "http-proxy-middleware": "^3.0.0", + "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "next": "14.2.4", "react": "^18", "react-copy-to-clipboard": "^5.1.0", diff --git a/src/app/liquors/page.tsx b/src/app/liquors/page.tsx index f891f39..3389a02 100644 --- a/src/app/liquors/page.tsx +++ b/src/app/liquors/page.tsx @@ -15,15 +15,16 @@ import { } from "@mui/material"; import axios from "axios"; import Link from "next/link"; -import React, { useState } from "react"; +import React, { useCallback, useState } from "react"; import { useQuery } from "react-query"; import AddIcon from "@mui/icons-material/Add"; +import debounce from "lodash.debounce"; /** 주류 검색 API 요청 함수 */ const getLiquorList = async (keyword: string) => { if (!keyword) return null; const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquorsearch?keyword=${keyword}`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquorsearch?keyword=${keyword}` ); return response.data; }; @@ -31,17 +32,26 @@ const getLiquorList = async (keyword: string) => { export default function LiquorsPage() { // 검색 키워드 state const [keyword, setKeyword] = useState(""); + const [debouncedKeyword, setDebouncedKeyword] = useState(keyword); + + // 주류 검색 api query + const { data, status, isFetching } = useQuery({ + queryKey: ["liquorList", debouncedKeyword], + queryFn: () => getLiquorList(debouncedKeyword), + enabled: !!debouncedKeyword, + }); + + // debounce function + const debounceKeywordChange = useCallback( + debounce((nextValue: string) => setDebouncedKeyword(nextValue), 300), + [] + ); const handleKeywordChange = (e: React.ChangeEvent) => { setKeyword(e.target.value); + debounceKeywordChange(e.target.value); }; - // 주류 검색 api query - const { data, status } = useQuery({ - queryKey: ["liquorList", keyword], - queryFn: () => getLiquorList(keyword), - }); - return ( {/* 주류 검색창 */} @@ -70,8 +80,36 @@ export default function LiquorsPage() { }} /> + + {/* 초기 화면 */} + {status == "idle" && ( + + + + 테이스팅 노트 작성을 위해서는 + + + 주류를 선택해야 합니다. + + + 💡 Tip: 찾는 주류가 없으신가요? + + + + + + + )} + {/* 로딩 UI */} - {status === "loading" && ( + {isFetching && ( 열심히 검색 중... @@ -85,7 +123,7 @@ export default function LiquorsPage() { ({ From 740d4a0708de4d6dcb981a40a4fd6309d685c0f2 Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Sat, 21 Sep 2024 19:43:04 +0900 Subject: [PATCH 02/28] =?UTF-8?q?docs:=20coderabbit=20ai=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coderabbit.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..726056b --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +language: "ko-KR" +tone_instructions: "you must use talk like my boss" +early_access: false +enable_free_tier: true +reviews: + profile: "assertive" From 66ca4fcd44c17119aec85c17f39c6562407fe426 Mon Sep 17 00:00:00 2001 From: Youngmin Song <68019733+y-ngm-n@users.noreply.github.com> Date: Mon, 23 Sep 2024 17:22:39 +0900 Subject: [PATCH 03/28] =?UTF-8?q?[BYOB-210]=20=EC=A3=BC=EB=A5=98=20?= =?UTF-8?q?=EC=83=9D=ED=99=9C=20=EB=A9=94=EB=89=B4=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A3=BC=EB=A5=98=EB=B3=84=20=EB=85=B8=ED=8A=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 네비게이션 바 변경 * feat: 주류별 테이스팅노트 그룹화 로직을 위한 리팩토링 * feat: 사용자 테이스팅 노트 그룹화 로직 작성 * feat: 주류 생활 페이지에 작성한 노트 목록 방식 탭 분리 * feat: 주류 생활 페이지에 주류별 노트 보기 옵션 구현 * docs: PR 템플릿 추가 * feat: type 관련 수정 --- .github/PULL_REQUEST_TEMPLATE.md | 7 + new-types.d.ts | 17 +- src/app/mypage/page.tsx | 100 +------ src/app/report/page.tsx | 3 + src/app/tasting-notes/[id]/page.tsx | 4 +- .../LayoutComponents/NavigationComponent.tsx | 21 +- .../UserNoteGroupComponent.tsx | 90 ++++++ .../UserTastingComponent.tsx | 266 +++++++++--------- .../MyPageContentsComponent.tsx | 190 +++++++++++++ 9 files changed, 458 insertions(+), 240 deletions(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/app/report/page.tsx create mode 100644 src/components/LiquorUserTastingComponent/UserNoteGroupComponent.tsx create mode 100644 src/components/MyPageContentsComponent/MyPageContentsComponent.tsx diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8649761 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## 🚀 완료한 기능 혹은 수정 기능 + +**Jira 티켓: [BYOB-]** + +
+ +## 🔨 작업 사항 diff --git a/new-types.d.ts b/new-types.d.ts index cc842b2..d45bf03 100644 --- a/new-types.d.ts +++ b/new-types.d.ts @@ -57,7 +57,8 @@ interface LiquorInfo { /** 주류 type: DB 버전 */ interface LiquorData { - thumbnailImageUrl: string | null; + id: number; + thumbnailImageUrl: string | undefined; koName: string | null; enName: string | null; type: string | null; @@ -102,6 +103,18 @@ interface TastingNoteList { user: User; } +/** 사용자 작성 노트 type: 주류별 작성 노트 그룹 정보 */ +interface UserNoteGroup { + liquor: LiquorData; + notesCount: number; +} + +/** 사용자 작성 노트 목록 type */ +interface UserNoteData { + list: TastingNoteList[]; + group: UserNoteGroup[]; +} + interface aiNotes { tastingNotesAroma: string; tastingNotesTaste: string; @@ -112,7 +125,7 @@ interface aiNotes { /** LiquorTitle 컴포넌트 호출 시 사용되는 props type */ interface LiquorTitleProps { - thumbnailImageUrl: string | null; + thumbnailImageUrl: string | undefined; koName: string | null; type: string | null; abv: string | null; diff --git a/src/app/mypage/page.tsx b/src/app/mypage/page.tsx index 15ad1e1..9ae70f8 100644 --- a/src/app/mypage/page.tsx +++ b/src/app/mypage/page.tsx @@ -1,32 +1,17 @@ "use client"; -import UserTastingComponent from "@/components/LiquorUserTastingComponent/UserTastingComponent"; -import { Edit } from "@mui/icons-material"; -import { Box, Button, Divider, Stack, Typography } from "@mui/material"; -import Image from "next/image"; +import { Button, Stack, Typography } from "@mui/material"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import LogoutIcon from "@mui/icons-material/Logout"; -import axios from "axios"; +import MyPageContentsComponent from "@/components/MyPageContentsComponent/MyPageContentsComponent"; export default function MyPage() { const [isLoggedIn, setIsLoggedIn] = useState(null); const [currentUrl, setCurrentUrl] = useState(""); const [user, setUser] = useState(null); const router = useRouter(); - const handleLogout = async () => { - try { - await axios.post( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/logout`, - {}, - { withCredentials: true }, - ); - window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}`; // 로그아웃 후 메인 페이지로 이동 - } catch (error) { - console.error("Error logging out", error); - } - }; + useEffect(() => { const checkAuth = async () => { try { @@ -35,7 +20,7 @@ export default function MyPage() { { method: "GET", credentials: "include", // 쿠키 포함 - }, + } ); if (response.status === 401) { @@ -83,81 +68,6 @@ export default function MyPage() { // 페이지: 로그인 되어 있는 경우 if (user) { - return ( - - {/* 사용자 프로필 */} - - profile image - - - {user?.profileNickname} - - {/**/} - {/* 지역*/} - {/**/} - - - - {/*}*/} - {/* sx={{*/} - {/* margin: "5px 15px",*/} - {/* fontSize: "13px",*/} - {/* color: "gray",*/} - {/* backgroundColor: "#f5f5f5",*/} - {/* }}*/} - {/*>*/} - {/* 회원 정보 수정*/} - {/**/} - - - - - - 내가 작성한 테이스팅 노트 - - - {/* 사용자 활동 정보 */} - - - ); + return ; } } diff --git a/src/app/report/page.tsx b/src/app/report/page.tsx new file mode 100644 index 0000000..5095da9 --- /dev/null +++ b/src/app/report/page.tsx @@ -0,0 +1,3 @@ +export default function ReportPage() { + return "준비중..."; +} diff --git a/src/app/tasting-notes/[id]/page.tsx b/src/app/tasting-notes/[id]/page.tsx index 58c2a8f..560ac2a 100644 --- a/src/app/tasting-notes/[id]/page.tsx +++ b/src/app/tasting-notes/[id]/page.tsx @@ -21,7 +21,7 @@ const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; interface LiquorData { id: number; - thumbnailImageUrl: string | null; + thumbnailImageUrl: string | undefined; koName: string; type: string | null; abv: string | null; @@ -145,7 +145,7 @@ export default async function PostPage({ params: { id } }: PostPageProps) { style={{ textDecoration: "none", color: "inherit" }} > + {/* 소제목 */} + + 내가 감상한 주류 + + + {/* 감상 주류 목록 */} + + {data.map((group: UserNoteGroup, idx) => ( + + + {/* 주류 사진 */} + + + {/* 주류 정보 */} + + + {group.liquor.koName} + + {group.liquor.enName && ( + + {group.liquor.enName} + + )} + + {group.notesCount}개의 노트 + + + + + ))} + + + ); +} diff --git a/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx b/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx index 09cfb10..7995c86 100644 --- a/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx +++ b/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx @@ -10,152 +10,156 @@ import { useQuery } from "react-query"; import Link from "next/link"; import SingleTastingComponent from "../SingleTastingComponent/SingleTastingComponent"; -/** 유저 주류 테이스팅 리뷰 목록 API 요청 함수 */ -const getLiquorTastingList = async (id: string) => { - const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes/user/${id}`, - ); - // console.log(response.data); - return response.data; -}; +// /** 유저 주류 테이스팅 리뷰 목록 API 요청 함수 */ +// const getLiquorTastingList = async (id: string) => { +// const response = await axios.get( +// `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes/user/${id}`, +// ); +// // console.log(response.data); +// return response.data; +// }; -export default function UserTastingComponent({ userId }: { userId: string }) { - // 주류 검색 api query - const { data, status } = useQuery({ - queryKey: ["userTastingList", userId], - queryFn: () => getLiquorTastingList(userId), - }); +export default function UserTastingComponent({ + data, +}: { + data: TastingNoteList[]; +}) { + // // 주류 검색 api query + // const { data, status } = useQuery({ + // queryKey: ["userTastingList", userId], + // queryFn: () => getLiquorTastingList(userId), + // }); return ( - {status == "success" && - (data && data.length ? ( - data.map((tasting: TastingNoteList) => ( - + 내가 작성한 테이스팅 노트 + + + {/* 테이스팅 노트 목록 */} + {data.map((tasting: TastingNoteList) => ( + + + {/* 카드 헤더 */} + - - {/* 카드 헤더 */} - - {/* 주류 및 작성 정보 */} - - user profile image - - - {tasting.liquor.koName} - - - {formatDateTime(tasting.createdAt)} - - - - {/* 작성자 총점 */} - - {calculateAverageScore( + user profile image + + + {tasting.liquor.koName} + + + {formatDateTime(tasting.createdAt)} + + + + {/* 작성자 총점 */} + + {calculateAverageScore( + tasting.noseScore, + tasting.palateScore, + tasting.finishScore + ) && ( + + tasting.finishScore )} - - + /> + )} + +
- + - {/* 테이스팅 리뷰 내용: 상세 */} - - - - - + {/* 테이스팅 리뷰 내용: 상세 */} + + + + + - {/* 테이스팅 리뷰 내용: 총평 */} - - - {tasting.overallNote} - - - - - )) - ) : ( - - - 아직 작성된 테이스팅 리뷰가 없습니다. - - - 가장 먼저 테이스팅 리뷰를 등록해보세요! - + {/* 테이스팅 리뷰 내용: 총평 */} + + + {tasting.overallNote} + + - ))} + + ))} ); } diff --git a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx new file mode 100644 index 0000000..2048a1a --- /dev/null +++ b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { + Box, + Button, + Divider, + Stack, + Tab, + Tabs, + Typography, +} from "@mui/material"; +import axios from "axios"; +import Image from "next/image"; +import { useQuery } from "react-query"; +import UserTastingComponent from "../LiquorUserTastingComponent/UserTastingComponent"; +import LogoutIcon from "@mui/icons-material/Logout"; +import { useState } from "react"; +import { Edit } from "@mui/icons-material"; +import UserNoteGroupComponent from "../LiquorUserTastingComponent/UserNoteGroupComponent"; + +/** 유저 주류 테이스팅 리뷰 목록 API 요청 함수 */ +const getLiquorTastingList = async (id: string) => { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes/user/${id}` + ); + const list: TastingNoteList[] = response.data; + + // 그룹화 로직 + const groupMap = new Map(); + list.forEach((note) => { + const liquorId = note.liquor.id.toString(); // liquor의 고유 식별자로 가정 + if (groupMap.has(liquorId)) { + groupMap.get(liquorId)!.notesCount++; + } else { + groupMap.set(liquorId, { liquor: note.liquor, notesCount: 1 }); + } + }); + + const group: UserNoteGroup[] = Array.from(groupMap.values()); + + return { list, group }; +}; + +/** 로그아웃 API 요청 함수 */ +const handleLogout = async () => { + try { + await axios.post( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/logout`, + {}, + { withCredentials: true } + ); + window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}`; // 로그아웃 후 메인 페이지로 이동 + } catch (error) { + console.error("Error logging out", error); + } +}; + +export default function MyPageContentsComponent({ user }: { user: User }) { + // 사용자 작성 노트 탭 state + const [noteTabOption, setNoteTabOption] = useState("group"); + const handleNoteTabOptionChange = ( + e: React.SyntheticEvent, + value: string + ) => { + setNoteTabOption(value); + }; + + // 주류 검색 api query + const { data, status } = useQuery({ + queryKey: ["userTastingList", user.userUuid], + queryFn: () => getLiquorTastingList(user.userUuid), + }); + + return ( + + {/* 사용자 프로필 */} + + profile image + + + {user?.profileNickname} + + + 작성한 노트 수 + + {data && + data.group.reduce((acc, item) => acc + item.notesCount, 0)} + + + + + + + + + + + {/* 사용자 작성 노트 보기 옵션 탭 */} + + + + + + + + {/* 사용자 활동 정보 */} + {status == "success" && + (data.list.length && data.group.length ? ( + <> + {noteTabOption === "group" && ( + + )} + {noteTabOption === "list" && ( + + )} + + ) : ( + + + 아직 작성된 테이스팅 노트가 없습니다. + + + 먼저 테이스팅 노트를 작성해보세요! + + + ))} + + ); +} From 7210428366af65d929467cc06fb029491524c637 Mon Sep 17 00:00:00 2001 From: Youngmin Song <68019733+y-ngm-n@users.noreply.github.com> Date: Tue, 8 Oct 2024 20:39:59 +0900 Subject: [PATCH 04/28] =?UTF-8?q?[BYOB-213]=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 신규 로그인 시 보여줄 회원가입 화면 구현 * feat: 회원 정보 수정 버튼 활성화 및 기능 구현 * refactor: 사용하지 않는 함수 제거 * docs: github PR 템플릿 수정 및 coderabbit ai 프롬프트 수정 --- .coderabbit.yaml | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 4 +- src/app/join/page.tsx | 17 +-- src/app/mypage/edit/page.tsx | 11 ++ .../MyPageContentsComponent.tsx | 1 + .../UserUpdateForm/UserUpdateForm.tsx | 133 ++++++++++++++++++ 6 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 src/app/mypage/edit/page.tsx create mode 100644 src/components/UserUpdateForm/UserUpdateForm.tsx diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 726056b..7095bac 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,5 +1,5 @@ language: "ko-KR" -tone_instructions: "you must use talk like my boss" +tone_instructions: "please talk softly" early_access: false enable_free_tier: true reviews: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8649761..ca905ca 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ -## 🚀 완료한 기능 혹은 수정 기능 +## 🚀 Jira 티켓 -**Jira 티켓: [BYOB-]** +**[BYOB-]**
diff --git a/src/app/join/page.tsx b/src/app/join/page.tsx index e335409..f4a06f5 100644 --- a/src/app/join/page.tsx +++ b/src/app/join/page.tsx @@ -1,27 +1,14 @@ "use client"; import SelectRegion from "@/components/SelectRegion/SelectRegion"; +import UserUpdateForm from "@/components/UserUpdateForm/UserUpdateForm"; import Button from "@mui/material/Button"; import { useState } from "react"; export default function JoinPage() { - const [region, setRegion] = useState(); - - const regionChange = (newRegion: String | undefined) => { - setRegion(newRegion); - }; - - const joinButtonClicked = () => { - if (!region) console.log("지역을 선택해주세요"); - else console.log(region); - }; - return ( <> - - + ); } diff --git a/src/app/mypage/edit/page.tsx b/src/app/mypage/edit/page.tsx new file mode 100644 index 0000000..f63ca48 --- /dev/null +++ b/src/app/mypage/edit/page.tsx @@ -0,0 +1,11 @@ +import UserUpdateForm from "@/components/UserUpdateForm/UserUpdateForm"; +import { Stack, Typography } from "@mui/material"; + +export default function ProfileEditPage() { + return ( + + 회원 정보 수정 + + + ); +} diff --git a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx index 2048a1a..883a83d 100644 --- a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx +++ b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx @@ -124,6 +124,7 @@ export default function MyPageContentsComponent({ user }: { user: User }) { color: "gray", backgroundColor: "#f5f5f5", }} + href="/mypage/edit" > 회원 정보 수정 diff --git a/src/components/UserUpdateForm/UserUpdateForm.tsx b/src/components/UserUpdateForm/UserUpdateForm.tsx new file mode 100644 index 0000000..f856b0b --- /dev/null +++ b/src/components/UserUpdateForm/UserUpdateForm.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Box, Button, Stack, TextField, Typography } from "@mui/material"; +import axios, { AxiosError } from "axios"; +import Image from "next/image"; +import { usePathname, useRouter } from "next/navigation"; +import { ChangeEvent, useEffect, useState } from "react"; + +export default function UserUpdateForm() { + const pathName = usePathname(); + const router = useRouter(); + const [user, setUser] = useState(); // 응답받은 user 객체 관리 + const [nickname, setNickname] = useState(""); // 이름 + const [profileImage, setProfileImage] = useState(""); // 이미지 + + /** 초기 사용자 정보 요청 api */ + const getUserInfo = async () => { + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, + { withCredentials: true } + ); + setUser(response.data); + return response.data; + } catch (err) { + if (axios.isAxiosError(err) && err.code === "ERR_BAD_REQUEST") { + router.push("/login"); + } else console.error(err); + } + }; + + /** 사용자 정보 수정 요청 api -> 이전 경로로 리다이렉트 */ + const updateUserInfo = async () => { + if (user) { + user.profileNickname = nickname; + user.profileImage = profileImage; + user.profileThumbnailImage = profileImage; + + // 호출 경로에 따라 동작 구분 (회원가입 시 정보 입력 or 회원 정보 수정) + try { + switch (pathName) { + // 회원 가입 정보 입력 시 + case "/join": + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, + user, + { withCredentials: true } + ); + const redirectUrl = response.data; + router.push(redirectUrl); + // 회원 정보 수정 시 + case "/mypage/edit": + await axios.put( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, + user, + { withCredentials: true } + ); + router.push("/mypage"); + } + } catch (err) { + if (axios.isAxiosError(err) && err.code === "ERR_BAD_REQUEST") { + router.push("/login"); + } else console.error(err); + } + } + }; + + /** 랜덤 프로필 이미지 요청 api */ + const getRandomImage = async () => { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/users/random-image` + ); + setProfileImage(response.data); + }; + + /** 랜덤 사용자 이름 요청 api */ + const getRandomNickname = async () => { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/users/random-name` + ); + setNickname(response.data); + }; + + useEffect(() => { + const init = async () => { + const user = await getUserInfo(); + if (user) setNickname(user.profileNickname ? user.profileNickname : ""); + if (user) setProfileImage(user.profileImage ? user.profileImage : ""); + }; + + init(); + }, []); + + const handleNicknameChange = (e: ChangeEvent) => { + setNickname(e.target.value); + }; + + return ( + user && ( + + + 프로필 사진 + + + profile image + + 이름 + + + + + + ) + ); +} From 78a35e18b0a77785a02d81b2807e0342ba7e8ac5 Mon Sep 17 00:00:00 2001 From: Youngmin Song <68019733+y-ngm-n@users.noreply.github.com> Date: Thu, 24 Oct 2024 21:33:35 +0900 Subject: [PATCH 05/28] =?UTF-8?q?[BYOB-222]=20API=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20API=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=A0=84=ED=99=98=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: user api 및 auth api 갱신 * refactor: 나머지 api 전환 완료 --- new-types.d.ts | 65 +++++- src/api/tastingNotesApi.ts | 12 +- src/app/liquors/[id]/page.tsx | 2 +- src/app/liquors/new/page.tsx | 6 +- src/app/liquors/page.tsx | 2 +- src/app/login/page.tsx | 2 +- src/app/meetings/[mId]/page.tsx | 2 +- src/app/meetings/page.tsx | 2 +- src/app/mypage/page.tsx | 26 +-- .../purchase-notes/[id]/StyledComponent.tsx | 60 ++++++ src/app/purchase-notes/[id]/edit/page.tsx | 19 ++ src/app/purchase-notes/[id]/page.tsx | 136 +++++++++++++ src/app/tasting-notes/[id]/edit/page.tsx | 23 ++- src/app/tasting-notes/[id]/page.tsx | 111 +++------- src/app/tasting-notes/new/page.tsx | 36 ++-- src/app/temp/page.tsx | 113 ----------- .../LiquorUserTastingComponent.tsx | 189 +++++++++--------- .../UserTastingComponent.tsx | 154 +------------- .../MyPageContentsComponent.tsx | 34 ++-- .../NoteCard/UserPurchaseNoteCard.tsx | 112 +++++++++++ .../NoteCard/UserTastingNoteCard.tsx | 112 +++++++++++ .../UserUpdateForm/UserUpdateForm.tsx | 40 ++-- 22 files changed, 734 insertions(+), 524 deletions(-) create mode 100644 src/app/purchase-notes/[id]/StyledComponent.tsx create mode 100644 src/app/purchase-notes/[id]/edit/page.tsx create mode 100644 src/app/purchase-notes/[id]/page.tsx delete mode 100644 src/app/temp/page.tsx create mode 100644 src/components/NoteCard/UserPurchaseNoteCard.tsx create mode 100644 src/components/NoteCard/UserTastingNoteCard.tsx diff --git a/new-types.d.ts b/new-types.d.ts index d45bf03..ccc96d8 100644 --- a/new-types.d.ts +++ b/new-types.d.ts @@ -77,9 +77,57 @@ interface LiquorData { /** 사용자 type */ interface User { userUuid: string; - profileNickname: string | null; - profileImage: string | null; - profileThumbnailImage: string | null; + profileNickname: string; + profileThumbnailImage: string; +} + +/** 노트 type */ +interface Note { + type: string; + purchaseNote: PurchaseNote; + tastingNote: TastingNote; +} + +/** 노트 이미지 type */ +interface NoteImage { + id: number; + fileName: string; + fileUrl: string; +} + +/** 구매 노트 type */ +interface PurchaseNote { + id: number; + createdAt: string; + updatedAt: string; + user: User; + liquor: LiquorData; + noteImages: NoteImage[]; + purchaseAt: string; + place: string; + price: number; + volume: number; + content: string; +} + +/** 감상 노트 type */ +interface TastingNote { + id: number; + createdAt: string; + updatedAt: string; + user: User; + liquor: LiquorData; + noteImages: NoteImage[]; + tastingAt: string; + method: string; + place: string; + noteAromas: Aroma[]; + score: number; + isDetail: boolean; + content: string; + nose?: string; + palate?: string; + finish?: string; } /** 테이스팅노트 type: 주류 상세정보 페이지에서 보이는 유저 테이스팅 리뷰 목록 API 응답 객체 타입 */ @@ -103,6 +151,12 @@ interface TastingNoteList { user: User; } +/** 아로마 type */ +interface Aroma { + id: number; + name: string; +} + /** 사용자 작성 노트 type: 주류별 작성 노트 그룹 정보 */ interface UserNoteGroup { liquor: LiquorData; @@ -149,3 +203,8 @@ interface SingleTastingProps { detailContent: string | null; keyMinWidth: number; } + +/** PostPage(테이스팅 노트 상세 페이지) 호출 시 사용되는 props type */ +interface PostPageProps { + params: { id: string }; +} diff --git a/src/api/tastingNotesApi.ts b/src/api/tastingNotesApi.ts index 3b82094..f246823 100644 --- a/src/api/tastingNotesApi.ts +++ b/src/api/tastingNotesApi.ts @@ -40,7 +40,7 @@ const AI_LIQUOR_NOTES_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/ai-similar- const SAVE_REVIEW_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes`; export const fetchLiquorData = async ( - liquorId: string, + liquorId: string ): Promise => { const response = await fetch(LIQUOR_URL + liquorId); if (!response.ok) throw new Error("Failed to fetch data"); @@ -56,10 +56,10 @@ export const fetchAiNotes = async (liquorId: string): Promise => { export const fetchRelatedNotes = async ( note: string, - exclude: string, + exclude: string ): Promise => { const response = await fetch( - `${LIQUOR_NOTES_URL}?keyword=${encodeURIComponent(note)}&exclude=${encodeURIComponent(exclude)}&limit=5`, + `${LIQUOR_NOTES_URL}?keyword=${encodeURIComponent(note)}&exclude=${encodeURIComponent(exclude)}&limit=5` ); if (!response.ok) return []; @@ -84,7 +84,7 @@ export const saveReviewData = async (data: ReviewSavingData): Promise => { }; export const updateReviewData = async ( - data: ReviewUpdateData, + data: ReviewUpdateData ): Promise => { const response = await fetch(SAVE_REVIEW_URL + "/" + data.id, { method: "PUT", @@ -104,11 +104,11 @@ export const updateReviewData = async ( export const checkUserPermission = async (user: User) => { try { const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/users`, { method: "GET", credentials: "include", - }, + } ); const fetchedUser = await response.json(); return fetchedUser.userUuid === user.userUuid; diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index 993a398..7ae6688 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -18,7 +18,7 @@ import { Edit } from "@mui/icons-material"; /** 주류 상세정보 API 요청 함수 */ const getLiquorInfo = async (id: string) => { const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquors/${id}` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquors/${id}` ); if (!res.ok) { if (res.status === 404) { diff --git a/src/app/liquors/new/page.tsx b/src/app/liquors/new/page.tsx index 7f8b497..7f9b533 100644 --- a/src/app/liquors/new/page.tsx +++ b/src/app/liquors/new/page.tsx @@ -146,17 +146,15 @@ const LiquorForm: React.FC = () => { console.log("폼 제출 시작", data); try { const response = await axios.post( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquors`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquors`, data, { headers: { "Content-Type": "application/json", }, withCredentials: true, - }, + } ); - - console.log("서버 응답:", response.data); localStorage.removeItem("liquorFormData"); const liquorId = response.data; router.push(`/tasting-notes/new?liquorId=${liquorId}`); diff --git a/src/app/liquors/page.tsx b/src/app/liquors/page.tsx index 3389a02..4c77f82 100644 --- a/src/app/liquors/page.tsx +++ b/src/app/liquors/page.tsx @@ -24,7 +24,7 @@ import debounce from "lodash.debounce"; const getLiquorList = async (keyword: string) => { if (!keyword) return null; const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquorsearch?keyword=${keyword}` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquorsearch?keyword=${keyword}` ); return response.data; }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index cadb8e8..995b2d6 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -24,7 +24,7 @@ function LoginComponent() { const handleKakaoLogin = () => { const redirectUrl = params.get("redirectTo"); router.push( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/oauth2/authorization/kakao${redirectUrl ? `?redirectTo=${encodeURIComponent(redirectUrl)}` : ""}`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/oauth2/authorization/kakao${redirectUrl ? `?redirectTo=${encodeURIComponent(redirectUrl)}` : ""}` ); }; diff --git a/src/app/meetings/[mId]/page.tsx b/src/app/meetings/[mId]/page.tsx index 22597c2..0046274 100644 --- a/src/app/meetings/[mId]/page.tsx +++ b/src/app/meetings/[mId]/page.tsx @@ -32,7 +32,7 @@ const EXTERNAL_SERVICE_MESSAGE = // 1분마다 캐시를 업데이트 async function fetchData(mId: string) { const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/meetings/${mId}`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/meetings/${mId}`, { next: { revalidate: 1 }, } diff --git a/src/app/meetings/page.tsx b/src/app/meetings/page.tsx index 1679ea9..36152ef 100644 --- a/src/app/meetings/page.tsx +++ b/src/app/meetings/page.tsx @@ -74,7 +74,7 @@ const getMeetingList = async ({ .join("&"); response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/meetings?${queryString}`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/meetings?${queryString}`, { params } ); diff --git a/src/app/mypage/page.tsx b/src/app/mypage/page.tsx index 9ae70f8..6759b52 100644 --- a/src/app/mypage/page.tsx +++ b/src/app/mypage/page.tsx @@ -1,7 +1,5 @@ "use client"; -import { Button, Stack, Typography } from "@mui/material"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import MyPageContentsComponent from "@/components/MyPageContentsComponent/MyPageContentsComponent"; @@ -16,7 +14,7 @@ export default function MyPage() { const checkAuth = async () => { try { const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/users`, { method: "GET", credentials: "include", // 쿠키 포함 @@ -30,7 +28,6 @@ export default function MyPage() { } else { setIsLoggedIn(true); setUser(await response.json()); - // console.log(await response.json()); } } catch (error) { console.error("Error checking auth:", error); @@ -45,27 +42,6 @@ export default function MyPage() { // 페이지: 로그인 여부 로딩 중 if (isLoggedIn === null || (isLoggedIn === true && user === null)) return; - // 페이지: 로그인 되지 않은 경우 - if (isLoggedIn === false) { - return ( - - - 로그인이 필요합니다. - - - - - - ); - } - // 페이지: 로그인 되어 있는 경우 if (user) { return ; diff --git a/src/app/purchase-notes/[id]/StyledComponent.tsx b/src/app/purchase-notes/[id]/StyledComponent.tsx new file mode 100644 index 0000000..b048b53 --- /dev/null +++ b/src/app/purchase-notes/[id]/StyledComponent.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Avatar, Box, Button, styled } from "@mui/material"; + +export const Header = styled(Box)(({ theme }) => ({ + position: "fixed", + top: 0, + left: "50%", + transform: "translateX(-50%)", + width: "100%", + height: 80, + display: "flex", + justifyContent: "center", + alignItems: "center", + backgroundColor: "rgba(255, 255, 255, 1)", + borderBottom: "1px solid #D9D9D9", + boxShadow: theme.shadows[1], + zIndex: 1000, +})); + +export const Container = styled(Box)({ + maxWidth: "800px", + padding: "10px", + backgroundColor: "#ffffff", + borderRadius: "12px", + boxShadow: "0 6px 12px rgba(0,0,0,0.1)", +}); + +export const TitleHeader = styled(Box)({ + display: "flex", + alignItems: "center", + marginBottom: "30px", + borderBottom: "2px solid #eee", + paddingBottom: "10px", +}); + +export const WhiskeyImage = styled(Avatar)({ + width: "100px", + height: "100px", + marginRight: "20px", +}); + +export const TabContent = styled(Box)({ + marginTop: "20px", + padding: "20px", + backgroundColor: "#f9f9f9", + borderRadius: "8px", + boxShadow: "0 4px 8px rgba(0,0,0,0.1)", +}); + +export const SaveButton = styled(Button)({ + marginTop: "20px", + width: "100%", + padding: "10px", + backgroundColor: "#3f51b5", + color: "#ffffff", + "&:hover": { + backgroundColor: "#303f9f", + }, +}); diff --git a/src/app/purchase-notes/[id]/edit/page.tsx b/src/app/purchase-notes/[id]/edit/page.tsx new file mode 100644 index 0000000..31c60fe --- /dev/null +++ b/src/app/purchase-notes/[id]/edit/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function TastingNotesEditPage({ + params: { id }, +}: { + params: { id: string }; +}) { + const router = useRouter(); + + useEffect(() => { + alert("주모 공사중입니다."); + router.back(); + }); + + return null; +} diff --git a/src/app/purchase-notes/[id]/page.tsx b/src/app/purchase-notes/[id]/page.tsx new file mode 100644 index 0000000..72fe0ce --- /dev/null +++ b/src/app/purchase-notes/[id]/page.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { Container, Typography } from "@mui/material"; +import LiquorTitle from "@/components/TastingNotesComponent/LiquorTitle"; +import { formatDate } from "@/utils/format"; +import EditButton from "@/components/TastingNotesComponent/EditButton"; +import { notFound } from "next/navigation"; +import ShareButton from "@/components/Button/ShareButton"; +import { Metadata } from "next"; +import Link from "next/link"; +import TastingNotesButton from "@/components/Button/tastingNotesButton"; + +const NOTE_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/v2/notes/"; +const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/purchase-notes/"; +const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; + +/** 노트 상세 조회 API 호출 함수 */ +async function getNote(id: string): Promise { + const res = await fetch(NOTE_API_URL + id, { + next: { revalidate: 1, tags: ["review"] }, + }); + + if (!res.ok) { + if (res.status === 404) { + throw notFound(); + } + throw new Error("Failed to fetch data"); + } + return await res.json(); +} + +/** 메타데이터 생성 함수 */ +export async function generateMetadata({ + params, +}: { + params: { id: string }; +}): Promise { + const note = await getNote(params.id); + + const { user, createdAt, liquor } = note.purchaseNote; + + const title = `${liquor.koName} 구매 노트`; + const description = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 구매 노트`; + const url = `${NOTE_URL}${params.id}`; + + return { + title, + description, + openGraph: { + title, + description, + url, + images: [ + { + url: + liquor.thumbnailImageUrl || + "https://github.com/user-attachments/assets/36420b2d-e392-4b20-bcda-80d7944d9658", + width: 1200, + height: 630, + alt: "liquor image", + }, + ], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ + liquor.thumbnailImageUrl || + "https://github.com/user-attachments/assets/36420b2d-e392-4b20-bcda-80d7944d9658", + ], + }, + }; +} + +export default async function PurchaseNotePage({ + params: { id }, +}: PostPageProps) { + const note = await getNote(id); + + const { content, user, createdAt, liquor } = note.purchaseNote; + + const text = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; + + const shareData = { + title: `${liquor.koName} 테이스팅 노트`, + text, + url: `${NOTE_URL}${id}`, + }; + + return ( + + {/* 주류 정보 */} + + + + + {/* 버튼 그룹 */} + + + {user.profileNickname}님이 {formatDate(createdAt)}에 작성함 + + {/* {mood && } */} + + + ); +} diff --git a/src/app/tasting-notes/[id]/edit/page.tsx b/src/app/tasting-notes/[id]/edit/page.tsx index ddd9ac0..3805b51 100644 --- a/src/app/tasting-notes/[id]/edit/page.tsx +++ b/src/app/tasting-notes/[id]/edit/page.tsx @@ -1,4 +1,7 @@ "use client"; + +// 임시 비활성화 + import React, { Suspense, useCallback, useEffect, useState } from "react"; import { Button, @@ -185,7 +188,7 @@ const TastingNotesEditPageComponent = ({ }; const updateSetRelatedNotes = ( newRelatedNotes: string[], - currentTab: number, + currentTab: number ) => { setRelatedNotes((prev) => { const updatedRelatedNotes = [...prev]; @@ -369,9 +372,17 @@ export default function TastingNotesEditPage({ }: { params: { id: string }; }) { - return ( - Loading...}> - - - ); + const router = useRouter(); + + useEffect(() => { + alert("주모 공사중입니다."); + router.back(); + }); + + return null; + // return ( + // Loading...}> + // + // + // ); } diff --git a/src/app/tasting-notes/[id]/page.tsx b/src/app/tasting-notes/[id]/page.tsx index 560ac2a..9d1f604 100644 --- a/src/app/tasting-notes/[id]/page.tsx +++ b/src/app/tasting-notes/[id]/page.tsx @@ -1,56 +1,25 @@ import React from "react"; -import { Box, Button, Container, Typography } from "@mui/material"; +import { Box, Container, Typography } from "@mui/material"; import { GiNoseSide, GiTongue } from "react-icons/gi"; import LiquorTitle from "@/components/TastingNotesComponent/LiquorTitle"; import { HiOutlineLightBulb } from "react-icons/hi"; import NotesSection from "@/components/TastingNotesComponent/NotesSection"; import { MdOutlineStickyNote2 } from "react-icons/md"; -import { calculateAverageScore, formatDate } from "@/utils/format"; -import MoodSelectedComponent from "@/components/TastingNotesComponent/MoodSelectedComponent"; +import { formatDate } from "@/utils/format"; import EditButton from "@/components/TastingNotesComponent/EditButton"; import { notFound } from "next/navigation"; import ShareButton from "@/components/Button/ShareButton"; import { Metadata } from "next"; import Link from "next/link"; -import HomeButton from "@/components/Button/tastingNotesButton"; import TastingNotesButton from "@/components/Button/tastingNotesButton"; -const REVIEW_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/tasting-notes/"; -const REVIEW_URL = process.env.NEXT_PUBLIC_BASE_URL + "/tasting-notes/"; +const NOTE_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/v2/notes/"; +const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/tasting-notes/"; const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; -interface LiquorData { - id: number; - thumbnailImageUrl: string | undefined; - koName: string; - type: string | null; - abv: string | null; - volume: string | null; - country: string | null; - region: string | null; - grapeVariety: string | null; -} - -interface ReviewData { - productID: number | null; - noseScore: number | null; - palateScore: number | null; - finishScore: number | null; - noseMemo: string | null; - palateMemo: string | null; - finishMemo: string | null; - overallNote: string | null; - mood: string | null; - noseNotes: string | null; - palateNotes: string | null; - finishNotes: string | null; - user: User; - createdAt: string; - liquor: LiquorData; -} - -async function fetchData(id: string): Promise { - const res = await fetch(REVIEW_API_URL + id, { +/** 노트 상세 조회 API 호출 함수 */ +async function getNote(id: string): Promise { + const res = await fetch(NOTE_API_URL + id, { next: { revalidate: 1, tags: ["review"] }, }); @@ -63,17 +32,19 @@ async function fetchData(id: string): Promise { return await res.json(); } +/** 메타데이터 생성 함수 */ export async function generateMetadata({ params, }: { params: { id: string }; }): Promise { - const reviewData = await fetchData(params.id); - const { user, createdAt, liquor } = reviewData; + const note = await getNote(params.id); + + const { user, createdAt, liquor } = note.tastingNote; const title = `${liquor.koName} 테이스팅 노트`; const description = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; - const url = `${REVIEW_URL}${params.id}`; + const url = `${NOTE_URL}${params.id}`; return { title, @@ -89,7 +60,7 @@ export async function generateMetadata({ "https://github.com/user-attachments/assets/36420b2d-e392-4b20-bcda-80d7944d9658", width: 1200, height: 630, - alt: liquor.koName, + alt: "liquor image", }, ], }, @@ -105,36 +76,20 @@ export async function generateMetadata({ }; } -interface PostPageProps { - params: { id: string }; -} - -export default async function PostPage({ params: { id } }: PostPageProps) { - const reviewData = await fetchData(id); +export default async function TastingNotePage({ + params: { id }, +}: PostPageProps) { + const note = await getNote(id); - const { - noseScore, - palateScore, - finishScore, - noseMemo, - palateMemo, - finishMemo, - overallNote, - mood, - noseNotes, - palateNotes, - finishNotes, - user, - createdAt, - liquor, - } = reviewData; + const { score, nose, palate, finish, content, user, createdAt, liquor } = + note.tastingNote; const text = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; const shareData = { title: `${liquor.koName} 테이스팅 노트`, text, - url: `${REVIEW_URL}${id}`, + url: `${NOTE_URL}${id}`, }; return ( @@ -161,7 +116,7 @@ export default async function PostPage({ params: { id } }: PostPageProps) { text={shareData.text} url={shareData.url} /> - } - score={noseScore} - notes={noseNotes ? noseNotes.split(",") : []} - formattedDescription={noseMemo} + score={score} + notes={[]} + formattedDescription={nose} /> } - score={palateScore} - notes={palateNotes ? palateNotes.split(",") : []} - formattedDescription={palateMemo} + score={score} + notes={[]} + formattedDescription={palate} /> } - notes={finishNotes ? finishNotes.split(",") : []} - score={finishScore} - formattedDescription={finishMemo} + score={score} + notes={[]} + formattedDescription={finish} /> } notes={[]} - score={calculateAverageScore(noseScore, palateScore, finishScore)} - formattedDescription={overallNote} + score={score} + formattedDescription={content} /> - {mood && } + {/* {mood && } */} ); diff --git a/src/app/tasting-notes/new/page.tsx b/src/app/tasting-notes/new/page.tsx index df848ad..050791c 100644 --- a/src/app/tasting-notes/new/page.tsx +++ b/src/app/tasting-notes/new/page.tsx @@ -1,4 +1,7 @@ "use client"; + +// 임시 비활성화 + import React, { Suspense, useCallback, useEffect, useState } from "react"; import { Alert, @@ -36,7 +39,6 @@ import { } from "@/api/tastingNotesApi"; import { useRouter, useSearchParams } from "next/navigation"; import TastingNotesSkeleton from "@/components/TastingNotesComponent/TastingNotesSkeleton"; -import { styled } from "@mui/material/styles"; import { CustomSnackbar, useCustomSnackbar, @@ -85,16 +87,16 @@ const TastingNotesNewPageComponent = () => { const getAuth = useCallback(async () => { try { const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/users`, { method: "GET", credentials: "include", // 세션 기반 인증에 필요한 경우 추가 - }, + } ); if (response.status === 401) { alert( - "리뷰 작성은 로그인이 필요합니다.(카카오로 1초 로그인 하러 가기)", + "리뷰 작성은 로그인이 필요합니다.(카카오로 1초 로그인 하러 가기)" ); const redirectUrl = window.location.href; router.push(`/login?redirectTo=${encodeURIComponent(redirectUrl)}`); @@ -117,13 +119,13 @@ const TastingNotesNewPageComponent = () => { setLiquorData(data); let tastingNotesAroma = new Set( - data.tastingNotesAroma?.split(", ") || [], + data.tastingNotesAroma?.split(", ") || [] ); let tastingNotesTaste = new Set( - data.tastingNotesTaste?.split(", ") || [], + data.tastingNotesTaste?.split(", ") || [] ); let tastingNotesFinish = new Set( - data.tastingNotesFinish?.split(", ") || [], + data.tastingNotesFinish?.split(", ") || [] ); if (data.aiNotes) { @@ -180,7 +182,7 @@ const TastingNotesNewPageComponent = () => { }; const updateSetRelatedNotes = ( newRelatedNotes: string[], - currentTab: number, + currentTab: number ) => { setRelatedNotes((prev) => { const updatedRelatedNotes = [...prev]; @@ -363,9 +365,17 @@ const TastingNotesNewPageComponent = () => { }; export default function TastingNotesNewPage() { - return ( - Loading...}> - - - ); + const router = useRouter(); + + useEffect(() => { + alert("주모 공사중입니다."); + router.back(); + }, []); + + return null; + // return ( + // Loading...}> + // + // + // ); } diff --git a/src/app/temp/page.tsx b/src/app/temp/page.tsx deleted file mode 100644 index 3e33d32..0000000 --- a/src/app/temp/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -"use client"; - -import * as React from "react"; -import { - Container, - Typography, - Button, - AppBar, - Toolbar, - Avatar, -} from "@mui/material"; -import { useEffect, useState } from "react"; -import axios from "axios"; -import styled from "@emotion/styled"; - -interface Profile { - profileNickname: string; - profileImage: string; -} - -const MainContainer = styled.main` - display: flex; - min-height: 100vh; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px; -`; - -const AvatarStyle = styled(Avatar)` - width: 100px; - height: 100px; -`; - -export default function TempPage() { - const [profile, setProfile] = useState(null); - - useEffect(() => { - async function fetchProfile() { - try { - const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/users`, - { - withCredentials: true, - }, - ); - setProfile(response.data); - } catch (error) { - console.error("User not logged in or error fetching profile", error); - setProfile(null); - } - } - - fetchProfile(); - }, []); - - const handleLogout = async () => { - try { - await axios.post( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/logout`, - {}, - { withCredentials: true }, - ); - setProfile(null); - window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}`; // 로그아웃 후 메인 페이지로 이동 - } catch (error) { - console.error("Error logging out", error); - } - }; - - const handleLoginRedirect = () => { - window.location.href = `${process.env.NEXT_PUBLIC_BASE_URL}/login`; // 로그인 페이지로 이동 - }; - - return ( - - - - - 주모 홈페이지 - - {profile ? ( - - ) : ( - - )} - - - - - {profile ? ( - <> - - - {profile.profileNickname} - - - ) : ( - - 주모 홈페이지 - - )} - - - ); -} diff --git a/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx b/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx index 48598a5..e0aa699 100644 --- a/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx +++ b/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx @@ -13,9 +13,8 @@ import SingleTastingComponent from "../SingleTastingComponent/SingleTastingCompo /** 주류 유저 테이스팅 리뷰 목록 API 요청 함수 */ const getLiquorTastingList = async (id: number) => { const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes/liquor/${id}`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes/liquor/${id}` ); - // console.log(response.data); return response.data; }; @@ -25,7 +24,7 @@ export default function LiquorUserTastingComponent({ liquorId: string; }) { // 주류 검색 api query - const { data, status } = useQuery({ + const { data, status } = useQuery({ queryKey: ["liquorTastingList", liquorId], queryFn: () => getLiquorTastingList(+liquorId), }); @@ -34,119 +33,111 @@ export default function LiquorUserTastingComponent({ {status == "success" && (data && data.length ? ( - data.map((tasting: TastingNoteList) => ( - - + type == "TASTING" ? ( + - {/* 카드 헤더 */} - - {/* 작성자 정보 */} + {/* 카드 헤더 */} - user profile image - - - {tasting.user.profileNickname} - - - {formatDateTime(tasting.createdAt)} - - - - {/* 작성자 총점 */} - - {calculateAverageScore( - tasting.noseScore, - tasting.palateScore, - tasting.finishScore, - ) && ( - + user profile image - )} + + + {tastingNote.user.profileNickname} + + + {formatDateTime(tastingNote.createdAt)} + + + + {/* 작성자 총점 */} + + {tastingNote.score && } + - - + - {/* 테이스팅 리뷰 내용: 상세 */} - - - - - + {/* 테이스팅 리뷰 내용: 상세 */} + + + + + - {/* 테이스팅 리뷰 내용: 총평 */} - - -  {tasting.overallNote} - + {/* 테이스팅 리뷰 내용: 총평 */} + + +  {tastingNote.content} + + - - - )) + + ) : null + ) ) : ( diff --git a/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx b/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx index 7995c86..5b6a701 100644 --- a/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx +++ b/src/components/LiquorUserTastingComponent/UserTastingComponent.tsx @@ -2,34 +2,11 @@ "use client"; -import { calculateAverageScore, formatDateTime } from "@/utils/format"; -import { Box, Chip, Divider, Stack, Typography } from "@mui/material"; -import axios from "axios"; -import Image from "next/image"; -import { useQuery } from "react-query"; -import Link from "next/link"; -import SingleTastingComponent from "../SingleTastingComponent/SingleTastingComponent"; - -// /** 유저 주류 테이스팅 리뷰 목록 API 요청 함수 */ -// const getLiquorTastingList = async (id: string) => { -// const response = await axios.get( -// `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes/user/${id}`, -// ); -// // console.log(response.data); -// return response.data; -// }; - -export default function UserTastingComponent({ - data, -}: { - data: TastingNoteList[]; -}) { - // // 주류 검색 api query - // const { data, status } = useQuery({ - // queryKey: ["userTastingList", userId], - // queryFn: () => getLiquorTastingList(userId), - // }); +import { Stack, Typography } from "@mui/material"; +import UserPurchaseNoteCard from "../NoteCard/UserPurchaseNoteCard"; +import UserTastingNoteCard from "../NoteCard/UserTastingNoteCard"; +export default function UserTastingComponent({ data }: { data: Note[] }) { return ( {/* 소제목 */} @@ -44,122 +21,13 @@ export default function UserTastingComponent({ {/* 테이스팅 노트 목록 */} - {data.map((tasting: TastingNoteList) => ( - - - {/* 카드 헤더 */} - - {/* 주류 및 작성 정보 */} - - user profile image - - - {tasting.liquor.koName} - - - {formatDateTime(tasting.createdAt)} - - - - {/* 작성자 총점 */} - - {calculateAverageScore( - tasting.noseScore, - tasting.palateScore, - tasting.finishScore - ) && ( - - )} - - - - - - {/* 테이스팅 리뷰 내용: 상세 */} - - - - - - - {/* 테이스팅 리뷰 내용: 총평 */} - - - {tasting.overallNote} - - - - - ))} + {data.map((note: Note, idx) => + note.type == "PURCHASE" ? ( + + ) : ( + + ) + )} ); } diff --git a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx index 883a83d..d4b9417 100644 --- a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx +++ b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx @@ -19,20 +19,27 @@ import { Edit } from "@mui/icons-material"; import UserNoteGroupComponent from "../LiquorUserTastingComponent/UserNoteGroupComponent"; /** 유저 주류 테이스팅 리뷰 목록 API 요청 함수 */ -const getLiquorTastingList = async (id: string) => { +const getUserNoteList = async (id: string) => { const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes/user/${id}` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes/user/${id}` ); - const list: TastingNoteList[] = response.data; + const list: Note[] = response.data; // 그룹화 로직 - const groupMap = new Map(); + const groupMap = new Map(); list.forEach((note) => { - const liquorId = note.liquor.id.toString(); // liquor의 고유 식별자로 가정 - if (groupMap.has(liquorId)) { - groupMap.get(liquorId)!.notesCount++; - } else { - groupMap.set(liquorId, { liquor: note.liquor, notesCount: 1 }); + let liquor = null; + if (note.type == "PURCHASE" && note.purchaseNote) + liquor = note.purchaseNote.liquor; + else if (note.type == "TASTING" && note.tastingNote) + liquor = note.tastingNote.liquor; + + if (liquor) { + if (groupMap.has(liquor.id)) { + groupMap.get(liquor.id)!.notesCount++; + } else { + groupMap.set(liquor.id, { liquor, notesCount: 1 }); + } } }); @@ -45,7 +52,7 @@ const getLiquorTastingList = async (id: string) => { const handleLogout = async () => { try { await axios.post( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/logout`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/logout`, {}, { withCredentials: true } ); @@ -65,10 +72,10 @@ export default function MyPageContentsComponent({ user }: { user: User }) { setNoteTabOption(value); }; - // 주류 검색 api query + // 노트 목록 api query const { data, status } = useQuery({ - queryKey: ["userTastingList", user.userUuid], - queryFn: () => getLiquorTastingList(user.userUuid), + queryKey: ["userNoteList", user.userUuid], + queryFn: () => getUserNoteList(user.userUuid), }); return ( @@ -109,7 +116,6 @@ export default function MyPageContentsComponent({ user }: { user: User }) { display: "flex", justifyContent: "center", alignItems: "center", - // gap: "2px", }} > - - + + + + 🏪 현재 가격 비교 지원 중인 마켓 + + + {["트레이더스", "데일리샷", "무카와", "CU"].map((market) => ( + {market} + ))} + + ))} @@ -208,3 +208,37 @@ const TipTypography = styled(Typography)(({ theme }) => ({ marginBottom: theme.spacing(1), fontSize: "0.9rem", })); + +const MarketInfoBox = styled(Box)(({ theme }) => ({ + marginTop: theme.spacing(4), + padding: theme.spacing(2), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + maxWidth: "300px", + margin: "32px auto 0", +})); + +const MarketInfoTitle = styled(Typography)(({ theme }) => ({ + fontSize: "0.9rem", + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1.5), + textAlign: "center", + fontWeight: 500, +})); + +const MarketChipsContainer = styled(Box)({ + display: "flex", + flexWrap: "wrap", + gap: "8px", + justifyContent: "center", +}); + +const MarketChip = styled(Box)(({ theme }) => ({ + padding: "4px 12px", + borderRadius: "16px", + fontSize: "0.85rem", + backgroundColor: theme.palette.primary.main + "15", + color: theme.palette.primary.main, + border: `1px solid ${theme.palette.primary.main}30`, +})); diff --git a/src/app/page.tsx b/src/app/page.tsx index cf09b66..a8beba8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,12 @@ -import ServiceIntroductionComponent from "@/components/ServiceIntroductionComponent/ServiceIntroductionComponent"; import { Stack } from "@mui/material"; import { redirect } from "next/navigation"; +import ServiceIntroductionComponentV2 from "@/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2"; export default function Home() { return ( {/* 서비스 소개 및 유저 피드백 관련 컴포넌트 */} - + ); // redirect("/meetings"); diff --git a/src/components/LayoutComponents/NavigationComponent.tsx b/src/components/LayoutComponents/NavigationComponent.tsx index 06d32dd..14c442e 100644 --- a/src/components/LayoutComponents/NavigationComponent.tsx +++ b/src/components/LayoutComponents/NavigationComponent.tsx @@ -5,7 +5,6 @@ import { Diversity3, LocalBar, Warehouse, - EditNote, Search, } from "@mui/icons-material"; import { Box, Stack, Typography } from "@mui/material"; @@ -62,11 +61,12 @@ export default function NavigationComponent() { }, }, { - title: "주류 생활", - link: "/mypage", + title: "주류 가격 비교", + link: "/liquors", icon: function () { return ( - = ({ {store !== "traders" && ( 클릭시 상세 페이지로 이동합니다. + {store === "mukawa" && + " 일본 사이트에서 정보를 가져와 번역하기에 오래 걸립니다."} + )} diff --git a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx new file mode 100644 index 0000000..b2f5ead --- /dev/null +++ b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx @@ -0,0 +1,330 @@ +"use client"; + +import { + Avatar, + Box, + Card, + CardContent, + Chip, + Stack, + Typography, +} from "@mui/material"; +import Link from "next/link"; +import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; +import StorefrontIcon from "@mui/icons-material/Storefront"; +import SearchIcon from "@mui/icons-material/Search"; +import TouchAppIcon from "@mui/icons-material/TouchApp"; +import PriceCheckIcon from "@mui/icons-material/PriceCheck"; + +const GOOGLE_FORM_URL = "https://forms.gle/cuoJy7uJF4r2ewMg9"; +const KAKAO_OPENCHAT_URL = "https://open.kakao.com/o/sSDeVvGg"; + +export default function ServiceIntroductionComponent() { + return ( + + + + {/* 헤더 */} + + + 주류 가격 통합 비교 + + + 국내외 주요 매장의 가격을 한 눈에 비교하세요 + + + + {/* 사용 방법 */} + + + 이용 방법 + + + {[ + { + icon: , + step: "1", + title: "원하는 주류 검색", + desc: "찾고 싶은 주류의 이름을 검색해보세요. 영문, 한글 모두 가능합니다.", + }, + { + icon: , + step: "2", + title: "주류 선택", + desc: "검색 결과에서 원하는 주류를 선택하세요.", + }, + { + icon: , + step: "3", + title: "가격 비교", + desc: "각 매장별 실시간 가격을 한 눈에 비교하고 최저가를 확인하세요.", + }, + ].map((item, index) => ( + + + {item.icon} + + + + + {item.step} + + + {item.title} + + + + {item.desc} + + + + ))} + + + + {/* 지원 매장 */} + + + + + + + 지원 매장 + + + {["트레이더스", "데일리샷", "무카와", "CU"].map((store) => ( + + ))} + + + + {/* CTA 버튼 */} + + + 지금 바로 가격 비교하기 + + + + {/* 피드백 섹션 */} + + + 문의 및 피드백 + + + {[ + { + icon: "/Google_logo.png", + title: "매장 추가 요청 / 기능 제안하기", + desc: "Google Form으로 의견을 제출해주세요", + url: GOOGLE_FORM_URL, + }, + { + icon: "/KakaoTalk_logo.svg", + title: "오픈채팅방 참여하기", + desc: "실시간으로 소통하고 피드백을 나눠보세요", + url: KAKAO_OPENCHAT_URL, + }, + ].map((item, index) => ( + + + + + + {item.title} + + + {item.desc} + + + + + ))} + + + + + + ); +} From 145f542c88cd3d9bb37348f2489772364d4379c4 Mon Sep 17 00:00:00 2001 From: yangdongsuk <51476641+yangdongsuk@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:39:11 +0900 Subject: [PATCH 11/28] =?UTF-8?q?Revert=20"release=202.4.0=20=EB=94=94?= =?UTF-8?q?=EB=B2=A8=EB=A1=AD=EC=97=90=20=EB=B0=98=EC=98=81=20(#63)"=20(#6?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1c55076179b9c2537063be5c218dcc1c4f397180. --- .github/workflows/frontend_prod.yml | 58 ++- src/app/liquors/[id]/page.tsx | 4 +- src/app/liquors/page.tsx | 62 +--- src/app/page.tsx | 4 +- .../LayoutComponents/NavigationComponent.tsx | 8 +- src/components/PriceInfo/PriceInfo.tsx | 3 - .../ServiceIntroductionComponentV2.tsx | 330 ------------------ 7 files changed, 48 insertions(+), 421 deletions(-) delete mode 100644 src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx diff --git a/.github/workflows/frontend_prod.yml b/.github/workflows/frontend_prod.yml index 4ad290e..1449c46 100644 --- a/.github/workflows/frontend_prod.yml +++ b/.github/workflows/frontend_prod.yml @@ -22,8 +22,6 @@ jobs: echo "NEXT_PUBLIC_GA_ID=${{ secrets.NEXT_PUBLIC_GA_ID_PROD }}" >> .env echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env echo "NEXT_PUBLIC_SENTRY_DSN=${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}" >> .env - echo "DEEPL_API_KEY=${{ secrets.DEEPL_API_KEY }}" >> .env - - name: Configure AWS credentials @@ -41,37 +39,33 @@ jobs: docker build -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest . docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest - # deploy-to-az1: - # needs: build-docker-image - # runs-on: [prod, 2a] - # strategy: - # max-parallel: 1 - # steps: - # - name: ecr get-login-password - # run: | - # aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod - # - name: Pull Docker image from AWS ECR - # run: | - # docker pull ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest - - # - name: stop Docker container before running new container - # run: | - # sudo docker stop $(sudo docker ps -aq) || true - - # - name: Run new Docker container - # run: | - # docker run --rm -it -d -p 80:3000 \ - # -e SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \ - # -e DEEPL_API_KEY=${{ secrets.DEEPL_API_KEY }} \ - # --name jumo_front_prod ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest - - # - name: Clean up unused Docker images after deployment - # run: | - # sudo docker image prune -a -f || true + deploy-to-az1: + needs: build-docker-image + runs-on: [prod, 2a] + strategy: + max-parallel: 1 + steps: + - name: ecr get-login-password + run: | + aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | docker login --username AWS --password-stdin ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod + - name: Pull Docker image from AWS ECR + run: | + docker pull ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest + + - name: stop Docker container before running new container + run: | + sudo docker stop $(sudo docker ps -aq) || true + + - name: Run new Docker container + run: | + docker run --rm -it -d -p 80:3000 --name jumo_front_prod ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest + + - name: Clean up unused Docker images after deployment + run: | + sudo docker image prune -a -f || true deploy-to-az2: - # needs: deploy-to-az1 - needs: build-docker-image + needs: deploy-to-az1 runs-on: [prod, 2c] strategy: max-parallel: 1 @@ -89,7 +83,7 @@ jobs: - name: Run new Docker container run: | - docker run --rm -it -d -p 80:3000 -e SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} -e DEEPL_API_KEY=${{ secrets.DEEPL_API_KEY }} --name jumo_front_prod ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest + docker run --rm -it -d -p 80:3000 --name jumo_front_prod ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_prod:latest - name: Clean up unused Docker images after deployment run: | diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index 008b006..f4b0973 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -37,7 +37,7 @@ export default async function LiquorDetailPage({ }) { // 주류 데이터 const liquor = await getLiquorInfo(id); - const userProfileNickname = liquor?.user?.profileNickname || ""; + const userProfileNickname = liquor.user.profileNickname || ""; return ( - {userProfileNickname === "데일리샷" || !userProfileNickname + {userProfileNickname === "데일리샷" ? "데일리샷 정보" : userProfileNickname + "님이 등록한 정보"} diff --git a/src/app/liquors/page.tsx b/src/app/liquors/page.tsx index eb99bcf..4c77f82 100644 --- a/src/app/liquors/page.tsx +++ b/src/app/liquors/page.tsx @@ -24,8 +24,7 @@ import debounce from "lodash.debounce"; const getLiquorList = async (keyword: string) => { if (!keyword) return null; const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquorsearch?keyword=${keyword}` - + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquorsearch?keyword=${keyword}` ); return response.data; }; @@ -140,24 +139,25 @@ export default function LiquorsPage() { 검색 결과가 없습니다. - 가격 통합 비교 및 테이스팅 노트 작성을 위해서는 주류를 선택해야 합니다. - - - - 🏪 현재 가격 비교 지원 중인 마켓 - - - {["트레이더스", "데일리샷", "무카와", "CU"].map((market) => ( - {market} - ))} - - + + 💡 Tip: 찾는 주류가 없으신가요? + + + + ))} @@ -208,37 +208,3 @@ const TipTypography = styled(Typography)(({ theme }) => ({ marginBottom: theme.spacing(1), fontSize: "0.9rem", })); - -const MarketInfoBox = styled(Box)(({ theme }) => ({ - marginTop: theme.spacing(4), - padding: theme.spacing(2), - backgroundColor: theme.palette.background.paper, - borderRadius: theme.shape.borderRadius, - border: `1px solid ${theme.palette.divider}`, - maxWidth: "300px", - margin: "32px auto 0", -})); - -const MarketInfoTitle = styled(Typography)(({ theme }) => ({ - fontSize: "0.9rem", - color: theme.palette.text.secondary, - marginBottom: theme.spacing(1.5), - textAlign: "center", - fontWeight: 500, -})); - -const MarketChipsContainer = styled(Box)({ - display: "flex", - flexWrap: "wrap", - gap: "8px", - justifyContent: "center", -}); - -const MarketChip = styled(Box)(({ theme }) => ({ - padding: "4px 12px", - borderRadius: "16px", - fontSize: "0.85rem", - backgroundColor: theme.palette.primary.main + "15", - color: theme.palette.primary.main, - border: `1px solid ${theme.palette.primary.main}30`, -})); diff --git a/src/app/page.tsx b/src/app/page.tsx index a8beba8..cf09b66 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,12 @@ +import ServiceIntroductionComponent from "@/components/ServiceIntroductionComponent/ServiceIntroductionComponent"; import { Stack } from "@mui/material"; import { redirect } from "next/navigation"; -import ServiceIntroductionComponentV2 from "@/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2"; export default function Home() { return ( {/* 서비스 소개 및 유저 피드백 관련 컴포넌트 */} - + ); // redirect("/meetings"); diff --git a/src/components/LayoutComponents/NavigationComponent.tsx b/src/components/LayoutComponents/NavigationComponent.tsx index 14c442e..06d32dd 100644 --- a/src/components/LayoutComponents/NavigationComponent.tsx +++ b/src/components/LayoutComponents/NavigationComponent.tsx @@ -5,6 +5,7 @@ import { Diversity3, LocalBar, Warehouse, + EditNote, Search, } from "@mui/icons-material"; import { Box, Stack, Typography } from "@mui/material"; @@ -61,12 +62,11 @@ export default function NavigationComponent() { }, }, { - title: "주류 가격 비교", - link: "/liquors", + title: "주류 생활", + link: "/mypage", icon: function () { return ( - = ({ {store !== "traders" && ( 클릭시 상세 페이지로 이동합니다. - {store === "mukawa" && - " 일본 사이트에서 정보를 가져와 번역하기에 오래 걸립니다."} - )} diff --git a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx deleted file mode 100644 index b2f5ead..0000000 --- a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx +++ /dev/null @@ -1,330 +0,0 @@ -"use client"; - -import { - Avatar, - Box, - Card, - CardContent, - Chip, - Stack, - Typography, -} from "@mui/material"; -import Link from "next/link"; -import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; -import StorefrontIcon from "@mui/icons-material/Storefront"; -import SearchIcon from "@mui/icons-material/Search"; -import TouchAppIcon from "@mui/icons-material/TouchApp"; -import PriceCheckIcon from "@mui/icons-material/PriceCheck"; - -const GOOGLE_FORM_URL = "https://forms.gle/cuoJy7uJF4r2ewMg9"; -const KAKAO_OPENCHAT_URL = "https://open.kakao.com/o/sSDeVvGg"; - -export default function ServiceIntroductionComponent() { - return ( - - - - {/* 헤더 */} - - - 주류 가격 통합 비교 - - - 국내외 주요 매장의 가격을 한 눈에 비교하세요 - - - - {/* 사용 방법 */} - - - 이용 방법 - - - {[ - { - icon: , - step: "1", - title: "원하는 주류 검색", - desc: "찾고 싶은 주류의 이름을 검색해보세요. 영문, 한글 모두 가능합니다.", - }, - { - icon: , - step: "2", - title: "주류 선택", - desc: "검색 결과에서 원하는 주류를 선택하세요.", - }, - { - icon: , - step: "3", - title: "가격 비교", - desc: "각 매장별 실시간 가격을 한 눈에 비교하고 최저가를 확인하세요.", - }, - ].map((item, index) => ( - - - {item.icon} - - - - - {item.step} - - - {item.title} - - - - {item.desc} - - - - ))} - - - - {/* 지원 매장 */} - - - - - - - 지원 매장 - - - {["트레이더스", "데일리샷", "무카와", "CU"].map((store) => ( - - ))} - - - - {/* CTA 버튼 */} - - - 지금 바로 가격 비교하기 - - - - {/* 피드백 섹션 */} - - - 문의 및 피드백 - - - {[ - { - icon: "/Google_logo.png", - title: "매장 추가 요청 / 기능 제안하기", - desc: "Google Form으로 의견을 제출해주세요", - url: GOOGLE_FORM_URL, - }, - { - icon: "/KakaoTalk_logo.svg", - title: "오픈채팅방 참여하기", - desc: "실시간으로 소통하고 피드백을 나눠보세요", - url: KAKAO_OPENCHAT_URL, - }, - ].map((item, index) => ( - - - - - - {item.title} - - - {item.desc} - - - - - ))} - - - - - - ); -} From 5f23a01455f0a2d6391e58ed603fd9a05f6c0173 Mon Sep 17 00:00:00 2001 From: yangdongsuk <51476641+yangdongsuk@users.noreply.github.com> Date: Thu, 31 Oct 2024 11:34:24 +0900 Subject: [PATCH 12/28] =?UTF-8?q?release=202.4.0=20=EB=94=94=EB=B2=A8?= =?UTF-8?q?=EB=A1=AD=EC=97=90=20=EB=B0=98=EC=98=81=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * deepl api 키 반영 * release 2.2.0 (#60) * [BYOB-223] 주류 가격 통합 비교 기능 구축 (#58) * feat: 주류 가격 정보 조회 API 구현 * feat: 검색 결과가 없으면 쿼리 중 첫번째로 두글자 이상인 단어를 찾아서 다시 검색 * feat: 주류 상세 페이지 가격 정보 추가 * feat: 무카와 추가 * feat: 캐시 추가 * refactor: PriceInfo 개선 * feat: cu 가격 정보 추가 * refactor: 주류 검색 페이지 cors관련 설정 제거 * fix: 타입 오류 해결 * refactor: 주류 가격 정보 타입 오류 해결 * feat: 일본어 검색 기능 개선 (#59) * release 2.2.1 (#61) * [BYOB-223] 주류 가격 통합 비교 기능 구축 (#58) * feat: 주류 가격 정보 조회 API 구현 * feat: 검색 결과가 없으면 쿼리 중 첫번째로 두글자 이상인 단어를 찾아서 다시 검색 * feat: 주류 상세 페이지 가격 정보 추가 * feat: 무카와 추가 * feat: 캐시 추가 * refactor: PriceInfo 개선 * feat: cu 가격 정보 추가 * refactor: 주류 검색 페이지 cors관련 설정 제거 * fix: 타입 오류 해결 * refactor: 주류 가격 정보 타입 오류 해결 * feat: 일본어 검색 기능 개선 (#59) * release 2.4.0 가격 비교 기능 추가 (#62) * feat: 레이아웃 수정 및 검색 UI 수정 * feat: 홈페이지 변경 * feat: 홈페이지 디자인 변경 * fix: null 에러 수정 * feat: 무카와 상세 설명 추가 * az1 제거 * 명령어 에러 수정 --- .../LayoutComponents/NavigationComponent.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/components/LayoutComponents/NavigationComponent.tsx b/src/components/LayoutComponents/NavigationComponent.tsx index d6d6b56..cc221c7 100644 --- a/src/components/LayoutComponents/NavigationComponent.tsx +++ b/src/components/LayoutComponents/NavigationComponent.tsx @@ -75,11 +75,25 @@ export default function NavigationComponent() { }, }, { - title: "마이페이지", - link: "/mypage", + title: "주류 가격 비교", + link: "/liquors", + icon: function () { + return ( + + ); + }, + }, + { + title: "주모 레포트", + link: "/report", icon: function () { return ( - Date: Thu, 31 Oct 2024 12:34:47 +0900 Subject: [PATCH 13/28] =?UTF-8?q?feat:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LayoutComponents/NavigationComponent.tsx | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/src/components/LayoutComponents/NavigationComponent.tsx b/src/components/LayoutComponents/NavigationComponent.tsx index cc221c7..ab2787e 100644 --- a/src/components/LayoutComponents/NavigationComponent.tsx +++ b/src/components/LayoutComponents/NavigationComponent.tsx @@ -33,7 +33,7 @@ export default function NavigationComponent() { /** 네비게이션 바 옵션 객체 */ const NAV_OPTIONS = [ { - title: "JUMO", + title: "홈", link: "/", icon: function () { return ( @@ -75,25 +75,11 @@ export default function NavigationComponent() { }, }, { - title: "주류 가격 비교", - link: "/liquors", - icon: function () { - return ( - - ); - }, - }, - { - title: "주모 레포트", - link: "/report", + title: "마이페이지", + link: "/mypage", icon: function () { return ( - Date: Thu, 31 Oct 2024 12:43:07 +0900 Subject: [PATCH 14/28] =?UTF-8?q?feat:=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/liquors/[id]/page.tsx | 2 +- src/app/liquors/page.tsx | 58 ++++++----------------------------- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index 008b006..cc8ca4d 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -16,7 +16,7 @@ import { translateWhiskyNameToJapenese } from "@/utils/translateWhiskyNameToJape /** 주류 상세정보 API 요청 함수 */ const getLiquorInfo = async (id: string) => { const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquors/${id}` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquors/${id}` ); if (!res.ok) { if (res.status === 404) { diff --git a/src/app/liquors/page.tsx b/src/app/liquors/page.tsx index 8569b4e..2a207c0 100644 --- a/src/app/liquors/page.tsx +++ b/src/app/liquors/page.tsx @@ -15,10 +15,9 @@ import { } from "@mui/material"; import axios from "axios"; import Link from "next/link"; -import React, { useCallback, useState } from "react"; +import React, { useState } from "react"; import { useQuery } from "react-query"; import AddIcon from "@mui/icons-material/Add"; -import debounce from "lodash.debounce"; /** 주류 검색 API 요청 함수 */ const getLiquorList = async (keyword: string) => { @@ -32,26 +31,17 @@ const getLiquorList = async (keyword: string) => { export default function LiquorsPage() { // 검색 키워드 state const [keyword, setKeyword] = useState(""); - const [debouncedKeyword, setDebouncedKeyword] = useState(keyword); - - // 주류 검색 api query - const { data, status, isFetching } = useQuery({ - queryKey: ["liquorList", debouncedKeyword], - queryFn: () => getLiquorList(debouncedKeyword), - enabled: !!debouncedKeyword, - }); - - // debounce function - const debounceKeywordChange = useCallback( - debounce((nextValue: string) => setDebouncedKeyword(nextValue), 300), - [] - ); const handleKeywordChange = (e: React.ChangeEvent) => { setKeyword(e.target.value); - debounceKeywordChange(e.target.value); }; + // 주류 검색 api query + const { data, status } = useQuery({ + queryKey: ["liquorList", keyword], + queryFn: () => getLiquorList(keyword), + }); + return ( {/* 주류 검색창 */} @@ -80,36 +70,8 @@ export default function LiquorsPage() { }} /> - - {/* 초기 화면 */} - {status == "idle" && ( - - - - 테이스팅 노트 작성을 위해서는 - - - 주류를 선택해야 합니다. - - - 💡 Tip: 찾는 주류가 없으신가요? - - - - - - - )} - {/* 로딩 UI */} - {isFetching && ( + {status === "loading" && ( 열심히 검색 중... @@ -123,7 +85,7 @@ export default function LiquorsPage() { ({ From 6c895405081f352e178d4b1f991dce6db216fa0d Mon Sep 17 00:00:00 2001 From: yangdongsuk <51476641+yangdongsuk@users.noreply.github.com> Date: Thu, 31 Oct 2024 17:34:02 +0900 Subject: [PATCH 15/28] =?UTF-8?q?[BYOB-229]=20=EA=B0=80=EA=B2=A9=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=20=EB=8B=A4=EC=96=91=ED=95=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 겟주 가격 비교 추가 * feat: 메인 페이지 소개 문구 수정 * feat: 롯데마트 주류 가격 정보 추가 * feat: 롯데마트 비동기 처리 * feat: 이마트 추가 * fix: 타입 에러 수정 * feat: 겟주, 롯데마트, 이마트 관련 홍보글 * feat: 빅카메라 가격 정보 추가 * feat: 홈페이지 홍보글 업데이트 --- src/app/api/price-search/route.ts | 302 +++++++++++++++--- src/app/liquors/[id]/page.tsx | 4 + src/app/liquors/page.tsx | 11 +- src/components/PriceInfo/PriceInfo.tsx | 20 +- .../ServiceIntroductionComponentV2.tsx | 62 +++- 5 files changed, 346 insertions(+), 53 deletions(-) diff --git a/src/app/api/price-search/route.ts b/src/app/api/price-search/route.ts index 7a63f04..42056d9 100644 --- a/src/app/api/price-search/route.ts +++ b/src/app/api/price-search/route.ts @@ -51,14 +51,22 @@ function toEUCJPEncoding(query: string) { .join(""); } +// store 타입에 lottemart 추가 +type StoreType = + | "dailyshot" + | "mukawa" + | "cu" + | "traders" + | "getju" + | "emart" + | "lottemart" + | "biccamera"; + export async function GET(request: NextRequest) { const params = { query: request.nextUrl.searchParams.get("q") || "", - store: (request.nextUrl.searchParams.get("store") || "dailyshot") as - | "dailyshot" - | "mukawa" - | "cu" - | "traders", + store: (request.nextUrl.searchParams.get("store") || + "dailyshot") as StoreType, page: parseInt(request.nextUrl.searchParams.get("page") || "1"), pageSize: parseInt(request.nextUrl.searchParams.get("pageSize") || "12"), }; @@ -82,7 +90,7 @@ export async function GET(request: NextRequest) { return NextResponse.json(cachedResult.data); } - // 무카와인 경우 위스키 이름을 일본어로 번역 + // 카와인 경우 위스 이름을 일본어로 번역 if (params.store === "mukawa" && params.query) { const translatedQuery = translateWhiskyNameToJapenese(params.query); params.query = translatedQuery.japanese || params.query; @@ -91,8 +99,10 @@ export async function GET(request: NextRequest) { try { let searchResult = await performSearch(params); - if (params.store === "mukawa" && searchResult) { - // 상품명만 번역하도록 수정 + if ( + (params.store === "mukawa" || params.store === "biccamera") && + searchResult + ) { searchResult = await translateProductNames(searchResult); } @@ -105,7 +115,10 @@ export async function GET(request: NextRequest) { ...params, query: firstValidWord, }); - if (params.store === "mukawa" && searchResult) { + if ( + (params.store === "mukawa" || params.store === "biccamera") && + searchResult + ) { searchResult = await translateProductNames(searchResult); } } @@ -133,6 +146,7 @@ interface SearchResultItem { price: number; url?: string; description?: string; + soldOut?: boolean; original?: { name: string; }; @@ -188,17 +202,185 @@ interface CUItem { }; } +function getSearchUrl( + store: StoreType, + query: string, + page: number, + pageSize: number +) { + const urls = { + cu: () => `https://www.pocketcu.co.kr/api/search/rest/total/cubar`, + dailyshot: (q: string, p: number, ps: number) => + `https://api.dailyshot.co/items/search?q=${q}&page=${p}&page_size=${ps}`, + traders: (q: string, p: number, ps: number) => + `https://hbsinvtje8.execute-api.ap-northeast-2.amazonaws.com/ps/search/products?offset=${ + (p - 1) * ps + }&limit=${ps}&store_id=2006&biztp=1200&search_term=${q}&sort_type=recommend`, + mukawa: (q: string) => + `https://mukawa-spirit.com/?mode=srh&cid=&keyword=${toEUCJPEncoding(q)}`, + getju: (q: string) => + `https://www.getju.co.kr/shop/search_result.php?search_str=${encodeURIComponent(q)}`, + lottemart: () => + `https://company.lottemart.com/mobiledowa/product/search_product.asp`, + emart: (q: string, p: number, ps: number) => + `https://hbsinvtje8.execute-api.ap-northeast-2.amazonaws.com/ps/search/products?offset=${ + (p - 1) * ps + }&limit=${ps}&store_id=1090&biztp=1100&search_term=${encodeURIComponent(q)}&sort_type=sale`, + biccamera: (q: string) => + `https://www.biccamera.com/bc/category/?q=${encodeURIComponent(q.replace(/ /g, "+"))}`, + }; + + const urlGenerator = urls[store]; + return urlGenerator ? urlGenerator(query, page, pageSize) : null; +} + +// getju HTML 파싱 함수 추가 +function parseGetjuHtml(html: string) { + const dom = new JSDOM(html); + const document = dom.window.document; + const productItems = document.querySelectorAll("#prd_basic li"); + + const results = Array.from(productItems).map((item) => { + const element = item as Element; + const name = element.querySelector(".info .name a")?.textContent?.trim(); + const priceText = element + .querySelector(".price .sell strong") + ?.textContent?.trim(); + const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; + const url = + "https://www.getju.co.kr" + + element.querySelector(".info .name a")?.getAttribute("href"); + const type = element.querySelector(".info .type")?.textContent?.trim(); + const isSoldOut = element.querySelector(".soldout") !== null; + + return { + name, + price, + url, + type, + isSoldOut, + }; + }); + + return results.filter((item) => item.name && item.price); +} + +// 롯데마트 HTML 파싱 함수 추가 +function parseLottemartHtml(html: string) { + const dom = new JSDOM(html); + const document = dom.window.document; + const productItems = document.querySelectorAll(".list-result li"); + + const results = Array.from(productItems).map((item) => { + const element = item as Element; + const name = element.querySelector(".prod-name")?.textContent?.trim(); + const size = element.querySelector(".prod-count")?.textContent?.trim(); + + // layer_popup 내부의 info-list에서 가격 정보 추출 + const infoList = element.querySelector(".info-list"); + const rows = infoList?.querySelectorAll("tr"); + + let price = 0; + + rows?.forEach((row) => { + const label = row.querySelector("th")?.textContent?.trim(); + const value = row.querySelector("td")?.textContent?.trim(); + + if (label?.includes("가격")) { + price = value ? parseInt(value.replace(/[^0-9]/g, "")) : 0; + } + }); + + return { + name: name ? `${name} ${size || ""}` : "", + price, + url: undefined, + description: undefined, + }; + }); + + return results.filter((item) => item.name && item.price); +} + +// 롯데마트 지점 정보 정의 +const LOTTEMART_STORES = [ + { area: "서울", market: "301" }, + { area: "경기", market: "471" }, + { area: "서울", market: "334" }, + { area: "서울", market: "307" }, + { area: "경기", market: "415" }, +]; + +async function performLottemartSearch( + query: string +): Promise { + const url = + "https://company.lottemart.com/mobiledowa/product/search_product.asp"; + + // 모든 지점에 대한 검색 요청을 병렬로 실행 + const searchPromises = LOTTEMART_STORES.map(async ({ area, market }) => { + const formData = new URLSearchParams({ + p_area: area, + p_market: market, + p_schWord: query, + }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: formData, + }); + + if (!response.ok) { + console.error( + `롯데마트 ${area} ${market} 검색 실패: ${response.status}` + ); + return []; + } + + const html = await response.text(); + return parseLottemartHtml(html); + } catch (error) { + console.error(`롯데마트 ${area} ${market} 검색 오류:`, error); + return []; + } + }); + + // 모든 검색 결과 대기 + const allResults = await Promise.all(searchPromises); + + // 결과 합치기 및 중복 제거 (상품명 기준) + const uniqueResults = new Map(); + + allResults.flat().forEach((item) => { + const existingItem = uniqueResults.get(item.name); + if (!existingItem || item.price < existingItem.price) { + // 같은 상품이 있는 경우 더 낮은 가격으로 업데이트 + uniqueResults.set(item.name, item); + } + }); + + return Array.from(uniqueResults.values()); +} + async function performSearch({ store, query, page, pageSize, }: { - store: "dailyshot" | "mukawa" | "cu" | "traders"; + store: StoreType; query: string; page: number; pageSize: number; }): Promise { + if (store === "lottemart") { + return performLottemartSearch(query); + } + const url = getSearchUrl(store, query, page, pageSize); if (!url) { throw new Error("지원하지 않는 스토어입니다."); @@ -207,7 +389,14 @@ async function performSearch({ let response; let results; - if (store === "cu") { + if (store === "getju") { + response = await fetch(url); + if (!response.ok) { + throw new Error(`겟주 API 요청 실패: ${response.status}`); + } + const html = await response.text(); + results = parseGetjuHtml(html); + } else if (store === "cu") { // CU는 POST 방식 사용 response = await fetch(url, { method: "POST", @@ -216,7 +405,7 @@ async function performSearch({ }, body: JSON.stringify({ searchWord: query, - prevSearchWord: query.split(" ")[0], // 첫 단어를 prevSearchWord로 사용 + prevSearchWord: query.split(" ")[0], spellModifyUseYn: "Y", offset: (page - 1) * pageSize, limit: pageSize, @@ -229,16 +418,12 @@ async function performSearch({ } const data = (await response.json()) as CUApiResponse; - results = data.data.cubarResult.result.rows.map((item: CUItem) => { - const fields = item.fields; - return { - name: fields.item_nm, - price: parseInt(fields.hyun_maega, 10), - url: `https://www.pocketcu.co.kr${fields.link_url}`, - }; - }); + results = data.data.cubarResult.result.rows.map((item: CUItem) => ({ + name: item.fields.item_nm, + price: parseInt(item.fields.hyun_maega, 10), + url: `https://www.pocketcu.co.kr${item.fields.link_url}`, + })); } else if (store === "mukawa") { - // 무카와는 HTML 파싱을 사용 response = await fetch(url); if (!response.ok) { throw new Error(`무카와 API 요청 실패: ${response.status}`); @@ -247,8 +432,22 @@ async function performSearch({ const buffer = await response.arrayBuffer(); const decodedHtml = iconv.decode(Buffer.from(buffer), "euc-jp"); results = parseMukawaHtml(decodedHtml); + } else if (store === "biccamera") { + response = await fetch(url, { + headers: { + "User-Agent": "PostmanRuntime/7.42.0", + }, + }); + + if (!response.ok) { + throw new Error(`빅카메라 API 요청 실패: ${response.status}`); + } + + const buffer = await response.arrayBuffer(); + const decodedHtml = iconv.decode(Buffer.from(buffer), "shift-jis"); + results = parseBiccameraHtml(decodedHtml); } else { - // dailyshot, traders는 기존 방식 유지 + // dailyshot, traders, emart는 기존 방식 유지 response = await fetch(url); if (!response.ok) { throw new Error(`API 요청 실패: ${response.status}`); @@ -257,47 +456,30 @@ async function performSearch({ const data = await response.json(); if (store === "dailyshot") { results = data.results || []; - } else if (store === "traders") { - results = data.data || []; + } else if (store === "traders" || store === "emart") { + results = data.data.map((item: any) => ({ + name: item.sku_nm, + price: item.sell_price, + soldOut: item.stock_status === "NO_STOCK", + })); } else { results = []; } } // 통일된 형식으로 변환 - return results.map((item: SearchItem) => ({ + return results.map((item: SearchItem & { isSoldOut?: boolean }) => ({ name: item.name || item.sku_nm, price: item.price || item.sell_price, url: item.url || item.web_url || undefined, description: item.description, + soldOut: item.isSoldOut || false, original: { name: item.name, }, })); } -function getSearchUrl( - store: "dailyshot" | "mukawa" | "cu" | "traders", - query: string, - page: number, - pageSize: number -) { - const urls = { - cu: () => `https://www.pocketcu.co.kr/api/search/rest/total/cubar`, - dailyshot: (q: string, p: number, ps: number) => - `https://api.dailyshot.co/items/search?q=${q}&page=${p}&page_size=${ps}`, - traders: (q: string, p: number, ps: number) => - `https://hbsinvtje8.execute-api.ap-northeast-2.amazonaws.com/ps/search/products?offset=${ - (p - 1) * ps - }&limit=${ps}&store_id=2006&biztp=1200&search_term=${q}&sort_type=recommend`, - mukawa: (q: string) => - `https://mukawa-spirit.com/?mode=srh&cid=&keyword=${toEUCJPEncoding(q)}`, - }; - - const urlGenerator = urls[store]; - return urlGenerator ? urlGenerator(query, page, pageSize) : null; -} - function parseMukawaHtml(html: string) { const dom = new JSDOM(html); const document = dom.window.document; @@ -325,6 +507,32 @@ function parseMukawaHtml(html: string) { return results; } +// 빅카메라 HTML 파싱 함수 추가 +function parseBiccameraHtml(html: string) { + const dom = new JSDOM(html); + const document = dom.window.document; + const productItems = document.querySelectorAll(".prod_box"); + + const results = Array.from(productItems).map((item) => { + const element = item as Element; + const name = element.querySelector(".bcs_title a")?.textContent?.trim(); + const priceText = element + .querySelector(".bcs_price .val") + ?.textContent?.trim(); + const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; + // URL 중복 제거 + const urlPath = + element.querySelector(".bcs_title a")?.getAttribute("href") || ""; + const url = urlPath.startsWith("http") + ? urlPath + : `https://www.biccamera.com${urlPath}`; + + return { name, price, url }; + }); + + return results.filter((item) => item.name && item.price); +} + interface SearchItem { name?: string; sku_nm?: string; diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index cc8ca4d..86d782b 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -91,6 +91,10 @@ export default async function LiquorDetailPage({ + + + + {/* 주류 리뷰 */} diff --git a/src/app/liquors/page.tsx b/src/app/liquors/page.tsx index 2a207c0..e87db04 100644 --- a/src/app/liquors/page.tsx +++ b/src/app/liquors/page.tsx @@ -114,7 +114,16 @@ export default function LiquorsPage() { 🏪 현재 가격 비교 지원 중인 마켓 - {["트레이더스", "데일리샷", "무카와", "CU"].map((market) => ( + {[ + "트레이더스", + "데일리샷", + "무카와", + "CU", + "겟주", + "롯데마트", + "이마트", + "빅카메라", + ].map((market) => ( {market} ))} diff --git a/src/components/PriceInfo/PriceInfo.tsx b/src/components/PriceInfo/PriceInfo.tsx index 5ef58f1..89fd819 100644 --- a/src/components/PriceInfo/PriceInfo.tsx +++ b/src/components/PriceInfo/PriceInfo.tsx @@ -16,7 +16,15 @@ import axios from "axios"; interface PriceInfoProps { liquorName: string; - store?: "dailyshot" | "traders" | "mukawa" | "cu"; + store?: + | "dailyshot" + | "traders" + | "mukawa" + | "cu" + | "getju" + | "lottemart" + | "emart" + | "biccamera"; } const storeDisplayName = { @@ -24,6 +32,10 @@ const storeDisplayName = { traders: "트레이더스", mukawa: "무카와", cu: "CU", + getju: "겟주", + lottemart: "롯데마트", + emart: "이마트", + biccamera: "빅카메라", }; const PriceInfo: React.FC = ({ @@ -89,10 +101,10 @@ const PriceInfo: React.FC = ({ {storeDisplayName[store]} 가격 정보 - {store !== "traders" && ( + {store !== "traders" && store !== "lottemart" && store !== "emart" && ( 클릭시 상세 페이지로 이동합니다. - {store === "mukawa" && + {(store === "mukawa" || store === "biccamera") && " 일본 사이트에서 정보를 가져와 번역하기에 오래 걸립니다."} )} @@ -123,7 +135,7 @@ const PriceInfo: React.FC = ({ {item.name} - {store === "mukawa" + {store === "mukawa" || store === "biccamera" ? `${item.price.toLocaleString()} 엔` : `${item.price.toLocaleString()} 원`} diff --git a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx index b2f5ead..7cd00dd 100644 --- a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx +++ b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx @@ -55,6 +55,56 @@ export default function ServiceIntroductionComponent() { 국내외 주요 매장의 가격을 한 눈에 비교하세요 + {/* 업데이트 소식 */} + + + + 🎉 + + + + + New! 빅카메라, 겟주, 롯데마트, 이마트 가격 비교 추가 + + + {/* 사용 방법 */} @@ -199,7 +249,16 @@ export default function ServiceIntroductionComponent() { 지원 매장 - {["트레이더스", "데일리샷", "무카와", "CU"].map((store) => ( + {[ + "트레이더스", + "데일리샷", + "무카와", + "CU", + "겟주", + "롯데마트", + "이마트", + "빅카메라", + ].map((store) => ( + {/* CTA 버튼 */} Date: Fri, 1 Nov 2024 18:59:27 +0900 Subject: [PATCH 16/28] =?UTF-8?q?[BYOB-225]=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 피드 페이지 - 노트 목록 화면 구현 * feat: 구매 노트 작성 페이지 구현 * feat: 감상 노트 작성 페이지 구현 * feat: 구매 노트 상세 페이지 구현 * feat: 감상 노트 상세 페이지 구현 * feat: 잡다한 페이지 구현 * feat: 공유 버튼 추가 --- new-types.d.ts | 56 +- package-lock.json | 185 +++- package.json | 2 + src/api/tastingNotesApi.ts | 6 +- src/app/join/layout.tsx | 10 +- src/app/liquors/[id]/page.tsx | 51 +- src/app/mypage/[uuid]/liquor/[id]/page.tsx | 115 +++ src/app/mypage/edit/page.tsx | 5 +- src/app/notes-feed/page.tsx | 215 ++++ src/app/provider.tsx | 9 +- src/app/purchase-notes/[id]/page.tsx | 208 +++- src/app/purchase-notes/new/page.tsx | 486 +++++++++ src/app/report/page.tsx | 3 - src/app/tasting-notes/[id]/edit/page.tsx | 60 +- src/app/tasting-notes/[id]/legacyPage.tsx | 198 ++++ src/app/tasting-notes/[id]/page.tsx | 425 ++++++-- src/app/tasting-notes/new/StyledComponent.tsx | 2 +- src/app/tasting-notes/new/legacyPage.tsx | 381 +++++++ src/app/tasting-notes/new/page.tsx | 947 ++++++++++++------ src/components/Button/ShareButton.tsx | 40 +- .../FloatingButton/CreateNoteDial.tsx | 66 ++ src/components/ImageSlider/ImageSlider.tsx | 169 ++-- .../KeyValueInfoComponent.tsx | 2 + .../LayoutComponents/HeaderComponent.tsx | 16 +- .../LayoutComponents/NavigationComponent.tsx | 135 +-- .../LayoutComponents/PageTitleComponent.tsx | 38 + .../LiquorInfoCardComponent.tsx | 2 +- .../UserNoteGroupComponent.tsx | 87 +- .../MyPageContentsComponent.tsx | 21 +- src/components/NoteCard/NoteCardSkeleton.tsx | 66 ++ src/components/NoteCard/PurchaseNoteCard.tsx | 115 +++ src/components/NoteCard/TastingNoteCard.tsx | 176 ++++ .../NoteComponent/LiquorInfoComponent.tsx | 58 ++ src/components/NoteComponent/LiquorList.tsx | 56 ++ .../NoteComponent/LiquorSelectComponent.tsx | 11 + .../NoteComponent/LiquorSelectModal.tsx | 218 ++++ .../NoteComponent/UserInfoComponent.tsx | 39 + .../TastingNotesComponent/LiquorTitle.tsx | 31 +- .../UserUpdateForm/UserUpdateForm.tsx | 135 ++- src/styles/globals.css | 1 - src/utils/format.ts | 13 + 41 files changed, 4085 insertions(+), 774 deletions(-) create mode 100644 src/app/mypage/[uuid]/liquor/[id]/page.tsx create mode 100644 src/app/notes-feed/page.tsx create mode 100644 src/app/purchase-notes/new/page.tsx delete mode 100644 src/app/report/page.tsx create mode 100644 src/app/tasting-notes/[id]/legacyPage.tsx create mode 100644 src/app/tasting-notes/new/legacyPage.tsx create mode 100644 src/components/FloatingButton/CreateNoteDial.tsx create mode 100644 src/components/LayoutComponents/PageTitleComponent.tsx create mode 100644 src/components/NoteCard/NoteCardSkeleton.tsx create mode 100644 src/components/NoteCard/PurchaseNoteCard.tsx create mode 100644 src/components/NoteCard/TastingNoteCard.tsx create mode 100644 src/components/NoteComponent/LiquorInfoComponent.tsx create mode 100644 src/components/NoteComponent/LiquorList.tsx create mode 100644 src/components/NoteComponent/LiquorSelectComponent.tsx create mode 100644 src/components/NoteComponent/LiquorSelectModal.tsx create mode 100644 src/components/NoteComponent/UserInfoComponent.tsx diff --git a/new-types.d.ts b/new-types.d.ts index ccc96d8..da756f2 100644 --- a/new-types.d.ts +++ b/new-types.d.ts @@ -35,43 +35,52 @@ interface MeetingDetailInfo extends MeetingInfo { images: string[]; } +/** 주종 type */ +interface LiquorCategory { + id: number; + name: string; + image: string; +} + /** 주류 type: ES 버전 */ interface LiquorInfo { id: number; - en_name: string; ko_name: string; ko_name_origin: string; - price: string; + en_name: string; + type: string; + abv: string; + volume: string; + country: string; thumbnail_image_url: string; tasting_notes_Aroma: string; tasting_notes_Taste: string; tasting_notes_Finish: string; - type: string; - volume: string; - abv: string; - country: string; region: string; grape_variety: string; notes_count: number; + price: string; } /** 주류 type: DB 버전 */ -interface LiquorData { +interface Liquor { id: number; - thumbnailImageUrl: string | undefined; koName: string | null; enName: string | null; type: string | null; abv: string | null; volume: string | null; country: string | null; + thumbnailImageUrl: string | undefined; tastingNotesAroma: string | null; tastingNotesTaste: string | null; tastingNotesFinish: string | null; region: string | null; grapeVariety: string | null; - aiNotes: aiNotes | null; - user: User; + // aiNotes: aiNotes | null; + category: LiquorCategory | null; + liquorAromas: Aroma[]; + user: User | null; } /** 사용자 type */ @@ -81,6 +90,13 @@ interface User { profileThumbnailImage: string; } +/** 노트 페이지네이션 type */ +interface NoteList { + cursor: number; + eof: boolean; + notes: Note[]; +} + /** 노트 type */ interface Note { type: string; @@ -101,7 +117,7 @@ interface PurchaseNote { createdAt: string; updatedAt: string; user: User; - liquor: LiquorData; + liquor: Liquor; noteImages: NoteImage[]; purchaseAt: string; place: string; @@ -116,7 +132,7 @@ interface TastingNote { createdAt: string; updatedAt: string; user: User; - liquor: LiquorData; + liquor: Liquor; noteImages: NoteImage[]; tastingAt: string; method: string; @@ -133,7 +149,7 @@ interface TastingNote { /** 테이스팅노트 type: 주류 상세정보 페이지에서 보이는 유저 테이스팅 리뷰 목록 API 응답 객체 타입 */ interface TastingNoteList { id: number; - liquor: LiquorData; + liquor: Liquor; noseScore: number | null; palateScore: number | null; finishScore: number | null; @@ -159,7 +175,7 @@ interface Aroma { /** 사용자 작성 노트 type: 주류별 작성 노트 그룹 정보 */ interface UserNoteGroup { - liquor: LiquorData; + liquor: Liquor; notesCount: number; } @@ -177,18 +193,6 @@ interface aiNotes { // Props Types -/** LiquorTitle 컴포넌트 호출 시 사용되는 props type */ -interface LiquorTitleProps { - thumbnailImageUrl: string | undefined; - koName: string | null; - type: string | null; - abv: string | null; - volume: string | null; - country: string | null; - region: string | null; - grapeVariety: string | null; -} - /** KeyValueInfoComponent 호출 시 사용되는 props type */ interface KeyValueInfoProps { keyContent: string | null; diff --git a/package-lock.json b/package-lock.json index 73a4c8a..082975e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@mui/icons-material": "^5.16.7", "@mui/material": "^5.16.1", "@mui/material-nextjs": "^5.16.1", + "@mui/x-date-pickers": "^7.21.0", "@next/third-parties": "^14.2.5", "@sentry/nextjs": "^8.26.0", "@tanstack/react-query": "^5.51.3", @@ -23,6 +24,7 @@ "@types/lodash.debounce": "^4.0.9", "@vitalets/google-translate-api": "^9.2.0", "axios": "^1.3.4", + "dayjs": "^1.11.13", "embla-carousel-react": "^8.1.6", "http-proxy-middleware": "^3.0.0", "iconv-lite": "^0.6.3", @@ -366,9 +368,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", - "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz", + "integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1132,11 +1134,11 @@ } }, "node_modules/@mui/types": { - "version": "7.2.15", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.15.tgz", - "integrity": "sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==", + "version": "7.2.18", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.18.tgz", + "integrity": "sha512-uvK9dWeyCJl/3ocVnTOS6nlji/Knj8/tVqVX03UVTpdmTJYu/s4jtDd9Kvv0nRGE0CUSNW1UYAci7PYypjealg==", "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@types/react": { @@ -1176,6 +1178,158 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, + "node_modules/@mui/x-date-pickers": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.21.0.tgz", + "integrity": "sha512-WLpuTu3PvhYwd7IAJSuDWr1Zd8c5C8Cc7rpAYCaV5+tGBoEP0C2UKqClMR4F1wTiU2a7x3dzgQzkcgK72yyqDw==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.21.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.5.tgz", + "integrity": "sha512-vp2WfNDY+IbKUIGg+eqX1Ry4t/BilMjzp6p9xO1rfqpYjH1mj8coQxxDfKxcQLzBQkmBJjymjoGOak5VUYwXug==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/types": "^7.2.18", + "@types/prop-types": "^15.7.13", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/@mui/x-internals": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.21.0.tgz", + "integrity": "sha512-94YNyZ0BhK5Z+Tkr90RKf47IVCW8R/1MvdUhh6MCQg6sZa74jsX+x+gEZ4kzuCqOsuyTyxikeQ8vVuCIQiP7UQ==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.5.tgz", + "integrity": "sha512-vp2WfNDY+IbKUIGg+eqX1Ry4t/BilMjzp6p9xO1rfqpYjH1mj8coQxxDfKxcQLzBQkmBJjymjoGOak5VUYwXug==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/types": "^7.2.18", + "@types/prop-types": "^15.7.13", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, "node_modules/@next/env": { "version": "14.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz", @@ -2621,9 +2775,9 @@ } }, "node_modules/@types/prop-types": { - "version": "15.7.12", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" }, "node_modules/@types/react": { "version": "18.3.3", @@ -2653,9 +2807,9 @@ } }, "node_modules/@types/react-transition-group": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", - "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", "dependencies": { "@types/react": "*" } @@ -4007,6 +4161,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/package.json b/package.json index 06b57cd..f2e4f1b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@mui/icons-material": "^5.16.7", "@mui/material": "^5.16.1", "@mui/material-nextjs": "^5.16.1", + "@mui/x-date-pickers": "^7.21.0", "@next/third-parties": "^14.2.5", "@sentry/nextjs": "^8.26.0", "@tanstack/react-query": "^5.51.3", @@ -24,6 +25,7 @@ "@types/lodash.debounce": "^4.0.9", "@vitalets/google-translate-api": "^9.2.0", "axios": "^1.3.4", + "dayjs": "^1.11.13", "embla-carousel-react": "^8.1.6", "http-proxy-middleware": "^3.0.0", "iconv-lite": "^0.6.3", diff --git a/src/api/tastingNotesApi.ts b/src/api/tastingNotesApi.ts index f246823..72b19aa 100644 --- a/src/api/tastingNotesApi.ts +++ b/src/api/tastingNotesApi.ts @@ -34,14 +34,12 @@ export interface ReviewUpdateData { finishNotes: string | null; } -const LIQUOR_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquors/`; +const LIQUOR_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/liquors/`; const LIQUOR_NOTES_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/similar-tasting-notes`; const AI_LIQUOR_NOTES_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/ai-similar-tasting-notes`; const SAVE_REVIEW_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/tasting-notes`; -export const fetchLiquorData = async ( - liquorId: string -): Promise => { +export const fetchLiquorData = async (liquorId: string): Promise => { const response = await fetch(LIQUOR_URL + liquorId); if (!response.ok) throw new Error("Failed to fetch data"); diff --git a/src/app/join/layout.tsx b/src/app/join/layout.tsx index fb40291..0f6049e 100644 --- a/src/app/join/layout.tsx +++ b/src/app/join/layout.tsx @@ -1,14 +1,18 @@ import React from "react"; import { ContainerBox, HeaderBox } from "./StyledComponent"; -export default function JoinLayout({ children }: { children: React.ReactNode }) { +export default function JoinLayout({ + children, +}: { + children: React.ReactNode; +}) { return (

JUMO

- 당신이 찾던 완벽한 주류모임, 주모 + 내가 찾던 완벽한 주류모임, 주모
{children}
); -} \ No newline at end of file +} diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index 86d782b..77ce8bb 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -11,8 +11,11 @@ import { } from "@mui/material"; import Image from "next/image"; import FloatingButton from "@/components/FloatingButton/FloatingButton"; +import { Edit } from "@mui/icons-material"; +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; import PriceInfo from "@/components/PriceInfo/PriceInfo"; import { translateWhiskyNameToJapenese } from "@/utils/translateWhiskyNameToJapenese"; + /** 주류 상세정보 API 요청 함수 */ const getLiquorInfo = async (id: string) => { const res = await fetch( @@ -24,7 +27,7 @@ const getLiquorInfo = async (id: string) => { } throw new Error("Failed to fetch data"); } - const data: LiquorData = await res.json(); + const data: Liquor = await res.json(); return data; }; @@ -37,31 +40,35 @@ export default async function LiquorDetailPage({ }) { // 주류 데이터 const liquor = await getLiquorInfo(id); - const userProfileNickname = liquor?.user?.profileNickname || ""; + const userProfileNickname = liquor.user ? liquor.user.profileNickname : ""; return ( - - {/* 주류 이미지 */} - + + - 주류 이미지 - + {/* 주류 이미지 */} + + 주류 이미지 + {/* 주류 정보 */} @@ -115,4 +122,4 @@ export default async function LiquorDetailPage({ ); -} +} \ No newline at end of file diff --git a/src/app/mypage/[uuid]/liquor/[id]/page.tsx b/src/app/mypage/[uuid]/liquor/[id]/page.tsx new file mode 100644 index 0000000..d9a2ddb --- /dev/null +++ b/src/app/mypage/[uuid]/liquor/[id]/page.tsx @@ -0,0 +1,115 @@ +"use client"; + +import CreateNoteDial from "@/components/FloatingButton/CreateNoteDial"; +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; +import PurchaseNoteCard from "@/components/NoteCard/PurchaseNoteCard"; +import TastingNoteCard from "@/components/NoteCard/TastingNoteCard"; +import { LiquorInfoComponent } from "@/components/NoteComponent/LiquorInfoComponent"; +import { Backdrop, Stack, Typography } from "@mui/material"; +import axios from "axios"; +import { stat } from "fs"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useQuery } from "react-query"; + +const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; + +const getUserLiquorNoteList = async (userUuid: string, liquorId: string) => { + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes/user/${userUuid}`, + { params: { liquorId } } + ); + console.log(response.data); + return response.data; + } catch (err) { + console.error(err); + } +}; + +export default function MypageLiquorPage({ + params: { uuid, id }, +}: { + params: { uuid: string; id: string }; +}) { + // 노트 작성 다이얼 옵션 + const [dialOpen, setDialOpen] = useState(false); + // 다이얼 상태 변경 함수 + const handleDialOpen = () => { + setDialOpen(true); + }; + const handleDialClose = () => { + setDialOpen(false); + }; + + // 노트 목록 api query + const { data, status } = useQuery({ + queryKey: ["userLiquorNoteList", id], + queryFn: () => getUserLiquorNoteList(uuid, id), + }); + + const liquor = useMemo( + () => + data && + data[0][data[0].type === "PURCHASE" ? "purchaseNote" : "tastingNote"] + .liquor, + [data] + ); + + return ( + + + + {/* 페이지 내용 */} + {status == "success" ? ( + + {/* 주류 정보 */} + + + + {/* 노트 목록 */} + + {data.length ? ( + data.map((note: Note, idx: number) => { + if (note.type === "PURCHASE") + return ( + + ); + else + return ; + }) + ) : ( + + 작성된 노트가 없습니다. + + )} + + + ) : null} + + {/* 노트 작성 다이얼 */} + + + + ); +} diff --git a/src/app/mypage/edit/page.tsx b/src/app/mypage/edit/page.tsx index f63ca48..cb81080 100644 --- a/src/app/mypage/edit/page.tsx +++ b/src/app/mypage/edit/page.tsx @@ -1,10 +1,11 @@ +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; import UserUpdateForm from "@/components/UserUpdateForm/UserUpdateForm"; import { Stack, Typography } from "@mui/material"; export default function ProfileEditPage() { return ( - - 회원 정보 수정 + + ); diff --git a/src/app/notes-feed/page.tsx b/src/app/notes-feed/page.tsx new file mode 100644 index 0000000..f89993e --- /dev/null +++ b/src/app/notes-feed/page.tsx @@ -0,0 +1,215 @@ +"use client"; + +import useObserver from "@/hooks/useObserver"; +import { + List, + styled, + Box, + Tabs, + Tab, + Backdrop, + SpeedDial, + SpeedDialIcon, + SpeedDialAction, +} from "@mui/material"; + +import axios from "axios"; +import { SyntheticEvent, useRef, useState } from "react"; +import { useInfiniteQuery } from "react-query"; +import PurchaseNoteCard from "@/components/NoteCard/PurchaseNoteCard"; +import TastingNoteCard from "@/components/NoteCard/TastingNoteCard"; +import { ShoppingCartOutlined, WineBarOutlined } from "@mui/icons-material"; +import { useRouter } from "next/navigation"; +import NoteCardSkeleton from "@/components/NoteCard/NoteCardSkeleton"; +import CreateNoteDial from "@/components/FloatingButton/CreateNoteDial"; + +/** MeetingListResponse와 MeetingInfo 타입 정의 (필요한 경우 추가) */ +interface pageParamType { + id: number | null; +} +/** getMeetingList의 optionParams */ +interface OptionParams { + type: string; +} + +/** 노트 유형 옵션 배열 */ +const NOTES_TYPE_OPTIONS = [ + { option: "ALL", label: "모두" }, + { option: "PURCHASE", label: "구매했어요" }, + { option: "TASTING", label: "마셨어요" }, +]; + +/** 노트 목록 API 호출 함수 */ +const getNotesList = async ({ + pageParam = { id: null }, + options, +}: { + pageParam: pageParamType; + options: OptionParams; +}) => { + let response; + if (pageParam.id === -1) return { meetings: [] }; + + const params = + pageParam.id === null + ? { limit: 20 } + : { + cursor: pageParam.id, + limit: 20, + }; + + const queryString = Object.entries(params) + .map(([key, value]) => { + if (!value) return null; + return `${key}=${encodeURIComponent(value)}`; + }) + .filter((item) => item !== null) + .join("&"); + + console.log(queryString); + + switch (options.type) { + case "ALL": + response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes?${queryString}`, + { params } + ); + break; + case "PURCHASE": + response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes/purchase?${queryString}`, + { params } + ); + break; + case "TASTING": + response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes/tasting?${queryString}`, + { params } + ); + break; + } + + return response ? response.data : null; +}; + +export default function NotesFeedPage() { + const router = useRouter(); + + // 노트 유형 옵션 상태 관리 + const [typeOption, setTypeOption] = useState("ALL"); + // 노트 작성 다이얼 옵션 + const [dialOpen, setDialOpen] = useState(false); + + // useInfiniteQuery 설정 + const { data, fetchNextPage, isFetchingNextPage, status } = useInfiniteQuery({ + queryKey: ["meetingList", typeOption], + queryFn: ({ pageParam }) => + getNotesList({ + pageParam, + options: { + type: typeOption, + }, + }), + getNextPageParam: (lastPage: NoteList) => + lastPage.eof ? undefined : { id: lastPage.cursor }, + }); + + // IntersectionObserver API 설정: 페이지 마지막 요소 도달 시 다음 페이지 호출 + const target = useRef(null); + const onIntersect = ([entry]: IntersectionObserverEntry[]) => { + return entry.isIntersecting && fetchNextPage(); + }; + useObserver({ target, onIntersect }); + + /** 노트 유형 옵션 변경 함수 */ + const handleTypeOptionChange = ( + _event: SyntheticEvent, + newTypeOption: string + ) => setTypeOption(newTypeOption); + + /** 다이얼 상태 변경 함수 */ + const handleDialOpen = () => { + setDialOpen(true); + }; + const handleDialClose = () => { + setDialOpen(false); + }; + + // return + return ( + + {/* 노트 유형 옵션 선택 탭 */} + + {NOTES_TYPE_OPTIONS.map(({ option, label }) => ( + + ))} + + + {/* 노트 목록 */} + {(() => { + switch (status) { + case "error": + return "error"; + case "loading": + return ( + + {Array.from({ length: 30 }).map((_, i) => ( + + ))} + + ); + case "success": + return ( + + {data.pages.map((page: NoteList, i) => ( +
+ {page.notes.map((note: Note, idx) => { + if (note.type == "PURCHASE") + return ( + + ); + if (note.type == "TASTING") + return ( + + ); + return null; + })} +
+ ))} +
+ ); + default: + return null; + } + })()} +
+ + {/* 로딩 시 보여질 스켈레톤 */} + {isFetchingNextPage && ( +
+ {Array.from({ length: 30 }).map((_, i) => ( + + ))} +
+ )} + + {/* 노트 작성 다이얼 */} + + + + ); +} + +const ContainerBox = styled(Box)({ + display: "flex", + flexDirection: "column", + justifyContent: "center", +}); diff --git a/src/app/provider.tsx b/src/app/provider.tsx index 2f9197e..9b86835 100644 --- a/src/app/provider.tsx +++ b/src/app/provider.tsx @@ -3,6 +3,9 @@ import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter"; import { QueryClient, QueryClientProvider } from "react-query"; import { PropsWithChildren } from "react"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import "dayjs/locale/ko"; const MINUTE = 60 * 1000; @@ -18,7 +21,11 @@ const queryClient = new QueryClient({ export default function Provider({ children }: PropsWithChildren) { return ( - {children} + + + {children} + + ); } diff --git a/src/app/purchase-notes/[id]/page.tsx b/src/app/purchase-notes/[id]/page.tsx index 72fe0ce..1060e86 100644 --- a/src/app/purchase-notes/[id]/page.tsx +++ b/src/app/purchase-notes/[id]/page.tsx @@ -1,13 +1,26 @@ -import React from "react"; -import { Container, Typography } from "@mui/material"; +import React, { Fragment } from "react"; +import { + Box, + Container, + Divider, + Stack, + styled, + Typography, +} from "@mui/material"; import LiquorTitle from "@/components/TastingNotesComponent/LiquorTitle"; -import { formatDate } from "@/utils/format"; +import { formatDate, formatFullDate } from "@/utils/format"; import EditButton from "@/components/TastingNotesComponent/EditButton"; import { notFound } from "next/navigation"; import ShareButton from "@/components/Button/ShareButton"; import { Metadata } from "next"; import Link from "next/link"; import TastingNotesButton from "@/components/Button/tastingNotesButton"; +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; +import { LiquorList } from "@/components/NoteComponent/LiquorList"; +import ImageSlider from "@/components/ImageSlider/ImageSlider"; +import UserInfoComponent from "@/components/NoteComponent/UserInfoComponent"; +import { LiquorInfoComponent } from "@/components/NoteComponent/LiquorInfoComponent"; +import KeyValueInfoComponent from "@/components/KeyValueInfoComponent/KeyValueInfoComponent"; const NOTE_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/v2/notes/"; const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/purchase-notes/"; @@ -79,58 +92,161 @@ export default async function PurchaseNotePage({ const { content, user, createdAt, liquor } = note.purchaseNote; - const text = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; + const text = `${user.profileNickname}님이 ${formatFullDate(createdAt)}에 작성한 ${liquor.koName} 구매 노트`; const shareData = { - title: `${liquor.koName} 테이스팅 노트`, + title: `${liquor.koName} 구매 노트`, text, url: `${NOTE_URL}${id}`, }; return ( - - {/* 주류 정보 */} - - - - - {/* 버튼 그룹 */} - - + + - {user.profileNickname}님이 {formatDate(createdAt)}에 작성함 - - {/* {mood && } */} - - + {/* 사용자 정보 */} + + + + + + {/* 이미지 */} + {note.purchaseNote.noteImages && note.purchaseNote.noteImages.length ? ( + + image.fileUrl + )} + /> + + ) : null} + + + {/* 주류 정보 */} + + + 구매한 주류 + + + + + + + {/* 구매 정보 */} + + + 구매 정보 + + + + + + + + + + + + + + + + {/* 본문 */} + + + 구매 후기 + + + + {note.purchaseNote.content} + + + + + + {/* 버튼 그룹 */} + + + {/* + + {liquorData ? ( + + + + ) : ( + + )} + + + + {/* 구매 장소 */} + + 어디서 구매했나요? + + + + + 언제 구매했나요? + + + + + 얼마에 구매했나요? + + + + + 용량은 얼마인가요? + + + + {/* 본문 및 이미지 */} + + 후기를 자유롭게 작성해주세요. + + {/* 이미지 목록 */} + {noteImages.length ? ( + + {noteImages.map((image, idx) => { + return ( + + note image + handleImageRemove(idx)} + sx={{ + position: "absolute", + top: -8, + right: -8, + backgroundColor: "background.paper", + boxShadow: 1, + "&:hover": { + backgroundColor: "background.paper", + }, + }} + > + + + + ); + })} + + ) : null} + + {/* 이미지 버튼 */} + + + + {/* 본문 */} + + + + {/* 버튼 그룹 */} + + {/* 버튼 */} + + {saving ? : "저장하기"} + + + 취소하기 + + + + {/* 취소 시 dialog */} + setopenCancelDialog(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {"작성 취소하기"} + + + 현재 작성 중인 테이스팅 노트의 내용이 저장되지 않습니다. 정말로 + 취소하시겠습니까? + + + + + + + + + + + ); +} + +const SaveButton = styled(Button)({ + marginTop: "20px", + width: "100%", + padding: "10px", + backgroundColor: "#3f51b5", + color: "#ffffff", + display: "block", + marginLeft: "auto", + marginRight: "auto", + "&:hover": { + backgroundColor: "#303f9f", + }, +}); diff --git a/src/app/report/page.tsx b/src/app/report/page.tsx deleted file mode 100644 index 5095da9..0000000 --- a/src/app/report/page.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function ReportPage() { - return "준비중..."; -} diff --git a/src/app/tasting-notes/[id]/edit/page.tsx b/src/app/tasting-notes/[id]/edit/page.tsx index 3805b51..6191e77 100644 --- a/src/app/tasting-notes/[id]/edit/page.tsx +++ b/src/app/tasting-notes/[id]/edit/page.tsx @@ -88,7 +88,7 @@ const TastingNotesEditPageComponent = ({ const [totalScore, setTotalScore] = useState(""); const [overallNote, setOverallNote] = useState(""); const [mood, setMood] = useState(""); - const [liquorData, setLiquorData] = useState(null); + const [liquorData, setLiquorData] = useState(null); const loadReviewData = useCallback(async () => { try { @@ -128,35 +128,35 @@ const TastingNotesEditPageComponent = ({ new Set(tastingNotesTaste), new Set(tastingNotesFinish), ]); - tastingNotesAroma = [ - ...tastingNotesAroma, - ...(liquor.tastingNotesAroma - ? liquor.tastingNotesAroma.split(", ") - : []), - ...(liquor.aiNotes?.tastingNotesAroma - ? liquor.aiNotes.tastingNotesAroma.split(", ") - : []), - ]; - - tastingNotesTaste = [ - ...tastingNotesTaste, - ...(liquor.tastingNotesTaste - ? liquor.tastingNotesTaste.split(", ") - : []), - ...(liquor.aiNotes?.tastingNotesTaste - ? liquor.aiNotes.tastingNotesTaste.split(", ") - : []), - ]; - - tastingNotesFinish = [ - ...tastingNotesFinish, - ...(liquor.tastingNotesFinish - ? liquor.tastingNotesFinish.split(", ") - : []), - ...(liquor.aiNotes?.tastingNotesFinish - ? liquor.aiNotes.tastingNotesFinish.split(", ") - : []), - ]; + // tastingNotesAroma = [ + // ...tastingNotesAroma, + // ...(liquor.tastingNotesAroma + // ? liquor.tastingNotesAroma.split(", ") + // : []), + // ...(liquor.aiNotes?.tastingNotesAroma + // ? liquor.aiNotes.tastingNotesAroma.split(", ") + // : []), + // ]; + + // tastingNotesTaste = [ + // ...tastingNotesTaste, + // ...(liquor.tastingNotesTaste + // ? liquor.tastingNotesTaste.split(", ") + // : []), + // ...(liquor.aiNotes?.tastingNotesTaste + // ? liquor.aiNotes.tastingNotesTaste.split(", ") + // : []), + // ]; + + // tastingNotesFinish = [ + // ...tastingNotesFinish, + // ...(liquor.tastingNotesFinish + // ? liquor.tastingNotesFinish.split(", ") + // : []), + // ...(liquor.aiNotes?.tastingNotesFinish + // ? liquor.aiNotes.tastingNotesFinish.split(", ") + // : []), + // ]; setRelatedNotes([ new Set(tastingNotesAroma), new Set(tastingNotesTaste), diff --git a/src/app/tasting-notes/[id]/legacyPage.tsx b/src/app/tasting-notes/[id]/legacyPage.tsx new file mode 100644 index 0000000..4f1265e --- /dev/null +++ b/src/app/tasting-notes/[id]/legacyPage.tsx @@ -0,0 +1,198 @@ +import React from "react"; +import { Box, Container, Stack, Typography } from "@mui/material"; +import { GiNoseSide, GiTongue } from "react-icons/gi"; +import LiquorTitle from "@/components/TastingNotesComponent/LiquorTitle"; +import { HiOutlineLightBulb } from "react-icons/hi"; +import NotesSection from "@/components/TastingNotesComponent/NotesSection"; +import { MdOutlineStickyNote2 } from "react-icons/md"; +import { formatDate } from "@/utils/format"; +import EditButton from "@/components/TastingNotesComponent/EditButton"; +import { notFound } from "next/navigation"; +import ShareButton from "@/components/Button/ShareButton"; +import { Metadata } from "next"; +import Link from "next/link"; +import TastingNotesButton from "@/components/Button/tastingNotesButton"; +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; + +const NOTE_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/v2/notes/"; +const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/tasting-notes/"; +const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; + +/** 노트 상세 조회 API 호출 함수 */ +async function getNote(id: string): Promise { + const res = await fetch(NOTE_API_URL + id, { + next: { revalidate: 1, tags: ["review"] }, + }); + + if (!res.ok) { + if (res.status === 404) { + throw notFound(); + } + throw new Error("Failed to fetch data"); + } + return await res.json(); +} + +/** 메타데이터 생성 함수 */ +export async function generateMetadata({ + params, +}: { + params: { id: string }; +}): Promise { + const note = await getNote(params.id); + + const { user, createdAt, liquor } = note.tastingNote; + + const title = `${liquor.koName} 테이스팅 노트`; + const description = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; + const url = `${NOTE_URL}${params.id}`; + + return { + title, + description, + openGraph: { + title, + description, + url, + images: [ + { + url: + liquor.thumbnailImageUrl || + "https://github.com/user-attachments/assets/36420b2d-e392-4b20-bcda-80d7944d9658", + width: 1200, + height: 630, + alt: "liquor image", + }, + ], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ + liquor.thumbnailImageUrl || + "https://github.com/user-attachments/assets/36420b2d-e392-4b20-bcda-80d7944d9658", + ], + }, + }; +} + +export default async function TastingNotePage({ + params: { id }, +}: PostPageProps) { + const note = await getNote(id); + + const { score, nose, palate, finish, content, user, createdAt, liquor } = + note.tastingNote; + + const text = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; + + const shareData = { + title: `${liquor.koName} 테이스팅 노트`, + text, + url: `${NOTE_URL}${id}`, + }; + + return ( + + + + + + + + + + {user.profileNickname}님이 {formatDate(createdAt)}에 작성함 + + + + + } + score={score} + notes={[]} + formattedDescription={nose} + /> + + + + } + score={score} + notes={[]} + formattedDescription={palate} + /> + + + + } + score={score} + notes={[]} + formattedDescription={finish} + /> + + + + } + notes={[]} + score={score} + formattedDescription={content} + /> + {/* {mood && } */} + + + + ); +} diff --git a/src/app/tasting-notes/[id]/page.tsx b/src/app/tasting-notes/[id]/page.tsx index 9d1f604..7186fa4 100644 --- a/src/app/tasting-notes/[id]/page.tsx +++ b/src/app/tasting-notes/[id]/page.tsx @@ -1,20 +1,32 @@ -import React from "react"; -import { Box, Container, Typography } from "@mui/material"; -import { GiNoseSide, GiTongue } from "react-icons/gi"; +import React, { Fragment } from "react"; +import { + Box, + Button, + Chip, + Container, + Divider, + Rating, + Stack, + styled, + Typography, +} from "@mui/material"; import LiquorTitle from "@/components/TastingNotesComponent/LiquorTitle"; -import { HiOutlineLightBulb } from "react-icons/hi"; -import NotesSection from "@/components/TastingNotesComponent/NotesSection"; -import { MdOutlineStickyNote2 } from "react-icons/md"; -import { formatDate } from "@/utils/format"; +import { formatDate, formatFullDate } from "@/utils/format"; import EditButton from "@/components/TastingNotesComponent/EditButton"; import { notFound } from "next/navigation"; import ShareButton from "@/components/Button/ShareButton"; import { Metadata } from "next"; import Link from "next/link"; import TastingNotesButton from "@/components/Button/tastingNotesButton"; +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; +import { LiquorList } from "@/components/NoteComponent/LiquorList"; +import ImageSlider from "@/components/ImageSlider/ImageSlider"; +import UserInfoComponent from "@/components/NoteComponent/UserInfoComponent"; +import { LiquorInfoComponent } from "@/components/NoteComponent/LiquorInfoComponent"; +import KeyValueInfoComponent from "@/components/KeyValueInfoComponent/KeyValueInfoComponent"; const NOTE_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/v2/notes/"; -const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/tasting-notes/"; +const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/purchase-notes/"; const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; /** 노트 상세 조회 API 호출 함수 */ @@ -43,7 +55,7 @@ export async function generateMetadata({ const { user, createdAt, liquor } = note.tastingNote; const title = `${liquor.koName} 테이스팅 노트`; - const description = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; + const description = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 테이스팅 노트`; const url = `${NOTE_URL}${params.id}`; return { @@ -79,12 +91,11 @@ export async function generateMetadata({ export default async function TastingNotePage({ params: { id }, }: PostPageProps) { - const note = await getNote(id); + const note: Note = await getNote(id); - const { score, nose, palate, finish, content, user, createdAt, liquor } = - note.tastingNote; + const { content, user, createdAt, liquor } = note.tastingNote; - const text = `${user.profileNickname}님이 ${formatDate(createdAt)}에 작성한 ${liquor.koName} 리뷰`; + const text = `${user.profileNickname}님이 ${formatFullDate(createdAt)}에 작성한 ${liquor.koName} 테이스팅 노트`; const shareData = { title: `${liquor.koName} 테이스팅 노트`, @@ -93,102 +104,304 @@ export default async function TastingNotePage({ }; return ( - - - - - - - + + - {user.profileNickname}님이 {formatDate(createdAt)}에 작성함 - - - - - } - score={score} - notes={[]} - formattedDescription={nose} - /> - - - - } - score={score} - notes={[]} - formattedDescription={palate} - /> - - - - } - score={score} - notes={[]} - formattedDescription={finish} - /> - - + {/* 사용자 정보 */} + + + + + {/* 이미지 */} + {note.tastingNote.noteImages && note.tastingNote.noteImages.length ? ( + + image.fileUrl)} + /> - } - notes={[]} - score={score} - formattedDescription={content} - /> - {/* {mood && } */} - - + ) : null} + + {/* 주류 정보 */} + + + 감상한 주류 + + + + + + + {/* 테이스팅 정보 */} + + + 테이스팅 정보 + + + + + + + + + + + + + {/* 테이스팅 노트 */} + + + 테이스팅 노트 + + + {/* 점수 */} + + + 점수 + + + + + {/* 테이스팅 칩 */} + + {note.tastingNote.noteAromas && + note.tastingNote.noteAromas.length ? ( + note.tastingNote.noteAromas.map((aroma: Aroma, index) => ( + + )) + ) : ( + + 선택된 아로마가 없습니다. + + )} + + + {/* 본문 */} + {!note.tastingNote.isDetail && note.tastingNote.content ? ( + + + {note.tastingNote.content} + + + ) : null} + + + {/* 상세 테이스팅 노트 */} + {note.tastingNote.isDetail ? ( + + + 상세 테이스팅 노트 + + + {/* 노즈 */} + + + Nose + + + {note.tastingNote.nose ? note.tastingNote.nose : "-"} + + + + {/* 팔레트 */} + + + Palate + + + {note.tastingNote.palate ? note.tastingNote.palate : "-"} + + + + {/* 피니시 */} + + + Finish + + + {note.tastingNote.finish ? note.tastingNote.finish : "-"} + + + + {/* 총평 */} + + + Overall + + + {note.tastingNote.content ? note.tastingNote.content : "-"} + + + + ) : null} + + {/* 버튼 그룹 */} + + + {/* + + + + + ); +}; + +export default function TastingNotesNewPage() { + const router = useRouter(); + + useEffect(() => { + alert("주모 공사중입니다."); + router.back(); + }, []); + + return null; + // return ( + // Loading...
}> + // + // + // ); +} diff --git a/src/app/tasting-notes/new/page.tsx b/src/app/tasting-notes/new/page.tsx index 050791c..6c8dd53 100644 --- a/src/app/tasting-notes/new/page.tsx +++ b/src/app/tasting-notes/new/page.tsx @@ -1,89 +1,282 @@ "use client"; -// 임시 비활성화 - -import React, { Suspense, useCallback, useEffect, useState } from "react"; +import React, { + SyntheticEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { - Alert, - AlertProps, + Box, Button, + Checkbox, + Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, - Skeleton, - Snackbar, - Tab, - Tabs, + FormControlLabel, + IconButton, + Rating, + Stack, + styled, + TextField, + Typography, } from "@mui/material"; -import TabContentComponent from "@/components/TastingNotesComponent/TabContentComponent"; -import TotalScoreComponent from "@/components/TastingNotesComponent/TotalScoreComponent"; -import MoodSelectorComponent from "@/components/TastingNotesComponent/MoodSelectorComponent"; -import { - Container, - SaveButton, - StyledTab, - StyledTabs, - TabContent, -} from "@/app/tasting-notes/new/StyledComponent"; import LiquorTitle from "@/components/TastingNotesComponent/LiquorTitle"; -import { calculateAverageScore } from "@/utils/format"; -import { - fetchAiNotes, - fetchLiquorData, - fetchRelatedNotes, - ReviewSavingData, - saveReviewData, -} from "@/api/tastingNotesApi"; +import { fetchLiquorData } from "@/api/tastingNotesApi"; import { useRouter, useSearchParams } from "next/navigation"; -import TastingNotesSkeleton from "@/components/TastingNotesComponent/TastingNotesSkeleton"; import { CustomSnackbar, useCustomSnackbar, } from "@/components/Snackbar/CustomSnackbar"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import { Add, Close } from "@mui/icons-material"; +import LiquorSelectModal from "@/components/NoteComponent/LiquorSelectModal"; +import { Dayjs } from "dayjs"; +import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; +import Image from "next/image"; +import axios from "axios"; + +/** 구매 노트 생성 요청 type */ +interface TastingNoteReq { + liquorId: number; + noteImages: File[]; + noteAromas: number[]; + tastingAt: string; + place: string; + method: string; + score: number; + content: string; + isDetail: boolean; + nose: string; + palate: string; + finish: string; +} -const TastingNotesNewPageComponent = () => { - const { snackbar, showSnackbar, hideSnackbar } = useCustomSnackbar(); +/** 이미지 미리보기 */ +interface FileWithPreview { + file: File; + preview: string; + id: string; +} + +const saveTastingNote = async (data: TastingNoteReq) => { + const formData = new FormData(); + formData.append("liquorId", `${data.liquorId}`); + formData.append("tastingAt", data.tastingAt); + formData.append("place", data.place); + formData.append("method", data.method); + formData.append("score", `${data.score}`); + formData.append("content", data.content); + formData.append("isDetail", `${data.isDetail}`); + formData.append("nose", data.nose); + formData.append("palate", data.palate); + formData.append("finish", data.finish); + data.noteImages.forEach((image) => { + formData.append("noteImages", image); + }); + data.noteAromas.forEach((aroma) => { + formData.append("noteAromas", `${aroma}`); + }); + + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/notes/tasting`, + formData, + { + headers: { "Content-Type": "multipart/form-data" }, + withCredentials: true, + } + ); + return response.data.id; + } catch (err) { + console.error(err); + } +}; + +/** 주류 상세정보 API 요청 함수 */ +const getLiquor = async (liquorId: number) => { + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/liquors/${liquorId}` + ); + return response.data; + } catch (err) { + console.error(err); + } +}; +/** 아로마 추천 API 요청 함수 */ +const getRecommendAroma = async (aromaId: number, exclude: number[]) => { + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/aromas/similar`, + { params: { aromaId, exclude: exclude.join(",") } } + ); + console.log(response.data); + return response.data; + } catch (err) { + console.error(err); + } +}; + +/** 아로마 추가 API 요청 함수 */ +const createCustomAroma = async (aromaName: string) => { + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/aromas`, + { aromaName } + ); + console.log(response.data); + return response.data; + } catch (err) { + console.error(err); + } +}; + +export default function NewTastingNotePage() { + const { snackbar, showSnackbar, hideSnackbar } = useCustomSnackbar(); const params = useSearchParams(); const router = useRouter(); + const fileInputRef = useRef(null); + + const liquorIdParam = params.get("liquorId"); + + // states + const [tastingAt, setTastingAt] = useState(); + const [place, setPlace] = useState(""); + const [method, setMethod] = useState(""); + const [score, setScore] = useState(0); + const [content, setContent] = useState(""); + const [isDetail, setIsDetail] = useState(false); + const [nose, setNose] = useState(""); + const [palate, setPalate] = useState(""); + const [finish, setFinish] = useState(""); + const [noteImages, setNoteImages] = useState([]); + const [aromas, setAromas] = useState([]); + const [selectedAromas, setSelectedAromas] = useState([]); + const [customAroma, setCustomAroma] = useState(""); + const handleTastingAtChange = (value: Dayjs | null) => { + setTastingAt(value); + }; + const handlePlaceChange = (e: React.ChangeEvent) => { + setPlace(e.target.value); + }; + const handleMethodChange = (e: React.ChangeEvent) => { + setMethod(e.target.value); + }; + const handleScoreChange = ( + e: SyntheticEvent, + value: number | null + ) => { + if (value) setScore(value); + }; + const handleContentChange = (e: React.ChangeEvent) => { + setContent(e.target.value); + }; + const handleIsDetailChange = ( + e: React.ChangeEvent, + checked: boolean + ) => { + setIsDetail(checked); + }; + const handleNoseChange = (e: React.ChangeEvent) => { + setNose(e.target.value); + }; + const handlePalateChange = (e: React.ChangeEvent) => { + setPalate(e.target.value); + }; + const handleFinishChange = (e: React.ChangeEvent) => { + setFinish(e.target.value); + }; + const handleCustomAromaChange = (e: React.ChangeEvent) => { + setCustomAroma(e.target.value); + }; + const handleImageChange = (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + const newFiles = Array.from(e.target.files).map((file) => ({ + file, + preview: URL.createObjectURL(file), + id: `${file.name}-${Date.now()}`, + })); + setNoteImages((prev) => [...prev, ...newFiles]); + }; + const handleImageRemove = useCallback((indexToRemove: number) => { + setNoteImages((prev) => { + // 삭제되는 파일의 미리보기 URL 해제 + URL.revokeObjectURL(prev[indexToRemove].preview); + return prev.filter((_, index) => index !== indexToRemove); + }); + }, []); + const handleImageButtonClick = (): void => { + fileInputRef.current?.click(); + }; + + const [liquorData, setLiquorData] = useState(null); + const handleClearLiquorData = () => { + setLiquorData(null); + }; - const liquorId = params.get("liquorId"); + // 주류 선택 모달 관련 state 및 callback + const [openLiquorDialog, setOpenLiquorDialog] = useState(false); + const handleOpenLiquorDialog = () => { + setOpenLiquorDialog(true); + }; + const handleCloseLiquorDialog = (value: Liquor | null) => { + setOpenLiquorDialog(false); + setLiquorData(value); + }; + const [saving, setSaving] = useState(false); // 추가 const [openCancelDialog, setopenCancelDialog] = useState(false); const handleCancel = () => { setopenCancelDialog(true); }; const handleCancelRedirect = () => { setopenCancelDialog(false); - router.push(`/liquors/${liquorId}`); + router.back(); }; - const [selectedTab, setSelectedTab] = useState(0); - const [relatedNotes, setRelatedNotes] = useState[]>([ - new Set(), - new Set(), - new Set(), - ]); - const [selectedNotes, setSelectedNotes] = useState[]>([ - new Set(), - new Set(), - new Set(), - ]); + /** 테이스팅 칩 선택 처리 */ + const handleAromaSelect = async (newAroma: Aroma) => { + let deleted = false; + + const newSelectedAromas = selectedAromas.filter((aroma) => { + if (aroma.id === newAroma.id) { + deleted = true; + return false; + } + return true; + }); - const [hasAiNotes, setHasAiNotes] = useState(null); + if (!deleted) { + // selectedAromas에 newAroma 추가 + newSelectedAromas.push(newAroma); - const [scores, setScores] = useState<(number | null)[]>([null, null, null]); - const [memos, setMemos] = useState(["", "", ""]); + // 아로마 추천 API 호출 + const recommendedAromas = await getRecommendAroma( + newAroma.id, + aromas.map((aroma) => aroma.id) + ); + setAromas([...aromas, ...recommendedAromas]); + } - const [saving, setSaving] = useState(false); // 추가 + setSelectedAromas(newSelectedAromas); + }; + + const handleCustomAromaCreate = async () => { + if (customAroma) { + const newAroma = await createCustomAroma(customAroma); + const isExist = aromas.some((aroma: Aroma) => aroma.id == newAroma.id); + if (!isExist) setAromas((prev) => [...prev, newAroma]); + setCustomAroma(""); + } + }; - const [totalScore, setTotalScore] = useState(""); - const [overallNote, setOverallNote] = useState(""); - const [mood, setMood] = useState(""); - const [liquorData, setLiquorData] = useState(null); const getAuth = useCallback(async () => { try { const response = await fetch( @@ -96,7 +289,7 @@ const TastingNotesNewPageComponent = () => { if (response.status === 401) { alert( - "리뷰 작성은 로그인이 필요합니다.(카카오로 1초 로그인 하러 가기)" + "노트 작성은 로그인이 필요합니다. 카카오 1초 로그인을 진행해보세요!" ); const redirectUrl = window.location.href; router.push(`/login?redirectTo=${encodeURIComponent(redirectUrl)}`); @@ -110,154 +303,64 @@ const TastingNotesNewPageComponent = () => { const loadLiquorData = useCallback(async () => { try { - if (!liquorId) { - alert("리뷰 작성을 위해서는 주류 검색이 필요합니다."); - router.push("/liquors"); - return; - } - const data = await fetchLiquorData(liquorId); - setLiquorData(data); - - let tastingNotesAroma = new Set( - data.tastingNotesAroma?.split(", ") || [] - ); - let tastingNotesTaste = new Set( - data.tastingNotesTaste?.split(", ") || [] - ); - let tastingNotesFinish = new Set( - data.tastingNotesFinish?.split(", ") || [] - ); - - if (data.aiNotes) { - setHasAiNotes(true); - data.aiNotes.tastingNotesAroma - .split(", ") - .forEach((note: string) => tastingNotesAroma.add(note)); - data.aiNotes.tastingNotesTaste - .split(", ") - .forEach((note: string) => tastingNotesTaste.add(note)); - data.aiNotes.tastingNotesFinish - .split(", ") - .forEach((note: string) => tastingNotesFinish.add(note)); - } else { - getAiNotes(liquorId); + if (liquorIdParam) { + const data = await fetchLiquorData(liquorIdParam); + setLiquorData(data); } - - setRelatedNotes([ - tastingNotesAroma, - tastingNotesTaste, - tastingNotesFinish, - ]); - } catch (error) { - alert("주류 정보를 불러오는데 실패했습니다. 주류를 다시 선택해주세요."); - router.push("/liquors"); + } catch (err) { + console.error(err); } - }, [liquorId, router]); + }, [liquorIdParam, router]); useEffect(() => { (async () => { await getAuth(); await loadLiquorData(); })(); - }, [getAuth, loadLiquorData]); - - const getAiNotes = async (liquorId: string) => { - setHasAiNotes(false); - const aiData = await fetchAiNotes(liquorId); - setRelatedNotes((prev) => [ - new Set([...Array.from(prev[0]), ...aiData.noseNotes]), - new Set([...Array.from(prev[1]), ...aiData.palateNotes]), - new Set([...Array.from(prev[2]), ...aiData.finishNotes]), - ]); - setHasAiNotes(true); - }; + }, [getAuth]); useEffect(() => { - const averageScore = calculateAverageScore(scores[0], scores[1], scores[2]); - setTotalScore(averageScore ? averageScore.toString() : ""); - }, [scores]); - - const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { - setSelectedTab(newValue); - }; - const updateSetRelatedNotes = ( - newRelatedNotes: string[], - currentTab: number - ) => { - setRelatedNotes((prev) => { - const updatedRelatedNotes = [...prev]; - updatedRelatedNotes[currentTab] = new Set([ - ...Array.from(prev[currentTab]), - ...newRelatedNotes, - ]); - return updatedRelatedNotes; - }); - }; - - const handleNoteClick = async (note: string) => { - const currentTab = selectedTab; - - setSelectedNotes((prev) => { - const newSelectedNotes = [...prev]; - const noteSet = newSelectedNotes[currentTab]; - - if (noteSet.has(note)) { - noteSet.delete(note); - } else { - noteSet.add(note); - } - - return newSelectedNotes; - }); + (async () => { + if (liquorData) { + const result: Liquor = await getLiquor(liquorData.id); + setAromas(result.liquorAromas); + } else setAromas([]); + })(); + }, [liquorData]); - if (selectedNotes[currentTab].has(note)) { - const exclude = Array.from(selectedNotes[currentTab]).join(","); - const newRelatedNotes = await fetchRelatedNotes(note, exclude); + // 컴포넌트 언마운트 시 모든 미리보기 URL 정리 + useEffect(() => { + return () => { + noteImages.forEach((image) => URL.revokeObjectURL(image.preview)); + }; + }, [noteImages]); - updateSetRelatedNotes(newRelatedNotes, currentTab); + const handleSave = async () => { + if (!liquorData || !tastingAt) { + alert("주류를 선택해주세요."); + return; } - }; - - const onAddNote = (note: string) => { - const currentTab = selectedTab; - setRelatedNotes((prev) => { - const newRelatedNotes = [...prev]; - newRelatedNotes[currentTab].add(note); - return newRelatedNotes; - }); - }; - - if (!liquorId) { - return null; // 또는 로딩 인디케이터나 에러 메시지를 표시할 수 있습니다. - } - const handleSave = async () => { - const ReviewSavingData: ReviewSavingData = { - liquorId, - noseScore: scores[0], - palateScore: scores[1], - finishScore: scores[2], - noseMemo: memos[0] || null, - palateMemo: memos[1] || null, - finishMemo: memos[2] || null, - overallNote: overallNote || null, - mood: mood || null, - noseNotes: selectedNotes[0].size - ? Array.from(selectedNotes[0]).join(", ") - : null, - palateNotes: selectedNotes[1].size - ? Array.from(selectedNotes[1]).join(", ") - : null, - finishNotes: selectedNotes[2].size - ? Array.from(selectedNotes[2]).join(", ") - : null, + const noteSavingData: TastingNoteReq = { + liquorId: liquorData.id, + noteImages: noteImages.map((image) => image.file), + noteAromas: selectedAromas.map((aroma) => aroma.id), + tastingAt: tastingAt?.format("YYYY-MM-DD"), + place, + method, + score, + content, + isDetail, + nose, + palate, + finish, }; setSaving(true); try { - const tastingNotesId = await saveReviewData(ReviewSavingData); - router.push(`/tasting-notes/${tastingNotesId}`); + const noteId = await saveTastingNote(noteSavingData); + router.push(`/tasting-notes/${noteId}`); showSnackbar("저장에 성공했습니다.", "success"); } catch (error: unknown) { const errorMessage = @@ -270,112 +373,384 @@ const TastingNotesNewPageComponent = () => { } }; - if (!liquorData) { - return ; - } - return ( - - - - - - - - - - - - setScores((prev: (number | null)[]) => { - const newScores: (number | null)[] = [...prev]; - newScores[selectedTab] = value; - return newScores; - }) - } - memo={memos[selectedTab]} - setMemo={(value: string) => - setMemos((prev) => { - const newMemos = [...prev]; - newMemos[selectedTab] = value; - return newMemos; - }) - } - onAddNote={onAddNote} - /> - - - - - - {saving ? : "저장하기"} - - - 취소하기 - - - setopenCancelDialog(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - - {"작성 중인 테이스팅 노트를 취소하시겠습니까?"} - - - - 현재 작성 중인 테이스팅 노트의 내용이 저장되지 않습니다. 정말로 - 취소하시겠습니까? - - - - - +
+ {liquorData ? ( + + + + ) : ( + + )} + +
+ + {/* 감상 장소 */} + + 어디서 마셨나요? + + + + {/* 감상 일자 */} + + 언제 마셨나요? + + + + {/* 감상 방법 */} + + 어떤 방법으로 마셨나요? + + + + {/* 아로마 선택 */} + + + 어떤 향이 느껴졌나요? + + 키워드를 선택하면, 인공지능이 계속해서 추천해줍니다. + + + + {/* 키워드 선택 */} + + {aromas && aromas.length ? ( + aromas.map((aroma: Aroma, index) => ( + handleAromaSelect(aroma)} + selected={selectedAromas.some( + (selectedAroma) => selectedAroma.id === aroma.id + )} + /> + )) + ) : ( + + 추천 키워드가 없습니다. + + )} + + + {/* 키워드 추가 */} + + + + + + + {/* 점수 */} + + 점수를 매겨주세요! + + + + {/* 본문 및 이미지 */} + + 테이스팅 노트를 자유롭게 작성해주세요. + + {/* 이미지 목록 */} + {noteImages.length ? ( + + {noteImages.map((image, idx) => { + return ( + + note image + handleImageRemove(idx)} + sx={{ + position: "absolute", + top: -8, + right: -8, + backgroundColor: "background.paper", + boxShadow: 1, + "&:hover": { + backgroundColor: "background.paper", + }, + }} + > + + + + ); + })} + + ) : null} + + {/* 이미지 버튼 */} + - - - + + + {/* 본문 */} + {isDetail ? ( + + + + + + ) : null} + + {/* 본문 */} + + + {/* 상세작성 버튼 */} + + } + label="상세작성" + /> + + + {/* 버튼 그룹 */} + + {/* 버튼 */} + + {saving ? : "저장하기"} + + + 취소하기 + + + + {/* 취소 시 dialog */} + setopenCancelDialog(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + {"작성 취소하기"} + + + 현재 작성 중인 테이스팅 노트의 내용이 저장되지 않습니다. 정말로 + 취소하시겠습니까? + + + + + + + + + + ); -}; - -export default function TastingNotesNewPage() { - const router = useRouter(); - - useEffect(() => { - alert("주모 공사중입니다."); - router.back(); - }, []); - - return null; - // return ( - // Loading...}> - // - // - // ); } + +const SaveButton = styled(Button)({ + marginTop: "20px", + width: "100%", + padding: "10px", + backgroundColor: "#3f51b5", + color: "#ffffff", + display: "block", + marginLeft: "auto", + marginRight: "auto", + "&:hover": { + backgroundColor: "#303f9f", + }, +}); + +const StyledChip = styled(Chip)<{ selected: boolean }>(({ selected }) => ({ + backgroundColor: selected ? "#ffeb3b" : "#e0e0e0", + // 호버링 애니메이션 제거 + "&:hover": { + backgroundColor: selected ? "#ffeb3b" : "#e0e0e0", + }, +})); diff --git a/src/components/Button/ShareButton.tsx b/src/components/Button/ShareButton.tsx index 2dcc9a7..bd915a0 100644 --- a/src/components/Button/ShareButton.tsx +++ b/src/components/Button/ShareButton.tsx @@ -11,26 +11,26 @@ interface ShareButtonProps { url: string; } -const FloatingShareButton = styled(Button)` - position: fixed; - bottom: 90px; - right: 20px; - z-index: 9999; - width: 100px; - height: 40px; - min-width: 0; - padding: 0; - background-color: rgba(186, 104, 200, 0.6); - color: white; - border-radius: 20px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); - display: flex; - align-items: center; - justify-content: center; - &:hover { - background-color: rgba(171, 71, 188, 0.7); - } -`; +const FloatingShareButton = styled(Button)({ + // position: "fixed", + // bottom: "90px", + // right: "20px", + // zIndex: "9999", + width: "100px", + height: "40px", + minWidth: "0", + padding: "0", + // backgroundColor: "whitesmoke", + color: "black", + borderRadius: "20px", + boxShadow: "0 2px 2px rgba(0, 0, 0, 0.3)", + display: "flex", + alignItems: "center", + justifyContent: "center", + // "&:hover": { + // backgroundColor: "rgba(171, 71, 188, 0.7)" + // } +}); const ShareButton: React.FC = ({ title, text, url }) => { const [copied, setCopied] = useState(false); diff --git a/src/components/FloatingButton/CreateNoteDial.tsx b/src/components/FloatingButton/CreateNoteDial.tsx new file mode 100644 index 0000000..bbc5d02 --- /dev/null +++ b/src/components/FloatingButton/CreateNoteDial.tsx @@ -0,0 +1,66 @@ +import { ShoppingCartOutlined, WineBarOutlined } from "@mui/icons-material"; +import { SpeedDial, SpeedDialAction, SpeedDialIcon } from "@mui/material"; +import { useRouter } from "next/navigation"; + +interface CreateNoteDialProps { + dialOpen: boolean; + handleDialOpen: () => void; + handleDialClose: () => void; + liquorId?: number; + offset: boolean; +} + +export default function CreateNoteDial(props: CreateNoteDialProps) { + const router = useRouter(); + const { dialOpen, handleDialOpen, handleDialClose, liquorId, offset } = props; + + /** 노트 작성 다이얼 옵션 */ + const dialAction = [ + { + icon: , + name: "마셨어요", + onClick: () => { + if (liquorId) router.push(`/tasting-notes/new?liquorId=${liquorId}`); + else router.push(`/tasting-notes/new`); + }, + }, + { + icon: , + name: "구매했어요", + onClick: () => { + if (liquorId) router.push(`/purchase-notes/new?liquorId=${liquorId}`); + else router.push(`/purchase-notes/new`); + }, + }, + ]; + + return ( + } + onClose={handleDialClose} + onOpen={handleDialOpen} + open={dialOpen} + ariaLabel="dial" + > + {dialAction.map((action) => ( + + ))} + + ); +} diff --git a/src/components/ImageSlider/ImageSlider.tsx b/src/components/ImageSlider/ImageSlider.tsx index d9ebdb9..24f151d 100644 --- a/src/components/ImageSlider/ImageSlider.tsx +++ b/src/components/ImageSlider/ImageSlider.tsx @@ -1,80 +1,121 @@ "use client"; -import React, { useCallback, useEffect, useState } from 'react'; -import Image from 'next/image'; -import { Box, Typography, Button } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; -import useEmblaCarousel from 'embla-carousel-react'; -import {ArrowBackIos, ArrowForwardIos} from '@mui/icons-material'; +import React, { useCallback, useEffect, useState } from "react"; +import Image from "next/image"; +import { Box, Typography, Button } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import useEmblaCarousel from "embla-carousel-react"; +import { ArrowBackIos, ArrowForwardIos } from "@mui/icons-material"; interface ImageSliderProps { - images: string[]; + images: string[]; } const ImageSlider: React.FC = ({ images }) => { - const theme = useTheme(); - const [emblaRef, embla] = useEmblaCarousel({ loop: false }); - const [selectedIndex, setSelectedIndex] = useState(0); - const [showArrows, setShowArrows] = useState(false); + const theme = useTheme(); + const [emblaRef, embla] = useEmblaCarousel({ loop: false }); + const [selectedIndex, setSelectedIndex] = useState(0); + const [showArrows, setShowArrows] = useState(false); - const handleSelect = useCallback(() => { - if (!embla) return; - setSelectedIndex(embla.selectedScrollSnap()); - }, [embla]); + const handleSelect = useCallback(() => { + if (!embla) return; + setSelectedIndex(embla.selectedScrollSnap()); + }, [embla]); - useEffect(() => { - if (!embla) return; - embla.on('select', handleSelect); - }, [embla, handleSelect]); + useEffect(() => { + if (!embla) return; + embla.on("select", handleSelect); + }, [embla, handleSelect]); - const handleNext = () => { - if (embla) embla.scrollNext(); - }; + const handleNext = () => { + if (embla) embla.scrollNext(); + }; - const handleBack = () => { - if (embla) embla.scrollPrev(); - }; + const handleBack = () => { + if (embla) embla.scrollPrev(); + }; - return ( + return ( + setShowArrows(true)} + onMouseLeave={() => setShowArrows(false)} + > + + + {images.map((src, index) => ( + + {`image + + ))} + + + + {images.map((_, index) => ( + + ))} + + {showArrows && ( setShowArrows(true)} - onMouseLeave={() => setShowArrows(false)} + sx={{ + position: "absolute", + top: "50%", + width: "100%", + display: "flex", + justifyContent: "space-between", + transform: "translateY(-50%)", + }} > - - - {images.map((src, index) => ( - - {`image - - ))} - - - - {images.map((_, index) => ( - - ))} - - {showArrows && ( - - - - - )} + + - ); + )} + + ); }; -export default ImageSlider; \ No newline at end of file +export default ImageSlider; diff --git a/src/components/KeyValueInfoComponent/KeyValueInfoComponent.tsx b/src/components/KeyValueInfoComponent/KeyValueInfoComponent.tsx index c7670b4..424c17d 100644 --- a/src/components/KeyValueInfoComponent/KeyValueInfoComponent.tsx +++ b/src/components/KeyValueInfoComponent/KeyValueInfoComponent.tsx @@ -9,6 +9,7 @@ export default function KeyValueInfoComponent({ - typeof path === "string" ? pathName === path : path.test(pathName), - ) - ) { + // 현재 경로가 숨길 경로 중 하나로 시작하면 네비게이션 바를 렌더링하지 않음 + if (hideNavPaths.some((path) => pathName.startsWith(path))) { return null; } diff --git a/src/components/LayoutComponents/NavigationComponent.tsx b/src/components/LayoutComponents/NavigationComponent.tsx index ab2787e..ac662ca 100644 --- a/src/components/LayoutComponents/NavigationComponent.tsx +++ b/src/components/LayoutComponents/NavigationComponent.tsx @@ -6,6 +6,7 @@ import { LocalBar, Warehouse, Search, + Forum, } from "@mui/icons-material"; import { Box, Stack, Typography } from "@mui/material"; import Link from "next/link"; @@ -17,16 +18,17 @@ export default function NavigationComponent() { // 네비게이션 바를 숨길 경로 패턴 const hideNavPaths = [ + "/purchase-notes/new", "/tasting-notes/new", - /^\/tasting-notes\/\d+\/edit$/, // 동적 ID를 포함한 edit 경로 + "/purchase-notes/", // 구매 노트 관련 경로들의 공통 부분 + "/tasting-notes/", // 감상 노트 관련 경로들의 공통 부분 + "/liquors/", + "/mypage/", + "/join", ]; - // 현재 경로가 숨길 경로 중 하나와 일치하면 네비게이션 바를 렌더링하지 않음 - if ( - hideNavPaths.some((path) => - typeof path === "string" ? pathName === path : path.test(pathName) - ) - ) { + // 현재 경로가 숨길 경로 중 하나로 시작하면 네비게이션 바를 렌더링하지 않음 + if (hideNavPaths.some((path) => pathName.startsWith(path))) { return null; } @@ -46,6 +48,20 @@ export default function NavigationComponent() { ); }, }, + { + title: "주모 피드", + link: "/notes-feed", + icon: function () { + return ( + + ); + }, + }, { title: "모임", link: "/meetings", @@ -91,67 +107,70 @@ export default function NavigationComponent() { ]; return ( - + - {NAV_OPTIONS.map((option, index) => ( - - + {NAV_OPTIONS.map((option, index) => ( + - {option.icon()} - - {option.title} - - - - ))} + + {option.icon()} + + {option.title} + + + + ))} + - +
+ ); } diff --git a/src/components/LayoutComponents/PageTitleComponent.tsx b/src/components/LayoutComponents/PageTitleComponent.tsx new file mode 100644 index 0000000..3769aa2 --- /dev/null +++ b/src/components/LayoutComponents/PageTitleComponent.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { ArrowBack } from "@mui/icons-material"; +import { Box, Button, Divider, Stack, Typography } from "@mui/material"; +import { useRouter } from "next/navigation"; + +export default function PageTitleComponent({ title }: { title: string }) { + const router = useRouter(); + const handleButtonClick = () => { + router.back(); + }; + + return ( + + + + + + + {title} + + + + {/* */} + + ); +} diff --git a/src/components/LiquorInfoCardComponent/LiquorInfoCardComponent.tsx b/src/components/LiquorInfoCardComponent/LiquorInfoCardComponent.tsx index e3c8e95..e9554f3 100644 --- a/src/components/LiquorInfoCardComponent/LiquorInfoCardComponent.tsx +++ b/src/components/LiquorInfoCardComponent/LiquorInfoCardComponent.tsx @@ -4,7 +4,7 @@ import KeyValueInfoComponent from "../KeyValueInfoComponent/KeyValueInfoComponen export default function LiquorInfoCardComponent({ liquor, }: { - liquor: LiquorData; + liquor: Liquor; }) { /** 주류 기본 정보 */ const LIQUOR_INFO_FIELDS = [ diff --git a/src/components/LiquorUserTastingComponent/UserNoteGroupComponent.tsx b/src/components/LiquorUserTastingComponent/UserNoteGroupComponent.tsx index ca0884b..18f37db 100644 --- a/src/components/LiquorUserTastingComponent/UserNoteGroupComponent.tsx +++ b/src/components/LiquorUserTastingComponent/UserNoteGroupComponent.tsx @@ -8,11 +8,15 @@ import { Stack, Typography, } from "@mui/material"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; export default function UserNoteGroupComponent({ data, + userUuid, }: { data: UserNoteGroup[]; + userUuid: string; }) { return ( @@ -24,7 +28,7 @@ export default function UserNoteGroupComponent({ textAlign: "center", }} > - 내가 감상한 주류 + 나의 주류 {/* 감상 주류 목록 */} @@ -35,53 +39,60 @@ export default function UserNoteGroupComponent({ > {data.map((group: UserNoteGroup, idx) => ( - - {/* 주류 사진 */} - + + + {/* 주류 사진 */} + - {/* 주류 정보 */} - - - {group.liquor.koName} - - {group.liquor.enName && ( + {/* 주류 정보 */} + + {group.liquor.koName} + + {group.liquor.enName && ( + + {group.liquor.enName} + + )} + - {group.liquor.enName} + {group.notesCount}개의 노트 - )} - - {group.notesCount}개의 노트 - - - + + + ))} diff --git a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx index d4b9417..810cf1d 100644 --- a/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx +++ b/src/components/MyPageContentsComponent/MyPageContentsComponent.tsx @@ -160,7 +160,7 @@ export default function MyPageContentsComponent({ user }: { user: User }) { borderColor: "divider", }} > - - + */}
{/* 사용자 활동 정보 */} {status == "success" && (data.list.length && data.group.length ? ( - <> - {noteTabOption === "group" && ( - - )} - {noteTabOption === "list" && ( - - )} - + ) : ( + // <> + // {noteTabOption === "group" && ( + // + // )} + // {noteTabOption === "list" && ( + // + // )} + // 아직 작성된 테이스팅 노트가 없습니다. diff --git a/src/components/NoteCard/NoteCardSkeleton.tsx b/src/components/NoteCard/NoteCardSkeleton.tsx new file mode 100644 index 0000000..5eacebb --- /dev/null +++ b/src/components/NoteCard/NoteCardSkeleton.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { + Avatar, + Box, + ListItemAvatar, + ListItemText, + Skeleton, + Stack, + styled, +} from "@mui/material"; +import { useRef, useState } from "react"; +import useObserver from "@/hooks/useObserver"; +import Link from "next/link"; + +export default function NoteCardSkeleton() { + // const [visible, setVisible] = useState(false); + + // // IntersectionObserver API 설정: 뷰포트 안에 요소가 들어올 때만 DOM에 마운트 + // const target = useRef(null); + // const onIntersect = ([entry]: IntersectionObserverEntry[]) => + // setVisible(entry.isIntersecting); + // useObserver({ target, onIntersect, threshold: 0.1 }); + + return ( + + + + + + + + + + + + + + ); +} + +const StyledBox = styled(Box)({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "5px", + margin: "15px 0", + padding: "15px 15px", + minHeight: "150px", + borderRadius: "5px", + color: "inherit", + textDecoration: "none", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + backgroundColor: "#ffffff", + boxShadow: "0 6px 12px rgba(0,0,0,0.1)", +}); diff --git a/src/components/NoteCard/PurchaseNoteCard.tsx b/src/components/NoteCard/PurchaseNoteCard.tsx new file mode 100644 index 0000000..58adb20 --- /dev/null +++ b/src/components/NoteCard/PurchaseNoteCard.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { Box, Link, Stack, styled, Typography } from "@mui/material"; +import { useRef, useState } from "react"; +import useObserver from "@/hooks/useObserver"; +import { formatDateTime, formatFullDateTime } from "@/utils/format"; +import Image from "next/image"; + +export default function PurchaseNoteCard({ note }: { note: PurchaseNote }) { + const [visible, setVisible] = useState(true); + // const [visible, setVisible] = useState(false); + + // // IntersectionObserver API 설정: 뷰포트 안에 요소가 들어올 때만 DOM에 마운트 + // const target = useRef(null); + // const onIntersect = ([entry]: IntersectionObserverEntry[]) => + // setVisible(entry.isIntersecting); + // useObserver({ target, onIntersect, threshold: 0.1 }); + + return ( + // + + {visible && ( + + {/* 제목 및 작성일시 */} + + + {note.user.profileNickname}님이 {note.liquor.koName}을 구매했어요. + + + {formatFullDateTime(note.createdAt)} + + + + {/* 이미지 */} + {note.noteImages.length ? ( + note image + ) : null} + + {/* 구매 정보 */} + + + 구매처 {note.place} + + + 가격 {note.price} + + + + {/* 본문 내용 */} + + + {note.content} + + + + )} + + ); +} + +const LinkButton = styled(Link)({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "2px", + margin: "15px 0", + padding: "15px 15px", + // maxWidth: "800px", + minHeight: "150px", + // border: "0.5px solid", + // borderColor: "gray", + borderRadius: "5px", + color: "inherit", + textDecoration: "none", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + backgroundColor: "#ffffff", + boxShadow: "0 6px 12px rgba(0,0,0,0.1)", +}); + +const ContentTypography = styled(Typography)({ + maxWidth: "100%", + display: "-webkit-box", + // WebkitLineClamp: 2, // 표시할 줄 수 + WebkitBoxOrient: "vertical", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "normal", +}); diff --git a/src/components/NoteCard/TastingNoteCard.tsx b/src/components/NoteCard/TastingNoteCard.tsx new file mode 100644 index 0000000..74e8965 --- /dev/null +++ b/src/components/NoteCard/TastingNoteCard.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { + Box, + Chip, + Divider, + Link, + Rating, + Stack, + styled, + Typography, +} from "@mui/material"; +import { useRef, useState } from "react"; +import useObserver from "@/hooks/useObserver"; +import { formatDateTime, formatFullDateTime } from "@/utils/format"; +import Image from "next/image"; + +export default function TastingNoteCard({ note }: { note: TastingNote }) { + const [visible, setVisible] = useState(true); + // const [visible, setVisible] = useState(false); + + // // IntersectionObserver API 설정: 뷰포트 안에 요소가 들어올 때만 DOM에 마운트 + // const target = useRef(null); + // const onIntersect = ([entry]: IntersectionObserverEntry[]) => + // setVisible(entry.isIntersecting); + // useObserver({ target, onIntersect, threshold: 0.1 }); + + return ( + // + + {visible && ( + + {/* 제목 및 작성일시 */} + + + {note.user.profileNickname}님이 {note.liquor.koName}을 마셨어요. + + + {formatFullDateTime(note.createdAt)} + + + + {/* 이미지 */} + {note.noteImages.length ? ( + note image + ) : null} + + {/* 노트 아로마 칩 */} + {note.noteAromas.length ? ( + + {note.noteAromas.map((aroma: Aroma, index) => ( + + ))} + + ) : null} + + {/* 점수 및 본문 */} + + {/* 점수 */} + + + {/* 본문 내용 */} + + {note.content} + + + {/* 상세작성 내용 */} + {note.isDetail && ( + + + NOSE: {note.nose} + + + PALATE: {note.palate} + + + FINISH: {note.finish} + + + )} + + + )} + + ); +} + +const LinkButton = styled(Link)({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + justifyContent: "space-between", + gap: "2px", + margin: "15px 0", + padding: "15px 15px", + // maxWidth: "800px", + // minHeight: "150px", + // border: "0.5px solid", + // borderColor: "gray", + borderRadius: "5px", + color: "inherit", + textDecoration: "none", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + backgroundColor: "#ffffff", + boxShadow: "0 6px 12px rgba(0,0,0,0.1)", +}); + +const ContentTypography = styled(Typography)({ + maxWidth: "100%", + display: "-webkit-box", + // WebkitLineClamp: 2, // 표시할 줄 수 + WebkitBoxOrient: "vertical", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "normal", +}); diff --git a/src/components/NoteComponent/LiquorInfoComponent.tsx b/src/components/NoteComponent/LiquorInfoComponent.tsx new file mode 100644 index 0000000..25467f3 --- /dev/null +++ b/src/components/NoteComponent/LiquorInfoComponent.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Avatar, Box, Typography } from "@mui/material"; + +export const LiquorInfoComponent = ({ + thumbnailImageUrl, + koName, + type, + abv, +}: Pick) => { + return ( + + +
+ + {koName} + + + {type && `${type}`} + {type && abv && ", "} + {abv && `도수 ${abv}`} + +
+
+ ); +}; diff --git a/src/components/NoteComponent/LiquorList.tsx b/src/components/NoteComponent/LiquorList.tsx new file mode 100644 index 0000000..cd31ceb --- /dev/null +++ b/src/components/NoteComponent/LiquorList.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Avatar, Box, Typography } from "@mui/material"; +import { TitleHeader } from "@/app/tasting-notes/new/StyledComponent"; + +export const LiquorList: React.FC = ({ + thumbnailImageUrl, + koName, + type, + abv, + volume, + country, + region, + grapeVariety, +}) => { + return ( + + +
+ + {koName} + + + {type && `${type}`} + {type && abv && ", "} + {abv && `도수 ${abv}`} + +
+
+ ); +}; diff --git a/src/components/NoteComponent/LiquorSelectComponent.tsx b/src/components/NoteComponent/LiquorSelectComponent.tsx new file mode 100644 index 0000000..604cc90 --- /dev/null +++ b/src/components/NoteComponent/LiquorSelectComponent.tsx @@ -0,0 +1,11 @@ +import { Add } from "@mui/icons-material"; +import { Button, Typography } from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; + +export default function LiquorSelectComponent({ + setLiquorData, +}: { + setLiquorData: Dispatch>; +}) { + return; +} diff --git a/src/components/NoteComponent/LiquorSelectModal.tsx b/src/components/NoteComponent/LiquorSelectModal.tsx new file mode 100644 index 0000000..317e9b4 --- /dev/null +++ b/src/components/NoteComponent/LiquorSelectModal.tsx @@ -0,0 +1,218 @@ +import { + Box, + CircularProgress, + Dialog, + DialogTitle, + InputAdornment, + styled, + TextField, + Typography, +} from "@mui/material"; +import axios from "axios"; +import { useCallback, useState } from "react"; +import debounce from "lodash.debounce"; +import { useQuery } from "react-query"; +import { Search } from "@mui/icons-material"; +import { LiquorList } from "./LiquorList"; + +/** LiquorSelectModal 컴포넌트 props 타입 */ +interface LiquorSelectModalProps { + open: boolean; + value: Liquor | null; + onClose: (value: Liquor | null) => void; +} + +/** 주류 검색 API 요청 함수 */ +const getLiquorList = async (keyword: string) => { + if (!keyword) return null; + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquorsearch?keyword=${keyword}` + ); + return response.data; +}; + +export default function LiquorSelectModal(props: LiquorSelectModalProps) { + const { open, value, onClose } = props; + + // 검색 키워드 state + const [keyword, setKeyword] = useState(""); + const [debouncedKeyword, setDebouncedKeyword] = useState(keyword); + + // 주류 검색 api query + const { data, status, isFetching } = useQuery({ + queryKey: ["liquorList", debouncedKeyword], + queryFn: () => getLiquorList(debouncedKeyword), + enabled: !!debouncedKeyword, + }); + + // debounce function + const debounceKeywordChange = useCallback( + debounce((nextValue: string) => setDebouncedKeyword(nextValue), 300), + [] + ); + + /** 검색어 변경 처리 */ + const handleKeywordChange = (e: React.ChangeEvent) => { + setKeyword(e.target.value); + debounceKeywordChange(e.target.value); + }; + + /** 주류 선택 없이 모달 닫을 경우 처리 */ + const handleClose = () => { + onClose(value); + }; + /** 주류 선택할 경우 처리 */ + const handleLiquorListClick = (newValue: LiquorInfo) => { + const result: Liquor = { + id: newValue.id, + koName: newValue.ko_name_origin, + enName: newValue.en_name, + type: newValue.type, + abv: newValue.abv, + volume: newValue.volume, + country: newValue.country, + thumbnailImageUrl: newValue.thumbnail_image_url, + tastingNotesAroma: newValue.tasting_notes_Aroma, + tastingNotesTaste: newValue.tasting_notes_Taste, + tastingNotesFinish: newValue.tasting_notes_Finish, + region: newValue.region, + grapeVariety: newValue.grape_variety, + category: null, + liquorAromas: [], + user: null, + }; + onClose(result); + }; + + return ( + + {/* 모달 제목 */} + 주류 선택 + + {/* 주류 검색창 */} + + + + + ), + sx: { + height: "56px", // 텍스트 필드의 높이를 증가시킴 + fontSize: "16px", // 텍스트 크기를 키움 + padding: "0 12px", // 내부 패딩을 조정하여 더 넓게 보이도록 함 + }, + }} + onChange={handleKeywordChange} + sx={{ + fontSize: "16px", // 입력 텍스트의 크기를 증가시킴 + height: "56px", // 텍스트 필드 전체 높이를 키움 + }} + /> + + + {/* 초기 화면 */} + {/* {status == "idle" && ( + + + + 테이스팅 노트 작성을 위해서는 + + + 주류를 선택해야 합니다. + + + + )} */} + + {/* 로딩 UI */} + {isFetching && ( + + + + 열심히 검색 중... + + + )} + + {/* 검색 결과 */} + {status == "success" && + (data && data.length ? ( + data.map((liquor: LiquorInfo) => ( + handleLiquorListClick(liquor)} + > + + + )) + ) : ( + + + + 검색 결과가 없습니다. + + + 테이스팅 노트 작성을 위해서는 + + + 주류를 선택해야 합니다. + + + + ))} + + ); +} + +const SearchResultBox = styled(Box)({ + height: "70vh", + display: "flex", + justifyContent: "center", + alignItems: "center", + gap: "10px", +}); + +const SearchResultTypography = styled(Typography)({ + textAlign: "center", + color: "gray", +}); diff --git a/src/components/NoteComponent/UserInfoComponent.tsx b/src/components/NoteComponent/UserInfoComponent.tsx new file mode 100644 index 0000000..d9fa6ea --- /dev/null +++ b/src/components/NoteComponent/UserInfoComponent.tsx @@ -0,0 +1,39 @@ +import { formatDateTime, formatFullDateTime } from "@/utils/format"; +import { Box, Stack, Typography } from "@mui/material"; +import Image from "next/image"; + +export default function UserInfoComponent({ + user, + createdAt, +}: { + user: User; + createdAt: string; +}) { + return ( + + user image + + + {user.profileNickname} + + + {formatFullDateTime(createdAt)} + + + + ); +} diff --git a/src/components/TastingNotesComponent/LiquorTitle.tsx b/src/components/TastingNotesComponent/LiquorTitle.tsx index db8de69..8719716 100644 --- a/src/components/TastingNotesComponent/LiquorTitle.tsx +++ b/src/components/TastingNotesComponent/LiquorTitle.tsx @@ -5,6 +5,18 @@ import { WhiskeyImage, } from "@/app/tasting-notes/new/StyledComponent"; +/** LiquorTitle 컴포넌트 호출 시 사용되는 props type */ +interface LiquorTitleProps { + thumbnailImageUrl: string | undefined; + koName: string | null; + type: string | null; + abv: string | null; + volume: string | null; + country: string | null; + region: string | null; + grapeVariety: string | null; +} + const LiquorTitle: React.FC = ({ thumbnailImageUrl, koName, @@ -26,22 +38,31 @@ const LiquorTitle: React.FC = ({ />
{koName} - + {type && `${type}`} {type && abv && ", "} {abv && `도수 ${abv}`} - + {volume && `${volume}`} {volume && country && ", "} {country && `${country}`} - + {region && `${region}`} {region && grapeVariety && ", "} {grapeVariety && `${grapeVariety}`} diff --git a/src/components/UserUpdateForm/UserUpdateForm.tsx b/src/components/UserUpdateForm/UserUpdateForm.tsx index 91233bd..9e5fb89 100644 --- a/src/components/UserUpdateForm/UserUpdateForm.tsx +++ b/src/components/UserUpdateForm/UserUpdateForm.tsx @@ -11,7 +11,8 @@ export default function UserUpdateForm() { const router = useRouter(); const [user, setUser] = useState(); // 응답받은 user 객체 관리 const [nickname, setNickname] = useState(""); // 이름 - const [profileImage, setProfileImage] = useState(""); // 이미지 + const [profileImage, setProfileImage] = useState(""); // 이미지: 직접 업로드 + const [defaultImage, setDefaultImage] = useState(""); // 이미지: 기본 이미지 /** 초기 사용자 정보 요청 api */ const getUserInfo = async () => { @@ -35,6 +36,7 @@ export default function UserUpdateForm() { const formData = new FormData(); formData.append("userUuid", user.userUuid); formData.append("profileNickname", nickname); + if (defaultImage) formData.append("defaultImage", defaultImage); // 호출 경로에 따라 동작 구분 (회원가입 시 정보 입력 or 회원 정보 수정) try { @@ -73,12 +75,17 @@ export default function UserUpdateForm() { } }; + /** 사용자 정보 변경 취소 */ + const handleCancle = () => { + router.push("/mypage"); + }; + /** 랜덤 프로필 이미지 요청 api */ const getRandomImage = async () => { const response = await axios.get( `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/users/random-image` ); - setProfileImage(response.data); + setDefaultImage(response.data); }; /** 랜덤 사용자 이름 요청 api */ @@ -109,34 +116,108 @@ export default function UserUpdateForm() { return ( user && ( - - 프로필 사진 - + + profile image + + + {/* 이름 설정 */} + + + + 이름 + + + + + + + {/* 버튼 그룹 */} + + - - profile image - - 이름 - - - - + ) ); diff --git a/src/styles/globals.css b/src/styles/globals.css index 3e41319..0cab3a8 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -22,7 +22,6 @@ body::-webkit-scrollbar { min-height: 100%; margin: 0 auto; /* 중앙 정렬 */ padding: 0 16px; /* 양쪽 여백 추가 */ - padding-bottom: 80px; display: flex; flex-direction: column; position: relative; diff --git a/src/utils/format.ts b/src/utils/format.ts index 37ff116..d9e7017 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -41,6 +41,19 @@ export const formatDate = (dateString: string): string => { return `${("0" + (date.getUTCMonth() + 1)).slice(-2)}.${("0" + date.getUTCDate()).slice(-2)}(${day})`; }; +export const formatFullDate = (dateString: string): string => { + const date = new Date(dateString); + const day = days[date.getUTCDay()]; + return `${date.getUTCFullYear()}년 ${date.getUTCMonth() + 1}월 ${date.getUTCDate()}일`; +}; + +export const formatFullDateTime = (dateString: string): string => { + const date = new Date(dateString); + const hours = ("0" + date.getHours()).slice(-2); + const minutes = ("0" + date.getMinutes()).slice(-2); + return `${date.getUTCFullYear()}년 ${date.getUTCMonth() + 1}월 ${date.getUTCDate()}일 ${hours}:${minutes}`; +}; + export const formatDateWithoutDay = (dateString: string): string => { const date = new Date(dateString); return `${("0" + (date.getUTCMonth() + 1)).slice(-2)}.${("0" + date.getUTCDate()).slice(-2)}`; From 077a6474c9e49325e805498635e5f8577c3f8aee Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Fri, 1 Nov 2024 19:57:44 +0900 Subject: [PATCH 17/28] =?UTF-8?q?fix:=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/liquors/[id]/page.tsx | 91 +++++++++++---------- src/app/liquors/page.tsx | 4 +- src/components/NoteComponent/LiquorList.tsx | 12 ++- 3 files changed, 59 insertions(+), 48 deletions(-) diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index 77ce8bb..54e262e 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -19,7 +19,7 @@ import { translateWhiskyNameToJapenese } from "@/utils/translateWhiskyNameToJape /** 주류 상세정보 API 요청 함수 */ const getLiquorInfo = async (id: string) => { const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquors/${id}` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v2/liquors/${id}` ); if (!res.ok) { if (res.status === 404) { @@ -70,56 +70,57 @@ export default async function LiquorDetailPage({ /> - {/* 주류 정보 */} - - {/* 이름 */} - + {/* 주류 정보 */} + + {/* 이름 */} + + + + {userProfileNickname === "데일리샷" || !userProfileNickname + ? "데일리샷 정보" + : userProfileNickname + "님이 등록한 정보"} + + + {liquor.koName} + + + {liquor.enName} + + + + + + + {/* 정보 */} + + {/* 주류 가격 정보 */} + + + + + + + + + + + {/* 주류 리뷰 */} + - - {userProfileNickname === "데일리샷" || !userProfileNickname - ? "데일리샷 정보" - : userProfileNickname + "님이 등록한 정보"} - - - {liquor.koName} + + 테이스팅 리뷰 - - {liquor.enName} + + 다른 사용자들의 테이스팅 리뷰들을 확인해보세요. - - + + - {/* 정보 */} - - {/* 주류 가격 정보 */} - - - - - - - - + {/* 테이스팅 리뷰 작성 버튼 */} + - - {/* 주류 리뷰 */} - - - - 테이스팅 리뷰 - - - 다른 사용자들의 테이스팅 리뷰들을 확인해보세요. - - - - - - - {/* 테이스팅 리뷰 작성 버튼 */} - ); -} \ No newline at end of file +} diff --git a/src/app/liquors/page.tsx b/src/app/liquors/page.tsx index e87db04..4c82f81 100644 --- a/src/app/liquors/page.tsx +++ b/src/app/liquors/page.tsx @@ -23,7 +23,7 @@ import AddIcon from "@mui/icons-material/Add"; const getLiquorList = async (keyword: string) => { if (!keyword) return null; const response = await axios.get( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/liquorsearch?keyword=${keyword}` + `${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/liquorsearch?keyword=${keyword}` ); return response.data; }; @@ -85,7 +85,7 @@ export default function LiquorsPage() { = ({ thumbnailImageUrl, From 1a7ce18da20880c08b98cc72331789bd9af79be4 Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Fri, 1 Nov 2024 20:18:37 +0900 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20=EC=A3=BC=EB=A5=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/liquors/[id]/page.tsx | 8 +- src/app/mypage/[uuid]/liquor/[id]/page.tsx | 14 -- src/app/notes-feed/page.tsx | 18 +- .../FloatingButton/CreateNoteDial.tsx | 17 +- .../LiquorUserTastingComponent.tsx | 202 +++++++++--------- 5 files changed, 120 insertions(+), 139 deletions(-) diff --git a/src/app/liquors/[id]/page.tsx b/src/app/liquors/[id]/page.tsx index 54e262e..b159926 100644 --- a/src/app/liquors/[id]/page.tsx +++ b/src/app/liquors/[id]/page.tsx @@ -1,6 +1,7 @@ import LiquorInfoCardComponent from "@/components/LiquorInfoCardComponent/LiquorInfoCardComponent"; import LiquorUserTastingComponent from "@/components/LiquorUserTastingComponent/LiquorUserTastingComponent"; import { + Backdrop, Box, Button, Divider, @@ -10,11 +11,10 @@ import { Typography, } from "@mui/material"; import Image from "next/image"; -import FloatingButton from "@/components/FloatingButton/FloatingButton"; -import { Edit } from "@mui/icons-material"; import PageTitleComponent from "@/components/LayoutComponents/PageTitleComponent"; import PriceInfo from "@/components/PriceInfo/PriceInfo"; import { translateWhiskyNameToJapenese } from "@/utils/translateWhiskyNameToJapenese"; +import CreateNoteDial from "@/components/FloatingButton/CreateNoteDial"; /** 주류 상세정보 API 요청 함수 */ const getLiquorInfo = async (id: string) => { @@ -118,8 +118,8 @@ export default async function LiquorDetailPage({ - {/* 테이스팅 리뷰 작성 버튼 */} - + {/* 노트 작성 다이얼 */} + ); diff --git a/src/app/mypage/[uuid]/liquor/[id]/page.tsx b/src/app/mypage/[uuid]/liquor/[id]/page.tsx index d9a2ddb..fbecf9e 100644 --- a/src/app/mypage/[uuid]/liquor/[id]/page.tsx +++ b/src/app/mypage/[uuid]/liquor/[id]/page.tsx @@ -32,16 +32,6 @@ export default function MypageLiquorPage({ }: { params: { uuid: string; id: string }; }) { - // 노트 작성 다이얼 옵션 - const [dialOpen, setDialOpen] = useState(false); - // 다이얼 상태 변경 함수 - const handleDialOpen = () => { - setDialOpen(true); - }; - const handleDialClose = () => { - setDialOpen(false); - }; - // 노트 목록 api query const { data, status } = useQuery({ queryKey: ["userLiquorNoteList", id], @@ -96,11 +86,7 @@ export default function MypageLiquorPage({ ) : null} {/* 노트 작성 다이얼 */} - setTypeOption(newTypeOption); - /** 다이얼 상태 변경 함수 */ - const handleDialOpen = () => { - setDialOpen(true); - }; - const handleDialClose = () => { - setDialOpen(false); - }; - // return return ( @@ -197,13 +187,7 @@ export default function NotesFeedPage() { )} {/* 노트 작성 다이얼 */} - - + ); } diff --git a/src/components/FloatingButton/CreateNoteDial.tsx b/src/components/FloatingButton/CreateNoteDial.tsx index bbc5d02..dc48747 100644 --- a/src/components/FloatingButton/CreateNoteDial.tsx +++ b/src/components/FloatingButton/CreateNoteDial.tsx @@ -1,18 +1,27 @@ +"use client"; + import { ShoppingCartOutlined, WineBarOutlined } from "@mui/icons-material"; import { SpeedDial, SpeedDialAction, SpeedDialIcon } from "@mui/material"; import { useRouter } from "next/navigation"; +import { useState } from "react"; interface CreateNoteDialProps { - dialOpen: boolean; - handleDialOpen: () => void; - handleDialClose: () => void; liquorId?: number; offset: boolean; } export default function CreateNoteDial(props: CreateNoteDialProps) { + // 노트 작성 다이얼 옵션 + const [dialOpen, setDialOpen] = useState(false); + // 다이얼 상태 변경 함수 + const handleDialOpen = () => { + setDialOpen(true); + }; + const handleDialClose = () => { + setDialOpen(false); + }; const router = useRouter(); - const { dialOpen, handleDialOpen, handleDialClose, liquorId, offset } = props; + const { liquorId, offset } = props; /** 노트 작성 다이얼 옵션 */ const dialAction = [ diff --git a/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx b/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx index e0aa699..2083487 100644 --- a/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx +++ b/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx @@ -9,6 +9,7 @@ import Image from "next/image"; import { useQuery } from "react-query"; import Link from "next/link"; import SingleTastingComponent from "../SingleTastingComponent/SingleTastingComponent"; +import TastingNoteCard from "../NoteCard/TastingNoteCard"; /** 주류 유저 테이스팅 리뷰 목록 API 요청 함수 */ const getLiquorTastingList = async (id: number) => { @@ -33,110 +34,111 @@ export default function LiquorUserTastingComponent({ {status == "success" && (data && data.length ? ( - data.map(({ type, tastingNote }) => + data.map(({ type, tastingNote }, idx) => type == "TASTING" ? ( - - - {/* 카드 헤더 */} - - {/* 작성자 정보 */} - - user profile image - - - {tastingNote.user.profileNickname} - - - {formatDateTime(tastingNote.createdAt)} - - - - {/* 작성자 총점 */} - - {tastingNote.score && } - - + + ) : // + // + // {/* 카드 헤더 */} + // + // {/* 작성자 정보 */} + // + // user profile image + // + // + // {tastingNote.user.profileNickname} + // + // + // {formatDateTime(tastingNote.createdAt)} + // + // + // + // {/* 작성자 총점 */} + // + // {tastingNote.score && } + // + // - + // - {/* 테이스팅 리뷰 내용: 상세 */} - - - - - + // {/* 테이스팅 리뷰 내용: 상세 */} + // + // + // + // + // - {/* 테이스팅 리뷰 내용: 총평 */} - - -  {tastingNote.content} - - - - - ) : null + // {/* 테이스팅 리뷰 내용: 총평 */} + // + // + //  {tastingNote.content} + // + // + // + // + null ) ) : ( From eb16b07bad564dd78d4cfabd6f353405c1e480e8 Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Fri, 1 Nov 2024 20:53:37 +0900 Subject: [PATCH 19/28] =?UTF-8?q?fix:=20useSearchParams=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/purchase-notes/new/page.tsx | 452 +++++++++++++++------------- src/app/tasting-notes/new/page.tsx | 11 +- 2 files changed, 245 insertions(+), 218 deletions(-) diff --git a/src/app/purchase-notes/new/page.tsx b/src/app/purchase-notes/new/page.tsx index 2d62653..aef0e11 100644 --- a/src/app/purchase-notes/new/page.tsx +++ b/src/app/purchase-notes/new/page.tsx @@ -1,6 +1,12 @@ "use client"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { Box, Button, @@ -76,7 +82,7 @@ const savePurchaseNote = async (data: PurchaseNoteReq) => { } }; -export default function NewPurchaseNotePage() { +function NewPurchaseNotePageComponent() { const { snackbar, showSnackbar, hideSnackbar } = useCustomSnackbar(); const params = useSearchParams(); const router = useRouter(); @@ -233,241 +239,253 @@ export default function NewPurchaseNotePage() { }; return ( - - - - {/* 주류 선택 */} - - - 어떤 주류를 구매했나요? - - - {liquorData ? ( - - - - ) : ( - - )} - - + 어떤 주류를 구매했나요? + + + {liquorData ? ( + + + + ) : ( + + )} + + - {/* 구매 장소 */} - - 어디서 구매했나요? - - + {/* 구매 장소 */} + + 어디서 구매했나요? + + - - 언제 구매했나요? - - + + 언제 구매했나요? + + - - 얼마에 구매했나요? - - + + 얼마에 구매했나요? + + - - 용량은 얼마인가요? - - + + 용량은 얼마인가요? + + - {/* 본문 및 이미지 */} - - 후기를 자유롭게 작성해주세요. + {/* 본문 및 이미지 */} + + 후기를 자유롭게 작성해주세요. - {/* 이미지 목록 */} - {noteImages.length ? ( - + {noteImages.map((image, idx) => { + return ( + + note image + handleImageRemove(idx)} + sx={{ + position: "absolute", + top: -8, + right: -8, + backgroundColor: "background.paper", + boxShadow: 1, + "&:hover": { + backgroundColor: "background.paper", + }, + }} + > + + + + ); + })} + + ) : null} + + {/* 이미지 버튼 */} + - - - {/* 본문 */} - - + + + 이미지 추가하기 + + + - {/* 버튼 그룹 */} - - {/* 버튼 */} - - {saving ? : "저장하기"} - - - 취소하기 - - - - {/* 취소 시 dialog */} - setopenCancelDialog(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - {"작성 취소하기"} - - - 현재 작성 중인 테이스팅 노트의 내용이 저장되지 않습니다. 정말로 - 취소하시겠습니까? - - - - - - - + {/* 본문 */} + + + + {/* 버튼 그룹 */} + + {/* 버튼 */} + + {saving ? : "저장하기"} + + + 취소하기 + + + + {/* 취소 시 dialog */} + setopenCancelDialog(false)} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + + {"작성 취소하기"} + + + + 현재 작성 중인 테이스팅 노트의 내용이 저장되지 않습니다. + 정말로 취소하시겠습니까? + + + + + + + + - + + ); +} + +export default function NewPurchaseNotePage() { + return ( + + + ); } diff --git a/src/app/tasting-notes/new/page.tsx b/src/app/tasting-notes/new/page.tsx index 6c8dd53..8d989d0 100644 --- a/src/app/tasting-notes/new/page.tsx +++ b/src/app/tasting-notes/new/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { + Suspense, SyntheticEvent, useCallback, useEffect, @@ -138,7 +139,7 @@ const createCustomAroma = async (aromaName: string) => { } }; -export default function NewTastingNotePage() { +function NewTastingNotePageComponent() { const { snackbar, showSnackbar, hideSnackbar } = useCustomSnackbar(); const params = useSearchParams(); const router = useRouter(); @@ -733,6 +734,14 @@ export default function NewTastingNotePage() { ); } +export default function NewTastingNotePage() { + return ( + + + + ); +} + const SaveButton = styled(Button)({ marginTop: "20px", width: "100%", From 0ac64ae3ea7e4333b00d138d5b7cfd0c4f4c571a Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Fri, 1 Nov 2024 21:02:30 +0900 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20=EC=A3=BC=EB=A5=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LiquorUserTastingComponent/LiquorUserTastingComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx b/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx index 2083487..556f64d 100644 --- a/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx +++ b/src/components/LiquorUserTastingComponent/LiquorUserTastingComponent.tsx @@ -31,7 +31,7 @@ export default function LiquorUserTastingComponent({ }); return ( - + {status == "success" && (data && data.length ? ( data.map(({ type, tastingNote }, idx) => From 412c523a27df47c99cdd35fe88a9df1f2c271471 Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Fri, 1 Nov 2024 21:34:07 +0900 Subject: [PATCH 21/28] =?UTF-8?q?fix:=20=EA=B3=B5=EC=9C=A0=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/purchase-notes/new/page.tsx | 6 +++++- src/app/tasting-notes/[id]/page.tsx | 2 +- src/app/tasting-notes/new/page.tsx | 7 ++++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/app/purchase-notes/new/page.tsx b/src/app/purchase-notes/new/page.tsx index aef0e11..b3c4d69 100644 --- a/src/app/purchase-notes/new/page.tsx +++ b/src/app/purchase-notes/new/page.tsx @@ -206,10 +206,14 @@ function NewPurchaseNotePageComponent() { }, [noteImages]); const handleSave = async () => { - if (!liquorData || !purchaseAt) { + if (!liquorData) { alert("주류를 선택해주세요."); return; } + if (!purchaseAt) { + alert("구매 일자를 선택해주세요."); + return; + } const noteSavingData: PurchaseNoteReq = { liquorId: liquorData.id, diff --git a/src/app/tasting-notes/[id]/page.tsx b/src/app/tasting-notes/[id]/page.tsx index 7186fa4..9ab17ef 100644 --- a/src/app/tasting-notes/[id]/page.tsx +++ b/src/app/tasting-notes/[id]/page.tsx @@ -26,7 +26,7 @@ import { LiquorInfoComponent } from "@/components/NoteComponent/LiquorInfoCompon import KeyValueInfoComponent from "@/components/KeyValueInfoComponent/KeyValueInfoComponent"; const NOTE_API_URL = process.env.NEXT_PUBLIC_API_BASE_URL + "/v2/notes/"; -const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/purchase-notes/"; +const NOTE_URL = process.env.NEXT_PUBLIC_BASE_URL + "/tasting-notes/"; const LIQUOR_URL = process.env.NEXT_PUBLIC_BASE_URL + "/liquors/"; /** 노트 상세 조회 API 호출 함수 */ diff --git a/src/app/tasting-notes/new/page.tsx b/src/app/tasting-notes/new/page.tsx index 8d989d0..484e1d8 100644 --- a/src/app/tasting-notes/new/page.tsx +++ b/src/app/tasting-notes/new/page.tsx @@ -337,11 +337,16 @@ function NewTastingNotePageComponent() { }, [noteImages]); const handleSave = async () => { - if (!liquorData || !tastingAt) { + if (!liquorData) { alert("주류를 선택해주세요."); return; } + if (!tastingAt) { + alert("테이스팅 일자를 선택해주세요."); + return; + } + const noteSavingData: TastingNoteReq = { liquorId: liquorData.id, noteImages: noteImages.map((image) => image.file), From e4f927ea1bb1d2f821584e3bd201f09538d3e6aa Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Sat, 2 Nov 2024 19:51:55 +0900 Subject: [PATCH 22/28] =?UTF-8?q?fix:=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/purchase-notes/new/page.tsx | 14 ++++++++++---- src/app/tasting-notes/new/page.tsx | 14 ++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/purchase-notes/new/page.tsx b/src/app/purchase-notes/new/page.tsx index b3c4d69..3487795 100644 --- a/src/app/purchase-notes/new/page.tsx +++ b/src/app/purchase-notes/new/page.tsx @@ -78,7 +78,9 @@ const savePurchaseNote = async (data: PurchaseNoteReq) => { ); return response.data.id; } catch (err) { - console.error(err); + if (axios.isAxiosError(err) && err.response?.status === 413) { + return -1; + } else console.error(err); } }; @@ -229,8 +231,12 @@ function NewPurchaseNotePageComponent() { try { const noteId = await savePurchaseNote(noteSavingData); - router.push(`/purchase-notes/${noteId}`); - showSnackbar("저장에 성공했습니다.", "success"); + if (noteId == -1) { + alert("10MB 이내의 파일을 선택해주세요."); + } else { + router.push(`/purchase-notes/${noteId}`); + showSnackbar("저장에 성공했습니다.", "success"); + } } catch (error: unknown) { const errorMessage = error instanceof Error @@ -408,7 +414,7 @@ function NewPurchaseNotePageComponent() { > - 이미지 추가하기 + 이미지 추가하기 (10MB 이내) { ); return response.data.id; } catch (err) { - console.error(err); + if (axios.isAxiosError(err) && err.response?.status === 413) { + return -1; + } else console.error(err); } }; @@ -366,8 +368,12 @@ function NewTastingNotePageComponent() { try { const noteId = await saveTastingNote(noteSavingData); - router.push(`/tasting-notes/${noteId}`); - showSnackbar("저장에 성공했습니다.", "success"); + if (noteId == -1) { + alert("10MB 이내의 파일을 선택해주세요."); + } else { + router.push(`/tasting-notes/${noteId}`); + showSnackbar("저장에 성공했습니다.", "success"); + } } catch (error: unknown) { const errorMessage = error instanceof Error @@ -623,7 +629,7 @@ function NewTastingNotePageComponent() { > - 이미지 추가하기 + 이미지 추가하기 (10MB 이내) Date: Sat, 2 Nov 2024 21:30:55 +0900 Subject: [PATCH 23/28] =?UTF-8?q?feat:=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/NoteCard/PurchaseNoteCard.tsx | 78 +++++++++--- src/components/NoteCard/TastingNoteCard.tsx | 120 ++++++++++++------- 2 files changed, 141 insertions(+), 57 deletions(-) diff --git a/src/components/NoteCard/PurchaseNoteCard.tsx b/src/components/NoteCard/PurchaseNoteCard.tsx index 58adb20..7472df9 100644 --- a/src/components/NoteCard/PurchaseNoteCard.tsx +++ b/src/components/NoteCard/PurchaseNoteCard.tsx @@ -31,7 +31,9 @@ export default function PurchaseNoteCard({ note }: { note: PurchaseNote }) { > {note.user.profileNickname}님이 {note.liquor.koName}을 구매했어요. - + {formatFullDateTime(note.createdAt)} @@ -55,27 +57,71 @@ export default function PurchaseNoteCard({ note }: { note: PurchaseNote }) { ) : null} {/* 구매 정보 */} - - - 구매처 {note.place} - - - 가격 {note.price} - + + + + 구매처 + + + {note.place ? note.place : "-"} + + + + + 가격 + + + {note.price ? `${note.price}원` : "-"} + + {/* 본문 내용 */} - - - {note.content} - - + + {note.content} + + + ) : null} )} @@ -89,7 +135,7 @@ const LinkButton = styled(Link)({ justifyContent: "space-between", gap: "2px", margin: "15px 0", - padding: "15px 15px", + padding: "15px", // maxWidth: "800px", minHeight: "150px", // border: "0.5px solid", diff --git a/src/components/NoteCard/TastingNoteCard.tsx b/src/components/NoteCard/TastingNoteCard.tsx index 74e8965..81b3429 100644 --- a/src/components/NoteCard/TastingNoteCard.tsx +++ b/src/components/NoteCard/TastingNoteCard.tsx @@ -40,7 +40,9 @@ export default function TastingNoteCard({ note }: { note: TastingNote }) { > {note.user.profileNickname}님이 {note.liquor.koName}을 마셨어요. - + {formatFullDateTime(note.createdAt)} @@ -84,59 +86,95 @@ export default function TastingNoteCard({ note }: { note: TastingNote }) { ) : null} - {/* 점수 및 본문 */} - - {/* 점수 */} - - - {/* 본문 내용 */} - + - {note.content} - + 점수 + + + - {/* 상세작성 내용 */} - {note.isDetail && ( - - - NOSE: {note.nose} - + {/* 본문 */} + {note.content ? ( + + {/* 본문 내용 */} + - PALATE: {note.palate} + {note.content} - + + {/* 상세작성 내용 */} + {note.isDetail && ( + - FINISH: {note.finish} - - - )} - + + NOSE: {note.nose} + + + PALATE: {note.palate} + + + FINISH: {note.finish} + + + )} + + ) : null} )} From ca7866cbaa8ddb110333ed85d2908023e9999150 Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Mon, 4 Nov 2024 17:35:51 +0900 Subject: [PATCH 24/28] =?UTF-8?q?fix:=20=EA=B5=AC=EB=A7=A4=20=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=EC=88=AB=EC=9E=90?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/purchase-notes/new/page.tsx | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/src/app/purchase-notes/new/page.tsx b/src/app/purchase-notes/new/page.tsx index 3487795..f22d5c5 100644 --- a/src/app/purchase-notes/new/page.tsx +++ b/src/app/purchase-notes/new/page.tsx @@ -17,6 +17,8 @@ import { DialogContentText, DialogTitle, IconButton, + InputAdornment, + OutlinedInput, Stack, styled, TextField, @@ -43,8 +45,8 @@ interface PurchaseNoteReq { noteImages: File[]; purchaseAt: string; place: string; - price: number; - volume: number; + price: number | undefined; + volume: number | undefined; content: string; } @@ -106,10 +108,16 @@ function NewPurchaseNotePageComponent() { setPlace(e.target.value); }; const handlePriceChange = (e: React.ChangeEvent) => { - setPrice(+e.target.value); + // 빈 문자열이거나 숫자만 있는 경우에만 허용 + if (e.target.value === "" || /^[0-9]+$/.test(e.target.value)) { + setPrice(+e.target.value); + } }; const handleVolumeChange = (e: React.ChangeEvent) => { - setVolume(+e.target.value); + // 빈 문자열이거나 숫자만 있는 경우에만 허용 + if (e.target.value === "" || /^[0-9]+$/.test(e.target.value)) { + setVolume(+e.target.value); + } }; const handleContentChange = (e: React.ChangeEvent) => { setContent(e.target.value); @@ -329,9 +337,13 @@ function NewPurchaseNotePageComponent() { 얼마에 구매했나요? @@ -340,9 +352,13 @@ function NewPurchaseNotePageComponent() { 용량은 얼마인가요? From 443d7fa8a45de11c9cec6109a976c21148abaa8d Mon Sep 17 00:00:00 2001 From: y-ngm-n Date: Mon, 4 Nov 2024 21:04:34 +0900 Subject: [PATCH 25/28] =?UTF-8?q?docs:=20=ED=99=88=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ServiceIntroductionComponentV2.tsx | 114 ++++++++++-------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx index 7cd00dd..af13f5d 100644 --- a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx +++ b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx @@ -15,6 +15,7 @@ import StorefrontIcon from "@mui/icons-material/Storefront"; import SearchIcon from "@mui/icons-material/Search"; import TouchAppIcon from "@mui/icons-material/TouchApp"; import PriceCheckIcon from "@mui/icons-material/PriceCheck"; +import { EditNote } from "@mui/icons-material"; const GOOGLE_FORM_URL = "https://forms.gle/cuoJy7uJF4r2ewMg9"; const KAKAO_OPENCHAT_URL = "https://open.kakao.com/o/sSDeVvGg"; @@ -39,7 +40,7 @@ export default function ServiceIntroductionComponent() { variant="h4" sx={{ fontWeight: 800, - fontSize: { xs: "24px", md: "32px" }, + fontSize: { xs: "20px", md: "32px" }, letterSpacing: "-0.02em", }} > @@ -48,7 +49,7 @@ export default function ServiceIntroductionComponent() { @@ -56,55 +57,68 @@ export default function ServiceIntroductionComponent() { {/* 업데이트 소식 */} - + + + 업데이트 소식 + - 🎉 + + 🎉 + + + + + New! 주류 구매 노트 작성 기능 추가 + - - - New! 빅카메라, 겟주, 롯데마트, 이마트 가격 비교 추가 - - - + {/* 사용 방법 */} @@ -112,8 +126,8 @@ export default function ServiceIntroductionComponent() { variant="h6" sx={{ fontWeight: 700, - mb: 3, - fontSize: "18px", + mb: 1, + fontSize: { xs: "15px", md: "18px" }, letterSpacing: "-0.01em", }} > @@ -122,30 +136,36 @@ export default function ServiceIntroductionComponent() { {[ { - icon: , + icon: , step: "1", title: "원하는 주류 검색", desc: "찾고 싶은 주류의 이름을 검색해보세요. 영문, 한글 모두 가능합니다.", }, { - icon: , + icon: , step: "2", title: "주류 선택", desc: "검색 결과에서 원하는 주류를 선택하세요.", }, { - icon: , + icon: , step: "3", title: "가격 비교", desc: "각 매장별 실시간 가격을 한 눈에 비교하고 최저가를 확인하세요.", }, + { + icon: , + step: "4", + title: "노트 기록", + desc: "주류에 대한 구매 노트와 테이스팅 노트를 작성해 기록을 남겨보세요.", + }, ].map((item, index) => ( Date: Fri, 8 Nov 2024 21:32:37 +0900 Subject: [PATCH 26/28] =?UTF-8?q?feat:=20=EC=9E=90=EC=B2=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20api=20=EA=B2=BD=EB=A1=9C=EC=99=80=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20api=20=EA=B2=BD=EB=A1=9C=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/{api => external-api}/price-search/route.ts | 0 src/components/PriceInfo/PriceInfo.tsx | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/app/{api => external-api}/price-search/route.ts (100%) diff --git a/src/app/api/price-search/route.ts b/src/app/external-api/price-search/route.ts similarity index 100% rename from src/app/api/price-search/route.ts rename to src/app/external-api/price-search/route.ts diff --git a/src/components/PriceInfo/PriceInfo.tsx b/src/components/PriceInfo/PriceInfo.tsx index 89fd819..310479e 100644 --- a/src/components/PriceInfo/PriceInfo.tsx +++ b/src/components/PriceInfo/PriceInfo.tsx @@ -57,7 +57,7 @@ const PriceInfo: React.FC = ({ setIsLoading(true); try { console.log(`${store}의 가격 정보를 가져오는 중:`, liquorName); - const fetchUrl = `/api/price-search?q=${encodeURIComponent(liquorName)}&store=${store}`; + const fetchUrl = `/external-api/price-search?q=${encodeURIComponent(liquorName)}&store=${store}`; console.log("요청 URL:", fetchUrl); const response = await axios.get(fetchUrl); From c22afeff94fe917a57dffb6bfa2c757db9913675 Mon Sep 17 00:00:00 2001 From: yangdongsuk <51476641+yangdongsuk@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:42:44 +0900 Subject: [PATCH 27/28] =?UTF-8?q?OPENAI=5FAPI=5FKEY=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/frontend_dev.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/frontend_dev.yml b/.github/workflows/frontend_dev.yml index 9e4ad03..b093484 100644 --- a/.github/workflows/frontend_dev.yml +++ b/.github/workflows/frontend_dev.yml @@ -23,6 +23,7 @@ jobs: echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> .env echo "NEXT_PUBLIC_SENTRY_DSN=${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}" >> .env echo "DEEPL_API_KEY=${{ secrets.DEEPL_API_KEY }}" >> .env + echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env - name: Configure AWS credentials @@ -62,6 +63,7 @@ jobs: docker run --rm -it -d -p 80:3000 \ -e SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} \ -e DEEPL_API_KEY=${{ secrets.DEEPL_API_KEY }} \ + -e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \ --name jumo_front_dev ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/jumo_front_dev:latest - name: Clean up unused Docker images after deployment From 6b647d47222aa5d96031acdb42ea31a7156369e1 Mon Sep 17 00:00:00 2001 From: yangdongsuk <51476641+yangdongsuk@users.noreply.github.com> Date: Sun, 10 Nov 2024 16:45:06 +0900 Subject: [PATCH 28/28] =?UTF-8?q?[BYOB-230]=20=EB=B2=88=EC=97=AD=20api=20g?= =?UTF-8?q?pt=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 번역 gpt api로 변경 * feat: 번역 한방에 하게 수정 * fix: 겟주 링크 에러 해결 * refactor: 함수 분리 * feat: 홈페이지 문구 업데이트 --- .gitignore | 2 + package-lock.json | 97 +++++- package.json | 1 + src/app/external-api/price-search/route.ts | 306 +++++------------- .../ServiceIntroductionComponentV2.tsx | 176 ++++++---- src/utils/parseHtml.ts | 122 +++++++ src/utils/translateProductNames.ts | 81 +++++ 7 files changed, 495 insertions(+), 290 deletions(-) create mode 100644 src/utils/parseHtml.ts create mode 100644 src/utils/translateProductNames.ts diff --git a/.gitignore b/.gitignore index 1dd45b2..aa4c6ab 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin +.idea/vcs.xml +.idea/inspectionProfiles/Project_Default.xml diff --git a/package-lock.json b/package-lock.json index 082975e..a9811c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", "next": "14.2.4", + "openai": "^4.71.1", "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", @@ -2745,7 +2746,6 @@ "version": "2.6.11", "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", - "dev": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.0" @@ -3231,6 +3231,17 @@ "node": ">= 6.0.0" } }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5351,6 +5362,23 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -5967,6 +5995,14 @@ "node": ">= 6" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7162,6 +7198,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -7371,6 +7425,39 @@ "wrappy": "1" } }, + "node_modules/openai": { + "version": "4.71.1", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.71.1.tgz", + "integrity": "sha512-C6JNMaQ1eijM0lrjiRUL3MgThVP5RdwNAghpbJFdW0t11LzmyqON8Eh8MuUuEZ+CeD6bgYl2Fkn2BoptVxv9Ug==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.64", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.64.tgz", + "integrity": "sha512-955mDqvO2vFf/oL7V3WiUtiz+BugyX8uVbaT2H8oj3+8dRyH2FLiNdowe7eNqRM7IOIZvzDH76EoAT+gwm6aIQ==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/opentelemetry-instrumentation-fetch-node": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/opentelemetry-instrumentation-fetch-node/-/opentelemetry-instrumentation-fetch-node-1.2.3.tgz", @@ -9531,6 +9618,14 @@ "node": ">=10.13.0" } }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index f2e4f1b..3ce8231 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", "next": "14.2.4", + "openai": "^4.71.1", "react": "^18", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18", diff --git a/src/app/external-api/price-search/route.ts b/src/app/external-api/price-search/route.ts index 42056d9..44b56d0 100644 --- a/src/app/external-api/price-search/route.ts +++ b/src/app/external-api/price-search/route.ts @@ -1,104 +1,48 @@ import { NextRequest, NextResponse } from "next/server"; import iconv from "iconv-lite"; -import { JSDOM } from "jsdom"; import { translateWhiskyNameToJapenese } from "@/utils/translateWhiskyNameToJapenese"; -import fetch from "node-fetch"; - -// 캐시 타입 정의 -interface CacheItem { - data: any; - timestamp: number; -} - -// 캐시를 저장할 객체 (In-Memory) -let cache: Record = {}; - -async function translateText(text: string): Promise { - if (!text) return ""; - - try { - const response = await fetch("https://api-free.deepl.com/v2/translate", { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Authorization: `DeepL-Auth-Key ${process.env.DEEPL_API_KEY}`, - }, - body: new URLSearchParams({ - text: text, - source_lang: "JA", - target_lang: "KO", - }), - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error("Translation API error:", errorData); - return text; - } - - const data = await response.json(); - return data.translations[0]?.text || text; - } catch (error) { - console.error("Translation error:", error); - return text; - } -} - -function toEUCJPEncoding(query: string) { - const eucJPEncoded = iconv.encode(query, "euc-jp"); - return Array.from(eucJPEncoded) - .map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`) - .join(""); -} - -// store 타입에 lottemart 추가 -type StoreType = - | "dailyshot" - | "mukawa" - | "cu" - | "traders" - | "getju" - | "emart" - | "lottemart" - | "biccamera"; +import { translateProductNames } from "@/utils/translateProductNames"; // 추가된 부분 +import { parseGetjuHtml,parseLottemartHtml, parseMukawaHtml, parseBiccameraHtml } from "@/utils/parseHtml"; // 추가된 부분 export async function GET(request: NextRequest) { - const params = { - query: request.nextUrl.searchParams.get("q") || "", - store: (request.nextUrl.searchParams.get("store") || - "dailyshot") as StoreType, - page: parseInt(request.nextUrl.searchParams.get("page") || "1"), - pageSize: parseInt(request.nextUrl.searchParams.get("pageSize") || "12"), - }; - - if (!params.query) { - return NextResponse.json( - { error: "검색어가 필요합니다." }, - { status: 400 } + try { + const query = request.nextUrl.searchParams.get("q") || ""; + const store = (request.nextUrl.searchParams.get("store") || + "dailyshot") as StoreType; + const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); + const pageSize = parseInt( + request.nextUrl.searchParams.get("pageSize") || "12", + 10 ); - } - // 캐싱된 결과가 있는지 확인 - const cacheKey = `${params.query}-${params.store}`; - const cachedResult = cache[cacheKey]; + const params: SearchParams = { query, store, page, pageSize }; - if ( - cachedResult && - Date.now() - cachedResult.timestamp < 24 * 60 * 60 * 1000 - ) { - // 캐싱된 데이터를 반환 - return NextResponse.json(cachedResult.data); - } + if (!params.query) { + return NextResponse.json( + { error: "검색어가 필요합니다." }, + { status: 400 } + ); + } - // 카와인 경우 위스 이름을 일본어로 번역 - if (params.store === "mukawa" && params.query) { - const translatedQuery = translateWhiskyNameToJapenese(params.query); - params.query = translatedQuery.japanese || params.query; - } + // 캐시 확인 + const cacheKey = `${params.query}-${params.store}`; + const cachedResult = cache[cacheKey]; + if ( + cachedResult && + Date.now() - cachedResult.timestamp < 24 * 60 * 60 * 1000 + ) { + return NextResponse.json(cachedResult.data); + } + + // 무카와 스토어의 경우 위스키 이름 번역 + if (params.store === "mukawa" && params.query) { + const translatedQuery = translateWhiskyNameToJapenese(params.query); + params.query = translatedQuery.japanese || params.query; + } - try { let searchResult = await performSearch(params); + // 무카와/빅카메라 결과 번역 if ( (params.store === "mukawa" || params.store === "biccamera") && searchResult @@ -106,6 +50,7 @@ export async function GET(request: NextRequest) { searchResult = await translateProductNames(searchResult); } + // 검색 결과가 없는 경우 첫 단어로 재검색 if (!searchResult?.length) { const firstValidWord = params.query ?.split(" ") @@ -115,6 +60,7 @@ export async function GET(request: NextRequest) { ...params, query: firstValidWord, }); + if ( (params.store === "mukawa" || params.store === "biccamera") && searchResult @@ -124,7 +70,7 @@ export async function GET(request: NextRequest) { } } - // 캐싱 저장 + // 캐시 저장 cache[cacheKey] = { data: searchResult, timestamp: Date.now(), @@ -140,7 +86,15 @@ export async function GET(request: NextRequest) { } } -// 검색 결과 아이템의 인터페이스 정의 +// 캐시 타입 정의 +interface CacheItem { + data: any; + timestamp: number; +} + +let cache: Record = {}; + +// SearchResult 인터페이스 정의 interface SearchResultItem { name: string; price: number; @@ -152,30 +106,43 @@ interface SearchResultItem { }; } -// 상품명만 번역하는 새로운 함수 -async function translateProductNames( - results: SearchResultItem[] -): Promise { - const translatedResults: SearchResultItem[] = []; - - for (const item of results) { - // Rate limiting 방지를 위한 딜레이 - await new Promise((resolve) => setTimeout(resolve, 1000)); +function toEUCJPEncoding(query: string): string { + const eucJPEncoded = iconv.encode(query, "euc-jp"); + return Array.from(eucJPEncoded) + .map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`) + .join(""); +} - const translatedName = await translateText(item.name); +type StoreType = + | "dailyshot" + | "mukawa" + | "cu" + | "traders" + | "getju" + | "emart" + | "lottemart" + | "biccamera"; - translatedResults.push({ - ...item, - name: translatedName, - original: { - name: item.name, - }, - }); - } +interface SearchParams { + query: string; + store: StoreType; + page: number; + pageSize: number; +} - return translatedResults; +// 검색 결과 아이템의 인터페이스 정의 +interface SearchResultItem { + name: string; + price: number; + url?: string; + description?: string; + soldOut?: boolean; + original?: { + name: string; + }; } +// 나머지 API 함수들은 생략합니다. // CU API 응답의 타입을 정의합니다. interface CUApiResponse { data: { @@ -234,73 +201,6 @@ function getSearchUrl( return urlGenerator ? urlGenerator(query, page, pageSize) : null; } -// getju HTML 파싱 함수 추가 -function parseGetjuHtml(html: string) { - const dom = new JSDOM(html); - const document = dom.window.document; - const productItems = document.querySelectorAll("#prd_basic li"); - - const results = Array.from(productItems).map((item) => { - const element = item as Element; - const name = element.querySelector(".info .name a")?.textContent?.trim(); - const priceText = element - .querySelector(".price .sell strong") - ?.textContent?.trim(); - const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; - const url = - "https://www.getju.co.kr" + - element.querySelector(".info .name a")?.getAttribute("href"); - const type = element.querySelector(".info .type")?.textContent?.trim(); - const isSoldOut = element.querySelector(".soldout") !== null; - - return { - name, - price, - url, - type, - isSoldOut, - }; - }); - - return results.filter((item) => item.name && item.price); -} - -// 롯데마트 HTML 파싱 함수 추가 -function parseLottemartHtml(html: string) { - const dom = new JSDOM(html); - const document = dom.window.document; - const productItems = document.querySelectorAll(".list-result li"); - - const results = Array.from(productItems).map((item) => { - const element = item as Element; - const name = element.querySelector(".prod-name")?.textContent?.trim(); - const size = element.querySelector(".prod-count")?.textContent?.trim(); - - // layer_popup 내부의 info-list에서 가격 정보 추출 - const infoList = element.querySelector(".info-list"); - const rows = infoList?.querySelectorAll("tr"); - - let price = 0; - - rows?.forEach((row) => { - const label = row.querySelector("th")?.textContent?.trim(); - const value = row.querySelector("td")?.textContent?.trim(); - - if (label?.includes("가격")) { - price = value ? parseInt(value.replace(/[^0-9]/g, "")) : 0; - } - }); - - return { - name: name ? `${name} ${size || ""}` : "", - price, - url: undefined, - description: undefined, - }; - }); - - return results.filter((item) => item.name && item.price); -} // 롯데마트 지점 정보 정의 const LOTTEMART_STORES = [ @@ -480,58 +380,6 @@ async function performSearch({ })); } -function parseMukawaHtml(html: string) { - const dom = new JSDOM(html); - const document = dom.window.document; - const productItems = document.querySelectorAll(".list-product-item"); - - const results = Array.from(productItems).map((item) => { - const element = item as Element; - const name = element - .querySelector(".list-product-item__ttl") - ?.textContent?.trim(); - const priceText = element - .querySelector(".list-product-item__price") - ?.textContent?.trim(); - const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; - const description = element - .querySelector(".list-product-item__memo") - ?.textContent?.trim(); - const url = - "https://mukawa-spirit.com" + - element.querySelector("a")?.getAttribute("href"); - - return { name, price, description, url }; - }); - - return results; -} - -// 빅카메라 HTML 파싱 함수 추가 -function parseBiccameraHtml(html: string) { - const dom = new JSDOM(html); - const document = dom.window.document; - const productItems = document.querySelectorAll(".prod_box"); - - const results = Array.from(productItems).map((item) => { - const element = item as Element; - const name = element.querySelector(".bcs_title a")?.textContent?.trim(); - const priceText = element - .querySelector(".bcs_price .val") - ?.textContent?.trim(); - const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; - // URL 중복 제거 - const urlPath = - element.querySelector(".bcs_title a")?.getAttribute("href") || ""; - const url = urlPath.startsWith("http") - ? urlPath - : `https://www.biccamera.com${urlPath}`; - - return { name, price, url }; - }); - - return results.filter((item) => item.name && item.price); -} interface SearchItem { name?: string; diff --git a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx index af13f5d..b199703 100644 --- a/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx +++ b/src/components/ServiceIntroductionComponent/ServiceIntroductionComponentV2.tsx @@ -56,70 +56,126 @@ export default function ServiceIntroductionComponent() { 국내외 주요 매장의 가격을 한 눈에 비교하세요 - {/* 업데이트 소식 */} - - - 업데이트 소식 - - - - - 🎉 - - - + {/* 업데이트 소식 */} + - New! 주류 구매 노트 작성 기능 추가 + 업데이트 소식 - - - + {/* 새 기능 알림 */} + + + + 🎉 + + + + + New! 주류 구매 노트 작성 기능 추가 + + + + + {/* 버그 수정 알림 */} + + + + 🛠️ + + + + + 버그 수정 및 개선 + + +
  • 겟주 URL 오류 수정
  • +
  • 일본어 번역 오류 개선
  • +
    +
    +
    + {/* 사용 방법 */} { + const element = item as Element; + const name = element.querySelector(".info .name a")?.textContent?.trim(); + const priceText = element + .querySelector(".price .sell strong") + ?.textContent?.trim(); + const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; + const url = element.querySelector(".info .name a")?.getAttribute("href"); + const type = element.querySelector(".info .type")?.textContent?.trim(); + const isSoldOut = element.querySelector(".soldout") !== null; + + return { + name, + price, + url, + type, + isSoldOut, + }; + }); + + return results.filter((item) => item.name && item.price); +} + +// 롯데마트 HTML 파싱 함수 추가 +export function parseLottemartHtml(html: string) { + const dom = new JSDOM(html); + const document = dom.window.document; + const productItems = document.querySelectorAll(".list-result li"); + + const results = Array.from(productItems).map((item) => { + const element = item as Element; + const name = element.querySelector(".prod-name")?.textContent?.trim(); + const size = element.querySelector(".prod-count")?.textContent?.trim(); + + // layer_popup 내부의 info-list에서 가격 정보 추출 + const infoList = element.querySelector(".info-list"); + const rows = infoList?.querySelectorAll("tr"); + + let price = 0; + + rows?.forEach((row) => { + const label = row.querySelector("th")?.textContent?.trim(); + const value = row.querySelector("td")?.textContent?.trim(); + + if (label?.includes("가격")) { + price = value ? parseInt(value.replace(/[^0-9]/g, "")) : 0; + } + }); + + return { + name: name ? `${name} ${size || ""}` : "", + price, + url: undefined, + description: undefined, + }; + }); + + return results.filter((item) => item.name && item.price); +} + + +export function parseMukawaHtml(html: string) { + const dom = new JSDOM(html); + const document = dom.window.document; + const productItems = document.querySelectorAll(".list-product-item"); + + const results = Array.from(productItems).map((item) => { + const element = item as Element; + const name = element + .querySelector(".list-product-item__ttl") + ?.textContent?.trim(); + const priceText = element + .querySelector(".list-product-item__price") + ?.textContent?.trim(); + const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; + const description = element + .querySelector(".list-product-item__memo") + ?.textContent?.trim(); + const url = + "https://mukawa-spirit.com" + + element.querySelector("a")?.getAttribute("href"); + + return { name, price, description, url }; + }); + + return results; +} + +// 빅카메라 HTML 파싱 함수 추가 +export function parseBiccameraHtml(html: string) { + const dom = new JSDOM(html); + const document = dom.window.document; + const productItems = document.querySelectorAll(".prod_box"); + + const results = Array.from(productItems).map((item) => { + const element = item as Element; + const name = element.querySelector(".bcs_title a")?.textContent?.trim(); + const priceText = element + .querySelector(".bcs_price .val") + ?.textContent?.trim(); + const price = priceText ? parseInt(priceText.replace(/[^0-9]/g, "")) : 0; + // URL 중복 제거 + const urlPath = + element.querySelector(".bcs_title a")?.getAttribute("href") || ""; + const url = urlPath.startsWith("http") + ? urlPath + : `https://www.biccamera.com${urlPath}`; + + return { name, price, url }; + }); + + return results.filter((item) => item.name && item.price); +} \ No newline at end of file diff --git a/src/utils/translateProductNames.ts b/src/utils/translateProductNames.ts new file mode 100644 index 0000000..81f80ee --- /dev/null +++ b/src/utils/translateProductNames.ts @@ -0,0 +1,81 @@ +import OpenAI from "openai"; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}); + +interface SearchResultItem { + name: string; + price: number; + url?: string; + description?: string; + soldOut?: boolean; + original?: { + name: string; + }; +} + +export async function translateProductNames( + results: SearchResultItem[] +): Promise { + if (!results.length) return []; + + const names = results.map((item) => item.name); + + try { + const response = await openai.chat.completions.create({ + model: "gpt-4o-2024-08-06", + messages: [ + { + role: "system", + content: + "You are a professional translator. Translate the given Japanese product names to Korean. Keep brand names, numbers and units (ml, L) unchanged. Return the translations in the same order as input array.", + }, + { + role: "user", + content: JSON.stringify(names), + }, + ], + temperature: 0.3, + response_format: { + type: "json_schema", + json_schema: { + name: "translation_response", + strict: true, + schema: { + type: "object", + properties: { + translations: { + type: "array", + items: { + type: "string", + description: "Translated product name", + }, + }, + }, + required: ["translations"], + additionalProperties: false, + }, + }, + }, + }); + + const content = response.choices[0]?.message?.content; + if (content) { + const { translations } = JSON.parse(content); + + return results.map((item, index) => ({ + ...item, + name: translations[index], + original: { + name: item.name, + }, + })); + } + + return results; + } catch (error) { + console.error("Translation error:", error); + return results; + } +}