diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..d44107b --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,74 @@ +# labeler "full" schema + +# enable labeler on issues, prs, or both. +enable: + issues: true + prs: true +# comments object allows you to specify a different message for issues and prs + +comments: + issues: | + Thanks for opening this issue! + I have applied any labels matching special text in your title and description. + + Please review the labels and make any necessary changes. + prs: | + Thanks for the contribution! + I have applied any labels matching special text in your title and description. + + Please review the labels and make any necessary changes. + +# Labels is an object where: +# - keys are labels +# - values are objects of { include: [ pattern ], exclude: [ pattern ] } +# - pattern must be a valid regex, and is applied globally to +# title + description of issues and/or prs (see enabled config above) +# - 'include' patterns will associate a label if any of these patterns match +# - 'exclude' patterns will ignore this label if any of these patterns match +# - 'branches' is an optional array of branch names (or patterns) to limit labeling according to PR target branch +labels: + 'bug': + include: + - '(?i)bug' + - '(?i)fix' + exclude: [] + 'feature': + include: + - '(?i)feat' + exclude: [] + 'mod': + include: + - '(?i)mod' + exclude: [] + 'chore': + include: + - '(?i)chore' + exclude: [] + 'del': + include: + - '(?i)del' + exclude: [] + 'merge': + include: + - '(?i)merge' + exclude: [] + 'move': + include: + - '(?i)move' + exclude: [] + 'rename': + include: + - '(?i)rename' + exclude: [] + 'refactor': + include: + - '(?i)refactor' + exclude: [] + 'docs': + include: + - '(?i)docs' + exclude: [] + 'deploy': + include: + - '(?i)deploy' + exclude: [] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..a291c49 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## πŸ“ μš”μ•½ +- + +## βš™οΈ μž‘μ—… λ‚΄μš© +- +- + +## πŸ”— κ΄€λ ¨ 이슈 +- Closes # + +## βœ… 체크리슀트 +- [ ] μ½”λ”© μ»¨λ²€μ…˜(Biome/Lint)을 μ€€μˆ˜ν•˜μ˜€μŠ΅λ‹ˆλ‹€. +- [ ] λͺ¨λ“  νƒ€μž… μ—λŸ¬λ₯Ό ν•΄κ²°ν•˜μ˜€μŠ΅λ‹ˆλ‹€. (Typecheck) +- [ ] λ³€κ²½ 사항에 λŒ€ν•œ ν…ŒμŠ€νŠΈλ₯Ό λ§ˆμ³€μŠ΅λ‹ˆλ‹€. +- [ ] λΆˆν•„μš”ν•œ 둜그(console.log)λ₯Ό μ œκ±°ν•˜μ˜€μŠ΅λ‹ˆλ‹€. + +## πŸ’¬ λ¦¬λ·°μ–΄μ—κ²Œ +- \ No newline at end of file diff --git a/.github/workflows/ai-labeler.yml b/.github/workflows/ai-labeler.yml new file mode 100644 index 0000000..450f864 --- /dev/null +++ b/.github/workflows/ai-labeler.yml @@ -0,0 +1,22 @@ +name: Community +on: + issues: + types: [opened, edited, milestoned] + pull_request_target: + types: [opened] + +permissions: + issues: write + pull-requests: write + +jobs: + + labeler: + runs-on: ubuntu-latest + + steps: + - name: Check Labels + id: labeler + uses: jimschubert/labeler-action@v2 + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d2d9130 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + +jobs: + # 린트 검사 + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run Biome + run: yarn biome:lint + + # νƒ€μž… 검사 + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run tsc + run: yarn typecheck + + # ν”„λ‘œμ νŠΈ 진단 및 λΉŒλ“œ ν…ŒμŠ€νŠΈ + doctor: + name: Expo Doctor + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run expo-doctor + run: npx expo-doctor + + export: + name: Expo Export Check (${{ matrix.platform }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [lint, typecheck, doctor] + + strategy: + fail-fast: false + matrix: + platform: [ios, android] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Expo Export Check (${{ matrix.platform }}) + run: npx expo export --platform ${{ matrix.platform }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8c6c2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +app-example + +# generated native folders +/ios +/android diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7309382 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/README.md b/README.md index 6dd54fe..cb81211 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,27 @@ # A:SSU Frontend (React Native + Expo) -![Expo](https://img.shields.io/badge/Expo-54.0-000?logo=expo) -![React Native](https://img.shields.io/badge/React%20Native-0.81-61DAFB?logo=react) +![Expo](https://img.shields.io/badge/Expo-54.0.33-000?logo=expo) +![React Native](https://img.shields.io/badge/React%20Native-0.81.5-61DAFB?logo=react) ![React](https://img.shields.io/badge/React-19.1-61DAFB?logo=react) -![TypeScript](https://img.shields.io/badge/TypeScript-5.9-3178C6?logo=typescript) -![Tailwind%20CSS](https://img.shields.io/badge/Tailwind-3.4-38B2AC?logo=tailwindcss) -![NativeWind](https://img.shields.io/badge/NativeWind-4.2-06B6D4) -![Reanimated](https://img.shields.io/badge/Reanimated-3.17-FF6F61) -![Biome](https://img.shields.io/badge/Biome-2.3-2D2E83) -![React Query](https://img.shields.io/badge/React%20Query-5.x-FF4154?logo=reactquery) -![Zustand](https://img.shields.io/badge/Zustand-5.x-444444) +![TypeScript](https://img.shields.io/badge/TypeScript-5.9.3-3178C6?logo=typescript) +![Tailwind%20CSS](https://img.shields.io/badge/Tailwind-3.4.17-38B2AC?logo=tailwindcss) +![NativeWind](https://img.shields.io/badge/NativeWind-4.2.1-06B6D4) +![Reanimated](https://img.shields.io/badge/Reanimated-4.1.1-FF6F61) +![Biome](https://img.shields.io/badge/Biome-2.3.14-2D2E83) +![React Query](https://img.shields.io/badge/React%20Query-5.90-FF4154?logo=reactquery) +![Zustand](https://img.shields.io/badge/Zustand-5.0-444444) ## 기술 μŠ€νƒ -- **μ•± λŸ°νƒ€μž„**: Expo SDK 54 / React Native 0.81 -- **μ–Έμ–΄**: TypeScript 5.9 -- **μƒνƒœ 관리**: Zustand 5 -- **μ„œλ²„ μƒνƒœ**: @tanstack/react-query 5 -- **μŠ€νƒ€μΌλ§**: Tailwind 3.4 + NativeWind 4 -- **μ• λ‹ˆλ©”μ΄μ…˜**: react-native-reanimated 3 + worklets -- **ν’ˆμ§ˆ 도ꡬ**: Biome +- **μ•± λŸ°νƒ€μž„**: Expo SDK 54 / React Native 0.81.5 / React 19.1 +- **λΌμš°νŒ…**: Expo Router (`typedRoutes: true`) +- **μ–Έμ–΄**: TypeScript 5.9.3 +- **μƒνƒœ 관리**: Zustand 5 +- **μ„œλ²„ μƒνƒœ**: @tanstack/react-query 5 +- **μŠ€νƒ€μΌλ§**: Tailwind 3.4.17 + NativeWind 4.2.1 + - μ „μ—­ 토큰: `src/shared/styles/global.styles.css` + - Tailwind μ„€μ •: `tailwind.config.js` +- **μ• λ‹ˆλ©”μ΄μ…˜**: react-native-reanimated 4 + react-native-worklets +- **ν’ˆμ§ˆ 도ꡬ**: Biome 2.3.14 ## μ•„ν‚€ν…μ²˜ (FSD) ``` @@ -28,24 +31,19 @@ src/ widgets/ # ν™”λ©΄μ—μ„œ 곡용으둜 μ‚¬μš©ν•˜λŠ” 독립적인 UI μ»΄ν¬λ„ŒνŠΈ features/ # νŠΉμ • κΈ°λŠ₯의 둜직, UI, API 호좜 entities/ # 도메인 λͺ¨λΈκ³Ό κ΄€λ ¨λœ 데이터 처리 - shared/ # 곡용 lib/api/ui/config + shared/ # 곡용 assets/hooks/styles/utils λ“± ``` ## 슀크립트 (yarn) -- `yarn start` / `yarn android` / `yarn ios` / `yarn web` -- `yarn lint` β€” Biome lint -- `yarn format` β€” Biome format (write) -- `yarn check` β€” Biome check (type-aware, write) +- `yarn start` β€” Expo 개발 μ„œλ²„ μ‹€ν–‰ +- `yarn typecheck` β€” TypeScript νƒ€μž…μ²΄ν¬ (`tsc --noEmit`) +- `yarn biome:lint` β€” Biome lint (`./src`) +- `yarn biome:format` β€” Biome formatter (`./src`, write) +- `yarn biome:fix` β€” Biome check + μžλ™ μˆ˜μ • (`./src`, write) -## 개발 κ°€μ΄λ“œ -- μŠ€νƒ€μΌ: `global.css` + `tailwind.config.js` 프리셋, RN μ»΄ν¬λ„ŒνŠΈμ— `className`. -- 비동기: `QueryClientProvider`둜 감싸고 κΈ€λ‘œλ²Œ μƒνƒœλŠ” Zustand store와 μ‘°ν•©. -- ν’ˆμ§ˆ 체크: 컀밋 μ „ `yarn format && yarn lint && yarn check`. -- λ¦¬μ†ŒμŠ€ 배치: 곡용 색상/μƒμˆ˜ `src/shared/config`, λ„€νŠΈμ›Œν¬ λͺ¨λ“ˆ `src/shared/api`. ## λΉ λ₯Έ μ‹œμž‘ -1) μ˜μ‘΄μ„±: `yarn install` +1) μ˜μ‘΄μ„±: `yarn install --frozen-lockfile` 2) μ‹€ν–‰: `yarn start` ν›„ a/i/w 선택 -3) 포맷 & 린트: `yarn format && yarn lint && yarn check` +3) ν’ˆμ§ˆ 체크(ꢌμž₯): `yarn biome:format && yarn biome:lint && yarn typecheck` -> νŒ¨ν‚€μ§€ λ§€λ‹ˆμ €: **yarn** κ³ μ • diff --git a/app.json b/app.json new file mode 100644 index 0000000..d9eddd3 --- /dev/null +++ b/app.json @@ -0,0 +1,48 @@ +{ + "expo": { + "name": "ASSU_FE_RN", + "slug": "ASSU_FE_RN", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./src/shared/assets/images/icon.png", + "scheme": "assufern", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "ios": { + "supportsTablet": true + }, + "android": { + "adaptiveIcon": { + "backgroundColor": "#E6F4FE", + "foregroundImage": "./src/shared/assets/images/android-icon-foreground.png", + "backgroundImage": "./src/shared/assets/images/android-icon-background.png", + "monochromeImage": "./src/shared/assets/images/android-icon-monochrome.png" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false + }, + "web": { + "output": "static", + "favicon": "./src/shared/assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-splash-screen", + { + "image": "./src/shared/assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000" + } + } + ] + ], + "experiments": { + "typedRoutes": true, + "reactCompiler": true + } + } +} diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..23741e3 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,9 @@ +module.exports = (api) => { + api.cache(true); + return { + presets: [ + ["babel-preset-expo", { jsxImportSource: "nativewind" }], + "nativewind/babel", + ], + }; +}; diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e144f47 --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": ["**/*.css"], + "linter": { + "enabled": false + } + } + ] +} diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..77cf7a3 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,9 @@ +const { getDefaultConfig } = require("expo/metro-config"); +const { withNativeWind } = require("nativewind/metro"); +const path = require("path"); + +const config = getDefaultConfig(path.resolve(__dirname)); + +module.exports = withNativeWind(config, { + input: "./src/shared/styles/global.styles.css", +}); diff --git a/nativewind-env.d.ts b/nativewind-env.d.ts new file mode 100644 index 0000000..a13e313 --- /dev/null +++ b/nativewind-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/package.json b/package.json new file mode 100644 index 0000000..8e97b55 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "assu_fe_rn", + "main": "expo-router/entry", + "version": "1.0.0", + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "biome:lint": "biome lint ./src", + "biome:fix": "biome check --write ./src", + "biome:format": "biome format ./src --write" + }, + "dependencies": { + "@expo/vector-icons": "^15.0.3", + "@react-navigation/bottom-tabs": "^7.4.0", + "@react-navigation/elements": "^2.6.3", + "@react-navigation/native": "^7.1.8", + "@tanstack/react-query": "^5.90.20", + "expo": "~54.0.33", + "expo-constants": "~18.0.13", + "expo-font": "~14.0.11", + "expo-haptics": "~15.0.8", + "expo-image": "~3.0.11", + "expo-linking": "~8.0.11", + "expo-router": "~6.0.23", + "expo-splash-screen": "~31.0.13", + "expo-status-bar": "~3.0.9", + "expo-symbols": "~1.0.8", + "expo-system-ui": "~6.0.9", + "expo-web-browser": "~15.0.10", + "nativewind": "^4.2.1", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-native": "0.81.5", + "react-native-gesture-handler": "~2.28.0", + "react-native-reanimated": "~4.1.1", + "react-native-safe-area-context": "~5.6.0", + "react-native-screens": "~4.16.0", + "react-native-web": "~0.21.0", + "react-native-worklets": "0.5.1", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.14", + "@types/react": "~19.1.10", + "babel-plugin-module-resolver": "^5.0.2", + "eslint": "^9.25.0", + "eslint-config-expo": "~10.0.0", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "3.4.17", + "typescript": "^5.9.3" + }, + "private": true +} diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..ef71d9f --- /dev/null +++ b/src/app/(tabs)/_layout.tsx @@ -0,0 +1,15 @@ +import { Tabs } from "expo-router"; + +export default function TabLayout() { + return ( + + null, + }} + /> + + ); +} diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..01b6a24 --- /dev/null +++ b/src/app/(tabs)/index.tsx @@ -0,0 +1,182 @@ +import { ScrollView, Text, View } from "react-native"; +import { shadows } from "../../shared/styles/shadows"; + +export default function HomeScreen() { + return ( + + {/* 헀더 μ˜μ—­ */} + + + Welcome + + + λ””μžμΈ 토큰이 적용된 메인 ν™”λ©΄ + + + + {/* Primary λ²„νŠΌ with Shadow */} + + + Primary λ²„νŠΌ (Primary Shadow) + + + + {/* μΉ΄λ“œ with Neutral Shadow */} + + + μΉ΄λ“œ 제λͺ© + + + Neutral λ°°κ²½ + Neutral Shadow 적용 + + + + {/* Primary Tint μΉ΄λ“œ with Shadow */} + + + Primary Tint μΉ΄λ“œ + + + μ—°ν•œ 블루 λ°°κ²½ + Shadow + + + + {/* κ·Έλ¦¬λ“œ μ•„μ΄ν…œλ“€ with Shadow */} + + + κ·Έλ¦¬λ“œ μ•„μ΄ν…œ (Gutter 적용) + + + + + μ•„μ΄ν…œ 1 + + + + + μ•„μ΄ν…œ 2 + + + + + μ•„μ΄ν…œ 3 + + + + + + {/* 리슀트 μ•„μ΄ν…œλ“€ with Shadow */} + + + 리슀트 μ•„μ΄ν…œ (Gutter 적용) + + + + + 리슀트 μ•„μ΄ν…œ 1 + + + + + 리슀트 μ•„μ΄ν…œ 2 + + + + + 리슀트 μ•„μ΄ν…œ 3 + + + + + + {/* λΉ„ν™œμ„±ν™” λ²„νŠΌ */} + + + λΉ„ν™œμ„±ν™” λ²„νŠΌ (opacity 30%) + + + + {/* Danger μƒνƒœ */} + + + Danger μƒνƒœ + + + + {/* 폰트 ꡡ기별 μ˜ˆμ‹œ */} + + + 폰트 κ΅΅κΈ° (Pretendard) + + + + Regular (400) - κΈ°λ³Έ κ΅΅κΈ° + + + Medium (500) - 쀑간 κ΅΅κΈ° + + + SemiBold (600) - μ„Έλ―Έλ³Όλ“œ + + + Bold (700) - λ³Όλ“œ + + + + + {/* 폰트 ꡡ기별 크기 비ꡐ */} + + + 폰트 크기별 μ˜ˆμ‹œ + + + + text-xs (12px) - Regular + + + text-sm (14px) - Regular + + + text-base (16px) - Regular + + + text-lg (18px) - Medium + + + text-xl (20px) - SemiBold + + + text-2xl (24px) - Bold + + + text-[32px] - Bold + + + + + ); +} diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx new file mode 100644 index 0000000..231f50e --- /dev/null +++ b/src/app/_layout.tsx @@ -0,0 +1,25 @@ +import { Stack } from "expo-router"; +import { StatusBar } from "expo-status-bar"; +import { useLoadFonts } from "@/shared/lib/hooks/useLoadFonts"; +import "@/shared/styles/global.styles.css"; + +export const unstable_settings = { + anchor: "(tabs)", +}; + +export default function RootLayout() { + const fontsLoaded = useLoadFonts(); + + if (!fontsLoaded) { + return null; + } + + return ( + <> + + + + + + ); +} diff --git a/src/app/index.tsx b/src/app/index.tsx new file mode 100644 index 0000000..d7a8009 --- /dev/null +++ b/src/app/index.tsx @@ -0,0 +1,5 @@ +import { Redirect } from "expo-router"; + +export default function Index() { + return ; +} diff --git a/src/entities/README.md b/src/entities/README.md new file mode 100644 index 0000000..5ab2ac4 --- /dev/null +++ b/src/entities/README.md @@ -0,0 +1,415 @@ +# Entities Layer + +`entities` λ ˆμ΄μ–΄λŠ” λΉ„μ¦ˆλ‹ˆμŠ€ μ—”ν‹°ν‹°(도메인 λͺ¨λΈ)와 κ΄€λ ¨λœ μ½”λ“œλ₯Ό ν¬ν•¨ν•©λ‹ˆλ‹€. + +## πŸ“‹ λͺ©μ°¨ + +1. [κ°œμš”](#κ°œμš”) +2. [디렉토리 ꡬ쑰](#디렉토리-ꡬ쑰) +3. [μ‚¬μš© κ°€μ΄λ“œ](#μ‚¬μš©-κ°€μ΄λ“œ) +4. [μ£Όμ˜μ‚¬ν•­](#μ£Όμ˜μ‚¬ν•­) +5. [μ˜ˆμ‹œ](#μ˜ˆμ‹œ) + +--- + +## κ°œμš” + +`entities` λ ˆμ΄μ–΄λŠ” **λΉ„μ¦ˆλ‹ˆμŠ€ λ„λ©”μΈμ˜ 핡심 κ°œλ…(μ—”ν‹°ν‹°)**을 ν‘œν˜„ν•©λ‹ˆλ‹€. 예λ₯Ό λ“€μ–΄, μ‚¬μš©μž(User), μƒν’ˆ(Product), μ£Όλ¬Έ(Order) 등이 μ—”ν‹°ν‹°μž…λ‹ˆλ‹€. + +### νŠΉμ§• + +- βœ… **도메인 λͺ¨λΈ**: λΉ„μ¦ˆλ‹ˆμŠ€μ˜ 핡심 κ°œλ…μ„ ν‘œν˜„ +- βœ… **λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 포함**: μ—”ν‹°ν‹° κ΄€λ ¨ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ 포함 κ°€λŠ₯ +- βœ… **μž¬μ‚¬μš© κ°€λŠ₯**: μ—¬λŸ¬ featuresμ—μ„œ μ‚¬μš©λ  수 있음 +- βœ… **독립적**: νŠΉμ • κΈ°λŠ₯에 μ’…μ†λ˜μ§€ μ•ŠμŒ + +--- + +## 디렉토리 ꡬ쑰 + +``` +entities/ +β”œβ”€β”€ user/ # μ‚¬μš©μž μ—”ν‹°ν‹° +β”‚ β”œβ”€β”€ model/ # νƒ€μž… μ •μ˜, μΈν„°νŽ˜μ΄μŠ€ +β”‚ β”‚ └── types.ts +β”‚ β”œβ”€β”€ api/ # API 호좜 (선택적) +β”‚ β”‚ └── userApi.ts +β”‚ β”œβ”€β”€ lib/ # μ—”ν‹°ν‹° κ΄€λ ¨ μœ ν‹Έλ¦¬ν‹° +β”‚ β”‚ └── formatUser.ts +β”‚ └── ui/ # μ—”ν‹°ν‹° ν‘œμ‹œμš© κΈ°λ³Έ μ»΄ν¬λ„ŒνŠΈ (선택적) +β”‚ └── UserAvatar.tsx +β”œβ”€β”€ product/ # μƒν’ˆ μ—”ν‹°ν‹° +β”‚ β”œβ”€β”€ model/ +β”‚ β”‚ └── types.ts +β”‚ └── lib/ +β”‚ └── calculatePrice.ts +└── README.md # 이 λ¬Έμ„œ +``` + +### 각 디렉토리 μ„€λͺ… + +#### `model/` +μ—”ν‹°ν‹°μ˜ νƒ€μž… μ •μ˜, μΈν„°νŽ˜μ΄μŠ€, κΈ°λ³Έ 데이터 ꡬ쑰λ₯Ό μ •μ˜ν•©λ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `entities/user/model/types.ts`** +```tsx +export interface User { + id: string; + name: string; + email: string; + avatarUrl?: string; + createdAt: Date; +} + +export type UserRole = "admin" | "user" | "guest"; +``` + +#### `api/` (선택적) +μ—”ν‹°ν‹° κ΄€λ ¨ API ν˜ΈμΆœμ„ μ •μ˜ν•©λ‹ˆλ‹€. λ‹¨μˆœ CRUD μž‘μ—…μ΄ μ£Όλ₯Ό μ΄λ£Ήλ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `entities/user/api/userApi.ts`** +```tsx +import { User } from "../model/types"; + +export async function fetchUser(id: string): Promise { + // API 호좜 +} + +export async function updateUser(id: string, data: Partial): Promise { + // API 호좜 +} +``` + +#### `lib/` +μ—”ν‹°ν‹° κ΄€λ ¨ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 및 μœ ν‹Έλ¦¬ν‹° ν•¨μˆ˜λ₯Ό ν¬ν•¨ν•©λ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `entities/user/lib/formatUser.ts`** +```tsx +import { User } from "../model/types"; + +export function formatUserName(user: User): string { + return user.name.trim(); +} + +export function getUserInitials(user: User): string { + return user.name.charAt(0).toUpperCase(); +} +``` + +#### `ui/` (선택적) +μ—”ν‹°ν‹°λ₯Ό ν‘œμ‹œν•˜λŠ” 기본적인 UI μ»΄ν¬λ„ŒνŠΈλ₯Ό ν¬ν•¨ν•©λ‹ˆλ‹€. **λ‹¨μˆœν•œ ν‘œμ‹œμš© μ»΄ν¬λ„ŒνŠΈλ§Œ** ν¬ν•¨ν•©λ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `entities/user/ui/UserAvatar.tsx`** +```tsx +import { User } from "../model/types"; + +interface UserAvatarProps { + user: User; + size?: number; +} + +export function UserAvatar({ user, size = 40 }: UserAvatarProps) { + return ( + + ); +} +``` + +--- + +## μ‚¬μš© κ°€μ΄λ“œ + +### 1. μƒˆλ‘œμš΄ μ—”ν‹°ν‹° μΆ”κ°€ + +μƒˆλ‘œμš΄ λΉ„μ¦ˆλ‹ˆμŠ€ μ—”ν‹°ν‹°λ₯Ό μΆ”κ°€ν•  λ•ŒλŠ” λ‹€μŒ ꡬ쑰λ₯Ό λ”°λ¦…λ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `entities/product/` 생성** + +1. **νƒ€μž… μ •μ˜ (`model/types.ts`)** +```tsx +export interface Product { + id: string; + name: string; + price: number; + description?: string; + category: ProductCategory; + inStock: boolean; +} + +export type ProductCategory = "electronics" | "clothing" | "food"; +``` + +2. **λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 (`lib/calculatePrice.ts`)** +```tsx +import { Product } from "../model/types"; + +export function calculateDiscountedPrice( + product: Product, + discountPercent: number +): number { + return product.price * (1 - discountPercent / 100); +} +``` + +3. **API 호좜 (`api/productApi.ts`)** - 선택적 +```tsx +import { Product } from "../model/types"; + +export async function fetchProduct(id: string): Promise { + // API 호좜 +} +``` + +### 2. μ—”ν‹°ν‹° κ°„ 관계 μ •μ˜ + +μ—”ν‹°ν‹° κ°„ κ΄€κ³„λŠ” νƒ€μž…μœΌλ‘œ ν‘œν˜„ν•©λ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `entities/order/model/types.ts`** +```tsx +import { User } from "@/entities/user/model/types"; +import { Product } from "@/entities/product/model/types"; + +export interface Order { + id: string; + userId: string; + user?: User; // 관계 (선택적) + items: OrderItem[]; + totalAmount: number; + status: OrderStatus; +} + +export interface OrderItem { + productId: string; + product?: Product; // 관계 (선택적) + quantity: number; + price: number; +} +``` + +--- + +## μ£Όμ˜μ‚¬ν•­ + +### βœ… DO (ν•΄μ•Ό ν•  것) + +1. **도메인 λͺ¨λΈ μ€‘μ‹¬μœΌλ‘œ ꡬ성** + ```tsx + // βœ… 쒋은 예: μ‚¬μš©μž μ—”ν‹°ν‹° + entities/user/model/types.ts + entities/user/lib/formatUser.ts + ``` + +2. **λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 포함** + ```tsx + // βœ… 쒋은 예: μ—”ν‹°ν‹° κ΄€λ ¨ λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ + export function calculateUserAge(user: User): number { + return new Date().getFullYear() - user.birthYear; + } + ``` + +3. **νƒ€μž… μ•ˆμ •μ„± 보μž₯** + ```tsx + // βœ… 쒋은 예: λͺ…μ‹œμ  νƒ€μž… μ •μ˜ + export interface User { + id: string; + name: string; + } + ``` + +4. **μž¬μ‚¬μš© κ°€λŠ₯ν•œ ꡬ쑰** + ```tsx + // βœ… 쒋은 예: μ—¬λŸ¬ featuresμ—μ„œ μ‚¬μš© κ°€λŠ₯ + import { User } from "@/entities/user/model/types"; + ``` + +### ❌ DON'T (ν•˜μ§€ 말아야 ν•  것) + +1. **UI 둜직 포함 κΈˆμ§€** + ```tsx + // ❌ λ‚˜μœ 예: UI μƒνƒœ 관리 + export function useUserProfile() { + const [isLoading, setIsLoading] = useState(false); + // UI λ‘œμ§μ€ featuresλ‚˜ widgets에 μžˆμ–΄μ•Ό 함 + } + + // βœ… 쒋은 예: 순수 λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 + export function formatUserName(user: User): string { + return user.name.trim(); + } + ``` + +2. **νŠΉμ • κΈ°λŠ₯에 μ’…μ†λœ μ½”λ“œ κΈˆμ§€** + ```tsx + // ❌ λ‚˜μœ 예: νŠΉμ • κΈ°λŠ₯μ—λ§Œ μ‚¬μš©λ˜λŠ” 둜직 + export function useUserProfileForSettings() { + // settings κΈ°λŠ₯μ—λ§Œ μ‚¬μš©λ˜λŠ” λ‘œμ§μ€ features/settings에 μžˆμ–΄μ•Ό 함 + } + + // βœ… 쒋은 예: λ²”μš© μ—”ν‹°ν‹° 둜직 + export function getUserDisplayName(user: User): string { + return user.name || user.email; + } + ``` + +3. **λ³΅μž‘ν•œ μƒνƒœ 관리 κΈˆμ§€** + ```tsx + // ❌ λ‚˜μœ 예: μ „μ—­ μƒνƒœ 관리 + import { create } from "zustand"; + export const useUserStore = create(...); + + // βœ… 쒋은 예: λ‹¨μˆœ 데이터 ꡬ쑰 + export interface User { ... } + ``` + +4. **UI μ»΄ν¬λ„ŒνŠΈλŠ” μ΅œμ†Œν™”** + ```tsx + // ⚠️ 주의: UI μ»΄ν¬λ„ŒνŠΈλŠ” λ‹¨μˆœ ν‘œμ‹œμš©λ§Œ + // λ³΅μž‘ν•œ μΈν„°λž™μ…˜μ΄λ‚˜ μƒνƒœ κ΄€λ¦¬λŠ” featuresλ‚˜ widgets에 μžˆμ–΄μ•Ό 함 + + // βœ… 쒋은 예: λ‹¨μˆœ ν‘œμ‹œ μ»΄ν¬λ„ŒνŠΈ + export function UserAvatar({ user }: { user: User }) { + return ; + } + + // ❌ λ‚˜μœ 예: λ³΅μž‘ν•œ μΈν„°λž™μ…˜ + export function UserProfileCard() { + const [isEditing, setIsEditing] = useState(false); + // λ³΅μž‘ν•œ λ‘œμ§μ€ features에 μžˆμ–΄μ•Ό 함 + } + ``` + +5. **λ‹€λ₯Έ λ ˆμ΄μ–΄ import μ œν•œ** + ```tsx + // βœ… entitiesλŠ” shared와 λ‹€λ₯Έ entities만 import κ°€λŠ₯ + import { formatDate } from "@/shared/lib/format"; + import { Product } from "@/entities/product/model/types"; + + // ❌ λ‚˜μœ 예: μƒμœ„ λ ˆμ΄μ–΄ import κΈˆμ§€ + import { SomeFeature } from "@/features/some-feature"; + ``` + +--- + +## μ˜ˆμ‹œ + +### μ™„μ „ν•œ μ—”ν‹°ν‹° μ˜ˆμ‹œ + +**`entities/user/model/types.ts`** +```tsx +export interface User { + id: string; + name: string; + email: string; + avatarUrl?: string; + role: UserRole; + createdAt: Date; + updatedAt: Date; +} + +export type UserRole = "admin" | "user" | "guest"; + +export interface UserPreferences { + theme: "light" | "dark"; + language: "ko" | "en"; +} +``` + +**`entities/user/lib/formatUser.ts`** +```tsx +import { User } from "../model/types"; + +export function formatUserName(user: User): string { + return user.name.trim(); +} + +export function getUserDisplayName(user: User): string { + return user.name || user.email; +} + +export function getUserInitials(user: User): string { + const name = formatUserName(user); + return name.charAt(0).toUpperCase(); +} +``` + +**`entities/user/api/userApi.ts`** +```tsx +import { User } from "../model/types"; + +export async function fetchUser(id: string): Promise { + const response = await fetch(`/api/users/${id}`); + if (!response.ok) { + throw new Error("Failed to fetch user"); + } + return response.json(); +} + +export async function updateUser( + id: string, + data: Partial +): Promise { + const response = await fetch(`/api/users/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error("Failed to update user"); + } + return response.json(); +} +``` + +**`entities/user/ui/UserAvatar.tsx`** (선택적) +```tsx +import { View, Image, Text } from "react-native"; +import { User } from "../model/types"; +import { getUserInitials } from "../lib/formatUser"; + +interface UserAvatarProps { + user: User; + size?: number; +} + +export function UserAvatar({ user, size = 40 }: UserAvatarProps) { + const initials = getUserInitials(user); + + if (user.avatarUrl) { + return ( + + ); + } + + return ( + + + {initials} + + + ); +} +``` + +--- + +## μ°Έκ³  + +- **FSD μ•„ν‚€ν…μ²˜**: [Feature-Sliced Design 곡식 λ¬Έμ„œ](https://feature-sliced.design/docs/get-started/overview) +- **μ—”ν‹°ν‹° κ°€μ΄λ“œ**: [FSD Entities λ ˆμ΄μ–΄ κ°€μ΄λ“œ](https://feature-sliced.design/docs/reference/layers/entities) diff --git a/src/features/README.md b/src/features/README.md new file mode 100644 index 0000000..24b2b76 --- /dev/null +++ b/src/features/README.md @@ -0,0 +1,518 @@ +# Features Layer + +`features` λ ˆμ΄μ–΄λŠ” μ‚¬μš©μž κΈ°λŠ₯(κΈ°λŠ₯ λ‹¨μœ„)κ³Ό κ΄€λ ¨λœ μ½”λ“œλ₯Ό ν¬ν•¨ν•©λ‹ˆλ‹€. + +## πŸ“‹ λͺ©μ°¨ + +1. [κ°œμš”](#κ°œμš”) +2. [디렉토리 ꡬ쑰](#디렉토리-ꡬ쑰) +3. [μ‚¬μš© κ°€μ΄λ“œ](#μ‚¬μš©-κ°€μ΄λ“œ) +4. [μ£Όμ˜μ‚¬ν•­](#μ£Όμ˜μ‚¬ν•­) +5. [μ˜ˆμ‹œ](#μ˜ˆμ‹œ) + +--- + +## κ°œμš” + +`features` λ ˆμ΄μ–΄λŠ” **μ‚¬μš©μžκ°€ μˆ˜ν–‰ν•  수 μžˆλŠ” ꡬ체적인 κΈ°λŠ₯**을 κ΅¬ν˜„ν•©λ‹ˆλ‹€. 예λ₯Ό λ“€μ–΄, "μ‚¬μš©μž ν”„λ‘œν•„ νŽΈμ§‘", "μƒν’ˆ 검색", "μ£Όλ¬Έν•˜κΈ°" 등이 featureμž…λ‹ˆλ‹€. + +### νŠΉμ§• + +- βœ… **μ‚¬μš©μž κΈ°λŠ₯**: μ‚¬μš©μžκ°€ μˆ˜ν–‰ν•˜λŠ” ꡬ체적인 μ•‘μ…˜ +- βœ… **λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 포함**: κΈ°λŠ₯ κ΄€λ ¨ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 포함 +- βœ… **UI 포함**: κΈ°λŠ₯을 μœ„ν•œ UI μ»΄ν¬λ„ŒνŠΈ 포함 +- βœ… **독립적**: 각 featureλŠ” λ…λ¦½μ μœΌλ‘œ λ™μž‘ κ°€λŠ₯ + +--- + +## 디렉토리 ꡬ쑰 + +``` +features/ +β”œβ”€β”€ edit-user-profile/ # μ‚¬μš©μž ν”„λ‘œν•„ νŽΈμ§‘ κΈ°λŠ₯ +β”‚ β”œβ”€β”€ ui/ # UI μ»΄ν¬λ„ŒνŠΈ +β”‚ β”‚ β”œβ”€β”€ EditProfileForm.tsx +β”‚ β”‚ └── EditProfileButton.tsx +β”‚ β”œβ”€β”€ model/ # κΈ°λŠ₯ κ΄€λ ¨ μƒνƒœ/νƒ€μž… +β”‚ β”‚ └── types.ts +β”‚ β”œβ”€β”€ api/ # κΈ°λŠ₯ κ΄€λ ¨ API 호좜 +β”‚ β”‚ └── updateProfile.ts +β”‚ └── lib/ # κΈ°λŠ₯ κ΄€λ ¨ μœ ν‹Έλ¦¬ν‹° +β”‚ └── validateProfile.ts +β”œβ”€β”€ search-products/ # μƒν’ˆ 검색 κΈ°λŠ₯ +β”‚ β”œβ”€β”€ ui/ +β”‚ β”‚ └── SearchBar.tsx +β”‚ β”œβ”€β”€ model/ +β”‚ β”‚ └── useSearchProducts.ts +β”‚ └── api/ +β”‚ └── searchProducts.ts +└── README.md # 이 λ¬Έμ„œ +``` + +### 각 디렉토리 μ„€λͺ… + +#### `ui/` +κΈ°λŠ₯을 μœ„ν•œ UI μ»΄ν¬λ„ŒνŠΈλ₯Ό ν¬ν•¨ν•©λ‹ˆλ‹€. μ‚¬μš©μž μΈν„°λž™μ…˜μ„ μ²˜λ¦¬ν•©λ‹ˆλ‹€. + +**μ˜ˆμ‹œ: `features/edit-user-profile/ui/EditProfileForm.tsx`** +```tsx +import { useState } from "react"; +import { View, TextInput, Button } from "react-native"; +import { updateProfile } from "../api/updateProfile"; +import { validateProfile } from "../lib/validateProfile"; + +export function EditProfileForm({ userId }: { userId: string }) { + const [name, setName] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async () => { + if (!validateProfile({ name })) return; + + setIsLoading(true); + await updateProfile(userId, { name }); + setIsLoading(false); + }; + + return ( + + +