diff --git a/client/public/retreat/main/fourth/calendar.png b/client/public/retreat/main/fourth/calendar.png index 70c7cc6c..258850a4 100644 Binary files a/client/public/retreat/main/fourth/calendar.png and b/client/public/retreat/main/fourth/calendar.png differ diff --git a/client/public/retreat/main/third/table2.png b/client/public/retreat/main/third/table2.png index be5b3daa..4ab1e6fd 100644 Binary files a/client/public/retreat/main/third/table2.png and b/client/public/retreat/main/third/table2.png differ diff --git a/client/public/retreat/main/third/table3.png b/client/public/retreat/main/third/table3.png index 78ca0362..117e4a3f 100644 Binary files a/client/public/retreat/main/third/table3.png and b/client/public/retreat/main/third/table3.png differ diff --git a/client/public/retreat/main/third/table4.png b/client/public/retreat/main/third/table4.png index ccf700fa..53d90d6d 100644 Binary files a/client/public/retreat/main/third/table4.png and b/client/public/retreat/main/third/table4.png differ diff --git a/client/public/retreat/og.png b/client/public/retreat/og.png new file mode 100644 index 00000000..d6237ecf Binary files /dev/null and b/client/public/retreat/og.png differ diff --git a/client/src/app/common/login/page.tsx b/client/src/app/common/login/page.tsx index 432affbe..a5e5eb1c 100644 --- a/client/src/app/common/login/page.tsx +++ b/client/src/app/common/login/page.tsx @@ -28,12 +28,26 @@ function Login() { const code = searchParams.get("code") if (code) { //카카오에서 리다이렉트된 경우 - getKakaoTokenFromAuthCode(code).then((kakaoToken) => { - login(kakaoToken).then(() => { + getKakaoTokenFromAuthCode(code) + .then((kakaoToken) => { + login(kakaoToken) + .then(() => { + const returnUrl = searchParams.get("state") + push(returnUrl || "/") + }) + .catch((e) => { + const returnUrl = searchParams.get("state") + if (returnUrl === "/retreat/register") { + push(`/retreat/search`) + } + }) + }) + .catch((e) => { const returnUrl = searchParams.get("state") - push(returnUrl || "/") + if (returnUrl === "/retreat/register") { + push(`/retreat/search`) + } }) - }) } else if (isLogin) { const returnUrl = searchParams.get("returnUrl") push(returnUrl || "/") diff --git a/client/src/app/globals.css b/client/src/app/globals.css index 37269514..942820e1 100644 --- a/client/src/app/globals.css +++ b/client/src/app/globals.css @@ -1,14 +1,13 @@ @font-face { - font-family: "PretendardVariable"; + font-family: "Pretendard"; src: url("../font/PretendardVariable.woff2"); } -/* @font-face { - font-family: "HandFont"; - src: url("../font/hand-font.woff2"); + font-family: "MapoDacapo"; + src: url("../font/MapoDacapo.otf"); + font-weight: normal; } -*/ @font-face { font-family: "NEXON_Warhaven_OTF"; diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index 64889f05..157202e9 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -3,8 +3,6 @@ import Notification from "@/components/notification/notification" import "./globals.css" import { Provider } from "jotai" -import Head from "next/head" -import Script from "next/script" export default function RootLayout({ children, @@ -43,12 +41,8 @@ export default function RootLayout({ - - + + diff --git a/client/src/app/leader/attendance/useAttendance.ts b/client/src/app/leader/attendance/useAttendance.ts index 4ab6593b..dc2d8927 100644 --- a/client/src/app/leader/attendance/useAttendance.ts +++ b/client/src/app/leader/attendance/useAttendance.ts @@ -98,14 +98,12 @@ export default function useAttendance() { if (!groupInfo) return const dateStr = getLastSundayDateString() - console.log("지난주 일요일 날짜:", dateStr) // 지난주 일요일 일정 찾기 const lastSundaySchedule = worshipScheduleList.find( (schedule) => schedule.date === dateStr, ) - console.log("지난주 일요일 일정:", lastSundaySchedule?.id) if (!lastSundaySchedule) { error("지난주 일요일 예배 일정이 없습니다.") return @@ -132,7 +130,6 @@ export default function useAttendance() { return attendData }) - console.log("updatedAttendData", updatedAttendData) setSoonAttendData(updatedAttendData) success("지난주 일요일 출석 데이터를 불러왔습니다.") } diff --git a/client/src/app/retreat/complete/page.tsx b/client/src/app/retreat/complete/page.tsx index 88dce2b9..aeec139a 100644 --- a/client/src/app/retreat/complete/page.tsx +++ b/client/src/app/retreat/complete/page.tsx @@ -1,41 +1,80 @@ "use client" -import { Box, Button, IconButton, Stack } from "@mui/material" import ArrowBackIosNewRoundedIcon from "@mui/icons-material/ArrowBackIosNewRounded" +import { Box, Button, IconButton, Stack } from "@mui/material" +import { keyframes } from "@mui/material/styles" import { useRouter, useSearchParams } from "next/navigation" import { Suspense } from "react" +import usePageColor from "@/hooks/usePageColor" import { useNotification } from "@/hooks/useNotification" +import useRetreat from "../hooks/useRetreat" + +const revealPage = keyframes` + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +` export default function RetreatRegister() { const { push } = useRouter() + usePageColor("#FFFFFF") + function handleGoToBack() { push("/retreat") } return ( - - + + - 수련회 신청 + + 수련회 신청 + - - + + + 수련회 신청이 완료되었습니다! + + Loading...}> + + - Loading...}> - - ) } @@ -44,31 +83,64 @@ function Content() { const { success } = useNotification() const searchParams = useSearchParams() const isWorker = searchParams.get("isWorker") === "true" + const { isHalf } = useRetreat() function copyToClipboard() { const accountInfo = `3333342703455 카카오뱅크\n - ${isWorker ? "150,000원" : "120,000원"}` + ${getPrice()}` navigator.clipboard.writeText(accountInfo) success("계좌 정보가 복사되었습니다.") } + function getText() { + if (isHalf) { + return "부분 참여" + } + if (!isWorker) { + return "학생" + } + return "직장인 " + } + + function getPrice() { + if (isHalf) { + return "100,000원" + } + if (!isWorker) { + return "120,000원" + } + return "150,000원" + } + return ( - + - 입금 계좌 - 3333342703455 카카오뱅크 (성은비) - 수련회비 + + 입금 계좌 + + + 3333342703455 카카오뱅크 (성은비) + + + 수련회비 + - - {isWorker ? "직장인" : "학생"}:{" "} - {isWorker ? "150,000원" : "120,000원"} + + {getText()}: {getPrice()} diff --git a/client/src/app/retreat/intro/page.module.css b/client/src/app/retreat/intro/page.module.css index 9e8be8e0..85bc65c1 100644 --- a/client/src/app/retreat/intro/page.module.css +++ b/client/src/app/retreat/intro/page.module.css @@ -34,8 +34,8 @@ letter-spacing: 0.02em; opacity: 0; transition: - opacity 700ms ease, - transform 700ms ease; + opacity 1400ms ease, + transform 1400ms ease; will-change: opacity, transform; text-shadow: 0 1px 2px rgba(255, 255, 255, 0.2); } diff --git a/client/src/app/retreat/intro/page.tsx b/client/src/app/retreat/intro/page.tsx index a2ae187c..37ab4ec1 100644 --- a/client/src/app/retreat/intro/page.tsx +++ b/client/src/app/retreat/intro/page.tsx @@ -83,9 +83,14 @@ export default function RetreatIntroPage() { return ( - + - + {texts.map((item, index) => (

+ + + + + + 수련회 신청 + + + + + + 언제 참여하시나요? + + + + 참여 가능한 일정을 선택해주세요. + + + + + + + + + + + + ) +} diff --git a/client/src/app/retreat/register/page.tsx b/client/src/app/retreat/register/page.tsx index 54118ad0..98859c27 100644 --- a/client/src/app/retreat/register/page.tsx +++ b/client/src/app/retreat/register/page.tsx @@ -1,105 +1,163 @@ "use client" -import { Box, Button, IconButton, Stack } from "@mui/material" import ArrowBackIosNewRoundedIcon from "@mui/icons-material/ArrowBackIosNewRounded" +import CheckRoundedIcon from "@mui/icons-material/CheckRounded" +import { Box, Button, IconButton, Stack } from "@mui/material" +import { keyframes } from "@mui/material/styles" import { useRouter } from "next/navigation" -import { useState } from "react" -import axios from "@/config/axios" -import { useNotification } from "@/hooks/useNotification" +import usePageColor from "@/hooks/usePageColor" +import useRetreat from "../hooks/useRetreat" + +const revealPage = keyframes` + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +` export default function RetreatRegister() { const { push } = useRouter() - const { success } = useNotification() - const [isWorking, setIsWorking] = useState(false) + const { isWorker, setIsWorker } = useRetreat() + + usePageColor("#FFFFFF") function handleGoToBack() { push("/retreat") } - async function handleSubmit() { - const { data } = await axios.post("/retreat/attend", { - isWorker: isWorking, - }) - success(data.result) - push(`/retreat/complete?isWorker=${isWorking}`) + function handleSubmit() { + push(`/retreat/participation-time`) } return ( - - + + - 수련회 신청 - - - - 직장인이신가요? + + 수련회 신청 + - { - setIsWorking(true) - }} - > - 직장인이에요! + + 직장인이신가요? - { - setIsWorking(false) - }} - > - 직장인 아니에요! + + + 해당되는 항목을 선택해주세요. + + + + + + - - - + + ) } diff --git a/client/src/app/retreat/search/page.tsx b/client/src/app/retreat/search/page.tsx new file mode 100644 index 00000000..210611bf --- /dev/null +++ b/client/src/app/retreat/search/page.tsx @@ -0,0 +1,386 @@ +"use client" + +import ArrowBackIosNewRoundedIcon from "@mui/icons-material/ArrowBackIosNewRounded" +import { + Box, + Button, + FormControl, + FormControlLabel, + FormLabel, + IconButton, + Radio, + RadioGroup, + Stack, + TextField, +} from "@mui/material" +import { keyframes } from "@mui/material/styles" +import { FormEvent, useState } from "react" +import { useRouter } from "next/navigation" +import { useNotification } from "@/hooks/useNotification" +import usePageColor from "@/hooks/usePageColor" +import axios from "@/config/axios" +import useAuth from "@/hooks/useAuth" +import useRetreat from "../hooks/useRetreat" + +const revealField = keyframes` + from { + opacity: 0; + transform: translateY(18px); + filter: blur(2px); + } + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +` + +const fieldSx = { + "& .MuiOutlinedInput-root": { + height: "50px", + bgcolor: "#FCFAFA", + borderRadius: "10px", + fontFamily: "Pretendard", + fontSize: "16px", + "& fieldset": { + borderColor: "#F0E4E4", + }, + "&:hover fieldset": { + borderColor: "#E9D4D4", + }, + "&.Mui-focused fieldset": { + borderColor: "#EAAFAF", + borderWidth: "1px", + }, + }, +} + +const YOUNGEST_BIRTH_YEAR = 2007 +const OLDEST_BIRTH_YEAR = 1940 +const BIRTH_YEARS = Array.from( + { length: YOUNGEST_BIRTH_YEAR - OLDEST_BIRTH_YEAR + 1 }, + (_, index) => YOUNGEST_BIRTH_YEAR - index, +) + +function getRevealSx(delay: number) { + return { + opacity: 0, + animation: `${revealField} 520ms cubic-bezier(0.22, 1, 0.36, 1) forwards`, + animationDelay: `${delay}ms`, + "@media (prefers-reduced-motion: reduce)": { + opacity: 1, + animation: "none", + }, + } +} + +function formatPhoneNumber(value: string) { + const digits = value.replace(/\D/g, "").slice(0, 11) + + return digits.replace( + /^(\d{3})(\d{0,4})(\d{0,4})$/, + (_, first, middle, last) => [first, middle, last].filter(Boolean).join("-"), + ) +} + +export default function RetreatSearch() { + const { push } = useRouter() + const { warning } = useNotification() + const [phone, setPhone] = useState("") + const [isLookupComplete, setIsLookupComplete] = useState(false) + const [name, setName] = useState("") + const [gender, setGender] = useState<"남성" | "여성" | "">("") + const [birthYear, setBirthYear] = useState("") + const { kakaoToken, login } = useAuth() + const { updateNuon } = useRetreat() + + usePageColor("#FFFFFF") + + function handleGoToBack() { + push("/retreat") + } + + async function handlePhoneLookup(event: FormEvent) { + event.preventDefault() + if (phone.replace(/\D/g, "").length !== 11) { + warning("전화번호 11자리를 입력해주세요.") + return + } + + // TODO: 전화번호 조회 API가 연결되면 성공 응답을 받은 경우에만 true로 변경해주세요. + // 현재는 올바른 길이의 전화번호로 폼을 제출하면 조회가 완료된 것으로 처리합니다. + try { + const purePhone = phone.replace(/\D/g, "") + await axios.get("/retreat/isRegistered", { params: { phone: purePhone } }) + setIsLookupComplete(false) + if (!kakaoToken) { + warning("카카오 로그인 정보가 없습니다. 다시 시도해주세요. (1)") + return + } + await axios.post("/retreat/bind", { + phone: purePhone, + kakaoToken, + }) + await login(kakaoToken) + push("/retreat/register") + } catch (error) { + setIsLookupComplete(true) + } + } + + async function handleNextStep(event: FormEvent) { + event.preventDefault() + + if (phone.replace(/\D/g, "").length !== 11) { + warning("전화번호 11자리를 입력해주세요.") + return + } + + if (!name.trim() || !gender || !birthYear) { + warning("필수 정보를 모두 입력해주세요.") + return + } + + if (!kakaoToken) { + warning("카카오 로그인 정보가 없습니다. 다시 시도해주세요. (2)") + return + } + + await updateNuon({ + kakaoToken, + name, + yearOfBirth: parseInt(birthYear), + gender: gender === "남성" ? "man" : "woman", + phone: phone.replace(/\D/g, ""), + }) + + push("/retreat/register") + } + + function handleSubmit(event: FormEvent) { + if (isLookupComplete) { + return handleNextStep(event) + } + + return handlePhoneLookup(event) + } + + return ( + + + + + + + 수련회 신청 조회 + + + + + + 기본 정보를 입력해주세요! + + + + + 전화번호{" "} + + + { + setPhone(formatPhoneNumber(event.target.value)) + setIsLookupComplete(false) + }} + placeholder="010-0000-0000" + type="tel" + autoComplete="tel" + required + fullWidth + inputProps={{ + inputMode: "numeric", + maxLength: 13, + "aria-label": "전화번호", + }} + sx={{ + ...fieldSx, + }} + /> + + + {isLookupComplete && ( + + + + 이름{" "} + + + setName(event.target.value)} + placeholder="이름을 입력해주세요" + autoComplete="name" + required + fullWidth + sx={fieldSx} + /> + + + + + 성별 + + + setGender(event.target.value as "남성" | "여성") + } + sx={{ gap: "10px", flexWrap: "nowrap" }} + > + {(["남성", "여성"] as const).map((option) => ( + + } + label={option} + sx={{ + m: 0, + flex: 1, + height: "50px", + borderRadius: "10px", + border: "1px solid", + borderColor: gender === option ? "#EAAFAF" : "#F0E4E4", + bgcolor: gender === option ? "#FFF1F1" : "#FCFAFA", + color: gender === option ? "#C87575" : "#555", + fontFamily: "Pretendard", + fontSize: "16px", + textTransform: "none", + justifyContent: "center", + "& .MuiFormControlLabel-label": { + fontFamily: "Pretendard", + fontSize: "16px", + }, + "&:hover": { + borderColor: "#EAAFAF", + bgcolor: "#FFF6F6", + }, + }} + /> + ))} + + + + + + 출생연도{" "} + + + setBirthYear(event.target.value)} + autoComplete="bday-year" + required + fullWidth + select + SelectProps={{ + native: true, + }} + sx={fieldSx} + > + + {BIRTH_YEARS.map((year) => ( + + ))} + + + + )} + + + + + ) +} diff --git a/client/src/app/retreat/sections/fourth.tsx b/client/src/app/retreat/sections/fourth.tsx index 3e0e11d5..ccdcf0de 100644 --- a/client/src/app/retreat/sections/fourth.tsx +++ b/client/src/app/retreat/sections/fourth.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react" import { Box, Stack } from "@mui/material" const DAY_MS = 1000 * 60 * 60 * 24 +const MAP_ADDRESS = "경기도 화성시 정남면 세자로 286" +const MAP_URL = `https://map.naver.com/p/search/${encodeURIComponent(MAP_ADDRESS)}` function getTargetDday() { const targetDate = new Date(2026, 7, 14) @@ -121,7 +123,18 @@ export default function RetreatMainFourth() { - place + + place + + + place + ) diff --git a/client/src/font/MapoDacapo.otf b/client/src/font/MapoDacapo.otf new file mode 100644 index 00000000..487bdf51 Binary files /dev/null and b/client/src/font/MapoDacapo.otf differ diff --git a/client/src/font/hand-font.woff2 b/client/src/font/hand-font.woff2 deleted file mode 100644 index 61308735..00000000 Binary files a/client/src/font/hand-font.woff2 and /dev/null differ diff --git a/server/src/routes/admin/permissionRouter.ts b/server/src/routes/admin/permissionRouter.ts index 3260ee68..4cc0d0ad 100644 --- a/server/src/routes/admin/permissionRouter.ts +++ b/server/src/routes/admin/permissionRouter.ts @@ -46,8 +46,6 @@ router.post("/set-user-permission", async (req, res) => { return } - console.log("user is admin", isAdmin) - const permission = user.permissions.find( (p) => p.permissionType === permissionType, ) diff --git a/server/src/routes/retreat/retreatRouter.ts b/server/src/routes/retreat/retreatRouter.ts index 740f1866..87578b6b 100644 --- a/server/src/routes/retreat/retreatRouter.ts +++ b/server/src/routes/retreat/retreatRouter.ts @@ -7,6 +7,8 @@ import { import { getUserFromToken } from "../../util/util" import adminRouter from "./adminRouter" import sharingRouter from "./sharingRouter" +import { getKakaoIdFromAccessToken } from "../../util/auth" +import { IsNull } from "typeorm" const router = express.Router() @@ -57,7 +59,7 @@ router.get("/", async (req, res) => { }) interface JoinNuonRequest { - kakaoId: string + kakaoToken: string name: string yearOfBirth: number gender: "man" | "woman" @@ -67,9 +69,11 @@ interface JoinNuonRequest { router.post("/join", async (req, res) => { const retreatAttend: JoinNuonRequest = req.body + const kakaoId = await getKakaoIdFromAccessToken(retreatAttend.kakaoToken) + const foundUser = await userDatabase.findOne({ where: { - kakaoId: retreatAttend.kakaoId, + kakaoId: kakaoId, }, }) @@ -89,14 +93,14 @@ router.post("/join", async (req, res) => { }) if (foundUserByPhoneAndName) { - foundUserByPhoneAndName.kakaoId = retreatAttend.kakaoId + foundUserByPhoneAndName.kakaoId = kakaoId await userDatabase.save(foundUserByPhoneAndName) res.send({ result: "success" }) return } const newUser = await userDatabase.create({ - kakaoId: retreatAttend.kakaoId, + kakaoId: kakaoId, name: retreatAttend.name, yearOfBirth: retreatAttend.yearOfBirth, gender: retreatAttend.gender, @@ -124,19 +128,19 @@ router.post("/attend", async (req, res) => { }) if (foundRetreatAttend) { - //foundRetreatAttend.isHalf = req.body.isHalf + foundRetreatAttend.isHalf = req.body.isHalf foundRetreatAttend.isWorker = req.body.isWorker await retreatAttendDatabase.save(foundRetreatAttend) res.send({ result: "수련회 정보가 수정 되었습니다." }) return } - const { isWorker } = req.body + const { isHalf, isWorker } = req.body const retreatAttend = retreatAttendDatabase.create({ user: { id: foundUser.id, }, - isHalf: false, + isHalf: isHalf, isWorker: isWorker, }) retreatAttend.attendanceNumber = (await retreatAttendDatabase.count()) + 1 @@ -144,6 +148,44 @@ router.post("/attend", async (req, res) => { res.send({ result: "수련회 정보가 등록 되었습니다." }) }) +router.post("/bind", async (req, res) => { + const { phone, kakaoToken } = req.body + + const foundUser = await userDatabase.findOne({ + where: { + phone: phone as string, + kakaoId: IsNull(), + }, + }) + + if (!foundUser) { + res.status(401).send({ result: "fail" }) + return + } + + const kakaoId = await getKakaoIdFromAccessToken(kakaoToken as string) + foundUser.kakaoId = kakaoId + await userDatabase.save(foundUser) + res.send({ result: "success" }) +}) + +router.get("/isRegistered", async (req, res) => { + const { phone } = req.query + + const foundUser = await userDatabase.findOne({ + where: { + phone: phone as string, + }, + }) + + if (!foundUser) { + res.status(404).send({ result: "fail" }) + return + } + + res.send({ result: foundUser ? "success" : "fail" }) +}) + router.use("/sharing", sharingRouter) router.use("/admin", adminRouter)