From 10b825d9c0def293fc4dd96d3f49fa964d724fb4 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Thu, 15 Jan 2026 19:28:26 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EC=A1=B0=ED=95=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=201,2,3=EB=8B=A8=EA=B3=84=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/images/combination/stage1.svg | 9 ++++++++ src/assets/images/combination/stage2.svg | 9 ++++++++ src/assets/images/combination/stage3.svg | 9 ++++++++ src/components/Combination/Stage1Section.tsx | 21 +++++++++++++++++++ src/components/Combination/Stage2Section.tsx | 22 ++++++++++++++++++++ src/components/Combination/Stage3Section.tsx | 19 +++++++++++++++++ 6 files changed, 89 insertions(+) create mode 100644 src/assets/images/combination/stage1.svg create mode 100644 src/assets/images/combination/stage2.svg create mode 100644 src/assets/images/combination/stage3.svg create mode 100644 src/components/Combination/Stage1Section.tsx create mode 100644 src/components/Combination/Stage2Section.tsx create mode 100644 src/components/Combination/Stage3Section.tsx diff --git a/src/assets/images/combination/stage1.svg b/src/assets/images/combination/stage1.svg new file mode 100644 index 0000000..b9cfdd6 --- /dev/null +++ b/src/assets/images/combination/stage1.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/combination/stage2.svg b/src/assets/images/combination/stage2.svg new file mode 100644 index 0000000..9a15b89 --- /dev/null +++ b/src/assets/images/combination/stage2.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/combination/stage3.svg b/src/assets/images/combination/stage3.svg new file mode 100644 index 0000000..7e1f118 --- /dev/null +++ b/src/assets/images/combination/stage3.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/components/Combination/Stage1Section.tsx b/src/components/Combination/Stage1Section.tsx new file mode 100644 index 0000000..99b421d --- /dev/null +++ b/src/components/Combination/Stage1Section.tsx @@ -0,0 +1,21 @@ +import Stage1 from '@/assets/images/combination/stage1.svg?react'; + +const Stage1Section = () => { + return ( +
+
+
+

1단계

+

+ 나만의 기기 조합을 만들어보세요!
조합명을 입력하고, 조합 생성하기 버튼을 누르면 +
+ 나만의 조합이 생성돼요! +

+
+ +
+
+ ); +}; + +export default Stage1Section; diff --git a/src/components/Combination/Stage2Section.tsx b/src/components/Combination/Stage2Section.tsx new file mode 100644 index 0000000..30a8bf3 --- /dev/null +++ b/src/components/Combination/Stage2Section.tsx @@ -0,0 +1,22 @@ +import Stage2 from '@/assets/images/combination/stage2.svg?react'; + +const Stage2Section = () => { + return ( +
+
+
+

2단계

+

+ 기기검색 창에서 원하는 기기들을 골라 +
내가 만든 조합에 담아보세요! +
이때 한 조합에는 동일한 카테고리의 기기를
+ 하나씩만 담는 것을 추천해요! +

+
+ +
+
+ ); +}; + +export default Stage2Section; diff --git a/src/components/Combination/Stage3Section.tsx b/src/components/Combination/Stage3Section.tsx new file mode 100644 index 0000000..10ed424 --- /dev/null +++ b/src/components/Combination/Stage3Section.tsx @@ -0,0 +1,19 @@ +import Stage3 from '@/assets/images/combination/stage3.svg?react'; + +const Stage3Section = () => { + return ( +
+
+
+

3단계

+

+ 우측 상단 MY에 들어가서
내가 만든 조합의 조합도를 확인해 보세요! +

+
+ +
+
+ ); +}; + +export default Stage3Section; From b45014ce245f1a4aae89c61d3bc3e95ae355521a Mon Sep 17 00:00:00 2001 From: YuminPark Date: Thu, 15 Jan 2026 21:50:38 +0900 Subject: [PATCH 02/15] =?UTF-8?q?test:=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EB=B3=B8=EC=97=90=20=EB=A7=9E=EA=B2=8C=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=83=9D=EC=84=B1=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?ui=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Combination/Stage1Section.tsx | 10 +++--- src/components/Combination/Stage2Section.tsx | 10 +++--- src/components/Combination/Stage3Section.tsx | 10 +++--- src/components/Home/HomeIndicator.tsx | 2 +- src/index.css | 7 ---- .../combination/CombinationCreatePage.tsx | 36 ++++++++++++++++++- 6 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/components/Combination/Stage1Section.tsx b/src/components/Combination/Stage1Section.tsx index 99b421d..42e6af5 100644 --- a/src/components/Combination/Stage1Section.tsx +++ b/src/components/Combination/Stage1Section.tsx @@ -2,17 +2,17 @@ import Stage1 from '@/assets/images/combination/stage1.svg?react'; const Stage1Section = () => { return ( -
-
+
+
-

1단계

-

+

1단계

+

나만의 기기 조합을 만들어보세요!
조합명을 입력하고, 조합 생성하기 버튼을 누르면
나만의 조합이 생성돼요!

- +
); diff --git a/src/components/Combination/Stage2Section.tsx b/src/components/Combination/Stage2Section.tsx index 30a8bf3..6fb4e18 100644 --- a/src/components/Combination/Stage2Section.tsx +++ b/src/components/Combination/Stage2Section.tsx @@ -2,18 +2,18 @@ import Stage2 from '@/assets/images/combination/stage2.svg?react'; const Stage2Section = () => { return ( -
-
+
+
-

2단계

-

+

2단계

+

기기검색 창에서 원하는 기기들을 골라
내가 만든 조합에 담아보세요!
이때 한 조합에는 동일한 카테고리의 기기를
하나씩만 담는 것을 추천해요!

- +
); diff --git a/src/components/Combination/Stage3Section.tsx b/src/components/Combination/Stage3Section.tsx index 10ed424..e513c10 100644 --- a/src/components/Combination/Stage3Section.tsx +++ b/src/components/Combination/Stage3Section.tsx @@ -2,15 +2,15 @@ import Stage3 from '@/assets/images/combination/stage3.svg?react'; const Stage3Section = () => { return ( -
-
+
+
-

3단계

-

+

3단계

+

우측 상단 MY에 들어가서
내가 만든 조합의 조합도를 확인해 보세요!

- +
); diff --git a/src/components/Home/HomeIndicator.tsx b/src/components/Home/HomeIndicator.tsx index e78d848..6280c2a 100644 --- a/src/components/Home/HomeIndicator.tsx +++ b/src/components/Home/HomeIndicator.tsx @@ -21,7 +21,7 @@ const HomeIndicator = () => { `${brandLinkClass} ${isActive ? 'text-blue-600 hover:text-blue-500' : ''}`; return ( -
+
diff --git a/src/index.css b/src/index.css index 41da752..1d785df 100644 --- a/src/index.css +++ b/src/index.css @@ -104,13 +104,6 @@ } /* Background Effects */ -/* Radial glow - 조합 생성 완료 화면 */ -.bg-effect-glow { - border-radius: 800px; - opacity: 0.8; - background: radial-gradient(50% 50% at 50% 50%, var(--color-blue-200), transparent); -} - /* Bottom fade overlay - 기기 검색 화면 */ .bg-effect-fade-bottom { background: linear-gradient(180deg, rgba(0, 0, 0, 0) 95%, rgba(0, 0, 0, 0.2) 100%); diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index b3bf230..c8aec77 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -1,5 +1,39 @@ +import PrimaryButton from '@/components/Button/PrimaryButton'; +import Stage1Section from '@/components/Combination/Stage1Section'; +import Stage2Section from '@/components/Combination/Stage2Section'; +import Stage3Section from '@/components/Combination/Stage3Section'; + const CombinationCreatePage = () => { - return
CombinationCreatePage
; + return ( +
+
+
+ +

+ *회원의 경우에는 로그인 한 뒤, 조합을 생성해야지 마이페이지>내 조합 목록에 + 저장됩니다. +

+
+ +
+
+ + + +
+
+ ); }; export default CombinationCreatePage; + +// TODO: 간격 줄인 버전으로 간격 재조정 +// 입력창 유효성 검사 함수 +// 배경 깔기 + +// TODO: 간격 줄인 버전으로 간격 재조정 +// 입력창 유효성 검사 함수 From c5c5a8caf85470eee162a9de7ec6737735036c36 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Thu, 15 Jan 2026 22:59:40 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=98=EA=B8=B0=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B0=84=EA=B2=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Home/HomeIndicator.tsx | 2 +- src/pages/combination/CombinationCreatePage.tsx | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/components/Home/HomeIndicator.tsx b/src/components/Home/HomeIndicator.tsx index 6280c2a..e78d848 100644 --- a/src/components/Home/HomeIndicator.tsx +++ b/src/components/Home/HomeIndicator.tsx @@ -21,7 +21,7 @@ const HomeIndicator = () => { `${brandLinkClass} ${isActive ? 'text-blue-600 hover:text-blue-500' : ''}`; return ( -
+
diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index c8aec77..67f0083 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -5,7 +5,7 @@ import Stage3Section from '@/components/Combination/Stage3Section'; const CombinationCreatePage = () => { return ( -
+
{
-
+
@@ -31,9 +31,5 @@ const CombinationCreatePage = () => { export default CombinationCreatePage; -// TODO: 간격 줄인 버전으로 간격 재조정 -// 입력창 유효성 검사 함수 -// 배경 깔기 - -// TODO: 간격 줄인 버전으로 간격 재조정 -// 입력창 유효성 검사 함수 +// TODO +// 입력창 유효성 검사 처리(버튼 disabled->active) From 21b4a8fc1b0ab00e4df7b23c86cb2e055e66ecd8 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 02:51:03 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20=EC=A1=B0=ED=95=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.css | 4 + .../combination/CombinationCreatePage.tsx | 286 ++++++++++++++++-- 2 files changed, 269 insertions(+), 21 deletions(-) diff --git a/src/index.css b/src/index.css index 1d785df..b4fd80f 100644 --- a/src/index.css +++ b/src/index.css @@ -122,6 +122,10 @@ .border-shadow-blue { box-shadow: 0 0 7px 0 var(--color-blue-400); } + + .border-shadow-blue-double { + box-shadow: 0 0 16px 0 var(--color-blue-400); + } } @font-face { diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index 67f0083..9b6244d 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -1,35 +1,279 @@ +import { useMemo, useRef, useState } from 'react'; + import PrimaryButton from '@/components/Button/PrimaryButton'; import Stage1Section from '@/components/Combination/Stage1Section'; import Stage2Section from '@/components/Combination/Stage2Section'; import Stage3Section from '@/components/Combination/Stage3Section'; +type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done'; + +const HEADER_H = 80; +const INNER_W = 600; +const INNER_H = 72; +const SHRINK_W = 558; +const SHRINK_H = 66; +const OUTER_W = 638; +const OUTER_H = 111; +const T_SHRINK = 420; +const T_STACK = 520; +const DROP_DURATION = 1600; +const DROP_EASING = 'cubic-bezier(0.12, 0.95, 0.18, 1)'; +const LIFT_DISTANCE = 100; +const LIFT_DURATION = 1800; +const LIFT_DELAY = 180; +const LIFT_EASING = 'cubic-bezier(0.12, 0.9, 0.18, 1)'; +const DOUBLE_DELAY = 160; +const EXTRAS_AT_LIFT_PROGRESS = 0.01; + const CombinationCreatePage = () => { + const [name, setName] = useState(''); + const [centerText, setCenterText] = useState(null); + const [mode, setMode] = useState<'form' | 'result'>('form'); + const [bgOn, setBgOn] = useState(false); + const [resultOn, setResultOn] = useState(false); + const [phase, setPhase] = useState('idle'); + const [showDouble, setShowDouble] = useState(false); + const [showExtras, setShowExtras] = useState(false); + const inputRef = useRef(null); + const styleProbeRef = useRef(null); + const isValid = useMemo(() => name.trim().length > 0, [name]); + + const dropToCenter = (text: string) => { + const startEl = inputRef.current; + + if (!startEl) { + setCenterText(text); + setMode('result'); + setResultOn(true); + setPhase('done'); + setShowDouble(true); + + const extrasDelay = Math.round(LIFT_DELAY + LIFT_DURATION * EXTRAS_AT_LIFT_PROGRESS); + window.setTimeout(() => setShowExtras(true), extrasDelay); + + return; + } + + const startRect = startEl.getBoundingClientRect(); + const targetLeft = window.innerWidth / 2 - INNER_W / 2; + const targetTop = HEADER_H + (window.innerHeight - HEADER_H) / 2 - INNER_H / 2; + const startLeft = startRect.left + startRect.width / 2 - INNER_W / 2; + const startTop = startRect.top + startRect.height / 2 - INNER_H / 2; + + const floating = document.createElement('div'); + floating.textContent = text; + floating.style.position = 'fixed'; + floating.style.left = `${startLeft}px`; + floating.style.top = `${startTop}px`; + floating.style.width = `${INNER_W}px`; + floating.style.height = `${INNER_H}px`; + floating.style.padding = '20px'; + + floating.style.display = 'flex'; + floating.style.flexDirection = 'column'; + floating.style.justifyContent = 'center'; + floating.style.alignItems = 'center'; + floating.style.gap = '10px'; + + const probe = styleProbeRef.current; + if (probe) { + const cs = window.getComputedStyle(probe); + floating.style.borderRadius = cs.borderRadius; + floating.style.boxShadow = cs.boxShadow; + floating.style.backgroundColor = cs.backgroundColor; + floating.style.border = cs.border; + + floating.style.fontFamily = cs.fontFamily; + floating.style.fontSize = cs.fontSize; + floating.style.fontWeight = cs.fontWeight; + floating.style.lineHeight = cs.lineHeight; + floating.style.letterSpacing = cs.letterSpacing; + floating.style.color = cs.color; + floating.style.textAlign = cs.textAlign; + } + + floating.style.zIndex = '9999'; + floating.style.pointerEvents = 'none'; + floating.style.willChange = 'transform, opacity'; + floating.style.opacity = '1'; + + document.body.appendChild(floating); + + const dx = targetLeft - startLeft; + const dy = targetTop - startTop; + + floating.animate( + [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(${dx}px, ${dy}px, 0)` }], + { + duration: DROP_DURATION, + easing: DROP_EASING, + fill: 'forwards', + } + ); + + window.setTimeout(() => { + setCenterText(text); + setMode('result'); + setResultOn(false); + setPhase('idle'); + setShowDouble(false); + setShowExtras(false); + + requestAnimationFrame(() => setResultOn(true)); + + floating.style.transition = 'opacity 240ms ease-out'; + floating.style.opacity = '0'; + window.setTimeout(() => floating.remove(), 260); + window.setTimeout(() => setPhase('shrink'), 80); + window.setTimeout(() => setPhase('stack'), 80 + T_SHRINK); + window.setTimeout(() => setShowDouble(true), 80 + T_SHRINK + DOUBLE_DELAY); + window.setTimeout( + () => { + setPhase('done'); + + const extrasDelay = Math.round(LIFT_DELAY + LIFT_DURATION * EXTRAS_AT_LIFT_PROGRESS); + window.setTimeout(() => setShowExtras(true), extrasDelay); + }, + 80 + T_SHRINK + Math.max(T_STACK, 520) + ); + }, DROP_DURATION); + }; + + const handleCreate = () => { + if (!isValid) return; + setBgOn(true); + dropToCenter(name.trim()); + }; + + const innerSize = phase === 'shrink' ? { w: SHRINK_W, h: SHRINK_H } : { w: INNER_W, h: INNER_H }; + const liftActive = phase === 'done'; + + const liftStyle: React.CSSProperties = liftActive + ? { + transform: `translate3d(0, -${LIFT_DISTANCE}px, 0)`, + transitionProperty: 'transform', + transitionDuration: `${LIFT_DURATION}ms`, + transitionDelay: `${LIFT_DELAY}ms`, + transitionTimingFunction: LIFT_EASING, + willChange: 'transform', + } + : { + transform: 'translate3d(0, 0, 0)', + transitionProperty: 'transform', + transitionDuration: `260ms`, + transitionTimingFunction: 'ease-out', + willChange: 'transform', + }; + return (
-
-
- -

- *회원의 경우에는 로그인 한 뒤, 조합을 생성해야지 마이페이지>내 조합 목록에 - 저장됩니다. -

-
- -
-
- - - +
+ probe
+
+ {mode === 'result' && centerText && ( +
+
+
+
+
+
+ {centerText} +
+
+
+
+

+ 이제 기기검색 창에서 원하는 기기들을 골라 내가 만든 조합에 담아보세요! +

+
+ +
+
+
+
+ )} + {mode === 'form' && ( + <> +
+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleCreate(); + }} + className="w-600 h-72 px-20 py-20 rounded-button bg-blue-100 placeholder-gray-300 font-body-1-r outline-none" + /> +

+ *회원의 경우에는 로그인 한 뒤, 조합을 생성해야지 마이페이지>내 조합 목록에 + 저장됩니다. +

+
+ +
+
+ + + +
+ + )}
); }; export default CombinationCreatePage; - -// TODO -// 입력창 유효성 검사 처리(버튼 disabled->active) From a519031cf1c38cea36c122063b758cbf1f614461 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 02:51:25 +0900 Subject: [PATCH 05/15] =?UTF-8?q?del:=20=EC=A1=B0=ED=95=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=99=84=EB=A3=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/combination/CombinationCompletePage.tsx | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/pages/combination/CombinationCompletePage.tsx diff --git a/src/pages/combination/CombinationCompletePage.tsx b/src/pages/combination/CombinationCompletePage.tsx deleted file mode 100644 index 8f76a24..0000000 --- a/src/pages/combination/CombinationCompletePage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -const CombinationCompletePage = () => { - return
CombinationCompletePage
; -}; - -export default CombinationCompletePage; From 6bdb78dd90d2c4b5dbb57135376a022d70720583 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 03:03:05 +0900 Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20?= =?UTF-8?q?=ED=9B=85,=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/combination.ts | 27 +++ src/hooks/useCombinationMotion.ts | 116 +++++++++++++ .../combination/CombinationCreatePage.tsx | 155 +++--------------- src/utils/combinationFloating.ts | 85 ++++++++++ 4 files changed, 255 insertions(+), 128 deletions(-) create mode 100644 src/hooks/useCombinationMotion.ts create mode 100644 src/utils/combinationFloating.ts diff --git a/src/constants/combination.ts b/src/constants/combination.ts index e6aa711..f44956c 100644 --- a/src/constants/combination.ts +++ b/src/constants/combination.ts @@ -3,3 +3,30 @@ export const COMBINATION_STATUSES = ['최적', '보통', '미흡', '-'] as const export type CombinationName = (typeof COMBINATION_NAMES)[number]; export type CombinationStatus = (typeof COMBINATION_STATUSES)[number]; + +export const COMBO_MOTION = { + HEADER_H: 80, + + INNER_W: 600, + INNER_H: 72, + + SHRINK_W: 558, + SHRINK_H: 66, + + OUTER_W: 638, + OUTER_H: 111, + + T_SHRINK: 420, + T_STACK: 520, + + DROP_DURATION: 1600, + DROP_EASING: 'cubic-bezier(0.12, 0.95, 0.18, 1)', + + LIFT_DISTANCE: 100, + LIFT_DURATION: 1800, + LIFT_DELAY: 180, + LIFT_EASING: 'cubic-bezier(0.12, 0.9, 0.18, 1)', + + DOUBLE_DELAY: 160, + EXTRAS_AT_LIFT_PROGRESS: 0.01, +} as const; diff --git a/src/hooks/useCombinationMotion.ts b/src/hooks/useCombinationMotion.ts new file mode 100644 index 0000000..0aa784d --- /dev/null +++ b/src/hooks/useCombinationMotion.ts @@ -0,0 +1,116 @@ +import { useCallback } from 'react'; +import { COMBO_MOTION as M } from '@/constants/combination'; +import { + animateDrop, + copyComputedStyle, + createFloating, + fadeOutAndRemove, +} from '@/utils/combinationFloating'; + +type Setters = { + setCenterText: (v: string) => void; + setMode: (v: 'form' | 'result') => void; + setResultOn: (v: boolean) => void; + setPhase: (v: 'idle' | 'shrink' | 'stack' | 'done') => void; + setShowDouble: (v: boolean) => void; + setShowExtras: (v: boolean) => void; +}; + +export const useCombinationMotion = ({ + inputRef, + styleProbeRef, + setCenterText, + setMode, + setResultOn, + setPhase, + setShowDouble, + setShowExtras, +}: { + inputRef: React.RefObject; + styleProbeRef: React.RefObject; +} & Setters) => { + const scheduleExtras = useCallback(() => { + const extrasDelay = Math.round(M.LIFT_DELAY + M.LIFT_DURATION * M.EXTRAS_AT_LIFT_PROGRESS); + window.setTimeout(() => setShowExtras(true), extrasDelay); + }, [setShowExtras]); + + const start = useCallback( + (text: string) => { + const startEl = inputRef.current; + + // fallback (DOM 없으면 즉시 결과) + if (!startEl) { + setCenterText(text); + setMode('result'); + setResultOn(true); + setPhase('done'); + setShowDouble(true); + scheduleExtras(); + return; + } + + const startRect = startEl.getBoundingClientRect(); + + const targetLeft = window.innerWidth / 2 - M.INNER_W / 2; + const targetTop = M.HEADER_H + (window.innerHeight - M.HEADER_H) / 2 - M.INNER_H / 2; + + const startLeft = startRect.left + startRect.width / 2 - M.INNER_W / 2; + const startTop = startRect.top + startRect.height / 2 - M.INNER_H / 2; + + const floating = createFloating({ + text, + startLeft, + startTop, + width: M.INNER_W, + height: M.INNER_H, + padding: 20, + }); + + copyComputedStyle(floating, styleProbeRef.current); + + const dx = targetLeft - startLeft; + const dy = targetTop - startTop; + + animateDrop({ el: floating, dx, dy, duration: M.DROP_DURATION, easing: M.DROP_EASING }); + + window.setTimeout(() => { + setCenterText(text); + setMode('result'); + + // reset + setResultOn(false); + setPhase('idle'); + setShowDouble(false); + setShowExtras(false); + + requestAnimationFrame(() => setResultOn(true)); + fadeOutAndRemove(floating, 240); + + window.setTimeout(() => setPhase('shrink'), 80); + window.setTimeout(() => setPhase('stack'), 80 + M.T_SHRINK); + window.setTimeout(() => setShowDouble(true), 80 + M.T_SHRINK + M.DOUBLE_DELAY); + + window.setTimeout( + () => { + setPhase('done'); + scheduleExtras(); + }, + 80 + M.T_SHRINK + Math.max(M.T_STACK, 520) + ); + }, M.DROP_DURATION); + }, + [ + inputRef, + styleProbeRef, + setCenterText, + setMode, + setResultOn, + setPhase, + setShowDouble, + setShowExtras, + scheduleExtras, + ] + ); + + return { start }; +}; diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index 9b6244d..e4d207d 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -4,27 +4,11 @@ import PrimaryButton from '@/components/Button/PrimaryButton'; import Stage1Section from '@/components/Combination/Stage1Section'; import Stage2Section from '@/components/Combination/Stage2Section'; import Stage3Section from '@/components/Combination/Stage3Section'; +import { COMBO_MOTION as M } from '@/constants/combination'; +import { useCombinationMotion } from '@/hooks/useCombinationMotion'; type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done'; -const HEADER_H = 80; -const INNER_W = 600; -const INNER_H = 72; -const SHRINK_W = 558; -const SHRINK_H = 66; -const OUTER_W = 638; -const OUTER_H = 111; -const T_SHRINK = 420; -const T_STACK = 520; -const DROP_DURATION = 1600; -const DROP_EASING = 'cubic-bezier(0.12, 0.95, 0.18, 1)'; -const LIFT_DISTANCE = 100; -const LIFT_DURATION = 1800; -const LIFT_DELAY = 180; -const LIFT_EASING = 'cubic-bezier(0.12, 0.9, 0.18, 1)'; -const DOUBLE_DELAY = 160; -const EXTRAS_AT_LIFT_PROGRESS = 0.01; - const CombinationCreatePage = () => { const [name, setName] = useState(''); const [centerText, setCenterText] = useState(null); @@ -34,127 +18,40 @@ const CombinationCreatePage = () => { const [phase, setPhase] = useState('idle'); const [showDouble, setShowDouble] = useState(false); const [showExtras, setShowExtras] = useState(false); + const inputRef = useRef(null); const styleProbeRef = useRef(null); const isValid = useMemo(() => name.trim().length > 0, [name]); - const dropToCenter = (text: string) => { - const startEl = inputRef.current; - - if (!startEl) { - setCenterText(text); - setMode('result'); - setResultOn(true); - setPhase('done'); - setShowDouble(true); - - const extrasDelay = Math.round(LIFT_DELAY + LIFT_DURATION * EXTRAS_AT_LIFT_PROGRESS); - window.setTimeout(() => setShowExtras(true), extrasDelay); - - return; - } - - const startRect = startEl.getBoundingClientRect(); - const targetLeft = window.innerWidth / 2 - INNER_W / 2; - const targetTop = HEADER_H + (window.innerHeight - HEADER_H) / 2 - INNER_H / 2; - const startLeft = startRect.left + startRect.width / 2 - INNER_W / 2; - const startTop = startRect.top + startRect.height / 2 - INNER_H / 2; - - const floating = document.createElement('div'); - floating.textContent = text; - floating.style.position = 'fixed'; - floating.style.left = `${startLeft}px`; - floating.style.top = `${startTop}px`; - floating.style.width = `${INNER_W}px`; - floating.style.height = `${INNER_H}px`; - floating.style.padding = '20px'; - - floating.style.display = 'flex'; - floating.style.flexDirection = 'column'; - floating.style.justifyContent = 'center'; - floating.style.alignItems = 'center'; - floating.style.gap = '10px'; - - const probe = styleProbeRef.current; - if (probe) { - const cs = window.getComputedStyle(probe); - floating.style.borderRadius = cs.borderRadius; - floating.style.boxShadow = cs.boxShadow; - floating.style.backgroundColor = cs.backgroundColor; - floating.style.border = cs.border; - - floating.style.fontFamily = cs.fontFamily; - floating.style.fontSize = cs.fontSize; - floating.style.fontWeight = cs.fontWeight; - floating.style.lineHeight = cs.lineHeight; - floating.style.letterSpacing = cs.letterSpacing; - floating.style.color = cs.color; - floating.style.textAlign = cs.textAlign; - } - - floating.style.zIndex = '9999'; - floating.style.pointerEvents = 'none'; - floating.style.willChange = 'transform, opacity'; - floating.style.opacity = '1'; - - document.body.appendChild(floating); - - const dx = targetLeft - startLeft; - const dy = targetTop - startTop; - - floating.animate( - [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(${dx}px, ${dy}px, 0)` }], - { - duration: DROP_DURATION, - easing: DROP_EASING, - fill: 'forwards', - } - ); - - window.setTimeout(() => { - setCenterText(text); - setMode('result'); - setResultOn(false); - setPhase('idle'); - setShowDouble(false); - setShowExtras(false); - - requestAnimationFrame(() => setResultOn(true)); - - floating.style.transition = 'opacity 240ms ease-out'; - floating.style.opacity = '0'; - window.setTimeout(() => floating.remove(), 260); - window.setTimeout(() => setPhase('shrink'), 80); - window.setTimeout(() => setPhase('stack'), 80 + T_SHRINK); - window.setTimeout(() => setShowDouble(true), 80 + T_SHRINK + DOUBLE_DELAY); - window.setTimeout( - () => { - setPhase('done'); - - const extrasDelay = Math.round(LIFT_DELAY + LIFT_DURATION * EXTRAS_AT_LIFT_PROGRESS); - window.setTimeout(() => setShowExtras(true), extrasDelay); - }, - 80 + T_SHRINK + Math.max(T_STACK, 520) - ); - }, DROP_DURATION); - }; + const { start } = useCombinationMotion({ + inputRef, + styleProbeRef, + setCenterText, + setMode, + setResultOn, + setPhase, + setShowDouble, + setShowExtras, + }); const handleCreate = () => { if (!isValid) return; setBgOn(true); - dropToCenter(name.trim()); + start(name.trim()); }; - const innerSize = phase === 'shrink' ? { w: SHRINK_W, h: SHRINK_H } : { w: INNER_W, h: INNER_H }; + const innerSize = + phase === 'shrink' ? { w: M.SHRINK_W, h: M.SHRINK_H } : { w: M.INNER_W, h: M.INNER_H }; + const liftActive = phase === 'done'; const liftStyle: React.CSSProperties = liftActive ? { - transform: `translate3d(0, -${LIFT_DISTANCE}px, 0)`, + transform: `translate3d(0, -${M.LIFT_DISTANCE}px, 0)`, transitionProperty: 'transform', - transitionDuration: `${LIFT_DURATION}ms`, - transitionDelay: `${LIFT_DELAY}ms`, - transitionTimingFunction: LIFT_EASING, + transitionDuration: `${M.LIFT_DURATION}ms`, + transitionDelay: `${M.LIFT_DELAY}ms`, + transitionTimingFunction: M.LIFT_EASING, willChange: 'transform', } : { @@ -164,7 +61,6 @@ const CombinationCreatePage = () => { transitionTimingFunction: 'ease-out', willChange: 'transform', }; - return (
{ `} style={liftStyle} > -
+
{ transition-opacity duration-320 ease-out ${showDouble ? 'opacity-100' : 'opacity-0'} `} - style={{ width: `${OUTER_W}px`, height: `${OUTER_H}px` }} + style={{ width: `${M.OUTER_W}px`, height: `${M.OUTER_H}px` }} />
{ height: `${innerSize.h}px`, padding: '20px', transitionProperty: 'width, height', - transitionDuration: `${phase === 'shrink' ? T_SHRINK : T_STACK}ms`, + transitionDuration: `${phase === 'shrink' ? M.T_SHRINK : M.T_STACK}ms`, transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)', }} > diff --git a/src/utils/combinationFloating.ts b/src/utils/combinationFloating.ts new file mode 100644 index 0000000..b64d37d --- /dev/null +++ b/src/utils/combinationFloating.ts @@ -0,0 +1,85 @@ +type FloatingStyleSource = HTMLElement | null; + +export const createFloating = ({ + text, + startLeft, + startTop, + width, + height, + padding = 20, +}: { + text: string; + startLeft: number; + startTop: number; + width: number; + height: number; + padding?: number; +}) => { + const floating = document.createElement('div'); + floating.textContent = text; + + floating.style.position = 'fixed'; + floating.style.left = `${startLeft}px`; + floating.style.top = `${startTop}px`; + floating.style.width = `${width}px`; + floating.style.height = `${height}px`; + floating.style.padding = `${padding}px`; + + floating.style.display = 'flex'; + floating.style.flexDirection = 'column'; + floating.style.justifyContent = 'center'; + floating.style.alignItems = 'center'; + floating.style.gap = '10px'; + + floating.style.zIndex = '9999'; + floating.style.pointerEvents = 'none'; + floating.style.willChange = 'transform, opacity'; + floating.style.opacity = '1'; + + document.body.appendChild(floating); + return floating; +}; + +export const copyComputedStyle = (target: HTMLElement, source: FloatingStyleSource) => { + if (!source) return; + + const cs = window.getComputedStyle(source); + + target.style.borderRadius = cs.borderRadius; + target.style.boxShadow = cs.boxShadow; + target.style.backgroundColor = cs.backgroundColor; + target.style.border = cs.border; + + target.style.fontFamily = cs.fontFamily; + target.style.fontSize = cs.fontSize; + target.style.fontWeight = cs.fontWeight; + target.style.lineHeight = cs.lineHeight; + target.style.letterSpacing = cs.letterSpacing; + target.style.color = cs.color; + target.style.textAlign = cs.textAlign; +}; + +export const animateDrop = ({ + el, + dx, + dy, + duration, + easing, +}: { + el: HTMLElement; + dx: number; + dy: number; + duration: number; + easing: string; +}) => { + el.animate( + [{ transform: 'translate3d(0,0,0)' }, { transform: `translate3d(${dx}px, ${dy}px, 0)` }], + { duration, easing, fill: 'forwards' } + ); +}; + +export const fadeOutAndRemove = (el: HTMLElement, ms = 240) => { + el.style.transition = `opacity ${ms}ms ease-out`; + el.style.opacity = '0'; + window.setTimeout(() => el.remove(), ms + 20); +}; From e8bea95f42865de4d018479f3ee6c610286ba7c5 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 03:04:14 +0900 Subject: [PATCH 07/15] =?UTF-8?q?del:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/routes.ts | 1 - src/hooks/.gitkeep | 2 -- src/routes/AppRoutes.tsx | 6 +----- src/utils/.gitkeep | 2 -- 4 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 src/hooks/.gitkeep delete mode 100644 src/utils/.gitkeep diff --git a/src/constants/routes.ts b/src/constants/routes.ts index 189389b..b8d32ca 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -28,7 +28,6 @@ export const ROUTES = { // combination combination: { create: '/combination/create', - complete: '/combination/complete', }, // my diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep deleted file mode 100644 index 5f72043..0000000 --- a/src/hooks/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# keep - diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx index 600a510..24c137c 100644 --- a/src/routes/AppRoutes.tsx +++ b/src/routes/AppRoutes.tsx @@ -13,7 +13,6 @@ import DeviceDetailPage from '@/pages/devices/DeviceDetailPage'; // combination import CombinationCreatePage from '@/pages/combination/CombinationCreatePage'; -import CombinationCompletePage from '@/pages/combination/CombinationCompletePage'; // my import MyPage from '@/pages/my/MyPage'; @@ -54,10 +53,7 @@ export const AppRoutes = [ // combination { path: 'combination', - children: [ - { path: 'create', element: }, - { path: 'complete', element: }, - ], + children: [{ path: 'create', element: }], }, // my page diff --git a/src/utils/.gitkeep b/src/utils/.gitkeep deleted file mode 100644 index 5f72043..0000000 --- a/src/utils/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# keep - From f70c9fa68dade3e05342d6884d643f464f04162c Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 03:08:49 +0900 Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A8?= =?UTF-8?q?=EC=85=98=20=EB=A1=9C=EC=A7=81=20=ED=9B=85=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20ResultOverlay=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Combination/CombinationResultOverlay.tsx | 102 ++++++++++++++++++ .../Combination/CombinationStyleProbe.tsx | 16 +++ .../CombinationTag.tsx | 0 .../combination/CombinationCreatePage.tsx | 101 ++--------------- 4 files changed, 129 insertions(+), 90 deletions(-) create mode 100644 src/components/Combination/CombinationResultOverlay.tsx create mode 100644 src/components/Combination/CombinationStyleProbe.tsx rename src/components/{Combintaion => Combination}/CombinationTag.tsx (100%) diff --git a/src/components/Combination/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx new file mode 100644 index 0000000..c22a8da --- /dev/null +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -0,0 +1,102 @@ +import PrimaryButton from '@/components/Button/PrimaryButton'; +import { COMBO_MOTION as M } from '@/constants/combination'; + +type Props = { + centerText: string; + resultOn: boolean; + phase: 'idle' | 'shrink' | 'stack' | 'done'; + showDouble: boolean; + showExtras: boolean; +}; + +const CombinationResultOverlay = ({ + centerText, + resultOn, + phase, + showDouble, + showExtras, +}: Props) => { + const innerSize = + phase === 'shrink' ? { w: M.SHRINK_W, h: M.SHRINK_H } : { w: M.INNER_W, h: M.INNER_H }; + + const liftActive = phase === 'done'; + + const liftStyle: React.CSSProperties = liftActive + ? { + transform: `translate3d(0, -${M.LIFT_DISTANCE}px, 0)`, + transitionProperty: 'transform', + transitionDuration: `${M.LIFT_DURATION}ms`, + transitionDelay: `${M.LIFT_DELAY}ms`, + transitionTimingFunction: M.LIFT_EASING, + willChange: 'transform', + } + : { + transform: 'translate3d(0, 0, 0)', + transitionProperty: 'transform', + transitionDuration: `260ms`, + transitionTimingFunction: 'ease-out', + willChange: 'transform', + }; + + return ( +
+
+
+
+
+
+ {centerText} +
+
+
+ +
+

+ 이제 기기검색 창에서 원하는 기기들을 골라 내가 만든 조합에 담아보세요! +

+
+ +
+
+
+
+ ); +}; + +export default CombinationResultOverlay; diff --git a/src/components/Combination/CombinationStyleProbe.tsx b/src/components/Combination/CombinationStyleProbe.tsx new file mode 100644 index 0000000..7daeca4 --- /dev/null +++ b/src/components/Combination/CombinationStyleProbe.tsx @@ -0,0 +1,16 @@ +import { forwardRef } from 'react'; + +const CombinationStyleProbe = forwardRef((_, ref) => { + return ( +
+ probe +
+ ); +}); + +CombinationStyleProbe.displayName = 'CombinationStyleProbe'; + +export default CombinationStyleProbe; diff --git a/src/components/Combintaion/CombinationTag.tsx b/src/components/Combination/CombinationTag.tsx similarity index 100% rename from src/components/Combintaion/CombinationTag.tsx rename to src/components/Combination/CombinationTag.tsx diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index e4d207d..9c976fd 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -4,7 +4,8 @@ import PrimaryButton from '@/components/Button/PrimaryButton'; import Stage1Section from '@/components/Combination/Stage1Section'; import Stage2Section from '@/components/Combination/Stage2Section'; import Stage3Section from '@/components/Combination/Stage3Section'; -import { COMBO_MOTION as M } from '@/constants/combination'; +import CombinationResultOverlay from '@/components/Combination/CombinationResultOverlay'; +import CombinationStyleProbe from '@/components/Combination/CombinationStyleProbe'; import { useCombinationMotion } from '@/hooks/useCombinationMotion'; type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done'; @@ -40,35 +41,9 @@ const CombinationCreatePage = () => { start(name.trim()); }; - const innerSize = - phase === 'shrink' ? { w: M.SHRINK_W, h: M.SHRINK_H } : { w: M.INNER_W, h: M.INNER_H }; - - const liftActive = phase === 'done'; - - const liftStyle: React.CSSProperties = liftActive - ? { - transform: `translate3d(0, -${M.LIFT_DISTANCE}px, 0)`, - transitionProperty: 'transform', - transitionDuration: `${M.LIFT_DURATION}ms`, - transitionDelay: `${M.LIFT_DELAY}ms`, - transitionTimingFunction: M.LIFT_EASING, - willChange: 'transform', - } - : { - transform: 'translate3d(0, 0, 0)', - transitionProperty: 'transform', - transitionDuration: `260ms`, - transitionTimingFunction: 'ease-out', - willChange: 'transform', - }; return (
-
- probe -
+
{ `} /> {mode === 'result' && centerText && ( -
-
-
-
-
-
- {centerText} -
-
-
-
-

- 이제 기기검색 창에서 원하는 기기들을 골라 내가 만든 조합에 담아보세요! -

-
- -
-
-
-
+ )} {mode === 'form' && ( <> @@ -147,9 +70,7 @@ const CombinationCreatePage = () => { placeholder="생성하고 싶은 조합명을 입력하세요" value={name} onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleCreate(); - }} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} className="w-600 h-72 px-20 py-20 rounded-button bg-blue-100 placeholder-gray-300 font-body-1-r outline-none" />

From dfcf6d4360208ff90ca93e33712a7fbcf7875f29 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 03:35:14 +0900 Subject: [PATCH 09/15] =?UTF-8?q?feat:=20=EC=A1=B0=ED=95=A9=EB=AA=85=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=ED=9B=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCombinationNameInput.ts | 109 ++++++++++++++++++ .../combination/CombinationCreatePage.tsx | 45 ++++++-- 2 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useCombinationNameInput.ts diff --git a/src/hooks/useCombinationNameInput.ts b/src/hooks/useCombinationNameInput.ts new file mode 100644 index 0000000..77639bc --- /dev/null +++ b/src/hooks/useCombinationNameInput.ts @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useState } from 'react'; + +type UseCombinationNameInputParams = { + inputRef: React.RefObject; + existingNames?: string[]; + maxLen?: number; +}; + +type ErrorType = 'invalidChar' | 'tooLong' | 'onlySpace' | 'duplicate' | null; + +const DEFAULT_MAX_LEN = 20; +const ALLOWED_REGEX = /^[가-힣a-zA-Z0-9 ]*$/; +const DISALLOWED_GLOBAL = /[^가-힣a-zA-Z0-9 ]/g; + +export const useCombinationNameInput = ({ + inputRef, + existingNames = [], + maxLen = DEFAULT_MAX_LEN, +}: UseCombinationNameInputParams) => { + const [value, setValue] = useState(''); + const [error, setError] = useState(null); + const [isComposing, setIsComposing] = useState(false); + + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef]); + + const normalizedExisting = useMemo(() => existingNames.map((v) => v.trim()), [existingNames]); + + const validate = (next: string): ErrorType => { + if (!ALLOWED_REGEX.test(next)) return 'invalidChar'; + if (next.length > maxLen) return 'tooLong'; + if (next.trim().length === 0) return 'onlySpace'; + if (normalizedExisting.includes(next.trim())) return 'duplicate'; + return null; + }; + + const sanitize = (raw: string) => { + const removedInvalid = raw.replace(DISALLOWED_GLOBAL, ''); + const sliced = removedInvalid.slice(0, maxLen); + return { + sanitized: sliced, + hadInvalid: removedInvalid !== raw, + wasTooLong: removedInvalid.length > maxLen, + }; + }; + + const apply = (raw: string) => { + const { sanitized, hadInvalid, wasTooLong } = sanitize(raw); + setValue(sanitized); + + if (hadInvalid) { + setError('invalidChar'); + return; + } + if (wasTooLong) { + setError('tooLong'); + return; + } + + setError(validate(sanitized)); + }; + + const onChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + + if (isComposing) { + setValue(raw); + return; + } + apply(raw); + }; + + const onCompositionStart = () => setIsComposing(true); + + const onCompositionEnd = (e: React.CompositionEvent) => { + setIsComposing(false); + apply(e.currentTarget.value); + }; + + const isValid = useMemo(() => validate(value) === null, [value]); + + const errorMessage = useMemo(() => { + switch (error) { + case 'invalidChar': + return '특수문자나 이모지는 사용할 수 없습니다.'; + case 'tooLong': + return `조합명은 최대 ${maxLen}자까지 입력 가능합니다.`; + case 'onlySpace': + return '조합명을 한 글자 이상 입력해주세요.'; + case 'duplicate': + return '이미 존재하는 조합명입니다. 다른 이름을 시도해주세요.'; + default: + return null; + } + }, [error, maxLen]); + + return { + value, + onChange, + onCompositionStart, + onCompositionEnd, + error, + errorMessage, + isValid, + validate, + setValue, + }; +}; diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index 9c976fd..59da2a9 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -7,11 +7,14 @@ import Stage3Section from '@/components/Combination/Stage3Section'; import CombinationResultOverlay from '@/components/Combination/CombinationResultOverlay'; import CombinationStyleProbe from '@/components/Combination/CombinationStyleProbe'; import { useCombinationMotion } from '@/hooks/useCombinationMotion'; +import { useCombinationNameInput } from '@/hooks/useCombinationNameInput'; type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done'; +// TODO: 나중에 API/상태에서 가져오기 (내가 만든 조합명 리스트) +const EXISTING_COMBO_NAMES = ['사무실 세팅']; + const CombinationCreatePage = () => { - const [name, setName] = useState(''); const [centerText, setCenterText] = useState(null); const [mode, setMode] = useState<'form' | 'result'>('form'); const [bgOn, setBgOn] = useState(false); @@ -22,7 +25,20 @@ const CombinationCreatePage = () => { const inputRef = useRef(null); const styleProbeRef = useRef(null); - const isValid = useMemo(() => name.trim().length > 0, [name]); + + const { + value: name, + onChange: onNameChange, + onCompositionStart, + onCompositionEnd, + errorMessage, + isValid, + validate, + } = useCombinationNameInput({ + inputRef, + existingNames: EXISTING_COMBO_NAMES, + maxLen: 20, + }); const { start } = useCombinationMotion({ inputRef, @@ -37,10 +53,20 @@ const CombinationCreatePage = () => { const handleCreate = () => { if (!isValid) return; + if (validate(name) !== null) return; setBgOn(true); start(name.trim()); }; + const helperText = + errorMessage ?? + '회원의 경우 로그인 한 뒤 조합을 생성해야 마이페이지>내 조합 목록에 저장됩니다.'; + + const buttonClass = useMemo( + () => `w-280 ${isValid ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-300 cursor-not-allowed'}`, + [isValid] + ); + return (

@@ -69,20 +95,21 @@ const CombinationCreatePage = () => { type="text" placeholder="생성하고 싶은 조합명을 입력하세요" value={name} - onChange={(e) => setName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + onChange={onNameChange} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} + onKeyDown={(e) => { + if (e.key === 'Enter' && isValid) handleCreate(); + }} className="w-600 h-72 px-20 py-20 rounded-button bg-blue-100 placeholder-gray-300 font-body-1-r outline-none" /> -

- *회원의 경우에는 로그인 한 뒤, 조합을 생성해야지 마이페이지>내 조합 목록에 - 저장됩니다. -

+

{helperText}

From 489b074ea90b1e0420543c38530a7bd1a2a61344 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 03:49:46 +0900 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20shrink=20=EC=95=A0=EB=8B=88?= =?UTF-8?q?=EB=A9=94=EC=9D=B4=EC=85=98=20=EC=8B=9C=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=81=AC=EA=B8=B0=20=EB=B6=80=EB=93=9C=EB=9F=BD?= =?UTF-8?q?=EA=B2=8C=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Combination/CombinationResultOverlay.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Combination/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx index c22a8da..7cbd88c 100644 --- a/src/components/Combination/CombinationResultOverlay.tsx +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -35,7 +35,7 @@ const CombinationResultOverlay = ({ transitionProperty: 'transform', transitionDuration: `260ms`, transitionTimingFunction: 'ease-out', - willChange: 'transform', + willChange: undefined, }; return ( @@ -63,12 +63,15 @@ const CombinationResultOverlay = ({ absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col justify-center items-center gap-8 rounded-button bg-white border-shadow-blue font-heading-2 text-black + whitespace-nowrap overflow-hidden text-ellipsis + transition-all `} style={{ width: `${innerSize.w}px`, height: `${innerSize.h}px`, padding: '20px', - transitionProperty: 'width, height', + fontSize: phase === 'shrink' ? '30px' : undefined, + transitionDuration: `${phase === 'shrink' ? M.T_SHRINK : M.T_STACK}ms`, transitionTimingFunction: 'cubic-bezier(0.22,1,0.36,1)', }} From 9aaeaf670f2fe385e08fb2a07403053831930ff6 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 03:54:30 +0900 Subject: [PATCH 11/15] =?UTF-8?q?refactor:=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=EA=B2=80=EC=83=89=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Combination/CombinationResultOverlay.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Combination/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx index 7cbd88c..29362cb 100644 --- a/src/components/Combination/CombinationResultOverlay.tsx +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -1,5 +1,6 @@ import PrimaryButton from '@/components/Button/PrimaryButton'; import { COMBO_MOTION as M } from '@/constants/combination'; +import { useNavigate } from 'react-router-dom'; type Props = { centerText: string; @@ -38,6 +39,8 @@ const CombinationResultOverlay = ({ willChange: undefined, }; + const navigate = useNavigate(); + return (
@@ -94,7 +97,11 @@ const CombinationResultOverlay = ({ 이제 기기검색 창에서 원하는 기기들을 골라 내가 만든 조합에 담아보세요!

- + navigate('/devices')} + />
From ee8cfe6ade6e5c076487ac57e2b1922cac0803f4 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 12:34:07 +0900 Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20=EC=9D=B8=ED=84=B0=EB=9E=99?= =?UTF-8?q?=EC=85=98=20=EC=88=98=EC=B9=98=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=99=88=20=EC=9D=B8=EB=94=94=EC=BC=80=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Combination/CombinationResultOverlay.tsx | 3 +- src/components/Home/HomeIndicator.tsx | 42 ++++++------------- src/constants/combination.ts | 6 +-- 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/src/components/Combination/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx index 29362cb..1a9eda9 100644 --- a/src/components/Combination/CombinationResultOverlay.tsx +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -42,7 +42,7 @@ const CombinationResultOverlay = ({ const navigate = useNavigate(); return ( -
+
{ `${brandLinkClass} ${isActive ? 'text-blue-600 hover:text-blue-500' : ''}`; return ( -
+
-
+
- + Device Life @@ -49,8 +45,8 @@ const HomeIndicator = ({ paddingRight = 0 }: HomeIndicatorProps) => { 조합 생성하기
-
- {authStatus === 'logout' && ( +
+ {authStatus === 'logout' ? ( <> 로그인 @@ -59,8 +55,7 @@ const HomeIndicator = ({ paddingRight = 0 }: HomeIndicatorProps) => { 회원가입 - )} - {authStatus !== 'logout' && ( + ) : ( <> { <> - + - {authStatus === 'guest' ? 'MY(guest)' : 'MY'} + MY )} @@ -102,7 +84,7 @@ const HomeIndicator = ({ paddingRight = 0 }: HomeIndicatorProps) => { to="/" className={navTextClass} onClick={() => { - // TODO: 로그아웃 로직 작성 + // TODO: 로그아웃 로직 }} > 로그아웃 diff --git a/src/constants/combination.ts b/src/constants/combination.ts index f44956c..dcc95bd 100644 --- a/src/constants/combination.ts +++ b/src/constants/combination.ts @@ -19,11 +19,11 @@ export const COMBO_MOTION = { T_SHRINK: 420, T_STACK: 520, - DROP_DURATION: 1600, + DROP_DURATION: 1200, DROP_EASING: 'cubic-bezier(0.12, 0.95, 0.18, 1)', - LIFT_DISTANCE: 100, - LIFT_DURATION: 1800, + LIFT_DISTANCE: 90, + LIFT_DURATION: 1500, LIFT_DELAY: 180, LIFT_EASING: 'cubic-bezier(0.12, 0.9, 0.18, 1)', From 4d7e2b243a892e17d0ac47898aae457d2cc55590 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Fri, 16 Jan 2026 12:56:54 +0900 Subject: [PATCH 13/15] =?UTF-8?q?chore:=204=EC=9D=98=20=EB=B0=B0=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=92=20=EB=B3=B4=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=88=EC=A4=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Button/.gitkeep | 2 -- src/components/Combination/CombinationStyleProbe.tsx | 2 +- src/components/Combination/Stage2Section.tsx | 2 +- src/components/Combination/Stage3Section.tsx | 2 +- src/hooks/useCombinationMotion.ts | 4 ---- src/pages/combination/CombinationCreatePage.tsx | 2 +- src/utils/combinationFloating.ts | 5 ----- 7 files changed, 4 insertions(+), 15 deletions(-) delete mode 100644 src/components/Button/.gitkeep diff --git a/src/components/Button/.gitkeep b/src/components/Button/.gitkeep deleted file mode 100644 index 5f72043..0000000 --- a/src/components/Button/.gitkeep +++ /dev/null @@ -1,2 +0,0 @@ -# keep - diff --git a/src/components/Combination/CombinationStyleProbe.tsx b/src/components/Combination/CombinationStyleProbe.tsx index 7daeca4..2cad435 100644 --- a/src/components/Combination/CombinationStyleProbe.tsx +++ b/src/components/Combination/CombinationStyleProbe.tsx @@ -4,7 +4,7 @@ const CombinationStyleProbe = forwardRef((_, ref) => { return (
probe
diff --git a/src/components/Combination/Stage2Section.tsx b/src/components/Combination/Stage2Section.tsx index 6fb4e18..3d43060 100644 --- a/src/components/Combination/Stage2Section.tsx +++ b/src/components/Combination/Stage2Section.tsx @@ -13,7 +13,7 @@ const Stage2Section = () => { 하나씩만 담는 것을 추천해요!

- +
); diff --git a/src/components/Combination/Stage3Section.tsx b/src/components/Combination/Stage3Section.tsx index e513c10..1a93823 100644 --- a/src/components/Combination/Stage3Section.tsx +++ b/src/components/Combination/Stage3Section.tsx @@ -10,7 +10,7 @@ const Stage3Section = () => { 우측 상단 MY에 들어가서
내가 만든 조합의 조합도를 확인해 보세요!

- +
); diff --git a/src/hooks/useCombinationMotion.ts b/src/hooks/useCombinationMotion.ts index 0aa784d..4ecd554 100644 --- a/src/hooks/useCombinationMotion.ts +++ b/src/hooks/useCombinationMotion.ts @@ -38,7 +38,6 @@ export const useCombinationMotion = ({ (text: string) => { const startEl = inputRef.current; - // fallback (DOM 없으면 즉시 결과) if (!startEl) { setCenterText(text); setMode('result'); @@ -53,7 +52,6 @@ export const useCombinationMotion = ({ const targetLeft = window.innerWidth / 2 - M.INNER_W / 2; const targetTop = M.HEADER_H + (window.innerHeight - M.HEADER_H) / 2 - M.INNER_H / 2; - const startLeft = startRect.left + startRect.width / 2 - M.INNER_W / 2; const startTop = startRect.top + startRect.height / 2 - M.INNER_H / 2; @@ -76,8 +74,6 @@ export const useCombinationMotion = ({ window.setTimeout(() => { setCenterText(text); setMode('result'); - - // reset setResultOn(false); setPhase('idle'); setShowDouble(false); diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index 59da2a9..2cbbc6a 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -68,7 +68,7 @@ const CombinationCreatePage = () => { ); return ( -
+
{ const floating = document.createElement('div'); floating.textContent = text; - floating.style.position = 'fixed'; floating.style.left = `${startLeft}px`; floating.style.top = `${startTop}px`; floating.style.width = `${width}px`; floating.style.height = `${height}px`; floating.style.padding = `${padding}px`; - floating.style.display = 'flex'; floating.style.flexDirection = 'column'; floating.style.justifyContent = 'center'; floating.style.alignItems = 'center'; floating.style.gap = '10px'; - floating.style.zIndex = '9999'; floating.style.pointerEvents = 'none'; floating.style.willChange = 'transform, opacity'; floating.style.opacity = '1'; - document.body.appendChild(floating); return floating; }; @@ -49,7 +45,6 @@ export const copyComputedStyle = (target: HTMLElement, source: FloatingStyleSour target.style.boxShadow = cs.boxShadow; target.style.backgroundColor = cs.backgroundColor; target.style.border = cs.border; - target.style.fontFamily = cs.fontFamily; target.style.fontSize = cs.fontSize; target.style.fontWeight = cs.fontWeight; From 14c905a0e58f11095944368c4c2c59a94f82dcc5 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Sat, 17 Jan 2026 01:05:14 +0900 Subject: [PATCH 14/15] =?UTF-8?q?fix:=20=EC=A1=B0=ED=95=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EB=93=9C=EB=A1=AD=20->=20shrink=20?= =?UTF-8?q?=EC=A0=84=20=EC=9C=84=EC=B9=98=20=EC=9D=B4=EB=8F=99=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Combination/CombinationResultOverlay.tsx | 13 ++++-- src/hooks/useCombinationMotion.ts | 41 ++++++++++++------- .../combination/CombinationCreatePage.tsx | 21 +++++----- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/src/components/Combination/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx index 1a9eda9..6b9cd6d 100644 --- a/src/components/Combination/CombinationResultOverlay.tsx +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -8,6 +8,7 @@ type Props = { phase: 'idle' | 'shrink' | 'stack' | 'done'; showDouble: boolean; showExtras: boolean; + targetRef: React.RefObject; }; const CombinationResultOverlay = ({ @@ -16,6 +17,7 @@ const CombinationResultOverlay = ({ phase, showDouble, showExtras, + targetRef, }: Props) => { const innerSize = phase === 'shrink' ? { w: M.SHRINK_W, h: M.SHRINK_H } : { w: M.INNER_W, h: M.INNER_H }; @@ -42,7 +44,10 @@ const CombinationResultOverlay = ({ const navigate = useNavigate(); return ( -
+
-

diff --git a/src/hooks/useCombinationMotion.ts b/src/hooks/useCombinationMotion.ts index 4ecd554..c5e8e97 100644 --- a/src/hooks/useCombinationMotion.ts +++ b/src/hooks/useCombinationMotion.ts @@ -19,6 +19,7 @@ type Setters = { export const useCombinationMotion = ({ inputRef, styleProbeRef, + targetRef, setCenterText, setMode, setResultOn, @@ -28,6 +29,7 @@ export const useCombinationMotion = ({ }: { inputRef: React.RefObject; styleProbeRef: React.RefObject; + targetRef: React.RefObject; } & Setters) => { const scheduleExtras = useCallback(() => { const extrasDelay = Math.round(M.LIFT_DELAY + M.LIFT_DURATION * M.EXTRAS_AT_LIFT_PROGRESS); @@ -37,6 +39,15 @@ export const useCombinationMotion = ({ const start = useCallback( (text: string) => { const startEl = inputRef.current; + const targetRect = targetRef.current?.getBoundingClientRect(); + + const targetLeft = targetRect + ? targetRect.left + targetRect.width / 2 - M.INNER_W / 2 + : window.innerWidth / 2 - M.INNER_W / 2; + + const targetTop = targetRect + ? targetRect.top + targetRect.height / 2 - M.INNER_H / 2 + : M.HEADER_H + (window.innerHeight - M.HEADER_H) / 2 - M.INNER_H / 2; if (!startEl) { setCenterText(text); @@ -50,8 +61,6 @@ export const useCombinationMotion = ({ const startRect = startEl.getBoundingClientRect(); - const targetLeft = window.innerWidth / 2 - M.INNER_W / 2; - const targetTop = M.HEADER_H + (window.innerHeight - M.HEADER_H) / 2 - M.INNER_H / 2; const startLeft = startRect.left + startRect.width / 2 - M.INNER_W / 2; const startTop = startRect.top + startRect.height / 2 - M.INNER_H / 2; @@ -79,25 +88,27 @@ export const useCombinationMotion = ({ setShowDouble(false); setShowExtras(false); - requestAnimationFrame(() => setResultOn(true)); - fadeOutAndRemove(floating, 240); - - window.setTimeout(() => setPhase('shrink'), 80); - window.setTimeout(() => setPhase('stack'), 80 + M.T_SHRINK); - window.setTimeout(() => setShowDouble(true), 80 + M.T_SHRINK + M.DOUBLE_DELAY); + requestAnimationFrame(() => { + setResultOn(true); + setPhase('shrink'); + window.setTimeout(() => setPhase('stack'), M.T_SHRINK); + window.setTimeout(() => setShowDouble(true), M.T_SHRINK + M.DOUBLE_DELAY); - window.setTimeout( - () => { - setPhase('done'); - scheduleExtras(); - }, - 80 + M.T_SHRINK + Math.max(M.T_STACK, 520) - ); + window.setTimeout( + () => { + setPhase('done'); + scheduleExtras(); + }, + M.T_SHRINK + Math.max(M.T_STACK, 520) + ); + }); + fadeOutAndRemove(floating, 180); }, M.DROP_DURATION); }, [ inputRef, styleProbeRef, + targetRef, setCenterText, setMode, setResultOn, diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx index 2cbbc6a..2f560f8 100644 --- a/src/pages/combination/CombinationCreatePage.tsx +++ b/src/pages/combination/CombinationCreatePage.tsx @@ -15,7 +15,7 @@ type ResultPhase = 'idle' | 'shrink' | 'stack' | 'done'; const EXISTING_COMBO_NAMES = ['사무실 세팅']; const CombinationCreatePage = () => { - const [centerText, setCenterText] = useState(null); + const [centerText, setCenterText] = useState(''); const [mode, setMode] = useState<'form' | 'result'>('form'); const [bgOn, setBgOn] = useState(false); const [resultOn, setResultOn] = useState(false); @@ -25,6 +25,7 @@ const CombinationCreatePage = () => { const inputRef = useRef(null); const styleProbeRef = useRef(null); + const targetRef = useRef(null); const { value: name, @@ -43,6 +44,7 @@ const CombinationCreatePage = () => { const { start } = useCombinationMotion({ inputRef, styleProbeRef, + targetRef, setCenterText, setMode, setResultOn, @@ -77,15 +79,14 @@ const CombinationCreatePage = () => { ${bgOn ? 'opacity-100' : 'opacity-0'} `} /> - {mode === 'result' && centerText && ( - - )} + {mode === 'form' && ( <>

From 9dbc3216f47956b639a90b8fe1847d5a22f72805 Mon Sep 17 00:00:00 2001 From: YuminPark Date: Sat, 17 Jan 2026 01:12:50 +0900 Subject: [PATCH 15/15] =?UTF-8?q?chore:=20=EC=A4=91=EC=95=99=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=93=9C=EB=9E=8D=EB=90=98=EB=8A=94=20=EC=86=8D?= =?UTF-8?q?=EB=8F=84=20=EB=8A=98=EB=A6=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/combination.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/combination.ts b/src/constants/combination.ts index dcc95bd..023ef2d 100644 --- a/src/constants/combination.ts +++ b/src/constants/combination.ts @@ -19,7 +19,7 @@ export const COMBO_MOTION = { T_SHRINK: 420, T_STACK: 520, - DROP_DURATION: 1200, + DROP_DURATION: 2000, DROP_EASING: 'cubic-bezier(0.12, 0.95, 0.18, 1)', LIFT_DISTANCE: 90,