From 0a907b4bfe751b70c6f996870d7f4b45116c8410 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 22 Mar 2026 16:45:08 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B8=ED=85=8C=EB=A6=AC=EC=96=B4=20?= =?UTF-8?q?=EC=83=98=ED=94=8C=20=E2=80=94=20=ED=9E=88=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=B0=EC=B9=98=C2=B7=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=95=A0?= =?UTF-8?q?=EB=8B=88=EB=A9=94=EC=9D=B4=EC=85=98=C2=B7=ED=8F=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Root cause 3가지] 1. 스크롤 이벤트 타깃 오류 - window.scroll → .main-content (overflow-y:auto) 로 수정 - DashboardShell의 내부 스크롤 컨테이너를 querySelector로 탐색 2. 히어로 높이 오류 - height:100dvh → calc(100dvh - 40px - 72px) - 배너(40px) + 네비(72px) 이후 남은 뷰포트를 정확히 채움 3. 스크롤 텍스트 transform 충돌 - top:50%; transform:translateY(-50%) 위치지정 제거 - au-scrub-text를 inset:0 flex 레이아웃으로 변경 - JS는 opacity+filter만 갱신 (transform 불변) [추가 개선] - nav: 히어로 위에서 투명, 스크롤 후 cream 배경+blur 전환 - 스크롤 섹션 초기 onScrub() 즉시 호출로 첫 텍스트 표시 - IntersectionObserver root를 .main-content로 지정 - 마일스톤 2 data-end="1.01" (경계값 처리) - 전체 페이지 코드 정리 및 중복 제거 Co-Authored-By: Claude Sonnet 4.6 --- .../website/samples/interior/page.tsx | 675 +++++++++--------- 1 file changed, 326 insertions(+), 349 deletions(-) diff --git a/app/services/website/samples/interior/page.tsx b/app/services/website/samples/interior/page.tsx index 874db76..cabe288 100644 --- a/app/services/website/samples/interior/page.tsx +++ b/app/services/website/samples/interior/page.tsx @@ -3,33 +3,32 @@ import Link from 'next/link'; import { useState, useEffect, useRef } from 'react'; -/* ── DATA ── */ +/* ══════════════════════════════════════════════ + DATA +══════════════════════════════════════════════ */ const portfolio = [ { title: '한남동 단독주택', cat: '주거 인테리어', area: '245㎡', img: 'https://i.pinimg.com/1200x/a7/56/f4/a756f4482ad282353fe89b6ddc4ba3e1.jpg' }, - { title: '청담 파인다이닝', cat: '상업 공간', area: '190㎡', img: 'https://i.pinimg.com/736x/f2/68/a7/f268a7cb3405e960a3d1bf7c44c9c7e5.jpg' }, - { title: '성수 브랜드 오피스', cat: '업무 공간', area: '380㎡', img: 'https://i.pinimg.com/736x/f3/15/2a/f3152a792b7310b6475b40cf912ae0c1.jpg' }, - { title: '용산 아파트 리모델링', cat: '리모델링', area: '95㎡', img: 'https://i.pinimg.com/474x/76/14/4a/76144a948cea14b77dd2fd43f0da8484.jpg' }, + { title: '청담 파인다이닝', cat: '상업 공간', area: '190㎡', img: 'https://i.pinimg.com/736x/f2/68/a7/f268a7cb3405e960a3d1bf7c44c9c7e5.jpg' }, + { title: '성수 브랜드 오피스', cat: '업무 공간', area: '380㎡', img: 'https://i.pinimg.com/736x/f3/15/2a/f3152a792b7310b6475b40cf912ae0c1.jpg' }, + { title: '용산 아파트 리모델링', cat: '리모델링', area: '95㎡', img: 'https://i.pinimg.com/474x/76/14/4a/76144a948cea14b77dd2fd43f0da8484.jpg' }, { title: '강남 카페 에스프레소랩', cat: '상업 공간', area: '120㎡', img: 'https://i.pinimg.com/736x/03/72/b0/0372b0f07d36982f4d3889290a7c762f.jpg' }, ]; const services = [ { - title: '주거 인테리어', - sub: 'Residential', + title: '주거 인테리어', sub: 'Residential', desc: '생활의 리듬에 맞춘 공간을 설계합니다. 단독주택부터 아파트까지, 당신의 일상이 더 아름다워지도록 모든 디테일을 손수 고릅니다.', details: ['공간 기획 및 3D 시뮬레이션', '자재 선정 동행 서비스', '시공 전 과정 PM', '준공 후 AS 1년'], img: 'https://i.pinimg.com/736x/1d/af/b2/1dafb2117511994568cc45ceed09a64c.jpg', }, { - title: '상업 공간 디자인', - sub: 'Commercial', + title: '상업 공간 디자인', sub: 'Commercial', desc: '브랜드의 철학이 공간 언어로 번역됩니다. 첫 방문객이 문을 열었을 때 느끼는 그 감정까지 설계의 범위입니다.', details: ['브랜드 아이덴티티 반영', '동선 및 고객 UX 설계', '조명·음향 플래닝', '설비 협력사 연계'], img: 'https://www.lunalightstudios.com/cdn/shop/files/contemporary-aluminum-funnel-suspension-pendant-lamp-fits-study-room-or-cafe-6-5-10-inch-wide-1-light-grey-226.webp?v=1768032185&width=675', }, { - title: '리모델링 & 재생', - sub: 'Remodeling', + title: '리모델링 & 재생', sub: 'Remodeling', desc: '기존 공간의 가능성을 새로운 시선으로 바라봅니다. 구조적 변경부터 마감재 교체까지, 완전한 변신을 지원합니다.', details: ['현장 실측 및 구조 분석', '철거~완공 원스톱', '예산 내 최적 시공', '친환경 자재 우선 적용'], img: 'https://i.pinimg.com/474x/76/14/4a/76144a948cea14b77dd2fd43f0da8484.jpg', @@ -37,24 +36,9 @@ const services = [ ]; const testimonials = [ - { - name: '하윤서', role: '한남동 단독주택 의뢰인', u: 'hayunseo', - rating: 5, - text: '처음엔 예산이 걱정됐는데, 아우라 팀이 범위를 명확히 정해줘서 오히려 계획보다 적게 들었습니다. 무엇보다 완공된 공간에서 매일 아침 커피 한 잔 하는 지금이 너무 행복해요.', - highlight: '계획보다 적은 예산', - }, - { - name: '박도현', role: '카페 에스프레소랩 대표', u: 'parkdohyun', - rating: 5, - text: '우리 브랜드 철학을 완벽하게 공간으로 옮겨줬습니다. 오픈 첫날부터 SNS 바이럴이 터졌고, 오픈 3개월 만에 매출이 전년 대비 340% 올랐어요.', - highlight: '매출 340% 상승', - }, - { - name: '이서진', role: '루미너스 COO', u: 'leeseojin', - rating: 5, - text: '직원들이 출근하고 싶은 공간을 만드는 게 목표였습니다. 리모델링 후 직원 만족도 설문에서 93점, 퇴직률이 절반으로 줄었습니다.', - highlight: '직원 만족도 93점', - }, + { name: '하윤서', role: '한남동 단독주택 의뢰인', u: 'hayunseo', rating: 5, highlight: '계획보다 적은 예산', text: '처음엔 예산이 걱정됐는데, 아우라 팀이 범위를 명확히 정해줘서 오히려 계획보다 적게 들었습니다. 무엇보다 완공된 공간에서 매일 아침 커피 한 잔 하는 지금이 너무 행복해요.' }, + { name: '박도현', role: '카페 에스프레소랩 대표', u: 'parkdohyun', rating: 5, highlight: '매출 340% 상승', text: '우리 브랜드 철학을 완벽하게 공간으로 옮겨줬습니다. 오픈 첫날부터 SNS 바이럴이 터졌고, 오픈 3개월 만에 매출이 전년 대비 340% 올랐어요.' }, + { name: '이서진', role: '루미너스 COO', u: 'leeseojin', rating: 5, highlight: '직원 만족도 93점', text: '직원들이 출근하고 싶은 공간을 만드는 게 목표였습니다. 리모델링 후 직원 만족도 설문에서 93점, 퇴직률이 절반으로 줄었습니다.' }, ]; const steps = [ @@ -64,49 +48,62 @@ const steps = [ { num: '04', title: '준공 & AS', desc: '완공 후 1년간 무상 AS. 공간이 오래 아름답도록 함께합니다.' }, ]; -/* ── SVG ICONS ── */ +/* ══════════════════════════════════════════════ + SVG ICONS +══════════════════════════════════════════════ */ const StarIcon = ({ filled }: { filled: boolean }) => ( ); - const CheckIcon = () => ( ); - const ArrowRight = ({ color = '#8B6914' }: { color?: string }) => ( ); -/* ── COMPONENT ── */ +/* ══════════════════════════════════════════════ + CONSTANTS +══════════════════════════════════════════════ */ const FRAME_COUNT = 48; +const BANNER_H = 40; // px — back-banner explicit height +const NAV_H = 72; // px — sticky nav height +/* ══════════════════════════════════════════════ + PAGE COMPONENT +══════════════════════════════════════════════ */ export default function InteriorSample() { const [scrolled, setScrolled] = useState(false); - const canvasRef = useRef(null); + const canvasRef = useRef(null); const scrollSectionRef = useRef(null); - const textOverlayRef = useRef(null); - const framesRef = useRef([]); + const textOverlayRef = useRef(null); + const framesRef = useRef([]); useEffect(() => { - /* ── NAV scroll state ── */ - const onNavScroll = () => setScrolled(window.scrollY > 80); - window.addEventListener('scroll', onNavScroll, { passive: true }); + /* ─── 스크롤 컨테이너: DashboardShell의 .main-content (overflow-y: auto) + window가 아닌 이 요소에서 scroll 이벤트가 발생함 ─── */ + const scroller: HTMLElement = + (document.querySelector('.main-content') as HTMLElement | null) ?? + document.documentElement; - /* ── Reveal animations ── */ + /* ── nav 투명 → 불투명 전환 ── */ + const onNavScroll = () => setScrolled(scroller.scrollTop > 60); + scroller.addEventListener('scroll', onNavScroll, { passive: true }); + + /* ── Intersection reveal ── */ const observer = new IntersectionObserver( (entries) => entries.forEach((e) => { if (e.isIntersecting) e.target.classList.add('au-visible'); }), - { threshold: 0.08 } + { threshold: 0.08, root: scroller === document.documentElement ? null : scroller } ); document.querySelectorAll('.au-reveal').forEach((el) => observer.observe(el)); - /* ── Frame preloading ── */ + /* ── WebP 프레임 프리로드 ── */ const frames: HTMLImageElement[] = new Array(FRAME_COUNT); framesRef.current = frames; let firstLoaded = false; @@ -118,8 +115,9 @@ export default function InteriorSample() { const ctx = canvas.getContext('2d'); if (!ctx) return; const cw = canvas.width, ch = canvas.height; + // cover-fit: 비율 유지하며 캔버스를 꽉 채움 const scale = Math.max(cw / img.naturalWidth, ch / img.naturalHeight); - const dx = (cw - img.naturalWidth * scale) / 2; + const dx = (cw - img.naturalWidth * scale) / 2; const dy = (ch - img.naturalHeight * scale) / 2; ctx.clearRect(0, 0, cw, ch); ctx.drawImage(img, dx, dy, img.naturalWidth * scale, img.naturalHeight * scale); @@ -128,213 +126,253 @@ export default function InteriorSample() { for (let i = 0; i < FRAME_COUNT; i++) { const img = new Image(); img.src = `/interior-frames/frame-${String(i + 1).padStart(3, '0')}.webp`; - img.onload = () => { - if (!firstLoaded) { firstLoaded = true; drawFrame(0); } - }; + img.onload = () => { if (!firstLoaded) { firstLoaded = true; drawFrame(0); } }; frames[i] = img; } - /* ── Canvas resize ── */ + /* ── 캔버스 크기를 컨테이너에 맞춤 ── */ const resizeCanvas = () => { const canvas = canvasRef.current; if (!canvas) return; - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + // sticky 컨테이너의 실제 크기 사용 + const parent = canvas.parentElement; + canvas.width = parent?.clientWidth ?? window.innerWidth; + canvas.height = parent?.clientHeight ?? window.innerHeight; drawFrame(0); }; resizeCanvas(); window.addEventListener('resize', resizeCanvas); - /* ── Scroll scrubbing ── */ - const textMilestones = [ - { start: 0, end: 0.33, idx: 0 }, - { start: 0.33, end: 0.66, idx: 1 }, - { start: 0.66, end: 1.0, idx: 2 }, - ]; - + /* ── 스크롤 스크러빙 (frame-by-frame) ── */ let rafId = 0; const onScrub = () => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const section = scrollSectionRef.current; if (!section) return; - const rect = section.getBoundingClientRect(); - const scrollable = section.offsetHeight - window.innerHeight; - if (scrollable <= 0) return; - const progress = Math.max(0, Math.min(1, -rect.top / scrollable)); - const frameIdx = Math.floor(progress * (FRAME_COUNT - 1)); - drawFrame(frameIdx); - // Update text overlay visibility via DOM (no re-render) + // getBoundingClientRect()는 viewport 기준 → scroller가 .main-content여도 정상 작동 + const rect = section.getBoundingClientRect(); + const vh = scroller === document.documentElement + ? window.innerHeight + : scroller.clientHeight; + const scrollable = section.offsetHeight - vh; + if (scrollable <= 0) return; + + const progress = Math.max(0, Math.min(1, -rect.top / scrollable)); + drawFrame(Math.floor(progress * (FRAME_COUNT - 1))); + + /* 텍스트 오버레이: opacity + filter만 변경 (transform 금지) */ const overlayEl = textOverlayRef.current; if (!overlayEl) return; - const els = overlayEl.querySelectorAll('[data-milestone]'); - els.forEach((el) => { - const s = parseFloat(el.dataset.start ?? '0'); - const e2 = parseFloat(el.dataset.end ?? '1'); + overlayEl.querySelectorAll('[data-milestone]').forEach((el) => { + const s = parseFloat(el.dataset.start ?? '0'); + const e2 = parseFloat(el.dataset.end ?? '1'); const vis = progress >= s && progress < e2; el.style.opacity = vis ? '1' : '0'; - el.style.transform = vis ? 'translateY(0)' : `translateY(${progress < s ? '1.5rem' : '-1.5rem'})`; + el.style.filter = vis ? 'blur(0px)' : 'blur(6px)'; }); - // Update progress bar + + /* 진행 바 */ const bar = overlayEl.querySelector('[data-progress-bar]'); if (bar) bar.style.width = `${progress * 100}%`; }); }; - window.addEventListener('scroll', onScrub, { passive: true }); + scroller.addEventListener('scroll', onScrub, { passive: true }); + onScrub(); // 초기 상태 즉시 반영 return () => { - window.removeEventListener('scroll', onNavScroll); - window.removeEventListener('scroll', onScrub); + scroller.removeEventListener('scroll', onNavScroll); + scroller.removeEventListener('scroll', onScrub); window.removeEventListener('resize', resizeCanvas); observer.disconnect(); cancelAnimationFrame(rafId); }; }, []); - const NAV_H = 72; - const GOLD = '#8B6914'; - const DARK = '#1C1A17'; - const SAGE = '#4E5C3E'; - const CREAM = '#FAF8F5'; + /* ── 팔레트 상수 ── */ + const GOLD = '#8B6914'; + const DARK = '#1C1A17'; + const SAGE = '#4E5C3E'; + const CREAM = '#FAF8F5'; const SURFACE = '#F0ECE4'; return (
+ + {/* ══ 폰트 + 전역 CSS ══ */}