diff --git a/FE/next.config.js b/FE/next.config.js index 2817fe6f..afc9c01d 100644 --- a/FE/next.config.js +++ b/FE/next.config.js @@ -1,15 +1,14 @@ /** @type {import('next').NextConfig} */ const nextConfig = { // rewrite - async redirects() { - return [ - { - source: "/", - destination: "/main", - permanent: true, - }, - ]; - }, + // async rewrites() { + // return [ + // { + // source: "/main", + // destination: "/", + // }, + // ]; + // }, }; module.exports = nextConfig; diff --git a/FE/package.json b/FE/package.json index d07162d4..80eb256b 100644 --- a/FE/package.json +++ b/FE/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^4.35.7", + "@tanstack/react-query-devtools": "^5.28.14", "@types/qs": "^6.9.10", "@types/react-syntax-highlighter": "^15.5.11", "axios": "^1.6.2", diff --git a/FE/pnpm-lock.yaml b/FE/pnpm-lock.yaml index 12ca9281..e7b0f0c5 100644 --- a/FE/pnpm-lock.yaml +++ b/FE/pnpm-lock.yaml @@ -8,6 +8,9 @@ dependencies: '@tanstack/react-query': specifier: ^4.35.7 version: 4.35.7(react-dom@18.2.0)(react@18.2.0) + '@tanstack/react-query-devtools': + specifier: ^5.28.14 + version: 5.28.14(@tanstack/react-query@4.35.7)(react@18.2.0) '@types/qs': specifier: ^6.9.10 version: 6.9.10 @@ -371,6 +374,21 @@ packages: resolution: {integrity: sha512-PgDJtX75ubFS0WCYFM7DqEoJ4QbxU3S5OH3gJSI40xr7UVVax3/J4CM3XUMOTs+EOT5YGEfssi3tfRVGte4DEw==} dev: false + /@tanstack/query-devtools@5.28.10: + resolution: {integrity: sha512-5UN629fKa5/1K/2Pd26gaU7epxRrYiT1gy+V+pW5K6hnf1DeUKK3pANSb2eHKlecjIKIhTwyF7k9XdyE2gREvQ==} + dev: false + + /@tanstack/react-query-devtools@5.28.14(@tanstack/react-query@4.35.7)(react@18.2.0): + resolution: {integrity: sha512-4CrFBI1O5wibV1ZdGAnBMmTuc7SiShhxWubxRMyIloeEioxs3DQkFbouGBea5nexuwIxAkvhUB8khpPnNjhxMw==} + peerDependencies: + '@tanstack/react-query': ^5.28.14 + react: ^18.0.0 + dependencies: + '@tanstack/query-devtools': 5.28.10 + '@tanstack/react-query': 4.35.7(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + dev: false + /@tanstack/react-query@4.35.7(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-0MankquP/6EOM2ATfEov6ViiKemey5uTbjGlFMX1xGotwNaqC76YKDMJdHumZupPbZcZPWAeoPGEHQmVKIKoOQ==} peerDependencies: diff --git a/FE/public/icons/blackCompany.svg b/FE/public/icons/blackCompany.svg new file mode 100644 index 00000000..7a43211d --- /dev/null +++ b/FE/public/icons/blackCompany.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/FE/src/apis/instance.ts b/FE/src/apis/instance.ts index 74bc153e..70a8b913 100644 --- a/FE/src/apis/instance.ts +++ b/FE/src/apis/instance.ts @@ -19,6 +19,7 @@ const https = axios.create({ https.interceptors.request.use( async (config) => { + if (typeof window === "undefined") return config; const accessToken = localStorage.getItem("accessToken")?.replace(/"/g, ""); const tokenExpiration = localStorage.getItem("tokenExpiration"); diff --git a/FE/src/apis/program.ts b/FE/src/apis/program.ts index 562b6dac..88df4037 100644 --- a/FE/src/apis/program.ts +++ b/FE/src/apis/program.ts @@ -21,9 +21,13 @@ import MESSAGE from "@/constants/MESSAGE"; export const getProgramById = async ( programId: number, + isLoggedIn: boolean, ): Promise => { + const url = isLoggedIn + ? API.PROGRAM.DETAIL(programId) + : API.PROGRAM.GUEST_DETAIL(programId); const { data } = await https({ - url: API.PROGRAM.DETAIL(programId), + url, }); return new ProgramInfoDto(data?.data); }; @@ -37,6 +41,7 @@ export interface GetProgramListRequest { programStatus: ProgramStatus; size: number; page: number; + isLoggedIn: boolean; } export const getProgramList = async ({ @@ -44,9 +49,11 @@ export const getProgramList = async ({ programStatus, size, page, + isLoggedIn, }: GetProgramListRequest): Promise => { + const url = isLoggedIn ? API.PROGRAM.LIST : API.PROGRAM.GUEST_LIST; const { data } = await https({ - url: API.PROGRAM.LIST, + url, method: "GET", params: { category, @@ -86,6 +93,32 @@ export interface PostProgramRequest members: { memberId: number }[]; } +export const sendSlackMessage = async ( + programId: number, + isRetry: boolean = false, +) => { + if (!window) return; + + if (!isRetry) { + const isConfirmed = confirm(MESSAGE.SLACK_MESSAGE.CONFIRM); + if (!isConfirmed) return; + } + + return await https({ + url: API.PROGRAM.SEND_MESSAGE(programId), + method: "POST", + data: { + programUrl: + process.env.NEXT_PUBLIC_SLACK_MESSAGE_REQUEST_URL_PREFIX + programId, + }, + }) + .then(() => alert(MESSAGE.SLACK_MESSAGE.SUCCESS)) + .catch(() => { + const retry = confirm(MESSAGE.SLACK_MESSAGE.FAIL); + if (retry) sendSlackMessage(programId); + }); +}; + export const postProgram = async ( body: PostProgramRequest, ): Promise => { diff --git a/FE/src/app/(auth)/layout.tsx b/FE/src/app/(auth)/layout.tsx index d5e7264a..e1c97542 100644 --- a/FE/src/app/(auth)/layout.tsx +++ b/FE/src/app/(auth)/layout.tsx @@ -1,8 +1,10 @@ import { PropsWithChildren } from "react"; +import AuthValidate from "@/components/common/validate/Auth"; export default function AuthLayout({ children }: PropsWithChildren) { return (
+ {children}
); diff --git a/FE/src/app/(guest)/guest/detail/[programId]/page.tsx b/FE/src/app/(guest)/guest/detail/[programId]/page.tsx new file mode 100644 index 00000000..72bb0405 --- /dev/null +++ b/FE/src/app/(guest)/guest/detail/[programId]/page.tsx @@ -0,0 +1,22 @@ +import AttendeeInfoContainer from "@/components/programDetail/attendee/AttendeeInfo.container"; +import ProgramInfo from "@/components/programDetail/program/ProgramInfo"; +import UserAttendModalContainer from "@/components/programDetail/userAttendModal/UserAttendModal.container"; + +interface ProgramDetailPageProps { + params: { + programId: string; + }; +} + +const ProgramDetailPage = ({ params }: ProgramDetailPageProps) => { + const { programId } = params; + + return ( +
+ + + +
+ ); +}; +export default ProgramDetailPage; diff --git a/FE/src/app/(guest)/guest/detail/error.tsx b/FE/src/app/(guest)/guest/detail/error.tsx new file mode 100644 index 00000000..adf565a8 --- /dev/null +++ b/FE/src/app/(guest)/guest/detail/error.tsx @@ -0,0 +1,19 @@ +"use client"; + +import ErrorFallback from "@/components/common/ErrorFallback"; + +const DetailPageError = () => { + const error = { + message: "행사 정보를 불러오는 중에 오류가 발생했습니다.", + }; + + return ( + { + window.location.reload(); + }} + /> + ); +}; +export default DetailPageError; diff --git a/FE/src/app/(guest)/guest/detail/loading.tsx b/FE/src/app/(guest)/guest/detail/loading.tsx new file mode 100644 index 00000000..4a5cb70e --- /dev/null +++ b/FE/src/app/(guest)/guest/detail/loading.tsx @@ -0,0 +1,4 @@ +import LoadingSpinner from "@/components/common/LoadingSpinner"; + +const DetailLoading = () => ; +export default DetailLoading; diff --git a/FE/src/app/(private)/(program)/layout.tsx b/FE/src/app/(guest)/guest/layout.tsx similarity index 50% rename from FE/src/app/(private)/(program)/layout.tsx rename to FE/src/app/(guest)/guest/layout.tsx index ebe8f27e..66e74b1a 100644 --- a/FE/src/app/(private)/(program)/layout.tsx +++ b/FE/src/app/(guest)/guest/layout.tsx @@ -1,10 +1,13 @@ -import { PropsWithChildren } from "react"; import Header from "@/components/common/header/Header"; +import AuthValidate from "@/components/common/validate/Auth"; -export default function ProgramLayout({ children }: PropsWithChildren) { +export default function GuestLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { return ( <>
+
{children}
diff --git a/FE/src/app/(guest)/guest/main/loading.tsx b/FE/src/app/(guest)/guest/main/loading.tsx new file mode 100644 index 00000000..2ec65fef --- /dev/null +++ b/FE/src/app/(guest)/guest/main/loading.tsx @@ -0,0 +1,6 @@ +import LoadingSpinner from "@/components/common/LoadingSpinner"; + +const MainLoading = () => { + return ; +}; +export default MainLoading; diff --git a/FE/src/app/(guest)/guest/main/page.tsx b/FE/src/app/(guest)/guest/main/page.tsx new file mode 100644 index 00000000..8af24436 --- /dev/null +++ b/FE/src/app/(guest)/guest/main/page.tsx @@ -0,0 +1,98 @@ +// TODO: 서버 컴포넌트로 변경하기 +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import ErrorFallback from "@/components/common/ErrorFallback"; +import Tab from "@/components/common/tabs/Tab"; +import TextTab from "@/components/common/tabs/TextTab"; +import ProgramList from "@/components/main/ProgramList"; +import ProgramListLoader from "@/components/main/ProgramList.loader"; +import TeamBuildingDropup from "@/components/main/TeamBuildingDropup"; +import MAIN from "@/constants/MAIN"; +import PROGRAM from "@/constants/PROGRAM"; +import { ProgramCategoryWithAll, ProgramStatus } from "@/types/program"; + +const MainPage = () => { + const searchParams = useSearchParams(); + + // TODO: Hook으로 변경하기 + const [queryValue, setQueryValue] = useState(MAIN.DEFAULT_QUERY); + + // TODO: useEffect를 Hook으로 변경하기 + useEffect(() => { + setQueryValue({ + ...MAIN.DEFAULT_QUERY, + category: + (searchParams.get("category") as ProgramCategoryWithAll) ?? "all", + status: (searchParams.get("status") as ProgramStatus) ?? "active", + page: searchParams.get("page") ?? "1", + }); + }, [searchParams]); + + useEffect(() => { + window.history.replaceState( + {}, + "", + `?category=${queryValue.category}&status=${queryValue.status}&page=${queryValue.page}`, + ); + }, [queryValue]); + + const handleSetCategory = (category: ProgramCategoryWithAll) => { + setQueryValue({ + ...queryValue, + category, + page: "1", + }); + }; + + const handleSetStatus = (status: ProgramStatus) => { + setQueryValue({ + ...queryValue, + status, + page: "1", + }); + }; + + const handleSetPage = (page: number) => { + setQueryValue({ + ...queryValue, + page: page.toString(), + }); + }; + + // TODO: 합성 컴포넌트! + return ( +
+ + options={Object.values(PROGRAM.CATEGORY_TAB_WITH_ALL)} + selected={queryValue.category} + onItemClick={(v) => handleSetCategory(v)} + size="lg" + baseColor="white" + pointColor="navy" + align="line" + /> + + options={Object.values(PROGRAM.STATUS_TAB)} + selected={queryValue.status} + onClick={(v) => handleSetStatus(v)} + /> + + }> + + + + +
+ ); +}; + +export default MainPage; diff --git a/FE/src/app/(private)/(program)/detail/[programId]/page.tsx b/FE/src/app/(private)/(program)/detail/[programId]/page.tsx index b9feb876..9315cc77 100644 --- a/FE/src/app/(private)/(program)/detail/[programId]/page.tsx +++ b/FE/src/app/(private)/(program)/detail/[programId]/page.tsx @@ -13,9 +13,9 @@ const ProgramDetailPage = ({ params }: ProgramDetailPageProps) => { return (
- - - + + +
); }; diff --git a/FE/src/app/(private)/(program)/edit/[programId]/page.tsx b/FE/src/app/(private)/(program)/edit/[programId]/page.tsx index e9ec9227..4aa6aaec 100644 --- a/FE/src/app/(private)/(program)/edit/[programId]/page.tsx +++ b/FE/src/app/(private)/(program)/edit/[programId]/page.tsx @@ -14,7 +14,7 @@ interface ProgramEditPageProps { const ProgramEditPage = ({ params }: ProgramEditPageProps) => { const { programId } = params; - const { data: programInfo, isLoading } = useGetProgramById(+programId); + const { data: programInfo, isLoading } = useGetProgramById(+programId, true); if (isLoading) return ; diff --git a/FE/src/app/(private)/(program)/main/page.tsx b/FE/src/app/(private)/(program)/main/page.tsx index e3dcb8b2..78b3e60d 100644 --- a/FE/src/app/(private)/(program)/main/page.tsx +++ b/FE/src/app/(private)/(program)/main/page.tsx @@ -86,6 +86,7 @@ const MainPage = () => { programStatus={queryValue.status} page={+queryValue.page} setPage={handleSetPage} + isLoggedIn /> diff --git a/FE/src/app/(private)/layout.tsx b/FE/src/app/(private)/layout.tsx index b3876d21..cbebc4be 100644 --- a/FE/src/app/(private)/layout.tsx +++ b/FE/src/app/(private)/layout.tsx @@ -1,11 +1,15 @@ import { PropsWithChildren } from "react"; +import Header from "@/components/common/header/Header"; import AuthValidate from "@/components/common/validate/Auth"; const PrivateLayout = ({ children }: PropsWithChildren) => { return ( <> - {children} +
+
+ {children} +
); }; diff --git a/FE/src/app/page.tsx b/FE/src/app/page.tsx index 01bd7eb3..58cbd423 100644 --- a/FE/src/app/page.tsx +++ b/FE/src/app/page.tsx @@ -1,4 +1,7 @@ +import { redirect } from "next/navigation"; + export default function Home() { + redirect("/login"); return (

Home

diff --git a/FE/src/components/common/MemberList.tsx b/FE/src/components/common/MemberList.tsx index 9ec65440..c62f573c 100644 --- a/FE/src/components/common/MemberList.tsx +++ b/FE/src/components/common/MemberList.tsx @@ -2,11 +2,16 @@ import { SimpleMemberInfo } from "@/types/member"; interface MemberListProps { members: SimpleMemberInfo[]; + blur?: boolean; } -const MemberList = ({ members }: MemberListProps) => { +const MemberList = ({ members, blur = false }: MemberListProps) => { return ( -
+
{members.map((member) => ( ))} @@ -16,7 +21,7 @@ const MemberList = ({ members }: MemberListProps) => { const MemberListItem = ({ name }: Omit) => { return ( -
+
{name}
); diff --git a/FE/src/components/common/header/Header.tsx b/FE/src/components/common/header/Header.tsx index 53c27539..0ec97a95 100644 --- a/FE/src/components/common/header/Header.tsx +++ b/FE/src/components/common/header/Header.tsx @@ -1,15 +1,36 @@ +"use client"; +import { useEffect, useState } from "react"; import CreateBtn from "./CreateBtn"; +import LoginRedirectBtn from "./LoginRedirectBtn"; import Logo from "./Logo"; import UserBtn from "./UserBtn"; +import { CheckIsLoggedIn } from "@/utils/authWithStorage"; const Header = () => { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const isLoggedIn = CheckIsLoggedIn(); + setIsLoggedIn(isLoggedIn); + setIsLoading(false); + }, []); + return (
- -
- - -
+ + {!isLoading && ( +
+ {isLoggedIn ? ( + <> + + + + ) : ( + + )} +
+ )}
); }; diff --git a/FE/src/components/common/header/LoginRedirectBtn.tsx b/FE/src/components/common/header/LoginRedirectBtn.tsx new file mode 100644 index 00000000..06ff0a15 --- /dev/null +++ b/FE/src/components/common/header/LoginRedirectBtn.tsx @@ -0,0 +1,20 @@ +import Image from "next/image"; +import Link from "../Link"; +import ROUTES from "@/constants/ROUTES"; + +const LOGIN = "로그인 후 사용해주세요!"; + +export default function LoginRedirectBtn() { + return ( + + 로그인 버튼 + {LOGIN} + + ); +} diff --git a/FE/src/components/common/header/Logo.tsx b/FE/src/components/common/header/Logo.tsx index db45bdf3..f5b26fa2 100644 --- a/FE/src/components/common/header/Logo.tsx +++ b/FE/src/components/common/header/Logo.tsx @@ -1,5 +1,3 @@ -"use client"; - import Image from "next/image"; import { usePathname, useRouter } from "next/navigation"; import ROUTES from "@/constants/ROUTES"; @@ -8,16 +6,22 @@ const INIT_CATEGORY = "all"; const INIT_STATUS = "active"; const INIT_PAGE = "1"; -const Logo = () => { +interface LogoProps { + isLoggedIn: boolean; +} + +const Logo = ({ isLoggedIn }: LogoProps) => { const router = useRouter(); const pathname = usePathname(); + const mainUrl = isLoggedIn ? ROUTES.MAIN : ROUTES.GUEST_MAIN; + const handleClick = () => { - if (pathname === ROUTES.MAIN) { - window.location.href = `${ROUTES.MAIN}?category=${INIT_CATEGORY}&status=${INIT_STATUS}&page=${INIT_PAGE}`; + if (pathname === mainUrl) { + window.location.href = `${mainUrl}?category=${INIT_CATEGORY}&status=${INIT_STATUS}&page=${INIT_PAGE}`; return; } - router.push(ROUTES.MAIN); + router.push(mainUrl); }; return ( diff --git a/FE/src/components/common/header/UserBtn.tsx b/FE/src/components/common/header/UserBtn.tsx index ada937e9..921fc870 100644 --- a/FE/src/components/common/header/UserBtn.tsx +++ b/FE/src/components/common/header/UserBtn.tsx @@ -1,5 +1,3 @@ -"use client"; - import Image from "next/image"; import UserActiveModal from "./Modal/UserActiveModal"; import useModal from "@/hooks/useModal"; diff --git a/FE/src/components/common/validate/Auth.tsx b/FE/src/components/common/validate/Auth.tsx index 0f87d9d6..e226edec 100644 --- a/FE/src/components/common/validate/Auth.tsx +++ b/FE/src/components/common/validate/Auth.tsx @@ -5,16 +5,25 @@ import { useEffect } from "react"; import ROUTES from "@/constants/ROUTES"; import { deleteTokenInfo } from "@/utils/authWithStorage"; -const AuthValidate = () => { +interface AuthValidateProps { + isHaveToLoggedInRoute?: boolean; +} + +const AuthValidate = ({ isHaveToLoggedInRoute = true }: AuthValidateProps) => { const router = useRouter(); useEffect(() => { const accessToken = localStorage.getItem("accessToken"); const tokenExpiration = localStorage.getItem("tokenExpiration"); - if (!accessToken || !tokenExpiration) { + const isLoggedIn = accessToken || tokenExpiration; + + if (isHaveToLoggedInRoute && !isLoggedIn) { deleteTokenInfo(); router.push(ROUTES.LOGIN); } + if (!isHaveToLoggedInRoute && isLoggedIn) { + router.push(ROUTES.MAIN); + } }, []); return <>; diff --git a/FE/src/components/login/RightSection.tsx b/FE/src/components/login/RightSection.tsx index bcf030c1..72a2c867 100644 --- a/FE/src/components/login/RightSection.tsx +++ b/FE/src/components/login/RightSection.tsx @@ -1,5 +1,7 @@ import Title from "../common/Title"; -import SlackLoginSection from "./slack/LoginSection"; +import GuestLoginButton from "./guest/GuestLoginButton"; +import LoginSection from "./slack/LoginSection"; +import SlackLoginButton from "./slack/SlackLoginButton"; const LoginRightSection = () => { return ( @@ -8,10 +10,17 @@ const LoginRightSection = () => { className="flex flex-col items-center justify-center gap-24" > - <SlackLoginSection /> + <div className="flex flex-col gap-6"> + <LoginSection + title="에코노베이션 슬랙으로 로그인" + loginBtnComponent={<SlackLoginButton />} + /> + <LoginSection + title="게스트모드로 EEOS 둘러보기" + loginBtnComponent={<GuestLoginButton />} + /> + </div> </div> ); }; export default LoginRightSection; - -// diff --git a/FE/src/components/login/guest/GuestLoginButton.tsx b/FE/src/components/login/guest/GuestLoginButton.tsx new file mode 100644 index 00000000..6c556b66 --- /dev/null +++ b/FE/src/components/login/guest/GuestLoginButton.tsx @@ -0,0 +1,12 @@ +import StyledLoginButton from "../ui/StyledLoginButton"; + +export default function GuestLoginButton() { + return ( + <StyledLoginButton + linkUrl="/guest/main" + buttonText="Visit to EEOS" + imageUrl="/icons/blackCompany.svg" + color="guest" + /> + ); +} diff --git a/FE/src/components/login/guest/GuestLoginSection.tsx b/FE/src/components/login/guest/GuestLoginSection.tsx new file mode 100644 index 00000000..e898203f --- /dev/null +++ b/FE/src/components/login/guest/GuestLoginSection.tsx @@ -0,0 +1,12 @@ +import GuestLoginButton from "./GuestLoginButton"; + +const GuestLoginSection = ({ title }) => { + return ( + <div className="flex flex-col items-center gap-4"> + <p className="font-light">{title}</p> + <GuestLoginButton /> + </div> + ); +}; + +export default GuestLoginSection; diff --git a/FE/src/components/login/slack/LoginSection.tsx b/FE/src/components/login/slack/LoginSection.tsx index f239cc6d..a187254b 100644 --- a/FE/src/components/login/slack/LoginSection.tsx +++ b/FE/src/components/login/slack/LoginSection.tsx @@ -1,12 +1,17 @@ -import SlackLoginButton from "./LoginButton"; +import React from "react"; -const SlackLoginSection = () => { +interface LoginSectionProps { + title: string; + loginBtnComponent: React.ReactNode; +} + +const LoginSection = ({ title, loginBtnComponent }: LoginSectionProps) => { return ( - <div className="flex flex-col items-center gap-4"> - <p className="font-light">에코노베이션 슬랙으로 로그인</p> - <SlackLoginButton /> + <div className="flex flex-col items-center gap-6"> + <p className="font-light">{title}</p> + {loginBtnComponent} </div> ); }; -export default SlackLoginSection; +export default LoginSection; diff --git a/FE/src/components/login/slack/LoginButton.tsx b/FE/src/components/login/slack/SlackLoginButton.tsx similarity index 70% rename from FE/src/components/login/slack/LoginButton.tsx rename to FE/src/components/login/slack/SlackLoginButton.tsx index b7a29e8a..b1f4ad75 100644 --- a/FE/src/components/login/slack/LoginButton.tsx +++ b/FE/src/components/login/slack/SlackLoginButton.tsx @@ -1,9 +1,8 @@ "use client"; -import Image from "next/image"; -import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; +import StyledLoginButton from "../ui/StyledLoginButton"; import { useSlackLoginMutation } from "@/hooks/query/useAuthQuery"; const SlackLoginButton = () => { @@ -24,13 +23,13 @@ const SlackLoginButton = () => { }, [code]); return ( - <Link - className="flex w-64 justify-center gap-4 rounded-3xl bg-slack py-3" - href={slackLoginUrl} - > - <Image src="/icons/slack.svg" alt="슬랙 로고" width={24} height={24} /> - <p className="text-center font-semibold text-white">슬랙으로 로그인</p> - </Link> + <StyledLoginButton + linkUrl={slackLoginUrl} + buttonText="슬랙으로 로그인" + imageUrl="/icons/slack.svg" + color="slack" + /> ); }; + export default SlackLoginButton; diff --git a/FE/src/components/login/ui/StyledLoginButton.tsx b/FE/src/components/login/ui/StyledLoginButton.tsx new file mode 100644 index 00000000..21a5be66 --- /dev/null +++ b/FE/src/components/login/ui/StyledLoginButton.tsx @@ -0,0 +1,32 @@ +import classNames from "classnames"; +import Image from "next/image"; +import Link from "next/link"; + +const defaultStyle = "flex w-64 justify-center gap-4 rounded-3xl py-3"; + +const colors = { + slack: "bg-slack text-white text-paragraph", + guest: "bg-primary text-black text-paragraph", +}; + +interface StyledLoginButtonProps { + linkUrl: string; + buttonText: string; + imageUrl: string; + color: keyof typeof colors; +} + +export default function StyledLoginButton({ + linkUrl, + buttonText, + imageUrl, + color, +}: StyledLoginButtonProps) { + const buttonStyle = classNames(defaultStyle, colors[color]); + return ( + <Link className={buttonStyle} href={linkUrl}> + <Image src={imageUrl} alt="슬랙 로고" width={24} height={24} /> + <p className="text-center font-semibold">{buttonText}</p> + </Link> + ); +} diff --git a/FE/src/components/main/ProgramList.tsx b/FE/src/components/main/ProgramList.tsx index cabccb60..b661ff5c 100644 --- a/FE/src/components/main/ProgramList.tsx +++ b/FE/src/components/main/ProgramList.tsx @@ -10,6 +10,7 @@ interface ProgramListProps { programStatus?: ProgramStatus; page?: number; setPage: (page: number) => void; + isLoggedIn: boolean; } const ProgramList = ({ @@ -17,6 +18,7 @@ const ProgramList = ({ programStatus = "active", page = 1, setPage: handleSetPage, + isLoggedIn, }: ProgramListProps) => { const queryClient = useQueryClient(); const { data: programListData } = useGetProgramList({ @@ -24,6 +26,7 @@ const ProgramList = ({ programStatus, page: page - 1, size: PROGRAM.LIST_SIZE, + isLoggedIn, }); queryClient.setQueryData<number>(["totalPage"], programListData.totalPage); @@ -33,7 +36,11 @@ const ProgramList = ({ <> <div className="w-full space-y-5"> {programs.map((program) => ( - <ProgramListItem key={program.programId} programData={program} /> + <ProgramListItem + key={program.programId} + programData={program} + isLoggedIn={isLoggedIn} + /> ))} </div> <Paginataion diff --git a/FE/src/components/main/ProgramListItem.tsx b/FE/src/components/main/ProgramListItem.tsx index 0bc9439a..325fb703 100644 --- a/FE/src/components/main/ProgramListItem.tsx +++ b/FE/src/components/main/ProgramListItem.tsx @@ -5,14 +5,18 @@ import { convertDate } from "@/utils/convert"; interface ProgramListItemProps { programData: ProgramSimpleInfoDto; + isLoggedIn: boolean; } -const ProgramListItem = ({ programData }: ProgramListItemProps) => { +const ProgramListItem = ({ programData, isLoggedIn }: ProgramListItemProps) => { const { programId, title, deadLine } = programData; + const lingUrl = isLoggedIn + ? ROUTES.DETAIL(programId) + : ROUTES.GUEST_DETAIL(programId); return ( <Link - href={ROUTES.DETAIL(programId)} className="flex w-full flex-col items-center justify-between gap-4 rounded-lg bg-gray-10 px-8 py-6 transition-all hover:bg-secondary-20 sm:flex-row" + href={lingUrl} key={programId} > <p className="w-full truncate text-center text-lg font-bold sm:text-left"> diff --git a/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx b/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx index 12c92ed5..4e24007f 100644 --- a/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx +++ b/FE/src/components/programDetail/attendee/AttendeeInfo.container.tsx @@ -2,22 +2,45 @@ import { ErrorBoundary } from "react-error-boundary"; import AttendeeInfo from "./AttendeeInfo"; +import BluredAttedee from "./BluredAttedee"; import ErrorFallback from "@/components/common/ErrorFallback"; interface AttendeeInfoContainerProps { programId: number; + isLoggedIn: boolean; } -const AttendeeInfoContainer = ({ programId }: AttendeeInfoContainerProps) => { +export const attendStatuses = [ + "attend", + "late", + "absent", + "nonResponse", +] as const; + +const AttendeeInfoContainer = ({ + programId, + isLoggedIn, +}: AttendeeInfoContainerProps) => { return ( - <ErrorBoundary FallbackComponent={ErrorFallback}> - <div className="space-y-16"> - <AttendeeInfo programId={programId} status="attend" /> - <AttendeeInfo programId={programId} status="late" /> - <AttendeeInfo programId={programId} status="absent" /> - <AttendeeInfo programId={programId} status="nonResponse" /> - </div> - </ErrorBoundary> + <> + {!isLoggedIn && + attendStatuses.map((status) => ( + <BluredAttedee key={status} status={status} /> + ))} + + <ErrorBoundary FallbackComponent={ErrorFallback}> + <div className="space-y-16"> + {isLoggedIn && + attendStatuses.map((status) => ( + <AttendeeInfo + key={status} + programId={programId} + status={status} + /> + ))} + </div> + </ErrorBoundary> + </> ); }; export default AttendeeInfoContainer; diff --git a/FE/src/components/programDetail/attendee/BluredAttedee.tsx b/FE/src/components/programDetail/attendee/BluredAttedee.tsx new file mode 100644 index 00000000..ee73847d --- /dev/null +++ b/FE/src/components/programDetail/attendee/BluredAttedee.tsx @@ -0,0 +1,35 @@ +import AttendeeStatus from "./AttendeeStatus"; +import { MemberAttendStatusInfoDto } from "@/apis/dtos/member.dto"; +import MemberList from "@/components/common/MemberList"; +import { AttendStatus } from "@/types/member"; + +const mockMember: MemberAttendStatusInfoDto = { + memberId: 1, + name: "20기 박건규", + attendStatus: "attend", +}; + +interface BluredAttedeeProps { + status: AttendStatus; +} + +export default function BluredAttedee({ status }: BluredAttedeeProps) { + const population = + status === "attend" + ? 52 + : status === "late" + ? 3 + : status === "absent" + ? 1 + : 0; + const members = Array.from({ length: population }, (_, i) => ({ + ...mockMember, + memberId: i + 1, + })); + return ( + <div className="space-y-16"> + <AttendeeStatus status={status} members={members} /> + <MemberList members={members} blur /> + </div> + ); +} diff --git a/FE/src/components/programDetail/program/ProgramInfo.tsx b/FE/src/components/programDetail/program/ProgramInfo.tsx index 632832ad..6c9a016c 100644 --- a/FE/src/components/programDetail/program/ProgramInfo.tsx +++ b/FE/src/components/programDetail/program/ProgramInfo.tsx @@ -7,14 +7,15 @@ import { useGetProgramById } from "@/hooks/query/useProgramQuery"; interface ProgramInfoProps { programId: number; + isLoggedIn: boolean; } -const ProgramInfo = ({ programId }: ProgramInfoProps) => { +const ProgramInfo = ({ programId, isLoggedIn }: ProgramInfoProps) => { const { data: programData, isLoading, isError, - } = useGetProgramById(programId); + } = useGetProgramById(programId, isLoggedIn); if (isLoading) return <ProgramInfoLoader />; if (isError) return <div>에러 발생</div>; diff --git a/FE/src/components/programDetail/userAttendModal/LoginModal.tsx b/FE/src/components/programDetail/userAttendModal/LoginModal.tsx new file mode 100644 index 00000000..d3f4a3bf --- /dev/null +++ b/FE/src/components/programDetail/userAttendModal/LoginModal.tsx @@ -0,0 +1,24 @@ +// import AttendToggleLabel from "./AttendToggleLabel"; +import Link from "next/link"; +import AttendStatusToggle from "@/components/common/attendStatusToggle/AttendStatusToggle"; +import StatusToggleItem from "@/components/common/attendStatusToggle/StatusToggleItem"; +import ROUTES from "@/constants/ROUTES"; + +export default function LoginModal() { + return ( + <div> + <Link + href={ROUTES.LOGIN} + className="flex items-center justify-center gap-4" + > + <StatusToggleItem text="로그인" color="yellow" /> + </Link> + <p className="my-5">로그인 후 사용해주세요! </p> + <AttendStatusToggle + selectedValue={"nonRelated"} + disabled={true} + onSelect={() => {}} + /> + </div> + ); +} diff --git a/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx b/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx index ee68ae12..653782ad 100644 --- a/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx +++ b/FE/src/components/programDetail/userAttendModal/UserAttendModal.container.tsx @@ -3,6 +3,7 @@ import classNames from "classnames"; import Image from "next/image"; import { ErrorBoundary } from "react-error-boundary"; +import LoginModal from "./LoginModal"; import UserAttendModal from "./UserAttendModal"; import ErrorFallbackNoIcon from "@/components/common/ErrorFallbackNoIcon"; import useModal from "@/hooks/useModal"; @@ -10,9 +11,13 @@ import useOutsideRef from "@/hooks/useOutsideRef"; interface UserAttendModalProps { programId: number; + isLoggedIn: boolean; } -const UserAttendModalContainer = ({ programId }: UserAttendModalProps) => { +const UserAttendModalContainer = ({ + programId, + isLoggedIn, +}: UserAttendModalProps) => { const { isOpen, openModal, closeModal } = useModal(); const modalRef = useOutsideRef(closeModal); @@ -26,7 +31,6 @@ const UserAttendModalContainer = ({ programId }: UserAttendModalProps) => { const handleOpenModal = (e: React.MouseEvent) => { e.stopPropagation(); - console.log("hi", isOpen); isOpen ? closeModal() : openModal(); }; @@ -46,9 +50,13 @@ const UserAttendModalContainer = ({ programId }: UserAttendModalProps) => { style={{ width: 38, height: 6 }} /> </div> - <ErrorBoundary FallbackComponent={ErrorFallbackNoIcon}> - <UserAttendModal programId={programId} /> - </ErrorBoundary> + {isLoggedIn ? ( + <ErrorBoundary FallbackComponent={ErrorFallbackNoIcon}> + <UserAttendModal programId={programId} /> + </ErrorBoundary> + ) : ( + <LoginModal /> + )} </button> ); }; diff --git a/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx b/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx index 3c48780b..ad1a8efb 100644 --- a/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx +++ b/FE/src/components/programDetail/userAttendModal/UserAttendModal.tsx @@ -1,5 +1,3 @@ -"use client"; - import { useQueryClient } from "@tanstack/react-query"; import AttendStatusModalLoader from "./AttendStatusModal.loader"; import AttendStatusView from "./AttendStatusView"; diff --git a/FE/src/constants/API.ts b/FE/src/constants/API.ts index 6f04c50e..2349f8ed 100644 --- a/FE/src/constants/API.ts +++ b/FE/src/constants/API.ts @@ -1,10 +1,14 @@ const PROGRAM = { LIST: "/programs", CREATE: "/programs", + GUEST_LIST: "/guest/programs", UPDATE: (programId: number) => `/programs/${programId}`, DELETE: (programId: number) => `/programs/${programId}`, DETAIL: (programId: number) => `/programs/${programId}`, + GUEST_DETAIL: (programId: number) => `/guest/programs/${programId}`, ACCESS_RIGHT: (programId: number) => `/programs/${programId}/accessRight`, + SEND_MESSAGE: (programId: number) => + `/programs/${programId}/slack/notification`, }; const MEMBER = { diff --git a/FE/src/constants/MESSAGE.ts b/FE/src/constants/MESSAGE.ts index 2a275832..6dbee93e 100644 --- a/FE/src/constants/MESSAGE.ts +++ b/FE/src/constants/MESSAGE.ts @@ -41,6 +41,13 @@ const TEAM_BUILDING = { INCREATABLE: "진행중인 팀빌딩이 있어 팀빌딩을 생성할 수 없습니다.", }; +const SLACK_MESSAGE = { + CONFIRM: + "슬랙 알림을 보내시겠습니까? 슬랙 알림은 공지사항 채널에 전송됩니다.", + SUCCESS: "슬랙 알림이 성공적으로 보내졌습니다! 공지사항 채널을 확인해주세요", + FAIL: "알 수 없는 이유로 슬랙 알림 전송에 실패했습니다. 다시 시도하겠습니까?", +}; + Object.freeze(EDIT_DISABLED); Object.freeze(AUTH); Object.freeze(EDIT); @@ -59,4 +66,5 @@ export default { CONFIRM, TEAM_BUILDING, COMPLATE, + SLACK_MESSAGE, }; diff --git a/FE/src/constants/ROUTES.ts b/FE/src/constants/ROUTES.ts index a95dd097..a13007d5 100644 --- a/FE/src/constants/ROUTES.ts +++ b/FE/src/constants/ROUTES.ts @@ -1,7 +1,9 @@ const ROUTES = { MAIN: "/main", + GUEST_MAIN: "/guest/main", CREATE: "/create", DETAIL: (programId: number) => `/detail/${programId}`, + GUEST_DETAIL: (programId: number) => `/guest/detail/${programId}`, EDIT: (programId: number) => `/edit/${programId}`, ERROR: "/error", LOGIN: "/login", diff --git a/FE/src/hooks/query/useProgramQuery.ts b/FE/src/hooks/query/useProgramQuery.ts index d45bc985..1534a5a5 100644 --- a/FE/src/hooks/query/useProgramQuery.ts +++ b/FE/src/hooks/query/useProgramQuery.ts @@ -10,6 +10,7 @@ import { getProgramList, patchProgram, postProgram, + sendSlackMessage, } from "@/apis/program"; import API from "@/constants/API"; import ROUTES from "@/constants/ROUTES"; @@ -26,10 +27,14 @@ export const useCreateProgram = ({ programData, formReset }: CreateProgram) => { return useMutation({ mutationKey: [API.PROGRAM.CREATE], - mutationFn: () => postProgram(programData), - onSettled: (data) => { + mutationFn: async () => { + const { programId } = await postProgram(programData); + await sendSlackMessage(programId); + return programId; + }, + onSuccess: (programId) => { formReset(); - data && router.replace(ROUTES.DETAIL(data?.programId)); + programId && router.replace(ROUTES.DETAIL(programId)); }, }); }; @@ -66,13 +71,13 @@ export const useDeleteProgram = (programId: number) => { }); }; -export const useGetProgramById = (programId: number) => { +export const useGetProgramById = (programId: number, isLoggedIn: boolean) => { const queryClient = useQueryClient(); return useQuery({ queryKey: [API.PROGRAM.DETAIL(programId)], queryFn: () => - getProgramById(programId).then((res) => { + getProgramById(programId, isLoggedIn).then((res) => { queryClient.setQueryData<ProgramStatus>( ["programStatus", programId], res.programStatus, @@ -91,10 +96,12 @@ export const useGetProgramList = ({ programStatus, size, page, + isLoggedIn, }: GetProgramListRequest) => { return useQuery({ queryKey: [API.PROGRAM.LIST, category, programStatus, size, page], - queryFn: () => getProgramList({ category, programStatus, size, page }), + queryFn: () => + getProgramList({ category, programStatus, size, page, isLoggedIn }), select: (data) => ({ totalPage: data?.totalPage, programs: data?.programs, diff --git a/FE/src/utils/authWithStorage.ts b/FE/src/utils/authWithStorage.ts index 4bbfc9bc..2fa269c9 100644 --- a/FE/src/utils/authWithStorage.ts +++ b/FE/src/utils/authWithStorage.ts @@ -21,3 +21,31 @@ export const deleteTokenInfo = () => { removeAccessToken(); removeTokenExpiration(); }; + +export const getAccessToken = () => { + return localStorage.getItem("accessToken"); +}; + +export const getTokenExpiration = () => { + return localStorage.getItem("tokenExpiration"); +}; + +export const CheckIsLoggedIn = () => { + // if (typeof window !== "undefined") return false; + const accessToken = getAccessToken(); + const tokenExpiration = getTokenExpiration(); + + if (!accessToken || !tokenExpiration) { + deleteTokenInfo(); + return false; + } + + const tokenExpirationDate = new Date(+tokenExpiration); + const now = new Date(); + + if (tokenExpirationDate < now) { + deleteTokenInfo(); + return false; + } + return true; +}; diff --git a/FE/src/utils/provider.tsx b/FE/src/utils/provider.tsx index 9786c4b3..2115f7c6 100644 --- a/FE/src/utils/provider.tsx +++ b/FE/src/utils/provider.tsx @@ -1,6 +1,7 @@ "use client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { PropsWithChildren } from "react"; import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; @@ -12,6 +13,9 @@ const Provider = ({ children }: PropsWithChildren) => { retry: false, useErrorBoundary: true, }, + mutations: { + retry: false, + }, }, }); @@ -24,6 +28,7 @@ const Provider = ({ children }: PropsWithChildren) => { pauseOnFocusLoss={false} /> {children} + <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> ); };