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/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/CombinationResultOverlay.tsx b/src/components/Combination/CombinationResultOverlay.tsx new file mode 100644 index 0000000..6b9cd6d --- /dev/null +++ b/src/components/Combination/CombinationResultOverlay.tsx @@ -0,0 +1,116 @@ +import PrimaryButton from '@/components/Button/PrimaryButton'; +import { COMBO_MOTION as M } from '@/constants/combination'; +import { useNavigate } from 'react-router-dom'; + +type Props = { + centerText: string; + resultOn: boolean; + phase: 'idle' | 'shrink' | 'stack' | 'done'; + showDouble: boolean; + showExtras: boolean; + targetRef: React.RefObject; +}; + +const CombinationResultOverlay = ({ + centerText, + resultOn, + 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 }; + + 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: undefined, + }; + + const navigate = useNavigate(); + + return ( +
+
+
+
+
+
+ {centerText} +
+
+
+
+

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

+
+ navigate('/devices')} + /> +
+
+
+
+ ); +}; + +export default CombinationResultOverlay; diff --git a/src/components/Combination/CombinationStyleProbe.tsx b/src/components/Combination/CombinationStyleProbe.tsx new file mode 100644 index 0000000..2cad435 --- /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/Combination/Stage1Section.tsx b/src/components/Combination/Stage1Section.tsx new file mode 100644 index 0000000..42e6af5 --- /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..3d43060 --- /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..1a93823 --- /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; diff --git a/src/components/Home/HomeIndicator.tsx b/src/components/Home/HomeIndicator.tsx index 0deaf10..347aea3 100644 --- a/src/components/Home/HomeIndicator.tsx +++ b/src/components/Home/HomeIndicator.tsx @@ -8,8 +8,7 @@ import { useState } from 'react'; const brandLinkClass = 'font-service-name-sm text-black hover:text-blue-500 active:text-blue-600'; const navTextClass = 'font-body-1-sm text-black hover:text-blue-500 active:text-blue-600'; - -type AuthStatus = 'logout' | 'login' | 'guest'; +type AuthStatus = 'logout' | 'login'; interface HomeIndicatorProps { paddingRight?: number; @@ -25,16 +24,13 @@ const HomeIndicator = ({ paddingRight = 0 }: HomeIndicatorProps) => { `${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 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); +};