diff --git a/client/src/components/Scroll/index.tsx b/client/src/components/Scroll/index.tsx
index 3b172b68..df0cdb71 100644
--- a/client/src/components/Scroll/index.tsx
+++ b/client/src/components/Scroll/index.tsx
@@ -4,6 +4,7 @@ import { cva } from "class-variance-authority";
export interface ScrollProps {
type: "light" | "dark";
children: ReactNode;
+ onClick?: () => void;
}
const scrollTextVariants = cva(`h-body-2-regular`, {
@@ -15,9 +16,12 @@ const scrollTextVariants = cva(`h-body-2-regular`, {
},
});
-export default function Scroll({ type, children }: ScrollProps) {
+export default function Scroll({ type, children, onClick }: ScrollProps) {
return (
-
+
{children}
작성한 기대평
-
{expectations}
+
+ {expectations}
+
)}
diff --git a/client/src/features/CasperCustom/CasperCard/CasperFlipCard.tsx b/client/src/features/CasperCustom/CasperCard/CasperFlipCard.tsx
index dd73c174..3f40dd73 100644
--- a/client/src/features/CasperCustom/CasperCard/CasperFlipCard.tsx
+++ b/client/src/features/CasperCustom/CasperCard/CasperFlipCard.tsx
@@ -1,7 +1,7 @@
import { memo } from "react";
import { motion } from "framer-motion";
import { CASPER_CARD_SIZE, CASPER_SIZE_OPTION } from "@/constants/CasperCustom/casper";
-import { CasperCardType } from "@/features/CasperShowCase/TransitionCasperCards";
+import type { CasperCardType } from "@/types/casper";
import { CasperCardBackUI } from "./CasperCardBackUI";
import { CasperCardFrontUI } from "./CasperCardFrontUI";
diff --git a/client/src/features/CasperCustom/CustomProcess/CasperCustomFinish.tsx b/client/src/features/CasperCustom/CustomProcess/CasperCustomFinish.tsx
index 56aee58c..d1092a5d 100644
--- a/client/src/features/CasperCustom/CustomProcess/CasperCustomFinish.tsx
+++ b/client/src/features/CasperCustom/CustomProcess/CasperCustomFinish.tsx
@@ -50,7 +50,7 @@ export function CasperCustomFinish({
const { showToast, ToastComponent } = useToast(
isErrorGetShareLink
? "공유 링크 생성에 실패했습니다! 캐스퍼 봇 생성 후 다시 시도해주세요."
- : "링크가 복사되었어요!"
+ : "🔗 링크가 복사되었어요!"
);
const dispatch = useCasperCustomDispatchContext();
diff --git a/client/src/features/CasperCustom/CustomProcess/CasperCustomFinishing.tsx b/client/src/features/CasperCustom/CustomProcess/CasperCustomFinishing.tsx
index 344fa134..74c53203 100644
--- a/client/src/features/CasperCustom/CustomProcess/CasperCustomFinishing.tsx
+++ b/client/src/features/CasperCustom/CustomProcess/CasperCustomFinishing.tsx
@@ -4,9 +4,9 @@ import { CASPER_SIZE_OPTION } from "@/constants/CasperCustom/casper";
import { DISSOLVE } from "@/constants/animation";
import { SCROLL_MOTION } from "@/constants/animation";
import { CasperFlipCard } from "@/features/CasperCustom/CasperCard/CasperFlipCard";
-import { CasperCardType } from "@/features/CasperShowCase/TransitionCasperCards";
import useCasperCustomStateContext from "@/hooks/useCasperCustomStateContext";
import useToast from "@/hooks/useToast";
+import type { CasperCardType } from "@/types/casper";
interface CasperCustomFinishingProps {
navigateNextStep: () => void;
diff --git a/client/src/features/CasperShowCase/CasperCards.tsx b/client/src/features/CasperShowCase/CasperCards.tsx
index d1a1cdde..a960cc48 100644
--- a/client/src/features/CasperShowCase/CasperCards.tsx
+++ b/client/src/features/CasperShowCase/CasperCards.tsx
@@ -1,5 +1,7 @@
+import { useMemo } from "react";
import { CASPER_CARD_SIZE, CASPER_SIZE_OPTION } from "@/constants/CasperCustom/casper";
-import { CasperCardType, TransitionCasperCards } from "./TransitionCasperCards";
+import type { CasperCardType } from "@/types/casper";
+import { TransitionCasperCards } from "./TransitionCasperCards";
interface CasperCardsProps {
cardList: CasperCardType[];
@@ -8,12 +10,20 @@ interface CasperCardsProps {
export function CasperCards({ cardList }: CasperCardsProps) {
const cardLength = cardList.length;
const cardLengthHalf = Math.floor(cardLength / 2);
- const topCardList = cardList.slice(0, cardLengthHalf);
- const bottomCardList = cardList.slice(cardLengthHalf, cardLength);
+ const visibleCardCount = useMemo(() => {
+ const width = window.innerWidth;
+ const cardWidth = CASPER_CARD_SIZE[CASPER_SIZE_OPTION.SM].CARD_WIDTH;
+
+ return Math.ceil(width / cardWidth);
+ }, []);
+ const isMultipleLine = visibleCardCount * 2 <= cardLength;
+
+ const topCardList = cardList.slice(0, isMultipleLine ? cardLengthHalf : cardLength);
+ const bottomCardList = isMultipleLine ? cardList.slice(cardLengthHalf, cardLength) : [];
const itemWidth = CASPER_CARD_SIZE[CASPER_SIZE_OPTION.SM].CARD_WIDTH;
const gap = 40;
- const totalWidth = (itemWidth + gap) * topCardList.length;
+ const totalWidth = (itemWidth + gap) * visibleCardCount;
const isEndTopCard = (latestX: number) => {
return latestX <= -totalWidth;
@@ -29,7 +39,7 @@ export function CasperCards({ cardList }: CasperCardsProps) {
initialX={0}
gap={gap}
diffX={-totalWidth}
- totalWidth={totalWidth}
+ visibleCardCount={visibleCardCount}
isEndCard={isEndTopCard}
/>
);
diff --git a/client/src/features/CasperShowCase/TransitionCasperCardItem.tsx b/client/src/features/CasperShowCase/TransitionCasperCardItem.tsx
new file mode 100644
index 00000000..13eaa09f
--- /dev/null
+++ b/client/src/features/CasperShowCase/TransitionCasperCardItem.tsx
@@ -0,0 +1,44 @@
+import { useState } from "react";
+import { CASPER_CARD_SIZE, CASPER_SIZE_OPTION } from "@/constants/CasperCustom/casper";
+import { CasperFlipCard } from "@/features/CasperCustom/CasperCard/CasperFlipCard";
+import type { CasperCardType } from "@/types/casper";
+
+interface TransitionCasperCardItemProps {
+ cardItem: CasperCardType;
+ id: string;
+ stopAnimation?: () => void;
+ startAnimation?: () => void;
+}
+
+export function TransitionCasperCardItem({
+ cardItem,
+ id,
+ stopAnimation,
+ startAnimation,
+}: TransitionCasperCardItemProps) {
+ const [isFlipped, setIsFlipped] = useState
(false);
+
+ const handleMouseEnter = () => {
+ stopAnimation && stopAnimation();
+ setIsFlipped(true);
+ };
+
+ const handleMouseLeave = () => {
+ startAnimation && startAnimation();
+ setIsFlipped(false);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/features/CasperShowCase/TransitionCasperCards.tsx b/client/src/features/CasperShowCase/TransitionCasperCards.tsx
index 460e4731..49673914 100644
--- a/client/src/features/CasperShowCase/TransitionCasperCards.tsx
+++ b/client/src/features/CasperShowCase/TransitionCasperCards.tsx
@@ -1,111 +1,125 @@
-import { useEffect, useRef, useState } from "react";
-import { AnimatePresence, motion, useAnimation } from "framer-motion";
-import { CASPER_CARD_SIZE, CASPER_SIZE_OPTION } from "@/constants/CasperCustom/casper";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { AnimatePresence, type ResolvedValues, motion, useAnimation } from "framer-motion";
import { CARD_TRANSITION } from "@/constants/CasperShowCase/showCase";
-import { CasperFlipCard } from "@/features/CasperCustom/CasperCard/CasperFlipCard";
-import useLazyLoading from "@/hooks/useLazyLoading";
-import { SelectedCasperIdxType } from "@/types/casperCustom";
+import type { CasperCardType } from "@/types/casper";
+import { TransitionCasperCardItem } from "./TransitionCasperCardItem";
-export interface CasperCardType {
- id: number;
- casperName: string;
- expectations: string;
- selectedCasperIdx: SelectedCasperIdxType;
-}
interface TransitionCasperCardsProps {
cardList: CasperCardType[];
initialX: number;
diffX: number;
- totalWidth: number;
+ visibleCardCount: number;
gap: number;
isEndCard: (latestX: number) => boolean;
+ isReverseCards?: boolean;
}
export function TransitionCasperCards({
cardList,
initialX,
diffX,
- totalWidth,
gap,
+ visibleCardCount,
isEndCard,
+ isReverseCards = false,
}: TransitionCasperCardsProps) {
+ const isAnimated = visibleCardCount <= cardList.length;
+ const expandedCardList = useMemo(() => [...cardList, ...cardList, ...cardList], [cardList]);
+
const containerRef = useRef(null);
const transitionControls = useAnimation();
const [x, setX] = useState(initialX);
+ const [visibleCardListIdx, setVisibleCardListIdx] = useState(0);
- const startAnimation = (x: number) => {
- transitionControls.start({
- x: [x, x + diffX],
- transition: CARD_TRANSITION(cardList.length),
- });
- };
+ const startAnimation = useCallback(
+ (x: number) => {
+ transitionControls.start({
+ x: [x, x + diffX * 2],
+ transition: CARD_TRANSITION(visibleCardCount * 2),
+ });
+ },
+ [visibleCardCount, transitionControls]
+ );
- const stopAnimation = () => {
+ const stopAnimation = useCallback(() => {
transitionControls.stop();
if (containerRef.current) {
const computedStyle = window.getComputedStyle(containerRef.current);
const matrix = new DOMMatrix(computedStyle.transform);
setX(matrix.m41);
}
- };
+ }, [transitionControls, containerRef]);
+
+ const visibleCardList = useMemo(() => {
+ const list = expandedCardList.slice(
+ visibleCardListIdx,
+ visibleCardListIdx + visibleCardCount * 2
+ );
+
+ if (isAnimated && isReverseCards) {
+ return list.reverse();
+ }
+
+ return isAnimated ? list : cardList;
+ }, [
+ isReverseCards,
+ expandedCardList,
+ cardList,
+ isAnimated,
+ visibleCardCount,
+ visibleCardListIdx,
+ ]);
useEffect(() => {
startAnimation(x);
- }, [transitionControls, totalWidth]);
+ }, []);
- const renderCardItem = (cardItem: CasperCardType, id: string) => {
- const [isFlipped, setIsFlipped] = useState(false);
- const { isInView, cardRef } = useLazyLoading();
+ const handleUpdateAnimation = (latest: ResolvedValues) => {
+ if (isEndCard(parseInt(String(latest.x)))) {
+ let nextIdx = visibleCardListIdx + visibleCardCount;
- const handleMouseEnter = () => {
- stopAnimation();
- setIsFlipped(true);
- };
+ // 만약 nextIdx가 cardList의 길이를 초과하면 배열의 처음부터 다시 index를 카운트하도록 함
+ if (nextIdx >= cardList.length) {
+ nextIdx = nextIdx % cardList.length;
+ }
- const handleMouseLeave = () => {
- startAnimation(x);
- setIsFlipped(false);
- };
-
- return (
-
- {isInView && (
-
- )}
-
- );
+ setVisibleCardListIdx(nextIdx);
+ startAnimation(initialX);
+ }
};
return (
- {
- if (isEndCard(parseInt(String(latest.x)))) {
- startAnimation(initialX);
- }
- }}
- >
- {cardList.map((card) => renderCardItem(card, `${card.id}`))}
- {cardList.map((card) => renderCardItem(card, `${card.id}-clone`))}
-
+ {isAnimated ? (
+
+ {visibleCardList.map((card, idx) => (
+ startAnimation(x)}
+ />
+ ))}
+
+ ) : (
+
+ {visibleCardList.map((card, idx) => (
+
+ ))}
+
+ )}
);
}
diff --git a/client/src/features/Lottery/Headline.tsx b/client/src/features/Lottery/Headline.tsx
index 2443b71e..0acd1958 100644
--- a/client/src/features/Lottery/Headline.tsx
+++ b/client/src/features/Lottery/Headline.tsx
@@ -7,9 +7,10 @@ import { SectionKeyProps } from "@/types/sections.ts";
interface HeadlineProps extends SectionKeyProps {
handleClickShortCutButton: () => void;
+ handleClickScroll: () => void;
}
-function Headline({ id, handleClickShortCutButton }: HeadlineProps) {
+function Headline({ id, handleClickShortCutButton, handleClickScroll }: HeadlineProps) {
return (
-
+
캐스퍼 일렉트릭 봇이 어디서 왔는지 궁금하다면
스크롤
해보세요
diff --git a/client/src/features/Lottery/Intro.tsx b/client/src/features/Lottery/Intro.tsx
index 240a91b9..4a8e4bf3 100644
--- a/client/src/features/Lottery/Intro.tsx
+++ b/client/src/features/Lottery/Intro.tsx
@@ -1,11 +1,11 @@
-import { memo } from "react";
+import { forwardRef, memo } from "react";
import { motion } from "framer-motion";
import { ASCEND, DISSOLVE, SCROLL_MOTION } from "@/constants/animation.ts";
import { SectionKeyProps } from "@/types/sections.ts";
-function Intro({ id }: SectionKeyProps) {
+const Intro = forwardRef(function Intro({ id }, ref) {
return (
-
+
);
-}
+});
const MemoizedIntro = memo(Intro);
export { MemoizedIntro as Intro };
diff --git a/client/src/features/Main/Headline.tsx b/client/src/features/Main/Headline.tsx
index 031b61fb..4f05c807 100644
--- a/client/src/features/Main/Headline.tsx
+++ b/client/src/features/Main/Headline.tsx
@@ -11,7 +11,11 @@ import { SectionKeyProps } from "@/types/sections.ts";
import { GetTotalEventDateResponse } from "@/types/totalApi.ts";
import { formatEventDateRangeWithDot } from "@/utils/formatDate.ts";
-function Headline({ id }: SectionKeyProps) {
+interface HeadlineProps extends SectionKeyProps {
+ handleClickScroll: () => void;
+}
+
+function Headline({ id, handleClickScroll }: HeadlineProps) {
// DATA RESET TEST API
const { fetchData: getRushTodayEventTest } = useFetch(() =>
RushAPI.getRushTodayEventTest()
@@ -54,7 +58,7 @@ function Headline({ id }: SectionKeyProps) {
-
+
이벤트에 대해 궁금하다면
스크롤
해 보세요
diff --git a/client/src/features/Main/Lottery.tsx b/client/src/features/Main/Lottery.tsx
index 34e5cd91..2f0ccb4c 100644
--- a/client/src/features/Main/Lottery.tsx
+++ b/client/src/features/Main/Lottery.tsx
@@ -1,4 +1,4 @@
-import { memo, useEffect } from "react";
+import { forwardRef, memo, useEffect } from "react";
import { Link } from "react-router-dom";
import { LotteryAPI } from "@/apis/lotteryAPI.ts";
import LotteryEvent from "@/components/LotteryEvent";
@@ -10,7 +10,7 @@ import { SectionKeyProps } from "@/types/sections.ts";
import { formatEventDateRangeWithDot } from "@/utils/formatDate.ts";
import ArrowRightIcon from "/public/assets/icons/arrow-line-right.svg?react";
-function Lottery({ id }: SectionKeyProps) {
+const Lottery = forwardRef(function Lottery({ id }, ref) {
const {
data: lotteryData,
isSuccess: isSuccessLottery,
@@ -27,6 +27,7 @@ function Lottery({ id }: SectionKeyProps) {
return (
);
-}
+});
const MemoizedLottery = memo(Lottery);
export { MemoizedLottery as Lottery };
diff --git a/client/src/features/Main/Section.tsx b/client/src/features/Main/Section.tsx
index 4550643f..09e216ed 100644
--- a/client/src/features/Main/Section.tsx
+++ b/client/src/features/Main/Section.tsx
@@ -1,4 +1,4 @@
-import { PropsWithChildren } from "react";
+import { PropsWithChildren, forwardRef } from "react";
import { motion } from "framer-motion";
import CTAButton from "@/components/CTAButton";
import { ASCEND, SCROLL_MOTION } from "@/constants/animation.ts";
@@ -15,21 +15,25 @@ interface SectionProps extends PropsWithChildren, SectionKeyProps {
onClick?: () => void;
}
-export function Section({
- id,
- backgroundColor,
- title,
- titleColor,
- subtitle,
- description,
- descriptionColor,
- children,
- url,
- onClick,
-}: SectionProps) {
+export const Section = forwardRef(function Section(
+ {
+ id,
+ backgroundColor,
+ title,
+ titleColor,
+ subtitle,
+ description,
+ descriptionColor,
+ children,
+ url,
+ onClick,
+ },
+ ref
+) {
return (
);
-}
+});
diff --git a/client/src/features/Rush/Common/Headline.tsx b/client/src/features/Rush/Common/Headline.tsx
index a1c0d5d2..379fb7cf 100644
--- a/client/src/features/Rush/Common/Headline.tsx
+++ b/client/src/features/Rush/Common/Headline.tsx
@@ -11,7 +11,11 @@ import { GetTotalRushEventsResponse } from "@/types/rushApi.ts";
import { SectionKeyProps } from "@/types/sections.ts";
import { getMsTime } from "@/utils/getMsTime.ts";
-export function Headline({ id }: SectionKeyProps) {
+interface HeadlineProps extends SectionKeyProps {
+ handleClickScroll: () => void;
+}
+
+export function Headline({ id, handleClickScroll }: HeadlineProps) {
const rushData = useLoaderData() as GetTotalRushEventsResponse;
const { phoneNumberState, handlePhoneNumberChange, handlePhoneNumberConfirm } =
@@ -63,7 +67,7 @@ export function Headline({ id }: SectionKeyProps) {
-
+
스크롤
하고 캐스퍼 일렉트릭의 놀라운 성능을 알아보세요
diff --git a/client/src/features/Rush/Common/Intro.tsx b/client/src/features/Rush/Common/Intro.tsx
index 1b46a306..41e06263 100644
--- a/client/src/features/Rush/Common/Intro.tsx
+++ b/client/src/features/Rush/Common/Intro.tsx
@@ -1,12 +1,13 @@
-import { memo } from "react";
+import { forwardRef, memo } from "react";
import { motion } from "framer-motion";
import { ASCEND, SCROLL_MOTION } from "@/constants/animation.ts";
import { SectionKeyProps } from "@/types/sections.ts";
-function Intro({ id }: SectionKeyProps) {
+const Intro = forwardRef(function Intro({ id }, ref) {
return (
@@ -29,7 +30,7 @@ function Intro({ id }: SectionKeyProps) {
);
-}
+});
const MemoizedIntro = memo(Intro);
export { MemoizedIntro as Intro };
diff --git a/client/src/hooks/useScrollToTarget.ts b/client/src/hooks/useScrollToTarget.ts
new file mode 100644
index 00000000..46cbd199
--- /dev/null
+++ b/client/src/hooks/useScrollToTarget.ts
@@ -0,0 +1,11 @@
+import { useRef } from "react";
+
+export default function useScrollToTarget() {
+ const targetRef = useRef(null);
+
+ const handleScrollToTarget = () => {
+ targetRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ return { targetRef, handleScrollToTarget };
+}
diff --git a/client/src/pages/CasperShowCase/index.tsx b/client/src/pages/CasperShowCase/index.tsx
index c005e155..0c98833a 100644
--- a/client/src/pages/CasperShowCase/index.tsx
+++ b/client/src/pages/CasperShowCase/index.tsx
@@ -53,7 +53,7 @@ export default function CasperShowCase() {
const { showToast, ToastComponent } = useToast(
isErrorGetShareLink
? "공유 링크 생성에 실패했습니다! 캐스퍼 봇 생성 후 다시 시도해주세요."
- : "링크가 복사되었어요!"
+ : "🔗 링크가 복사되었어요!"
);
const casperList = useLoaderData() as GetCasperListResponse;
@@ -79,16 +79,32 @@ export default function CasperShowCase() {
id={CASPER_SHOWCASE_SECTIONS.SHOWCASE}
className="flex flex-col justify-center items-center gap-800 w-full h-screen bg-n-neutral-950 overflow-hidden pt-1000"
>
-
-
- 카드 위에 커서를 올리면 기대평을 볼 수 있어요
-
+ {cardListData.length === 0 ? (
+
+
+ 나만의 캐스퍼 일렉트릭 봇을 만들어주세요!
+
+
+
+ ) : (
+
+
+ 카드 위에 커서를 올리면 기대평을 볼 수 있어요
+
-
-
+
+
+ )}
diff --git a/client/src/pages/Lottery/index.tsx b/client/src/pages/Lottery/index.tsx
index 766bead7..9c6922bc 100644
--- a/client/src/pages/Lottery/index.tsx
+++ b/client/src/pages/Lottery/index.tsx
@@ -17,6 +17,7 @@ import {
import { useAuth } from "@/hooks/useAuth.ts";
import useHeaderStyleObserver from "@/hooks/useHeaderStyleObserver.ts";
import usePopup from "@/hooks/usePopup";
+import useScrollToTarget from "@/hooks/useScrollToTarget";
import useScrollTop from "@/hooks/useScrollTop";
import useToast from "@/hooks/useToast";
import { GetLotteryResponse } from "@/types/lotteryApi";
@@ -28,6 +29,8 @@ export default function Lottery() {
darkSections: [LOTTERY_SECTIONS.HEADLINE, LOTTERY_SECTIONS.SHORT_CUT],
});
+ const { targetRef, handleScrollToTarget } = useScrollToTarget();
+
const lotteryData = useLoaderData() as GetLotteryResponse;
const { phoneNumberState, handlePhoneNumberChange, handlePhoneNumberConfirm } =
@@ -60,8 +63,9 @@ export default function Lottery() {
-
+
diff --git a/client/src/pages/Main/index.tsx b/client/src/pages/Main/index.tsx
index 05e60f44..ed58bd12 100644
--- a/client/src/pages/Main/index.tsx
+++ b/client/src/pages/Main/index.tsx
@@ -3,6 +3,7 @@ import Notice from "@/components/Notice";
import { MAIN_SECTIONS } from "@/constants/PageSections/sections.ts";
import { Headline, LearnMore, Lottery, Rush } from "@/features/Main";
import useHeaderStyleObserver from "@/hooks/useHeaderStyleObserver.ts";
+import useScrollToTarget from "@/hooks/useScrollToTarget";
import useScrollTop from "@/hooks/useScrollTop.tsx";
export default function Main() {
@@ -11,10 +12,12 @@ export default function Main() {
darkSections: [MAIN_SECTIONS.LOTTERY, MAIN_SECTIONS.LEARN_MORE],
});
+ const { targetRef, handleScrollToTarget } = useScrollToTarget();
+
return (
-
-
+
+
diff --git a/client/src/pages/Rush/index.tsx b/client/src/pages/Rush/index.tsx
index 43db10e0..0f8e5c00 100644
--- a/client/src/pages/Rush/index.tsx
+++ b/client/src/pages/Rush/index.tsx
@@ -17,6 +17,7 @@ import {
ReasonSecond,
} from "@/features/Rush";
import useHeaderStyleObserver from "@/hooks/useHeaderStyleObserver.ts";
+import useScrollToTarget from "@/hooks/useScrollToTarget";
import useScrollTop from "@/hooks/useScrollTop.tsx";
export default function Rush() {
@@ -25,10 +26,12 @@ export default function Rush() {
darkSections: [RUSH_SECTIONS.INTRO],
});
+ const { targetRef, handleScrollToTarget } = useScrollToTarget
();
+
return (
-
-
+
+
diff --git a/client/src/types/casper.ts b/client/src/types/casper.ts
new file mode 100644
index 00000000..7a358979
--- /dev/null
+++ b/client/src/types/casper.ts
@@ -0,0 +1,8 @@
+import { SelectedCasperIdxType } from "./casperCustom";
+
+export interface CasperCardType {
+ id: number;
+ casperName: string;
+ expectations: string;
+ selectedCasperIdx: SelectedCasperIdxType;
+}