diff --git a/package.json b/package.json index 00f3c70..5d9a3eb 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "dependencies": { "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", + "@gsap/react": "^2.1.2", "@tanstack/react-query": "^5.24.1", "axios": "^1.6.7", "dotenv": "^16.4.5", "es-toolkit": "^1.4.0", + "gsap": "^3.12.7", "jotai": "^2.9.0", "jsdoc-builder": "^0.0.6", "lodash": "^4.17.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f831d50..924c8eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: es-toolkit: specifier: ^1.4.0 version: 1.4.0 + gsap: + specifier: ^3.12.7 + version: 3.12.7 jotai: specifier: ^2.9.0 version: 2.9.0(@types/react@18.2.61)(react@18.2.0) @@ -2664,4 +2667,4 @@ packages: '@types/react': 18.2.61 react: 18.2.0 use-sync-external-store: 1.2.0(react@18.2.0) - dev: false + dev: false \ No newline at end of file diff --git a/src/app/home/index.tsx b/src/app/home/index.tsx index 7757298..efb1387 100644 --- a/src/app/home/index.tsx +++ b/src/app/home/index.tsx @@ -1,5 +1,8 @@ import Button from "@/components/common/Button"; import Lottie from "lottie-react"; +import gsap from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useGSAP } from "@gsap/react"; import { BREAK_POINTS, DESIGN_SYSTEM_COLOR } from "@/style/variable"; import { css } from "@emotion/react"; import { @@ -21,13 +24,15 @@ import MAINBOTTOMIMG from "@/assets/main-bottom.gif"; import NewletterAni from "@/assets/newletter.json"; import CommunityAni from "@/assets/community.json"; import { useGetChartDataQuery } from "@/api/chartApi"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useModal } from "@/hooks/useModal.ts"; import { useAtom } from "jotai"; import { loginState } from "@/store/user"; import { SubscriptionModalContent } from "@/components/common/modal/SubscriptionModalContent"; import FloatingWidget from "@/components/app/home/floating/floatingWidget"; +import { useApiTotalView } from "@/hooks/api/visitor/useApiTotalView"; +import { VisitorCount } from "@/components/app/home/VisitorCount"; type CoinInfoType = { [coin in "BTC" | "ETH" | "XRP"]: { @@ -37,6 +42,8 @@ type CoinInfoType = { }; }; +gsap.registerPlugin(ScrollTrigger); + export default function HomePage() { // 차트 데이터 가지고 오기 const getBTCData = useGetChartDataQuery("BTC"); @@ -48,6 +55,77 @@ export default function HomePage() { const { open, close } = useModal(); const navigate = useNavigate(); + const { data: totalView, refetch, sendTotalView } = useApiTotalView(); + const topNumberRef = useRef(null); + const bottomNumberRef = useRef(null); + const topSectionRef = useRef(null); + const bottomSectionRef = useRef(null); + const [currentNum, setCurrentNum] = useState(0); + const [animationTotal, setAnimationTotal] = useState(0); + + const slotAnimation = async () => { + if (!topNumberRef.current || !bottomNumberRef.current) return; + + await refetch(); + + setCurrentNum(0); + + let current = 0; + const step = Math.ceil(animationTotal / 20000); + + const animate = () => { + current += step; + if (current >= animationTotal) { + setCurrentNum(animationTotal); + setCurrentNum(animationTotal.toLocaleString()); + return; + } + + setCurrentNum(current.toLocaleString()); + requestAnimationFrame(animate); + }; + + requestAnimationFrame(animate); + }; + + useGSAP(() => { + if (!topSectionRef.current || !bottomSectionRef.current) { + return; + } + + const ctx = gsap.context(() => { + ScrollTrigger.create({ + trigger: topSectionRef.current, + start: "top 5%", + end: "top 5%", + toggleActions: "restart complete restart reset", + onEnter: () => slotAnimation(), + onEnterBack: () => slotAnimation(), + }); + + ScrollTrigger.create({ + trigger: bottomSectionRef.current, + start: "bottom 80%", + toggleActions: "restart complete restart reset", + onEnter: () => slotAnimation(), + onEnterBack: () => slotAnimation(), + }); + + slotAnimation(); + }); + + return () => ctx.revert(); + }, { dependencies: [animationTotal] }); + + useEffect(() => { + sendTotalView(); + }, []); + + useEffect(() => { + if (!totalView) return; + setAnimationTotal(totalView.data); + }, [totalView]); + useEffect(() => { if (!getBTCData.isSuccess || !getETHData.isSuccess || !getXRPData.isSuccess) return; @@ -161,10 +239,16 @@ export default function HomePage() { > {/* LEFT SIDE */}
+

+ {/* 하단 배너 */} +
+ +
); } diff --git a/src/assets/black_ether.svg b/src/assets/black_ether.svg new file mode 100644 index 0000000..c43acb8 --- /dev/null +++ b/src/assets/black_ether.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/green_bit.svg b/src/assets/green_bit.svg new file mode 100644 index 0000000..a6a8195 --- /dev/null +++ b/src/assets/green_bit.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/orange_bit.svg b/src/assets/orange_bit.svg new file mode 100644 index 0000000..ac10b98 --- /dev/null +++ b/src/assets/orange_bit.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/skyblue_L.svg b/src/assets/skyblue_L.svg new file mode 100644 index 0000000..0d78d27 --- /dev/null +++ b/src/assets/skyblue_L.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/yellow_bit.svg b/src/assets/yellow_bit.svg new file mode 100644 index 0000000..ed376ce --- /dev/null +++ b/src/assets/yellow_bit.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/app/home/VisitorCount.tsx b/src/components/app/home/VisitorCount.tsx new file mode 100644 index 0000000..f0482ef --- /dev/null +++ b/src/components/app/home/VisitorCount.tsx @@ -0,0 +1,255 @@ +import { css } from "@emotion/react"; +import { BREAK_POINTS, DESIGN_SYSTEM_COLOR, DESIGN_SYSTEM_TEXT } from "@/style/variable"; +import BlackEtherImg from "@/assets/black_ether.svg"; +import SkyblueLImg from "@/assets/skyblue_L.svg"; +import GreenBitImg from "@/assets/green_bit.svg"; +import YellowBitImg from "@/assets/yellow_bit.svg"; +import OrangeBitImg from "@/assets/orange_bit.svg"; + +interface VisitorCountProps { + currentNum: number | string; + numberRef: React.RefObject; + sectionRef?: React.RefObject; + isBottom?: boolean; +} + +interface MessageProps { + currentNum: number | string; + numberRef: React.RefObject; + isBottom?: boolean; +} + +const containerStyle = css` + padding: 12rem 0; + + ${BREAK_POINTS.TABLET} { + padding: 8rem 0; + } + + ${BREAK_POINTS.MOBILE} { + padding: 0; + } +`; + +const visitorCountStyle = css` + ${DESIGN_SYSTEM_TEXT.T2} + position: relative; + width: 100%; + height: 16.4rem; + border-radius: 1.6rem; + background-color: ${DESIGN_SYSTEM_COLOR.BRAND_BLUE}; + color: ${DESIGN_SYSTEM_COLOR.GRAY_50}; + display: flex; + justify-content: center; + align-items: center; + text-align: center; + + ${BREAK_POINTS.TABLET} { + ${DESIGN_SYSTEM_TEXT.T4} + height: 14rem; + } + + ${BREAK_POINTS.MOBILE} { + ${DESIGN_SYSTEM_TEXT.CAPTION} + height: 7.2rem; + border-radius: 1rem; + } +`; + +const countNumberStyle = css` + color: #79deff; +`; + +const blackEtherStyle = css` + position: absolute; + bottom: 2px; + left: 16%; + + ${BREAK_POINTS.TABLET} { + width: 7rem; + } + + ${BREAK_POINTS.MOBILE} { + width: 3rem; + } +`; + +const skyblueLStyle = css` + position: absolute; + top: 20%; + left: 6%; + + ${BREAK_POINTS.TABLET} { + left: 3%; + width: 6rem; + } + + ${BREAK_POINTS.MOBILE} { + left: 2px; + top: 5%; + width: 3rem; + } +`; + +const greenBitStyle = css` + position: absolute; + top: 0px; + left: 27%; + + ${BREAK_POINTS.TABLET} { + width: 4rem; + } + + ${BREAK_POINTS.MOBILE} { + width: 3rem; + } +`; + +const yellowBitStyle = css` + position: absolute; + top: 0px; + left: 78%; + + ${BREAK_POINTS.TABLET} { + width: 8rem; + } + + ${BREAK_POINTS.MOBILE} { + width: 5rem; + } +`; + +const orangeBitStyle = css` + position: absolute; + bottom: 0px; + left: 82%; + + ${BREAK_POINTS.TABLET} { + width: 8rem; + } + + ${BREAK_POINTS.MOBILE} { + width: 5rem; + } +`; + +const topContainerStyle = css` + display: flex; + justify-content: space-between; + + ${BREAK_POINTS.TABLET} { + flex-direction: column; + } + + ${BREAK_POINTS.MOBILE} { + flex-direction: column; + } +`; + +const topVisitorCountStyle = css` + position: relative; + width: 36.5rem; + height: 4rem; + padding: 0px; + background: ${DESIGN_SYSTEM_COLOR.BLUE_GRAY_700}; + color: ${DESIGN_SYSTEM_COLOR.BLUE_GRAY_50}; + border-radius: 1rem; + display: flex; + justify-content: center; + align-items: center; + + &::after { + content: ""; + position: absolute; + width: 22px; + height: 20px; + background: ${DESIGN_SYSTEM_COLOR.BLUE_GRAY_700}; + clip-path: path("M0,0 L22,0 L13,16 Q11,20 9,16 L0,0"); + display: block; + z-index: 1; + margin-left: -2.6rem; + bottom: -1rem; + left: 13%; + } +`; + +const topCountNumberStyle = css` + color: ${DESIGN_SYSTEM_COLOR.BRAND_OCEAN}; +`; + +const Message = ({ currentNum, numberRef, isBottom = false }: MessageProps) => { + return ( +

+ 지금까지 플로우빗의 예측가격이{" "} + + {currentNum.toLocaleString()} + + 번 조회됐어요 + {isBottom && ( + <> +
+ 지금 바로 시작하세요! + + )} +

+ ); +}; + +export const VisitorCount = ({ + currentNum, + numberRef, + sectionRef, + isBottom = false, +}: VisitorCountProps) => { + if (isBottom) { + return ( +
+
+ + + + + + +
+
+ ); + } + + return ( +
+
+ +
+
+ ); +}; diff --git a/src/hooks/api/visitor/useApiTotalView.ts b/src/hooks/api/visitor/useApiTotalView.ts new file mode 100644 index 0000000..a6184de --- /dev/null +++ b/src/hooks/api/visitor/useApiTotalView.ts @@ -0,0 +1,24 @@ +import { api } from "@/api"; +import { useQuery } from "@tanstack/react-query"; + +export const useApiTotalView = () => { + const GetTotalView = async () => { + const response = await api.get("/user-service/api/v1/visitor/total-view"); + return response.data; + }; + + const sendTotalView = async () => { + const response = await api.post("/user-service/api/v1/visitor"); + return response.data; + }; + + return { + ...useQuery({ + queryKey: ['totalVisitors'], + queryFn: () => GetTotalView(), + staleTime: 60000 * 60 * 2, // 2시간 + gcTime: 60000 * 60 * 2, // 2시간 + }), + sendTotalView, // POST 요청 함수 반환 +}; +};