diff --git a/.eslintrc.js b/.eslintrc.js index 89b5764e..7999be84 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -15,7 +15,6 @@ module.exports = { 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/recommended', - 'plugin:jsx-a11y/recommended', 'plugin:prettier/recommended', 'next/core-web-vitals', 'next/typescript', diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml deleted file mode 100644 index fce223c4..00000000 --- a/.github/workflows/chromatic.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: 'Chromatic' -on: - pull_request: -jobs: - chromatic: - name: Run Chromatic - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: cache dependencies - id: cache - uses: actions/cache@v4 - with: - path: '**/node_modules' - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-storybook - - - name: Install dependencies - run: npm ci - - - name: Publish to Chromatic - id: chromatic - uses: chromaui/action@latest - with: - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - - - name: comment on PR - uses: thollander/actions-comment-pull-request@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - message: '🚀 Storybook읎 배포 됐얎요! 확읞하러 가Ʞ => ${{ steps.chromatic.outputs.storybookUrl }}' diff --git a/README.md b/README.md index b798611d..488fbc69 100644 --- a/README.md +++ b/README.md @@ -1 +1,12 @@ # 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)/strategies/[strategyId]/_ui/details-side-item/styles.module.scss b/app/(dashboard)/_ui/details-side-item/styles.module.scss similarity index 60% rename from app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/styles.module.scss rename to app/(dashboard)/_ui/details-side-item/styles.module.scss index 879b5812..b4393c12 100644 --- a/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/styles.module.scss +++ b/app/(dashboard)/_ui/details-side-item/styles.module.scss @@ -1,18 +1,24 @@ .side-item { - width: 276px; - height: 100px; - background-color: $color-white; - padding: 20px 20px 0; - border-top-left-radius: 5px; - border-top-right-radius: 5px; + height: 120px; + padding: 20px; +} - &.gap { - height: 120px; - margin-bottom: 20px; - border-radius: 5px; - 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; @@ -20,21 +26,21 @@ border-bottom: 0.75px solid $color-gray-500; padding-bottom: 12px; } - .data { display: flex; justify-content: space-between; align-items: center; padding-top: 12px; - .avatar { - display: flex; - align-items: center; - p { - margin-left: 11px; - } - } 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 index d2a76c5c..951168a3 100644 --- a/app/(dashboard)/_ui/navigation.tsx +++ b/app/(dashboard)/_ui/navigation.tsx @@ -8,14 +8,14 @@ import { TradersIcon, } from '@/public/icons' -import { fetchUser } from '@/shared/api/user' 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 = fetchUser() - const isTrader = user.role.includes('trader') + const { user } = useAuthStore() return ( @@ -25,7 +25,7 @@ const DashboardNavigation = () => { 튞레읎더 목록 - {isTrader && ( + {isTrader(user) && ( 나의 전략 diff --git a/app/(dashboard)/_ui/strategies-item/area-chart.tsx b/app/(dashboard)/_ui/strategies-item/area-chart.tsx index 0bd8134d..e03e012e 100644 --- a/app/(dashboard)/_ui/strategies-item/area-chart.tsx +++ b/app/(dashboard)/_ui/strategies-item/area-chart.tsx @@ -2,24 +2,29 @@ import dynamic from 'next/dynamic' +import classNames from 'classnames/bind' import Highcharts from 'highcharts' -import { ProfitRateChartDataModel } from '@/shared/types/strategy-details-data' +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[] + profitRateChartData: ProfitRateChartDataModel } -const AreaChart = ({ profitRateChartData }: Props) => { - const profit = profitRateChartData.map((data) => data.profitRate) +const AreaChart = ({ profitRateChartData: data }: Props) => { + if (!data) return
const chartOptions: Highcharts.Options = { chart: { type: 'areaspline', height: 100, - width: 180, backgroundColor: 'transparent', margin: [0, 0, 0, 0], }, @@ -29,14 +34,14 @@ const AreaChart = ({ profitRateChartData }: Props) => { }, yAxis: { visible: false, - min: Math.min(...profit), - max: Math.max(...profit), + min: Math.min(...data.profitRates), + max: Math.max(...data.profitRates), }, legend: { enabled: false }, plotOptions: { areaspline: { lineWidth: 1, - lineColor: '#4d4d4d', + lineColor: '#ff8f70', marker: { enabled: false, symbol: 'circle', @@ -52,14 +57,14 @@ const AreaChart = ({ profitRateChartData }: Props) => { series: [ { type: 'areaspline', - data: profitRateChartData.map((data) => ({ - x: new Date(data.date).getTime(), - y: data.profitRate, + 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, '#4d4d4d'], + [0, '#ff8f70'], [1, 'rgba(255, 255, 255, 0)'], ], }, @@ -67,9 +72,27 @@ const AreaChart = ({ profitRateChartData }: Props) => { ], credits: { enabled: false }, tooltip: { enabled: false }, + responsive: { + rules: [ + { + condition: { + maxWidth: 200, + }, + chartOptions: { + chart: { + width: null, + }, + }, + }, + ], + }, } - return + return ( +
+ +
+ ) } export default AreaChart diff --git a/app/(dashboard)/_ui/strategies-item/index.tsx b/app/(dashboard)/_ui/strategies-item/index.tsx index 09a00900..63a99ae4 100644 --- a/app/(dashboard)/_ui/strategies-item/index.tsx +++ b/app/(dashboard)/_ui/strategies-item/index.tsx @@ -1,51 +1,103 @@ -import Link from 'next/link' +import { useRouter } from 'next/navigation' -import AreaChart from '@/app/(dashboard)/_ui/strategies-item/area-chart' -import StrategiesSummary from '@/app/(dashboard)/_ui/strategies-item/strategies-summary' -import Subscribe from '@/app/(dashboard)/_ui/strategies-item/subscribe' import classNames from 'classnames/bind' -import { StrategiesModel } from '@/shared/types/strategy-details-data' +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 }: Props) => { +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 ( - - - -
-

{data.mdd}

-
-
-

{data.smScore}

-
-
- 누적 수익률 -

{data.cumulativeProfitLossRate}%

- 최귌 1년 수익률 -

{data.recentYearProfitLossRate ? data.recentYearProfitLossRate + '%' : '-'}

-
-
- -
- + <> + + + + )} + + + ) } 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 index 383993d3..8447e5d2 100644 --- a/app/(dashboard)/_ui/strategies-item/strategies-icon.tsx +++ b/app/(dashboard)/_ui/strategies-item/strategies-icon.tsx @@ -1,9 +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 { - icon: string[] | React.ElementType + iconUrls?: string[] + iconNames?: string[] + isDetailsPage?: boolean } -const StrategiesIcon = ({ icon }: Props) => { - return
[종목 & 맀맀 Icon]
+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 index fc65ad81..7211a056 100644 --- a/app/(dashboard)/_ui/strategies-item/strategies-item.stories.tsx +++ b/app/(dashboard)/_ui/strategies-item/strategies-item.stories.tsx @@ -1,7 +1,8 @@ -import StrategiesItem from '@/app/(dashboard)/_ui/strategies-item' import type { Meta, StoryFn } from '@storybook/react' -import { StrategiesModel } from '@/shared/types/strategy-details-data' +import { StrategiesModel } from '@/shared/types/strategy-data' + +import StrategiesItem from './index' const meta: Meta = { title: 'components/StrategiesItem', @@ -21,53 +22,72 @@ export const Primary = strategy.bind({}) Primary.args = { strategiesData: [ { - strategyId: '123', - strategyName: '늬치테크 FuturesDay', - nickname: 'MACS', - stockTypeIconUrl: [], - profitRateChartData: [ - { date: '2024-01-01', profitRate: 5.2 }, - { date: '2024-02-01', profitRate: 6.4 }, - { date: '2024-03-01', profitRate: 12.8 }, - { date: '2024-04-01', profitRate: 8.2 }, - { date: '2024-05-01', profitRate: 9.4 }, - { date: '2024-06-01', profitRate: 15.8 }, - ], - tradeTypeIconUrl: '', - mdd: '-20,580,856', - smScore: 60.6, - cumulativeProfitLossRate: 120.1, - recentYearProfitLossRate: 30.1, - subscriptionCnt: 23, + 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, - averageRating: 4.8, - totalReview: 12, }, { - strategyId: '12345', - strategyName: 'ETF 레버늬지/읞버', - nickname: '수밍', - stockTypeIconUrl: [], - profitRateChartData: [ - { date: '2023-12-01', profitRate: 7.2 }, - { date: '2024-01-01', profitRate: 5.2 }, - { date: '2024-02-01', profitRate: 25 }, - { date: '2024-03-01', profitRate: 12.8 }, - { date: '2024-04-01', profitRate: 17.2 }, - { date: '2024-05-01', profitRate: 11.4 }, - { date: '2024-06-01', profitRate: 20 }, - { date: '2024-07-01', profitRate: 16 }, - { date: '2024-08-01', profitRate: 18 }, - ], - tradeTypeIconUrl: '', - mdd: '-20,580,856', - smScore: 60.6, - cumulativeProfitLossRate: 120.1, - recentYearProfitLossRate: 30.1, - subscriptionCnt: 23, + 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, - averageRating: 4.8, - totalReview: 12, }, ], } diff --git a/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx b/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx index 18389d84..78455cbb 100644 --- a/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx +++ b/app/(dashboard)/_ui/strategies-item/strategies-summary.tsx @@ -1,9 +1,9 @@ -import StrategiesIcon from '@/app/(dashboard)/_ui/strategies-item/strategies-icon' 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) @@ -14,7 +14,8 @@ interface ProfileModel { } interface Props { - icon: string[] + iconUrls?: string[] + iconNames?: string[] title: string profile: ProfileModel subscriptionCount: number @@ -23,7 +24,8 @@ interface Props { } const StrategiesSummary = ({ - icon, + iconUrls, + iconNames, title, profile, subscriptionCount, @@ -32,7 +34,7 @@ const StrategiesSummary = ({ }: Props) => { return (
- +

{title}

diff --git a/app/(dashboard)/_ui/strategies-item/styles.module.scss b/app/(dashboard)/_ui/strategies-item/styles.module.scss index d36ed422..6f871b2d 100644 --- a/app/(dashboard)/_ui/strategies-item/styles.module.scss +++ b/app/(dashboard)/_ui/strategies-item/styles.module.scss @@ -1,17 +1,33 @@ .container { margin-bottom: 24px; display: grid; - grid-template-columns: 1.6fr 1.2fr repeat(3, 0.6fr) 0.4fr; + 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 { + .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; @@ -29,16 +45,20 @@ .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 { + & p { margin-left: 10px; @include typo-b2; } @@ -47,12 +67,10 @@ display: flex; align-items: center; height: 20px; - p { + gap: 6px; + padding-top: 10px; + & p { @include typo-c1; - padding-top: 10px; - &:first-child { - margin-right: 6px; - } } } } @@ -60,5 +78,41 @@ .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 index 183595ac..8e9d7a78 100644 --- a/app/(dashboard)/_ui/strategies-item/subscribe.tsx +++ b/app/(dashboard)/_ui/strategies-item/subscribe.tsx @@ -5,16 +5,26 @@ 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 = ({ subscriptionStatus }: Props) => { +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) { @@ -23,16 +33,28 @@ const Subscribe = ({ subscriptionStatus }: Props) => { }, [subscriptionStatus]) const handleSubscribe = (e: React.MouseEvent) => { - e.preventDefault() - setIsSubscribed(!isSubscribed) + if (user) { + e.stopPropagation() + if (user?.nickname === traderName) { + openModal() + } else { + e.preventDefault() + mutate(strategyId, { + onSuccess: () => setIsSubscribed(!isSubscribed), + }) + } + } } return ( -
- -
+ <> +
+ +
+ + ) } 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)/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 index 6d98eaf1..aa5d51c0 100644 --- a/app/(dashboard)/my/favorites/page.tsx +++ b/app/(dashboard)/my/favorites/page.tsx @@ -1,5 +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 <> + 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 index c0cd893f..da50ca52 100644 --- a/app/(dashboard)/my/profile/page.tsx +++ b/app/(dashboard)/my/profile/page.tsx @@ -1,5 +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 = () => { - return <></> + 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 index 5f164eef..4c219c08 100644 --- a/app/(dashboard)/my/questions/page.tsx +++ b/app/(dashboard)/my/questions/page.tsx @@ -1,5 +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 = () => { - return <></> + 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 index f3022b99..0e14f21c 100644 --- a/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx +++ b/app/(dashboard)/my/strategies/manage/[strategyId]/page.tsx @@ -1,5 +1,73 @@ -const StrategyManagePage = () => { - return <></> +'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 index 6f7e77b5..9b5e093c 100644 --- a/app/(dashboard)/my/strategies/page.tsx +++ b/app/(dashboard)/my/strategies/page.tsx @@ -1,5 +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 = () => { - return <></> + 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-side-item/details-side-item.stories.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/details-side-item.stories.tsx deleted file mode 100644 index b1563431..00000000 --- a/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/details-side-item.stories.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import DetailsSideItem, { - TitleType, -} from '@/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item' -import type { Meta, StoryFn } from '@storybook/react' - -const meta: Meta<typeof DetailsSideItem> = { - title: 'components/DetailsSideItem', - component: DetailsSideItem, - tags: ['autodocs'], -} - -const sideItem: StoryFn<{ data: { title: TitleType; data: string | number }[] }> = ({ data }) => ( - <div style={{ width: '100%', height: '100%', background: '#fafafa', padding: '20px' }}> - {data.map((item, idx) => ( - <DetailsSideItem - key={item.title} - title={item.title} - data={item.data} - hasGap={idx === 3 || idx === 5 ? false : true} - /> - ))} - </div> -) -export const Default = sideItem.bind({}) -Default.args = { - data: [{ title: '투자 원ꞈ', data: '10,000,000' }], -} - -export const Trader = sideItem.bind({}) -Trader.args = { - data: [{ title: '튞레읎더', data: '수밍' }], -} - -export const LayoutExample = sideItem.bind({}) -LayoutExample.args = { - data: [ - { title: '튞레읎더', data: '수밍' }, - { title: '최소 투자 ꞈ액', data: '1억 ~ 2억' }, - { title: '투자 원ꞈ', data: '10,000,000' }, - { title: 'KP Ratio', data: 0.3993 }, - { title: 'SM SCORE', data: 67.38 }, - { title: '최종손익입력음자', data: '2016.04.30' }, - { title: '등록음', data: '2016.02.30' }, - ], -} - -export default meta diff --git a/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/index.tsx b/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/index.tsx deleted file mode 100644 index b7ec9602..00000000 --- a/app/(dashboard)/strategies/[strategyId]/_ui/details-side-item/index.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import classNames from 'classnames/bind' - -import { PATH } from '@/shared/constants/path' -import Avatar from '@/shared/ui/avatar' -import { LinkButton } from '@/shared/ui/link-button' - -import styles from './styles.module.scss' - -const cx = classNames.bind(styles) - -export type TitleType = - | '튞레읎더' - | '최소 투자 ꞈ액' - | '투자 원ꞈ' - | 'KP Ratio' - | 'SM SCORE' - | '최종손익입력음자' - | '등록음' - -interface Props { - title: TitleType - data: string | number - imageUrl?: string - hasGap?: boolean -} - -const DetailsSideItem = ({ title, data, imageUrl, hasGap = true }: Props) => { - return ( - <div className={cx('side-item', hasGap && 'gap')}> - <div className={cx('title')}>{title}</div> - <div className={cx('data')}> - {title === '튞레읎더' ? ( - <> - <div className={cx('avatar')}> - <Avatar src={imageUrl} /> - <p>{data}</p> - </div> - <LinkButton href={PATH.MY_QUESTIONS} size="small" style={{ height: '30px' }}> - 묞의하Ʞ - </LinkButton> - </> - ) : ( - <p>{data}</p> - )} - </div> - </div> - ) -} - -export default DetailsSideItem 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 index 1fffbb1f..d928a094 100644 --- a/app/(dashboard)/strategies/[strategyId]/page.tsx +++ b/app/(dashboard)/strategies/[strategyId]/page.tsx @@ -1,5 +1,112 @@ -const StrategyDetailPage = () => { - return <></> +'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 index 95276f9b..a12301c3 100644 --- a/app/(dashboard)/strategies/page.tsx +++ b/app/(dashboard)/strategies/page.tsx @@ -1,5 +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 <></> + 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 index 66a91ecf..390556e1 100644 --- a/app/(dashboard)/traders/[traderId]/page.tsx +++ b/app/(dashboard)/traders/[traderId]/page.tsx @@ -1,5 +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 = () => { - return <></> + 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 index 77fbb01d..1d5cbef4 100644 --- a/app/(dashboard)/traders/page.tsx +++ b/app/(dashboard)/traders/page.tsx @@ -1,5 +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 = () => { - return <></> + 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 index 7740671c..2c6bb469 100644 --- a/app/(landing)/(home)/_ui/line-chart/index.tsx +++ b/app/(landing)/(home)/_ui/line-chart/index.tsx @@ -4,7 +4,7 @@ import dynamic from 'next/dynamic' import Highcharts from 'highcharts' -import { CardSizeType } from '../sm-score-card/index' +import { CardSizeType } from '../top-strategy-card/types' const HighchartsReact = dynamic(() => import('highcharts-react-official'), { ssr: false, @@ -17,7 +17,7 @@ interface Props { } const getChartDimensions = (size: CardSizeType) => ({ - height: size === 'small' ? 55 : 165, + height: size === 'small' ? 55 : 120, width: size === 'small' ? 90 : 185, }) 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/sm-score-card/index.tsx b/app/(landing)/(home)/_ui/sm-score-card/index.tsx deleted file mode 100644 index 15f35322..00000000 --- a/app/(landing)/(home)/_ui/sm-score-card/index.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client' - -import classNames from 'classnames/bind' - -import Avatar from '@/shared/ui/avatar' - -import LineChart from '../line-chart' -import styles from './styles.module.scss' - -const cx = classNames.bind(styles) - -export type CardSizeType = 'small' | 'large' - -interface Props { - name: string - title: string - score: number - percentageChange: number - chartData: number[] - ranking: number - profileImage?: string - size?: CardSizeType -} - -const ScoreCard = ({ - name, - title, - score, - percentageChange, - chartData, - ranking, - profileImage, - size = 'small', -}: Props) => { - const isNegative = percentageChange < 0 - - return ( -
-
-
-
Top {ranking}
-
- - {name} -
-

{title}

-
-
- -
-
-
-
- SM SCORE - {score.toFixed(2)} -
-
- 누적수익률 - - {isNegative ? '' : '+'} - {percentageChange}% - -
-
-
- ) -} - -export default ScoreCard diff --git a/app/(landing)/(home)/_ui/sm-score-card/sm-score-card.stories.tsx b/app/(landing)/(home)/_ui/sm-score-card/sm-score-card.stories.tsx deleted file mode 100644 index f8d51e08..00000000 --- a/app/(landing)/(home)/_ui/sm-score-card/sm-score-card.stories.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' - -import ScoreCard from './index' - -const meta = { - title: 'Components/ScoreCard', - component: ScoreCard, - parameters: { - layout: 'centered', - backgrounds: { - default: 'dark', - values: [ - { - name: 'dark', - value: '#000000', - }, - ], - }, - }, - tags: ['autodocs'], - argTypes: { - size: { - control: 'radio', - options: ['small', 'large'], - }, - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -} satisfies Meta - -export default meta -type StoryType = StoryObj - -const mockChartData = [8, 15, 10, 17, 15, 19, 25, 20] - -export const Default: StoryType = { - args: { - name: '낮 읎늄은 김닀은', - title: '낮 전략은 엄청나', - score: 60.63, - percentageChange: 37, - chartData: mockChartData, - ranking: 1, - size: 'small', - profileImage: 'https://lh3.googleusercontent.com/a/your-image-id', - }, -} - -export const LargeCard: StoryType = { - args: { - ...Default.args, - size: 'large', - }, -} - -export const NegativeChange: StoryType = { - args: { - name: '나 김닀은 아니닀', - title: '낮 전략은 엄청나ㅋ', - score: 50.63, - percentageChange: -12, - chartData: [20, 19, 17, 18, 15, 13, 11, 8], - ranking: 4, - profileImage: 'https://lh3.googleusercontent.com/a/your-image-id', - }, -} - -export const LayoutExample: StoryType = { - args: { - name: '낮 읎늄은 김닀은', - title: '낮 전략은 엄청나', - score: 60.63, - percentageChange: 37, - chartData: [8, 15, 10, 17, 15, 19, 25, 20], - ranking: 1, - size: 'large', - profileImage: 'https://lh3.googleusercontent.com/a/your-image-id', - }, - decorators: [ - () => ( -
-
-
- -
-
- {[2, 3, 4, 5].map((ranking) => ( - - ))} -
-
-
- ), - ], -} diff --git a/app/(landing)/(home)/_ui/sm-score-card/styles.module.scss b/app/(landing)/(home)/_ui/sm-score-card/styles.module.scss deleted file mode 100644 index 8d571acd..00000000 --- a/app/(landing)/(home)/_ui/sm-score-card/styles.module.scss +++ /dev/null @@ -1,108 +0,0 @@ -.card-wrapper { - background-color: $color-white; - border-radius: 5px; - padding: 14px 18px; - width: 300px; - min-width: 300px; - display: flex; - flex-direction: column; - - &.small { - height: 160px; - } - - &.large { - height: 340px; - } -} - -.top-frame { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - flex: 1; - - &.large { - flex-direction: column; - align-items: flex-start; - } -} - -.content-area { - display: flex; - flex-direction: column; - gap: 14px; -} - -.rank { - font-size: $text-b1; - font-weight: $text-bold; - color: $color-black; -} - -.profile { - display: flex; - align-items: center; - - .profile-name { - font-size: $text-c1; - color: $color-gray-500; - font-weight: $text-medium; - margin-left: 0.5rem; - } -} - -.title { - font-size: $text-b2; - font-weight: $text-semibold; - color: $color-black; - margin-top: -0.5rem; - @include ellipsis(2); -} - -.chart-area { - display: flex; - align-items: center; - flex: 1; - justify-content: center; - - &.large { - width: 100%; - } - - &.small { - margin-left: 2rem; - } -} - -.bottom-frame { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 14px; -} - -.sm-score, -.profit { - display: flex; - gap: 0.5rem; - align-items: center; - font-size: $text-b2; - font-weight: $text-semibold; - margin-bottom: 7px; - - .label { - color: $color-gray-700; - } - - .value { - &.negative { - color: $color-indigo; - } - - &:not(.negative) { - color: $color-orange-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.tsx b/app/(landing)/(home)/page.tsx index ae3650fe..ac7a069c 100644 --- a/app/(landing)/(home)/page.tsx +++ b/app/(landing)/(home)/page.tsx @@ -1,5 +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 <>Home + 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 index 5015777f..31af9c37 100644 --- a/app/(landing)/layout.tsx +++ b/app/(landing)/layout.tsx @@ -1,13 +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 HomeLayout = ({ children }: Props) => { +const LandingLayout = ({ children }: Props) => { + const pathname = usePathname() + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + const hasFooter = !pathname.includes('/signin') && !pathname.includes('/signup') return ( <> +
{children}
+ {hasFooter &&