diff --git a/public/icons/register@1x.png b/public/icons/register@1x.png new file mode 100644 index 00000000..6490bb88 Binary files /dev/null and b/public/icons/register@1x.png differ diff --git a/public/icons/register@2x.png b/public/icons/register@2x.png new file mode 100644 index 00000000..9a66fdc3 Binary files /dev/null and b/public/icons/register@2x.png differ diff --git a/public/icons/waiting@1x.png b/public/icons/waiting@1x.png new file mode 100644 index 00000000..45e551d9 Binary files /dev/null and b/public/icons/waiting@1x.png differ diff --git a/public/icons/waiting@2x.png b/public/icons/waiting@2x.png new file mode 100644 index 00000000..9da958b9 Binary files /dev/null and b/public/icons/waiting@2x.png differ diff --git a/src/auth/InputWithTimer.jsx b/src/auth/AuthCode/InputWithTimer.jsx similarity index 68% rename from src/auth/InputWithTimer.jsx rename to src/auth/AuthCode/InputWithTimer.jsx index 1c535af8..09014ee8 100644 --- a/src/auth/InputWithTimer.jsx +++ b/src/auth/AuthCode/InputWithTimer.jsx @@ -1,11 +1,16 @@ import Input from "@/common/Input.jsx"; +const ONE_MINUTES = 60; + function InputWithTimer({ text, setText, timer, ...otherProps }) { + const minute = Math.floor(timer / ONE_MINUTES); + const seconds = timer % ONE_MINUTES; + return (
- {timer} + {minute}:{seconds.toString().padStart(2, "0")}
); diff --git a/src/auth/AuthCode/index.jsx b/src/auth/AuthCode/index.jsx new file mode 100644 index 00000000..378634b9 --- /dev/null +++ b/src/auth/AuthCode/index.jsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import InputWithTimer from "./InputWithTimer.jsx"; +import useTimer from "./useTimer.js"; +import submitAuthCode from "./submitAuthCode.js"; +import requestAuthCode from "../requestAuthCode.js"; +import Button from "@/common/Button.jsx"; + +const AUTH_MAX_DURATION = 5 * 60; + +function AuthSecondSection({ name, phone, onComplete }) { + // 상태 + const [authCode, setAuthCode] = useState(""); + const [timer, resetTimer] = useTimer(AUTH_MAX_DURATION); + const [errorMessage, setErrorMessage] = useState(""); + + // 인증코드 재전송 동작 + function retryAuthCode() { + requestAuthCode(name, phone) + .then(() => { + setErrorMessage(""); + setAuthCode(""); + resetTimer(); + }) + .catch((error) => setErrorMessage(error.message)); + } + + // 인증코드 전송 동작 + function onSubmit(e) { + e.preventDefault(); + submitAuthCode(name, phone, authCode) + .then(() => { + setErrorMessage(""); + onComplete(true); + }) + .catch((error) => { + setErrorMessage(error.message); + }); + } + + const josa = "013678".includes(phone[phone.length - 1]) ? "으" : ""; + return ( +
+

+ {phone} + {josa}로
+ 인증번호를 전송했어요. +

+
+
+ + + {errorMessage || (timer === 0 ? "입력시간이 종료되었습니다." : "")} + +
+
+ + +
+
+
+ ); +} + +export default AuthSecondSection; diff --git a/src/auth/AuthCode/submitAuthCode.js b/src/auth/AuthCode/submitAuthCode.js new file mode 100644 index 00000000..d711688e --- /dev/null +++ b/src/auth/AuthCode/submitAuthCode.js @@ -0,0 +1,29 @@ +import { fetchServer, handleError } from "@/common/fetchServer.js"; +import { EVENT_ID } from "@/common/constants.js"; +import tokenSaver from "../tokenSaver.js"; + +async function submitAuthCode(name, phoneNumber, authCode) { + try { + const body = { + name, + phoneNumber: phoneNumber.replace(/\D+/g, ""), + authCode, + }; + const { token } = await fetchServer( + `/api/v1/event-user/check-auth/${EVENT_ID}`, + { + method: "post", + body, + }, + ); + tokenSaver.set(token); + return ""; + } catch (e) { + return handleError({ + 400: "잘못된 요청 형식입니다.", + 401: "인증번호가 틀렸습니다. 다시 입력하세요.", + })(e); + } +} + +export default submitAuthCode; diff --git a/src/auth/AuthCode/useTimer.js b/src/auth/AuthCode/useTimer.js new file mode 100644 index 00000000..38d4514f --- /dev/null +++ b/src/auth/AuthCode/useTimer.js @@ -0,0 +1,32 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import IntervalController from "@/common/IntervalController.js"; + +function useTimer(remainTime) { + const [timer, setTimer] = useState(remainTime); + const intervalController = useRef(new IntervalController(1000)); + + useEffect(() => { + const ticker = intervalController.current; + + function decreaseTime() { + setTimer((timer) => (timer > 0 ? timer - 1 : 0)); + } + ticker.addEventListener("interval", decreaseTime); + ticker.start(); + + return () => { + ticker.end(); + ticker.removeEventListener("interval", decreaseTime); + }; + }, []); + + const resetTimer = useCallback(() => { + setTimer(remainTime); + intervalController.current.end(); + intervalController.current.start(); + }, [remainTime]); + + return [timer, resetTimer]; +} + +export default useTimer; diff --git a/src/auth/AuthModal.jsx b/src/auth/AuthModal.jsx index 4bbfde74..faa29ab9 100644 --- a/src/auth/AuthModal.jsx +++ b/src/auth/AuthModal.jsx @@ -1,32 +1,44 @@ import { useState, useContext } from "react"; -import AuthFirstSection from "./AuthFirstSection.jsx"; -import AuthSecondSection from "./AuthSecondSection.jsx"; +import InfoInputStage from "./InfoInput"; +import AuthCodeStage from "./AuthCode"; +import UserFindStage from "./UserFind"; import { ModalCloseContext } from "@/modal/modal.jsx"; const AUTH_INPUT_PAGE = Symbol("input"); const AUTH_CODE_PAGE = Symbol("code"); +const AUTH_FIND_PAGE = Symbol("find"); -function AuthModal() { +function AuthModal({ onComplete: onCompleteCallback }) { const close = useContext(ModalCloseContext); const [name, setName] = useState(""); const [phone, setPhone] = useState(""); const [page, setPage] = useState(AUTH_INPUT_PAGE); + function onComplete(isFreshMember) { + onCompleteCallback(isFreshMember); + close(); + } + const firstSectionProps = { name, setName, phone, setPhone, goNext: () => setPage(AUTH_CODE_PAGE), + goFindUser: () => setPage(AUTH_FIND_PAGE), + }; + const secondSectionProps = { name, phone, onComplete }; + const findSectionProps = { + onComplete, + goPrev: () => setPage(AUTH_INPUT_PAGE), }; - const secondSectionProps = { name, phone }; + + const containerClass = `w-[calc(100%-1rem)] max-w-[31.25rem] shadow bg-white relative flex flex-col gap-14`; return ( -
- {page === AUTH_CODE_PAGE ? ( - - ) : ( - - )} +
+ {page === AUTH_INPUT_PAGE && } + {page === AUTH_CODE_PAGE && } + {page === AUTH_FIND_PAGE && } - -
- - - ); -} - -export default AuthSecondSection; diff --git a/src/auth/AuthFirstSection.jsx b/src/auth/InfoInput/index.jsx similarity index 76% rename from src/auth/AuthFirstSection.jsx rename to src/auth/InfoInput/index.jsx index 37063f62..7712771e 100644 --- a/src/auth/AuthFirstSection.jsx +++ b/src/auth/InfoInput/index.jsx @@ -1,42 +1,32 @@ import { useState } from "react"; +import requestAuthCode from "../requestAuthCode.js"; import Input from "@/common/Input.jsx"; import PhoneInput from "@/common/PhoneInput.jsx"; import Button from "@/common/Button.jsx"; -import { fetchServer, HTTPError } from "@/common/fetchServer.js"; -function AuthFirstSection({ name, setName, phone, setPhone, goNext }) { +function AuthFirstSection({ + name, + setName, + phone, + setPhone, + goNext, + goFindUser, +}) { const [errorMessage, setErrorMessage] = useState(""); const checkboxStyle = `size-4 appearance-none border border-neutral-300 checked:bg-blue-400 checked:border-0 checked:bg-checked bg-center`; - async function onSubmit(e) { + function onSubmit(e) { e.preventDefault(); - try { - const body = { name, phoneNumber: phone.replace(/\D+/g, "") }; - await fetchServer("/api/v1/event-user/send-auth", { - method: "post", - body, - }); - setErrorMessage(""); - goNext(); - } catch (e) { - if (e instanceof HTTPError) { - if (e.status === 400) return setErrorMessage("잘못된 요청 형식입니다."); - if (e.status === 409) - return setErrorMessage("등록된 참여자 정보가 있습니다."); - return setErrorMessage("서버와의 통신 중 오류가 발생했습니다."); - } - console.error(e); - setErrorMessage( - "알 수 없는 오류입니다. 프론트엔드 개발자에게 제보하세요.", - ); - } + requestAuthCode(name, phone) + .then(() => goNext()) + .catch((error) => setErrorMessage(error.message)); } return ( - <> +

이벤트 응모를 위해
@@ -95,12 +85,13 @@ function AuthFirstSection({ name, setName, phone, setPhone, goNext }) {

- +
); } diff --git a/src/auth/UserFind/index.jsx b/src/auth/UserFind/index.jsx new file mode 100644 index 00000000..777defe2 --- /dev/null +++ b/src/auth/UserFind/index.jsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import requestLogin from "./requestLogin.js"; +import Input from "@/common/Input.jsx"; +import PhoneInput from "@/common/PhoneInput.jsx"; +import Button from "@/common/Button.jsx"; + +function AuthFindSection({ goPrev, onComplete }) { + const [name, setName] = useState(""); + const [phone, setPhone] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + function onSubmit(e) { + e.preventDefault(); + requestLogin(name, phone) + .then(() => { + setErrorMessage(""); + onComplete(false); + }) + .catch((error) => setErrorMessage(error.message)); + } + + return ( +
+

+ 등록했던 정보를 +
+ 다시 한 번 입력해주세요! +

+
+
+ + +
+

+ {errorMessage} +

+
+ + +
+
+
+ ); +} + +export default AuthFindSection; diff --git a/src/auth/UserFind/requestLogin.js b/src/auth/UserFind/requestLogin.js new file mode 100644 index 00000000..a8d10fcc --- /dev/null +++ b/src/auth/UserFind/requestLogin.js @@ -0,0 +1,21 @@ +import { fetchServer, handleError } from "@/common/fetchServer.js"; +import tokenSaver from "../tokenSaver.js"; + +async function requestLogin(name, phoneNumber) { + try { + const body = { name, phoneNumber: phoneNumber.replace(/\D+/g, "") }; + const { token } = await fetchServer("/api/v1/event-user/login", { + method: "post", + body, + }); + tokenSaver.set(token); + return ""; + } catch (e) { + return handleError({ + 400: "잘못된 요청 형식입니다.", + 404: "등록된 참여자 정보가 없습니다.", + })(e); + } +} + +export default requestLogin; diff --git a/src/auth/Welcome/index.jsx b/src/auth/Welcome/index.jsx new file mode 100644 index 00000000..edff69e9 --- /dev/null +++ b/src/auth/Welcome/index.jsx @@ -0,0 +1,39 @@ +import { useContext } from "react"; +import { ModalCloseContext } from "@/modal/modal.jsx"; +import Button from "@/common/Button.jsx"; + +function WelcomeModal() { + const close = useContext(ModalCloseContext); + + return ( +
+

+ 정보가 +
+ 등록되었습니다! +

+ 정보 등록 완료 +
+ +
+ +
+ ); +} + +export default WelcomeModal; diff --git a/src/auth/mock.js b/src/auth/mock.js index 425b25bd..778d58f1 100644 --- a/src/auth/mock.js +++ b/src/auth/mock.js @@ -1,5 +1,11 @@ import { http, HttpResponse } from "msw"; +function isValidInput(name, phoneNumber) { + return ( + name.length >= 2 && phoneNumber.length < 12 && phoneNumber.startsWith("01") + ); +} + const handlers = [ http.post("/api/v1/event-user/send-auth", async ({ request }) => { const { name, phoneNumber } = await request.json(); @@ -8,18 +14,45 @@ const handlers = [ { error: "중복된 사용자가 있음" }, { status: 409 }, ); - if (name.length < 2) + if (!isValidInput(name, phoneNumber)) return HttpResponse.json( { error: "응답 내용이 잘못됨" }, { status: 400 }, ); - if (phoneNumber.length >= 12) + + return HttpResponse.json({ return: true }); + }), + + http.post( + "/api/v1/event-user/check-auth/:eventFrameId", + async ({ request }) => { + const { name, phoneNumber, authCode } = await request.json(); + + if (!isValidInput(name, phoneNumber)) + return HttpResponse.json( + { error: "응답 내용이 잘못됨" }, + { status: 400 }, + ); + if (authCode !== "726679") + return HttpResponse.json( + { error: "인증번호 일치 안 함" }, + { status: 401 }, + ); + return HttpResponse.json({ token: "test_token" }); + }, + ), + + http.post("/api/v1/event-user/login", async ({ request }) => { + const { name, phoneNumber } = await request.json(); + + if (!isValidInput(name, phoneNumber)) return HttpResponse.json( { error: "응답 내용이 잘못됨" }, { status: 400 }, ); - - return HttpResponse.json({ return: true }); + if (name !== "오렌지" || phoneNumber !== "01019991999") + return HttpResponse.json({ error: "사용자 없음" }, { status: 404 }); + return HttpResponse.json({ token: "test_token" }); }), ]; diff --git a/src/auth/requestAuthCode.js b/src/auth/requestAuthCode.js new file mode 100644 index 00000000..af1cf2ca --- /dev/null +++ b/src/auth/requestAuthCode.js @@ -0,0 +1,19 @@ +import { fetchServer, handleError } from "@/common/fetchServer.js"; + +async function requestAuthCode(name, phoneNumber) { + try { + const body = { name, phoneNumber: phoneNumber.replace(/\D+/g, "") }; + await fetchServer("/api/v1/event-user/send-auth", { + method: "post", + body, + }); + return ""; + } catch (e) { + return handleError({ + 400: "잘못된 요청 형식입니다.", + 409: "등록된 참여자 정보가 있습니다.", + })(e); + } +} + +export default requestAuthCode; diff --git a/src/auth/tokenSaver.js b/src/auth/tokenSaver.js new file mode 100644 index 00000000..4bf00373 --- /dev/null +++ b/src/auth/tokenSaver.js @@ -0,0 +1,35 @@ +import { TOKEN_ID } from "@/common/constants.js"; + +class TokenSaver { + initialized = false; + token = null; + init() { + if (typeof window === "undefined") return; + this.token = localStorage.getItem(TOKEN_ID) ?? null; + this.initialized = true; + } + get() { + if (this.initialized) return this.token; + this.init(); + return this.token; + } + set(token) { + this.token = token; + if (typeof window !== "undefined") localStorage.setItem(TOKEN_ID, token); + this.initialzed = true; + } + has() { + if (this.initialized) return this.token !== null; + this.init(); + return this.token !== null; + } + remove() { + this.token = null; + if (typeof window !== "undefined") localStorage.removeItem(TOKEN_ID); + this.initialzed = true; + } +} + +const tokenSaver = new TokenSaver(); + +export default tokenSaver; diff --git a/src/comment/commentForm/index.jsx b/src/comment/commentForm/index.jsx new file mode 100644 index 00000000..1c0a2a10 --- /dev/null +++ b/src/comment/commentForm/index.jsx @@ -0,0 +1,78 @@ +import { useState } from "react"; +import CommentSuccessModal from "../modals/CommentSuccessModal.jsx"; +import CommentNegativeModal from "../modals/CommentNegativeModal.jsx"; +import CommentNoUserModal from "../modals/CommentNoUserModal.jsx"; +import NoServerModal from "@/common/NoServerModal.jsx"; + +import Button from "@/common/Button.jsx"; +import { fetchServer, handleError } from "@/common/fetchServer.js"; +import { EVENT_ID } from "@/common/constants.js"; +import openModal from "@/modal/openModal.js"; + +const submitCommentErrorHandle = { + 400: "negative", + 401: "unauthorized", + 409: "하루에 1번만 기대평을 등록할 수 있습니다.", + offline: "offline", +}; + +function CommentForm() { + const [errorMessage, setErrorMessage] = useState(""); + + const successModal = ; + const negativeModal = ; + const noUserModal = ; + const noServerModal = ; + + async function onSubmit(e) { + e.preventDefault(); + const commentDom = e.target.elements.comment; + const content = commentDom.value; + if (content.length < 10 || content.length > 50) return; + + commentDom.value = ""; + setErrorMessage(""); + try { + await fetchServer(`/api/v1/comment/${EVENT_ID}`, { + method: "post", + body: { content }, + }).catch(handleError(submitCommentErrorHandle)); + openModal(successModal); + } catch (e) { + switch (e.message) { + case submitCommentErrorHandle[400]: + return openModal(negativeModal); + case submitCommentErrorHandle[401]: + return openModal(noUserModal); + case submitCommentErrorHandle["offline"]: + return openModal(noServerModal); + default: + setErrorMessage(e.message); + } + } + } + + return ( +
+ + +

+ {errorMessage} +

+
+ ); +} + +export default CommentForm; diff --git a/src/comment/index.jsx b/src/comment/index.jsx index 685abab3..d7c384d1 100644 --- a/src/comment/index.jsx +++ b/src/comment/index.jsx @@ -1,7 +1,8 @@ -import CommentCarousel from "./commentCarousel"; -import decoration from "./assets/decoration.svg"; import { useRef } from "react"; +import CommentCarousel from "./commentCarousel"; +import CommentForm from "./commentForm"; import useSectionInitialize from "../scroll/useSectionInitialize"; +import decoration from "./assets/decoration.svg"; function CommentSection() { const SECTION_IDX = 3; @@ -34,6 +35,7 @@ function CommentSection() {

+ ); } diff --git a/src/comment/mock.js b/src/comment/mock.js index c017a514..f4b72f36 100644 --- a/src/comment/mock.js +++ b/src/comment/mock.js @@ -32,10 +32,26 @@ function getCommentMock() { }); } +const commentSet = new Set(); + const handlers = [ - http.get("/api/v1/comment", async () => { + http.get("/api/v1/comment", () => { return HttpResponse.json({ comments: getCommentMock() }); }), + http.post("/api/v1/comment/:eventFrameId", async ({ request }) => { + const token = request.headers.get("authorization"); + + if (token === null) return HttpResponse.json(false, { status: 401 }); + + const { content } = await request.json(); + + if (commentSet.has(token)) return HttpResponse.json(true, { status: 409 }); + if (content.includes("시발")) + return HttpResponse.json(true, { status: 400 }); + + commentSet.add(token); + return HttpResponse.json(true, { status: 200 }); + }), ]; export default handlers; diff --git a/src/comment/modals/CommentNegativeModal.jsx b/src/comment/modals/CommentNegativeModal.jsx new file mode 100644 index 00000000..69ddb78a --- /dev/null +++ b/src/comment/modals/CommentNegativeModal.jsx @@ -0,0 +1,26 @@ +import { useContext } from "react"; +import { ModalCloseContext } from "@/modal/modal.jsx"; +import Button from "@/common/Button.jsx"; + +function CommentNegativeModal() { + const close = useContext(ModalCloseContext); + + return ( +
+
+

+ 해당 기대평을 등록할 수 없습니다 +

+

+ 비속어, 혐오표현 등 타인에게 불쾌감을 줄 수 있는 표현이 포함된 + 기대평은 작성이 불가합니다 +

+
+ +
+ ); +} + +export default CommentNegativeModal; diff --git a/src/comment/modals/CommentNoUserModal.jsx b/src/comment/modals/CommentNoUserModal.jsx new file mode 100644 index 00000000..c10614b7 --- /dev/null +++ b/src/comment/modals/CommentNoUserModal.jsx @@ -0,0 +1,46 @@ +import { useContext } from "react"; +import { ModalCloseContext } from "@/modal/modal.jsx"; +import Button from "@/common/Button.jsx"; +import scrollTo from "@/scroll/scrollTo.js"; +import { INTERACTION_SECTION } from "@/common/constants.js"; + +function CommentNoUserModal() { + const close = useContext(ModalCloseContext); + + function toMoveInteraction() { + close(); + scrollTo(INTERACTION_SECTION); + } + + return ( +
+
+

+ 아직 기대평을 작성할 수 없습니다. +

+

+ 오늘의 추첨 이벤트에 참여하고 기대평을 작성하세요 +

+
+
+ 추첨 이벤트 참여 바랍니다 +
+
+ + +
+
+ ); +} + +export default CommentNoUserModal; diff --git a/src/comment/modals/CommentSuccessModal.jsx b/src/comment/modals/CommentSuccessModal.jsx new file mode 100644 index 00000000..cee9cb18 --- /dev/null +++ b/src/comment/modals/CommentSuccessModal.jsx @@ -0,0 +1,29 @@ +import { useContext } from "react"; +import { ModalCloseContext } from "@/modal/modal.jsx"; +import Button from "@/common/Button.jsx"; + +function CommentSuccessModal() { + const close = useContext(ModalCloseContext); + + return ( +
+

+ 기대평이 등록되었습니다! +

+
+ 기대평 등록 완료 +
+ +
+ ); +} + +export default CommentSuccessModal; diff --git a/src/common/IntervalController.js b/src/common/IntervalController.js new file mode 100644 index 00000000..701dfe43 --- /dev/null +++ b/src/common/IntervalController.js @@ -0,0 +1,55 @@ +class IntervalController extends EventTarget { + #expected; + #isTicking = false; + #timeout = null; + #pauseDelta = 0; + get isTicking() { + return this.#isTicking; + } + constructor(interval) { + super(); + this.interval = interval; + } + start() { + if (this.#isTicking) return; + + this.#isTicking = true; + this.#expected = performance.now() + this.interval; + this.#timeout = setTimeout(() => this.#step(), this.interval); + } + end() { + if (!this.#isTicking) return; + + this.#isTicking = false; + this.#expected = 0; + clearTimeout(this.#timeout); + this.#timeout = null; + } + pause() { + if (!this.#isTicking) return; + + this.#pauseDelta = this.#expected - performance.now(); + this.#isTicking = false; + clearTimeout(this.#timeout); + this.#timeout = null; + } + resume() { + if (this.#isTicking) return; + + this.#isTicking = true; + this.#expected = performance.now() + this.#pauseDelta; + this.#timeout = setTimeout(() => this.#step(), this.#pauseDelta); + } + #step() { + const delta = this.#expected - performance.now(); + this.#expected += this.interval; + this.dispatchEvent(new Event("interval")); + if (this.callback) this.callback(); + this.#timeout = setTimeout( + () => this.#step(), + Math.max(0, this.interval + delta), + ); + } +} + +export default IntervalController; diff --git a/src/common/NoServerModal.jsx b/src/common/NoServerModal.jsx new file mode 100644 index 00000000..8e130e9f --- /dev/null +++ b/src/common/NoServerModal.jsx @@ -0,0 +1,28 @@ +import { useContext } from "react"; +import { ModalCloseContext } from "@/modal/modal.jsx"; +import Button from "@/common/Button.jsx"; + +function NoServerModal() { + const close = useContext(ModalCloseContext); + + return ( +
+
+

+ 서버가 닫혔어요! +

+

+ 괜찮아요. 저희는 서버가 닫혀도 일부 동작은 가능하니까요! +

+
+
+ 서버 닫힘 +
+ +
+ ); +} + +export default NoServerModal; diff --git a/src/common/constants.js b/src/common/constants.js new file mode 100644 index 00000000..0c012c18 --- /dev/null +++ b/src/common/constants.js @@ -0,0 +1,5 @@ +export const EVENT_ID = "0"; +export const TOKEN_ID = "AWESOME_ORANGE_ACCESS_TOKEN"; + +// scroll section constants +export const INTERACTION_SECTION = 1; diff --git a/src/common/fetchServer.js b/src/common/fetchServer.js index 980b65c4..f37b7e04 100644 --- a/src/common/fetchServer.js +++ b/src/common/fetchServer.js @@ -1,4 +1,5 @@ import wrapPromise from "./wrapPromise.js"; +import tokenSaver from "@/auth/tokenSaver.js"; const cacheMap = new Map(); const CACHE_DURATION = 0.2 * 1000; @@ -12,6 +13,12 @@ class HTTPError extends Error { } } +class ServerCloseError extends Error { + constructor() { + super("Server Closed"); + } +} + function fetchServer(url, options = {}) { const key = JSON.stringify({ url, options }); if (cacheMap.has(key)) { @@ -29,12 +36,20 @@ function fetchServer(url, options = {}) { options.headers["Content-Type"] === "application/json") ) { fetchOptions.headers = { - ...(fetchOptions?.headers ?? {}), + ...(fetchOptions.headers ?? {}), "Content-Type": "application/json", }; fetchOptions.body = JSON.stringify(options.body); } + // token이 존재한다면, 토큰을 요청할 때 끼워넣습니다. + if (tokenSaver.has()) { + fetchOptions.headers = { + ...(fetchOptions.headers ?? {}), + Authorization: `Bearer ${tokenSaver.get()}`, + }; + } + const promise = fetch(url, fetchOptions) .then((e) => { if (e.status >= 400 && e.status <= 599) throw new HTTPError(e); @@ -45,6 +60,9 @@ function fetchServer(url, options = {}) { if (e instanceof HTTPError) { e.data = await e.response.json(); } + if (e instanceof TypeError && e.message === "Failed to fetch") { + throw new ServerCloseError(); + } throw e; }); cacheMap.set(key, { promise, date: Date.now() }); @@ -56,4 +74,23 @@ function fetchResource(url) { return wrapPromise(fetchServer(url)); } -export { fetchServer, fetchResource, HTTPError }; +function handleError(errorDescriptor) { + return (error) => { + if (error instanceof HTTPError) { + throw new Error( + errorDescriptor[error.status] ?? + errorDescriptor.http ?? + "서버와의 통신 중 오류가 발생했습니다.", + ); + } + if (error instanceof ServerCloseError) { + if (errorDescriptor.offlineFallback !== undefined) + return errorDescriptor.offlineFallback; + throw new Error(errorDescriptor.offline ?? "서버가 닫혔습니다."); + } + console.error(error); + throw new Error("알 수 없는 오류입니다. 프론트엔드 개발자에게 제보하세요."); + }; +} + +export { fetchServer, fetchResource, handleError, HTTPError, ServerCloseError }; diff --git a/src/detailInformation/DetailSwiper.jsx b/src/detailInformation/DetailSwiper.jsx index 562b2c1d..8235e800 100644 --- a/src/detailInformation/DetailSwiper.jsx +++ b/src/detailInformation/DetailSwiper.jsx @@ -47,7 +47,7 @@ function DetailSwiper({ content }) {
-
    +
      {content.map(({ tabName }, i) => (
diff --git a/src/header/index.jsx b/src/header/index.jsx index 31ab5fc2..cc029e7e 100644 --- a/src/header/index.jsx +++ b/src/header/index.jsx @@ -1,10 +1,11 @@ import style from "./index.module.css"; import scrollTo from "../scroll/scrollTo"; import { useSectionStore } from "../scroll/store"; +import openModal from "@/modal/openModal.js"; +import AuthModal from "@/auth/AuthModal.jsx"; +import WelcomeModal from "@/auth/Welcome"; export default function Header() { - const ITEM_WIDTH = 96; // w-24 - const ITEM_GAP = 32; // gap-8 const currentSection = useSectionStore((state) => { return state.isVisibleList.findIndex((value) => value === true); }); @@ -14,6 +15,12 @@ export default function Header() { "기대평", "선착순 이벤트", ]; + const welcomeModal = ; + const authModal = ( + isFreshMember && openModal(welcomeModal)} + /> + ); function gotoTop() { window.scrollTo({ top: 0, behavior: "smooth" }); @@ -26,19 +33,13 @@ export default function Header() { } function openVerifyModal() { - /* - * 본인인증 모달 여는 코드 미작성 - */ + openModal(authModal); } function scrollDynamicStyle() { if (currentSection <= 0) return; - - const position = Math.floor( - ITEM_WIDTH / 4 + (currentSection - 1) * (ITEM_WIDTH + ITEM_GAP), - ); return { - "--pos": position, + "--section": currentSection, }; } @@ -46,17 +47,19 @@ export default function Header() {
The new IONIQ 5 -
+
{scrollSectionList.map((scrollSection, index) => (
onClickScrollSection(index + 1)} - className={`flex justify-center items-center w-24 cursor-pointer ${currentSection - 1 === index ? "text-black" : "text-neutral-300"}`} + className={`flex justify-center items-center w-20 lg:w-24 cursor-pointer ${currentSection - 1 === index ? "text-black" : "text-neutral-300"}`} > {scrollSection}
@@ -64,13 +67,13 @@ export default function Header() {
0 ? style.moveBar : "hidden"}`} + className={`w-20 lg:w-24 h-[3px] transition-transform ease-in-out-cubic duration-200 absolute bottom-0 left-0 ${currentSection > 0 ? style.moveBar : "hidden"}`} />
diff --git a/src/header/index.module.css b/src/header/index.module.css index a66abcde..eef8460e 100644 --- a/src/header/index.module.css +++ b/src/header/index.module.css @@ -1,4 +1,29 @@ .moveBar { - transition: all 0.2s ease-in-out; - transform: translateX(calc(var(--pos) * 1px)); + display: flex; + justify-content: center; + align-items: center; +} + +.moveBar::after { + content: ""; + width: 50px; + height: 3px; + background-color: #0d0d0d; +} + +@media (min-width: 768px) { + .moveBar { + transform: translateX( + calc((var(--section) - 1) * (var(--item-width) + var(--item-gap))) + ); + --item-width: 80px; + --item-gap: 16px; + } +} + +@media (min-width: 1024px) { + .moveBar { + --item-width: 96px; + --item-gap: 32px; + } } diff --git a/src/interactions/distanceDriven/index.jsx b/src/interactions/distanceDriven/index.jsx index 62e0ee2d..6f89b9b8 100644 --- a/src/interactions/distanceDriven/index.jsx +++ b/src/interactions/distanceDriven/index.jsx @@ -60,7 +60,7 @@ function DistanceDrivenInteraction({ interactCallback, $ref }) {

- + {Math.round(Math.hypot(x, y) / 3)} km diff --git a/src/interactions/fastCharge/index.jsx b/src/interactions/fastCharge/index.jsx index 049a4dc8..b93cedb9 100644 --- a/src/interactions/fastCharge/index.jsx +++ b/src/interactions/fastCharge/index.jsx @@ -59,7 +59,7 @@ function FastChargeInteraction({ interactCallback, $ref }) { draggable="false" />

- + {Math.round(progress * MAX_MINUTE)} 분 diff --git a/src/interactions/subsidy/index.jsx b/src/interactions/subsidy/index.jsx index cf0bb18a..69d336f5 100644 --- a/src/interactions/subsidy/index.jsx +++ b/src/interactions/subsidy/index.jsx @@ -61,7 +61,7 @@ function SubsidyInteraction({ interactCallback, $ref }) { />

- + {count * 10} 만원 diff --git a/src/introSection/index.jsx b/src/introSection/index.jsx index 571b68d3..43711197 100644 --- a/src/introSection/index.jsx +++ b/src/introSection/index.jsx @@ -75,16 +75,17 @@ function IntroSection() { return ( <>

-
+

- The new IONIQ 5 + The new
+ IONIQ 5

diff --git a/src/main-client.jsx b/src/main-client.jsx index 7fb0898c..2190ff74 100644 --- a/src/main-client.jsx +++ b/src/main-client.jsx @@ -2,9 +2,11 @@ import { StrictMode } from "react"; import { createRoot, hydrateRoot } from "react-dom/client"; import { register } from "swiper/element/bundle"; import App from "./App.jsx"; +import tokenSaver from "./auth/tokenSaver.js"; import "./index.css"; register(); +tokenSaver.init(); const $root = document.getElementById("root"); const app = ( diff --git a/src/scroll/useSectionInitialize.js b/src/scroll/useSectionInitialize.js index a4aef911..d84ccbf5 100644 --- a/src/scroll/useSectionInitialize.js +++ b/src/scroll/useSectionInitialize.js @@ -27,10 +27,5 @@ export default function useSectionInitialize(SECTION_IDX, sectionRef) { observer.unobserve(sectionDOM); } }; - }, [ - SECTION_IDX, - sectionRef, - uploadSection, - setIsVisibleList, - ]); + }, [SECTION_IDX, sectionRef, uploadSection, setIsVisibleList]); } diff --git a/tailwind.redefine.js b/tailwind.redefine.js index f430caf3..31ce5c5f 100644 --- a/tailwind.redefine.js +++ b/tailwind.redefine.js @@ -57,6 +57,11 @@ export default { }, black: "#0D0D0D", }, + spacing: { + "11.5": "2.875rem", //46px + "15": "3.75rem", //60px + "17.5": "4.375rem", //70px + }, fontSize: { "detail-s": ["10px", "13px"], "detail-m": ["11px", "14px"],