From d4775ff74513dcbe24baf0624f9e076ba845934f Mon Sep 17 00:00:00 2001 From: LGS Date: Thu, 12 Feb 2026 17:41:36 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EB=AF=BC=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=EB=AC=BC=EC=9E=AC=EC=9D=B4=EC=9A=A9=20=EB=9E=9C=EB=94=A9=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80(=EC=88=98=EC=9A=94=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=99=B8=20=EC=84=9C=EB=B8=8C=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reuse/_components/CaseTabsCarousel.tsx | 519 ++++++++++++++++++ .../reuse/_components/ImageSlider.tsx | 167 ++++++ .../app/(landing)/reuse/_components/Lnb.tsx | 135 +++-- .../reuse/_components/SubPageHeader.tsx | 6 +- .../(landing)/reuse/_components/TopNav.tsx | 6 +- apps/dm/app/(landing)/reuse/_constants/lnb.ts | 37 +- .../app/(landing)/reuse/_sections/Cases.tsx | 114 ++-- .../dm/app/(landing)/reuse/_sections/Hero.tsx | 4 +- .../(landing)/reuse/_sections/IntroCards.tsx | 104 ++-- .../(landing)/reuse/_sections/ProcessFlow.tsx | 23 +- .../app/(landing)/reuse/business/layout.tsx | 16 + .../reuse/business/rain/suwon/page.tsx | 93 ++++ .../reuse/business/sewage/bolyeong/page.tsx | 111 ++++ .../reuse/business/sewage/cheongju/page.tsx | 94 ++++ .../reuse/business/sewage/guri/page.tsx | 171 ++++++ .../reuse/business/sewage/hwanggu/page.tsx | 171 ++++++ .../reuse/business/sewage/paju/page.tsx | 112 ++++ .../reuse/business/sewage/yeosu/page.tsx | 101 ++++ .../(landing)/reuse/cases/greywater/page.tsx | 96 ++++ apps/dm/app/(landing)/reuse/cases/layout.tsx | 16 + .../app/(landing)/reuse/cases/rain/page.tsx | 86 +++ .../(landing)/reuse/cases/treated/page.tsx | 96 ++++ .../(landing)/reuse/intro/greywater/page.tsx | 86 +++ .../dm/app/(landing)/reuse/intro/law/page.tsx | 122 ++++ apps/dm/app/(landing)/reuse/intro/layout.tsx | 16 + .../app/(landing)/reuse/intro/rain/page.tsx | 76 +++ .../(landing)/reuse/intro/treated/page.tsx | 76 +++ apps/dm/app/(landing)/reuse/page.tsx | 18 +- .../images/landing/business/rain/rain_01.png | Bin 0 -> 263405 bytes .../images/landing/business/rain/rain_02.png | Bin 0 -> 314034 bytes .../images/landing/business/rain/rain_03.png | Bin 0 -> 333203 bytes .../images/landing/business/rain/rain_04.png | Bin 0 -> 344317 bytes .../landing/business/sewage/bolyeong_01.png | Bin 0 -> 185353 bytes .../landing/business/sewage/cheongju_01.png | Bin 0 -> 428282 bytes .../landing/business/sewage/hwanggu_01.png | Bin 0 -> 285722 bytes .../landing/business/sewage/paju_01.png | Bin 0 -> 356780 bytes .../landing/business/sewage/yeosu_01.png | Bin 0 -> 351736 bytes apps/dm/public/images/landing/card_01.jpg | Bin 0 -> 105869 bytes apps/dm/public/images/landing/card_02.jpg | Bin 0 -> 188410 bytes apps/dm/public/images/landing/card_03.jpg | Bin 0 -> 181172 bytes apps/dm/public/images/landing/card_04.jpg | Bin 0 -> 255362 bytes .../landing/cases/greywater_building.png | Bin 0 -> 51551 bytes .../landing/cases/greywater_factory.png | Bin 0 -> 56622 bytes .../landing/cases/greywater_overall.png | Bin 0 -> 72692 bytes .../images/landing/cases/greywater_usage.png | Bin 0 -> 45483 bytes .../landing/cases/rainwater_building.png | Bin 0 -> 41751 bytes .../images/landing/cases/rainwater_usage.png | Bin 0 -> 62674 bytes .../landing/cases/rainwater_usage_amount.png | Bin 0 -> 41171 bytes .../landing/cases/treated_water_facility.png | Bin 0 -> 58845 bytes .../landing/cases/treated_water_region.png | Bin 0 -> 106934 bytes .../landing/cases/treated_water_usage.png | Bin 0 -> 74707 bytes .../landing/cases/treated_water_year.png | Bin 0 -> 72692 bytes apps/dm/public/images/landing/drop_arrow.svg | 3 + apps/dm/public/images/landing/global_01.png | Bin 0 -> 85203 bytes .../public/images/landing/icon_left_arrow.png | Bin 0 -> 250 bytes .../images/landing/icon_right_arrow.png | Bin 0 -> 258 bytes .../images/landing/intro/domestic_textbox.png | Bin 0 -> 237540 bytes .../images/landing/intro/greywater_intro.png | Bin 0 -> 321224 bytes .../landing/intro/greywater_mandatory.png | Bin 0 -> 64590 bytes .../landing/intro/greywater_standard.png | Bin 0 -> 53834 bytes .../images/landing/intro/law_timeline.png | Bin 0 -> 173354 bytes .../public/images/landing/intro/linkage.png | Bin 0 -> 445248 bytes .../images/landing/intro/performance_left.png | Bin 0 -> 363152 bytes .../images/landing/intro/policy_timeline.png | Bin 0 -> 1121906 bytes .../images/landing/intro/rainreuse_intro.png | Bin 0 -> 331677 bytes .../landing/intro/rainreuse_mandatory.png | Bin 0 -> 74345 bytes .../public/images/landing/intro/rel_note.png | Bin 0 -> 175900 bytes .../landing/intro/treatedwater_intro.png | Bin 0 -> 141470 bytes .../landing/intro/treatedwater_mandatory.png | Bin 0 -> 25186 bytes apps/dm/public/images/landing/rain_01.png | Bin 0 -> 148115 bytes apps/dm/public/images/landing/section_03.png | Bin 0 -> 260096 bytes apps/dm/public/images/landing/sewage_01.png | Bin 0 -> 192090 bytes apps/dm/public/images/landing/sewage_02.png | Bin 0 -> 111450 bytes apps/dm/public/images/landing/sewage_03.png | Bin 0 -> 184355 bytes 74 files changed, 2502 insertions(+), 173 deletions(-) create mode 100644 apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx create mode 100644 apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/layout.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/rain/suwon/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/sewage/bolyeong/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/sewage/cheongju/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/sewage/guri/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/sewage/hwanggu/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/sewage/paju/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/business/sewage/yeosu/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/cases/greywater/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/cases/layout.tsx create mode 100644 apps/dm/app/(landing)/reuse/cases/rain/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/cases/treated/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/intro/greywater/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/intro/law/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/intro/layout.tsx create mode 100644 apps/dm/app/(landing)/reuse/intro/rain/page.tsx create mode 100644 apps/dm/app/(landing)/reuse/intro/treated/page.tsx create mode 100644 apps/dm/public/images/landing/business/rain/rain_01.png create mode 100644 apps/dm/public/images/landing/business/rain/rain_02.png create mode 100644 apps/dm/public/images/landing/business/rain/rain_03.png create mode 100644 apps/dm/public/images/landing/business/rain/rain_04.png create mode 100644 apps/dm/public/images/landing/business/sewage/bolyeong_01.png create mode 100644 apps/dm/public/images/landing/business/sewage/cheongju_01.png create mode 100644 apps/dm/public/images/landing/business/sewage/hwanggu_01.png create mode 100644 apps/dm/public/images/landing/business/sewage/paju_01.png create mode 100644 apps/dm/public/images/landing/business/sewage/yeosu_01.png create mode 100644 apps/dm/public/images/landing/card_01.jpg create mode 100644 apps/dm/public/images/landing/card_02.jpg create mode 100644 apps/dm/public/images/landing/card_03.jpg create mode 100644 apps/dm/public/images/landing/card_04.jpg create mode 100644 apps/dm/public/images/landing/cases/greywater_building.png create mode 100644 apps/dm/public/images/landing/cases/greywater_factory.png create mode 100644 apps/dm/public/images/landing/cases/greywater_overall.png create mode 100644 apps/dm/public/images/landing/cases/greywater_usage.png create mode 100644 apps/dm/public/images/landing/cases/rainwater_building.png create mode 100644 apps/dm/public/images/landing/cases/rainwater_usage.png create mode 100644 apps/dm/public/images/landing/cases/rainwater_usage_amount.png create mode 100644 apps/dm/public/images/landing/cases/treated_water_facility.png create mode 100644 apps/dm/public/images/landing/cases/treated_water_region.png create mode 100644 apps/dm/public/images/landing/cases/treated_water_usage.png create mode 100644 apps/dm/public/images/landing/cases/treated_water_year.png create mode 100644 apps/dm/public/images/landing/drop_arrow.svg create mode 100644 apps/dm/public/images/landing/global_01.png create mode 100644 apps/dm/public/images/landing/icon_left_arrow.png create mode 100644 apps/dm/public/images/landing/icon_right_arrow.png create mode 100644 apps/dm/public/images/landing/intro/domestic_textbox.png create mode 100644 apps/dm/public/images/landing/intro/greywater_intro.png create mode 100644 apps/dm/public/images/landing/intro/greywater_mandatory.png create mode 100644 apps/dm/public/images/landing/intro/greywater_standard.png create mode 100644 apps/dm/public/images/landing/intro/law_timeline.png create mode 100644 apps/dm/public/images/landing/intro/linkage.png create mode 100644 apps/dm/public/images/landing/intro/performance_left.png create mode 100644 apps/dm/public/images/landing/intro/policy_timeline.png create mode 100644 apps/dm/public/images/landing/intro/rainreuse_intro.png create mode 100644 apps/dm/public/images/landing/intro/rainreuse_mandatory.png create mode 100644 apps/dm/public/images/landing/intro/rel_note.png create mode 100644 apps/dm/public/images/landing/intro/treatedwater_intro.png create mode 100644 apps/dm/public/images/landing/intro/treatedwater_mandatory.png create mode 100644 apps/dm/public/images/landing/rain_01.png create mode 100644 apps/dm/public/images/landing/section_03.png create mode 100644 apps/dm/public/images/landing/sewage_01.png create mode 100644 apps/dm/public/images/landing/sewage_02.png create mode 100644 apps/dm/public/images/landing/sewage_03.png diff --git a/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx new file mode 100644 index 000000000..2d936ffc5 --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_components/CaseTabsCarousel.tsx @@ -0,0 +1,519 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; + +/** + * CaseTabsCarousel.tsx (FINAL OPTIMIZED) + * - 탭별 데이터 + * - 카드 perPage(1/2/3) 반응형 + * - 카드 perPage개 = 1그룹, 좌/우 클릭 시 1그룹 단위 이동 + * - 3그룹 가상화(prev/cur/next)로 DOM 최소화 + * - px 기반 트랙 이동 + "측정 박스 분리"로 좌/우 비침 방지 + * - stale state 제거(방향/인덱스 ref 관리)로 슬라이드 안정성 강화 + * - BUGFIX 1) onTransitionEnd 버블링 방지 (target===currentTarget, transform만 처리) + * - BUGFIX 2) 슬라이드 중(busy) clipWidth 변화로 트랙 스냅(-W) 덮어쓰지 않게 분리 + */ + +type TabKey = "rain" | "reuse" | "sewage" | "global"; + +type CaseItem = { + id: string; + title: string; + desc: string; + imageSrc: string; + href?: string; +}; + +const TABS: { key: TabKey; label: string }[] = [ + { key: "rain", label: "빗물 재이용 사례" }, + { key: "reuse", label: "중수 재이용 사례" }, + { key: "sewage", label: "하수 재이용 사례" }, + { key: "global", label: "해외 재이용 사례" }, +]; + +// 실제 데이터로 교체해서 쓰세요(구조 유지) +const MOCK: Record = { + rain: [ + { + id: "rain-0", + title: "경기도 수원시 빗물이용과 중수도", + desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…", + imageSrc: "/images/landing/rain_01.png", + href: "/reuse/business/rain/suwon", + }, + // { + // id: "rain-2", + // title: "경기도 수원시 빗물이용과 중수도", + // desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기…", + // imageSrc: "/images/landing/rain_01.png", + // href: "#", + // }, + ], + reuse: Array.from({ length: 6 }).map((_, i) => ({ + id: `reuse-${i}`, + title: `중수 재이용 사례 ${i + 1}`, + desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", + imageSrc: "/images/landing/sample_case.png", + href: "#", + })), + sewage: [ + { + id: "sewage-1", + title: "경기도 파주시 하수 재이용", + desc: "운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에...", + imageSrc: "/images/landing/sewage_01.png", + href: "#", + }, + { + id: "sewage-2", + title: "충남보령 하수재이용", + desc: "보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업...", + imageSrc: "/images/landing/sewage_02.png", + href: "#", + }, + { + id: "sewage-3", + title: "여수시 하수처리수 재이용", + desc: "(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로...", + imageSrc: "/images/landing/sewage_03.png", + href: "#", + }, + ...Array.from({ length: 6 }).map((_, i) => ({ + id: `sewage-x-${i}`, + title: `하수 재이용 사례 ${i + 4}`, + desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", + imageSrc: "/images/landing/sample_case.png", + href: "#", + })), + ], + global: Array.from({ length: 6 }).map((_, i) => ({ + id: `global-${i}`, + title: `해외 재이용 사례 ${i + 1}`, + desc: "설명 문구가 들어갑니다. (최대 2~3줄) …", + imageSrc: "/images/landing/sample_case.png", + href: "#", + })), +}; + +/* ---------------- utils ---------------- */ +function chunk(arr: T[], size: number) { + const res: T[][] = []; + if (size <= 0) return res; + for (let i = 0; i < arr.length; i += size) res.push(arr.slice(i, i + size)); + return res; +} +function mod(n: number, m: number) { + return ((n % m) + m) % m; +} +function usePrefersReducedMotion() { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const onChange = () => setReduced(mq.matches); + onChange(); + mq.addEventListener?.("change", onChange); + return () => mq.removeEventListener?.("change", onChange); + }, []); + return reduced; +} + +/** + * wrapRef: perPage 계산용 + * clipRef: "패딩 없는 측정 박스" 폭(px) 계산용 + */ +function useCarouselMeasure() { + const wrapRef = useRef(null); + const clipRef = useRef(null); + + const [perPage, setPerPage] = useState(3); + const [clipWidth, setClipWidth] = useState(0); + + // perPage: container width로 반응형 결정 (rAF로 디바운스) + useEffect(() => { + const el = wrapRef.current; + if (!el) return; + + let raf = 0; + + const calc = () => { + const w = el.clientWidth; + if (w < 640) setPerPage(1); + else if (w < 1024) setPerPage(2); + else setPerPage(3); + }; + + const ro = new ResizeObserver(() => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(calc); + }); + + calc(); + ro.observe(el); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, []); + + // clipWidth: 패딩 없는 박스에서 width를 정확히 측정 (sub-pixel 반올림) + useEffect(() => { + const el = clipRef.current; + if (!el) return; + + let raf = 0; + + const calc = () => { + const w = Math.round(el.getBoundingClientRect().width); + setClipWidth(w); + }; + + const ro = new ResizeObserver(() => { + cancelAnimationFrame(raf); + raf = requestAnimationFrame(calc); + }); + + calc(); + ro.observe(el); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, []); + + return { wrapRef, clipRef, perPage, clipWidth }; +} + +/* ---------------- component ---------------- */ +export default function CaseTabsCarousel() { + const [active, setActive] = useState("rain"); + + // 탭별 마지막 그룹 인덱스 기억 + const tabIndexRef = useRef>({ + rain: 0, + reuse: 0, + sewage: 0, + global: 0, + }); + + const reducedMotion = usePrefersReducedMotion(); + const { wrapRef, clipRef, perPage, clipWidth } = useCarouselMeasure(); + + const items = useMemo(() => MOCK[active] ?? [], [active]); + const groups = useMemo(() => chunk(items, perPage), [items, perPage]); + const groupCount = groups.length; + + const [cur, setCur] = useState(0); + const curRef = useRef(0); + + // px 기반 트랙 + const [trackPx, setTrackPx] = useState(0); + const [anim, setAnim] = useState(false); + const [busy, setBusy] = useState(false); + + // 방향 ref (onTransitionEnd에서 trackPx 비교하지 않음) + const dirRef = useRef<-1 | 1 | 0>(0); + + // cur 최신값 유지 + useEffect(() => { + curRef.current = cur; + }, [cur]); + + /** + * 리셋 로직 분리: + * - 탭/perPage/groupCount 바뀔 때만 "인덱스/상태" 리셋 + * - clipWidth 변화는 별도 effect에서 "busy 아닐 때만" 중앙(-W)로 배치 + */ + useLayoutEffect(() => { + const saved = tabIndexRef.current[active] ?? 0; + const nextCur = groupCount ? mod(saved, groupCount) : 0; + + dirRef.current = 0; + setCur(nextCur); + setBusy(false); + setAnim(false); + }, [active, perPage, groupCount]); + + // clipWidth 변화로 트랙 위치를 잡되, 슬라이드 중(busy)엔 덮어쓰지 않음 + useLayoutEffect(() => { + if (busy) return; + if (clipWidth > 0) setTrackPx(-clipWidth); + else setTrackPx(0); + }, [clipWidth, busy]); + + // 3그룹 가상화 + const viewGroups = useMemo(() => { + if (!groupCount) return [[], [], []] as CaseItem[][]; + return [ + groups[mod(cur - 1, groupCount)] ?? [], + groups[mod(cur, groupCount)] ?? [], + groups[mod(cur + 1, groupCount)] ?? [], + ]; + }, [groups, cur, groupCount]); + + const canSlide = groupCount > 1 && clipWidth > 0; + + const go = useCallback( + (dir: -1 | 1) => { + if (!canSlide) return; + if (busy) return; + + dirRef.current = dir; + + setBusy(true); + setAnim(!reducedMotion); + + // 중앙(-W) 기준: prev=0, next=-2W + setTrackPx(dir === 1 ? -2 * clipWidth : 0); + + // reduced motion이면 즉시 확정 + if (reducedMotion) { + const nextCur = mod(curRef.current + dir, groupCount); + tabIndexRef.current[active] = nextCur; + + setCur(nextCur); + setAnim(false); + setTrackPx(-clipWidth); + setBusy(false); + dirRef.current = 0; + } + }, + [active, busy, canSlide, clipWidth, groupCount, reducedMotion] + ); + + // BUGFIX 1) 자식 transitionend 버블링 방지 + transform만 처리 + const onTransitionEnd = useCallback( + (e: React.TransitionEvent) => { + // 자식 transitionend 버블링 방지 + if (e.target !== e.currentTarget) return; + if (e.propertyName !== "transform") return; + + if (!busy || reducedMotion) return; + + const dir = dirRef.current; + if (dir !== 1 && dir !== -1) return; + + const nextCur = mod(curRef.current + dir, groupCount); + tabIndexRef.current[active] = nextCur; + + // 1) 먼저 transition을 끄고(중요) + setAnim(false); + + // 2) cur를 회전 + 트랙을 즉시 중앙(-W)로 스냅 + // (이 순간은 "무조건 애니메이션 없이" 처리되어야 함) + setCur(nextCur); + setTrackPx(-clipWidth); + + // 3) 다음 프레임(2번 rAF)에서 다시 애니메이션 켜고 busy 해제 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + setAnim(!reducedMotion); + setBusy(false); + dirRef.current = 0; + }); + }); + }, + [active, busy, clipWidth, groupCount, reducedMotion] +); + + const goDot = useCallback( + (idx: number) => { + if (!groupCount) return; + if (busy) return; + + const target = mod(idx, groupCount); + tabIndexRef.current[active] = target; + + dirRef.current = 0; + setCur(target); + setAnim(false); + if (clipWidth > 0) setTrackPx(-clipWidth); + }, + [active, busy, clipWidth, groupCount] + ); + + const trackStyle: React.CSSProperties = useMemo( + () => ({ + transform: `translate3d(${trackPx}px, 0, 0)`, + transition: anim ? "transform 700ms cubic-bezier(.22,.61,.36,1)" : "none", + willChange: busy ? "transform" : undefined, + }), + [trackPx, anim, busy] + ); + + return ( +
+ {/* 탭 바 */} +
+
+
+ {TABS.map((t) => { + const isActive = t.key === active; + + return ( + + ); + })} +
+
+
+ + {/* 슬라이더 */} +
+
+ {/* 좌우 버튼 */} + + + + + {/* 중요: clip(가림)과 measure(측정) 분리 */} +
+
+ {/* 측정은 패딩 없는 박스에서 */} +
+ {/* track */} +
+ {viewGroups.map((group, gi) => ( +
+ {/* 그룹 내부: perPage 컬럼 */} +
+ {(group ?? []).map((c) => ( + + ))} +
+
+ ))} +
+
+
+
+ + {/* 도트 */} +
+ {Array.from({ length: Math.max(groupCount, 1) }).map((_, i) => ( +
+
+
+
+ ); +} diff --git a/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx b/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx new file mode 100644 index 000000000..6c50d5a5c --- /dev/null +++ b/apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx @@ -0,0 +1,167 @@ +// apps/dm/app/(landing)/reuse/_components/ImageSlider.tsx +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +type Slide = { src: string; alt?: string }; + +type Props = { + slides: Slide[]; + className?: string; + maxWidthClassName?: string; // 예: "max-w-[760px]" +}; + +export default function CaseImageSlider({ + slides, + className = "", + maxWidthClassName = "max-w-[760px]", +}: Props) { + const safeSlides = useMemo(() => slides.filter(Boolean), [slides]); + const [idx, setIdx] = useState(0); + const [dragging, setDragging] = useState(false); + + const startX = useRef(0); + const deltaX = useRef(0); + + const total = safeSlides.length; + const canSlide = total > 1; + + const go = (dir: number) => { + if (!canSlide) return; + setIdx((prev) => { + const next = prev + dir; + if (next < 0) return total - 1; + if (next >= total) return 0; + return next; + }); + }; + + // ← → 키보드 지원(선택) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!canSlide) return; + if (e.key === "ArrowLeft") go(-1); + if (e.key === "ArrowRight") go(1); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [canSlide, total]); + + // 드래그(마우스/터치)로 넘기기 + const onPointerDown = (e: React.PointerEvent) => { + if (!canSlide) return; + setDragging(true); + startX.current = e.clientX; + deltaX.current = 0; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging) return; + deltaX.current = e.clientX - startX.current; + }; + + const onPointerUp = () => { + if (!dragging) return; + setDragging(false); + + const threshold = 60; // 넘김 감도 + if (deltaX.current > threshold) go(-1); + else if (deltaX.current < -threshold) go(1); + + deltaX.current = 0; + }; + + if (!total) return null; + + return ( +
+
+
+ {/* 좌측 버튼 */} + + + + {/* 우측 버튼 */} + + + + {/* 뷰포트 */} +
+
+ {safeSlides.map((s, i) => ( +
+ {s.alt +
+ ))} +
+
+
+ + {/* 도트 */} + {canSlide && ( +
+ {safeSlides.map((_, i) => { + const active = i === idx; + return ( +
+ )} +
+
+ ); +} diff --git a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx index e19b60d0b..c7d834844 100644 --- a/apps/dm/app/(landing)/reuse/_components/Lnb.tsx +++ b/apps/dm/app/(landing)/reuse/_components/Lnb.tsx @@ -1,22 +1,33 @@ +// apps/dm/app/(landing)/reuse/_components/Lnb.tsx "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useMemo, useState } from "react"; export type LnbItem = { label: string; - href?: string; // ✅ href 없는 부모(2뎁스 컨테이너)도 허용 + href?: string; // href 없는 부모(2뎁스 컨테이너)도 허용 children?: LnbItem[]; }; function isExactActive(pathname: string, href?: string) { if (!href) return false; - return pathname === href; // ✅ exact match만 허용 (중요!) + return pathname === href; } export default function Lnb({ title, items }: { title: string; items: LnbItem[] }) { const pathname = usePathname(); + // ✅ 현재 경로가 속한 2depth 그룹을 기본 open + const defaultOpenLabel = useMemo(() => { + const found = items.find((it) => it.children?.some((c) => isExactActive(pathname, c.href))); + return found?.label ?? null; + }, [items, pathname]); + + // ✅ 아코디언: 한 번에 하나만 열리도록 openLabel 1개만 관리 + const [openLabel, setOpenLabel] = useState(defaultOpenLabel); + return (