diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts index f5e381b1..9b707033 100644 --- a/apps/web/src/api/index.ts +++ b/apps/web/src/api/index.ts @@ -4,6 +4,8 @@ import Cookies from "js-cookie"; /** API 사용 전, ENV 파일을 통해 서버 연동 설정을 해주세요 */ const API_URL = import.meta.env.VITE_API_URL as string; +export type ErrorResponse = { name: string; message: string }; + const baseApi = axios.create({ baseURL: API_URL, timeout: 5000, @@ -21,8 +23,8 @@ const logOnDev = (message: string) => { }; /** API 요청이 실패한 경우 호출되는 함수 */ -const onError = (status: number, message: string) => { - const error = { status, message }; +const onError = (status: number, message: string, data?: ErrorResponse) => { + const error = { status, message, data }; throw error; }; @@ -64,32 +66,32 @@ const onErrorResponse = (error: AxiosError | Error) => { if (axios.isAxiosError(error)) { const { message } = error; const { method, url } = error?.config as AxiosRequestConfig; - const { status, statusText } = error?.response as AxiosResponse; + const { status, statusText, data } = error?.response as AxiosResponse; logOnDev(`[API ERROR_RESPONSE ${status} | ${statusText} | ${message}] ${method?.toUpperCase()} ${url}`); switch (status) { case 400: - onError(status, "잘못된 요청을 했어요"); + onError(status, "잘못된 요청을 했어요", data); break; case 401: { - onError(status, "인증을 실패했어요"); + onError(status, "인증을 실패했어요", data); break; } case 403: { - onError(status, "권한이 없는 상태로 접근했어요"); + onError(status, "권한이 없는 상태로 접근했어요", data); break; } case 404: { - onError(status, "찾을 수 없는 페이지를 요청했어요"); + onError(status, "찾을 수 없는 페이지를 요청했어요", data); break; } case 500: { - onError(status, "서버 오류가 발생했어요"); + onError(status, "서버 오류가 발생했어요", data); break; } default: { - onError(status, `기타 에러가 발생했어요 : ${error?.message}`); + onError(status, `기타 에러가 발생했어요 : ${error?.message}`, data); } } } else if (error instanceof Error && error?.name === "TimoutError") { diff --git a/apps/web/src/app/info/PrivacyPolicyPage.tsx b/apps/web/src/app/info/PrivacyPolicyPage.tsx index a4b7233d..f7250cb5 100644 --- a/apps/web/src/app/info/PrivacyPolicyPage.tsx +++ b/apps/web/src/app/info/PrivacyPolicyPage.tsx @@ -1,3 +1,5 @@ +import { css } from "@emotion/react"; + import { Typography } from "@/component/common/typography"; import { info } from "@/config/info"; import { DefaultLayout } from "@/layout/DefaultLayout"; @@ -5,7 +7,17 @@ import { DefaultLayout } from "@/layout/DefaultLayout"; export function PrivacyPolicyPage() { return ( - {info.privacyPolicy} + +
+          {info.privacyPolicy}
+        
+
); } diff --git a/apps/web/src/app/info/TermsOfServicePage.tsx b/apps/web/src/app/info/TermsOfServicePage.tsx index 7fb0368e..34461238 100644 --- a/apps/web/src/app/info/TermsOfServicePage.tsx +++ b/apps/web/src/app/info/TermsOfServicePage.tsx @@ -1,3 +1,5 @@ +import { css } from "@emotion/react"; + import { Typography } from "@/component/common/typography"; import { info } from "@/config/info"; import { DefaultLayout } from "@/layout/DefaultLayout"; @@ -5,7 +7,17 @@ import { DefaultLayout } from "@/layout/DefaultLayout"; export function TermsOfServicePage() { return ( - {info.termsOfService} + +
+          {info.termsOfService}
+        
+
); } diff --git a/apps/web/src/app/retrospect/analysis/RetrospectAnalysisPage.tsx b/apps/web/src/app/retrospect/analysis/RetrospectAnalysisPage.tsx index a7d7d72a..e965ffe3 100644 --- a/apps/web/src/app/retrospect/analysis/RetrospectAnalysisPage.tsx +++ b/apps/web/src/app/retrospect/analysis/RetrospectAnalysisPage.tsx @@ -1,4 +1,4 @@ -import { Fragment } from "react"; +import { Fragment, useEffect } from "react"; import { useLocation } from "react-router-dom"; import { LoadingModal } from "@/component/common/Modal/LoadingModal.tsx"; @@ -10,9 +10,10 @@ import { QuestionForm } from "@/component/retrospect/analysis/QuestionForm.tsx"; import { useGetAnalysisAnswer } from "@/hooks/api/retrospect/analysis/useGetAnalysisAnswer.ts"; import { useTabs } from "@/hooks/useTabs"; import { DualToneLayout } from "@/layout/DualToneLayout"; +import { EmptyList } from "@/component/common/empty"; export const RetrospectAnalysisPage = () => { - const { title } = useLocation().state as { title: string }; + const { title, defaultTab } = useLocation().state as { title: string; defaultTab: "질문" | "개별" | "분석" }; const tabMappings = { 질문: "QUESTIONS", @@ -27,6 +28,11 @@ export const RetrospectAnalysisPage = () => { const spaceId = queryParams.get("spaceId"); const retrospectId = queryParams.get("retrospectId"); const { data, isLoading } = useGetAnalysisAnswer({ spaceId: spaceId!, retrospectId: retrospectId! }); + useEffect(() => { + if (defaultTab) { + selectTab(defaultTab); + } + }, []); return ( { } > {isLoading && } - { + {!data || data.individuals.length === 0 ? ( + + ) : ( { - QUESTIONS: , - INDIVIDUAL_ANALYSIS: , + QUESTIONS: , + INDIVIDUAL_ANALYSIS: , ANALYSIS: , }[selectedTab] - } + )} ); }; diff --git a/apps/web/src/app/retrospect/template/recommend/RecommendSearch.tsx b/apps/web/src/app/retrospect/template/recommend/RecommendSearch.tsx index b9789ec1..b332e309 100644 --- a/apps/web/src/app/retrospect/template/recommend/RecommendSearch.tsx +++ b/apps/web/src/app/retrospect/template/recommend/RecommendSearch.tsx @@ -1,19 +1,27 @@ import { useLocation } from "react-router-dom"; import { Header } from "@/component/common/header"; +import { LoadingModal } from "@/component/common/Modal/LoadingModal"; import { Spacing } from "@/component/common/Spacing"; import { CardCarousel } from "@/component/retrospect/template/card/CardCarousel"; import { TemplateKey } from "@/component/retrospect/template/card/template.const"; +import { useApiGetSpace } from "@/hooks/api/space/useApiGetSpace"; import { DefaultLayout } from "@/layout/DefaultLayout"; +import { chooseParticle } from "@/utils/retrospect/chooseParticle"; import { createTemplateArr } from "@/utils/retrospect/createTemplateArr"; export function RecommendSearch() { const { templateId, spaceId } = useLocation().state as { templateId: string; spaceId: string }; const TemplateArr = createTemplateArr(templateId as unknown as TemplateKey); + const { data, isLoading } = useApiGetSpace(spaceId); + + if (isLoading) return ; + + const particle = chooseParticle(data?.name ?? ""); return ( -
+
diff --git a/apps/web/src/app/space/CreateDonePage.tsx b/apps/web/src/app/space/CreateDonePage.tsx index 64fca506..e1480b29 100644 --- a/apps/web/src/app/space/CreateDonePage.tsx +++ b/apps/web/src/app/space/CreateDonePage.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import CreateDone from "@/assets/lottie/space/create_done.json"; import { ButtonProvider, IconButton } from "@/component/common/button"; import { Icon } from "@/component/common/Icon"; +import { LoadingModal } from "@/component/common/Modal/LoadingModal"; import { Spacing } from "@/component/common/Spacing"; import { Typography } from "@/component/common/typography"; import { useApiGetUser } from "@/hooks/api/auth/useApiGetUser"; @@ -20,7 +21,7 @@ import { encryptId } from "@/utils/space/cryptoKey"; export function CreateDonePage() { const navigate = useNavigate(); const { spaceId } = useLocation().state as { spaceId: string }; - const { data: spaceData } = useApiGetSpace(spaceId); + const { data: spaceData, isLoading } = useApiGetSpace(spaceId); const [animate, setAnimate] = useState(spaceData?.category === ProjectType.Individual); const { data: userData } = useApiGetUser(); const { toast } = useToast(); @@ -41,8 +42,8 @@ export function CreateDonePage() { if (bridge.isWebViewBridgeAvailable) { await bridge.sendShareToKakao({ content: { - title: `${userData.name}님이 스페이스에 초대했습니다.`, - description: "어서오세용~!!", + title: `${userData.name}님의 회고 초대장`, + description: `함께 회고해요! ${userData.name}님이 팀 레이어 스페이스에 초대했어요`, imageUrl: "https://kr.object.ncloudstorage.com/layer-bucket/small_banner.png", link: { mobileWebUrl: `${window.location.protocol}//${window.location.host}/space/join/${encryptedId}`, @@ -62,8 +63,8 @@ export function CreateDonePage() { } else { shareKakaoWeb( `${window.location.protocol}//${window.location.host}/space/join/${encryptedId}`, - `${userData.name}님이 스페이스에 초대했습니다.`, - "어서오세용~!!", + `${userData.name}님의 회고 초대장`, + `함께 회고해요! ${userData.name}님이 ${spaceData?.name} 스페이스에 초대했어요`, ); } }; @@ -71,12 +72,14 @@ export function CreateDonePage() { const handleCopyClipBoard = async () => { try { await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}/space/join/${encryptedId}`); - toast.success("복사 성공!!"); + toast.success("링크 복사가 완료되었어요!"); } catch (e) { - alert("failed"); + alert("링크 복사에 실패했어요!"); } }; + if (isLoading) return ; + return ( <> {spaceData && ( diff --git a/apps/web/src/app/space/SpaceViewPage.tsx b/apps/web/src/app/space/SpaceViewPage.tsx index 0f4bfc1b..29ec028e 100644 --- a/apps/web/src/app/space/SpaceViewPage.tsx +++ b/apps/web/src/app/space/SpaceViewPage.tsx @@ -2,7 +2,7 @@ import { css } from "@emotion/react"; import { PATHS } from "@layer/shared"; import { useQueries } from "@tanstack/react-query"; import Cookies from "js-cookie"; -import { Fragment, useEffect, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { BottomSheet } from "@/component/BottomSheet"; @@ -22,12 +22,16 @@ import { useApiOptionsGetSpaceInfo } from "@/hooks/api/space/useApiOptionsGetSpa import { useBottomSheet } from "@/hooks/useBottomSheet"; import { useModal } from "@/hooks/useModal"; import { useRequiredParams } from "@/hooks/useRequiredParams"; -import { DualToneLayout } from "@/layout/DualToneLayout"; +import { DefaultLayout } from "@/layout/DefaultLayout"; import { useTestNatigate } from "@/lib/test-natigate"; import { DESIGN_TOKEN_COLOR } from "@/style/designTokens"; import { Retrospect } from "@/types/retrospect"; +import { useCollisionDetection } from "@/hooks/useCollisionDetection"; export function SpaceViewPage() { + const appbarRef = useRef(null); + const contentRef = useRef(null); + const isColliding = useCollisionDetection(appbarRef, contentRef); const navigate = useNavigate(); const appNavigate = useTestNatigate(); const memberId = Cookies.get("memberId"); @@ -99,32 +103,20 @@ export function SpaceViewPage() { } return ( - appNavigate(PATHS.home())} - color={DESIGN_TOKEN_COLOR.gray00} - /> - } - TopComp={ - <> - + appNavigate(PATHS.home())} + color={DESIGN_TOKEN_COLOR.gray00} /> - - - - +
} title={spaceInfo?.name} RightComp={ @@ -154,6 +146,18 @@ export function SpaceViewPage() { /> } > +
+ + + + +
- 완료된 회고 + 마감된 회고 {doneRetrospects?.length} @@ -245,6 +249,6 @@ export function SpaceViewPage() { handler={true} /> - + ); } diff --git a/apps/web/src/component/common/empty/EmptyList.tsx b/apps/web/src/component/common/empty/EmptyList.tsx index 379ba1e8..823c9dde 100644 --- a/apps/web/src/component/common/empty/EmptyList.tsx +++ b/apps/web/src/component/common/empty/EmptyList.tsx @@ -6,12 +6,13 @@ import { IconType } from "@/component/common/Icon/Icon"; import { Typography } from "@/component/common/typography"; type EmptyListProps = { + title?: React.ReactNode; message: React.ReactNode; icon: IconType; iconSize?: number; } & React.HTMLAttributes; -export function EmptyList({ message, icon, iconSize = 7.2, children, ...props }: PropsWithChildren) { +export function EmptyList({ title, message, icon, iconSize = 7.2, children, ...props }: PropsWithChildren) { return (
+ + {title} +
("personal"); if (isLoading) { return ; } + { /**분석이 진행중일 때**/ } if (hasAIAnalyzed == false) { return ; } - { - /**분석이 결과가 아무것도 없을 때**/ - } return (
- - - - + {data.teamAnalyze.goodPoints && } + {data.teamAnalyze.badPoints && } + {data.teamAnalyze.improvementPoints && ( + + )} + + )} + {selectedTab === "personal" && ( + <> + {data?.individualAnalyze.badPoints == null && + data?.individualAnalyze.goodPoints == null && + data?.individualAnalyze.improvementPoints == null ? ( + + 회고를 작성하지 않으셨거나
+ 너무 적은 내용을 입력하셨어요 + + } + icon={"ic_empty_list"} + iconSize={12} + /> + ) : ( + <> + {data?.individualAnalyze.goodPoints && ( + + )} + {data?.individualAnalyze.badPoints && ( + + )} + {data?.individualAnalyze.improvementPoints && ( + + )} + + )} )} - {selectedTab === "personal" && - (data?.individualAnalyze ? ( - <> - - - - - ) : ( - 회고를 작성해야 확인할 수 있어요} icon={"ic_empty_list"} iconSize={12} /> - ))}
); } diff --git a/apps/web/src/component/space/create/CreateSpace.tsx b/apps/web/src/component/space/create/CreateSpace.tsx index 269bae1c..520dd972 100644 --- a/apps/web/src/component/space/create/CreateSpace.tsx +++ b/apps/web/src/component/space/create/CreateSpace.tsx @@ -1,11 +1,8 @@ import { css } from "@emotion/react"; -import axios from "axios"; import { useAtom } from "jotai"; import { useCallback, useEffect } from "react"; -import { api } from "@/api"; import { Icon } from "@/component/common/Icon"; -import { LoadingModal } from "@/component/common/Modal/LoadingModal"; import { ProgressBar } from "@/component/common/ProgressBar"; import { Category } from "@/component/space/create/Category"; import { Field } from "@/component/space/create/Field"; @@ -13,6 +10,7 @@ import { Home } from "@/component/space/create/Home"; import { Info } from "@/component/space/create/Info"; import { Thumb } from "@/component/space/create/Thumb"; import { useApiPostSpace } from "@/hooks/api/space/useApiPostSpace"; +import { useApiUploadImage } from "@/hooks/api/space/useApiUploadImage"; import { DefaultLayout } from "@/layout/DefaultLayout"; import { useBridge } from "@/lib/provider/bridge-provider"; import { useTestNatigate } from "@/lib/test-natigate"; @@ -27,10 +25,11 @@ export function CreateSpace() { const [spaceValue, setSpaceValue] = useAtom(spaceState); const { mutate, isPending } = useApiPostSpace(); const { bridge } = useBridge(); + const { mutate: uploadImage, isPending: isImageUploading } = useApiUploadImage(); useEffect(() => { bridge.sendBGColor(spaceValue.step === 0 ? "#212529" : "#FFFFFF").catch(console.error); - if (spaceValue.step === LAST_PAGE + 1) { + if (spaceValue.submit) { mutate({ ...spaceValue, }); @@ -68,31 +67,28 @@ export function CreateSpace() { })); }; - const handleThumbChange = async (thumbValues: Pick) => { + const handleThumbChange = (thumbValues: Pick) => { try { const { imgUrl } = thumbValues; if (imgUrl) { - const { - data: { presignedUrl, imageUrl }, - } = await api.get<{ presignedUrl: string; imageUrl: string }>("/external/image/presigned?domain=SPACE"); - - await axios.put(presignedUrl, imgUrl, { - headers: { - "Content-Type": "image/png", + uploadImage(imgUrl as File, { + onSuccess: (imageUrl) => { + setSpaceValue((prevValues) => ({ + ...prevValues, + imgUrl: imageUrl, + submit: true, + })); + }, + onError: (error) => { + console.error("이미지 업로드 실패:", error); }, }); - - setSpaceValue((prevValues) => ({ - ...prevValues, - imgUrl: imageUrl, - step: prevValues.step + 1, - })); } else { setSpaceValue((prevValues) => ({ ...prevValues, imgUrl: null, - step: prevValues.step + 1, + submit: true, })); } } catch (error) { @@ -101,16 +97,14 @@ export function CreateSpace() { }; const handleBack = useCallback(() => { - spaceValue.step > 0 + void (spaceValue.step > 0 ? setSpaceValue((prevValues) => ({ ...prevValues, step: prevValues.step - 1, })) - : navigate(-1); + : navigate(-1)); }, [navigate, setSpaceValue, spaceValue.step]); - if (isPending) return ; - return ( } {spaceValue.step === 2 && } {spaceValue.step === 3 && } - {spaceValue.step === 4 && } + {spaceValue.step === 4 && } ); } diff --git a/apps/web/src/component/space/create/Thumb.tsx b/apps/web/src/component/space/create/Thumb.tsx index 5086cd45..3fd36f6e 100644 --- a/apps/web/src/component/space/create/Thumb.tsx +++ b/apps/web/src/component/space/create/Thumb.tsx @@ -10,7 +10,6 @@ type ThumbValues = Pick; export function Thumb({ onNext, isPending }: { onNext: (thumbValues: ThumbValues) => void; isPending: boolean }) { const [imgFile, setImgFile] = useState(null); - return ( diff --git a/apps/web/src/component/space/members/MemberInviteModal.tsx b/apps/web/src/component/space/members/MemberInviteModal.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/web/src/component/space/members/MembersList.tsx b/apps/web/src/component/space/members/MembersList.tsx index 848f5336..06e5309a 100644 --- a/apps/web/src/component/space/members/MembersList.tsx +++ b/apps/web/src/component/space/members/MembersList.tsx @@ -10,10 +10,12 @@ import { Typography } from "@/component/common/typography"; import { MembersItem } from "@/component/space/members/MembersItem"; import { useApiGetUser } from "@/hooks/api/auth/useApiGetUser"; import { useApiGetMemers } from "@/hooks/api/space/members/useApiGetMembers"; +import { useApiGetSpace } from "@/hooks/api/space/useApiGetSpace"; import { useModal } from "@/hooks/useModal"; import { useToast } from "@/hooks/useToast"; import { DefaultLayout } from "@/layout/DefaultLayout"; import { useBridge } from "@/lib/provider/bridge-provider"; +import { ProjectType } from "@/types/space"; import { shareKakaoWeb } from "@/utils/kakao/sharedKakaoLink"; import { encryptId } from "@/utils/space/cryptoKey"; @@ -21,6 +23,7 @@ export type EditType = "LEADER" | "KICK"; export function MembersList() { const { spaceId } = useParams() as { spaceId: string }; + const { data: spaceInfo, isLoading: spaceInfoLoading } = useApiGetSpace(spaceId); const { data, isLoading } = useApiGetMemers(spaceId); const { open, close } = useModal(); const { data: userData } = useApiGetUser(); @@ -33,8 +36,8 @@ export function MembersList() { if (bridge.isWebViewBridgeAvailable) { await bridge.sendShareToKakao({ content: { - title: `${userData.name}님이 스페이스에 초대했습니다.`, - description: "어서오세용~!!", + title: `${userData.name}님의 회고 초대장`, + description: `함께 회고해요! ${userData.name}님이 팀 레이어 스페이스에 초대했어요`, imageUrl: "https://kr.object.ncloudstorage.com/layer-bucket/small_banner.png", link: { mobileWebUrl: `${window.location.protocol}//${window.location.host}/space/join/${encryptedId}`, @@ -54,8 +57,8 @@ export function MembersList() { } else { shareKakaoWeb( `${window.location.protocol}//${window.location.host}/space/join/${encryptedId}`, - `${userData.name}님이 스페이스에 초대했습니다.`, - "어서오세용~!!", + `${userData.name}님의 회고 초대장.`, + `함께 회고해요! ${userData.name}님이 ${spaceInfo?.name} 스페이스에 초대했어요`, ); } close(); @@ -64,10 +67,10 @@ export function MembersList() { const handleCopyClipBoard = async () => { try { await navigator.clipboard.writeText(`${window.location.protocol}//${window.location.host}/space/join/${encryptedId}`); - toast.success("복사 성공!!"); + toast.success("링크 복사가 완료되었어요!"); close(); } catch (e) { - toast.error("복사 실패!!"); + toast.error("링크 복사에 실패했어요!"); } }; @@ -123,12 +126,13 @@ export function MembersList() { return data[0].id === userData.memberId && data[0].isLeader; }, [data, userData.memberId]); - if (isLoading) return ; + if (isLoading || spaceInfoLoading) return ; return ( onChangeEditType(value as EditType)} offset={[8, 8]}> @@ -153,10 +157,10 @@ export function MembersList() { > - {`팀원 ${data?.length}`} + {`인원 ${data?.length}`} - {isLeader && } + {spaceInfo?.category === ProjectType.Team && isLeader && } {data?.map((member) => )} diff --git a/apps/web/src/component/space/view/ProceedingTextBox.tsx b/apps/web/src/component/space/view/ProceedingTextBox.tsx new file mode 100644 index 00000000..d2d15e81 --- /dev/null +++ b/apps/web/src/component/space/view/ProceedingTextBox.tsx @@ -0,0 +1,80 @@ +import { css } from "@emotion/react"; +import { RetrospectProceed } from "@/types/retrospect"; +import { Typography } from "@/component/common/typography"; +import { DESIGN_TOKEN_COLOR } from "@/style/designTokens"; + +export function ProceedingTextBox({ writeStatus, analysisStatus }: RetrospectProceed) { + // 상태에 따라 스타일 및 텍스트 설정 + let proceedStyle; + + switch (true) { + case analysisStatus === "DONE": + proceedStyle = proceedStyles.ANALYZED; + break; + case analysisStatus === "PROCEEDING": + proceedStyle = proceedStyles.ANALYZING; + break; + case writeStatus === "NOT_STARTED": + proceedStyle = proceedStyles.NOT_STARTED; + break; + case writeStatus === "PROCEEDING": + proceedStyle = proceedStyles.WRITING; + break; + case writeStatus === "DONE": + proceedStyle = proceedStyles.COMPLETED; + break; + default: + proceedStyle = proceedStyles.NOT_STARTED; + } + + const { borderColor, backgroundColor, textColor, text } = proceedStyle; + + return ( + + {text} + + ); +} + +const proceedStyles = { + NOT_STARTED: { + borderColor: DESIGN_TOKEN_COLOR.gray400, + backgroundColor: DESIGN_TOKEN_COLOR.gray200, + textColor: DESIGN_TOKEN_COLOR.gray600, + text: "작성 전", + }, + WRITING: { + borderColor: DESIGN_TOKEN_COLOR.blue300, + backgroundColor: DESIGN_TOKEN_COLOR.blue200, + textColor: DESIGN_TOKEN_COLOR.blue700, + text: "작성 중", + }, + COMPLETED: { + borderColor: "#D3E1D3", + backgroundColor: "#E7F6E6", + textColor: "#034C0C", + text: "제출 완료", + }, + ANALYZING: { + borderColor: DESIGN_TOKEN_COLOR.gray500, + backgroundColor: "#2123291A", + textColor: DESIGN_TOKEN_COLOR.gray900, + text: "분석 중", + }, + ANALYZED: { + borderColor: DESIGN_TOKEN_COLOR.gray500, + backgroundColor: "#2123291A", + textColor: DESIGN_TOKEN_COLOR.gray900, + text: "분석 완료", + }, +}; diff --git a/apps/web/src/component/space/view/RetrospectBox.tsx b/apps/web/src/component/space/view/RetrospectBox.tsx index c475e909..0a02f7fc 100644 --- a/apps/web/src/component/space/view/RetrospectBox.tsx +++ b/apps/web/src/component/space/view/RetrospectBox.tsx @@ -1,7 +1,7 @@ import { css } from "@emotion/react"; import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; -import { RetrospectButton } from "./RetrospectButton"; import { RetrospectOptions } from "./RetrospectOptions"; import { Icon } from "@/component/common/Icon"; @@ -14,15 +14,13 @@ import { useModal } from "@/hooks/useModal"; import { useToast } from "@/hooks/useToast.ts"; import { DESIGN_TOKEN_COLOR } from "@/style/designTokens"; import { Retrospect } from "@/types/retrospect"; -import { formatDateAndTime, calculateDeadlineRemaining } from "@/utils/date"; +import { formatDateAndTime } from "@/utils/date"; +import { ProceedingTextBox } from "./ProceedingTextBox"; +import { PATHS } from "@layer/shared"; const statusStyles = { - PROCEEDING: { - backgroundColor: DESIGN_TOKEN_COLOR.blue50, - }, - DONE: { - backgroundColor: DESIGN_TOKEN_COLOR.gray100, - }, + PROCEEDING: DESIGN_TOKEN_COLOR.blue50, + DONE: DESIGN_TOKEN_COLOR.gray100, }; export function RetrospectBox({ @@ -38,19 +36,45 @@ export function RetrospectBox({ refetchRestrospectData?: () => void; isLeader: boolean; }) { + const navigate = useNavigate(); const { open } = useModal(); const [isOptionsVisible, setIsOptionsVisible] = useState(false); const [isDeleted, setIsDeleted] = useState(false); const optionsRef = useRef(null); - const { retrospectId, title, introduction, retrospectStatus, isWrite, writeCount, totalCount, deadline } = retrospect; - const { backgroundColor } = statusStyles[retrospectStatus]; + const { retrospectId, title, introduction, retrospectStatus, writeStatus, analysisStatus, writeCount, totalCount, deadline } = retrospect; const [isEditModalOpen, setIsEditModalOpen] = useState(false); const { toast } = useToast(); - const isAllAnswered = writeCount === totalCount; const { mutate: retrospectDelete } = useApiDeleteRetrospect(); const { mutate: retrospectClose, isPending } = useApiCloseRetrospect(); + const boxClickFun = () => { + const { analysisStatus, retrospectStatus, writeStatus } = retrospect; + + const navigateToAnalysis = (defaultTab?: "분석" | "") => + navigate(PATHS.retrospectAnalysis(spaceId, retrospectId), { state: { title, ...(defaultTab && { defaultTab }) } }); + + if (analysisStatus === "DONE") { + navigateToAnalysis("분석"); + return; + } + + if (retrospectStatus === "PROCEEDING") { + if (analysisStatus === "NOT_STARTED" && (writeStatus === "NOT_STARTED" || writeStatus === "PROCEEDING")) { + navigate(PATHS.write(), { + state: { + retrospectId, + spaceId, + }, + }); + } else { + navigateToAnalysis(); + } + } else { + navigateToAnalysis("분석"); + } + }; + const closeBtnClickFun = () => { open({ title: "회고를 마감할까요?", @@ -126,77 +150,60 @@ export function RetrospectBox({ {isPending && }
- - {title} - -
- {retrospectStatus === "PROCEEDING" && ( - - {calculateDeadlineRemaining(deadline)} - - )} - {isLeader && ( - - )} -
+ + {isLeader && ( + + )}
+ + {title} + {introduction && ( )} -
- - {formatDateAndTime(deadline)} + {retrospect.deadline == null ? ( + <>모든 인원 제출 시 마감 + ) : ( + <> + {" "} + {retrospect.retrospectStatus === "DONE" ? "마감일" : "마감 예정일"} | {formatDateAndTime(deadline!)} + + )} -
- -
@@ -256,19 +258,15 @@ export function RetrospectBox({ color="gray500" css={css` margin: 0 0.2rem; + white-space: nowrap; `} > / {totalCount}
-
+ {isEditModalOpen && ( { if (value) setDeadline(value); }} diff --git a/apps/web/src/component/space/view/RetrospectOptions.tsx b/apps/web/src/component/space/view/RetrospectOptions.tsx index 53b63604..3aacecbe 100644 --- a/apps/web/src/component/space/view/RetrospectOptions.tsx +++ b/apps/web/src/component/space/view/RetrospectOptions.tsx @@ -4,8 +4,11 @@ import { motion, AnimatePresence } from "framer-motion"; import { Icon } from "@/component/common/Icon"; import { Typography } from "@/component/common/typography"; import { DESIGN_TOKEN_COLOR } from "@/style/designTokens"; +import { RetrospectStatus } from "@/types/retrospect"; +import React from "react"; export function RetrospectOptions({ + retrospectStatus, isOptionsVisible, toggleOptionsVisibility, removeBtnClickFun, @@ -13,6 +16,7 @@ export function RetrospectOptions({ closeBtnClickFun, optionsRef, }: { + retrospectStatus: RetrospectStatus; isOptionsVisible: boolean; toggleOptionsVisibility: () => void; removeBtnClickFun: () => void; @@ -21,19 +25,28 @@ export function RetrospectOptions({ optionsRef: React.RefObject; }) { return ( - <> +
event.stopPropagation()} + css={css` + width: 2.5rem; + height: 2.5rem; + display: flex; + justify-content: center; + align-items: center; + `} + > {isOptionsVisible && ( - {closeBtnClickFun && ( + + {retrospectStatus === "PROCEEDING" && ( )} -
); } diff --git a/apps/web/src/component/space/view/SpaceCountView.tsx b/apps/web/src/component/space/view/SpaceCountView.tsx index c732e25e..e10785c9 100644 --- a/apps/web/src/component/space/view/SpaceCountView.tsx +++ b/apps/web/src/component/space/view/SpaceCountView.tsx @@ -128,7 +128,7 @@ export function SpaceCountView({ mainTemplate, memberCount, isLeader }: SpaceCou `} > - 팀원 + 인원 {memberCount}명
diff --git a/apps/web/src/component/write/phase/Write.tsx b/apps/web/src/component/write/phase/Write.tsx index a67c113d..95d0ab9d 100644 --- a/apps/web/src/component/write/phase/Write.tsx +++ b/apps/web/src/component/write/phase/Write.tsx @@ -290,7 +290,7 @@ export function Write() { onClick={mutateSaveTemporaryData} disabled={!hasChanges()} > - 저장 + 임시 저장
쿠키 및 사이트 권한 > 쿠키 및 사이트 데이터 관리 및 삭제 +2. Chrome : 웹브라우저 우측 상단의 설정 메뉴 > 개인정보 및 보안 > 쿠키 및 기타 사이트 데이터 +3. Whale : 웹브라우저 우측 상단의 설정 메뉴 > 개인정보 보호 > 쿠키 및 기타 사이트 데이터 + +제29조(회사의 개인정보 보호 책임자 지정) +1. 회사는 이용자의 개인정보를 보호하고 개인정보와 관련한 불만을 처리하기 위하여 아래와 같이 관련 부서 및 개인정보 보호 책임자를 지정하고 있습니다. + 1. 개인정보 보호 책임자 + 1. 성명: 김현우 + 2. 직책: 개발책임자 + 3. 전화번호: 010-3747-4646 + 4. 이메일: gentlemonster77@likelion.org +2. 회사는 개인정보의 보호를 위해 개인정보보호 전담부서를 운영하고 있으며, 개인정보처리방침의 이행사항 및 담당자의 준수여부를 확인하여 문제가 발견될 경우 즉시 해결하고 바로 잡을 수 있도록 최선을 다하고 있습니다. +3. 정보주체는 개인정보 보호법 제35조에 따른 개인정보의 열람 청구를 아래의 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다. + +제30조(권익침해에 대한 구제방법) +1. 정보주체는 개인정보침해로 인한 구제를 받기 위하여 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보침해신고센터 등에 분쟁해결이나 상담 등을 신청할 수 있습니다. 이 밖에 기타 개인정보침해의 신고, 상담에 대하여는 아래의 기관에 문의하시기 바랍니다. + 1. 개인정보분쟁조정위원회 : (국번없이) 1833-6972 (www.kopico.go.kr) + 2. 개인정보침해신고센터 : (국번없이) 118 (privacy.kisa.or.kr) + 3. 대검찰청 : (국번없이) 1301 (www.spo.go.kr) + 4. 경찰청 : (국번없이) 182 (ecrm.cyber.go.kr) +2. 회사는 정보주체의 개인정보자기결정권을 보장하고, 개인정보침해로 인한 상담 및 피해 구제를 위해 노력하고 있으며, 신고나 상담이 필요한 경우 제1항의 담당부서로 연락해주시기 바랍니다. +3. 개인정보 보호법 제35조(개인정보의 열람), 제36조(개인정보의 정정·삭제), 제37조(개인정보의 처리정지 등)의 규정에 의한 요구에 대 하여 공공기관의 장이 행한 처분 또는 부작위로 인하여 권리 또는 이익의 침해를 받은 자는 행정심판법이 정하는 바에 따라 행정심판을 청구할 수 있습니다. + 1. 중앙행정심판위원회 : (국번없이) 110 (www.simpan.go.kr) + +부칙 +제1조 본 방침은 2024.08.10.부터 시행됩니다. `, }; diff --git a/apps/web/src/hooks/api/analysis/useApiGetAnalysis.ts b/apps/web/src/hooks/api/analysis/useApiGetAnalysis.ts index 4e7ca10b..3aef809a 100644 --- a/apps/web/src/hooks/api/analysis/useApiGetAnalysis.ts +++ b/apps/web/src/hooks/api/analysis/useApiGetAnalysis.ts @@ -3,6 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/api"; import { useMixpanel } from "@/lib/provider/mix-pannel-provider"; import { Insight } from "@/types/analysis"; +import { AxiosResponse } from "axios"; +import { ErrorResponse } from "react-router-dom"; export type AnalysisType = { teamAnalyze: { @@ -27,7 +29,12 @@ export const useApiGetAnalysis = ({ spaceId, retrospectId }: { spaceId: string; const { track } = useMixpanel(); const getAnalysis = () => { - const res = api.get(`/space/${spaceId}/retrospect/${retrospectId}/analyze`).then((res) => res.data); + const res = api + .get(`/space/${spaceId}/retrospect/${retrospectId}/analyze`) + .then((res) => res.data) + .catch((error: AxiosResponse) => { + throw error; + }); track("RESULT_ANALYSIS_VIEW", { retrospectId: +retrospectId, spaceId: +spaceId, @@ -38,5 +45,6 @@ export const useApiGetAnalysis = ({ spaceId, retrospectId }: { spaceId: string; return useQuery({ queryFn: () => getAnalysis(), queryKey: [spaceId, retrospectId], + retry: 1, }); }; diff --git a/apps/web/src/hooks/api/retrospect/analysis/useGetAnalysisAnswer.ts b/apps/web/src/hooks/api/retrospect/analysis/useGetAnalysisAnswer.ts index e48e509e..f514db68 100644 --- a/apps/web/src/hooks/api/retrospect/analysis/useGetAnalysisAnswer.ts +++ b/apps/web/src/hooks/api/retrospect/analysis/useGetAnalysisAnswer.ts @@ -65,5 +65,6 @@ export const useGetAnalysisAnswer = ({ spaceId, retrospectId }: getAnalysisAnswe return useQuery({ queryFn: () => getAnalysisAnswer(), queryKey: [spaceId, retrospectId, "analysis"], + retry: 1, }); }; diff --git a/apps/web/src/hooks/api/retrospect/edit/usePatchRetrospect.ts b/apps/web/src/hooks/api/retrospect/edit/usePatchRetrospect.ts index 963df44c..ad67f0ce 100644 --- a/apps/web/src/hooks/api/retrospect/edit/usePatchRetrospect.ts +++ b/apps/web/src/hooks/api/retrospect/edit/usePatchRetrospect.ts @@ -6,7 +6,7 @@ import { useToast } from "@/hooks/useToast"; type RetrospectEditReq = { title: string; introduction: string; - deadline: string; + deadline: string | null; }; type PatchRetrospect = { spaceId: number; retrospectId: number; data: RetrospectEditReq }; @@ -25,7 +25,7 @@ export const usePatchRetrospect = (spaceId: string) => { onSuccess: async () => { toast.success("회고 정보가 수정되었어요!"); await queryClient.invalidateQueries({ - queryKey: ["getRetrospects", spaceId], // FIXME - query key 상수화 + queryKey: ["getRetrospects", spaceId], }); }, }); diff --git a/apps/web/src/hooks/api/retrospect/useApiOptionsGetRetrospects.ts b/apps/web/src/hooks/api/retrospect/useApiOptionsGetRetrospects.ts index b03de801..14150fe7 100644 --- a/apps/web/src/hooks/api/retrospect/useApiOptionsGetRetrospects.ts +++ b/apps/web/src/hooks/api/retrospect/useApiOptionsGetRetrospects.ts @@ -14,7 +14,7 @@ const spaceRestrospectFetch = async (spaceId: string | undefined) => { }; export const useApiOptionsGetRetrospects = (spaceId?: string): UseQueryOptions => ({ - queryKey: ["getRetrospects", spaceId!], //FIXME - query key 상수화 + queryKey: ["getRetrospects", spaceId!], queryFn: () => spaceRestrospectFetch(spaceId), select(data) { return data.retrospects; diff --git a/apps/web/src/hooks/api/space/useApiUploadImage.ts b/apps/web/src/hooks/api/space/useApiUploadImage.ts new file mode 100644 index 00000000..6c7b3cc9 --- /dev/null +++ b/apps/web/src/hooks/api/space/useApiUploadImage.ts @@ -0,0 +1,20 @@ +import { useMutation } from "@tanstack/react-query"; +import axios from "axios"; + +import { api } from "@/api"; + +export function useApiUploadImage() { + return useMutation({ + mutationFn: async (imgUrl: File) => { + const { data: presignedData } = await api.get<{ presignedUrl: string; imageUrl: string }>("/external/image/presigned?domain=SPACE"); + + await axios.put(presignedData.presignedUrl, imgUrl, { + headers: { + "Content-Type": "image/png", + }, + }); + + return presignedData.imageUrl; + }, + }); +} diff --git a/apps/web/src/store/space/spaceAtom.ts b/apps/web/src/store/space/spaceAtom.ts index 1a48b499..d8b6f62d 100644 --- a/apps/web/src/store/space/spaceAtom.ts +++ b/apps/web/src/store/space/spaceAtom.ts @@ -9,6 +9,7 @@ const initialState = { introduction: "", imgUrl: "", step: 0, + submit: false, }; export const spaceState = atomWithReset(initialState); diff --git a/apps/web/src/types/retrospect/index.ts b/apps/web/src/types/retrospect/index.ts index 269eb690..6eeac52f 100644 --- a/apps/web/src/types/retrospect/index.ts +++ b/apps/web/src/types/retrospect/index.ts @@ -1,11 +1,21 @@ +export type WriteStatus = "NOT_STARTED" | "PROCEEDING" | "DONE"; +export type RetrospectStatus = "PROCEEDING" | "DONE"; +export type AnalysisStatus = "NOT_STARTED" | "PROCEEDING" | "DONE"; + export type Retrospect = { retrospectId: number; title: string; introduction: string; - isWrite: boolean; - retrospectStatus: "PROCEEDING" | "DONE"; + writeStatus: WriteStatus; + retrospectStatus: RetrospectStatus; + analysisStatus: AnalysisStatus; totalCount: number; writeCount: number; createdAt: string; - deadline: string; + deadline: string | null; +}; + +export type RetrospectProceed = { + writeStatus: WriteStatus; + analysisStatus: AnalysisStatus; }; diff --git a/apps/web/src/types/space.ts b/apps/web/src/types/space.ts index 2813cad9..3dddb838 100644 --- a/apps/web/src/types/space.ts +++ b/apps/web/src/types/space.ts @@ -9,6 +9,7 @@ export type SpaceValue = { introduction?: string; imgUrl?: string | File | null; step: number; + submit?: boolean; }; export enum ProjectType { diff --git a/apps/web/src/utils/date/index.ts b/apps/web/src/utils/date/index.ts index e0d3fdb2..c103a899 100644 --- a/apps/web/src/utils/date/index.ts +++ b/apps/web/src/utils/date/index.ts @@ -2,8 +2,8 @@ import { format, differenceInDays } from "date-fns"; const formatDateAndTime = (dateString: string): string => { const date = new Date(dateString); - const formattedDate = format(date, "yyyy MM. dd a hh:mm"); - return formattedDate.replace("AM", "오전").replace("PM", "오후"); + const formattedDate = format(date, "yyyy.MM.dd HH:mm"); + return formattedDate; }; const formatOnlyDate = (dateString: string): string => { diff --git a/apps/web/src/utils/retrospect/chooseParticle.ts b/apps/web/src/utils/retrospect/chooseParticle.ts new file mode 100644 index 00000000..abaad554 --- /dev/null +++ b/apps/web/src/utils/retrospect/chooseParticle.ts @@ -0,0 +1,14 @@ +export function chooseParticle(word: string): string { + if (!word || typeof word !== "string") { + return "와"; + } + + const lastChar = word.charAt(word.length - 1); + + if (lastChar < "가" || lastChar > "힣") { + return "와"; + } + + const hasLastConsonant = (lastChar.charCodeAt(0) - 0xac00) % 28 > 0; + return hasLastConsonant ? "과" : "와"; +} diff --git a/readme.md b/readme.md new file mode 100644 index 00000000..85c707e1 --- /dev/null +++ b/readme.md @@ -0,0 +1,97 @@ +# [Layer] 성장하는 당신을 위한 회고 서비스 + +
+image + +[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fdepromeet%2Flayer%2F&count_bg=%23000000&title_bg=%230F0F0F&icon=openlayers.svg&icon_color=%23009BFF&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) + +
+ +# [Layer] : WEB FRONTEND + +> **개발자 X 디자이너 간 연합 동아리인 디프만의 떡잎마을 방범대 팀입니다.**
**개발기간: 2024.06 ~ ing** + +## 👋 배포 주소 + +> **Layer Service URL** : [Layer 경험하기](https://www.layerapp.io/)
> **프론트 Github** : [프론트 Github](https://github.com/depromeet/layer)
> **백엔드 Github** : [백엔드 Github](https://github.com/depromeet/layer-server)
+ +## 🎉 FRONTEND 팀 소개 + +| 김현우 | 이민희 | 이동훈 | 주시현 | +| :------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------: | +| | | | | +| [@klm hyeon woo](https://github.com/klmhyeonwoo) | [@Minhee Lee](https://github.com/leeminhee119) | [@donghunee](https://github.com/donghunee) | [@Juicycool](https://github.com/sean2337) | + +## 🔥 프로젝트 소개 + +작성부터 AI 분석까지 성장하는 당신을 위한 회고 서비스, Layer는 회고 작성의 어려움과 부담감을 덜어주며, 회고를 통해 성장을 위한 개선점을 설정하고 +효율적으로 회고를 관리하는 데 도움을 주는 서비스에요. + +
+ +## 🌊 서비스 플로우 + +![image](https://github.com/user-attachments/assets/e21ff69a-06ce-4e69-b6e9-9fe35be70397) + +저희 서비스는 다음과 같은 플로우로 회고에 대한 접근성을 크게 향상시켰어요! + +
+ +## ✨ 서비스 핵심 기능 + +### 1. 맞춤 템플릿 추천받기 + +image +
+ + 회고 템플릿 고르시느라 힘드셨죠? 저희 서비스는 고객에 맞춘 회고 템플릿을 추천해드려요. + +### 2. 회고 작성하기 + +image +
+템플릿으로 추천받거나 커스텀한 나만의 회고를 작성하실 수 있어요. + +### 3. 회고 요약 확인하기 + +image +
+ 회고 내용 정리하느라 힘드셨죠? 우리 팀원이나 내가 작성한 회고를 파악하기 쉽게 정리해드려요. + +### 4. AI 회고 분석 + +image +
+ + 여기서 끝일까요? 저희 [Layer] 서비스는 한 발 더 나아가, 사용자분께 AI를 통해 회고 분석을 통해 인사이트를 제공해요.
이외에도 다양한 기능을 누려보세요!
+ +--- + +## 💻 Stacks + +| 구분 | 기술 | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Core** | ![React](https://img.shields.io/badge/React-61DAFB?style=flat-square&logo=react&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=flat-square&logo=typescript&logoColor=white) | +| **Build Tool** | ![Vite](https://img.shields.io/badge/Vite-646CFF?style=flat-square&logo=vite&logoColor=white) | +| **Package Manager** | ![pnpm](https://img.shields.io/badge/pnpm-CB3837?style=flat-square&logo=pnpm&logoColor=white) | +| **Style** | ![Emotion](https://img.shields.io/badge/Emotion-C965BF?style=flat-square&logo=emotion&logoColor=white) | +| **State Management** | ![Jotai](https://img.shields.io/badge/Jotai-00C7B7?style=flat-square) ![TanStack Query](https://img.shields.io/badge/TanStack_Query-FF4154?style=flat-square&logo=react-query&logoColor=white) | +| **Etc** | ![Recharts.js](https://img.shields.io/badge/Recharts.js-FF6384?style=flat-square) | + +--- + +## ✍️ [Layer] 프론트엔드 팀의 이이기 + +### 1. [Layer] 소개 디스콰이엇 + +image + +[해당 글로 바로가기](https://disquiet.io/product/%EB%A0%88%EC%9D%B4%EC%96%B4) + +### 2. [Layer] 서비스 탄생 배경 + +image + +[해당 글로 바로가기](https://disquiet.io/@klmhyeonwoo/makerlog/%ED%9A%8C%EA%B3%A0-%EC%9E%98%ED%95%98%EB%8A%94-%ED%8C%80%EC%97%90%EC%84%9C-%ED%9A%8C%EA%B3%A0-%EC%A7%84%ED%96%89%ED%95%98%EA%B8%B0-%EC%9E%91%EC%84%B1-%EC%A4%91) + +---