diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..7999be84 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,129 @@ +module.exports = { + root: true, + env: { browser: true, es2021: true, node: true }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:prettier/recommended', + 'next/core-web-vitals', + 'next/typescript', + 'plugin:storybook/recommended', + ], + plugins: ['@typescript-eslint', 'react', 'jsx-a11y', 'import', 'prettier', 'filenames'], + rules: { + 'prettier/prettier': 'error', + 'filenames/match-regex': ['error', '^[a-z-]+.[a-z]+$'], + 'react/jsx-pascal-case': 'error', + camelcase: ['error', { properties: 'never' }], + 'react/jsx-handler-names': [ + 'error', + { + eventHandlerPrefix: 'handle', + eventHandlerPropPrefix: 'on', + }, + ], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'variable', + types: ['boolean'], + format: ['PascalCase'], + prefix: ['is', 'has', 'should'], + }, + { + selector: 'variable', + types: ['function'], + format: ['camelCase'], + suffix: ['Ref'], + filter: { + regex: 'Ref$', + match: true, + }, + }, + { + selector: 'interface', + format: ['PascalCase'], + custom: { + regex: '(Props|Model)$', + match: true, + }, + }, + { + selector: 'typeAlias', + format: ['PascalCase'], + suffix: ['Type'], + }, + ], + 'react/destructuring-assignment': ['error', 'always'], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], + 'react/function-component-definition': [ + 2, + { + namedComponents: 'arrow-function', + unnamedComponents: 'arrow-function', + }, + ], + 'react/jsx-key': ['error', { checkFragmentShorthand: true }], + 'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }], + 'jsx-a11y/alt-text': [ + 'error', + { + elements: ['img', 'object', 'area', 'input[type="image"]'], + img: ['Image'], + object: [], + area: [], + 'input[type="image"]': [], + }, + ], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'import/no-default-export': 'off', + 'import/prefer-default-export': 'off', + 'import/no-unresolved': 'off', + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: 'error', + 'prefer-destructuring': [ + 'error', + { + array: false, + object: true, + }, + ], + 'no-useless-rename': 'error', + 'object-shorthand': 'error', + }, + settings: { + react: { + version: 'detect', + }, + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + paths: ['src', '.'], + }, + }, + }, + ignorePatterns: ['dist', '.eslintrc.js', 'public/mockServiceWorker.js'], +} diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 37224185..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": ["next/core-web-vitals", "next/typescript"] -} diff --git a/.github/.gitmessage.txt b/.github/.gitmessage.txt new file mode 100644 index 00000000..936cd276 --- /dev/null +++ b/.github/.gitmessage.txt @@ -0,0 +1,27 @@ +# <타입>: <제목> (#이슈 번호) 의 형식으로 제목 작성 + +# 변경 사항이 "무엇"인지 명확히 작성 / 끝에 마침표(.) 금지 +# 예) feat: 마이페이지에 개인정보 수정 버튼 추가 (#1) + +# 본문은 아래에 작성 + +# 여러 줄의 메시지를 작성할 땐 "-"로 구분 +# 본문은 "어떻게" 보다 "무엇을", "왜"를 설명 + +# --- COMMIT END --- +# <타입> 리스트 +# feat : 기능 (새로운 기능) +# fix : 버그 수정 +# refactor : 리팩토링 +# design : CSS 등 사용자 UI 디자인 변경 +# docs : 문서 수정 (문서 추가, 수정, 삭제, README) +# test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음) +# settings : 프로젝트 세팅 관련 +# chore : 패키지 매니저 수정, 그 외 기타 수정 ex) .gitignore +# init : 초기 생성 +# rename : 파일 혹은 폴더명을 수정하거나 옮기는 작업만 한 경우 +# remove : 파일을 삭제하는 작업만 수행한 경우 +# ------------------ + +# 템플릿 설정 방법 +# git config commit.template .github/.gitmessage.txt \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..9233ccf4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,36 @@ +name: Bug +description: 버그가 발생했나요? +title: '[Bug] ' +labels: ['🐛 Bug'] +projects: ['FC-DEV-FinalProject/6'] +body: + - type: textarea + id: bug-description + attributes: + label: 🐞 설명 + description: 버그에 대한 설명을 작성해 주세요. + validations: + required: true + - type: textarea + id: bug-solution + attributes: + label: 💡 해결 방법 + description: 해결 방법을 알고 있다면 작성해 주세요. + validations: + required: false + - type: textarea + id: bug-os + attributes: + label: 🌏 환경 + description: 버그가 발생한 환경에 대해 작성해 주세요. + placeholder: | + OS: macOS 14.5 + validations: + required: false + - type: textarea + id: bug-more + attributes: + label: 📝 메모 + description: 더 하고 싶은 말이 있다면 작성해 주세요. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 00000000..4f3c0f4b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,21 @@ +name: Documentation +description: 문서 추가/수정/삭제가 필요한가요? +title: '[Docs] ' +labels: ['📃 Docs'] +projects: ['FC-DEV-FinalProject/6'] +body: + - type: textarea + id: docs-description + attributes: + label: 📄 설명 + description: 추가/수정/삭제할 내용을 작성해 주세요. + placeholder: ex) README.md에 팀원 소개 추가 + validations: + required: true + - type: textarea + id: docs-memo + attributes: + label: 📝 메모 + description: 더 하고 싶은 말이 있다면 작성해 주세요. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 00000000..1cb847af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,21 @@ +name: Feature +description: 새로운 기능이나 명세가 있나요? +title: '[Feat] ' +labels: ['✨ Feature'] +projects: ['FC-DEV-FinalProject/6'] +body: + - type: textarea + id: feature-todo + attributes: + label: ✅ 해야 할 일 + description: 해야 할 일에 대한 Tasks를 작성해 주세요. + placeholder: 최대한 세분화해서 작성! (체크박스 활용하기) + validations: + required: true + - type: textarea + id: feature-memo + attributes: + label: 📝 메모 + description: 더 하고 싶은 말이 있다면 작성해 주세요. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml new file mode 100644 index 00000000..f2a6afcc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.yml @@ -0,0 +1,21 @@ +name: Refactor +description: 리팩토링이 필요한가요? +title: '[Refactor] ' +labels: ['🔨 Refactor'] +projects: ['FC-DEV-FinalProject/6'] +body: + - type: textarea + id: refactor-todo + attributes: + label: ✅ 해야 할 일 + description: 해야 할 일에 대한 Tasks를 작성해 주세요. + placeholder: 최대한 세분화해서 작성! (체크박스 활용하기) + validations: + required: true + - type: textarea + id: refactor-memo + attributes: + label: 📝 메모 + description: 더 하고 싶은 말이 있다면 작성해 주세요. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e34b9491 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,21 @@ +# 🚀 풀 리퀘스트 제안 + +## 🔍 작업 내용 + +> 작업한 내용에 대해 자세히 설명해 주세요. + +## 🔧 변경 사항 + +> 주요 변경 사항을 요약해 주세요. ex) validate 로직 수정, package.json 수정, 파일 수정/삭제 등 + +## 📸 스크린샷 (권장) + +> 수정된 화면 또는 기능을 시연할 수 있는 스크린샷을 첨부해 주세요. + +## 🙏 리뷰 참고 (선택 사항) + +> 개발 과정에서 다른 분들의 의견이 궁금했거나 크로스 체크가 필요하다고 느껴진 코드가 있다면 남겨주세요. + +## 📄 기타 (선택 사항) + +> 그 외 전달하고 싶은 내용이나 특별한 요구 사항이 있으면 작성해 주세요. diff --git a/.gitignore b/.gitignore index fd3dbb57..b77597cf 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ yarn-debug.log* yarn-error.log* # local env files +.env .env*.local # vercel @@ -34,3 +35,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +*storybook.log diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..f8c96102 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "bracketSpacing": true, + "semi": false, + "trailingComma": "es5", + "arrowParens": "always", + "endOfLine": "auto", + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrder": ["^react$", "^next(/.*)?$", "", "^@/shared/(.*)$", "^[./]"], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true +} diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 00000000..94e78745 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,71 @@ +import type { StorybookConfig } from '@storybook/nextjs' +import path from 'path' + +const config: StorybookConfig = { + stories: ['../**/*.stories.mdx', '../**/*.stories.@(js|jsx|mjs|ts|tsx)'], + staticDirs: ['../public'], + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-essentials', + '@chromatic-com/storybook', + '@storybook/addon-interactions', + '@storybook/addon-docs', + ], + framework: { + name: '@storybook/nextjs', + options: {}, + }, + webpackFinal: async (config) => { + if (config.module?.rules) { + const rules = config.module.rules as Array + const scssRule = rules.find((rule) => rule.test && rule.test.toString().includes('scss')) + const imageRule = config.module?.rules?.find((rule) => { + const test = (rule as { test: RegExp }).test + + if (!test) { + return false + } + + return test.test('.svg') + }) as { [key: string]: any } + + imageRule.exclude = /\.svg$/ + + config.module?.rules?.push({ + test: /\.svg$/, + use: ['@svgr/webpack'], + }) + + if (scssRule) { + const sassLoader = scssRule.use.find( + (loader: any) => loader && loader.loader && loader.loader.includes('sass-loader') + ) + + if (sassLoader) { + sassLoader.options = { + ...sassLoader.options, + additionalData: ` + @import "@/shared/styles/base/variables"; + @import "@/shared/styles/base/mixins"; + @import "@/shared/styles/base/functions"; + `, + sassOptions: { + includePaths: [path.resolve(__dirname, '..')], + }, + } + } + } + } + + if (config.resolve) { + config.resolve.alias = { + ...config.resolve.alias, + '@': path.resolve(__dirname, '..'), + } + } + + return config + }, +} + +export default config diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 00000000..11e11cb8 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,13 @@ + diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 00000000..23e07eb9 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,16 @@ +import type { Preview } from '@storybook/react' + +import '@/shared/styles/global.scss' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +} + +export default preview diff --git a/README.md b/README.md index e215bc4c..488fbc69 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,12 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). - -## Getting Started - -First, run the development server: - -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev -``` - -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +# InvestMetic + +

+ + + + Storybook + + + + +

diff --git a/app/(dashboard)/_ui/analysis-container/account-content.tsx b/app/(dashboard)/_ui/analysis-container/account-content.tsx new file mode 100644 index 00000000..5b157a51 --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/account-content.tsx @@ -0,0 +1,183 @@ +'use client' + +import { useState } from 'react' + +import Image from 'next/image' + +import classNames from 'classnames/bind' + +import { ACCOUNT_PAGE_COUNT } from '@/shared/constants/count-per-page' +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import Checkbox from '@/shared/ui/check-box' +import AccountImageModal from '@/shared/ui/modal/account-image-modal' +import AccountRegisterModal from '@/shared/ui/modal/account-register-modal' +import Pagination from '@/shared/ui/pagination' +import sliceArray from '@/shared/utils/slice-array' + +import { useDeleteAccountImages } from '../../my/_hooks/query/use-delete-account-images' +import useGetMyAccountImages from '../../my/_hooks/query/use-get-my-account-image' +import useGetAccountImages from '../../strategies/[strategyId]/_hooks/query/use-get-account-images' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +export interface ImageDataModel { + id: number + imageUrl: string + title: string +} + +interface Props { + strategyId: number + currentPage: number + onPageChange: (page: number) => void + isEditable?: boolean +} + +const AccountContent = ({ strategyId, currentPage, onPageChange, isEditable = false }: Props) => { + const [selectedImage, setSelectedImage] = useState(null) + const [selectedImages, setSelectedImages] = useState([]) + + const { + isModalOpen: isViewModalOpen, + openModal: openViewModal, + closeModal: closeViewModal, + } = useModal() + + const { + isModalOpen: isUploadModalOpen, + openModal: openUploadModal, + closeModal: closeUploadModal, + } = useModal() + + const viewImagesQuery = useGetAccountImages(strategyId) + const editImagesQuery = useGetMyAccountImages(strategyId) + const deleteImagesMutation = useDeleteAccountImages() + + const { data, isLoading } = isEditable ? editImagesQuery : viewImagesQuery + + const handleOpenViewModal = (image: ImageDataModel) => { + setSelectedImage(image) + openViewModal() + } + + const handleImageSelect = (id: number, checked: boolean) => { + setSelectedImages((prev) => { + if (!checked) { + return prev.filter((imageId) => imageId !== id) + } + return [...prev, id] + }) + } + + const handleDeleteSelected = async () => { + if (selectedImages.length === 0) return + + try { + await deleteImagesMutation.mutateAsync({ + strategyId, + imageIds: selectedImages, + }) + setSelectedImages([]) + } catch (error) { + console.error('Failed to delete images:', error) + } + } + + if (!data || !Array.isArray(data.content) || isLoading) return null + + const imagesData = data.content + const croppedImagesData: ImageDataModel[] = sliceArray( + imagesData ?? [], + ACCOUNT_PAGE_COUNT, + currentPage + ) + + const isTwoLines = (croppedImagesData?.length || 0) > 4 + return ( +
+ {isEditable && ( +
+ + +
+ )} + {croppedImagesData && croppedImagesData.length !== 0 ? ( + <> +
+ {croppedImagesData?.map((imageData: ImageDataModel) => ( +
+
handleOpenViewModal(imageData)} + > + {imageData.title} +
+
+ {isEditable && ( + handleImageSelect(imageData.id, checked)} + label={imageData.title} + textSize="c1" + textColor="gray600" + /> + )} + {!isEditable && {imageData.title}} +
+
+ ))} +
+ {imagesData && ( + + )} + + ) : ( +
+

업데이트 된 실거래계좌 이미지가 없습니다.

+
+ )} + + {selectedImage && ( + + )} + + +
+ ) +} + +export default AccountContent diff --git a/app/(dashboard)/_ui/analysis-container/analysis-chart.stories.tsx b/app/(dashboard)/_ui/analysis-container/analysis-chart.stories.tsx new file mode 100644 index 00000000..155b2404 --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/analysis-chart.stories.tsx @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import AnalysisChart from './analysis-chart' + +const meta = { + title: 'Components/AnalysisChart', + component: AnalysisChart, + tags: ['autodocs'], +} satisfies Meta + +type StoryType = StoryObj + +export const Default: StoryType = { + decorators: [ + (Story) => { + return ( +
+ +
+ ) + }, + ], + args: { + analysisChartData: { + dates: ['2023-01-01', '2023-01-02', '2023-01-03', '2023-01-04', '2023-01-05', '2023-01-06'], + data: { + CURRENT_DRAWDOWN: [2000, 5660, 4000, 9000, 7000, 10000], + PRINCIPAL: [50000, 60000, 80000, 80000, 80000, 80000], + }, + }, + }, +} + +export const SameOption: StoryType = { + decorators: [ + (Story) => { + return ( +
+ +
+ ) + }, + ], + args: { + analysisChartData: { + dates: [...Default.args.analysisChartData.dates], + data: { + CURRENT_DRAWDOWN: [2000, 5660, 4000, 9000, 7000, 10000], + }, + }, + }, +} + +export default meta diff --git a/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx new file mode 100644 index 00000000..8eff7139 --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/analysis-chart.tsx @@ -0,0 +1,183 @@ +'use client' + +import dynamic from 'next/dynamic' + +import classNames from 'classnames/bind' +import Highcharts, { SeriesOptionsType } from 'highcharts' + +import styles from './styles.module.scss' +import { YAXIS_OPTIONS } from './yaxis-options' + +const HighchartsReact = dynamic(() => import('highcharts-react-official'), { + ssr: false, +}) + +const cx = classNames.bind(styles) + +type YAxisType = keyof typeof YAXIS_OPTIONS + +interface AnalysisChartDataModel { + dates: string[] + data: { + [key in YAxisType]?: number[] + } +} + +interface Props { + analysisChartData: AnalysisChartDataModel +} + +const AnalysisChart = ({ analysisChartData: data }: Props) => { + const getOptionName = (sequence: number) => { + const key = Object.keys(data.data)[sequence] as YAxisType | undefined + return key ? YAXIS_OPTIONS[key] : '' + } + if (!data) return
+ const chartOptions: Highcharts.Options = { + chart: { + type: 'areaspline', + height: 367, + backgroundColor: 'transparent', + margin: [10, 60, 10, 60], + zoomType: 'x', + } as Highcharts.ChartOptions, + title: { text: undefined }, + xAxis: { + visible: false, + categories: data.dates, + min: data.dates.length > 30 ? data.dates.length - 30 : 0, + max: data.dates.length - 1, + }, + yAxis: [ + { + title: { + text: getOptionName(0), + style: { + color: '#797979', + fontSize: '10px', + }, + }, + labels: { + style: { + color: '#797979', + fontSize: '10px', + }, + }, + }, + { + title: { + text: getOptionName(1), + style: { + color: '#797979', + fontSize: '10px', + }, + }, + opposite: true, + labels: { + style: { + color: '#797979', + fontSize: '10px', + }, + }, + }, + ], + legend: { + enabled: true, + align: 'left', + verticalAlign: 'top', + layout: 'vertical', + x: 40, + y: -10, + itemStyle: { + color: '#4D4D4D', + fontSize: '12px', + }, + backgroundColor: '#FFFFFF', + borderColor: '#A7A7A7', + borderRadius: 4, + borderWidth: 1, + padding: 5, + }, + tooltip: { + useHTML: true, + headerFormat: '
{point.key}
', + pointFormat: '{point.y:.2f}', + footerFormat: '', + borderColor: '#ECECEC', + borderWidth: 1, + shadow: false, + backgroundColor: '#FFFFFF', + style: { + padding: '10px', + }, + }, + plotOptions: { + areaspline: { + fillOpacity: 0.5, + lineWidth: 1, + marker: { + enabled: false, + }, + fillColor: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, '#ffbfad'], + [1, '#FFFFFF'], + ], + }, + }, + spline: { + lineWidth: 1, + marker: { + enabled: false, + }, + }, + }, + series: [ + { + type: 'areaspline', + name: getOptionName(0), + data: Object.values(data.data)[0], + color: '#ff5f33', + yAxis: 0, + stickyTracking: false, + pointPlacement: 'on', + }, + ...(Object.values(data.data)[1] + ? [ + { + type: 'spline', + name: getOptionName(1), + data: Object.values(data.data)[1], + color: '#6877FF', + yAxis: 1, + stickyTracking: false, + pointPlacement: 'on', + }, + ] + : []), + ] as SeriesOptionsType[], + responsive: { + rules: [ + { + condition: { + maxWidth: 960, + }, + chartOptions: { + chart: { + width: null, + }, + }, + }, + ], + }, + credits: { enabled: false }, + } + return ( +
+ +
+ ) +} + +export default AnalysisChart diff --git a/app/(dashboard)/_ui/analysis-container/analysis-content.tsx b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx new file mode 100644 index 00000000..43b53475 --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/analysis-content.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react' + +import classNames from 'classnames/bind' + +import { ANALYSIS_PAGE_COUNT } from '@/shared/constants/count-per-page' +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import AnalysisUploadModal from '@/shared/ui/modal/analysis-upload-modal' +import Pagination from '@/shared/ui/pagination' +import VerticalTable from '@/shared/ui/table/vertical' + +import { useAnalysisUploadMutation } from '../../my/_hooks/query/use-analysis-mutation' +import useGetMyDailyAnalysis from '../../my/_hooks/query/use-get-my-daily-analysis' +import useGetAnalysis from '../../strategies/[strategyId]/_hooks/query/use-get-analysis' +import useGetAnalysisDownload from '../../strategies/[strategyId]/_hooks/query/use-get-analysis-download' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const DAILY_TABLE_HEADER = [ + '날짜', + '원금', + '입출금', + '일 손익', + '일 손익률', + '누적 손익', + '누적 수익률', +] + +const MONTHLY_TABLE_HEADER = [ + '날짜', + '원금', + '입출금', + '월 손익', + '월 손익률', + '누적 손익', + '누적 수익률', +] + +interface Props { + type: 'daily' | 'monthly' + strategyId: number + currentPage: number + onPageChange: (page: number) => void + isEditable?: boolean +} + +const AnalysisContent = ({ + type, + strategyId, + currentPage, + onPageChange, + isEditable = false, +}: Props) => { + const { mutate } = useGetAnalysisDownload() + + const [uploadType, setUploadType] = useState<'excel' | 'direct' | null>(null) + const { isModalOpen, openModal, closeModal } = useModal() + + //TODO 현재 나의 전략 일간분석 조회 권한이 없어서 안보임 + const { data: myAnalysisData } = useGetMyDailyAnalysis( + strategyId, + currentPage, + ANALYSIS_PAGE_COUNT + ) + const { data: publicAnalysisData } = useGetAnalysis( + strategyId, + type, + currentPage, + ANALYSIS_PAGE_COUNT + ) + + const analysisData = isEditable ? myAnalysisData : publicAnalysisData + + const { deleteAllAnalysis, isLoading } = useAnalysisUploadMutation( + strategyId, + currentPage, + ANALYSIS_PAGE_COUNT + ) + + const handleDownload = () => { + mutate({ strategyId, type }) + } + + const tableHeader = type === 'daily' ? DAILY_TABLE_HEADER : MONTHLY_TABLE_HEADER + + const handleExcelUpload = () => { + setUploadType('excel') + openModal() + } + + const handleDirectInput = () => { + setUploadType('direct') + openModal() + } + + const handleCloseModal = () => { + closeModal() + setUploadType(null) + } + + const handleDeleteAll = async () => { + if (window.confirm('모든 데이터를 삭제하시겠습니까?')) { + try { + await deleteAllAnalysis() + } catch (error) { + console.error('Delete error:', error) + alert('데이터 삭제 중 오류가 발생했습니다.') + } + } + } + + return ( +
+ {!isEditable && analysisData && ( + + )} + {isEditable && ( +
+
+ + +
+ +
+ )} + {analysisData ? ( + <> + + + + ) : ( +
+

업데이트 된 분석 데이터가 없습니다.

+
+ )} + + {uploadType && ( + + )} +
+ ) +} + +export default AnalysisContent diff --git a/app/(dashboard)/_ui/analysis-container/example.ts b/app/(dashboard)/_ui/analysis-container/example.ts new file mode 100644 index 00000000..c1b1289d --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/example.ts @@ -0,0 +1,123 @@ +export const analysisChartData = { + dates: ['2023-01-01', '2023-01-02', '2023-01-03', '2023-01-04', '2023-01-05', '2023-01-06'], + data: { + CURRENT_DRAWDOWN: [2000, 5660, 4000, 9000, 7000, 10000], + PRINCIPAL: [50000, 60000, 80000, 70000, 80000, 90000], + }, +} + +export const statisticsData = { + assetManagement: { + balance: 896217437, // 잔고 + cumulativeTransactionAmount: 896217437, // 누적 거래 금액 + principal: 238704360, // 원금 + operationPeriod: '2년 4월', // 운용 기간 + startDate: '2012-10-11', // 시작 일자 + endDate: '2015-03-11', // 종료 일자 (endDate) + daysSincePeakUpdate: 513, // 고점 갱신 후 경과일 + }, + profitLoss: { + cumulativeProfitAmount: 247525031, // 누적 수익 금액 + cumulativeProfitRate: 49.24, // 누적 수익률 + maxCumulativeProfitAmount: 247525031, // 최대 누적 수익 금액 + maxCumulativeProfitRate: 49.24, // 최대 누적 수익률 + averageProfitLossAmount: 336311, // 평균 손익 금액 + averageProfitLossRate: 6, // 평균 손익률 + maxDailyProfitAmount: 25257250, // 최대 일 수익 금액 + maxDailyProfitRate: 3.985, // 최대 일 수익률 + maxDailyLossAmount: -17465050, // 최대 일 손실 금액 + maxDailyLossRate: -3.95, // 최대 일 손실률 + roa: 453, // 자산 수익률 (Return on Assets) + profitFactor: 1.48, // Profit Factor + }, + ddMddInfo: { + currentDrawdown: 0, // 현재 자본 인하 금액 + currentDrawdownRate: 0, // 현재 자본 인하율 + maxDrawdown: -54832778, // 최대 자본 인하 금액 + maxDrawdownRate: -13.98, // 최대 자본 인하율 + }, + tradingInfo: { + totalTradeDays: 736, // 총 거래 일수 + totalProfitableDays: 508, // 총 이익 일수 + totalLossDays: 228, // 총 손실 일수 + currentConsecutiveLossDays: 6, // 현재 연속 손실 일수 + maxConsecutiveProfitDays: 22, // 최대 연속 이익 일수 + maxConsecutiveLossDays: 8, // 최대 연속 손실 일수 + winRate: 69, // 승률 + }, +} + +export const tableBody = [ + { + date: '2015-03-12', // 날짜 + principal: 100000000, // 원금 + transaction: 0, // 입출금 + dailyProfitLoss: 332410, // 일 손익 + dailyProfitLossRate: 0.33, // 일 수익률 + cumulativeProfitLoss: 302280, // 누적 손익 + cumulativeProfitLossRate: 0.3, // 누적 수익률 + }, + { + date: '2015-03-13', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, + { + date: '2015-03-14', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, + { + date: '2015-03-15', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, + { + date: '2015-03-16', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, + { + date: '2015-03-17', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, + { + date: '2015-03-18', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, + { + date: '2015-03-19', + principal: 100000000, + transaction: 0, + dailyProfitLoss: 332410, + dailyProfitLossRate: 0.33, + cumulativeProfitLoss: 302280, + cumulativeProfitLossRate: 0.3, + }, +] diff --git a/app/(dashboard)/_ui/analysis-container/index.tsx b/app/(dashboard)/_ui/analysis-container/index.tsx new file mode 100644 index 00000000..c6422eff --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/index.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState } from 'react' + +import classNames from 'classnames/bind' + +import Select from '@/shared/ui/select' + +import useGetAnalysisChart from '../../strategies/[strategyId]/_hooks/query/use-get-analysis-chart' +import AnalysisChart from './analysis-chart' +import styles from './styles.module.scss' +import TabsWithTable from './tabs-width-table' +import { YAXIS_OPTIONS } from './yaxis-options' + +const cx = classNames.bind(styles) + +export type AnalysisChartOptionsType = keyof typeof YAXIS_OPTIONS + +interface Props { + strategyId: number + type?: 'default' | 'my' +} + +const AnalysisContainer = ({ strategyId, type = 'default' }: Props) => { + const [firstOption, setFirstOption] = useState('PRINCIPAL') + const [secondOption, setSecondOption] = + useState('CUMULATIVE_PROFIT_LOSS') + const { data: chartData } = useGetAnalysisChart({ strategyId, firstOption, secondOption }) + + const optionsToArray = Object.entries(YAXIS_OPTIONS) + const options: { value: string; label: string }[] = [] + + for (const [key, value] of optionsToArray) { + options.push({ value: key, label: value }) + } + + return ( +
+
+

분석

+ {type === 'default' && ( +
+ setSecondOption(newValue as AnalysisChartOptionsType)} + /> +
+ )} +
+ {type === 'default' && ( +
+ +
+ )} + +
+ ) +} + +export default AnalysisContainer diff --git a/app/(dashboard)/_ui/analysis-container/statistics-content.tsx b/app/(dashboard)/_ui/analysis-container/statistics-content.tsx new file mode 100644 index 00000000..b70db4fb --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/statistics-content.tsx @@ -0,0 +1,36 @@ +import classNames from 'classnames/bind' + +import StatisticsTable from '@/shared/ui/table/statistics' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface StatisticsDataModel { + assetManagement: Record + profitLoss: Record + ddMddInfo: Record + tradingInfo: Record +} + +interface Props { + statisticsData: StatisticsDataModel +} + +const StatisticsContent = ({ statisticsData }: Props) => { + return ( +
+ {statisticsData ? ( + Object.entries(statisticsData).map(([title, data]) => ( + + )) + ) : ( +
+

업데이트 된 통계 데이터가 없습니다.

+
+ )} +
+ ) +} + +export default StatisticsContent diff --git a/app/(dashboard)/_ui/analysis-container/styles.module.scss b/app/(dashboard)/_ui/analysis-container/styles.module.scss new file mode 100644 index 00000000..e139a8e9 --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/styles.module.scss @@ -0,0 +1,112 @@ +.container { + padding: 20px; + border-radius: 5px; + background-color: $color-white; + .analysis-header { + display: flex; + justify-content: space-between; + p { + @include typo-h4; + color: $color-gray-600; + &.my { + margin-bottom: 40px; + } + } + div { + display: flex; + gap: 20px; + } + } +} + +.chart { + width: 100%; + height: 367px; + border-radius: 5px; + margin: 20px 0 40px; +} + +.table-wrapper { + margin-top: 20px; + position: relative; + .edit-button-container { + display: flex; + justify-content: space-between; + margin-bottom: 30px; + .edit-button, + .delete-button { + padding: 7px 18px; + } + .delete-button:disabled { + background-color: transparent; + } + } + &.analysis { + margin-top: 40px; + } + .excel-button { + height: 30px; + position: absolute; + right: 0; + top: -30px; + } +} + +.account-images-container { + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: repeat(1, 1fr); + column-gap: 20px; + &.line { + grid-template-rows: repeat(2, 1fr); + row-gap: 10px; + } + .image-data { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + .image { + position: relative; + width: 100%; + height: 100px; + border-radius: 8px; + cursor: pointer; + overflow: hidden; + } + span { + text-align: center; + @include typo-c1; + } + } + .title-wrapper { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + margin-top: 8px; + } +} + +.no-data { + display: flex; + justify-content: center; + margin-top: 80px; + color: $color-gray-600; + height: 200px; + @include typo-b1; +} + +.button-container { + display: flex; + justify-content: space-between; + height: 30px; + margin-top: -20px; + margin-bottom: 10px; + + button.upload-button { + height: 100%; + padding: 7px 18px; + margin-right: 10px; + } +} diff --git a/app/(dashboard)/_ui/analysis-container/tabs-width-table.tsx b/app/(dashboard)/_ui/analysis-container/tabs-width-table.tsx new file mode 100644 index 00000000..bab75655 --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/tabs-width-table.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useEffect, useState } from 'react' +import React from 'react' + +import { DailyGraphIcon, MoneyIcon, MonthlyGraphIcon, StatisticsIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import Tabs from '@/shared/ui/tabs' + +import useGetStatistics from '../../strategies/[strategyId]/_hooks/query/use-get-statistics' +import AccountContent from './account-content' +import AnalysisContent from './analysis-content' +import StatisticsContent from './statistics-content' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +export type AnalysisTabType = 'statistics' | 'daily' | 'monthly' | 'account-images' +interface Props { + strategyId: number + isEditable?: boolean +} + +const TabsWithTable = ({ strategyId, isEditable = false }: Props) => { + const [activeTab, setActiveTab] = useState('statistics') + const [currentPage, setCurrentPage] = useState(1) + const { data: statisticsData } = useGetStatistics(strategyId) + + useEffect(() => { + setCurrentPage(1) + }, [activeTab]) + + const handlePageChange = (page: number) => setCurrentPage(page) + + const TABS = [ + { + id: 'statistics', + label: '통계', + icon: StatisticsIcon, + content: , + }, + { + id: 'daily', + label: '일간분석', + icon: DailyGraphIcon, + content: ( + + ), + }, + { + id: 'monthly', + label: '월간분석', + icon: MonthlyGraphIcon, + content: ( + + ), + }, + { + id: 'account-images', + label: '실거래계좌', + icon: MoneyIcon, + content: ( +
+ +
+ ), + }, + ] + + return ( + setActiveTab(id as AnalysisTabType)} + /> + ) +} + +export default TabsWithTable diff --git a/app/(dashboard)/_ui/analysis-container/yaxis-options.ts b/app/(dashboard)/_ui/analysis-container/yaxis-options.ts new file mode 100644 index 00000000..a9be1ddf --- /dev/null +++ b/app/(dashboard)/_ui/analysis-container/yaxis-options.ts @@ -0,0 +1,19 @@ +export const YAXIS_OPTIONS = { + BALANCE: '잔고', + PRINCIPAL: '원금', + CUMULATIVE_TRANSACTION_AMOUNT: '누적 입출 금액', + TRANSACTION: '일별 입출 금액', + DAILY_PROFIT_LOSS: '일 손익 금액', + DAILY_PROFIT_LOSS_RATE: '일 손익률', + CUMULATIVE_PROFIT_LOSS: '누적 수익 금액', + CUMULATIVE_PROFIT_LOSS_RATE: '누적 수익률', + CURRENT_DRAWDOWN: '현재 자본 인하 금액', + CURRENT_DRAWDOWN_RATE: '현재 자본 인하율', + AVERAGE_PROFIT_LOSS: '평균 손익 금액', + AVERAGE_PROFIT_LOSS_RATIO: '평균 손익률', + WIN_RATE: '승률', + PROFIT_FACTOR: 'Profit Factor', + ROA: 'ROA', + TOTAL_PROFIT: '총 이익', + TOTAL_LOSS: '총 손실', +} as const diff --git a/app/(dashboard)/_ui/details-information/index.tsx b/app/(dashboard)/_ui/details-information/index.tsx new file mode 100644 index 00000000..3aae89cd --- /dev/null +++ b/app/(dashboard)/_ui/details-information/index.tsx @@ -0,0 +1,61 @@ +import classNames from 'classnames/bind' + +import { StrategyDetailsInformationModel } from '@/shared/types/strategy-data' + +import StrategyIntroduction from '../introduction' +import InvestInformation from './invest-information' +import Percentage from './percentage' +import StrategyNameBox from './strategy-name-box' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number + information: StrategyDetailsInformationModel + type?: 'default' | 'my' +} + +const DetailsInformation = ({ strategyId, information, type = 'default' }: Props) => { + const percentageToArray = [ + { percent: information.cumulativeProfitRate, label: '누적 수익률' }, + { percent: information.maxDrawdownRate, label: '최대 자본 인하율' }, + { percent: information.averageProfitLossRate, label: '평균 손익률' }, + { percent: information.profitFactor, label: 'Profit Factor' }, + { percent: information.winRate, label: '승률' }, + ] + if (!information) return null + return ( + <> +
+ + +
+ + {type === 'default' && ( +
+ {percentageToArray.map((data) => ( + + ))} +
+ )} + + ) +} + +export default DetailsInformation diff --git a/app/(dashboard)/_ui/details-information/invest-information.tsx b/app/(dashboard)/_ui/details-information/invest-information.tsx new file mode 100644 index 00000000..9a9a78a8 --- /dev/null +++ b/app/(dashboard)/_ui/details-information/invest-information.tsx @@ -0,0 +1,31 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + stock: string[] + trade: string + cycle: string +} + +const InvestInformation = ({ stock, trade, cycle }: Props) => { + const investData = [ + { title: '투자 종목', data: stock.join(',') }, + { title: '매매 유형', data: trade }, + { title: '투자 주기', data: cycle }, + ] + return ( +
+ {investData.map((data, idx) => ( +
+

{data.title}

+

{data.data}

+
+ ))} +
+ ) +} + +export default InvestInformation diff --git a/app/(dashboard)/_ui/details-information/percentage.tsx b/app/(dashboard)/_ui/details-information/percentage.tsx new file mode 100644 index 00000000..85856597 --- /dev/null +++ b/app/(dashboard)/_ui/details-information/percentage.tsx @@ -0,0 +1,26 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + percent: number + label: string +} + +const Percentage = ({ percent, label }: Props) => { + const isMinus = percent < 0 + + return ( +
+

+ {percent.toFixed(2)} + {label !== 'Profit Factor' && '%'} +

+

{label}

+
+ ) +} + +export default Percentage diff --git a/app/(dashboard)/_ui/details-information/strategy-name-box.tsx b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx new file mode 100644 index 00000000..ff29ebe5 --- /dev/null +++ b/app/(dashboard)/_ui/details-information/strategy-name-box.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +import StrategiesIcon from '@/app/(dashboard)/_ui/strategies-item/strategies-icon' +import classNames from 'classnames/bind' + +import useGetProposalDownload from '../../strategies/[strategyId]/_hooks/query/use-get-proposal-download' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number + iconUrls?: string[] + iconNames?: string[] + name: string +} + +const StrategyNameBox = ({ strategyId, iconUrls, iconNames, name }: Props) => { + const { mutate } = useGetProposalDownload() + + const handleDownload = () => { + mutate({ strategyId, name }) + } + + return ( +
+ +

{name}

+ +
+ ) +} + +export default StrategyNameBox diff --git a/app/(dashboard)/_ui/details-information/styles.module.scss b/app/(dashboard)/_ui/details-information/styles.module.scss new file mode 100644 index 00000000..46224894 --- /dev/null +++ b/app/(dashboard)/_ui/details-information/styles.module.scss @@ -0,0 +1,82 @@ +.information-top { + display: flex; + margin: 20px 0; + gap: 10px; +} + +.name-container { + width: 30%; + height: 144px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; + border-radius: 5px; + background-color: $color-white; + .name { + @include typo-h4; + } + button { + height: 36px; + border-radius: 8px; + background-color: $color-gray-100; + color: $color-gray-700; + } +} + +.invest-container { + width: 70%; + height: 144px; + display: flex; + gap: 20px; + padding: 30px; + border-radius: 5px; + background-color: $color-white; + .info-item { + width: 100%; + height: 100%; + border-right: 1px solid $color-gray-200; + padding-right: 4px; + &:last-child { + border-right: 0; + } + .invest-title { + @include typo-b1; + color: $color-gray-500; + } + .invest-data { + @include typo-b3; + color: $color-gray-700; + margin-top: 16px; + } + } +} + +.percentage-container { + width: 100%; + display: flex; + gap: 10px; + margin: 20px 0; +} +.percentage-wrapper { + width: 20%; + height: 108px; + border-radius: 5px; + background-color: $color-white; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .label { + @include typo-b2; + color: $color-gray-800; + } + .percent { + @include typo-h3; + color: #f53500; + &.minus { + color: #6877ff; + } + } +} diff --git a/app/(dashboard)/_ui/details-side-item/details-side-item.stories.tsx b/app/(dashboard)/_ui/details-side-item/details-side-item.stories.tsx new file mode 100644 index 00000000..a29fe657 --- /dev/null +++ b/app/(dashboard)/_ui/details-side-item/details-side-item.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryFn } from '@storybook/react' + +import DetailsSideItem, { InformationModel } from './index' + +const meta: Meta = { + title: 'components/DetailsSideItem', + component: DetailsSideItem, + tags: ['autodocs'], +} + +const sideItems: StoryFn<{ + information: InformationModel | InformationModel[] + strategyId: number +}> = (args) => ( +
+ +
+) + +export const Default = sideItems.bind({}) +Default.args = { + information: { title: '투자 원금', data: '10,000,000' }, + strategyId: 1, +} + +export const Trader = sideItems.bind({}) +Trader.args = { + information: { title: '트레이더', data: '수밍' }, + strategyId: 1, +} + +export const Multiple = sideItems.bind({}) +Multiple.args = { + information: [ + { title: 'KP Ratio', data: 0.3993 }, + { title: 'SM SCORE', data: 67.38 }, + ], + strategyId: 1, +} + +export default meta diff --git a/app/(dashboard)/_ui/details-side-item/index.tsx b/app/(dashboard)/_ui/details-side-item/index.tsx new file mode 100644 index 00000000..c550f81a --- /dev/null +++ b/app/(dashboard)/_ui/details-side-item/index.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames/bind' + +import SideItem from './side-item' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +export type TitleType = + | '트레이더' + | '최소 투자 금액' + | '투자 원금' + | 'KP Ratio' + | 'SM SCORE' + | '최종손익입력일자' + | '등록일' + +export interface InformationModel { + title: TitleType + data: string | number +} + +interface Props { + strategyId: number + information: InformationModel | InformationModel[] + profileImage?: string + isMyStrategy?: boolean + strategyName?: string +} + +const DetailsSideItem = ({ + strategyId, + information, + profileImage, + isMyStrategy = true, + strategyName, +}: Props) => { + const isArray = Array.isArray(information) + return ( + <> + {isArray ? ( +
+ {information.map((item) => ( +
+
{item.title}
+
+

{item.data}

+
+
+ ))} +
+ ) : ( + + )} + + ) +} + +export default DetailsSideItem diff --git a/app/(dashboard)/_ui/details-side-item/side-item.tsx b/app/(dashboard)/_ui/details-side-item/side-item.tsx new file mode 100644 index 00000000..265bac8e --- /dev/null +++ b/app/(dashboard)/_ui/details-side-item/side-item.tsx @@ -0,0 +1,97 @@ +'use client' + +import { usePathname, useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import useModal from '@/shared/hooks/custom/use-modal' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import Avatar from '@/shared/ui/avatar' +import { Button } from '@/shared/ui/button' +import AddQuestionModal from '@/shared/ui/modal/add-question-modal' +import QuestionGuideModal from '@/shared/ui/modal/question-guide-modal' +import { formatNumber } from '@/shared/utils/format' + +import { TitleType } from '.' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number + title: Omit + data: number | string + profileImage?: string + isMyStrategy?: boolean + strategyName?: string +} + +const SideItem = ({ + strategyId, + title, + data, + profileImage, + isMyStrategy = false, + strategyName, +}: Props) => { + const { + isModalOpen: isAddQuestionModalOpen, + openModal: questionOpenModal, + closeModal: questionCloseModal, + } = useModal() + const { + isModalOpen: isQuestionGuideModalOpen, + openModal: guideOpenModal, + closeModal: guideCloseModal, + } = useModal() + const user = useAuthStore((state) => state.user) + const router = useRouter() + const path = usePathname() + + const handleRouter = () => { + router.push(`${PATH.MY_STRATEGIES}/manage/${strategyId}`) + } + + const isTrader = user?.role.includes('TRADER') + + return ( +
+
{title}
+
+ {title === '트레이더' ? ( + <> +
+ +

{data}

+
+ {!isMyStrategy && !isTrader && ( + + )} + {isMyStrategy && !path.includes('my') && ( + + )} + + ) : ( +

{formatNumber(data)}

+ )} +
+ {strategyName && ( + + )} + +
+ ) +} + +export default SideItem diff --git a/app/(dashboard)/_ui/details-side-item/side-skeleton/index.tsx b/app/(dashboard)/_ui/details-side-item/side-skeleton/index.tsx new file mode 100644 index 00000000..5c67fff6 --- /dev/null +++ b/app/(dashboard)/_ui/details-side-item/side-skeleton/index.tsx @@ -0,0 +1,16 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) +const SideSkeleton = () => { + return ( +
+ {Array.from({ length: 6 }, (_, idx) => ( +
+ ))} +
+ ) +} + +export default SideSkeleton diff --git a/app/(dashboard)/_ui/details-side-item/side-skeleton/styles.module.scss b/app/(dashboard)/_ui/details-side-item/side-skeleton/styles.module.scss new file mode 100644 index 00000000..ce911561 --- /dev/null +++ b/app/(dashboard)/_ui/details-side-item/side-skeleton/styles.module.scss @@ -0,0 +1,26 @@ +.container { + width: 100%; + div { + @include skeleton; + width: 100%; + margin-bottom: 20px; + } + :first-child { + height: 83px; + } + :nth-child(2) { + height: 122px; + } + :nth-child(3) { + height: 108px; + } + :nth-child(4) { + height: 108px; + } + :nth-child(5) { + height: 200px; + } + :nth-child(6) { + height: 200px; + } +} diff --git a/app/(dashboard)/_ui/details-side-item/styles.module.scss b/app/(dashboard)/_ui/details-side-item/styles.module.scss new file mode 100644 index 00000000..b4393c12 --- /dev/null +++ b/app/(dashboard)/_ui/details-side-item/styles.module.scss @@ -0,0 +1,46 @@ +.side-item { + height: 120px; + padding: 20px; +} + +.side-items { + height: 210px; + padding: 20px 20px 0; + .data { + p { + margin-bottom: 20px; + } + } +} + +.side-item, +.side-items { + width: 276px; + background-color: $color-white; + border-radius: 5px; + margin-bottom: 20px; + .title { + font-weight: $text-bold; + font-size: $text-b2; + color: $color-gray-500; + border-bottom: 0.75px solid $color-gray-500; + padding-bottom: 12px; + } + .data { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 12px; + p { + @include typo-b1; + } + } +} + +.avatar { + display: flex; + align-items: center; + p { + margin: 0 4px 0 11px; + } +} diff --git a/app/(dashboard)/_ui/introduction/index.tsx b/app/(dashboard)/_ui/introduction/index.tsx new file mode 100644 index 00000000..b9ee51cd --- /dev/null +++ b/app/(dashboard)/_ui/introduction/index.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' + +import { CloseIcon, OpenIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + content: string +} + +const StrategyIntroduction = ({ content }: Props) => { + const [shouldShowMore, setShouldShowMore] = useState(false) + const [isOverflow, setIsOverflow] = useState(false) + const contentRef = useRef(null) + + useEffect(() => { + checkOverflow() + }, [content]) + + const checkOverflow = () => { + if (contentRef.current) { + setIsOverflow(contentRef.current.scrollHeight > contentRef.current.offsetHeight) + } + } + + return ( +
+

전략 상세 소개

+
+

{content}

+
+ {isOverflow && ( +
+ +
+ )} +
+ ) +} + +export default StrategyIntroduction diff --git a/app/(dashboard)/_ui/introduction/introduction.stories.tsx b/app/(dashboard)/_ui/introduction/introduction.stories.tsx new file mode 100644 index 00000000..83b9bbf4 --- /dev/null +++ b/app/(dashboard)/_ui/introduction/introduction.stories.tsx @@ -0,0 +1,28 @@ +import { Meta, StoryFn } from '@storybook/react' + +import StrategyIntroduction from '.' + +const meta: Meta = { + title: 'components/StrategyIntroduction', + component: StrategyIntroduction, + tags: ['autodocs'], +} + +export default meta + +const introduction: StoryFn<{ content: string }> = ({ content }) => ( +
+ +
+) + +export const Default = introduction.bind({}) +Default.args = { + content: '안녕하세요. 전랙에 대한 설명입니다.', +} + +export const MaxContent = introduction.bind({}) +MaxContent.args = { + content: + '전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 안녕하세요. 안녕하세요. 안녕하세요..전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 전략에 대한 상세한 설명을 입력해주세요. 안녕하세요. 안녕하세요.', +} diff --git a/app/(dashboard)/_ui/introduction/styles.module.scss b/app/(dashboard)/_ui/introduction/styles.module.scss new file mode 100644 index 00000000..773a3369 --- /dev/null +++ b/app/(dashboard)/_ui/introduction/styles.module.scss @@ -0,0 +1,41 @@ +.container { + width: 100%; + background-color: $color-white; + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; + .title { + @include typo-b2; + margin-bottom: 15px; + } + + .content { + width: 100%; + @include ellipsis(4); + &.expand { + display: contents; + } + p { + @include typo-c1; + line-height: 18px; + color: $color-gray-600; + } + } + + .button-wrapper { + width: 100%; + display: flex; + justify-content: flex-end; + margin-top: 10px; + button { + @include typo-c1; + display: flex; + align-items: center; + color: $color-gray-500; + background-color: transparent; + svg { + margin: -3px 0 0 7px; + } + } + } +} diff --git a/app/(dashboard)/_ui/list-header/index.tsx b/app/(dashboard)/_ui/list-header/index.tsx new file mode 100644 index 00000000..b8ace106 --- /dev/null +++ b/app/(dashboard)/_ui/list-header/index.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const LIST_HEADER = { + default: ['전략', '분석', 'MDD', 'SM SCORE', '수익률', '구독'], + my: ['전략', '분석', 'MDD', 'SM SCORE', '수익률', '공개', '관리'], +} + +interface Props { + type?: 'default' | 'my' +} + +const ListHeader = ({ type = 'default' }: Props) => { + return ( +
+ {LIST_HEADER[type].map((category) => ( +
+ {category} +
+ ))} +
+ ) +} + +export default ListHeader diff --git a/app/(dashboard)/_ui/list-header/styles.module.scss b/app/(dashboard)/_ui/list-header/styles.module.scss new file mode 100644 index 00000000..5efb4b05 --- /dev/null +++ b/app/(dashboard)/_ui/list-header/styles.module.scss @@ -0,0 +1,25 @@ +.container { + display: grid; + grid-template-columns: 1.5fr 1.2fr 0.8fr repeat(2, 0.6fr) 0.4fr; + width: 100%; + height: 42px; + margin: 20px 0 10px; + grid-gap: 4px; + &.my { + grid-template-columns: 2.5fr 2.4fr 1.5fr 1.1fr 1.3fr 1fr 1fr; + } + + .category { + display: flex; + align-items: center; + justify-content: center; + background-color: $color-white; + border: 1px solid $color-gray-200; + border-radius: 4px; + @include typo-b2; + + &:not(:first-child) { + margin-left: 5px; + } + } +} diff --git a/app/(dashboard)/_ui/navigation.tsx b/app/(dashboard)/_ui/navigation.tsx new file mode 100644 index 00000000..951168a3 --- /dev/null +++ b/app/(dashboard)/_ui/navigation.tsx @@ -0,0 +1,43 @@ +'use client' + +import { + BookmarkIcon, + QuestionIcon, + StrategyIcon, + StrategyRankingIcon, + TradersIcon, +} from '@/public/icons' + +import { PATH } from '@/shared/constants/path' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import { isTrader } from '@/shared/types/auth' +import SideNavigation from '@/shared/ui/side-navigation' +import NavLinkItem from '@/shared/ui/side-navigation/nav-link-item' + +const DashboardNavigation = () => { + const { user } = useAuthStore() + + return ( + + + 전략 랭킹 + + + 트레이더 목록 + + {isTrader(user) && ( + + 나의 전략 + + )} + + 구독한 전략 + + + 문의 내역 + + + ) +} + +export default DashboardNavigation diff --git a/app/(dashboard)/_ui/strategies-item/area-chart.tsx b/app/(dashboard)/_ui/strategies-item/area-chart.tsx new file mode 100644 index 00000000..e03e012e --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/area-chart.tsx @@ -0,0 +1,98 @@ +'use client' + +import dynamic from 'next/dynamic' + +import classNames from 'classnames/bind' +import Highcharts from 'highcharts' + +import { ProfitRateChartDataModel } from '@/shared/types/strategy-data' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const HighchartsReact = dynamic(() => import('highcharts-react-official'), { + ssr: false, +}) + +interface Props { + profitRateChartData: ProfitRateChartDataModel +} + +const AreaChart = ({ profitRateChartData: data }: Props) => { + if (!data) return
+ const chartOptions: Highcharts.Options = { + chart: { + type: 'areaspline', + height: 100, + backgroundColor: 'transparent', + margin: [0, 0, 0, 0], + }, + title: { text: undefined }, + xAxis: { + visible: false, + }, + yAxis: { + visible: false, + min: Math.min(...data.profitRates), + max: Math.max(...data.profitRates), + }, + legend: { enabled: false }, + plotOptions: { + areaspline: { + lineWidth: 1, + lineColor: '#ff8f70', + marker: { + enabled: false, + symbol: 'circle', + radius: 2, + states: { + hover: { + enabled: false, + }, + }, + }, + }, + }, + series: [ + { + type: 'areaspline', + data: data.dates.map((x, idx) => ({ + x: new Date(x).getTime(), + y: data.profitRates[idx], + })), + color: { + linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 }, + stops: [ + [0, '#ff8f70'], + [1, 'rgba(255, 255, 255, 0)'], + ], + }, + }, + ], + credits: { enabled: false }, + tooltip: { enabled: false }, + responsive: { + rules: [ + { + condition: { + maxWidth: 200, + }, + chartOptions: { + chart: { + width: null, + }, + }, + }, + ], + }, + } + + return ( +
+ +
+ ) +} + +export default AreaChart diff --git a/app/(dashboard)/_ui/strategies-item/index.tsx b/app/(dashboard)/_ui/strategies-item/index.tsx new file mode 100644 index 00000000..63a99ae4 --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/index.tsx @@ -0,0 +1,104 @@ +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import useModal from '@/shared/hooks/custom/use-modal' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import { StrategiesModel } from '@/shared/types/strategy-data' +import { Button } from '@/shared/ui/button' +import { LinkButton } from '@/shared/ui/link-button' +import SigninCheckModal from '@/shared/ui/modal/signin-check-modal' +import { formatNumber } from '@/shared/utils/format' + +import AreaChart from './area-chart' +import StrategiesSummary from './strategies-summary' +import styles from './styles.module.scss' +import Subscribe from './subscribe' + +const cx = classNames.bind(styles) + +interface Props { + strategiesData: StrategiesModel + type?: 'default' | 'my' +} + +const StrategiesItem = ({ strategiesData: data, type = 'default' }: Props) => { + const router = useRouter() + const user = useAuthStore((state) => state.user) + const { isModalOpen, openModal, closeModal } = useModal() + + const handleRouter = () => { + if (!user) { + openModal() + } else { + router.push(`${PATH.STRATEGIES}/${data.strategyId}`) + } + } + + return ( + <> + + + + )} + + + + ) +} + +export default StrategiesItem diff --git a/app/(dashboard)/_ui/strategies-item/skeleton/index.tsx b/app/(dashboard)/_ui/strategies-item/skeleton/index.tsx new file mode 100644 index 00000000..22d8cf89 --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/skeleton/index.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const StrategiesItemSkeleton = () => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +export default StrategiesItemSkeleton diff --git a/app/(dashboard)/_ui/strategies-item/skeleton/styles.module.scss b/app/(dashboard)/_ui/strategies-item/skeleton/styles.module.scss new file mode 100644 index 00000000..2b661ffb --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/skeleton/styles.module.scss @@ -0,0 +1,48 @@ +.container { + @include skeleton; + margin-bottom: 24px; + display: grid; + grid-template-columns: 1.5fr 1.2fr 0.8fr repeat(2, 0.6fr) 0.4fr; + height: 158px; + width: 100%; + align-items: center; + grid-gap: 4px; + div { + width: 100%; + } + .first { + background-color: $color-gray-200; + justify-content: flex-start; + padding-left: 20px; + & * { + height: 18px; + margin-bottom: 10px; + } + :first-child { + width: 50px; + } + :nth-child(2) { + width: 250px; + } + :nth-child(3), + :nth-child(4) { + width: 120px; + } + } + .second { + background-color: $color-gray-200; + place-items: center center; + :first-child { + width: 140px; + height: 115px; + } + } + .last { + background-color: $color-gray-200; + place-items: center center; + :first-child { + width: 100px; + height: 18px; + } + } +} diff --git a/app/(dashboard)/_ui/strategies-item/strategies-icon.tsx b/app/(dashboard)/_ui/strategies-item/strategies-icon.tsx new file mode 100644 index 00000000..8447e5d2 --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/strategies-icon.tsx @@ -0,0 +1,98 @@ +'use client' + +import { useEffect, useState } from 'react' + +import Image from 'next/image' + +import classNames from 'classnames/bind' +import { Tooltip } from 'react-tooltip' + +import styles from './styles.module.scss' + +/* eslint-disable react-hooks/exhaustive-deps */ + +const cx = classNames.bind(styles) + +interface Props { + iconUrls?: string[] + iconNames?: string[] + isDetailsPage?: boolean +} + +const StrategiesIcon = ({ iconUrls, iconNames, isDetailsPage = false }: Props) => { + const [imageSizes, setImageSizes] = useState<{ [key: string]: number }>({}) + const [validImages, setValidImages] = useState<{ [key: string]: boolean }>({}) + + useEffect(() => { + const images: HTMLImageElement[] = [] + iconUrls?.forEach((url) => { + const image = new window.Image() + image.src = url + image.onload = () => updateImageSize(url, image.width) + image.onerror = () => updateImageSize(url, 22) + images.push(image) + }) + + return () => { + images.forEach((image) => { + image.onload = null + image.onerror = null + }) + } + }, [iconUrls]) + + const updateImageSize = (url: string, width: number) => { + setImageSizes((prev) => ({ + ...prev, + [url]: width, + })) + } + + const getImageSize = (url: string) => imageSizes[url] || 22 + + const handleImageErr = (url: string) => { + setValidImages((prev) => ({ ...prev, [url]: false })) + } + + const handleImageLoad = (url: string) => { + setValidImages((prev) => ({ ...prev, [url]: true })) + } + + if (iconUrls?.length === 0 || iconNames?.length === 0) return null + if (iconUrls?.length !== iconNames?.length) return null + + return ( +
+ {iconUrls?.map((url, idx) => { + const name = iconNames?.[idx] + if (!url || !name || validImages[url] === false) return null + const width = getImageSize(url) + + return ( +
+
+ {name} handleImageLoad(url)} + onError={() => handleImageErr(url)} + /> +
+ + {name} + +
+ ) + })} +
+ ) +} + +export default StrategiesIcon diff --git a/app/(dashboard)/_ui/strategies-item/strategies-item.stories.tsx b/app/(dashboard)/_ui/strategies-item/strategies-item.stories.tsx new file mode 100644 index 00000000..7211a056 --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/strategies-item.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryFn } from '@storybook/react' + +import { StrategiesModel } from '@/shared/types/strategy-data' + +import StrategiesItem from './index' + +const meta: Meta = { + title: 'components/StrategiesItem', + component: StrategiesItem, + tags: ['autodocs'], +} + +const strategy: StoryFn<{ strategiesData: StrategiesModel[] }> = ({ strategiesData }) => ( + <> + {strategiesData.map((strategy) => ( + + ))} + +) + +export const Primary = strategy.bind({}) +Primary.args = { + strategiesData: [ + { + strategyId: 1, + strategyName: 'Dynamic ETF 전략', + traderImgUrl: '/images/trader1.png', + nickname: 'AlphaTrader', + stockTypeInfo: { + stockTypeIconUrls: ['/images/stock.png'], + stockTypeNames: ['해외지수선물'], + }, + profitRateChartData: { + dates: [ + '2023-01-01', + '2023-01-02', + '2023-01-03', + '2023-01-04', + '2023-01-05', + '2023-01-06', + '2023-01-07', + '2023-01-08', + '2023-01-09', + ], + profitRates: [7.2, 5.2, 25, 12.8, 17.2, 11.4, 20, 16, 18], + }, + tradeTypeIconUrl: '/images/trade.png', + tradeTypeName: '자동', + mdd: -15432567, + smScore: 72.1, + cumulativeProfitRate: 140.5, + recentYearProfitLossRate: 35.2, + subscriptionCount: 45, + averageRating: 4.9, + totalReviews: 34, + isSubscribed: true, + }, + { + strategyId: 2, + strategyName: '고수익 ETF', + traderImgUrl: '/images/trader2.png', + nickname: 'BetaTrader', + stockTypeInfo: { + stockTypeIconUrls: ['/images/stock.png'], + stockTypeNames: ['해외지수선물'], + }, + profitRateChartData: { + dates: [ + '2023-01-01', + '2023-01-02', + '2023-01-03', + '2023-01-04', + '2023-01-05', + '2023-01-06', + '2023-01-07', + '2023-01-08', + '2023-01-09', + ], + profitRates: [7.2, 5.2, 25, 12.8, 17.2, 11.4, 20, 16, 18], + }, + tradeTypeIconUrl: '/images/trade.png', + tradeTypeName: '자동', + mdd: -12786543, + smScore: 65.4, + cumulativeProfitRate: 125.3, + recentYearProfitLossRate: 28.4, + subscriptionCount: 67, + averageRating: 4.6, + totalReviews: 19, + isSubscribed: false, + }, + ], +} + +export default meta diff --git a/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx b/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx new file mode 100644 index 00000000..78455cbb --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames/bind' + +import Avatar from '@/shared/ui/avatar' +import TotalStar from '@/shared/ui/total-star' + +import StrategiesIcon from './strategies-icon' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface ProfileModel { + traderImage?: string + nickname: string +} + +interface Props { + iconUrls?: string[] + iconNames?: string[] + title: string + profile: ProfileModel + subscriptionCount: number + averageRating: number + totalReview: number +} + +const StrategiesSummary = ({ + iconUrls, + iconNames, + title, + profile, + subscriptionCount, + averageRating, + totalReview, +}: Props) => { + return ( +
+ +

{title}

+
+ +

{profile.nickname}

+
+
+

구독 {subscriptionCount}개

+

|

+ +
+
+ ) +} + +export default StrategiesSummary diff --git a/app/(dashboard)/_ui/strategies-item/styles.module.scss b/app/(dashboard)/_ui/strategies-item/styles.module.scss new file mode 100644 index 00000000..6f871b2d --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/styles.module.scss @@ -0,0 +1,118 @@ +.container { + margin-bottom: 24px; + display: grid; + grid-template-columns: 1.5fr 1.2fr 0.8fr repeat(2, 0.6fr) 0.4fr; + height: 158px; + width: 100%; + align-items: center; + background-color: $color-white; + grid-gap: 4px; + &.my { + grid-template-columns: 2.5fr 2.4fr 1.5fr 1.1fr 1.3fr 1fr 1fr; + } + .mdd, + .sm-score, + .profit, + .subscribe, + .public, + .manage-buttons { + place-items: center center; + } + .manage-buttons { + display: flex; + flex-direction: column; + gap: 8px; + .manage-button { + width: 74px; + height: 30px; + padding: 7px 16px; + } + } + .mdd, + .sm-score { + @include typo-b2; + } + .profit { + & span { + @include typo-c1; + } + p { + @include typo-b2; + color: $color-orange-500; + } + } +} + +.summary { + padding-left: 30px; + overflow: hidden; + :not(:first-child) { + margin: 4px 0; + } + .title { + @include typo-h4; + @include ellipsis(1); + display: flex; + justify-content: flex-start; + } + .trader-profile { + display: flex; + align-items: center; + & p { + margin-left: 10px; + @include typo-b2; + } + } + .total-subscribe-star { + display: flex; + align-items: center; + height: 20px; + gap: 6px; + padding-top: 10px; + & p { + @include typo-c1; + } + } +} + +.subscribe-icon { + button { + background: transparent; + svg { + width: 36px; + } + } +} + +.chart { + width: 100%; + overflow: hidden; +} + +.icon-container { + display: flex; + align-items: center; + column-gap: 4px; + .icon-wrapper { + .icon { + position: relative; + height: 21px; + } + .tooltip { + background-color: $color-gray-700; + border-radius: 20px; + padding: 8px 20px 6px; + @include typo-c1; + .arrow { + width: 14px; + height: 14px; + z-index: zIndex(hidden); + } + } + } + &.details { + flex-wrap: wrap; + max-height: 50px; + row-gap: 1px; + } +} diff --git a/app/(dashboard)/_ui/strategies-item/subscribe.tsx b/app/(dashboard)/_ui/strategies-item/subscribe.tsx new file mode 100644 index 00000000..8e9d7a78 --- /dev/null +++ b/app/(dashboard)/_ui/strategies-item/subscribe.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { BookmarkIcon, BookmarkOutlineIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import useModal from '@/shared/hooks/custom/use-modal' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import SubscribeWarningModal from '@/shared/ui/modal/subscribe-warning-modal' + +import useGetSubscribe from '../../strategies/_hooks/query/use-get-subscribe' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number + subscriptionStatus: boolean + traderName: string +} + +const Subscribe = ({ strategyId, subscriptionStatus, traderName }: Props) => { + const [isSubscribed, setIsSubscribed] = useState(false) + const user = useAuthStore((state) => state.user) + const { isModalOpen, openModal, closeModal } = useModal() + const { mutate } = useGetSubscribe() + + useEffect(() => { + if (subscriptionStatus) { + setIsSubscribed(true) + } + }, [subscriptionStatus]) + + const handleSubscribe = (e: React.MouseEvent) => { + if (user) { + e.stopPropagation() + if (user?.nickname === traderName) { + openModal() + } else { + e.preventDefault() + mutate(strategyId, { + onSuccess: () => setIsSubscribed(!isSubscribed), + }) + } + } + } + + return ( + <> +
+ +
+ + + ) +} + +export default Subscribe diff --git a/app/(dashboard)/_ui/subscriber-item/index.tsx b/app/(dashboard)/_ui/subscriber-item/index.tsx new file mode 100644 index 00000000..68c73bd0 --- /dev/null +++ b/app/(dashboard)/_ui/subscriber-item/index.tsx @@ -0,0 +1,35 @@ +'use client' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isMyStrategy?: boolean + isSubscribed?: boolean + subscribers: number + onClick?: () => void +} + +const SubscriberItem = ({ isSubscribed, isMyStrategy = false, subscribers, onClick }: Props) => { + return ( +
+
+ 구독 + | + {subscribers} +
+ {!isMyStrategy && ( + + )} +
+ ) +} + +export default SubscriberItem diff --git a/app/(dashboard)/_ui/subscriber-item/styles.module.scss b/app/(dashboard)/_ui/subscriber-item/styles.module.scss new file mode 100644 index 00000000..4d49f1e8 --- /dev/null +++ b/app/(dashboard)/_ui/subscriber-item/styles.module.scss @@ -0,0 +1,16 @@ +.container { + display: flex; + align-items: center; + justify-content: space-between; + height: 83px; + padding: 0 30px; + border-radius: 5px; + background-color: $color-white; + margin-bottom: 18px; + span { + font-size: 18px; + font-weight: $text-semibold; + color: $color-gray-800; + margin-left: 4px; + } +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..50a267c8 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,12 @@ +import DashboardNavigation from './_ui/navigation' + +const DashboardLayout = ({ children }: { children: React.ReactNode }) => { + return ( + <> + +
{children}
+ + ) +} + +export default DashboardLayout diff --git a/app/(dashboard)/my/_api/add-strategy.ts b/app/(dashboard)/my/_api/add-strategy.ts new file mode 100644 index 00000000..d78a692d --- /dev/null +++ b/app/(dashboard)/my/_api/add-strategy.ts @@ -0,0 +1,70 @@ +import axiosInstance from '@/shared/api/axios' + +export interface StockTypeModel { + stockTypeId: number + stockTypeName: string + stockIconUrl: string +} + +export interface TradeTypeModel { + tradeTypeId: number + tradeTypeName: string + tradeTypeIconUrl: string +} + +export interface StrategyTypeResponseModel { + isSuccess: boolean + message: string + result: { + stockTypes: StockTypeModel[] + tradeTypes: TradeTypeModel[] + } +} + +export type OperationCycleType = 'DAY' | 'POSITION' + +export type MinimumInvestmentAmountType = + | 'UNDER_10K' + | 'UP_TO_500K' + | 'UP_TO_1M' + | 'UP_TO_2M' + | 'UP_TO_5M' + | 'FROM_5M_TO_10M' + | 'FROM_10M_TO_20M' + | 'FROM_20M_TO_30M' + | 'FROM_30M_TO_40M' + | 'FROM_40M_TO_50M' + | 'FROM_50M_TO_100M' + | 'ABOVE_100M' + +export interface ProposalFileInfoModel { + proposalFileName: string + proposalFileSize: number +} + +export interface StrategyModel { + strategyName: string + tradeTypeId: number + operationCycle: OperationCycleType + stockTypeIds: number[] + minimumInvestmentAmount: MinimumInvestmentAmountType + description: string + proposalFile?: ProposalFileInfoModel +} + +export interface StrategyResponseModel { + isSuccess: boolean + message: string + result: { + presignedUrl: string + } + code: number +} + +export const strategyApi = { + getStrategyTypes: () => + axiosInstance.get('/api/my-strategies/register'), + + registerStrategy: (data: StrategyModel) => + axiosInstance.post('/api/my-strategies/register', data), +} diff --git a/app/(dashboard)/my/_api/get-favorite-strategy-list.ts b/app/(dashboard)/my/_api/get-favorite-strategy-list.ts new file mode 100644 index 00000000..18a17cae --- /dev/null +++ b/app/(dashboard)/my/_api/get-favorite-strategy-list.ts @@ -0,0 +1,30 @@ +import axiosInstance from '@/shared/api/axios' +import { StrategiesModel } from '@/shared/types/strategy-data' + +interface Props { + page: number + size: number +} + +const getFavoriteStrategyList = async ({ + page = 1, + size = 6, +}: Props): Promise<{ strategiesData: StrategiesModel[]; totalPages: number } | undefined> => { + try { + const response = await axiosInstance.get( + `/api/my-strategies/subscribed?page=${page}&size=${size}` + ) + + const { + content: strategiesData, + totalPages, + }: { content: StrategiesModel[]; totalPages: number } = await response.data.result + + return { strategiesData, totalPages } + } catch (err) { + console.error(err) + throw new Error('구독한 전략 조회에 실패했습니다.') + } +} + +export default getFavoriteStrategyList diff --git a/app/(dashboard)/my/_api/get-my-account-iamges.ts b/app/(dashboard)/my/_api/get-my-account-iamges.ts new file mode 100644 index 00000000..297958f9 --- /dev/null +++ b/app/(dashboard)/my/_api/get-my-account-iamges.ts @@ -0,0 +1,26 @@ +import { ImageDataModel } from '@/app/(dashboard)/_ui/analysis-container/account-content' + +import axiosInstance from '@/shared/api/axios' + +interface ResponseModel { + content: ImageDataModel + first: boolean + last: boolean + page: number + size: number + totalElements: number + totalPages: number +} + +const getMyAccountImages = async ( + strategyId: number +): Promise => { + try { + const response = await axiosInstance.get(`/api/my-strategies/${strategyId}/account-images`) + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default getMyAccountImages diff --git a/app/(dashboard)/my/_api/get-my-daily-analysis.ts b/app/(dashboard)/my/_api/get-my-daily-analysis.ts new file mode 100644 index 00000000..cf54e6c3 --- /dev/null +++ b/app/(dashboard)/my/_api/get-my-daily-analysis.ts @@ -0,0 +1,14 @@ +import axiosInstance from '@/shared/api/axios' + +const getMyDailyAnalysis = async (strategyId: number, page: number, size: number) => { + try { + const response = await axiosInstance.get( + `/api/my-strategies/${strategyId}/daily-analysis?page=${page}&size=${size}` + ) + return response.data.result + } catch (err) { + console.error(err, `일간 분석 조회 실패`) + } +} + +export default getMyDailyAnalysis diff --git a/app/(dashboard)/my/_api/get-my-strategy-list.ts b/app/(dashboard)/my/_api/get-my-strategy-list.ts new file mode 100644 index 00000000..0bce1fd6 --- /dev/null +++ b/app/(dashboard)/my/_api/get-my-strategy-list.ts @@ -0,0 +1,27 @@ +import axiosInstance from '@/shared/api/axios' +import { StrategiesModel } from '@/shared/types/strategy-data' + +interface StrategiesResponseModel { + isSuccess: boolean + message: string + result: { + content: StrategiesModel[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean + } +} + +export const getMyStrategyList = async ({ page = 1, size }: { page: number; size: number }) => { + const response = await axiosInstance.get( + `/api/my-strategies?userId=1&page=${page}&size=${size}` + ) + const { content, totalElements, page: page_, size: size_ } = response.data.result + return { + strategies: content, + hasMore: totalElements > page_ * size_, + } +} diff --git a/app/(dashboard)/my/_api/get-profile.ts b/app/(dashboard)/my/_api/get-profile.ts new file mode 100644 index 00000000..2112cd32 --- /dev/null +++ b/app/(dashboard)/my/_api/get-profile.ts @@ -0,0 +1,34 @@ +import axiosInstance from '@/shared/api/axios' + +export interface ProfileModel { + userId: number + userName: string + email: string + imageUrl: string | null + nickname: string + phone: string + infoAgreement: boolean + role: string + birthDate: string +} + +interface ProfileResponseModel { + isSuccess: boolean + message: string + result: ProfileModel +} + +export const getProfile = async (): Promise => { + try { + const response = await axiosInstance.get('/api/users/mypage/profile') + + if (response.data.isSuccess) { + return response.data.result + } else { + throw new Error(response.data.message || '요청 실패') + } + } catch (err) { + console.error(err) + throw new Error('해당 회원의 정보를 찾을 수 없습니다.') + } +} diff --git a/app/(dashboard)/my/_api/patch-profile.ts b/app/(dashboard)/my/_api/patch-profile.ts new file mode 100644 index 00000000..e613effb --- /dev/null +++ b/app/(dashboard)/my/_api/patch-profile.ts @@ -0,0 +1,25 @@ +import axiosInstance from '@/shared/api/axios' + +import { UserProfileModel } from '../_hooks/query/use-patch-profile' + +interface PatchUserProfileModel { + isSuccess: boolean + message: string + result: string + code: number +} + +const patchUserProfile = async (data: UserProfileModel) => { + try { + const response = await axiosInstance.patch( + `/api/users/mypage/profile`, + data + ) + return response.data + } catch (err) { + console.error('Error updating user profile:', err) + throw err + } +} + +export default patchUserProfile diff --git a/app/(dashboard)/my/_api/post-account-image.ts b/app/(dashboard)/my/_api/post-account-image.ts new file mode 100644 index 00000000..7e433c37 --- /dev/null +++ b/app/(dashboard)/my/_api/post-account-image.ts @@ -0,0 +1,49 @@ +import axiosInstance from 'shared/api/axios' + +interface UploadAccountImagesRequestModel { + fileName: string + fileSize: number + title: string +} + +interface UploadAccountImagesResponseModel { + isSuccess: boolean + message: string + result: { + presignedUrls: { + presignedUrl: string + }[] + } + code: number +} + +interface DeleteAccountImagesRequestModel { + strategyId: number + imageIds: number[] +} + +interface DeleteAccountImagesResponseModel { + isSuccess: boolean + message: string + code: number +} + +export const uploadAccountImages = async ( + strategyId: number, + data: UploadAccountImagesRequestModel[] +): Promise => { + const response = await axiosInstance.post(`/api/my-strategies/${strategyId}/account-images`, data) + return response.data +} + +export const deleteAccountImages = async ({ + strategyId, + imageIds, +}: DeleteAccountImagesRequestModel): Promise => { + const response = await axiosInstance.post( + `/api/my-strategies/${strategyId}/delete-account-images`, + imageIds + ) + console.log(response.data) + return response.data +} diff --git a/app/(dashboard)/my/_api/post-daily-analysis.ts b/app/(dashboard)/my/_api/post-daily-analysis.ts new file mode 100644 index 00000000..9c9ec145 --- /dev/null +++ b/app/(dashboard)/my/_api/post-daily-analysis.ts @@ -0,0 +1,15 @@ +import axiosInstance from '@/shared/api/axios' +import { AnalysisDataModel } from '@/shared/types/strategy-data' + +export const uploadDailyAnalysis = async ( + strategyId: number, + data: AnalysisDataModel[] +): Promise => { + const response = await axiosInstance.post(`/api/my-strategies/${strategyId}/daily-analysis`, data) + return response.data +} + +export const deleteAllAnalysis = async (strategyId: number): Promise => { + const response = await axiosInstance.delete(`/api/my-strategies/${strategyId}/daily-analysis`) + return response.data +} diff --git a/app/(dashboard)/my/_constants/investment-amount.ts b/app/(dashboard)/my/_constants/investment-amount.ts new file mode 100644 index 00000000..be410c08 --- /dev/null +++ b/app/(dashboard)/my/_constants/investment-amount.ts @@ -0,0 +1,33 @@ +import { MinimumInvestmentAmountType, OperationCycleType } from '../_api/add-strategy' + +const INVESTMENT_AMOUNT_MAP: Record = { + UNDER_10K: '1만원 ~ 500만원', + UP_TO_500K: '500만원', + UP_TO_1M: '1000만원', + UP_TO_2M: '2000만원', + UP_TO_5M: '5000만원', + FROM_5M_TO_10M: '5000만원 ~ 1억', + FROM_10M_TO_20M: '1억 ~ 2억', + FROM_20M_TO_30M: '2억 ~ 3억', + FROM_30M_TO_40M: '3억 ~ 4억', + FROM_40M_TO_50M: '4억 ~ 5억', + FROM_50M_TO_100M: '5억 ~ 10억', + ABOVE_100M: '10억 이상', +} + +const OPERATION_CYCLE_MAP: Record = { + DAY: '데이', + POSITION: '포지션', +} + +export const minimumInvestmentAmountOptions = Object.entries(INVESTMENT_AMOUNT_MAP).map( + ([value, label]) => ({ + value, + label, + }) +) + +export const operationCycleOptions = Object.entries(OPERATION_CYCLE_MAP).map(([value, label]) => ({ + value, + label, +})) diff --git a/app/(dashboard)/my/_hooks/query/use-add-strategy.ts b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts new file mode 100644 index 00000000..73f69c94 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-add-strategy.ts @@ -0,0 +1,59 @@ +import { useState } from 'react' + +import { useRouter } from 'next/navigation' + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { AxiosError } from 'axios' + +import { + StrategyModel, + StrategyResponseModel, + StrategyTypeResponseModel, + strategyApi, +} from '../../_api/add-strategy' + +interface ErrorResponseModel { + message: string +} + +export const useAddStrategy = () => { + const router = useRouter() + const queryClient = useQueryClient() + const [error, setError] = useState(null) + + const { data: strategyTypes, isLoading: isTypesLoading } = useQuery< + StrategyTypeResponseModel, + AxiosError + >({ + queryKey: ['strategyTypes'], + queryFn: () => strategyApi.getStrategyTypes().then((response) => response.data), + retry: false, + refetchOnWindowFocus: false, + }) + + const mutation = useMutation< + StrategyResponseModel, + AxiosError, + StrategyModel + >({ + mutationFn: (data) => strategyApi.registerStrategy(data).then((response) => response.data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['addStrategies'], + }) + router.back() + }, + onError: (err) => { + const errorMessage = err.response?.data?.message || '전략 등록에 실패했습니다.' + setError(errorMessage) + }, + }) + + return { + strategyTypes: strategyTypes?.result, + isTypesLoading, + registerStrategy: mutation.mutate, + isRegistering: mutation.isPending, + error, + } +} diff --git a/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts b/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts new file mode 100644 index 00000000..fae29d08 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-analysis-mutation.ts @@ -0,0 +1,60 @@ +import getMyDailyAnalysis from '@/app/(dashboard)/my/_api/get-my-daily-analysis' +import { + deleteAllAnalysis, + uploadDailyAnalysis, +} from '@/app/(dashboard)/my/_api/post-daily-analysis' +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { AnalysisDataModel } from '@/shared/types/strategy-data' + +interface UploadMutationParamsModel { + data: AnalysisDataModel[] +} + +export const useAnalysisUploadMutation = ( + strategyId: number, + page: number = 1, + size: number = 10 +) => { + const queryClient = useQueryClient() + + const uploadMutation = useMutation({ + mutationFn: ({ data }) => uploadDailyAnalysis(strategyId, data), + onSuccess: async () => { + queryClient.invalidateQueries({ + queryKey: ['myDailyAnalysis', strategyId], + }) + + try { + const newData = await getMyDailyAnalysis(strategyId, page, size) + queryClient.setQueryData(['myDailyAnalysis', strategyId], newData) + } catch (error) { + console.error('Failed to fetch updated my daily analysis data:', error) + } + }, + }) + + const deleteMutation = useMutation({ + mutationFn: () => deleteAllAnalysis(strategyId), + onSuccess: async () => { + queryClient.invalidateQueries({ + queryKey: ['myDailyAnalysis', strategyId], + }) + + try { + const newData = await getMyDailyAnalysis(strategyId, page, size) + queryClient.setQueryData(['myDailyAnalysis', strategyId], newData) + } catch (error) { + console.error('Failed to fetch updated my daily analysis data:', error) + } + }, + }) + + return { + uploadAnalysis: uploadMutation.mutate, + deleteAllAnalysis: deleteMutation.mutate, + isLoading: uploadMutation.status === 'pending' || deleteMutation.status === 'pending', + isError: uploadMutation.status === 'error' || deleteMutation.status === 'error', + error: uploadMutation.error || deleteMutation.error, + } +} diff --git a/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts b/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts new file mode 100644 index 00000000..bb376905 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-delete-account-images.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { deleteAccountImages } from '../../_api/post-account-image' + +interface DeleteAccountImagesRequestModel { + strategyId: number + imageIds: number[] +} + +export const useDeleteAccountImages = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: DeleteAccountImagesRequestModel) => deleteAccountImages(request), + onSuccess: (_, request) => { + queryClient.invalidateQueries({ + queryKey: ['myAccountImages', request.strategyId], + }) + }, + }) +} diff --git a/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts b/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts new file mode 100644 index 00000000..7138a936 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-get-favorite-strategy-list.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' + +import getFavoriteStrategyList from '../../_api/get-favorite-strategy-list' + +interface Props { + page: number + size: number +} + +const useGetFavoriteStrategyList = ({ page, size }: Props) => { + return useQuery({ + queryKey: ['favoriteStrategies', page, size], + queryFn: () => getFavoriteStrategyList({ page, size }), + }) +} + +export default useGetFavoriteStrategyList diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts b/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts new file mode 100644 index 00000000..7b9d32bc --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-get-my-account-image.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' + +import getMyAccountImages from '../../_api/get-my-account-iamges' + +const useGetMyAccountImages = (strategyId: number) => { + return useQuery({ + queryKey: ['myAccountImages', strategyId], + queryFn: () => getMyAccountImages(strategyId), + }) +} + +export default useGetMyAccountImages diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts b/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts new file mode 100644 index 00000000..3df8e5c8 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-get-my-daily-analysis.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' + +import getMyDailyAnalysis from '../../_api/get-my-daily-analysis' + +const useGetAnalysis = (strategyId: number, page: number, size: number) => { + return useQuery({ + queryKey: ['myDailyAnalysis', strategyId, page], + queryFn: () => getMyDailyAnalysis(strategyId, page, size), + }) +} + +export default useGetAnalysis diff --git a/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts b/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts new file mode 100644 index 00000000..f0f096e2 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list.ts @@ -0,0 +1,24 @@ +import { getMyStrategyList } from '@/app/(dashboard)/my/_api/get-my-strategy-list' +import { useInfiniteQuery } from '@tanstack/react-query' + +import { StrategiesModel } from '@/shared/types/strategy-data' + +interface StrategiesPageModel { + strategies: StrategiesModel[] + hasMore: boolean +} + +export const useGetMyStrategyList = () => { + return useInfiniteQuery({ + queryKey: ['myStrategies'], + queryFn: async ({ pageParam = 1 }) => { + const page = typeof pageParam === 'number' ? pageParam : 1 + return getMyStrategyList({ page, size: 4 }) + }, + getNextPageParam: (lastPage, pages) => { + if (!lastPage.hasMore) return undefined + return pages.length + 1 + }, + initialPageParam: 1, + }) +} diff --git a/app/(dashboard)/my/_hooks/query/use-get-profile.ts b/app/(dashboard)/my/_hooks/query/use-get-profile.ts new file mode 100644 index 00000000..67ee9ebc --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-get-profile.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' + +import { getProfile } from './../../_api/get-profile' + +const useGetProfile = () => { + return useQuery({ + queryKey: ['userProfile'], + queryFn: getProfile, + }) +} + +export default useGetProfile diff --git a/app/(dashboard)/my/_hooks/query/use-patch-profile.ts b/app/(dashboard)/my/_hooks/query/use-patch-profile.ts new file mode 100644 index 00000000..6d122247 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-patch-profile.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import patchProfile from '../../_api/patch-profile' + +export interface UserProfileModel { + nickname?: string + password?: string + imageDto?: { + imageName: string + size: number + } + phone?: string + email?: string + imageChange: boolean +} + +const usePatchUserProfile = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (data: UserProfileModel) => patchProfile(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['userProfile'] }) + }, + onError: (error) => { + console.error('Error updating user profile:', error) + }, + }) +} + +export default usePatchUserProfile diff --git a/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts b/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts new file mode 100644 index 00000000..aa7d3a76 --- /dev/null +++ b/app/(dashboard)/my/_hooks/query/use-upload-account-images.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { uploadAccountImages } from '../../_api/post-account-image' + +interface UseUploadAccountImagesProps { + strategyId: number + onSuccess?: () => void + onError?: (error: unknown) => void +} + +export const useUploadAccountImages = ({ + strategyId, + onSuccess, + onError, +}: UseUploadAccountImagesProps) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (files: { title: string; imageFile: File }[]) => { + const uploadData = files.map(({ title, imageFile }) => ({ + fileName: imageFile.name, + fileSize: imageFile.size, + title, + })) + const response = await uploadAccountImages(strategyId, uploadData) + + const { presignedUrls } = response.result + const uploadPromises = files.map(({ imageFile }, index) => { + return fetch(presignedUrls[index].presignedUrl, { + method: 'PUT', + body: imageFile, + headers: { + 'Content-Type': imageFile.type, + }, + }) + }) + + await Promise.all(uploadPromises) + return response + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['myAccountImages', strategyId], + exact: true, + }) + + onSuccess?.() + }, + onError, + }) +} diff --git a/app/(dashboard)/my/favorites/_ui/favorite-strategy-list/index.tsx b/app/(dashboard)/my/favorites/_ui/favorite-strategy-list/index.tsx new file mode 100644 index 00000000..31a50927 --- /dev/null +++ b/app/(dashboard)/my/favorites/_ui/favorite-strategy-list/index.tsx @@ -0,0 +1,62 @@ +'use client' + +import { Suspense } from 'react' + +import ListHeader from '@/app/(dashboard)/_ui/list-header' +import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' +import StrategiesItemSkeleton from '@/app/(dashboard)/_ui/strategies-item/skeleton' +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { usePagination } from '@/shared/hooks/custom/use-pagination' +import Pagination from '@/shared/ui/pagination' + +import useGetFavoriteStrategyList from '../../../_hooks/query/use-get-favorite-strategy-list' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const COUNT_PER_PAGE = 6 + +const FavoriteStrategyList = () => { + const { page, handlePageChange } = usePagination({ + basePath: PATH.FAVORITES, + pageSize: COUNT_PER_PAGE, + }) + + const { data } = useGetFavoriteStrategyList({ page, size: COUNT_PER_PAGE }) + + const strategiesData = data?.strategiesData || [] + const totalPages = data?.totalPages || null + + return ( + <> + + }> +
+ {!strategiesData.length && ( +

구독한 관심 전략이 없습니다.

+ )} + {strategiesData?.map((strategy) => ( + + ))} + {totalPages && ( + + )} +
+
+ + ) +} + +const Skeleton = () => { + return ( + <> + {Array.from({ length: 6 }, (_, idx) => ( + + ))} + + ) +} + +export default FavoriteStrategyList diff --git a/app/(dashboard)/my/favorites/_ui/favorite-strategy-list/styles.module.scss b/app/(dashboard)/my/favorites/_ui/favorite-strategy-list/styles.module.scss new file mode 100644 index 00000000..57247f5a --- /dev/null +++ b/app/(dashboard)/my/favorites/_ui/favorite-strategy-list/styles.module.scss @@ -0,0 +1,10 @@ +.pagination { + margin-bottom: 24px; +} + +.no-strategy { + margin-top: 180px; + text-align: center; + @include typo-b1; + color: $color-gray-600; +} diff --git a/app/(dashboard)/my/favorites/page.module.scss b/app/(dashboard)/my/favorites/page.module.scss new file mode 100644 index 00000000..62016214 --- /dev/null +++ b/app/(dashboard)/my/favorites/page.module.scss @@ -0,0 +1,5 @@ +.container { + h1 { + margin: 80px 0 24px; + } +} diff --git a/app/(dashboard)/my/favorites/page.tsx b/app/(dashboard)/my/favorites/page.tsx new file mode 100644 index 00000000..aa5d51c0 --- /dev/null +++ b/app/(dashboard)/my/favorites/page.tsx @@ -0,0 +1,19 @@ +import classNames from 'classnames/bind' + +import Title from '@/shared/ui/title' + +import FavoriteStrategyList from './_ui/favorite-strategy-list' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + +const MyFavoritesPage = () => { + return ( +
+ + <FavoriteStrategyList /> + </div> + ) +} + +export default MyFavoritesPage diff --git a/app/(dashboard)/my/profile/_ui/user-info/index.tsx b/app/(dashboard)/my/profile/_ui/user-info/index.tsx new file mode 100644 index 00000000..0d3aa53d --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-info/index.tsx @@ -0,0 +1,419 @@ +'use client' + +import { ChangeEvent, useState } from 'react' + +import { useRouter } from 'next/navigation' + +import { SIGNUP_ERROR_MESSAGES } from '@/app/(landing)/signup/_constants/signup' +import { CameraIcon } from '@/public/icons' +import axios from 'axios' +import classNames from 'classnames/bind' + +import axiosInstance from '@/shared/api/axios' +import { checkNicknameDuplicate, checkPhoneDuplicate } from '@/shared/api/check-duplicate' +import { PATH } from '@/shared/constants/path' +import Avatar from '@/shared/ui/avatar' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { LinkButton } from '@/shared/ui/link-button' + +import { ProfileModel } from '../../../_api/get-profile' +import styles from './styles.module.scss' +import { ProfileFormErrorsModel, ProfileFormModel, ProfileFormStateModel } from './types' +import { validateProfileForm } from './utils' + +const cx = classNames.bind(styles) + +const initialFormState = { + isNicknameVerified: false, + isPhoneVerified: false, +} + +interface Props { + isEditable?: boolean + profile: ProfileModel +} + +const uploadImageToS3 = async (presignedUrl: string, file: File): Promise<void> => { + try { + await axiosInstance.put(presignedUrl, file, { + headers: { + 'Content-Type': file.type, + }, + }) + } catch (error) { + console.error('이미지 업로드 실패:', error) + throw new Error('이미지 업로드에 실패했습니다') + } +} +const UserInfo = ({ profile, isEditable = false }: Props) => { + const router = useRouter() + + const initialForm: ProfileFormModel = { + name: profile?.userName || '', + nickname: profile?.nickname || '', + email: profile?.email || '', + password: '', + passwordConfirm: '', + phone: profile?.phone || '', + birthDate: profile?.birthDate || '', + } + + const [form, setForm] = useState<ProfileFormModel>(initialForm) + const [formState, setFormState] = useState<ProfileFormStateModel>(initialFormState) + const [errors, setErrors] = useState<ProfileFormErrorsModel>({}) + const [isValidated, setIsValidated] = useState(false) + const [isNicknameModified, setIsNicknameModified] = useState(false) + const [isPhoneModified, setIsPhoneModified] = useState(false) + const [selectedImage, setSelectedImage] = useState<File | null>(null) + const [previewUrl, setPreviewUrl] = useState<string | null>(null) + const [isUploading, setIsUploading] = useState(false) + + const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { + const { name, value } = e.target + setForm((prev) => ({ ...prev, [name]: value })) + + if (isValidated) { + setErrors((prev) => ({ + ...prev, + [name]: null, + })) + } + + if (name === 'nickname') { + setIsNicknameModified(true) + setErrors((prev) => ({ ...prev, nickname: null })) + } + if (name === 'phone') { + setIsPhoneModified(true) + setErrors((prev) => ({ ...prev, phone: null })) + } + } + + const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if (!file) return + + if (!file.type.startsWith('image/')) { + alert('이미지 파일만 업로드가 가능합니다.') + return + } + + const maxSize = 5 * 1024 * 1024 + if (file.size > maxSize) { + alert('파일 크기는 5MB 이하여야 합니다.') + return + } + + const reader = new FileReader() + reader.onload = (e) => { + setPreviewUrl(e.target?.result as string) + } + reader.readAsDataURL(file) + + setSelectedImage(file) + } + + const handleNicknameCheck = async () => { + try { + const response = await checkNicknameDuplicate(form.nickname) + if (response.result.isAvailable) { + setFormState((prev) => ({ ...prev, isNicknameVerified: true })) + setIsNicknameModified(false) + if (errors.nickname) { + setErrors((prev) => ({ ...prev, nickname: null })) + } + } else { + setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_DUPLICATED })) + setFormState((prev) => ({ ...prev, isNicknameVerified: false })) + } + } catch (err) { + console.error('닉네임 중복 확인 실패:', err) + setErrors((prev) => ({ ...prev, nickname: SIGNUP_ERROR_MESSAGES.NICKNAME_CHECK_FAILED })) + setFormState((prev) => ({ ...prev, isNicknameVerified: false })) + } + } + + const handlePhoneCheck = async () => { + try { + const response = await checkPhoneDuplicate(form.phone) + if (response.result.isAvailable) { + setFormState((prev) => ({ ...prev, isPhoneVerified: true })) + setIsPhoneModified(false) + if (errors.phone) { + setErrors((prev) => ({ ...prev, phone: null })) + } + } else { + setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_DUPLICATED })) + setFormState((prev) => ({ ...prev, isPhoneVerified: false })) + } + } catch (err) { + console.error('휴대폰 번호 중복 확인 실패:', err) + setErrors((prev) => ({ ...prev, phone: SIGNUP_ERROR_MESSAGES.PHONE_CHECK_FAILED })) + setFormState((prev) => ({ ...prev, isPhoneVerified: false })) + } + } + + const handleImageDelete = () => { + setSelectedImage(null) + setPreviewUrl(null) + } + + const handleBack = () => { + router.back() + } + + const handleFormSubmit = async () => { + const formErrors = validateProfileForm( + form, + formState.isNicknameVerified, + formState.isPhoneVerified + ) + setIsValidated(true) + + if (Object.keys(formErrors).length > 0) { + setErrors(formErrors) + return + } + + try { + setIsUploading(true) + + const updateData = { + nickName: form.nickname, + phoneNum: form.phone, + password: form.password || null, + email: form.email, + imageChange: selectedImage !== null, + profileImage: selectedImage + ? { + imageName: selectedImage.name, + size: selectedImage.size, + } + : null, + } + + console.log('요청 데이터:', updateData) + + const response = await axiosInstance.patch('/api/users/mypage/profile', updateData) + + if (!response.data.isSuccess) { + throw new Error(response.data.message) + } + + if (selectedImage && response.data.data?.presignedUrl) { + await uploadImageToS3(response.data.data.presignedUrl, selectedImage) + } + + alert('프로필이 성공적으로 업데이트되었습니다.') + router.push(PATH.PROFILE) + } catch (error) { + console.error('프로필 업데이트 실패:', error) + if (axios.isAxiosError(error)) { + const errorMessage = error.response?.data?.message || '프로필 업데이트에 실패했습니다.' + alert(errorMessage) + } else { + alert('프로필 업데이트에 실패했습니다. 다시 시도해주세요.') + } + } finally { + setIsUploading(false) + } + } + + if (!profile) { + return null + } + + return ( + <div className={cx('container')}> + <p className={cx('title')}>개인 정보</p> + <div className={cx('line')}></div> + + <div className={cx('content')}> + <div className={cx('content-wrapper')}> + <div className={cx('left-wrapper')}> + <div className={cx('avatar-wrapper')}> + {previewUrl ? ( + <img src={previewUrl} alt="Preview" className={cx('avatar-preview')} /> + ) : ( + <Avatar size="xxlarge" /> + )} + <div className={cx('camera-wrapper')}> + <input + type="file" + id="profile-image" + accept="image/*" + onChange={handleImageChange} + style={{ display: 'none' }} + /> + <label htmlFor="profile-image"> + <CameraIcon + className={cx('camera-icon')} + style={{ cursor: isUploading ? 'wait' : 'pointer' }} + /> + </label> + </div> + </div> + {isEditable && selectedImage && ( + <Button onClick={handleImageDelete}>프로필 사진 삭제</Button> + )} + </div> + + <div className={cx('right-wrapper')}> + {!isEditable && ( + <LinkButton variant="filled" className={cx('edit-button')} href={PATH.EDIT_PROFILE}> + 개인 정보 수정 + </LinkButton> + )} + <div className={cx('first-row')}> + <p className={cx('title')}>이름</p> + <Input + id="name" + name="name" + value={form.name} + inputSize="compact" + className={cx('input')} + isWhiteDisabled={!isEditable} + disabled={isEditable} + /> + </div> + + <div className={cx('row')}> + <div> + <p className={cx('title')}>이메일</p> + <Input + inputSize="compact" + value={form.email} + className={cx('input')} + isWhiteDisabled={!isEditable} + disabled={isEditable} + /> + </div> + <div> + <p className={cx('title')}>휴대전화</p> + <div className={cx('position')}> + <div className={cx('position-wrapper')}> + <Input + id="phone" + name="phone" + value={form.phone} + onChange={handleInputChange} + className={cx('input')} + inputSize="compact" + isWhiteDisabled={!isEditable} + errorMessage={errors.phone} + /> + {isEditable && <Button onClick={handlePhoneCheck}>확인</Button>} + </div> + {formState.isPhoneVerified && !isPhoneModified && ( + <p className={cx('verified-message')}>사용할 수 있는 휴대폰 번호입니다.</p> + )} + {isPhoneModified && ( + <p className={cx('modified-message')}> + 휴대폰 번호가 수정되었습니다. 다시 확인해 주세요. + </p> + )} + </div> + </div> + </div> + + <div className={cx('row')}> + <div> + <p className={cx('title')}>생년월일</p> + <Input + inputSize="compact" + value={form.birthDate} + className={cx('input')} + isWhiteDisabled={!isEditable} + disabled={isEditable} + /> + </div> + <div> + <p className={cx('title')}>닉네임</p> + <div className={cx('position')}> + <div className={cx('position-wrapper')}> + <Input + id="nickname" + name="nickname" + inputSize="compact" + value={form.nickname} + onChange={handleInputChange} + className={cx('input')} + isWhiteDisabled={!isEditable} + errorMessage={errors.nickname} + /> + {isEditable && <Button onClick={handleNicknameCheck}>확인</Button>} + </div> + {formState.isNicknameVerified && !isNicknameModified && ( + <p className={cx('verified-message')}>사용할 수 있는 닉네임입니다.</p> + )} + {isNicknameModified && ( + <p className={cx('modified-message')}> + 닉네임이 수정되었습니다. 다시 중복확인해 주세요. + </p> + )} + </div> + </div> + </div> + + {isEditable && ( + <div className={cx('password-row')}> + <div> + <p className={cx('title')}>비밀번호</p> + <Input + id="password" + name="password" + type="password" + inputSize="compact" + value={form.password} + onChange={handleInputChange} + placeholder="비밀번호를 입력하세요" + className={cx('input')} + errorMessage={errors.password} + /> + </div> + <div> + <p className={cx('title')}>비밀번호 확인</p> + <Input + id="passwordConfirm" + name="passwordConfirm" + type="password" + value={form.passwordConfirm} + onChange={handleInputChange} + placeholder="한 번 더 입력하세요" + className={cx('input')} + inputSize="compact" + /> + </div> + </div> + )} + {isEditable && ( + <div> + <p className={cx('notification')}> + * 비밀번호는 문자, 숫자 포함 6~20자로 구성되어야 합니다. + </p> + </div> + )} + </div> + </div> + {isEditable && ( + <div className={cx('button-wrapper')}> + <Button className={cx('left-button')} onClick={handleBack} disabled={isUploading}> + 뒤로가기 + </Button> + <Button + className={cx('right-button')} + variant="filled" + onClick={handleFormSubmit} + disabled={isUploading} + > + {isUploading ? '저장 중...' : '저장하기'} + </Button> + </div> + )} + </div> + </div> + ) +} + +export default UserInfo diff --git a/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss new file mode 100644 index 00000000..da2506e2 --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-info/styles.module.scss @@ -0,0 +1,146 @@ +.container { + background-color: $color-white; + width: 897px; + height: 854px; + padding: 44px 40px; +} + +.title { + @include typo-b1; + margin-bottom: 22px; + color: $color-gray-700; +} + +.line { + width: 100%; + height: 1px; + background-color: $color-gray-300; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; +} + +.content-wrapper { + display: flex; + max-width: 820px; + justify-content: space-between; + margin-top: 44px; +} + +.left-wrapper { + display: flex; + flex-direction: column; + flex: 1; + margin-right: 50px; +} + +.avatar-wrapper { + position: relative; + display: inline-block; + margin-bottom: 35px; + + .camera-wrapper { + position: absolute; + bottom: 40px; + right: 10px; + width: 36px; + height: 36px; + transform: translate(50%, 50%); + background: $color-orange-500; + border-radius: 50%; + border: 4px solid $color-white; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + + .camera-icon { + fill: $color-white; + width: 18px; + height: 18px; + } + } +} + +.right-wrapper { + display: flex; + flex-direction: column; + + .edit-button { + align-self: flex-end; + margin-top: 20px; + } + + .first-row { + display: flex; + flex-direction: column; + margin-bottom: 36px; + } + + .title { + color: $color-gray-800; + margin-bottom: 15px; + @include typo-b3; + } + + .row { + display: flex; + gap: 27px; + align-items: center; + margin-bottom: 36px; + + .input { + flex: 1; + } + } + + .password-row { + display: flex; + gap: 27px; + align-items: center; + } +} + +.button-wrapper { + margin-top: 103px; + display: flex; + justify-content: center; + gap: 32px; + width: 100%; + height: 40px; + + .left-button { + width: 112px; + } + + .right-button { + width: 112px; + } +} + +.notification { + font-size: 12px; + color: $color-gray-600; + margin-top: 8px; + @include typo-c1; +} + +.position { + display: flex; + flex-direction: column; + align-items: center; + gap: 27px; +} +.position-wrapper { + display: flex; + gap: 12px; +} + +.nickname-verified { + margin-top: 0; + @include typo-b3; + color: $color-indigo; +} diff --git a/app/(dashboard)/my/profile/_ui/user-info/types.ts b/app/(dashboard)/my/profile/_ui/user-info/types.ts new file mode 100644 index 00000000..05409966 --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-info/types.ts @@ -0,0 +1,26 @@ +import { SIGNUP_ERROR_MESSAGES } from '@/app/(landing)/signup/_constants/signup' + +export type ProfileErrorMessageType = + (typeof SIGNUP_ERROR_MESSAGES)[keyof typeof SIGNUP_ERROR_MESSAGES] + +export interface ProfileFormModel { + name: string + nickname: string + email: string + password: string + passwordConfirm: string + phone: string + birthDate: string +} + +export interface ProfileFormStateModel { + isNicknameVerified: boolean + isPhoneVerified: boolean +} + +export interface ProfileFormErrorsModel { + nickname?: ProfileErrorMessageType | null + password?: ProfileErrorMessageType | null + passwordConfirm?: ProfileErrorMessageType | null + phone?: ProfileErrorMessageType | null +} diff --git a/app/(dashboard)/my/profile/_ui/user-info/utils.ts b/app/(dashboard)/my/profile/_ui/user-info/utils.ts new file mode 100644 index 00000000..1165c166 --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-info/utils.ts @@ -0,0 +1,58 @@ +import { SIGNUP_ERROR_MESSAGES } from '@/app/(landing)/signup/_constants/signup' + +import { isValidNickname, isValidPassword, isValidPhone } from '@/shared/utils/validation' + +import { ProfileErrorMessageType, ProfileFormModel } from './types' + +type ValidationFieldType = keyof typeof SIGNUP_ERROR_MESSAGES + +const validateField = (field: string, value: string): ProfileErrorMessageType | null => { + if (!value.trim()) { + return SIGNUP_ERROR_MESSAGES[`${field}_REQUIRED` as ValidationFieldType] || null + } + + switch (field) { + case 'NICKNAME': + return isValidNickname(value) ? null : SIGNUP_ERROR_MESSAGES.NICKNAME_LENGTH + case 'PASSWORD': + return isValidPassword(value) ? null : SIGNUP_ERROR_MESSAGES.PASSWORD_INVALID + case 'PHONE': + return isValidPhone(value) ? null : SIGNUP_ERROR_MESSAGES.PHONE_INVALID + default: + return null + } +} + +export const validateProfileForm = ( + form: ProfileFormModel, + isNicknameVerified: boolean, + isPhoneVerified: boolean +): Record<string, ProfileErrorMessageType> => { + const errors: Record<string, ProfileErrorMessageType> = {} + + const nicknameError = validateField('NICKNAME', form.nickname) + if (nicknameError) errors.nickname = nicknameError + else if (!isNicknameVerified) errors.nickname = SIGNUP_ERROR_MESSAGES.NICKNAME_CHECK_REQUIRED + + const passwordError = validateField('PASSWORD', form.password) + if (passwordError) errors.password = passwordError + + const passwordMatchError = validatePasswordMatch(form.password, form.passwordConfirm) + if (passwordMatchError) errors.passwordConfirm = passwordMatchError + + const phoneError = validateField('PHONE', form.phone) + if (phoneError) errors.phone = phoneError + if (!isPhoneVerified) errors.phone = SIGNUP_ERROR_MESSAGES.PHONE_CHECK_REQUIRED + + return errors +} + +export const validatePasswordMatch = ( + password: string, + confirmPassword: string +): ProfileErrorMessageType | null => { + if (!confirmPassword.trim()) { + return SIGNUP_ERROR_MESSAGES.PASSWORD_REQUIRED + } + return password === confirmPassword ? null : SIGNUP_ERROR_MESSAGES.PASSWORD_MISMATCH +} diff --git a/app/(dashboard)/my/profile/_ui/user-profile/index.tsx b/app/(dashboard)/my/profile/_ui/user-profile/index.tsx new file mode 100644 index 00000000..bd4e247e --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-profile/index.tsx @@ -0,0 +1,34 @@ +import classNames from 'classnames/bind' + +import Avatar from '@/shared/ui/avatar' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + role: string + nickname: string + email: string +} + +const UserProfile = ({ role, nickname, email }: Props) => { + return ( + <div className={cx('container')}> + <p className={cx('title')}>프로필 정보</p> + <div className={cx('line')}></div> + <div className={cx('content')}> + <div className={cx('left-wrapper')}> + <p className={cx('role')}>{role}</p> + <p className={cx('nickname')}>{nickname}</p> + <p className={cx('email')}>{email}</p> + </div> + <div className={cx('right-wrapper')}> + <Avatar size="xlarge" /> + </div> + </div> + </div> + ) +} + +export default UserProfile diff --git a/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss new file mode 100644 index 00000000..f95f67fe --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-profile/styles.module.scss @@ -0,0 +1,56 @@ +.container { + background-color: $color-white; + width: 375px; + height: 280px; + padding: 44px 40px; +} + +.title { + @include typo-b1; + color: $color-gray-700; + margin-bottom: 22px; +} + +.line { + width: 100%; + height: 1px; + background-color: $color-gray-300; +} + +.content { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 44px; +} + +.left-wrapper { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1; + gap: 12px; + + .role { + @include typo-b2; + color: $color-orange-500; + } + + .nickname { + @include typo-h4; + font-weight: bold; + color: $color-gray-800; + } + + .email { + @include typo-b2; + color: $color-gray-400; + } +} + +.right-wrapper { + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; +} diff --git a/app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx b/app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx new file mode 100644 index 00000000..6e443ff4 --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-withdraw/index.tsx @@ -0,0 +1,41 @@ +'use client' + +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const UserWithdraw = () => { + const router = useRouter() + const handleWithdraw = () => {} + const handleBack = () => { + router.back() + } + return ( + <div className={cx('container')}> + <p className={cx('title')}>회원탈퇴</p> + <div className={cx('line')}></div> + <p className={cx('message')}>회원 탈퇴 메세지</p> + <div className={cx('content')}> + <div className={cx('message-wrapper')}> + <p>ㆍ탈퇴 즉시 모든 개인정보가 삭제됩니다.</p> + <p>ㆍ구독한 전략에 대한 모든 내용이 삭제됩니다.</p> + </div> + </div> + <div className={cx('button-wrapper')}> + <Button className={cx('left-button')} onClick={handleBack}> + 뒤로가기 + </Button> + <Button className={cx('right-button')} variant="filled" onClick={handleWithdraw}> + 탈퇴 + </Button> + </div> + </div> + ) +} +export default UserWithdraw diff --git a/app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss b/app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss new file mode 100644 index 00000000..35e2f626 --- /dev/null +++ b/app/(dashboard)/my/profile/_ui/user-withdraw/styles.module.scss @@ -0,0 +1,68 @@ +.container { + background-color: $color-white; + width: 897px; + height: 854px; + padding: 44px 40px; +} + +.title { + @include typo-b1; + color: $color-gray-700; + margin-bottom: 22px; +} + +.line { + width: 100%; + height: 1px; + background-color: $color-gray-300; +} + +.message { + margin-top: 69px; + text-align: center; + @include typo-h4; + color: $color-gray-700; +} + +.content { + display: flex; + justify-content: center; + align-items: center; +} + +.message-wrapper { + margin-top: 51px; + border: 1px solid $color-gray-600; + border-radius: 6px; + width: 569px; + height: 206px; + color: $color-gray-700; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + + p { + @include typo-b2; + margin: 0; + line-height: 1.5; + } +} + +.button-wrapper { + margin-top: 58px; + display: flex; + justify-content: center; + gap: 32px; + width: 100%; + height: 40px; + + .left-button { + width: 112px; + } + + .right-button { + width: 112px; + } +} diff --git a/app/(dashboard)/my/profile/edit/page.tsx b/app/(dashboard)/my/profile/edit/page.tsx new file mode 100644 index 00000000..5c2b9406 --- /dev/null +++ b/app/(dashboard)/my/profile/edit/page.tsx @@ -0,0 +1,20 @@ +'use client' + +import useGetProfile from '../../_hooks/query/use-get-profile' +import UserInfo from '../_ui/user-info' + +const MyProfileEditPage = () => { + const { data: profile, isLoading } = useGetProfile() + + if (!profile) { + return null + } + + return ( + <> + <UserInfo profile={profile} isEditable={true} /> + </> + ) +} + +export default MyProfileEditPage diff --git a/app/(dashboard)/my/profile/page.module.scss b/app/(dashboard)/my/profile/page.module.scss new file mode 100644 index 00000000..9fe14a05 --- /dev/null +++ b/app/(dashboard)/my/profile/page.module.scss @@ -0,0 +1,24 @@ +.container { + padding: 40px 28px; +} + +.title { + margin-top: 40px; + @include typo-h4; + margin-bottom: 22px; +} + +.wrapper { + display: flex; + justify-content: space-between; +} + +.user-profile { + display: flex; + flex-direction: column; +} + +.link-button { + align-self: flex-end; + margin-top: 25px; +} diff --git a/app/(dashboard)/my/profile/page.tsx b/app/(dashboard)/my/profile/page.tsx new file mode 100644 index 00000000..da50ca52 --- /dev/null +++ b/app/(dashboard)/my/profile/page.tsx @@ -0,0 +1,38 @@ +'use client' + +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { LinkButton } from '@/shared/ui/link-button' + +import useGetProfile from '../_hooks/query/use-get-profile' +import UserInfo from './_ui/user-info' +import UserProfile from './_ui/user-profile' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + +const MyProfilePage = () => { + const { data: profile, isLoading } = useGetProfile() + + if (!profile) { + return null + } + + return ( + <div className={cx('container')}> + <p className={cx('title')}>나의 정보</p> + <div className={cx('wrapper')}> + <UserInfo profile={profile} /> + <div className={cx('user-profile')}> + <UserProfile role={profile.role} nickname={profile.nickname} email={profile.email} /> + <div className={cx('link-button')}> + <LinkButton href={PATH.PROFILE_WITHDRAW}>탈퇴하기</LinkButton> + </div> + </div> + </div> + </div> + ) +} + +export default MyProfilePage diff --git a/app/(dashboard)/my/profile/withdraw/page.tsx b/app/(dashboard)/my/profile/withdraw/page.tsx new file mode 100644 index 00000000..178574c8 --- /dev/null +++ b/app/(dashboard)/my/profile/withdraw/page.tsx @@ -0,0 +1,7 @@ +import UserWithdraw from '../_ui/user-withdraw' + +const MyProfileWithdrawPage = () => { + return <UserWithdraw /> +} + +export default MyProfileWithdrawPage diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx new file mode 100644 index 00000000..9150e3f7 --- /dev/null +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/index.tsx @@ -0,0 +1,196 @@ +'use client' + +import { useRef, useState } from 'react' + +import { useParams, useRouter } from 'next/navigation' + +import usePostQuestion from '@/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question' +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import { Button } from '@/shared/ui/button' +import { ErrorMessage } from '@/shared/ui/error-message' +import AddQuestionModal from '@/shared/ui/modal/add-question-modal' +import { Textarea } from '@/shared/ui/textarea' + +import useDeleteAnswer from '../../../_hooks/query/use-delete-answer' +import useDeleteQuestion from '../../../_hooks/query/use-delete-question' +import useGetQuestionDetails from '../../../_hooks/query/use-get-question-details' +import usePostAnswer from '../../../_hooks/query/use-post-answer' +import QuestionDeleteModal from '../../../_ui/modal/question-delete-modal' +import QuestionDetailCard from '../question-detail-card' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const QuestionContainer = () => { + const [isActiveAnswer, setIsActiveAnswer] = useState(false) + const [isAnswerDeleteModalOpen, setIsAnswerDeleteModalOpen] = useState(false) + const [isQuestionDeleteModalOpen, setIsQuestionDeleteModalOpen] = useState(false) + const [isAddQuestionModalOpen, setIsAddQuestionModalOpen] = useState(false) + const [answerErrorMessage, setAnswerErrorMessage] = useState<string | null>(null) + const { questionId } = useParams() + const router = useRouter() + + const textareaRef = useRef<HTMLTextAreaElement | null>(null) + + const { mutate: submitAnswer } = usePostAnswer(parseInt(questionId as string)) + const { mutate: deleteAnswer } = useDeleteAnswer() + const { mutate: deleteQuestion } = useDeleteQuestion() + const { mutate: postQuestion } = usePostQuestion() + const { data: questionDetails } = useGetQuestionDetails({ + questionId: parseInt(questionId as string), + }) + + const user = useAuthStore((state) => state.user) + + if (!user || !questionDetails) { + return null + } + + const isTrader = user.role.includes('TRADER') + const isInvestor = user.role.includes('INVESTOR') + + const handleQuestionAdd = () => { + setIsAddQuestionModalOpen(true) + } + + const handleAnswerAdd = () => { + setIsActiveAnswer((prevState) => !prevState) + } + + const handleAnswerSubmit = () => { + const content = textareaRef.current?.value + + if (!content) { + setAnswerErrorMessage('답변을 입력해주세요.') + return + } + + setAnswerErrorMessage(null) + + submitAnswer(content, { + onSuccess: () => { + if (textareaRef.current) { + textareaRef.current.value = '' + } + setIsActiveAnswer(false) + }, + onError: () => { + setAnswerErrorMessage('답변 등록에 실패했습니다.') + }, + }) + } + + const handleDeleteAnswerClick = () => { + setIsAnswerDeleteModalOpen(true) + } + + const handleDeleteQuestionClick = () => { + setIsQuestionDeleteModalOpen(true) + } + + const handleDeleteAnswer = () => { + if (!questionDetails?.answer) return + deleteAnswer( + { + questionId: parseInt(questionId as string), + answerId: questionDetails.answer.answerId, + }, + { + onSuccess: () => { + setIsAnswerDeleteModalOpen(false) + }, + } + ) + } + + const handleDeleteQuestion = () => { + deleteQuestion( + { + questionId: parseInt(questionId as string), + strategyId: questionDetails.strategyId, + }, + { + onSuccess: () => { + setIsQuestionDeleteModalOpen(false) + router.push(PATH.MY_QUESTIONS) + }, + } + ) + } + + return ( + <> + <div className={cx('container')}> + <QuestionDetailCard + isAuthor={isInvestor} + strategyName={questionDetails.strategyName} + title={questionDetails.title} + contents={questionDetails.content} + nickname={questionDetails.nickname} + createdAt={questionDetails.createdAt} + status={questionDetails.state === 'WAITING' ? '답변 대기' : '답변 완료'} + onDelete={handleDeleteQuestionClick} + /> + {questionDetails.answer ? ( + <QuestionDetailCard + type="answer" + isAuthor={isTrader} + contents={questionDetails.answer.content} + nickname={questionDetails.answer.nickname} + createdAt={questionDetails.answer.createdAt} + onDelete={handleDeleteAnswerClick} + /> + ) : ( + <>{!isActiveAnswer && <p className={cx('empty-message')}>아직 답변이 없습니다</p>}</> + )} + {isActiveAnswer ? ( + <div className={cx('answer-input-wrapper')}> + <div className={cx('title-wrapper')}> + <h2 className={cx('title')}>답변</h2> + <Button size="small" onClick={handleAnswerSubmit}> + 등록하기 + </Button> + </div> + <Textarea placeholder="내용을 입력하세요." ref={textareaRef} /> + <ErrorMessage errorMessage={answerErrorMessage} /> + </div> + ) : ( + ((isTrader && !questionDetails.answer) || isInvestor) && ( + <Button + variant="filled" + className={cx('button')} + onClick={isTrader ? handleAnswerAdd : handleQuestionAdd} + > + {isTrader ? '답변하기' : '추가 질문하기'} + </Button> + ) + )} + </div> + <QuestionDeleteModal + isModalOpen={isAnswerDeleteModalOpen} + onCloseModal={() => setIsAnswerDeleteModalOpen(false)} + onDelete={handleDeleteAnswer} + message="답변을 삭제하시겠습니까?" + /> + <QuestionDeleteModal + isModalOpen={isQuestionDeleteModalOpen} + onCloseModal={() => setIsQuestionDeleteModalOpen(false)} + onDelete={handleDeleteQuestion} + message="문의 내역을 삭제하시겠습니까?" + /> + <AddQuestionModal + strategyId={questionDetails.strategyId} + isModalOpen={isAddQuestionModalOpen} + strategyName={questionDetails.strategyName} + onCloseModal={() => setIsAddQuestionModalOpen(false)} + title={`RE: ${questionDetails.title}`} + content={questionDetails.content} + /> + </> + ) +} + +export default QuestionContainer diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-container/styles.module.scss b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/styles.module.scss new file mode 100644 index 00000000..0e982a35 --- /dev/null +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-container/styles.module.scss @@ -0,0 +1,35 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + margin-top: 86px; +} + +.button { + margin: 48px 0 72px; +} + +.empty-message { + margin-top: 90px; + color: $color-gray-600; + @include typo-b1; +} + +.answer-input-wrapper { + width: 100%; + padding: 35px 40px; + margin-bottom: 120px; + background-color: $color-white; + + .title-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; + } + + .title { + @include typo-b1; + } +} diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx new file mode 100644 index 00000000..e1f21245 --- /dev/null +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/index.tsx @@ -0,0 +1,75 @@ +'use client' + +import React from 'react' + +import classNames from 'classnames/bind' + +import { QuestionStatusType } from '@/shared/types/questions' +import Avatar from '@/shared/ui/avatar' +import Label from '@/shared/ui/label' +import { formatDateTime } from '@/shared/utils/format' + +import { QuestionCardProps } from '../../../_ui/question-card' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +type QuestionDetailCardType = 'question' | 'answer' + +interface Props extends QuestionCardProps { + type?: QuestionDetailCardType + strategyName?: string + title?: string + status?: QuestionStatusType + isAuthor: boolean + onDelete?: () => void +} + +const QuestionDetailCard = ({ + profileImage, + nickname, + contents, + type = 'question', + status, + strategyName, + title = '답변', + createdAt, + isAuthor, + onDelete, +}: Props) => { + return ( + <div className={cx('card-container')}> + <div className={cx('card-header')}> + {type === 'question' && ( + <div className={cx('top-wrapper')}> + <strong className={cx('strategy-name')}>{strategyName}</strong> + <Label color={status === '답변 완료' ? 'indigo' : 'orange'}>{status}</Label> + </div> + )} + <h2 className={cx('title', type)}>{title}</h2> + <div className={cx('bottom-wrapper')}> + <div className={cx('avatar-wrapper')}> + <Avatar src={profileImage} size="medium" /> + <span>{nickname}</span> + <span className={cx('created-at')}>ㅣ {formatDateTime(createdAt)}</span> + </div> + {isAuthor && ( + <button type="button" className={cx('delete-button')} onClick={onDelete}> + 삭제 + </button> + )} + </div> + </div> + <div className={cx('card-contents')}> + {contents.split('\n').map((line, idx) => ( + <React.Fragment key={line + idx}> + {line} + <br /> + </React.Fragment> + ))} + </div> + </div> + ) +} + +export default QuestionDetailCard diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx new file mode 100644 index 00000000..65d61a95 --- /dev/null +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/question-detail-card.stories.tsx @@ -0,0 +1,56 @@ +import { Meta, StoryFn } from '@storybook/react' + +import QuestionDetailCard from '.' + +const meta: Meta = { + title: 'Components/QuestionDetailCard', + component: QuestionDetailCard, + tags: ['autodocs'], +} + +const Template: StoryFn<typeof QuestionDetailCard> = (args) => ( + <div style={{ width: '900px', padding: '20px', backgroundColor: '#f8f9fa' }}> + <QuestionDetailCard {...args} /> + </div> +) + +export const Question = Template.bind({}) +Question.args = { + type: 'question', + strategyName: '엄청난 전략', + title: '미국발 경제악화가 한국 증시에 미치는 영향은 무엇인가요?', + contents: + '안녕하세요. 주식투자를 시작하려고 하는데 미국의 경제 상황이 좋지 않다고 들었습니다. 이런 상황에서 한국 증시는 어떤 영향을 받을까요? 구체적인 설명 부탁드립니다.', + nickname: '투자초보', + profileImage: '', + createdAt: '2024-11-03T15:00:00', + isAuthor: false, + onDelete: () => alert('삭제 버튼 클릭'), +} + +export const QuestionWithDeleteButton = Template.bind({}) +QuestionWithDeleteButton.args = { + ...Question.args, + isAuthor: true, +} + +export const Answer = Template.bind({}) +Answer.args = { + type: 'answer', + title: '답변', + contents: + '안녕하세요. 문의하신 내용에 대해 답변드리겠습니다. 미국과 한국 증시는 높은 상관관계를 보이고 있어 미국의 경제 상황이 한국 증시에 큰 영향을 미칠 수 있습니다. 구체적으로는...', + nickname: '전문가', + profileImage: '', + createdAt: '2024-11-03T16:30:00', + isAuthor: false, + onDelete: () => alert('삭제 버튼 클릭'), +} + +export const AnswerWithDeleteButton = Template.bind({}) +AnswerWithDeleteButton.args = { + ...Answer.args, + isAuthor: true, +} + +export default meta diff --git a/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/styles.module.scss b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/styles.module.scss new file mode 100644 index 00000000..d7cb99ed --- /dev/null +++ b/app/(dashboard)/my/questions/[questionId]/_ui/question-detail-card/styles.module.scss @@ -0,0 +1,55 @@ +@import '../../../_ui/question-card/card-mixins.scss'; + +.card-container { + @include card-base; + width: 100%; + padding: 35px 40px; +} + +.top-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + + .strategy-name { + @include card-subtitle; + } +} + +.title { + @include card-title; + margin-bottom: 18px; + + &.answer { + @include typo-b1; + } +} + +.bottom-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 12px; + margin-bottom: 24px; + border-bottom: 1px solid $color-gray-300; + + .delete-button { + @include typo-c1; + background-color: transparent; + } +} + +.avatar-wrapper { + @include avatar-group; + + .created-at { + margin-left: -8px; + color: $color-gray-400; + } +} + +.card-contents { + color: $color-gray-600; + @include typo-b3; + line-height: 140%; +} diff --git a/app/(dashboard)/my/questions/[questionId]/page.tsx b/app/(dashboard)/my/questions/[questionId]/page.tsx new file mode 100644 index 00000000..4f452c26 --- /dev/null +++ b/app/(dashboard)/my/questions/[questionId]/page.tsx @@ -0,0 +1,16 @@ +import BackHeader from '@/shared/ui/header/back-header' +import Title from '@/shared/ui/title' + +import QuestionContainer from './_ui/question-container' + +const QuestionDetailPage = () => { + return ( + <> + <BackHeader label={'문의 내역으로 돌아가기'} /> + <Title label="문의 내역" /> + <QuestionContainer /> + </> + ) +} + +export default QuestionDetailPage diff --git a/app/(dashboard)/my/questions/_api/delete-answer.ts b/app/(dashboard)/my/questions/_api/delete-answer.ts new file mode 100644 index 00000000..65d82170 --- /dev/null +++ b/app/(dashboard)/my/questions/_api/delete-answer.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@/shared/api/axios' + +export interface DeleteAnswerProps { + questionId: number + answerId: number +} + +const deleteAnswer = async ({ questionId, answerId }: DeleteAnswerProps) => { + try { + const response = await axiosInstance.delete( + `/api/trader/questions/${questionId}/answers/${answerId}` + ) + return response.data.isSuccess + } catch (err) { + console.error(err) + throw new Error('답변 삭제에 실패했습니다.') + } +} + +export default deleteAnswer diff --git a/app/(dashboard)/my/questions/_api/delete-question.ts b/app/(dashboard)/my/questions/_api/delete-question.ts new file mode 100644 index 00000000..b1c413dc --- /dev/null +++ b/app/(dashboard)/my/questions/_api/delete-question.ts @@ -0,0 +1,20 @@ +import axiosInstance from '@/shared/api/axios' + +export interface DeleteQuestionProps { + strategyId: number + questionId: number +} + +const deleteQuestion = async ({ strategyId, questionId }: DeleteQuestionProps) => { + try { + const response = await axiosInstance.delete( + `/api/strategies/${strategyId}/questions/${questionId}` + ) + return response.data.isSuccess + } catch (err) { + console.error(err) + throw new Error('문의 내역 삭제에 실패했습니다.') + } +} + +export default deleteQuestion diff --git a/app/(dashboard)/my/questions/_api/get-my-question-list.ts b/app/(dashboard)/my/questions/_api/get-my-question-list.ts new file mode 100644 index 00000000..736bcc50 --- /dev/null +++ b/app/(dashboard)/my/questions/_api/get-my-question-list.ts @@ -0,0 +1,47 @@ +import axiosInstance from '@/shared/api/axios' +import { UserType } from '@/shared/types/auth' +import { + QuestionModel, + QuestionSearchConditionType, + QuestionStateTapType, +} from '@/shared/types/questions' + +interface Props { + userType: UserType + page?: number + size?: number + keyword?: string + searchCondition?: QuestionSearchConditionType + stateCondition: QuestionStateTapType +} + +interface QuestionReturnModel { + content: QuestionModel[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean +} + +const getMyQuestionList = async ({ + userType, + page = 1, + size = 3, + keyword = '', + searchCondition = 'CONTENT', + stateCondition, +}: Props): Promise<QuestionReturnModel> => { + try { + const response = await axiosInstance.get( + `/api/${userType.toLowerCase()}/questions?page=${page}&size=${size}&keyword=${keyword}&searchCondition=${searchCondition}&stateCondition=${stateCondition}` + ) + return response.data.result + } catch (err) { + console.error(err) + throw new Error('문의 목록 조회에 실패했습니다.') + } +} + +export default getMyQuestionList diff --git a/app/(dashboard)/my/questions/_api/get-question-details.ts b/app/(dashboard)/my/questions/_api/get-question-details.ts new file mode 100644 index 00000000..f112c922 --- /dev/null +++ b/app/(dashboard)/my/questions/_api/get-question-details.ts @@ -0,0 +1,18 @@ +import axiosInstance from '@/shared/api/axios' +import { QuestionDetailsModel } from '@/shared/types/questions' + +interface Props { + questionId: number +} + +const getQuestionDetails = async ({ questionId }: Props): Promise<QuestionDetailsModel> => { + try { + const response = await axiosInstance.get(`/api/questions/${questionId}`) + return response.data.result + } catch (err) { + console.error(err) + throw new Error('문의 목록 조회에 실패했습니다.') + } +} + +export default getQuestionDetails diff --git a/app/(dashboard)/my/questions/_api/post-answer.ts b/app/(dashboard)/my/questions/_api/post-answer.ts new file mode 100644 index 00000000..ab6af76f --- /dev/null +++ b/app/(dashboard)/my/questions/_api/post-answer.ts @@ -0,0 +1,15 @@ +import axiosInstance from '@/shared/api/axios' + +const postAnswer = async (questionId: number, content: string) => { + try { + const response = await axiosInstance.post(`/api/trader/questions/${questionId}/answers`, { + content, + }) + return response.data.isSuccess + } catch (err) { + console.error(err) + throw new Error('답변 등록에 실패했습니다.') + } +} + +export default postAnswer diff --git a/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts b/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts new file mode 100644 index 00000000..808cf3ec --- /dev/null +++ b/app/(dashboard)/my/questions/_hooks/query/use-delete-answer.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import deleteAnswer, { DeleteAnswerProps } from '../../_api/delete-answer' + +const useDeleteAnswer = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ questionId, answerId }: DeleteAnswerProps) => + deleteAnswer({ questionId, answerId }), + onSuccess: (_, { questionId }) => { + queryClient.invalidateQueries({ + queryKey: ['questionDetails', questionId], + }) + + queryClient.invalidateQueries({ + queryKey: ['questionList'], + }) + }, + }) +} + +export default useDeleteAnswer diff --git a/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts b/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts new file mode 100644 index 00000000..85498daf --- /dev/null +++ b/app/(dashboard)/my/questions/_hooks/query/use-delete-question.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import deleteQuestion, { DeleteQuestionProps } from '../../_api/delete-question' + +const useDeleteQuestion = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ questionId, strategyId }: DeleteQuestionProps) => + deleteQuestion({ questionId, strategyId }), + onSuccess: (_, { questionId }) => { + queryClient.invalidateQueries({ + queryKey: ['questionDetails', questionId], + }) + + queryClient.invalidateQueries({ + queryKey: ['questionList'], + }) + }, + }) +} + +export default useDeleteQuestion diff --git a/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts b/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts new file mode 100644 index 00000000..2f84b48d --- /dev/null +++ b/app/(dashboard)/my/questions/_hooks/query/use-get-my-question-list.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query' + +import { UserType } from '@/shared/types/auth' +import { QuestionSearchOptionsModel } from '@/shared/types/questions' + +import getMyQuestionList from '../../_api/get-my-question-list' + +interface Props { + page: number + size: number + options: QuestionSearchOptionsModel + userType: UserType +} + +const useGetMyQuestionList = ({ page, size, userType, options }: Props) => { + return useQuery({ + queryKey: ['questionList', page, size, options], + queryFn: () => { + const { keyword, searchCondition, stateCondition } = options + return getMyQuestionList({ + userType, + page, + size, + keyword, + searchCondition, + stateCondition, + }) + }, + }) +} + +export default useGetMyQuestionList diff --git a/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts b/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts new file mode 100644 index 00000000..06f737ae --- /dev/null +++ b/app/(dashboard)/my/questions/_hooks/query/use-get-question-details.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query' + +import getQuestionDetails from '../../_api/get-question-details' + +interface Props { + questionId: number +} + +const useGetQuestionDetails = ({ questionId }: Props) => { + return useQuery({ + queryKey: ['questionDetails', questionId], + queryFn: () => getQuestionDetails({ questionId }), + }) +} + +export default useGetQuestionDetails diff --git a/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts b/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts new file mode 100644 index 00000000..0d3a64af --- /dev/null +++ b/app/(dashboard)/my/questions/_hooks/query/use-post-answer.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import postAnswer from '../../_api/post-answer' + +const usePostAnswer = (questionId: number) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (content: string) => postAnswer(questionId, content), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['questionDetails', questionId], + }) + + queryClient.invalidateQueries({ + queryKey: ['questionList'], + }) + }, + }) +} + +export default usePostAnswer diff --git a/app/(dashboard)/my/questions/_ui/modal/question-delete-modal.tsx b/app/(dashboard)/my/questions/_ui/modal/question-delete-modal.tsx new file mode 100644 index 00000000..94a5caaf --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/modal/question-delete-modal.tsx @@ -0,0 +1,33 @@ +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import Modal from '@/shared/ui/modal' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + onCloseModal: () => void + onDelete: () => void + message: string +} + +const QuestionDeleteModal = ({ isModalOpen, onCloseModal, onDelete, message }: Props) => { + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <p className={cx('message')}>{message}</p> + + <Button.ButtonGroup> + <Button onClick={onCloseModal}>취소</Button> + <Button onClick={onDelete} variant="filled"> + 삭제 + </Button> + </Button.ButtonGroup> + </Modal> + ) +} + +export default QuestionDeleteModal diff --git a/app/(dashboard)/my/questions/_ui/modal/styles.module.scss b/app/(dashboard)/my/questions/_ui/modal/styles.module.scss new file mode 100644 index 00000000..9f169860 --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/modal/styles.module.scss @@ -0,0 +1,5 @@ +.message { + margin-bottom: 30px; + @include typo-h4; + text-align: center; +} diff --git a/app/(dashboard)/my/questions/_ui/question-card/card-mixins.scss b/app/(dashboard)/my/questions/_ui/question-card/card-mixins.scss new file mode 100644 index 00000000..a3f373a3 --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/question-card/card-mixins.scss @@ -0,0 +1,40 @@ +@mixin card-base { + padding: 24px 40px 16px; + border-radius: 8px; + background-color: $color-white; +} + +@mixin card-subtitle { + @include typo-b2; + color: $color-orange-600; +} + +@mixin card-created-at { + @include typo-b3; + color: $color-gray-400; +} + +@mixin card-top { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin card-title { + margin: 12px 0 8px; + @include typo-h4; +} + +@mixin card-bottom { + display: flex; + align-items: center; + justify-content: space-between; +} + +@mixin avatar-group { + display: flex; + align-items: center; + gap: 16px; + color: $color-gray-600; + @include typo-b3; +} diff --git a/app/(dashboard)/my/questions/_ui/question-card/index.tsx b/app/(dashboard)/my/questions/_ui/question-card/index.tsx new file mode 100644 index 00000000..ddc67a9c --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/question-card/index.tsx @@ -0,0 +1,62 @@ +import Link from 'next/link' + +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { QuestionStateConditionType } from '@/shared/types/questions' +import Avatar from '@/shared/ui/avatar' +import Label from '@/shared/ui/label' +import { formatDateTime } from '@/shared/utils/format' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +export interface QuestionCardProps { + contents: string + nickname: string + profileImage?: string + createdAt: string +} + +interface Props extends QuestionCardProps { + questionId: number + strategyName: string + title: string + questionState: QuestionStateConditionType +} + +const QuestionCard = ({ + questionId, + strategyName, + title, + contents, + nickname, + profileImage, + createdAt, + questionState, +}: Props) => { + const status = questionState === 'COMPLETED' ? '답변 완료' : '답변 대기' + + return ( + <div className={cx('card-container')}> + <Link href={`${PATH.MY_QUESTIONS}/${questionId}`}> + <div className={cx('top-wrapper')}> + <strong className={cx('strategy-name')}>{strategyName}</strong> + <span className={cx('created-at')}>{formatDateTime(createdAt)}</span> + </div> + <h2 className={cx('title')}>{title}</h2> + <p className={cx('contents')}>{contents}</p> + <div className={cx('bottom-wrapper')}> + <div className={cx('avatar-wrapper')}> + <Avatar src={profileImage} size="medium" /> + <span>{nickname}</span> + </div> + <Label color={questionState === 'COMPLETED' ? 'indigo' : 'orange'}>{status}</Label> + </div> + </Link> + </div> + ) +} + +export default QuestionCard diff --git a/app/(dashboard)/my/questions/_ui/question-card/question-card.stories.tsx b/app/(dashboard)/my/questions/_ui/question-card/question-card.stories.tsx new file mode 100644 index 00000000..6f7c3eab --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/question-card/question-card.stories.tsx @@ -0,0 +1,35 @@ +import { Meta, StoryFn } from '@storybook/react' + +import QuestionCard from '.' + +const meta: Meta = { + title: 'Components/QuestionCard', + component: QuestionCard, + tags: ['autodocs'], +} + +const Template: StoryFn<typeof QuestionCard> = (args) => ( + <div style={{ width: '900px', padding: '20px', backgroundColor: '#f8f9fa' }}> + <QuestionCard {...args} /> + </div> +) + +export const Default = Template.bind({}) +Default.args = { + strategyName: '전략 이름', + title: '미국발 경제악화가 한국 증시에 미치는 영향은 무엇인가요?', + contents: + '안녕하세요 주식투자를 해보려고 하는데요 어쩌구... 저쩌구..........안녕하세요 주식투자를 해보려고 하는데요 어쩌구... 저쩌구..........', + nickname: '투자할래요', + profileImage: '', + createdAt: '2024-11-03T15:00:00', + questionState: 'WAITING', +} + +export const Answered = Template.bind({}) +Answered.args = { + ...Default.args, + questionState: 'COMPLETED', +} + +export default meta diff --git a/app/(dashboard)/my/questions/_ui/question-card/styles.module.scss b/app/(dashboard)/my/questions/_ui/question-card/styles.module.scss new file mode 100644 index 00000000..68caf223 --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/question-card/styles.module.scss @@ -0,0 +1,38 @@ +@import './card-mixins.scss'; + +.card-container { + @include card-base; +} + +.top-wrapper { + display: flex; + align-items: center; + justify-content: space-between; + + .strategy-name { + @include card-subtitle; + } + + .created-at { + @include card-created-at; + } +} + +.title { + @include card-title; +} + +.contents { + margin-bottom: 18px; + color: $color-gray-600; + @include typo-b2; + @include ellipsis(1); +} + +.bottom-wrapper { + @include card-bottom; +} + +.avatar-wrapper { + @include avatar-group; +} diff --git a/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx new file mode 100644 index 00000000..e7225d35 --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/questions-tab-content/index.tsx @@ -0,0 +1,78 @@ +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { usePagination } from '@/shared/hooks/custom/use-pagination' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import { QuestionSearchOptionsModel } from '@/shared/types/questions' +import Pagination from '@/shared/ui/pagination' + +import useGetMyQuestionList from '../../_hooks/query/use-get-my-question-list' +import QuestionCard from '../question-card' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const COUNT_PER_PAGE = 3 + +interface Props { + options: QuestionSearchOptionsModel +} + +const QuestionsTabContent = ({ options }: Props) => { + const { page, handlePageChange } = usePagination({ + basePath: PATH.MY_QUESTIONS, + pageSize: COUNT_PER_PAGE, + }) + + const user = useAuthStore((state) => state.user) + + const { data } = useGetMyQuestionList({ + page, + size: COUNT_PER_PAGE, + userType: user?.role.includes('TRADER') ? 'TRADER' : 'INVESTOR', + options, + }) + + if (!data) { + return + } + + const questionsData = data.content + + return ( + <> + <ul className={cx('question-list')}> + {questionsData && + !!questionsData.length && + questionsData.map((question) => ( + <li key={question.questionId}> + <QuestionCard + questionId={question.questionId} + strategyName={question.strategyName} + title={question.title} + questionState={question.stateCondition} + contents={question.questionContent} + nickname={question.nickname} + createdAt={question.createdAt} + /> + </li> + ))} + </ul> + + {(!questionsData || !questionsData.length) && ( + <p className={cx('empty-message')}>문의 내역이 없습니다.</p> + )} + <div className={cx('pagination-wrapper')}> + {data.totalElements > 0 && ( + <Pagination + currentPage={page} + maxPage={data.totalPages} + onPageChange={handlePageChange} + /> + )} + </div> + </> + ) +} + +export default QuestionsTabContent diff --git a/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss b/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss new file mode 100644 index 00000000..3517a7ec --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/questions-tab-content/styles.module.scss @@ -0,0 +1,16 @@ +.question-list { + padding-top: 24px; + + li { + margin-bottom: 24px; + } +} + +.empty-message { + margin: 12px 0; + @include typo-b2; +} + +.pagination-wrapper { + margin-bottom: 24px; +} diff --git a/app/(dashboard)/my/questions/_ui/questions-tab/index.tsx b/app/(dashboard)/my/questions/_ui/questions-tab/index.tsx new file mode 100644 index 00000000..505f0ba8 --- /dev/null +++ b/app/(dashboard)/my/questions/_ui/questions-tab/index.tsx @@ -0,0 +1,83 @@ +'use client' + +import { Suspense, useState } from 'react' + +import { useRouter, useSearchParams } from 'next/navigation' + +import { QuestionSearchConditionType } from '@/shared/types/questions' +import Tabs from '@/shared/ui/tabs' + +import QuestionsTabContent from '../questions-tab-content' + +interface Props { + searchOptions: { + keyword: string + searchCondition: QuestionSearchConditionType + } +} + +const QuestionsTab = ({ searchOptions }: Props) => { + const router = useRouter() + const searchParams = useSearchParams() + const [activeTab, setActiveTab] = useState('all') + + const handleTabChange = (tabId: string) => { + setActiveTab(tabId) + + const params = new URLSearchParams(searchParams) + params.set('page', '1') + router.push(`?${params.toString()}`) + } + + const TABS = [ + { + id: 'all', + label: '모든 질문', + content: ( + <Suspense> + <QuestionsTabContent + options={{ + stateCondition: 'ALL', + searchCondition: searchOptions.searchCondition, + keyword: searchOptions.keyword, + }} + /> + </Suspense> + ), + }, + { + id: 'waiting', + label: '답변 대기', + content: ( + <Suspense> + <QuestionsTabContent + options={{ + stateCondition: 'WAITING', + searchCondition: searchOptions.searchCondition, + keyword: searchOptions.keyword, + }} + /> + </Suspense> + ), + }, + { + id: 'completed', + label: '답변 완료', + content: ( + <Suspense> + <QuestionsTabContent + options={{ + stateCondition: 'COMPLETED', + searchCondition: searchOptions.searchCondition, + keyword: searchOptions.keyword, + }} + /> + </Suspense> + ), + }, + ] + + return <Tabs activeTab={activeTab} onTabChange={handleTabChange} tabs={TABS} /> +} + +export default QuestionsTab diff --git a/app/(dashboard)/my/questions/page.module.scss b/app/(dashboard)/my/questions/page.module.scss new file mode 100644 index 00000000..a745f035 --- /dev/null +++ b/app/(dashboard)/my/questions/page.module.scss @@ -0,0 +1,12 @@ +.title-wrapper { + margin: 80px 0 32px; + + display: flex; + align-items: center; + justify-content: space-between; +} + +.search-wrapper { + display: flex; + gap: 24px; +} diff --git a/app/(dashboard)/my/questions/page.tsx b/app/(dashboard)/my/questions/page.tsx new file mode 100644 index 00000000..4c219c08 --- /dev/null +++ b/app/(dashboard)/my/questions/page.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useRef, useState } from 'react' + +import classNames from 'classnames/bind' + +import { QuestionSearchConditionType } from '@/shared/types/questions' +import { DropdownValueType } from '@/shared/ui/dropdown/types' +import { SearchInput } from '@/shared/ui/search-input' +import Select from '@/shared/ui/select' +import Title from '@/shared/ui/title' + +import QuestionsTab from './_ui/questions-tab' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + +const searchSelectOptions = [ + { + value: 'TITLE', + label: '제목', + }, + { + value: 'CONTENT', + label: '내용', + }, + { + value: 'TITLE_OR_CONTENT', + label: '제목 또는 내용', + }, + { + value: 'TRADER_NAME', + label: '트레이더명', + }, + { + value: 'INVESTOR_NAME', + label: '투자자명', + }, + { + value: 'STRATEGY_NAME', + label: '전략명', + }, +] + +const MyQuestionsPage = () => { + const [selectedOption, setSelectedOption] = useState<DropdownValueType>('TITLE') + const [searchOptions, setSearchOptions] = useState({ + keyword: '', + searchCondition: selectedOption as QuestionSearchConditionType, + }) + + const inputRef = useRef<HTMLInputElement | null>(null) + + const handleSearch = () => { + setSearchOptions({ + keyword: inputRef.current?.value || '', + searchCondition: selectedOption as QuestionSearchConditionType, + }) + } + + return ( + <div className={cx('container')}> + <div className={cx('title-wrapper')}> + <Title label="문의 내역" /> + <div className={cx('search-wrapper')}> + <Select + size="small" + value={selectedOption} + placeholder="검색 조건" + onChange={setSelectedOption} + options={searchSelectOptions} + /> + <SearchInput + ref={inputRef} + placeholder="검색어를 입력하세요." + onSearchIconClick={handleSearch} + /> + </div> + </div> + <QuestionsTab searchOptions={searchOptions} /> + </div> + ) +} + +export default MyQuestionsPage diff --git a/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx b/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx new file mode 100644 index 00000000..61254eda --- /dev/null +++ b/app/(dashboard)/my/strategies/_ui/my-strategy-list/index.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useCallback, useRef } from 'react' + +import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' +import { useGetMyStrategyList } from '@/app/(dashboard)/my/_hooks/query/use-get-my-strategy-list' + +import { useIntersectionObserver } from '@/shared/hooks/custom/use-intersection-observer' + +const MyStrategyList = () => { + const { + data: strategyData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useGetMyStrategyList() + + const loadMoreRef = useRef<HTMLDivElement>(null) + + const onIntersect = useCallback( + (entry: IntersectionObserverEntry) => { + if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, + [fetchNextPage, hasNextPage, isFetchingNextPage] + ) + + useIntersectionObserver({ + ref: loadMoreRef, + onIntersect, + }) + + const strategies = strategyData?.pages.flatMap((page) => page.strategies) || [] + return ( + <> + {strategies.map((strategy) => ( + <StrategiesItem key={strategy.strategyId} strategiesData={strategy} type="my" /> + ))} + <div ref={loadMoreRef} /> + {isFetchingNextPage && <div>로딩 중...</div>} + </> + ) +} + +export default MyStrategyList diff --git a/app/(dashboard)/my/strategies/add/page.tsx b/app/(dashboard)/my/strategies/add/page.tsx new file mode 100644 index 00000000..c7999780 --- /dev/null +++ b/app/(dashboard)/my/strategies/add/page.tsx @@ -0,0 +1,308 @@ +'use client' + +import { useState } from 'react' + +import Image from 'next/image' +import { useRouter } from 'next/navigation' + +import { FileIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import BackHeader from '@/shared/ui/header/back-header' +import { Input } from '@/shared/ui/input' +import Select from '@/shared/ui/select' +import Title from '@/shared/ui/title' + +import { + MinimumInvestmentAmountType, + OperationCycleType, + ProposalFileInfoModel, + StrategyModel, +} from '../../_api/add-strategy' +import { + minimumInvestmentAmountOptions, + operationCycleOptions, +} from '../../_constants/investment-amount' +import { useAddStrategy } from '../../_hooks/query/use-add-strategy' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface StrategyFormDataModel { + strategyName: string + tradeType: string + operationCycle: OperationCycleType + stockTypes: string[] + minimumInvestmentAmount: MinimumInvestmentAmountType + description: string + proposalFile?: File +} + +interface FormErrorsModel { + strategyName: string + tradeType: string + operationCycle: string + stockTypes: string + minimumInvestmentAmount: string + description: string + proposalFile?: string +} + +const StrategyAddPage = () => { + const router = useRouter() + const { strategyTypes, registerStrategy, isTypesLoading, isRegistering, error } = useAddStrategy() + + const [formData, setFormData] = useState<StrategyFormDataModel>({ + strategyName: '', + tradeType: '', + operationCycle: 'DAY', + stockTypes: [], + minimumInvestmentAmount: 'UNDER_10K', + description: '', + }) + + const [formErrors, setFormErrors] = useState<FormErrorsModel>({ + strategyName: '', + tradeType: '', + operationCycle: '', + stockTypes: '', + minimumInvestmentAmount: '', + description: '', + }) + + const validateForm = (): boolean => { + const newErrors = { + strategyName: !formData.strategyName ? '전략 명칭을 입력해주세요.' : '', + tradeType: !formData.tradeType ? '매매 유형을 선택해주세요.' : '', + operationCycle: !formData.operationCycle ? '주기를 선택해주세요.' : '', + stockTypes: formData.stockTypes.length === 0 ? '종목을 선택해주세요.' : '', + minimumInvestmentAmount: !formData.minimumInvestmentAmount + ? '최소 운용가능 금액을 선택해주세요.' + : '', + description: !formData.description ? '전략 소개를 입력해주세요.' : '', + proposalFile: '', + } + + setFormErrors(newErrors) + return !Object.values(newErrors).some((error) => error) + } + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if ( + file && + (file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || + file.type === 'application/vnd.ms-excel') + ) { + setFormData((prev) => ({ ...prev, proposalFile: file })) + setFormErrors((prev) => ({ ...prev, proposalFile: '' })) + } else { + setFormErrors((prev) => ({ ...prev, proposalFile: '엑셀 파일만 업로드 가능합니다.' })) + } + } + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault() + + if (!validateForm()) return + + const fileInfo: ProposalFileInfoModel | undefined = formData.proposalFile + ? { + proposalFileName: formData.proposalFile.name, + proposalFileSize: formData.proposalFile.size, + } + : undefined + + const data: StrategyModel = { + strategyName: formData.strategyName, + tradeTypeId: Number(formData.tradeType), + operationCycle: formData.operationCycle, + stockTypeIds: formData.stockTypes.map(Number), + minimumInvestmentAmount: formData.minimumInvestmentAmount, + description: formData.description, + proposalFile: fileInfo, + } + + registerStrategy(data) + } + + const toggleStockType = (value: string) => { + setFormData((prev) => { + const newStockTypes = prev.stockTypes.includes(value) + ? prev.stockTypes.filter((type) => type !== value) + : [...prev.stockTypes, value] + return { ...prev, stockTypes: newStockTypes } + }) + setFormErrors((prev) => ({ ...prev, stockTypes: '' })) + } + + const tradeTypeOptions = + strategyTypes?.tradeTypes.map((type) => ({ + value: String(type.tradeTypeId), + label: type.tradeTypeName, + })) || [] + + if (isTypesLoading) { + return <div className={cx('loading')}>로딩 중...</div> + } + return ( + <> + <BackHeader label="전략관리로 돌아가기" /> + <Title label="전략 등록" /> + <form onSubmit={handleSubmit} className={cx('form')}> + {error && <div className={cx('error')}>{error}</div>} + <div className={cx('form-row')}> + <label>전략 명칭</label> + <div className={cx('form-field')}> + <Input + value={formData.strategyName} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { + setFormData((prev) => ({ ...prev, strategyName: e.target.value })) + setFormErrors((prev) => ({ ...prev, strategyName: '' })) + }} + placeholder="전략명을 입력하세요" + errorMessage={formErrors.strategyName} + /> + </div> + </div> + <div className={cx('horizontal-wrapper')}> + <div className={cx('form-row', 'half')}> + <label>매매 유형</label> + <div className={cx('form-field')}> + <Select + value={formData.tradeType} + onChange={(value) => { + setFormData((prev) => ({ ...prev, tradeType: value as string })) + setFormErrors((prev) => ({ ...prev, tradeType: '' })) + }} + options={tradeTypeOptions} + placeholder="매매 유형 선택" + titleStyle={{ width: '200px', height: '50px' }} + /> + {formErrors.tradeType && ( + <div className={cx('field-error')}>{formErrors.tradeType}</div> + )} + </div> + </div> + + <div className={cx('form-row', 'half')}> + <label>주기</label> + <div className={cx('form-field')}> + <Select + value={formData.operationCycle} + onChange={(value) => { + setFormData((prev) => ({ ...prev, operationCycle: value as OperationCycleType })) + setFormErrors((prev) => ({ ...prev, operationCycle: '' })) + }} + options={operationCycleOptions} + placeholder="주기 선택" + titleStyle={{ width: '200px', height: '50px' }} + containerStyle={{ width: '100%' }} + /> + {formErrors.operationCycle && ( + <div className={cx('field-error')}>{formErrors.operationCycle}</div> + )} + </div> + </div> + </div> + <div className={cx('form-row')}> + <label>종목</label> + <div className={cx('form-field')}> + <div className={cx('stock-grid')}> + {strategyTypes?.stockTypes.map((type) => ( + <button + key={type.stockTypeId} + type="button" + onClick={() => toggleStockType(String(type.stockTypeId))} + className={cx('stock-item', { + selected: formData.stockTypes.includes(String(type.stockTypeId)), + })} + > + {type.stockTypeName} + <span className={cx('marker')}> + <Image + src={type.stockIconUrl} + alt={type.stockTypeName} + width={20} + height={20} + /> + </span> + </button> + ))} + </div> + {formErrors.stockTypes && ( + <div className={cx('field-error')}>{formErrors.stockTypes}</div> + )} + </div> + </div> + <div className={cx('form-row')}> + <label>최소 운용가능 금액</label> + <div className={cx('form-field')}> + <Select + value={formData.minimumInvestmentAmount} + onChange={(value) => { + setFormData((prev) => ({ + ...prev, + minimumInvestmentAmount: value as MinimumInvestmentAmountType, + })) + setFormErrors((prev) => ({ ...prev, minimumInvestmentAmount: '' })) + }} + options={minimumInvestmentAmountOptions} + placeholder="최소 운용가능 금액 선택" + titleStyle={{ width: '200px', height: '50px' }} + containerStyle={{ width: '100%' }} + /> + {formErrors.minimumInvestmentAmount && ( + <div className={cx('field-error')}>{formErrors.minimumInvestmentAmount}</div> + )} + </div> + </div> + <div className={cx('form-row')}> + <label>전략 소개</label> + <div className={cx('form-field')}> + <Input + value={formData.description} + onChange={(e: React.ChangeEvent<HTMLInputElement>) => { + setFormData((prev) => ({ ...prev, description: e.target.value })) + setFormErrors((prev) => ({ ...prev, description: '' })) + }} + placeholder="내용을 입력하세요" + errorMessage={formErrors.description} + /> + </div> + </div> + <div className={cx('form-row')}> + <label>제안서</label> + <div className={cx('form-field')}> + <div className={cx('file-upload')}> + <input + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + id="proposalFile" + className={cx('file-input')} + /> + <label htmlFor="proposalFile" className={cx('file-label')}> + <span>{formData.proposalFile?.name || '엑셀 파일을 선택해주세요'}</span> + <FileIcon className={cx('file-icon')} /> + </label> + </div> + {formErrors.proposalFile && ( + <div className={cx('field-error')}>{formErrors.proposalFile}</div> + )} + </div> + </div> + <div className={cx('button-wrapper')}> + <Button variant="outline" onClick={() => router.back()} type="button"> + 취소 + </Button> + <Button variant="filled" type="submit" disabled={isRegistering}> + {isRegistering ? '등록 중...' : '전략 등록하기'} + </Button> + </div> + </form> + </> + ) +} +export default StrategyAddPage diff --git a/app/(dashboard)/my/strategies/add/styles.module.scss b/app/(dashboard)/my/strategies/add/styles.module.scss new file mode 100644 index 00000000..54fc4866 --- /dev/null +++ b/app/(dashboard)/my/strategies/add/styles.module.scss @@ -0,0 +1,144 @@ +.form { + max-width: 800px; + margin: 0 auto; + padding: 24px; + position: relative; +} + +.form-row { + display: flex; + align-items: flex-start; + margin-bottom: 24px; + gap: 24px; + + label { + flex: 0 0 120px; + margin-top: 8px; + font-weight: 500; + } + + &.half { + margin-bottom: 0; + } +} + +.horizontal-wrapper { + display: flex; + gap: 24px; + margin-bottom: 24px; + + .form-row { + flex: 1; + } +} + +.form-field { + flex: 1; +} + +.error { + margin-bottom: 16px; + padding: 12px; + border-radius: 4px; + background-color: $color-white; + color: $color-orange-600; + font-size: 14px; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.input { + width: 100%; + padding: 8px 12px; + border: 1px solid $color-gray-300; + border-radius: 4px; + font-size: 14px; + line-height: 1.5; + transition: border-color 0.2s; + + &.error { + border-color: $color-orange-600; + } +} + +.stock-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 8px; +} + +.stock-item { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border: 1px solid $color-gray-300; + border-radius: 4px; + background-color: $color-white; + font-size: 14px; + transition: all 0.2s; + cursor: pointer; + width: 100%; + + &:hover { + border-color: $color-indigo; + } + + &.selected { + border-color: $color-indigo; + } + + .marker { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + overflow: hidden; + } +} + +.file-upload { + position: relative; + + .file-input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + } + + .file-label { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + padding: 8px 16px; + border: 1px solid $color-gray-300; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + color: $color-gray-500; + font-size: 14px; + + .file-icon { + width: 24px; + height: 24px; + } + } +} + +.button-wrapper { + display: flex; + justify-content: center; + margin-top: 32px; + gap: 24px; +} diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx new file mode 100644 index 00000000..0e14f21c --- /dev/null +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx @@ -0,0 +1,73 @@ +'use client' + +import AnalysisContainer from '@/app/(dashboard)/_ui/analysis-container' +import DetailsInformation from '@/app/(dashboard)/_ui/details-information' +import DetailsSideItem, { + InformationModel, + TitleType, +} from '@/app/(dashboard)/_ui/details-side-item' +import SubscriberItem from '@/app/(dashboard)/_ui/subscriber-item' +import useGetDetailsInformationData from '@/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data' +import SideContainer from '@/app/(dashboard)/strategies/_ui/side-container' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import BackHeader from '@/shared/ui/header/back-header' +import Title from '@/shared/ui/title' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +export type InformationType = { title: TitleType; data: string | number } | InformationModel[] + +const StrategyManagePage = ({ params }: { params: { strategyId: string } }) => { + const strategyNumber = parseInt(params.strategyId) + const { data: detailsInfoData } = useGetDetailsInformationData({ + strategyId: strategyNumber, + }) + const { data: subscribeData } = useGetDetailsInformationData({ + strategyId: strategyNumber, + }) + + const { detailsSideData, detailsInformationData } = detailsInfoData || {} + const { detailsInformationData: subscribeInfo } = subscribeData || {} + const hasDetailsSideData = detailsSideData?.map((data) => { + if (!Array.isArray(data)) return data.data !== undefined + }) + + return ( + <div className={cx('container')}> + <BackHeader label={'나의 전략으로 돌아가기'} /> + <div className={cx('header')}> + <Title label={'나의 전략 관리'} /> + <Button size="small" variant="filled" className={cx('edit-button')}> + 정보 수정하기 + </Button> + </div> + <div className={cx('strategy-container')}> + {detailsInformationData && ( + <DetailsInformation + information={detailsInformationData} + strategyId={strategyNumber} + type="my" + /> + )} + <AnalysisContainer type="my" strategyId={strategyNumber} /> + <SideContainer hasButton={true}> + {subscribeInfo && ( + <SubscriberItem subscribers={subscribeInfo?.subscriptionCount} isMyStrategy={true} /> + )} + {hasDetailsSideData?.[0] && + detailsSideData?.map((data, idx) => ( + <div key={`${data}_${idx}`}> + <DetailsSideItem information={data} strategyId={strategyNumber} /> + </div> + ))} + </SideContainer> + </div> + </div> + ) +} + +export default StrategyManagePage diff --git a/app/(dashboard)/my/strategies/manage/[strategyId]/styles.module.scss b/app/(dashboard)/my/strategies/manage/[strategyId]/styles.module.scss new file mode 100644 index 00000000..77611d8a --- /dev/null +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/styles.module.scss @@ -0,0 +1,20 @@ +.container { + position: relative; +} + +.header { + display: flex; + justify-content: space-between; + + .edit-button { + width: $strategy-sidebar-width; + height: 40px; + } +} + +.strategy-container { + width: calc(100% - $strategy-sidebar-width); + max-width: $max-width; + padding-right: 10px; + margin-top: -10px; +} diff --git a/app/(dashboard)/my/strategies/page.tsx b/app/(dashboard)/my/strategies/page.tsx new file mode 100644 index 00000000..9b5e093c --- /dev/null +++ b/app/(dashboard)/my/strategies/page.tsx @@ -0,0 +1,40 @@ +'use client' + +import { Suspense } from 'react' + +import { useRouter } from 'next/navigation' + +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { Button } from '@/shared/ui/button' +import Title from '@/shared/ui/title' + +import ListHeader from '../../_ui/list-header' +import MyStrategyList from './_ui/my-strategy-list' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const MyStrategiesPage = () => { + const router = useRouter() + const handleClick = () => { + router.push(PATH.ADD_STRATEGY) + } + return ( + <div className={cx('container')}> + <div className={cx('wrapper')}> + <Title label={'나의 전략'} /> + <Button size="small" variant="filled" onClick={handleClick}> + 전략 등록하기 + </Button> + </div> + <ListHeader type="my" /> + <Suspense fallback={<div>Loading...</div>}> + <MyStrategyList /> + </Suspense> + </div> + ) +} + +export default MyStrategiesPage diff --git a/app/(dashboard)/my/strategies/styles.module.scss b/app/(dashboard)/my/strategies/styles.module.scss new file mode 100644 index 00000000..ed53fc58 --- /dev/null +++ b/app/(dashboard)/my/strategies/styles.module.scss @@ -0,0 +1,10 @@ +.container { + margin-top: 80px; +} + +.wrapper { + display: flex; + justify-content: space-between; + align-items: center; + padding-right: 20px; +} diff --git a/app/(dashboard)/strategies/[strategyId]/_api/delete-review.ts b/app/(dashboard)/strategies/[strategyId]/_api/delete-review.ts new file mode 100644 index 00000000..1e3c6b31 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/delete-review.ts @@ -0,0 +1,12 @@ +import axiosInstance from '@/shared/api/axios' + +const deleteReview = async (strategyId: number, reviewId: number) => { + try { + const response = await axiosInstance.delete(`/api/strategies/${strategyId}/reviews/${reviewId}`) + return response.data.isSuccess + } catch (err) { + console.error(err) + } +} + +export default deleteReview diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts new file mode 100644 index 00000000..f21a5a45 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-account-images.ts @@ -0,0 +1,24 @@ +import { ImageDataModel } from '@/app/(dashboard)/_ui/analysis-container/account-content' + +import axiosInstance from '@/shared/api/axios' + +interface ResponseModel { + content: ImageDataModel + first: boolean + last: boolean + page: number + size: number + totalElements: number + totalPages: number +} + +const getAccountImages = async (strategyId: number): Promise<ResponseModel | null | undefined> => { + try { + const response = await axiosInstance.get(`/api/strategies/${strategyId}/account-images`) + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default getAccountImages diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-chart.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-chart.ts new file mode 100644 index 00000000..09551448 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-chart.ts @@ -0,0 +1,20 @@ +import { AnalysisChartOptionsType } from '@/app/(dashboard)/_ui/analysis-container' + +import axiosInstance from '@/shared/api/axios' + +const getAnalysisChart = async ( + strategyId: number, + firstOption: AnalysisChartOptionsType, + secondOption: AnalysisChartOptionsType +) => { + try { + const response = await axiosInstance.get( + `/api/strategies/${strategyId}/analysis?option1=${firstOption}&option2=${secondOption}` + ) + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default getAnalysisChart diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts new file mode 100644 index 00000000..49d80032 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis-download.ts @@ -0,0 +1,22 @@ +import axiosInstance from '@/shared/api/axios' + +import { downloadFile } from './helper-download-file' + +const getAnalysisDownload = async (strategyId: number, type: 'daily' | 'monthly') => { + try { + const response = await axiosInstance.get( + `/api/strategies/${strategyId}/${type}-analysis/download`, + { + responseType: 'blob', + } + ) + const blob = new Blob([response.data], { type: response.headers['content-type'] }) + const contentDisposition = response.headers['content-disposition'] + + downloadFile(blob, contentDisposition, `${type}_분석자료`) + } catch (err) { + console.error(err) + } +} + +export default getAnalysisDownload diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-analysis.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis.ts new file mode 100644 index 00000000..38a8711c --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-analysis.ts @@ -0,0 +1,22 @@ +import { AnalysisTabType } from '@/app/(dashboard)/_ui/analysis-container/tabs-width-table' + +import axiosInstance from '@/shared/api/axios' + +const getAnalysis = async ( + strategyId: number, + type: AnalysisTabType, + page: number, + size: number +) => { + if (type !== 'daily' && type !== 'monthly') return null + try { + const response = await axiosInstance.get( + `/api/strategies/${strategyId}/${type}-analysis?page=${page}&size=${size}` + ) + return response.data.result + } catch (err) { + console.error(err, `${type} 분석 조회 실패`) + } +} + +export default getAnalysis diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-details-information.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-details-information.ts new file mode 100644 index 00000000..7cb39730 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-details-information.ts @@ -0,0 +1,35 @@ +import axiosInstance from '@/shared/api/axios' + +import { InformationType } from '../page' + +const getDetailsInformation = async (strategyId: number) => { + if (!strategyId) return + + try { + const response = await axiosInstance.get(`/api/strategies/${strategyId}/detail`) + const data = await response.data.result + const detailsSideData: InformationType[] = [ + { title: '트레이더', data: data.nickname }, + { title: '최소 투자 금액', data: data.minimumInvestmentAmount }, + { title: '투자 원금', data: data.initialInvestment }, + + [ + { title: 'KP Ratio', data: data.kpRatio }, + { title: 'SM SCORE', data: data.smScore }, + ], + + [ + { title: '최종손익입력일자', data: data.finalProfitLossDate }, + { title: '등록일', data: data.createdAt }, + ], + ] + const detailsInformationData = { + ...data, + } + return { detailsSideData, detailsInformationData } + } catch (err) { + console.error(err) + } +} + +export default getDetailsInformation diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts new file mode 100644 index 00000000..7992f065 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-proposal-download.ts @@ -0,0 +1,19 @@ +import axiosInstance from '@/shared/api/axios' + +import { downloadFile } from './helper-download-file' + +const getProposalDownload = async (strategyId: number, name: string) => { + try { + const response = await axiosInstance.get(`/api/my-strategies/${strategyId}/download-proposal`, { + responseType: 'blob', + }) + const blob = new Blob([response.data], { type: response.headers['content-type'] }) + const contentDisposition = response.headers['content-disposition'] + + downloadFile(blob, contentDisposition, `${name}_제안서`) + } catch (err) { + console.error(err) + } +} + +export default getProposalDownload diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-reviews.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-reviews.ts new file mode 100644 index 00000000..b91c21ef --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-reviews.ts @@ -0,0 +1,18 @@ +import axiosInstance from '@/shared/api/axios' +import { REVIEW_PAGE_COUNT } from '@/shared/constants/count-per-page' + +const getReviews = async (strategyId: number, page: number | undefined) => { + if (!strategyId && !page) return + + try { + const response = await axiosInstance.get( + `/api/strategies/${strategyId}/reviews?userId=1&page=${page}&size=${REVIEW_PAGE_COUNT}` + ) + const data = await response.data.result + return data + } catch (err) { + console.error(err, '리뷰 데이터 가져오기 실패') + } +} + +export default getReviews diff --git a/app/(dashboard)/strategies/[strategyId]/_api/get-statistics.ts b/app/(dashboard)/strategies/[strategyId]/_api/get-statistics.ts new file mode 100644 index 00000000..0a8bd487 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/get-statistics.ts @@ -0,0 +1,12 @@ +import axiosInstance from '@/shared/api/axios' + +const getStatistics = async (strategyId: number) => { + try { + const response = await axiosInstance.get(`/api/strategies/${strategyId}/statistics`) + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default getStatistics diff --git a/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts b/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts new file mode 100644 index 00000000..3cfb1819 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/helper-download-file.ts @@ -0,0 +1,20 @@ +export const downloadFile = ( + blob: Blob, + contentDisposition: string | undefined, + defaultFileName: string +) => { + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + + const fileName = contentDisposition + ? decodeURIComponent(contentDisposition.split('filename=')[1]) + : defaultFileName + + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + window.URL.revokeObjectURL(url) +} diff --git a/app/(dashboard)/strategies/[strategyId]/_api/patch-review.ts b/app/(dashboard)/strategies/[strategyId]/_api/patch-review.ts new file mode 100644 index 00000000..3c218151 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/patch-review.ts @@ -0,0 +1,19 @@ +import axiosInstance from '@/shared/api/axios' + +const patchReview = async ( + strategyId: number, + reviewId: number, + content: { content: string; starRating: number } +) => { + try { + const response = await axiosInstance.patch( + `/api/strategies/${strategyId}/reviews/${reviewId}`, + content + ) + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default patchReview diff --git a/app/(dashboard)/strategies/[strategyId]/_api/post-question.ts b/app/(dashboard)/strategies/[strategyId]/_api/post-question.ts new file mode 100644 index 00000000..9e1ec0fc --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/post-question.ts @@ -0,0 +1,24 @@ +import axiosInstance from '@/shared/api/axios' +import { APIResponseBaseModel } from '@/shared/types/response' + +interface PostQuestionsReturnModel extends APIResponseBaseModel<boolean> { + result: object +} + +const postQuestion = async ( + strategyId: number, + title: string, + content: string +): Promise<PostQuestionsReturnModel | null | undefined> => { + try { + const response = await axiosInstance.post(`/api/strategies/${strategyId}/questions`, { + title, + content, + }) + return response.data + } catch (err) { + throw new Error(err instanceof Error ? err.message : '문의 등록 실패') + } +} + +export default postQuestion diff --git a/app/(dashboard)/strategies/[strategyId]/_api/post-review.ts b/app/(dashboard)/strategies/[strategyId]/_api/post-review.ts new file mode 100644 index 00000000..f6447366 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_api/post-review.ts @@ -0,0 +1,22 @@ +import axios from 'axios' + +import axiosInstance from '@/shared/api/axios' + +import { PostReviewErrModel } from '../_hooks/query/use-post-review' + +const postReview = async ( + strategyId: number, + content: { content: string; starRating: number } +): Promise<boolean | undefined | PostReviewErrModel> => { + try { + const response = await axiosInstance.post(`/api/strategies/${strategyId}/reviews`, content) + return response.data.isSuccess + } catch (err) { + if (axios.isAxiosError(err) && err.response) { + throw err.response.data + } + console.error(err) + } +} + +export default postReview diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts new file mode 100644 index 00000000..1b9db669 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-delete-review.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import deleteReview from '../../_api/delete-review' + +const useDeleteReview = (strategyId: number) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ strategyId, reviewId }: { strategyId: number; reviewId: number }) => + deleteReview(strategyId, reviewId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviews', strategyId] }) + }, + }) +} + +export default useDeleteReview diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts new file mode 100644 index 00000000..d84e32d6 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-account-images.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' + +import getAccountImages from '../../_api/get-account-images' + +const useGetAccountImages = (strategyId: number) => { + return useQuery({ + queryKey: ['account-images', strategyId], + queryFn: () => getAccountImages(strategyId), + }) +} + +export default useGetAccountImages diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts new file mode 100644 index 00000000..4f913c85 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-chart.ts @@ -0,0 +1,19 @@ +import { AnalysisChartOptionsType } from '@/app/(dashboard)/_ui/analysis-container' +import { useQuery } from '@tanstack/react-query' + +import getAnalysisChart from '../../_api/get-analysis-chart' + +interface Props { + strategyId: number + firstOption: AnalysisChartOptionsType + secondOption: AnalysisChartOptionsType +} + +const useGetAnalysisChart = ({ strategyId, firstOption, secondOption }: Props) => { + return useQuery({ + queryKey: ['analysisChart', strategyId, firstOption, secondOption], + queryFn: () => getAnalysisChart(strategyId, firstOption, secondOption), + }) +} + +export default useGetAnalysisChart diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-download.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-download.ts new file mode 100644 index 00000000..c2c669fb --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis-download.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query' + +import getAnalysisDownload from '../../_api/get-analysis-download' + +const useGetAnalysisDownload = () => { + return useMutation({ + mutationFn: ({ strategyId, type }: { strategyId: number; type: 'daily' | 'monthly' }) => + getAnalysisDownload(strategyId, type), + }) +} + +export default useGetAnalysisDownload diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts new file mode 100644 index 00000000..44208c6d --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-analysis.ts @@ -0,0 +1,13 @@ +import { AnalysisTabType } from '@/app/(dashboard)/_ui/analysis-container/tabs-width-table' +import { useQuery } from '@tanstack/react-query' + +import getAnalysis from '../../_api/get-analysis' + +const useGetAnalysis = (strategyId: number, type: AnalysisTabType, page: number, size: number) => { + return useQuery({ + queryKey: ['analysis', strategyId, type, page], + queryFn: () => getAnalysis(strategyId, type, page, size), + }) +} + +export default useGetAnalysis diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts new file mode 100644 index 00000000..87f4d668 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-details-information-data.ts @@ -0,0 +1,24 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query' + +import { StrategyDetailsInformationModel } from '@/shared/types/strategy-data' + +import getDetailsInformation from '../../_api/get-details-information' +import { InformationType } from '../../page' + +interface Props { + strategyId: number +} + +const useGetDetailsInformationData = ({ + strategyId, +}: Props): UseQueryResult<{ + detailsSideData: InformationType[] + detailsInformationData: StrategyDetailsInformationModel +}> => { + return useQuery({ + queryKey: ['strategyDetails', strategyId], + queryFn: () => getDetailsInformation(strategyId), + }) +} + +export default useGetDetailsInformationData diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-proposal-download.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-proposal-download.ts new file mode 100644 index 00000000..b14c58a2 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-proposal-download.ts @@ -0,0 +1,12 @@ +import { useMutation } from '@tanstack/react-query' + +import getProposalDownload from '../../_api/get-proposal-download' + +const useGetProposalDownload = () => { + return useMutation({ + mutationFn: ({ strategyId, name }: { strategyId: number; name: string }) => + getProposalDownload(strategyId, name), + }) +} + +export default useGetProposalDownload diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts new file mode 100644 index 00000000..dec69f32 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-reviews-data.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' + +import getReviews from '../../_api/get-reviews' + +interface Props { + strategyId: number + page: number | undefined +} + +const useGetReviewsData = ({ strategyId, page }: Props) => { + return useQuery({ + queryKey: ['reviews', strategyId], + queryFn: () => getReviews(strategyId, page), + }) +} + +export default useGetReviewsData diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts new file mode 100644 index 00000000..b633062d --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-get-statistics.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' + +import getStatistics from '../../_api/get-statistics' + +const useGetStatistics = (strategyId: number) => { + return useQuery({ + queryKey: ['statistics', strategyId], + queryFn: () => getStatistics(strategyId), + }) +} + +export default useGetStatistics diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts new file mode 100644 index 00000000..cc4f9b3a --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-patch-review.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import patchReview from '../../_api/patch-review' + +const usePatchReview = (strategyId: number) => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ + strategyId, + reviewId, + content, + }: { + strategyId: number + reviewId: number + content: { content: string; starRating: number } + }) => patchReview(strategyId, reviewId, content), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviews', strategyId] }) + }, + }) +} + +export default usePatchReview diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts new file mode 100644 index 00000000..a340a14f --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-question.ts @@ -0,0 +1,18 @@ +import { useMutation } from '@tanstack/react-query' + +import postQuestions from '../../_api/post-question' + +interface Props { + strategyId: number + title: string + content: string +} + +const usePostQuestion = () => { + return useMutation({ + mutationFn: ({ strategyId, title, content }: Props) => + postQuestions(strategyId, title, content), + }) +} + +export default usePostQuestion diff --git a/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts new file mode 100644 index 00000000..668e16b6 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_hooks/query/use-post-review.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import postReview from '../../_api/post-review' + +export interface PostReviewErrModel { + isSuccess: boolean + message: string + code: number +} + +const usePostReview = (strategyId: number) => { + const queryClient = useQueryClient() + return useMutation< + boolean | undefined | PostReviewErrModel, + PostReviewErrModel, + { strategyId: number; content: { content: string; starRating: number } } + >({ + mutationFn: ({ + strategyId, + content, + }: { + strategyId: number + content: { content: string; starRating: number } + }) => postReview(strategyId, content), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['reviews', strategyId] }) + }, + }) +} + +export default usePostReview diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/details-skeleton/index.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/details-skeleton/index.tsx new file mode 100644 index 00000000..4f30f76a --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/details-skeleton/index.tsx @@ -0,0 +1,58 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const DetailsLoading = () => { + return ( + <div className={cx('container')}> + <div className={cx('first-wrapper')}> + <div className={cx('left')}> + {Array.from({ length: 3 }, (_, idx) => ( + <div key={idx}></div> + ))} + </div> + <div className={cx('right')}> + {Array.from({ length: 3 }, (_, idx) => ( + <div key={idx}> + <div></div> + <div></div> + </div> + ))} + </div> + </div> + <div className={cx('second-wrapper')}> + <p>전략 상세 소개</p> + <div></div> + </div> + <div className={cx('third-wrapper')}> + {Array.from({ length: 5 }, (_, idx) => ( + <div key={idx}> + <div></div> + <div></div> + </div> + ))} + </div> + <div className={cx('fourth-wrapper')}> + <p>분석</p> + <div className={cx('chart')}></div> + <div className={cx('tab')}> + {Array.from({ length: 4 }, (_, idx) => ( + <div key={idx}></div> + ))} + </div> + <div className={cx('analysis')}> + {Array.from({ length: 4 }, (_, idx) => ( + <div key={idx}> + <div></div> + <div></div> + </div> + ))} + </div> + </div> + </div> + ) +} + +export default DetailsLoading diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/details-skeleton/styles.module.scss b/app/(dashboard)/strategies/[strategyId]/_ui/details-skeleton/styles.module.scss new file mode 100644 index 00000000..91c5aecb --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/details-skeleton/styles.module.scss @@ -0,0 +1,167 @@ +@mixin line-small { + width: 70px; + height: 17px; +} + +@mixin line-medium { + width: 170px; + height: 25px; +} + +@mixin line-large { + width: 236px; + height: 33px; +} + +@mixin box-small { + width: 100px; + height: 33px; +} + +@mixin box-medium { + width: 168px; + height: 33px; +} + +@mixin box-large { + width: 417px; + height: 98px; +} + +@mixin flex-column { + display: flex; + flex-direction: column; +} + +.container { + margin-top: 20px; + .first-wrapper { + display: grid; + grid-template-columns: 0.5fr 1.5fr; + height: 132px; + gap: 10px; + margin-bottom: 20px; + .left { + @include skeleton; + @include flex-column; + padding: 10px; + * { + margin-bottom: 15px; + } + :first-child { + @include line-small; + } + :nth-child(2) { + @include line-medium; + } + :nth-child(3) { + @include line-large; + } + } + .right { + @include skeleton; + display: grid; + padding: 15px; + grid-template-columns: repeat(3, 1fr); + * { + margin-top: 10px; + } + :first-child { + @include skeleton; + @include flex-column; + :first-child { + @include box-small; + } + :nth-child(2) { + @include box-medium; + } + } + :nth-child(2) { + @include skeleton; + @include flex-column; + :first-child { + @include box-small; + } + :nth-child(2) { + @include box-medium; + } + } + :nth-child(3) { + @include skeleton; + @include flex-column; + :first-child { + @include box-small; + } + :nth-child(2) { + @include box-medium; + } + } + } + } + .second-wrapper { + height: 160px; + @include skeleton; + padding: 20px 20px 40px; + margin-bottom: 20px; + p { + margin-bottom: 10px; + } + div { + width: 100%; + height: 72px; + } + } + .third-wrapper { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; + height: 108px; + margin-bottom: 20px; + div { + @include skeleton; + @include flex-column; + align-items: center; + padding: 10px; + div { + @include box-small; + margin: 5px 0; + } + } + } + .fourth-wrapper { + @include skeleton; + padding: 20px; + p { + margin-bottom: 10px; + @include typo-h4; + color: $color-gray-600; + } + .chart { + height: 367px; + margin-bottom: 30px; + } + .tab { + display: flex; + background-color: $color-gray-200; + margin-bottom: 30px; + div { + @include box-small; + margin-right: 10px; + } + } + .analysis { + background-color: $color-gray-200; + padding: 10px; + div { + display: flex; + justify-content: space-between; + @include skeleton; + div { + height: 131px; + width: 90%; + margin: 10px; + } + } + } + } +} diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx new file mode 100644 index 00000000..2aaf024f --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/add-review.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useRef, useState } from 'react' + +import classNames from 'classnames/bind' + +import useModal from '@/shared/hooks/custom/use-modal' +import { Button } from '@/shared/ui/button' +import { ErrorMessage } from '@/shared/ui/error-message' +import { Textarea } from '@/shared/ui/textarea' + +import usePatchReview from '../../_hooks/query/use-patch-review' +import usePostReview from '../../_hooks/query/use-post-review' +import StarRating from '../star-rating/index' +import ReviewGuideModal from './review-guide-modal' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number + reviewId?: number + isEditable?: boolean + content?: string + starRating?: number + onCancel?: () => void +} + +const AddReview = ({ + strategyId, + reviewId, + isEditable = false, + content, + starRating, + onCancel, +}: Props) => { + const [starRatingValue, setStarRatingValue] = useState(starRating ?? 0) + const [isEmpty, setIsEmpty] = useState(false) + const { isModalOpen, openModal, closeModal } = useModal() + const textareaRef = useRef<HTMLTextAreaElement>(null) + const { mutate: postMutate, isError } = usePostReview(strategyId) + const { mutate: patchMutate } = usePatchReview(strategyId) + + const handleFetchReview = (type: 'add' | 'edit') => { + if (textareaRef.current) { + const review = textareaRef.current.value.trim() + if (review && starRatingValue > 0) { + const content = { + content: review, + starRating: starRatingValue, + } + if (type === 'add') { + postMutate( + { strategyId, content }, + { + onSuccess: () => { + if (textareaRef.current) { + textareaRef.current.value = '' + setStarRatingValue(0) + } + }, + onError: (err) => { + if (err && !err.isSuccess) { + openModal() + } + }, + } + ) + } else if (type === 'edit' && reviewId && onCancel) { + patchMutate( + { strategyId, reviewId, content }, + { + onSuccess: () => { + onCancel() + }, + } + ) + } + } else { + setIsEmpty(true) + } + } + } + + const handleStarRating = (idx: number) => setStarRatingValue(idx + 1) + + return ( + <div className={cx('add-review-wrapper', { edit: isEditable })}> + <div className={cx('textarea-wrapper')}> + <Textarea + defaultValue={content && content} + rows={5} + placeholder="리뷰를 작성해주세요." + ref={textareaRef} + /> + {isEmpty && <ErrorMessage errorMessage="리뷰 작성 또는 별점을 선택해주세요." />} + </div> + <div className={cx('add-button-wrapper', { edit: isEditable })}> + {!isEditable && <p className={cx('strategy')}>전략이 어땠나요?</p>} + <StarRating starRatingValue={starRatingValue} onRatingChange={handleStarRating} /> + {isEditable ? ( + <div className={cx('button-wrapper', { edit: isEditable })}> + <button onClick={() => handleFetchReview('edit')}>저장</button> + <button onClick={onCancel}>취소</button> + </div> + ) : ( + <Button + variant="filled" + size="small" + className={cx('review-button')} + onClick={() => handleFetchReview('add')} + > + 리뷰 등록하기 + </Button> + )} + </div> + <ReviewGuideModal isModalOpen={isModalOpen} isErr={isError} onCloseModal={closeModal} /> + </div> + ) +} + +export default AddReview diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx new file mode 100644 index 00000000..7d3e881f --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/index.tsx @@ -0,0 +1,50 @@ +'use client' + +import { useState } from 'react' + +import classNames from 'classnames/bind' + +import TotalStar from '@/shared/ui/total-star' + +import useGetReviewsData from '../../_hooks/query/use-get-reviews-data' +import AddReview from './add-review' +import ReviewList from './review-list' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + strategyId: number +} + +const ReviewContainer = ({ strategyId }: Props) => { + const [currentPage, setCurrentPage] = useState(1) + const { data: reviewData } = useGetReviewsData({ strategyId, page: currentPage }) + + return ( + <div className={cx('container')}> + <div className={cx('title-wrapper')}> + <p className={cx('review-title')}>리뷰</p> + <TotalStar + size="medium" + averageRating={reviewData?.averageRating} + totalElements={reviewData?.reviews.totalElements} + /> + </div> + <AddReview strategyId={strategyId} /> + {reviewData && reviewData.reviews.content.length !== 0 ? ( + <ReviewList + strategyId={strategyId} + reviews={reviewData.reviews.content} + totalReview={reviewData.reviews.totalElements} + currentPage={currentPage} + setCurrentPage={setCurrentPage} + /> + ) : ( + <div className={cx('no-review')}>등록된 리뷰가 없습니다.</div> + )} + </div> + ) +} + +export default ReviewContainer diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-guide-modal.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-guide-modal.tsx new file mode 100644 index 00000000..92acec9e --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-guide-modal.tsx @@ -0,0 +1,48 @@ +'use client' + +import React from 'react' + +import { ModalAlertIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import Modal from '@/shared/ui/modal' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isModalOpen: boolean + isErr: boolean + onCloseModal: () => void + onChange?: () => void +} + +const ReviewGuideModal = ({ isModalOpen, isErr, onCloseModal, onChange }: Props) => { + return ( + <Modal isOpen={isModalOpen} icon={ModalAlertIcon}> + <span className={cx('message')}> + {isErr ? ( + <> + 이미 등록된 리뷰가 있습니다. <br /> 리뷰는 한 번만 등록 가능합니다. + </> + ) : ( + <>리뷰를 삭제하시겠습니까?</> + )} + </span> + {isErr && !onChange ? ( + <Button onClick={onCloseModal}>닫기</Button> + ) : ( + <div className={cx('two-button')}> + <Button onClick={onCloseModal}>아니오</Button> + <Button onClick={onChange} variant="filled" className={cx('button')}> + 예 + </Button> + </div> + )} + </Modal> + ) +} + +export default ReviewGuideModal diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx new file mode 100644 index 00000000..d9111cfd --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-item.tsx @@ -0,0 +1,100 @@ +'use client' + +import { useState } from 'react' + +import classNames from 'classnames/bind' + +import useModal from '@/shared/hooks/custom/use-modal' +import Avatar from '@/shared/ui/avatar' + +import useDeleteReview from '../../_hooks/query/use-delete-review' +import StarRating from '../star-rating/index' +import AddReview from './add-review' +import ReviewGuideModal from './review-guide-modal' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + reviewId: number + strategyId: number + nickname: string + content: string + profileImage?: string + createdAt: string + starRating: number + isReviewer: boolean + isAdmin: boolean +} + +const ReviewItem = ({ + reviewId, + strategyId, + nickname, + profileImage, + createdAt, + starRating, + content, + isReviewer, + isAdmin, +}: Props) => { + const [isEditable, setIsEditable] = useState(false) + const { isModalOpen, openModal, closeModal } = useModal() + const { mutate } = useDeleteReview(strategyId) + + const handleDelete = () => { + mutate( + { strategyId, reviewId }, + { + onSuccess: () => { + closeModal() + }, + } + ) + } + + const editedCreatedAt = createdAt.slice(0, -3) + + return ( + <li className={cx('review-item')}> + <div className={cx('information-wrapper')}> + <div className={cx('reviewer')}> + <Avatar src={profileImage} /> + <p className={cx('nickname')}>{nickname}</p> + <span>|</span> + <span>{editedCreatedAt}</span> + {!isEditable && <StarRating starRating={starRating} />} + </div> + <div className={cx('button-wrapper')}> + {isReviewer && !isEditable && ( + <> + <button onClick={() => setIsEditable(true)}>수정</button> + <button onClick={openModal}>삭제</button> + </> + )} + {!isReviewer && isAdmin && <button>삭제</button>} + </div> + </div> + {isEditable ? ( + <AddReview + strategyId={strategyId} + reviewId={reviewId} + isEditable={isEditable} + content={content} + starRating={starRating} + onCancel={() => setIsEditable(false)} + /> + ) : ( + <div className={cx('content')}>{content}</div> + )} + <ReviewGuideModal + isModalOpen={isModalOpen} + isErr={false} + onCloseModal={closeModal} + onChange={handleDelete} + /> + </li> + ) +} + +export default ReviewItem diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx new file mode 100644 index 00000000..1e05cef3 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/review-list.tsx @@ -0,0 +1,60 @@ +import classNames from 'classnames/bind' + +import { REVIEW_PAGE_COUNT } from '@/shared/constants/count-per-page' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import Pagination from '@/shared/ui/pagination' + +import ReviewItem from './review-item' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface ReviewContentModel { + reviewId: number + nickname: string + content: string + imageUrl?: string + createdAt: string + starRating: number +} + +interface Props { + strategyId: number + reviews: ReviewContentModel[] + totalReview: number + currentPage: number + setCurrentPage: (page: number) => void +} + +const ReviewList = ({ strategyId, reviews, totalReview, currentPage, setCurrentPage }: Props) => { + const handlePageChange = (page: number) => setCurrentPage(page) + const user = useAuthStore((state) => state.user) + + return ( + <> + <ul className={cx('review-list')}> + {reviews.map((review) => ( + <ReviewItem + key={review.reviewId} + reviewId={review.reviewId} + strategyId={strategyId} + nickname={review.nickname} + profileImage={review.imageUrl} + createdAt={review.createdAt} + starRating={review.starRating} + content={review.content} + isReviewer={user?.nickname === review.nickname} + isAdmin={user?.role.includes('admin') ?? false} + /> + ))} + </ul> + <Pagination + currentPage={currentPage} + maxPage={Math.ceil(totalReview / REVIEW_PAGE_COUNT)} + onPageChange={handlePageChange} + /> + </> + ) +} + +export default ReviewList diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss new file mode 100644 index 00000000..02376d20 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/review-container/styles.module.scss @@ -0,0 +1,116 @@ +.container { + width: 100%; + padding: 25px; + margin: 20px 0; + background-color: $color-white; + border-radius: 5px; + .title-wrapper { + display: flex; + align-items: end; + .review-title { + @include typo-h4; + color: $color-gray-600; + margin-right: 4px; + } + } + .no-review { + width: 100%; + display: flex; + justify-content: center; + color: $color-gray-600; + margin: 40px 0; + @include typo-b2; + } +} + +.add-review-wrapper { + width: 100%; + height: 107px; + display: flex; + justify-content: space-between; + gap: 20px; + margin: 30px 0 40px; + .textarea-wrapper { + width: 100%; + height: 100%; + } + .add-button-wrapper { + width: 120px; + & p { + font-size: $text-c1; + font-weight: $text-semibold; + color: $color-gray-600; + margin-left: 4px; + } + .review-button { + margin: 20px 0 0 4px; + } + &.edit { + padding: 20px 0; + } + } + &.edit { + margin: 10px 0; + } +} + +.review-list { + margin-bottom: 40px; +} + +.review-item { + width: 100%; + margin-bottom: 5px; + border-bottom: 1px solid $color-gray-400; + .information-wrapper { + display: flex; + justify-content: space-between; + .reviewer { + display: flex; + align-items: center; + p { + @include typo-b2; + margin: 0 10px 0 5px; + } + span { + color: $color-gray-500; + font-weight: $text-normal; + margin-right: 5px; + } + } + } + .content { + margin: 10px 0; + font-size: 18px; + font-weight: $text-medium; + } +} + +.button-wrapper { + button { + font-weight: $text-bold; + font-size: $text-c1; + color: $color-gray-500; + background-color: transparent; + margin-left: 20px; + } + &.edit { + margin-top: 20px; + padding-left: 10px; + } +} + +.message { + @include typo-b1; + text-align: center; + color: $color-gray-800; + margin-bottom: 30px; +} + +.two-button { + display: flex; + gap: 10px; + & .button { + width: 90px; + } +} diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/index.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/index.tsx new file mode 100644 index 00000000..642a0208 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/index.tsx @@ -0,0 +1,35 @@ +'use client' + +import classNames from 'classnames/bind' + +import Star from '@/shared/ui/total-star/star-icon' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + starRating?: number + starRatingValue?: number + onRatingChange?: (value: number) => void +} + +const StarRating = ({ starRating, starRatingValue, onRatingChange }: Props) => { + return ( + <div className={cx('container')}> + {starRating + ? [...Array(Math.floor(starRating))].map((_, idx) => <Star key={idx} size="small" />) + : [...Array(5)].map((_, idx) => ( + <button + key={idx} + className={cx('click-star', idx < (starRatingValue || 0) && 'onColor')} + onClick={() => onRatingChange && onRatingChange(idx)} + > + <Star size="large" /> + </button> + ))} + </div> + ) +} + +export default StarRating diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/star-rating.stories.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/star-rating.stories.tsx new file mode 100644 index 00000000..cc64e19e --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/star-rating.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react' + +import type { Meta, StoryFn } from '@storybook/react' + +import StarRating from './index' + +const meta: Meta = { + title: 'components/StarRating', + component: StarRating, + tags: ['autodocs'], +} + +const starRating: StoryFn<{ starRating: number | undefined }> = ({ starRating }) => { + const [starRatingValue, setStarRatingValue] = useState(0) + const handleStarRating = (idx: number) => setStarRatingValue(idx + 1) + return ( + <StarRating + starRating={starRating} + starRatingValue={starRatingValue} + onRatingChange={handleStarRating} + /> + ) +} + +export const Rated = starRating.bind({}) +Rated.args = { + starRating: 5, +} + +export const Rating = starRating.bind({}) +Rating.args = { + starRating: undefined, +} + +export default meta diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/styles.module.scss b/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/styles.module.scss new file mode 100644 index 00000000..d7747a7f --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/_ui/star-rating/styles.module.scss @@ -0,0 +1,13 @@ +.container { + display: flex; + padding: 0; + color: $color-yellow; + .click-star { + margin: -1px; + background-color: transparent; + color: #e3e3e3; + &.onColor { + color: $color-yellow; + } + } +} diff --git a/app/(dashboard)/strategies/[strategyId]/page.tsx b/app/(dashboard)/strategies/[strategyId]/page.tsx new file mode 100644 index 00000000..d928a094 --- /dev/null +++ b/app/(dashboard)/strategies/[strategyId]/page.tsx @@ -0,0 +1,112 @@ +'use client' + +import React, { Suspense } from 'react' + +import dynamic from 'next/dynamic' + +import useModal from '@/shared/hooks/custom/use-modal' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import BackHeader from '@/shared/ui/header/back-header' +import SubscribeCheckModal from '@/shared/ui/modal/subscribe-check-modal' +import Title from '@/shared/ui/title' + +import { InformationModel, TitleType } from '../../_ui/details-side-item' +import SideSkeleton from '../../_ui/details-side-item/side-skeleton' +import useGetSubscribe from '../_hooks/query/use-get-subscribe' +import SideContainer from '../_ui/side-container' +import useGetDetailsInformationData from './_hooks/query/use-get-details-information-data' +import DetailsLoading from './_ui/details-skeleton' + +const DetailsInformation = React.lazy(() => import('../../_ui/details-information')) +const AnalysisContainer = React.lazy(() => import('@/app/(dashboard)/_ui/analysis-container')) +const ReviewContainer = React.lazy(() => import('./_ui/review-container')) +const SubscriberItem = React.lazy(() => import('@/app/(dashboard)/_ui/subscriber-item')) +const DetailsSideItem = React.lazy(() => import('../../_ui/details-side-item')) + +const DynamicSkeleton = dynamic(() => import('./_ui/details-skeleton'), { + loading: () => <DetailsLoading />, + ssr: false, +}) + +const DynamicSideSkeleton = dynamic(() => import('../../_ui/details-side-item/side-skeleton'), { + loading: () => <SideSkeleton />, + ssr: false, +}) + +export type InformationType = { title: TitleType; data: string | number } | InformationModel[] + +const StrategyDetailPage = ({ params }: { params: { strategyId: string } }) => { + const strategyNumber = parseInt(params.strategyId) + const user = useAuthStore((state) => state.user) + const { isModalOpen, openModal, closeModal } = useModal() + const { mutate } = useGetSubscribe() + const { refetch, data } = useGetDetailsInformationData({ + strategyId: strategyNumber, + }) + + const { detailsSideData, detailsInformationData: information } = data || {} + + const handleSubscribe = () => { + mutate(strategyNumber, { + onSuccess: () => { + closeModal() + refetch() + }, + }) + } + + const hasDetailsSideData = detailsSideData?.map((data) => { + if (!Array.isArray(data)) return data.data !== undefined + }) + + return ( + <> + <BackHeader label={'목록으로 돌아가기'} /> + <Title label={'전략 상세보기'} /> + <Suspense fallback={<DynamicSkeleton />}> + {information && ( + <> + <DetailsInformation information={information} strategyId={strategyNumber} /> + <AnalysisContainer strategyId={strategyNumber} /> + <ReviewContainer strategyId={strategyNumber} /> + </> + )} + </Suspense> + <SideContainer> + <Suspense fallback={<DynamicSideSkeleton />}> + {information && ( + <> + <SubscriberItem + isMyStrategy={user?.nickname === information.nickname} + isSubscribed={information?.isSubscribed} + subscribers={information?.subscriptionCount} + onClick={openModal} + /> + {hasDetailsSideData?.[0] && + detailsSideData?.map((data, idx) => ( + <div key={`${data}_${idx}`}> + <DetailsSideItem + strategyId={strategyNumber} + information={data} + isMyStrategy={user?.nickname === information.nickname} + strategyName={information.strategyName} + /> + </div> + ))} + </> + )} + </Suspense> + </SideContainer> + {information && ( + <SubscribeCheckModal + isSubscribing={information?.isSubscribed} + isModalOpen={isModalOpen} + onCloseModal={closeModal} + onChange={handleSubscribe} + /> + )} + </> + ) +} + +export default StrategyDetailPage diff --git a/app/(dashboard)/strategies/_api/get-strategies-search.ts b/app/(dashboard)/strategies/_api/get-strategies-search.ts new file mode 100644 index 00000000..2646df0d --- /dev/null +++ b/app/(dashboard)/strategies/_api/get-strategies-search.ts @@ -0,0 +1,12 @@ +import axiosInstance from '@/shared/api/axios' + +const getStrategiesSearch = async () => { + try { + const response = await axiosInstance.get('api/strategies/search') + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default getStrategiesSearch diff --git a/app/(dashboard)/strategies/_api/get-subscribe.ts b/app/(dashboard)/strategies/_api/get-subscribe.ts new file mode 100644 index 00000000..8a243a16 --- /dev/null +++ b/app/(dashboard)/strategies/_api/get-subscribe.ts @@ -0,0 +1,13 @@ +import axiosInstance from '@/shared/api/axios' + +const getSubscribe = async (strategyId: number) => { + try { + const response = await axiosInstance.get(`/api/strategies/${strategyId}/subscribe`) + return response.data.isSuccess + } catch (err) { + console.error(err) + throw err + } +} + +export default getSubscribe diff --git a/app/(dashboard)/strategies/_api/post-strategies.ts b/app/(dashboard)/strategies/_api/post-strategies.ts new file mode 100644 index 00000000..3cc5563b --- /dev/null +++ b/app/(dashboard)/strategies/_api/post-strategies.ts @@ -0,0 +1,17 @@ +import axiosInstance from '@/shared/api/axios' + +import { SearchTermsModel } from '../_ui/search-bar/_type/search' + +const postStrategies = async (page: number, size: number, searchTerms: SearchTermsModel) => { + try { + const response = await axiosInstance.post( + `/api/strategies/search?page=${page}&size=${size}`, + searchTerms + ) + return response.data.result + } catch (err) { + console.error(err) + } +} + +export default postStrategies diff --git a/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts b/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts new file mode 100644 index 00000000..77574220 --- /dev/null +++ b/app/(dashboard)/strategies/_hooks/query/use-get-strategies-search.ts @@ -0,0 +1,12 @@ +import { useQuery } from '@tanstack/react-query' + +import getStrategiesSearch from '../../_api/get-strategies-search' + +const useGetStrategiesSearch = () => { + return useQuery({ + queryKey: ['strategiesSearch'], + queryFn: getStrategiesSearch, + }) +} + +export default useGetStrategiesSearch diff --git a/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts b/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts new file mode 100644 index 00000000..c50532f4 --- /dev/null +++ b/app/(dashboard)/strategies/_hooks/query/use-get-subscribe.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query' + +import getSubscribe from '../../_api/get-subscribe' + +const useGetSubscribe = () => { + return useMutation({ + mutationFn: (strategyId: number) => getSubscribe(strategyId), + }) +} + +export default useGetSubscribe diff --git a/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts new file mode 100644 index 00000000..c9297832 --- /dev/null +++ b/app/(dashboard)/strategies/_hooks/query/use-post-strategies.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query' + +import postStrategies from '../../_api/post-strategies' +import { SearchTermsModel } from '../../_ui/search-bar/_type/search' + +const usePostStrategies = ({ + page, + size, + searchTerms, +}: { + page: number + size: number + searchTerms: SearchTermsModel +}) => { + return useQuery({ + queryKey: ['strategies'], + queryFn: () => postStrategies(page, size, searchTerms), + }) +} + +export default usePostStrategies diff --git a/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-button.ts b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-button.ts new file mode 100644 index 00000000..bf65d5df --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-button.ts @@ -0,0 +1,18 @@ +import { useRef, useState } from 'react' + +export interface ButtonIdStateModel { + [key: string]: boolean +} + +const useAccordionButton = () => { + const [openIds, setOpenIds] = useState<ButtonIdStateModel | null>(null) + const panelRef = useRef<HTMLDivElement>(null) + + const handleButtonIds = (id: string, isOpen: boolean) => { + setOpenIds((prev) => ({ ...prev, [id]: isOpen })) + } + + return { panelRef, openIds, handleButtonIds } +} + +export default useAccordionButton diff --git a/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-context.ts b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-context.ts new file mode 100644 index 00000000..49707f96 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_hooks/use-accordion-context.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react' + +import { AccordionContext } from '../accordion-container' + +export const useAccordionContext = () => { + const context = useContext(AccordionContext) + if (!context) { + throw new Error('검색 메뉴 로드 실패') + } + return context +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts b/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts new file mode 100644 index 00000000..8f78db4c --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_store/use-searching-item-store.ts @@ -0,0 +1,102 @@ +import { create } from 'zustand' + +import { AlgorithmItemType, RangeModel, SearchTermsModel } from '../_type/search' +import { isRangeModel } from '../_utils/type-validate' +import { PANEL_MAPPING } from '../panel-mapping' + +interface StateModel { + searchTerms: SearchTermsModel + errOptions: (keyof SearchTermsModel)[] | null +} + +interface ActionModel { + setAlgorithm: (algorithm: AlgorithmItemType) => void + setPanelItem: (key: keyof SearchTermsModel, item: string) => void + setRangeValue: (key: keyof SearchTermsModel, type: keyof RangeModel, value: number) => void + setSearchWord: (searchWord: string) => void + resetState: () => void + validateRangeValues: () => void +} + +interface ActionsModel { + actions: ActionModel +} + +const initialState = { + searchWord: null, + tradeTypeNames: null, + operationCycles: null, + stockTypeNames: null, + durations: null, + profitRanges: null, + principalRange: null, + mddRange: null, + smScoreRange: null, + algorithmType: null, +} + +const useSearchingItemStore = create<StateModel & ActionsModel>((set, get) => ({ + searchTerms: { + ...initialState, + }, + errOptions: null, + + actions: { + setAlgorithm: (algorithm) => + set((state) => ({ + searchTerms: { ...state.searchTerms, algorithmType: algorithm }, + })), + + setPanelItem: (key, item) => + set((state) => { + const mappingItem = PANEL_MAPPING[key]?.[item] || item + const currentItems = state.searchTerms[key] + if (Array.isArray(currentItems)) { + const updatedItems = currentItems.includes(mappingItem) + ? currentItems.filter((i) => i !== mappingItem) + : [...currentItems, mappingItem] + return { searchTerms: { ...state.searchTerms, [key]: [...updatedItems] } } + } + return { searchTerms: { ...state.searchTerms, [key]: [mappingItem] } } + }), + + setRangeValue: (key, type, value) => + set((state) => ({ + searchTerms: { + ...state.searchTerms, + [key]: { ...(state.searchTerms[key] as RangeModel), [type]: value }, + }, + })), + + setSearchWord: (searchWord) => + set((state) => ({ + searchTerms: { + ...state.searchTerms, + searchWord, + }, + })), + + resetState: () => { + set(() => ({ searchTerms: { ...initialState }, errOptions: null })) + }, + + validateRangeValues: () => { + const { searchTerms } = get() + const rangeOptions: (keyof SearchTermsModel)[] = [ + 'principalRange', + 'mddRange', + 'smScoreRange', + ] + const errOptions = rangeOptions.filter((option) => { + const value = searchTerms[option] + if (value !== null && isRangeModel(value)) { + return value.min > value.max + } + return false + }) + set({ errOptions }) + }, + }, +})) + +export default useSearchingItemStore diff --git a/app/(dashboard)/strategies/_ui/search-bar/_type/search.ts b/app/(dashboard)/strategies/_ui/search-bar/_type/search.ts new file mode 100644 index 00000000..3bacc688 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_type/search.ts @@ -0,0 +1,19 @@ +export type AlgorithmItemType = 'EFFICIENT_STRATEGY' | 'ATTACK_STRATEGY' | 'DEFENSIVE_STRATE' + +export interface SearchTermsModel { + searchWord: string | null + tradeTypeNames: string[] | null + operationCycles: string[] | null + stockTypeNames: string[] | null + durations: string[] | null + profitRanges: string[] | null + principalRange: RangeModel | null + mddRange: RangeModel | null + smScoreRange: RangeModel | null + algorithmType: AlgorithmItemType | null +} + +export interface RangeModel { + min: number + max: number +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/_utils/type-validate.ts b/app/(dashboard)/strategies/_ui/search-bar/_utils/type-validate.ts new file mode 100644 index 00000000..7f71ad9f --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/_utils/type-validate.ts @@ -0,0 +1,12 @@ +import { RangeModel } from '../_type/search' + +export const isRangeModel = (value: unknown): value is RangeModel => { + return ( + typeof value === 'object' && + value !== null && + 'min' in value && + 'max' in value && + typeof (value as RangeModel).min === 'number' && + typeof (value as RangeModel).max === 'number' + ) +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx new file mode 100644 index 00000000..78752772 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-button.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useContext } from 'react' + +import { CloseIcon, OpenIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { SearchTermsModel } from './_type/search' +import { AccordionContext } from './accordion-container' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + optionId: keyof SearchTermsModel + title: string + size?: number +} + +const AccordionButton = ({ optionId, title, size }: Props) => { + const { openIds, handleButtonIds } = useContext(AccordionContext) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + + const hasOpenId = openIds?.[optionId] + const clickedValue = searchTerms[optionId] + + return ( + <div className={cx('accordion-button', { active: hasOpenId })}> + <button onClick={() => handleButtonIds(optionId, !hasOpenId)}> + <p> + {title} + {Array.isArray(clickedValue) && + clickedValue?.length !== 0 && + (clickedValue.length !== size ? ( + <span> + ({clickedValue.length}/{size}) + </span> + ) : ( + <span>(All)</span> + ))} + </p> + {hasOpenId ? <CloseIcon /> : <OpenIcon />} + </button> + </div> + ) +} + +export default AccordionButton diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx new file mode 100644 index 00000000..7c5c4acf --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-container.tsx @@ -0,0 +1,46 @@ +'use client' + +import { createContext } from 'react' + +import useAccordionButton, { ButtonIdStateModel } from './_hooks/use-accordion-button' +import { SearchTermsModel } from './_type/search' +import AccordionButton from './accordion-button' +import AccordionPanel from './accordion-panel' + +interface AccordionContextModel { + panelRef: React.RefObject<HTMLDivElement> + openIds: ButtonIdStateModel | null + handleButtonIds: (id: string, open: boolean) => void +} + +const initialState: AccordionContextModel = { + panelRef: { current: null }, + openIds: null, + handleButtonIds: () => {}, +} + +export const AccordionContext = createContext(initialState) + +interface Props { + optionId: keyof SearchTermsModel + title: string + panels?: string[] +} + +const AccordionContainer = ({ optionId, title, panels }: Props) => { + const { openIds, panelRef, handleButtonIds } = useAccordionButton() + + if (optionId === 'tradeTypeNames' && panels?.length === 0) return null + if (optionId === 'stockTypeNames' && panels?.length === 0) return null + + return ( + <AccordionContext.Provider value={{ openIds, panelRef, handleButtonIds }}> + <div> + <AccordionButton optionId={optionId} title={title} size={panels?.length} /> + <AccordionPanel optionId={optionId} panels={panels} /> + </div> + </AccordionContext.Provider> + ) +} + +export default AccordionContainer diff --git a/app/(dashboard)/strategies/_ui/search-bar/accordion-panel.tsx b/app/(dashboard)/strategies/_ui/search-bar/accordion-panel.tsx new file mode 100644 index 00000000..d7bd0212 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/accordion-panel.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useContext, useEffect, useState } from 'react' + +import { CheckedCircleIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { SearchTermsModel } from './_type/search' +import { AccordionContext } from './accordion-container' +import { PANEL_MAPPING } from './panel-mapping' +import RangeContainer from './range-container' +import styles from './styles.module.scss' + +/* eslint-disable react-hooks/exhaustive-deps */ + +const cx = classNames.bind(styles) + +interface Props { + optionId: keyof SearchTermsModel + panels?: string[] +} + +const AccordionPanel = ({ optionId, panels }: Props) => { + const { openIds, panelRef } = useContext(AccordionContext) + const [panelHeight, setPanelHeight] = useState<number | null>(null) + const [isClose, setIsClose] = useState(false) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const { setPanelItem } = useSearchingItemStore((state) => state.actions) + + useEffect(() => { + if (panelRef.current && hasOpenId) { + const panelHeight = panelRef.current.clientHeight + 32 * (panels?.length || 1) + setPanelHeight(panelHeight) + panelRef.current.style.setProperty('--panel-height', `${panelHeight}px`) + } + + if (!hasOpenId) { + setIsClose(true) + const timeout = setTimeout(() => { + setIsClose(false) + setPanelHeight(null) + }, 300) + return () => clearTimeout(timeout) + } + }, [openIds, panelRef, optionId]) + + const hasOpenId = openIds?.[optionId] + const clickedValue = searchTerms[optionId] + + return ( + <> + {hasOpenId !== undefined && ( + <div + className={cx('panel-wrapper', { open: hasOpenId, close: isClose })} + style={{ '--panel-height': `${panelHeight}px` || '0px' } as React.CSSProperties} + ref={panelRef} + > + {panels + ? hasOpenId && + panels?.map((panel, idx) => ( + <button + key={`${panel}-${idx}`} + onClick={() => setPanelItem(optionId, panel)} + className={cx({ + active: + Array.isArray(clickedValue) && + clickedValue?.includes(PANEL_MAPPING[optionId]?.[panel] ?? panel), + })} + > + <p>{panel}</p> + <CheckedCircleIcon /> + </button> + )) + : hasOpenId && <RangeContainer optionId={optionId} />} + </div> + )} + </> + ) +} + +export default AccordionPanel diff --git a/app/(dashboard)/strategies/_ui/search-bar/algorithm-item.tsx b/app/(dashboard)/strategies/_ui/search-bar/algorithm-item.tsx new file mode 100644 index 00000000..fc234356 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/algorithm-item.tsx @@ -0,0 +1,28 @@ +'use client' + +import classNames from 'classnames/bind' + +import { AlgorithmItemType } from './_type/search' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + optionId: AlgorithmItemType + name: string + clickedAlgorithm: AlgorithmItemType | null + onChange: (algorithm: AlgorithmItemType) => void +} + +const AlgorithmItem = ({ optionId, name, clickedAlgorithm, onChange }: Props) => { + return ( + <button + className={cx('algorithm-button', { active: clickedAlgorithm === optionId })} + onClick={() => onChange(optionId)} + > + {name} + </button> + ) +} + +export default AlgorithmItem diff --git a/app/(dashboard)/strategies/_ui/search-bar/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/index.tsx new file mode 100644 index 00000000..bdb0a0ce --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/index.tsx @@ -0,0 +1,130 @@ +'use client' + +import { useRef, useState } from 'react' + +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' +import { SearchInput } from '@/shared/ui/search-input' + +import useGetStrategiesSearch from '../../_hooks/query/use-get-strategies-search' +import usePostStrategies from '../../_hooks/query/use-post-strategies' +import useSearchingItemStore from './_store/use-searching-item-store' +import { AlgorithmItemType, SearchTermsModel } from './_type/search' +import AccordionContainer from './accordion-container' +import AlgorithmItem from './algorithm-item' +import SearchBarTab from './search-bar-tab' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface AccordionMenuDataModel { + id: keyof SearchTermsModel + title: string + panels?: string[] +} + +const SearchBarContainer = () => { + const [isMainTab, setIsMainTab] = useState(true) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const errOptions = useSearchingItemStore((state) => state.errOptions) + const { setSearchWord, setAlgorithm, resetState, validateRangeValues } = useSearchingItemStore( + (state) => state.actions + ) + const searchRef = useRef<HTMLInputElement>(null) + const { data } = useGetStrategiesSearch() + const { refetch } = usePostStrategies({ page: 1, size: 8, searchTerms }) + + const handleSearchWord = () => { + if (searchRef.current) { + setSearchWord(searchRef.current.value) + } + } + + const handleEnterSearch = (e: React.KeyboardEvent<HTMLInputElement>) => { + if (e.key === 'Enter') { + onSearch() + } + } + + const onReset = async () => { + await resetState() + if (searchRef.current) { + searchRef.current.value = '' + } + refetch() + } + + const onSearch = async () => { + await validateRangeValues() + if (errOptions === null || errOptions.length === 0) { + refetch() + } + } + + const ALGORITHM_MENU = [ + { id: 'EFFICIENT_STRATEGY', name: '효율형 전략' }, + { id: 'ATTACK_STRATEGY', name: '공격형 전략' }, + { id: 'DEFENSIVE_STRATEGY', name: '방어형 전략' }, + ] + + const ACCORDION_MENU: AccordionMenuDataModel[] = [ + { id: 'tradeTypeNames', title: '매매 유형', panels: data?.tradeTypeNames }, + { id: 'operationCycles', title: '운용 주기', panels: ['데이', '포지션'] }, + { id: 'stockTypeNames', title: '운영 종목', panels: data?.stockTypeNames }, + { id: 'durations', title: '기간', panels: ['1년 이하', '1년 ~ 2년', '2년 ~ 3년', '3년 이상'] }, + { + id: 'profitRanges', + title: '수익률', + panels: ['10% 이하', '10% ~ 20%', '20% ~ 30%', '30% 이상'], + }, + { id: 'principalRange', title: '원금' }, + { id: 'mddRange', title: 'MDD' }, + { id: 'smScoreRange', title: 'SM SCORE' }, + ] + + return ( + <> + <div className={cx('searchInput-wrapper')}> + <SearchInput + ref={searchRef} + placeholder="전략명을 검색하세요." + onChange={handleSearchWord} + onKeyDown={(e) => handleEnterSearch(e)} + onSearchIconClick={onSearch} + /> + </div> + <div className={cx('searchInput-wrapper')}> + <SearchBarTab isMainTab={isMainTab} onChangeTab={setIsMainTab} /> + {isMainTab + ? ACCORDION_MENU.map((menu) => ( + <AccordionContainer + key={menu.id} + optionId={menu.id} + title={menu.title} + panels={menu.panels} + /> + )) + : ALGORITHM_MENU.map((menu) => ( + <AlgorithmItem + key={menu.id} + optionId={menu.id as AlgorithmItemType} + name={menu.name} + clickedAlgorithm={searchTerms.algorithmType} + onChange={setAlgorithm} + /> + ))} + <div className={cx('search-button-wrapper')}> + <Button className={cx('button', 'initialize')} onClick={onReset}> + 초기화 + </Button> + <Button variant="filled" className={cx('button', 'searching')} onClick={onSearch}> + 검색하기 + </Button> + </div> + </div> + </> + ) +} + +export default SearchBarContainer diff --git a/app/(dashboard)/strategies/_ui/search-bar/panel-mapping.ts b/app/(dashboard)/strategies/_ui/search-bar/panel-mapping.ts new file mode 100644 index 00000000..e11ca03f --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/panel-mapping.ts @@ -0,0 +1,18 @@ +export const PANEL_MAPPING: { [key: string]: Record<string, string> } = { + operationCycles: { + 데이: 'DAY', + 포지션: 'POSITION', + }, + durations: { + '1년 이하': 'ONE_YEAR_OR_LESS', + '1년 ~ 2년': 'ONE_TO_TWO_YEARS', + '2년 ~ 3년': 'TWO_TO_THREE_YEARS', + '3년 이상': 'THREE_YEARS_OR_MORE', + }, + profitRanges: { + '10% 이하': 'UNDER_10_PERCENT', + '10% ~ 20%': 'BETWEEN_10_AND_20', + '20% ~ 30%': 'BETWEEN_20_AND_30', + '30% 이상': 'OVER_30_PERCENT', + }, +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx b/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx new file mode 100644 index 00000000..44aa439b --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/range-container.tsx @@ -0,0 +1,51 @@ +'use client' + +import classNames from 'classnames/bind' + +import useSearchingItemStore from './_store/use-searching-item-store' +import { RangeModel, SearchTermsModel } from './_type/search' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + optionId: keyof SearchTermsModel +} + +const RangeContainer = ({ optionId }: Props) => { + const errOptions = useSearchingItemStore((state) => state.errOptions) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const { setRangeValue } = useSearchingItemStore((state) => state.actions) + + const handleRangeValue = (e: React.ChangeEvent<HTMLInputElement>, type: 'min' | 'max') => { + const value = Number(e.target.value) + setRangeValue(optionId, type, value) + } + + const option = searchTerms?.[optionId] as RangeModel | null + + return ( + <div className={cx('range-container')}> + <div className={cx('range-wrapper')}> + <input + className={cx('range')} + value={option?.min ?? ''} + type="number" + placeholder="0" + onChange={(e) => handleRangeValue(e, 'min')} + /> + <span>~</span> + <input + className={cx('range')} + value={option?.max ?? ''} + type="number" + placeholder="0" + onChange={(e) => handleRangeValue(e, 'max')} + /> + </div> + {errOptions?.includes(optionId) && <p>최소 값은 최대 값보다 작아야합니다.</p>} + </div> + ) +} + +export default RangeContainer diff --git a/app/(dashboard)/strategies/_ui/search-bar/search-bar-skeleton/index.tsx b/app/(dashboard)/strategies/_ui/search-bar/search-bar-skeleton/index.tsx new file mode 100644 index 00000000..3acad3f6 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/search-bar-skeleton/index.tsx @@ -0,0 +1,20 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const SearchBarSkeleton = () => { + return ( + <> + <div className={cx('top')}></div> + <div className={cx('container')}> + {Array.from({ length: 7 }, (_, idx) => ( + <div key={idx}></div> + ))} + </div> + </> + ) +} + +export default SearchBarSkeleton diff --git a/app/(dashboard)/strategies/_ui/search-bar/search-bar-skeleton/styles.module.scss b/app/(dashboard)/strategies/_ui/search-bar/search-bar-skeleton/styles.module.scss new file mode 100644 index 00000000..0754a67c --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/search-bar-skeleton/styles.module.scss @@ -0,0 +1,20 @@ +.top { + @include skeleton; + width: 276px; + height: 66px; + margin-bottom: 10px; +} +.container { + @include skeleton; + width: 276px; + height: 520px; + padding: 15px; + * { + height: 40px; + width: 240px; + margin-bottom: 20px; + } + :first-child { + margin-top: 30px; + } +} diff --git a/app/(dashboard)/strategies/_ui/search-bar/search-bar-tab.tsx b/app/(dashboard)/strategies/_ui/search-bar/search-bar-tab.tsx new file mode 100644 index 00000000..a2c98b6a --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/search-bar-tab.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames/bind' + +import { Button } from '@/shared/ui/button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + isMainTab: boolean + onChangeTab: (isMainTab: boolean) => void +} + +const SearchBarTab = ({ isMainTab, onChangeTab }: Props) => { + return ( + <div className={cx('tab-container')}> + <Button + className={cx('button', isMainTab ? 'main-on' : 'main-off')} + onClick={() => onChangeTab(!isMainTab)} + > + 항목별 + </Button> + <Button + className={cx('button', isMainTab ? 'main-off' : 'main-on')} + onClick={() => onChangeTab(!isMainTab)} + > + 알고리즘별 + </Button> + </div> + ) +} + +export default SearchBarTab diff --git a/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss new file mode 100644 index 00000000..fe548f66 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/search-bar/styles.module.scss @@ -0,0 +1,185 @@ +@mixin item-align { + display: flex; + justify-content: space-between; +} + +.searchInput-wrapper { + background-color: $color-white; + padding: 20px; + border: 5px; + margin-bottom: 10px; +} + +.search-button-wrapper { + @include item-align; + margin-top: 20px; + .button { + height: 40px; + &.initialize { + width: 90px; + padding: 0; + } + &.searching { + width: 140px; + } + } +} + +.tab-container { + @include item-align; + margin-bottom: 20px; + .button { + border: 0; + width: 118px; + height: 48px; + &.main-on { + background-color: $color-orange-500; + color: $color-white; + } + &.main-off { + background-color: transparent; + color: $color-gray-700; + } + } +} + +.algorithm-button { + width: 100%; + padding: 10.8px 20px; + margin-bottom: 5px; + border-radius: 5px; + background-color: transparent; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25); + @include typo-b3; + color: $color-gray-600; + &:hover { + background-color: $color-orange-100; + } + &.active { + background-color: $color-orange-600; + color: $color-white; + } +} + +.accordion-button, +.panel-wrapper { + padding: 2px 0; + margin-bottom: 5px; + border-radius: 5px; + overflow: hidden; + button { + @include item-align; + width: 100%; + padding: 4px 20px; + align-items: center; + background-color: transparent; + } +} + +.accordion-button { + border: 1px solid $color-gray-200; + background-color: $color-gray-100; + button { + p { + @include typo-c1; + color: $color-gray-800; + span { + color: $color-orange-500; + margin-left: 4px; + } + } + svg { + width: 26px; + path { + fill: #171717; + } + } + } + &:hover { + border: 1px solid $color-orange-300; + } + &.active { + border: 1px solid $color-orange-500; + box-shadow: 0px 0px 2px rgba(255, 119, 82, 1); + } +} + +.panel-wrapper { + display: none; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25); + button { + p { + @include typo-c1; + color: $color-gray-600; + } + svg, + svg circle { + width: 24px; + .checked { + fill: $color-orange-600; + } + } + &.active { + svg, + svg circle { + fill: $color-orange-600; + } + } + &:hover { + background-color: $color-orange-100; + } + } + &.open { + display: block; + animation: accordionDown 0.3s cubic-bezier(0.2, 0.2, 0.2, 0.6); + } + &.close { + display: block; + animation: accordionUp 0.3s cubic-bezier(0.2, 0.2, 0.2, 0.6); + } + .range-container { + padding: 4px 20px; + p { + @include typo-c1; + color: $color-orange-800; + margin-top: 2px; + } + .range-wrapper { + @include item-align; + @include typo-c1; + align-items: center; + .range { + width: 80px; + height: 24px; + border-radius: 2px; + border: 1px solid $color-gray-300; + padding: 0 4px; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + display: none; + } + } + span { + color: $color-gray-600; + } + } + } +} + +@keyframes accordionDown { + from { + height: 0; + } + to { + height: var(--panel-height); + } +} + +@keyframes accordionUp { + from { + height: var(--panel-height); + } + to { + height: 0; + } +} diff --git a/app/(dashboard)/strategies/_ui/side-container/index.tsx b/app/(dashboard)/strategies/_ui/side-container/index.tsx new file mode 100644 index 00000000..394d5e06 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/side-container/index.tsx @@ -0,0 +1,16 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + children: React.ReactNode + hasButton?: boolean +} + +const SideContainer = ({ children }: Props) => { + return <aside className={cx('side-bar')}>{children}</aside> +} + +export default SideContainer diff --git a/app/(dashboard)/strategies/_ui/side-container/styles.module.scss b/app/(dashboard)/strategies/_ui/side-container/styles.module.scss new file mode 100644 index 00000000..9a600262 --- /dev/null +++ b/app/(dashboard)/strategies/_ui/side-container/styles.module.scss @@ -0,0 +1,6 @@ +.side-bar { + width: $strategy-sidebar-width; + position: absolute; + right: 0px; + top: 130px; +} diff --git a/app/(dashboard)/strategies/_ui/strategy-list/index.tsx b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx new file mode 100644 index 00000000..46d8113d --- /dev/null +++ b/app/(dashboard)/strategies/_ui/strategy-list/index.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useEffect } from 'react' + +import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' +import classNames from 'classnames/bind' + +import { STRATEGIES_PAGE_COUNT } from '@/shared/constants/count-per-page' +import { PATH } from '@/shared/constants/path' +import { usePagination } from '@/shared/hooks/custom/use-pagination' +import { StrategiesModel } from '@/shared/types/strategy-data' +import Pagination from '@/shared/ui/pagination' + +import usePostStrategies from '../../_hooks/query/use-post-strategies' +import useSearchingItemStore from '../search-bar/_store/use-searching-item-store' +import styles from './styles.module.scss' + +/* eslint-disable react-hooks/exhaustive-deps */ + +const cx = classNames.bind(styles) + +const StrategyList = () => { + const { size, page, handlePageChange } = usePagination({ + basePath: PATH.STRATEGIES, + pageSize: STRATEGIES_PAGE_COUNT, + }) + const searchTerms = useSearchingItemStore((state) => state.searchTerms) + const { resetState } = useSearchingItemStore((state) => state.actions) + const { data } = usePostStrategies({ page, size, searchTerms }) + + useEffect(() => { + resetState() + }, []) + + const strategiesData = data?.content as StrategiesModel[] + const totalPages = (data?.totalPages as number) || null + + return ( + <> + {strategiesData?.map((strategy) => ( + <StrategiesItem key={strategy.strategyId} strategiesData={strategy} /> + ))} + <div className={cx('pagination')}> + {totalPages && ( + <Pagination currentPage={page} maxPage={totalPages} onPageChange={handlePageChange} /> + )} + </div> + </> + ) +} + +export default StrategyList diff --git a/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss b/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss new file mode 100644 index 00000000..f5081c9f --- /dev/null +++ b/app/(dashboard)/strategies/_ui/strategy-list/styles.module.scss @@ -0,0 +1,3 @@ +.pagination { + margin-bottom: 24px; +} diff --git a/app/(dashboard)/strategies/layout.module.scss b/app/(dashboard)/strategies/layout.module.scss new file mode 100644 index 00000000..29e26f4b --- /dev/null +++ b/app/(dashboard)/strategies/layout.module.scss @@ -0,0 +1,10 @@ +.strategy-layout { + display: flex; + position: relative; + + .strategy { + width: calc(100% - $strategy-sidebar-width); + max-width: $max-width; + padding-right: 10px; + } +} diff --git a/app/(dashboard)/strategies/layout.tsx b/app/(dashboard)/strategies/layout.tsx new file mode 100644 index 00000000..d342a119 --- /dev/null +++ b/app/(dashboard)/strategies/layout.tsx @@ -0,0 +1,19 @@ +import classNames from 'classnames/bind' + +import styles from './layout.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + children: React.ReactNode +} + +const StrategiesLayout = ({ children }: Props) => { + return ( + <div className={cx('strategy-layout')}> + <section className={cx('strategy')}>{children}</section> + </div> + ) +} + +export default StrategiesLayout diff --git a/app/(dashboard)/strategies/page.module.scss b/app/(dashboard)/strategies/page.module.scss new file mode 100644 index 00000000..f53b42ce --- /dev/null +++ b/app/(dashboard)/strategies/page.module.scss @@ -0,0 +1,21 @@ +.container { + margin-top: 80px; +} + +.strategy-layout { + display: flex; + position: relative; + + .strategy { + width: calc(100% - $strategy-sidebar-width); + max-width: $max-width; + padding-right: 10px; + } +} + +.skeleton-side-bar { + width: $strategy-sidebar-width; + position: absolute; + right: 0px; + top: 130px; +} diff --git a/app/(dashboard)/strategies/page.tsx b/app/(dashboard)/strategies/page.tsx new file mode 100644 index 00000000..a12301c3 --- /dev/null +++ b/app/(dashboard)/strategies/page.tsx @@ -0,0 +1,44 @@ +import { Suspense } from 'react' + +import classNames from 'classnames/bind' + +import Title from '@/shared/ui/title' + +import ListHeader from '../_ui/list-header' +import StrategiesItemSkeleton from '../_ui/strategies-item/skeleton' +import SearchBarContainer from './_ui/search-bar' +import SearchBarSkeleton from './_ui/search-bar/search-bar-skeleton' +import SideContainer from './_ui/side-container' +import StrategyList from './_ui/strategy-list' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + +const StrategiesPage = () => { + return ( + <div className={cx('container')}> + <Title label={'전략 랭킹 모음'} /> + <ListHeader /> + <Suspense fallback={<Skeleton />}> + <StrategyList /> + </Suspense> + <SideContainer> + <Suspense fallback={<SearchBarSkeleton />}> + <SearchBarContainer /> + </Suspense> + </SideContainer> + </div> + ) +} + +const Skeleton = () => { + return ( + <> + {Array.from({ length: 8 }, (_, idx) => ( + <StrategiesItemSkeleton key={idx} /> + ))} + </> + ) +} + +export default StrategiesPage diff --git a/app/(dashboard)/traders/[traderId]/page.module.scss b/app/(dashboard)/traders/[traderId]/page.module.scss new file mode 100644 index 00000000..c385baef --- /dev/null +++ b/app/(dashboard)/traders/[traderId]/page.module.scss @@ -0,0 +1,11 @@ +.page-container { + padding: 0 15px; +} + +.title { + margin-bottom: 48px; +} + +.card-wrapper { + margin-bottom: 54px; +} diff --git a/app/(dashboard)/traders/[traderId]/page.tsx b/app/(dashboard)/traders/[traderId]/page.tsx new file mode 100644 index 00000000..390556e1 --- /dev/null +++ b/app/(dashboard)/traders/[traderId]/page.tsx @@ -0,0 +1,54 @@ +'use client' + +import classNames from 'classnames/bind' + +import BackHeader from '@/shared/ui/header/back-header' +import Title from '@/shared/ui/title' +import TradersListCard from '@/shared/ui/traders-list-card' + +import ListHeader from '../../_ui/list-header' +import StrategiesItem from '../../_ui/strategies-item' +import useGetTraderStrategies from '../_hooks/use-get-trader-details' +import styles from './page.module.scss' + +const cx = classNames.bind(styles) + +const TraderDetailPage = () => { + const traderId = 1 + const { data: strategiesData, isLoading } = useGetTraderStrategies({ + traderId, + }) + + const strategies = strategiesData?.content + const firstStrategy = strategies?.[0] + + if (!firstStrategy || isLoading) { + return null + } + + return ( + <> + <div className={cx('page-container')}> + <BackHeader label={'목록으로 돌아가기'} /> + <div className={cx('title')}> + <Title label={'트레이더 상세보기'} /> + </div> + <div className={cx('card-wrapper')}> + <TradersListCard + imageUrl={firstStrategy.traderImgUrl} + nickname={firstStrategy.nickname} + strategyCount={strategies.length} + subscriberCount={firstStrategy.subscriptionCount} + userId={traderId} + /> + </div> + <ListHeader /> + {strategies?.map((strategy) => ( + <StrategiesItem key={strategy.strategyId} strategiesData={strategy} /> + ))} + </div> + </> + ) +} + +export default TraderDetailPage diff --git a/app/(dashboard)/traders/_api/get-trader-details.ts b/app/(dashboard)/traders/_api/get-trader-details.ts new file mode 100644 index 00000000..348c2724 --- /dev/null +++ b/app/(dashboard)/traders/_api/get-trader-details.ts @@ -0,0 +1,64 @@ +import axiosInstance from '@/shared/api/axios' + +export interface StockTypeInfoModel { + stockTypeIconUrls: string[] + stockTypeNames: string[] +} + +export interface ProfitRateChartDataModel { + dates: string[] + profitRates: number[] +} + +export interface StrategyModel { + strategyId: number + strategyName: string + traderImgUrl: string + nickname: string + stockTypeInfo: StockTypeInfoModel + tradeTypeIconUrl: string + tradeTypeName: string + profitRateChartData: ProfitRateChartDataModel + mdd: number + smScore: number + cumulativeProfitRate: number + recentYearProfitLossRate: number + subscriptionCount: number + averageRating: number + totalReviews: number + isSubscribed: boolean +} + +interface TraderStrategiesResponseModel { + isSuccess: boolean + message: string + result: { + content: StrategyModel[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean + } +} + +interface Props { + traderId: number +} + +const getTraderStrategies = async ({ + traderId, +}: Props): Promise<TraderStrategiesResponseModel['result']> => { + try { + const response = await axiosInstance.get<TraderStrategiesResponseModel>( + `/api/strategies/search/trader/${traderId}` + ) + return response.data.result + } catch (err) { + console.error(err) + throw new Error('트레이더의 전략 목록 조회에 실패했습니다.') + } +} + +export default getTraderStrategies diff --git a/app/(dashboard)/traders/_api/get-traders.ts b/app/(dashboard)/traders/_api/get-traders.ts new file mode 100644 index 00000000..65fcc5c9 --- /dev/null +++ b/app/(dashboard)/traders/_api/get-traders.ts @@ -0,0 +1,52 @@ +import axiosInstance from '@/shared/api/axios' + +export interface TraderModel { + userId: number + nickname: string + userName: string + imageUrl: string + strategyCount: number + totalSubCount: number +} + +interface TradersResponseModel { + isSuccess: boolean + message: string + result: { + content: TraderModel[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean + } + code: number +} + +export interface TradersParamsModel { + page: number + size: number + keyword?: string + orderBy: 'STRATEGY_TOTAL' | 'SUBSCRIBE_TOTAL' +} + +export const getTraders = async ( + params: TradersParamsModel +): Promise<TradersResponseModel['result']> => { + try { + const { page, size, keyword = '', orderBy } = params + const response = await axiosInstance.get<TradersResponseModel>( + `/api/users/traders?sort=${orderBy}&page=${page}&size=${size}&keyword=${keyword}` + ) + + if (response.data.isSuccess) { + return response.data.result + } else { + throw new Error(response.data.message || '요청 실패') + } + } catch (err) { + console.error(err) + throw new Error('트레이더 목록 조회에 실패하였습니다.') + } +} diff --git a/app/(dashboard)/traders/_hooks/use-get-trader-details.ts b/app/(dashboard)/traders/_hooks/use-get-trader-details.ts new file mode 100644 index 00000000..3193d3b6 --- /dev/null +++ b/app/(dashboard)/traders/_hooks/use-get-trader-details.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query' + +import getTraderStrategies from '../_api/get-trader-details' + +interface Props { + traderId: number +} + +const useGetTraderStrategies = ({ traderId }: Props) => { + return useQuery({ + queryKey: ['trader-strategies', traderId], + queryFn: () => getTraderStrategies({ traderId }), + enabled: !!traderId, + }) +} + +export default useGetTraderStrategies diff --git a/app/(dashboard)/traders/_hooks/use-get-traders.ts b/app/(dashboard)/traders/_hooks/use-get-traders.ts new file mode 100644 index 00000000..d03ce64c --- /dev/null +++ b/app/(dashboard)/traders/_hooks/use-get-traders.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query' + +import { TradersParamsModel, getTraders } from '../_api/get-traders' + +const useGetTraders = ({ page, size, keyword, orderBy }: TradersParamsModel) => { + return useQuery({ + queryKey: ['traders', page, size, keyword, orderBy], + queryFn: () => getTraders({ page, size, keyword, orderBy }), + }) +} +export default useGetTraders diff --git a/app/(dashboard)/traders/page.module.scss b/app/(dashboard)/traders/page.module.scss new file mode 100644 index 00000000..615e9a98 --- /dev/null +++ b/app/(dashboard)/traders/page.module.scss @@ -0,0 +1,28 @@ +.page-container { + padding: 0 15px; +} + +.title { + margin-top: 80px; +} + +.search-wrapper { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + padding: 16px; + margin-top: 18px; + margin-bottom: 31px; +} + +.traders-list-wrapper { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px 32px; + margin-bottom: 30px; +} + +.pagination-wrapper { + margin-bottom: 64px; +} diff --git a/app/(dashboard)/traders/page.tsx b/app/(dashboard)/traders/page.tsx new file mode 100644 index 00000000..1d5cbef4 --- /dev/null +++ b/app/(dashboard)/traders/page.tsx @@ -0,0 +1,98 @@ +'use client' + +import { useRef, useState } from 'react' + +import styles from '@/app/(dashboard)/traders/page.module.scss' +import { COUNT_PER_PAGE } from '@/app/admin/category/_ui/shared/manage-table/constant' +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { usePagination } from '@/shared/hooks/custom/use-pagination' +import { DropdownValueType } from '@/shared/ui/dropdown/types' +import Pagination from '@/shared/ui/pagination' +import { SearchInput } from '@/shared/ui/search-input' +import Select from '@/shared/ui/select' +import Title from '@/shared/ui/title' +import TradersListCard from '@/shared/ui/traders-list-card' + +import useGetTraders from './_hooks/use-get-traders' + +const cx = classNames.bind(styles) + +const TradersPage = () => { + const [selectedOption, setSelectedOption] = useState<DropdownValueType>('STRATEGY_TOTAL') + const [searchKeyword, setSearchKeyword] = useState('') + const searchInputRef = useRef<HTMLInputElement>(null) + + const { page, handlePageChange } = usePagination({ + basePath: PATH.TRADERS, + pageSize: COUNT_PER_PAGE, + }) + + const { data } = useGetTraders({ + page, + size: 12, + keyword: searchKeyword, + orderBy: selectedOption as 'STRATEGY_TOTAL' | 'SUBSCRIBE_TOTAL', + }) + + const handleSearch = () => { + if (searchInputRef.current) { + setSearchKeyword(searchInputRef.current.value || '') + handlePageChange(1) + } + } + + const traders = data?.content + + if (!traders) { + return null + } + + return ( + <> + <div className={cx('page-container')}> + <div className={cx('title')}> + <Title label={'트레이더 목록'} marginLeft={'13px'}> +
+
+ + +
+
+ {traders.map((trader) => ( + + ))} +
+ +
+ {data.totalElements > 0 && ( + + )} +
+ + + ) +} + +export default TradersPage diff --git a/app/(landing)/(home)/_api/strategies-metrics.ts b/app/(landing)/(home)/_api/strategies-metrics.ts new file mode 100644 index 00000000..8f55f2bd --- /dev/null +++ b/app/(landing)/(home)/_api/strategies-metrics.ts @@ -0,0 +1,11 @@ +import axios from 'axios' + +export const getStrategiesMetrics = async () => { + try { + const response = await axios.get('/api/main/total-strategies-metrics') + return response.data.result + } catch (err) { + console.error(err) + throw new Error('대표 전략 통합 지표 조회에 실패했습니다.') + } +} diff --git a/app/(landing)/(home)/_api/top-strategies.ts b/app/(landing)/(home)/_api/top-strategies.ts new file mode 100644 index 00000000..f67aaffd --- /dev/null +++ b/app/(landing)/(home)/_api/top-strategies.ts @@ -0,0 +1,21 @@ +import axios from 'axios' + +export const getTopRanking = async () => { + try { + const response = await axios.get('/api/main/top-ranking') + return response.data.result + } catch (err) { + console.error(err) + throw new Error('구독수 상위 전략 조회에 실패했습니다.') + } +} + +export const getTopRankingSmScore = async () => { + try { + const response = await axios.get('/api/main/top-ranking-smscore') + return response.data.result + } catch (err) { + console.error(err) + throw new Error('SM Score 상위 전략 조회에 실패했습니다.') + } +} diff --git a/app/(landing)/(home)/_api/user-metrics.ts b/app/(landing)/(home)/_api/user-metrics.ts new file mode 100644 index 00000000..714c6654 --- /dev/null +++ b/app/(landing)/(home)/_api/user-metrics.ts @@ -0,0 +1,11 @@ +import axios from 'axios' + +export const getUserMetrics = async () => { + try { + const response = await axios.get('/api/main/total-rate') + return response.data.result + } catch (err) { + console.error(err) + throw new Error('사용자 이용 지표 조회에 실패했습니다.') + } +} diff --git a/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts b/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts new file mode 100644 index 00000000..34e94629 --- /dev/null +++ b/app/(landing)/(home)/_hooks/query/use-get-strategies-metrics.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' + +import { getStrategiesMetrics } from '../../_api/strategies-metrics' +import { AverageMetricsChartDataModel } from '../../_ui/average-metrics-section/average-metrics-chart' + +const useGetStrategiesMetrics = () => { + return useQuery({ + queryKey: ['totalStrategiesMetrics'], + queryFn: getStrategiesMetrics, + }) +} + +export default useGetStrategiesMetrics diff --git a/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts b/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts new file mode 100644 index 00000000..1b632f85 --- /dev/null +++ b/app/(landing)/(home)/_hooks/query/use-get-top-ranking-smscore.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' + +import { StrategyCardModel } from '@/shared/types/strategy-data' + +import { getTopRankingSmScore } from '../../_api/top-strategies' + +const useGetTopRankingSmScore = () => { + return useQuery({ + queryKey: ['topRankingSmScore'], + queryFn: getTopRankingSmScore, + }) +} + +export default useGetTopRankingSmScore diff --git a/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts b/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts new file mode 100644 index 00000000..2f8f3429 --- /dev/null +++ b/app/(landing)/(home)/_hooks/query/use-get-top-ranking.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' + +import { StrategyCardModel } from '@/shared/types/strategy-data' + +import { getTopRanking } from '../../_api/top-strategies' + +const useGetTopRanking = () => { + return useQuery({ + queryKey: ['topRanking'], + queryFn: getTopRanking, + }) +} + +export default useGetTopRanking diff --git a/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts b/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts new file mode 100644 index 00000000..2368a017 --- /dev/null +++ b/app/(landing)/(home)/_hooks/query/use-get-user-metrics.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query' + +import { getUserMetrics } from '../../_api/user-metrics' +import { UserMetricsModel } from '../../types' + +const useGetUserMetrics = () => { + return useQuery({ + queryKey: ['userMetrics'], + queryFn: getUserMetrics, + }) +} + +export default useGetUserMetrics diff --git a/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx b/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx new file mode 100644 index 00000000..bbe0af43 --- /dev/null +++ b/app/(landing)/(home)/_ui/average-metrics-section/average-metrics-chart.tsx @@ -0,0 +1,200 @@ +'use client' + +import dynamic from 'next/dynamic' + +import Highcharts from 'highcharts' +import mouseWheelZoom from 'highcharts/modules/mouse-wheel-zoom' + +mouseWheelZoom(Highcharts) + +const HighchartsReact = dynamic(() => import('highcharts-react-official'), { + ssr: false, +}) + +export interface AverageMetricsChartDataModel { + dates: string[] + data: { + avgReferencePrice: number[] + highestSmScoreReferencePrice: number[] + highestSubscribeScoreReferencePrice: number[] + } +} + +interface Props { + data: AverageMetricsChartDataModel +} + +const AverageMetricsChart = ({ data }: Props) => { + const chartOptions: Highcharts.Options = { + chart: { + type: 'areaspline', + height: 450, + backgroundColor: '#FFFFFF', + zooming: { + mouseWheel: { + enabled: true, + }, + type: 'x', + }, + }, + title: { text: undefined }, + xAxis: { + categories: data.dates, + labels: { enabled: false }, + gridLineWidth: 0, + tickLength: 0, + lineColor: '#E3E3E3', + startOnTick: true, + endOnTick: true, + tickmarkPlacement: 'on', + }, + yAxis: [ + { + title: { + text: '통합기준가', + style: { + color: '#797979', + fontSize: '10px', + }, + }, + labels: { + style: { + color: '#797979', + fontSize: '10px', + }, + }, + }, + { + title: { + text: '기준가', + style: { + color: '#797979', + fontSize: '10px', + }, + }, + opposite: true, + labels: { + style: { + color: '#797979', + fontSize: '10px', + }, + }, + }, + ], + legend: { + enabled: true, + align: 'left', + verticalAlign: 'top', + layout: 'vertical', + x: 70, + y: -10, + itemStyle: { + color: '#4D4D4D', + fontSize: '12px', + }, + floating: true, + backgroundColor: '#FFFFFF', + borderColor: '#A7A7A7', + borderRadius: 4, + borderWidth: 1, + padding: 16, + }, + tooltip: { + useHTML: true, + headerFormat: '
{point.key}
', + pointFormat: '{point.y:.2f}', + footerFormat: '', + borderColor: '#ECECEC', + borderWidth: 1, + shadow: false, + backgroundColor: '#FFFFFF', + style: { + padding: '10px', + }, + }, + + plotOptions: { + series: { + animation: { + duration: 2000, + }, + marker: { + enabled: false, + }, + }, + areaspline: { + fillOpacity: 0.5, + lineWidth: 2, + marker: { + enabled: false, + }, + fillColor: { + linearGradient: { + x1: 0, + y1: 0, + x2: 0, + y2: 1, + }, + stops: [ + [0, '#FF4F1F'], + [1, '#FFFFFF'], + ], + }, + }, + spline: { + lineWidth: 2, + marker: { + enabled: false, + }, + }, + }, + series: [ + { + type: 'areaspline', + name: '평균', + data: data.data.avgReferencePrice, + color: '#FF4F1F', + yAxis: 0, + stickyTracking: false, + pointPlacement: 'on', + }, + { + type: 'spline', + name: 'SM SCORE 1위', + data: data.data.highestSmScoreReferencePrice, + color: '#6877FF', + yAxis: 1, + stickyTracking: false, + pointPlacement: 'on', + }, + { + type: 'spline', + name: '구독 1위', + data: data.data.highestSubscribeScoreReferencePrice, + color: '#FFE070', + yAxis: 1, + stickyTracking: false, + pointPlacement: 'on', + }, + ], + responsive: { + rules: [ + { + condition: { + maxWidth: 1000, + }, + chartOptions: { + chart: { + width: null, + }, + }, + }, + ], + }, + credits: { enabled: false }, + } + + return +} + +export default AverageMetricsChart diff --git a/app/(landing)/(home)/_ui/average-metrics-section/average-metrics.stories.tsx b/app/(landing)/(home)/_ui/average-metrics-section/average-metrics.stories.tsx new file mode 100644 index 00000000..aa036746 --- /dev/null +++ b/app/(landing)/(home)/_ui/average-metrics-section/average-metrics.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import AverageMetricsContainer from './index' + +const meta = { + title: 'Components/AverageMetricsChart', + component: AverageMetricsContainer, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta + +type StoryType = StoryObj + +export const Default: StoryType = {} + +export default meta diff --git a/app/(landing)/(home)/_ui/average-metrics-section/index.tsx b/app/(landing)/(home)/_ui/average-metrics-section/index.tsx new file mode 100644 index 00000000..5d2156d0 --- /dev/null +++ b/app/(landing)/(home)/_ui/average-metrics-section/index.tsx @@ -0,0 +1,47 @@ +'use client' + +import dynamic from 'next/dynamic' + +import classNames from 'classnames/bind' + +import useGetStrategiesMetrics from '../../_hooks/query/use-get-strategies-metrics' +import HomeSubtitle from '../home-subtitle' +import styles from './styles.module.scss' + +const AverageMetricsChart = dynamic(() => import('./average-metrics-chart'), { + ssr: false, + loading: () =>
Loading...
, +}) + +const cx = classNames.bind(styles) + +const AverageMetricsSection = () => { + const { data: chartData } = useGetStrategiesMetrics() + + if (!chartData) { + return

차트 조회에 실패했습니다.

+ } + + const startDate = chartData.dates[0] + const endDate = chartData.dates.at(-1) + + return ( +
+ 대표 전략 통합 평균 지표 + +
+
+
+ FROM {startDate}TO + {endDate} +
+
+ +
+
+
+
+ ) +} + +export default AverageMetricsSection diff --git a/app/(landing)/(home)/_ui/average-metrics-section/styles.module.scss b/app/(landing)/(home)/_ui/average-metrics-section/styles.module.scss new file mode 100644 index 00000000..1350af68 --- /dev/null +++ b/app/(landing)/(home)/_ui/average-metrics-section/styles.module.scss @@ -0,0 +1,37 @@ +.container { + width: 100%; + max-width: 1260px; + margin: 48px auto 0; + padding: 48px 0 60px; + border-radius: 10px; + background-color: $color-white; +} + +.contents-wrapper { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + width: 100%; + max-width: 1000px; + margin: 0 auto; +} + +.date-wrapper { + align-self: flex-end; + padding-right: 24px; + margin-bottom: 20px; + @include typo-c1; + + .date { + padding: 2px 8px; + margin: 0 6px; + border-radius: 20px; + color: $color-gray-500; + background-color: $color-gray-200; + } +} + +.chart-wrapper { + width: 100%; +} diff --git a/app/(landing)/(home)/_ui/hero-section/index.tsx b/app/(landing)/(home)/_ui/hero-section/index.tsx new file mode 100644 index 00000000..8c2a4af0 --- /dev/null +++ b/app/(landing)/(home)/_ui/hero-section/index.tsx @@ -0,0 +1,35 @@ +'use client' + +import Logo from '@/public/images/logo.svg' +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { useAuthStore } from '@/shared/stores/use-auth-store' +import { LinkButton } from '@/shared/ui/link-button' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const HeroSection = () => { + const user = useAuthStore((state) => state.user) + + return ( +
+

+ 성공적인 투자 전략을 +
+ 참고하거나 공유하고 싶다면 +
+ 인베스트메틱에서! +

+ {!user?.userId && ( + + 바로 함께하기 + + )} +
+ ) +} + +export default HeroSection diff --git a/app/(landing)/(home)/_ui/hero-section/styles.module.scss b/app/(landing)/(home)/_ui/hero-section/styles.module.scss new file mode 100644 index 00000000..875f2978 --- /dev/null +++ b/app/(landing)/(home)/_ui/hero-section/styles.module.scss @@ -0,0 +1,27 @@ +.section { + text-align: center; +} + +.title { + @include typo-h1; + padding-top: 100px; + color: $color-gray-800; +} + +.logo { + display: inline-block; + width: 65px; + height: auto; + + @include tablet-md { + width: 42px; + } + + @include mobile { + width: 34px; + } +} + +.button { + margin-top: 77px; +} diff --git a/app/(landing)/(home)/_ui/home-subtitle/index.tsx b/app/(landing)/(home)/_ui/home-subtitle/index.tsx new file mode 100644 index 00000000..712be719 --- /dev/null +++ b/app/(landing)/(home)/_ui/home-subtitle/index.tsx @@ -0,0 +1,15 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + children: React.ReactNode +} + +const HomeSubtitle = ({ children }: Props) => { + return

{children}

+} + +export default HomeSubtitle diff --git a/app/(landing)/(home)/_ui/home-subtitle/styles.module.scss b/app/(landing)/(home)/_ui/home-subtitle/styles.module.scss new file mode 100644 index 00000000..03244175 --- /dev/null +++ b/app/(landing)/(home)/_ui/home-subtitle/styles.module.scss @@ -0,0 +1,7 @@ +.subtitle { + margin-top: 120px; + @include typo-h4; + color: $color-gray-600; + font-weight: $text-bold; + text-align: center; +} diff --git a/app/(landing)/(home)/_ui/line-chart/index.tsx b/app/(landing)/(home)/_ui/line-chart/index.tsx new file mode 100644 index 00000000..2c6bb469 --- /dev/null +++ b/app/(landing)/(home)/_ui/line-chart/index.tsx @@ -0,0 +1,68 @@ +'use client' + +import dynamic from 'next/dynamic' + +import Highcharts from 'highcharts' + +import { CardSizeType } from '../top-strategy-card/types' + +const HighchartsReact = dynamic(() => import('highcharts-react-official'), { + ssr: false, +}) + +interface Props { + data: number[] + isNegative?: boolean + size?: CardSizeType +} + +const getChartDimensions = (size: CardSizeType) => ({ + height: size === 'small' ? 55 : 120, + width: size === 'small' ? 90 : 185, +}) + +const LineChart = ({ data, isNegative = false, size = 'small' }: Props) => { + const dimensions = getChartDimensions(size) + + const chartOptions: Highcharts.Options = { + chart: { + type: 'line', + height: dimensions.height, + width: dimensions.width, + backgroundColor: 'transparent', + margin: [0, 0, 0, 0], + }, + title: { text: undefined }, + xAxis: { + visible: false, + }, + yAxis: { + visible: false, + min: Math.min(...data) * 0.95, + max: Math.max(...data) * 1.05, + }, + legend: { enabled: false }, + plotOptions: { + series: { + animation: false, + lineWidth: 2, + marker: { enabled: false }, + states: { hover: { enabled: false } }, + enableMouseTracking: false, + }, + }, + series: [ + { + type: 'line', + data, + color: isNegative ? '#6877FF' : '#FF7752', + }, + ], + credits: { enabled: false }, + tooltip: { enabled: false }, + } + + return +} + +export default LineChart diff --git a/app/(landing)/(home)/_ui/metric-card/index.tsx b/app/(landing)/(home)/_ui/metric-card/index.tsx new file mode 100644 index 00000000..25ef5887 --- /dev/null +++ b/app/(landing)/(home)/_ui/metric-card/index.tsx @@ -0,0 +1,21 @@ +import classNames from 'classnames/bind' + +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +interface Props { + count: number + label: string +} + +const MetricCard = ({ count, label }: Props) => { + return ( +
+ {count.toLocaleString()} +

{label}

+
+ ) +} + +export default MetricCard diff --git a/app/(landing)/(home)/_ui/metric-card/styles.module.scss b/app/(landing)/(home)/_ui/metric-card/styles.module.scss new file mode 100644 index 00000000..c21b5655 --- /dev/null +++ b/app/(landing)/(home)/_ui/metric-card/styles.module.scss @@ -0,0 +1,20 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + width: 300px; + height: 160px; + border-radius: 5px; + background-color: $color-white; +} + +.count { + @include typo-h1; + color: $color-orange-500; +} + +.label { + color: $color-gray-800; +} diff --git a/app/(landing)/(home)/_ui/top-favorite-section/index.tsx b/app/(landing)/(home)/_ui/top-favorite-section/index.tsx new file mode 100644 index 00000000..51d3321d --- /dev/null +++ b/app/(landing)/(home)/_ui/top-favorite-section/index.tsx @@ -0,0 +1,50 @@ +'use client' + +import classNames from 'classnames/bind' + +import { PATH } from '@/shared/constants/path' +import { LinkButton } from '@/shared/ui/link-button' + +import useGetTopRanking from '../../_hooks/query/use-get-top-ranking' +import HomeSubtitle from '../home-subtitle' +import TopFavoriteCard from '../top-strategy-card/top-favorite-card' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const TopFavoriteSection = () => { + const { data: favoriteStrategies } = useGetTopRanking() + + return ( +
+ + 인베스트메틱에서 제공하는
+ 인기 있는 전략을 확인해보세요! +
+ +
    + {favoriteStrategies && + favoriteStrategies.map((strategy, idx) => ( +
  • + +
  • + ))} +
+ + + 전략랭킹 더보기 + +
+ ) +} + +export default TopFavoriteSection diff --git a/app/(landing)/(home)/_ui/top-favorite-section/styles.module.scss b/app/(landing)/(home)/_ui/top-favorite-section/styles.module.scss new file mode 100644 index 00000000..f6cefe03 --- /dev/null +++ b/app/(landing)/(home)/_ui/top-favorite-section/styles.module.scss @@ -0,0 +1,16 @@ +.section-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 48px; +} + +.strategy-wrapper { + display: flex; + justify-content: center; + gap: 20px; + + @include tablet-md { + flex-direction: column; + } +} diff --git a/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx b/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx new file mode 100644 index 00000000..52b699df --- /dev/null +++ b/app/(landing)/(home)/_ui/top-sm-score-section/index.tsx @@ -0,0 +1,39 @@ +'use client' + +import classNames from 'classnames/bind' + +import useGetTopRankingSmScore from '../../_hooks/query/use-get-top-ranking-smscore' +import HomeSubtitle from '../home-subtitle' +import TopSmScoreCard from '../top-strategy-card/top-sm-score-card' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const TopSmScoreSection = () => { + const { data: topSmScoreStrategies } = useGetTopRankingSmScore() + + return ( +
+ 높은 SM 스코어별로 전략을 확인해보세요! + +
    + {topSmScoreStrategies && + topSmScoreStrategies.map((strategy, idx) => ( +
  • + 0 ? 'small' : 'large'} + ranking={idx + 1} + nickname={strategy.nickname} + title={strategy.strategyName} + chartData={strategy.profitRateChartData} + percentageChange={strategy.cumulativeProfitRate} + score={strategy.smScore} + /> +
  • + ))} +
+
+ ) +} + +export default TopSmScoreSection diff --git a/app/(landing)/(home)/_ui/top-sm-score-section/styles.module.scss b/app/(landing)/(home)/_ui/top-sm-score-section/styles.module.scss new file mode 100644 index 00000000..2de03be0 --- /dev/null +++ b/app/(landing)/(home)/_ui/top-sm-score-section/styles.module.scss @@ -0,0 +1,27 @@ +.section-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 48px; + padding-bottom: 90px; +} + +.strategy-wrapper { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + width: 940px; + + li { + &:nth-child(1) { + grid-column: 1; + grid-row: 1 / 3; + } + } + + @include tablet-md { + display: flex; + flex-direction: column; + align-items: center; + } +} diff --git a/app/(landing)/(home)/_ui/top-strategy-card/index.tsx b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx new file mode 100644 index 00000000..73907c20 --- /dev/null +++ b/app/(landing)/(home)/_ui/top-strategy-card/index.tsx @@ -0,0 +1,100 @@ +import { StarIcon } from '@/public/icons' +import classNames from 'classnames/bind' + +import Avatar from '@/shared/ui/avatar' + +import LineChart from '../line-chart' +import styles from './styles.module.scss' +import { + CardSizeType, + TopCardContentDetailsProps, + TopCardContentProps, + TopCardProfitChartProps, +} from './types' + +const cx = classNames.bind(styles) + +interface Props { + size?: CardSizeType + children: React.ReactNode +} + +const TopStrategyCard = ({ size, children }: Props) => { + return
{children}
+} + +const ContentsWrapper = ({ children }: { children: React.ReactNode }) => { + return
{children}
+} + +const Content = ({ ranking, profileImage, nickname, title }: TopCardContentProps) => { + return ( +
+ Top {ranking} +
+ + {nickname} +
+

{title}

+
+ ) +} + +const ContentDetails = ({ + subscriptionCount, + averageRating, + reviewCount, +}: TopCardContentDetailsProps) => { + return ( +
+ {subscriptionCount.toLocaleString()}명 구독 +
+ + + {averageRating} ({reviewCount}) + +
+
+ ) +} + +const SmScore = ({ score }: { score: number }) => { + return ( +
+ SM SCORE + {score} +
+ ) +} + +const ProfitChart = ({ + chartData, + profitAlign = 'horizontal', + percentageChange, + size, +}: TopCardProfitChartProps) => { + const isNegative = percentageChange < 0 + + return ( +
+
+ +
+
+ 누적수익률 + + {isNegative ? '' : '+'} + {percentageChange.toFixed(2)}% + +
+
+ ) +} + +export default Object.assign(TopStrategyCard, { + ContentsWrapper, + Content, + ContentDetails, + SmScore, + ProfitChart, +}) diff --git a/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss b/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss new file mode 100644 index 00000000..d1d9f731 --- /dev/null +++ b/app/(landing)/(home)/_ui/top-strategy-card/styles.module.scss @@ -0,0 +1,154 @@ +.card-container { + display: flex; + justify-content: space-between; + width: 300px; + height: 170px; + padding: 20px 28px 18px; + border-radius: 5px; + background-color: $color-white; + + &.large { + position: relative; + flex-direction: column; + height: 360px; + + .content-wrapper { + .title { + max-width: 100%; + } + } + + .score-wrapper { + position: absolute; + bottom: 19px; + } + + .profit-wrapper { + gap: 36px; + + .profit { + align-self: end; + } + } + + .chart { + margin: 0; + } + } +} + +.contents-wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.content-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + + .ranking { + @include typo-b1; + font-weight: $text-bold; + } + + .profile { + display: flex; + align-items: center; + + .nickname { + margin-left: 0.5rem; + color: $color-gray-500; + line-height: normal; + @include typo-c1; + } + } + + .title { + max-width: 128px; + @include typo-b2; + @include ellipsis(2); + line-height: normal; + } +} + +.content-details-wrapper { + display: flex; + align-items: center; + gap: 4px; + @include typo-c1; + font-weight: $text-semibold; + + .subscription { + margin-top: 2px; + color: $color-gray-800; + } + + .rating-wrapper { + display: flex; + align-items: center; + + svg { + color: $color-yellow; + } + + span { + margin-top: 2px; + color: $color-gray-400; + } + } +} + +.profit-wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + + .profit { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 2px; + + &.vertical { + flex-direction: column; + gap: 2px; + } + } + + .chart { + margin-top: 12px; + } + + .label { + color: $color-gray-700; + @include typo-b3; + } + + .value { + color: $color-orange-800; + } + + &.large { + height: 340px; + } +} + +.score-wrapper { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 2px; + line-height: normal; + + .label { + color: $color-gray-700; + @include typo-b3; + } + + .score { + color: $color-orange-800; + } +} diff --git a/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx b/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx new file mode 100644 index 00000000..c938c278 --- /dev/null +++ b/app/(landing)/(home)/_ui/top-strategy-card/top-favorite-card.tsx @@ -0,0 +1,42 @@ +'use client' + +import TopStrategyCard from '.' +import { TopStrategyCardCommonProps } from './types' + +interface Props extends TopStrategyCardCommonProps { + subscriptionCount: number + averageRating: number + reviewCount: number +} + +const TopFavoriteCard = ({ + ranking, + nickname, + title, + chartData, + percentageChange, + subscriptionCount, + averageRating, + reviewCount, +}: Props) => { + return ( + + + + + + + + ) +} + +export default TopFavoriteCard diff --git a/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx b/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx new file mode 100644 index 00000000..2285522a --- /dev/null +++ b/app/(landing)/(home)/_ui/top-strategy-card/top-sm-score-card.tsx @@ -0,0 +1,37 @@ +'use client' + +import TopStrategyCard from '.' +import { TopStrategyCardCommonProps } from './types' + +export type CardSizeType = 'small' | 'large' + +interface Props extends TopStrategyCardCommonProps { + size?: CardSizeType + score: number +} + +const TopSmScoreCard = ({ + size = 'small', + ranking, + nickname, + title, + chartData, + percentageChange, + score, +}: Props) => { + return ( + + + + + + + + ) +} + +export default TopSmScoreCard diff --git a/app/(landing)/(home)/_ui/top-strategy-card/types.ts b/app/(landing)/(home)/_ui/top-strategy-card/types.ts new file mode 100644 index 00000000..2e2ece81 --- /dev/null +++ b/app/(landing)/(home)/_ui/top-strategy-card/types.ts @@ -0,0 +1,30 @@ +export type ProfitAlignType = 'vertical' | 'horizontal' +export type CardSizeType = 'small' | 'large' + +export interface TopCardContentProps { + ranking: number + nickname: string + profileImage?: string + title: string +} + +export interface TopCardProfitChartProps { + chartData: number[] + profitAlign?: ProfitAlignType + percentageChange: number + size: CardSizeType +} + +export interface TopCardContentDetailsProps { + subscriptionCount: number + averageRating: number + reviewCount: number +} + +export interface TopStrategyCardCommonProps { + ranking: number + nickname: string + title: string + chartData: number[] + percentageChange: number +} diff --git a/app/(landing)/(home)/_ui/user-metrics-section/index.tsx b/app/(landing)/(home)/_ui/user-metrics-section/index.tsx new file mode 100644 index 00000000..aed9187d --- /dev/null +++ b/app/(landing)/(home)/_ui/user-metrics-section/index.tsx @@ -0,0 +1,39 @@ +'use client' + +import classNames from 'classnames/bind' + +import Spinner from '@/shared/ui/spinner' + +import useGetUserMetrics from '../../_hooks/query/use-get-user-metrics' +import HomeSubtitle from '../home-subtitle' +import MetricCard from '../metric-card' +import styles from './styles.module.scss' + +const cx = classNames.bind(styles) + +const UserMetricsSection = () => { + const { data: metrics, isLoading } = useGetUserMetrics() + + if (isLoading) { + return + } + + if (!metrics) { + return null + } + + return ( +
+ 이미 이렇게 많은 사람들이 주식 전략을 공유하고, 구독하고 있어요! + +
+ + + + +
+
+ ) +} + +export default UserMetricsSection diff --git a/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss b/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss new file mode 100644 index 00000000..e7316e0d --- /dev/null +++ b/app/(landing)/(home)/_ui/user-metrics-section/styles.module.scss @@ -0,0 +1,23 @@ +.card-wrapper { + display: flex; + gap: 20px; + justify-content: center; + margin-top: 36px; + + @include tablet-md { + & > div { + padding: 10px; + } + } + + @include mobile { + flex-wrap: wrap; + & > div { + width: calc(50% - 10px); + } + } +} + +.spinner { + margin: 200px 0; +} diff --git a/app/(landing)/(home)/page.module.scss b/app/(landing)/(home)/page.module.scss new file mode 100644 index 00000000..e69de29b diff --git a/app/(landing)/(home)/page.tsx b/app/(landing)/(home)/page.tsx new file mode 100644 index 00000000..ac7a069c --- /dev/null +++ b/app/(landing)/(home)/page.tsx @@ -0,0 +1,19 @@ +import AverageMetricsSection from './_ui/average-metrics-section' +import HeroSection from './_ui/hero-section' +import TopFavoriteSection from './_ui/top-favorite-section' +import TopSmScoreSection from './_ui/top-sm-score-section' +import UserMetricsSection from './_ui/user-metrics-section' + +const HomePage = () => { + return ( + <> + + + + + + + ) +} + +export default HomePage diff --git a/app/(landing)/(home)/types.ts b/app/(landing)/(home)/types.ts new file mode 100644 index 00000000..13e87c2f --- /dev/null +++ b/app/(landing)/(home)/types.ts @@ -0,0 +1,6 @@ +export interface UserMetricsModel { + totalInvestor: number + totalTrader: number + totalStrategies: number + totalSubscribe: number +} diff --git a/app/(landing)/layout.tsx b/app/(landing)/layout.tsx new file mode 100644 index 00000000..31af9c37 --- /dev/null +++ b/app/(landing)/layout.tsx @@ -0,0 +1,26 @@ +'use client' + +import { usePathname } from 'next/navigation' + +import { useAuthStore } from '@/shared/stores/use-auth-store' +import Footer from '@/shared/ui/footer' +import LogoHeader from '@/shared/ui/header/logo-header' + +interface Props { + children: React.ReactNode +} + +const LandingLayout = ({ children }: Props) => { + const pathname = usePathname() + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + const hasFooter = !pathname.includes('/signin') && !pathname.includes('/signup') + return ( + <> + +
{children}
+ {hasFooter &&