0 ? style.moveBar : "hidden"}`}
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/packages/mainPage/src/features/header/index.module.css b/packages/mainPage/src/features/header/index.module.css
new file mode 100644
index 00000000..cd8eb4cb
--- /dev/null
+++ b/packages/mainPage/src/features/header/index.module.css
@@ -0,0 +1,27 @@
+.moveBar {
+ 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/packages/mainPage/src/features/interactions/InteractionDescription.jsx b/packages/mainPage/src/features/interactions/InteractionDescription.jsx
new file mode 100644
index 00000000..55340a60
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/InteractionDescription.jsx
@@ -0,0 +1,17 @@
+function InteractionDescription({ order, title, description, directive, shouldNotSelect = false }) {
+ const divStyle =
+ "w-full max-w-[1200px] px-10 lg:px-20 flex gap-2 items-start mt-16 lg:mt-[6.25rem]";
+
+ return (
+
+
+
+
{title}
+
{description}
+
{directive}
+
+
+ );
+}
+
+export default InteractionDescription;
diff --git a/packages/mainPage/src/features/interactions/content.json b/packages/mainPage/src/features/interactions/content.json
new file mode 100644
index 00000000..16074dd2
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/content.json
@@ -0,0 +1,76 @@
+{
+ "interaction": [
+ "걱정 없이, 더 멀리",
+ "불편함 없이, 더 빠르게",
+ "나에게 맞게 자유자재로",
+ "언제 어디서나, 편리하게",
+ "누구보다 경제적으로"
+ ],
+ "answer": [
+ {
+ "head": "485km",
+ "desc": "더 뉴 아이오닉 5는 84.0kWh의 4세대 배터리 셀을 탑재하여 보다 여유있는 장거리 주행이 가능합니다.",
+ "subdesc": "배터리 용량이 늘어났음에도 기존과 동일하거나 더 빠른 속도로 차량을 충전할 수 있습니다. 또한 다양한 EV 충전 솔루션과 충전 서비스를 통해 차별화된 충전 경험을 제공합니다."
+ },
+ {
+ "head": "18분",
+ "desc": "더 뉴 아이오닉 5는 350kW 급속 충전기 사용 시 18분 이내에 배터리 용량의 10%에서 80%까지 충전이 가능합니다.",
+ "subdesc": "배터리 용량이 늘어났음에도 기존과 동일하거나 더 빠른 속도로 차량을 충전할 수 있습니다. 또한 다양한 EV 충전 솔루션과 충전 서비스를 통해 차별화된 충전 경험을 제공합니다."
+ },
+ {
+ "head": "Universal\nIsland",
+ "desc": "더 뉴 아이오닉 5는 유니버설 아일랜드를 적용해 다양한 상황에 맞는 최적화된 공간 활용성을 제공합니다.",
+ "subdesc": "기존 모델과 달리 더 뉴 아이오닉 5에서는 자주 사용하는 기능을 버튼식으로 배치했으며, 스마트폰 무선 충전 패드도 상단으로 옮겨 사용 편의성을 높였습니다."
+ },
+ {
+ "head": "V2L",
+ "desc": "더 뉴 아이오닉 5는 차량 외부로 전력을 공급할 수 있는 V2L 기능을 통해 새로운 전동화 경험을 제공합니다.",
+ "subdesc": "야외활동 시 여러가지 외부환경에서도 다양한 전자기기 사용이 가능합니다. 또한 2열 시트 하단의 실내 V2L을 사용하여 차량 내부에서도 배터리 걱정 없는 전자기기 사용이 가능합니다."
+ },
+ {
+ "head": "690만원",
+ "desc": "더 뉴 아이오닉 5의 뛰어난 성능과 합리적인 가격으로 최대 690만원의 국비 보조금을 혜택을 누릴 수 있습니다.",
+ "subdesc": "에너지 밀도와 재활용 가치가 높은 4세대 배터리를 탑재하여 성능보조금 100%를 지원 받을 수 있으며 더 뉴 아이오닉 5만의 혁신적인 기술과 충전 인프라로 추가적인 경제적 혜택을 제공받을 수 있습니다."
+ }
+ ],
+ "detail": {
+ "durationYear": "2024년",
+ "duration": "09월09일(월)~13일(금)",
+ "announceDate": "2024년 9월 말 **(예정)**",
+ "announceDateCaption": "* 추후 응모 시 입력한 연락처로 개별 안내",
+ "howto": [
+ "**매일매일 공개되는** 더 뉴 아이오닉5과 관련한 **인터랙션을 수행한다.**",
+ "이벤트 경품을 받기 위한 **필수 정보를 입력하면 응모 완료!**",
+ "날마다 응모하고 **기대평을 작성하면 당첨 확률 UP!**"
+ ]
+ },
+ "gift": [
+ {
+ "src": "images/gift_jeju.png",
+ "name": "제주 여행 패키지",
+ "num": 10,
+ "desc": "항공권, 숙박비, 아이오닉5 렌터카\n(충전 비용 포함)",
+ "grade": "1st",
+ "starColor": "#F9BB44",
+ "starTextColor": "#957029"
+ },
+ {
+ "src": "images/gift_car.png",
+ "name": "더뉴 아이오닉5 시승권",
+ "num": 50,
+ "desc": "10-12월 중 날짜 선택 가능",
+ "grade": "2nd",
+ "starColor": "#CFF0FF",
+ "starTextColor": "#36B1E6"
+ },
+ {
+ "src": "images/gift_portable.png",
+ "name": "포터블 인덕션",
+ "num": 100,
+ "desc": "블랙/화이트 컬러 랜덤 증정",
+ "grade": "3rd",
+ "starColor": "#B6423C",
+ "starTextColor": "#F9BB44"
+ }
+ ]
+}
diff --git a/packages/mainPage/src/features/interactions/description/GiftDetail.jsx b/packages/mainPage/src/features/interactions/description/GiftDetail.jsx
new file mode 100644
index 00000000..05ef2048
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/description/GiftDetail.jsx
@@ -0,0 +1,44 @@
+import Star from "./assets/star.svg?react";
+
+export default function GiftDetail({ contentList }) {
+ return (
+
+ {contentList.map((content, index) => (
+
+
+
+
+
+ {content.name}
+ {content.num}명
+
+
+
+ {content.desc}
+
+
+
+
+
+
+
+ {content.grade}
+
+
+
+ ))}
+
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/description/InteractionSlide.jsx b/packages/mainPage/src/features/interactions/description/InteractionSlide.jsx
new file mode 100644
index 00000000..a46d17d9
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/description/InteractionSlide.jsx
@@ -0,0 +1,83 @@
+import InteractionModal from "../modal/InteractionModal.jsx";
+import InteractionContext from "../modal/context.js";
+import openModal from "@common/modal/openModal.js";
+import { padNumber } from "@common/utils.js";
+import { EVENT_START_DATE, DAY_MILLISEC } from "@common/constants.js";
+import useDrawEventStore from "@main/drawEvent/store.js";
+
+function getEventDateString(eventDate) {
+ const day = ["일", "월", "화", "수", "목", "금", "토"];
+ const fullDate = new Date(eventDate);
+
+ const month = fullDate.getMonth() + 1;
+ const date = fullDate.getDate();
+
+ return `${padNumber(month)}월 ${padNumber(date)}일(${day[fullDate.getDay()]})`;
+}
+
+export default function InteractionSlide({ interactionDesc, index, isCurrent, slideTo }) {
+ const isOpened = useDrawEventStore((store) => store.getOpenStatus(index));
+
+ const activeImgPath = `images/active${index + 1}.png`;
+ const inactiveImgPath = `images/inactive${index + 1}.png`;
+ const numberImgPath = `icons/rect${index + 1}.svg`;
+ const eventDate = EVENT_START_DATE.getTime() + index * DAY_MILLISEC;
+
+ function onClickExperience() {
+ openModal(
+
+
+ ,
+ "interaction",
+ );
+ }
+
+ return (
+
slideTo(index)}
+ className="w-full h-full flex flex-col justify-center items-center select-none"
+ >
+
+ {getEventDateString(eventDate)}
+
+
+
+
+
+
+ {interactionDesc}
+
+
+
+
+
+
+ 오픈 예정
+
+
+
+
+
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/description/IntroductionDetail.jsx b/packages/mainPage/src/features/interactions/description/IntroductionDetail.jsx
new file mode 100644
index 00000000..3ac696c4
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/description/IntroductionDetail.jsx
@@ -0,0 +1,24 @@
+import EventDescriptionLayout from "@main/eventDescription/EventDescriptionLayout.jsx";
+
+export default function IntroductionDetail({ prizes }) {
+ return (
+
+ {prizes.map((contentSubList, index) => (
+
+
+ {index + 1}
+
+
+ {contentSubList.map((content, index) => (
+
+ {content}
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/description/TapBar.jsx b/packages/mainPage/src/features/interactions/description/TapBar.jsx
new file mode 100644
index 00000000..a1529206
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/description/TapBar.jsx
@@ -0,0 +1,63 @@
+import useDrawEvent from "@main/drawEvent/store.js";
+
+function TabBarItem({ currentInteraction, slideTo, index }) {
+ const isJoined = useDrawEvent((store) => store.getJoinStatus(index));
+ const isInvisible = useDrawEvent((store) => store.fallbackMode || !store.getOpenStatus(index));
+
+ return (
+
+ );
+}
+
+export default function TapBar({ currentInteraction, slideTo }) {
+ const isJoinedList = useDrawEvent((store) => store.joinStatus);
+
+ return (
+ <>
+
+ EVENT 1
+
+
+
+ The 새로워진 IONIQ 5, 인터랙션으로 만나다
+
+
+
+ {`The new IONIQ 5의 새로운 기능을 날마다 체험하고 이벤트에 응모하세요!\n추첨을 통해 IONIQ과 함께하는 제주 여행 패키지를 드립니다`}
+
+
+
+ {isJoinedList.map((_, index) => (
+
+ ))}
+
+ >
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/description/assets/star.svg b/packages/mainPage/src/features/interactions/description/assets/star.svg
new file mode 100644
index 00000000..2a412f9d
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/description/assets/star.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/packages/mainPage/src/features/interactions/distanceDriven/index.jsx b/packages/mainPage/src/features/interactions/distanceDriven/index.jsx
new file mode 100644
index 00000000..f5d00dfb
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/distanceDriven/index.jsx
@@ -0,0 +1,82 @@
+import { useEffect, useImperativeHandle, useId } from "react";
+import InteractionDescription from "../InteractionDescription.jsx";
+import usePointDrag from "./usePointDrag.js";
+import useDeviceRatio from "./useDeviceRatio.js";
+
+const MAX_DISTANCE = 800;
+
+function DistanceDrivenInteraction({ interactCallback, $ref, disabled }) {
+ const { x, y, reset, isDragging, onPointerDown, handleRef, subtitle } = usePointDrag(!disabled);
+ const ratio = useDeviceRatio();
+ const km = Math.floor((Math.hypot(x, y) * MAX_DISTANCE) / ratio);
+
+ const circleStyle = {
+ transform: `translate(${x}px, ${y}px)`,
+ };
+
+ function pulseAnimation(e) {
+ e.currentTarget.animate(
+ [
+ { transform: "scale(1)", opacity: 0.5 },
+ { transform: "scale(16)", opacity: 0 },
+ ],
+ {
+ duration: 300,
+ iteractions: 1,
+ easing: "ease-out",
+ pseudoElement: "::before",
+ },
+ );
+ }
+
+ useEffect(() => {
+ if (km !== 0) interactCallback?.();
+ }, [km, interactCallback]);
+ useImperativeHandle($ref, () => ({ reset }), [reset]);
+ const descriptionId = useId();
+
+ return (
+
+
+
+ {subtitle(x, y, km)}
+
+
+ 스페이스바를 눌러서 드래그 상태를 전환하세요.
+
+
+
{
+ onPointerDown(e);
+ pulseAnimation(e);
+ }}
+ style={circleStyle}
+ ref={handleRef}
+ role="button"
+ aria-describedby={descriptionId}
+ />
+
+
+
+ {km}
+ km
+
+
+ );
+}
+
+export default DistanceDrivenInteraction;
diff --git a/packages/mainPage/src/features/interactions/distanceDriven/useDeviceRatio.js b/packages/mainPage/src/features/interactions/distanceDriven/useDeviceRatio.js
new file mode 100644
index 00000000..922436d8
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/distanceDriven/useDeviceRatio.js
@@ -0,0 +1,18 @@
+import { useState, useEffect } from "react";
+import throttleRaf from "@common/throttleRaf.js";
+
+function useDeviceRatio() {
+ const [ratio, setRatio] = useState(1);
+ useEffect(() => {
+ setRatio(Math.hypot(window.innerWidth, window.innerHeight) / 2);
+ const onResize = throttleRaf(() => {
+ setRatio(Math.hypot(window.innerWidth, window.innerHeight) / 2);
+ });
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, []);
+
+ return ratio;
+}
+
+export default useDeviceRatio;
diff --git a/packages/mainPage/src/features/interactions/distanceDriven/usePointDrag.js b/packages/mainPage/src/features/interactions/distanceDriven/usePointDrag.js
new file mode 100644
index 00000000..1b6317c1
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/distanceDriven/usePointDrag.js
@@ -0,0 +1,61 @@
+import { useState, useRef, useCallback } from "react";
+import useMountDragEvent from "@main/hooks/useMountDragEvent.js";
+import useA11yDrag from "@main/hooks/useA11yDrag.js";
+
+const grabText = (x, y, km) =>
+ `점을 잡았습니다. 거리는 ${km}km이며, 현재 좌표는 (${x}, ${y})입니다. 방향키를 눌러 점의 위치를 조정하세요. 스페이스바를 눌러 점을 놓을 수 있습니다.`;
+const moveText = (x, y, km) => `${km}km입니다. (현재 좌표: ${x}, ${y})`;
+const dropText = (x, y, km) => `점이 놓였습니다. 거리는 ${km}km입니다. (새 좌표: ${x}, ${y})`;
+
+function usePointDrag(enabled) {
+ const prevState = useRef({ x: 0, y: 0, mouseX: 0, mouseY: 0 });
+ const [x, setX] = useState(0);
+ const [y, setY] = useState(0);
+ const [subtitle, setSubtitle] = useState(() => () => "");
+
+ const onDragStart = useCallback(
+ function ({ x: mouseX, y: mouseY }) {
+ Object.assign(prevState.current, { mouseX, mouseY, x, y });
+ },
+ [x, y],
+ );
+ const onDrag = useCallback(function (mouse) {
+ setX(prevState.current.x + mouse.x - prevState.current.mouseX);
+ setY(prevState.current.y + mouse.y - prevState.current.mouseY);
+ }, []);
+
+ const onKeyMove = useCallback(function (x, y) {
+ setX((prev) => prev + x * 10);
+ setY((prev) => prev + y * 10);
+ }, []);
+
+ const { onPointerDown, dragState } = useMountDragEvent({
+ onDragStart,
+ onDrag,
+ enabled,
+ });
+
+ const handleRef = useA11yDrag({
+ grabText,
+ moveText,
+ dropText,
+ onKeyMove,
+ enabled,
+ setSubtitle,
+ });
+
+ return {
+ x,
+ y,
+ reset() {
+ setX(0);
+ setY(0);
+ },
+ isDragging: dragState,
+ onPointerDown,
+ handleRef,
+ subtitle,
+ };
+}
+
+export default usePointDrag;
diff --git a/packages/mainPage/src/features/interactions/fastCharge/BatteryProgressBar.jsx b/packages/mainPage/src/features/interactions/fastCharge/BatteryProgressBar.jsx
new file mode 100644
index 00000000..c4ea4a20
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/fastCharge/BatteryProgressBar.jsx
@@ -0,0 +1,30 @@
+import style from "./batteryStyle.module.css";
+
+const RED_BAR_SIZE = 50;
+const YELLOW_BAR_SIZE = 190;
+const MAX_BAR_SIZE = 330;
+
+function getBatteryColor(progress) {
+ if (progress <= RED_BAR_SIZE / MAX_BAR_SIZE) return "bg-red-500";
+ if (progress <= YELLOW_BAR_SIZE / MAX_BAR_SIZE) return "bg-yellow-400";
+ return "bg-blue-400";
+}
+
+function BatteryProgressBar({ progress }) {
+ const batteryColor = getBatteryColor(progress);
+ const batteryDynamicStyle = {
+ "--progress": progress,
+ };
+
+ return (
+
+ );
+}
+
+export default BatteryProgressBar;
diff --git a/packages/mainPage/src/features/interactions/fastCharge/assets/timer.svg b/packages/mainPage/src/features/interactions/fastCharge/assets/timer.svg
new file mode 100644
index 00000000..ef8de03b
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/fastCharge/assets/timer.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/mainPage/src/features/interactions/fastCharge/batteryStyle.module.css b/packages/mainPage/src/features/interactions/fastCharge/batteryStyle.module.css
new file mode 100644
index 00000000..f93d2cef
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/fastCharge/batteryStyle.module.css
@@ -0,0 +1,27 @@
+.left {
+ width: 1.5rem;
+ transition: background-color 0.3s;
+}
+
+.bar {
+ width: calc(100% - 2.5rem);
+ transform-origin: left center;
+ transform: scaleX(calc((var(--progress, 1) * 208 + 8) / 216)); /* 8px ~ 216px */
+ transition: background-color 0.3s;
+}
+
+.right {
+ width: 1.5rem;
+ transform-origin: left center;
+ transform: translateX(calc((1 - var(--progress, 1)) * -13rem)); /* -208px ~ 0px */
+ transition: background-color 0.3s;
+}
+
+@media (min-width: 768px) {
+ .bar {
+ transform: scaleX(calc((var(--progress, 1) * 286 + 26) / 312)); /* 26px ~ 312px */
+ }
+ .right {
+ transform: translateX(calc((1 - var(--progress, 1)) * -17.875rem)); /* -286px ~ 0px */
+ }
+}
diff --git a/packages/mainPage/src/features/interactions/fastCharge/index.jsx b/packages/mainPage/src/features/interactions/fastCharge/index.jsx
new file mode 100644
index 00000000..2467e5ae
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/fastCharge/index.jsx
@@ -0,0 +1,83 @@
+import { useEffect, useImperativeHandle, useId } from "react";
+import InteractionDescription from "../InteractionDescription.jsx";
+import BatteryProgressBar from "./BatteryProgressBar.jsx";
+import dialSvg from "./assets/timer.svg";
+import useDialDrag from "./useDialDrag.js";
+
+const MAX_MINUTE = 30;
+
+function getProgress(angle) {
+ const rawProgress = -angle / (Math.PI * 2);
+ if (rawProgress < 0) return 0;
+ if (rawProgress > 1) return 1;
+ return rawProgress;
+}
+
+function FastChargeInteraction({ interactCallback, $ref, disabled }) {
+ const {
+ angle,
+ style: dialStyle,
+ ref: dialRef,
+ keyRef,
+ onPointerDown,
+ resetAngle: reset,
+ isDragging,
+ subtitle,
+ } = useDialDrag(!disabled);
+
+ useEffect(() => {
+ if (angle !== 0) interactCallback?.();
+ }, [angle, interactCallback]);
+ useImperativeHandle($ref, () => ({ reset }), [reset]);
+ const descriptionId = useId();
+
+ const progress = getProgress(angle);
+ const answer = Math.round(progress * MAX_MINUTE);
+
+ return (
+
+
+
+ {subtitle(answer, angle)}
+
+
+ 스페이스바를 눌러서 다이얼 조작 여부를 전환하세요.
+
+
+
+
+
+
+ {answer}
+
+ 분
+
+
+
+ );
+}
+
+export default FastChargeInteraction;
diff --git a/packages/mainPage/src/features/interactions/fastCharge/useDialDrag.js b/packages/mainPage/src/features/interactions/fastCharge/useDialDrag.js
new file mode 100644
index 00000000..cb0129ef
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/fastCharge/useDialDrag.js
@@ -0,0 +1,111 @@
+import { useState, useRef, useCallback } from "react";
+import useMountDragEvent from "@main/hooks/useMountDragEvent.js";
+import useA11yDrag from "@main/hooks/useA11yDrag.js";
+import { clamp } from "@common/utils.js";
+
+const MAX_MINUTE = 30;
+
+function getAngle(pointer, center) {
+ const vx = pointer.x - center.x;
+ const vy = pointer.y - center.y;
+ return Math.atan2(vx, -vy);
+}
+
+function getAngleDelta(prev, current) {
+ if (prev > Math.PI * 0.5 && current < -Math.PI * 0.5) return current + Math.PI * 2 - prev;
+ if (prev < -Math.PI * 0.5 && current > Math.PI * 0.5) return current - Math.PI * 2 - prev;
+ return current - prev;
+}
+
+const grabText = (value) =>
+ `다이얼 조작을 시작합니다. 현재 당신이 선택한 충전 시간은 ${value}분입니다. 왼쪽 방향키를 눌러서 충전 시간을 줄이고, 오른쪽 방향키를 눌러서 충전 시간을 늘려보세요. 최대 30분까지만 늘릴 수 있습니다.`;
+const moveText = (value, angle) => {
+ if (angle > 0) return `다이얼을 0도 이하로 조작할 수 없습니다.`;
+ if (angle < -Math.PI * 2) return `다이얼을 360도 이상으로 조작할 수 없습니다.`;
+ return `다이얼을 돌렸습니다. 당신이 선택한 충전 시간은 ${value}분입니다.`;
+};
+const dropText = (value) =>
+ `다이얼 조작을 해제했습니다. 당신이 선택한 충전 시간은 ${value}분입니다.`;
+
+function useDialDrag(enabled = true) {
+ const [angle, setAngle] = useState(0);
+ const dialRef = useRef(null);
+ const dialCenter = useRef({ x: 0, y: 0 });
+ const prevAngle = useRef(0);
+ const angleCache = useRef(0);
+ const [subtitle, setSubtitle] = useState(() => () => "");
+
+ const onDragStart = useCallback((cursor) => {
+ if (dialRef.current === null) return;
+
+ const boundRect = dialRef.current.getBoundingClientRect();
+ dialCenter.current.x = boundRect.x + boundRect.width / 2;
+ dialCenter.current.y = boundRect.y + boundRect.height / 2;
+ prevAngle.current = getAngle(cursor, dialCenter.current);
+ }, []);
+ const onDrag = useCallback((cursor) => {
+ const currentAngle = getAngle(cursor, dialCenter.current);
+ angleCache.current += getAngleDelta(prevAngle.current, currentAngle);
+ setAngle(angleCache.current);
+ prevAngle.current = currentAngle;
+ }, []);
+ const onDragEnd = useCallback(() => {
+ angleCache.current = clamp(angleCache.current, -Math.PI * 2, 0);
+ setAngle(angleCache.current);
+ }, []);
+
+ const { onPointerDown, dragState } = useMountDragEvent({
+ onDragStart,
+ onDrag,
+ onDragEnd,
+ enabled,
+ });
+
+ const resetAngle = useCallback(() => {
+ setAngle(0);
+ angleCache.current = 0;
+ prevAngle.current = 0;
+ }, []);
+
+ const onKeyMove = useCallback((x, y) => {
+ const UNIT = (Math.PI * 2) / MAX_MINUTE;
+
+ const delta = x !== 0 ? x : -y;
+ function getNewAngle(angle) {
+ const rounded = Math.round(angle / UNIT);
+ if (rounded - delta > 0) return UNIT;
+ else if (rounded - delta < -MAX_MINUTE) return -Math.PI * 2 - UNIT;
+ return (rounded - delta) * UNIT;
+ }
+
+ angleCache.current = getNewAngle(angleCache.current);
+ setAngle(angleCache.current);
+ }, []);
+
+ const keyRef = useA11yDrag({
+ grabText,
+ moveText,
+ dropText,
+ onKeyMove,
+ enabled,
+ setSubtitle,
+ });
+
+ const style = {
+ transform: `rotate(${angle}rad)`,
+ transition: dragState ? "none" : "transform 0.5s",
+ };
+
+ return {
+ angle,
+ style,
+ ref: dialRef,
+ keyRef,
+ onPointerDown,
+ resetAngle,
+ isDragging: dragState,
+ subtitle,
+ };
+}
+
+export default useDialDrag;
diff --git a/packages/mainPage/src/features/interactions/index.jsx b/packages/mainPage/src/features/interactions/index.jsx
new file mode 100644
index 00000000..a1c1dadc
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/index.jsx
@@ -0,0 +1,61 @@
+import { useRef } from "react";
+import TapBar from "./description/TapBar.jsx";
+import InteractionSlide from "./description/InteractionSlide.jsx";
+import GiftDetail from "./description/GiftDetail.jsx";
+import JSONData from "./content.json";
+
+import EventDescriptionLayout from "@main/eventDescription/EventDescriptionLayout.jsx";
+import useSectionInitialize from "@main/scroll/useSectionInitialize.js";
+import { INTERACTION_SECTION } from "@main/scroll/constants.js";
+import useSwiperState from "@main/hooks/useSwiperState.js";
+
+import DrawEventFetcher from "@main/drawEvent/DrawEventFetcher.jsx";
+import Suspense from "@common/components/Suspense.jsx";
+import ErrorBoundary from "@common/components/ErrorBoundary.jsx";
+
+export default function InteractionPage() {
+ const sectionRef = useRef(null);
+ const [currentInteraction, swiperRef] = useSwiperState();
+ const slideTo = (_index) => swiperRef.current.swiper.slideTo(_index);
+
+ useSectionInitialize(INTERACTION_SECTION, sectionRef);
+
+ return (
+ <>
+
+
+
+
+ {JSONData.interaction.map((interactionDesc, index) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/mock.js b/packages/mainPage/src/features/interactions/mock.js
new file mode 100644
index 00000000..b8a8b1e2
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/mock.js
@@ -0,0 +1,32 @@
+import { http, HttpResponse } from "msw";
+
+const eventParticipationDate = {
+ dates: ["2024-09-09T06:46:21.585Z", "2024-09-11T06:46:21.585Z", "2024-09-13T06:46:21.585Z"],
+};
+
+const handlers = [
+ http.get("/api/v1/event/draw/:eventId/participation", ({ request }) => {
+ const token = request.headers.get("authorization");
+ if (token === null) return HttpResponse.json({ dates: [] });
+
+ return HttpResponse.json(eventParticipationDate);
+ }),
+ http.post("/api/v1/event/draw/:eventId/participation", ({ request }) => {
+ const token = request.headers.get("authorization");
+ if (token === null) return HttpResponse.json(false, { status: 401 });
+
+ const dummyTodayStatus = "2024-09-10T12:00:00.000Z";
+ if (eventParticipationDate.dates.includes(dummyTodayStatus))
+ return HttpResponse.json(false, { status: 409 });
+
+ eventParticipationDate.dates.push("2024-09-10T12:00:00.000Z");
+ return HttpResponse.json(true);
+ }),
+ http.post("/api/v1/url/shorten", () =>
+ HttpResponse.json({
+ shortUrl: "o1PiWwlZZU",
+ }),
+ ),
+];
+
+export default handlers;
diff --git a/packages/mainPage/src/features/interactions/modal/AnswerDescription.jsx b/packages/mainPage/src/features/interactions/modal/AnswerDescription.jsx
new file mode 100644
index 00000000..8ef9d991
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/AnswerDescription.jsx
@@ -0,0 +1,17 @@
+function AnswerDescription({ head, desc, subdesc }) {
+ return (
+
+
+ {head}
+
+
+
+ {desc}
+
+ {subdesc}
+
+
+ );
+}
+
+export default AnswerDescription;
diff --git a/packages/mainPage/src/features/interactions/modal/InteractionAnswer.jsx b/packages/mainPage/src/features/interactions/modal/InteractionAnswer.jsx
new file mode 100644
index 00000000..76fbc24e
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/InteractionAnswer.jsx
@@ -0,0 +1,70 @@
+import { useState, useContext } from "react";
+import InteractionContext from "./context.js";
+import MoveCommentButton from "./buttons/MoveCommentButton.jsx";
+import ShareButton from "./buttons/ShareButton.jsx";
+import ParticipateButton from "./buttons/ParticipateButton.jsx";
+import AnswerDescription from "./AnswerDescription.jsx";
+
+import useAuthStore from "@main/auth/store.js";
+import useDrawEventStore from "@main/drawEvent/store.js";
+
+import style from "./InteractionAnswer.module.css";
+import content from "../content.json";
+
+function getParticipantState(index) {
+ return (state) => {
+ if (!state.getOpenStatus(index) || state.fallbackMode) return "";
+ if (state.isTodayEvent(index) && state.currentJoined) return "오늘 응모가 완료되었습니다!";
+ if (state.joinStatus[index]) return "이미 응모하셨습니다!";
+ else return "응모 기간이 지났습니다!";
+ };
+}
+
+export default function InteractionAnswer({ isAnswerUp, goBack }) {
+ const index = useContext(InteractionContext);
+
+ const isLogin = useAuthStore((state) => state.isLogin);
+ const isTodayEvent = useDrawEventStore((state) => state.isTodayEvent(index));
+ const participantState = useDrawEventStore(getParticipantState(index));
+ const [isAniPlaying, setIsAniPlaying] = useState(false);
+
+ return (
+
+
setIsAniPlaying(false)}
+ className={`${isAniPlaying ? style.toast : ""} opacity-0 fixed top-10 left-1/2 -translate-x-1/2 px-8 py-4 rounded-full bg-blue-100 text-neutral-600 text-body-m font-bold`}
+ >
+ 단축 URL이 클립보드에 복사 되었습니다!
+
+
+
+
+
+ {isLogin || !isTodayEvent ? (
+ <>
+
{participantState}
+
+
+ setIsAniPlaying(true)}
+ disabled={!isAnswerUp}
+ url="https://softeer-awesome-orange.vercel.app/"
+ />
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/modal/InteractionAnswer.module.css b/packages/mainPage/src/features/interactions/modal/InteractionAnswer.module.css
new file mode 100644
index 00000000..d6507531
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/InteractionAnswer.module.css
@@ -0,0 +1,18 @@
+.toast {
+ animation: toast-ani 5s linear;
+}
+
+@keyframes toast-ani {
+ 0% {
+ opacity: 0;
+ }
+ 5% {
+ opacity: 1;
+ }
+ 95% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+}
diff --git a/packages/mainPage/src/features/interactions/modal/InteractionModal.jsx b/packages/mainPage/src/features/interactions/modal/InteractionModal.jsx
new file mode 100644
index 00000000..a0480d44
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/InteractionModal.jsx
@@ -0,0 +1,67 @@
+import { lazy, useContext, useRef, useState } from "react";
+
+import IntearctionContext from "./context.js";
+import InteractionAnswer from "./InteractionAnswer.jsx";
+import ShowAnswerButton from "./buttons/ShowAnswerButton.jsx";
+import ResetButton from "@main/components/ResetButton.jsx";
+
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import Spinner from "@common/components/Spinner.jsx";
+import Suspense from "@common/components/Suspense.jsx";
+
+const lazyInteractionList = [
+ lazy(() => import("../distanceDriven")),
+ lazy(() => import("../fastCharge")),
+ lazy(() => import("../univasalIsland")),
+ lazy(() => import("../v2l")),
+ lazy(() => import("../subsidy")),
+];
+
+export default function InteractionModal() {
+ const close = useContext(ModalCloseContext);
+ const index = useContext(IntearctionContext);
+ const InteractionComponent = lazyInteractionList[index];
+ const [isActive, setIsActive] = useState(false);
+ const [isAnswerUp, setIsAnswerUp] = useState(false);
+ const interactionRef = useRef(null);
+ const closeButtonRef = useRef(null);
+
+ if (!InteractionComponent) return;
+
+ // backdrop-blur-[100px]을 적용시키면 느린 성능의 컴퓨터에서 인터랙션이 매우 느리게 동작함
+ return (
+
+
}>
+
setIsActive(true)}
+ $ref={interactionRef}
+ disabled={isAnswerUp}
+ />
+
+
+
+ setIsAnswerUp(true)} />
+ interactionRef.current.reset()} disabled={isAnswerUp} />
+
+
+
+
+ {
+ setIsAnswerUp(false);
+ closeButtonRef.current.focus();
+ }}
+ />
+
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/modal/buttons/MoveCommentButton.jsx b/packages/mainPage/src/features/interactions/modal/buttons/MoveCommentButton.jsx
new file mode 100644
index 00000000..29484f58
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/buttons/MoveCommentButton.jsx
@@ -0,0 +1,36 @@
+import { useContext } from "react";
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import scrollTo from "@main/scroll/scrollTo.js";
+import { COMMENT_SECTION } from "@main/scroll/constants.js";
+import Button from "@common/components/Button.jsx";
+
+function MoveCommentButton({ disabled, hidden }) {
+ const close = useContext(ModalCloseContext);
+
+ async function onClickWrite() {
+ await close();
+ scrollTo(COMMENT_SECTION);
+ }
+
+ return (
+
+
+
+ 당첨확률 UP!
+
+
+
+
+
+ );
+}
+
+export default MoveCommentButton;
diff --git a/packages/mainPage/src/features/interactions/modal/buttons/ParticipateButton.jsx b/packages/mainPage/src/features/interactions/modal/buttons/ParticipateButton.jsx
new file mode 100644
index 00000000..7e04f2e6
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/buttons/ParticipateButton.jsx
@@ -0,0 +1,26 @@
+import { useContext } from "react";
+import InteractionContext from "../context.js";
+
+import AuthModal from "@main/auth/AuthModal.jsx";
+import openModal from "@common/modal/openModal.js";
+import joinEvent from "../joinEvent.js";
+import Button from "@common/components/Button.jsx";
+
+function ParticipateButton({ disabled }) {
+ const index = useContext(InteractionContext);
+ const authModal =
joinEvent(index)} />;
+
+ return (
+
+ );
+}
+
+export default ParticipateButton;
diff --git a/packages/mainPage/src/features/interactions/modal/buttons/ShareButton.jsx b/packages/mainPage/src/features/interactions/modal/buttons/ShareButton.jsx
new file mode 100644
index 00000000..d399d747
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/buttons/ShareButton.jsx
@@ -0,0 +1,31 @@
+import { fetchServer } from "@common/dataFetch/fetchServer.js";
+import Button from "@common/components/Button.jsx";
+
+function ShareButton({ url, openToast, disabled }) {
+ function onClickShare() {
+ fetchServer(`/api/v1/url/shorten?originalUrl=${encodeURIComponent(url)}`, {
+ method: "POST",
+ })
+ .then(({ shortUrl }) => {
+ navigator.clipboard.writeText(`http://softeerorange.store/api/v1/url/${shortUrl}`);
+ })
+ .catch(() => {
+ navigator.clipboard.writeText(url);
+ })
+ .finally(openToast);
+ }
+
+ return (
+
+ );
+}
+
+export default ShareButton;
diff --git a/packages/mainPage/src/features/interactions/modal/buttons/ShowAnswerButton.jsx b/packages/mainPage/src/features/interactions/modal/buttons/ShowAnswerButton.jsx
new file mode 100644
index 00000000..084a335d
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/buttons/ShowAnswerButton.jsx
@@ -0,0 +1,29 @@
+import { useContext } from "react";
+import IntearctionContext from "../context.js";
+
+import joinEvent from "../joinEvent.js";
+import Button from "@common/components/Button.jsx";
+
+function ShowAnswerButton({ disabled, onClick }) {
+ const index = useContext(IntearctionContext);
+
+ function onClickConfirm() {
+ if (disabled) return;
+ onClick();
+ joinEvent(index);
+ }
+
+ return (
+
+ );
+}
+
+export default ShowAnswerButton;
diff --git a/packages/mainPage/src/features/interactions/modal/context.js b/packages/mainPage/src/features/interactions/modal/context.js
new file mode 100644
index 00000000..fc5494e9
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/context.js
@@ -0,0 +1,5 @@
+import { createContext } from "react";
+
+const InteractionContext = createContext(0);
+
+export default InteractionContext;
diff --git a/packages/mainPage/src/features/interactions/modal/joinEvent.js b/packages/mainPage/src/features/interactions/modal/joinEvent.js
new file mode 100644
index 00000000..6607d40d
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/modal/joinEvent.js
@@ -0,0 +1,49 @@
+import { isLogined } from "@main/auth/store.js";
+import drawEventStore from "@main/drawEvent/store.js";
+
+import { fetchServer, handleError } from "@common/dataFetch/fetchServer.js";
+import { mutate } from "@common/dataFetch/getQuery.js";
+import { EVENT_DRAW_ID } from "@common/constants.js";
+
+const joinEventErrorHandler = {
+ 409: "이미 참여했습니다.",
+ 404: "이벤트가 존재하지 않습니다.",
+ 400: "사용자가 다른 이벤트에 소속되어 있으므로, 이벤트를 참여할 수 없습니다.",
+ offline: "오프라인 폴백 모드로 전환합니다.",
+};
+
+export default function joinEvent(index) {
+ const isLogin = isLogined();
+ const { isTodayEvent, getJoinStatus, setCurrentJoin, readjustJoinStatus, setFallbackMode } =
+ drawEventStore.getState();
+ const todayEvent = isTodayEvent(index);
+
+ if (!isLogin || !todayEvent || getJoinStatus(index)) return;
+
+ mutate(
+ "draw-info-data",
+ () =>
+ fetchServer(`/api/v1/event/draw/${EVENT_DRAW_ID}/participation`, { method: "post" }).catch(
+ handleError(joinEventErrorHandler),
+ ),
+ {
+ onSuccess: () => setCurrentJoin(true),
+ onError: (e) => {
+ switch (e.message) {
+ case joinEventErrorHandler[409]:
+ readjustJoinStatus();
+ break;
+ case joinEventErrorHandler[404]:
+ case joinEventErrorHandler["offline"]:
+ setFallbackMode();
+ break;
+ case joinEventErrorHandler[400]:
+ alert(joinEventErrorHandler[400]);
+ break;
+ default:
+ alert("이벤트 참여에 실패했습니다.");
+ }
+ },
+ },
+ );
+}
diff --git a/packages/mainPage/src/features/interactions/subsidy/assets/coin.json b/packages/mainPage/src/features/interactions/subsidy/assets/coin.json
new file mode 100644
index 00000000..c322582a
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/subsidy/assets/coin.json
@@ -0,0 +1,1035 @@
+{
+ "v": "4.8.0",
+ "meta": { "g": "LottieFiles AE 3.5.8", "a": "", "k": "", "d": "", "tc": "" },
+ "fr": 30,
+ "ip": 0,
+ "op": 30,
+ "w": 500,
+ "h": 500,
+ "nm": "Comp 2",
+ "ddd": 0,
+ "assets": [],
+ "layers": [
+ {
+ "ddd": 0,
+ "ind": 1,
+ "ty": 4,
+ "nm": "Shape Layer 6",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 19,
+ "s": [100]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 23,
+ "s": [1]
+ },
+ { "t": 25, "s": [1] }
+ ],
+ "ix": 11
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.123], "y": [0.335] },
+ "o": { "x": [0], "y": [0] },
+ "t": 5,
+ "s": [0]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.7] },
+ "o": { "x": [0.496], "y": [0.424] },
+ "t": 13,
+ "s": [-24]
+ },
+ { "t": 25, "s": [-56] }
+ ],
+ "ix": 10
+ },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": 0, "y": 0.296 },
+ "o": { "x": 0, "y": 0 },
+ "t": 5,
+ "s": [250, 250, 0],
+ "to": [-40.667, -47.5, 0],
+ "ti": [57.284, -10.284, 0]
+ },
+ {
+ "i": { "x": 0.778, "y": 0.451 },
+ "o": { "x": 0.21, "y": 0.14 },
+ "t": 13,
+ "s": [126, 200, 0],
+ "to": [-71.118, 12.767, 0],
+ "ti": [5, -36, 0]
+ },
+ { "t": 25, "s": [-12, 368, 0] }
+ ],
+ "ix": 2
+ },
+ "a": { "a": 0, "k": [-151.707, -94.707, 0], "ix": 1 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833, 0.234, 0.833], "y": [1, 0.487, 1] },
+ "o": { "x": [0, 0, 0.333], "y": [0, 0, 0] },
+ "t": 5,
+ "s": [100, 100, 100]
+ },
+ {
+ "i": { "x": [0.833, 0.613, 0.833], "y": [1, 0.513, 1] },
+ "o": { "x": [0.167, 0.41, 0.167], "y": [0, 0.118, 0] },
+ "t": 13,
+ "s": [100, 40, 100]
+ },
+ { "t": 21, "s": [100, -100, 100] }
+ ],
+ "ix": 6
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "d": 1,
+ "ty": "el",
+ "s": { "a": 0, "k": [30, 30], "ix": 2 },
+ "p": { "a": 0, "k": [0, 0], "ix": 3 },
+ "nm": "Ellipse Path 1",
+ "mn": "ADBE Vector Shape - Ellipse",
+ "hd": false
+ },
+ {
+ "ty": "st",
+ "c": {
+ "a": 0,
+ "k": [0.33662701775, 0.33662701775, 0.33662701775, 1],
+ "ix": 3
+ },
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 0, "ix": 5 },
+ "lc": 1,
+ "lj": 1,
+ "ml": 4,
+ "bm": 0,
+ "nm": "Stroke 1",
+ "mn": "ADBE Vector Graphic - Stroke",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 5,
+ "s": [0.149019607843, 0.898039215686, 1, 1]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 13,
+ "s": [0.58659620098, 0.950467756683, 1, 1]
+ },
+ { "t": 25, "s": [0.84753370098, 0.981732117896, 1, 1] }
+ ],
+ "ix": 4
+ },
+ "o": { "a": 0, "k": 100, "ix": 5 },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fill 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": { "a": 0, "k": [-151.707, -94.707], "ix": 2 },
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "o": { "a": 0, "k": 100, "ix": 7 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "nm": "Transform"
+ }
+ ],
+ "nm": "Ellipse 1",
+ "np": 3,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 5,
+ "op": 2705,
+ "st": 5,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 2,
+ "ty": 4,
+ "nm": "Shape Layer 5",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 22,
+ "s": [100]
+ },
+ { "t": 26, "s": [0] }
+ ],
+ "ix": 11
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.123], "y": [1.425] },
+ "o": { "x": [0], "y": [0] },
+ "t": 3,
+ "s": [0]
+ },
+ {
+ "i": { "x": [0.833], "y": [1.193] },
+ "o": { "x": [0.496], "y": [-0.272] },
+ "t": 13,
+ "s": [47]
+ },
+ { "t": 26, "s": [101] }
+ ],
+ "ix": 10
+ },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": 0, "y": 0.163 },
+ "o": { "x": 0, "y": 0 },
+ "t": 3,
+ "s": [250, 250, 0],
+ "to": [8.333, -36.5, 0],
+ "ti": [-44.659, -3.543, 0]
+ },
+ {
+ "i": { "x": 0.778, "y": 0.425 },
+ "o": { "x": 0.21, "y": 0.146 },
+ "t": 13,
+ "s": [359, 160, 0],
+ "to": [58.396, 4.633, 0],
+ "ti": [-5.5, -86.833, 0]
+ },
+ { "t": 26, "s": [464, 353, 0] }
+ ],
+ "ix": 2
+ },
+ "a": { "a": 0, "k": [-151.707, -94.707, 0], "ix": 1 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833, 0.833, 0.833], "y": [1, 1, 1] },
+ "o": { "x": [0.167, 0.41, 0.167], "y": [0, -0.343, 0] },
+ "t": 3,
+ "s": [100, 40, 100]
+ },
+ {
+ "i": { "x": [0.833, 0.613, 0.833], "y": [1, 0.446, 1] },
+ "o": { "x": [0, 0, 0.333], "y": [0, 0, 0] },
+ "t": 13,
+ "s": [100, 100, 100]
+ },
+ { "t": 26, "s": [100, -100, 100] }
+ ],
+ "ix": 6
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "d": 1,
+ "ty": "el",
+ "s": { "a": 0, "k": [30, 30], "ix": 2 },
+ "p": { "a": 0, "k": [0, 0], "ix": 3 },
+ "nm": "Ellipse Path 1",
+ "mn": "ADBE Vector Shape - Ellipse",
+ "hd": false
+ },
+ {
+ "ty": "st",
+ "c": {
+ "a": 0,
+ "k": [0.33662701775, 0.33662701775, 0.33662701775, 1],
+ "ix": 3
+ },
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 0, "ix": 5 },
+ "lc": 1,
+ "lj": 1,
+ "ml": 4,
+ "bm": 0,
+ "nm": "Stroke 1",
+ "mn": "ADBE Vector Graphic - Stroke",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 3,
+ "s": [0.58659620098, 0.950467756683, 1, 1]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 13,
+ "s": [0.149019607843, 0.898039215686, 1, 1]
+ },
+ { "t": 26, "s": [0.84753370098, 0.981732117896, 1, 1] }
+ ],
+ "ix": 4
+ },
+ "o": { "a": 0, "k": 100, "ix": 5 },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fill 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": { "a": 0, "k": [-151.707, -94.707], "ix": 2 },
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "o": { "a": 0, "k": 100, "ix": 7 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "nm": "Transform"
+ }
+ ],
+ "nm": "Ellipse 1",
+ "np": 3,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 3,
+ "op": 2703,
+ "st": 3,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 3,
+ "ty": 4,
+ "nm": "Shape Layer 4",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 23,
+ "s": [100]
+ },
+ { "t": 27, "s": [0] }
+ ],
+ "ix": 11
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.123], "y": [2.497] },
+ "o": { "x": [0], "y": [0] },
+ "t": 4,
+ "s": [0]
+ },
+ {
+ "i": { "x": [0.833], "y": [1.22] },
+ "o": { "x": [0.496], "y": [-0.31] },
+ "t": 13,
+ "s": [12]
+ },
+ { "t": 27, "s": [63] }
+ ],
+ "ix": 10
+ },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": 0, "y": 0.375 },
+ "o": { "x": 0, "y": 0 },
+ "t": 4,
+ "s": [250, 250, 0],
+ "to": [-1.667, -56.5, 0],
+ "ti": [45.583, -0.146, 0]
+ },
+ {
+ "i": { "x": 0.823, "y": 0.638 },
+ "o": { "x": 0.21, "y": 0.125 },
+ "t": 13,
+ "s": [164.5, 148, 0],
+ "to": [-72.093, 0.23, 0],
+ "ti": [-4.5, -60.833, 0]
+ },
+ { "t": 27, "s": [63, 345, 0] }
+ ],
+ "ix": 2
+ },
+ "a": { "a": 0, "k": [-151.707, -94.707, 0], "ix": 1 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0, 0.312, 0.833], "y": [0.247, -20.657, 1] },
+ "o": { "x": [0, 0, 0.167], "y": [0, -0.005, 0] },
+ "t": 4,
+ "s": [100, 100, 100]
+ },
+ {
+ "i": { "x": [0.936, 0.837, 0.833], "y": [0.869, 30.541, 1] },
+ "o": { "x": [0.693, 0.563, 0.167], "y": [0.348, 27.567, 0] },
+ "t": 13,
+ "s": [52, 100, 100]
+ },
+ { "t": 27, "s": [-60, 100, 100] }
+ ],
+ "ix": 6
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "d": 1,
+ "ty": "el",
+ "s": { "a": 0, "k": [30, 30], "ix": 2 },
+ "p": { "a": 0, "k": [0, 0], "ix": 3 },
+ "nm": "Ellipse Path 1",
+ "mn": "ADBE Vector Shape - Ellipse",
+ "hd": false
+ },
+ {
+ "ty": "st",
+ "c": {
+ "a": 0,
+ "k": [0.33662701775, 0.33662701775, 0.33662701775, 1],
+ "ix": 3
+ },
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 0, "ix": 5 },
+ "lc": 1,
+ "lj": 1,
+ "ml": 4,
+ "bm": 0,
+ "nm": "Stroke 1",
+ "mn": "ADBE Vector Graphic - Stroke",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 4,
+ "s": [0.58659620098, 0.950467756683, 1, 1]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 13,
+ "s": [0.149019607843, 0.898039215686, 1, 1]
+ },
+ { "t": 27, "s": [0.84753370098, 0.981732117896, 1, 1] }
+ ],
+ "ix": 4
+ },
+ "o": { "a": 0, "k": 100, "ix": 5 },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fill 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": { "a": 0, "k": [-151.707, -94.707], "ix": 2 },
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "o": { "a": 0, "k": 100, "ix": 7 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "nm": "Transform"
+ }
+ ],
+ "nm": "Ellipse 1",
+ "np": 3,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 4,
+ "op": 2704,
+ "st": 4,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 4,
+ "ty": 4,
+ "nm": "Shape Layer 3",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 20,
+ "s": [100]
+ },
+ { "t": 24, "s": [0] }
+ ],
+ "ix": 11
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.123], "y": [2.497] },
+ "o": { "x": [0], "y": [0] },
+ "t": 1,
+ "s": [0]
+ },
+ {
+ "i": { "x": [0.833], "y": [1.22] },
+ "o": { "x": [0.496], "y": [-0.31] },
+ "t": 10,
+ "s": [12]
+ },
+ { "t": 24, "s": [63] }
+ ],
+ "ix": 10
+ },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": 0, "y": 0.28 },
+ "o": { "x": 0, "y": 0 },
+ "t": 1,
+ "s": [250, 250, 0],
+ "to": [33.333, -39.5, 0],
+ "ti": [-45.383, -4.275, 0]
+ },
+ {
+ "i": { "x": 0.823, "y": 0.559 },
+ "o": { "x": 0.21, "y": 0.152 },
+ "t": 10,
+ "s": [362, 203, 0],
+ "to": [44.907, 4.23, 0],
+ "ti": [-26.5, -61.833, 0]
+ },
+ { "t": 24, "s": [510, 323, 0] }
+ ],
+ "ix": 2
+ },
+ "a": { "a": 0, "k": [-151.707, -94.707, 0], "ix": 1 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0, 0.312, 0.833], "y": [0.247, -20.657, 1] },
+ "o": { "x": [0, 0, 0.167], "y": [0, -0.005, 0] },
+ "t": 1,
+ "s": [100, 100, 100]
+ },
+ {
+ "i": { "x": [0.936, 0.837, 0.833], "y": [0.869, 30.541, 1] },
+ "o": { "x": [0.693, 0.563, 0.167], "y": [0.348, 27.567, 0] },
+ "t": 10,
+ "s": [52, 100, 100]
+ },
+ { "t": 24, "s": [-60, 100, 100] }
+ ],
+ "ix": 6
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "d": 1,
+ "ty": "el",
+ "s": { "a": 0, "k": [30, 30], "ix": 2 },
+ "p": { "a": 0, "k": [0, 0], "ix": 3 },
+ "nm": "Ellipse Path 1",
+ "mn": "ADBE Vector Shape - Ellipse",
+ "hd": false
+ },
+ {
+ "ty": "st",
+ "c": {
+ "a": 0,
+ "k": [0.33662701775, 0.33662701775, 0.33662701775, 1],
+ "ix": 3
+ },
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 0, "ix": 5 },
+ "lc": 1,
+ "lj": 1,
+ "ml": 4,
+ "bm": 0,
+ "nm": "Stroke 1",
+ "mn": "ADBE Vector Graphic - Stroke",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 1,
+ "s": [0.58659620098, 0.950467756683, 1, 1]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 10,
+ "s": [0.149019607843, 0.898039215686, 1, 1]
+ },
+ { "t": 24, "s": [0.84753370098, 0.981732117896, 1, 1] }
+ ],
+ "ix": 4
+ },
+ "o": { "a": 0, "k": 100, "ix": 5 },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fill 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": { "a": 0, "k": [-151.707, -94.707], "ix": 2 },
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "o": { "a": 0, "k": 100, "ix": 7 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "nm": "Transform"
+ }
+ ],
+ "nm": "Ellipse 1",
+ "np": 3,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 1,
+ "op": 2701,
+ "st": 1,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 5,
+ "ty": 4,
+ "nm": "Shape Layer 2",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 23,
+ "s": [100]
+ },
+ { "t": 27, "s": [0] }
+ ],
+ "ix": 11
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.123], "y": [1.425] },
+ "o": { "x": [0], "y": [0] },
+ "t": 4,
+ "s": [0]
+ },
+ {
+ "i": { "x": [0.833], "y": [1.193] },
+ "o": { "x": [0.496], "y": [-0.272] },
+ "t": 14,
+ "s": [47]
+ },
+ { "t": 27, "s": [101] }
+ ],
+ "ix": 10
+ },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": 0, "y": 0.117 },
+ "o": { "x": 0, "y": 0 },
+ "t": 4,
+ "s": [250, 250, 0],
+ "to": [8.333, -36.5, 0],
+ "ti": [-42.806, -13.216, 0]
+ },
+ {
+ "i": { "x": 0.778, "y": 0.437 },
+ "o": { "x": 0.21, "y": 0.143 },
+ "t": 14,
+ "s": [343, 159, 0],
+ "to": [47.396, 14.633, 0],
+ "ti": [-15.5, -82.833, 0]
+ },
+ { "t": 27, "s": [456, 362, 0] }
+ ],
+ "ix": 2
+ },
+ "a": { "a": 0, "k": [-151.707, -94.707, 0], "ix": 1 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833, 0.833, 0.833], "y": [1, 1, 1] },
+ "o": { "x": [0.167, 0.41, 0.167], "y": [0, -0.343, 0] },
+ "t": 4,
+ "s": [100, 40, 100]
+ },
+ {
+ "i": { "x": [0.833, 0.613, 0.833], "y": [1, 0.446, 1] },
+ "o": { "x": [0, 0, 0.333], "y": [0, 0, 0] },
+ "t": 14,
+ "s": [100, 100, 100]
+ },
+ { "t": 27, "s": [100, -100, 100] }
+ ],
+ "ix": 6
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "d": 1,
+ "ty": "el",
+ "s": { "a": 0, "k": [30, 30], "ix": 2 },
+ "p": { "a": 0, "k": [0, 0], "ix": 3 },
+ "nm": "Ellipse Path 1",
+ "mn": "ADBE Vector Shape - Ellipse",
+ "hd": false
+ },
+ {
+ "ty": "st",
+ "c": {
+ "a": 0,
+ "k": [0.33662701775, 0.33662701775, 0.33662701775, 1],
+ "ix": 3
+ },
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 0, "ix": 5 },
+ "lc": 1,
+ "lj": 1,
+ "ml": 4,
+ "bm": 0,
+ "nm": "Stroke 1",
+ "mn": "ADBE Vector Graphic - Stroke",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 4,
+ "s": [0.58659620098, 0.950467756683, 1, 1]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 14,
+ "s": [0.149019607843, 0.898039215686, 1, 1]
+ },
+ { "t": 27, "s": [0.84753370098, 0.981732117896, 1, 1] }
+ ],
+ "ix": 4
+ },
+ "o": { "a": 0, "k": 100, "ix": 5 },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fill 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": { "a": 0, "k": [-151.707, -94.707], "ix": 2 },
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "o": { "a": 0, "k": 100, "ix": 7 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "nm": "Transform"
+ }
+ ],
+ "nm": "Ellipse 1",
+ "np": 3,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 4,
+ "op": 2704,
+ "st": 4,
+ "bm": 0
+ },
+ {
+ "ddd": 0,
+ "ind": 6,
+ "ty": 4,
+ "nm": "Shape Layer 1",
+ "sr": 1,
+ "ks": {
+ "o": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 14,
+ "s": [100]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 18,
+ "s": [1]
+ },
+ { "t": 20, "s": [1] }
+ ],
+ "ix": 11
+ },
+ "r": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.123], "y": [0.335] },
+ "o": { "x": [0], "y": [0] },
+ "t": 0,
+ "s": [0]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.7] },
+ "o": { "x": [0.496], "y": [0.424] },
+ "t": 8,
+ "s": [-24]
+ },
+ { "t": 20, "s": [-56] }
+ ],
+ "ix": 10
+ },
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": 0, "y": 0.296 },
+ "o": { "x": 0, "y": 0 },
+ "t": 0,
+ "s": [250, 250, 0],
+ "to": [-40.667, -47.5, 0],
+ "ti": [57.284, -10.284, 0]
+ },
+ {
+ "i": { "x": 0.778, "y": 0.451 },
+ "o": { "x": 0.21, "y": 0.14 },
+ "t": 8,
+ "s": [126, 200, 0],
+ "to": [-71.118, 12.767, 0],
+ "ti": [5, -36, 0]
+ },
+ { "t": 20, "s": [-12, 368, 0] }
+ ],
+ "ix": 2
+ },
+ "a": { "a": 0, "k": [-151.707, -94.707, 0], "ix": 1 },
+ "s": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833, 0.234, 0.833], "y": [1, 0.487, 1] },
+ "o": { "x": [0, 0, 0.333], "y": [0, 0, 0] },
+ "t": 0,
+ "s": [100, 100, 100]
+ },
+ {
+ "i": { "x": [0.833, 0.613, 0.833], "y": [1, 0.513, 1] },
+ "o": { "x": [0.167, 0.41, 0.167], "y": [0, 0.118, 0] },
+ "t": 8,
+ "s": [100, 40, 100]
+ },
+ { "t": 16, "s": [100, -100, 100] }
+ ],
+ "ix": 6
+ }
+ },
+ "ao": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "d": 1,
+ "ty": "el",
+ "s": { "a": 0, "k": [30, 30], "ix": 2 },
+ "p": { "a": 0, "k": [0, 0], "ix": 3 },
+ "nm": "Ellipse Path 1",
+ "mn": "ADBE Vector Shape - Ellipse",
+ "hd": false
+ },
+ {
+ "ty": "st",
+ "c": {
+ "a": 0,
+ "k": [0.33662701775, 0.33662701775, 0.33662701775, 1],
+ "ix": 3
+ },
+ "o": { "a": 0, "k": 100, "ix": 4 },
+ "w": { "a": 0, "k": 0, "ix": 5 },
+ "lc": 1,
+ "lj": 1,
+ "ml": 4,
+ "bm": 0,
+ "nm": "Stroke 1",
+ "mn": "ADBE Vector Graphic - Stroke",
+ "hd": false
+ },
+ {
+ "ty": "fl",
+ "c": {
+ "a": 1,
+ "k": [
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 0,
+ "s": [0.149019607843, 0.898039215686, 1, 1]
+ },
+ {
+ "i": { "x": [0.833], "y": [0.833] },
+ "o": { "x": [0.167], "y": [0.167] },
+ "t": 8,
+ "s": [0.58659620098, 0.950467756683, 1, 1]
+ },
+ { "t": 20, "s": [0.84753370098, 0.981732117896, 1, 1] }
+ ],
+ "ix": 4
+ },
+ "o": { "a": 0, "k": 100, "ix": 5 },
+ "r": 1,
+ "bm": 0,
+ "nm": "Fill 1",
+ "mn": "ADBE Vector Graphic - Fill",
+ "hd": false
+ },
+ {
+ "ty": "tr",
+ "p": { "a": 0, "k": [-151.707, -94.707], "ix": 2 },
+ "a": { "a": 0, "k": [0, 0], "ix": 1 },
+ "s": { "a": 0, "k": [100, 100], "ix": 3 },
+ "r": { "a": 0, "k": 0, "ix": 6 },
+ "o": { "a": 0, "k": 100, "ix": 7 },
+ "sk": { "a": 0, "k": 0, "ix": 4 },
+ "sa": { "a": 0, "k": 0, "ix": 5 },
+ "nm": "Transform"
+ }
+ ],
+ "nm": "Ellipse 1",
+ "np": 3,
+ "cix": 2,
+ "bm": 0,
+ "ix": 1,
+ "mn": "ADBE Vector Group",
+ "hd": false
+ }
+ ],
+ "ip": 0,
+ "op": 2700,
+ "st": 0,
+ "bm": 0
+ }
+ ],
+ "markers": []
+}
diff --git a/packages/mainPage/src/features/interactions/subsidy/assets/dollor.svg b/packages/mainPage/src/features/interactions/subsidy/assets/dollor.svg
new file mode 100644
index 00000000..55578184
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/subsidy/assets/dollor.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/mainPage/src/features/interactions/subsidy/index.jsx b/packages/mainPage/src/features/interactions/subsidy/index.jsx
new file mode 100644
index 00000000..dda35c79
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/subsidy/index.jsx
@@ -0,0 +1,103 @@
+import { useState, useRef, useImperativeHandle } from "react";
+import Lottie from "react-lottie-player/dist/LottiePlayerLight";
+import coinLottie from "./assets/coin.json";
+import dollor from "./assets/dollor.svg";
+import InteractionDescription from "../InteractionDescription.jsx";
+
+function SubsidyInteraction({ interactCallback, $ref, disabled }) {
+ const [count, setCount] = useState(0);
+ const [lotties, setLotties] = useState(new Set());
+ const coinRef = useRef(null);
+ const [subtitle, setSubtitle] = useState("");
+
+ function onClick() {
+ setCount((count) => count + 1);
+ setSubtitle(`${(count + 1) * 10}만원을 입력하셨습니다.`);
+ coinRef.current?.animate([{ transform: "rotateY(0)" }, { transform: "rotateY(360deg)" }], {
+ duration: 500,
+ iteractions: 1,
+ easing: "cubic-bezier(0.215, 0.610, 0.355, 1.000)", // ease-out-cubic
+ });
+ setLotties((lotties) => {
+ const newLotties = new Set(lotties);
+ newLotties.add(Math.floor(Math.random() * 10000000));
+ return newLotties;
+ });
+ interactCallback?.();
+ }
+
+ function deleteLottie(id) {
+ setLotties((lotties) => {
+ const newLotties = new Set(lotties);
+ newLotties.delete(id);
+ return newLotties;
+ });
+ }
+
+ useImperativeHandle(
+ $ref,
+ () => ({
+ reset() {
+ setCount(0);
+ setSubtitle("선택한 금액이 초기화되었습니다.");
+ },
+ }),
+ [],
+ );
+
+ return (
+
+
+
+ {subtitle}
+
+
+
+
+ {[...lotties].map((id) => (
+ deleteLottie(id)}
+ play={true}
+ loop={false}
+ />
+ ))}
+
+
+
+
+ {count * 10}
+
+ 만원
+
+
+ );
+}
+
+export default SubsidyInteraction;
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/Phone.jsx b/packages/mainPage/src/features/interactions/univasalIsland/Phone.jsx
new file mode 100644
index 00000000..ef54c24e
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/Phone.jsx
@@ -0,0 +1,59 @@
+import style from "./style.module.css";
+
+function Phone({ dynamicStyle, onPointerDown, isSnapped, disabled, $ref, describedBy }) {
+ const staticStyle = `absolute flex justify-center items-center ${style.phone} cursor-pointer touch-none`;
+ const phoneScreenFill = isSnapped ? "fill-green-700" : "fill-neutral-900";
+ const lightningOpacity = isSnapped ? "opacity-100" : "opacity-0";
+
+ return (
+
+ );
+}
+
+export default Phone;
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/assets/chargeMark.svg b/packages/mainPage/src/features/interactions/univasalIsland/assets/chargeMark.svg
new file mode 100644
index 00000000..a10e8f9c
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/assets/chargeMark.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/assets/iphone.svg b/packages/mainPage/src/features/interactions/univasalIsland/assets/iphone.svg
new file mode 100644
index 00000000..f03e7e57
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/assets/iphone.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/assets/seat.png b/packages/mainPage/src/features/interactions/univasalIsland/assets/seat.png
new file mode 100644
index 00000000..1e9fd1eb
Binary files /dev/null and b/packages/mainPage/src/features/interactions/univasalIsland/assets/seat.png differ
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland2.png b/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland2.png
new file mode 100644
index 00000000..5e5135c9
Binary files /dev/null and b/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland2.png differ
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland@1x.png b/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland@1x.png
new file mode 100644
index 00000000..3ce19393
Binary files /dev/null and b/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland@1x.png differ
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland@2x.png b/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland@2x.png
new file mode 100644
index 00000000..7a82758f
Binary files /dev/null and b/packages/mainPage/src/features/interactions/univasalIsland/assets/univasalIsland@2x.png differ
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/index.jsx b/packages/mainPage/src/features/interactions/univasalIsland/index.jsx
new file mode 100644
index 00000000..423e9efc
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/index.jsx
@@ -0,0 +1,90 @@
+import { useImperativeHandle, useId } from "react";
+import InteractionDescription from "../InteractionDescription.jsx";
+import Phone from "./Phone.jsx";
+import useIslandDrag from "./useIslandDrag.js";
+import style from "./style.module.css";
+
+import seat from "./assets/seat.png";
+import univasalIsland1x from "./assets/univasalIsland@1x.png";
+import univasalIsland2x from "./assets/univasalIsland@2x.png";
+import univasalIslandLeg from "./assets/univasalIsland2.png";
+
+function UnivasalIslandInteraction({ interactCallback, $ref, disabled }) {
+ const {
+ islandEventListener,
+ phoneEventListener,
+ islandStyle,
+ phoneStyle,
+ reset,
+ phoneSnapArea,
+ phoneIsSnapping,
+ isDragging,
+ islandRef,
+ phoneRef,
+ subtitle,
+ } = useIslandDrag(!disabled, interactCallback);
+
+ useImperativeHandle($ref, () => ({ reset }), [reset]);
+ const desc = useId();
+ const desc2 = useId();
+
+ const seatHullStyle = `absolute w-[1200px] h-[800px] ${style.hull} flex justify-center items-end select-none`;
+ const univasalIslandStaticStyle = `${style.island} flex flex-col gap-2 cursor-pointer touch-none`;
+ const snapAreaStyle = `absolute scale-50 ${style.snap}`;
+
+ return (
+
+
+
+ {subtitle}
+
+
+ 스페이스바를 눌러서 유니버설 아일랜드를 잡으세요.
+
+
+ 스페이스바를 눌러서 스마트폰을 잡으세요.
+
+
+
+
{
+ islandEventListener.onPointerDown(e);
+ }}
+ >
+
+
+
+
+
+
{
+ phoneEventListener.onPointerDown(e);
+ }}
+ describedBy={desc2}
+ />
+
+
+ );
+}
+
+export default UnivasalIslandInteraction;
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/reducer.js b/packages/mainPage/src/features/interactions/univasalIsland/reducer.js
new file mode 100644
index 00000000..fbf70ff3
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/reducer.js
@@ -0,0 +1,58 @@
+import { clamp } from "@common/utils.js";
+import { PHONE_INITIAL_X, PHONE_INITIAL_Y, canSnapPhoneInKeyboardMode } from "./utils.js";
+
+export function getDefaultState() {
+ return {
+ islandY: 0,
+ phoneX: PHONE_INITIAL_X,
+ phoneY: PHONE_INITIAL_Y,
+ phoneIsSnapping: false,
+ phoneShouldSnapped: false,
+ islandKeyControlled: false,
+ };
+}
+
+function islandReducer(state, action) {
+ switch (action.type) {
+ case "reset-snap":
+ return { ...state, phoneShouldSnapped: false, islandKeyControlled: false };
+ case "grab-key-island":
+ return { ...state, islandKeyControlled: action.value };
+ case "move-island": {
+ const newY = typeof action.mutate === "function" ? action.mutate(state.islandY) : action.y;
+ const islandY = clamp(newY, -50, 50);
+ return {
+ ...state,
+ phoneShouldSnapped: false,
+ islandY,
+ phoneX: state.phoneIsSnapping ? 0 : state.phoneX,
+ phoneY: state.phoneIsSnapping ? islandY : state.phoneY,
+ };
+ }
+ case "move-phone":
+ if (typeof action.mutate === "function") {
+ const { x, y } = action.mutate({ x: state.phoneX, y: state.phoneY });
+ return { ...state, phoneShouldSnapped: false, phoneX: x, phoneY: y };
+ } else return { ...state, phoneX: action.x, phoneY: action.y };
+ case "drop-phone": {
+ let snap = action.isSnapped;
+ if (action.valueSnap) {
+ snap = canSnapPhoneInKeyboardMode(state);
+ }
+ if (snap)
+ return {
+ islandY: state.islandY,
+ phoneX: 0,
+ phoneY: state.islandY,
+ phoneShouldSnapped: true,
+ phoneIsSnapping: true,
+ islandKeyControlled: false,
+ };
+ else return { ...state, phoneIsSnapping: false };
+ }
+ case "reset":
+ return getDefaultState();
+ }
+}
+
+export default islandReducer;
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/style.module.css b/packages/mainPage/src/features/interactions/univasalIsland/style.module.css
new file mode 100644
index 00000000..f6c3fc85
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/style.module.css
@@ -0,0 +1,75 @@
+.hull {
+ bottom: min(calc(100% - 800px), -140px);
+}
+.seat {
+ width: 317.44px;
+ height: 501.88px;
+}
+.island {
+ width: 158.2px;
+ height: 546px;
+}
+.phone {
+ top: 293px;
+ left: 541px;
+ width: 54px;
+ height: 97px;
+}
+.snap {
+ top: 40px;
+ left: 21px;
+ width: 54px;
+ height: 97px;
+}
+
+@media (min-width: 1024px) {
+ .hull {
+ bottom: min(calc(100% - 950px), -240px);
+ }
+ .seat {
+ width: 385.46px;
+ height: 610.64px;
+ }
+ .island {
+ width: 192.1px;
+ height: 663px;
+ }
+ .phone {
+ top: 185px;
+ left: 528px;
+ width: 66px;
+ height: 118px;
+ }
+ .snap {
+ top: 49px;
+ left: 25px;
+ width: 66px;
+ height: 118px;
+ }
+}
+
+@media (min-width: 1280px) {
+ .hull {
+ bottom: min(calc(100% - 1100px), -300px);
+ }
+ .seat {
+ width: 453.48px;
+ height: 718.4px;
+ }
+ .island {
+ width: 226px;
+ height: 780px;
+ }
+ .phone {
+ top: 75px;
+ left: 516px;
+ width: 77px;
+ height: 140px;
+ }
+ .snap {
+ top: 56px;
+ left: 30px;
+ width: 77px;
+ height: 140px;
+ }
+}
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/useIslandDrag.js b/packages/mainPage/src/features/interactions/univasalIsland/useIslandDrag.js
new file mode 100644
index 00000000..034d1cb6
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/useIslandDrag.js
@@ -0,0 +1,227 @@
+import { useState, useReducer, useRef, useMemo, useCallback } from "react";
+import islandReducer, { getDefaultState } from "./reducer.js";
+import { PHONE_INITIAL_X, PHONE_INITIAL_Y, STEP, canSnapPhoneInKeyboardMode } from "./utils.js";
+import useMountDragEvent from "@main/hooks/useMountDragEvent.js";
+import useA11yDrag from "@main/hooks/useA11yDrag.js";
+
+const assistive = {
+ univasal: {
+ grabText: () =>
+ "유니버설 아일랜드를 잡았습니다. 위,아래 방향키로 유니버설 아일랜드의 위치를 이동하세요. 스페이스바로 유니버설 아일랜드를 놓으세요.",
+ moveText: ({ islandY }) => `유니버설 아일랜드를 이동했습니다. (y: ${islandY})`,
+ dropText: () => "유니버설 아일랜드를 놓았습니다.",
+ },
+ phone: {
+ grabText: () =>
+ "스마트폰을 잡았습니다. 방향키로 스마트폰의 위치를 이동하세요. 스페이스바로 스마트폰을 놓으세요",
+ moveText: ({
+ phoneX,
+ phoneY,
+ islandY,
+ }) => `스마트폰을 이동했습니다. (x: ${phoneX}, y: ${phoneY}) 유니버설 아일랜드는 (0, ${islandY})에 있습니다.
+ ${canSnapPhoneInKeyboardMode({ phoneX, phoneY, islandY }) ? "스마트폰을 스냅할 수 있습니다. 스페이스바로 유니버설 아일랜드를 놓아보세요." : ""}`,
+ dropText: ({ phoneIsSnapping }) =>
+ `스마트폰을 놓았습니다. ${phoneIsSnapping ? "스마트폰이 아일랜드에 스냅되었습니다." : "스마트폰이 아일랜드에서 벗어났습니다."}`,
+ },
+};
+
+function aabbCheck(bound1, bound2) {
+ if (bound1.right < bound2.left) return false;
+ if (bound1.left > bound2.right) return false;
+ if (bound1.top > bound2.bottom) return false;
+ if (bound1.bottom < bound2.top) return false;
+ return true;
+}
+
+function useIslandDrag(enabled = true, interactCallback = null) {
+ /**-------------------------------------------------------------------*
+ * *
+ * State - ref : 아일랜드 드래그의 상태입니다. *
+ * *
+ *--------------------------------------------------------------------*/
+
+ // island state
+ const islandStartMouseYPosition = useRef(0);
+ const islandStartPosition = useRef(0);
+
+ // phone state
+ const phoneStartMousePosition = useRef({ x: 0, y: 0 });
+ const phoneStartPosition = useRef({ x: PHONE_INITIAL_X, y: PHONE_INITIAL_Y });
+
+ // reducer
+ const [state, dispatch] = useReducer(islandReducer, null, getDefaultState);
+ const { islandY, phoneX, phoneY, phoneIsSnapping, phoneShouldSnapped, islandKeyControlled } =
+ state;
+
+ // phone snap area
+ const phoneSnapArea = useRef(null);
+
+ // A11y subtitle
+ const [subtitle, setSubtitle] = useState(() => () => "");
+
+ /**-------------------------------------------------------------------*
+ * *
+ * 아일랜드 오브젝트를 드래그 앤 드롭할 때 호출되는 함수입니다. *
+ * *
+ *--------------------------------------------------------------------*/
+
+ // mount island drag event
+ const islandOnDragStart = useCallback(
+ ({ y }) => {
+ dispatch({ type: "reset-snap" });
+ islandStartMouseYPosition.current = y;
+ islandStartPosition.current = islandY;
+ interactCallback?.();
+ },
+ [islandY, interactCallback],
+ );
+ const islandOnDragging = useCallback(function ({ y: mouseY }) {
+ const rawY = mouseY - islandStartMouseYPosition.current + islandStartPosition.current;
+ dispatch({ type: "move-island", y: rawY });
+ }, []);
+ const { onPointerDown: islandOnPointerDown, dragState: islandIsDrag } = useMountDragEvent({
+ onDragStart: islandOnDragStart,
+ onDrag: islandOnDragging,
+ enabled,
+ });
+
+ // a11y island keyboard event
+ const onKeyGrab = useCallback(() => {
+ dispatch({ type: "grab-key-island", value: true });
+ }, []);
+ const onIslandKeyMove = useCallback(
+ (_, y) => {
+ dispatch({ type: "move-island", mutate: (state) => state + y * STEP });
+ interactCallback?.();
+ },
+ [interactCallback],
+ );
+ const onKeyRelease = useCallback(() => {
+ dispatch({ type: "grab-key-island", value: false });
+ }, []);
+ const islandRef = useA11yDrag({
+ ...assistive.univasal,
+ onKeyGrab,
+ onKeyMove: onIslandKeyMove,
+ onKeyRelease,
+ enabled,
+ setSubtitle,
+ });
+
+ /**-------------------------------------------------------------------*
+ * *
+ * 스마트폰 오브젝트를 드래그 앤 드롭할 때 호출되는 함수입니다. *
+ * *
+ *--------------------------------------------------------------------*/
+
+ // mount phone drag event
+ const phoneOnDragStart = useCallback(
+ (position) => {
+ dispatch({ type: "reset-snap" });
+ phoneStartMousePosition.current = position;
+ phoneStartPosition.current = { x: phoneX, y: phoneY };
+ interactCallback?.();
+ },
+ [phoneX, phoneY, interactCallback],
+ );
+ const phoneOnDragging = useCallback(function ({ x: mouseX, y: mouseY }) {
+ const x = mouseX - phoneStartMousePosition.current.x + phoneStartPosition.current.x;
+ const y = mouseY - phoneStartMousePosition.current.y + phoneStartPosition.current.y;
+ dispatch({ type: "move-phone", x, y });
+ }, []);
+ const phoneOnDragEnd = useCallback((e) => {
+ const isSnapped = aabbCheck(
+ e.target.getBoundingClientRect(),
+ phoneSnapArea.current.getBoundingClientRect(),
+ );
+ dispatch({ type: "drop-phone", isSnapped });
+ }, []);
+ const { onPointerDown: phoneOnPointerDown, dragState: phoneIsDrag } = useMountDragEvent({
+ onDragStart: phoneOnDragStart,
+ onDrag: phoneOnDragging,
+ onDragEnd: phoneOnDragEnd,
+ enabled,
+ });
+
+ // a11y phone keyboard event
+ const onPhoneKeyMove = useCallback(
+ (x, y) => {
+ dispatch({
+ type: "move-phone",
+ mutate: (state) => ({ x: state.x + x * STEP, y: state.y + y * STEP }),
+ });
+ interactCallback?.();
+ },
+ [interactCallback],
+ );
+ const onPhoneKeyUp = useCallback(() => {
+ dispatch({ type: "drop-phone", valueSnap: true });
+ }, []);
+ const phoneRef = useA11yDrag({
+ ...assistive.phone,
+ onKeyGrab,
+ onKeyMove: onPhoneKeyMove,
+ onKeyRelease: onPhoneKeyUp,
+ enabled,
+ setSubtitle,
+ });
+
+ /**-------------------------------------------------------------------*
+ * *
+ * 상위 컴포넌트에서 호출할 수 있는 reset 인터페이스입니다. *
+ * *
+ *--------------------------------------------------------------------*/
+
+ // reset function interface
+ const reset = useCallback(() => {
+ islandStartMouseYPosition.current = 0;
+ phoneStartMousePosition.current = { x: 0, y: 0 };
+ islandStartPosition.current = 0;
+ phoneStartPosition.current = { x: PHONE_INITIAL_X, y: PHONE_INITIAL_Y };
+ dispatch({ type: "reset" });
+ }, []);
+
+ /**-------------------------------------------------------------------*
+ * *
+ * style - 아일랜드 오브젝트/스마트폰 오브젝트에 적용할 동적 style입니다. *
+ * *
+ *--------------------------------------------------------------------*/
+
+ // island style
+ const islandStyle = useMemo(
+ () => ({
+ transform: `translateY(${islandY}px)`,
+ transition: !islandIsDrag ? "transform 0.2s" : "none",
+ }),
+ [islandY, islandIsDrag],
+ );
+
+ // phone style은 상당히 많은 state 종속성을 가지고 있으므로 useMemo가 의미가 없음
+ const phoneStyle = {
+ transform: `translate(${phoneX}px, ${phoneY}px)`,
+ transition:
+ !islandKeyControlled && phoneShouldSnapped
+ ? "transform 0.5s"
+ : islandKeyControlled || (!phoneIsSnapping && !phoneIsDrag)
+ ? "transform 0.2s"
+ : "none",
+ };
+
+ return {
+ reset,
+ islandStyle,
+ phoneStyle,
+
+ phoneIsSnapping,
+ islandEventListener: { onPointerDown: islandOnPointerDown },
+ phoneEventListener: { onPointerDown: phoneOnPointerDown },
+ phoneSnapArea,
+ isDragging: islandIsDrag || phoneIsDrag,
+
+ islandRef,
+ phoneRef,
+ subtitle: subtitle({ islandY, phoneX, phoneY, phoneIsSnapping }),
+ };
+}
+
+export default useIslandDrag;
diff --git a/packages/mainPage/src/features/interactions/univasalIsland/utils.js b/packages/mainPage/src/features/interactions/univasalIsland/utils.js
new file mode 100644
index 00000000..71ba9874
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/univasalIsland/utils.js
@@ -0,0 +1,7 @@
+export const PHONE_INITIAL_X = 150;
+export const PHONE_INITIAL_Y = 100;
+export const STEP = 25;
+
+export function canSnapPhoneInKeyboardMode({ islandY, phoneX, phoneY }) {
+ return Math.hypot(islandY - phoneY, phoneX) < 75;
+}
diff --git a/packages/mainPage/src/features/interactions/v2l/Puzzle.jsx b/packages/mainPage/src/features/interactions/v2l/Puzzle.jsx
new file mode 100644
index 00000000..57fe9a7f
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/Puzzle.jsx
@@ -0,0 +1,131 @@
+import { useReducer, useEffect, useImperativeHandle } from "react";
+import reducer from "./businessLogic/reducer.js";
+import {
+ generatePiece,
+ generateAnswer,
+ checkPuzzle,
+ getLinkedPuzzleState,
+} from "./businessLogic/utils.js";
+import { WIDTH, HEIGHT } from "./businessLogic/constants.js";
+import usePuzzleKeyMount from "./businessLogic/usePuzzleKeyMount.js";
+import PuzzlePiece from "./PuzzlePiece.jsx";
+import style from "./style.module.css";
+import car1x from "./assets/car@1x.png";
+import car2x from "./assets/car@2x.png";
+import panContainer1x from "./assets/panContainer@1x.png";
+import panContainer2x from "./assets/panContainer@2x.png";
+import pan from "./assets/pan.svg";
+
+// ─│┌┐┘└
+
+function Puzzle({ interactCallback, $ref, disabled }) {
+ const [state, dispatch] = useReducer(reducer, {
+ answer: generateAnswer(`
+ ─┐.
+ .│.
+ .└─`),
+ piece: generatePiece(`
+ │┘─
+ ─││
+ │┘│`),
+ subtitle: `IONIQ 5의 브이투엘 기능을 홍보하는 길 맞추기 퍼즐 게임입니다. 방향키나 탭 키로 퍼즐 조각을 탐색할 수 있고,
+ 스페이스바로 퍼즐 조각을 눌러서 퍼즐을 오른쪽으로 돌려보세요.
+ 퍼즐은 가로 3칸, 세로 3칸으로 구성되어 있으며, 왼쪽 위부터 1번입니다. 1번 퍼즐이 9번 퍼즐까지 이어지면 게임에서 이길 수 있습니다.`,
+ });
+
+ const { answer, piece, subtitle } = state;
+ useEffect(() => dispatch({ type: "reset", initialized: true }), []);
+ useImperativeHandle(
+ $ref,
+ () => ({ reset: () => dispatch({ type: "reset", initialized: false }) }),
+ [],
+ );
+
+ const isCorrect = checkPuzzle(piece, answer);
+ const [glown] = getLinkedPuzzleState(piece, WIDTH, HEIGHT);
+ const puzzleRef = usePuzzleKeyMount();
+
+ return (
+
+
+ {subtitle}
+
+
+
+
+
+
+
+ {piece.map((shape, i) => {
+ const onClick = () => {
+ if (disabled) return;
+ dispatch({ type: "rotate", index: i });
+ interactCallback?.();
+ };
+ const fixRotate = () => {
+ dispatch({ type: "reconcile-rotate", index: i });
+ };
+ const label = `${i + 1} 번째 퍼즐입니다. ${shape.getLabel()}`;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Puzzle;
diff --git a/packages/mainPage/src/features/interactions/v2l/PuzzlePiece.jsx b/packages/mainPage/src/features/interactions/v2l/PuzzlePiece.jsx
new file mode 100644
index 00000000..28f6fe04
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/PuzzlePiece.jsx
@@ -0,0 +1,42 @@
+import { useState } from "react";
+import { LINEAR } from "./businessLogic/constants.js";
+
+function PuzzlePiece({ shape, onClick, fixRotate, ariaLabel, disabled, $ref, glow }) {
+ const [fixing, setFixing] = useState(false);
+ const style = {
+ transform: `rotate( ${shape.rotate * 90}deg)`,
+ };
+ const staticStyle = `size-28 bg-black rounded-xl border-2 border-white outline-yellow-400
+ transition-transform ease-out ${fixing ? "duration-0" : "duration-500"}
+ ${glow ? "shadow-[0_0px_8px_2px_#97E0FF]" : ""}`;
+
+ return (
+
+ );
+}
+
+export default PuzzlePiece;
diff --git a/packages/mainPage/src/features/interactions/v2l/assets/car@1x.png b/packages/mainPage/src/features/interactions/v2l/assets/car@1x.png
new file mode 100644
index 00000000..a77bd9d6
Binary files /dev/null and b/packages/mainPage/src/features/interactions/v2l/assets/car@1x.png differ
diff --git a/packages/mainPage/src/features/interactions/v2l/assets/car@2x.png b/packages/mainPage/src/features/interactions/v2l/assets/car@2x.png
new file mode 100644
index 00000000..4c5e1cbe
Binary files /dev/null and b/packages/mainPage/src/features/interactions/v2l/assets/car@2x.png differ
diff --git a/packages/mainPage/src/features/interactions/v2l/assets/pan.svg b/packages/mainPage/src/features/interactions/v2l/assets/pan.svg
new file mode 100644
index 00000000..11e59e12
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/assets/pan.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/mainPage/src/features/interactions/v2l/assets/panContainer@1x.png b/packages/mainPage/src/features/interactions/v2l/assets/panContainer@1x.png
new file mode 100644
index 00000000..03161d31
Binary files /dev/null and b/packages/mainPage/src/features/interactions/v2l/assets/panContainer@1x.png differ
diff --git a/packages/mainPage/src/features/interactions/v2l/assets/panContainer@2x.png b/packages/mainPage/src/features/interactions/v2l/assets/panContainer@2x.png
new file mode 100644
index 00000000..37c0d434
Binary files /dev/null and b/packages/mainPage/src/features/interactions/v2l/assets/panContainer@2x.png differ
diff --git a/packages/mainPage/src/features/interactions/v2l/businessLogic/PieceData.js b/packages/mainPage/src/features/interactions/v2l/businessLogic/PieceData.js
new file mode 100644
index 00000000..4f7cbb23
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/businessLogic/PieceData.js
@@ -0,0 +1,88 @@
+import { LINEAR, CURVED, ANY, DOWN, RIGHT, LEFT, UP } from "./constants.js";
+
+// ─│┌┐┘└
+
+class PieceData {
+ constructor(shapeChar) {
+ switch (shapeChar) {
+ case "─":
+ this.type = LINEAR;
+ this.rotate = 0;
+ break;
+ case "│":
+ this.type = LINEAR;
+ this.rotate = 1;
+ break;
+ case "┌":
+ this.type = CURVED;
+ this.rotate = 0;
+ break;
+ case "┐":
+ this.type = CURVED;
+ this.rotate = 1;
+ break;
+ case "┘":
+ this.type = CURVED;
+ this.rotate = 2;
+ break;
+ case "└":
+ this.type = CURVED;
+ this.rotate = 3;
+ break;
+ }
+ this.symbol = shapeChar;
+ }
+ rotated() {
+ const newPiece = new PieceData(this.symbol);
+ newPiece.rotate = this.rotate + 1;
+ return newPiece;
+ }
+ isCorrect(answer) {
+ if (answer === ANY) return true;
+ if (this.type === LINEAR) return this.rotate % 2 === answer;
+ return this.rotate % 4 === answer;
+ }
+ fixedRotated() {
+ const newPiece = new PieceData(this.symbol);
+ newPiece.rotate = this.rotate % 4;
+ return newPiece;
+ }
+ getConnectData() {
+ if (this.type === LINEAR) {
+ if (this.rotate % 2)
+ return UP | DOWN; // │
+ else return LEFT | RIGHT; // ─
+ } else if (this.type === CURVED) {
+ switch (this.rotate % 4) {
+ case 0:
+ return RIGHT | DOWN; //┌
+ case 1:
+ return DOWN | LEFT; //┐
+ case 2:
+ return LEFT | UP; //┘
+ case 3:
+ return UP | RIGHT; //└
+ }
+ } else return 0b0000;
+ }
+ getLabel() {
+ switch (this.getConnectData()) {
+ case UP | DOWN:
+ return "위에서 아래로 이어짐."; // linear, rotate % 2 === 1
+ case LEFT | RIGHT:
+ return "왼쪽에서 오른쪽으로 이어짐."; // linear, rotate % 2 === 0
+ case RIGHT | DOWN:
+ return "오른쪽에서 아래로 이어짐."; // curved, rotate % 4 === 0
+ case DOWN | LEFT:
+ return "왼쪽에서 아래로 이어짐."; // curved, rotate % 4 === 1
+ case LEFT | UP:
+ return "왼쪽에서 위로 이어짐."; // curved, rotate % 4 === 2
+ case UP | RIGHT:
+ return "오른쪽에서 위로 이어짐."; // curved, rotate % 4 === 3
+ default:
+ return "알 수 없는 모양.";
+ }
+ }
+}
+
+export default PieceData;
diff --git a/packages/mainPage/src/features/interactions/v2l/businessLogic/constants.js b/packages/mainPage/src/features/interactions/v2l/businessLogic/constants.js
new file mode 100644
index 00000000..28ec817b
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/businessLogic/constants.js
@@ -0,0 +1,11 @@
+export const LINEAR = Symbol("linear");
+export const CURVED = Symbol("curved");
+export const ANY = Symbol("any");
+
+export const DOWN = 0b0001;
+export const RIGHT = 0b0010;
+export const LEFT = 0b0100;
+export const UP = 0b1000;
+
+export const WIDTH = 3;
+export const HEIGHT = 3;
diff --git a/packages/mainPage/src/features/interactions/v2l/businessLogic/generateRandom.js b/packages/mainPage/src/features/interactions/v2l/businessLogic/generateRandom.js
new file mode 100644
index 00000000..27f535fe
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/businessLogic/generateRandom.js
@@ -0,0 +1,116 @@
+import { generatePiece, generateAnswer } from "./utils.js";
+import { DOWN, RIGHT, LEFT, UP, WIDTH, HEIGHT } from "./constants.js";
+
+const dir = [
+ [1, 0],
+ [0, 1],
+ [-1, 0],
+ [0, -1],
+];
+
+function randInt(min, max) {
+ return Math.floor(Math.random() * (max - min)) + min;
+}
+
+function generateRandomPath(width, height) {
+ let traced = Array.from({ length: width }, () => new Array(height).fill(false));
+ const stack = [];
+ let cursor = [0, 0];
+
+ function getNextCursorHubo([x, y]) {
+ return dir
+ .map(([dx, dy]) => [x + dx, y + dy])
+ .filter(([tx, ty]) => {
+ if (tx < 0 || ty < 0) return false;
+ if (tx >= width || ty >= height) return false;
+ if (traced[tx][ty]) return false;
+ return true;
+ });
+ }
+
+ while (cursor[0] !== width - 1 || cursor[1] !== height - 1) {
+ traced[cursor[0]][cursor[1]] = true;
+ let hubo = getNextCursorHubo(cursor);
+ // backtracking
+ if (hubo.length === 0) {
+ while (stack.length > 0) {
+ let backtrackedCursor = stack[stack.length - 1];
+ hubo = getNextCursorHubo(backtrackedCursor);
+ if (hubo.length !== 0) break;
+ stack.pop();
+ }
+ } else stack.push(cursor);
+ cursor = hubo[randInt(0, hubo.length)];
+ }
+ stack.push(cursor);
+
+ return stack;
+}
+
+function getDirection(base, target) {
+ let dx = target[0] - base[0];
+ let dy = target[1] - base[1];
+
+ if (dx === 0 && dy === 1) return DOWN; // down
+ if (dx === 1 && dy === 0) return RIGHT; // right
+ if (dx === -1 && dy === 0) return LEFT; // left
+ if (dx === 0 && dy === -1) return UP; // up
+ return 0b0000;
+}
+
+// ─│┌┐┘└
+function getShapeChar(before, after) {
+ const code = before | after;
+ switch (code) {
+ case UP | LEFT:
+ return "┘";
+ case UP | RIGHT:
+ return "└";
+ case UP | DOWN:
+ return "│";
+ case LEFT | RIGHT:
+ return "─";
+ case LEFT | DOWN:
+ return "┐";
+ case RIGHT | DOWN:
+ return "┌";
+ default:
+ return ".";
+ }
+}
+
+function generateRandomPuzzle() {
+ const path = generateRandomPath(WIDTH, HEIGHT);
+
+ // path에 대한 길 shape를 생성
+ const shapes = [getShapeChar(LEFT, getDirection(path[0], path[1]))];
+ for (let i = 1; i < path.length - 1; i++) {
+ const before = getDirection(path[i], path[i - 1]);
+ const after = getDirection(path[i], path[i + 1]);
+ shapes.push(getShapeChar(before, after));
+ }
+ shapes.push(getShapeChar(getDirection(path[path.length - 1], path[path.length - 2]), RIGHT));
+
+ // shape 리스트를 3x3 그리드에 맞도록 재배열
+
+ const shapeBoard = new Array(WIDTH * HEIGHT).fill(".");
+ path.forEach(([x, y], i) => {
+ shapeBoard[y * HEIGHT + x] = shapes[i];
+ });
+
+ const answer = generateAnswer(shapeBoard.join(""));
+ const board = generatePiece(
+ shapeBoard
+ .map((c) => {
+ if (c !== ".") return c;
+ if (Math.random() > 0.5) return "─";
+ return "┘";
+ })
+ .join(""),
+ );
+ board.forEach((piece) => (piece.rotate = randInt(0, 4)));
+
+ return [answer, board];
+}
+
+export default generateRandomPuzzle;
diff --git a/packages/mainPage/src/features/interactions/v2l/businessLogic/reducer.js b/packages/mainPage/src/features/interactions/v2l/businessLogic/reducer.js
new file mode 100644
index 00000000..9cfb3e8f
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/businessLogic/reducer.js
@@ -0,0 +1,61 @@
+import generateRandomPuzzle from "./generateRandom.js";
+import { checkPuzzle, getLinkedPuzzleState } from "./utils.js";
+import { WIDTH, HEIGHT } from "./constants.js";
+
+function getLinkSubtitle(linked) {
+ if (linked.length === 0) return "현재 퍼즐 조각이 이어져 있지 않습니다.";
+ else return `현재 퍼즐 조각 (${linked.map((n) => `${n + 1}번`).join(", ")})이 이어져 있습니다.`;
+}
+
+function getAnswerStateSubtitle(prevBoard, newBoard, answer) {
+ const prevIsAnswer = checkPuzzle(prevBoard, answer);
+ const nextIsAnswer = checkPuzzle(newBoard, answer);
+
+ if (prevIsAnswer === nextIsAnswer) return "";
+ if (nextIsAnswer) return "퍼즐이 전부 이어졌습니다!";
+ return "이어졌던 퍼즐이 끊어졌습니다.";
+}
+
+function getSubtitle(prevBoard, newBoard, answer) {
+ const [linked, nextCursor] = getLinkedPuzzleState(newBoard, WIDTH, HEIGHT);
+
+ const linkedSubtitle = getLinkSubtitle(linked);
+ const answerSubtitle = getAnswerStateSubtitle(prevBoard, newBoard, answer);
+
+ if (answerSubtitle === "") {
+ if (nextCursor === -1) return `${linkedSubtitle} 퍼즐이 밖으로 이어졌습니다.`;
+ else return `${linkedSubtitle} 다음 퍼즐 조각은 ${nextCursor + 1}번입니다.`;
+ } else return `${linkedSubtitle} ${answerSubtitle}`;
+}
+
+function puzzleReducer(state, action) {
+ switch (action.type) {
+ case "reset": {
+ const [randAnswer, randPiece] = generateRandomPuzzle();
+ return {
+ answer: randAnswer,
+ piece: randPiece,
+ subtitle: action.initialized ? state.subtitle : "퍼즐이 초기화되었습니다.",
+ };
+ }
+ case "rotate": {
+ const newBoard = [...state.piece];
+ const i = action.index;
+ newBoard[i] = state.piece[i].rotated();
+
+ return {
+ ...state,
+ piece: newBoard,
+ subtitle: `${i + 1}번 퍼즐 조각을 돌렸습니다. ${getSubtitle(state.piece, newBoard, state.answer)}`,
+ };
+ }
+ case "reconcile-rotate": {
+ const newBoard = [...state.piece];
+ const i = action.index;
+ newBoard[i] = state.piece[i].fixedRotated();
+ return { ...state, piece: newBoard };
+ }
+ }
+}
+
+export default puzzleReducer;
diff --git a/packages/mainPage/src/features/interactions/v2l/businessLogic/usePuzzleKeyMount.js b/packages/mainPage/src/features/interactions/v2l/businessLogic/usePuzzleKeyMount.js
new file mode 100644
index 00000000..91f2025b
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/businessLogic/usePuzzleKeyMount.js
@@ -0,0 +1,49 @@
+import { useEffect, useRef } from "react";
+import { WIDTH, HEIGHT } from "./constants.js";
+import { KVMap } from "@common/utils.js";
+
+function usePuzzleKetMount() {
+ const refs = useRef(null);
+
+ function getRefMap() {
+ if (refs.current === null) refs.current = new KVMap();
+ return refs.current;
+ }
+
+ useEffect(() => {
+ function onKeyPress(e) {
+ const map = getRefMap();
+
+ if (!map.hasValue(document.activeElement)) return;
+
+ const key = map.getWithValue(document.activeElement);
+ const x = key % WIDTH;
+ const y = Math.floor(key / WIDTH);
+ if (e.code === "ArrowUp") {
+ if (y - 1 < 0) return;
+ map.getWithKey(key - WIDTH).focus();
+ }
+ if (e.code === "ArrowDown") {
+ if (y + 1 >= HEIGHT) return;
+ map.getWithKey(key + WIDTH).focus();
+ }
+ if (e.code === "ArrowLeft") {
+ if (x - 1 < 0) return;
+ map.getWithKey(key - 1).focus();
+ }
+ if (e.code === "ArrowRight") {
+ if (x + 1 >= WIDTH) return;
+ map.getWithKey(key + 1).focus();
+ }
+ }
+
+ document.addEventListener("keydown", onKeyPress);
+ return () => document.removeEventListener("keydown", onKeyPress);
+ }, []);
+
+ return (i) => (ref) => {
+ getRefMap().set(i, ref);
+ };
+}
+
+export default usePuzzleKetMount;
diff --git a/packages/mainPage/src/features/interactions/v2l/businessLogic/utils.js b/packages/mainPage/src/features/interactions/v2l/businessLogic/utils.js
new file mode 100644
index 00000000..bb888bf8
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/businessLogic/utils.js
@@ -0,0 +1,73 @@
+import { ANY, DOWN, RIGHT, LEFT, UP } from "./constants.js";
+import PieceData from "./PieceData.js";
+
+// ─│┌┐┘└
+
+export function generatePiece(shapeString) {
+ const rawString = [...shapeString.replace(/\s+/gm, "")];
+ return rawString.map((c) => new PieceData(c));
+}
+
+export function generateAnswer(shapeString) {
+ const rawString = [...shapeString.replace(/\s+/gm, "")];
+ return rawString.map((c) => {
+ switch (c) {
+ case "─":
+ return 0;
+ case "│":
+ return 1;
+ case "┌":
+ return 0;
+ case "┐":
+ return 1;
+ case "┘":
+ return 2;
+ case "└":
+ return 3;
+ default:
+ return ANY;
+ }
+ });
+}
+
+export function checkPuzzle(pieces, answer) {
+ return pieces.every((piece, i) => piece.isCorrect(answer[i]));
+}
+
+export function getLinkedPuzzleState(pieces, width, height) {
+ const linked = [];
+ let [x, y] = [0, 0];
+ let prev = LEFT;
+ while (x >= 0 && x < width && y >= 0 && y < height) {
+ // 직전 연결된 상태와 현재 커서가 연결되어 있지 않으면 return
+ const cursor = y * width + x;
+ const connectData = pieces[cursor].getConnectData();
+ if ((prev & connectData) === 0) return [linked, cursor];
+ // 연결되어 있다면 인덱스를 배열에 넣음
+ linked.push(cursor);
+ // 다음 연결된 상태를 가져옴
+ prev = connectData ^ prev;
+ // 다음 연결 상태를 기반으로 커서를 옮기고, 다음 연결 상태를 반전시킴 (이전 커서에서 오른쪽 = 다음 커서에서 왼쪽)
+ switch (prev) {
+ case LEFT:
+ x--;
+ prev = RIGHT;
+ break;
+ case RIGHT:
+ x++;
+ prev = LEFT;
+ break;
+ case UP:
+ y--;
+ prev = DOWN;
+ break;
+ case DOWN:
+ y++;
+ prev = UP;
+ break;
+ default:
+ return [linked, -1];
+ }
+ }
+ return [linked, -1];
+}
diff --git a/packages/mainPage/src/features/interactions/v2l/index.jsx b/packages/mainPage/src/features/interactions/v2l/index.jsx
new file mode 100644
index 00000000..5ba0c60b
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/index.jsx
@@ -0,0 +1,25 @@
+import ClientOnly from "@common/components/ClientOnly.jsx";
+import InteractionDescription from "../InteractionDescription.jsx";
+import Puzzle from "./Puzzle.jsx";
+import style from "./style.module.css";
+//import PuzzleSkeleton from "./PuzzleSkeleton.jsx";
+
+function V2LInteraction({ interactCallback, $ref, disabled }) {
+ return (
+
+
+
+ 스켈레톤 그릴 예정
}>
+
+
+
+
+ );
+}
+
+export default V2LInteraction;
diff --git a/packages/mainPage/src/features/interactions/v2l/style.module.css b/packages/mainPage/src/features/interactions/v2l/style.module.css
new file mode 100644
index 00000000..331e5752
--- /dev/null
+++ b/packages/mainPage/src/features/interactions/v2l/style.module.css
@@ -0,0 +1,32 @@
+.container {
+ top: max(var(--top-area, 11rem), calc(60% - 11.5rem));
+ transform: scale(0.7);
+ --top-area: 11rem;
+}
+
+.rotate {
+ animation: spin 1s linear infinite;
+}
+
+@media (min-width: 768px) {
+ .container {
+ --top-area: 12rem;
+ transform: scale(0.9);
+ }
+}
+
+@media (min-width: 1024px) {
+ .container {
+ --top-area: 15rem;
+ transform: scale(1);
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/packages/mainPage/src/features/introSection/LineHighlight.jsx b/packages/mainPage/src/features/introSection/LineHighlight.jsx
new file mode 100644
index 00000000..9af98c40
--- /dev/null
+++ b/packages/mainPage/src/features/introSection/LineHighlight.jsx
@@ -0,0 +1,37 @@
+import style from "./index.module.css";
+
+function LineHighlight() {
+ return (
+
+ );
+}
+
+export default LineHighlight;
diff --git a/packages/mainPage/src/features/introSection/car-spin-small.webm b/packages/mainPage/src/features/introSection/car-spin-small.webm
new file mode 100644
index 00000000..c7cfb9c6
Binary files /dev/null and b/packages/mainPage/src/features/introSection/car-spin-small.webm differ
diff --git a/packages/mainPage/src/features/introSection/car-spin.webm b/packages/mainPage/src/features/introSection/car-spin.webm
new file mode 100644
index 00000000..3dc41160
Binary files /dev/null and b/packages/mainPage/src/features/introSection/car-spin.webm differ
diff --git a/packages/mainPage/src/features/introSection/index.jsx b/packages/mainPage/src/features/introSection/index.jsx
new file mode 100644
index 00000000..c3de340f
--- /dev/null
+++ b/packages/mainPage/src/features/introSection/index.jsx
@@ -0,0 +1,127 @@
+import { useEffect, useRef, useState } from "react";
+import LineHighlight from "./LineHighlight.jsx";
+import FcfsNotifier from "./notifier";
+
+import useScrollTransition from "@main/hooks/useScrollTransition.js";
+import style from "./index.module.css";
+import SpinningCarVideo from "./car-spin.webm";
+import Pointer from "./pointer.svg";
+
+function IntroSection() {
+ const VIDEO_LENGTH = 2;
+ const videoRef = useRef(null);
+ const introRef = useRef(null);
+ const frameRef = useRef(null);
+ const [isTimerVisible, setIsTimerVisible] = useState(false);
+ const [videoTimeline, setVideoTimeline] = useState(0);
+
+ const titleOpacity = useScrollTransition({
+ scrollStart: 0,
+ scrollEnd: 500,
+ valueStart: 1,
+ valueEnd: 0,
+ });
+
+ function calculateTimeline() {
+ const frameDOM = frameRef.current;
+ if (frameDOM) {
+ const videoHeight = frameDOM.offsetHeight;
+ const videoTop = frameDOM.getBoundingClientRect().top + window.scrollY;
+ const startScroll = videoTop + videoHeight / 2 - window.innerHeight;
+ const endScroll = startScroll + window.innerHeight;
+ const timeline = ((window.scrollY - startScroll) / (endScroll - startScroll)) * VIDEO_LENGTH;
+
+ setVideoTimeline(timeline);
+ }
+ }
+
+ useEffect(() => {
+ const introDOM = introRef.current;
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setIsTimerVisible(false);
+ } else {
+ setIsTimerVisible(true);
+ }
+ });
+ });
+
+ if (introDOM) {
+ observer.observe(introDOM);
+ }
+
+ return () => {
+ if (introDOM) {
+ observer.unobserve(introDOM);
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ window.addEventListener("scroll", calculateTimeline);
+ window.addEventListener("resize", calculateTimeline);
+ return () => {
+ window.removeEventListener("scroll", calculateTimeline);
+ window.removeEventListener("resize", calculateTimeline);
+ };
+ }, []);
+
+ useEffect(() => {
+ videoRef.current.currentTime = videoTimeline;
+ }, [videoTimeline]);
+
+ return (
+ <>
+
+
+
+
+ The new
+ IONIQ 5
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 더뉴 아이오닉5 신차 출시 이벤트
+
+
+ 09/09 (mon) - 09/13 (fri)
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export default IntroSection;
diff --git a/packages/mainPage/src/features/introSection/index.module.css b/packages/mainPage/src/features/introSection/index.module.css
new file mode 100644
index 00000000..ea4b6827
--- /dev/null
+++ b/packages/mainPage/src/features/introSection/index.module.css
@@ -0,0 +1,29 @@
+.openTitle {
+ animation: open-title 0.5s ease-in-out;
+}
+
+@keyframes open-title {
+ from {
+ transform: translateY(80px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0px);
+ opacity: 1;
+ }
+}
+
+.openVector {
+ stroke-dasharray: 790px;
+ stroke-dashoffset: 790px;
+ animation: open-vector 0.3s ease-in-out 0.3s forwards;
+}
+
+@keyframes open-vector {
+ from {
+ stroke-dashoffset: 790px;
+ }
+ to {
+ stroke-dashoffset: 0px;
+ }
+}
diff --git a/packages/mainPage/src/features/introSection/notifier/FcfsNotifierCountdown.jsx b/packages/mainPage/src/features/introSection/notifier/FcfsNotifierCountdown.jsx
new file mode 100644
index 00000000..32a50cc8
--- /dev/null
+++ b/packages/mainPage/src/features/introSection/notifier/FcfsNotifierCountdown.jsx
@@ -0,0 +1,17 @@
+import useEventStore from "@main/realtimeEvent/store.js";
+import { convertSecondsToString } from "@common/utils.js";
+
+function FcfsNotifierCountdown() {
+ const countdown = useEventStore((store) => store.countdown);
+
+ return (
+ <>
+
선착순 이벤트까지
+
+ {convertSecondsToString(countdown)}
+
+ >
+ );
+}
+
+export default FcfsNotifierCountdown;
diff --git a/packages/mainPage/src/features/introSection/notifier/index.jsx b/packages/mainPage/src/features/introSection/notifier/index.jsx
new file mode 100644
index 00000000..45f080a7
--- /dev/null
+++ b/packages/mainPage/src/features/introSection/notifier/index.jsx
@@ -0,0 +1,35 @@
+import FcfsNotifierCountdown from "./FcfsNotifierCountdown.jsx";
+import scrollTo from "@main/scroll/scrollTo.js";
+import { FCFS_SECTION } from "@main/scroll/constants.js";
+
+import useEventStore from "@main/realtimeEvent/store.js";
+import { PROGRESS, OFFLINE } from "@main/realtimeEvent/constants.js";
+
+function FcfsNotifier({ visible }) {
+ const eventStatus = useEventStore((store) => store.eventStatus);
+
+ function onClick() {
+ scrollTo(FCFS_SECTION);
+ }
+
+ if (eventStatus === OFFLINE) return null;
+
+ return (
+
+ );
+}
+
+export default FcfsNotifier;
diff --git a/packages/mainPage/src/features/introSection/pointer.svg b/packages/mainPage/src/features/introSection/pointer.svg
new file mode 100644
index 00000000..976c6368
--- /dev/null
+++ b/packages/mainPage/src/features/introSection/pointer.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/packages/mainPage/src/features/qna/QnAArticle.jsx b/packages/mainPage/src/features/qna/QnAArticle.jsx
new file mode 100644
index 00000000..ff603d55
--- /dev/null
+++ b/packages/mainPage/src/features/qna/QnAArticle.jsx
@@ -0,0 +1,50 @@
+import { useState, useRef } from "react";
+import arrow from "./assets/arrow.svg";
+
+function QnAArticle({ question, answer }) {
+ const [opened, setOpened] = useState(false);
+ const [visible, setVisible] = useState(false);
+ const timeoutRef = useRef(null);
+
+ function onClick() {
+ if (!opened) {
+ setVisible(true);
+ clearTimeout(timeoutRef.current);
+ requestAnimationFrame(() => setOpened(true));
+ } else {
+ setOpened(false);
+ timeoutRef.current = setTimeout(() => setVisible(false), 200);
+ }
+ }
+ const staticArticleStyle = `text-neutral-400 text-justify font-regular relative
+ text-body-s md:text-body-m lg:text-body-l
+ before:w-full before:h-full before-block before:absolute before:bg-white before:pointer-events-none
+ before:scale-y-0 before:transition-transform before:duration-200 before:ease-linear before:origin-bottom`;
+ const staticIconStyle = "size-[1.8181818em] transition-transform ease-in-out-cubic select-none";
+
+ return (
+
+
+
+ {answer}
+
+
+ );
+}
+
+export default QnAArticle;
diff --git a/packages/mainPage/src/features/qna/assets/arrow.svg b/packages/mainPage/src/features/qna/assets/arrow.svg
new file mode 100644
index 00000000..e32b7f02
--- /dev/null
+++ b/packages/mainPage/src/features/qna/assets/arrow.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/mainPage/src/features/qna/content.json b/packages/mainPage/src/features/qna/content.json
new file mode 100644
index 00000000..887cd8be
--- /dev/null
+++ b/packages/mainPage/src/features/qna/content.json
@@ -0,0 +1,30 @@
+[
+ {
+ "question": "이벤트는 하나만 참여할 수 있나요?",
+ "answer": "EVENT 1과 EVENT 2는 중복 참여가 가능합니다. 다만 이벤트와 무관하거나 부적절한 방법을 통한 참여는 모두 무효 처리될 수 있습니다."
+ },
+ {
+ "question": "이벤트 참여 시 꼭 본인인증을 해야 하나요?",
+ "answer": "모든 이벤트는 참여자 정보를 등록한 이후에만 응모할 수 있습니다. 당첨자 발표 및 경품 수령 방법은 당첨자에 한해 이벤트 종료 후 입력하신 연락처로 개별 안내될 예정입니다. 본 이벤트를 위하여 수집된 개인 정보는 상품 지급 외 다른 용도로 사용되지 않으며 지급 후 폐기됩니다."
+ },
+ {
+ "question": "이벤트 경품이 변경될 수도 있나요?",
+ "answer": "경품 내용 및 당첨 인원은 사전 고지 없이 변경될 수 있습니다. 실제 지급되는 경품은 세부 조건이 상기 이미지와 상이할 수 있습니다. 경품에 대한 제세공과금은 주최사에서 부담합니다. 또한 EVENT1의 1,2등 경품의 경우 미성년자는 수령이 불가한 점 참고 바랍니다."
+ },
+ {
+ "question": "추첨 이벤트는 어떻게 응모할 수 있나요?",
+ "answer": "EVENT 1은 1일 1회만 응모가 가능하며 이벤트 기간동안 최대 5회까지 응모가 가능합니다. 이벤트 참여시 당일 인터랙션을 수행하고 참여자 정보를 입력해야 해당 날짜의 이벤트의 응모가 정상적으로 완료됩니다. 인터랙션을 수행하더라도 지난 날짜의 이벤트 응모는 불가합니다."
+ },
+ {
+ "question": "당첨 확률을 높일 수 있는 방법이 있나요?",
+ "answer": "응모 횟수가 많을수록 해당 이벤트 당첨 확률이 증가합니다. 또한 응모를 완료한 참여자에 한해 기대평 작성을 완료하면 해당 이벤트 당첨 확률이 증가합니다."
+ },
+ {
+ "question": "선착순 이벤트는 몇 번까지 응모할 수 있나요?",
+ "answer": "EVENT 2는 이벤트 기간동안 중복 참여가 가능하지만, 당일 당첨(상품 수령) 횟수는 1일 최대 1회만 가능합니다. 또한 해당 이벤트 참여시 사전에 반드시 참여자 정보 등록이 완료되어야 하는 점 유의 바랍니다."
+ },
+ {
+ "question": "기대평은 어떻게 작성하나요?",
+ "answer": "기대평은 1일 1회만 작성이 가능하며 당일 추첨 이벤트에 응모를 완료해야만 작성할 수 있습니다. 작성 시아래의 사항을 유의해주세요.\n1. 상품 또는 업체 홍보 목적 글, 직접 작성하지 않은 기대평은 기대평 작성 완료로 인정되지 않을 수 있습니다.\n2. 동일한 문자, 문구 등을 단순히 반복한 기대평, 복사/붙여넣기 또는 자동화된 수단을 사용한 기대평 등 이벤트 취지에 맞지 않는 기대평은 기대평 작성 완료로 인정되지 않을 수 있습니다.\n3. 비속어, 금칙어 등이 포함된 부적절한 내용의 기대평은 작성이 불가합니다."
+ }
+]
diff --git a/packages/mainPage/src/features/qna/index.jsx b/packages/mainPage/src/features/qna/index.jsx
new file mode 100644
index 00000000..1af6ec88
--- /dev/null
+++ b/packages/mainPage/src/features/qna/index.jsx
@@ -0,0 +1,15 @@
+import QnAArticle from "./QnAArticle.jsx";
+import content from "./content.json";
+
+function QnASection() {
+ return (
+
+ 자주 묻는 질문
+ {content.map((item) => (
+
+ ))}
+
+ );
+}
+
+export default QnASection;
diff --git a/packages/mainPage/src/features/simpleInformation/content.json b/packages/mainPage/src/features/simpleInformation/content.json
new file mode 100644
index 00000000..720278c5
--- /dev/null
+++ b/packages/mainPage/src/features/simpleInformation/content.json
@@ -0,0 +1,34 @@
+{
+ "content": [
+ {
+ "src": "images/carimg1.png",
+ "title": "독창적인 디자인",
+ "desc": "아이오닉 브랜드를 상징하는\n**파라메트릭 픽셀 램프 디자인**으로\n유니크한 이미지를 구현합니다.",
+ "sub": "프로젝션 타입과 MFR 타입 중 선택 가능"
+ },
+ {
+ "src": "images/carimg2.png",
+ "title": "전용 전기차 플랫폼(E-GMP)",
+ "desc": "**새로워진 전기차 플랫폼 E-GMP**는\n알루미늄 압출재를 이용해\n구조적 안정성을 높였습니다.",
+ "sub": "연출된 이미지로 실제 작동 사양과 다를 수 있음"
+ },
+ {
+ "src": "images/carimg3.png",
+ "title": "인터랙티브 픽셀 라이트",
+ "desc": "**신규 디자인의 스티어링 휠**로\n아이오닉5만의 차별화된\n주행 경험을 제공합니다.",
+ "sub": "시동 / 충전 중 / 후진 중 표시 가능"
+ },
+ {
+ "src": "images/carimg4.png",
+ "title": "증강현실 내비게이션",
+ "desc": "주행에 필요한 각종 정보들을\n**증강현실 기술**을 통해\n직관적으로 제공합니다.",
+ "sub": "인포테인먼트 시스템 화면 이미지는 업데이트에 따라 변동 가능"
+ },
+ {
+ "src": "images/carimg5.png",
+ "title": "디지털 사이드 미러",
+ "desc": "보다 슬림해진 **디지털 사이드 미러**는\n카메라와 **OLED 모니터**를 통해\n선명한 후방 시야를 제공합니다.",
+ "sub": "모니터는 내부 운전석에 위치"
+ }
+ ]
+}
diff --git a/packages/mainPage/src/features/simpleInformation/contentSection.jsx b/packages/mainPage/src/features/simpleInformation/contentSection.jsx
new file mode 100644
index 00000000..27612af6
--- /dev/null
+++ b/packages/mainPage/src/features/simpleInformation/contentSection.jsx
@@ -0,0 +1,65 @@
+import { useEffect, useRef, useState } from "react";
+import makeHighlight from "@main/makeHighlight.jsx";
+import style from "./contentSection.module.css";
+
+export default function ContentSection({ content }) {
+ const contentRef = useRef(null);
+ const [isVisible, setIsVisible] = useState(false);
+ const [isHighlighted, setIsHighlighted] = useState(false);
+
+ useEffect(() => {
+ const contentDOM = contentRef.current;
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true);
+ observer.unobserve(entry.target); // 애니메이션 실행 후 옵저버 중지
+ }
+ });
+ },
+ {
+ threshold: 0.8, // 80%가 보일 때 실행
+ },
+ );
+
+ if (contentDOM) {
+ observer.observe(contentDOM);
+ }
+
+ // 클린업 함수
+ return () => {
+ if (contentDOM) {
+ observer.unobserve(contentDOM);
+ }
+ };
+ }, []);
+
+ const highlightDynamicStyle = {
+ "--progress": isHighlighted ? "100%" : "0%",
+ };
+
+ return (
+
setIsHighlighted(true)}
+ className={`${isVisible ? style.fadeIn : "opacity-0"} z-0 flex flex-col font-bold`}
+ >
+
+
+
{content.title}
+
+
+
+ {makeHighlight(content.desc, style.highlightAnim)}
+
+
+
{content.sub}
+
+
+ );
+}
diff --git a/packages/mainPage/src/features/simpleInformation/contentSection.module.css b/packages/mainPage/src/features/simpleInformation/contentSection.module.css
new file mode 100644
index 00000000..a74692ae
--- /dev/null
+++ b/packages/mainPage/src/features/simpleInformation/contentSection.module.css
@@ -0,0 +1,30 @@
+.fadeIn {
+ animation: fade-in 0.4s ease-out forwards;
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.highlightAnim {
+ display: inline-block;
+ position: relative;
+}
+
+.highlightAnim::before {
+ content: "";
+ position: absolute;
+ display: block;
+ width: 100%;
+ height: 1.4em;
+ background: linear-gradient(90deg, #3ed7be, #069af8);
+ opacity: 0.3;
+ z-index: -1;
+ clip-path: polygon(0 0, 0 100%, var(--progress, 0) 100%, var(--progress, 0) 0);
+ transition: clip-path 0.4s;
+}
diff --git a/packages/mainPage/src/features/simpleInformation/index.jsx b/packages/mainPage/src/features/simpleInformation/index.jsx
new file mode 100644
index 00000000..2bb3a51a
--- /dev/null
+++ b/packages/mainPage/src/features/simpleInformation/index.jsx
@@ -0,0 +1,27 @@
+import { useRef } from "react";
+import ContentSection from "./contentSection.jsx";
+import useSectionInitialize from "@main/scroll/useSectionInitialize.js";
+import { OTHER_SECTION } from "@main/scroll/constants.js";
+import JSONData from "./content.json";
+
+export default function SimpleInformation() {
+ const sectionRef = useRef(null);
+ const contentList = JSONData.content;
+ useSectionInitialize(OTHER_SECTION, sectionRef);
+
+ return (
+
+
+
+ 내가 선택한 단 하나의 전기차
+
+ The new IONIQ 5
+
+
+ {contentList.map((content, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/mainPage/src/index.css b/packages/mainPage/src/index.css
new file mode 100644
index 00000000..f5637092
--- /dev/null
+++ b/packages/mainPage/src/index.css
@@ -0,0 +1,54 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@font-face {
+ font-family: "ds-digital";
+ src: url("/font/DS-DIGI.TTF") format("truetype");
+ font-display: swap;
+}
+
+@layer base {
+ body {
+ font-family: "hdsans", sans-serif;
+ }
+ body.scrollLocked {
+ position: fixed;
+ width: 100%;
+ overflow-y: scroll;
+ }
+}
+
+@layer components {
+ .graphic-gradient {
+ @apply bg-gradient-to-r from-[#3ED7BE] to-[#069AF8];
+ }
+ .sketch-line {
+ position: relative;
+ }
+ .sketch-line::after {
+ content: "";
+ background: url(/sketchLine.svg);
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+ position: absolute;
+ width: 3.6em;
+ height: 1em;
+ bottom: -0.75em;
+ left: calc(50% - 1.8em);
+ }
+ .assistive-text {
+ /* background-color: #24adaf;
+ color: white;
+ padding: 4px;*/
+ position: absolute;
+ margin: -1px;
+ border: 0;
+ padding: 0;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ }
+}
diff --git a/packages/mainPage/src/main-client.jsx b/packages/mainPage/src/main-client.jsx
new file mode 100644
index 00000000..7fb0898c
--- /dev/null
+++ b/packages/mainPage/src/main-client.jsx
@@ -0,0 +1,30 @@
+import { StrictMode } from "react";
+import { createRoot, hydrateRoot } from "react-dom/client";
+import { register } from "swiper/element/bundle";
+import App from "./App.jsx";
+import "./index.css";
+
+register();
+const $root = document.getElementById("root");
+const app = (
+
+
+
+);
+
+if (import.meta.env.DEV) {
+ // 개발 시
+ const enableMocking = async function () {
+ // 실서버와 연동시 //return;의 주석 지워서 테스트해주세요
+ // return;
+ const worker = (await import("./mock.js")).default;
+ await worker.start({ onUnhandledRequest: "bypass" });
+ };
+ enableMocking().then(() => {
+ const root = createRoot($root);
+ root.render(app);
+ });
+} else {
+ // 배포 시
+ hydrateRoot($root, app);
+}
diff --git a/packages/mainPage/src/main-server.jsx b/packages/mainPage/src/main-server.jsx
new file mode 100644
index 00000000..b085d3ff
--- /dev/null
+++ b/packages/mainPage/src/main-server.jsx
@@ -0,0 +1,25 @@
+import { StrictMode } from "react";
+import { renderToString } from "react-dom/server";
+import App from "./App.jsx";
+
+export default function render() {
+ return renderToString(
+
+
+ ,
+ );
+}
+
+/**
+ * 우리의 메인 컴포넌트를 문자열로 렌더링하는 함수를 반환합니다.
+ *
+ * 향후 페이지가 추가된다면,
+ *
+ * import SecondPage from "./SecondPage.jsx";
+ *
+ * export default function render(url) {
+ * // 여기에서 url에 따라 분기처리를 하면 됩니다.
+ * }
+ *
+ * 현재로서는 단일 페이지이므로 render 함수 내에 분기처리를 하지 않습니다.
+ */
diff --git a/packages/mainPage/src/mock.js b/packages/mainPage/src/mock.js
new file mode 100644
index 00000000..7c6e392e
--- /dev/null
+++ b/packages/mainPage/src/mock.js
@@ -0,0 +1,15 @@
+import { setupWorker } from "msw/browser";
+import commentHandler from "./features/comment/mock.js";
+import authHandler from "./shared/auth/mock.js";
+import fcfsHandler from "./features/fcfs/mock.js";
+import interactionHandler from "./features/interactions/mock.js";
+
+// mocking은 기본적으로 각 feature 폴더 내의 mock.js로 정의합니다.
+// 새로운 feature의 mocking을 추가하셨으면, mock.js의 setupWorker 내부 함수에 인자를 spread 연산자를 이용해 추가해주세요.
+// 예시 : export default setupWorker(...authHandler, ...questionHandler, ...articleHandler);
+export default setupWorker(
+ ...commentHandler,
+ ...authHandler,
+ ...fcfsHandler,
+ ...interactionHandler,
+);
diff --git a/packages/mainPage/src/shared/auth/AuthButton.jsx b/packages/mainPage/src/shared/auth/AuthButton.jsx
new file mode 100644
index 00000000..846a5273
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/AuthButton.jsx
@@ -0,0 +1,43 @@
+import openModal from "@common/modal/openModal.js";
+import AuthModal from "./AuthModal.jsx";
+import WelcomeModal from "./Welcome";
+import LogoutModal from "./Logout/LogoutConfirmModal.jsx";
+import useAuthStore from "./store.js";
+import useDrawEventStore from "@main/drawEvent/store.js";
+
+function AuthButton() {
+ const isLogin = useAuthStore((store) => store.isLogin);
+ const userName = useAuthStore((store) => store.userName);
+ const setCurrentJoin = useDrawEventStore((store) => store.setCurrentJoin);
+
+ const welcomeModal =
;
+ const authModal = (
+
isFreshMember && openModal(welcomeModal)} />
+ );
+ const logoutModal = setCurrentJoin(false)} />;
+
+ if (isLogin)
+ return (
+
+ );
+
+ return (
+
+ );
+}
+
+export default AuthButton;
diff --git a/packages/mainPage/src/shared/auth/AuthCode/InputWithTimer.jsx b/packages/mainPage/src/shared/auth/AuthCode/InputWithTimer.jsx
new file mode 100644
index 00000000..b051efa0
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/AuthCode/InputWithTimer.jsx
@@ -0,0 +1,19 @@
+import Input from "@common/components/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 (
+
+
+
+ {minute}:{seconds.toString().padStart(2, "0")}
+
+
+ );
+}
+
+export default InputWithTimer;
diff --git a/packages/mainPage/src/shared/auth/AuthCode/index.jsx b/packages/mainPage/src/shared/auth/AuthCode/index.jsx
new file mode 100644
index 00000000..44601ee4
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/AuthCode/index.jsx
@@ -0,0 +1,80 @@
+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/components/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((token) => {
+ setErrorMessage("");
+ onComplete(token, true);
+ })
+ .catch((error) => {
+ setErrorMessage(error.message);
+ });
+ }
+
+ const josa = "013678".includes(phone[phone.length - 1]) ? "으" : "";
+ return (
+
+
+ {phone}
+ {josa}로
+ 인증번호를 전송했어요.
+
+
+
+ );
+}
+
+export default AuthSecondSection;
diff --git a/packages/mainPage/src/shared/auth/AuthCode/submitAuthCode.js b/packages/mainPage/src/shared/auth/AuthCode/submitAuthCode.js
new file mode 100644
index 00000000..95ef6c0b
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/AuthCode/submitAuthCode.js
@@ -0,0 +1,24 @@
+import { fetchServer, handleError } from "@common/dataFetch/fetchServer.js";
+import { EVENT_ID } from "@common/constants.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,
+ });
+ return token;
+ } catch (e) {
+ return handleError({
+ 400: "잘못된 요청 형식입니다.",
+ 401: "인증번호가 틀렸습니다. 다시 입력하세요.",
+ })(e);
+ }
+}
+
+export default submitAuthCode;
diff --git a/packages/mainPage/src/shared/auth/AuthCode/useTimer.js b/packages/mainPage/src/shared/auth/AuthCode/useTimer.js
new file mode 100644
index 00000000..c2b0c497
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/AuthCode/useTimer.js
@@ -0,0 +1,32 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+import IntervalController from "../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/packages/mainPage/src/shared/auth/AuthModal.jsx b/packages/mainPage/src/shared/auth/AuthModal.jsx
new file mode 100644
index 00000000..84cad166
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/AuthModal.jsx
@@ -0,0 +1,57 @@
+import { useState, useContext } from "react";
+import InfoInputStage from "./InfoInput";
+import AuthCodeStage from "./AuthCode";
+import UserFindStage from "./UserFind";
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import { login } from "./store.js";
+
+const AUTH_INPUT_PAGE = Symbol("input");
+const AUTH_CODE_PAGE = Symbol("code");
+const AUTH_FIND_PAGE = Symbol("find");
+
+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(token, isFreshMember) {
+ login(token);
+ 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 containerClass = `w-[calc(100%-1rem)] max-w-[31.25rem] shadow bg-white relative flex flex-col gap-14`;
+
+ return (
+
+ {page === AUTH_INPUT_PAGE &&
}
+ {page === AUTH_CODE_PAGE &&
}
+ {page === AUTH_FIND_PAGE &&
}
+
+
+ );
+}
+
+export default AuthModal;
diff --git a/packages/mainPage/src/shared/auth/InfoInput/index.jsx b/packages/mainPage/src/shared/auth/InfoInput/index.jsx
new file mode 100644
index 00000000..66b6db06
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/InfoInput/index.jsx
@@ -0,0 +1,77 @@
+import { useState } from "react";
+import requestAuthCode from "../requestAuthCode.js";
+import Input from "@common/components/Input.jsx";
+import PhoneInput from "@common/components/PhoneInput.jsx";
+import Button from "@common/components/Button.jsx";
+import Checkbox from "@common/components/Checkbox.jsx";
+
+function AuthFirstSection({ name, setName, phone, setPhone, goNext, goFindUser }) {
+ const [errorMessage, setErrorMessage] = useState("");
+
+ function onSubmit(e) {
+ e.preventDefault();
+ requestAuthCode(name, phone)
+ .then(() => goNext())
+ .catch((error) => setErrorMessage(error.message));
+ }
+
+ return (
+
+
+ 이벤트 응모를 위해
+
+ 간단한 정보를 입력해주세요!
+
+
+
+ );
+}
+
+export default AuthFirstSection;
diff --git a/packages/mainPage/src/shared/auth/IntervalController.js b/packages/mainPage/src/shared/auth/IntervalController.js
new file mode 100644
index 00000000..d1a1f576
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/IntervalController.js
@@ -0,0 +1,52 @@
+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/packages/mainPage/src/shared/auth/Logout/LogoutAlertModal.jsx b/packages/mainPage/src/shared/auth/Logout/LogoutAlertModal.jsx
new file mode 100644
index 00000000..09cd2ead
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/Logout/LogoutAlertModal.jsx
@@ -0,0 +1,20 @@
+import { useContext } from "react";
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import AlertModalContainer from "@main/components/AlertModalContainer.jsx";
+import Button from "@common/components/Button.jsx";
+
+function LogoutAlertModal() {
+ const close = useContext(ModalCloseContext);
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default LogoutAlertModal;
diff --git a/packages/mainPage/src/shared/auth/Logout/LogoutConfirmModal.jsx b/packages/mainPage/src/shared/auth/Logout/LogoutConfirmModal.jsx
new file mode 100644
index 00000000..a5de5b2c
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/Logout/LogoutConfirmModal.jsx
@@ -0,0 +1,32 @@
+import { useContext } from "react";
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import openModal from "@common/modal/openModal.js";
+import AlertModalContainer from "@main/components/AlertModalContainer.jsx";
+import Button from "@common/components/Button.jsx";
+
+import { logout } from "@main/auth/store.js";
+import LogoutAlertModal from "./LogoutAlertModal.jsx";
+
+function LogoutConfirmModal({ onLogout }) {
+ const close = useContext(ModalCloseContext);
+ function clickLogout() {
+ logout();
+ onLogout?.();
+ openModal();
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+export default LogoutConfirmModal;
diff --git a/packages/mainPage/src/shared/auth/UserFind/index.jsx b/packages/mainPage/src/shared/auth/UserFind/index.jsx
new file mode 100644
index 00000000..a24dfd27
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/UserFind/index.jsx
@@ -0,0 +1,61 @@
+import { useState } from "react";
+import requestLogin from "./requestLogin.js";
+import Input from "@common/components/Input.jsx";
+import PhoneInput from "@common/components/PhoneInput.jsx";
+import Button from "@common/components/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((token) => {
+ setErrorMessage("");
+ onComplete(token, false);
+ })
+ .catch((error) => setErrorMessage(error.message));
+ }
+
+ return (
+
+
+ 등록했던 정보를
+
+ 다시 한 번 입력해주세요!
+
+
+
+ );
+}
+
+export default AuthFindSection;
diff --git a/packages/mainPage/src/shared/auth/UserFind/requestLogin.js b/packages/mainPage/src/shared/auth/UserFind/requestLogin.js
new file mode 100644
index 00000000..7fc7ca2f
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/UserFind/requestLogin.js
@@ -0,0 +1,20 @@
+import { fetchServer, handleError } from "@common/dataFetch/fetchServer.js";
+import { EVENT_ID } from "@common/constants";
+
+async function requestLogin(name, phoneNumber) {
+ try {
+ const body = { name, phoneNumber: phoneNumber.replace(/\D+/g, "") };
+ const { token } = await fetchServer(`/api/v1/event-user/login/${EVENT_ID}`, {
+ method: "post",
+ body,
+ });
+ return token;
+ } catch (e) {
+ return handleError({
+ 400: "잘못된 요청 형식입니다.",
+ 404: "등록된 참여자 정보가 없습니다.",
+ })(e);
+ }
+}
+
+export default requestLogin;
diff --git a/packages/mainPage/src/shared/auth/Welcome/index.jsx b/packages/mainPage/src/shared/auth/Welcome/index.jsx
new file mode 100644
index 00000000..a2897cfd
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/Welcome/index.jsx
@@ -0,0 +1,41 @@
+import { useContext, useId } from "react";
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import Button from "@common/components/Button.jsx";
+
+function WelcomeModal() {
+ const close = useContext(ModalCloseContext);
+ const id = useId();
+
+ return (
+
+
+ 정보가
+
+ 등록되었습니다!
+
+
+
+
+
+
+
+ );
+}
+
+export default WelcomeModal;
diff --git a/packages/mainPage/src/shared/auth/mock.js b/packages/mainPage/src/shared/auth/mock.js
new file mode 100644
index 00000000..46f4563b
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/mock.js
@@ -0,0 +1,42 @@
+import { http, HttpResponse } from "msw";
+
+function isValidInput(name, phoneNumber) {
+ return name.length >= 2 && phoneNumber.length < 12 && phoneNumber.startsWith("01");
+}
+
+const token =
+ "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZWFtLW9yYW5nZSIsImlhdCI6MTcyNDA0NDc5MCwiZXhwIjoxNzI0MDQ4MzkwLCJzdWIiOiJldmVudFVzZXIiLCJ1c2VyTmFtZSI6Iuq5gOyCoeu6qSIsInVzZXJJZCI6ImtpbXBpcHB5YXAiLCJyb2xlIjoiZXZlbnRfdXNlciJ9.m5m_PkwmYz5Mt-kjn28435bQtwgph3WO-2J42X82lCg";
+
+const handlers = [
+ http.post("/api/v1/event-user/send-auth/:eventFrameId", async ({ request }) => {
+ const { name, phoneNumber } = await request.json();
+ if (phoneNumber === "01019991999")
+ return HttpResponse.json({ error: "중복된 사용자가 있음" }, { status: 409 });
+ if (!isValidInput(name, phoneNumber))
+ return HttpResponse.json({ error: "응답 내용이 잘못됨" }, { status: 400 });
+
+ return new HttpResponse();
+ }),
+
+ 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 < 500000 === false)
+ return HttpResponse.json({ error: "인증번호 일치 안 함" }, { status: 401 });
+ return HttpResponse.json({ token });
+ }),
+
+ http.post("/api/v1/event-user/login/:eventFrameId", async ({ request }) => {
+ const { name, phoneNumber } = await request.json();
+
+ if (!isValidInput(name, phoneNumber))
+ return HttpResponse.json({ error: "응답 내용이 잘못됨" }, { status: 400 });
+ if (name !== "오렌지" || phoneNumber !== "01019991999")
+ return HttpResponse.json({ error: "사용자 없음" }, { status: 404 });
+ return HttpResponse.json({ token });
+ }),
+];
+
+export default handlers;
diff --git a/packages/mainPage/src/shared/auth/requestAuthCode.js b/packages/mainPage/src/shared/auth/requestAuthCode.js
new file mode 100644
index 00000000..ed234a8d
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/requestAuthCode.js
@@ -0,0 +1,20 @@
+import { fetchServer, handleError } from "@common/dataFetch/fetchServer.js";
+import { EVENT_ID } from "@common/constants";
+
+async function requestAuthCode(name, phoneNumber) {
+ try {
+ const body = { name, phoneNumber: phoneNumber.replace(/\D+/g, "") };
+ await fetchServer(`/api/v1/event-user/send-auth/${EVENT_ID}`, {
+ method: "post",
+ body,
+ });
+ return "";
+ } catch (e) {
+ return handleError({
+ 400: "잘못된 요청 형식입니다.",
+ 409: "이미 등록된 전화번호가 존재합니다. 하단의 '이미 정보를 입력하신 적이 있으신가요?'를 클릭하세요.",
+ })(e);
+ }
+}
+
+export default requestAuthCode;
diff --git a/packages/mainPage/src/shared/auth/store.js b/packages/mainPage/src/shared/auth/store.js
new file mode 100644
index 00000000..04d6ff24
--- /dev/null
+++ b/packages/mainPage/src/shared/auth/store.js
@@ -0,0 +1,77 @@
+import { useSyncExternalStore } from "react";
+import { jwtDecode } from "jwt-decode";
+import tokenSaver from "@common/dataFetch/tokenSaver.js";
+import { SERVICE_TOKEN_ID } from "@common/constants.js";
+
+const defaultUserState = {
+ isLogin: false,
+ userName: "",
+};
+
+class UserStore {
+ state;
+ observers = new Set();
+ constructor() {
+ this.state = createUserStore();
+ }
+ getState(getter) {
+ return getter(this.state);
+ }
+ subscribe(callback) {
+ this.observers.add(callback);
+ return () => this.observers.delete(callback);
+ }
+ setState(mutateFunc) {
+ const oldState = this.state;
+ const newState = typeof mutateFunc === "function" ? mutateFunc(oldState) : mutateFunc;
+ if (oldState === newState) return;
+ this.state = newState;
+ this.observers.forEach((callback) => callback());
+ }
+}
+
+function createUserStore() {
+ if (typeof window === "undefined") return defaultUserState;
+ tokenSaver.init(SERVICE_TOKEN_ID);
+ const token = tokenSaver.get(SERVICE_TOKEN_ID);
+ const { userName, userId } = parseTokenAndGetData(token);
+ if (token === null) return { isLogin: false, userName: "", userId: "" };
+ else return { isLogin: true, userName, userId };
+}
+
+function parseTokenAndGetData(token) {
+ if (token === null) return "";
+ try {
+ const { userName, userId } = jwtDecode(token);
+ return { userName, userId };
+ } catch {
+ return { userName: "사용자", userId: "1nvalidU5er" };
+ }
+}
+
+const userStore = new UserStore();
+
+export function login(token) {
+ tokenSaver.set(token);
+ const { userName, userId } = parseTokenAndGetData(token);
+ userStore.setState(() => ({ isLogin: true, userName, userId }));
+}
+
+export function logout() {
+ tokenSaver.remove();
+ userStore.setState(() => ({ isLogin: false, userName: "", userId: "" }));
+}
+
+function useAuthStore(func, defaultValue = defaultUserState) {
+ return useSyncExternalStore(
+ userStore.subscribe.bind(userStore),
+ () => userStore.getState(func),
+ () => func(defaultValue),
+ );
+}
+
+export function isLogined() {
+ return userStore.state.isLogin;
+}
+
+export default useAuthStore;
diff --git a/packages/mainPage/src/shared/components/AlertModalContainer.jsx b/packages/mainPage/src/shared/components/AlertModalContainer.jsx
new file mode 100644
index 00000000..d2025fd3
--- /dev/null
+++ b/packages/mainPage/src/shared/components/AlertModalContainer.jsx
@@ -0,0 +1,39 @@
+import { useId } from "react";
+
+function AlertModalContainer({ title, description, image, children }) {
+ const id = useId();
+ const descId = useId();
+
+ const containerStyle =
+ "w-[calc(100%-1rem)] max-w-[31.25rem] h-[calc(100svh-2rem)] p-10 shadow bg-white relative flex flex-col justify-between items-center";
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ {image && (
+
+ {image}
+
+ )}
+ {children}
+
+ );
+}
+
+export default AlertModalContainer;
diff --git a/packages/mainPage/src/shared/components/NoServerModal.jsx b/packages/mainPage/src/shared/components/NoServerModal.jsx
new file mode 100644
index 00000000..88254fc9
--- /dev/null
+++ b/packages/mainPage/src/shared/components/NoServerModal.jsx
@@ -0,0 +1,22 @@
+import { useContext } from "react";
+import { ModalCloseContext } from "@common/modal/modal.jsx";
+import AlertModalContainer from "@main/components/AlertModalContainer.jsx";
+import Button from "@common/components/Button.jsx";
+
+function NoServerModal() {
+ const close = useContext(ModalCloseContext);
+
+ return (
+ }
+ >
+
+
+ );
+}
+
+export default NoServerModal;
diff --git a/packages/mainPage/src/shared/components/ResetButton.jsx b/packages/mainPage/src/shared/components/ResetButton.jsx
new file mode 100644
index 00000000..e4a24693
--- /dev/null
+++ b/packages/mainPage/src/shared/components/ResetButton.jsx
@@ -0,0 +1,17 @@
+import Button from "@common/components/Button.jsx";
+import RefreshIcon from "./refresh.svg?react";
+
+export default function ResetButton({ onClick, disabled }) {
+ return (
+
+ );
+}
diff --git a/packages/mainPage/src/shared/components/refresh.svg b/packages/mainPage/src/shared/components/refresh.svg
new file mode 100644
index 00000000..4334e0b9
--- /dev/null
+++ b/packages/mainPage/src/shared/components/refresh.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/mainPage/src/shared/drawEvent/DrawEventFetcher.jsx b/packages/mainPage/src/shared/drawEvent/DrawEventFetcher.jsx
new file mode 100644
index 00000000..3300919f
--- /dev/null
+++ b/packages/mainPage/src/shared/drawEvent/DrawEventFetcher.jsx
@@ -0,0 +1,12 @@
+import useAuthStore from "@main/auth/store.js";
+import useDrawStore from "./store.js";
+
+function InteractionEventJoinDataFetcher() {
+ const userId = useAuthStore((store) => store.userId);
+ const getData = useDrawStore((store) => store.getJoinData);
+
+ getData(userId);
+ return null;
+}
+
+export default InteractionEventJoinDataFetcher;
diff --git a/packages/mainPage/src/shared/drawEvent/store.js b/packages/mainPage/src/shared/drawEvent/store.js
new file mode 100644
index 00000000..5c17c6d7
--- /dev/null
+++ b/packages/mainPage/src/shared/drawEvent/store.js
@@ -0,0 +1,85 @@
+import { create } from "zustand";
+import { fetchServer, handleError } from "@common/dataFetch/fetchServer.js";
+import { getQuery, getQuerySuspense } from "@common/dataFetch/getQuery.js";
+import { getServerPresiseTime, getDayDifference } from "@common/utils.js";
+import { EVENT_DRAW_ID, EVENT_START_DATE, DAY_MILLISEC } from "@common/constants.js";
+
+function getJoinDataEvent() {
+ return fetchServer(`/api/v1/event/draw/${EVENT_DRAW_ID}/participation`)
+ .then(({ dates }) => {
+ let newJoinedList = [false, false, false, false, false];
+ dates.forEach((date) => {
+ const day = getDayDifference(EVENT_START_DATE, new Date(date));
+ newJoinedList[day] = true;
+ });
+ return newJoinedList;
+ })
+ .catch(handleError({ 401: "unauthorized" }))
+ .catch((e) => {
+ if (e.message === "unauthorized") return [false, false, false, false, false];
+ throw e;
+ });
+}
+
+const drawEventStore = create((set, get) => ({
+ joinStatus: [false, false, false, false, false],
+ openBaseDate: new Date("9999-12-31"),
+ currentJoined: false,
+ fallbackMode: false,
+ getJoinData: (userId) => {
+ async function promiseFn() {
+ try {
+ const [serverTime, joinStatus] = await Promise.all([
+ getQuery("server-time", getServerPresiseTime),
+ getJoinDataEvent(),
+ ]);
+ return { joinStatus, openBaseDate: serverTime, fallbackMode: false };
+ } catch (e) {
+ return {
+ joinStatus: [false, false, false, false, false],
+ openBaseDate: new Date("9999-12-31"),
+ currentJoined: false,
+ fallbackMode: true,
+ };
+ }
+ }
+ async function setter() {
+ const newState = await getQuery(`draw-info-data@${userId}`, promiseFn);
+ set(newState);
+ return newState;
+ }
+ return getQuerySuspense("__zustand__draw-event-store-getData", setter, [userId, set]);
+ },
+ setCurrentJoin: (value) => {
+ set({ currentJoined: value });
+ },
+ readjustJoinStatus: (index) => {
+ set(({ joinStatus }) => {
+ const newJoinStatus = [...joinStatus];
+ newJoinStatus[index] = true;
+ return { joinStatus: newJoinStatus };
+ });
+ },
+ setFallbackMode: () => {
+ set({
+ joinStatus: [false, false, false, false, false],
+ openBaseDate: new Date("9999-12-31"),
+ currentJoined: false,
+ fallbackMode: true,
+ });
+ },
+ getJoinStatus: (index) => {
+ if (get().isTodayEvent(index)) return get().currentJoined || get().joinStatus[index];
+ return get().joinStatus[index];
+ },
+ getOpenStatus: (index) => {
+ return get().openBaseDate >= EVENT_START_DATE.getTime() + index * DAY_MILLISEC;
+ },
+ isTodayEvent: (index) => {
+ return (
+ getDayDifference(get().openBaseDate, EVENT_START_DATE.getTime() + index * DAY_MILLISEC) === 0
+ );
+ },
+}));
+
+export default drawEventStore;
diff --git a/packages/mainPage/src/shared/eventDescription/EventDescriptionLayout.jsx b/packages/mainPage/src/shared/eventDescription/EventDescriptionLayout.jsx
new file mode 100644
index 00000000..699a7402
--- /dev/null
+++ b/packages/mainPage/src/shared/eventDescription/EventDescriptionLayout.jsx
@@ -0,0 +1,15 @@
+import EventDetail from "./EventDetail.jsx";
+
+function EventDescriptionLayout({ detail, children }) {
+ return (
+
+
+
+
경품 안내
+ {children}
+
+
+ );
+}
+
+export default EventDescriptionLayout;
diff --git a/packages/mainPage/src/shared/eventDescription/EventDetail.jsx b/packages/mainPage/src/shared/eventDescription/EventDetail.jsx
new file mode 100644
index 00000000..ae7f5267
--- /dev/null
+++ b/packages/mainPage/src/shared/eventDescription/EventDetail.jsx
@@ -0,0 +1,51 @@
+import makeHighlight from "@main/makeHighlight.jsx";
+
+export default function EventDetail({
+ durationYear,
+ duration,
+ announceDate,
+ announceDateCaption,
+ howto,
+}) {
+ return (
+
+
상세 안내
+
+
+
+ 이벤트 기간
+
+ {durationYear}
+
+ {duration}
+
+
+
+ 당첨자 발표
+
+
+ {makeHighlight(announceDate, "font-medium text-neutral-300")}
+
+
+ {announceDateCaption}
+
+
+
+
+ );
+}
diff --git a/packages/mainPage/src/shared/hooks/useA11yDrag.js b/packages/mainPage/src/shared/hooks/useA11yDrag.js
new file mode 100644
index 00000000..b9ddb4e2
--- /dev/null
+++ b/packages/mainPage/src/shared/hooks/useA11yDrag.js
@@ -0,0 +1,88 @@
+import { useEffect, useRef } from "react";
+
+const voidAssistive = () => "";
+
+function getDir(keyCode) {
+ switch (keyCode) {
+ case "ArrowUp":
+ return [0, -1];
+ case "ArrowDown":
+ return [0, 1];
+ case "ArrowLeft":
+ return [-1, 0];
+ case "ArrowRight":
+ return [1, 0];
+ default:
+ return [0, 0];
+ }
+}
+
+function useA11yDrag({
+ grabText = voidAssistive,
+ moveText = voidAssistive,
+ dropText = voidAssistive,
+ onKeyGrab,
+ onKeyMove,
+ onKeyRelease,
+ setSubtitle,
+ enabled = true,
+}) {
+ const target = useRef(null);
+ const grabbed = useRef(false);
+
+ useEffect(() => {
+ if (target.current === null || !enabled) return;
+
+ function onKeyDown(e) {
+ if (document.activeElement !== target.current) return;
+
+ if (grabbed.current) {
+ switch (e.code) {
+ case "Tab": {
+ e.preventDefault();
+ break;
+ }
+ case "Space": {
+ grabbed.current = false;
+ setSubtitle(() => dropText);
+ e.preventDefault();
+ onKeyRelease?.();
+ break;
+ }
+ case "ArrowUp":
+ case "ArrowDown":
+ case "ArrowLeft":
+ case "ArrowRight": {
+ const [x, y] = getDir(e.code);
+ onKeyMove(x, y);
+ setSubtitle(() => moveText);
+ e.preventDefault();
+ break;
+ }
+ }
+ } else if (e.code === "Space") {
+ grabbed.current = true;
+ setSubtitle(() => grabText);
+ onKeyGrab?.();
+ e.preventDefault();
+ }
+ }
+ function onFocusOut() {
+ grabbed.current = false;
+ onKeyRelease?.();
+ setSubtitle(() => voidAssistive);
+ }
+
+ const targetDom = target.current;
+ document.addEventListener("keydown", onKeyDown);
+ targetDom.addEventListener("blur", onFocusOut);
+ return () => {
+ document.removeEventListener("keydown", onKeyDown);
+ targetDom.removeEventListener("blur", onFocusOut);
+ };
+ }, [grabText, moveText, dropText, onKeyGrab, onKeyMove, onKeyRelease, setSubtitle, enabled]);
+
+ return target;
+}
+
+export default useA11yDrag;
diff --git a/packages/mainPage/src/shared/hooks/useMountDragEvent.js b/packages/mainPage/src/shared/hooks/useMountDragEvent.js
new file mode 100644
index 00000000..8776e108
--- /dev/null
+++ b/packages/mainPage/src/shared/hooks/useMountDragEvent.js
@@ -0,0 +1,74 @@
+import { useState, useRef, useEffect, useCallback } from "react";
+import throttleRaf from "@common/throttleRaf.js";
+
+function useMountDragEvent({
+ onDragStart: userDragStart,
+ onDrag,
+ onDragEnd: userDragEnd,
+ enabled = true,
+} = {}) {
+ const [dragState, setDragState] = useState(false);
+ const isDragging = useRef(false);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const onPointerMove = throttleRaf((e) => {
+ if (e.pointerType === "touch") return;
+ if (!isDragging.current) return;
+ const { clientX, clientY } = e;
+ onDrag({ x: clientX, y: clientY });
+ });
+ const onTouchMove = throttleRaf((e) => {
+ const { clientX, clientY } = e.touches[0];
+ if (!isDragging.current) return;
+ onDrag({ x: clientX, y: clientY });
+ });
+
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("touchmove", onTouchMove);
+ return () => {
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("touchmove", onTouchMove);
+ };
+ }, [onDrag, enabled]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const onDragEnd = (e) => {
+ if (!isDragging.current) return;
+ isDragging.current = false;
+ setDragState(false);
+ userDragEnd?.(e);
+ };
+
+ window.addEventListener("pointerup", onDragEnd);
+ window.addEventListener("pointercancel", onDragEnd);
+ window.addEventListener("touchend", onDragEnd);
+ window.addEventListener("touchcancel", onDragEnd);
+ return () => {
+ window.removeEventListener("pointerup", onDragEnd);
+ window.removeEventListener("pointercancel", onDragEnd);
+ window.removeEventListener("touchend", onDragEnd);
+ window.removeEventListener("touchcancel", onDragEnd);
+ };
+ }, [userDragEnd, enabled]);
+
+ const onPointerDown = useCallback(
+ (e) => {
+ if (!enabled) return;
+ isDragging.current = true;
+ setDragState(true);
+ userDragStart?.({ x: e.clientX, y: e.clientY });
+ },
+ [userDragStart, enabled],
+ );
+
+ return {
+ onPointerDown,
+ dragState,
+ };
+}
+
+export default useMountDragEvent;
diff --git a/packages/mainPage/src/shared/hooks/useScrollTransition.js b/packages/mainPage/src/shared/hooks/useScrollTransition.js
new file mode 100644
index 00000000..a5ad909a
--- /dev/null
+++ b/packages/mainPage/src/shared/hooks/useScrollTransition.js
@@ -0,0 +1,30 @@
+import { useState, useEffect } from "react";
+import throttleRaf from "@common/throttleRaf.js";
+import { clamp } from "@common/utils.js";
+
+/**
+ * 스크롤 트랜지션을 더 쉽게 사용할 수 있게 하는 커스텀 훅입니다.
+ *
+ * @param {number} scrollStart - 스크롤이 시작되는 위치입니다.
+ * @param {number} scrollEnd - 스크롤이 종료되는 위치입니다.
+ * @param {number} valueStart - 값의 시작 지점입니다.
+ * @param {number} valueEnd - 값의 종료 지점입니다.
+ *
+ * @return {number} 실제로 변환된 값입니다.
+ */
+function useScrollTransition({ scrollStart, scrollEnd, valueStart, valueEnd }) {
+ const [scroll, setScroll] = useState(0);
+
+ useEffect(() => {
+ const scrollRenew = throttleRaf(() => setScroll(window.scrollY));
+ document.addEventListener("scroll", scrollRenew);
+ () => {
+ document.removeEventListener("scroll", scrollRenew);
+ };
+ }, []);
+
+ const ratio = clamp((scroll - scrollStart) / (scrollEnd - scrollStart), 0, 1);
+ return ratio * (valueEnd - valueStart) + valueStart;
+}
+
+export default useScrollTransition;
diff --git a/packages/mainPage/src/shared/hooks/useSwiperState.js b/packages/mainPage/src/shared/hooks/useSwiperState.js
new file mode 100644
index 00000000..cadd699a
--- /dev/null
+++ b/packages/mainPage/src/shared/hooks/useSwiperState.js
@@ -0,0 +1,21 @@
+import { useState, useEffect, useRef } from "react";
+
+function useSwiperState() {
+ const [swiperState, setSwiperState] = useState(0);
+ const swiperElRef = useRef(null);
+ useEffect(() => {
+ if (swiperElRef.current === null) return;
+
+ const swiperEl = swiperElRef.current;
+ function onSlideChange(e) {
+ setSwiperState(e.detail[0].realIndex);
+ }
+ swiperEl.addEventListener("swiperslidechange", onSlideChange);
+
+ return () => swiperEl.removeEventListener("swiperslidechange", onSlideChange);
+ }, []);
+
+ return [swiperState, swiperElRef];
+}
+
+export default useSwiperState;
diff --git a/packages/mainPage/src/shared/makeHighlight.jsx b/packages/mainPage/src/shared/makeHighlight.jsx
new file mode 100644
index 00000000..6286a017
--- /dev/null
+++ b/packages/mainPage/src/shared/makeHighlight.jsx
@@ -0,0 +1,17 @@
+// **매일매일 공개되는** 더 뉴 아이오닉과 관련된 **인터랙션을 수행한다.**
+// **...**로 감싸진 것은 강조입니다.
+
+function makeHighlight(plainText, highlightClass) {
+ const tokened = plainText.split(/\*\*(.*?)\*\*/gm);
+
+ return tokened.map((content, index) => {
+ if (index % 2 === 0) return content;
+ return (
+
+ {content}
+
+ );
+ });
+}
+
+export default makeHighlight;
diff --git a/packages/mainPage/src/shared/realtimeEvent/constants.js b/packages/mainPage/src/shared/realtimeEvent/constants.js
new file mode 100644
index 00000000..fdb6650f
--- /dev/null
+++ b/packages/mainPage/src/shared/realtimeEvent/constants.js
@@ -0,0 +1,5 @@
+export const PROGRESS = "progress";
+export const COUNTDOWN = "countdown";
+export const WAITING = "waiting";
+export const OFFLINE = "offline";
+export const ALREADY = "already-participated";
diff --git a/packages/mainPage/src/shared/realtimeEvent/getEventDateState.js b/packages/mainPage/src/shared/realtimeEvent/getEventDateState.js
new file mode 100644
index 00000000..c0b1b9ef
--- /dev/null
+++ b/packages/mainPage/src/shared/realtimeEvent/getEventDateState.js
@@ -0,0 +1,9 @@
+const ONE_DAY = 24 * 60 * 60 * 1000;
+export default function getEventDateState(currrentTimeDate, eventTimeDate) {
+ const currentTime = currrentTimeDate.valueOf();
+ const eventTime = eventTimeDate.valueOf();
+ const eventEndTime = eventTimeDate.valueOf() + ONE_DAY;
+ if (currentTime < eventTime) return "default";
+ if (currentTime < eventEndTime) return "active";
+ return "ended";
+}
diff --git a/packages/mainPage/src/shared/realtimeEvent/store.js b/packages/mainPage/src/shared/realtimeEvent/store.js
new file mode 100644
index 00000000..a1c28b55
--- /dev/null
+++ b/packages/mainPage/src/shared/realtimeEvent/store.js
@@ -0,0 +1,95 @@
+import { create } from "zustand";
+import * as Status from "./constants.js";
+import { fetchServer, HTTPError, ServerCloseError } from "@common/dataFetch/fetchServer.js";
+import { getQuery, getQuerySuspense } from "@common/dataFetch/getQuery.js";
+import { getServerPresiseTime } from "@common/utils.js";
+import { EVENT_FCFS_ID } from "@common/constants.js";
+
+const HOURS = 60 * 60;
+
+async function getFcfsEventInfo() {
+ try {
+ const eventData = await fetchServer(`/api/v1/event/fcfs/${EVENT_FCFS_ID}/info`);
+ return eventData;
+ } catch (e) {
+ if (e instanceof HTTPError && e.status === 404)
+ return {
+ eventStartTime: "9999-12-31T11:59:59.000Z",
+ eventStatus: Status.OFFLINE,
+ };
+ if (e instanceof ServerCloseError)
+ return {
+ eventStartTime: "9999-12-31T11:59:59.000Z",
+ eventStatus: Status.OFFLINE,
+ };
+ throw e;
+ }
+}
+
+async function getFcfsParticipated() {
+ try {
+ const eventData = await fetchServer(`/api/v1/event/fcfs/${EVENT_FCFS_ID}/participated`); // ???
+ return eventData;
+ } catch (e) {
+ if (e instanceof HTTPError && (e.status === 401 || e.status == 404)) return false;
+ if (e instanceof ServerCloseError) return false;
+ throw e;
+ }
+}
+
+function getEventStatusFromCount(countdown) {
+ if (countdown <= -7 * HOURS) return Status.OFFLINE;
+ if (countdown <= 0) return Status.PROGRESS;
+ if (countdown <= 3 * HOURS) return Status.COUNTDOWN;
+ return Status.WAITING;
+}
+
+const fcfsStore = create((set) => ({
+ countdown: 0,
+ currentServerTime: 0,
+ currentEventTime: 0,
+ eventStatus: Status.OFFLINE,
+ isParticipated: false,
+ getData: () => {
+ const promiseFn = async function () {
+ // get server time and event info
+ const [serverTime, eventInfo] = await Promise.all([
+ getQuery("server-time", getServerPresiseTime),
+ getFcfsEventInfo(),
+ ]);
+ const currentServerTime = serverTime;
+ const currentEventTime = new Date(eventInfo.eventStartTime).getTime();
+
+ // get countdown and syncronize state
+ const countdown = Math.ceil((currentEventTime - currentServerTime) / 1000);
+ return {
+ currentServerTime,
+ currentEventTime,
+ countdown,
+ eventStatus: eventInfo.eventStatus,
+ };
+ };
+ const setter = async function () {
+ const newState = await getQuery("fcfs-info-data", promiseFn);
+ set(newState);
+ return newState;
+ };
+ return getQuerySuspense("fcfs-info-data", setter, [set]);
+ },
+ getPariticipatedData: (userId) => {
+ const setter = async function () {
+ const participated = await getQuery(`fcfs-participated-data@${userId}`, getFcfsParticipated);
+ set({ isParticipated: participated });
+ return participated;
+ };
+ return getQuerySuspense(`__zustand__fcfs-participated-getData`, setter, [set, userId]);
+ },
+ setEventStatus: (eventStatus) => set({ eventStatus }),
+ handleCountdown: () =>
+ set((state) => ({
+ countdown: state.countdown - 1,
+ eventStatus: getEventStatusFromCount(state.countdown - 1),
+ })),
+}));
+
+export default fcfsStore;
diff --git a/packages/mainPage/src/shared/scroll/constants.js b/packages/mainPage/src/shared/scroll/constants.js
new file mode 100644
index 00000000..c8b8bc20
--- /dev/null
+++ b/packages/mainPage/src/shared/scroll/constants.js
@@ -0,0 +1,6 @@
+// scroll section constants
+export const OTHER_SECTION = 0;
+export const INTERACTION_SECTION = 1;
+export const DETAIL_SECTION = 2;
+export const COMMENT_SECTION = 3;
+export const FCFS_SECTION = 4;
diff --git a/packages/mainPage/src/shared/scroll/scrollTo.js b/packages/mainPage/src/shared/scroll/scrollTo.js
new file mode 100644
index 00000000..99cdada1
--- /dev/null
+++ b/packages/mainPage/src/shared/scroll/scrollTo.js
@@ -0,0 +1,7 @@
+import { useSectionStore } from "./store";
+
+export default function scrollTo(scrollIndex) {
+ const state = useSectionStore.getState();
+ const sectionDOM = state.sectionList[scrollIndex];
+ sectionDOM.scrollIntoView({ behavior: "smooth" });
+}
diff --git a/packages/mainPage/src/shared/scroll/store.js b/packages/mainPage/src/shared/scroll/store.js
new file mode 100644
index 00000000..96c2c8d0
--- /dev/null
+++ b/packages/mainPage/src/shared/scroll/store.js
@@ -0,0 +1,18 @@
+import { create } from "zustand";
+
+export const useSectionStore = create((set) => ({
+ sectionList: [null, null, null, null, null],
+ isVisibleList: [false, false, false, false, false],
+ uploadSection: (index, section) =>
+ set((state) => {
+ const updatedList = [...state.sectionList];
+ updatedList[index] = section;
+ return { sectionList: updatedList };
+ }),
+ setIsVisibleList: (index, value) =>
+ set((state) => {
+ const updatedList = [...state.isVisibleList];
+ updatedList[index] = value;
+ return { isVisibleList: updatedList };
+ }),
+}));
diff --git a/packages/mainPage/src/shared/scroll/useSectionInitialize.js b/packages/mainPage/src/shared/scroll/useSectionInitialize.js
new file mode 100644
index 00000000..d84ccbf5
--- /dev/null
+++ b/packages/mainPage/src/shared/scroll/useSectionInitialize.js
@@ -0,0 +1,31 @@
+import { useEffect } from "react";
+import { useSectionStore } from "./store";
+
+export default function useSectionInitialize(SECTION_IDX, sectionRef) {
+ const uploadSection = useSectionStore((state) => state.uploadSection);
+ const setIsVisibleList = useSectionStore((state) => state.setIsVisibleList);
+ useEffect(() => {
+ const sectionDOM = sectionRef.current;
+ if (sectionDOM) {
+ uploadSection(SECTION_IDX, sectionRef.current);
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ setIsVisibleList(SECTION_IDX, entry.isIntersecting);
+ });
+ },
+ { threshold: 0.01 },
+ );
+
+ if (sectionDOM) {
+ observer.observe(sectionDOM);
+ }
+ return () => {
+ if (sectionDOM) {
+ observer.unobserve(sectionDOM);
+ }
+ };
+ }, [SECTION_IDX, sectionRef, uploadSection, setIsVisibleList]);
+}
diff --git a/packages/mainPage/tailwind.config.js b/packages/mainPage/tailwind.config.js
new file mode 100644
index 00000000..df2f7e34
--- /dev/null
+++ b/packages/mainPage/tailwind.config.js
@@ -0,0 +1,16 @@
+/** @type {import('tailwindcss').Config} */
+import redefinedStyles from "@awesome-orange/common/tailwind.redefine.js";
+
+export default {
+ content: ["./index.html", "./**/*.{js,ts,jsx,tsx}", "../common/**/*.{js,ts,jsx,tsx}"],
+ theme: {
+ extend: {
+ ...redefinedStyles,
+ },
+ fontFamily: {
+ "ds-digital": ["ds-digital"],
+ hdsans: ["hdsans"],
+ },
+ },
+ plugins: [],
+};
diff --git a/packages/mainPage/vercel.json b/packages/mainPage/vercel.json
new file mode 100644
index 00000000..b5d1e148
--- /dev/null
+++ b/packages/mainPage/vercel.json
@@ -0,0 +1,8 @@
+{
+ "rewrites": [
+ {
+ "source": "/api/:path*",
+ "destination": "http://softeerorange.store/api/:path*"
+ }
+ ]
+}
diff --git a/packages/mainPage/vite.config.js b/packages/mainPage/vite.config.js
new file mode 100644
index 00000000..b21fb821
--- /dev/null
+++ b/packages/mainPage/vite.config.js
@@ -0,0 +1,36 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import svgr from "vite-plugin-svgr";
+import sharedAssetRouter from "@awesome-orange/common/sharedAssetRouter.js";
+
+const __dirname = fileURLToPath(new URL(".", import.meta.url));
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [
+ react(),
+ svgr(),
+ sharedAssetRouter([
+ ["/font", "/public/font"],
+ ["/shared", "/public"],
+ ["/mockServiceWorker.js", "/public/mockServiceWorker.js"],
+ ]),
+ ],
+ resolve: {
+ alias: [
+ { find: "@", replacement: resolve(__dirname, "src") },
+ { find: "@common", replacement: "@awesome-orange/common/src" },
+ { find: "@main", replacement: resolve(__dirname, "src/shared") },
+ ],
+ },
+ preview: {
+ proxy: {
+ "/api": {
+ target: "http://softeerorange.store",
+ changeOrigin: true,
+ },
+ },
+ },
+});
diff --git a/public/font/HyundaiSansTextKROTFBold.woff b/public/font/HyundaiSansTextKROTFBold.woff
new file mode 100644
index 00000000..283e4cd3
Binary files /dev/null and b/public/font/HyundaiSansTextKROTFBold.woff differ
diff --git a/public/font/HyundaiSansTextKROTFBold.woff2 b/public/font/HyundaiSansTextKROTFBold.woff2
new file mode 100644
index 00000000..cd9a8250
Binary files /dev/null and b/public/font/HyundaiSansTextKROTFBold.woff2 differ
diff --git a/public/font/HyundaiSansTextKROTFRegular.woff b/public/font/HyundaiSansTextKROTFRegular.woff
new file mode 100644
index 00000000..a1b49cfb
Binary files /dev/null and b/public/font/HyundaiSansTextKROTFRegular.woff differ
diff --git a/public/font/HyundaiSansTextKROTFRegular.woff2 b/public/font/HyundaiSansTextKROTFRegular.woff2
new file mode 100644
index 00000000..a04a7917
Binary files /dev/null and b/public/font/HyundaiSansTextKROTFRegular.woff2 differ
diff --git a/public/font/fonts.css b/public/font/fonts.css
new file mode 100644
index 00000000..af885022
--- /dev/null
+++ b/public/font/fonts.css
@@ -0,0 +1,15 @@
+@font-face {
+ font-family: "hdsans";
+ src: url("/font/HyundaiSansTextKROTFBold.woff2") format("woff2"),
+ url('/font/HyundaiSansTextKROTFBold.woff') format('woff');
+ font-weight: bold;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "hdsans";
+ src: url("/font/HyundaiSansTextKROTFRegular.woff2") format("woff2"),
+ url('/font/HyundaiSansTextKROTFRegular.woff') format('woff');
+ font-weight: 400;
+ font-display: swap;
+}
\ No newline at end of file
diff --git a/public/icons/checked.svg b/public/icons/checked.svg
new file mode 100644
index 00000000..0daca305
--- /dev/null
+++ b/public/icons/checked.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/icons/close.svg b/public/icons/close.svg
new file mode 100644
index 00000000..4720abe8
--- /dev/null
+++ b/public/icons/close.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js
new file mode 100644
index 00000000..cbd28e53
--- /dev/null
+++ b/public/mockServiceWorker.js
@@ -0,0 +1,284 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ * - Please do NOT serve this file on production.
+ */
+
+const PACKAGE_VERSION = '2.3.4'
+const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
+const activeClientIds = new Set()
+
+self.addEventListener('install', function () {
+ self.skipWaiting()
+})
+
+self.addEventListener('activate', function (event) {
+ event.waitUntil(self.clients.claim())
+})
+
+self.addEventListener('message', async function (event) {
+ const clientId = event.source.id
+
+ if (!clientId || !self.clients) {
+ return
+ }
+
+ const client = await self.clients.get(clientId)
+
+ if (!client) {
+ return
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: {
+ packageVersion: PACKAGE_VERSION,
+ checksum: INTEGRITY_CHECKSUM,
+ },
+ })
+ break
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: true,
+ })
+ break
+ }
+
+ case 'MOCK_DEACTIVATE': {
+ activeClientIds.delete(clientId)
+ break
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId
+ })
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister()
+ }
+
+ break
+ }
+ }
+})
+
+self.addEventListener('fetch', function (event) {
+ const { request } = event
+
+ // Bypass navigation requests.
+ if (request.mode === 'navigate') {
+ return
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
+ return
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been deleted (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return
+ }
+
+ // Generate unique request ID.
+ const requestId = crypto.randomUUID()
+ event.respondWith(handleRequest(event, requestId))
+})
+
+async function handleRequest(event, requestId) {
+ const client = await resolveMainClient(event)
+ const response = await getResponse(event, client, requestId)
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ ;(async function () {
+ const responseClone = response.clone()
+
+ sendToClient(
+ client,
+ {
+ type: 'RESPONSE',
+ payload: {
+ requestId,
+ isMockedResponse: IS_MOCKED_RESPONSE in response,
+ type: responseClone.type,
+ status: responseClone.status,
+ statusText: responseClone.statusText,
+ body: responseClone.body,
+ headers: Object.fromEntries(responseClone.headers.entries()),
+ },
+ },
+ [responseClone.body],
+ )
+ })()
+ }
+
+ return response
+}
+
+// Resolve the main client for the given event.
+// Client that issues a request doesn't necessarily equal the client
+// that registered the worker. It's with the latter the worker should
+// communicate with during the response resolving phase.
+async function resolveMainClient(event) {
+ const client = await self.clients.get(event.clientId)
+
+ if (client?.frameType === 'top-level') {
+ return client
+ }
+
+ const allClients = await self.clients.matchAll({
+ type: 'window',
+ })
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible'
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id)
+ })
+}
+
+async function getResponse(event, client, requestId) {
+ const { request } = event
+
+ // Clone the request because it might've been already used
+ // (i.e. its body has been read and sent to the client).
+ const requestClone = request.clone()
+
+ function passthrough() {
+ const headers = Object.fromEntries(requestClone.headers.entries())
+
+ // Remove internal MSW request header so the passthrough request
+ // complies with any potential CORS preflight checks on the server.
+ // Some servers forbid unknown request headers.
+ delete headers['x-msw-intention']
+
+ return fetch(requestClone, { headers })
+ }
+
+ // Bypass mocking when the client is not active.
+ if (!client) {
+ return passthrough()
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return passthrough()
+ }
+
+ // Notify the client that a request has been intercepted.
+ const requestBuffer = await request.arrayBuffer()
+ const clientMessage = await sendToClient(
+ client,
+ {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ url: request.url,
+ mode: request.mode,
+ method: request.method,
+ headers: Object.fromEntries(request.headers.entries()),
+ cache: request.cache,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body: requestBuffer,
+ keepalive: request.keepalive,
+ },
+ },
+ [requestBuffer],
+ )
+
+ switch (clientMessage.type) {
+ case 'MOCK_RESPONSE': {
+ return respondWithMock(clientMessage.data)
+ }
+
+ case 'PASSTHROUGH': {
+ return passthrough()
+ }
+ }
+
+ return passthrough()
+}
+
+function sendToClient(client, message, transferrables = []) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel()
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
+ }
+
+ resolve(event.data)
+ }
+
+ client.postMessage(
+ message,
+ [channel.port2].concat(transferrables.filter(Boolean)),
+ )
+ })
+}
+
+async function respondWithMock(response) {
+ // Setting response status code to 0 is a no-op.
+ // However, when responding with a "Response.error()", the produced Response
+ // instance will have status code set to 0. Since it's not possible to create
+ // a Response instance with status code 0, handle that use-case separately.
+ if (response.status === 0) {
+ return Response.error()
+ }
+
+ const mockedResponse = new Response(response.body, response)
+
+ Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+ value: true,
+ enumerable: true,
+ })
+
+ return mockedResponse
+}
diff --git a/src/App.css b/src/App.css
deleted file mode 100644
index b9d355df..00000000
--- a/src/App.css
+++ /dev/null
@@ -1,42 +0,0 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
diff --git a/src/App.jsx b/src/App.jsx
deleted file mode 100644
index cfe277f0..00000000
--- a/src/App.jsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useState } from "react";
-import reactLogo from "./assets/react.svg";
-import viteLogo from "/vite.svg";
-import "./App.css";
-
-function App() {
- const [count, setCount] = useState(0);
-
- return (
- <>
-
- Vite + React
-
-
-
- Edit src/App.jsx
and save to test HMR
-
-
-
- Click on the Vite and React logos to learn more
-
- >
- );
-}
-
-export default App;
diff --git a/src/assets/react.svg b/src/assets/react.svg
deleted file mode 100644
index 6c87de9b..00000000
--- a/src/assets/react.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
deleted file mode 100644
index bd6213e1..00000000
--- a/src/index.css
+++ /dev/null
@@ -1,3 +0,0 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
\ No newline at end of file
diff --git a/src/main.jsx b/src/main.jsx
deleted file mode 100644
index c9900533..00000000
--- a/src/main.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from "react";
-import ReactDOM from "react-dom/client";
-import App from "./App.jsx";
-import "./index.css";
-
-ReactDOM.createRoot(document.getElementById("root")).render(
-
-
- ,
-);
diff --git a/tailwind.config.js b/tailwind.config.js
deleted file mode 100644
index d37737fc..00000000
--- a/tailwind.config.js
+++ /dev/null
@@ -1,12 +0,0 @@
-/** @type {import('tailwindcss').Config} */
-export default {
- content: [
- "./index.html",
- "./src/**/*.{js,ts,jsx,tsx}",
- ],
- theme: {
- extend: {},
- },
- plugins: [],
-}
-
diff --git a/vite-devRouter.js b/vite-devRouter.js
new file mode 100644
index 00000000..ab85f5f9
--- /dev/null
+++ b/vite-devRouter.js
@@ -0,0 +1,33 @@
+// from monument gallery
+export default function devRouter(rawRouteList) {
+ // 문자열로 된 route list를 정규표현식으로 변환하고, 정규표현식이 아닌 것들을 제거합니다.
+ const routeList = rawRouteList
+ .map(([route, html]) => {
+ if (typeof route === "string") {
+ route = new RegExp(`^${route.replace(/\*/g, ".*").replace(/\?/g, ".")}/?$`);
+ }
+ return [route, html];
+ })
+ .filter(([route]) => route instanceof RegExp);
+
+ // 요청한 패스가 설정된 라우팅 경로에 맞는지 확인합니다.
+ function foundRoute(path) {
+ for (let [route, html] of routeList) {
+ if (route.test(path)) return html;
+ }
+ return null;
+ }
+
+ return {
+ name: "route-server",
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ const htmlPath = foundRoute(req.originalUrl);
+ if (htmlPath === null) return next();
+ req.url = htmlPath;
+ req.originalUrl = htmlPath;
+ next();
+ });
+ },
+ };
+}
\ No newline at end of file
diff --git a/vite.config.js b/vite.config.js
deleted file mode 100644
index 5a33944a..00000000
--- a/vite.config.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
-
-// https://vitejs.dev/config/
-export default defineConfig({
- plugins: [react()],
-})