diff --git a/src/apis/client/getFeedbacks.ts b/src/apis/client/getFeedbacks.ts new file mode 100644 index 00000000..9f973a69 --- /dev/null +++ b/src/apis/client/getFeedbacks.ts @@ -0,0 +1,15 @@ +import { DOMAIN } from '@/constants/api'; +import { GetFeedbacksResponse } from '@/types/apis/feedback/GetFeedbacks'; +import { axiosInstanceClient } from '../axiosInstanceClient'; + +export const getFeedbacks = async (planId: number) => { + const { data } = await axiosInstanceClient.get( + DOMAIN.GET_FEEDBACKS(planId), + { + params: { + planId, + }, + }, + ); + return data; +}; diff --git a/src/apis/client/postFeedback.ts b/src/apis/client/postFeedback.ts deleted file mode 100644 index df120c7c..00000000 --- a/src/apis/client/postFeedback.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DOMAIN } from '@/constants/api'; -import { PostFeedbackRequest } from '@/types/apis/feedback/PostFeedbaks'; -import { axiosInstanceClient } from '../axiosInstanceClient'; - -export const postFeedback = async ({ - feedbackId, - body, -}: PostFeedbackRequest) => { - const { data } = await axiosInstanceClient.post( - DOMAIN.POST_FEEDBACKS(feedbackId), - { - ...body, - }, - ); - return data; -}; diff --git a/src/apis/client/postFeedbacks.ts b/src/apis/client/postFeedbacks.ts new file mode 100644 index 00000000..ad89d3a4 --- /dev/null +++ b/src/apis/client/postFeedbacks.ts @@ -0,0 +1,13 @@ +import { DOMAIN } from '@/constants'; +import { PostFeedbacksRequest } from '@/types/apis/feedback/PostFeedbaks'; +import { axiosInstanceClient } from '../axiosInstanceClient'; + +export const postFeedbacks = async ({ planId, body }: PostFeedbacksRequest) => { + const { data } = await axiosInstanceClient.post( + DOMAIN.POST_FEEDBACKS(planId), + { + ...body, + }, + ); + return data; +}; diff --git a/src/app/(header)/home/_components/Plan/Plan.tsx b/src/app/(header)/home/_components/Plan/Plan.tsx index 8720c113..7eeeca8f 100644 --- a/src/app/(header)/home/_components/Plan/Plan.tsx +++ b/src/app/(header)/home/_components/Plan/Plan.tsx @@ -36,15 +36,15 @@ export default function Plan({

{title}

달성률: {achieveRate}%

- 피드백 보기 (업데이트 중!) + 피드백 보기 diff --git a/src/app/feedback/[planId]/_components/index.scss b/src/app/feedback/[planId]/_components/index.scss new file mode 100644 index 00000000..2086a265 --- /dev/null +++ b/src/app/feedback/[planId]/_components/index.scss @@ -0,0 +1,65 @@ +.feedback { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + overflow-y: scroll; + margin-left: var(--scroll-margin-left); + padding: 2rem 0; + &__breadcrumb { + display: flex; + align-self: flex-start; + gap: 0.5rem; + a { + text-decoration: none; + color: var(--origin-text-100); + } + } + &__title { + margin-top: 2rem; + margin-bottom: 0rem; + } + &__circular-progressbar { + width: 4rem; + height: 4rem; + .CircularProgressbar-text { + font-size: 2rem; + fill: var(--origin-text-100); + } + .CircularProgressbar-path { + stroke: var(--origin-primary); + stroke-linecap: round; + } + .CircularProgressbar-trail { + stroke: var(--origin-secondary); + } + .CircularProgressbar-path { + stroke: var(--origin-primary); + } + } + &__evaluate { + width: 100%; + display: flex; + align-items: center; + gap: 1rem; + margin: 1rem 0; + &-item { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; + max-height: calc(100% - 14rem); + overflow-y: scroll; + margin-left: var(--scroll-margin-left); + padding-bottom: 1rem; + } + } + &__back-to-plans { + width: 100%; + text-decoration: none; + position: absolute; + bottom: 6rem; + max-width: calc(100% - 3rem); + } +} diff --git a/src/app/feedback/[planId]/page.tsx b/src/app/feedback/[planId]/page.tsx new file mode 100644 index 00000000..fe3480f5 --- /dev/null +++ b/src/app/feedback/[planId]/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { Button } from '@/components'; +import FeedbackItem from '@/components/FeedbackItem/FeedbackItem'; +import { useGetFeedbacksQuery } from '@/hooks/apis/useGetFeedbacksQuery'; +import { useScroll } from '@/hooks/useScroll'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { CircularProgressbar } from 'react-circular-progressbar'; +import 'react-circular-progressbar/dist/styles.css'; +import './_components/index.scss'; + +export default function FeedbackPage({ + params, +}: { + params: { planId: string }; +}) { + const { planId } = params; + const { feedback } = useGetFeedbacksQuery(parseInt(planId, 10)); + const { achieveRate, title, feedbacks } = feedback; + const { handleScroll, scrollableRef } = useScroll(); + let plan_evaluate_text = ''; + + if (achieveRate >= 80) { + plan_evaluate_text = '매우 잘 지키고 있어요!'; + } else if (achieveRate >= 50) { + plan_evaluate_text = '잘 지키고 있어요!'; + } else { + plan_evaluate_text = '잘 지켜주세요!'; + } + + return ( +
+
+ 홈 + > + 내 계획 + > + 피드백 +
+
+ 피드백 +
+
+ +
+

+ {title} +

+ 계획을 + + {plan_evaluate_text} + +
+
+
+ {feedbacks.map((item) => { + return ( + + ); + })} +
+ + + +
+ ); +} diff --git a/src/app/feedback/evaluate/_components/EvaluateRadio.tsx b/src/app/feedback/evaluate/_components/EvaluateRadio.tsx new file mode 100644 index 00000000..9f8ca7da --- /dev/null +++ b/src/app/feedback/evaluate/_components/EvaluateRadio.tsx @@ -0,0 +1,48 @@ +import classNames from 'classnames'; +import { ChangeEvent } from 'react'; + +type EvaluateRadioProps = { + evaluateOption: number; + setEvaluateOption: React.Dispatch>; +}; + +type Option = { + value: string; + label: string; +}; + +export default function EvaluateRadio({ + evaluateOption, + setEvaluateOption, +}: EvaluateRadioProps) { + const options: Option[] = [ + { value: '100', label: '매우 잘함 (100%)' }, + { value: '75', label: '잘 함 (75%)' }, + { value: '50', label: '보통 (50%)' }, + { value: '25', label: '대체로 못함 (25%)' }, + { value: '0', label: '전혀 못함 (0%)' }, + ]; + + const handleOptionChange = (event: ChangeEvent) => { + setEvaluateOption(parseInt(event.target.value, 10)); + }; + + return ( +
+ {options.map((option) => ( + <> + +

+ + ))} +
+ ); +} diff --git a/src/app/feedback/evaluate/_components/index.scss b/src/app/feedback/evaluate/_components/index.scss new file mode 100644 index 00000000..f5d4e9b2 --- /dev/null +++ b/src/app/feedback/evaluate/_components/index.scss @@ -0,0 +1,54 @@ +.feedback { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + overflow-y: scroll; + margin-left: var(--scroll-margin-left); + padding: 2rem 0; + gap: 2rem; + &__breadcrumb { + display: flex; + gap: 0.5rem; + a { + text-decoration: none; + color: var(--origin-text-100); + } + } + &__send { + width: 100%; + text-decoration: none; + position: absolute; + bottom: 6rem; + max-width: calc(100% - 3rem); + } + &__content { + width: 100%; + } +} +.evaluate-radio { + appearance: none; + display: flex; + flex-direction: column; + margin: 1.5rem 0; + label { + display: flex; + align-items: center; + &:hover { + cursor: pointer; + } + } + input[type='radio'] { + appearance: none; + width: 0.9rem; + height: 0.9rem; + border-radius: 100%; + margin-right: 0.5rem; + border: 1px solid var(--origin-primary); + + &:checked { + background-color: var(--origin-primary); + } + } +} diff --git a/src/app/feedback/evaluate/page.tsx b/src/app/feedback/evaluate/page.tsx new file mode 100644 index 00000000..85666067 --- /dev/null +++ b/src/app/feedback/evaluate/page.tsx @@ -0,0 +1,135 @@ +'use client'; + +import { Button, Modal, ModalBasic, PlanInput } from '@/components'; +import WrongApproach from '@/components/WrongApproach/WrongApproach'; +import { usePostFeedbacksMutation } from '@/hooks/apis/usePostFeedbacksMutation'; +import { useScroll } from '@/hooks/useScroll'; +import { AxiosError } from 'axios'; +import classNames from 'classnames'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import EvaluateRadio from './_components/EvaluateRadio'; +import './_components/index.scss'; + +export default function FeedbackPage() { + const searchParams = useSearchParams(); + const title = searchParams.get('title'); + const month = searchParams.get('month'); + const day = searchParams.get('day'); + const planId = searchParams.get('planId'); + const { handleScroll, scrollableRef } = useScroll(); + + const [evaluateOption, setEvaluateOption] = useState(100); + const [evaluateMessage, setEvaluateMessage] = useState(''); + const [errorCode, setErrorCode] = useState(); + const [errorMessage, setErrorMessage] = useState(''); + const [isFeedbackSendModalOpen, setIsFeedbackSendModalOpen] = useState(false); + const { mutate: postFeedbacks, error } = usePostFeedbacksMutation( + parseInt(planId as string, 10), + ); + + if (error) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + if (status && status !== errorCode) { + setErrorCode(status); + switch (status) { + case 400: + setErrorMessage('피드백 기간이 아닙니다!'); + break; + case 409: + setErrorMessage('이미 평가된 피드백입니다!'); + break; + } + } + } + + const handleChangeMessage = (changedMessage: string) => { + setEvaluateMessage(changedMessage); + }; + + const handleModalClickNo = () => { + setIsFeedbackSendModalOpen(false); + }; + const handleModalClickYes = () => { + postFeedbacks({ + planId: parseInt(planId as string, 10), + body: { rate: evaluateOption, message: evaluateMessage }, + }); + }; + const handleModalOpen = () => { + setIsFeedbackSendModalOpen(true); + }; + + return ( +
+
+ 홈 + > + 내 계획 + > + 피드백 + > + 피드백 하기 +
+ {title && month && day && planId && !errorCode ? ( + <> +
+ {title} + 에 대한 +

피드백을 완료해주세요!

+
+
+

+ {month}월 {day}일까지 계획을 얼마나 잘 이행했나요? +

+ + +
+ + {isFeedbackSendModalOpen && ( + + + 피드백을 완료하게 되면 수정할 수 없습니다.

정말 해당 + 피드백을 완료하시겠습니까? +
+
+ )} + + ) : ( + <> + {errorMessage.length ? ( + + ) : ( + + )} + + )} +
+ ); +} diff --git a/src/components/FeedbackItem/FeedbackItem.tsx b/src/components/FeedbackItem/FeedbackItem.tsx new file mode 100644 index 00000000..fe82d728 --- /dev/null +++ b/src/components/FeedbackItem/FeedbackItem.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { Icon, RemindInput } from '@/components'; +import { FeedbackItemData } from '@/types/Feedbacks'; +import classNames from 'classnames'; +import Link from 'next/link'; +import React, { useMemo, useState } from 'react'; +import './index.scss'; + +interface FeedbackItemProps { + data: FeedbackItemData; + title: string; + planId: number; + remindTime: number; + createdYear: number; + classNameList?: string[]; +} + +export default function FeedbackItem({ + data, + title, + planId, + remindTime, + createdYear, + classNameList = [], +}: FeedbackItemProps) { + const { achieve, message, remindMonth, remindDate, reminded } = data; + const currentDate = new Date(); + const targetDate = + remindMonth < 12 + ? new Date(createdYear, remindMonth, remindDate, remindTime, 0, 0) + : new Date(createdYear, 11, 31, 23, 59, 59); + const expired = currentDate >= targetDate; + const canCheckRemindMessage = useMemo(() => { + return reminded && (expired || message.length); + }, [reminded, expired, message.length]); + + const [isItemOpened, setIsItemOpened] = useState(false); + + const toggleIsItemOpened = () => { + if (canCheckRemindMessage) { + setIsItemOpened(!isItemOpened); + } + }; + return ( + <> + {reminded && !message.length && !expired ? ( + <> + + {remindMonth}월 {remindDate}일 피드백하기 + +
+ + {remindMonth < 12 ? ( +

+ {remindMonth + 1}월 {remindDate}일 {remindTime - 1}시 59분까지 + 피드백 가능 +

+ ) : ( +

12월 31일 23시 59분까지 피드백 가능

+ )} +
+ + ) : ( +
  • +
    +

    + {remindMonth}월 {remindDate}일 피드백 +

    + {expired && ( +

    {achieve}%

    + )} + {canCheckRemindMessage ? ( + + ) : ( + + )} +
    + + {isItemOpened && ( +
    + +
    + )} +
  • + )} + + ); +} diff --git a/src/components/FeedbackItem/index.scss b/src/components/FeedbackItem/index.scss new file mode 100644 index 00000000..520dd2b7 --- /dev/null +++ b/src/components/FeedbackItem/index.scss @@ -0,0 +1,70 @@ +.feedback-item { + display: flex; + flex-direction: column; + width: 100%; + border-radius: var(--border-radius); + border: 2px solid var(--origin-secondary); + box-shadow: var(--origin-shadow); + cursor: pointer; + + &--disabled { + pointer-events: none; + background-color: var(--origin-secondary); + } + + &__header { + display: flex; + flex-direction: column; + position: relative; + padding: 0.75rem 1rem; + justify-content: center; + align-items: center; + cursor: pointer; + + &__title { + overflow: hidden; + white-space: nowrap; + + &--lock { + color: var(--origin-background); + } + } + &__percent { + position: absolute; + right: 3rem; + } + + &__icon { + position: absolute; + right: 1rem; + } + } + &__link { + text-decoration: none; + padding: 0.5rem 1rem; + text-align: center; + box-shadow: var(--origin-shadow); + } + &__button { + height: 2.5rem; + } + + &__time { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + } + + &__message { + display: none; + flex-direction: column; + align-items: center; + border-radius: 0 0 12px 12px; + padding: 0rem 1.25rem 1rem 1rem; + + &--open { + display: flex; + } + } +} diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx index c95d1f3b..51f5e294 100644 --- a/src/components/Icon/Icon.tsx +++ b/src/components/Icon/Icon.tsx @@ -38,6 +38,7 @@ const ICON_NAME_MAP = { FAVORITE: 'favorite', ARROW_UP: 'arrow_upward', COPY: 'content_copy', + ARROW_RIGHT: 'subdirectory_arrow_right', }; interface IconProps { diff --git a/src/components/WrongApproach/WrongApproach.tsx b/src/components/WrongApproach/WrongApproach.tsx new file mode 100644 index 00000000..f2758239 --- /dev/null +++ b/src/components/WrongApproach/WrongApproach.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components'; +import classNames from 'classnames'; +import Image from 'next/image'; +import Link from 'next/link'; +import './index.scss'; + +export default function WrongApproach({ message }: { message?: string }) { + return ( +
    +
    + wrong-approach + {message ? ( +

    {message}

    + ) : ( +

    잘못된 접근입니다!

    + )} + + + + +
    +
    + ); +} diff --git a/src/components/WrongApproach/index.scss b/src/components/WrongApproach/index.scss new file mode 100644 index 00000000..4ec1d995 --- /dev/null +++ b/src/components/WrongApproach/index.scss @@ -0,0 +1,23 @@ +.wrong-approach { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + &__wrapper { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2rem; + margin: 4rem 0; + } + &__image { + width: 100%; + object-fit: contain; + } + &__back-to-home { + width: 100%; + text-decoration: none; + } +} diff --git a/src/constants/api.ts b/src/constants/api.ts index 87904485..774f2b1b 100644 --- a/src/constants/api.ts +++ b/src/constants/api.ts @@ -12,8 +12,7 @@ export const DOMAIN = { GET_USERS: '/users', POST_FEEDBACKS: (feedbackId: number) => `/feedbacks/${feedbackId}`, - GET_FEEDBACKS: (userId: number) => `/feedbacks/${userId}`, - GET_FEEDBACKS_EACH: (planId: number) => `/mock/${planId}/feedbacks`, + GET_FEEDBACKS: (planId: number) => `/feedbacks/${planId}`, POST_REISSUE: '/reissue', POST_LOGIN: `/login`, diff --git a/src/constants/queryKey.ts b/src/constants/queryKey.ts index eb3f2a93..3ce653a6 100644 --- a/src/constants/queryKey.ts +++ b/src/constants/queryKey.ts @@ -4,4 +4,5 @@ export const QUERY_KEY = { PLAN: 'getPlan', REMIND: 'getRemind', USER_INFORMATION: 'getUserInformation', + FEEDBACKS: 'getFeedbacks', }; diff --git a/src/hooks/apis/useGetFeedbacksQuery.ts b/src/hooks/apis/useGetFeedbacksQuery.ts new file mode 100644 index 00000000..da12deff --- /dev/null +++ b/src/hooks/apis/useGetFeedbacksQuery.ts @@ -0,0 +1,14 @@ +'use client'; + +import { getFeedbacks } from '@/apis/client/getFeedbacks'; +import { QUERY_KEY } from '@/constants'; +import { useSuspenseQuery } from '@tanstack/react-query'; + +export const useGetFeedbacksQuery = (planId: number) => { + const { data } = useSuspenseQuery({ + queryKey: [{ planId: planId }, QUERY_KEY.FEEDBACKS], + queryFn: () => getFeedbacks(planId), + staleTime: Infinity, + }); + return { feedback: data!.data }; +}; diff --git a/src/hooks/apis/usePostFeedbackMutation.ts b/src/hooks/apis/usePostFeedbacksMutation.ts similarity index 61% rename from src/hooks/apis/usePostFeedbackMutation.ts rename to src/hooks/apis/usePostFeedbacksMutation.ts index a6a9b3bd..97d79704 100644 --- a/src/hooks/apis/usePostFeedbackMutation.ts +++ b/src/hooks/apis/usePostFeedbacksMutation.ts @@ -1,27 +1,30 @@ -import { postFeedback } from '@/apis/client/postFeedback'; +import { postFeedbacks } from '@/apis/client/postFeedbacks'; import { ajajaToast } from '@/components/Toaster/customToast'; import { QUERY_KEY } from '@/constants/queryKey'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; -export const usePostFeedbackMutation = (planId: number) => { +export const usePostFeedbacksMutation = (planId: number) => { + const router = useRouter(); const queryClient = useQueryClient(); return useMutation({ - mutationFn: postFeedback, + mutationFn: postFeedbacks, onSuccess: () => { Promise.all([ queryClient.invalidateQueries({ queryKey: [QUERY_KEY.MY_PLANS], }), queryClient.invalidateQueries({ - queryKey: [{ planId: planId }, QUERY_KEY.REMIND], + queryKey: [{ planId: planId }, QUERY_KEY.FEEDBACKS], }), ]); ajajaToast.success('피드백 완료'); + router.push(`/feedback/${planId}`); }, onError: () => { ajajaToast.error('피드백 실패'); + return '에러!'; }, - throwOnError: true, }); }; diff --git a/src/styles/class/font-size.scss b/src/styles/class/font-size.scss index 7a490735..824d0409 100644 --- a/src/styles/class/font-size.scss +++ b/src/styles/class/font-size.scss @@ -5,15 +5,15 @@ .font-size { &-#{$key} { font-size: $value; - @if $value == 0.75rem { + @if $value < 0.75rem { line-height: 1rem; - } @else if $value == 0.875rem { + } @else if $value < 0.875rem { line-height: 1.25rem; - } @else if $value == 1rem { + } @else if $value < 1.3rem { line-height: 1.5rem; - } @else if $value == 1.5rem { + } @else if $value < 1.5rem { line-height: 2rem; - } @else if $value == 2rem { + } @else if $value < 2rem { @media screen and (max-width: 380px) { font-size: 1.75rem; } diff --git a/src/types/Feedbacks.ts b/src/types/Feedbacks.ts new file mode 100644 index 00000000..58d4a15a --- /dev/null +++ b/src/types/Feedbacks.ts @@ -0,0 +1,9 @@ +export type FeedbackItemData = { + achieve: number; + endDate: number; + endMonth: number; + message: string; + remindMonth: number; + remindDate: number; + reminded: boolean; +}; diff --git a/src/types/IconName.ts b/src/types/IconName.ts index d0f1edda..7d1528e0 100644 --- a/src/types/IconName.ts +++ b/src/types/IconName.ts @@ -34,4 +34,5 @@ export type IconName = | 'FAVORITE' | 'ARROW_UP' | 'COPY' - | 'HELP'; + | 'HELP' + | 'ARROW_RIGHT'; diff --git a/src/types/apis/feedback/GetFeedbacks.ts b/src/types/apis/feedback/GetFeedbacks.ts new file mode 100644 index 00000000..30cc5bf3 --- /dev/null +++ b/src/types/apis/feedback/GetFeedbacks.ts @@ -0,0 +1,14 @@ +import { FeedbackItemData } from '@/types/Feedbacks'; + +export interface GetFeedbacksResponse { + success: boolean; + data: FeedbacksData; +} + +interface FeedbacksData { + achieveRate: number; + createdYear: number; + title: string; + remindTime: number; + feedbacks: FeedbackItemData[]; +} diff --git a/src/types/apis/feedback/PostFeedbaks.ts b/src/types/apis/feedback/PostFeedbaks.ts index 689b2952..b23d2f63 100644 --- a/src/types/apis/feedback/PostFeedbaks.ts +++ b/src/types/apis/feedback/PostFeedbaks.ts @@ -1,8 +1,9 @@ -interface PostFeedbackRequestBody { +interface PostFeedbacksRequestBody { rate: number; + message: string; } -export interface PostFeedbackRequest { - feedbackId: number; - body: PostFeedbackRequestBody; +export interface PostFeedbacksRequest { + planId: number; + body: PostFeedbacksRequestBody; }