diff --git a/package.json b/package.json index ba787ee..af558fe 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@tailwindcss/vite": "^4.1.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "framer-motion": "^12.12.1", "jotai": "^2.12.4", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8be69f..6058ffc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + framer-motion: + specifier: ^12.12.1 + version: 12.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) jotai: specifier: ^2.12.4 version: 2.12.4(@types/react@19.1.5)(react@19.1.0) @@ -944,6 +947,20 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + framer-motion@12.12.1: + resolution: {integrity: sha512-PFw4/GCREHI2suK/NlPSUxd+x6Rkp80uQsfCRFSOQNrm5pZif7eGtmG1VaD/UF1fW9tRBy5AaS77StatB3OJDg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1153,6 +1170,12 @@ packages: modern-ahocorasick@1.1.0: resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + motion-dom@12.12.1: + resolution: {integrity: sha512-GXq/uUbZBEiFFE+K1Z/sxdPdadMdfJ/jmBALDfIuHGi0NmtealLOfH9FqT+6aNPgVx8ilq0DtYmyQlo6Uj9LKQ==} + + motion-utils@12.12.1: + resolution: {integrity: sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1364,6 +1387,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2201,6 +2227,15 @@ snapshots: flatted@3.3.3: {} + framer-motion@12.12.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + motion-dom: 12.12.1 + motion-utils: 12.12.1 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + fsevents@2.3.3: optional: true @@ -2355,6 +2390,12 @@ snapshots: modern-ahocorasick@1.1.0: {} + motion-dom@12.12.1: + dependencies: + motion-utils: 12.12.1 + + motion-utils@12.12.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -2503,6 +2544,8 @@ snapshots: dependencies: typescript: 5.8.3 + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/app/global.css b/src/app/global.css index 4590063..8602d86 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -10,6 +10,7 @@ --color-s: #767676; --color-sd: #333; --color-sl: #dadada; + --color-sxl: #f5f5f5; --color-grad1: #5ba5f8; --color-grad2: #7c81ff; @@ -20,7 +21,7 @@ --text-xl: 20px; --text-lg: 18px; - --text-md: 16px; + --text-mid: 16px; --text-sm: 14px; --spacing-normal: 20px; @@ -38,8 +39,8 @@ :root { font-family: - Pretendard, 'Pretendard Variable', + 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, @@ -55,6 +56,7 @@ sans-serif; font-size: 16px; color: #111; + letter-spacing: -0.48px; } /* Chrome, Safari, Edge, Opera */ @@ -91,4 +93,12 @@ input[type='number'] { .shadow-login { box-shadow: 0px 4px 20px 0px rgba(55, 127, 248, 0.04); } + + .shadow-card { + box-shadow: 8px 8px 30px rgba(55, 127, 248, 0.1); + } + + .shadow-button { + box-shadow: 4px 4px 20px 0px rgba(55, 127, 248, 0.1); + } } diff --git a/src/app/stackflow/Stack.tsx b/src/app/stackflow/Stack.tsx index be5e13f..1717a06 100644 --- a/src/app/stackflow/Stack.tsx +++ b/src/app/stackflow/Stack.tsx @@ -1,11 +1,13 @@ import { basicUIPlugin } from '@stackflow/plugin-basic-ui'; import { basicRendererPlugin } from '@stackflow/plugin-renderer-basic'; import { stackflow } from '@stackflow/react'; + import { LoginScreen } from '@/screen/login/ui'; +import { HomeScreen } from '@/screen/home/ui'; export const { Stack, useFlow } = stackflow({ transitionDuration: 350, - activities: { LoginScreen }, + activities: { LoginScreen, HomeScreen }, plugins: [ basicRendererPlugin(), basicUIPlugin({ diff --git a/src/assets/icon/icon-notice.svg b/src/assets/icon/icon-notice.svg new file mode 100644 index 0000000..827c5fc --- /dev/null +++ b/src/assets/icon/icon-notice.svg @@ -0,0 +1,25 @@ + + +
+ + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/assets/icon/icon-result.svg b/src/assets/icon/icon-result.svg new file mode 100644 index 0000000..8737208 --- /dev/null +++ b/src/assets/icon/icon-result.svg @@ -0,0 +1,28 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/assets/icon/icon-user.png b/src/assets/icon/icon-user.png new file mode 100644 index 0000000..6cfbad0 Binary files /dev/null and b/src/assets/icon/icon-user.png differ diff --git a/src/assets/icon/index.ts b/src/assets/icon/index.ts new file mode 100644 index 0000000..89cbc40 --- /dev/null +++ b/src/assets/icon/index.ts @@ -0,0 +1,5 @@ +import UserIcon from './icon-user.png'; +import ResultIcon from './icon-result.svg'; +import NoticeIcon from './icon-notice.svg'; + +export { UserIcon, ResultIcon, NoticeIcon }; diff --git a/src/assets/image/bg-home.png b/src/assets/image/bg-home.png new file mode 100644 index 0000000..0dccd4a Binary files /dev/null and b/src/assets/image/bg-home.png differ diff --git a/src/assets/image/index.ts b/src/assets/image/index.ts index 5de9c45..bcdd47a 100644 --- a/src/assets/image/index.ts +++ b/src/assets/image/index.ts @@ -1,3 +1,4 @@ import LoginBg from './bg-login.png'; +import HomeBg from './bg-home.png'; -export { LoginBg }; +export { LoginBg, HomeBg }; diff --git a/src/features/home/mock/card.ts b/src/features/home/mock/card.ts new file mode 100644 index 0000000..51cf3bc --- /dev/null +++ b/src/features/home/mock/card.ts @@ -0,0 +1,32 @@ +import type { CardProps } from '@/features/home/types'; + +export const CARD_MOCK: CardProps[] = [ + { + id: 1, + title: '2025 총학생회 선거', + campus: '수원캠', + status: '진행중', + date: '2024.04.15 - 2024.04.16', + }, + { + id: 2, + title: '제 2회 총무 선거', + campus: '수원캠', + status: '진행중', + date: '2024.04.15 - 2024.04.16', + }, + { + id: 3, + title: '제 3회 기획 선거', + campus: '수원캠', + status: '진행중', + date: '2024.04.15 - 2024.04.16', + }, + { + id: 4, + title: '제 3회 기획 선거', + campus: '수원캠', + status: '진행중', + date: '2024.04.15 - 2024.04.16', + }, +]; diff --git a/src/features/home/mock/index.ts b/src/features/home/mock/index.ts new file mode 100644 index 0000000..cb5809f --- /dev/null +++ b/src/features/home/mock/index.ts @@ -0,0 +1 @@ +export * from './card'; diff --git a/src/features/home/types/card.ts b/src/features/home/types/card.ts new file mode 100644 index 0000000..d4271cc --- /dev/null +++ b/src/features/home/types/card.ts @@ -0,0 +1,7 @@ +export type CardProps = { + id: number; + title: string; + campus: string; + status: string; + date: string; +}; diff --git a/src/features/home/types/index.ts b/src/features/home/types/index.ts new file mode 100644 index 0000000..cb5809f --- /dev/null +++ b/src/features/home/types/index.ts @@ -0,0 +1 @@ +export * from './card'; diff --git a/src/features/home/ui/Card.tsx b/src/features/home/ui/Card.tsx new file mode 100644 index 0000000..0ecbb94 --- /dev/null +++ b/src/features/home/ui/Card.tsx @@ -0,0 +1,33 @@ +import { Button } from '@/shared/ui'; + +interface CardProps { + campus: string; + status: string; + title: string; + date: string; +} + +export default function Card({ campus, status, title, date }: CardProps) { + return ( + <> +
+
+
+ {campus} +
+
+ {status} +
+
+
+ D-7 +
+
+

{title}

+

{date}

+ + + ); +} diff --git a/src/features/home/ui/CardStack.tsx b/src/features/home/ui/CardStack.tsx new file mode 100644 index 0000000..6a2d089 --- /dev/null +++ b/src/features/home/ui/CardStack.tsx @@ -0,0 +1,117 @@ +import { useRef, useState } from 'react'; +import { animate, motion } from 'framer-motion'; + +import { Card } from '@/features/home/ui'; +import type { CardProps } from '@/features/home/types'; +import { CARD_MOCK } from '@/features/home/mock'; + +export default function CardStack() { + const [stack, setStack] = useState(CARD_MOCK); + const [passedCards, setPassedCards] = useState([]); + const cardRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}); + const currentIndex = CARD_MOCK.length - stack.length; + + return ( + <> +
+ {stack.map((card, index) => { + const swipeRight = () => { + if (passedCards.length !== 0) { + setPassedCards(prev => prev.slice(0, -1)); + animate( + cardRefs.current[card.id]!, + { x: 0 }, + { type: 'spring', stiffness: 300 }, + ); + setStack(prev => [passedCards[passedCards.length - 1], ...prev]); + } else { + animate( + cardRefs.current[card.id]!, + { x: 0 }, + { type: 'spring', stiffness: 300 }, + ); + } + }; + + const swipeLeft = () => { + if (stack.length === 0) { + animate( + cardRefs.current[card.id]!, + { x: 0 }, + { type: 'spring', stiffness: 300 }, + ); + } + if (stack.length === 1) { + animate( + cardRefs.current[card.id]!, + { x: 0 }, + { type: 'spring', stiffness: 300 }, + ); + setStack(CARD_MOCK); + setPassedCards([]); + } else if (stack.length !== 0) { + setPassedCards(prev => [...prev, stack[0]]); + setStack(prev => prev.slice(1)); + } + }; + + return ( + { + if (Math.abs(info.offset.x) <= 70) { + animate( + cardRefs.current[card.id]!, + { x: 0 }, + { type: 'spring', stiffness: 300 }, + ); + } else { + if (info.offset.x >= 70) swipeRight(); + if (info.offset.x < -70) swipeLeft(); + } + }} + className="shadow-card absolute flex h-fit w-full cursor-pointer flex-col rounded-lg bg-white p-6" + style={{ + zIndex: stack.length - index, + scale: 1 - index * 0.03, + bottom: index * 14, + opacity: index === 0 ? 1 : index === 1 ? 0.8 : 0.4, + visibility: index > 3 ? 'hidden' : 'visible', + }} + initial={{ scale: 0, opacity: 0 }} + animate={{ + scale: 1 - index * 0.03, + opacity: index === 0 ? 1 : index === 1 ? 0.8 : 0.4, + bottom: index * 14, + }} + exit={{ scale: 0, opacity: 0 }} + ref={el => { + cardRefs.current[card.id] = el; + }} + > + + + ); + })} +
+
+ {CARD_MOCK.map((_, idx) => ( +
+ ))} +
+ + ); +} diff --git a/src/features/home/ui/index.ts b/src/features/home/ui/index.ts new file mode 100644 index 0000000..6cd45ca --- /dev/null +++ b/src/features/home/ui/index.ts @@ -0,0 +1,2 @@ +export { default as CardStack } from './CardStack'; +export { default as Card } from './Card'; diff --git a/src/features/login/ui/LoginForm.tsx b/src/features/login/ui/LoginForm.tsx index 299503a..416431f 100644 --- a/src/features/login/ui/LoginForm.tsx +++ b/src/features/login/ui/LoginForm.tsx @@ -1,6 +1,11 @@ +import { useFlow } from '@/app/stackflow'; + +import { PATH } from '@/shared/constants'; import { Button, Input } from '@/shared/ui'; export default function LoginForm() { + const { replace } = useFlow(); + return (
@@ -13,7 +18,9 @@ export default function LoginForm() {
- + diff --git a/src/screen/home/ui/HomeScreen.tsx b/src/screen/home/ui/HomeScreen.tsx new file mode 100644 index 0000000..0a9b1f5 --- /dev/null +++ b/src/screen/home/ui/HomeScreen.tsx @@ -0,0 +1,17 @@ +import { AppScreen } from '@stackflow/plugin-basic-ui'; + +import { HomeBg } from '@/assets/image'; +import { HomeAppBar } from '@/shared/ui'; +import { HomeContainer } from '@/widgets/home/ui'; + +export default function HomeScreen() { + return ( + + + + ); +} diff --git a/src/screen/home/ui/index.ts b/src/screen/home/ui/index.ts new file mode 100644 index 0000000..b9e63f1 --- /dev/null +++ b/src/screen/home/ui/index.ts @@ -0,0 +1 @@ +export { default as HomeScreen } from './HomeScreen'; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts new file mode 100644 index 0000000..44bb0aa --- /dev/null +++ b/src/shared/constants/index.ts @@ -0,0 +1 @@ +export * from './path'; diff --git a/src/shared/constants/path.ts b/src/shared/constants/path.ts new file mode 100644 index 0000000..54b2534 --- /dev/null +++ b/src/shared/constants/path.ts @@ -0,0 +1,4 @@ +export const PATH = { + HOME: 'HomeScreen', + LOGIN: 'LoginScreen', +} as const; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..44bb0aa --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export * from './path'; diff --git a/src/shared/types/path.ts b/src/shared/types/path.ts new file mode 100644 index 0000000..589199e --- /dev/null +++ b/src/shared/types/path.ts @@ -0,0 +1,3 @@ +import type { PATH } from '../constants'; + +export type PathItem = (typeof PATH)[keyof typeof PATH]; diff --git a/src/shared/ui/AppBar.tsx b/src/shared/ui/AppBar.tsx index 502665e..3e564fb 100644 --- a/src/shared/ui/AppBar.tsx +++ b/src/shared/ui/AppBar.tsx @@ -1,4 +1,5 @@ -import { LoginBg } from '@/assets/image'; +import { UserIcon } from '@/assets/icon'; +import { HomeBg, LoginBg } from '@/assets/image'; const baseStyle = { height: '58px', border: false }; @@ -9,3 +10,12 @@ export const BasicAppBar = { took! ), }; + +export const HomeAppBar = { + ...baseStyle, + backgroundImage: `url(${HomeBg})`, + renderLeft: () => ( + took! + ), + renderRight: () => user, +}; diff --git a/src/shared/ui/Button.tsx b/src/shared/ui/Button.tsx index 515534b..2caeaed 100644 --- a/src/shared/ui/Button.tsx +++ b/src/shared/ui/Button.tsx @@ -15,11 +15,12 @@ const ButtonVariants = cva( intent: { primary: 'bg-md text-white', textmain: 'text-m', + gradient: 'bg-gradient-to-r from-grad2 to-grad1 text-white', }, size: { fit: 'w-full h-fit text-lg', sm: '', - md: '', + md: 'w-full h-12 text-lg', lg: 'w-full h-14 text-xl', }, }, diff --git a/src/widgets/home/ui/HomeContainer.tsx b/src/widgets/home/ui/HomeContainer.tsx new file mode 100644 index 0000000..922fe87 --- /dev/null +++ b/src/widgets/home/ui/HomeContainer.tsx @@ -0,0 +1,59 @@ +import { NoticeIcon, ResultIcon } from '@/assets/icon'; +import { useFlow } from '@/app/stackflow'; +import type { PathItem } from '@/shared/types'; +import { PATH } from '@/shared/constants'; +import { CardStack } from '@/features/home/ui'; + +interface ServiceButtonProps { + title: string; + description: string; + icon: string; + to: PathItem; +} + +export default function HomeContainer() { + return ( +
+

진행중인 선거가 있어요!

+ +

이런 서비스도 있어요

+
+ + +
+
+ ); +} + +const ServiceButton = ({ + title, + description, + icon, + to, +}: ServiceButtonProps) => { + const { push } = useFlow(); + + return ( + + ); +}; diff --git a/src/widgets/home/ui/index.ts b/src/widgets/home/ui/index.ts new file mode 100644 index 0000000..8601ff2 --- /dev/null +++ b/src/widgets/home/ui/index.ts @@ -0,0 +1 @@ +export { default as HomeContainer } from './HomeContainer';