diff --git a/public/1Stars.webp b/public/1Stars.webp new file mode 100644 index 00000000..7082f36c Binary files /dev/null and b/public/1Stars.webp differ diff --git a/public/2Stars.webp b/public/2Stars.webp new file mode 100644 index 00000000..5a0a1ff7 Binary files /dev/null and b/public/2Stars.webp differ diff --git a/public/CocktailDrop_4x.webp b/public/CocktailDrop_4x.webp new file mode 100644 index 00000000..1f400b51 Binary files /dev/null and b/public/CocktailDrop_4x.webp differ diff --git a/src/app/design-system/page.tsx b/src/app/(no-layout)/design-system/page.tsx similarity index 100% rename from src/app/design-system/page.tsx rename to src/app/(no-layout)/design-system/page.tsx diff --git a/src/app/(no-layout)/layout.tsx b/src/app/(no-layout)/layout.tsx new file mode 100644 index 00000000..6a94947f --- /dev/null +++ b/src/app/(no-layout)/layout.tsx @@ -0,0 +1,4 @@ +function NoLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} +export default NoLayout; diff --git a/src/app/(no-layout)/page.tsx b/src/app/(no-layout)/page.tsx new file mode 100644 index 00000000..1e94c4b0 --- /dev/null +++ b/src/app/(no-layout)/page.tsx @@ -0,0 +1,9 @@ +import FinalLanding from '@/domains/main/components/FinalLanding'; + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/src/app/community/[id]/loading.tsx b/src/app/(with-layout)/community/[id]/loading.tsx similarity index 100% rename from src/app/community/[id]/loading.tsx rename to src/app/(with-layout)/community/[id]/loading.tsx diff --git a/src/app/community/[id]/page.tsx b/src/app/(with-layout)/community/[id]/page.tsx similarity index 100% rename from src/app/community/[id]/page.tsx rename to src/app/(with-layout)/community/[id]/page.tsx diff --git a/src/app/community/edit/[postId]/page.tsx b/src/app/(with-layout)/community/edit/[postId]/page.tsx similarity index 100% rename from src/app/community/edit/[postId]/page.tsx rename to src/app/(with-layout)/community/edit/[postId]/page.tsx diff --git a/src/app/community/loading.tsx b/src/app/(with-layout)/community/loading.tsx similarity index 100% rename from src/app/community/loading.tsx rename to src/app/(with-layout)/community/loading.tsx diff --git a/src/app/community/page.tsx b/src/app/(with-layout)/community/page.tsx similarity index 100% rename from src/app/community/page.tsx rename to src/app/(with-layout)/community/page.tsx diff --git a/src/app/community/write/loading.tsx b/src/app/(with-layout)/community/write/loading.tsx similarity index 100% rename from src/app/community/write/loading.tsx rename to src/app/(with-layout)/community/write/loading.tsx diff --git a/src/app/community/write/page.tsx b/src/app/(with-layout)/community/write/page.tsx similarity index 100% rename from src/app/community/write/page.tsx rename to src/app/(with-layout)/community/write/page.tsx diff --git a/src/app/(with-layout)/layout.tsx b/src/app/(with-layout)/layout.tsx new file mode 100644 index 00000000..6e337d69 --- /dev/null +++ b/src/app/(with-layout)/layout.tsx @@ -0,0 +1,15 @@ +import ClientInitHook from '@/domains/login/components/ClientInitHook'; +import FooterWrapper from '@/shared/components/footer/FooterWrapper'; +import Header from '@/shared/components/header/Header'; + +function LayoutWithHeaderFooter({ children }: { children: React.ReactNode }) { + return ( + <> +
+ +
{children}
+ + + ); +} +export default LayoutWithHeaderFooter; diff --git a/src/app/login/page.tsx b/src/app/(with-layout)/login/page.tsx similarity index 100% rename from src/app/login/page.tsx rename to src/app/(with-layout)/login/page.tsx diff --git a/src/app/login/success/page.tsx b/src/app/(with-layout)/login/success/page.tsx similarity index 100% rename from src/app/login/success/page.tsx rename to src/app/(with-layout)/login/success/page.tsx diff --git a/src/app/login/user/first-user/page.tsx b/src/app/(with-layout)/login/user/first-user/page.tsx similarity index 100% rename from src/app/login/user/first-user/page.tsx rename to src/app/(with-layout)/login/user/first-user/page.tsx diff --git a/src/app/login/user/success/page.tsx b/src/app/(with-layout)/login/user/success/page.tsx similarity index 100% rename from src/app/login/user/success/page.tsx rename to src/app/(with-layout)/login/user/success/page.tsx diff --git a/src/app/mypage/layout.tsx b/src/app/(with-layout)/mypage/layout.tsx similarity index 100% rename from src/app/mypage/layout.tsx rename to src/app/(with-layout)/mypage/layout.tsx diff --git a/src/app/mypage/my-active/my-comment/loading.tsx b/src/app/(with-layout)/mypage/my-active/my-comment/loading.tsx similarity index 100% rename from src/app/mypage/my-active/my-comment/loading.tsx rename to src/app/(with-layout)/mypage/my-active/my-comment/loading.tsx diff --git a/src/app/mypage/my-active/my-comment/page.tsx b/src/app/(with-layout)/mypage/my-active/my-comment/page.tsx similarity index 100% rename from src/app/mypage/my-active/my-comment/page.tsx rename to src/app/(with-layout)/mypage/my-active/my-comment/page.tsx diff --git a/src/app/mypage/my-active/my-like/loading.tsx b/src/app/(with-layout)/mypage/my-active/my-like/loading.tsx similarity index 100% rename from src/app/mypage/my-active/my-like/loading.tsx rename to src/app/(with-layout)/mypage/my-active/my-like/loading.tsx diff --git a/src/app/mypage/my-active/my-like/page.tsx b/src/app/(with-layout)/mypage/my-active/my-like/page.tsx similarity index 100% rename from src/app/mypage/my-active/my-like/page.tsx rename to src/app/(with-layout)/mypage/my-active/my-like/page.tsx diff --git a/src/app/mypage/my-active/my-post/loading.tsx b/src/app/(with-layout)/mypage/my-active/my-post/loading.tsx similarity index 100% rename from src/app/mypage/my-active/my-post/loading.tsx rename to src/app/(with-layout)/mypage/my-active/my-post/loading.tsx diff --git a/src/app/mypage/my-active/my-post/page.tsx b/src/app/(with-layout)/mypage/my-active/my-post/page.tsx similarity index 100% rename from src/app/mypage/my-active/my-post/page.tsx rename to src/app/(with-layout)/mypage/my-active/my-post/page.tsx diff --git a/src/app/mypage/my-active/page.tsx b/src/app/(with-layout)/mypage/my-active/page.tsx similarity index 100% rename from src/app/mypage/my-active/page.tsx rename to src/app/(with-layout)/mypage/my-active/page.tsx diff --git a/src/app/mypage/my-alarm/loading.tsx b/src/app/(with-layout)/mypage/my-alarm/loading.tsx similarity index 100% rename from src/app/mypage/my-alarm/loading.tsx rename to src/app/(with-layout)/mypage/my-alarm/loading.tsx diff --git a/src/app/mypage/my-alarm/page.tsx b/src/app/(with-layout)/mypage/my-alarm/page.tsx similarity index 100% rename from src/app/mypage/my-alarm/page.tsx rename to src/app/(with-layout)/mypage/my-alarm/page.tsx diff --git a/src/app/mypage/my-bar/loading.tsx b/src/app/(with-layout)/mypage/my-bar/loading.tsx similarity index 100% rename from src/app/mypage/my-bar/loading.tsx rename to src/app/(with-layout)/mypage/my-bar/loading.tsx diff --git a/src/app/mypage/my-bar/page.tsx b/src/app/(with-layout)/mypage/my-bar/page.tsx similarity index 100% rename from src/app/mypage/my-bar/page.tsx rename to src/app/(with-layout)/mypage/my-bar/page.tsx diff --git a/src/app/mypage/my-setting/loading.tsx b/src/app/(with-layout)/mypage/my-setting/loading.tsx similarity index 100% rename from src/app/mypage/my-setting/loading.tsx rename to src/app/(with-layout)/mypage/my-setting/loading.tsx diff --git a/src/app/mypage/my-setting/page.tsx b/src/app/(with-layout)/mypage/my-setting/page.tsx similarity index 100% rename from src/app/mypage/my-setting/page.tsx rename to src/app/(with-layout)/mypage/my-setting/page.tsx diff --git a/src/app/mypage/page.tsx b/src/app/(with-layout)/mypage/page.tsx similarity index 100% rename from src/app/mypage/page.tsx rename to src/app/(with-layout)/mypage/page.tsx diff --git a/src/app/recipe/[id]/page.tsx b/src/app/(with-layout)/recipe/[id]/page.tsx similarity index 100% rename from src/app/recipe/[id]/page.tsx rename to src/app/(with-layout)/recipe/[id]/page.tsx diff --git a/src/app/recipe/page.tsx b/src/app/(with-layout)/recipe/page.tsx similarity index 100% rename from src/app/recipe/page.tsx rename to src/app/(with-layout)/recipe/page.tsx diff --git a/src/app/recommend/loading.tsx b/src/app/(with-layout)/recommend/loading.tsx similarity index 100% rename from src/app/recommend/loading.tsx rename to src/app/(with-layout)/recommend/loading.tsx diff --git a/src/app/recommend/page.tsx b/src/app/(with-layout)/recommend/page.tsx similarity index 100% rename from src/app/recommend/page.tsx rename to src/app/(with-layout)/recommend/page.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 98c6f3c7..5edb3bdc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -28,12 +28,12 @@ export default function RootLayout({ -
-
{children}
- + + {children} + - - - - ); -} diff --git a/src/domains/main/cocktailDrop/CocktailDrop.tsx b/src/domains/main/cocktailDrop/CocktailDrop.tsx new file mode 100644 index 00000000..dc714918 --- /dev/null +++ b/src/domains/main/cocktailDrop/CocktailDrop.tsx @@ -0,0 +1,104 @@ +'use client'; + +import Image from 'next/image'; +import Cocktailcup from '../../../../public/CocktailDrop_4x.webp'; +import { useLayoutEffect, useRef } from 'react'; +import gsap from 'gsap'; +import { ScrollTrigger } from 'gsap/all'; + +gsap.registerPlugin(ScrollTrigger); + +function CocktailDrop() { + const containerRef = useRef(null); + const logoRef = useRef(null); + const line1Ref = useRef(null); + const line2Ref = useRef(null); + + useLayoutEffect(() => { + const ctx = gsap.context(() => { + // 양쪽 대각선 줄 들어오기 (line1, line2) + gsap.fromTo( + [line1Ref.current, line2Ref.current], + { + x: (i) => (i === 0 ? '-100%' : '100%'), + opacity: 0, + }, + { + x: '0%', + opacity: 1, + ease: 'power4.out', + duration: 1.2, + stagger: 0.2, + scrollTrigger: { + trigger: containerRef.current, + // markers: true, // ✅ 디버 + start: 'top 95%', + toggleActions: 'restart none none none', + once: false, + }, + } + ); + + // 로고 위에서 아래로 자연스럽게 등장 + gsap.fromTo( + logoRef.current, + { y: -300, opacity: 0 }, + { + y: -40, + opacity: 1, + duration: 3, + ease: 'power3.out', + scrollTrigger: { + trigger: containerRef.current, + // markers: true, // ✅ 디버 + start: 'top 90%', + toggleActions: 'restart none none none', + once: false, + }, + } + ); + ScrollTrigger.refresh(); + }, containerRef); + + return () => ctx.revert(); + }, [containerRef]); + + return ( +
+ {/* 대각선 줄 1 */} +
+ {/* 대각선 줄 2 */} +
+ + {/* 로고 */} +
+ 로고 이미지 +
+ +
+ + {/* 컵 이미지 */} +
+ 칵테일 컵 +
+
+ ); +} + +export default CocktailDrop; diff --git a/src/domains/main/components/3d/HomeBackground.tsx b/src/domains/main/components/3d/HomeBackground.tsx new file mode 100644 index 00000000..0f45736c --- /dev/null +++ b/src/domains/main/components/3d/HomeBackground.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react'; + +function HomeBackground() { + const bgRef = useRef(null); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const x = e.clientX / window.innerWidth; + const percentage = 6 + x * 70; + if (bgRef.current) { + bgRef.current.style.background = `linear-gradient(128deg, rgba(26, 26, 26, 0.7) ${percentage}%, rgba(42, 42, 42, 0.3) ${percentage + 10}%, rgba(60, 70, 78, 0) 100%)`; + } + }; + + window.addEventListener('mousemove', handleMouseMove); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, []); + + return ( +
+ ); +} + +export default HomeBackground; diff --git a/src/domains/main/components/3d/HomeLogo.tsx b/src/domains/main/components/3d/HomeLogo.tsx new file mode 100644 index 00000000..b80a8707 --- /dev/null +++ b/src/domains/main/components/3d/HomeLogo.tsx @@ -0,0 +1,14 @@ +import Image from 'next/image'; + +function HomeLogo({ isDesktop }: { isDesktop: boolean }) { + return ( +
+ 로고 이미지 +
+ ); +} + +export default HomeLogo; diff --git a/src/domains/main/components/3d/HomeModel.tsx b/src/domains/main/components/3d/HomeModel.tsx new file mode 100644 index 00000000..9d7a0d00 --- /dev/null +++ b/src/domains/main/components/3d/HomeModel.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Environment, OrbitControls, useGLTF } from '@react-three/drei'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { Bloom, EffectComposer } from '@react-three/postprocessing'; +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +interface Props { + onLoaded: () => void; +} + +function Model({ onLoaded }: Props) { + const { scene } = useGLTF('/3d/model/scene.gltf'); + + useEffect(() => { + if (scene) { + onLoaded(); // 모델이 로드되면 부모에게 알림 + } + }, [scene]); + + if (!scene) return null; // 로딩 전 대기 처리 + + scene.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh; + const material = mesh.material as THREE.MeshPhysicalMaterial; + + material.envMapIntensity = 3; + material.metalness = 1; + material.roughness = 0.3; + material.emissiveIntensity = 2; + material.clearcoat = 1; + material.clearcoatRoughness = 0.2; + material.needsUpdate = true; + material.opacity = 0.35; + material.bumpScale = 0.3; + material.thickness = 0.1; + } + }); + + return ; +} + +function CameraAnimation() { + const { camera } = useThree(); + const targetPosition = new THREE.Vector3(5, 10, 10); // 최종 위치 + const startPosition = new THREE.Vector3(0, 15, 6); // 시작 위치 + const progress = useRef(0); + + useFrame((state, delta) => { + if (progress.current < 1) { + progress.current += delta / 5; // 3초 동안 + const t = Math.min(progress.current, 1); + camera.position.lerpVectors(startPosition, targetPosition, t); + } + }); + + return null; +} + +function HomeModel({ onLoaded }: Props) { + return ( + + + + + + + + {/* */} + + + + + + + ); +} + +export default HomeModel; diff --git a/src/domains/main/components/3d/HomeText.tsx b/src/domains/main/components/3d/HomeText.tsx new file mode 100644 index 00000000..64ec5f48 --- /dev/null +++ b/src/domains/main/components/3d/HomeText.tsx @@ -0,0 +1,17 @@ +function HomeText({ isDesktop }: { isDesktop: boolean }) { + return ( + <> + {!isDesktop ? ( +

+ 어떤 칵테일이 끌리시나요? SSoul이 쉽게 골라드릴게요. +

+ ) : ( +

+ 어떤 칵테일이 끌리시나요? SSoul이 쉽게 골라드릴게요. +

+ )} + + ); +} + +export default HomeText; diff --git a/src/domains/main/components/3d/Landing.tsx b/src/domains/main/components/3d/Landing.tsx new file mode 100644 index 00000000..4f480cfb --- /dev/null +++ b/src/domains/main/components/3d/Landing.tsx @@ -0,0 +1,42 @@ +'use client'; + +import HomeModel from './HomeModel'; +import HomeLogo from './HomeLogo'; +import HomeText from './HomeText'; +import Scroll from './Scroll'; +import { useEffect, useState } from 'react'; +import ModelImage from './ModelImage'; + +interface Props { + setIsLoading: (value: boolean) => void; + isDesktop: boolean; +} + +function Landing({ setIsLoading, isDesktop }: Props) { + const [modelLoaded, setModelLoaded] = useState(false); + useEffect(() => { + if (modelLoaded) setIsLoading(false); + }, [modelLoaded, setIsLoading]); + return ( + <> +
+
+ {isDesktop ? ( + setModelLoaded(true)} /> + ) : ( + setModelLoaded(true)} /> + )} + {modelLoaded && ( + <> + + + + + )} +
+
+ + ); +} + +export default Landing; diff --git a/src/domains/main/components/3d/ModelImage.tsx b/src/domains/main/components/3d/ModelImage.tsx new file mode 100644 index 00000000..1f37f1e6 --- /dev/null +++ b/src/domains/main/components/3d/ModelImage.tsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; + +interface Props { + onLoaded: () => void; +} + +function ModelImage({ onLoaded }: Props) { + useEffect(() => { + onLoaded(); + }); + return
; +} + +export default ModelImage; diff --git a/src/domains/main/components/3d/Scroll.tsx b/src/domains/main/components/3d/Scroll.tsx new file mode 100644 index 00000000..0f289fe2 --- /dev/null +++ b/src/domains/main/components/3d/Scroll.tsx @@ -0,0 +1,23 @@ +import Lottie from 'lottie-react'; +import scroll from '@/shared/assets/lottie/ScrollDownAnimation.json'; + +function Scroll({ isDesktop }: { isDesktop: boolean }) { + const style = !isDesktop + ? { + width: 45, + height: 45, + } + : { + width: 60, + height: 60, + }; + return ( +
+
+ +
+
+ ); +} + +export default Scroll; diff --git a/src/domains/main/components/3d/StarMain.tsx b/src/domains/main/components/3d/StarMain.tsx new file mode 100644 index 00000000..575ef286 --- /dev/null +++ b/src/domains/main/components/3d/StarMain.tsx @@ -0,0 +1,96 @@ +'use client'; + +import Image from 'next/image'; +import foreStar from '../../../../../public/1Stars.webp'; +import backStar from '../../../../../public/2Stars.webp'; +import { useEffect, useRef } from 'react'; +import gsap from 'gsap'; + +function StarMain() { + // const background = useRef(null); + // const foreground = useRef(null); + // const mouse = useRef({ x: 0, y: 0 }); + // const rafId = useRef(null); + + // useEffect(() => { + // if (!background.current || !foreground.current) return; + + // const bgX = gsap.quickSetter(background.current, 'x', 'px'); + // const bgY = gsap.quickSetter(background.current, 'y', 'px'); + // const bgRotate = gsap.quickSetter(background.current, 'rotate', 'deg'); + + // const fgX = gsap.quickSetter(foreground.current, 'x', 'px'); + // const fgY = gsap.quickSetter(foreground.current, 'y', 'px'); + // const fgRotate = gsap.quickSetter(foreground.current, 'rotate', 'deg'); + + // const update = () => { + // const { x, y } = mouse.current; + + // bgX(x * -2); + // bgY(y * -2); + // bgRotate(x * -0.2); + + // fgX(x * 3); + // fgY(y * 3); + // fgRotate(y * 0.2); + + // rafId.current = requestAnimationFrame(update); + // }; + + // const handleMouseMove = (e: MouseEvent) => { + // // 화면 중앙 기준으로 얼마나 벗어났는지 (-1 ~ 1 범위) + // const x = (e.clientX / window.innerWidth - 0.5) * 2; + // const y = (e.clientY / window.innerHeight - 0.5) * 2; + // mouse.current = { x, y }; + // }; + + // const handleTouchMove = (e: TouchEvent) => { + // const touch = e.touches[0]; + // const x = (touch.clientX / window.innerWidth - 0.5) * 2; + // const y = (touch.clientY / window.innerHeight - 0.5) * 2; + // mouse.current = { x, y }; + // }; + + // window.addEventListener('mousemove', handleMouseMove); + // window.addEventListener('touchmove', handleTouchMove); + // rafId.current = requestAnimationFrame(update); + + // return () => { + // window.removeEventListener('mousemove', handleMouseMove); + // window.removeEventListener('touchmove', handleTouchMove); + // if (rafId.current) cancelAnimationFrame(rafId.current); + // }; + // }, []); + + return ( + <> + {/* */} +
+
+
+ 앞쪽 별 +
+
+ 뒤쪽 별 +
+
+
+ + ); +} + +export default StarMain; diff --git a/src/domains/main/components/FinalLanding.tsx b/src/domains/main/components/FinalLanding.tsx new file mode 100644 index 00000000..5f79c892 --- /dev/null +++ b/src/domains/main/components/FinalLanding.tsx @@ -0,0 +1,93 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import Landing from './3d/Landing'; +import MainSlide from './mainSlide/components/MainSlide'; +import Spinner from '@/shared/components/spinner/Spinner'; +import gsap from 'gsap'; +import { ScrollSmoother, ScrollTrigger } from 'gsap/all'; +import StarMain from './3d/StarMain'; +import CocktailDrop from '../cocktailDrop/CocktailDrop'; + +function FinalLanding() { + const [isLoading, setIsLoading] = useState(true); + const smootherRef = useRef(null); + + const [isDesktop, setIsDesktop] = useState(false); + + useEffect(() => { + const checkViewport = () => { + setIsDesktop(window.innerWidth >= 1024); + }; + + checkViewport(); + + window.addEventListener('resize', checkViewport); + + return () => { + window.removeEventListener('resize', checkViewport); + }; + }, []); + + useEffect(() => { + gsap.registerPlugin(ScrollTrigger, ScrollSmoother); + + // ScrollSmoother는 클라이언트에서 한 번만 초기화 + if (isDesktop && !smootherRef.current) { + smootherRef.current = ScrollSmoother.create({ + wrapper: '#scroll-wrapper', + content: '#scroll-content', + smooth: 2.0, + normalizeScroll: { + allowNestedScroll: true, + }, + ignoreMobileResize: true, + effects: true, + }); + } + + // ⚠️ 모바일에서 smoother 적용 안 하도록 명시적 분기 + if (!isDesktop && smootherRef.current) { + smootherRef.current.kill(); + smootherRef.current = null; + } + + return () => { + // cleanup: 컴포넌트 언마운트 시 smoother 제거 + smootherRef.current?.kill(); + smootherRef.current = null; + }; + }, [isDesktop]); + + return ( + <> + + {isDesktop ? ( +
+
+ {isLoading && } + + {!isLoading && ( + <> + + + + )} +
+
+ ) : ( +
+ {isLoading && } + + {!isLoading && ( + <> + + + + )} +
+ )} + + ); +} + +export default FinalLanding; diff --git a/src/domains/main/components/mainSlide/components/MainSlide.tsx b/src/domains/main/components/mainSlide/components/MainSlide.tsx index 88085e74..52e2e699 100644 --- a/src/domains/main/components/mainSlide/components/MainSlide.tsx +++ b/src/domains/main/components/mainSlide/components/MainSlide.tsx @@ -8,145 +8,114 @@ import MobileSlide from './mobile/MobileSlide'; import MainSlideIntro from './MainSlideIntro'; import MainSlideTest from './MainSlideTest'; import MainSlideCommunity from './MainSlideCommunity'; -import StarBg from '@/domains/shared/components/star-bg/StarBg'; gsap.registerPlugin(ScrollTrigger); -function MainSlide() { +function MainSlide({ isDesktop }: { isDesktop: boolean }) { const root = useRef(null); - const [isMobile, setIsMobile] = useState(false); + const initialRoot = useRef(null); const [mounted, setMounted] = useState(false); const cleanupFnRef = useRef<(() => void) | null>(null); - const resizeTimeoutRef = useRef(null); - useEffect(() => { - setIsMobile(window.innerWidth < 1024); + useLayoutEffect(() => { setMounted(true); - - const handleResize = () => { - // 디바운스: resize 이벤트를 200ms 지연 - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - - resizeTimeoutRef.current = setTimeout(() => { - const newIsMobile = window.innerWidth < 1024; - - // 모바일 ↔ 데스크탑 전환 시에만 cleanup 실행 - if (newIsMobile !== isMobile) { - // GSAP을 먼저 완전히 정리 - if (cleanupFnRef.current) { - cleanupFnRef.current(); - cleanupFnRef.current = null; - } - - // 상태 업데이트 - setIsMobile(newIsMobile); - } else if (!newIsMobile) { - // 데스크탑 내에서의 리사이즈 - ScrollTrigger refresh - ScrollTrigger.refresh(true); - } - }, 200); - }; - - window.addEventListener('resize', handleResize); - return () => { - window.removeEventListener('resize', handleResize); - if (resizeTimeoutRef.current) { - clearTimeout(resizeTimeoutRef.current); - } - if (cleanupFnRef.current) { - cleanupFnRef.current(); - } - }; - }, [isMobile]); + }, []); // GSAP 초기화 - 데스크탑에서만 useLayoutEffect(() => { - if (!mounted) return; - if (isMobile) return; - if (!root.current) return; + if (!mounted || !isDesktop || !root.current) return; const el = root.current; const stage = el.querySelector('.stage') as HTMLElement; if (!stage) return; - const timer = setTimeout(() => { - if (!root.current) return; - - const ctx = gsap.context(() => { - const panels = Array.from(el.querySelectorAll('.panel')); - const tl = gsap.timeline({ paused: true, defaults: { ease: 'power3.inOut' } }); - - panels.forEach((panel, i) => { - const c = panel.querySelector('.slide-content'); - if (!c) return; - const stageW = () => stage.clientWidth; - const contentW = () => c.getBoundingClientRect().width; - - gsap.set(c, { - x: () => stageW(), - immediateRender: false, - }); - - tl.to( - c, - { - x: () => stageW() - contentW(), - duration: 2, - immediateRender: false, - onStart: () => c.classList.remove('invisible'), - }, - i - ); - }); + const ctx = gsap.context(() => { + // 첫 진입 애니메이션 + gsap.fromTo( + initialRoot.current, + { opacity: 0 }, + { + y: 0, + opacity: 1, + ease: 'power5.out', + scrollTrigger: { + trigger: initialRoot.current, + start: 'top 80%', + end: 'top top', + scrub: 0.2, + }, + } + ); - ScrollTrigger.create({ - trigger: el, - start: 'top top', - end: `+=${panels.length * 100}%`, - pin: true, - scrub: true, - animation: tl, - invalidateOnRefresh: true, - }); + const panels = Array.from(el.querySelectorAll('.panel')); + const tl = gsap.timeline({ paused: true, defaults: { ease: 'power3.inOut' } }); - ScrollTrigger.refresh(); - }, root); + panels.forEach((panel, i) => { + const c = panel.querySelector('.slide-content'); + if (!c) return; + const stageW = () => stage.clientWidth; + const contentW = () => c.getBoundingClientRect().width; - cleanupFnRef.current = () => { - const allTriggers = ScrollTrigger.getAll(); - allTriggers.forEach((st) => { - if (st.trigger === el || el.contains(st.trigger as Node)) { - st.kill(true); - } + gsap.set(c, { + x: () => stageW(), + immediateRender: false, }); - try { - ctx.revert(); - } catch {} - - const pinSpacers = document.querySelectorAll('.pin-spacer'); - pinSpacers.forEach((spacer) => { - if (spacer.contains(el) || el.contains(spacer)) { - const child = spacer.querySelector('section'); - if (child && spacer.parentElement) { - spacer.parentElement.appendChild(child); - } - spacer.remove(); + tl.to( + c, + { + x: () => stageW() - contentW(), + duration: 2, + immediateRender: false, + onStart: () => c.classList.remove('invisible'), + }, + i + ); + }); + + ScrollTrigger.create({ + trigger: el, + start: 'top top', + end: `+=${panels.length * 100}%`, + pin: true, + scrub: true, + animation: tl, + invalidateOnRefresh: true, + }); + + ScrollTrigger.refresh(); + }, root); + + cleanupFnRef.current = () => { + const allTriggers = ScrollTrigger.getAll(); + allTriggers.forEach((st) => { + if (st.trigger === el || el.contains(st.trigger as Node)) { + st.kill(true); + } + }); + + try { + ctx.revert(); + } catch {} + + // pin-spacer 정리 + const pinSpacers = document.querySelectorAll('.pin-spacer'); + pinSpacers.forEach((spacer) => { + if (spacer.contains(el) || el.contains(spacer)) { + const child = spacer.querySelector('section'); + if (child && spacer.parentElement) { + spacer.parentElement.appendChild(child); } - }); - }; - }, 50); + spacer.remove(); + } + }); + }; return () => { - clearTimeout(timer); - if (cleanupFnRef.current) { - cleanupFnRef.current(); - cleanupFnRef.current = null; - } + cleanupFnRef.current?.(); + cleanupFnRef.current = null; }; - }, [isMobile, mounted]); + }, [isDesktop, mounted]); // SSR 방지 if (!mounted) { return null; @@ -154,29 +123,27 @@ function MainSlide() { return ( <> - {isMobile ? ( - - - + {!isDesktop ? ( + ) : ( - -
-
-
+
+
+
+
-
+
-
+
-
+
- +
)} ); diff --git a/src/domains/main/components/mainSlide/components/MainSlideAbv.tsx b/src/domains/main/components/mainSlide/components/MainSlideAbv.tsx index 31113543..4870bf08 100644 --- a/src/domains/main/components/mainSlide/components/MainSlideAbv.tsx +++ b/src/domains/main/components/mainSlide/components/MainSlideAbv.tsx @@ -41,28 +41,30 @@ function MainSlideAbv() { ]; return ( -
-
+
+
3 -
-

- 내 알콜도수 UP -

-

- 5도 부터 시작하는 내 알콜도수
글 작성,댓글,좋아요 / 킵으로 알콜도수 UP!
- 알콜도수에 따라 변하는 쑤리(SSURY)를 보는 재미도 있어요. -

-
-
-
    - {SSURY_DRUNK.map(({ id, src, abv }) => ( -
  • - -
  • - ))} -
-
- +
+
+

+ 내 알콜도수 UP +

+

+ 5도 부터 시작하는 내 알콜도수
글 작성,댓글,좋아요 / 킵으로 알콜도수 UP!
+ 알콜도수에 따라 변하는 쑤리(SSURY)를 보는 재미도 있어요. +

+
+
+
    + {SSURY_DRUNK.map(({ id, src, abv }) => ( +
  • + +
  • + ))} +
+
+ +
diff --git a/src/domains/main/components/mainSlide/components/MainSlideCommunity.tsx b/src/domains/main/components/mainSlide/components/MainSlideCommunity.tsx index fc1b4c3b..f8547b2e 100644 --- a/src/domains/main/components/mainSlide/components/MainSlideCommunity.tsx +++ b/src/domains/main/components/mainSlide/components/MainSlideCommunity.tsx @@ -1,9 +1,9 @@ function MainSlideCommunity() { return ( -
-
-
- 2 +
+
+ 2 +

술술 즐기는, 커뮤니티 diff --git a/src/domains/main/components/mainSlide/components/MainSlideIntro.tsx b/src/domains/main/components/mainSlide/components/MainSlideIntro.tsx index 3733a450..2ff790b7 100644 --- a/src/domains/main/components/mainSlide/components/MainSlideIntro.tsx +++ b/src/domains/main/components/mainSlide/components/MainSlideIntro.tsx @@ -1,15 +1,11 @@ -import background from '@/shared/assets/images/cocktailBg.webp'; -import Image from 'next/image'; - function MainSlideIntro() { return (
- -
-

+
+

칵테일
누구나 쉽게 즐길 수 있어요

-

+

SSOUL의 재밌고 다양한 기능들로 더 친근하게 접해보세요

diff --git a/src/domains/main/components/mainSlide/components/MainSlideTest.tsx b/src/domains/main/components/mainSlide/components/MainSlideTest.tsx index 482e2c1e..3f382e9a 100644 --- a/src/domains/main/components/mainSlide/components/MainSlideTest.tsx +++ b/src/domains/main/components/mainSlide/components/MainSlideTest.tsx @@ -15,10 +15,10 @@ const DUMMY_TEST = [ function MainSlideTest() { return ( -
-
+
+
1 -
+

AI기반 취향테스트 diff --git a/src/domains/main/components/mainSlide/components/MainTestDummy.tsx b/src/domains/main/components/mainSlide/components/MainTestDummy.tsx index f7460351..b3db4a5e 100644 --- a/src/domains/main/components/mainSlide/components/MainTestDummy.tsx +++ b/src/domains/main/components/mainSlide/components/MainTestDummy.tsx @@ -48,7 +48,7 @@ function MainTestDummy({ message, option, type }: Props) {

쑤리

{message && ( -
+

{message}

@@ -69,7 +69,7 @@ function MainTestDummy({ message, option, type }: Props) {
)} {type == 'text' && ( -
+
{DUMMY_CARD.map(({ id, src, cocktailName }) => ( ))} diff --git a/src/domains/main/components/mainSlide/components/mobile/MobileSlide.tsx b/src/domains/main/components/mainSlide/components/mobile/MobileSlide.tsx index 11e4c8e3..9f4c8321 100644 --- a/src/domains/main/components/mainSlide/components/mobile/MobileSlide.tsx +++ b/src/domains/main/components/mainSlide/components/mobile/MobileSlide.tsx @@ -1,24 +1,14 @@ -import background from '@/shared/assets/images/cocktailBg.webp'; import MobileSlideTest from './MobileSlideTest'; import MobileSlideCommunity from './MobileSlideCommunity'; import MobileAbv from './MobileAbv'; function MobileSlide() { return ( -
-

+
+

칵테일
누구나 쉽게 즐길 수 있어요

-

+

SSOUL의 재밌고 다양한 기능들로 더 친근하게 접해보세요

@@ -26,7 +16,7 @@ function MobileSlide() {
-

+
); } export default MobileSlide; diff --git a/src/domains/mypage/components/DeleteAllModal.tsx b/src/domains/mypage/components/DeleteAllModal.tsx index c7720a56..44e16dca 100644 --- a/src/domains/mypage/components/DeleteAllModal.tsx +++ b/src/domains/mypage/components/DeleteAllModal.tsx @@ -2,6 +2,7 @@ import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; import { Dispatch, SetStateAction } from 'react'; import useFetchMyBar from '../api/fetchMyBar'; import useFetchAlarm from '../api/fetchAlarm'; +import { useToast } from '@/shared/hook/useToast'; interface Props { open: boolean; @@ -11,16 +12,23 @@ interface Props { } function DeleteAllModal({ open, onClose, setIsModal, type }: Props) { + const { toastSuccess } = useToast(); const { deleteMyBar } = useFetchMyBar(); const { deleteAlarm } = useFetchAlarm(); const handleBarDelete = () => { deleteMyBar.mutate(undefined, { - onSuccess: () => setIsModal(false), + onSuccess: () => { + toastSuccess('성공적으로 삭제 되었습니다.'); + setIsModal(false); + }, }); }; const handleAlarmDelete = () => { deleteAlarm.mutate(undefined, { - onSuccess: () => setIsModal(false), + onSuccess: () => { + setIsModal(false); + toastSuccess('성공적으로 삭제 되었습니다.'); + }, }); }; diff --git a/src/domains/mypage/components/EditNickName.tsx b/src/domains/mypage/components/EditNickName.tsx index dcee62c8..c355c8bd 100644 --- a/src/domains/mypage/components/EditNickName.tsx +++ b/src/domains/mypage/components/EditNickName.tsx @@ -80,7 +80,7 @@ function EditNickName({ value={editNickName} className="w-full" /> - 전 닉네임으로 돌아가기 + 초기화
); diff --git a/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx b/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx index 0124b1ee..3b95536f 100644 --- a/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx +++ b/src/domains/mypage/components/pages/my-alarm/MyAlarm.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import useFetchAlarm from '@/domains/mypage/api/fetchAlarm'; import { useQuery } from '@tanstack/react-query'; import DeleteAllModal from '../../DeleteAllModal'; +import { useToast } from '@/shared/hook/useToast'; interface MyAlarm { createdAt: Date; @@ -21,6 +22,7 @@ interface MyAlarm { } function MyAlarm() { + const { toastInfo } = useToast(); const [isModal, setIsModal] = useState(false); const { fetchAlarm } = useFetchAlarm(); const { data } = useQuery({ @@ -29,6 +31,10 @@ function MyAlarm() { }); const handleDelete = () => { + if (data.items.length == 0) { + toastInfo('아직 알림이 없습니다.'); + return; + } setIsModal(!isModal); }; diff --git a/src/domains/mypage/components/pages/my-bar/MyBar.tsx b/src/domains/mypage/components/pages/my-bar/MyBar.tsx index 9ebd921d..521cfa3a 100644 --- a/src/domains/mypage/components/pages/my-bar/MyBar.tsx +++ b/src/domains/mypage/components/pages/my-bar/MyBar.tsx @@ -8,6 +8,7 @@ import { useState } from 'react'; import DeleteAllModal from '../../DeleteAllModal'; import useFetchMyBar from '@/domains/mypage/api/fetchMyBar'; import { useQuery } from '@tanstack/react-query'; +import { useToast } from '@/shared/hook/useToast'; interface MyCocktail { cocktailId: number; @@ -19,6 +20,7 @@ interface MyCocktail { } function MyBar() { + const { toastInfo } = useToast(); const [isModal, setIsModal] = useState(false); const { fetchMyBar } = useFetchMyBar(); const { data } = useQuery({ @@ -28,6 +30,10 @@ function MyBar() { }); const handleDelete = () => { + if (data.items.length == 0) { + toastInfo('저장한 칵테일이 없습니다.'); + return; + } setIsModal(!isModal); }; diff --git a/src/domains/not-found/components/NotFoundCont.tsx b/src/domains/not-found/components/NotFoundCont.tsx index a2154e2c..2f571ac5 100644 --- a/src/domains/not-found/components/NotFoundCont.tsx +++ b/src/domains/not-found/components/NotFoundCont.tsx @@ -13,10 +13,11 @@ function NotFoundCont() {

페이지를 찾을 수 없어요🥲

); diff --git a/src/domains/recipe/components/details/DetailMain.tsx b/src/domains/recipe/components/details/DetailMain.tsx index 3ee858ba..b0b11f90 100644 --- a/src/domains/recipe/components/details/DetailMain.tsx +++ b/src/domains/recipe/components/details/DetailMain.tsx @@ -7,79 +7,33 @@ import SsuryShake from '@/shared/assets/ssury/ssury_make.webp'; import SsuryDrink from '@/shared/assets/ssury/ssury_drink.webp'; import Image from 'next/image'; import DetailList from './DetailList'; -import { Suspense, useEffect, useState } from 'react'; -import { getApi } from '@/app/api/config/appConfig'; -import { useAuthStore } from '@/domains/shared/store/auth'; +import { Suspense } from 'react'; import SkeletonDetail from '../../skeleton/SkeletonDetail'; import RecipeComment from './RecipeComment'; - -interface Kept { - cocktailId: number; - id: number; - keptAt: Date; -} +import { useDetailRecipe } from '../../api/useRecipeDetails'; function DetailMain({ id }: { id: number }) { - const user = useAuthStore(); - const [cocktail, setCocktail] = useState(); - const [isKept, setIsKept] = useState(null); - - const fetchData = async () => { - const res = await fetch(`${getApi}/cocktails/${id}`); - const json = await res.json(); - if (!res.ok) throw new Error('데이터 요청 실패'); - setCocktail(json.data); - - if (!user) { - setIsKept(false); - return; - } else { - const keepRes = await fetch(`${getApi}/me/bar`, { - method: 'GET', - credentials: 'include', - }); - const keepjson = await keepRes.json(); - const keepIds = keepjson.data.map((a: Kept) => String(a.cocktailId)); - setIsKept(keepIds.includes(String(id))); - } - }; - - useEffect(() => { - fetchData(); - }, []); + const { data } = useDetailRecipe(id); - useEffect(() => { - window.scrollTo(0, 0); - return () => { - // 레시피 페이지로 돌아가지 않는 경우 (헤더 탭 클릭 등) - // 네비게이션 플래그를 제거하여 스크롤 복원 방지 - const currentPath = window.location.pathname; + if (!data?.recipe) return null; - // 디테일 페이지를 벗어나는 경우 - if (!currentPath.includes('/recipe')) { - sessionStorage.removeItem('cocktails_scroll_state_nav_flag'); - } - }; - }, []); - - if (!cocktail) return; const { - cocktailId, - cocktailImgUrl, cocktailName, cocktailNameKo, cocktailStory, + cocktailImgUrl, alcoholStrength, cocktailType, ingredient, recipe, - } = cocktail; + cocktailId, + } = data?.recipe; return ( }> -

${cocktailNameKo} 상세정보

+

{`${cocktailNameKo} 상세정보`}

- +
diff --git a/src/domains/recipe/components/details/DetailsHeader.tsx b/src/domains/recipe/components/details/DetailsHeader.tsx index d15c0533..96ccc60e 100644 --- a/src/domains/recipe/components/details/DetailsHeader.tsx +++ b/src/domains/recipe/components/details/DetailsHeader.tsx @@ -13,7 +13,7 @@ interface Meta { url: string; } -function DetailsHeader({ id, favor }: { id: number; favor: boolean | null }) { +function DetailsHeader({ id, favor }: { id: number; favor: boolean | undefined }) { const [isShare, setIsShare] = useState(false); const [meta, setMeta] = useState(null); diff --git a/src/domains/recipe/components/details/RecipeComment.tsx b/src/domains/recipe/components/details/RecipeComment.tsx index 901a43c4..4fa13285 100644 --- a/src/domains/recipe/components/details/RecipeComment.tsx +++ b/src/domains/recipe/components/details/RecipeComment.tsx @@ -1,11 +1,9 @@ import CommentHeader from '@/domains/shared/components/comment/CommentHeader'; import CommentList from '@/domains/shared/components/comment/CommentList'; -import { useAuthStore } from '@/domains/shared/store/auth'; -import { useShallow } from 'zustand/shallow'; import ConfirmModal from '@/shared/components/modal-pop/ConfirmModal'; -import { useRecipeComments } from '../../api/useRecipeComment'; -import { getApi } from '@/app/api/config/appConfig'; -import { useToast } from '@/shared/hook/useToast'; +import { useRecipeComment } from '../../api/useRecipeComment'; +import { useState } from 'react'; +import { CommentType } from '@/domains/community/types/post'; import { ParamValue } from 'next/dist/server/request/params'; interface Props { @@ -13,53 +11,37 @@ interface Props { } function RecipeComment({ cocktailId }: Props) { - const { user } = useAuthStore( - useShallow((state) => ({ - user: state.user, - })) - ); - - const { toastInfo } = useToast(); + const [deleteTarget, setDeleteTarget] = useState<{ + commentId: number; + cocktailId: number; + } | null>(null); - const postRecipeComment = async (cocktailId: number | ParamValue, content: string) => { - if (!user?.id) { - toastInfo('로그인 후 이용 가능합니다'); - return; - } - const body = { - cocktailId, - content: content, - }; + const { refetch, createMut, deleteMut, updateMut, user, comments, isLoading } = useRecipeComment({ + cocktailId, + }); - const res = await fetch(`${getApi}/cocktails/${cocktailId}/comments`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(body), - }); - - const text = await res.text(); - if (!res.ok) { - toastInfo('댓글은 한 개만 작성가능합니다'); - return; - } + const postRecipeComment = async ( + postId: number | ParamValue, + content: string + ): Promise => { + if (typeof postId !== 'number') return null; + await createMut.mutateAsync(content); + const referesh = await refetch(); + return referesh.data ?? null; + }; + const handleUpdateComment = (commentId: number, content: string) => + updateMut.mutateAsync({ commentId, content }); - const data = JSON.parse(text); - return data; + const handleConfirmDelete = async () => { + if (!deleteTarget) return; + await deleteMut.mutateAsync(deleteTarget.commentId); + setDeleteTarget(null); }; - const { - comments, - fetchData, - handleAskDeleteComment, - handleUpdateComment, - loadMoreComments, - isEnd, - isLoading, - deleteTarget, - handleConfirmDelete, - setDeleteTarget, - } = useRecipeComments(cocktailId, user); + const fetchData = () => refetch; + const loadMoreComments = () => {}; + const isEnd = true; + const handleAskDeleteComment = (commentId: number) => setDeleteTarget({ commentId, cocktailId }); return (
diff --git a/src/domains/recipe/components/main/Accordion.tsx b/src/domains/recipe/components/main/Accordion.tsx index 771c5a1b..286a782d 100644 --- a/src/domains/recipe/components/main/Accordion.tsx +++ b/src/domains/recipe/components/main/Accordion.tsx @@ -1,7 +1,7 @@ 'use client'; import SelectBox from '@/shared/components/select-box/SelectBox'; -import { Dispatch, SetStateAction, useEffect, useMemo } from 'react'; +import { Dispatch, SetStateAction, useEffect } from 'react'; import { useSearchParams, usePathname, useRouter } from 'next/navigation'; interface Props { @@ -93,13 +93,13 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths }; // URL 파라미터에서 현재 선택된 값 가져오기 아코디언 UI에 적용 - const currentValues = useMemo(() => { + const currentValues = () => { return { abv: getDisplayValue('abv', searchParams.get('abv')), base: getDisplayValue('base', searchParams.get('base')), glass: getDisplayValue('glass', searchParams.get('glass')), }; - }, [searchParams]); + }; const handleSelect = (id: string, value: string) => { const optionGroup = SELECT_OPTIONS.find((opt) => opt.id === id); @@ -140,7 +140,7 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths return (
    {SELECT_OPTIONS.map(({ id, option, title }) => { - const currentValue = currentValues[id as keyof typeof currentValues]; + const currentValue = currentValues()[id as keyof typeof currentValues]; return (
  • @@ -149,7 +149,7 @@ function Accordion({ setAlcoholBaseTypes, setCocktailTypes, setAlcoholStrengths title={title} id={id} groupKey="filter" - value={currentValue} // 현재 선택된 값 전달 + value={currentValue} onChange={(value) => handleSelect(id, value)} />
  • diff --git a/src/domains/recipe/components/main/CocktailFilter.tsx b/src/domains/recipe/components/main/CocktailFilter.tsx index 589823c6..209b94aa 100644 --- a/src/domains/recipe/components/main/CocktailFilter.tsx +++ b/src/domains/recipe/components/main/CocktailFilter.tsx @@ -1,47 +1,32 @@ -import { getApi } from '@/app/api/config/appConfig'; import SelectBox from '@/shared/components/select-box/SelectBox'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { Dispatch, SetStateAction } from 'react'; -import { Cocktail } from '../../types/types'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; interface Props { - cocktailsEA: string; - setData: Dispatch>; + cocktailsEA: number; } -function CocktailFilter({ cocktailsEA, setData }: Props) { +function CocktailFilter({ cocktailsEA }: Props) { const sortMap = { 최신순: 'recent', 인기순: 'keeps', 댓글순: 'comments', }; - const searchParams = useSearchParams(); - const query = searchParams.get('sortBy'); + const queryClient = useQueryClient(); const router = useRouter(); const handleChange = async (selectTitle: string) => { - if (!query) return; - try { - const res = await fetch(`${getApi}/cocktails`); - const json = await res.json(); - setData(json.data); - } catch { - console.error(); - console.log(selectTitle); - } + const sortValue = sortMap[selectTitle as keyof typeof sortMap]; + queryClient.removeQueries({ + queryKey: ['cocktails', 'infinite'], + exact: false, + }); + router.push(`?sortBy=${sortValue}`); }; return (
    -

    {cocktailsEA}개

    - { - const sortValue = sortMap[value as keyof typeof sortMap]; - handleChange(value); - router.push(`?sortBy=${sortValue}`); - }} - /> +

    {cocktailsEA}개+

    +
    ); } diff --git a/src/domains/recipe/components/main/CocktailList.tsx b/src/domains/recipe/components/main/CocktailList.tsx index 0b2f7e63..3c43da7b 100644 --- a/src/domains/recipe/components/main/CocktailList.tsx +++ b/src/domains/recipe/components/main/CocktailList.tsx @@ -1,35 +1,25 @@ 'use client'; -import { useRef } from 'react'; + import Link from 'next/link'; -import { useIntersectionObserver } from '@/domains/shared/hook/useIntersectionObserver'; import { Cocktail } from '../../types/types'; import CocktailCard from '@/domains/shared/components/cocktail-card/CocktailCard'; -import { useScrollRestore } from '@/domains/shared/hook/useMemoScroll'; +import { useSaveScroll } from '../../hook/useSaveScroll'; interface Props { cocktails: Cocktail[]; - RecipeFetch?: (cursor?: string | undefined) => Promise; - hasNextPage: boolean; - lastId: number | null; } -function CocktailList({ cocktails, RecipeFetch, hasNextPage, lastId }: Props) { - const cocktailRef = useRef(null); - const onIntersect: IntersectionObserverCallback = ([entry]) => { - if (!RecipeFetch) return; - if (!lastId) return; - if (entry.isIntersecting && lastId > 1) { - RecipeFetch(); - } +function CocktailList({ cocktails }: Props) { + const { saveAndNavigate } = useSaveScroll({ + storageKey: 'cocktail_list_scroll', + }); + + const handleClick = (cocktailId: number) => (e: React.MouseEvent) => { + e.preventDefault(); + + saveAndNavigate(`/recipe/${cocktailId}`); }; - useIntersectionObserver(cocktailRef, onIntersect, hasNextPage); - const saveScroll = useScrollRestore({ - lastId, - fetchData: RecipeFetch!, - hasNextPage, - currentDataLength: cocktails.length, - }); return (
      {cocktails.map( - ({ - cocktailImgUrl, - cocktailId, - cocktailName, - cocktailNameKo, - alcoholStrength, - isFavorited, - }) => ( -
    • - + ( + { cocktailImgUrl, cocktailId, cocktailName, cocktailNameKo, alcoholStrength, isKeep }, + i + ) => ( +
    • + ) )} -
    ); } diff --git a/src/domains/recipe/components/main/CocktailSearchBar.tsx b/src/domains/recipe/components/main/CocktailSearchBar.tsx index 5850054e..96247815 100644 --- a/src/domains/recipe/components/main/CocktailSearchBar.tsx +++ b/src/domains/recipe/components/main/CocktailSearchBar.tsx @@ -1,16 +1,16 @@ import Input from '@/shared/components/Input-box/Input'; interface Props { - value: string; + keyword: string; onChange: (v: string) => void; } -function CocktailSearchBar({ value, onChange }: Props) { +function CocktailSearchBar({ keyword, onChange }: Props) { return ( onChange(e.target.value)} variant="search" className="w-full md:max-w-80" diff --git a/src/domains/recipe/components/main/Cocktails.tsx b/src/domains/recipe/components/main/Cocktails.tsx index a63dc93f..1a298611 100644 --- a/src/domains/recipe/components/main/Cocktails.tsx +++ b/src/domains/recipe/components/main/Cocktails.tsx @@ -1,76 +1,52 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import CocktailFilter from './CocktailFilter'; import CocktailList from './CocktailList'; -import { Cocktail } from '../../types/types'; import Accordion from './Accordion'; -import { RecipeFetch } from '../../api/RecipeFetch'; import CocktailSearchBar from './CocktailSearchBar'; -import useSearchControl from '../../hook/useSearchControl'; -import CocktailSearch from '../../api/CocktailSearch'; -import { useAuthStore } from '@/domains/shared/store/auth'; +import { useCocktails } from '../../api/fetchRecipe'; +import { useInView } from 'react-intersection-observer'; +import { debounce } from '@/shared/utills/debounce'; +import { useSearchParams } from 'next/navigation'; +import { Sort } from '../../types/types'; function Cocktails() { - const user = useAuthStore((state) => state.user); + const searchParams = useSearchParams(); + const sortBy = searchParams.get('sortBy') as Sort; + const [keyword, setKeyword] = useState(''); + const [input, setInput] = useState(''); - const [data, setData] = useState([]); - const [lastId, setLastId] = useState(null); - const [hasNextPage, setHasNextPage] = useState(true); + const [alcoholStrengths, setAlcoholStrengths] = useState([]); + const [alcoholBaseTypes, setAlcoholBaseTypes] = useState([]); + const [cocktailTypes, setCocktailTypes] = useState([]); - const { inputValue, keyword, isSearching, onInputChange, noResults, setNoResults } = - useSearchControl({ delay: 300, storageKey: 'cocktails_scoll_state' }); - const { fetchData } = RecipeFetch({ setData, lastId, setLastId, hasNextPage, setHasNextPage }); + const { data, fetchNextPage, hasNextPage, noResults, isSearchMode } = useCocktails( + { + keyword, + alcoholBaseTypes, + alcoholStrengths, + cocktailTypes, + }, + 20, + sortBy + ); - const { - searchApi, - setAlcoholBaseTypes, - setAlcoholStrengths, - setCocktailTypes, - alcoholBaseTypes, - cocktailTypes, - alcoholStrengths, - } = CocktailSearch({ - setData, - setNoResults, + const { ref, inView } = useInView({ + threshold: 0.1, }); - const countLabel = isSearching - ? hasNextPage - ? `검색결과 현재 ${data.length}+` - : `검색결과 총 ${data.length}` - : hasNextPage - ? `전체 ${data.length}+` - : `전체 ${data.length}`; - - // 초기 로드 시 검색어가 있으면 검색 실행 - // useEffect(() => { - // const readyForFirstLoad = !isSearching && hasNextPage && lastId == null && data.length === 0; - - // if (readyForFirstLoad) { - // fetchData(); - // } - // }, [hasNextPage, lastId]); - - // 검색어 변경 시 useEffect(() => { - if (isSearching && keyword.trim()) { - setLastId(null); - setHasNextPage(false); - searchApi(keyword.trim()); - } else if (!isSearching) { - // 검색어를 지웠을 때만 초기화 - setData([]); - setLastId(null); - setHasNextPage(true); + if (!isSearchMode && inView && hasNextPage) { + fetchNextPage?.(); } - }, [keyword, isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]); + }, [inView, hasNextPage, fetchNextPage]); - // 일반 fetch - useEffect(() => { - if (isSearching) return; - fetchData(); - }, [isSearching, alcoholBaseTypes, alcoholStrengths, cocktailTypes]); + const debounceKeyword = useMemo(() => debounce((v: string) => setKeyword(v), 300), []); + const handleSearch = (v: string) => { + setInput(v); + debounceKeyword(v); + }; return (
    @@ -80,23 +56,15 @@ function Cocktails() { setAlcoholStrengths={setAlcoholStrengths} setCocktailTypes={setCocktailTypes} /> - +
- +
- {isSearching && noResults ? ( -
검색결과가 없습니다.
- ) : ( - - )} + {noResults ?
검색 결과가 없습니다.
: }
+
); } diff --git a/src/domains/recipe/hook/useSaveScroll.ts b/src/domains/recipe/hook/useSaveScroll.ts new file mode 100644 index 00000000..8971021b --- /dev/null +++ b/src/domains/recipe/hook/useSaveScroll.ts @@ -0,0 +1,69 @@ +import { useRouter } from 'next/navigation'; +import { useEffect, useRef } from 'react'; + +interface Scroll { + storageKey?: string; + enabled?: boolean; + pageType?: 'list' | 'detail'; +} + +export const useSaveScroll = (opt: Scroll = {}) => { + const { storageKey = 'cocktail_scroll', enabled = true, pageType = 'list' } = opt; + const router = useRouter(); + const hasRestore = useRef(false); + + useEffect(() => { + if (pageType === 'detail') return; + + if (!enabled || hasRestore.current) return; + + const savedPosition = sessionStorage.getItem(storageKey); + const shouldRestore = sessionStorage.getItem(`${storageKey}_should_restore`); + + if (savedPosition && shouldRestore === 'true') { + const position = parseInt(savedPosition, 10); + + const restoreScroll = () => { + window.scrollTo(0, position); + hasRestore.current = true; + }; + + requestAnimationFrame(restoreScroll); + setTimeout(restoreScroll, 0); + setTimeout(restoreScroll, 100); + + sessionStorage.removeItem(`${storageKey}_should_restore`); + } + }, [storageKey, enabled, pageType]); + + const saveScroll = () => { + if (!enabled) return; + const currentScroll = window.scrollY; + sessionStorage.setItem(storageKey, currentScroll.toString()); + }; + + // 상세 페이지로 이동 (스크롤 위치만 저장, 복원 플래그는 설정 안함) + const saveAndNavigate = (href: string) => { + saveScroll(); + sessionStorage.setItem(`${storageKey}_url`, location.href); + router.push(href); + }; + + // 뒤로가기 (복원 플래그 설정) + const restoreAndGoBack = () => { + const saveUrl = sessionStorage.getItem(`${storageKey}_url`); + + if (!saveUrl) return; + + // 뒤로가기할 때만 복원 플래그 설정 + sessionStorage.setItem(`${storageKey}_should_restore`, 'true'); + + router.replace(saveUrl, { scroll: false }); + }; + + return { + saveScroll, + saveAndNavigate, + restoreAndGoBack, + }; +}; diff --git a/src/domains/recipe/hook/useSearchControl.tsx b/src/domains/recipe/hook/useSearchControl.tsx deleted file mode 100644 index a00e40f1..00000000 --- a/src/domains/recipe/hook/useSearchControl.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { debounce } from '@/shared/utills/debounce'; -import { useEffect, useMemo, useState } from 'react'; - -interface UseSearchControlProps { - delay?: number; - storageKey?: string; // 검색 상태 저장용 키 -} - -function useSearchControl({ delay = 300, storageKey }: UseSearchControlProps) { - // 초기값을 sessionStorage에서 복원 - const [inputValue, setInputValue] = useState(() => { - if (typeof window === 'undefined' || !storageKey) return ''; - const saved = sessionStorage.getItem(`${storageKey}_search`); - return saved ? JSON.parse(saved).inputValue : ''; - }); - - const [keyword, setKeyword] = useState(() => { - if (typeof window === 'undefined' || !storageKey) return ''; - const saved = sessionStorage.getItem(`${storageKey}_search`); - return saved ? JSON.parse(saved).keyword : ''; - }); - - const [noResults, setNoResults] = useState(false); - - const isSearching = keyword.trim().length > 0; - - // 검색 상태를 sessionStorage에 저장 - useEffect(() => { - if (!storageKey) return; - sessionStorage.setItem( - `${storageKey}_search`, - JSON.stringify({ - inputValue, - keyword, - }) - ); - }, [inputValue, keyword, storageKey]); - - const debouncedKeyword = useMemo(() => debounce((v: string) => setKeyword(v), delay), [delay]); - - const onInputChange = (v: string) => { - setInputValue(v); - debouncedKeyword(v); - }; - - // 검색 상태 초기화 함수 - const resetSearch = () => { - setInputValue(''); - setKeyword(''); - setNoResults(false); - if (storageKey) { - sessionStorage.removeItem(`${storageKey}_search`); - } - }; - - return { - inputValue, - keyword, - isSearching, - onInputChange, - noResults, - setNoResults, - resetSearch, - }; -} - -export default useSearchControl; diff --git a/src/domains/recipe/types/types.ts b/src/domains/recipe/types/types.ts index b3a176f9..2d53040b 100644 --- a/src/domains/recipe/types/types.ts +++ b/src/domains/recipe/types/types.ts @@ -4,7 +4,7 @@ export interface Cocktail { cocktailName: string; cocktailImgUrl: string; cocktailNameKo: string; - isFavorited: boolean; + isKeep: boolean; } export interface RecommendCocktail { @@ -36,3 +36,4 @@ export type TagType = { cocktailName: string; cocktailNameKo: string; }; +export type Sort = 'recent' | 'keeps' | 'comments'; diff --git a/src/domains/shared/components/cocktail-card/CocktailCard.tsx b/src/domains/shared/components/cocktail-card/CocktailCard.tsx index b113c4fa..593e3fa5 100644 --- a/src/domains/shared/components/cocktail-card/CocktailCard.tsx +++ b/src/domains/shared/components/cocktail-card/CocktailCard.tsx @@ -33,7 +33,6 @@ function CocktailCard({ favor, }: Props) { const alcoholTitle = labelTitle(alcohol); - return (
( - targetRef: RefObject, // 관찰하는 요소 - onIntersect: IntersectionObserverCallback, // 관찰 될 때 실행할 함수 - hasNextPage: boolean | undefined // 무한스크롤로 더 불러올 요소가 있는지 -) => { - const observer = useRef(null); - - useEffect(() => { - if (targetRef && targetRef.current) { - observer.current = new IntersectionObserver(onIntersect, { - root: null, - rootMargin: '200px', - threshold: 1.0, - }); - if (!hasNextPage) { - observer.current?.unobserve(targetRef.current); - return; - } - observer.current.observe(targetRef.current); - } - return () => observer && observer.current?.disconnect(); - }, [targetRef, onIntersect]); -}; diff --git a/src/domains/shared/hook/useMemoScroll.ts b/src/domains/shared/hook/useMemoScroll.ts deleted file mode 100644 index 87bd13fd..00000000 --- a/src/domains/shared/hook/useMemoScroll.ts +++ /dev/null @@ -1,156 +0,0 @@ -// useScrollRestore.ts -import { usePathname } from 'next/navigation'; -import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; - -interface UseScrollRestoreProps { - lastId: number | null; // 현재까지 로드한 "최소 id"(내림차순에서 커서) - fetchData: (cursor?: string) => Promise; - currentDataLength: number; - hasNextPage?: boolean; // 선택: 있으면 조기 종료에 사용 -} - -type SavedShape = { targetId: number | null; scrollY: number }; - -export function useScrollRestore({ - lastId, - fetchData, - currentDataLength, - hasNextPage, -}: UseScrollRestoreProps) { - const pathname = usePathname(); - const KEY = `scroll-${pathname}`; - - const isRestoringRef = useRef(false); - const hasRestoredRef = useRef(false); - const lastIdRef = useRef(lastId); - const lenRef = useRef(currentDataLength); - - useEffect(() => { - lastIdRef.current = lastId; - }, [lastId]); - useEffect(() => { - lenRef.current = currentDataLength; - }, [currentDataLength]); - - // 브라우저 기본 복원 비활성화 - useLayoutEffect(() => { - if ('scrollRestoration' in history) { - try { - history.scrollRestoration = 'manual'; - } catch {} - } - }, []); - - const jumpOnce = useCallback( - (y: number) => { - const el = document.scrollingElement || document.documentElement; - const enough = () => document.body.scrollHeight >= y + window.innerHeight; - let done = false; - - const finish = () => { - if (done) return; - done = true; - el.scrollTo({ top: y, behavior: 'auto' }); - isRestoringRef.current = false; - hasRestoredRef.current = true; - sessionStorage.removeItem(KEY); - }; - - if (enough()) { - requestAnimationFrame(finish); - return; - } - - const ro = new ResizeObserver(() => { - if (enough()) { - ro.disconnect(); - finish(); - } - }); - ro.observe(document.body); - window.addEventListener( - 'load', - () => { - if (enough()) finish(); - }, - { once: true } - ); - setTimeout(() => finish(), 1000); - }, - [KEY] - ); - - // 복원 - useEffect(() => { - if (hasRestoredRef.current) return; - - const raw = sessionStorage.getItem(KEY); - if (!raw) { - hasRestoredRef.current = true; - return; - } - - let saved: SavedShape | null = null; - try { - saved = JSON.parse(raw) as SavedShape; - } catch { - sessionStorage.removeItem(KEY); - return; - } - if (!saved) { - sessionStorage.removeItem(KEY); - return; - } - - const { targetId, scrollY } = saved; - isRestoringRef.current = true; - - const MAX_FETCH = 50; - - const restore = async () => { - let tries = 0; - let lastProgressLen = lenRef.current; - let lastProgressId = lastIdRef.current; - - // 내림차순 전제: - // 더 불러올수록 현재 최소 id(=lastIdRef.current)가 "작아진다" - // 목표 지점 도달 조건: currentMinId <= targetId 또는 targetId==null - while ( - targetId != null && - (lastIdRef.current == null || (lastIdRef.current as number) > targetId) - ) { - if (hasNextPage === false) break; // 더 없음 - if (tries++ >= MAX_FETCH) break; // 안전망 - - await fetchData(); - - // 진행 없음(길이와 lastId 모두 동일) → 중단 - const noLenChange = lenRef.current === lastProgressLen; - const noIdChange = lastIdRef.current === lastProgressId; - if (noLenChange && noIdChange) break; - - lastProgressLen = lenRef.current; - lastProgressId = lastIdRef.current; - - // 다음 렌더로 넘겨 레이아웃 안정화 - await new Promise((r) => setTimeout(r, 0)); - } - - requestAnimationFrame(() => jumpOnce(scrollY)); - }; - - restore(); - }, [KEY, fetchData, hasNextPage, jumpOnce]); - - // 저장 - const saveScroll = useCallback(() => { - const payload: SavedShape = { - targetId: lastIdRef.current, - scrollY: window.scrollY, - }; - sessionStorage.setItem(KEY, JSON.stringify(payload)); - sessionStorage.setItem('saveUrl', location.href); - }, [KEY]); - - return saveScroll; -} diff --git a/src/shared/assets/lottie/ScrollDownAnimation.json b/src/shared/assets/lottie/ScrollDownAnimation.json new file mode 100644 index 00000000..2b64a828 --- /dev/null +++ b/src/shared/assets/lottie/ScrollDownAnimation.json @@ -0,0 +1 @@ +{"v":"5.5.6","fr":29.9700012207031,"ip":0,"op":89.0000036250443,"w":51,"h":130,"nm":"Scroll_icon_white","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Слой 6 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.433,"y":1},"o":{"x":0.717,"y":0.009},"t":0,"s":[25.179,30.476,0],"to":[0,3.604,0],"ti":[0,-3.604,0]},{"t":59.0000024031193,"s":[25.179,52.101,0]}],"ix":2},"a":{"a":0,"k":[0.763,3.624,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,0,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":30,"s":[100,100,100]},{"t":59.0000024031193,"s":[100,0,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[0.763,0.763],[0.763,6.485]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.526,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Слой 10 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":51,"s":[100]},{"t":68.0000027696968,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[25.179,119.784,0],"ix":2},"a":{"a":0,"k":[12.971,7.248,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-11.445,-5.722],[0,5.722],[11.445,-5.722]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.526,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[12.971,7.248],"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":34.0000013848484,"op":934.0000380426,"st":34.0000013848484,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Слой 9 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":34,"s":[100]},{"t":51.0000020772726,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[25.179,102.284,0],"ix":2},"a":{"a":0,"k":[12.971,7.248,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-11.445,-5.722],[0,5.722],[11.445,-5.722]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.526,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[12.971,7.248],"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":17.0000006924242,"op":917.000037350176,"st":17.0000006924242,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Слой 4 Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[0]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[100]},{"t":34.0000013848484,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[25.179,82.784,0],"ix":2},"a":{"a":0,"k":[12.971,7.248,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[-11.445,-5.722],[0,5.722],[11.445,-5.722]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.526,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[12.971,7.248],"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"Слой 5 Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[25.179,46.542,0],"ix":2},"a":{"a":0,"k":[12.208,22.127,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-5.899,0],[0,0],[0,-5.899],[0,0],[5.899,0],[0,5.899],[0,0]],"o":[[0,0],[5.899,0],[0,0],[0,5.899],[-5.899,0],[0,0],[0,-5.899]],"v":[[0,-20.6],[0,-20.6],[10.682,-9.918],[10.682,9.92],[0,20.6],[-10.682,9.92],[-10.682,-9.918]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":1.526,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[12.208,22.127],"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":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0}],"markers":[]} \ No newline at end of file