diff --git a/README.md b/README.md index 2573f1c..af4989c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ https://zzansuni-fe-vercel.vercel.app/ 사용자들에게 챌린지에 대해 공유 할 수 있으며, 랭킹을 통해 서로 경쟁할 수 있습니다. +### 개발자 소개 + +| 백엔드 | 백엔드 | 프론트엔드 | 프론트엔드 | 백엔드 | +| :----------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------: | +| [](https://github.com/momnpa333) | [](https://github.com/kwonssshyeon) | [](https://github.com/Dobbymin) | [](https://github.com/joojjang) | [](https://github.com/bayy1216) | +| 권다운 | 권수현 | 김강민 | 김민주 | 손홍석 | + ### 개발 동기 기획 단계에서 팀원들과 다양한 아이디어에 대해 생각을 해 보았고, 공통적으로 diff --git a/package.json b/package.json index 89bc908..47a2e72 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "tsc && vite build", - "port": "vite --host 0.0.0.0 --port 5173", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" }, diff --git a/src/apis/challenge-record/challenge.record.api.ts b/src/apis/challenge-record/challenge.record.api.ts index c6578e7..00b3121 100644 --- a/src/apis/challenge-record/challenge.record.api.ts +++ b/src/apis/challenge-record/challenge.record.api.ts @@ -1,3 +1,5 @@ +import { AxiosError } from 'axios'; + import { multiPartClient, axiosClient } from '../AxiosClient'; import { ApiResponse, @@ -9,34 +11,42 @@ import { // POST: /api/challenges/{challengeId}/verification export async function postVerification( - id: number, - image: File, - content: string -): Promise { - const formData = new FormData(); - formData.append( + challengeId: number, + content: string, + image: File +): Promise { + const requestBody = new FormData(); + requestBody.append( 'body', new Blob([JSON.stringify({ content })], { type: 'application/json' }) ); - formData.append('image', image); + requestBody.append('image', image); const response = await multiPartClient.post( - `api/challenges/${id}/verifications`, - formData + `api/challenges/${challengeId}/verification`, + requestBody ); - console.log('postVerification response: ', response.data); - return response.data; + console.log('postVerification response: ', response.data); // test } -// GET: /api/challenges/{challengeId}/record export async function getChallengeRecord( - id: number + challengeId: number ): Promise { - const response = await axiosClient.get>( - `api/challenges/${id}/record` - ); - console.log('getChallengeRecord response: ', response.data); - return response.data.data; + try { + const response = await axiosClient.get( + `api/challenges/${challengeId}/record` + ); + // console.log('getChallengeRecord response: ', response.data); // test + return response.data.data; + } catch (error) { + if (error instanceof AxiosError) { + throw new Error( + `getChallengeRecord error: ${error.response?.data.message || error.message}` + ); + } else { + throw new Error('getChallengeRecord error: unexpected'); + } + } } // GET: /api/challenges/record/{recordId} @@ -50,11 +60,22 @@ export async function getChallengeRecordDetailorigin( return response.data.data; } -export async function getChallengeRecordDetail( +export async function getChallengeRecordDetails( recordId: number ): Promise { - const response = await axiosClient.get(`api/challenges/record/${recordId}`); - return response.data; + try { + const response = await axiosClient.get(`api/challenges/record/${recordId}`); + // console.log('getChallengeRecordDetails response: ', response.data); // test + return response.data.data; + } catch (error) { + if (error instanceof AxiosError) { + throw new Error( + `getChallengeRecord error: ${error.response?.data.message || error.message}` + ); + } else { + throw new Error('getChallengeRecord error: unexpected'); + } + } } // GET: /api/user/challenges/completes diff --git a/src/apis/challenge-record/challenge.record.response.ts b/src/apis/challenge-record/challenge.record.response.ts index edf347f..8273a39 100644 --- a/src/apis/challenge-record/challenge.record.response.ts +++ b/src/apis/challenge-record/challenge.record.response.ts @@ -6,29 +6,19 @@ export type ApiResponse = { }; export type ChallengeRecordData = { - result: string; - data: { - title: string; - totalCount: number; - successCount: number; - startDate: string; - endDate: string; - recordIds: number[]; - }; - message: string; - errorCode: string; + title: string; + totalCount: number; + successCount: number; + startDate: string; + endDate: string; + recordIds: number[]; }; export type ChallengeRecordDetailData = { - result: string; - data: { - id: 0; - createdAt: string; - content: string; - imageUrl: string; - }; - message: string; - errorCode: string; + id: 0; + createdAt: string; + content: string; + imageUrl: string; }; export type CompleteChallengeData = { diff --git a/src/apis/recently-review/getRecentlyReview.api.ts b/src/apis/recently-review/getRecentlyReview.api.ts new file mode 100644 index 0000000..ff46c42 --- /dev/null +++ b/src/apis/recently-review/getRecentlyReview.api.ts @@ -0,0 +1,27 @@ +import { RecentlyReviewResponse } from './getRecentlyReview.response'; +import { axiosClient } from '@/apis/AxiosClient'; +import { useQuery } from '@tanstack/react-query'; + +const getRecentlyReviewPath = () => '/api/challengeGroups/shorts'; + +const recentlyReviewQueryKey = [getRecentlyReviewPath()]; + +const getRecentlyReview = async ( + page: number, + size: number +): Promise => { + const response = await axiosClient.get(getRecentlyReviewPath(), { + params: { + page, + size, + }, + }); + return response.data; +}; + +export const useGetRecentlyReview = (page: number, size: number) => { + return useQuery({ + queryKey: [recentlyReviewQueryKey, page, size], + queryFn: () => getRecentlyReview(page, size), + }); +}; diff --git a/src/apis/recently-review/getRecentlyReview.response.ts b/src/apis/recently-review/getRecentlyReview.response.ts new file mode 100644 index 0000000..3d69449 --- /dev/null +++ b/src/apis/recently-review/getRecentlyReview.response.ts @@ -0,0 +1,24 @@ +import ApiResponse from '@/apis/ApiResponse'; + +export type RecentlyReviewResponse = { + totalPage: number; + hasNext: boolean; + data: { + challengeId: number; + challengeTitle: string; + user: { + id: number; + nickname: string; + profileImageUrl: string; + tierInfo: { + tier: string; + totalExp: number; + currentExp: number; + }; + }; + content: string; + rating: number; + }[]; +}; + +export type ChallengeListResponse = ApiResponse; diff --git a/src/assets/shorts/shorts-img.png b/src/assets/shorts/shorts-img.png new file mode 100644 index 0000000..32fd298 Binary files /dev/null and b/src/assets/shorts/shorts-img.png differ diff --git a/src/assets/stamp-active.svg b/src/assets/stamp-active.svg new file mode 100644 index 0000000..5e72aaf --- /dev/null +++ b/src/assets/stamp-active.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/stamp-inactive.svg b/src/assets/stamp-inactive.svg new file mode 100644 index 0000000..0015328 --- /dev/null +++ b/src/assets/stamp-inactive.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/components/common/challenge-title/index.tsx b/src/components/common/challenge-title/index.tsx new file mode 100644 index 0000000..380d529 --- /dev/null +++ b/src/components/common/challenge-title/index.tsx @@ -0,0 +1,29 @@ +import { Text } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +type ChallengeTitleProps = { + category: string; + title: string; +}; + +const ChallengeTitle = ({ category, title }: ChallengeTitleProps) => { + return ( + + + {category} + + + {title} + + + ); +}; + +export default ChallengeTitle; + +const Wrapper = styled.div` + margin: 16px; + display: flex; + flex-direction: column; + text-align: left; +`; diff --git a/src/components/common/cta/index.tsx b/src/components/common/cta/index.tsx index ca8ad21..dbffe59 100644 --- a/src/components/common/cta/index.tsx +++ b/src/components/common/cta/index.tsx @@ -16,6 +16,8 @@ const CTA = ({ label, disabled, onClick }: CTAProps) => { export default CTA; +export const CTA_CONTAINER_HEIGHT = '4rem'; + const StyledCTA = styled.button<{ disabled?: boolean }>` width: calc(100% - 16px); // 부모 요소의 좌우 padding 빼고 border: none; @@ -53,7 +55,8 @@ export const CTAContainer = styled.div` bottom: 0; display: flex; width: 100%; - height: 4rem; + height: ${CTA_CONTAINER_HEIGHT}; padding: 8px 16px; background-color: var(--color-white); + z-index: 1; `; diff --git a/src/components/common/form/textarea/index.tsx b/src/components/common/form/textarea/index.tsx index c2b1b2d..08716e4 100644 --- a/src/components/common/form/textarea/index.tsx +++ b/src/components/common/form/textarea/index.tsx @@ -1,20 +1,41 @@ +import { Text } from '@chakra-ui/react'; import styled from '@emotion/styled'; type TextareaProps = { placeholder?: string; value: string; onChange: (e: React.ChangeEvent) => void; + minValueLength?: number; valid?: boolean; }; -const Textarea = ({ placeholder, value, onChange, valid }: TextareaProps) => { +const Textarea = ({ + placeholder, + value, + onChange, + minValueLength, + valid, +}: TextareaProps) => { return ( - + <> + + {/* 최소 길이 제한 있을 때만 보이기 */} + {minValueLength && ( + + {value.length} / 최소 {minValueLength}자 + + )} + ); }; @@ -29,10 +50,10 @@ const StyledTextarea = styled.textarea<{ valid?: boolean }>` ? 'var(--color-grey-02) 1px solid' : 'var(--color-class-05) 1px solid'}; padding: 12px; - width: 100%; height: 180px; resize: none; outline: none; + margin: 0 16px; &::placeholder { color: var(--color-grey-01); diff --git a/src/components/common/tabs/tab-panels/index.tsx b/src/components/common/tabs/tab-panels/index.tsx index 220e035..a2f3564 100644 --- a/src/components/common/tabs/tab-panels/index.tsx +++ b/src/components/common/tabs/tab-panels/index.tsx @@ -27,19 +27,20 @@ export const TabPanel = ({ children, value, selectedIndex }: TabPanelProps) => { }; export const StyledTabPanels = styled.div` - height: 100%; width: 100%; position: relative; text-align: center; + display: flex; + flex-direction: column; + flex: 1; `; export const StyledTabPanel = styled.div<{ active: boolean; }>` display: ${(p) => (p.active ? 'flex' : 'none')}; - font-size: 2rem; flex-direction: column; + flex: 1; width: 100%; - height: 100%; - justify-content: center; + font-size: 2rem; `; diff --git a/src/components/features/layout/nav-bar/index.tsx b/src/components/features/layout/nav-bar/index.tsx index 03dffe0..9289dc7 100644 --- a/src/components/features/layout/nav-bar/index.tsx +++ b/src/components/features/layout/nav-bar/index.tsx @@ -1,7 +1,7 @@ import { useNavigate } from 'react-router-dom'; import { navBarData } from '@/constants/nav-bar'; -import { Box } from '@chakra-ui/react'; +import { Box, Image } from '@chakra-ui/react'; import styled from '@emotion/styled'; export const NAVBAR_HEIGHT = '3.44rem'; @@ -14,14 +14,10 @@ const NavBar = () => { }; return ( - + {navBarData.map((item) => ( - - handleNav(item.path)} - /> + handleNav(item.path)}> + ))} @@ -44,20 +40,17 @@ const Wrapper = styled(Box)` background-color: #fafafa; `; -const Tab = styled.div` - width: 50%; +const Tab = styled.a` + width: 50%; // 요소마다 부모 요소의 너비를 균등하게 차지하도록 height: 100%; - display: inline-flex; + display: flex; justify-content: center; align-items: center; + cursor: pointer; `; -const Icon = styled.button<{ src: string }>` +const IconImage = styled(Image)` width: 2rem; height: 2rem; outline: none; - background-image: url(${({ src }) => src}); - background-size: cover; - background-position: center; - background-repeat: no-repeat; `; diff --git a/src/components/features/layout/top-bar/page-bar.tsx b/src/components/features/layout/top-bar/page-bar.tsx index 3020a30..7af2507 100644 --- a/src/components/features/layout/top-bar/page-bar.tsx +++ b/src/components/features/layout/top-bar/page-bar.tsx @@ -60,7 +60,7 @@ const PageBarLayout = styled(Box)<{ padding: 0.5rem; gap: 1rem; background-color: ${(props) => props.backgroundColor}; - z-index: 1000; + z-index: 1; position: sticky; top: ${({ show }) => (show ? '0' : '-100px')}; transition: top 0.3s; diff --git a/src/pages/challenge-detail/index.tsx b/src/pages/challenge-detail/index.tsx index 3652cbb..d3961ee 100644 --- a/src/pages/challenge-detail/index.tsx +++ b/src/pages/challenge-detail/index.tsx @@ -7,6 +7,7 @@ import { RankingSection } from './ranking-section/'; import { ReviewSection } from './review-section/'; import { type ChallengeDetailData } from '@/apis/challenge-detail/challenge.detail.response'; import DefaultImage from '@/assets/Default-Image.svg'; +import ChallengeTitle from '@/components/common/challenge-title'; import { Tabs, Tab } from '@/components/common/tabs'; import { TabPanels, TabPanel } from '@/components/common/tabs/tab-panels'; import TopBar from '@/components/features/layout/top-bar'; @@ -79,12 +80,12 @@ const ChallengeDetailPage = () => { )} - - - {formatCategory(data?.category)} - {data?.title} - - + {data && ( + + )} {tabsList.map((t, index) => ( @@ -140,20 +141,3 @@ export const StyledDefaultImage = styled.img` object-fit: cover; opacity: 50%; `; - -export const ChallengeTitleWrapper = styled.div` - margin: 16px; - display: flex; - flex-direction: column; - text-align: left; -`; - -export const Category = styled.div` - font-size: var(--font-size-xs); - color: var(--color-green-01); -`; - -export const Title = styled.div` - font-size: var(--font-size-xl); - font-weight: bold; -`; diff --git a/src/pages/challenge-list/components/contents/index.tsx b/src/pages/challenge-list/components/contents/index.tsx index 2aa0b64..b9e4f11 100644 --- a/src/pages/challenge-list/components/contents/index.tsx +++ b/src/pages/challenge-list/components/contents/index.tsx @@ -9,6 +9,8 @@ type Props = { startDate: string; endDate: string; participantCount: number; + id: number; + onClick: (id: number) => void; }; const Contents = ({ @@ -17,11 +19,14 @@ const Contents = ({ startDate, endDate, participantCount, + onClick, + id, }: Props) => { const [isClicked, setIsClicked] = useState(false); const handleBoxClick = () => { setIsClicked(!isClicked); + onClick(id); }; const date = `${startDate} ~ ${endDate}`; diff --git a/src/pages/challenge-list/index.tsx b/src/pages/challenge-list/index.tsx index 4ea2249..8d0da1c 100644 --- a/src/pages/challenge-list/index.tsx +++ b/src/pages/challenge-list/index.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import Contents from './components/contents'; import { useGetChallengeList } from '@/apis/challenge-list/getChallengeList.api'; @@ -23,6 +24,12 @@ const ChallengeList = () => { const [allData, setAllData] = useState([]); const [page, setPage] = useState(0); + const navigate = useNavigate(); + + const handleNavigate = (id: number) => { + navigate(`/challenge/${id}`); + }; + const categoryList = useMemo( () => [ { label: '에코', data: 'ECHO' }, @@ -102,6 +109,8 @@ const ChallengeList = () => { startDate={challenge.startDate} endDate={challenge.endDate} participantCount={challenge.participantCount} + onClick={() => handleNavigate(challenge.id)} + id={challenge.id} /> ))} diff --git a/src/pages/challenge-record/components/bottom-sheet/index.tsx b/src/pages/challenge-record/components/bottom-sheet/index.tsx deleted file mode 100644 index 8394b4b..0000000 --- a/src/pages/challenge-record/components/bottom-sheet/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { motion, PanInfo } from 'framer-motion'; - -import useBottomSheet from '@/hooks/useBottomSheet'; -import { Box, Image } from '@chakra-ui/react'; -import styled from '@emotion/styled'; - -type Props = { - data: { - id: 0; - createdAt: string; - content: string; - imageUrl: string; - } | null; - isOpen: boolean; - onDragEnd: ( - event: MouseEvent | TouchEvent | PointerEvent, - info: PanInfo - ) => void; -}; - -const BottomSheet = ({ data, isOpen, onDragEnd }: Props) => { - const { controls } = useBottomSheet(isOpen); - - if (!isOpen || !data) { - return null; - } - - return ( - - - - {data.createdAt.substr(0, 10)} - - - - {data.content} - Detail - - - - ); -}; - -export default BottomSheet; - -const BottomSheetBox = styled(Box)` - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 100; -`; - -const Wrapper = styled(motion.div)` - z-index: 101; - flex-direction: column; - position: fixed; - left: 0; - right: 0; - bottom: -50px; - border-top-left-radius: 20px; - border-top-right-radius: 20px; - background-color: var(--color-green-06); - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.6); - height: 70%; - width: 100%; - transition: transform 150ms ease-out; - margin: 0 auto; - overflow: auto; -`; - -const HeaderWrapper = styled(motion.div)` - z-index: 102; - height: 20px; - border-top-left-radius: 20px; - border-top-right-radius: 20px; - position: relative; - padding: 15px; - background-color: var(--color-green-01); - color: var(--color-white); - display: flex; - flex-direction: row; -`; - -const HandleBar = styled(motion.div)` - z-index: 103; - position: absolute; - left: 50%; - margin-left: -16px; - width: 32px; - height: 2px; - border-radius: 2px; - background-color: var(--color-white); -`; - -const Text = styled.div` - font-size: var(--font-size-lg); - align-self: center; -`; - -const ContentWrapper = styled.div` - padding: 20px; - display: flex; - flex-direction: column; - gap: 20px; -`; - -const SubText = styled.div` - font-size: var(--font-size-md); - text-align: left; -`; diff --git a/src/pages/challenge-record/components/caution/index.tsx b/src/pages/challenge-record/components/caution/index.tsx new file mode 100644 index 0000000..faa585d --- /dev/null +++ b/src/pages/challenge-record/components/caution/index.tsx @@ -0,0 +1,43 @@ +import { Text } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +const Caution = () => { + return ( + + + 유의 사항 + + +
  • 모든 스탬프를 모으면 챌린지를 완수하게 됩니다.
  • +
  • + 스탬프는 하루 1개로 제한됩니다. (동일 챌린지를 하루에 여러 번 + 인증하더라도 1회만 인정됩니다.) +
  • +
  • + 명시된 횟수를 초과한 경우 챌린지 완수로 인정되나 추가 인증에 대한 + 스탬프나 포인트는 제공되지 않습니다. +
  • +
  • + 사진 조작, 타인의 계정 이용 등의 부정 행위 적발 시 해당 계정은 강제 + 탈퇴되며 추후 서비스 이용에 제한이 있을 수 있습니다. +
  • +
  • 스탬프가 정상 인증되지 않는 경우 운영 측으로 문의해주세요.
  • +
    +
    + ); +}; + +export default Caution; + +const Wrapper = styled.div` + padding: 24px 16px; + background-color: var(--color-class-01); + color: var(--color-black); + text-align: left; + margin-top: auto; +`; diff --git a/src/pages/challenge-record/components/record-item/index.tsx b/src/pages/challenge-record/components/record-item/index.tsx new file mode 100644 index 0000000..cdddcac --- /dev/null +++ b/src/pages/challenge-record/components/record-item/index.tsx @@ -0,0 +1,145 @@ +import { motion, PanInfo } from 'framer-motion'; + +import { ChallengeRecordDetailData } from '@/apis/challenge-record/challenge.record.response'; +import { HEADER_HEIGHT } from '@/components/features/layout/top-bar'; +import useBottomSheet from '@/hooks/useBottomSheet'; +import { formatDate } from '@/utils/formatters'; +import { Image, Text } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +type RecordItemProps = { + data: ChallengeRecordDetailData; + recordIndex: number; + isOpen: boolean; + onDragEnd: ( + event: MouseEvent | TouchEvent | PointerEvent, + info: PanInfo + ) => void; +}; + +const RecordItem = ({ + data, + recordIndex, + isOpen, + onDragEnd, +}: RecordItemProps) => { + const { controls } = useBottomSheet(isOpen); + + if (!isOpen || !data) { + return null; + } + + return ( + + + + + + + + {recordIndex}회차 인증 + + + {formatDate(data.createdAt)} + + {data.content} + + + + + ); +}; + +export default RecordItem; + +const Wrapper = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: flex-end; // 자식이 하단에 붙게 + z-index: 100; + overflow: hidden; +`; + +const Inner = styled(motion.div)` + z-index: 101; + width: 100%; + min-height: 60%; + max-height: calc(100vh - ${HEADER_HEIGHT}); + overflow-y: auto; + position: absolute; + left: 0; + right: 0; + bottom: 0; + border-radius: 20px 20px 0 0; + background-color: var(--color-green-06); + transition: transform 150ms ease-out; + margin: 0 auto; +`; + +const Handle = styled(motion.div)` + z-index: 102; + height: 36px; + position: relative; + padding: 16px; + background-color: var(--color-green-01); + color: var(--color-white); +`; + +const HandleBar = styled(motion.div)` + z-index: 103; + position: absolute; + top: 10px; + transform: translateX(-50%); + left: 50%; + width: 40px; + height: 4px; + border-radius: 10px; + background-color: var(--color-green-06); +`; + +const Content = styled.div` + padding: 12px 16px 16px 20px; + display: flex; + flex-direction: column; + align-items: start; + position: relative; +`; diff --git a/src/pages/challenge-record/components/stamp-board/index.tsx b/src/pages/challenge-record/components/stamp-board/index.tsx deleted file mode 100644 index f03a1cb..0000000 --- a/src/pages/challenge-record/components/stamp-board/index.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { useState, useEffect } from 'react'; - -import BottomSheet from '../bottom-sheet'; -import Stamp from '../stamp'; -import { - getChallengeRecord, - getChallengeRecordDetail, -} from '@/apis/challenge-record/challenge.record.api'; -import { - ChallengeRecordData, - ChallengeRecordDetailData, -} from '@/apis/challenge-record/challenge.record.response'; -import { Box, Text } from '@chakra-ui/react'; -import styled from '@emotion/styled'; - -const StampBoard = () => { - const [isBottomSheetOpen, setBottomSheetOpen] = useState(false); - const [data, setData] = useState(null); - const [items, setItems] = useState([]); - const [record, setRecord] = useState< - ChallengeRecordDetailData['data'] | null - >(null); - - const setArray = (length: number, values: number[]) => { - const array = new Array(length).fill(-1); - for (let i = 0; i < values.length; i++) { - array[i] = values[i]; - } - setItems(array); - }; - - const chunkItems = (arr: number[], chunkSize: number): number[][] => { - const chunks = []; - const last = arr.length % chunkSize === 1 ? chunkSize + 1 : 0; - for (let i = 0; i < arr.length - last; i += chunkSize) - chunks.push(arr.slice(i, i + chunkSize)); - - for (let i = arr.length - last; i < arr.length; i += chunkSize - 1) - chunks.push(arr.slice(i, i + (chunkSize - 1))); - return chunks; - }; - - const rows = chunkItems(items, 3); - - const toggleBottomSheet = async (idx: number) => { - if (idx === -1) { - setBottomSheetOpen(false); - console.log('Closing BottomSheet'); - } else { - try { - const response = await getChallengeRecordDetail(idx); - setRecord(response.data); - setBottomSheetOpen(true); - } catch (error) { - console.error('Failed to fetch challenge record detail:', error); - } - } - }; - - const handleDragEnd = ( - _event: MouseEvent | TouchEvent | PointerEvent, - info: { offset: { x: number; y: number } } - ) => { - if (info.offset.y > 100) { - setBottomSheetOpen(false); - } - }; - - useEffect(() => { - const fetchChallengeRecord = async () => { - try { - const response = await getChallengeRecord(18); - setData(response.data); - setArray(response.data.totalCount, response.data.recordIds); - } catch (error) { - console.error('Failed to fetch challenge record:', error); - } - }; - - fetchChallengeRecord(); - }, []); - - return ( - <> - {data ? ( - - - {data.title} - - - - 챌린지 인증하고 레벨업! -
    - 짠돌이가 응원해요 -
    - - 챌린지 유효기간 {data.startDate} ~ {data.endDate} - - {rows.map((row, rowIndex) => ( - - {row.map((item, index) => ( - { - toggleBottomSheet(item); - }} - > - - - ))} - - ))} -
    -
    - ) : ( -
    - )} - - - - 유의사항 - - - 별도의 규칙이 없는 한 스탬프는 하루 한개로 제한됩니다. (동일 챌린지에 - 하루 여러건 참여한 것은 인정되지 않습니다.) -
    - 모든 스탬프를 찍은 후 챌린지가 완료되며, 일부만 수행한 경우 챌린지가 - 완료로 표시되지 않습니다. -
    - 명시된 횟수를 초과한 경우 챌린지 완료로 인정되나 추가 인증에 대한 - 포인트는 제공되지 않습니다. -
    - 사진 조작, 타인의 계정 이용등의 부정행위가 적발될 시 해당 계정은 자동 - 탈퇴되며 추후 서비스 이용에 제한이 있을 수 있습니다. -
    - 스탬프가 정상 인증되지 않는경우 고객센터로 문의하세요 -
    -
    -
    - - - - ); -}; - -export default StampBoard; - -const StampBoardBox = styled(Box)` - margin: 30px; - margin-bottom: 50px; - display: flex; - flex-direction: column; - text-align: left; - overflow-y: auto; - scrollbar-color: transparent transparent; - &::-webkit-scrollbar { - display: none; - } -`; - -const StampBox = styled.div` - border-radius: 20px; - text-align: center; - padding: 10px 20px; - background-color: var(--color-green-06); - margin-bottom: calc(100% - 250px); -`; - -const CautionWrapper = styled.div` - margin-bottom: 50px; - padding: 25px; - background-color: var(--color-grey-01); - color: var(--color-white); - text-align: left; -`; - -const Item = styled.div<{ rowLength: number }>` - width: 100%; - flex: 1; - text-align: center; - - ${({ rowLength }) => - rowLength === 1 && - ` - flex: 1 1 100%; - `} - ${({ rowLength }) => - rowLength === 2 && - ` - flex: 1 1 calc(50% - 10px); - `} - ${({ rowLength }) => - rowLength === 3 && - ` - flex: 1 1 calc(33.33% - 20px); - `} -`; diff --git a/src/pages/challenge-record/components/stamp/index.tsx b/src/pages/challenge-record/components/stamp/index.tsx index 1ce9cc6..607665c 100644 --- a/src/pages/challenge-record/components/stamp/index.tsx +++ b/src/pages/challenge-record/components/stamp/index.tsx @@ -1,19 +1,22 @@ -import StampActive from '@/assets/StampActive.svg'; -import StampInActive from '@/assets/StampInactive.svg'; +import StampActive from '@/assets/stamp-active.svg'; +import StampInActive from '@/assets/stamp-inactive.svg'; import { Image } from '@chakra-ui/react'; import styled from '@emotion/styled'; type StampProps = { - data: number; + id: number; + onClick?: () => void; }; -const Stamp = ({ data }: StampProps) => { +const Stamp = ({ id, onClick }: StampProps) => { + const active = id !== -1 ? true : false; // -1이면 inactive + return ( <> - {data === -1 ? ( - + {active ? ( + ) : ( - + )} ); @@ -21,8 +24,7 @@ const Stamp = ({ data }: StampProps) => { export default Stamp; -const StampImage = styled(Image)` - width: 80px; - height: 80px; - margin: 5px auto; +const StyledStamp = styled(Image)<{ active: boolean }>` + aspect-ratio: 1 / 1; + cursor: ${({ active }) => (active ? 'pointer' : null)}; `; diff --git a/src/pages/challenge-record/components/verification/index.tsx b/src/pages/challenge-record/components/verification/index.tsx deleted file mode 100644 index a00d96f..0000000 --- a/src/pages/challenge-record/components/verification/index.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { postVerification } from '@/apis/challenge-record/challenge.record.api'; -import { RouterPath } from '@/routes/path'; -import { Image, Text } from '@chakra-ui/react'; -import styled from '@emotion/styled'; - -const Verification = () => { - const fileInput = useRef(null); - const [previewImg, setPreviewImg] = useState(null); - const [text, setText] = useState(''); - const navigate = useNavigate(); - - const saveHandler = async () => { - try { - if (previewImg) { - const response = await postVerification(18, previewImg, text); - if (response.status === 200) { - console.log('응답: ', response); - alert('성공적으로 저장했습니다.'); - navigate(RouterPath.root); - } - } else { - alert('이미지를 선택하세요.'); - } - } catch (error) { - alert('저장에 실패했습니다.'); - } - }; - - const handleButtonClick = () => { - fileInput.current?.click(); - }; - - const imageHandler = (fileBlob: File) => { - setPreviewImg(fileBlob); // File 객체를 직접 설정합니다. - - // 미리보기를 위해 FileReader를 사용합니다. - const reader = new FileReader(); - reader.readAsDataURL(fileBlob); - reader.onload = () => { - if (reader.result && typeof reader.result === 'string') { - // 여기서 미리보기 이미지 URL을 설정합니다. - const imgElement = document.querySelector( - '#previewImage' - ) as HTMLImageElement; - if (imgElement) { - imgElement.src = reader.result; - } - } - }; - }; - - return ( - - - 길에 떨어진 쓰레기 줍기 챌린지 - - setText(e.target.value)} - /> - {!previewImg && ( - - { - if (e.target.files) { - imageHandler(e.target.files[0]); - } - }} - /> - 사진추가 - - )} - {previewImg && }{' '} - {/* 미리보기 이미지 */} - 참여하기 - - ); -}; - -export default Verification; - -// Styled components -const VerificationBox = styled.div` - display: flex; - margin: 2.5rem 0; - flex-direction: column; - text-align: left; - - overflow-y: auto; - scrollbar-color: transparent transparent; - &::-webkit-scrollbar { - display: none; - } -`; - -const InputArea = styled.textarea` - font-size: var(--font-size-sm); - border-radius: 20px; - border: var(--color-green-01) 1px solid; - padding: 10px; - height: 30vh; - resize: none; - :focus { - outline: none; - } -`; - -const AddImageBtn = styled.div` - border-radius: 20px; - background-color: #fff; - font-size: var(--font-size-md); - color: var(--color-green-01); - width: 100%; - display: flex; - align-items: center; - justify-content: center; - height: 45px; - border: var(--color-green-01) 1px solid; - margin-top: 30px; -`; - -const SummitButton = styled.button` - display: flex; - align-items: center; - justify-content: center; - padding: 0 1rem; - height: 50px; - margin-top: 30px; - border-radius: 20px; - background-color: var(--color-green-01); - color: #fff; - font-size: var(--font-size-md); - font-weight: bold; - border: none; -`; - -const PreviewImage = styled(Image)` - margin-top: 30px; - margin-bottom: 60px; - border-radius: 20px; - width: 100%; - object-fit: cover; - border: none; -`; - -export const ModalBack = styled.div` - position: fixed; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: rgba(0, 0, 0, 0.8); -`; - -export const ModalBackDrop = styled.div` - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 60%; - height: 20%; - background-color: var(--color-white); - border-radius: 10px; - display: flex; - justify-content: center; - align-items: center; - z-index: 100; - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); -`; diff --git a/src/pages/challenge-record/index.tsx b/src/pages/challenge-record/index.tsx index f9c0059..59bd32e 100644 --- a/src/pages/challenge-record/index.tsx +++ b/src/pages/challenge-record/index.tsx @@ -1,33 +1,43 @@ import { useState } from 'react'; -import StampBoard from './components/stamp-board'; -import Verification from './components/verification'; -import { Tabs, Tab } from '@/components/common/tabs'; -import { TabPanels, TabPanel } from '@/components/common/tabs/tab-panels'; -import TopBar from '@/components/features/layout/top-bar'; +import Records from './records'; +import Verification from './verification'; +import ChallengeTitle from '@/components/common/challenge-title'; +import { Tab, Tabs } from '@/components/common/tabs'; +import { TabPanel, TabPanels } from '@/components/common/tabs/tab-panels'; +import TopBar, { HEADER_HEIGHT } from '@/components/features/layout/top-bar'; import styled from '@emotion/styled'; +const SAMPLE_CATEGORY = '에코'; +const SAMPLE_TITLE = '환경 정화 활동'; + const ChallengeRecord = () => { - const [activeTab, setActiveTab] = useState(0); + const [activeTab, setActiveTab] = useState(() => { + // 세션 스토리지에 저장된 값 | 기본값 0 + const savedTab = sessionStorage.getItem('activeTab'); + return savedTab ? Number(savedTab) : 0; + }); const tabsList = [ { label: '인증 기록', - panel: , + panel: , }, { label: '인증하기', - panel: , + panel: , }, ]; const handleSelectedTab = (value: number) => { setActiveTab(value as 0 | 1); + sessionStorage.setItem('activeTab', String(value)); }; return ( <> + {tabsList.map((t, index) => ( @@ -48,6 +58,8 @@ const ChallengeRecord = () => { export default ChallengeRecord; const Wrapper = styled.div` - display: block; - width: 100%; + min-height: calc(100vh - ${HEADER_HEIGHT}); + display: flex; + flex-direction: column; + flex: 1; `; diff --git a/src/pages/challenge-record/records/index.tsx b/src/pages/challenge-record/records/index.tsx new file mode 100644 index 0000000..734f1f7 --- /dev/null +++ b/src/pages/challenge-record/records/index.tsx @@ -0,0 +1,195 @@ +import { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; + +import Caution from '../components/caution'; +import RecordItem from '../components/record-item'; +import Stamp from '../components/stamp'; +import { + getChallengeRecord, + getChallengeRecordDetails, +} from '@/apis/challenge-record/challenge.record.api'; +import { + ChallengeRecordData, + ChallengeRecordDetailData, +} from '@/apis/challenge-record/challenge.record.response'; +import { formatDate } from '@/utils/formatters'; +import { Text } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +const Records = () => { + const { id } = useParams(); + const challengeId = Number(id); + + const [data, setData] = useState(); // api 응답 데이터 전체 + const [recordIdList, setRecordIdList] = useState([]); + const [recordDetails, setRecordDetails] = + useState(); // 인증기록 상세 + const [isRecordItemOpen, setIsRecordItemOpen] = useState(false); + + // recordId를 recordList에 추가하는 함수 + const fillRecordList = (length: number, values: number[]) => { + const newRecordIdList = new Array(length).fill(-1); // 모두 -1로 초기화 + for (let i = 0; i < values.length; i++) { + newRecordIdList[i] = values[i]; // recordId로 바꾸기 + } + setRecordIdList(newRecordIdList); + }; + + // 기록 리스트 페칭 + useEffect(() => { + getChallengeRecord(challengeId) + .then((res) => { + setData(res); + fillRecordList(res.totalCount, res.recordIds); + }) + .catch((error) => { + console.error('Error fetching records:', error); + }); + }, [challengeId]); + + // 각각의 인증기록을 펼치는 핸들러 + const handleStampClick = (recordId: number) => { + if (recordId === -1) { + // Stamp 안에서 분기 처리 되어 있긴 한데. + setIsRecordItemOpen(false); + console.log('Closed the record item'); + } else { + getChallengeRecordDetails(recordId) + .then((res) => { + setRecordDetails(res); + setIsRecordItemOpen(true); + }) + .catch((error) => { + console.error('Error fetching record details:', error); + }); + } + }; + + // 바텀시트 열렸을 때 overflow-y 숨기기 + useEffect(() => { + if (isRecordItemOpen) { + document.body.style.overflowY = 'hidden'; + } else { + document.body.style.overflowY = ''; + } + + // 컴포넌트 언마운트 시 기본 값으로 되돌리기 + return () => { + document.body.style.overflowY = ''; + }; + }, [isRecordItemOpen]); + + // 인증기록 (RecordItem) 닫는 핸들러 + const handleDragEnd = ( + _event: MouseEvent | TouchEvent | PointerEvent, + info: { offset: { x: number; y: number } } + ) => { + if (info.offset.y > 100) { + setIsRecordItemOpen(false); + } + }; + + return ( + + + + 챌린지 인증하고 레벨 업! +
    + 짠돌이가 응원해요 +
    + {data && ( + <> + + {/* 참여 기간 */} + 참여 기간 + + {formatDate(data.startDate)} ~ {formatDate(data.endDate)} + + + {/* 인증 횟수 */} + 인증 횟수 + + {data.successCount} +  / {data.totalCount}회 + + + + + {recordIdList.map((recordId, index) => ( + { + handleStampClick(recordId); + }} + /> + ))} + + + )} +
    + + {recordDetails && ( + + )} + + +
    + ); +}; + +export default Records; + +const Wrapper = styled.div` + margin: 16px 0 0 0; + display: flex; + flex-direction: column; + flex: 1; + scrollbar-color: transparent transparent; + &::-webkit-scrollbar { + display: none; + } +`; + +const StampBoard = styled.div` + margin: 0 16px 32px; + padding: 24px 16px; + display: flex; + flex-direction: column; + text-align: center; + border-radius: 20px; + border: 1px solid var(--color-grey-02); + background-color: var(--color-white); +`; + +const InfoGrid = styled.div` + align-self: center; + display: grid; + grid-template-columns: max-content max-content; + grid-gap: 0 16px; + margin: 0 0 24px 0; + line-height: 2; + font-size: var(--font-size-sm); + + .highlight { + font-weight: 600; + color: var(--color-green-01); + } +`; + +const StampGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px 0; + justify-items: center; + align-items: center; +`; diff --git a/src/pages/challenge-record/verification/index.tsx b/src/pages/challenge-record/verification/index.tsx new file mode 100644 index 0000000..6710a8f --- /dev/null +++ b/src/pages/challenge-record/verification/index.tsx @@ -0,0 +1,193 @@ +import { useEffect, useRef, useState } from 'react'; +import { MdDeleteForever } from 'react-icons/md'; +import { useParams } from 'react-router-dom'; + +import Caution from '../components/caution'; +import { postVerification } from '@/apis/challenge-record/challenge.record.api'; +import CTA, { CTAContainer } from '@/components/common/cta'; +import Textarea from '@/components/common/form/textarea'; +import { Image } from '@chakra-ui/react'; +import styled from '@emotion/styled'; + +const MIN_CONTENT_LENGTH = 20; + +const Verification = () => { + const { id } = useParams(); + const challengeId = Number(id); + + const fileInput = useRef(null); + const [content, setContent] = useState(''); + const [isContentValid, setIsContentValid] = useState(true); + const [image, setImage] = useState(null); + const [isUploadDisabled, setIsUploadDisabled] = useState(true); + + // 폼 유효성 검사 -> 버튼 상태 관리 + useEffect(() => { + if (image && content.trim() && content.length >= MIN_CONTENT_LENGTH) { + setIsUploadDisabled(false); + } else { + setIsUploadDisabled(true); + } + }, [image, content]); + + const handleUploadImage = () => { + fileInput.current?.click(); + }; + + const handleImage = (fileBlob: File) => { + setImage(fileBlob); // File 객체를 직접 설정합니다. + + // 미리보기를 위해 FileReader를 사용합니다. + const reader = new FileReader(); + reader.readAsDataURL(fileBlob); + reader.onload = () => { + if (reader.result && typeof reader.result === 'string') { + // 여기서 미리보기 이미지 URL을 설정합니다. + const imgElement = document.querySelector( + '#previewImage' + ) as HTMLImageElement; + if (imgElement) { + imgElement.src = reader.result; + } + } + }; + }; + + const handleDeleteImage = (e: React.MouseEvent) => { + e.preventDefault(); + setImage(null); + }; + + // 내용 유효성 검사 + const handleContentChange = (e: React.ChangeEvent) => { + const newContent = e.target.value; + setContent(newContent); + // console.log(content); // test + + if (newContent.trim() && newContent.length >= MIN_CONTENT_LENGTH) { + setIsContentValid(true); + } else { + setIsContentValid(false); + } + // console.log(isContentValid); // test + }; + + const handleSave = async () => { + if (image) { + postVerification(challengeId, content, image) + .then(() => { + alert('챌린지 인증이 등록되었습니다!'); + handleSelectedTab(0); // 인증 기록 탭으로 이동 + }) + .catch((error) => { + // API에서 받은 오류 객체일 경우 + if (error?.result === 'FAIL') { + alert(error.message || '다시 시도해주세요.'); + } else { + // 예상치 못한 오류 처리 + alert('다시 시도해주세요.'); + } + }); + } + }; + + const handleSelectedTab = (value: number) => { + // setActiveTab(value as 0 | 1); + sessionStorage.setItem('activeTab', String(value)); + }; + + return ( + <> + +