+ {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 e6aa711..023ef2d 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: 2000,
+ DROP_EASING: 'cubic-bezier(0.12, 0.95, 0.18, 1)',
+
+ LIFT_DISTANCE: 90,
+ LIFT_DURATION: 1500,
+ 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/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/hooks/useCombinationMotion.ts b/src/hooks/useCombinationMotion.ts
new file mode 100644
index 0000000..c5e8e97
--- /dev/null
+++ b/src/hooks/useCombinationMotion.ts
@@ -0,0 +1,123 @@
+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,
+ targetRef,
+ setCenterText,
+ setMode,
+ setResultOn,
+ setPhase,
+ setShowDouble,
+ setShowExtras,
+}: {
+ 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);
+ window.setTimeout(() => setShowExtras(true), extrasDelay);
+ }, [setShowExtras]);
+
+ 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);
+ setMode('result');
+ setResultOn(true);
+ setPhase('done');
+ setShowDouble(true);
+ scheduleExtras();
+ return;
+ }
+
+ const startRect = startEl.getBoundingClientRect();
+
+ 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');
+ setResultOn(false);
+ setPhase('idle');
+ setShowDouble(false);
+ setShowExtras(false);
+
+ 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();
+ },
+ M.T_SHRINK + Math.max(M.T_STACK, 520)
+ );
+ });
+ fadeOutAndRemove(floating, 180);
+ }, M.DROP_DURATION);
+ },
+ [
+ inputRef,
+ styleProbeRef,
+ targetRef,
+ setCenterText,
+ setMode,
+ setResultOn,
+ setPhase,
+ setShowDouble,
+ setShowExtras,
+ scheduleExtras,
+ ]
+ );
+
+ return { start };
+};
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/index.css b/src/index.css
index 7882f9e..e8475d8 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%);
@@ -129,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/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;
diff --git a/src/pages/combination/CombinationCreatePage.tsx b/src/pages/combination/CombinationCreatePage.tsx
index b3bf230..2f560f8 100644
--- a/src/pages/combination/CombinationCreatePage.tsx
+++ b/src/pages/combination/CombinationCreatePage.tsx
@@ -1,5 +1,127 @@
+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';
+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 = () => {
- return CombinationCreatePage
;
+ const [centerText, setCenterText] = useState('');
+ 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 targetRef = useRef(null);
+
+ const {
+ value: name,
+ onChange: onNameChange,
+ onCompositionStart,
+ onCompositionEnd,
+ errorMessage,
+ isValid,
+ validate,
+ } = useCombinationNameInput({
+ inputRef,
+ existingNames: EXISTING_COMBO_NAMES,
+ maxLen: 20,
+ });
+
+ const { start } = useCombinationMotion({
+ inputRef,
+ styleProbeRef,
+ targetRef,
+ setCenterText,
+ setMode,
+ setResultOn,
+ setPhase,
+ setShowDouble,
+ setShowExtras,
+ });
+
+ 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 (
+
+
+
+
+ {mode === 'form' && (
+ <>
+
+
+
{
+ 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}
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
};
export default CombinationCreatePage;
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
-
diff --git a/src/utils/combinationFloating.ts b/src/utils/combinationFloating.ts
new file mode 100644
index 0000000..ec966bb
--- /dev/null
+++ b/src/utils/combinationFloating.ts
@@ -0,0 +1,80 @@
+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);
+};