diff --git a/app/recruit/page.tsx b/app/recruit/page.tsx index 0c44299..4ef8568 100644 --- a/app/recruit/page.tsx +++ b/app/recruit/page.tsx @@ -1,6 +1,4 @@ "use client"; - -import { RECRUIT } from "@/src/constants/recruit/recruit.ko"; import { Footer } from "@/src/components/common/Footer"; import { Faq } from "@/src/components/recruit/Faq"; import { Fileds } from "@/src/components/recruit/Fields"; @@ -8,11 +6,12 @@ import { Recruit } from "@/src/components/recruit/Recruit"; import { Subscription } from "@/src/components/recruit/Subscription"; import { Waiting } from "@/src/components/recruit/Waiting"; import { useInsertionEffect, useRef, useState } from "react"; +import useRecruitStatus from "../../src/hooks/useRecruitStatus"; const RecruitPage = () => { const [emailInputValue, setEmailInputValue] = useState(""); const recruitRef = useRef(null); - + const { recruitStatus } = useRecruitStatus(); const scrollToRecruit = () => window.scrollTo({ top: recruitRef.current?.offsetTop, @@ -33,9 +32,10 @@ const RecruitPage = () => { return ( <>
- {!RECRUIT.IS_ON && ( + {recruitStatus !== "OPEN" && (
setEmailInputValue(e.target.value)} scrollToRecruit={scrollToRecruit} diff --git a/docs/Recruit.md b/docs/Recruit.md index 209194f..99923db 100644 --- a/docs/Recruit.md +++ b/docs/Recruit.md @@ -25,8 +25,51 @@ const FAQ = { string의 값들을 관리하기 위해서 모아두었습니다. 안에 소개 글을 바꾸고 싶다면 여기 있는 내용을 바꾸면 됩니다. -IS_ON은 전역적으로 영향을 받으며 true는 리크루트를 하고 있는 상태, false는 리크루트를 하지 않는 상태입니다. -true일때는 메인 페이지 하단에 날자가 쓰여집니다. false일때 리크루트 구독 페이지가 활성화 됩니다. +### IS_ON (Deprecated 됨) + +~~IS_ON은 전역적으로 영향을 받으며 true는 리크루트를 하고 있는 상태, false는 리크루트를 하지 않는 상태입니다. +true일때는 메인 페이지 하단에 날자가 쓰여집니다. false일때 리크루트 구독 페이지가 활성화 됩니다.~~ + +IS_ON 속성은 Deprecated 되었습니다. 그 이유는 다음과 같습니다. + +- 관리자가 수동으로 상태를 변경해주어야 합니다. 그러므로 번거롭습니다. +- 아직 신입모집을 하고 있는 상태는 아니지만 준비된 상태를 나타내기 힘듭니다. 즉, READY | OPEN | CLOSED 의 상태가 필요합니다. + +### START_DATE, END_DATE + +위의 `IS_ON`이 해결하지 못하는 문제를 해결하기 위해 두가지 속성과 한 가지 훅으로 관리합니다. +이 섹션에서는 두가지 속성에 대해 설명합니다. + +**START_DATE** + +신입모집을 모집을 시작하는 UTC 시간을 나타냅니다. + +**END_DATE** + +신입모집의 마감하는 UTC 시간을 나타냅니다. + +> ⚠️ 반드시 신입모집 시작 일주일 전에는 해당 값과 기수 값을 바꿔주어야 합니다. + +우리는 이제 두 값만 설정해둔다면, 쉽게 상태를 얻을 수 있습니다. 이는 `useRecruitStatus()`훅을 통해 구현합니다. + +### useRecruitStatus() + +hook을 사용하기 전에, 이를 가정합니다. + +- `START_DATE` 는 항상 `END_DATE`보다 이른 시간이어야 합니다. +- 두 속성은 항상 UTC로 정의 되어야 합니다. + +훅은 다음과 같이 동작합나다. + +- 만약 현재 시간이 `START_DATE`보다 이전이면, 훅은 `READY`를 반환합니다. +- 만약 현재 시간이 `START_DATE`와 `END_DATE` 사이 이면, 상태는 `OPEN`을 반환합니다. +- 만약 현재 시간이 `END_DATE`이후면, 훅은 `CLOSED`를 반환합니다. + +#### how to use + +```ts +const { recruitStatus } = useRecruitStatus(); +``` GENERATION은 해당 진행되는 기수입니다. 2023년도 1학기 기준 25기를 모집하였습니다. @@ -34,13 +77,15 @@ GENERATION은 해당 진행되는 기수입니다. 2023년도 1학기 기준 25 const RECRUIT = { TITLE: "recruit", // recruit string 입니다. CONTENT: string, // recruit에 대한 소개입니다. - IS_ON: boolean, // 리크루트의 진행 상황에 대해 나타냅니다. + IS_ON: boolean, // DEPRECATED: 리크루트의 진행 상황에 대해 나타냅니다. + START_DATE: Date, // 신입모집 시작 시간을 UTC로 표현합니다. + END_DATE: Date, // 신입모집 서류 마감 시간을 UTC로 표현합니다. GENERATION: number, // 해당 기수에 대해서 입니다. SCHEDULE: { // 4개면 가장 좋습니다. TEXT: string, // 스케줄에 대해서 설명하는 string 입니다. DATE: string, //스케쥴이 언제까지 인지 설명하는 string 입니다. 추후 RECRUIT_FLOAT도 변경해야 합니다. }[], - WATING: { // IS_ON이 false일 때 활성화 되는 곳입니다. + WATING: { // RecruitStatus가 OPEN이 아닐 때 나타나는 정보들 입니다. TITLE: "comming soon", // warting string 입니다. CONTENT: string, // 마감되었다는 글입니다. \n으로 띄어쓰기를 할 수 있습니다. EMAIL_ALERT: string, // email로 받을 때 소개 글입니다. "입력한 정보의 보유기간은 모집 알림 전송시까지 보관됩니다.", @@ -76,6 +121,5 @@ const RECRUIT_FLOAT = { // 메인페이지에 뜨는 부분입니다. HOUR: "hour", MINUTE: "minute", SECOND: "second", - RECRUIT_START_DATE: "2023-08-04", // 시작하는 날짜 입니다. "yyyy-mm-dd" 형태를 유지해 주세요. }; ``` diff --git a/src/components/main/Intro.tsx b/src/components/main/Intro.tsx index b02d73b..7e6d5d2 100644 --- a/src/components/main/Intro.tsx +++ b/src/components/main/Intro.tsx @@ -4,14 +4,13 @@ import econovationBlackLogo from "/public/images/econovation-black.svg"; import { InfinityAutoScroll } from "../common/InfinityAutoScroll"; import { ABOUT, JOBS } from "@/src/constants/main.ko"; import { Fragment } from "react"; -import { RECRUIT } from "@/src/constants/recruit/recruit.ko"; import { RecruitFloat } from "./RecruitFloat"; import { cn } from "@/src/functions/util"; export const Intro = () => { return ( <> - {RECRUIT.IS_ON && } +

{ - const recruitDate = new Date(RECRUIT_FLOAT.RECRUIT_START_DATE); - const floatRef = useRef(null); - const floatDetailRef = useRef(null); - const { days, hours, minutes, seconds } = useTimeDiffer(recruitDate); - - const showDetail = () => { - gsap.to(floatDetailRef.current, { - duration: 0.5, - bottom: "-1rem", - ease: "back.out", - }); - gsap.to(floatRef.current, { - duration: 0.5, - bottom: "-150%", - ease: "back.in", - }); - }; +const renderwordByRecruitStatus = (recruitStatus: RecruitStatus) => { + switch (recruitStatus) { + case "READY": + return ( + + 시작까지 + + ); + case "OPEN": + return ( + + 마감까지 + + ); + case "CLOSED": + return <>; + default: + exhaustiveError(recruitStatus); + } +}; - const closeDetail = () => { - gsap.to(floatDetailRef.current, { - duration: 0.5, - bottom: "-200%", - ease: "back.in", - }); - gsap.to(floatRef.current, { - duration: 0.5, - bottom: "-1rem", - ease: "back.out", - }); - }; +export const RecruitFloat = () => { + const recruitStartDate = new Date(RECRUIT.START_DATE); + const recruitEndDate = new Date(RECRUIT.END_DATE); + const { floatDetailRef, floatRef, showDetail, closeDetail } = + useFloatAnimation(); + const { days, hours, minutes, seconds } = useTimeDiffer(recruitStartDate); + const { + days: endDays, + hours: endHours, + minutes: endMinutes, + seconds: endSeconds, + } = useTimeDiffer(recruitEndDate); + const { recruitStatus } = useRecruitStatus(); - return ( -
-
- {RECRUIT_FLOAT.ECONO_IS_RECRUITING} -
+ if (recruitStatus !== "CLOSED") { + return (
-
-
-
- {RECRUIT_FLOAT.ECONO_GENERTAION_RECRUIT_EN} -
-
{RECRUIT_FLOAT.ECONO_GENERTAION_RECRUIT_KR}
-
-
-
-
-
{days}
-
- {RECRUIT_FLOAT.DAY} -
-
-
-
{hours}
-
- {RECRUIT_FLOAT.HOUR} -
-
- colon -
-
{minutes}
-
- {RECRUIT_FLOAT.MINUTE} -
+
+ +
+
+
+
+
+ {RECRUIT_FLOAT.ECONO_GENERTAION_RECRUIT_EN}
- colon -
-
{seconds}
-
- {RECRUIT_FLOAT.SECOND} -
+
+ 에코노베이션 + ` {RECRUIT.GENERTAION}기 ` + 신입 모집  + {renderwordByRecruitStatus(recruitStatus)}
- - right-arrow - +
+ {recruitStatus === "OPEN" && ( + + )} + {recruitStatus === "READY" && ( + + )} + + + right-arrow + +
-
- ); + ); + } + return <>; }; diff --git a/src/components/main/RecruitFloat/HoverText.tsx b/src/components/main/RecruitFloat/HoverText.tsx new file mode 100644 index 0000000..1d385da --- /dev/null +++ b/src/components/main/RecruitFloat/HoverText.tsx @@ -0,0 +1,24 @@ +import { + RECRUIT_FLOAT, + type RecruitStatus, +} from "../../../constants/recruit/recruit.ko"; + +interface HoverTextProps { + status: RecruitStatus; + days?: number; +} + +const HoverText = ({ status, days }: HoverTextProps) => { + const hoverComponents = { + READY: ( + + {RECRUIT_FLOAT.ECONO_READY_FOR_RECRUIT} D-{days} + + ), + OPEN: {RECRUIT_FLOAT.ECONO_IS_RECRUITING}, + CLOSED: , + }; + return hoverComponents[status] ?? ; +}; + +export default HoverText; diff --git a/src/components/main/RecruitFloat/RecruitTimer.tsx b/src/components/main/RecruitFloat/RecruitTimer.tsx new file mode 100644 index 0000000..27ce21b --- /dev/null +++ b/src/components/main/RecruitFloat/RecruitTimer.tsx @@ -0,0 +1,44 @@ +import Image from "next/image"; +import { RECRUIT_FLOAT } from "../../../constants/recruit/recruit.ko"; + +interface RecruitTimerProps { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +const RecruitTimer = ({ days, hours, minutes, seconds }: RecruitTimerProps) => { + return ( +
+
+
{days}
+
{RECRUIT_FLOAT.DAY}
+
+
+
{hours}
+
{RECRUIT_FLOAT.HOUR}
+
+ colon +
+
{minutes}
+
{RECRUIT_FLOAT.MINUTE}
+
+ colon +
+
{seconds}
+
{RECRUIT_FLOAT.SECOND}
+
+
+ ); +}; + +export default RecruitTimer; diff --git a/src/components/recruit/Recruit.tsx b/src/components/recruit/Recruit.tsx index 9244309..083348a 100644 --- a/src/components/recruit/Recruit.tsx +++ b/src/components/recruit/Recruit.tsx @@ -1,13 +1,16 @@ +"use client"; + import { RECRUIT } from "@/src/constants/recruit/recruit.ko"; -import { LinkTo } from "../common/LinkTo"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useEffect } from "react"; import gsap from "gsap"; import { HambergerMenu } from "../common/Hamberger"; +import useRecruitStatus from "../../hooks/useRecruitStatus"; gsap.registerPlugin(ScrollTrigger); export const Recruit = () => { + const { recruitStatus } = useRecruitStatus(); useEffect(() => { gsap.to(".recruit-skedule-arrow", { width: "100%", @@ -30,7 +33,7 @@ export const Recruit = () => {
{d}
))}
- {RECRUIT.IS_ON && ( + {recruitStatus === "OPEN" && (

{RECRUIT.WAITING.CONTENT.split("\n").map((d, i) => (
- {RECRUIT.GENERTAION + i} + {RECRUIT.GENERTAION + i - (recruitStatus === "READY" ? 1 : 0)} {d}
))} diff --git a/src/constants/recruit/faq.ko.ts b/src/constants/recruit/faq.ko.ts index a86e50c..6bb5d8f 100644 --- a/src/constants/recruit/faq.ko.ts +++ b/src/constants/recruit/faq.ko.ts @@ -27,6 +27,10 @@ const FAQ = [ Q: "지원 결과는 언제 어디서 확인 가능한가요?", A: "지원 결과는 지원 마감일로부터 일주일 정도 후에 지원서에 써준 메일로 안내드리며,\n메일이 발송 되었다는 문자를 보내드립니다.", }, + { + Q: "지원 전에 꼭 알아두어야 할 사항이 있나요?", + A: " 에코노베이션은 열정을 가지고 3학기 이상 활동할 분을 찾고 있습니다. 또한 매주 금요일 17시 주간 발표 및 한 학기의 마무리 활동인 DEV 행사(7월 말, 1월 말)는 필수적으로 참여해야 합니다.", + }, ], }, { @@ -43,7 +47,7 @@ const FAQ = [ }, { Q: "면접 결과는 언제 받아볼 수 있나요?", - A: "면접 결과는 면접 마감일로부터 일주일 정도 후에 메일로 안내드리고 있으며,\n메일이 전송되었다는 문자를 발송해드립니다.", + A: "에코노베이션 28기 면접 결과는 9월 30일 중으로 메일을 통해서 개별 공지될 예정입니다. 면접 결과 발표시 문자로 메세지를 보내드릴 예정이오니 문자와 메일 확인에 신경 써주시길 부탁드립니다.", }, ], }, @@ -61,16 +65,24 @@ const FAQ = [ }, { Q: "주간 발표는 어떻게 진행되나요?", - A: "A팀, B팀 발표로 나누어져 한 주씩 진행되며 프로젝트를 진행하며 부딪쳤던 어려움과 그것을 어떻게 해결했는지에 대한 내용,\n해당 과정을 거치며 얻었던 부분에 대해 공유합니다.", + A: "A팀, B팀 발표로 나누어져 한 주씩 진행되며 프로젝트의 진행 상황, 프로젝트를 진행하며 부딪쳤던 어려움과 해결 방법에 대한 내용, 해당 과정을 거치며 얻었던 부분 그리고 자신이 공부한 내용에 대해 공유합니다.", }, { Q: "에코노베이션에 들어가면 주로 어떤 활동을 하게되나요?", - A: "에코노베이션은 매 학기 한 가지 주제를 가지고 실제 동작하는 서비스를 구현하는 프로젝트를 진행 합니다. 기한만 정해져 있는 프로젝트를 진행할 뿐만 아니라 의무적으로 매주 금요일에 모여서 프로젝트의 진행 상황과 공부한 내용을 공유하고 피드백을 주고받는 시간을 가집니다. 한 학기 동안 진행한 프로젝트는 DEV 행사에서 성과를 공유합니다.", + A: "에코노베이션은 매 학기 한 가지 주제를 가지고 실제 동작하는 서비스를 구현하는 프로젝트를 진행 합니다. 프로젝트를 진행할 뿐만 아니라 의무적으로 매주 금요일에 모여 주간발표시간을 가집니다. 한 학기 동안 진행한 프로젝트는 DEV 행사에서 성과를 공유합니다.", }, { Q: "프로젝트팀은 어떻게 구성하나요?", A: "첫 학기에는 지원하는 분야에 맞추어서 팀이 배정됩니다. 다음 학기부터는 '팀 빌딩 세션'을 통해 각자 관심 있는 분야에 대해 이야기를 나누고, 의견이 맞는 동아리원들이 모여 팀을 꾸리게 됩니다.", }, + { + Q: "에코노베이션은 학술활동만 하는 동아리인가요?", + A: "에코노베이션은 IT에 관심이 있는 사람들이 모인 커뮤니티입니다. 개발 프로젝트를 진행할 뿐만 아니라 회원들 간의 친목을 유지하기 위해서 핼러윈 파티, 야유회 등 다양한 행사를 기획하고 운영하고 있습니다.", + }, + { + Q: "회원 분류 제도가 있나요?", + A: "에코노베이션 회원은 AM, RM, CM으로 분류됩니다. AM(Active Member) 은 현 학기에 프로젝트를 수행하는 회원을 말합니다. RM(Rest Member)은 3학기 프로젝트를 이수하지 않았지만, 개인의 선택으로 인해 현 학기 동안에만 프로젝트를 수행하지 않는 회원을 말합니다. CM(Complete Member)은 3학기 프로젝트를 모두 이수한 회원을 말합니다.", + }, ], }, ]; diff --git a/src/constants/recruit/recruit.ko.ts b/src/constants/recruit/recruit.ko.ts index 4327b51..c817011 100644 --- a/src/constants/recruit/recruit.ko.ts +++ b/src/constants/recruit/recruit.ko.ts @@ -1,16 +1,21 @@ +import { exhaustiveError } from "../../functions/util"; import { URLS } from "../url.ko"; +export type RecruitStatus = "READY" | "OPEN" | "CLOSED"; + const RECRUIT = { TITLE: "recruit", CONTENT: "에코노베이션에서 함께할 여러분을 모집합니다.\n에코노베이션은 지식의 선순환이 자연스럽게 이루어지는 환경을 만드는 것을 목표하고 있습니다.\n개발에 열정이 있다면 에코노베이션에 들어와 지식의 선순환을 일으켜주세요.", - IS_ON: false, - GENERTAION: 27, + IS_ON: true /** FIXME: This property was deprecated. */, + START_DATE: Date.UTC(2024, 8, 2, 9, 0, 0), + END_DATE: Date.UTC(2024, 8, 15, 23, 59, 59), + GENERTAION: 28, SCHEDULE: [ - { TEXT: "서류 접수 시작", DATE: "3/4" }, - { TEXT: "서류 접수 마감", DATE: "3/15" }, - { TEXT: "면접 진행", DATE: "3/20 ~ 3/22" }, - { TEXT: "최종 합격 안내", DATE: "3/25" }, + { TEXT: "서류 접수 시작", DATE: "9/2" }, + { TEXT: "서류 접수 마감", DATE: "9/15" }, + { TEXT: "면접 진행", DATE: "9/23 ~ 9/25" }, + { TEXT: "최종 합격 안내", DATE: "9/30" }, ], WAITING: { TITLE: "comming soon", @@ -100,15 +105,16 @@ const RECRUIT = { }, }; +/** FIXME: 시간을 변경할 것 ! */ const RECRUIT_FLOAT = { ECONO_IS_RECRUITING: "econovation은 지금 신입 모집 중!", - ECONO_GENERTAION_RECRUIT_EN: `econovation ${RECRUIT.GENERTAION} GENERTAION recruit`, + ECONO_READY_FOR_RECRUIT: "econovation 신입 모집 시작까지", + ECONO_GENERTAION_RECRUIT_EN: `econovation ${RECRUIT.GENERTAION}th recruit`, ECONO_GENERTAION_RECRUIT_KR: `에코노베이션 ${RECRUIT.GENERTAION}기 신입 모집`, DAY: "day", HOUR: "hour", MINUTE: "minute", SECOND: "second", - RECRUIT_START_DATE: Date.UTC(2024, 2, 15, 15, 0, 0), }; export { RECRUIT, RECRUIT_FLOAT }; diff --git a/src/functions/util.ts b/src/functions/util.ts index 8fc0ea1..de31304 100644 --- a/src/functions/util.ts +++ b/src/functions/util.ts @@ -27,3 +27,7 @@ export const isEmail = (email: string): boolean => { export const cn = (...inputs: ClassValue[]) => { return twMerge(clsx(inputs)); }; + +export const exhaustiveError = (x: never): never => { + throw new Error(`해당 값은 전달받을 수 없는 케이스 입니다. 값: ${x}`); +}; diff --git a/src/hooks/useFloatAnimation.ts b/src/hooks/useFloatAnimation.ts new file mode 100644 index 0000000..f1edd02 --- /dev/null +++ b/src/hooks/useFloatAnimation.ts @@ -0,0 +1,37 @@ +import gsap from "gsap"; +import { useRef } from "react"; + +const useFloatAnimation = () => { + const floatRef = useRef(null); + const floatDetailRef = useRef(null); + + const showDetail = () => { + gsap.to(floatDetailRef.current, { + duration: 0.5, + bottom: "-1rem", + ease: "back.out", + }); + gsap.to(floatRef.current, { + duration: 0.5, + bottom: "-150%", + ease: "back.in", + }); + }; + + const closeDetail = () => { + gsap.to(floatDetailRef.current, { + duration: 0.5, + bottom: "-200%", + ease: "back.in", + }); + gsap.to(floatRef.current, { + duration: 0.5, + bottom: "-1rem", + ease: "back.out", + }); + }; + + return { floatDetailRef, floatRef, showDetail, closeDetail }; +}; + +export default useFloatAnimation; diff --git a/src/hooks/useRecruitStatus.ts b/src/hooks/useRecruitStatus.ts new file mode 100644 index 0000000..8716104 --- /dev/null +++ b/src/hooks/useRecruitStatus.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; + +import { RECRUIT, type RecruitStatus } from "../constants/recruit/recruit.ko"; + +const useRecruitStatus = () => { + const [recruitStatus, setRecruitStatus] = useState("CLOSED"); + useEffect(() => { + const intervalId = setInterval(() => { + const recruitStartDate = new Date(RECRUIT.START_DATE); + const recruitEndDate = new Date(RECRUIT.END_DATE); + + const now = Date.now(); + if ( + recruitStartDate.getTime() - now > 0 && + recruitEndDate.getTime() - now > 0 + ) { + setRecruitStatus("READY"); + } else if ( + recruitStartDate.getTime() - now <= 0 && + recruitEndDate.getTime() - now > 0 + ) { + setRecruitStatus("OPEN"); + } else { + setRecruitStatus("CLOSED"); + } + }, 1000); + return () => { + clearInterval(intervalId); + }; + }, []); + + return { recruitStatus }; +}; + +export default useRecruitStatus;