diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce5394f --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# .env.example +VITE_API_URI=https://api.example.com \ No newline at end of file diff --git a/index.html b/index.html index e4b78ea..43a4a9d 100644 --- a/index.html +++ b/index.html @@ -4,10 +4,12 @@ - Vite + React + TS + Hana-Piece + + -
+
diff --git a/package-lock.json b/package-lock.json index 0b2b88c..5eb4e13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,13 @@ "name": "hana-piece-client", "version": "0.0.0", "dependencies": { + "chart.js": "^4.4.3", + "chartjs-plugin-datalabels": "^2.2.0", + "clsx": "^2.1.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.2.1", "react-router-dom": "^6.23.1" }, "devDependencies": { @@ -18,9 +23,11 @@ "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "postcss": "^8.4.38", "tailwindcss": "^3.4.3", "typescript": "^5.2.2", "vite": "^5.2.0" @@ -702,6 +709,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1231,6 +1243,43 @@ "node": ">=8" } }, + "node_modules/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1364,6 +1413,25 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz", + "integrity": "sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chartjs-plugin-datalabels": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz", + "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==", + "peerDependencies": { + "chart.js": ">=3.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1400,6 +1468,14 @@ "node": ">= 6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1936,6 +2012,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2497,6 +2586,15 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2900,6 +2998,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -2913,6 +3020,14 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", + "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index 700fff6..dae5a95 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "chart.js": "^4.4.3", + "chartjs-plugin-datalabels": "^2.2.0", + "clsx": "^2.1.1", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-icons": "^5.2.1", "react-router-dom": "^6.23.1" diff --git a/public/byul2.png b/public/byul2.png new file mode 100644 index 0000000..c4a6c89 Binary files /dev/null and b/public/byul2.png differ diff --git a/public/byul3.png b/public/byul3.png new file mode 100644 index 0000000..d523afa Binary files /dev/null and b/public/byul3.png differ diff --git a/public/fonts/Hana2-B.woff b/public/fonts/Hana2-B.woff new file mode 100644 index 0000000..b97cb04 Binary files /dev/null and b/public/fonts/Hana2-B.woff differ diff --git a/public/fonts/Hana2-B.woff2 b/public/fonts/Hana2-B.woff2 new file mode 100644 index 0000000..ede4ebd Binary files /dev/null and b/public/fonts/Hana2-B.woff2 differ diff --git a/public/fonts/Hana2-CM.woff b/public/fonts/Hana2-CM.woff new file mode 100644 index 0000000..3487546 Binary files /dev/null and b/public/fonts/Hana2-CM.woff differ diff --git a/public/fonts/Hana2-CM.woff2 b/public/fonts/Hana2-CM.woff2 new file mode 100644 index 0000000..faf9657 Binary files /dev/null and b/public/fonts/Hana2-CM.woff2 differ diff --git a/public/fonts/Hana2-H.woff b/public/fonts/Hana2-H.woff new file mode 100644 index 0000000..397a361 Binary files /dev/null and b/public/fonts/Hana2-H.woff differ diff --git a/public/fonts/Hana2-H.woff2 b/public/fonts/Hana2-H.woff2 new file mode 100644 index 0000000..5db875b Binary files /dev/null and b/public/fonts/Hana2-H.woff2 differ diff --git a/public/fonts/Hana2-L.woff b/public/fonts/Hana2-L.woff new file mode 100644 index 0000000..c3f364f Binary files /dev/null and b/public/fonts/Hana2-L.woff differ diff --git a/public/fonts/Hana2-L.woff2 b/public/fonts/Hana2-L.woff2 new file mode 100644 index 0000000..0da0913 Binary files /dev/null and b/public/fonts/Hana2-L.woff2 differ diff --git a/public/fonts/Hana2-M.woff b/public/fonts/Hana2-M.woff new file mode 100644 index 0000000..2f040b8 Binary files /dev/null and b/public/fonts/Hana2-M.woff differ diff --git a/public/fonts/Hana2-M.woff2 b/public/fonts/Hana2-M.woff2 new file mode 100644 index 0000000..686f090 Binary files /dev/null and b/public/fonts/Hana2-M.woff2 differ diff --git a/public/fonts/Hana2-R.woff b/public/fonts/Hana2-R.woff new file mode 100644 index 0000000..a8388ee Binary files /dev/null and b/public/fonts/Hana2-R.woff differ diff --git a/public/fonts/Hana2-R.woff2 b/public/fonts/Hana2-R.woff2 new file mode 100644 index 0000000..a01cf15 Binary files /dev/null and b/public/fonts/Hana2-R.woff2 differ diff --git a/public/hana.png b/public/hana.png new file mode 100644 index 0000000..7c08786 Binary files /dev/null and b/public/hana.png differ diff --git a/public/img-hana-symbol-m.png b/public/img-hana-symbol-m.png new file mode 100644 index 0000000..c9cfc9b Binary files /dev/null and b/public/img-hana-symbol-m.png differ diff --git a/public/life_start.png b/public/life_start.png new file mode 100644 index 0000000..46af3cb Binary files /dev/null and b/public/life_start.png differ diff --git a/public/logo-8.png b/public/logo-8.png new file mode 100644 index 0000000..d196abb Binary files /dev/null and b/public/logo-8.png differ diff --git a/public/logo-eng.png b/public/logo-eng.png new file mode 100644 index 0000000..cc2ec8b Binary files /dev/null and b/public/logo-eng.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..03c6fbe Binary files /dev/null and b/public/logo.png differ diff --git a/public/split-start.png b/public/split-start.png new file mode 100644 index 0000000..3f061a8 Binary files /dev/null and b/public/split-start.png differ diff --git a/public/tutorial1.png b/public/tutorial1.png new file mode 100644 index 0000000..4ca0355 Binary files /dev/null and b/public/tutorial1.png differ diff --git a/public/tutorial2.png b/public/tutorial2.png new file mode 100644 index 0000000..b3246f6 Binary files /dev/null and b/public/tutorial2.png differ diff --git a/public/tutorial3.png b/public/tutorial3.png new file mode 100644 index 0000000..3ee3f64 Binary files /dev/null and b/public/tutorial3.png differ diff --git a/public/tutorial4.png b/public/tutorial4.png new file mode 100644 index 0000000..a80c31c Binary files /dev/null and b/public/tutorial4.png differ diff --git a/src/App.tsx b/src/App.tsx index 345464b..6bd1f0c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,48 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { Layout } from "./pages/Layout"; -import Splash from "./pages/start/Splash"; -import { TutorialPage1 } from "./pages/start/TutorialPage1"; +import { Splash } from "./pages/start/Splash"; +import { Tutorial1Page } from "./pages/start/Tutorial1Page"; +import { Tutorial2Page } from "./pages/start/Tutorial2Page"; +import { Tutorial3Page } from "./pages/start/Tutorial3Page"; +import { Tutorial4Page } from "./pages/start/Tutorial4Page"; +import { LoginPage } from "./pages/start/LoginPage"; import { HomePage } from "./pages/home/HomePage"; +import { GoalProductDetailPage } from "./pages/home/GoalProductDetailPage"; +import { SplitMainPage } from "./pages/split/SplitMainPage"; +import { SplitManualPage } from "./pages/split/SplitManualPage"; +import { SplitAutoPage } from "./pages/split/SplitAutoPage"; +import { ProductListPage } from "./pages/product/ProductListPage"; +import { ProductStartPage } from "./pages/product/ProductStartPage"; +import { ProductDetailPage } from "./pages/product/ProductDetailPage"; +import { ProductTermPage } from "./pages/product/ProductTermPage"; +import { ProductTermDetailPage } from "./pages/product/ProductTermDetailPage"; +import { ProductSignupPage } from "./pages/product/ProductSignupPage"; +import { ProductCompletePage } from "./pages/product/ProductCompletePage"; +import { MypagePage } from "./pages/mypage/MypagePage"; +import { SalaryPage } from "./pages/mypage/salary/SalaryPage"; +import { AccountPage } from "./pages/mypage/account/AccountPage"; +import { AccountAddPage } from "./pages/mypage/account/create/AccountAddPage"; +import { AccountTermPage } from "./pages/mypage/account/create/AccountTermPage"; +import { AccountCompletePage } from "./pages/mypage/account/create/AccountCompletePage"; +import { GoalListPage } from "./pages/mypage/goal/GoalListPage"; +import { GoalCreatePage } from "./pages/mypage/goal/GoalCreatePage"; +import { GoalDetailPage } from "./pages/mypage/goal/GoalDetailPage"; +import { AccountSettingPage } from "./pages/mypage/account/AccountSettingPage"; +import { AccountOpenListPage } from "./pages/mypage/account/AccountOpenListPage"; +import { AccountSavingListPage } from "./pages/mypage/account/AccountSavingListPage"; +import { AccountOpenUpdatePage } from "./pages/mypage/account/AccountOpenUpdatePage"; +import { LifePage } from "./pages/life/LifePage"; +import { ProductGoalPage } from "./pages/product/ProductGoalPage"; +import { SplitStartPage } from "./pages/split/SplitStartPage"; +import { SplitStartSettingPage } from "./pages/split/SplitStartSettingPage"; +import { SplitStartSplitPage } from "./pages/split/SplitStartSplitPage"; +import { SplitStartCompletePage } from "./pages/split/SplitStartCompletePage"; +import { TutorialPage } from "./pages/start/TutorialPage"; +import { LifeStartPage } from "./pages/life/LifeStartPage"; +import { AccountCreatePage } from "./pages/mypage/account/create/AccountCreatePage"; +import { AccountTermDetailPage } from "./pages/mypage/account/create/AccountTermDetailPage"; +import { GoalProductSelectPage } from "./pages/home/GoalProductSelectPage"; + function App() { return ( @@ -11,15 +51,88 @@ function App() { {/* NavBar 있는 화면 */} } /> + } /> + } + /> + + + } /> + } /> + + + } /> + } /> + + } /> + + } /> + + } /> + } /> + + } /> + + + + + } /> + } /> + } /> + } /> + + + } /> + } /> {/* NavBar 없는 화면 */} - - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + } /> + } /> + } + /> + } + /> + } + /> - - } /> + + } /> + + } /> + } /> + + + } /> + } /> + } /> + } /> + } /> + + + + + } /> + } /> + } /> diff --git a/src/assets/byul1.png b/src/assets/byul1.png new file mode 100644 index 0000000..f229271 Binary files /dev/null and b/src/assets/byul1.png differ diff --git a/src/assets/byul5.png b/src/assets/byul5.png new file mode 100644 index 0000000..79c2cee Binary files /dev/null and b/src/assets/byul5.png differ diff --git a/src/assets/img-hana-symbol-m.png b/src/assets/img-hana-symbol-m.png new file mode 100644 index 0000000..c9cfc9b Binary files /dev/null and b/src/assets/img-hana-symbol-m.png differ diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx new file mode 100644 index 0000000..112d43c --- /dev/null +++ b/src/components/ui/Checkbox.tsx @@ -0,0 +1,23 @@ + +interface CheckboxProps { + checked: boolean; + onChange: (checked: boolean) => void; + name: string; +} + +export const Checkbox = ({ checked, onChange, name } : CheckboxProps) => { + return ( + + ); +} \ No newline at end of file diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx new file mode 100644 index 0000000..6896710 --- /dev/null +++ b/src/components/ui/Modal.tsx @@ -0,0 +1,32 @@ +import React, { ReactNode } from "react"; +import clsx from "clsx"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children?: ReactNode; +} + +const Modal: React.FC = ({ isOpen, onClose, children }) => { + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +}; + +export default Modal; diff --git a/src/components/ui/PhoneModal.tsx b/src/components/ui/PhoneModal.tsx new file mode 100644 index 0000000..d04663c --- /dev/null +++ b/src/components/ui/PhoneModal.tsx @@ -0,0 +1,37 @@ +import React, { ReactNode } from "react"; +import clsx from "clsx"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children?: ReactNode; +} + +const PhoneModal: React.FC = ({ isOpen, onClose, children }) => { + return ( +
+
e.stopPropagation()} + style={{ maxHeight: "calc(3/4 * 100vh)" }} + > +
+
{children}
+
+
+
+
+
+ ); +}; + +export default PhoneModal; diff --git a/src/components/ui/TopLine.tsx b/src/components/ui/TopLine.tsx index a486341..fc32bc1 100644 --- a/src/components/ui/TopLine.tsx +++ b/src/components/ui/TopLine.tsx @@ -6,17 +6,16 @@ type Props = { export const TopLine = ({ name }: Props) => { return ( - <> -
- -

{name}

-
- +
+ +

{name}

+
); }; diff --git a/src/components/utils/formatters.ts b/src/components/utils/formatters.ts new file mode 100644 index 0000000..3a40c53 --- /dev/null +++ b/src/components/utils/formatters.ts @@ -0,0 +1,72 @@ +export const addCommas = (num: number): string => { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); +}; + +export const currentTime = (): string => { + const date = new Date(); + let hours = date.getHours(); + const minutes = date.getMinutes(); + + hours = hours % 12; + hours = hours ? hours : 12; + + const formattedMinutes = minutes.toString().padStart(2, '0'); + + return `${hours}:${formattedMinutes}`; +} + +export const dateToYYYYMM = (date: Date): string => { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + return `${year}${month}`; +}; + +// 'YYYY-MM-DD' to 'YYYY' +export const getYearFromDateString = (dateString: string): string => { + const parts = dateString.split("-"); + return parts[0]; +} + +// 'YYYY-MM-DD' to 'MM' +export const getMonthFromDateString = (dateString: string): string => { + const parts = dateString.split("-"); + return parts[1]; +} + +// 'YYYY-MM-DD' to 'YYYY년 MM월 DD일 W요일' +export const dateParse = (date: string): string => { + const dateObject = new Date(date); + const year = dateObject.getFullYear(); + const month = dateObject.getMonth() + 1; + const day = dateObject.getDate(); + const dayOfWeek = dateObject.getDay(); + + const daysOfWeek = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; + const dayName = daysOfWeek[dayOfWeek]; + + return `${year}년 ${month}월 ${day}일 ${dayName}`; +}; + +// 'YYYYMMDD' to 'YYYY-MM-DD' +export const goalDateParse = (date:string):string => { + const year = date.substring(0, 4); + const month = date.substring(4, 6); + const day = date.substring(6, 8); + + return `${year}-${month}-${day}`; +}; + +// date와 일자 to 'YYYY년 MM월 DD일 W요일' +export const getDateParseForLifePage = (date: Date, day: string): string => { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + + const dateStr = `${year}-${month}-${day}`; + const dateObject = new Date(dateStr); + const dayOfWeek = dateObject.getDay(); + + const daysOfWeek = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; + const dayName = daysOfWeek[dayOfWeek]; + + return `${year}년 ${month}월 ${day}일 ${dayName}`; +}; \ No newline at end of file diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e37b39a --- /dev/null +++ b/src/constants.ts @@ -0,0 +1 @@ +export const API_BASE_URL = import.meta.env.VITE_API_URI; diff --git a/src/contexts/ProductContext.tsx b/src/contexts/ProductContext.tsx new file mode 100644 index 0000000..96abaeb --- /dev/null +++ b/src/contexts/ProductContext.tsx @@ -0,0 +1,195 @@ +import { ReactNode, createContext, useContext, useReducer } from "react"; +import { ProductGetResponse } from "../pages/product/ProductListPage"; + +export type Goal = { + userGoalId: number; + goalAlias: string; + goalTypeCd: string; + goalSpecificId: number; + goalBeginDate: string; + duration: number; + amount: number; +}; + +type GoalProducts = { + goal: Goal; + products: ProductGetResponse; +}; + +type GoalsProducts = { + goalsProducts: GoalProducts[] | null; +}; + +type GoalsProductsContextProp = { + goalsProducts: GoalsProducts | null; + setGoal: (goals: Goal[]) => void; + createGoal: (goal: Goal) => void; + updateGoal: (goal: Goal) => void; + setProduct: (goalId: number, products: ProductGetResponse) => void; + updateProduct: (goalId: number) => void; + out: () => void; +}; + +type ProviderProp = { + children: ReactNode; +}; + +type Action = + | { type: "setGoal"; payload: Goal[] } //goal 채우기 + | { type: "createGoal"; payload: Goal } //goal 추가하기 + | { type: "updateGoal"; payload: Goal } //goal 그대로, product 비우기 + | { + type: "setProduct"; + payload: { goalId: number; products: ProductGetResponse }; + } //해당 goal에 대해서 product 없을 때, product 채우기 + | { type: "updateProduct"; payload: number } + | { type: "out"; payload: null }; //둘 다 비우기 + +const GPKEY = "goalsProducts"; +const DefaultGoalsProuducts: GoalsProducts = { + goalsProducts: null, +}; + +function setStorage(goalsProducts: GoalsProducts | null) { + localStorage.setItem(GPKEY, JSON.stringify(goalsProducts)); +} + +function getStorage() { + const storedGoalsProducts = localStorage.getItem(GPKEY); + if (storedGoalsProducts) { + return JSON.parse(storedGoalsProducts) as GoalsProducts; + } + setStorage(DefaultGoalsProuducts); + return DefaultGoalsProuducts; +} + +const GoalsProductsContext = createContext({ + goalsProducts: null, + setGoal: () => {}, + createGoal: () => {}, + updateGoal: () => {}, + setProduct: () => {}, + updateProduct: () => {}, + out: () => {}, +}); + +const reducer = (goalsProducts: GoalsProducts, { type, payload }: Action) => { + let newer: GoalsProducts = { ...goalsProducts }; + switch (type) { + case "setGoal": + newer = { + goalsProducts: payload.map((goal) => ({ + goal, + products: { recommendedProducts: [], enrolledProducts: [] }, + })), + }; + break; + case "createGoal": + newer = { + goalsProducts: newer.goalsProducts + ? [ + ...newer.goalsProducts, + { + goal: payload, + products: { recommendedProducts: [], enrolledProducts: [] }, + }, + ] + : [ + { + goal: payload, + products: { recommendedProducts: [], enrolledProducts: [] }, + }, + ], + }; + break; + case "updateGoal": + newer = { + goalsProducts: + newer.goalsProducts?.map((gp) => + gp.goal.userGoalId === payload.userGoalId + ? { + goal: payload, + products: { recommendedProducts: [], enrolledProducts: [] }, + } + : gp + ) || null, + }; + break; + case "setProduct": + newer = { + goalsProducts: + newer.goalsProducts?.map((gp) => + gp.goal.userGoalId === payload.goalId + ? { ...gp, products: payload.products } + : gp + ) || null, + }; + break; + case "updateProduct": + newer = { + goalsProducts: + newer.goalsProducts?.map((gp) => + gp.goal.userGoalId === payload + ? { + ...gp, + products: { recommendedProducts: [], enrolledProducts: [] }, + } + : gp + ) || null, + }; + break; + case "out": + newer = DefaultGoalsProuducts; + break; + default: + return goalsProducts; + } + setStorage(newer); + return newer; +}; + +export const GoalsProductsProvider = ({ children }: ProviderProp) => { + const [goalsProducts, dispatch] = useReducer(reducer, getStorage()); + + const setGoal = (goals: Goal[]) => { + dispatch({ type: "setGoal", payload: goals }); + }; + + const createGoal = (goal: Goal) => { + dispatch({ type: "createGoal", payload: goal }); + }; + + const updateGoal = (goal: Goal) => { + dispatch({ type: "updateGoal", payload: goal }); + }; + + const setProduct = (goalId: number, products: ProductGetResponse) => { + dispatch({ type: "setProduct", payload: { goalId, products } }); + }; + + const updateProduct = (goalId: number) => { + dispatch({ type: "updateProduct", payload: goalId }); + }; + + const out = () => { + dispatch({ type: "out", payload: null }); + }; + return ( + + {children} + + ); +}; + +// eslint-disable-next-line react-refresh/only-export-components +export const useGoalsProducts = () => useContext(GoalsProductsContext); diff --git a/src/contexts/UserContext.tsx b/src/contexts/UserContext.tsx new file mode 100644 index 0000000..f60579e --- /dev/null +++ b/src/contexts/UserContext.tsx @@ -0,0 +1,101 @@ +import { + ReactNode, + createContext, + useCallback, + useContext, + useReducer, +} from "react"; + +export type User = { + jwt: string | null; + name: string | null; + salary: string | null; +}; + +type UserContextProp = { + user: User; + login: (user: User) => void; + logout: () => void; + updateSalary: (salary: string) => void; +}; + +type ProviderProp = { + children: ReactNode; +}; + +type Action = + | { type: "login"; payload: User } + | { type: "logout" } + | { type: "updateSalary"; payload: string }; + +const UKEY = "user"; +const DefaultUser: User = { jwt: null, name: null, salary: null }; + +function setStorage(user: User) { + console.log("Setting storage:", user); + localStorage.setItem(UKEY, JSON.stringify(user)); +} + +function getStorage(): User { + const storedUser = localStorage.getItem(UKEY); + if (storedUser) { + console.log("Getting storage:", JSON.parse(storedUser)); + return JSON.parse(storedUser) as User; + } + return DefaultUser; +} + +const UserContext = createContext({ + user: DefaultUser, + login: () => {}, + logout: () => {}, + updateSalary: () => {}, +}); + +const reducer = (user: User, action: Action) => { + let newer: User; + switch (action.type) { + case "login": + newer = { ...action.payload }; + setStorage(newer); + + break; + case "logout": + newer = DefaultUser; + setStorage(newer); + break; + case "updateSalary": + newer = { ...user, salary: action.payload }; + setStorage(newer); + break; + default: + return user; + } + return newer; +}; + +export const UserProvider = ({ children }: ProviderProp) => { + const [user, dispatch] = useReducer(reducer, getStorage()); + + const login = useCallback((user: User) => { + console.log("Dispatch login:", user); + dispatch({ type: "login", payload: user }); + }, []); + + const logout = useCallback(() => { + dispatch({ type: "logout" }); + }, []); + + const updateSalary = useCallback((salary: string) => { + dispatch({ type: "updateSalary", payload: salary }); + }, []); + + return ( + + {children} + + ); +}; + +// eslint-disable-next-line react-refresh/only-export-components +export const useUser = () => useContext(UserContext); diff --git a/src/hooks/fetch.ts b/src/hooks/fetch.ts new file mode 100644 index 0000000..e2b9900 --- /dev/null +++ b/src/hooks/fetch.ts @@ -0,0 +1,51 @@ +import { useState, useEffect } from "react"; + +export interface FetchOptions { + method: "GET" | "POST" | "PUT" | "DELETE"; + headers?: Record; + body?: BodyInit | null; +} + +export const useFetch = ( + url: string, + options: FetchOptions +): { data: T | null; error: string | null; loading: boolean } => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch(url, { + method: options.method, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + body: options.method !== "GET" ? JSON.stringify(options.body) : null, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data: T = await response.json(); + setData(data); + } catch (error) { + if (error instanceof Error) { + setError(error.message); + } else { + setError("An unknown error occurred"); + } + } finally { + setLoading(false); + } + }; + + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url]); + + return { data, error, loading }; +}; diff --git a/src/index.css b/src/index.css index 08bc45e..2554262 100644 --- a/src/index.css +++ b/src/index.css @@ -7,31 +7,44 @@ max-width: 400px; max-height: 850px; margin: 0 auto; - background-color: #ffffff; + background-color: #fbfbfc; } html, body { max-width: 400px; - max-height: 850px; + max-height: 830px; margin: 0 auto; background-color: #dddddd; } .container { width: 400px; - height: 850px; + height: 830px; margin: 0 auto; /* Center the container */ } +* { + scrollbar-width: thin; + /* scrollbar-color: #e9e9e9 #f1f1f1; */ + scrollbar-color: #f1f1f1 #ffffff; +} + .nav-bar { background: linear-gradient(to bottom, #d2d2d2, #d2d2d2); } .nav-item { - color: #fff; + color: #828282; cursor: pointer; width: 70px; - height: 85px; + height: 70px; +} + +.selected-nav-item { + color: #008485; + cursor: pointer; + width: 70px; + height: 70px; } .nav-icon { @@ -43,9 +56,8 @@ body { } .green-button { - width: 350px; + width: 100%; height: 50px; - margin-left: 25px; background-color: #008485; color: white; border: none; @@ -69,3 +81,113 @@ body { margin: 15px; margin-left: 25px; } + +.hana-color { + background-color: #008485; +} + +.hana-text-color { + color: #008485; +} + +.shadow-top-lg { + box-shadow: 0 -18px 30px -3px rgba(0, 0, 0, 0.1), 0 -8px 10px -2px rgba(0, 0, 0, 0.05); +} + +.nav-shadow-top-lg { + box-shadow: 0 -18px 30px -3px rgba(0, 0, 0, 0.1), 0 -8px 10px -2px rgba(0, 0, 0, 0.05); +} + + +/* 폰트 설정 */ +@font-face { + font-family: 'Hana2.0 H'; + src: url('/fonts/Hana2-H.woff2') format('woff2'), + url('/fonts/Hana2-H.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Hana2.0 CM'; + src: url('/fonts/Hana2-CM.woff2') format('woff2'), + url('/fonts/Hana2-CM.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Hana2.0 B'; + src: url('/fonts/Hana2-B.woff2') format('woff2'), + url('/fonts/Hana2-B.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Hana2.0 L'; + src: url('/fonts/Hana2-L.woff2') format('woff2'), + url('/fonts/Hana2-L.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Hana2.0 R'; + src: url('/fonts/Hana2-R.woff2') format('woff2'), + url('/fonts/Hana2-R.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Hana2.0 M'; + src: url('/fonts/Hana2-M.woff2') format('woff2'), + url('/fonts/Hana2-M.woff') format('woff'); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +.font-hana-h{ + font-family: 'Hana2.0 H'; + font-weight: normal; + font-style: normal; +} + +.font-hana-cm{ + font-family: 'Hana2.0 CM'; + font-weight: normal; + font-style: normal; +} + +.font-hana-b{ + font-family: 'Hana2.0 B'; + font-weight: normal; + font-style: normal; +} + +.font-hana-l{ + font-family: 'Hana2.0 L'; + font-weight: normal; + font-style: normal; +} + +.font-hana-r{ + font-family: 'Hana2.0 R'; + font-weight: normal; + font-style: normal; +} + +.font-hana-m{ + font-family: 'Hana2.0 M'; + font-weight: normal; + font-style: normal; +} + + diff --git a/src/main.tsx b/src/main.tsx index f688424..6517257 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,10 +3,16 @@ import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; import { Header } from "./pages/common/Header.tsx"; +import { UserProvider } from "./contexts/UserContext.tsx"; +import { GoalsProductsProvider } from "./contexts/ProductContext.tsx"; ReactDOM.createRoot(document.getElementById("root")!).render(
- + + + + + ); diff --git a/src/pages/Layout.tsx b/src/pages/Layout.tsx index 4ea3366..ec96e01 100644 --- a/src/pages/Layout.tsx +++ b/src/pages/Layout.tsx @@ -4,8 +4,8 @@ import { NavBar } from "./common/NavBar"; export const Layout = () => { return ( <> -
-
+
+
diff --git a/src/pages/common/Header.tsx b/src/pages/common/Header.tsx index 36f487a..9a051de 100644 --- a/src/pages/common/Header.tsx +++ b/src/pages/common/Header.tsx @@ -1,8 +1,16 @@ +import { IoIosBatteryFull, IoIosWifi } from "react-icons/io"; +import { MdOutlineSignalCellularAlt } from "react-icons/md"; +import { currentTime } from "../../components/utils/formatters"; + export const Header = () => { return ( -
- Header Image -
+
+

{currentTime()}

+
+ + + +
); }; diff --git a/src/pages/common/NavBar.tsx b/src/pages/common/NavBar.tsx index a11edc7..0e2a169 100644 --- a/src/pages/common/NavBar.tsx +++ b/src/pages/common/NavBar.tsx @@ -1,54 +1,53 @@ -import { useState } from "react"; -// import { SlArrowRight } from "react-icons/sl"; -import { SlBookOpen, SlWallet, SlCreditCard, SlUser } from "react-icons/sl"; +import { useState } from 'react'; +import { + SlWallet, + SlCreditCard, + SlUser, + SlSocialDropbox, +} from 'react-icons/sl'; +import { useNavigate } from 'react-router-dom'; export const NavBar = () => { - const [selected, setSelected] = useState(0); + const navigate = useNavigate(); + const [selected, setSelected] = useState('/home'); - const handleClick = (index: number) => { - setSelected(index); + const handleNavigate = (path: string) => { + setSelected(path); + navigate(path); + }; + + const getIconClass = (path: string) => { + return selected === path ? 'nav-icon selected-nav-item' : 'nav-icon'; + }; + + const getTextClass = (path: string) => { + return selected === path ? 'text-xs font-bold mt-1 hana-text-color' : 'text-xs mt-1'; }; return ( - <> -
-
-
handleClick(0)} - > - -

상품

-
-
handleClick(1)} - > - -

통장쪼개기

-
-
handleClick(2)} - > - Home Image -
-
handleClick(2)} - > - -

생활

-
-
handleClick(2)} - > - -

마이페이지

-
+
+
+
handleNavigate('/life')}> + +

생활

+
+
handleNavigate('/split')}> + +

통장

+
+
handleNavigate('/home')}> + Home Image +
+
handleNavigate('/product/start')}> + +

상품

+
+
handleNavigate('/mypage')}> + +

마이페이지

- +
+
); }; diff --git a/src/pages/home/GoalProductDetail.tsx b/src/pages/home/GoalProductDetail.tsx new file mode 100644 index 0000000..b67d281 --- /dev/null +++ b/src/pages/home/GoalProductDetail.tsx @@ -0,0 +1,90 @@ +import { useParams } from "react-router-dom"; +import { API_BASE_URL } from "../../constants"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { UserGoalAccountGetResponse } from "./homeType"; +import { useEffect, useState } from "react"; + +export const GoalProductDetail = ({ accountId }: { accountId: number }) => { + const { user } = useUser(); + const { goalId } = useParams<{ goalId: string }>(); + + const [goalAccount, setGoalAccount] = + useState(null); + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/accounts/user-goal/${goalId}`, + fetchOptions + ); + + useEffect(() => { + if (data) { + const filteredAccount = data.find( + (account) => account.accountId === accountId + ); + setGoalAccount(filteredAccount || null); + } + }, [data, accountId]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + if (!goalAccount) return
해당 계좌를 찾을 수 없습니다.
; + + return ( + <> +
+

+ {goalAccount.productNm || "상품명 없음"} +

+
+

계좌번호: {goalAccount.accountNumber || "정보 없음"}

+

+ 개설일
{goalAccount.openingDate || "정보 없음"} +

+
+

+ 원금:{" "} + + {goalAccount.principal + ? `${goalAccount.principal.toLocaleString()} 원` + : "-"} + +

+

+ 목표 금액:{" "} + + {goalAccount.targetAmount + ? `${goalAccount.targetAmount.toLocaleString()} 원` + : "-"} + +

+

+ 쌓인 이자:{" "} + + {goalAccount.interestAmount + ? `${goalAccount.interestAmount.toLocaleString()} 원` + : "-"} + +

+
+
+ 만기일
2030.3.12 +
+ 하나주택청약종합저축 +
+
+ + ); +}; diff --git a/src/pages/home/GoalProductDetailPage.tsx b/src/pages/home/GoalProductDetailPage.tsx new file mode 100644 index 0000000..42f1112 --- /dev/null +++ b/src/pages/home/GoalProductDetailPage.tsx @@ -0,0 +1,23 @@ +import { useParams } from "react-router-dom"; +import { GoalProductDetail } from "./GoalProductDetail"; +import { GoalProductTransactionDetail } from "./GoalProductTransactionDetail"; +import { TopLine } from "../../components/ui/TopLine"; + +export const GoalProductDetailPage = () => { + const { accountId } = useParams(); + + return ( + <> +
+ 하나피스 +
+ +
+
+ + +
+
+ + ); +}; diff --git a/src/pages/home/GoalProductRecommend.tsx b/src/pages/home/GoalProductRecommend.tsx new file mode 100644 index 0000000..6e9d432 --- /dev/null +++ b/src/pages/home/GoalProductRecommend.tsx @@ -0,0 +1,191 @@ +import { useEffect } from "react"; +import { useGoalsProducts } from "../../contexts/ProductContext"; +import { useUser } from "../../contexts/UserContext"; +import { ProductGetResponse } from "../product/ProductListPage"; +import { API_BASE_URL } from "../../constants"; + +const calcExpectedAmount = (years: number, annualInterestRate: number, monthlySavings: number): string => { + const months = years * 12; + const monthlyInterestRate = annualInterestRate / 12 / 100; + + let totalAmount = 0; + + for (let i = 0; i < months; i++) { + totalAmount += monthlySavings; + totalAmount *= (1 + monthlyInterestRate); + } + + return (Math.floor(totalAmount/100)*100).toLocaleString(); +}; + +const GoalProductRecommend = ({ goalId }: { goalId: number }) => { + const { user } = useUser(); + const { goalsProducts, setProduct } = useGoalsProducts(); + const goalProduct = goalsProducts?.goalsProducts?.find( + (gp) => gp.goal.userGoalId === +goalId + ); + + useEffect(() => { + if (goalProduct && goalProduct.products.recommendedProducts.length === 0) { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/recommend/${goalId}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json: ProductGetResponse = await response.json(); + console.log(json); + setProduct(+goalId, json); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [goalId, user.jwt]); + + return ( + <> +
+

+ 아직 하나은행 적금이 없으시네요🥲
+ 다음 상품을 추천드려요 +

+
+
+

+ 설정 목표와 관련된 추천 적금 +

+

오늘 가입한다면?

+
+ {goalProduct?.products && + goalProduct.products.recommendedProducts.length > 0 ? ( + <> +
+
+
+
+ 하나은행 +
+
+ {goalProduct.products.recommendedProducts[0]?.productNm || + "상품명이 없습니다"} +
+
+
+
기간
+
+ {goalProduct.products.recommendedProducts[0]?.termYear || + "기간 정보 없음"} + 년 +
+
+
이자
+
+ + {goalProduct.products.recommendedProducts[0] + ?.interestRate || "이자율 없음"} + + % +
+
예상금액
+
+

+ 매달 10만원씩 적금한다면 + + {` ${calcExpectedAmount(goalProduct.products.recommendedProducts[0]?.termYear, goalProduct.products.recommendedProducts[0] + ?.interestRate, 100000)}`} + + 원 +

+

+ 매달 100만원씩 적금한다면 + + {` ${calcExpectedAmount(goalProduct.products.recommendedProducts[0]?.termYear, goalProduct.products.recommendedProducts[0] + ?.interestRate, 1000000)}`} + + 원 +

+
+
+
+
+ {goalProduct.products.recommendedProducts[1] && ( +
+
+
+
+ 하나은행 +
+
+ {goalProduct.products.recommendedProducts[1]?.productNm || + "상품명이 없습니다"} +
+
+
+
기간
+
+ {goalProduct.products.recommendedProducts[1]?.termYear || + "기간 정보 없음"} + 년 +
+
+
이자
+
+ + {goalProduct.products.recommendedProducts[1] + ?.interestRate || "이자율 없음"} + + % +
+
예상금액
+
+

+ 매달 10만원씩 적금한다면 + + {` ${calcExpectedAmount(goalProduct.products.recommendedProducts[1]?.termYear, goalProduct.products.recommendedProducts[0] + ?.interestRate, 100000)}`} + + 원 +

+

+ 매달 100만원씩 적금한다면 + + {` ${calcExpectedAmount(goalProduct.products.recommendedProducts[1]?.termYear, goalProduct.products.recommendedProducts[0] + ?.interestRate, 1000000)}`} + + 원 +

+
+
+
+
+ )} + + ) : ( +
Loading...
+ )} + + ); +}; + +export default GoalProductRecommend; diff --git a/src/pages/home/GoalProductSelectPage.tsx b/src/pages/home/GoalProductSelectPage.tsx new file mode 100644 index 0000000..d08f435 --- /dev/null +++ b/src/pages/home/GoalProductSelectPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { API_BASE_URL } from "../../constants"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { UserGoalAccountGetResponse } from "./homeType"; +import { GreenButton } from "../../components/ui/GreenButton"; +import GoalProductRecommend from "./GoalProductRecommend"; + +type Props = { + count: number; + name: string; + isSelected: boolean; + onSelect: () => void; +}; + +const GoalProduct = ({ count, name, isSelected, onSelect }: Props) => { + return ( +
+ 적금 {count} + {name} +
+ ); +}; + +export const GoalProductSelectPage = () => { + const { user } = useUser(); + const { goalId } = useParams(); + + const [goalAccount, setGoalAccount] = useState< + UserGoalAccountGetResponse[] | null + >(null); + const [selectedIdx, setSelectedIdx] = useState(0); + const [selectedAccount, setSelectedAccount] = useState(0); + const [isRecommend, setRecommend] = useState(false); + + const handleSelectAccount = (idx: number, accountId: number) => { + setSelectedIdx(idx); + setSelectedAccount(accountId); + }; + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/accounts/user-goal/${goalId}`, + fetchOptions + ); + + useEffect(() => { + if (data) { + console.log(data); + setGoalAccount(data); + if (data.length === 0) { + setRecommend(true); + } else { + setRecommend(false); // 추가: 데이터가 있는 경우 추천 상태를 false로 설정 + } + } + }, [data]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+ 하나피스 +
+
+
+

반갑습니다

+

{user.name} 님

+
+ {isRecommend ? ( + + ) : ( +
+
+

💰

+

연결된 적금 선택

+
+
+ {goalAccount?.map((account, index) => ( + + handleSelectAccount(index + 1, account.accountId) + } + /> + ))} +
+ +
+ )} +
+ + ); +}; diff --git a/src/pages/home/GoalProductTransactionDetail.tsx b/src/pages/home/GoalProductTransactionDetail.tsx new file mode 100644 index 0000000..fadd1bb --- /dev/null +++ b/src/pages/home/GoalProductTransactionDetail.tsx @@ -0,0 +1,62 @@ +import { SlCreditCard } from "react-icons/sl"; +import { UserGoalTransactionResponse } from "./homeType"; +import { + addCommas, + getMonthFromDateString, +} from "../../components/utils/formatters"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { useUser } from "../../contexts/UserContext"; +import { API_BASE_URL } from "../../constants"; + +export const GoalProductTransactionDetail = ({ + accountId, +}: { + accountId: number; +}) => { + const { user } = useUser(); + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/accounts/${accountId}/transactions/goal-installment-saving`, + fetchOptions + ); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+
+

납부 내역

+ +
+ {data?.map((transaction, index) => ( +
+
+ +
+
+

+ {getMonthFromDateString(transaction.transactionDate)}월 납부금 +

+

+ {transaction.transactionDate} +

+
+
+ {transaction.amount < 0 ? "" : "+"} + {addCommas(transaction.amount)} 원 +
+
+ ))} +
+ + ); +}; diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index a23c54b..ea14f15 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -1,8 +1,180 @@ +import { useNavigate } from "react-router-dom"; +import { + getMonthFromDateString, + getYearFromDateString, + goalDateParse, +} from "../../components/utils/formatters"; +import { FaPlus } from "react-icons/fa"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { UserGoalGetResponse } from "./homeType"; +import { useUser } from "../../contexts/UserContext"; +import { API_BASE_URL } from "../../constants"; +import { useEffect } from "react"; + +const GoalBox = ({ goal }: { goal: UserGoalGetResponse }) => { + const navigate = useNavigate(); + const goToSelect = (userGoalId: number) => { + navigate(`${userGoalId}`); + }; + + const beginDate = (date: string) => { + const goalDate = goalDateParse(date); + return ( + getYearFromDateString(goalDate) + "." + getMonthFromDateString(goalDate) + ); + }; + + const gradientClass = + goal.goalTypeCd === "CAR" + ? "bg-violet-50" + : goal.goalTypeCd === "HOUSE" + ? "bg-sky-50" + : goal.goalTypeCd === "WISH" + ? "bg-lime-50" + : "bg-stone-50"; + + const icon = + goal.goalTypeCd === "CAR" + ? "🚗" + : goal.goalTypeCd === "HOUSE" + ? "🏠" + : goal.goalTypeCd === "WISH" + ? "🙏" + : "💰"; + + return ( + <> +
goToSelect(goal.userGoalId)} + > +
+ 목표 {goal.userGoalId} +
+
+
+
+ + {goal.goalAlias} + +
+
+
+
+ {beginDate(goal.goalBeginDate)} ~ +
+
+ {goal.enrolledProducts.length >= 1 + ? goal.enrolledProducts[0].enrolledProductName + + (goal.enrolledProducts.length === 1 + ? `` + : ` 외 ${goal.enrolledProducts.length - 1}개`) + : null} +
+
+
+
+
현재 저축 금액
+
+
+
+ {goal.savingMoney.toLocaleString()} 원 +
+ +
+
{icon}
+
+
+
+ + ); +}; + +const calcTotalAmount = (goals: UserGoalGetResponse[] = []) => { + let totalAmount = 0; + goals.forEach((goal) => { + totalAmount += goal.savingMoney; + }); + return totalAmount; +}; + export const HomePage = () => { + const navigate = useNavigate(); + const { user } = useUser(); + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/user-goals/list`, + fetchOptions + ); + + useEffect(() => { + if (data) { + console.log(data); + } + }, [data]); + + const totalAmount = calcTotalAmount(data || []); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + return ( <> -
-

homePage

+
+ +
+
+
+

반갑습니다

+

{user.name} 님

+
+ {totalAmount.toString.length>10 ? ( + <> +
+

💰현재 저축액

+

+ {totalAmount.toLocaleString()}{" "} + +

+
+ + ): ( + <> +
+

💰현재 저축액 :

+

+ {totalAmount.toLocaleString()}{" "} + +

+
+ + )} + + + {data?.map((goal) => ( +
+ +
+ ))} + +
navigate("/mypage/goal/0/create")} + > +
+ +
+

목표를 추가해보세요!

+
); diff --git a/src/pages/home/homeType.d.ts b/src/pages/home/homeType.d.ts new file mode 100644 index 0000000..8aff351 --- /dev/null +++ b/src/pages/home/homeType.d.ts @@ -0,0 +1,31 @@ +export interface UserGoalAccountGetResponse { + accountId: number; + productNm: string; + accountNumber: string; + openingDate: string; + principal: number; + targetAmount: number; + interestAmount: number; +} + +export interface UserGoalTransactionResponse { + amount: number; + transactionDate: string; +} + +export interface enrolled { + enrolledProductId: number; + enrolledProductName: string; +} + +export interface UserGoalGetResponse { + userGoalId: number; + goalAlias: string; + goalTypeCd: "CAR" | "HOUSE" | "WISH"; + goalSpecificId: number; + goalBeginDate: string; + duration: number; + amount: number; + enrolledProducts: enrolled[]; + savingMoney: number; +} diff --git a/src/pages/life/ConsumptionChart.tsx b/src/pages/life/ConsumptionChart.tsx new file mode 100644 index 0000000..37db662 --- /dev/null +++ b/src/pages/life/ConsumptionChart.tsx @@ -0,0 +1,104 @@ +import { Doughnut } from 'react-chartjs-2'; +import { + Chart as ChartJS, + ArcElement, + Tooltip, + Legend, + CategoryScale, + LinearScale, + Title, + ChartData, + ChartOptions, +} from 'chart.js'; +import ChartDataLabels, { Context } from 'chartjs-plugin-datalabels'; + +// Register components and plugins +ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, Title, ChartDataLabels); + +interface ConsumptionChartProps { + amountByType: AmountByType; +} + +interface DataLabelsContext extends Context { + chart: ChartJS; + dataIndex: number; +} + +const keyTranslation: { [key: string]: string } = { + SHOPPING: '쇼핑', + LEISURE: '레저', + FOOD: '음식', + TRANSPORT: '교통', +}; + +const translateKeys = (amountByType: AmountByType): AmountByType => { + const translatedAmountByType: AmountByType = {}; + for (const key in amountByType) { + if (keyTranslation[key]) { + translatedAmountByType[keyTranslation[key]] = amountByType[key]; + } + } + return translatedAmountByType; +}; + + +const ConsumptionChart = ({ amountByType }:ConsumptionChartProps) => { + const translatedAmountByType = translateKeys(amountByType); + const consumptionLabels = Object.keys(translatedAmountByType); + const consumptionData = Object.values(translatedAmountByType); + + const data: ChartData<'doughnut', number[], string> = { + labels: consumptionLabels, + datasets: [ + { + label: '총합', + data: consumptionData, + backgroundColor: ['rgb(255, 99, 132)', 'rgb(54, 162, 235)', 'rgb(255, 205, 86)', 'rgb(54, 205, 86)'], + hoverOffset: 4, + }, + ], + }; + + const options: ChartOptions<'doughnut'> = { + responsive: true, + layout: { + padding: 20, // 차트 주변의 패딩을 추가 + }, + plugins: { + legend: { + display: false, // 레이블을 비활성화 + }, + title: { + display: false, + text: '소비 통계', + }, + datalabels: { + color: '#008485', + anchor: 'end', + align: 'end', + offset: -15, + borderColor: '#008485', + borderWidth: 1, + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,10)', + formatter: (_value: number, context: DataLabelsContext) => { + return `${context.chart.data.labels?.[context.dataIndex]}`; + }, + labels: { + title: { + font: { + weight: 'bold', + }, + }, + } + }, + }, + }; + + + return ( + + ); +}; + +export default ConsumptionChart; diff --git a/src/pages/life/LifePage.tsx b/src/pages/life/LifePage.tsx new file mode 100644 index 0000000..6951b17 --- /dev/null +++ b/src/pages/life/LifePage.tsx @@ -0,0 +1,234 @@ +import { useEffect, useState } from 'react'; +import { SlArrowLeft, SlArrowRight } from 'react-icons/sl'; +import ConsumptionChart from './ConsumptionChart'; +import { addCommas, dateToYYYYMM, getDateParseForLifePage } from '../../components/utils/formatters'; +import { useUser } from '../../contexts/UserContext'; +import { API_BASE_URL } from '../../constants'; +import { useNavigate } from 'react-router-dom'; + +const TodayDate = (): Date => { + return new Date(); +}; + +const AdjustMonth = (date: Date, adjustValue: number): Date => { + const newDate = new Date(date); + newDate.setMonth(newDate.getMonth() + adjustValue); + return newDate; +}; + +const icon = (transactionType: string): string => { + switch (transactionType) { + case "SHOPPING": + return "🛍️"; + case "FOOD": + return "🍔"; + case "TRANSPORT": + return "🚌"; + case "LEISURE": + return "🎮"; + default: + return "💸"; + } +}; + +const transactionTypeKor = (transactionType: string): string => { + switch (transactionType) { + case "SHOPPING": + return "쇼핑"; + case "FOOD": + return "음식"; + case "TRANSPORT": + return "교통"; + case "LEISURE": + return "문화"; + default: + return "소비"; + } +}; + +const groupByDate = ( + data: DailyTransaction[] +): { [key: string]: DailyTransaction[] } => { + return data.reduce((acc, curr) => { + const date = curr.transactionDay; + if (!acc[date]) { + acc[date] = []; + } + acc[date].push(curr); + return acc; + }, {} as { [key: string]: DailyTransaction[] }); +}; + +const calculateTotalAmount = (data: DailyTransaction[]): number => { + return data.reduce((acc, curr) => acc + curr.amount, 0); +}; + +const getLifeAccount = async ( + jwt: string | null, + setAccountId: (id: number) => void, + navigate: (path: string) => void, + setLoading: (loading: boolean) => void +) => { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts/checking`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${jwt}`, + }, + }); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data: AccountGetResponse[] = await response.json(); + const lifeAccount = data.find(account => account.accountTypeCd === 'LIFE'); + if (lifeAccount) { + setAccountId(lifeAccount.accountId); + } else { + navigate("start"); + } + } catch (error) { + console.error('Fetch error:', error); + } finally { + setLoading(false); + } +}; + +export const LifePage = () => { + const [date, setDate] = useState(TodayDate()); + const [accountId, setAccountId] = useState(0); + const [monthlyData, setMonthlyData] = useState(); + const [amountByType, setAmountByType] = useState<{ [key: string]: number }>({}); + const [dailyTransaction, setDailyTransaction] = useState([]); + const [loading, setLoading] = useState(true); + const { user } = useUser(); + const navigate = useNavigate(); + + const getAccountTransaction = async (yearMonth: string): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts/${accountId}/transactions?transactionYearMonth=${yearMonth}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.jwt}`, + }, + }); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data: MonthlyTransaction = await response.json(); + setMonthlyData(data); + setAmountByType(data.amountByType); + setDailyTransaction(data.dailyTransactionList); + } catch (error) { + console.error('Fetch error:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (accountId === 0) { + getLifeAccount(user.jwt, setAccountId, navigate, setLoading); + } else { + getAccountTransaction(dateToYYYYMM(date)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountId, date, navigate, user.jwt]); + + const handlePreviousMonth = () => { + setDate(AdjustMonth(date, -1)); + }; + + const handleNextMonth = () => { + setDate(AdjustMonth(date, 1)); + }; + + const groupedData = groupByDate(dailyTransaction); + const sortedDates = Object.keys(groupedData).sort((a, b) => parseInt(b) - parseInt(a)); + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

생활

+
+
+ +
+ {date.getMonth() + 1}월 +
+ +
+
+

지출 통계

+
+
+
+ {dailyTransaction.length !== 0 ? ( + + ) : ( + transaction_is_none + )} +
+
+

현재 사용액

+

+ {monthlyData?.monthlyTotalSpending ? ( + Math.abs(monthlyData.monthlyTotalSpending).toLocaleString() + ) : 0}원 +

+

+ / {monthlyData?.autoDebitTotalAmount ? ( + Math.abs(monthlyData.autoDebitTotalAmount).toLocaleString() + ) : 0}원 +

+
+
+
+
+
+
+

지출 내역

+ {dailyTransaction.length !== 0 ? ( + <> + {sortedDates.map((day) => ( +
+

{getDateParseForLifePage(date, day)}

+
+ {addCommas(calculateTotalAmount(groupedData[day]))}원 +
+ {groupedData[day].map((item, index) => ( +
+
+

{icon(item.accountTransactionType)}

+
+
+

{item.targetNm}

+

{transactionTypeKor(item.accountTransactionType)}

+
+
+ {addCommas(item.amount)}원 +
+
+ ))} +
+ ))} + + ) : ( +
+ 이번 달 소비내역이 없습니다. +
+ )} +
+
+
+ ); +}; + +export default LifePage; diff --git a/src/pages/life/LifeStartPage.tsx b/src/pages/life/LifeStartPage.tsx new file mode 100644 index 0000000..738a186 --- /dev/null +++ b/src/pages/life/LifeStartPage.tsx @@ -0,0 +1,20 @@ +import { GreenButton } from "../../components/ui/GreenButton"; +import { TopLine } from "../../components/ui/TopLine"; + +export const LifeStartPage = () => { + return ( + <> + +
+
+

생활 시작 하기

+

소비 내역을 확인하려면
소비 통장을 먼저 설정해주세요!

+
+
+ 소비내역조회_시작 +
+ +
+ + ); +}; \ No newline at end of file diff --git a/src/pages/life/lifeType.d.ts b/src/pages/life/lifeType.d.ts new file mode 100644 index 0000000..6018170 --- /dev/null +++ b/src/pages/life/lifeType.d.ts @@ -0,0 +1,28 @@ +interface AccountGetResponse { + accountId: number; + accountNumber: string; + accountTypeCd: string; +} + +interface MonthlyTransaction{ + autoDebitTotalAmount:number; + monthlyTotalSpending:number; + amountByType:AmountByType; + amountByDay:AmountByDay; + dailyTransactionList:DailyTransaction[]; +} + +interface DailyTransaction{ + transactionDay:number; + amount:number; + accountTransactionType:string; + targetNm:string; +} + +type AmountByType = { + [key : string]: number; +} + +interface AmountByDay { + [key: string]: number; +} \ No newline at end of file diff --git a/src/pages/mypage/MypagePage.tsx b/src/pages/mypage/MypagePage.tsx new file mode 100644 index 0000000..dfcec0e --- /dev/null +++ b/src/pages/mypage/MypagePage.tsx @@ -0,0 +1,87 @@ +import { + SlArrowRight, + SlBell, + SlBookOpen, + SlCalculator, + SlPieChart, + SlSettings, +} from "react-icons/sl"; +import { useNavigate } from "react-router-dom"; + +export const MypagePage = () => { + const navigate = useNavigate(); + + return ( + <> +
+
+ Hana Image + 김하나 님 +
+
메뉴
+
+
navigate("salary")} + className="grid grid-cols-10 cursor-pointer mt-2 p-2 hover:shadow-lg" + > +
+ +
+

월급관리

+
+ +
+
+
navigate("account")} + className="grid grid-cols-10 cursor-pointer pt-3 p-2 hover:shadow-lg" + > +
+ +
+

계좌관리

+
+ +
+
+
navigate("goal")} + className="grid grid-cols-10 cursor-pointer pt-3 p-2 hover:shadow-lg" + > +
+ +
+

목표 관리

+
+ +
+
+
navigate("")} + className="grid grid-cols-10 cursor-pointer pt-3 p-2 hover:shadow-lg" + > +
+ +
+

알림 관리

+
+ +
+
+
navigate("")} + className="grid grid-cols-10 cursor-pointer pt-3 p-2 hover:shadow-lg" + > +
+ +
+

설정

+
+ +
+
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/AccountOpenListPage.tsx b/src/pages/mypage/account/AccountOpenListPage.tsx new file mode 100644 index 0000000..a607191 --- /dev/null +++ b/src/pages/mypage/account/AccountOpenListPage.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { GreenButton } from "../../../components/ui/GreenButton"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useUser } from "../../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../../hooks/fetch"; +import { API_BASE_URL } from "../../../constants"; + +type AccountGetResponse = { + accountId: number; + accountNumber: string; + accountTypeCd: string; +}; + +type Props = { + count: number; + number: string; + state: string; +}; + +const stateKor = (state:string) => { + switch(state){ + case "SALARY": + return "월급"; + case "SAVING": + return "저축"; + case "LIFE": + return "생활"; + case "SPARE": + return "예비"; + } +}; + +const Account = ({ count, number, state }: Props) => { + return ( + <> +
+ 계좌 {count} + {number} + {state !== "CHECKING" && ( + + {stateKor(state)} + + )} +
+ + ); +}; + +export const AccountOpenListPage = () => { + const { user } = useUser(); + const [accounts, setAccounts] = useState(null); + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/accounts/checking`, + fetchOptions + ); + + useEffect(() => { + if (data) { + setAccounts(data); + } + }, [data]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+ +
+
+ 입출금 통장 +
+ {accounts?.map((account, count) => ( + + ))} +
+
+ +
+
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/AccountOpenUpdatePage.tsx b/src/pages/mypage/account/AccountOpenUpdatePage.tsx new file mode 100644 index 0000000..9864399 --- /dev/null +++ b/src/pages/mypage/account/AccountOpenUpdatePage.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } from "react"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useUser } from "../../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../../hooks/fetch"; +import { useNavigate } from "react-router-dom"; +import { API_BASE_URL } from "../../../constants"; +import Modal from "../../../components/ui/Modal"; +type AccountGetResponse = { + accountId: number; + accountNumber: string; + accountTypeCd: string; +}; +export const AccountOpenUpdatePage = () => { + const navigate = useNavigate(); + const { user } = useUser(); + const [isModalOpen, setModalOpen] = useState(false); + const [accounts, setAccounts] = useState(null); + const [selectedAccounts, setSelectedAccounts] = useState<{ + SALARY: number | null; + SAVING: number | null; + LIFE: number | null; + SPARE: number | null; + }>({ + SALARY: null, + SAVING: null, + LIFE: null, + SPARE: null, + }); + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/accounts/checking`, + fetchOptions + ); + useEffect(() => { + if (data) { + setAccounts(data); + const salaryAccount = data.find( + (account) => account.accountTypeCd === "SALARY" + ); + const savingAccount = data.find( + (account) => account.accountTypeCd === "SAVING" + ); + const lifeAccount = data.find( + (account) => account.accountTypeCd === "LIFE" + ); + const spareAccount = data.find( + (account) => account.accountTypeCd === "SPARE" + ); + setSelectedAccounts({ + SALARY: salaryAccount ? salaryAccount.accountId : null, + SAVING: savingAccount ? savingAccount.accountId : null, + LIFE: lifeAccount ? lifeAccount.accountId : null, + SPARE: spareAccount ? spareAccount.accountId : null, + }); + } + }, [data]); + const allSelected = Object.values(selectedAccounts).every( + (value) => value !== null + ); + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + const handleSelectChange = ( + event: React.ChangeEvent, + type: string + ) => { + const value = parseInt(event.target.value, 10); + setSelectedAccounts({ + ...selectedAccounts, + [type]: value, + }); + }; + const getFilteredAccounts = (excludeType: string) => { + const selectedIds = Object.keys(selectedAccounts) + .filter((key) => key !== excludeType) + .map((key) => selectedAccounts[key as keyof typeof selectedAccounts]) + .filter((id) => id !== null); + const filteredAccounts = accounts?.filter( + (account) => !selectedIds.includes(account.accountId) + ); + return filteredAccounts || []; + }; + const renderSelectOptions = (type: keyof typeof selectedAccounts) => { + const options = getFilteredAccounts(type).map((account) => ( + + )); + if (!selectedAccounts[type]) { + options.unshift( + + ); + } + return options; + }; + const buttonClicked = () => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/account-type-reg`, + { + method: "post", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + salaryAccountId: selectedAccounts.SALARY, + savingAccountId: selectedAccounts.SAVING, + lifeAccountId: selectedAccounts.LIFE, + spareAccountId: selectedAccounts.SPARE, + }), + } + ); + if (response.ok) { + navigate("/mypage"); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }; + return ( + <> +
+ +
+ 입출금 통장 +
+
+

💸 월급 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+
+

💰 저축 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+
+

💳 소비 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+
+

💡 예비 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+ +
+
+ setModalOpen(false)}> +

✔️

+

+ 정말로 수정하시겠습니까? +

+

계좌 수정 시 모든 정보가 초기화됩니다.

+

+ (통장쪼개기 자동 이체, 소비 내역 등) +

+ +
+
+ + ); +}; diff --git a/src/pages/mypage/account/AccountPage.tsx b/src/pages/mypage/account/AccountPage.tsx new file mode 100644 index 0000000..ade265e --- /dev/null +++ b/src/pages/mypage/account/AccountPage.tsx @@ -0,0 +1,50 @@ +import { SlArrowRight, SlPlus, SlWallet } from "react-icons/sl"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useNavigate } from "react-router-dom"; + +export const AccountPage = () => { + const navigate = useNavigate(); + + // onClick 이벤트 핸들러 + const handleCreateAccountClick = () => { + navigate("create"); + }; + + const handleAccountSettingClick = () => { + navigate("setting"); + }; + + return ( + <> + +
+
+
+
+ +
+

계좌 생성

+
+ +
+
+
+
+ +
+

계좌 설정

+
+ +
+
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/AccountSavingListPage.tsx b/src/pages/mypage/account/AccountSavingListPage.tsx new file mode 100644 index 0000000..5ddfb4d --- /dev/null +++ b/src/pages/mypage/account/AccountSavingListPage.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useUser } from "../../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../../hooks/fetch"; +import { API_BASE_URL } from "../../../constants"; + +type AccountGetResponse = { + accountId: number; + accountNumber: string; + accountTypeCd: string; +}; + +type Props = { + count: number; + number: string; +}; + +const Account = ({ count, number }: Props) => { + return ( + <> +
+ 적금 {count} + {number} +
+ + ); +}; + +export const AccountSavingListPage = () => { + const { user } = useUser(); + const [accounts, setAccounts] = useState(null); + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/accounts/installment-saving`, + fetchOptions + ); + + useEffect(() => { + if (data) { + setAccounts(data); + } + }, [data]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+ +
+ 적금 통장 +
+ {accounts?.map((account, count) => ( + + ))} +
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/AccountSettingPage.tsx b/src/pages/mypage/account/AccountSettingPage.tsx new file mode 100644 index 0000000..81a9163 --- /dev/null +++ b/src/pages/mypage/account/AccountSettingPage.tsx @@ -0,0 +1,34 @@ +import { SlArrowRight, SlBookOpen } from "react-icons/sl"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useNavigate } from "react-router-dom"; + +export const AccountSettingPage = () => { + const navigate = useNavigate(); + return ( + <> +
+ +
+
+
navigate("open")} + > +
+

입출금 통장

+
+
+
navigate("saving")} + > +
+

적금 통장

+
+
+
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/AccountTermPage.tsx b/src/pages/mypage/account/AccountTermPage.tsx new file mode 100644 index 0000000..73be0fa --- /dev/null +++ b/src/pages/mypage/account/AccountTermPage.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { GreenButton } from "../../../components/ui/GreenButton"; +import { TopLine } from "../../../components/ui/TopLine"; +import PhoneModal from "../../../components/ui/PhoneModal"; + +type Props = { + name: string; + content: string; +}; + +const AccountTerm = ({ name, content }: Props) => { + return ( + <> +
+
+ +
+
+
{name}
+
{content}
+
+
+
+ + ); +}; + +export const AccountTermPage = () => { + const [isModalOpen, setModalOpen] = useState(false); + return( + <> +
+ +
+
+
+ +
+

계좌 생성

+
+ + +
+ +
+

+ [필수] 입력하신 이메일로 상품 이용약관 설명서가 발송됩니다. +

+ + + setModalOpen(false)}> +

📢

+

중요사항을 충분히 이해하고
확인하셨나요?

+

금융소비자가 충분한 이해없이 확인한 경우,
추후 소송이나 분쟁에서 불리하게 적용될 수 있습니다.
고객센터 1599-3333

+ +
+
+
+ + ); +}; \ No newline at end of file diff --git a/src/pages/mypage/account/create/AccountAddPage.tsx b/src/pages/mypage/account/create/AccountAddPage.tsx new file mode 100644 index 0000000..c6a8ae5 --- /dev/null +++ b/src/pages/mypage/account/create/AccountAddPage.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { GreenButton } from "../../../../components/ui/GreenButton"; +import PhoneModal from "../../../../components/ui/PhoneModal"; +import { TopLine } from "../../../../components/ui/TopLine"; + +export const AccountAddPage = () => { + const [isModalOpen, setModalOpen] = useState(false); + return ( + <> +
+ +
+
+
+
+
+
+ 하나은행 +
+
+ 개인 정보 입력 +
+
+
+
+
이름
+
+ +
+
+
+
생년월일
+
+ +
+
+
+
핸드폰번호
+
+ +
+
+
+
이메일주소
+
+ +
+
+
+ setModalOpen(false)}> +

✔️

+

+ 정말로 가입하시겠습니까? +

+
+ +
+
+
+ +
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/create/AccountCompletePage.tsx b/src/pages/mypage/account/create/AccountCompletePage.tsx new file mode 100644 index 0000000..d809985 --- /dev/null +++ b/src/pages/mypage/account/create/AccountCompletePage.tsx @@ -0,0 +1,98 @@ +import { SlEnvolope, SlUser } from "react-icons/sl"; +import { GreenButton } from "../../../../components/ui/GreenButton"; +import { TopLine } from "../../../../components/ui/TopLine"; +import { useState, useEffect } from "react"; +import { useUser } from "../../../../contexts/UserContext"; +import { API_BASE_URL } from "../../../../constants"; + +type AccountGetResponse = { + accountNumber: string; +}; + +export const AccountCompletePage = () => { + const { user } = useUser(); + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; // Add this line + const fetchAccountData = async () => { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + "Cache-Control": "no-cache", // Add cache control header + }, + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data: AccountGetResponse = await response.json(); + if (isMounted) { + // Check if component is still mounted + setAccount(data.accountNumber); + console.log(data.accountNumber); + } + } catch (err) { + console.log(err); + } finally { + if (isMounted) { + // Check if component is still mounted + setLoading(false); + } + } + }; + + fetchAccountData(); + + return () => { + isMounted = false; // Cleanup function to set isMounted to false + }; + }, [user.jwt]); + + if (loading) return
Loading...
; + + return ( +
+ +
+
+
+
+
+ 🎊 +
+ 계좌가 +
+ 생성되었습니다 +
+
+
+

은행

+
+
+ +
+
하나은행
+
+
+
+

계좌 번호

+
+
+ +
+
{account}
+
+
+
+ +
+
+
+ ); +}; diff --git a/src/pages/mypage/account/create/AccountCreatePage.tsx b/src/pages/mypage/account/create/AccountCreatePage.tsx new file mode 100644 index 0000000..8d88402 --- /dev/null +++ b/src/pages/mypage/account/create/AccountCreatePage.tsx @@ -0,0 +1,77 @@ +import { GreenButton } from "../../../../components/ui/GreenButton"; +import { TopLine } from "../../../../components/ui/TopLine"; + +export const AccountCreatePage = () => { + return ( + <> +
+ +
+
+
+
+
+
+
+
+ 하나은행 +
+
+

입출금 계좌

+
+
+
+ 상품 안내 +
+
+
+
+
+ 상품 특징 +
+
+ 급여이체 하나만으로 우대금리 및 수수료 면제 서비스를 제공하는 + 급여 통장 +
+
+ 가입 대상 +
+
+ 만 14세 이상 실명의 개인 또는 개인사업자(1인 1계좌) +
+
+ 이자지급

+ 이자지급 +
+ 방법 +
+
+ 이자계산기간동안 매일의 최종잔액에 고시금리를 적용한 월별이자를 + 합산하여 지급 이자계산기간: 예금일(또는 원가일)부터 원가일(또는 + 지급일) 전날까지의 기간 이자결산일: 매월 제3금요일 + 이자지급일(원가일): 이자결산일의 익영업일 +
+
+ 기본금리 +
+
+ (2024-06-09 기준, 연율, 세전)
금액이 5천만원 미만일 때 + 금리 0.1%
금액이 5천만원 이상일 때 금리 0.1% +
+
+
+ + +
+
+ + ); +}; diff --git a/src/pages/mypage/account/create/AccountTermDetailPage.tsx b/src/pages/mypage/account/create/AccountTermDetailPage.tsx new file mode 100644 index 0000000..9b40a01 --- /dev/null +++ b/src/pages/mypage/account/create/AccountTermDetailPage.tsx @@ -0,0 +1,99 @@ +import { useState } from "react"; +import { TopLine } from "../../../../components/ui/TopLine"; +import { GreenButton } from "../../../../components/ui/GreenButton"; +import PhoneModal from "../../../../components/ui/PhoneModal"; + +type Term = { + id: number; + name: string; + content: string; +}; + +const terms: Term[] = [ + { + id: 1, + name: "유의사항", + content: + "이 예금은 양도 및 상속에 의한 명의변경이 불가합니다. 단, 상속에 의한 해지는 가능합니다. 이 예금의 신규계좌수 30만좌 제한에 따라 판매가 중단될수 있습니다. ※ 금융상품에 관한 계약을 체결하기 전에 금융상품 설명서 및 약관을 읽어 보시기 바랍니다. ※ 금융소비자는 해당 상품 또는 서비스에 대하여 설명 받을 권리가 있습니다. ※ 이 홍보물은 법령 및 내부통제기준에 따른 절차를 거쳐 제공됩니다.", + }, + { + id: 2, + name: "세제혜택", + content: + "비과세 종합저축으로 가입 가능(전 금융기관 통합한도 범위내) 관련 세법이 개정될 경우 세율이 변경되거나 세금이 부과될 수 있으며, 계약기간 이후의 이자는 과세됨", + }, + { + id: 3, + name: "원금 및 이자지급제한", + content: + "계좌에 압류, 가압류, 질권설정 등이 등록될 경우 원금 및 이자지급 제한될 수 있습니다. ※ 민사집행법에 따라 최저생계비 이하 등 압류금지 채 권에 해당하는 경우에는 법원에 압류금지채권범위 변경 신청 등을 통해 압류를 취소할 수 있습니다. 예금잔액증명서 발급 당일에는 잔액 변동 불가합니다. 통장이 ‘전기통신금융사기 피해 방지 및 피해금 환급에 관한 특별법’에서 정의한 피해 의심거래계좌 및 사기이용 계좌로 이용될 경우 이체, 송금지연, 지급정지 등의 금융거래 제한조치를 할 수 있습니다.", + }, + { + id: 4, + name: "위법계약해지권", + content: + "금융소비자 보호에 관한 법률 제47조에 따른 위법계약해지 사유가 발생한 경우, 계약체결일로부터 5년 이내 범위에서 위반사실을 안 날로부터 1년 이내에 서면 등으로 해당 계약의 해지를 요구할 수 있습니다. 이 경우 금융회사는 해지를 요구받은 날부터 10일 이내에 금융소비자에게 수락 여부를 통지하여야 하며, 거절할 때에는 거절사유를 함께 통지하여야 합니다. 만약 금융소비자의 요구가 정당한 것으로 판단될 경우 수수료 등 계약해지와 관련한 추가 비용 부담없이 계약해지가 가능합니다.", + }, +]; + +export const AccountTermDetailPage = () => { + const [isModalOpen, setModalOpen] = useState(false); + + return ( + <> +
+ +
+
+
+
+
+
+
+
+ 하나은행 +
+
+

입출금 통장

+
+
+
+ {terms.map((term: Term) => ( +
+

+ 📌 {term.name} +

+
+ {term.content.split('.').map((item, count) => ( + item.trim() &&

{item.trim()}.

+ ))} +
+
+ ))} +
+ + + + setModalOpen(false)}> +

✔️

+

+ 모든 약관에 동의하십니까? +

+
+ +
+
+
+
+ + ); +}; diff --git a/src/pages/mypage/account/create/AccountTermPage.tsx b/src/pages/mypage/account/create/AccountTermPage.tsx new file mode 100644 index 0000000..45355f9 --- /dev/null +++ b/src/pages/mypage/account/create/AccountTermPage.tsx @@ -0,0 +1,151 @@ +import { useState } from "react"; +import { TopLine } from "../../../../components/ui/TopLine"; +import PhoneModal from "../../../../components/ui/PhoneModal"; +import { useNavigate } from "react-router-dom"; +import Modal from "../../../../components/ui/Modal"; + +type Term = { + id: number; + name: string; + content: string; +}; + +const terms: Term[] = [ + { + id: 1, + name: "유의사항", + content: + "이 예금은 양도 및 상속에 의한 명의변경이 불가합니다. 단, 상속에 의한 해지는 가능합니다. 이 예금의 신규계좌수 30만좌 제한에 따라 판매가 중단될수 있습니다. ※ 금융상품에 관한 계약을 체결하기 전에 금융상품 설명서 및 약관을 읽어 보시기 바랍니다. ※ 금융소비자는 해당 상품 또는 서비스에 대하여 설명 받을 권리가 있습니다. ※ 이 홍보물은 법령 및 내부통제기준에 따른 절차를 거쳐 제공됩니다.", + }, + { + id: 2, + name: "세제혜택", + content: + "비과세 종합저축으로 가입 가능(전 금융기관 통합한도 범위내) 관련 세법이 개정될 경우 세율이 변경되거나 세금이 부과될 수 있으며, 계약기간 이후의 이자는 과세됨", + }, + { + id: 3, + name: "원금 및 이자지급제한", + content: + "계좌에 압류, 가압류, 질권설정 등이 등록될 경우 원금 및 이자지급 제한될 수 있습니다. ※ 민사집행법에 따라 최저생계비 이하 등 압류금지 채 권에 해당하는 경우에는 법원에 압류금지채권범위 변경 신청 등을 통해 압류를 취소할 수 있습니다. 예금잔액증명서 발급 당일에는 잔액 변동 불가합니다. 통장이 ‘전기통신금융사기 피해 방지 및 피해금 환급에 관한 특별법’에서 정의한 피해 의심거래계좌 및 사기이용 계좌로 이용될 경우 이체, 송금지연, 지급정지 등의 금융거래 제한조치를 할 수 있습니다.", + }, + { + id: 4, + name: "위법계약해지권", + content: + "금융소비자 보호에 관한 법률 제47조에 따른 위법계약해지 사유가 발생한 경우, 계약체결일로부터 5년 이내 범위에서 위반사실을 안 날로부터 1년 이내에 서면 등으로 해당 계약의 해지를 요구할 수 있습니다. 이 경우 금융회사는 해지를 요구받은 날부터 10일 이내에 금융소비자에게 수락 여부를 통지하여야 하며, 거절할 때에는 거절사유를 함께 통지하여야 합니다. 만약 금융소비자의 요구가 정당한 것으로 판단될 경우 수수료 등 계약해지와 관련한 추가 비용 부담없이 계약해지가 가능합니다.", + }, +]; + +type Props = { + name: string; + content: string; + onCheckboxChange: () => void; +}; + +const AccountTerm = ({ name, content, onCheckboxChange }: Props) => { + const [isModalOpen, setModalOpen] = useState(false); + return ( + <> +
+
+ +
+
+
{ + setModalOpen(true); + }} + > + {name} +
+ setModalOpen(false)}> +

{name}

+
+ {content.split('.').map((item, count) => ( + item.trim() &&

{item.trim()}.

+ ))} +
+ +
+
+
+
+ + ); +}; + +export const AccountTermPage = () => { + const navigate = useNavigate(); + const [termsChecked, setTermsChecked] = useState([false, false]); + const [isModalOpen, setModalOpen] = useState(false); + + const handleCheckboxChange = (index: number) => { + const updatedChecked = [...termsChecked]; + updatedChecked[index] = !updatedChecked[index]; + setTermsChecked(updatedChecked); + }; + + const handleSubmit = () => { + if (termsChecked.every((checked) => checked)) { + navigate(`detail`); + } else { + setModalOpen(true); + } + }; + + return ( + <> +
+ +
+
+
+ +
+

계좌 생성

+
+ {terms.map((term, idx) => ( + handleCheckboxChange(idx)} + /> + ))} +
+ +
+

+ [필수] 입력하신 이메일로 상품 이용약관 설명서가 발송됩니다. +

+ setModalOpen(false)}> +
+ 동의를 완료해주세요. +
+ +
+ +
+
+ + ); +}; diff --git a/src/pages/mypage/goal/GoalCar.tsx b/src/pages/mypage/goal/GoalCar.tsx new file mode 100644 index 0000000..821bf80 --- /dev/null +++ b/src/pages/mypage/goal/GoalCar.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState } from "react"; +import { useUser } from "../../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../../hooks/fetch"; +import { Car, UserGoalDetailGetResponse } from "./GoalDetailPage"; +import { useNavigate, useParams } from "react-router-dom"; +import { formatDateToYyyyMmDd, formatDateToYyyymmdd } from "./GoalUtil"; +import { Goal, useGoalsProducts } from "../../../contexts/ProductContext"; +import { API_BASE_URL } from "../../../constants"; +import { IoClose } from "react-icons/io5"; + +type Props = { + goal: UserGoalDetailGetResponse; + goalDetail: Car; +}; + +type CarGetResponse = { + carId: number; + carNm: string; + carPrice: number; +}; + +export const GoalCar = ({ goal, goalDetail }: Props) => { + const navigate = useNavigate(); + const { goalId } = useParams<{ goalId: string }>(); + const { user } = useUser(); + const { createGoal, updateGoal } = useGoalsProducts(); + + const [cars, setCars] = useState([]); + const [search, setSearch] = useState(goalDetail.carNm); + const [selectedCar, setSelectedCar] = useState(null); + const [alias, setAlias] = useState(goal.goalAlias); + const [duration, setDuration] = useState(goal.duration); + const [price, setPrice] = useState(goalDetail.carPrice); + const [begin, setBegin] = useState( + formatDateToYyyyMmDd(goal.goalBeginDate) + ); + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/user-goals/cars`, + fetchOptions + ); + + useEffect(() => { + if (data) { + setCars(data); + const defaultCar = data.find((car) => car.carNm === goalDetail.carNm); + if (defaultCar) { + setSelectedCar(defaultCar); + } + } + }, [data, goalDetail.carNm]); + + const handleCarSelect = (car: CarGetResponse) => { + setSelectedCar(car); + setPrice(car.carPrice); + setSearch(car.carNm); // 드롭다운 항목 클릭 시 검색창 업데이트 및 드롭다운 숨기기 + }; + + const filteredCars = cars.filter((car) => + car.carNm.toLowerCase().includes(search.toLowerCase()) + ); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + const buttonClicked = () => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/user-goals`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + userGoalId: goalId !== "0" ? goalId : null, + goalAlias: alias, + goalTypeCd: "CAR", + goalSpecificId: selectedCar?.carId, + goalBeginDate: formatDateToYyyymmdd(begin), + duration: duration, + amount: price, + }), + } + ); + if (response.ok) { + const json = await response.json(); + const goal: Goal = { + userGoalId: json["userGoalId"], + goalAlias: json["goalAlias"], + goalTypeCd: json["goalTypeCd"], + goalSpecificId: json["goalSpecificId"], + goalBeginDate: json["goalBeginDate"], + duration: json["duration"], + amount: json["amount"], + }; + if (goalId === "0") { + createGoal(goal); + } else { + updateGoal(goal); + } + navigate("/mypage/goal"); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err.message})`); + } + } + })(); + } + }; + + return ( + <> +
+ + setAlias(e.target.value)} + /> +
+ + +
+ setSearch(e.target.value)} + /> + {search && filteredCars.length > 0 && search !== selectedCar?.carNm && ( +
+
+
setSearch("")} className="cursor-pointer"> + +
+
+ {filteredCars.map((car) => ( +
handleCarSelect(car)} + > + {car.carNm} +
+ ))} +
+ )} +
+
+ + setDuration(Number(e.target.value))} + /> +
+ + setPrice(Number(e.target.value.replace(/,/g, "")))} + /> + + goalId === "0" && setBegin(e.target.value)} + disabled={goalId !== "0"} + /> +
+ +
+ + ); +}; diff --git a/src/pages/mypage/goal/GoalCreatePage.tsx b/src/pages/mypage/goal/GoalCreatePage.tsx new file mode 100644 index 0000000..2302603 --- /dev/null +++ b/src/pages/mypage/goal/GoalCreatePage.tsx @@ -0,0 +1,137 @@ +// import { GreenButton } from "../../../components/ui/GreenButton"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useState } from "react"; +import { SlArrowDown } from "react-icons/sl"; +import { GoalCar } from "./GoalCar"; +import { House, Car, Wish, UserGoalDetailGetResponse } from "./GoalDetailPage"; +import { GoalHouse } from "./GoalHouse"; +import { GoalWish } from "./GoalWish"; + +export const GoalCreatePage = () => { + const [category, setCategory] = useState("카테고리를 선택해주세요."); + const [isOpen, setIsOpen] = useState(false); + + const toggleDropdown = () => setIsOpen(!isOpen); + + const handleCategorySelect = (category: string) => { + setCategory(category); + setIsOpen(false); + }; + + const houseInit: UserGoalDetailGetResponse = { + goalAlias: "", + goalTypeCd: "HOUSE", + goalSpecificId: 0, + goalBeginDate: "", + duration: 0, + detail: { + apartmentNm: "", + apartmentPrice: 0, + regionNm: "", + exclusiveArea: 0, + }, + }; + const carInit: UserGoalDetailGetResponse = { + goalAlias: "", + goalTypeCd: "Car", + goalSpecificId: 0, + goalBeginDate: "", + duration: 0, + detail: { carNm: "", carPrice: 0 }, + }; + const wishInit: UserGoalDetailGetResponse = { + goalAlias: "", + goalTypeCd: "Wish", + goalSpecificId: 0, + goalBeginDate: "", + duration: 0, + detail: { wishNm: "", wishPrice: 0 }, + }; + + const [goal, setGoal] = useState(wishInit); + + return ( + <> +
+ +
+
+ +
+ +
+
+ +
+ + {isOpen && ( +
+
+ { + setGoal(houseInit); + handleCategorySelect("집"); + }} + > + 집 + + { + setGoal(carInit); + handleCategorySelect("차"); + }} + > + 차 + + { + setGoal(wishInit); + handleCategorySelect("소원"); + }} + > + 소원 + +
+
+ )} +
+
+
+ + {category === "집" && ( + + )} + {category === "차" && ( + + )} + {category === "소원" && ( + + )} + {/* */} +
+
+
+ + ); +}; diff --git a/src/pages/mypage/goal/GoalDetailPage.tsx b/src/pages/mypage/goal/GoalDetailPage.tsx new file mode 100644 index 0000000..86e7aa5 --- /dev/null +++ b/src/pages/mypage/goal/GoalDetailPage.tsx @@ -0,0 +1,104 @@ +import { useLocation, useParams } from "react-router-dom"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useEffect, useState } from "react"; +import { useUser } from "../../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../../hooks/fetch"; +import { GoalHouse } from "./GoalHouse"; +import { GoalCar } from "./GoalCar"; +import { GoalWish } from "./GoalWish"; +import { API_BASE_URL } from "../../../constants"; + +export type House = { + apartmentNm: string; + apartmentPrice: number; + regionNm: string; + exclusiveArea: number; +}; + +export type Car = { + carNm: string; + carPrice: number; +}; + +export type Wish = { + wishNm: string; + wishPrice: number; +}; + +export type UserGoalDetailGetResponse = { + goalAlias: string; + goalTypeCd: string; + goalSpecificId: number; + goalBeginDate: string; + duration: number; + detail: Car | House | Wish; +}; + +export const GoalDetailPage = () => { + const location = useLocation(); + const { count } = location.state; + const { goalId } = useParams(); + + const { user } = useUser(); + const init: UserGoalDetailGetResponse = { + goalAlias: "", + goalTypeCd: "", + goalSpecificId: 0, + goalBeginDate: "", + duration: 0, + detail: { wishNm: "", wishPrice: 0 }, + }; + const [goal, setGoal] = useState(init); + + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/user-goals/${goalId}`, + fetchOptions + ); + + useEffect(() => { + if (data) { + setGoal(data); + console.log(data); + } + }, [data]); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+ +
+
+

+ 목표 {count} +

+ + {goal?.goalAlias} +
+ 🚗🏠🙏🧙🪄 +
+
+ {goal?.goalTypeCd === "HOUSE" && ( +
+ +
+ )} + {goal?.goalTypeCd === "CAR" && ( + + )} + {goal?.goalTypeCd === "WISH" && ( + + )} +
+
+ + ); +}; diff --git a/src/pages/mypage/goal/GoalHouse.tsx b/src/pages/mypage/goal/GoalHouse.tsx new file mode 100644 index 0000000..2650f4b --- /dev/null +++ b/src/pages/mypage/goal/GoalHouse.tsx @@ -0,0 +1,276 @@ +import { useEffect, useState } from "react"; +// import { GreenButton } from "../../../components/ui/GreenButton"; +import { House, UserGoalDetailGetResponse } from "./GoalDetailPage"; +import { FetchOptions, useFetch } from "../../../hooks/fetch"; +import { useUser } from "../../../contexts/UserContext"; +import { useNavigate, useParams } from "react-router-dom"; +import { Goal, useGoalsProducts } from "../../../contexts/ProductContext"; +import { formatDateToYyyyMmDd, formatDateToYyyymmdd } from "./GoalUtil"; +import { API_BASE_URL } from "../../../constants"; +import { IoClose } from "react-icons/io5"; + +type Props = { + goal: UserGoalDetailGetResponse; + goalDetail: House; +}; + +type ApartmentGetResponse = { + apartmentId: number; + apartmentNm: string; + apartmentPrice: number; + regionCd: number; + regionNm: string; + exclusiveArea: number; +}; + +export const GoalHouse = ({ goal, goalDetail }: Props) => { + const navigate = useNavigate(); + const [apartments, setApartments] = useState([]); + const [selectedApartment, setSelectedApartment] = + useState(null); + const [selectedRegion, setSelectedRegion] = useState(""); + const [search, setSearch] = useState(goalDetail.apartmentNm); + const [alias, setAlias] = useState(goal.goalAlias); + const [duration, setDuration] = useState(goal.duration); + const [price, setPrice] = useState(goalDetail.apartmentPrice); + const [begin, setBegin] = useState( + formatDateToYyyyMmDd(goal.goalBeginDate) + ); + const { goalId } = useParams(); + const { user } = useUser(); + const fetchOptions: FetchOptions = { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + }; + + const { createGoal, updateGoal } = useGoalsProducts(); + + const { data, error, loading } = useFetch( + `${API_BASE_URL}/api/v1/user-goals/apartments`, + fetchOptions + ); + + // 아파트 리스트 + useEffect(() => { + console.log(begin); + if (data) { + setApartments(data); + console.log(apartments); + const defaultApartment = data.find( + (apartment) => apartment.apartmentNm === goalDetail.apartmentNm + ); + if (defaultApartment) { + setSelectedApartment(defaultApartment); + setSelectedRegion(defaultApartment.regionNm); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, goalDetail.apartmentNm]); + + // 아파트 시세 + const fetchPredictedPrice = async () => { + if (!selectedApartment || !duration) return; + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/user-goals/apartments/predict`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + region: selectedApartment.regionNm, + apartmentNm: selectedApartment.apartmentNm, + price: selectedApartment.apartmentPrice, + area: selectedApartment.exclusiveArea, + duration: duration, + }), + } + ); + if (!response.ok) throw new Error("Failed to fetch predicted price"); + const json = await response.json(); + setPrice(json["price"]); // 새로운 아파트 선택 시 가격 업데이트 + console.log(price); + } catch (error) { + console.error("Error fetching predicted price:", error); + } + }; + + const handleApartmentSelect = (apartment: ApartmentGetResponse) => { + setSelectedApartment(apartment); + setSearch(apartment.apartmentNm); // 선택 후 검색창을 아파트 이름으로 설정하여 드롭다운 숨기기 + fetchPredictedPrice(); + }; + + const handleRegionSelect = (region: string) => { + setSelectedRegion(region); + setSearch(""); // 지역 선택 시 검색창 초기화 + setSelectedApartment(null); // 지역 선택 시 아파트 선택 초기화 + }; + + useEffect(() => { + fetchPredictedPrice(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedApartment, duration]); + + const filteredApartments = apartments.filter( + (apartment) => + apartment.regionNm === selectedRegion && + apartment.apartmentNm.toLowerCase().includes(search.toLowerCase()) + ); + + const regions = [ + ...new Set(apartments.map((apartment) => apartment.regionNm)), + ]; + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + const buttonClicked = () => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/user-goals`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + userGoalId: goalId !== "0" ? goalId : null, + goalAlias: alias, + goalTypeCd: "HOUSE", + goalSpecificId: selectedApartment?.apartmentId, + goalBeginDate: formatDateToYyyymmdd(begin), + duration: duration, + amount: price, + }), + } + ); + if (response.ok) { + const json = await response.json(); + console.log(json); + const goal: Goal = { + userGoalId: json["userGoalId"], + goalAlias: json["goalAlias"], + goalTypeCd: json["goalTypeCd"], + goalSpecificId: json["goalSpecificId"], + goalBeginDate: json["goalBeginDate"], + duration: json["duration"], + amount: json["amount"], + }; + if (goalId === "0") { + createGoal(goal); + } else { + updateGoal(goal); + } + navigate("/mypage/goal"); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err.message})`); + } + } + })(); + } + }; + + return ( + <> +
+ + setAlias(e.target.value)} + /> +
+ +
+ +
+ +
+ setSearch(e.target.value)} + /> + {search && + filteredApartments.length > 0 && + search !== selectedApartment?.apartmentNm && ( +
+
+
setSearch("")} className="cursor-pointer"> + +
+
+ {filteredApartments.map((apartment) => ( +
handleApartmentSelect(apartment)} + > + {apartment.apartmentNm} +
+ ))} +
+ )} +
+
+ + setDuration(Number(e.target.value))} + /> +
+ + setPrice(Number(e.target.value.replace(/,/g, "")))} + /> + + + goalId === "0" && setBegin(e.target.value)} + disabled={goalId !== "0"} + /> +
+ + +
+ + ); +}; diff --git a/src/pages/mypage/goal/GoalListPage.tsx b/src/pages/mypage/goal/GoalListPage.tsx new file mode 100644 index 0000000..41060c8 --- /dev/null +++ b/src/pages/mypage/goal/GoalListPage.tsx @@ -0,0 +1,52 @@ +import { useNavigate } from "react-router-dom"; +import { TopLine } from "../../../components/ui/TopLine"; +// import { GreenButton } from "../../../components/ui/GreenButton"; +import { useGoalsProducts } from "../../../contexts/ProductContext"; + +type Props = { + id: number; + count: number; + name: string; +}; + +const Goal = ({ id, count, name }: Props) => { + const navigate = useNavigate(); + return ( +
navigate(`${id}`, { state: { count } })} + > + 목표 {count} + {name} +
+ ); +}; + +export const GoalListPage = () => { + const { goalsProducts } = useGoalsProducts(); + const navigate = useNavigate(); + const goalId = 0; + + return ( +
+ +
+ 🚩 목표 +
+ {goalsProducts?.goalsProducts?.map((goalProduct, index) => ( + + ))} +
+ + {/* */} +
+
+ ); +}; diff --git a/src/pages/mypage/goal/GoalUtil.ts b/src/pages/mypage/goal/GoalUtil.ts new file mode 100644 index 0000000..ac93ff8 --- /dev/null +++ b/src/pages/mypage/goal/GoalUtil.ts @@ -0,0 +1,16 @@ +// yyyy-mm-dd 형식을 yyyymmdd 형식으로 변환하는 함수 +export const formatDateToYyyymmdd = (dateString: string): string => { + const date = new Date(dateString); + const yyyy = date.getFullYear().toString(); + const mm = (date.getMonth() + 1).toString().padStart(2, "0"); // 월은 0부터 시작하므로 +1 필요 + const dd = date.getDate().toString().padStart(2, "0"); + return `${yyyy}${mm}${dd}`; +}; + +// yyyymmdd 형식을 yyyy-mm-dd 형식으로 변환하는 함수 +export const formatDateToYyyyMmDd = (dateString: string): string => { + const yyyy = dateString.slice(0, 4); + const mm = dateString.slice(4, 6); + const dd = dateString.slice(6, 8); + return `${yyyy}-${mm}-${dd}`; +}; diff --git a/src/pages/mypage/goal/GoalWish.tsx b/src/pages/mypage/goal/GoalWish.tsx new file mode 100644 index 0000000..dae6f66 --- /dev/null +++ b/src/pages/mypage/goal/GoalWish.tsx @@ -0,0 +1,119 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { UserGoalDetailGetResponse, Wish } from "./GoalDetailPage"; +import { useUser } from "../../../contexts/UserContext"; +import { useState } from "react"; +import { formatDateToYyyyMmDd, formatDateToYyyymmdd } from "./GoalUtil"; +import { Goal, useGoalsProducts } from "../../../contexts/ProductContext"; +import { API_BASE_URL } from "../../../constants"; + +type Props = { + goal: UserGoalDetailGetResponse; + goalDetail: Wish; +}; + +export const GoalWish = ({ goal, goalDetail }: Props) => { + const navigate = useNavigate(); + const { goalId } = useParams<{ goalId: string }>(); + const { user } = useUser(); + const { createGoal, updateGoal } = useGoalsProducts(); + + const [alias, setAlias] = useState(goal.goalAlias); + const [duration, setDuration] = useState(goal.duration); + const [price, setPrice] = useState(goalDetail.wishPrice); + const [begin, setBegin] = useState( + formatDateToYyyyMmDd(goal.goalBeginDate) + ); + + const buttonClicked = () => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/user-goals`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + userGoalId: goalId !== "0" ? goalId : null, + goalAlias: alias, + goalTypeCd: "WISH", + goalSpecificId: goal.goalSpecificId, + goalBeginDate: formatDateToYyyymmdd(begin), + duration: duration, + amount: price, + }), + } + ); + if (response.ok) { + const json = await response.json(); + const goal: Goal = { + userGoalId: json["userGoalId"], + goalAlias: json["goalAlias"], + goalTypeCd: json["goalTypeCd"], + goalSpecificId: json["goalSpecificId"], + goalBeginDate: json["goalBeginDate"], + duration: json["duration"], + amount: json["amount"], + }; + if (goalId === "0") { + createGoal(goal); + } else { + updateGoal(goal); + } + navigate("/mypage/goal"); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err.message})`); + } + } + })(); + } + }; + + return ( + <> +
+ + setAlias(e.target.value)} + /> +
+ + + setDuration(Number(e.target.value))} + /> +
+ + setPrice(Number(e.target.value.replace(/,/g, "")))} + /> + + goalId === "0" && setBegin(e.target.value)} + disabled={goalId !== "0"} + /> +
+ +
+ + ); +}; diff --git a/src/pages/mypage/salary/SalaryPage.tsx b/src/pages/mypage/salary/SalaryPage.tsx new file mode 100644 index 0000000..0b95a5c --- /dev/null +++ b/src/pages/mypage/salary/SalaryPage.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from "react"; +import { TopLine } from "../../../components/ui/TopLine"; +import { useUser } from "../../../contexts/UserContext"; +import { useNavigate } from "react-router-dom"; +import { API_BASE_URL } from "../../../constants"; + +type GetSalaryResponse = { + accountId: number; + accountNumber: string; + salary: number; + salaryDay: number; +}; + +export const SalaryPage = () => { + const navigate = useNavigate(); + const { user, updateSalary } = useUser(); + const [salaryInfo, setSalaryInfo] = useState(null); + const [newSalary, setNewSalary] = useState(""); + const [newSalaryDay, setNewSalaryDay] = useState(""); + + useEffect(() => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/salary`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + const res: GetSalaryResponse = { + accountId: json["accountId"], + accountNumber: json["accountNumber"], + salary: json["salary"], + salaryDay: json["salaryDay"], + }; + setSalaryInfo(res); + setNewSalary(res.salary.toString()); + setNewSalaryDay(res.salaryDay.toString()); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }, [user.jwt]); + + const buttonClicked = () => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/salary`, + { + method: "post", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + newSalary: newSalary, + newSalaryDay: newSalaryDay, + }), + } + ); + if (response.ok) { + updateSalary(newSalary); + navigate(`/mypage`); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }; + + return ( + <> +
+ + +

월급 정보

+
+
+ + 하나은행 + + + + {salaryInfo?.accountNumber} + + + + setNewSalary(e.target.value.replace(/,/g, ""))} + /> + + setNewSalaryDay(e.target.value)} + /> + +
+ +
+
+
+
+ + ); +}; diff --git a/src/pages/product/ProductCompletePage.tsx b/src/pages/product/ProductCompletePage.tsx new file mode 100644 index 0000000..14facf3 --- /dev/null +++ b/src/pages/product/ProductCompletePage.tsx @@ -0,0 +1,68 @@ +import { useLocation } from "react-router-dom"; +import { GreenButton } from "../../components/ui/GreenButton"; +import { TopLine } from "../../components/ui/TopLine"; + +export const ProductCompletePage = () => { + const location = useLocation(); + const { com } = location.state; + + return ( + <> +
+ +
+
+
+
+
+
+
+ 하나은행 +
+
+

청년 주택드림 청약통장

+
+
+

✔️

+

적금 개설 완료

+
+ +
+
+ + {com.autoDebitAmount.toLocaleString()} 원 +
+ +
+ + {com.autoDebitDay} 일 +
+ +
+ + {com.maturityDate} +
+ +
+ + {com.interestRate} % +
+
+ +
+
+ + ); +}; diff --git a/src/pages/product/ProductDetailPage.tsx b/src/pages/product/ProductDetailPage.tsx index beb953a..6b81eda 100644 --- a/src/pages/product/ProductDetailPage.tsx +++ b/src/pages/product/ProductDetailPage.tsx @@ -1,35 +1,112 @@ -import { useLocation } from "react-router-dom"; import { TopLine } from "../../components/ui/TopLine"; -import { Product } from "./ProductListPage"; import { GreenButton } from "../../components/ui/GreenButton"; +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { useUser } from "../../contexts/UserContext"; +import { API_BASE_URL } from "../../constants"; -export const ProductDetailPage = () => { - // const navigate = useNavigate(); - const location = useLocation(); - const product: Product = location.state; +export type ProductDetailResponse = { + productId: number; + productNm: string; + interestTypeCd: string; + interestRate: number; + imageUrl: string; + info: string; + termYear: number; + cautions: string; + depositProtection: string; + contractTerms: string; +}; +export const ProductDetailPage = () => { + const [guide, setGuide] = useState(1); + const { user } = useUser(); + const { productId } = useParams(); + const [product, setProduct] = useState(); + useEffect(() => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/${productId}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + console.log(json); + setProduct(json); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }, [productId, user.jwt]); return ( <> -
+
- -
-
-
-
내용
-
{product.info}
-
이자율
-
{product.rate}%
-
주의사항
-
{product.cautions}
-
예적금 보호법
-
{product.deposit_protection}
-
적금 약관동의
-
{product.contract_terms}
+
+
+
+
+
+
+
+
+ 하나은행 +
+

{product?.productNm}

- - +
+
setGuide(1)} + > + 상품 안내 +
+
setGuide(2)} + > + 금리 안내 +
+
+
+
+
내용
+
{product?.info}
+
이자율
+
{product?.interestRate}%
+
주의사항
+
{product?.cautions}
+
예금자
보호법
+
{product?.depositProtection}
+
적금
약관동의
+
{product?.contractTerms}
+
+
+
diff --git a/src/pages/product/ProductGoalPage.tsx b/src/pages/product/ProductGoalPage.tsx new file mode 100644 index 0000000..7480ca7 --- /dev/null +++ b/src/pages/product/ProductGoalPage.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { TopLine } from "../../components/ui/TopLine"; +import { GreenButton } from "../../components/ui/GreenButton"; +import { useGoalsProducts } from "../../contexts/ProductContext"; + +type Props = { + count: number; + name: string; + isSelected: boolean; + onSelect: () => void; +}; + +const Goal = ({ count, name, isSelected, onSelect }: Props) => { + return ( +
+ 목표 {count} + {name} +
+ ); +}; + +export const ProductGoalPage = () => { + const [selectedIdx, setSelectedIdx] = useState(0); + const [selectedGoal, setSelectedGoal] = useState(0); + const { goalsProducts } = useGoalsProducts(); + + const handleSelectGoal = (idx: number, goalId: number) => { + setSelectedIdx(idx); + setSelectedGoal(goalId); + }; + + return ( + <> +
+ +
+
+
+
+
+

🚩

+

목표 선택

+
+
+ {goalsProducts?.goalsProducts?.length === 0 ? ( +
+

+ 등록된 목표가 없습니다. +
+ 마이페이지에서 목표를 설정해주세요. +

+
+ ) : ( + goalsProducts?.goalsProducts?.map((goalProduct, index) => ( + + handleSelectGoal(index + 1, goalProduct.goal.userGoalId) + } + /> + )) + )} +
+ {goalsProducts?.goalsProducts?.length !== 0 && ( + + )} +
+
+ + ); +}; diff --git a/src/pages/product/ProductListPage.tsx b/src/pages/product/ProductListPage.tsx index 9226dbe..e6e7daa 100644 --- a/src/pages/product/ProductListPage.tsx +++ b/src/pages/product/ProductListPage.tsx @@ -1,5 +1,10 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useEffect } from "react"; +import { SlArrowRight } from "react-icons/sl"; +import { useNavigate, useParams } from "react-router-dom"; +import { useGoalsProducts } from "../../contexts/ProductContext"; +import { useUser } from "../../contexts/UserContext"; +import { TopLine } from "../../components/ui/TopLine"; +import { API_BASE_URL } from "../../constants"; export type Product = { id: number; @@ -12,6 +17,23 @@ export type Product = { contract_terms: string; }; +export type recommendedProducts = { + productId: number; + productNm: string; + termYear: number; + interestRate: number; +}; + +export type enrolledProducts = { + enrolledProductId: number; + productNm: string; +}; + +export type ProductGetResponse = { + recommendedProducts: recommendedProducts[]; + enrolledProducts: enrolledProducts[]; +}; + export type GoalProducts = { id: number; goal: string; @@ -19,132 +41,137 @@ export type GoalProducts = { }; type Props = { - product: Product; + product: recommendedProducts; }; -const products: Product[] = [ - { - id: 1, - name: "적금1", - rate: 2.5, - info: "적금와랄라라", - term_year: 1, - cautions: "주의사항", - deposit_protection: "예적금 보호법", - contract_terms: "적금 약관동의", - }, - { - id: 2, - name: "적금2", - rate: 2.3, - info: "적금와랄라라", - term_year: 2, - cautions: "주의사항", - deposit_protection: "예적금 보호법", - contract_terms: "적금 약관동의", - }, - { - id: 3, - name: "적금3", - rate: 2.2, - info: "적금와랄라라", - term_year: 1, - cautions: "주의사항", - deposit_protection: "예적금 보호법", - contract_terms: "적금 약관동의", - }, - { - id: 4, - name: "적금4", - rate: 2.2, - info: "적금와랄라라", - term_year: 1, - cautions: "주의사항", - deposit_protection: "예적금 보호법", - contract_terms: "적금 약관동의", - }, - { - id: 5, - name: "적금5", - rate: 2.2, - info: "적금와랄라라", - term_year: 1, - cautions: "주의사항", - deposit_protection: "예적금 보호법", - contract_terms: "적금 약관동의", - }, - { - id: 6, - name: "적금6", - rate: 2.2, - info: "적금와랄라라", - term_year: 1, - cautions: "주의사항", - deposit_protection: "예적금 보호법", - contract_terms: "적금 약관동의", - }, -]; - -const goalProducts: GoalProducts[] = [ - { id: 1, goal: "소원", products: products }, - { id: 2, goal: "집", products: products }, - { id: 3, goal: "차", products: products }, -]; - const Product = ({ product }: Props) => { const navigate = useNavigate(); if (!product) return null; // product가 없을 경우 렌더링하지 않음 - const goToDetail = (product: Product) => { - navigate(`/product/${product.id}`, { state: product }); + const goToDetail = (product: recommendedProducts) => { + navigate(`${product.productId}`); }; return ( <>
goToDetail(product)} - className="product-item cursor-pointer border border-gray-300 m-2 p-2 w-36 hover:shadow-lg" + className="grid grid-cols-7 pl-2 py-2 pb-3 mb-3 items-center hover:bg-slate-100 cursor-pointer" > -
{product.name}
-
{product.term_year}
-
{product.rate}
+
+
+ 하나은행 +
+
+
+

{product.productNm}

+

+ 기본 {product.interestRate}% ({product.termYear * 12}개월) +

+
+
+

+ 최고{product.interestRate}% +

+ +
); }; export const ProductListPage = () => { - const [selectedGoal, setSelectedGoal] = useState(goalProducts[0].id); - - const handleGoalClick = (goalId: number) => { - setSelectedGoal(goalId); - }; - - //Products[] - const selectedGoalProducts = - goalProducts.find((goal) => goal.id === selectedGoal)?.products || []; + const { user } = useUser(); + const { goalsProducts, setProduct } = useGoalsProducts(); + let { goalId } = useParams(); + console.log(goalId); + if (goalId === undefined) { + goalId = ""; + } + const goalProduct = goalsProducts?.goalsProducts?.find( + (gp) => gp.goal.userGoalId === +goalId + ); + useEffect(() => { + if (goalProduct && goalProduct.products.recommendedProducts.length === 0) { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/recommend/${goalId}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json: ProductGetResponse = await response.json(); + setProduct(+goalId, json); + console.log(json); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [goalId, user.jwt]); return ( <> -
-

적금 상품 추천

-
- {goalProducts.map((goal) => ( - - ))} + +
+
+
+
+

상품

+
+
+

+ 적금 상품 추천 +

+

+ {user.name}님의 목표에 따라서 +
+ 적합한 적금을 추천해 드릴게요! +

+ {/*

+ 이미 가입된 상품 목록입니다. +
+ {goalProduct?.products.enrolledProducts.map( + (product: enrolledProducts) => product.productNm + ", " + )} +

*/} +
+
+ ☝️ +
-
- {selectedGoalProducts.map((product) => ( - - ))} + {goalProduct?.products.enrolledProducts.length != 0 ? ( + <> +
+ 이미 가입된 적금 + {goalProduct?.products.enrolledProducts.length} + 개가 존재합니다. +
+ + ):( + null + )} + + +
+ {goalProduct?.products.recommendedProducts.map( + (product: recommendedProducts) => ( + + ) + )}
diff --git a/src/pages/product/ProductSignupPage.tsx b/src/pages/product/ProductSignupPage.tsx new file mode 100644 index 0000000..502b6a5 --- /dev/null +++ b/src/pages/product/ProductSignupPage.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from "react"; +import { Checkbox } from "../../components/ui/Checkbox"; +import { TopLine } from "../../components/ui/TopLine"; +import { useNavigate, useParams } from "react-router-dom"; +import { useUser } from "../../contexts/UserContext"; +import { useGoalsProducts } from "../../contexts/ProductContext"; +import { API_BASE_URL } from "../../constants"; + +type Complete = { + autoDebitAmount: number; + autoDebitDay: number; + maturityDate: string; + interestRate: number; +}; + +export const ProductSignupPage = () => { + const navigate = useNavigate(); + const { goalId, productId } = useParams(); + const [isAutoChecked, setIsAutoChecked] = useState(false); + const [isCloseChecked, setIsCloseChecked] = useState(false); + const { user } = useUser(); + const [accountNumber, setAccountNumber] = useState(""); + const [productNm, setProductNm] = useState(""); + const [termYear, setTermYear] = useState(1); + const [interestRate, setInterestRate] = useState(1); + const { updateProduct } = useGoalsProducts(); + + useEffect(() => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/saving`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + setAccountNumber(json["accountNumber"]); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/${productId}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + setProductNm(json["productNm"]); + setTermYear(json["termYear"]); + setInterestRate(json["interestRate"]); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }, [productId, user.jwt]); + + const buttonClicked = (event: React.MouseEvent) => { + event.preventDefault(); // 기본 동작 방지 + + const autoDebitAmountElement = document.getElementById( + "autoDebitAmount" + ) as HTMLInputElement; + const autoDebitDayElement = document.getElementById( + "autoDebitDay" + ) as HTMLInputElement; + + if (autoDebitAmountElement && autoDebitDayElement) { + const autoDebitAmount = Number(autoDebitAmountElement.value); + const autoDebitDay = Number(autoDebitDayElement.value); + + const currentDate = new Date(); + const year = currentDate.getFullYear() + termYear; + const month = (currentDate.getMonth() + 1).toString().padStart(2, "0"); + const day = autoDebitDay.toString().padStart(2, "0"); + const maturityDate = `${year}-${month}-${day}`; + const temp = `${year}${month}${day}`; + + const com: Complete = { + autoDebitAmount, + autoDebitDay, + maturityDate, + interestRate, + }; + console.log(com); + + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/enroll`, + { + method: "post", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + userGoalId: goalId, + productId: productId, + contractPeriod: termYear, + initialAmount: 0, + autoDebitAmount: com.autoDebitAmount, + autoDebitDay: com.autoDebitDay, + maturityDate: temp, + autoRenewal: true, + }), + } + ); + if (response.ok) { + updateProduct(Number(goalId)); + navigate(`/product/${goalId}/${productId}/complete`, { + state: { com }, + }); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + } + }; + + return ( + <> +
+ +
+
+
+
+
+
+
+ 하나은행 +
+
+

{productNm}

+
+
+
+ + +
+ + +
+ + + +
+ +
+ +
+ +
+
+ + +
+
+ + ); +}; diff --git a/src/pages/product/ProductStartPage.tsx b/src/pages/product/ProductStartPage.tsx new file mode 100644 index 0000000..6c440ac --- /dev/null +++ b/src/pages/product/ProductStartPage.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from "react-router-dom"; +import { TopLine } from "../../components/ui/TopLine"; + +export const ProductStartPage = () => { + const navigate = useNavigate(); + return ( + <> + +
+
+ 하나피스 +

목표 달성을 위한
맞춤형 적금 추천

+
+ +
+
+
1
+
+ 원하는 목표를 선택하고
+ (만들어져 있는 목표 중에 선택할 수 있어요!) +
+
+
+
+
2
+
맞춤형 적금 추천을 확인하고
+
+
+
+
3
+
원하는 적금을 선택하면
+
+
+
+
4
+
적금 개설 완료!
+
+
+ + +
+ + ); +}; diff --git a/src/pages/product/ProductTermDetailPage.tsx b/src/pages/product/ProductTermDetailPage.tsx new file mode 100644 index 0000000..a486867 --- /dev/null +++ b/src/pages/product/ProductTermDetailPage.tsx @@ -0,0 +1,122 @@ +import { useParams } from "react-router-dom"; +import { TopLine } from "../../components/ui/TopLine"; +import { useEffect, useState } from "react"; +import { GreenButton } from "../../components/ui/GreenButton"; +import PhoneModal from "../../components/ui/PhoneModal"; +import { useUser } from "../../contexts/UserContext"; +import { API_BASE_URL } from "../../constants"; + +type Term = { + id: number; + name: string; + content: string; +}; + +export const ProductTermDetailPage = () => { + const { goalId, productId } = useParams(); + const { user } = useUser(); + const [terms, setTerms] = useState([ + { id: 1, name: "적금 약관 동의", content: "empty" }, + ]); + const [name, setName] = useState(); + + useEffect(() => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/${productId}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + const jsonTerm: Term[] = [ + { id: 1, name: "적금 약관 동의", content: json["contractTerms"] }, + { + id: 1, + name: "예금자 보호법", + content: json["depositProtection"], + }, + ]; + setTerms(jsonTerm); + setName(json["productNm"]); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }, [user.jwt, productId]); + + const [isModalOpen, setModalOpen] = useState(false); + + return ( + <> +
+ +
+
+
+
+
+
+
+
+ 하나은행 +
+
+

{name}

+
+
+
+ {terms.map((term: Term) => ( + <> +
+

+ 📌 {term.name} +

+
+ {term.content.split(',').map((item, count)=>( +

{count+1}. {item}

+ ))} +
+
+ + ))} +
+ + + + setModalOpen(false)}> +

✔️

+

+ 정말로 가입하시겠습니까? +

+
+ +
+
+
+
+ + ); +}; diff --git a/src/pages/product/ProductTermPage.tsx b/src/pages/product/ProductTermPage.tsx new file mode 100644 index 0000000..3901492 --- /dev/null +++ b/src/pages/product/ProductTermPage.tsx @@ -0,0 +1,166 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { TopLine } from "../../components/ui/TopLine"; +import { useEffect, useState } from "react"; +import PhoneModal from "../../components/ui/PhoneModal"; +import { useUser } from "../../contexts/UserContext"; +import { ProductDetailResponse } from "./ProductDetailPage"; +import { API_BASE_URL } from "../../constants"; +import Modal from "../../components/ui/Modal"; + +type Props = { + name: string; + content: string; + onCheckboxChange: () => void; +}; + +const ProductTerm = ({ name, content, onCheckboxChange }: Props) => { + const [isModalOpen, setModalOpen] = useState(false); + + return ( + <> +
+
+ +
+
+
{ + setModalOpen(true); + }} + > + {name} +
+ setModalOpen(false)}> +

{name}

+
+ {content.split(',').map((item, count)=>( +

{count+1}. {item}

+ ))} +
+ +
+
+
+
+ + ); +}; + +export const ProductTermPage = () => { + const navigate = useNavigate(); + const [termsChecked, setTermsChecked] = useState([false, false]); + + const { user } = useUser(); + const { productId } = useParams(); + const [product, setProduct] = useState(); + + const [isModalOpen, setModalOpen] = useState(false); + + useEffect(() => { + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/products/${productId}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + console.log(json); + setProduct(json); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }, [productId, user.jwt]); + + const handleCheckboxChange = (index: number) => { + const updatedChecked = [...termsChecked]; + updatedChecked[index] = !updatedChecked[index]; + setTermsChecked(updatedChecked); + }; + + const handleSubmit = () => { + if (termsChecked.every((checked) => checked)) { + navigate(`detail`); + } else { + setModalOpen(true); + } + }; + + return ( + <> +
+ +
+
+
+
+
+
+
+ 하나은행 +
+
+

{product?.productNm}

+
+
+
+ handleCheckboxChange(0)} + /> + handleCheckboxChange(1)} + /> +
+ +
+

+ [필수] 입력하신 이메일로 상품 이용약관 설명서가 발송됩니다. +

+ setModalOpen(false)}> +
+ 동의를 완료해주세요. +
+ +
+ +
+
+ + ); +}; diff --git a/src/pages/split/SplitAutoPage.tsx b/src/pages/split/SplitAutoPage.tsx new file mode 100644 index 0000000..1a10e5b --- /dev/null +++ b/src/pages/split/SplitAutoPage.tsx @@ -0,0 +1,306 @@ +import { useEffect, useState } from "react"; +import { TopLine } from "../../components/ui/TopLine"; +import { Ratio } from "./SplitMainPage"; +import { Checkbox } from "../../components/ui/Checkbox"; +import { useUser } from "../../contexts/UserContext"; +import { API_BASE_URL } from "../../constants"; +import { FetchOptions } from "../../hooks/fetch"; +import { useNavigate } from "react-router-dom"; +import Modal from "../../components/ui/Modal"; + +export const SplitAutoPage = () => { + const [mode, setMode] = useState(true); + const [isModalOpen, setModalOpen] = useState(false); + const [isCheckModalOpen, setCheckModalOpen] = useState(false); + const [accountAutoDebitId, setAccountAutoDebitId] = useState({ saving: 0, life: 0, reserve: 0 }); + const navigate = useNavigate(); + const [ratio, setRatio] = useState({ + saving: 50, + life: 23, + reserve: 27, + }); + + const { user } = useUser(); + const salary = Number(user.salary); + + useEffect(() => { + let type: string = ""; + if (mode) type = "lux"; + else type = "save"; + + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/auto-debit/suggestions/${type}`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + setRatio({ + saving: json["saving"], + life: json["life"], + reserve: json["reserve"], + }); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + }, [user.jwt, mode]); + + const calcAmount = (ratio: number, salary: number): number => { + return 0.01 * ratio * salary; + }; + + // 사용자 계좌 가져오기 + const getAccounts = async () => { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.jwt}`, + }, + }); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data: AccountAutoDebitAdjustGetResponse[] = await response.json(); + const testData = setAccountId(data); + setAccountAutoDebitId(testData); + } catch (error) { + console.error('Fetch error:', error); + } + } + + // 타입별 통장 번호 저장 + const setAccountId = (accounts: AccountAutoDebitAdjustGetResponse[]): Ratio => { + let saving = 0; + let life = 0; + let reserve = 0; + accounts.forEach((account) => { + if (account.accountType === "SAVING") { + saving = account.accountAutoDebitId; + } else if (account.accountType === "LIFE") { + life = account.accountAutoDebitId; + } else if (account.accountType === "SPARE") { + reserve = account.accountAutoDebitId; + } + }); + + return { + saving: saving, + life: life, + reserve: reserve + }; + }; + + useEffect(() => { + if (accountAutoDebitId.saving !== 0 && accountAutoDebitId.life !== 0 && accountAutoDebitId.reserve !== 0) { + setAutoDebit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountAutoDebitId]); + + // 통장 쪼개기 (자동이체 설정) + const setAutoDebit = async () => { + const postOptions: FetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + "savingAccountAutoDebitId": accountAutoDebitId.saving, + "savingAutoDebitAmount": calcAmount(ratio.saving, salary), + "lifeAccountAutoDebitId": accountAutoDebitId.life, + "lifeAutoDebitAmount": calcAmount(ratio.life, salary), + "spareAccountAutoDebitId": accountAutoDebitId.reserve, + "spareAutoDebitAmount": calcAmount(ratio.reserve, salary) + }), + }; + + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, postOptions); + if (!response.ok) { + console.error('Failed to set account types'); + } else { + setModalOpen(false); + setCheckModalOpen(true); + } + } catch (error) { + console.error('Error:', error); + } + } + + return ( + <> + +
+
+
+

+ {user.name}님을 위한 +
+ 통장 쪼개기 추천 비율 +

+
+ 하나은행 +
+
+

+ 최근 한 달간 소비 패턴을 분석해서 추천해드렸어요 +

+
+
+
+
+ {ratio.saving}% +
+
+ {ratio.life}% +
+
+ {ratio.reserve}% +
+
+
+
+
저축
+
+
생활비
+
+
예비비
+
+
+
+
+
setMode(true)} + > + + 럭셔리 모드 +
+
setMode(false)} + > + + 짠돌이 모드 +
+
+
+
+
+

💰저축 통장

+
+
+ 비율 +
+
+ {ratio.saving}% +
+
+ 매달 +
+
+ {(Number(user.salary) * 0.01 * ratio.saving).toLocaleString()} + +
+
+
+
+
+
+

💳소비 통장

+
+
+ 비율 +
+
{ratio.life}%
+
+ 매달 +
+
+ {(Number(user.salary) * 0.01 * ratio.life).toLocaleString()} + +
+
+
+
+
+
+

💡예비 통장

+
+
+ 비율 +
+
+ {ratio.reserve}% +
+
+ 매달 +
+
+ {(Number(user.salary) * 0.01 * ratio.reserve).toLocaleString()} + +
+
+
+
+
+ + setModalOpen(false)}> +

✔️

+

+ 정말로 변경하시겠습니까? +

+
+ +
+
+ navigate("/split")}> +

✔️

+

+ 변경되었습니다. +

+
+ +
+
+
+ + ); +}; diff --git a/src/pages/split/SplitMainPage.tsx b/src/pages/split/SplitMainPage.tsx new file mode 100644 index 0000000..4cdce2b --- /dev/null +++ b/src/pages/split/SplitMainPage.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from "react-router-dom"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { addCommas } from '../../components/utils/formatters'; +import { API_BASE_URL } from '../../constants'; + +export type Ratio = { + saving: number; + life: number; + reserve: number; +}; + +export type SplitAccounts = { + savingAccountId: number; + lifeAccountId: number; + reserveAccountId: number; +}; + +const calcSplitRatio = (amount: Ratio, userSalary: string): Ratio => { + const salary = Number(userSalary); + + const saving = amount.saving/salary*100; + const life = amount.life/salary*100; + const reserve = amount.reserve/salary*100; + + return { + saving: saving, + life: life, + reserve: reserve + }; +}; + +const calcSplitAmount = (data: AccountAutoDebitAdjustGetResponse[]): Ratio =>{ + let saving = 0; + let life = 0; + let reserve = 0; + + data.forEach((account) => { + if (account.accountType === 'SAVING') { + saving = Math.abs(account.autoDebitAmount); + } else if (account.accountType === 'LIFE') { + life = Math.abs(account.autoDebitAmount); + } else if (account.accountType === 'SPARE') { + reserve = Math.abs(account.autoDebitAmount); + } + }); + + return { + saving: saving, + life: life, + reserve: reserve + }; +}; + +const setSplitAccounts = (data: AccountAutoDebitAdjustGetResponse[]): SplitAccounts =>{ + let saving = 0; + let life = 0; + let reserve = 0; + + data.forEach((account) => { + if (account.accountType === 'SAVING') { + saving = account.accountId; + } else if (account.accountType === 'LIFE') { + life = account.accountId; + } else if (account.accountType === 'SPARE') { + reserve = account.accountId; + } + }); + + return { + savingAccountId: saving, + lifeAccountId: life, + reserveAccountId: reserve + }; +}; + +export const SplitMainPage = () => { + const navigate = useNavigate(); + const { user } = useUser(); + const [ratio, setRatio] = useState({ saving: 0, life: 0, reserve: 0 }); + const [amount, setAmount] = useState({ saving: 0, life: 0, reserve: 0 }); + const [accounts, setAccounts] = useState({ savingAccountId: 0, lifeAccountId: 0, reserveAccountId: 0 }); + + const fetchOptions: FetchOptions = { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch(`${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, fetchOptions); + + const onAdjust = () => { + navigate("manual", { state: { splitRatio: ratio, splitAccounts: accounts } }); + }; + + useEffect(()=>{ + if (!loading){ + console.log(data); + // 자동이체 기록이 없거나 3개가 등록되어 있지 않을 때 -> 통장 쪼개기 처음 화면으로 + if(data && data.length>2){ + const salary = user.salary ? user.salary : "0"; + const settedAccounts = setSplitAccounts(data); + setAccounts(settedAccounts); + const calculatedAmount = calcSplitAmount(data); + setAmount(calculatedAmount); + const calculatedRatio = calcSplitRatio(calculatedAmount, salary); + setRatio(calculatedRatio); + }else{ + navigate("start"); + } + } + },[data, navigate, user.salary, loading]); + + + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+ +
+
+
+

+ 다음 달
+ 통장 쪼개기 비율 +

+
+
+
+
+ {ratio.saving}% +
+
+ {ratio.life}% +
+
+ {ratio.reserve}% +
+
+
+
+
저축
+
+
생활비
+
+
예비비
+
+
+
+
+

💰저축 통장

+
+
+ 비율 +
+
{ratio.saving}%
+
+ 매달 +
+
+ {addCommas(amount.saving)}원 +
+
+
+
+
+
+

💳소비 통장

+
+
+ 비율 +
+
{ratio.life}%
+
+ 매달 +
+
+ {addCommas(amount.life)}원 +
+
+
+
+
+
+

💡예비 통장

+
+
+ 비율 +
+
+ {ratio.reserve}% +
+
+ 매달 +
+
+ {addCommas(amount.reserve)}원 +
+
+
+
+
+ + +
+
+ + ); +}; diff --git a/src/pages/split/SplitManualPage.tsx b/src/pages/split/SplitManualPage.tsx new file mode 100644 index 0000000..bb3ee1e --- /dev/null +++ b/src/pages/split/SplitManualPage.tsx @@ -0,0 +1,285 @@ +import { useLocation, useNavigate } from "react-router-dom"; +import { TopLine } from "../../components/ui/TopLine"; +import { useEffect, useState } from "react"; +import { Ratio } from "./SplitMainPage"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions } from "../../hooks/fetch"; +import { API_BASE_URL } from "../../constants"; +import Modal from "../../components/ui/Modal"; + + + +export const SplitManualPage = () => { + const { user } = useUser(); + const navigate = useNavigate(); + const salary = Number(user.salary); + const [isModalOpen, setModalOpen] = useState(false); + const [isCheckModalOpen, setCheckModalOpen] = useState(false); + const [ratio, setRatio] = useState({ saving: 0, life: 0, reserve: 0 }); + const [isCorrect, setIsCorrect] = useState(true); + const [accountAutoDebitId, setAccountAutoDebitId] = useState({ saving: 0, life: 0, reserve: 0 }); + + //통장 쪼개기 메인 페이지에서 받아온 값 - 현재 쪼개기 비율 + const location = useLocation(); + const { splitRatio } = location.state || {}; + + useEffect(() => { + if (splitRatio) { + setRatio(splitRatio); + } + }, [splitRatio]); + + const handleRatioChange = (field: keyof Ratio, value: string) => { + const newValue = parseFloat(value) || 0; + setRatio((prevRatio) => ({ + ...prevRatio, + [field]: newValue, + })); + }; + + const calcTotalAmount = (): number => { + const savingAmount = salary * ratio.saving * 0.01; + const lifeAmount = salary * ratio.life * 0.01; + const reserveAmount = salary * ratio.reserve * 0.01; + + return savingAmount + lifeAmount + reserveAmount; + } + + const adjustComplete = () => { + const sum = ratio.saving + ratio.life + ratio.reserve; + if (sum !== 100) { + setIsCorrect(false); + return false; + } + + setModalOpen(true); + }; + + // 사용자 계좌 가져오기 + const getAccounts = async () => { + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.jwt}`, + }, + }); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data: AccountAutoDebitAdjustGetResponse[] = await response.json(); + const testData = setAccountId(data); + setAccountAutoDebitId(testData); + } catch (error) { + console.error('Fetch error:', error); + } + } + + // 타입별 통장 번호 저장 + const setAccountId = (accounts: AccountAutoDebitAdjustGetResponse[]): Ratio => { + let saving = 0; + let life = 0; + let reserve = 0; + accounts.forEach((account) => { + if (account.accountType === "SAVING") { + saving = account.accountAutoDebitId; + } else if (account.accountType === "LIFE") { + life = account.accountAutoDebitId; + } else if (account.accountType === "SPARE") { + reserve = account.accountAutoDebitId; + } + }); + + return { + saving: saving, + life: life, + reserve: reserve + }; + }; + + useEffect(() => { + if (accountAutoDebitId.saving !== 0 && accountAutoDebitId.life !== 0 && accountAutoDebitId.reserve !== 0) { + setAutoDebit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountAutoDebitId]); + + // 통장 쪼개기 (자동이체 설정) + const setAutoDebit = async () => { + const postOptions: FetchOptions = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + "savingAccountAutoDebitId": accountAutoDebitId.saving, + "savingAutoDebitAmount": calcAmount(ratio.saving, salary), + "lifeAccountAutoDebitId": accountAutoDebitId.life, + "lifeAutoDebitAmount": calcAmount(ratio.life, salary), + "spareAccountAutoDebitId": accountAutoDebitId.reserve, + "spareAutoDebitAmount": calcAmount(ratio.reserve, salary) + }), + }; + + try { + const response = await fetch(`${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, postOptions); + if (!response.ok) { + console.error('Failed to set account types'); + } else { + setModalOpen(false); + setCheckModalOpen(true); + // navigate("/split"); + } + } catch (error) { + console.error('Error:', error); + } + } + + const calcAmount = (ratio: number, salary: number): number => { + return 0.01 * ratio * salary; + }; + + + + return ( + <> + +
+
+

통장 쪼개기

+

+ 월 소득 {salary.toLocaleString()}원 +

+
+ +
+
+

💰 저축 통장

+
+
+ 비율 +
+
+ handleRatioChange("saving", e.target.value)} + />{" "} + % +
+
+ 매달 +
+
+ + {(salary*ratio.saving*0.01).toLocaleString()} + + +
+
+
+
+
+
+

💳 소비 통장

+
+
+ 비율 +
+
+ handleRatioChange("life", e.target.value)} + />{" "} + % +
+
+ 매달 +
+
+ + {(salary*ratio.life*0.01).toLocaleString()} + + +
+
+
+
+
+
+

💡 예비 통장

+
+
+ 비율 +
+
+ handleRatioChange("reserve", e.target.value)} + />{" "} + % +
+
+ 매달 +
+
+ + {(salary*ratio.reserve*0.01).toLocaleString()} + + +
+
+
+
+
+
+ 합 {ratio.saving+ratio.life+ratio.reserve}% +
+
+ 총 {calcTotalAmount().toLocaleString()}원 +
+
+ {isCorrect?(null):( +
+ 총합을 100%로 맞춰주세요 +
+ )} +
+ +
+ setModalOpen(false)}> +

✔️

+

+ 정말로 변경하시겠습니까? +

+
+ +
+
+ navigate("/split")}> +

✔️

+

+ 변경되었습니다. +

+
+ +
+
+
+ + ); +}; diff --git a/src/pages/split/SplitStartCompletePage.tsx b/src/pages/split/SplitStartCompletePage.tsx new file mode 100644 index 0000000..41da393 --- /dev/null +++ b/src/pages/split/SplitStartCompletePage.tsx @@ -0,0 +1,53 @@ +import { useLocation } from "react-router-dom"; +import { GreenButton } from "../../components/ui/GreenButton"; +import { TopLine } from "../../components/ui/TopLine"; +import { useUser } from "../../contexts/UserContext"; + +export const SplitStartCompletePage = () => { + const {user} = useUser(); + const salary = Number(user.salary); + const location = useLocation(); + const { splitRatio } = location.state || {}; + + const calcAmount=(ratio:number):number=>{ + return ratio*0.01*salary; + }; + + return( + <> +
+ +
+
+
+
+
+

✔️

+

통장쪼개기
완료 !

+
+
+
+ + {calcAmount(splitRatio.saving).toLocaleString()}원 ({splitRatio.saving}%) +
+
+ + {calcAmount(splitRatio.life).toLocaleString()}원 ({splitRatio.life}%) +
+
+ + {calcAmount(splitRatio.reserve).toLocaleString()}원 ({splitRatio.reserve}%) +
+
+ +
+
+ + ); +}; \ No newline at end of file diff --git a/src/pages/split/SplitStartPage.tsx b/src/pages/split/SplitStartPage.tsx new file mode 100644 index 0000000..62100f0 --- /dev/null +++ b/src/pages/split/SplitStartPage.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import { GreenButton } from "../../components/ui/GreenButton"; +import PhoneModal from "../../components/ui/PhoneModal"; +import { addCommas } from "../../components/utils/formatters"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { API_BASE_URL } from "../../constants"; + +export const SplitStartPage = () => { + const [isModalOpen, setModalOpen] = useState(false); + const { user } = useUser(); + + const fetchOptions: FetchOptions = { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch(`${API_BASE_URL}/api/v1/users`, fetchOptions); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( + <> +
+
+
+

통장 쪼개기

+

효율적인 돈 관리를 위해
하나피스가 대신 도와드릴게요!

+
+
+ +
+
+
+ +
+ +
+ + setModalOpen(false)}> +
+
+

+ {user.name}님의 기본 정보 +

+

적합한 통장 쪼개기 비율을 추천해드릴게요

+
+
+
나이
+
{data?.age} 세
+ +
성별
+
{data?.sex == 'M' ? '남' : '여'}
+ +
월급
+
{data?.salary !== undefined ? `${addCommas(data.salary)} 원` : '정보 없음'}
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/src/pages/split/SplitStartSettingPage.tsx b/src/pages/split/SplitStartSettingPage.tsx new file mode 100644 index 0000000..ad621b4 --- /dev/null +++ b/src/pages/split/SplitStartSettingPage.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { TopLine } from "../../components/ui/TopLine"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions, useFetch } from "../../hooks/fetch"; +import { API_BASE_URL } from "../../constants"; + +export const SplitStartSettingPage = () => { + const { user } = useUser(); + const [isCheck, setIsCheck] = useState(true); + const navigate = useNavigate(); + + const fetchOptions: FetchOptions = { + method: 'GET', + headers: { + 'Authorization': `Bearer ${user.jwt}`, + }, + }; + + const { data, error, loading } = useFetch(`${API_BASE_URL}/api/v1/accounts/checking`, fetchOptions); + + const [selectedAccounts, setSelectedAccounts] = useState<{ + salary: number | null; + saving: number | null; + spending: number | null; + reserve: number | null; + }>({ + salary: null, + saving: null, + spending: null, + reserve: null, + }); + + const handleSelectChange = ( + event: React.ChangeEvent, + type: string + ) => { + const value = parseInt(event.target.value, 10); + setSelectedAccounts({ + ...selectedAccounts, + [type]: value, + }); + }; + + const getFilteredAccounts = (excludeType: string) => { + if (!data) return []; + + const selectedIds = Object.keys(selectedAccounts) + .filter((key) => key !== excludeType) + .map((key) => selectedAccounts[key as keyof typeof selectedAccounts]) + .filter((id) => id !== null); + + return data.filter((account) => !selectedIds.includes(account?.accountId)); + }; + + const setAccountType = async () => { + if (Object.values(selectedAccounts).includes(null)){ + setIsCheck(false); + return false; + } + navigate("/split/start/split", { state: { selectedAccounts } }); + }; + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( +
+ +
+
+
+
+ 통장 설정하기 +

해당 통장으로 사용하실 계좌를 선택해주세요!

+
+
+

💸 월급 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+
+

💰 저축 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+
+

💳 소비 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+
+
+

💡 예비 통장

+
+
+ 계좌 +
+
+ +
+
+
+
+ {isCheck?( + null + ):(<> +
+ 모든 계좌를 선택해 주세요 +
+ + )} +
+ +
+
+
+ ); +}; diff --git a/src/pages/split/SplitStartSplitPage.tsx b/src/pages/split/SplitStartSplitPage.tsx new file mode 100644 index 0000000..23fe815 --- /dev/null +++ b/src/pages/split/SplitStartSplitPage.tsx @@ -0,0 +1,387 @@ +import { useLocation, useNavigate } from "react-router-dom"; +import { TopLine } from "../../components/ui/TopLine"; +import { Ratio } from "./SplitMainPage"; +import { useEffect, useState } from "react"; +import { useUser } from "../../contexts/UserContext"; +import { FetchOptions } from "../../hooks/fetch"; +import { API_BASE_URL } from "../../constants"; +export const SplitStartSplitPage = () => { + const navigate = useNavigate(); + const { user } = useUser(); + const salary = Number(user.salary); + const [accountAutoDebitId, setAccountAutoDebitId] = useState({ + saving: 0, + life: 0, + reserve: 0, + }); + //통장 설정 페이지에서 받아온 값 + const location = useLocation(); + const { selectedAccounts } = location.state || {}; + const [ratio, setRatio] = useState({ + saving: 0, + life: 0, + reserve: 0, + }); + const [recoRatio, setRecoRatio] = useState({ + saving: 0, + life: 0, + reserve: 0, + }); + useEffect(() => { + getRatio(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user.jwt]); + const getRatio = async () => { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/auto-debit/suggestions/init`, + { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + const data: Ratio = await response.json(); + setRatio(data); + setRecoRatio(data); + } catch (error) { + console.error("Fetch error:", error); + } + }; + const [isEditing, setIsEditing] = useState(false); + const [isCorrect, setIsCorrect] = useState(true); + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setRatio((prevRatio) => ({ + ...prevRatio, + [name]: Number(value), + })); + }; + const calcAmount = (ratio: number, salary: number): number => { + return 0.01 * ratio * salary; + }; + const setCancle = () => { + setRatio(recoRatio); + setIsEditing(false); + }; + // 완료 버튼 ----------- + const adjustComplete = () => { + const sum = ratio.saving + ratio.life + ratio.reserve; + if (sum != 100) { + setIsCorrect(false); + return false; + } + setAccountType(); + }; + useEffect(() => { + if ( + accountAutoDebitId.saving !== 0 && + accountAutoDebitId.life !== 0 && + accountAutoDebitId.reserve !== 0 + ) { + setAutoDebit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountAutoDebitId]); + // 통장 쪼개기 (자동이체 설정) + const setAutoDebit = async () => { + console.log(selectedAccounts.saving, calcAmount(ratio.saving, salary)); + const postOptions: FetchOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + savingAccountAutoDebitId: accountAutoDebitId.saving, + savingAutoDebitAmount: calcAmount(ratio.saving, salary), + lifeAccountAutoDebitId: accountAutoDebitId.life, + lifeAutoDebitAmount: calcAmount(ratio.life, salary), + spareAccountAutoDebitId: accountAutoDebitId.reserve, + spareAutoDebitAmount: calcAmount(ratio.reserve, salary), + }), + }; + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, + postOptions + ); + if (!response.ok) { + console.error("Failed to set account types"); + } else { + navigate("/split/start/complete", { state: { splitRatio: ratio } }); + } + } catch (error) { + console.error("Error:", error); + } + }; + // 계좌 타입 요청 + const setAccountType = async () => { + const postOptions: FetchOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${user.jwt}`, + }, + body: JSON.stringify({ + salaryAccountId: selectedAccounts.salary, + savingAccountId: selectedAccounts.saving, + lifeAccountId: selectedAccounts.spending, + spareAccountId: selectedAccounts.reserve, + }), + }; + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/account-type-reg`, + postOptions + ); + if (!response.ok) { + console.error("Failed to set account types"); + } else { + getAccounts(); + } + } catch (error) { + console.error("Error:", error); + } + }; + // 사용자 계좌 가져오기 + const getAccounts = async () => { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/accounts/auto-debit/adjust`, + { + method: "GET", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + const data: AccountAutoDebitAdjustGetResponse[] = await response.json(); + const testData = setAccountId(data); + setAccountAutoDebitId(testData); + } catch (error) { + console.error("Fetch error:", error); + } + }; + // 타입별 통장 번호 저장 + const setAccountId = ( + accounts: AccountAutoDebitAdjustGetResponse[] + ): Ratio => { + let saving = 0; + let life = 0; + let reserve = 0; + accounts.forEach((account) => { + if (account.accountType === "SAVING") { + saving = account.accountAutoDebitId; + } else if (account.accountType === "LIFE") { + life = account.accountAutoDebitId; + } else if (account.accountType === "SPARE") { + reserve = account.accountAutoDebitId; + } + }); + return { + saving: saving, + life: life, + reserve: reserve, + }; + }; + return ( + <> +
+ +
+
+
+
+
+
+

+ {user.name}님을 + 위한 +
+ 통장 쪼개기 추천 비율 +

+
+ 하나은행 +
+
+
+
+
+
+ {ratio.saving}% +
+
+ {ratio.life}% +
+
+ {ratio.reserve}% +
+
+
+
+
저축
+
+
생활비
+
+
예비비
+
+
+
+
+

💰저축 통장

+
+
+ 비율 +
+
+ {!isEditing ? ( + `${ratio.saving}%` + ) : ( + <> + {" "} + % + + )} +
+
+ 매달 +
+
+ {calcAmount(ratio.saving, salary).toLocaleString()}원 +
+
+
+
+
+
+

💳소비 통장

+
+
+ 비율 +
+
+ {!isEditing ? ( + `${ratio.life}%` + ) : ( + <> + {" "} + % + + )} +
+
+ 매달 +
+
+ {calcAmount(ratio.life, salary).toLocaleString()}원 +
+
+
+
+
+
+

💡예비 통장

+
+
+ 비율 +
+
+ {!isEditing ? ( + `${ratio.reserve}%` + ) : ( + <> + {" "} + % + + )} +
+
+ 매달 +
+
+ {calcAmount(ratio.reserve, salary).toLocaleString()}원 +
+
+
+
+
+ {isCorrect ? null : ( + <> +
+ 총합을 100%로 맞춰주세요 +
+ + )} +
+ {!isEditing ? ( + + ) : ( + + )} + +
+
+
+ + ); +}; diff --git a/src/pages/split/splitType.d.ts b/src/pages/split/splitType.d.ts new file mode 100644 index 0000000..c8d04d2 --- /dev/null +++ b/src/pages/split/splitType.d.ts @@ -0,0 +1,25 @@ +interface AccountAutoDebitAdjustGetResponse { + accountId: number; + accountType: string; + accountNumber: string; + accountAutoDebitId: number; + autoDebitAmount: number; +} + +interface UserGetResponse { + userId: number; + email: string; + sex: string; + age: number; + qualificationTypeCd: string; + cityTypeCd: string; + nickname: string; + salary: number; + salaryDay: number; +} + +interface AccountGetResponse { + accountId: number; + accountNumber: string; + accountTypeCd: string; +} diff --git a/src/pages/start/LoginPage.tsx b/src/pages/start/LoginPage.tsx new file mode 100644 index 0000000..471b255 --- /dev/null +++ b/src/pages/start/LoginPage.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState } from "react"; +import { SlArrowLeft } from "react-icons/sl"; +import { useNavigate } from "react-router-dom"; +import { User, useUser } from "../../contexts/UserContext"; +import { useGoalsProducts } from "../../contexts/ProductContext"; +import { API_BASE_URL } from "../../constants"; + +export const LoginPage = () => { + const navigate = useNavigate(); + const [password, setPassword] = useState(""); + const { user, login } = useUser(); + const { goalsProducts, setGoal } = useGoalsProducts(); + + useEffect(() => { + const loginRequest = async () => { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/users/login`, + { + method: "post", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: password, + }), + } + ); + if (response.ok) { + const json = await response.json(); + const customer: User = { + jwt: json["accessToken"], + name: json["name"], + salary: json["salary"], + }; + + console.log("Customer received:", customer); + login(customer); + } else { + alert("Incorrect password!"); + setPassword(""); + } + } catch (err) { + alert("에러가 발생했습니다. " + err); + } + }; + + if (password.length === 6) { + loginRequest(); + } + }, [password, login]); + + useEffect(() => { + console.log("Updated user:", user); + if (user.jwt) { + (async function () { + try { + const response = await fetch( + `${API_BASE_URL}/api/v1/user-goals`, + { + method: "get", + headers: { + Authorization: `Bearer ${user.jwt}`, + }, + } + ); + if (response.ok) { + const json = await response.json(); + console.log(json); + setGoal(json); + console.log(goalsProducts?.goalsProducts); + navigate("/home"); + } + } catch (err) { + if (err instanceof Error) { + alert(`에러가 발생했습니다. (${err}`); + } + } + })(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user, navigate]); + + const insertPassword = (value: string) => { + if (value === "back") { + setPassword(password.slice(0, -1)); + } else { + const newPassword = password + value; + if (newPassword.length <= 6) { + setPassword(newPassword); + } + } + }; + + return ( + <> +
+
+

로그인

+
+ {[...Array(6)].map((_, index) => ( +
index ? "bg-customGreen" : "bg-slate-200" + }`} + /> + ))} +
+
+
+
+ {[...Array(9)].map((_, index) => ( +
insertPassword((index + 1).toString())} + > + {index + 1} +
+ ))} +
+
insertPassword("0")} + > + 0 +
+
insertPassword("back")} + > + +
+
+
+
+ + ); +}; diff --git a/src/pages/start/Splash.tsx b/src/pages/start/Splash.tsx index 13d08c7..f1cffa9 100644 --- a/src/pages/start/Splash.tsx +++ b/src/pages/start/Splash.tsx @@ -1,19 +1,28 @@ -import { GreenButton } from "../../components/ui/GreenButton"; -import { TopLine } from "../../components/ui/TopLine"; +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; -function Splash() { +export const Splash = () => { + const navigate = useNavigate(); + + useEffect(() => { + const timer = setTimeout(() => { + navigate("/tutorial"); + }, 3000); + + return () => clearTimeout(timer); // 컴포넌트 언마운트 시 타이머 정리 + }, [navigate]); return ( <> -
- {/*

- SPLASHkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk -

*/} -

- - +
+
+
+ {/* Hana Picture */} + Hana Picture + {/* 하나피스 */} +
+ @Copyright HanaPiece +
); -} - -export default Splash; +}; diff --git a/src/pages/start/Tutorial1Page.tsx b/src/pages/start/Tutorial1Page.tsx new file mode 100644 index 0000000..9c5ef2b --- /dev/null +++ b/src/pages/start/Tutorial1Page.tsx @@ -0,0 +1,21 @@ +import { GreenButton } from "../../components/ui/GreenButton"; + +export const Tutorial1Page = () => { + return ( + <> +
+
+ Hana Picture + + 지출 내역을 확인해요. + + +
+
+ + ); +}; diff --git a/src/pages/start/Tutorial2Page.tsx b/src/pages/start/Tutorial2Page.tsx new file mode 100644 index 0000000..8def0d0 --- /dev/null +++ b/src/pages/start/Tutorial2Page.tsx @@ -0,0 +1,21 @@ +import { GreenButton } from "../../components/ui/GreenButton"; + +export const Tutorial2Page = () => { + return ( + <> +
+
+ Hana Picture + + 통장을 쪼개서 관리해요. + + +
+
+ + ); +}; diff --git a/src/pages/start/Tutorial3Page.tsx b/src/pages/start/Tutorial3Page.tsx new file mode 100644 index 0000000..655526a --- /dev/null +++ b/src/pages/start/Tutorial3Page.tsx @@ -0,0 +1,21 @@ +import { GreenButton } from "../../components/ui/GreenButton"; + +export const Tutorial3Page = () => { + return ( + <> +
+
+ Hana Picture + + 저축 목표를 설정해요. + + +
+
+ + ); +}; diff --git a/src/pages/start/Tutorial4Page.tsx b/src/pages/start/Tutorial4Page.tsx new file mode 100644 index 0000000..12098db --- /dev/null +++ b/src/pages/start/Tutorial4Page.tsx @@ -0,0 +1,24 @@ +import { GreenButton } from "../../components/ui/GreenButton"; +import { useUser } from "../../contexts/UserContext"; + +export const Tutorial4Page = () => { + const { logout } = useUser(); + return ( + <> +
+
+ Hana Picture + + 목표 달성을 위해 적금에 가입해요. + + + +
+
+ + ); +}; diff --git a/src/pages/start/TutorialPage.tsx b/src/pages/start/TutorialPage.tsx new file mode 100644 index 0000000..fca0c55 --- /dev/null +++ b/src/pages/start/TutorialPage.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { GreenButton } from '../../components/ui/GreenButton'; +import { useUser } from '../../contexts/UserContext'; + +export const TutorialPage = () => { + const [currentSlide, setCurrentSlide] = useState(0); + const { logout } = useUser(); + + const slides = [ + { + content: ( + <> +
+

소비 내역 확인

+

+ 월 별 지출 금액을
+ 한 눈에 확인하고
+ 소비 습관을 개선해보아요! +

+
+
+ +
+ + ), + }, + { + content: ( + <> +
+

통장 쪼개기 자동화

+

+ 자동으로
+ 소비에 맞게
+ 돈을 세분화 시켜줘요! +

+
+
+ +
+ + ), + }, + { + content: ( + <> +
+

목표 달성 금액

+

+ 구체적인 목표를 입력하면
+ 시세를 예측해서
+ 목표 달성 금액을 정해줘요! +

+
+
+ +
+ + ), + }, + { + content: ( + <> +
+

목표 추천 적금

+

+ 소비 목표를 만들고
목표를 달성할 수 있도록
최적의 적금을 추천해드려요! +

+
+
+ +
+ + ), + }, + ]; + + const nextSlide = () => { + if (currentSlide < slides.length - 1) { + setCurrentSlide(currentSlide + 1); + } + }; + + // const prevSlide = () => { + // if (currentSlide > 0) { + // setCurrentSlide(currentSlide - 1); + // } + // }; + + return ( + <> +
+
+
+ {slides.map((slide, index) => ( +
+ {slide.content} +
+ ))} +
+
+ +
+ {slides.map((_, index) => ( +
+ ))} +
+ {currentSlide !== slides.length - 1 ? ( + + ) : ( + + )} +
+ + + ); +}; diff --git a/src/pages/start/TutorialPage1.tsx b/src/pages/start/TutorialPage1.tsx deleted file mode 100644 index 2ceccfa..0000000 --- a/src/pages/start/TutorialPage1.tsx +++ /dev/null @@ -1,9 +0,0 @@ -export const TutorialPage1 = () => { - return ( - <> -
-
TutorialPage1
-
- - ); -}; diff --git a/src/types/ProductType.ts b/src/types/ProductType.ts new file mode 100644 index 0000000..9a7a13a --- /dev/null +++ b/src/types/ProductType.ts @@ -0,0 +1,24 @@ +type Product = { + id: number; + name: string; + rate: number; + info: string; + term_year: number; + cautions: string; + deposit_protection: string; + contract_terms: string; +}; + +// enum GOAL { +// HOUSE = "house", +// CAR = "car", +// WISH = "wish", +// } + +type GoalProducts = { + id: number; + goal: string; + products: Product[]; +}; + +export type { Product, GoalProducts }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..afe2148 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,10 @@ /// + +interface ImportMetaEnv { + readonly VITE_API_URI: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index df4a614..cd4fb8f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -4,7 +4,10 @@ export default { theme: { extend: { colors: { - customTeal: "#008485", + customGreen: "#008485", + }, + fontFamily: { + 'noto-sans-kr': ['Noto Sans KR', 'sans-serif'], }, }, }, diff --git a/yarn.lock b/yarn.lock index 724bf6a..674233f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,7 +28,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz" integrity sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ== -"@babel/core@^7.23.5": +"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.23.5": version "7.24.5" resolved "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz" integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA== @@ -214,116 +214,6 @@ "@babel/helper-validator-identifier" "^7.24.5" to-fast-properties "^2.0.0" -"@esbuild/aix-ppc64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537" - integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g== - -"@esbuild/android-arm64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" - integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== - -"@esbuild/android-arm@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995" - integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w== - -"@esbuild/android-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98" - integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg== - -"@esbuild/darwin-arm64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz#6e8517a045ddd86ae30c6608c8475ebc0c4000bb" - integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA== - -"@esbuild/darwin-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0" - integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA== - -"@esbuild/freebsd-arm64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911" - integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw== - -"@esbuild/freebsd-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c" - integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw== - -"@esbuild/linux-arm64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5" - integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A== - -"@esbuild/linux-arm@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c" - integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg== - -"@esbuild/linux-ia32@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" - integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== - -"@esbuild/linux-loong64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5" - integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ== - -"@esbuild/linux-mips64el@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa" - integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA== - -"@esbuild/linux-ppc64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20" - integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg== - -"@esbuild/linux-riscv64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300" - integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg== - -"@esbuild/linux-s390x@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685" - integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ== - -"@esbuild/linux-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff" - integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw== - -"@esbuild/netbsd-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6" - integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ== - -"@esbuild/openbsd-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf" - integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ== - -"@esbuild/sunos-x64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f" - integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w== - -"@esbuild/win32-arm64@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90" - integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ== - -"@esbuild/win32-ia32@0.20.2": - version "0.20.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23" - integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ== - "@esbuild/win32-x64@0.20.2": version "0.20.2" resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz" @@ -424,6 +314,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@kurkle/color@^0.3.0": + version "0.3.2" + resolved "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz" + integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -432,7 +327,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -455,81 +350,6 @@ resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz" integrity sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig== -"@rollup/rollup-android-arm-eabi@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.17.2.tgz#1a32112822660ee104c5dd3a7c595e26100d4c2d" - integrity sha512-NM0jFxY8bB8QLkoKxIQeObCaDlJKewVlIEkuyYKm5An1tdVZ966w2+MPQ2l8LBZLjR+SgyV+nRkTIunzOYBMLQ== - -"@rollup/rollup-android-arm64@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.17.2.tgz#5aeef206d65ff4db423f3a93f71af91b28662c5b" - integrity sha512-yeX/Usk7daNIVwkq2uGoq2BYJKZY1JfyLTaHO/jaiSwi/lsf8fTFoQW/n6IdAsx5tx+iotu2zCJwz8MxI6D/Bw== - -"@rollup/rollup-darwin-arm64@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.17.2.tgz#6b66aaf003c70454c292cd5f0236ebdc6ffbdf1a" - integrity sha512-kcMLpE6uCwls023+kknm71ug7MZOrtXo+y5p/tsg6jltpDtgQY1Eq5sGfHcQfb+lfuKwhBmEURDga9N0ol4YPw== - -"@rollup/rollup-darwin-x64@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.17.2.tgz#f64fc51ed12b19f883131ccbcea59fc68cbd6c0b" - integrity sha512-AtKwD0VEx0zWkL0ZjixEkp5tbNLzX+FCqGG1SvOu993HnSz4qDI6S4kGzubrEJAljpVkhRSlg5bzpV//E6ysTQ== - -"@rollup/rollup-linux-arm-gnueabihf@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.17.2.tgz#1a7641111be67c10111f7122d1e375d1226cbf14" - integrity sha512-3reX2fUHqN7sffBNqmEyMQVj/CKhIHZd4y631duy0hZqI8Qoqf6lTtmAKvJFYa6bhU95B1D0WgzHkmTg33In0A== - -"@rollup/rollup-linux-arm-musleabihf@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.17.2.tgz#c93fd632923e0fee25aacd2ae414288d0b7455bb" - integrity sha512-uSqpsp91mheRgw96xtyAGP9FW5ChctTFEoXP0r5FAzj/3ZRv3Uxjtc7taRQSaQM/q85KEKjKsZuiZM3GyUivRg== - -"@rollup/rollup-linux-arm64-gnu@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.17.2.tgz#fa531425dd21d058a630947527b4612d9d0b4a4a" - integrity sha512-EMMPHkiCRtE8Wdk3Qhtciq6BndLtstqZIroHiiGzB3C5LDJmIZcSzVtLRbwuXuUft1Cnv+9fxuDtDxz3k3EW2A== - -"@rollup/rollup-linux-arm64-musl@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.17.2.tgz#8acc16f095ceea5854caf7b07e73f7d1802ac5af" - integrity sha512-NMPylUUZ1i0z/xJUIx6VUhISZDRT+uTWpBcjdv0/zkp7b/bQDF+NfnfdzuTiB1G6HTodgoFa93hp0O1xl+/UbA== - -"@rollup/rollup-linux-powerpc64le-gnu@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.17.2.tgz#94e69a8499b5cf368911b83a44bb230782aeb571" - integrity sha512-T19My13y8uYXPw/L/k0JYaX1fJKFT/PWdXiHr8mTbXWxjVF1t+8Xl31DgBBvEKclw+1b00Chg0hxE2O7bTG7GQ== - -"@rollup/rollup-linux-riscv64-gnu@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.17.2.tgz#7ef1c781c7e59e85a6ce261cc95d7f1e0b56db0f" - integrity sha512-BOaNfthf3X3fOWAB+IJ9kxTgPmMqPPH5f5k2DcCsRrBIbWnaJCgX2ll77dV1TdSy9SaXTR5iDXRL8n7AnoP5cg== - -"@rollup/rollup-linux-s390x-gnu@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.17.2.tgz#f15775841c3232fca9b78cd25a7a0512c694b354" - integrity sha512-W0UP/x7bnn3xN2eYMql2T/+wpASLE5SjObXILTMPUBDB/Fg/FxC+gX4nvCfPBCbNhz51C+HcqQp2qQ4u25ok6g== - -"@rollup/rollup-linux-x64-gnu@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.17.2.tgz#b521d271798d037ad70c9f85dd97d25f8a52e811" - integrity sha512-Hy7pLwByUOuyaFC6mAr7m+oMC+V7qyifzs/nW2OJfC8H4hbCzOX07Ov0VFk/zP3kBsELWNFi7rJtgbKYsav9QQ== - -"@rollup/rollup-linux-x64-musl@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.17.2.tgz#9254019cc4baac35800991315d133cc9fd1bf385" - integrity sha512-h1+yTWeYbRdAyJ/jMiVw0l6fOOm/0D1vNLui9iPuqgRGnXA0u21gAqOyB5iHjlM9MMfNOm9RHCQ7zLIzT0x11Q== - -"@rollup/rollup-win32-arm64-msvc@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.17.2.tgz#27f65a89f6f52ee9426ec11e3571038e4671790f" - integrity sha512-tmdtXMfKAjy5+IQsVtDiCfqbynAQE/TQRpWdVataHmhMb9DCoJxp9vLcCBjEQWMiUYxO1QprH/HbY9ragCEFLA== - -"@rollup/rollup-win32-ia32-msvc@4.17.2": - version "4.17.2" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.17.2.tgz#a2fbf8246ed0bb014f078ca34ae6b377a90cb411" - integrity sha512-7II/QCSTAHuE5vdZaQEwJq2ZACkBpQDOmQsE6D6XUbnBHW8IAhm4eTufL6msLJorzrHDFv3CF8oCA/hSIRuZeQ== - "@rollup/rollup-win32-x64-msvc@4.17.2": version "4.17.2" resolved "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.17.2.tgz" @@ -608,7 +428,7 @@ natural-compare "^1.4.0" ts-api-utils "^1.3.0" -"@typescript-eslint/parser@^7.2.0": +"@typescript-eslint/parser@^7.0.0", "@typescript-eslint/parser@^7.2.0": version "7.10.0" resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.10.0.tgz" integrity sha512-2EjZMA0LUW5V5tGQiaa2Gys+nKdfrn2xiTIBLR4fxmPmVSvgPcKNW+AE/ln9k0A4zDUti0J/GZXMDupQoI+e1w== @@ -695,7 +515,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.9.0: +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0: version "8.11.3" resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -769,7 +589,7 @@ array-union@^2.1.0: autoprefixer@^10.4.19: version "10.4.19" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f" + resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz" integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew== dependencies: browserslist "^4.23.0" @@ -811,7 +631,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.22.2, browserslist@^4.23.0: +browserslist@^4.22.2, browserslist@^4.23.0, "browserslist@>= 4.21.0": version "4.23.0" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz" integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== @@ -853,6 +673,18 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chart.js@^4.1.1, chart.js@^4.4.3, chart.js@>=3.0.0: + version "4.4.3" + resolved "https://registry.npmjs.org/chart.js/-/chart.js-4.4.3.tgz" + integrity sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw== + dependencies: + "@kurkle/color" "^0.3.0" + +chartjs-plugin-datalabels@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz" + integrity sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw== + chokidar@^3.5.3: version "3.6.0" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" @@ -868,6 +700,11 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz" @@ -882,16 +719,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + commander@^4.0.0: version "4.1.1" resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz" @@ -1049,7 +886,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.57.0: +"eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^8.56.0, eslint@^8.57.0, eslint@>=7: version "8.57.0" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz" integrity sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ== @@ -1205,7 +1042,7 @@ foreground-child@^3.1.0: fraction.js@^4.3.7: version "4.3.7" - resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" + resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== fs.realpath@^1.0.0: @@ -1213,11 +1050,6 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2, fsevents@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -1228,7 +1060,7 @@ gensync@^1.0.0-beta.2: resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -1242,6 +1074,13 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + glob@^10.3.10: version "10.3.16" resolved "https://registry.npmjs.org/glob/-/glob-10.3.16.tgz" @@ -1520,7 +1359,14 @@ minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^9.0.1, minimatch@^9.0.4: +minimatch@^9.0.1: + version "9.0.4" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: version "9.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz" integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== @@ -1568,7 +1414,7 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: normalize-range@^0.1.2: version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" + resolved "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz" integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== object-assign@^4.0.1: @@ -1659,7 +1505,12 @@ picocolors@^1.0.0, picocolors@^1.0.1: resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz" integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== -picomatch@^2.0.4, picomatch@^2.2.1: +picomatch@^2.0.4: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^2.2.1: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -1723,7 +1574,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.4.23, postcss@^8.4.38: +postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.38, postcss@>=8.0.9: version "8.4.38" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -1747,7 +1598,12 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -react-dom@^18.2.0: +react-chartjs-2@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz" + integrity sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA== + +react-dom@^18.2.0, react-dom@>=16.8: version "18.3.1" resolved "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz" integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== @@ -1757,7 +1613,7 @@ react-dom@^18.2.0: react-icons@^5.2.1: version "5.2.1" - resolved "https://registry.yarnpkg.com/react-icons/-/react-icons-5.2.1.tgz#28c2040917b2a2eda639b0f797bff1888e018e4a" + resolved "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz" integrity sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw== react-refresh@^0.14.0: @@ -1780,7 +1636,7 @@ react-router@6.23.1: dependencies: "@remix-run/router" "1.16.1" -react@^18.2.0: +react@*, "react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^18.2.0, react@^18.3.1, react@>=16.8: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== @@ -1990,7 +1846,7 @@ supports-preserve-symlinks-flag@^1.0.0: tailwindcss@^3.4.3: version "3.4.3" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.3.tgz#be48f5283df77dfced705451319a5dffb8621519" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.3.tgz" integrity sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A== dependencies: "@alloc/quick-lru" "^5.2.0" @@ -2069,7 +1925,7 @@ type-fest@^0.20.2: resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== -typescript@^5.2.2: +typescript@^5.2.2, typescript@>=4.2.0: version "5.4.5" resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz" integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== @@ -2094,7 +1950,7 @@ util-deprecate@^1.0.2: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -vite@^5.2.0: +"vite@^4.2.0 || ^5.0.0", vite@^5.2.0: version "5.2.11" resolved "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz" integrity sha512-HndV31LWW05i1BLPMUCE1B9E9GFbOu1MbenhS58FuK6owSO5qHm7GiCotrNY1YE5rMeQSFBGmT5ZaLEjFizgiQ==