diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..7822e1dd
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,18 @@
+.git
+.github
+.turbo
+.bundle-check
+.history
+.idea
+.vscode
+**/.next
+**/.turbo
+**/node_modules
+**/android
+**/ios
+**/vendor
+**/.env*
+**/coverage
+**/dist
+**/out
+*.md
diff --git a/.github/actions/setup-android-env/action.yml b/.github/actions/setup-android-env/action.yml
index c904adb3..deba0275 100644
--- a/.github/actions/setup-android-env/action.yml
+++ b/.github/actions/setup-android-env/action.yml
@@ -1,19 +1,18 @@
name: Setup Android Environment
-description: Setup Node, Java, Corepack, and install dependencies
+description: Setup Node, pnpm, Java, and install dependencies
runs:
using: composite
steps:
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
- name: Setup Node
uses: actions/setup-node@v6.3.0
with:
- node-version: 20
- cache: yarn
- cache-dependency-path: yarn.lock
-
- - name: Enable Corepack
- run: corepack enable
- shell: bash
+ node-version: 22.13
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
- name: Setup Java
uses: actions/setup-java@v5.2.0
@@ -22,14 +21,6 @@ runs:
java-version: 17
cache: gradle
- - name: Restore node_modules cache
- id: cache_node_modules
- uses: actions/cache@v4
- with:
- path: node_modules
- key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'package.json', 'patches/**') }}
-
- name: Install dependencies
- if: steps.cache_node_modules.outputs.cache-hit != 'true'
- run: yarn install --frozen-lockfile --prefer-offline
+ run: pnpm install --frozen-lockfile
shell: bash
diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml
index f7451100..744ff600 100644
--- a/.github/actions/setup-node-env/action.yml
+++ b/.github/actions/setup-node-env/action.yml
@@ -1,28 +1,19 @@
name: Setup Node Environment
-description: Setup Node, Corepack, and install dependencies (with node_modules cache)
+description: Setup Node and pnpm, then install dependencies
runs:
using: composite
steps:
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
- name: Setup Node
uses: actions/setup-node@v6.3.0
with:
- node-version: 20
- cache: yarn
- cache-dependency-path: yarn.lock
-
- - name: Enable Corepack
- run: corepack enable
- shell: bash
-
- - name: Restore node_modules cache
- id: cache_node_modules
- uses: actions/cache@v4
- with:
- path: node_modules
- key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'package.json', 'patches/**') }}
+ node-version: 22.13
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
- if: steps.cache_node_modules.outputs.cache-hit != 'true'
- run: yarn install --frozen-lockfile --prefer-offline
+ run: pnpm install --frozen-lockfile
shell: bash
diff --git a/.github/workflows/_deploy-web.yml b/.github/workflows/_deploy-web.yml
new file mode 100644
index 00000000..87bc5216
--- /dev/null
+++ b/.github/workflows/_deploy-web.yml
@@ -0,0 +1,65 @@
+name: Deploy web image
+
+on:
+ workflow_call:
+ inputs:
+ ocir_repository:
+ description: OCIR repository that receives the web image
+ required: true
+ type: string
+ gitops_overlay_path:
+ description: Kustomize overlay path in waffle-world-oci
+ required: true
+ type: string
+ secrets:
+ OCI_AUTH_TOKEN:
+ required: true
+ DEPLOYER_APP_PRIVATE_KEY:
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ deploy:
+ name: Build and push web image
+ runs-on: ubuntu-24.04-arm
+ concurrency:
+ group: deploy-${{ inputs.ocir_repository }}
+ cancel-in-progress: true
+
+ env:
+ IMAGE_TAG: ${{ github.run_id }}-${{ github.run_attempt }}
+ OCIR_REGISTRY: yny.ocir.io
+ OCIR_NAMESPACE: ax1dvc8vmenm
+ OCIR_REPOSITORY: ${{ inputs.ocir_repository }}
+
+ steps:
+ - name: Checkout merged commit
+ uses: actions/checkout@v5.0.1
+ with:
+ ref: ${{ github.event.pull_request.merge_commit_sha }}
+
+ - name: Login to OCIR
+ run: echo "${{ secrets.OCI_AUTH_TOKEN }}" | docker login "$OCIR_REGISTRY" -u "$OCIR_NAMESPACE/members/waffle-deployer" --password-stdin
+
+ - name: Build web image
+ run: |
+ docker build \
+ --file apps/web/Dockerfile \
+ --platform linux/arm64 \
+ --tag "$OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG" \
+ .
+
+ - name: Push web image
+ run: docker push "$OCIR_REGISTRY/$OCIR_NAMESPACE/$OCIR_REPOSITORY:$IMAGE_TAG"
+
+ - name: Update GitOps image tag
+ uses: wafflestudio/waffle-world-oci/.github/actions/update-image@main
+ with:
+ image: ${{ env.OCIR_REGISTRY }}/${{ env.OCIR_NAMESPACE }}/${{ env.OCIR_REPOSITORY }}
+ tag: ${{ env.IMAGE_TAG }}
+ # The OCIR repository includes the service suffix, while the GitOps
+ # overlay is rooted at the environment directory (for example, argocd/allclear-dev).
+ overlay-path: ${{ inputs.gitops_overlay_path }}
+ private-key: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/android-dev-internal.yml b/.github/workflows/android-dev-internal.yml
index 799dc55c..c00624ad 100644
--- a/.github/workflows/android-dev-internal.yml
+++ b/.github/workflows/android-dev-internal.yml
@@ -27,6 +27,7 @@ jobs:
- name: Restore Android local config
shell: bash
+ working-directory: apps/mobile
env:
ANDROID_ENV_LOCAL: ${{ secrets.ANDROID_ENV_LOCAL }}
ANDROID_GOOGLE_SERVICES_JSON: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}
@@ -51,6 +52,7 @@ jobs:
chmod 600 android/app/debug.keystore
- name: Build standalone dev APK
+ working-directory: apps/mobile
run: |
chmod +x android/gradlew
cd android
@@ -60,7 +62,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: android-dev-release-apk-${{ github.run_number }}
- path: android/app/build/outputs/apk/devRelease/*.apk
+ path: apps/mobile/android/app/build/outputs/apk/devRelease/*.apk
if-no-files-found: error
retention-days: 7
@@ -81,6 +83,7 @@ jobs:
- name: Cleanup local config
if: always()
+ working-directory: apps/mobile
run: |
rm -f .env.local
rm -f android/app/google-services.json
diff --git a/.github/workflows/android-main-internal-testing.yml b/.github/workflows/android-main-internal-testing.yml
index 9f8bb277..748ee720 100644
--- a/.github/workflows/android-main-internal-testing.yml
+++ b/.github/workflows/android-main-internal-testing.yml
@@ -32,6 +32,7 @@ jobs:
- name: Restore Android release config
shell: bash
+ working-directory: apps/mobile
env:
ANDROID_ENV_PROD: ${{ secrets.ANDROID_ENV_PROD }}
ANDROID_GOOGLE_SERVICES_JSON_PROD: ${{ secrets.ANDROID_GOOGLE_SERVICES_JSON_PROD }}
@@ -76,11 +77,12 @@ jobs:
- name: Compute version code
shell: bash
run: |
- version_code=$((100009 + GITHUB_RUN_NUMBER))
+ version_code=$((100011 + GITHUB_RUN_NUMBER))
echo "VERSION_CODE=${version_code}" >> "$GITHUB_ENV"
echo "RELEASE_NAME=main internal ${version_code}" >> "$GITHUB_ENV"
- name: Build release bundle
+ working-directory: apps/mobile
run: |
chmod +x android/gradlew
cd android
@@ -91,7 +93,7 @@ jobs:
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.padocorp.clubhouse.applicationId
- releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
+ releaseFiles: apps/mobile/android/app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
releaseName: ${{ env.RELEASE_NAME }}
@@ -111,6 +113,7 @@ jobs:
- name: Cleanup local config
if: always()
+ working-directory: apps/mobile
run: |
rm -f .env.prod
rm -f android/app/google-services.json
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f22c9bb..78aa1006 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,11 +24,21 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
- - name: Lint
- run: yarn lint
+ - name: Lint, typecheck, and test
+ run: pnpm verify
- - name: Typecheck
- run: yarn typecheck
+ build-web:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v5.0.1
+
+ - name: Setup Node environment
+ uses: ./.github/actions/setup-node-env
+
+ - name: Build web
+ run: pnpm --filter allclear-web build
bundle:
runs-on: ubuntu-latest
@@ -46,4 +56,4 @@ jobs:
uses: ./.github/actions/setup-node-env
- name: Bundle (${{ matrix.platform }})
- run: yarn bundle:${{ matrix.platform }}
+ run: pnpm --dir apps/mobile run bundle:${{ matrix.platform }}
diff --git a/.github/workflows/deploy-web-dev.yml b/.github/workflows/deploy-web-dev.yml
new file mode 100644
index 00000000..721893de
--- /dev/null
+++ b/.github/workflows/deploy-web-dev.yml
@@ -0,0 +1,27 @@
+name: Deploy web to development
+
+on:
+ pull_request:
+ branches: [develop]
+ types: [closed]
+ paths:
+ - apps/web/**
+ - package.json
+ - pnpm-lock.yaml
+ - pnpm-workspace.yaml
+ - turbo.json
+ - biome.json
+ - .dockerignore
+ - .github/workflows/_deploy-web.yml
+ - .github/workflows/deploy-web-dev.yml
+
+jobs:
+ deploy:
+ if: github.event.pull_request.merged == true
+ uses: ./.github/workflows/_deploy-web.yml
+ with:
+ ocir_repository: allclear-dev/allclear-web
+ gitops_overlay_path: argocd/allclear-dev
+ secrets:
+ OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }}
+ DEPLOYER_APP_PRIVATE_KEY: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/deploy-web-prod.yml b/.github/workflows/deploy-web-prod.yml
new file mode 100644
index 00000000..84ebd6f3
--- /dev/null
+++ b/.github/workflows/deploy-web-prod.yml
@@ -0,0 +1,27 @@
+name: Deploy web to production
+
+on:
+ pull_request:
+ branches: [main]
+ types: [closed]
+ paths:
+ - apps/web/**
+ - package.json
+ - pnpm-lock.yaml
+ - pnpm-workspace.yaml
+ - turbo.json
+ - biome.json
+ - .dockerignore
+ - .github/workflows/_deploy-web.yml
+ - .github/workflows/deploy-web-prod.yml
+
+jobs:
+ deploy:
+ if: github.event.pull_request.merged == true
+ uses: ./.github/workflows/_deploy-web.yml
+ with:
+ ocir_repository: allclear-prod/allclear-web
+ gitops_overlay_path: argocd/allclear-prod
+ secrets:
+ OCI_AUTH_TOKEN: ${{ secrets.OCI_AUTH_TOKEN }}
+ DEPLOYER_APP_PRIVATE_KEY: ${{ secrets.DEPLOYER_APP_PRIVATE_KEY }}
diff --git a/.gitignore b/.gitignore
index 3b9de33a..971f5548 100644
--- a/.gitignore
+++ b/.gitignore
@@ -179,6 +179,9 @@ web_modules/
# Optional stylelint cache
.stylelintcache
+# Turborepo cache
+.turbo/
+
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
@@ -196,6 +199,7 @@ web_modules/
# dotenv environment variable files
.env.*
+!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
@@ -218,6 +222,9 @@ dist
# vuepress build output
.vuepress/dist
+# Local task planning
+tasks/
+
# vuepress v2.x temp and cache directory
.temp
@@ -501,8 +508,9 @@ $RECYCLE.BIN/
# .pnp.*
#CocoaPods
-ios/Pods
-ios/tmp.xcconfig
+apps/mobile/ios/Pods
+apps/mobile/ios/tmp.xcconfig
+apps/mobile/ios/.xcode.env.local
# End of https://www.toptal.com/developers/gitignore/api/reactnative,windows,macos,intellij+all,visualstudiocode,yarn,node
diff --git a/.husky/pre-commit b/.husky/pre-commit
deleted file mode 100644
index 2312dc58..00000000
--- a/.husky/pre-commit
+++ /dev/null
@@ -1 +0,0 @@
-npx lint-staged
diff --git a/.husky/pre-push b/.husky/pre-push
deleted file mode 100644
index b314caac..00000000
--- a/.husky/pre-push
+++ /dev/null
@@ -1 +0,0 @@
-yarn tsc --noEmit
diff --git a/.prettierrc.json b/.prettierrc.json
deleted file mode 100644
index 488d1b00..00000000
--- a/.prettierrc.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "singleQuote": true,
- "trailingComma": "all",
- "semi": false,
- "useTabs": true,
- "tabWidth": 2,
- "printWidth": 100,
- "endOfLine": "lf",
- "bracketSameLine": true,
- "jsxSingleQuote": false,
- "proseWrap": "preserve",
- "quoteProps": "as-needed",
- "arrowParens": "avoid",
- "bracketSpacing": true
-}
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 19864a5d..00000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,35 +0,0 @@
-{
- "editor.detectIndentation": false,
- "editor.defaultFormatter": "esbenp.prettier-vscode",
- "prettier.configPath": ".prettierrc.json",
- "prettier.useTabs": true,
- "prettier.tabWidth": 2,
- "[javascript]": {
- "editor.insertSpaces": false,
- "editor.tabSize": 2
- },
- "[javascriptreact]": {
- "editor.insertSpaces": false,
- "editor.tabSize": 2
- },
- "[typescript]": {
- "editor.insertSpaces": false,
- "editor.tabSize": 2
- },
- "[typescriptreact]": {
- "editor.insertSpaces": false,
- "editor.tabSize": 2
- },
- "files.associations": {
- "*.tsx": "typescriptreact",
- "*.yaml": "yaml",
- "*.html": "html",
- "*.css": "css",
- "*.md": "markdown",
- "*.json": "jsonc",
- "*.ts": "typescript",
- "*.feature": "feature",
- "*.ttml": "xml",
- "*.ttss": "css"
- }
-}
diff --git a/App.tsx b/App.tsx
deleted file mode 100644
index 9d8b1864..00000000
--- a/App.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'
-import { NavigationContainer } from '@react-navigation/native'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { LoginBottomSheetProvider } from '@/shared/contexts/loginBottomSheetContext'
-import { ManageClubBottomSheetProvider } from '@/shared/contexts/manageClubBottomSheet'
-import { ProfileProvider } from '@/shared/contexts/profileContext'
-import { serviceContext } from '@/shared/contexts/serviceContext'
-import { UserVoiceBottomSheetProvider } from '@/shared/contexts/userVoiceBottomSheetContext'
-import React, { useEffect, useState } from 'react'
-import { StyleSheet, Text, View } from 'react-native'
-import { GestureHandlerRootView } from 'react-native-gesture-handler'
-import { SafeAreaProvider } from 'react-native-safe-area-context'
-import Toast, { ToastConfig } from 'react-native-toast-message'
-import { getAnnouncementRepository } from '@/repositories/announcement'
-import { getAppVersionRepository } from '@/repositories/appVersion'
-import { getAuthRepository } from '@/repositories/auth'
-import { getCategoryRepository } from '@/repositories/category'
-import { getClubRepository } from '@/repositories/club'
-import { getRecentSearchRepository } from '@/repositories/recentSearch'
-import { getReviewRepository } from '@/repositories/review'
-import { getTermRepository } from '@/repositories/term'
-import { getUserRepository } from '@/repositories/user'
-import { TabNavigator } from '@/tabs/TabNavigator'
-import { getAnnouncementService } from '@/usecases/announcement'
-import { getAppVersionService } from '@/usecases/appVersion'
-import { getAuthService } from '@/usecases/auth'
-import { getCategoryService } from '@/usecases/category'
-import { getClubService } from '@/usecases/club'
-import { getEventLogService } from '@/usecases/eventLog'
-import { getRecentSearchService } from '@/usecases/recentSearch'
-import { getReviewService } from '@/usecases/review'
-import { getTermService } from '@/usecases/term'
-import { getUserService } from '@/usecases/user'
-import { _navigationRef, setIsNavigationReady } from '@/shared/utils/navigation'
-import { linking } from '@/config/linking'
-import { initToken } from '@/shared/utils/api'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
-import ForceUpdateGate from '@/shared/components/ForceUpdateGate'
-import AppModalManager from '@/shared/components/AppModalManager'
-
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 5 * 60 * 1000,
- cacheTime: 10 * 60 * 1000,
- },
- },
-})
-
-function App(): React.JSX.Element {
- const { Provider: ServiceProvider } = serviceContext
-
- const announcementRepository = getAnnouncementRepository()
- const appVersionRepository = getAppVersionRepository()
- const authRepository = getAuthRepository()
- const categoryRepository = getCategoryRepository()
- const clubRepository = getClubRepository()
- const recentSearchRepository = getRecentSearchRepository()
- const reviewRepository = getReviewRepository()
- const termRepository = getTermRepository()
- const userRepository = getUserRepository()
-
- const announcementService = getAnnouncementService({ repositories: [announcementRepository] })
- const appVersionService = getAppVersionService({ repositories: [appVersionRepository] })
- const authService = getAuthService({ repositories: [authRepository] })
- const categoryService = getCategoryService({ repositories: [categoryRepository] })
- const clubService = getClubService({ repositories: [clubRepository] })
- const eventLogService = getEventLogService()
- const recentSearchService = getRecentSearchService({ repositories: [recentSearchRepository] })
- const reviewService = getReviewService({ repositories: [reviewRepository] })
- const termService = getTermService({ repositories: [termRepository] })
- const userService = getUserService({ repositories: [userRepository] })
-
- const services = {
- announcementService,
- appVersionService,
- authService,
- categoryService,
- clubService,
- eventLogService,
- recentSearchService,
- reviewService,
- termService,
- userService,
- }
-
- // 토큰을 메모리로 복원한 뒤에야 인증 요청을 하는 트리(ProfileProvider 등)를 마운트한다.
- // 그렇지 않으면 _token이 채워지기 전에 /v2/users/me가 게스트로 나가 자동로그인이 깨진다.
- const [isBootstrapped, setIsBootstrapped] = useState(false)
-
- useEffect(() => {
- const bootstrap = async () => {
- await initToken()
- setIsNavigationReady(true)
- setIsBootstrapped(true)
- }
- bootstrap()
- }, [])
-
- // 부트스트랩 동안에는 네이티브 스플래시가 화면을 덮고 있어 사용자에겐 빈 화면이 보이지 않는다.
- if (!isBootstrapped) {
- return
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-export default App
-
-const styles = StyleSheet.create({
- bootstrapPlaceholder: {
- flex: 1,
- backgroundColor: Colors.WHITE,
- },
-})
-
-const toastConfig: ToastConfig = {
- info: ({ text1 }) => (
-
-
- {text1}
-
-
- ),
-}
-
-const toastStyles = StyleSheet.create({
- container: {
- backgroundColor: Colors.BODYTEXT_MAIN,
- borderRadius: ms(100),
- paddingHorizontal: s(24),
- paddingVertical: vs(14),
- marginHorizontal: s(16),
- shadowColor: Colors.BLACK,
- shadowOffset: { width: 0, height: vs(4) },
- shadowOpacity: 0.12,
- shadowRadius: ms(12),
- elevation: 8,
- },
- text: {
- ...typography.bodyMSemibold,
- color: Colors.WHITE,
- textAlign: 'center',
- },
-})
diff --git a/README.md b/README.md
index 80b3c6df..c2a18988 100644
--- a/README.md
+++ b/README.md
@@ -1,519 +1,33 @@
-# AllClear App
+# Allclear
-## 📋 목차
+Allclear 모바일 앱과 웹 서비스를 함께 관리하는 모노레포입니다.
-- [프로젝트 구조](#-프로젝트-구조)
-- [시작하기](#-시작하기)
-- [아키텍처](#-아키텍처)
-- [자체 ORM](#-자체-orm)
-- [데이터 플로우](#-데이터-플로우)
-- [웹뷰 기능](#-웹뷰-기능)
-- [개발 가이드](#-개발-가이드)
-- [빌드 및 배포](#-빌드-및-배포)
+## 구성
-## 🗂️ 프로젝트 구조
+- `apps/mobile`: React Native 기반 iOS/Android 앱
+- `apps/web`: Next.js 기반 웹 서비스
-**Clean Architecture 기반 4계층 구조로 Entity-Repository-UseCase-Presentation 패턴 적용**
+## 시작하기
-```
-src/
-├── assets/ # 이미지, 아이콘 등 정적 리소스
-├── config/ # 환경변수 설정
-├── entities/ # 도메인 타입 정의
-├── features/ # 도메인별 화면 구성
-├── repositories/ # 데이터 계층 (자체 ORM)
-├── shared/ # 공통 컴포넌트/상수/컨텍스트/훅/유틸
-├── tabs/ # 탭 네비게이션 구성
-└── usecases/ # 비즈니스 로직
-```
-
-### 주요 파일 구조
-
-- `entities/`: user.ts, club.ts, review.ts, category.ts
-- `repositories/`: 각 도메인별 CRUD + API 통신
-- `usecases/`: 비즈니스 로직 + Repository DI
-- `features/`: club, home, mypage, webview 화면 도메인
-- `shared/contexts/serviceContext.ts`: 전체 서비스 의존성 주입
-- `shared/utils/api.ts`: APIConnector 클래스 (HTTP 클라이언트)
-
-## 🚀 시작하기
-
-**Node.js 18+ 환경에서 React Native 앱 실행**
-
-### 설치 및 실행
+Node.js 22.13 이상과 pnpm 11이 필요합니다.
```bash
-# 의존성 설치
-yarn install && cd ios && pod install && cd ..
-
-# 개발 서버 실행
-yarn start
-yarn ios:local # iOS
-yarn android:dev # Android
-```
-
-### 환경 변수 설정
-
-`.env.local`, `.env.prod` 파일에 설정:
-
-```env
-API_SERVER_BASE_URL=https://your-api-server.com
-PROFILE=dev|staging|prod
-```
-
-## 🏛️ 아키텍처
-
-**Clean Architecture 4계층으로 의존성 역전과 계층 분리 달성**
-
-```
-┌─────────────────┐
-│ Presentation │ ← features/, shared/, tabs/ (UI + 상태)
-├─────────────────┤
-│ Use Cases │ ← usecases/ (비즈니스 로직)
-├─────────────────┤
-│ Repositories │ ← repositories/ (데이터 접근)
-├─────────────────┤
-│ Entities │ ← entities/ (도메인 모델)
-└─────────────────┘
-```
-
-### 계층별 역할
-
-**1. Entities** - 순수 TypeScript 타입
-
-```typescript
-export type User = {
- id: string
- nickname: string
- college: string // 단과대
- major: string // 학과
- grade: number | null
-}
-```
-
-**2. Repositories** - 데이터 추상화 + 자체 ORM
-
-```typescript
-export const getUserRepository = (): UserRepository => ({
- getUser: async () => {
- const token = await AsyncStorage.getItem(LOGIN_TOKEN)
- return apiConnector.get('/v1/users/me')
- },
-})
-```
-
-**3. Use Cases** - 비즈니스 로직 + DI
-
-```typescript
-export const getUserService = ({ repositories }: Deps): UserService => ({
- getUser: () => repositories[0].getUser(),
-})
-```
-
-**4. Presentation** - UI + 이벤트 로깅
-
-```typescript
-const HomeScreen = () => {
- const { logClickEvent } = useClickEventLog()
- return (
-
- {/* UI 컴포넌트 */}
-
- )
-}
-```
-
-## 🗄️ 자체 ORM
-
-**TypeScript + Repository 패턴으로 별도 ORM 라이브러리 없이 타입 안전한 데이터 계층 구현**
-
-### 1. APIConnector 클래스
-
-```typescript
-export class APIConnector {
- async get(path: string, params?: any): Promise {
- const token = await AsyncStorage.getItem(LOGIN_TOKEN)
- const headers = token ? { Authorization: `Bearer ${token}` } : {}
- return this.request(path, 'GET', { params, headers })
- }
-}
-
-export const apiConnector = new APIConnector()
+pnpm install
```
-### 2. Repository 패턴
-
-```typescript
-// 인터페이스 정의
-export type ClubRepository = {
- searchClubs: (req: SearchClubsRequest) => Promise
- getClub: (req: GetClubRequest) => Promise
-}
-
-// 구현체
-export const getClubRepository = (): ClubRepository => ({
- searchClubs: async req => apiConnector.get(`/v1/clubs/search`, req),
- getClub: async req => apiConnector.get(`/v1/clubs/${req.uuid}`),
-})
-```
-
-### 3. 타입 안전성
-
-모든 Request/Response에 TypeScript 타입 정의로 컴파일타임 검증
-
-**장점**: 타입 안전성 + 경량화 + 테스트 용이성 + 명확한 계층 분리
-
-## 🔄 데이터 플로우
-
-**App.tsx에서 전체 의존성 주입 후 Context로 전파하는 구조**
-
-### 의존성 주입 패턴
-
-```typescript
-// App.tsx
-function App() {
- // 1. Repository 생성
- const userRepository = getUserRepository()
- const clubRepository = getClubRepository()
-
- // 2. Service에 Repository 주입
- const userService = getUserService({ repositories: [userRepository] })
- const clubService = getClubService({ repositories: [clubRepository] })
-
- // 3. Context로 서비스 제공
- const services = { userService, clubService }
-
- return (
-
-
- {/* 앱 컴포넌트 */}
-
-
- )
-}
-```
-
-### 컴포넌트에서 사용
-
-```typescript
-export const ProfileProvider = ({ children }) => {
- const { userService } = useContext(serviceContext)
-
- useEffect(() => {
- userService.getUser().then(setUser)
- }, [])
-
- return {children}
-}
-```
-
-## 🌐 웹뷰 기능
-
-**React Native WebView를 활용한 하이브리드 기능 제공**
-
-### 개요
-
-앱 내에서 웹 페이지를 표시할 수 있는 WebView 화면을 제공합니다. 주로 동아리 정보 편집과 같이 복잡한 폼이 필요한 기능을 웹으로 구현하여 앱에서 사용합니다.
-
-### 주요 기능
-
-**1. 인앱 브라우저**
-
-```typescript
-// 파일: src/screens/WebviewScreen/index.tsx:12-54
-
-```
-
-**2. 인증 헤더 지원**
-
-- Authorization 토큰을 `x-authorization` 헤더로 전달
-- 로그인이 필요한 웹 페이지 접근 가능
-
-**3. 웹-네이티브 통신**
-
-```typescript
-// 웹에서 앱으로 메시지 전송
-// 파일: src/screens/WebviewScreen/index.tsx:17-24
-const onMessage = (e: WebViewMessageEvent) => {
- const event = JSON.parse(e.nativeEvent.data)
-
- switch (event.method) {
- case 'CLOSE_WEBVIEW':
- return navigation.goBack()
- }
-}
-```
-
-**4. 로딩 상태 표시**
-
-- 웹 페이지 로딩 중 ActivityIndicator 표시
-- `onLoadStart`, `onLoadEnd` 이벤트로 로딩 상태 관리
-
-**5. 네비게이션**
-
-- 커스텀 헤더 with 뒤로가기 버튼
-- 웹에서 `CLOSE_WEBVIEW` 메시지로 화면 닫기 가능
-
-### 사용 예시
-
-**동아리 편집 페이지 열기**
-
-```typescript
-// 파일: src/screens/MyPageScreen/index.tsx:69-71
-navigation.navigate(SCREEN_TYPE.WEBVIEW, {
- uri: ENV.WEB_URL + '/c/edit/' + club.uuid,
- authorization, // 사용자 토큰
-})
-```
-
-### 라우트 파라미터
-
-```typescript
-// 파일: src/entities/screen.ts:37
-type WebViewParams = {
- uri: string // 표시할 웹 페이지 URL
- authorization?: string // 선택적 인증 토큰
-}
-```
-
-### 웹에서 앱으로 메시지 전송 방법
-
-**웹 페이지에서 다음과 같이 메시지 전송:**
-
-```javascript
-// 웹 페이지 JavaScript 코드
-window.ReactNativeWebView.postMessage(
- JSON.stringify({
- method: 'CLOSE_WEBVIEW',
- }),
-)
-```
-
-### 지원 기능
-
-- JavaScript 실행
-- DOM Storage
-- 쿠키 공유 (앱과 웹 간)
-- Third-party 쿠키
-- 자동 창 열기
-- Bounce 효과 비활성화 (iOS)
-- OverScroll 비활성화 (Android)
-
-### 활용 사례
-
-- **동아리 관리**: 동아리 정보 편집 폼 (`/c/edit/:uuid`)
-- **복잡한 폼**: 여러 필드와 검증이 필요한 입력 폼
-- **외부 연동**: 웹에서만 제공되는 서비스 통합
-
-### 파일 구조
-
-```
-src/screens/WebviewScreen/
-├── index.tsx # WebView 메인 화면
-└── Header/
- └── index.tsx # 커스텀 네비게이션 헤더
-```
-
-## 🛠️ 개발 가이드
-
-**새 기능 개발 시 Entity → Repository → UseCase → UI 순서로 진행**
-
-### 새 기능 개발 플로우
-
-1. **Entity 타입 정의**
-
-```typescript
-export type NewFeature = { id: string; name: string }
-```
-
-2. **Repository 구현**
-
-```typescript
-export const getNewFeatureRepository = (): NewFeatureRepository => ({
- getFeature: async id => apiConnector.get(`/v1/features/${id}`),
-})
-```
-
-3. **UseCase 구현**
-
-```typescript
-export const getNewFeatureService = ({ repositories }) => ({
- getFeature: id => repositories[0].getFeature(id),
-})
-```
-
-4. **의존성 주입 + UI 사용**
-
-```typescript
-// App.tsx에서 서비스 추가 후 Context에서 사용
-const { newFeatureService } = useContext(serviceContext)
-```
-
-### 코드 스타일
-
```bash
-yarn lint # ESLint 검사
-npx tsc --noEmit # TypeScript 타입 체크
-```
-
-### 주요 라이브러리
-
-- **네비게이션**: `@react-navigation/native`
-- **상태관리**: `@tanstack/react-query` + React Context
-- **UI**: `react-native-elements`, `@gorhom/bottom-sheet`
-- **HTTP**: `axios`
-- **스토리지**: `@react-native-async-storage/async-storage`
-- **로그인**: `@react-native-seoul/kakao-login`, `@invertase/react-native-apple-authentication`
-
-### 테스트 예시
-
-```typescript
-const mockRepository: UserRepository = {
- getUser: jest.fn().mockResolvedValue(mockUser),
-}
-const userService = getUserService({ repositories: [mockRepository] })
-
-test('should get user', async () => {
- const user = await userService.getUser()
- expect(user).toEqual(mockUser)
-})
+pnpm start # 모바일 개발 서버
+pnpm dev:web # 웹 개발 서버
+pnpm ios:local # iOS 로컬 앱
+pnpm android:debug
```
-### 트러블슈팅
+환경 변수 파일은 저장소에 포함하지 않습니다. 각 앱의 `.env.example`을 복사해 로컬 환경에 맞게 설정하세요.
-```bash
-npx react-native start --reset-cache # Metro 캐시 클리어
-cd ios && pod deintegrate && pod install # iOS 의존성 재설치
-cd android && ./gradlew clean # Android 클리어
-yarn reset # 전체 리셋
-```
-
-### 빌드 명령어
+## 검사
```bash
-# 디버그
-yarn ios:local / yarn android:dev
-
-# 릴리스
-yarn build:ios:prod:release / yarn build:android:release
-
-```
-
----
-
-## 📋 전체 프로젝트 파일 구조
-
-```
-src/
-├── assets/ # 정적 리소스
-│ ├── icons/ # 앱 아이콘 (apple, kakao, trophy 등)
-│ └── images/ # 이미지 리소스 (header, mypage, theme, tab 등)
-│
-├── config/ # 환경변수/런타임 설정
-│ └── ENV.ts # 환경변수 검증 및 관리
-│
-├── entities/ # 도메인 모델 (타입 정의)
-│ ├── user.ts # 사용자 정보 (User, CollegeMajor)
-│ ├── club.ts # 동아리 정보 (Club, ClubRanking, ReviewKeyword)
-│ ├── category.ts # 동아리 카테고리 (9개 카테고리별 색상/이미지)
-│ ├── review.ts # 리뷰 키워드 시스템
-│ ├── eventLog.ts # 사용자 행동 로깅 (view, click, expose)
-│ └── screen.ts # React Navigation 스크린 타입
-│
-├── repositories/ # 데이터 접근 계층 (자체 ORM)
-│ ├── auth.ts # 인증 (카카오/애플 로그인, 회원탈퇴)
-│ ├── category.ts # 카테고리 데이터 관리
-│ ├── club.ts # 동아리 CRUD (검색, 목록, 상세, 저장, 랭킹)
-│ ├── review.ts # 리뷰 및 평점 시스템
-│ └── user.ts # 사용자 관리 (프로필, 피드백, 학과목록)
-│
-├── features/ # 도메인별 화면 컴포넌트
-│ ├── club/screens/ # 동아리 도메인 화면
-│ │ ├── ClubDetailScreen/
-│ │ ├── ClubListScreen/
-│ │ ├── ClubRankingScreen/
-│ │ │ └── RankedClubs/
-│ │ ├── ClubReviewScreen/
-│ │ └── SearchResultClubListScreen/
-│ ├── home/screens/
-│ │ └── HomeScreen/ # 홈 화면 (Header, CategoryBoard, RecommendClubs)
-│ ├── mypage/screens/
-│ │ ├── EditProfileScreen/
-│ │ ├── ManageClubListScreen/
-│ │ ├── MyPageScreen/
-│ │ └── SavedClubListScreen/
-│ └── webview/screens/
-│ └── WebviewScreen/
-│
-├── shared/ # 공통 모듈
-│ ├── components/ # 공통 UI 컴포넌트
-│ │ ├── AlertModal.tsx
-│ │ ├── Button.tsx
-│ │ ├── ClubListItem.tsx
-│ │ ├── HeaderProfile.tsx
-│ │ ├── HtmlView.tsx
-│ │ ├── LoginView.tsx
-│ │ ├── ManageClubView.tsx
-│ │ ├── TextField.tsx
-│ │ └── UserVoiceView.tsx
-│ ├── constants/ # 공통 상수
-│ │ ├── colors.ts
-│ │ ├── fixtures.ts
-│ │ └── localStorage.ts
-│ ├── contexts/ # React Context
-│ │ ├── loginBottomSheetContext.tsx
-│ │ ├── manageClubBottomSheet.tsx
-│ │ ├── profileContext.tsx
-│ │ ├── serviceContext.ts
-│ │ └── userVoiceBottomSheetContext.tsx
-│ ├── hocs/
-│ │ └── WithViewEventLog.tsx
-│ ├── hooks/
-│ │ ├── useClickEventLog.tsx
-│ │ └── useExposeEventLog.tsx
-│ └── utils/
-│ ├── api.ts
-│ └── navigation.ts
-│
-├── tabs/ # 탭 네비게이션
-│ ├── HomeTab.tsx
-│ ├── MyPageTab.tsx
-│ ├── RankingTab.tsx
-│ └── TabNavigator.tsx
-│
-└── usecases/ # 비즈니스 로직 계층
- ├── auth.ts # 인증 비즈니스 로직
- ├── category.ts # 카테고리 비즈니스 로직
- ├── club.ts # 동아리 비즈니스 로직
- ├── eventLog.ts # 이벤트 로깅 비즈니스 로직
- ├── review.ts # 리뷰 비즈니스 로직
- └── user.ts # 사용자 비즈니스 로직
+pnpm verify
```
-### 파일 역할 요약
-
-**🔸 Domain Layer (entities/)**
-순수 TypeScript 타입으로 비즈니스 도메인 모델 정의
-
-**🔸 Data Layer (repositories/)**
-API 통신과 데이터 CRUD 담당. 자체 ORM 역할 수행
-
-**🔸 Business Layer (usecases/)**
-Repository를 조합한 비즈니스 로직. 의존성 주입 패턴
-
-**🔸 Presentation Layer (features/, shared/, tabs/)**
-UI 컴포넌트와 전역 상태 관리
-
-**🔸 Infrastructure (config/, shared/)**
-HTTP 클라이언트, 환경설정, 공통 유틸리티
-
-**🔸 Cross-Cutting (hooks/, hocs/)**
-이벤트 로깅 시스템과 재사용 로직
+Turbo가 각 앱의 Biome 린트, 타입 검사와 테스트를 실행합니다.
diff --git a/android/app/src/main/res/drawable/splash.png b/android/app/src/main/res/drawable/splash.png
deleted file mode 100644
index d82b0a98..00000000
Binary files a/android/app/src/main/res/drawable/splash.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 91b9fb45..00000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png
deleted file mode 100644
index 6b1e15c5..00000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index a52db1d3..00000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png
deleted file mode 100644
index 9cc2f7f1..00000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index e5236ee9..00000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png
deleted file mode 100644
index 8ea4409d..00000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index a8faad59..00000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png
deleted file mode 100644
index c881e984..00000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index ae18a485..00000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png
deleted file mode 100644
index e6526767..00000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png and /dev/null differ
diff --git a/android/app/src/main/res/playstore-icon.png b/android/app/src/main/res/playstore-icon.png
deleted file mode 100644
index 16e74240..00000000
Binary files a/android/app/src/main/res/playstore-icon.png and /dev/null differ
diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example
new file mode 100644
index 00000000..2fcb055d
--- /dev/null
+++ b/apps/mobile/.env.example
@@ -0,0 +1,9 @@
+# Copy this file to .env.local for local development.
+# Never commit real credentials or production values.
+
+API_SERVER_BASE_URL=http://localhost:3000/api
+WEB_URL=http://localhost:3000
+PROFILE=dev
+
+# Required by the Android and iOS Kakao configuration.
+KAKAO_APP_KEY=
diff --git a/apps/mobile/.husky/pre-commit b/apps/mobile/.husky/pre-commit
new file mode 100644
index 00000000..f5c694f0
--- /dev/null
+++ b/apps/mobile/.husky/pre-commit
@@ -0,0 +1 @@
+pnpm --dir apps/mobile exec lint-staged
diff --git a/apps/mobile/.husky/pre-push b/apps/mobile/.husky/pre-push
new file mode 100644
index 00000000..dee03683
--- /dev/null
+++ b/apps/mobile/.husky/pre-push
@@ -0,0 +1 @@
+pnpm --dir apps/mobile run verify
diff --git a/.node-version b/apps/mobile/.node-version
similarity index 100%
rename from .node-version
rename to apps/mobile/.node-version
diff --git a/ios/.ruby-version b/apps/mobile/.ruby-version
similarity index 100%
rename from ios/.ruby-version
rename to apps/mobile/.ruby-version
diff --git a/.watchmanconfig b/apps/mobile/.watchmanconfig
similarity index 100%
rename from .watchmanconfig
rename to apps/mobile/.watchmanconfig
diff --git a/AGENTS.md b/apps/mobile/AGENTS.md
similarity index 67%
rename from AGENTS.md
rename to apps/mobile/AGENTS.md
index 11e632b1..feaeee07 100644
--- a/AGENTS.md
+++ b/apps/mobile/AGENTS.md
@@ -6,24 +6,27 @@ React Native app (iOS + Android) for the Allclear club discovery service.
```bash
# Dev
-yarn start # Metro bundler
-yarn ios:local # iOS (local env)
-yarn ios:prod # iOS (prod env)
-yarn android:debug # Android debug (local env)
-yarn android:release # Android release (prod env)
+pnpm start # Metro bundler
+pnpm ios:local # iOS (local env)
+pnpm ios:prod # iOS (prod env)
+pnpm android:debug # Android debug (local env)
+pnpm android:release # Android release (prod env)
# Quality
-yarn lint # ESLint
-yarn tsc --noEmit # Type check (also runs on pre-push)
+pnpm lint # Biome lint
+pnpm typecheck # TypeScript check (also runs on pre-push)
+pnpm test --runInBand # Jest test suite
+pnpm verify # Biome + typecheck + tests
+pnpm verify:full # verify + Android and iOS bundle checks
# Build
-yarn build:android:debug # Android APK (debug)
-yarn build:android:release # Android bundle (release)
-yarn build:ios:prod:release # iOS release build
+pnpm build:android:debug # Android APK (debug)
+pnpm build:android:release # Android bundle (release)
+pnpm build:ios:prod:release # iOS release build
# Maintenance
-yarn re-install # Clean reinstall node_modules
-yarn android:clean # Clean Android build artifacts
+pnpm re-install # Clean reinstall node_modules
+pnpm android:clean # Clean Android build artifacts
```
## Project Structure
@@ -61,17 +64,18 @@ Path alias: `@/` → `src/`
## Code Style
-- **Formatter**: Prettier — single quotes, no semicolons, trailing commas, tabs (width 2), printWidth 100, LF
-- **Linter**: ESLint with TypeScript, React, React Hooks, React Native plugins
-- Pre-commit hook runs `lint-staged` (prettier + eslint --fix on TS/TSX files automatically)
-- Pre-push hook runs `yarn tsc --noEmit` — fix type errors before pushing
+- **Formatter and linter**: Biome defaults with recommended lint, React, and test rules
+- Run `pnpm fix` followed by `pnpm verify` before committing
+- `pnpm fix` applies safe Biome formatting and lint fixes; `pnpm verify` is read-only
+- Pre-commit hook runs `lint-staged` (`biome check --write` on supported staged files)
+- Pre-push hook runs `pnpm verify` — fix formatting, lint, type, or test failures before pushing
## TypeScript
- Strict mode enabled
- `@/` path alias available everywhere
- Avoid `any`; use `unknown` and narrow types properly
-- `@typescript-eslint/no-unused-vars` warns on unused vars (prefix with `_` to suppress)
+- Biome warns on unused variables and parameters (prefix intentionally unused parameters with `_`)
## Environment
@@ -107,7 +111,8 @@ Path alias: `@/` → `src/`
## After Editing
-- Run `yarn tsc --noEmit` and `yarn lint`; fix all errors.
+- Run `pnpm fix`, then `pnpm verify`, and resolve any remaining errors before handing work back.
+- Run `pnpm verify:full` when changes affect dependencies, build configuration, bundling, native integration, or release behavior.
- Remove `console.log`, unused imports, and dead code.
## Do Not
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
new file mode 100644
index 00000000..dd84234a
--- /dev/null
+++ b/apps/mobile/App.tsx
@@ -0,0 +1,236 @@
+import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
+import { NavigationContainer } from "@react-navigation/native";
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import {
+ focusManager,
+ QueryClient,
+ QueryClientProvider,
+} from "@tanstack/react-query";
+import type React from "react";
+import { useCallback, useEffect, useState } from "react";
+import { AppState, StyleSheet, Text, View } from "react-native";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import {
+ initialWindowMetrics,
+ SafeAreaProvider,
+} from "react-native-safe-area-context";
+import Toast, { type ToastConfig } from "react-native-toast-message";
+import { linking } from "@/config/linking";
+import AnnouncementEditScreen from "@/features/club/screens/AnnouncementEditScreen";
+import AnnouncementRegistrationScreen from "@/features/club/screens/AnnouncementRegistrationScreen";
+import { getAnnouncementRepository } from "@/repositories/announcement";
+import { getAppVersionRepository } from "@/repositories/appVersion";
+import { getAuthRepository } from "@/repositories/auth";
+import { getCategoryRepository } from "@/repositories/category";
+import { getClubRepository } from "@/repositories/club";
+import { getRecentSearchRepository } from "@/repositories/recentSearch";
+import { getRecruitmentRepository } from "@/repositories/recruitment";
+import { getReviewRepository } from "@/repositories/review";
+import { getTermRepository } from "@/repositories/term";
+import { getUserRepository } from "@/repositories/user";
+import AppModalManager from "@/shared/components/AppModalManager";
+import ForceUpdateGate from "@/shared/components/ForceUpdateGate";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { LoginBottomSheetProvider } from "@/shared/contexts/loginBottomSheetContext";
+import { ManageClubBottomSheetProvider } from "@/shared/contexts/manageClubBottomSheet";
+import { ProfileProvider } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { UserVoiceBottomSheetProvider } from "@/shared/contexts/userVoiceBottomSheetContext";
+import { initToken } from "@/shared/utils/api";
+import { shouldShowGlobalAppModals } from "@/shared/utils/entrySplash";
+import {
+ _navigationRef,
+ setIsNavigationReady,
+} from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
+import { TabNavigator } from "@/tabs/TabNavigator";
+import { getAnnouncementService } from "@/usecases/announcement";
+import { getAppVersionService } from "@/usecases/appVersion";
+import { getAuthService } from "@/usecases/auth";
+import { getCategoryService } from "@/usecases/category";
+import { getClubService } from "@/usecases/club";
+import { getEventLogService } from "@/usecases/eventLog";
+import { getRecentSearchService } from "@/usecases/recentSearch";
+import { getRecruitmentService } from "@/usecases/recruitment";
+import { getReviewService } from "@/usecases/review";
+import { getTermService } from "@/usecases/term";
+import { getUserService } from "@/usecases/user";
+
+const RootStack = createNativeStackNavigator();
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000,
+ cacheTime: 10 * 60 * 1000,
+ },
+ },
+});
+
+function App(): React.JSX.Element {
+ const { Provider: ServiceProvider } = serviceContext;
+
+ const announcementRepository = getAnnouncementRepository();
+ const appVersionRepository = getAppVersionRepository();
+ const authRepository = getAuthRepository();
+ const categoryRepository = getCategoryRepository();
+ const clubRepository = getClubRepository();
+ const recentSearchRepository = getRecentSearchRepository();
+ const recruitmentRepository = getRecruitmentRepository();
+ const reviewRepository = getReviewRepository();
+ const termRepository = getTermRepository();
+ const userRepository = getUserRepository();
+
+ const announcementService = getAnnouncementService({
+ repositories: [announcementRepository],
+ });
+ const appVersionService = getAppVersionService({
+ repositories: [appVersionRepository],
+ });
+ const authService = getAuthService({ repositories: [authRepository] });
+ const categoryService = getCategoryService({
+ repositories: [categoryRepository],
+ });
+ const clubService = getClubService({ repositories: [clubRepository] });
+ const eventLogService = getEventLogService();
+ const recentSearchService = getRecentSearchService({
+ repositories: [recentSearchRepository],
+ });
+ const recruitmentService = getRecruitmentService({
+ repositories: [recruitmentRepository],
+ });
+ const reviewService = getReviewService({ repositories: [reviewRepository] });
+ const termService = getTermService({ repositories: [termRepository] });
+ const userService = getUserService({ repositories: [userRepository] });
+
+ const services = {
+ announcementService,
+ appVersionService,
+ authService,
+ categoryService,
+ clubService,
+ eventLogService,
+ recentSearchService,
+ recruitmentService,
+ reviewService,
+ termService,
+ userService,
+ };
+
+ // 토큰을 메모리로 복원한 뒤에야 인증 요청을 하는 트리(ProfileProvider 등)를 마운트한다.
+ // 그렇지 않으면 _token이 채워지기 전에 /v2/users/me가 게스트로 나가 자동로그인이 깨진다.
+ const [isBootstrapped, setIsBootstrapped] = useState(false);
+ const [entryFlowComplete, setEntryFlowComplete] = useState(false);
+ const handleEntryComplete = useCallback(() => setEntryFlowComplete(true), []);
+
+ useEffect(() => {
+ const bootstrap = async () => {
+ await initToken();
+ setIsNavigationReady(true);
+ setIsBootstrapped(true);
+ };
+ bootstrap();
+ }, []);
+
+ useEffect(() => {
+ const subscription = AppState.addEventListener("change", (nextAppState) => {
+ focusManager.setFocused(nextAppState === "active");
+ });
+
+ return () => subscription.remove();
+ }, []);
+
+ // 부트스트랩 동안에는 네이티브 스플래시가 화면을 덮고 있어 사용자에겐 빈 화면이 보이지 않는다.
+ if (!isBootstrapped) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {shouldShowGlobalAppModals({ entryFlowComplete }) && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
+
+const styles = StyleSheet.create({
+ bootstrapPlaceholder: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+});
+
+const toastConfig: ToastConfig = {
+ info: ({ text1 }) => (
+
+
+ {text1}
+
+
+ ),
+};
+
+const toastStyles = StyleSheet.create({
+ container: {
+ backgroundColor: Colors.BODYTEXT_MAIN,
+ borderRadius: ms(100),
+ paddingHorizontal: s(24),
+ paddingVertical: vs(14),
+ marginHorizontal: s(16),
+ shadowColor: Colors.BLACK,
+ shadowOffset: { width: 0, height: vs(4) },
+ shadowOpacity: 0.12,
+ shadowRadius: ms(12),
+ elevation: 8,
+ },
+ text: {
+ ...typography.bodyMSemibold,
+ color: Colors.WHITE,
+ textAlign: "center",
+ },
+});
diff --git a/CLAUDE.md b/apps/mobile/CLAUDE.md
similarity index 100%
rename from CLAUDE.md
rename to apps/mobile/CLAUDE.md
diff --git a/Gemfile b/apps/mobile/Gemfile
similarity index 100%
rename from Gemfile
rename to apps/mobile/Gemfile
diff --git a/Gemfile.lock b/apps/mobile/Gemfile.lock
similarity index 100%
rename from Gemfile.lock
rename to apps/mobile/Gemfile.lock
diff --git a/apps/mobile/README.md b/apps/mobile/README.md
new file mode 100644
index 00000000..7963cc75
--- /dev/null
+++ b/apps/mobile/README.md
@@ -0,0 +1,513 @@
+# AllClear App
+
+## 📋 목차
+
+- [프로젝트 구조](#-프로젝트-구조)
+- [시작하기](#-시작하기)
+- [아키텍처](#-아키텍처)
+- [자체 ORM](#-자체-orm)
+- [데이터 플로우](#-데이터-플로우)
+- [웹뷰 기능](#-웹뷰-기능)
+- [개발 가이드](#-개발-가이드)
+- [빌드 및 배포](#-빌드-및-배포)
+
+## 🗂️ 프로젝트 구조
+
+**Clean Architecture 기반 4계층 구조로 Entity-Repository-UseCase-Presentation 패턴 적용**
+
+```
+src/
+├── assets/ # 이미지, 아이콘 등 정적 리소스
+├── config/ # 환경변수 설정
+├── entities/ # 도메인 타입 정의
+├── features/ # 도메인별 화면 구성
+├── repositories/ # 데이터 계층 (자체 ORM)
+├── shared/ # 공통 컴포넌트/상수/컨텍스트/훅/유틸
+├── tabs/ # 탭 네비게이션 구성
+└── usecases/ # 비즈니스 로직
+```
+
+### 주요 파일 구조
+
+- `entities/`: user.ts, club.ts, review.ts, category.ts
+- `repositories/`: 각 도메인별 CRUD + API 통신
+- `usecases/`: 비즈니스 로직 + Repository DI
+- `features/`: club, home, mypage, webview 화면 도메인
+- `shared/contexts/serviceContext.ts`: 전체 서비스 의존성 주입
+- `shared/utils/api.ts`: APIConnector 클래스 (HTTP 클라이언트)
+
+## 🚀 시작하기
+
+**Node.js 22.13+ 및 pnpm 환경에서 React Native 앱 실행**
+
+### 설치 및 실행
+
+```bash
+# 의존성 설치
+pnpm install && cd ios && pod install && cd ..
+
+# 개발 서버 실행
+pnpm start
+pnpm ios:local # iOS
+pnpm android:debug # Android
+```
+
+### 환경 변수 설정
+
+`.env.local`, `.env.prod` 파일에 설정:
+
+```env
+API_SERVER_BASE_URL=https://your-api-server.com
+PROFILE=dev|staging|prod
+```
+
+## 🏛️ 아키텍처
+
+**Clean Architecture 4계층으로 의존성 역전과 계층 분리 달성**
+
+```
+┌─────────────────┐
+│ Presentation │ ← features/, shared/, tabs/ (UI + 상태)
+├─────────────────┤
+│ Use Cases │ ← usecases/ (비즈니스 로직)
+├─────────────────┤
+│ Repositories │ ← repositories/ (데이터 접근)
+├─────────────────┤
+│ Entities │ ← entities/ (도메인 모델)
+└─────────────────┘
+```
+
+### 계층별 역할
+
+**1. Entities** - 순수 TypeScript 타입
+
+```typescript
+export type User = {
+ id: string
+ nickname: string
+ name: string
+ phone: string
+ email: string
+ collegeMajor: CollegeMajor | null
+ admissionClass: number | null
+}
+
+export type CollegeMajor = {
+ id: number
+ college: string | null
+ major: string | null
+}
+```
+
+**2. Repositories** - 데이터 추상화 + 자체 ORM
+
+```typescript
+export const getUserRepository = (): UserRepository => ({
+ getUser: async () => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN)
+ return apiConnector.get('/v2/users/me')
+ },
+})
+```
+
+**3. Use Cases** - 비즈니스 로직 + DI
+
+```typescript
+export const getUserService = ({ repositories }: Deps): UserService => ({
+ getUser: () => repositories[0].getUser(),
+})
+```
+
+**4. Presentation** - UI + 이벤트 로깅
+
+```typescript
+const HomeScreen = () => {
+ const { logClickEvent } = useClickEventLog()
+ return (
+
+ {/* UI 컴포넌트 */}
+
+ )
+}
+```
+
+## 🗄️ 자체 ORM
+
+**TypeScript + Repository 패턴으로 별도 ORM 라이브러리 없이 타입 안전한 데이터 계층 구현**
+
+### 1. APIConnector 클래스
+
+```typescript
+export class APIConnector {
+ async get(path: string, params?: any): Promise {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN)
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
+ return this.request(path, 'GET', { params, headers })
+ }
+}
+
+export const apiConnector = new APIConnector()
+```
+
+### 2. Repository 패턴
+
+```typescript
+// 인터페이스 정의
+export type ClubRepository = {
+ searchClubs: (req: SearchClubsRequest) => Promise
+ getClub: (req: GetClubRequest) => Promise
+}
+
+// 구현체
+export const getClubRepository = (): ClubRepository => ({
+ searchClubs: async req => apiConnector.get(`/v2/clubs/search`, req),
+ getClub: async req => apiConnector.get(`/v2/clubs/${req.uuid}`),
+})
+```
+
+### 3. 타입 안전성
+
+모든 Request/Response에 TypeScript 타입 정의로 컴파일타임 검증
+
+**장점**: 타입 안전성 + 경량화 + 테스트 용이성 + 명확한 계층 분리
+
+## 🔄 데이터 플로우
+
+**App.tsx에서 전체 의존성 주입 후 Context로 전파하는 구조**
+
+### 의존성 주입 패턴
+
+```typescript
+// App.tsx
+function App() {
+ // 1. Repository 생성
+ const userRepository = getUserRepository()
+ const clubRepository = getClubRepository()
+
+ // 2. Service에 Repository 주입
+ const userService = getUserService({ repositories: [userRepository] })
+ const clubService = getClubService({ repositories: [clubRepository] })
+
+ // 3. Context로 서비스 제공
+ const services = { userService, clubService }
+
+ return (
+
+
+ {/* 앱 컴포넌트 */}
+
+
+ )
+}
+```
+
+### 컴포넌트에서 사용
+
+```typescript
+export const ProfileProvider = ({ children }) => {
+ const { userService } = useContext(serviceContext)
+
+ useEffect(() => {
+ userService.getUser().then(setUser)
+ }, [])
+
+ return {children}
+}
+```
+
+## 🌐 웹뷰 기능
+
+**React Native WebView를 활용한 하이브리드 기능 제공**
+
+### 개요
+
+앱 내에서 웹 페이지를 표시할 수 있는 WebView 화면을 제공합니다. 주로 동아리 정보 편집과 같이 복잡한 폼이 필요한 기능을 웹으로 구현하여 앱에서 사용합니다.
+
+### 주요 기능
+
+**1. 인앱 브라우저**
+
+```typescript
+// 파일: src/screens/WebviewScreen/index.tsx:12-54
+
+```
+
+**2. 인증 헤더 지원**
+
+- Authorization 토큰을 `x-authorization` 헤더로 전달
+- 로그인이 필요한 웹 페이지 접근 가능
+
+**3. 웹-네이티브 통신**
+
+```typescript
+// 웹에서 앱으로 메시지 전송
+// 파일: src/screens/WebviewScreen/index.tsx:17-24
+const onMessage = (e: WebViewMessageEvent) => {
+ const event = JSON.parse(e.nativeEvent.data)
+
+ switch (event.method) {
+ case 'CLOSE_WEBVIEW':
+ return navigation.goBack()
+ }
+}
+```
+
+**4. 로딩 상태 표시**
+
+- 웹 페이지 로딩 중 ActivityIndicator 표시
+- `onLoadStart`, `onLoadEnd` 이벤트로 로딩 상태 관리
+
+**5. 네비게이션**
+
+- 커스텀 헤더 with 뒤로가기 버튼
+- 웹에서 `CLOSE_WEBVIEW` 메시지로 화면 닫기 가능
+
+### 라우트 파라미터
+
+```typescript
+// 파일: src/entities/screen.ts:37
+type WebViewParams = {
+ uri: string // 표시할 웹 페이지 URL
+ authorization?: string // 선택적 인증 토큰
+}
+```
+
+### 웹에서 앱으로 메시지 전송 방법
+
+**웹 페이지에서 다음과 같이 메시지 전송:**
+
+```javascript
+// 웹 페이지 JavaScript 코드
+window.ReactNativeWebView.postMessage(
+ JSON.stringify({
+ method: 'CLOSE_WEBVIEW',
+ }),
+)
+```
+
+### 지원 기능
+
+- JavaScript 실행
+- DOM Storage
+- 쿠키 공유 (앱과 웹 간)
+- Third-party 쿠키
+- 자동 창 열기
+- Bounce 효과 비활성화 (iOS)
+- OverScroll 비활성화 (Android)
+
+### 활용 사례
+
+- **복잡한 폼**: 여러 필드와 검증이 필요한 입력 폼
+- **외부 연동**: 웹에서만 제공되는 서비스 통합
+
+### 파일 구조
+
+```
+src/screens/WebviewScreen/
+├── index.tsx # WebView 메인 화면
+└── Header/
+ └── index.tsx # 커스텀 네비게이션 헤더
+```
+
+## 🛠️ 개발 가이드
+
+**새 기능 개발 시 Entity → Repository → UseCase → UI 순서로 진행**
+
+### 새 기능 개발 플로우
+
+1. **Entity 타입 정의**
+
+```typescript
+export type NewFeature = { id: string; name: string }
+```
+
+2. **Repository 구현**
+
+```typescript
+export const getNewFeatureRepository = (): NewFeatureRepository => ({
+ getFeature: async id => apiConnector.get(`/v2/features/${id}`),
+})
+```
+
+3. **UseCase 구현**
+
+```typescript
+export const getNewFeatureService = ({ repositories }) => ({
+ getFeature: id => repositories[0].getFeature(id),
+})
+```
+
+4. **의존성 주입 + UI 사용**
+
+```typescript
+// App.tsx에서 서비스 추가 후 Context에서 사용
+const { newFeatureService } = useContext(serviceContext)
+```
+
+### 코드 스타일
+
+```bash
+pnpm lint # Biome 검사
+pnpm typecheck # TypeScript 타입 체크
+```
+
+### 주요 라이브러리
+
+- **네비게이션**: `@react-navigation/native`
+- **상태관리**: `@tanstack/react-query` + React Context
+- **UI**: `react-native-elements`, `@gorhom/bottom-sheet`
+- **HTTP**: `axios`
+- **스토리지**: `@react-native-async-storage/async-storage`
+- **로그인**: `@react-native-seoul/kakao-login`, `@invertase/react-native-apple-authentication`
+
+### 테스트 예시
+
+```typescript
+const mockRepository: UserRepository = {
+ getUser: jest.fn().mockResolvedValue(mockUser),
+}
+const userService = getUserService({ repositories: [mockRepository] })
+
+test('should get user', async () => {
+ const user = await userService.getUser()
+ expect(user).toEqual(mockUser)
+})
+```
+
+### 트러블슈팅
+
+```bash
+npx react-native start --reset-cache # Metro 캐시 클리어
+cd ios && pod deintegrate && pod install # iOS 의존성 재설치
+cd android && ./gradlew clean # Android 클리어
+pnpm reset # 전체 리셋
+```
+
+### 빌드 명령어
+
+```bash
+# 디버그
+pnpm ios:local / pnpm android:debug
+
+# 릴리스
+pnpm build:ios:prod:release / pnpm build:android:release
+
+```
+
+---
+
+## 📋 전체 프로젝트 파일 구조
+
+```
+src/
+├── assets/ # 정적 리소스
+│ ├── icons/ # 앱 아이콘 (apple, kakao, trophy 등)
+│ └── images/ # 이미지 리소스 (header, mypage, theme, tab 등)
+│
+├── config/ # 환경변수/런타임 설정
+│ └── ENV.ts # 환경변수 검증 및 관리
+│
+├── entities/ # 도메인 모델 (타입 정의)
+│ ├── user.ts # 사용자 정보 (User, CollegeMajor)
+│ ├── club.ts # 동아리 정보 (Club, ClubRanking, ReviewKeyword)
+│ ├── category.ts # 동아리 카테고리 (9개 카테고리별 색상/이미지)
+│ ├── review.ts # 리뷰 키워드 시스템
+│ ├── eventLog.ts # 사용자 행동 로깅 (view, click, expose)
+│ └── screen.ts # React Navigation 스크린 타입
+│
+├── repositories/ # 데이터 접근 계층 (자체 ORM)
+│ ├── auth.ts # 인증 (카카오/애플 로그인, 회원탈퇴)
+│ ├── category.ts # 카테고리 데이터 관리
+│ ├── club.ts # 동아리 CRUD (검색, 목록, 상세, 저장, 랭킹)
+│ ├── review.ts # 리뷰 및 평점 시스템
+│ └── user.ts # 사용자 관리 (프로필, 피드백, 학과목록)
+│
+├── features/ # 도메인별 화면 컴포넌트
+│ ├── club/screens/ # 동아리 도메인 화면
+│ │ ├── ClubDetailScreen/
+│ │ ├── ClubListScreen/
+│ │ ├── ClubRankingScreen/
+│ │ │ └── RankedClubs/
+│ │ ├── ClubReviewScreen/
+│ │ └── SearchResultClubListScreen/
+│ ├── home/screens/
+│ │ └── HomeScreen/ # 홈 화면 (Header, CategoryBoard, RecommendClubs)
+│ ├── mypage/screens/
+│ │ ├── EditProfileScreen/
+│ │ ├── MyPageScreen/
+│ │ └── SavedClubListScreen/
+│ └── webview/screens/
+│ └── WebviewScreen/
+│
+├── shared/ # 공통 모듈
+│ ├── components/ # 공통 UI 컴포넌트
+│ │ ├── AlertModal.tsx
+│ │ ├── Button.tsx
+│ │ ├── ClubListItem.tsx
+│ │ ├── HeaderProfile.tsx
+│ │ ├── HtmlView.tsx
+│ │ ├── LoginView.tsx
+│ │ ├── ManageClubView.tsx
+│ │ ├── TextField.tsx
+│ │ └── UserVoiceView.tsx
+│ ├── constants/ # 공통 상수
+│ │ ├── colors.ts
+│ │ ├── fixtures.ts
+│ │ └── localStorage.ts
+│ ├── contexts/ # React Context
+│ │ ├── loginBottomSheetContext.tsx
+│ │ ├── manageClubBottomSheet.tsx
+│ │ ├── profileContext.tsx
+│ │ ├── serviceContext.ts
+│ │ └── userVoiceBottomSheetContext.tsx
+│ ├── hocs/
+│ │ └── WithViewEventLog.tsx
+│ ├── hooks/
+│ │ ├── useClickEventLog.tsx
+│ │ └── useExposeEventLog.tsx
+│ └── utils/
+│ ├── api.ts
+│ └── navigation.ts
+│
+├── tabs/ # 탭 네비게이션
+│ ├── HomeTab.tsx
+│ ├── MyPageTab.tsx
+│ ├── RankingTab.tsx
+│ └── TabNavigator.tsx
+│
+└── usecases/ # 비즈니스 로직 계층
+ ├── auth.ts # 인증 비즈니스 로직
+ ├── category.ts # 카테고리 비즈니스 로직
+ ├── club.ts # 동아리 비즈니스 로직
+ ├── eventLog.ts # 이벤트 로깅 비즈니스 로직
+ ├── review.ts # 리뷰 비즈니스 로직
+ └── user.ts # 사용자 비즈니스 로직
+```
+
+### 파일 역할 요약
+
+**🔸 Domain Layer (entities/)**
+순수 TypeScript 타입으로 비즈니스 도메인 모델 정의
+
+**🔸 Data Layer (repositories/)**
+API 통신과 데이터 CRUD 담당. 자체 ORM 역할 수행
+
+**🔸 Business Layer (usecases/)**
+Repository를 조합한 비즈니스 로직. 의존성 주입 패턴
+
+**🔸 Presentation Layer (features/, shared/, tabs/)**
+UI 컴포넌트와 전역 상태 관리
+
+**🔸 Infrastructure (config/, shared/)**
+HTTP 클라이언트, 환경설정, 공통 유틸리티
+
+**🔸 Cross-Cutting (hooks/, hocs/)**
+이벤트 로깅 시스템과 재사용 로직
diff --git a/android/app/build.gradle b/apps/mobile/android/app/build.gradle
similarity index 89%
rename from android/app/build.gradle
rename to apps/mobile/android/app/build.gradle
index 080af27b..f709ed74 100644
--- a/android/app/build.gradle
+++ b/apps/mobile/android/app/build.gradle
@@ -11,7 +11,13 @@ project.ext.envConfigFiles = [
release: ".env.prod",
]
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
-apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
+
+// The vector-icons Gradle script defaults to apps/mobile/node_modules.
+// In this monorepo dependencies live at the repository root.
+project.ext.vectoricons = [
+ iconFontsDir: file("../../../../node_modules/react-native-vector-icons/Fonts"),
+]
+apply from: file("../../../../node_modules/react-native-vector-icons/fonts.gradle")
/**
* This is the configuration block to customize your React Native Android app.
@@ -21,14 +27,11 @@ react {
autolinkLibrariesWithApp()
/* Folders */
- // The root of your project, i.e. where "package.json" lives. Default is '..'
- // root = file("../")
- // The folder where the react-native NPM package is. Default is ../node_modules/react-native
- // reactNativeDir = file("../node_modules/react-native")
- // The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen
- // codegenDir = file("../node_modules/react-native-codegen")
- // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js
- // cliFile = file("../node_modules/react-native/cli.js")
+ root = file("../..")
+ reactNativeDir = file("../../../../node_modules/react-native")
+ codegenDir = file("../../../../node_modules/@react-native/codegen")
+ cliFile = file("../../../../node_modules/react-native/cli.js")
+ hermesCommand = file("../../../../node_modules/react-native/sdks/hermesc/%OS-BIN%/hermesc").absolutePath
/* Variants */
// The list of variants to that are debuggable. For those we're going to
@@ -105,7 +108,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode appVersionCode
- versionName "3.0.0"
+ versionName "3.0.1"
manifestPlaceholders = [kakaoScheme: kakaoScheme]
resValue "string", "build_config_package", "com.padocorp.clubhouse"
resValue "string", "app_name", "올클"
diff --git a/android/app/proguard-rules.pro b/apps/mobile/android/app/proguard-rules.pro
similarity index 100%
rename from android/app/proguard-rules.pro
rename to apps/mobile/android/app/proguard-rules.pro
diff --git a/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml
similarity index 100%
rename from android/app/src/debug/AndroidManifest.xml
rename to apps/mobile/android/app/src/debug/AndroidManifest.xml
diff --git a/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml
similarity index 85%
rename from android/app/src/main/AndroidManifest.xml
rename to apps/mobile/android/app/src/main/AndroidManifest.xml
index 501d0e5d..d662f1d1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/apps/mobile/android/app/src/main/AndroidManifest.xml
@@ -2,18 +2,24 @@
+
+
+
diff --git a/android/app/src/main/assets/fonts/Pretendard-Black.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-Black.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-Black.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-Black.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-Bold.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-Bold.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-Bold.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-Bold.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-ExtraBold.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-ExtraBold.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-ExtraBold.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-ExtraBold.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-ExtraLight.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-ExtraLight.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-ExtraLight.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-ExtraLight.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-Light.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-Light.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-Light.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-Light.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-Medium.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-Medium.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-Medium.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-Medium.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-Regular.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-Regular.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-Regular.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-Regular.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-SemiBold.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-SemiBold.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-SemiBold.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-SemiBold.otf
diff --git a/android/app/src/main/assets/fonts/Pretendard-Thin.otf b/apps/mobile/android/app/src/main/assets/fonts/Pretendard-Thin.otf
similarity index 100%
rename from android/app/src/main/assets/fonts/Pretendard-Thin.otf
rename to apps/mobile/android/app/src/main/assets/fonts/Pretendard-Thin.otf
diff --git a/android/app/src/main/java/com/padocorp/clubhouse/MainActivity.kt b/apps/mobile/android/app/src/main/java/com/padocorp/clubhouse/MainActivity.kt
similarity index 84%
rename from android/app/src/main/java/com/padocorp/clubhouse/MainActivity.kt
rename to apps/mobile/android/app/src/main/java/com/padocorp/clubhouse/MainActivity.kt
index 582ec06f..e6507ca5 100644
--- a/android/app/src/main/java/com/padocorp/clubhouse/MainActivity.kt
+++ b/apps/mobile/android/app/src/main/java/com/padocorp/clubhouse/MainActivity.kt
@@ -1,5 +1,6 @@
package com.padocorp.clubhouse
+import android.content.Intent
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
@@ -23,6 +24,14 @@ class MainActivity : ReactActivity() {
*/
override fun getMainComponentName(): String = "clubhouse";
+ /**
+ * launchMode="singleTask"; Deep Link is passed with onNewIntent
+ */
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ }
+
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
diff --git a/android/app/src/main/java/com/padocorp/clubhouse/MainApplication.kt b/apps/mobile/android/app/src/main/java/com/padocorp/clubhouse/MainApplication.kt
similarity index 100%
rename from android/app/src/main/java/com/padocorp/clubhouse/MainApplication.kt
rename to apps/mobile/android/app/src/main/java/com/padocorp/clubhouse/MainApplication.kt
diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/apps/mobile/android/app/src/main/res/drawable/rn_edit_text_material.xml
similarity index 100%
rename from android/app/src/main/res/drawable/rn_edit_text_material.xml
rename to apps/mobile/android/app/src/main/res/drawable/rn_edit_text_material.xml
diff --git a/apps/mobile/android/app/src/main/res/drawable/splash.png b/apps/mobile/android/app/src/main/res/drawable/splash.png
new file mode 100644
index 00000000..54fa824a
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/drawable/splash.png differ
diff --git a/android/app/src/main/res/drawable/splashscreen.xml b/apps/mobile/android/app/src/main/res/drawable/splashscreen.xml
similarity index 100%
rename from android/app/src/main/res/drawable/splashscreen.xml
rename to apps/mobile/android/app/src/main/res/drawable/splashscreen.xml
diff --git a/android/app/src/main/res/layout/launch_screen.xml b/apps/mobile/android/app/src/main/res/layout/launch_screen.xml
similarity index 100%
rename from android/app/src/main/res/layout/launch_screen.xml
rename to apps/mobile/android/app/src/main/res/layout/launch_screen.xml
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
similarity index 100%
rename from android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
rename to apps/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..034f5ef8
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png
similarity index 100%
rename from android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png
rename to apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_back.png
diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png
new file mode 100644
index 00000000..ab1546e6
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher_adaptive_fore.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..24b0fb0b
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png
similarity index 100%
rename from android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png
rename to apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_back.png
diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png
new file mode 100644
index 00000000..c6bf4533
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher_adaptive_fore.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..d075bb16
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png
similarity index 100%
rename from android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png
rename to apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_back.png
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png
new file mode 100644
index 00000000..c27488a4
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..b7ac2bd2
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png
similarity index 100%
rename from android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png
rename to apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png
new file mode 100644
index 00000000..6d48c58b
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png differ
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..879ba2a7
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png
similarity index 100%
rename from android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png
rename to apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png
diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png
new file mode 100644
index 00000000..a4bce235
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png differ
diff --git a/apps/mobile/android/app/src/main/res/playstore-icon.png b/apps/mobile/android/app/src/main/res/playstore-icon.png
new file mode 100644
index 00000000..57446a40
Binary files /dev/null and b/apps/mobile/android/app/src/main/res/playstore-icon.png differ
diff --git a/android/app/src/main/res/values/colors.xml b/apps/mobile/android/app/src/main/res/values/colors.xml
similarity index 100%
rename from android/app/src/main/res/values/colors.xml
rename to apps/mobile/android/app/src/main/res/values/colors.xml
diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml
similarity index 100%
rename from android/app/src/main/res/values/ic_launcher_background.xml
rename to apps/mobile/android/app/src/main/res/values/ic_launcher_background.xml
diff --git a/android/app/src/main/res/values/strings.xml b/apps/mobile/android/app/src/main/res/values/strings.xml
similarity index 100%
rename from android/app/src/main/res/values/strings.xml
rename to apps/mobile/android/app/src/main/res/values/strings.xml
diff --git a/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml
similarity index 100%
rename from android/app/src/main/res/values/styles.xml
rename to apps/mobile/android/app/src/main/res/values/styles.xml
diff --git a/android/build.gradle b/apps/mobile/android/build.gradle
similarity index 76%
rename from android/build.gradle
rename to apps/mobile/android/build.gradle
index dd0f894a..3f6dce6c 100644
--- a/android/build.gradle
+++ b/apps/mobile/android/build.gradle
@@ -2,10 +2,10 @@
buildscript {
ext {
- buildToolsVersion = "34.0.0"
+ buildToolsVersion = "36.0.0"
minSdkVersion = 24
- compileSdkVersion = 35
- targetSdkVersion = 35
+ compileSdkVersion = 36
+ targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.0.21"
@@ -15,7 +15,7 @@ buildscript {
mavenCentral()
}
dependencies {
- classpath("com.android.tools.build:gradle")
+ classpath("com.android.tools.build:gradle:8.9.1")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
diff --git a/android/gradle.properties b/apps/mobile/android/gradle.properties
similarity index 100%
rename from android/gradle.properties
rename to apps/mobile/android/gradle.properties
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/apps/mobile/android/gradle/wrapper/gradle-wrapper.jar
similarity index 100%
rename from android/gradle/wrapper/gradle-wrapper.jar
rename to apps/mobile/android/gradle/wrapper/gradle-wrapper.jar
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties
similarity index 92%
rename from android/gradle/wrapper/gradle-wrapper.properties
rename to apps/mobile/android/gradle/wrapper/gradle-wrapper.properties
index a56b8902..50ab9ad2 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/android/gradlew b/apps/mobile/android/gradlew
similarity index 100%
rename from android/gradlew
rename to apps/mobile/android/gradlew
diff --git a/android/gradlew.bat b/apps/mobile/android/gradlew.bat
similarity index 100%
rename from android/gradlew.bat
rename to apps/mobile/android/gradlew.bat
diff --git a/android/settings.gradle b/apps/mobile/android/settings.gradle
similarity index 61%
rename from android/settings.gradle
rename to apps/mobile/android/settings.gradle
index 99b6632a..33a6d2c0 100644
--- a/android/settings.gradle
+++ b/apps/mobile/android/settings.gradle
@@ -1,5 +1,5 @@
pluginManagement {
- includeBuild("../node_modules/@react-native/gradle-plugin")
+ includeBuild("../../../node_modules/@react-native/gradle-plugin")
}
plugins {
@@ -13,7 +13,7 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
rootProject.name = "clubhouse"
include(":app")
-includeBuild("../node_modules/@react-native/gradle-plugin")
+includeBuild("../../../node_modules/@react-native/gradle-plugin")
include(":react-native-config")
-project(":react-native-config").projectDir = new File(rootProject.projectDir, "../node_modules/react-native-config/android")
\ No newline at end of file
+project(":react-native-config").projectDir = new File(rootProject.projectDir, "../../../node_modules/react-native-config/android")
diff --git a/app.json b/apps/mobile/app.json
similarity index 100%
rename from app.json
rename to apps/mobile/app.json
diff --git a/apps/mobile/babel.config.js b/apps/mobile/babel.config.js
new file mode 100644
index 00000000..8b0ed86a
--- /dev/null
+++ b/apps/mobile/babel.config.js
@@ -0,0 +1,16 @@
+module.exports = {
+ presets: ["module:@react-native/babel-preset"],
+ plugins: [
+ ["@babel/plugin-proposal-decorators", { legacy: true }],
+ [
+ "module-resolver",
+ {
+ extensions: [".js", ".jsx", ".ts", ".tsx", ".json"],
+ alias: {
+ "@": "./src",
+ },
+ },
+ ],
+ "react-native-reanimated/plugin",
+ ],
+};
diff --git a/apps/mobile/index.js b/apps/mobile/index.js
new file mode 100644
index 00000000..1a3fbd11
--- /dev/null
+++ b/apps/mobile/index.js
@@ -0,0 +1,11 @@
+import "react-native-gesture-handler";
+
+import { AppRegistry } from "react-native";
+import App from "./App";
+import { name as appName } from "./app.json";
+import "@/config/ENV";
+import "react-native-url-polyfill/auto";
+
+const app = App;
+
+AppRegistry.registerComponent(appName, () => app);
diff --git a/apps/mobile/ios/.ruby-version b/apps/mobile/ios/.ruby-version
new file mode 100644
index 00000000..9c25013d
--- /dev/null
+++ b/apps/mobile/ios/.ruby-version
@@ -0,0 +1 @@
+3.3.6
diff --git a/ios/.xcode.env b/apps/mobile/ios/.xcode.env
similarity index 100%
rename from ios/.xcode.env
rename to apps/mobile/ios/.xcode.env
diff --git a/ios/Fonts/Pretendard-Black.otf b/apps/mobile/ios/Fonts/Pretendard-Black.otf
similarity index 100%
rename from ios/Fonts/Pretendard-Black.otf
rename to apps/mobile/ios/Fonts/Pretendard-Black.otf
diff --git a/ios/Fonts/Pretendard-Bold.otf b/apps/mobile/ios/Fonts/Pretendard-Bold.otf
similarity index 100%
rename from ios/Fonts/Pretendard-Bold.otf
rename to apps/mobile/ios/Fonts/Pretendard-Bold.otf
diff --git a/ios/Fonts/Pretendard-ExtraBold.otf b/apps/mobile/ios/Fonts/Pretendard-ExtraBold.otf
similarity index 100%
rename from ios/Fonts/Pretendard-ExtraBold.otf
rename to apps/mobile/ios/Fonts/Pretendard-ExtraBold.otf
diff --git a/ios/Fonts/Pretendard-ExtraLight.otf b/apps/mobile/ios/Fonts/Pretendard-ExtraLight.otf
similarity index 100%
rename from ios/Fonts/Pretendard-ExtraLight.otf
rename to apps/mobile/ios/Fonts/Pretendard-ExtraLight.otf
diff --git a/ios/Fonts/Pretendard-Light.otf b/apps/mobile/ios/Fonts/Pretendard-Light.otf
similarity index 100%
rename from ios/Fonts/Pretendard-Light.otf
rename to apps/mobile/ios/Fonts/Pretendard-Light.otf
diff --git a/ios/Fonts/Pretendard-Medium.otf b/apps/mobile/ios/Fonts/Pretendard-Medium.otf
similarity index 100%
rename from ios/Fonts/Pretendard-Medium.otf
rename to apps/mobile/ios/Fonts/Pretendard-Medium.otf
diff --git a/ios/Fonts/Pretendard-Regular.otf b/apps/mobile/ios/Fonts/Pretendard-Regular.otf
similarity index 100%
rename from ios/Fonts/Pretendard-Regular.otf
rename to apps/mobile/ios/Fonts/Pretendard-Regular.otf
diff --git a/ios/Fonts/Pretendard-SemiBold.otf b/apps/mobile/ios/Fonts/Pretendard-SemiBold.otf
similarity index 100%
rename from ios/Fonts/Pretendard-SemiBold.otf
rename to apps/mobile/ios/Fonts/Pretendard-SemiBold.otf
diff --git a/ios/Fonts/Pretendard-Thin.otf b/apps/mobile/ios/Fonts/Pretendard-Thin.otf
similarity index 100%
rename from ios/Fonts/Pretendard-Thin.otf
rename to apps/mobile/ios/Fonts/Pretendard-Thin.otf
diff --git a/ios/Gemfile b/apps/mobile/ios/Gemfile
similarity index 100%
rename from ios/Gemfile
rename to apps/mobile/ios/Gemfile
diff --git a/ios/Gemfile.lock b/apps/mobile/ios/Gemfile.lock
similarity index 100%
rename from ios/Gemfile.lock
rename to apps/mobile/ios/Gemfile.lock
diff --git a/ios/Podfile b/apps/mobile/ios/Podfile
similarity index 78%
rename from ios/Podfile
rename to apps/mobile/ios/Podfile
index 7e6f4ca8..eecd9ebc 100644
--- a/ios/Podfile
+++ b/apps/mobile/ios/Podfile
@@ -1,5 +1,5 @@
-require_relative '../node_modules/react-native/scripts/react_native_pods'
-require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
+require_relative '../../../node_modules/react-native/scripts/react_native_pods'
+require_relative '../../../node_modules/@react-native-community/cli-platform-ios/native_modules'
platform :ios, '16.0'
prepare_react_native_project!
@@ -31,6 +31,7 @@ end
post_install do |installer|
react_native_post_install(
installer,
+ "../../../node_modules/react-native",
:mac_catalyst_enabled => false
)
@@ -38,6 +39,8 @@ post_install do |installer|
target.build_configurations.each do |config|
config.build_settings['RCT_NEW_ARCH_ENABLED'] = '0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
+ # Folly New.h + Xcode 26 clang sized-deallocation 충돌 패치
+ config.build_settings['OTHER_CPLUSPLUSFLAGS'] = '$(inherited) -fno-sized-deallocation'
end
end
diff --git a/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock
similarity index 80%
rename from ios/Podfile.lock
rename to apps/mobile/ios/Podfile.lock
index 338580a8..0f09d830 100644
--- a/ios/Podfile.lock
+++ b/apps/mobile/ios/Podfile.lock
@@ -1379,6 +1379,27 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
+ - react-native-image-picker (8.2.1):
+ - DoubleConversion
+ - glog
+ - hermes-engine
+ - RCT-Folly (= 2024.11.18.00)
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - Yoga
- react-native-render-html (6.3.4):
- React-Core
- react-native-safe-area-context (5.7.0):
@@ -2022,95 +2043,96 @@ PODS:
- Yoga (0.0.0)
DEPENDENCIES:
- - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`)
- - BVLinearGradient (from `../node_modules/react-native-linear-gradient`)
- - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`)
+ - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`)
+ - BVLinearGradient (from `../../../node_modules/react-native-linear-gradient`)
+ - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
+ - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
+ - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
- Firebase/Analytics
- FirebaseCore
- - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`)
- - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
+ - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`)
+ - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`)
- GoogleUtilities
- - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- - "kakao-login (from `../node_modules/@react-native-seoul/kakao-login`)"
- - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- - RCT-Folly/Fabric (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
- - RCTRequired (from `../node_modules/react-native/Libraries/Required`)
- - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
- - React (from `../node_modules/react-native/`)
- - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`)
- - React-Core (from `../node_modules/react-native/`)
- - React-Core/RCTWebSocket (from `../node_modules/react-native/`)
- - React-CoreModules (from `../node_modules/react-native/React/CoreModules`)
- - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`)
- - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`)
- - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
- - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
- - React-Fabric (from `../node_modules/react-native/ReactCommon`)
- - React-FabricComponents (from `../node_modules/react-native/ReactCommon`)
- - React-FabricImage (from `../node_modules/react-native/ReactCommon`)
- - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`)
- - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
- - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`)
- - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`)
- - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
- - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
- - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`)
- - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`)
- - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`)
- - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`)
- - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`)
- - React-logger (from `../node_modules/react-native/ReactCommon/logger`)
- - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
- - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- - "react-native-blur (from `../node_modules/@react-native-community/blur`)"
- - react-native-config (from `../node_modules/react-native-config`)
- - react-native-render-html (from `../node_modules/react-native-render-html`)
- - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- - react-native-splash-screen (from `../node_modules/react-native-splash-screen`)
- - react-native-webview (from `../node_modules/react-native-webview`)
- - React-nativeconfig (from `../node_modules/react-native/ReactCommon`)
- - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`)
- - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
- - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`)
- - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`)
- - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`)
- - React-RCTFabric (from `../node_modules/react-native/React`)
- - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`)
- - React-RCTImage (from `../node_modules/react-native/Libraries/Image`)
- - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`)
- - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`)
- - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`)
- - React-RCTText (from `../node_modules/react-native/Libraries/Text`)
- - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`)
- - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`)
- - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`)
- - React-rncore (from `../node_modules/react-native/ReactCommon`)
- - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
- - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`)
- - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`)
- - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`)
- - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
- - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`)
- - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`)
+ - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
+ - "kakao-login (from `../../../node_modules/@react-native-seoul/kakao-login`)"
+ - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
+ - RCT-Folly/Fabric (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
+ - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)
+ - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`)
+ - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`)
+ - React (from `../../../node_modules/react-native/`)
+ - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`)
+ - React-Core (from `../../../node_modules/react-native/`)
+ - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`)
+ - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`)
+ - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`)
+ - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`)
+ - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`)
+ - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`)
+ - React-Fabric (from `../../../node_modules/react-native/ReactCommon`)
+ - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`)
+ - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`)
+ - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`)
+ - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`)
+ - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`)
+ - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`)
+ - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`)
+ - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`)
+ - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`)
+ - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`)
+ - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`)
+ - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`)
+ - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`)
+ - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`)
+ - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
+ - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
+ - "react-native-blur (from `../../../node_modules/@react-native-community/blur`)"
+ - react-native-config (from `../../../node_modules/react-native-config`)
+ - react-native-image-picker (from `../../../node_modules/react-native-image-picker`)
+ - react-native-render-html (from `../../../node_modules/react-native-render-html`)
+ - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
+ - react-native-splash-screen (from `../../../node_modules/react-native-splash-screen`)
+ - react-native-webview (from `../../../node_modules/react-native-webview`)
+ - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
+ - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
+ - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`)
+ - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`)
+ - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`)
+ - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`)
+ - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`)
+ - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`)
+ - React-RCTFabric (from `../../../node_modules/react-native/React`)
+ - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`)
+ - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`)
+ - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`)
+ - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`)
+ - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`)
+ - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`)
+ - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`)
+ - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`)
+ - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`)
+ - React-rncore (from `../../../node_modules/react-native/ReactCommon`)
+ - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`)
+ - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`)
+ - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`)
+ - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`)
+ - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`)
+ - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`)
+ - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`)
- ReactAppDependencyProvider (from `build/generated/ios`)
- ReactCodegen (from `build/generated/ios`)
- - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- - "RNAppleAuthentication (from `../node_modules/@invertase/react-native-apple-authentication`)"
- - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
- - "RNCMaskedView (from `../node_modules/@react-native-masked-view/masked-view`)"
- - RNDeviceInfo (from `../node_modules/react-native-device-info`)
- - "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)"
- - "RNFBApp (from `../node_modules/@react-native-firebase/app`)"
- - RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- - RNReanimated (from `../node_modules/react-native-reanimated`)
- - RNScreens (from `../node_modules/react-native-screens`)
- - RNStoreReview (from `../node_modules/react-native-store-review`)
- - Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
+ - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
+ - "RNAppleAuthentication (from `../../../node_modules/@invertase/react-native-apple-authentication`)"
+ - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
+ - "RNCMaskedView (from `../../../node_modules/@react-native-masked-view/masked-view`)"
+ - RNDeviceInfo (from `../../../node_modules/react-native-device-info`)
+ - "RNFBAnalytics (from `../../../node_modules/@react-native-firebase/analytics`)"
+ - "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)"
+ - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
+ - RNReanimated (from `../../../node_modules/react-native-reanimated`)
+ - RNScreens (from `../../../node_modules/react-native-screens`)
+ - RNStoreReview (from `../../../node_modules/react-native-store-review`)
+ - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
trunk:
@@ -2131,174 +2153,176 @@ SPEC REPOS:
EXTERNAL SOURCES:
boost:
- :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec"
+ :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec"
BVLinearGradient:
- :path: "../node_modules/react-native-linear-gradient"
+ :path: "../../../node_modules/react-native-linear-gradient"
DoubleConversion:
- :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
+ :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
fast_float:
- :podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec"
+ :podspec: "../../../node_modules/react-native/third-party-podspecs/fast_float.podspec"
FBLazyVector:
- :path: "../node_modules/react-native/Libraries/FBLazyVector"
+ :path: "../../../node_modules/react-native/Libraries/FBLazyVector"
fmt:
- :podspec: "../node_modules/react-native/third-party-podspecs/fmt.podspec"
+ :podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec"
glog:
- :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
+ :podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec"
hermes-engine:
- :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
+ :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
:tag: hermes-2024-11-25-RNv0.77.0-d4f25d534ab744866448b36ca3bf3d97c08e638c
kakao-login:
- :path: "../node_modules/@react-native-seoul/kakao-login"
+ :path: "../../../node_modules/@react-native-seoul/kakao-login"
RCT-Folly:
- :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
+ :podspec: "../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTDeprecation:
- :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
+ :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
RCTRequired:
- :path: "../node_modules/react-native/Libraries/Required"
+ :path: "../../../node_modules/react-native/Libraries/Required"
RCTTypeSafety:
- :path: "../node_modules/react-native/Libraries/TypeSafety"
+ :path: "../../../node_modules/react-native/Libraries/TypeSafety"
React:
- :path: "../node_modules/react-native/"
+ :path: "../../../node_modules/react-native/"
React-callinvoker:
- :path: "../node_modules/react-native/ReactCommon/callinvoker"
+ :path: "../../../node_modules/react-native/ReactCommon/callinvoker"
React-Core:
- :path: "../node_modules/react-native/"
+ :path: "../../../node_modules/react-native/"
React-CoreModules:
- :path: "../node_modules/react-native/React/CoreModules"
+ :path: "../../../node_modules/react-native/React/CoreModules"
React-cxxreact:
- :path: "../node_modules/react-native/ReactCommon/cxxreact"
+ :path: "../../../node_modules/react-native/ReactCommon/cxxreact"
React-debug:
- :path: "../node_modules/react-native/ReactCommon/react/debug"
+ :path: "../../../node_modules/react-native/ReactCommon/react/debug"
React-defaultsnativemodule:
- :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
+ :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults"
React-domnativemodule:
- :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom"
+ :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom"
React-Fabric:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
React-FabricComponents:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
React-FabricImage:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
React-featureflags:
- :path: "../node_modules/react-native/ReactCommon/react/featureflags"
+ :path: "../../../node_modules/react-native/ReactCommon/react/featureflags"
React-featureflagsnativemodule:
- :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
+ :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags"
React-graphics:
- :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics"
+ :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics"
React-hermes:
- :path: "../node_modules/react-native/ReactCommon/hermes"
+ :path: "../../../node_modules/react-native/ReactCommon/hermes"
React-idlecallbacksnativemodule:
- :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
+ :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks"
React-ImageManager:
- :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
+ :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios"
React-jserrorhandler:
- :path: "../node_modules/react-native/ReactCommon/jserrorhandler"
+ :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler"
React-jsi:
- :path: "../node_modules/react-native/ReactCommon/jsi"
+ :path: "../../../node_modules/react-native/ReactCommon/jsi"
React-jsiexecutor:
- :path: "../node_modules/react-native/ReactCommon/jsiexecutor"
+ :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor"
React-jsinspector:
- :path: "../node_modules/react-native/ReactCommon/jsinspector-modern"
+ :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern"
React-jsitracing:
- :path: "../node_modules/react-native/ReactCommon/hermes/executor/"
+ :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/"
React-logger:
- :path: "../node_modules/react-native/ReactCommon/logger"
+ :path: "../../../node_modules/react-native/ReactCommon/logger"
React-Mapbuffer:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
React-microtasksnativemodule:
- :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
+ :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-blur:
- :path: "../node_modules/@react-native-community/blur"
+ :path: "../../../node_modules/@react-native-community/blur"
react-native-config:
- :path: "../node_modules/react-native-config"
+ :path: "../../../node_modules/react-native-config"
+ react-native-image-picker:
+ :path: "../../../node_modules/react-native-image-picker"
react-native-render-html:
- :path: "../node_modules/react-native-render-html"
+ :path: "../../../node_modules/react-native-render-html"
react-native-safe-area-context:
- :path: "../node_modules/react-native-safe-area-context"
+ :path: "../../../node_modules/react-native-safe-area-context"
react-native-splash-screen:
- :path: "../node_modules/react-native-splash-screen"
+ :path: "../../../node_modules/react-native-splash-screen"
react-native-webview:
- :path: "../node_modules/react-native-webview"
+ :path: "../../../node_modules/react-native-webview"
React-nativeconfig:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
React-NativeModulesApple:
- :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
+ :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios"
React-perflogger:
- :path: "../node_modules/react-native/ReactCommon/reactperflogger"
+ :path: "../../../node_modules/react-native/ReactCommon/reactperflogger"
React-performancetimeline:
- :path: "../node_modules/react-native/ReactCommon/react/performance/timeline"
+ :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline"
React-RCTActionSheet:
- :path: "../node_modules/react-native/Libraries/ActionSheetIOS"
+ :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS"
React-RCTAnimation:
- :path: "../node_modules/react-native/Libraries/NativeAnimation"
+ :path: "../../../node_modules/react-native/Libraries/NativeAnimation"
React-RCTAppDelegate:
- :path: "../node_modules/react-native/Libraries/AppDelegate"
+ :path: "../../../node_modules/react-native/Libraries/AppDelegate"
React-RCTBlob:
- :path: "../node_modules/react-native/Libraries/Blob"
+ :path: "../../../node_modules/react-native/Libraries/Blob"
React-RCTFabric:
- :path: "../node_modules/react-native/React"
+ :path: "../../../node_modules/react-native/React"
React-RCTFBReactNativeSpec:
- :path: "../node_modules/react-native/React"
+ :path: "../../../node_modules/react-native/React"
React-RCTImage:
- :path: "../node_modules/react-native/Libraries/Image"
+ :path: "../../../node_modules/react-native/Libraries/Image"
React-RCTLinking:
- :path: "../node_modules/react-native/Libraries/LinkingIOS"
+ :path: "../../../node_modules/react-native/Libraries/LinkingIOS"
React-RCTNetwork:
- :path: "../node_modules/react-native/Libraries/Network"
+ :path: "../../../node_modules/react-native/Libraries/Network"
React-RCTSettings:
- :path: "../node_modules/react-native/Libraries/Settings"
+ :path: "../../../node_modules/react-native/Libraries/Settings"
React-RCTText:
- :path: "../node_modules/react-native/Libraries/Text"
+ :path: "../../../node_modules/react-native/Libraries/Text"
React-RCTVibration:
- :path: "../node_modules/react-native/Libraries/Vibration"
+ :path: "../../../node_modules/react-native/Libraries/Vibration"
React-rendererconsistency:
- :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency"
+ :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency"
React-rendererdebug:
- :path: "../node_modules/react-native/ReactCommon/react/renderer/debug"
+ :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug"
React-rncore:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
React-RuntimeApple:
- :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
+ :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios"
React-RuntimeCore:
- :path: "../node_modules/react-native/ReactCommon/react/runtime"
+ :path: "../../../node_modules/react-native/ReactCommon/react/runtime"
React-runtimeexecutor:
- :path: "../node_modules/react-native/ReactCommon/runtimeexecutor"
+ :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor"
React-RuntimeHermes:
- :path: "../node_modules/react-native/ReactCommon/react/runtime"
+ :path: "../../../node_modules/react-native/ReactCommon/react/runtime"
React-runtimescheduler:
- :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
+ :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler"
React-timing:
- :path: "../node_modules/react-native/ReactCommon/react/timing"
+ :path: "../../../node_modules/react-native/ReactCommon/react/timing"
React-utils:
- :path: "../node_modules/react-native/ReactCommon/react/utils"
+ :path: "../../../node_modules/react-native/ReactCommon/react/utils"
ReactAppDependencyProvider:
:path: build/generated/ios
ReactCodegen:
:path: build/generated/ios
ReactCommon:
- :path: "../node_modules/react-native/ReactCommon"
+ :path: "../../../node_modules/react-native/ReactCommon"
RNAppleAuthentication:
- :path: "../node_modules/@invertase/react-native-apple-authentication"
+ :path: "../../../node_modules/@invertase/react-native-apple-authentication"
RNCAsyncStorage:
- :path: "../node_modules/@react-native-async-storage/async-storage"
+ :path: "../../../node_modules/@react-native-async-storage/async-storage"
RNCMaskedView:
- :path: "../node_modules/@react-native-masked-view/masked-view"
+ :path: "../../../node_modules/@react-native-masked-view/masked-view"
RNDeviceInfo:
- :path: "../node_modules/react-native-device-info"
+ :path: "../../../node_modules/react-native-device-info"
RNFBAnalytics:
- :path: "../node_modules/@react-native-firebase/analytics"
+ :path: "../../../node_modules/@react-native-firebase/analytics"
RNFBApp:
- :path: "../node_modules/@react-native-firebase/app"
+ :path: "../../../node_modules/@react-native-firebase/app"
RNGestureHandler:
- :path: "../node_modules/react-native-gesture-handler"
+ :path: "../../../node_modules/react-native-gesture-handler"
RNReanimated:
- :path: "../node_modules/react-native-reanimated"
+ :path: "../../../node_modules/react-native-reanimated"
RNScreens:
- :path: "../node_modules/react-native-screens"
+ :path: "../../../node_modules/react-native-screens"
RNStoreReview:
- :path: "../node_modules/react-native-store-review"
+ :path: "../../../node_modules/react-native-store-review"
Yoga:
- :path: "../node_modules/react-native/ReactCommon/yoga"
+ :path: "../../../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
Alamofire: f36a35757af4587d8e4f4bfa223ad10be2422b8c
@@ -2354,6 +2378,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 3b784cf40f7c3c9500b9a5f9e4eec5c9bbfbef8f
react-native-blur: c4d838e8cd2896379328aec8a600c6ddad589c78
react-native-config: 1340fe63a5fb3f3d5a44b50838d72f000aac5b7f
+ react-native-image-picker: 84b261e109faa7312bca59f1f65c8f2b22865496
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: e88b83d859a7e849a9defbcf33d125cbfb45c98e
react-native-splash-screen: 95994222cc95c236bd3cdc59fe45ed5f27969594
@@ -2385,7 +2410,7 @@ SPEC CHECKSUMS:
React-timing: 7d80ad4297c57ec4cb49da4a5db1b82f93fedb2a
React-utils: 6b632afea2c4ec4b782497fb61af859a5b1b355f
ReactAppDependencyProvider: 31015410a4a53b9fd0a908ad4d6e3e2b9a25086a
- ReactCodegen: eac5d74d85dff515b48a5c36f154bc4128f217e6
+ ReactCodegen: 0bc214e29416a2fe0589237d7f93018e8b3a6f3d
ReactCommon: bf4612cba0fa356b529385029f470d5529dddde4
RNAppleAuthentication: a89c9804592b38ed4ab11f0aee68d05ba12ad432
RNCAsyncStorage: dc5ca6aeca46e73f963ea4f79e222838c2d186cb
@@ -2394,12 +2419,12 @@ SPEC CHECKSUMS:
RNFBAnalytics: f12c83fab147fea9b422524c0a1d9548d908ba90
RNFBApp: 0e66b9f844efdf2ac3fa2b30e64c9db41a263b3d
RNGestureHandler: 70069ab3e0431b03f6e465b65745f87a1a02c6c0
- RNReanimated: 447ba931b1e28efbc550ccfcbaa8f9b339f83fb7
+ RNReanimated: 4a80c7af3b56644d52ec512cba05064c77a27b15
RNScreens: 40f4d6b58fa33751b8b2b5450e3595898a993ad5
RNStoreReview: f1df4978ae22ec08d9c62d59437a1d5f056d0ccf
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 92f3bb322c40a86b7233b815854730442e01b8c4
-PODFILE CHECKSUM: f8fdb74b66739001b150dd6358b030dda7f78687
+PODFILE CHECKSUM: 5a519a5ee4f2eecfed9201ffda602999cd50887f
COCOAPODS: 1.16.2
diff --git a/ios/PrivacyInfo.xcprivacy b/apps/mobile/ios/PrivacyInfo.xcprivacy
similarity index 98%
rename from ios/PrivacyInfo.xcprivacy
rename to apps/mobile/ios/PrivacyInfo.xcprivacy
index ee65c9d0..685bbd1f 100644
--- a/ios/PrivacyInfo.xcprivacy
+++ b/apps/mobile/ios/PrivacyInfo.xcprivacy
@@ -29,6 +29,7 @@
NSPrivacyAccessedAPITypeReasons
C617.1
+ 3B52.1
diff --git a/ios/SplashScreen.storyboard b/apps/mobile/ios/SplashScreen.storyboard
similarity index 100%
rename from ios/SplashScreen.storyboard
rename to apps/mobile/ios/SplashScreen.storyboard
diff --git a/ios/SwiftBridge.swift b/apps/mobile/ios/SwiftBridge.swift
similarity index 100%
rename from ios/SwiftBridge.swift
rename to apps/mobile/ios/SwiftBridge.swift
diff --git a/ios/clubhouse-Bridging-Header.h b/apps/mobile/ios/clubhouse-Bridging-Header.h
similarity index 100%
rename from ios/clubhouse-Bridging-Header.h
rename to apps/mobile/ios/clubhouse-Bridging-Header.h
diff --git a/ios/clubhouse.debug.xcconfig b/apps/mobile/ios/clubhouse.debug.xcconfig
similarity index 100%
rename from ios/clubhouse.debug.xcconfig
rename to apps/mobile/ios/clubhouse.debug.xcconfig
diff --git a/ios/clubhouse.local.xcconfig b/apps/mobile/ios/clubhouse.local.xcconfig
similarity index 100%
rename from ios/clubhouse.local.xcconfig
rename to apps/mobile/ios/clubhouse.local.xcconfig
diff --git a/ios/clubhouse.release.xcconfig b/apps/mobile/ios/clubhouse.release.xcconfig
similarity index 100%
rename from ios/clubhouse.release.xcconfig
rename to apps/mobile/ios/clubhouse.release.xcconfig
diff --git a/ios/clubhouse.staging.xcconfig b/apps/mobile/ios/clubhouse.staging.xcconfig
similarity index 100%
rename from ios/clubhouse.staging.xcconfig
rename to apps/mobile/ios/clubhouse.staging.xcconfig
diff --git a/ios/clubhouse.xcodeproj/project.pbxproj b/apps/mobile/ios/clubhouse.xcodeproj/project.pbxproj
similarity index 95%
rename from ios/clubhouse.xcodeproj/project.pbxproj
rename to apps/mobile/ios/clubhouse.xcodeproj/project.pbxproj
index 22682547..bb83e979 100644
--- a/ios/clubhouse.xcodeproj/project.pbxproj
+++ b/apps/mobile/ios/clubhouse.xcodeproj/project.pbxproj
@@ -79,22 +79,22 @@
6D1A1EAC2B55454E00E909F1 /* SwiftBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBridge.swift; sourceTree = ""; };
6D2E94712B7219B500CABF12 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; };
6D2E9A6F2BC269BD00508824 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; };
- 6D2F04292A7F6D6100E8A8B8 /* FontAwesome.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf"; sourceTree = ""; };
- 6D2F042A2A7F6D6100E8A8B8 /* MaterialIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialIcons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf"; sourceTree = ""; };
- 6D2F042B2A7F6D6100E8A8B8 /* Zocial.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Zocial.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf"; sourceTree = ""; };
- 6D2F042C2A7F6D6100E8A8B8 /* Foundation.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Foundation.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf"; sourceTree = ""; };
- 6D2F042D2A7F6D6100E8A8B8 /* FontAwesome5_Solid.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome5_Solid.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf"; sourceTree = ""; };
- 6D2F042E2A7F6D6200E8A8B8 /* SimpleLineIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = SimpleLineIcons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf"; sourceTree = ""; };
- 6D2F042F2A7F6D6200E8A8B8 /* FontAwesome5_Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome5_Regular.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf"; sourceTree = ""; };
- 6D2F04302A7F6D6200E8A8B8 /* Fontisto.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Fontisto.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf"; sourceTree = ""; };
- 6D2F04312A7F6D6200E8A8B8 /* Feather.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Feather.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Feather.ttf"; sourceTree = ""; };
- 6D2F04322A7F6D6300E8A8B8 /* FontAwesome5_Brands.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome5_Brands.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf"; sourceTree = ""; };
- 6D2F04332A7F6D6300E8A8B8 /* AntDesign.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = AntDesign.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf"; sourceTree = ""; };
- 6D2F04342A7F6D6300E8A8B8 /* EvilIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = EvilIcons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf"; sourceTree = ""; };
- 6D2F04352A7F6D6300E8A8B8 /* Ionicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Ionicons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf"; sourceTree = ""; };
- 6D2F04362A7F6D6400E8A8B8 /* MaterialCommunityIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialCommunityIcons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf"; sourceTree = ""; };
- 6D2F04372A7F6D6400E8A8B8 /* Entypo.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Entypo.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf"; sourceTree = ""; };
- 6D2F04382A7F6D6400E8A8B8 /* Octicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Octicons.ttf; path = "../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf"; sourceTree = ""; };
+ 6D2F04292A7F6D6100E8A8B8 /* FontAwesome.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf"; sourceTree = ""; };
+ 6D2F042A2A7F6D6100E8A8B8 /* MaterialIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialIcons.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf"; sourceTree = ""; };
+ 6D2F042B2A7F6D6100E8A8B8 /* Zocial.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Zocial.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf"; sourceTree = ""; };
+ 6D2F042C2A7F6D6100E8A8B8 /* Foundation.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Foundation.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf"; sourceTree = ""; };
+ 6D2F042D2A7F6D6100E8A8B8 /* FontAwesome5_Solid.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome5_Solid.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf"; sourceTree = ""; };
+ 6D2F042E2A7F6D6200E8A8B8 /* SimpleLineIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = SimpleLineIcons.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf"; sourceTree = ""; };
+ 6D2F042F2A7F6D6200E8A8B8 /* FontAwesome5_Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome5_Regular.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf"; sourceTree = ""; };
+ 6D2F04302A7F6D6200E8A8B8 /* Fontisto.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Fontisto.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf"; sourceTree = ""; };
+ 6D2F04312A7F6D6200E8A8B8 /* Feather.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Feather.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Feather.ttf"; sourceTree = ""; };
+ 6D2F04322A7F6D6300E8A8B8 /* FontAwesome5_Brands.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FontAwesome5_Brands.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf"; sourceTree = ""; };
+ 6D2F04332A7F6D6300E8A8B8 /* AntDesign.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = AntDesign.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf"; sourceTree = ""; };
+ 6D2F04342A7F6D6300E8A8B8 /* EvilIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = EvilIcons.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf"; sourceTree = ""; };
+ 6D2F04352A7F6D6300E8A8B8 /* Ionicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Ionicons.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf"; sourceTree = ""; };
+ 6D2F04362A7F6D6400E8A8B8 /* MaterialCommunityIcons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = MaterialCommunityIcons.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf"; sourceTree = ""; };
+ 6D2F04372A7F6D6400E8A8B8 /* Entypo.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Entypo.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf"; sourceTree = ""; };
+ 6D2F04382A7F6D6400E8A8B8 /* Octicons.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = Octicons.ttf; path = "../../../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf"; sourceTree = ""; };
6D3B2A7A2B35627700159C01 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; };
6D3B2A7C2B35627800159C01 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; };
6D46EDD02B4ED4EC00484EA6 /* clubhouseRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = clubhouseRelease.entitlements; path = clubhouse/clubhouseRelease.entitlements; sourceTree = ""; };
@@ -417,7 +417,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "set -e\n\nWITH_ENVIRONMENT=\"../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../node_modules/react-native/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n";
+ shellScript = "set -e\n\nWITH_ENVIRONMENT=\"../../../node_modules/react-native/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"../../../node_modules/react-native/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"\n";
};
15F25258181A01DE68773794 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@@ -471,14 +471,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse/Pods-clubhouse-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse/Pods-clubhouse-resources-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-clubhouse/Pods-clubhouse-resources.sh\"\n";
@@ -492,14 +488,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse-clubhouseTests/Pods-clubhouse-clubhouseTests-resources-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse-clubhouseTests/Pods-clubhouse-clubhouseTests-resources-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-clubhouse-clubhouseTests/Pods-clubhouse-clubhouseTests-resources.sh\"\n";
@@ -514,8 +506,6 @@
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "[CP-User] [RNFB] Core Configuration";
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n";
@@ -528,14 +518,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse/Pods-clubhouse-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse/Pods-clubhouse-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-clubhouse/Pods-clubhouse-frameworks.sh\"\n";
@@ -549,14 +535,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse-clubhouseTests/Pods-clubhouse-clubhouseTests-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
- inputPaths = (
- );
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-clubhouse-clubhouseTests/Pods-clubhouse-clubhouseTests-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
- outputPaths = (
- );
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-clubhouse-clubhouseTests/Pods-clubhouse-clubhouseTests-frameworks.sh\"\n";
@@ -578,7 +560,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n";
+ shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../../../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../../../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
@@ -688,7 +670,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K9883YB4VR;
ENABLE_BITCODE = NO;
@@ -700,7 +682,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION=3.0.0;
+ MARKETING_VERSION = 3.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -727,7 +709,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K9883YB4VR;
INFOPLIST_FILE = clubhouse/Info.plist;
@@ -738,7 +720,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION=3.0.0;
+ MARKETING_VERSION = 3.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -825,7 +807,7 @@
"-DFOLLY_USE_LIBCPP=1",
);
OTHER_LDFLAGS = "$(inherited)";
- REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
USE_HERMES = true;
@@ -895,7 +877,7 @@
"-DFOLLY_USE_LIBCPP=1",
);
OTHER_LDFLAGS = "$(inherited)";
- REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
@@ -965,7 +947,7 @@
"-DFOLLY_USE_LIBCPP=1",
);
OTHER_LDFLAGS = "$(inherited)";
- REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
VALIDATE_PRODUCT = YES;
@@ -982,7 +964,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K9883YB4VR;
INFOPLIST_FILE = clubhouse/Info.plist;
@@ -993,7 +975,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION=3.0.0;
+ MARKETING_VERSION = 3.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -1105,7 +1087,7 @@
"-DFOLLY_USE_LIBCPP=1",
);
OTHER_LDFLAGS = "$(inherited)";
- REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
+ REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
SDKROOT = iphoneos;
USE_HERMES = true;
};
@@ -1121,7 +1103,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 15;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K9883YB4VR;
ENABLE_BITCODE = NO;
@@ -1133,7 +1115,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION=3.0.0;
+ MARKETING_VERSION = 3.0.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/ios/clubhouse.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/clubhouse.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
similarity index 100%
rename from ios/clubhouse.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
rename to apps/mobile/ios/clubhouse.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
diff --git a/ios/clubhouse.xcodeproj/xcshareddata/xcschemes/Local.xcscheme b/apps/mobile/ios/clubhouse.xcodeproj/xcshareddata/xcschemes/Local.xcscheme
similarity index 87%
rename from ios/clubhouse.xcodeproj/xcshareddata/xcschemes/Local.xcscheme
rename to apps/mobile/ios/clubhouse.xcodeproj/xcshareddata/xcschemes/Local.xcscheme
index 00f6f6ec..f2b9ff68 100644
--- a/ios/clubhouse.xcodeproj/xcshareddata/xcschemes/Local.xcscheme
+++ b/apps/mobile/ios/clubhouse.xcodeproj/xcshareddata/xcschemes/Local.xcscheme
@@ -10,7 +10,7 @@
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
+ scriptText = "echo ".env.local" > /tmp/envfile
touch "${PROJECT_DIR}/../../../node_modules/react-native-config/ios/ReactNativeConfig/BuildDotenvConfig.rb"
"${PROJECT_DIR}/../../../node_modules/react-native-config/ios/ReactNativeConfig/BuildXCConfig.rb" "${PROJECT_DIR}/.." "${PROJECT_DIR}/tmp.xcconfig"
">
+ scriptText = "echo ".env.local" > /tmp/envfile
touch "${PROJECT_DIR}/../../../node_modules/react-native-config/ios/ReactNativeConfig/BuildDotenvConfig.rb"
"${PROJECT_DIR}/../../../node_modules/react-native-config/ios/ReactNativeConfig/BuildXCConfig.rb" "${PROJECT_DIR}/.." "${PROJECT_DIR}/tmp.xcconfig"
">
+ scriptText = "if [ -f "${PROJECT_DIR}/../.env.staging" ]; then
echo ".env.staging" > /tmp/envfile
else
echo ".env.prod" > /tmp/envfile
fi
"${PROJECT_DIR}/../../../node_modules/react-native-config/ios/ReactNativeConfig/BuildXCConfig.rb" "${PROJECT_DIR}/.." "${PROJECT_DIR}/tmp.xcconfig"
">
+ scriptText = "echo ".env.prod" > /tmp/envfile
"${PROJECT_DIR}/../../../node_modules/react-native-config/ios/ReactNativeConfig/BuildXCConfig.rb" "${PROJECT_DIR}/.." "${PROJECT_DIR}/tmp.xcconfig"
">
NSLocationWhenInUseUsageDescription
+ NSPhotoLibraryUsageDescription
+ 동아리 대표 이미지, 공고 이미지 및 활동 사진을 등록하기 위해 사진 보관함 접근 권한이 필요해요
+ NSPhotoLibraryAddUsageDescription
+ 공고 이미지를 저장하기 위해 사진 라이브러리에 접근합니다.
UIAppFonts
AntDesign.ttf
diff --git a/ios/clubhouse/PrivacyInfo.xcprivacy b/apps/mobile/ios/clubhouse/PrivacyInfo.xcprivacy
similarity index 100%
rename from ios/clubhouse/PrivacyInfo.xcprivacy
rename to apps/mobile/ios/clubhouse/PrivacyInfo.xcprivacy
diff --git a/ios/clubhouse/clubhouse.entitlements b/apps/mobile/ios/clubhouse/clubhouse.entitlements
similarity index 100%
rename from ios/clubhouse/clubhouse.entitlements
rename to apps/mobile/ios/clubhouse/clubhouse.entitlements
diff --git a/ios/clubhouse/clubhouseDebug.entitlements b/apps/mobile/ios/clubhouse/clubhouseDebug.entitlements
similarity index 100%
rename from ios/clubhouse/clubhouseDebug.entitlements
rename to apps/mobile/ios/clubhouse/clubhouseDebug.entitlements
diff --git a/ios/clubhouse/clubhouseLocal.entitlements b/apps/mobile/ios/clubhouse/clubhouseLocal.entitlements
similarity index 100%
rename from ios/clubhouse/clubhouseLocal.entitlements
rename to apps/mobile/ios/clubhouse/clubhouseLocal.entitlements
diff --git a/ios/clubhouse/clubhouseRelease.entitlements b/apps/mobile/ios/clubhouse/clubhouseRelease.entitlements
similarity index 100%
rename from ios/clubhouse/clubhouseRelease.entitlements
rename to apps/mobile/ios/clubhouse/clubhouseRelease.entitlements
diff --git a/ios/clubhouse/main.m b/apps/mobile/ios/clubhouse/main.m
similarity index 100%
rename from ios/clubhouse/main.m
rename to apps/mobile/ios/clubhouse/main.m
diff --git a/ios/en.lproj/InfoPlist.strings b/apps/mobile/ios/en.lproj/InfoPlist.strings
similarity index 100%
rename from ios/en.lproj/InfoPlist.strings
rename to apps/mobile/ios/en.lproj/InfoPlist.strings
diff --git a/ios/ko.lproj/InfoPlist.strings b/apps/mobile/ios/ko.lproj/InfoPlist.strings
similarity index 100%
rename from ios/ko.lproj/InfoPlist.strings
rename to apps/mobile/ios/ko.lproj/InfoPlist.strings
diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js
new file mode 100644
index 00000000..3f146a2c
--- /dev/null
+++ b/apps/mobile/metro.config.js
@@ -0,0 +1,38 @@
+/**
+ * Metro configuration for React Native
+ * https://github.com/facebook/react-native
+ *
+ * @format
+ */
+
+const path = require("node:path");
+const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
+
+const defaultConfig = getDefaultConfig(__dirname);
+const workspaceRoot = path.resolve(__dirname, "../..");
+
+const {
+ resolver: { sourceExts, assetExts },
+} = getDefaultConfig(__dirname);
+
+const config = {
+ transformer: {
+ getTransformOptions: async () => ({
+ transform: {
+ experimentalImportSupport: false,
+ inlineRequires: true,
+ },
+ }),
+ },
+ resolver: {
+ assetExts: assetExts.filter((ext) => ext !== "svg"),
+ nodeModulesPaths: [
+ path.resolve(__dirname, "node_modules"),
+ path.resolve(workspaceRoot, "node_modules"),
+ ],
+ sourceExts: [...sourceExts, "svg"],
+ },
+ watchFolders: [workspaceRoot],
+};
+
+module.exports = mergeConfig(defaultConfig, config);
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
new file mode 100644
index 00000000..cd424c5a
--- /dev/null
+++ b/apps/mobile/package.json
@@ -0,0 +1,102 @@
+{
+ "name": "clubhouse-app",
+ "version": "1.0.0",
+ "private": true,
+ "packageManager": "pnpm@11.9.0",
+ "scripts": {
+ "clean": "rm -rf android/build && rm -rf android/app/build",
+ "re-install": "rm -rf node_modules && pnpm install",
+ "reset": "cd ios && pod deintegrate && cd ../android && ENVFILE=.env.local ./gradlew clean && cd .. && rm -rf node_modules && pnpm install && cd ios && pod install && cd ..",
+ "android:clean": "cd android && ENVFILE=.env.local ./gradlew clean && cd ..",
+ "android:debug": "ENVFILE=.env.local react-native run-android --mode=debug --appId=com.padocorp.clubhouse.applicationId.debug",
+ "android:release": "ENVFILE=.env.prod react-native run-android --mode=release --appId=com.padocorp.clubhouse.applicationId",
+ "ios": "react-native run-ios",
+ "ios:local": "ENVFILE=.env.local react-native run-ios --scheme \"Local\"",
+ "ios:prod": "ENVFILE=.env.prod react-native run-ios --scheme \"clubhouse\"",
+ "biome:check": "biome check .",
+ "biome:fix": "biome check --write .",
+ "format": "biome format --write .",
+ "format:check": "biome format .",
+ "lint": "biome lint .",
+ "test": "jest",
+ "typecheck": "tsc --noEmit",
+ "verify": "pnpm biome:check && pnpm typecheck && pnpm test --runInBand",
+ "verify:full": "pnpm verify && pnpm bundle:android && pnpm bundle:ios",
+ "bundle:android": "react-native bundle --platform android --dev true --entry-file index.js --bundle-output .bundle-check/android.bundle --assets-dest .bundle-check/android",
+ "bundle:ios": "react-native bundle --platform ios --dev true --entry-file index.js --bundle-output .bundle-check/ios.bundle --assets-dest .bundle-check/ios",
+ "fix": "biome check --write .",
+ "start": "react-native start",
+ "build:android:debug": "cd android && ENVFILE=.env.local ./gradlew assembleDebug",
+ "build:android:release": "cd android && ENVFILE=.env.prod ./gradlew bundleRelease",
+ "build:ios:prod:release": "ENVFILE=.env.prod react-native run-ios --configuration Release",
+ "postinstall": "cd ../.. && patch-package --patch-dir apps/mobile/patches",
+ "prepare": "cd ../.. && husky apps/mobile/.husky"
+ },
+ "dependencies": {
+ "@babel/plugin-proposal-decorators": "^7.21.0",
+ "@gorhom/bottom-sheet": "^5.2.14",
+ "@homielab/react-native-auto-scroll": "^1.1.2",
+ "@invertase/react-native-apple-authentication": "^2.3.0",
+ "@react-native-async-storage/async-storage": "^1.17.11",
+ "@react-native-community/blur": "^4.3.2",
+ "@react-native-firebase/analytics": "^20.3.0",
+ "@react-native-firebase/app": "^20.3.0",
+ "@react-native-masked-view/masked-view": "^0.3.1",
+ "@react-native-seoul/kakao-login": "^5.4.1",
+ "@react-native/metro-config": "0.77.3",
+ "@react-navigation/bottom-tabs": "^6.5.12",
+ "@react-navigation/native": "^6.1.3",
+ "@react-navigation/native-stack": "^6.9.9",
+ "@tanstack/react-query": "^4.29.14",
+ "axios": "^1.6.2",
+ "dayjs": "^1.11.10",
+ "patch-package": "^8.0.0",
+ "react": "18.2.0",
+ "react-native": "0.77.3",
+ "react-native-config": "^1.5.0",
+ "react-native-device-info": "14",
+ "react-native-dropdown-picker": "^5.4.6",
+ "react-native-elements": "^3.4.3",
+ "react-native-gesture-handler": "2.22.0",
+ "react-native-image-picker": "^8.2.1",
+ "react-native-linear-gradient": "^2.8.3",
+ "react-native-reanimated": "3.18.2",
+ "react-native-render-html": "^6.3.4",
+ "react-native-safe-area-context": "5.7.0",
+ "react-native-screens": "4.8.0",
+ "react-native-skeleton-placeholder": "^5.2.4",
+ "react-native-splash-screen": "^3.3.0",
+ "react-native-store-review": "^0.4.3",
+ "react-native-toast-message": "^2.2.0",
+ "react-native-vector-icons": "^10.3.0",
+ "react-native-webview": "13.16.1"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.20.0",
+ "@babel/preset-env": "^7.20.0",
+ "@babel/runtime": "^7.20.0",
+ "@react-native-community/cli": "15.0.1",
+ "@react-native-community/cli-platform-android": "15.0.1",
+ "@react-native-community/cli-platform-ios": "15.0.1",
+ "@tsconfig/react-native": "^2.0.2",
+ "@types/jest": "29.5.14",
+ "@types/react": "18.3.28",
+ "babel-plugin-module-resolver": "^5.0.0",
+ "husky": "^9.1.7",
+ "jest": "29.7.0",
+ "lint-staged": "^16",
+ "react-native-url-polyfill": "^2.0.0",
+ "typescript": "^5.9.3"
+ },
+ "jest": {
+ "preset": "react-native"
+ },
+ "engines": {
+ "node": ">=22.13"
+ },
+ "lint-staged": {
+ "*.{js,jsx,ts,tsx,json,jsonc}": [
+ "biome check --write --no-errors-on-unmatched"
+ ]
+ }
+}
diff --git a/apps/mobile/patches/README.md b/apps/mobile/patches/README.md
new file mode 100644
index 00000000..e7e30cbc
--- /dev/null
+++ b/apps/mobile/patches/README.md
@@ -0,0 +1,9 @@
+## PATCHES
+
+This patch directory contains a patch for the following library:
+
+- `react-native-dropdown-picker`: use Gesture Handler scrollables so dropdown lists handle
+ touch and scrolling correctly on Android.
+
+Remove this patch after verifying that the school and major dropdowns scroll correctly on a
+physical Android device without it.
diff --git a/patches/react-native-dropdown-picker+5.4.6.patch b/apps/mobile/patches/react-native-dropdown-picker+5.4.6.patch
similarity index 100%
rename from patches/react-native-dropdown-picker+5.4.6.patch
rename to apps/mobile/patches/react-native-dropdown-picker+5.4.6.patch
diff --git a/react-native.config.js b/apps/mobile/react-native.config.js
similarity index 80%
rename from react-native.config.js
rename to apps/mobile/react-native.config.js
index fcd2a11c..f15d758d 100644
--- a/react-native.config.js
+++ b/apps/mobile/react-native.config.js
@@ -5,10 +5,10 @@ module.exports = {
},
},
dependencies: {
- 'react-native-vector-icons': {
+ "react-native-vector-icons": {
platforms: {
ios: null,
},
},
},
-}
+};
diff --git a/src/assets/icons/apple.png b/apps/mobile/src/assets/icons/apple.png
similarity index 100%
rename from src/assets/icons/apple.png
rename to apps/mobile/src/assets/icons/apple.png
diff --git a/apps/mobile/src/assets/icons/category-title.png b/apps/mobile/src/assets/icons/category-title.png
new file mode 100644
index 00000000..fab49cc8
Binary files /dev/null and b/apps/mobile/src/assets/icons/category-title.png differ
diff --git a/src/assets/icons/category/academic.png b/apps/mobile/src/assets/icons/category/academic.png
similarity index 100%
rename from src/assets/icons/category/academic.png
rename to apps/mobile/src/assets/icons/category/academic.png
diff --git a/src/assets/icons/category/career.png b/apps/mobile/src/assets/icons/category/career.png
similarity index 100%
rename from src/assets/icons/category/career.png
rename to apps/mobile/src/assets/icons/category/career.png
diff --git a/src/assets/icons/category/culture.png b/apps/mobile/src/assets/icons/category/culture.png
similarity index 100%
rename from src/assets/icons/category/culture.png
rename to apps/mobile/src/assets/icons/category/culture.png
diff --git a/src/assets/icons/category/hobby.png b/apps/mobile/src/assets/icons/category/hobby.png
similarity index 100%
rename from src/assets/icons/category/hobby.png
rename to apps/mobile/src/assets/icons/category/hobby.png
diff --git a/src/assets/icons/category/performance.png b/apps/mobile/src/assets/icons/category/performance.png
similarity index 100%
rename from src/assets/icons/category/performance.png
rename to apps/mobile/src/assets/icons/category/performance.png
diff --git a/src/assets/icons/category/promotion.png b/apps/mobile/src/assets/icons/category/promotion.png
similarity index 100%
rename from src/assets/icons/category/promotion.png
rename to apps/mobile/src/assets/icons/category/promotion.png
diff --git a/src/assets/icons/category/religion.png b/apps/mobile/src/assets/icons/category/religion.png
similarity index 100%
rename from src/assets/icons/category/religion.png
rename to apps/mobile/src/assets/icons/category/religion.png
diff --git a/src/assets/icons/category/sports.png b/apps/mobile/src/assets/icons/category/sports.png
similarity index 100%
rename from src/assets/icons/category/sports.png
rename to apps/mobile/src/assets/icons/category/sports.png
diff --git a/src/assets/icons/category/volunteer.png b/apps/mobile/src/assets/icons/category/volunteer.png
similarity index 100%
rename from src/assets/icons/category/volunteer.png
rename to apps/mobile/src/assets/icons/category/volunteer.png
diff --git a/apps/mobile/src/assets/icons/club-edit-pencil.png b/apps/mobile/src/assets/icons/club-edit-pencil.png
new file mode 100644
index 00000000..71fe08cc
Binary files /dev/null and b/apps/mobile/src/assets/icons/club-edit-pencil.png differ
diff --git a/apps/mobile/src/assets/icons/club-edit-pencil@2x.png b/apps/mobile/src/assets/icons/club-edit-pencil@2x.png
new file mode 100644
index 00000000..eac02384
Binary files /dev/null and b/apps/mobile/src/assets/icons/club-edit-pencil@2x.png differ
diff --git a/apps/mobile/src/assets/icons/club-edit-pencil@3x.png b/apps/mobile/src/assets/icons/club-edit-pencil@3x.png
new file mode 100644
index 00000000..3c2c7669
Binary files /dev/null and b/apps/mobile/src/assets/icons/club-edit-pencil@3x.png differ
diff --git a/src/assets/icons/clubInfo/club-type.png b/apps/mobile/src/assets/icons/clubInfo/club-type.png
similarity index 100%
rename from src/assets/icons/clubInfo/club-type.png
rename to apps/mobile/src/assets/icons/clubInfo/club-type.png
diff --git a/src/assets/icons/clubInfo/college.png b/apps/mobile/src/assets/icons/clubInfo/college.png
similarity index 100%
rename from src/assets/icons/clubInfo/college.png
rename to apps/mobile/src/assets/icons/clubInfo/college.png
diff --git a/apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png b/apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png
new file mode 100644
index 00000000..1f6680f8
Binary files /dev/null and b/apps/mobile/src/assets/icons/clubInfo/official-verification-complete-indicator.png differ
diff --git a/apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png b/apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png
new file mode 100644
index 00000000..cc9c23be
Binary files /dev/null and b/apps/mobile/src/assets/icons/clubInfo/official-verification-pending-indicator.png differ
diff --git a/src/assets/icons/clubInfo/recruit-type.png b/apps/mobile/src/assets/icons/clubInfo/recruit-type.png
similarity index 100%
rename from src/assets/icons/clubInfo/recruit-type.png
rename to apps/mobile/src/assets/icons/clubInfo/recruit-type.png
diff --git a/apps/mobile/src/assets/icons/clubInfo/verification-mark.png b/apps/mobile/src/assets/icons/clubInfo/verification-mark.png
new file mode 100644
index 00000000..772d38ea
Binary files /dev/null and b/apps/mobile/src/assets/icons/clubInfo/verification-mark.png differ
diff --git a/apps/mobile/src/assets/icons/clubInfo/verification-pending-mark.png b/apps/mobile/src/assets/icons/clubInfo/verification-pending-mark.png
new file mode 100644
index 00000000..0b19e658
Binary files /dev/null and b/apps/mobile/src/assets/icons/clubInfo/verification-pending-mark.png differ
diff --git a/src/assets/icons/edit-pencil.png b/apps/mobile/src/assets/icons/edit-pencil.png
similarity index 100%
rename from src/assets/icons/edit-pencil.png
rename to apps/mobile/src/assets/icons/edit-pencil.png
diff --git a/src/assets/icons/heart-fill.png b/apps/mobile/src/assets/icons/heart-fill.png
similarity index 100%
rename from src/assets/icons/heart-fill.png
rename to apps/mobile/src/assets/icons/heart-fill.png
diff --git a/src/assets/icons/heart.png b/apps/mobile/src/assets/icons/heart.png
similarity index 100%
rename from src/assets/icons/heart.png
rename to apps/mobile/src/assets/icons/heart.png
diff --git a/src/assets/icons/kakao.png b/apps/mobile/src/assets/icons/kakao.png
similarity index 100%
rename from src/assets/icons/kakao.png
rename to apps/mobile/src/assets/icons/kakao.png
diff --git a/src/assets/icons/search-icon.png b/apps/mobile/src/assets/icons/search-icon.png
similarity index 100%
rename from src/assets/icons/search-icon.png
rename to apps/mobile/src/assets/icons/search-icon.png
diff --git a/src/assets/icons/search-reset.png b/apps/mobile/src/assets/icons/search-reset.png
similarity index 100%
rename from src/assets/icons/search-reset.png
rename to apps/mobile/src/assets/icons/search-reset.png
diff --git a/src/assets/icons/tab/explore-active.png b/apps/mobile/src/assets/icons/tab/explore-active.png
similarity index 100%
rename from src/assets/icons/tab/explore-active.png
rename to apps/mobile/src/assets/icons/tab/explore-active.png
diff --git a/src/assets/icons/tab/explore-default.png b/apps/mobile/src/assets/icons/tab/explore-default.png
similarity index 100%
rename from src/assets/icons/tab/explore-default.png
rename to apps/mobile/src/assets/icons/tab/explore-default.png
diff --git a/src/assets/icons/tab/home-active.png b/apps/mobile/src/assets/icons/tab/home-active.png
similarity index 100%
rename from src/assets/icons/tab/home-active.png
rename to apps/mobile/src/assets/icons/tab/home-active.png
diff --git a/src/assets/icons/tab/home-default.png b/apps/mobile/src/assets/icons/tab/home-default.png
similarity index 100%
rename from src/assets/icons/tab/home-default.png
rename to apps/mobile/src/assets/icons/tab/home-default.png
diff --git a/src/assets/icons/tab/mypage-active.png b/apps/mobile/src/assets/icons/tab/mypage-active.png
similarity index 100%
rename from src/assets/icons/tab/mypage-active.png
rename to apps/mobile/src/assets/icons/tab/mypage-active.png
diff --git a/src/assets/icons/tab/mypage-default.png b/apps/mobile/src/assets/icons/tab/mypage-default.png
similarity index 100%
rename from src/assets/icons/tab/mypage-default.png
rename to apps/mobile/src/assets/icons/tab/mypage-default.png
diff --git a/src/assets/icons/tab/saved-active.png b/apps/mobile/src/assets/icons/tab/saved-active.png
similarity index 100%
rename from src/assets/icons/tab/saved-active.png
rename to apps/mobile/src/assets/icons/tab/saved-active.png
diff --git a/src/assets/icons/tab/saved-default.png b/apps/mobile/src/assets/icons/tab/saved-default.png
similarity index 100%
rename from src/assets/icons/tab/saved-default.png
rename to apps/mobile/src/assets/icons/tab/saved-default.png
diff --git a/apps/mobile/src/assets/images/brand/entry-symbol-white.png b/apps/mobile/src/assets/images/brand/entry-symbol-white.png
new file mode 100644
index 00000000..4a6e689b
Binary files /dev/null and b/apps/mobile/src/assets/images/brand/entry-symbol-white.png differ
diff --git a/apps/mobile/src/assets/images/brand/entry-symbol.png b/apps/mobile/src/assets/images/brand/entry-symbol.png
new file mode 100644
index 00000000..86b8960f
Binary files /dev/null and b/apps/mobile/src/assets/images/brand/entry-symbol.png differ
diff --git a/apps/mobile/src/assets/images/brand/entry-wordmark-small.png b/apps/mobile/src/assets/images/brand/entry-wordmark-small.png
new file mode 100644
index 00000000..d5164c72
Binary files /dev/null and b/apps/mobile/src/assets/images/brand/entry-wordmark-small.png differ
diff --git a/apps/mobile/src/assets/images/brand/entry-wordmark-transition.png b/apps/mobile/src/assets/images/brand/entry-wordmark-transition.png
new file mode 100644
index 00000000..d3307158
Binary files /dev/null and b/apps/mobile/src/assets/images/brand/entry-wordmark-transition.png differ
diff --git a/apps/mobile/src/assets/images/brand/entry-wordmark.png b/apps/mobile/src/assets/images/brand/entry-wordmark.png
new file mode 100644
index 00000000..6a61632e
Binary files /dev/null and b/apps/mobile/src/assets/images/brand/entry-wordmark.png differ
diff --git a/apps/mobile/src/assets/images/brand/header-logo.png b/apps/mobile/src/assets/images/brand/header-logo.png
new file mode 100644
index 00000000..dfd19e1f
Binary files /dev/null and b/apps/mobile/src/assets/images/brand/header-logo.png differ
diff --git a/apps/mobile/src/assets/images/club-management-snu-logo.png b/apps/mobile/src/assets/images/club-management-snu-logo.png
new file mode 100644
index 00000000..f52e9fb3
Binary files /dev/null and b/apps/mobile/src/assets/images/club-management-snu-logo.png differ
diff --git a/apps/mobile/src/assets/images/club-management-snu-logo@2x.png b/apps/mobile/src/assets/images/club-management-snu-logo@2x.png
new file mode 100644
index 00000000..7830f9e9
Binary files /dev/null and b/apps/mobile/src/assets/images/club-management-snu-logo@2x.png differ
diff --git a/apps/mobile/src/assets/images/club-management-snu-logo@3x.png b/apps/mobile/src/assets/images/club-management-snu-logo@3x.png
new file mode 100644
index 00000000..36b63934
Binary files /dev/null and b/apps/mobile/src/assets/images/club-management-snu-logo@3x.png differ
diff --git a/src/assets/images/default-me.png b/apps/mobile/src/assets/images/default-me.png
similarity index 100%
rename from src/assets/images/default-me.png
rename to apps/mobile/src/assets/images/default-me.png
diff --git a/src/assets/images/detail/clubtype.png b/apps/mobile/src/assets/images/detail/clubtype.png
similarity index 100%
rename from src/assets/images/detail/clubtype.png
rename to apps/mobile/src/assets/images/detail/clubtype.png
diff --git a/src/assets/images/detail/collegetype.png b/apps/mobile/src/assets/images/detail/collegetype.png
similarity index 100%
rename from src/assets/images/detail/collegetype.png
rename to apps/mobile/src/assets/images/detail/collegetype.png
diff --git a/src/assets/images/detail/recruittype.png b/apps/mobile/src/assets/images/detail/recruittype.png
similarity index 100%
rename from src/assets/images/detail/recruittype.png
rename to apps/mobile/src/assets/images/detail/recruittype.png
diff --git a/src/assets/images/mypage/snu-logo.png b/apps/mobile/src/assets/images/mypage/snu-logo.png
similarity index 100%
rename from src/assets/images/mypage/snu-logo.png
rename to apps/mobile/src/assets/images/mypage/snu-logo.png
diff --git a/src/assets/images/mypage/studentid.png b/apps/mobile/src/assets/images/mypage/studentid.png
similarity index 100%
rename from src/assets/images/mypage/studentid.png
rename to apps/mobile/src/assets/images/mypage/studentid.png
diff --git a/src/assets/images/not-found.png b/apps/mobile/src/assets/images/not-found.png
similarity index 100%
rename from src/assets/images/not-found.png
rename to apps/mobile/src/assets/images/not-found.png
diff --git a/apps/mobile/src/config/ENV.ts b/apps/mobile/src/config/ENV.ts
new file mode 100644
index 00000000..60bc345c
--- /dev/null
+++ b/apps/mobile/src/config/ENV.ts
@@ -0,0 +1,22 @@
+import Config from "react-native-config";
+
+type Profile = "dev" | "prod";
+
+const API_SERVER_BASE_URL = Config.API_SERVER_BASE_URL;
+
+(function validateEnv() {
+ const message = [["API_SERVER_BASE_URL", API_SERVER_BASE_URL]]
+ .filter((it) => !it[1])
+ .map(([name, value]) => `Invalid environment variable ${name}: ${value}`)
+ .join("\n");
+
+ if (message) {
+ throw new Error(message);
+ }
+})();
+export const ENV = {
+ PROFILE: Config.PROFILE as Profile,
+ IS_PRODUCTION: !__DEV__,
+ API_SERVER_BASE_URL: API_SERVER_BASE_URL,
+ WEB_URL: Config.WEB_URL || "",
+};
diff --git a/apps/mobile/src/config/linking.ts b/apps/mobile/src/config/linking.ts
new file mode 100644
index 00000000..2f3b8f8f
--- /dev/null
+++ b/apps/mobile/src/config/linking.ts
@@ -0,0 +1,24 @@
+import { ENV } from "@/config/ENV";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+
+export const linking = {
+ prefixes: [
+ "allclear://",
+ "https://all-clear.cc",
+ "https://dev.all-clear.cc",
+ ENV.WEB_URL,
+ ],
+ config: {
+ screens: {
+ Main: {
+ screens: {
+ 홈: {
+ screens: {
+ [SCREEN_TYPE.CLUB_DETAIL]: "club/:uuid",
+ },
+ },
+ },
+ },
+ },
+ },
+};
diff --git a/apps/mobile/src/entities/announcement.ts b/apps/mobile/src/entities/announcement.ts
new file mode 100644
index 00000000..c145e857
--- /dev/null
+++ b/apps/mobile/src/entities/announcement.ts
@@ -0,0 +1,5 @@
+export type Announcement = {
+ uuid: string;
+ title: string;
+ content: string;
+};
diff --git a/apps/mobile/src/entities/appVersion.ts b/apps/mobile/src/entities/appVersion.ts
new file mode 100644
index 00000000..de008b44
--- /dev/null
+++ b/apps/mobile/src/entities/appVersion.ts
@@ -0,0 +1,8 @@
+export type ClientType = "android" | "ios";
+
+export type VersionCheckResult = {
+ updateRequired: boolean;
+ clientType: ClientType;
+ minSupportedVersion: string;
+ storeUrl: string;
+};
diff --git a/apps/mobile/src/entities/category.ts b/apps/mobile/src/entities/category.ts
new file mode 100644
index 00000000..4cffe99a
--- /dev/null
+++ b/apps/mobile/src/entities/category.ts
@@ -0,0 +1,12 @@
+export type Category = {
+ name:
+ | "학술"
+ | "종교"
+ | "공연"
+ | "봉사"
+ | "운동"
+ | "홍보"
+ | "취미"
+ | "문화"
+ | "진로";
+};
diff --git a/apps/mobile/src/entities/club.ts b/apps/mobile/src/entities/club.ts
new file mode 100644
index 00000000..23f69bee
--- /dev/null
+++ b/apps/mobile/src/entities/club.ts
@@ -0,0 +1,83 @@
+import type { Category } from "@/entities/category";
+
+export type ClubCollegeMajor = {
+ id: number;
+ college: string;
+ major: string;
+};
+
+export type OfficialVerificationStatus =
+ | "VERIFIED"
+ | "PENDING"
+ | "REJECTED"
+ | "UNVERIFIED";
+
+export type ManagedOfficialVerification = {
+ status: OfficialVerificationStatus;
+ requestId: number | null;
+ attemptNo: number | null;
+ retryCount: number;
+ retryLimit: number;
+ remainingRetryCount: number;
+ rejectReason: string | null;
+ requestedAt: string | null;
+};
+
+export type Club = {
+ id: string;
+ uuid: string; // id와 동일한 필드. 기존의 id는 number 타입이었으나, string 타입으로 변경되었다.
+ name: string;
+ description: string;
+ shortDescription?: string;
+ introduction: string;
+ type: string;
+ affiliationType?: string;
+ category: Category["name"];
+ collegeMajorId?: number;
+ collegeMajor?: ClubCollegeMajor | null;
+ recruitType: string;
+ hasManager: boolean;
+ hasDongbang: boolean;
+ dongbangLocation?: string | null;
+ minActivityPeriod?: number | null;
+ imageUri: string;
+ snsUrls?: string[];
+ article: string;
+ activeMemberCount?: number;
+ foundedAt?: string | null;
+ activityImageUrls?: string[];
+ status?: string;
+ rejectReason?: string | null;
+ isOfficialVerified?: boolean;
+ officialVerificationStatus?: OfficialVerificationStatus;
+ verifiedAt?: string | null;
+ reviewKeywords: ReviewKeyword[];
+ // v240218
+ totalReviews: number;
+};
+
+export type ManagedClubManagementStatus =
+ | "APPROVED"
+ | "REJECTED"
+ | "PENDING"
+ | "MANAGER_REQUEST_PENDING"
+ | "MANAGER_REQUEST_REJECTED";
+
+export type ManagedClubListItem = Club & {
+ managementStatus: ManagedClubManagementStatus;
+ managerRequestId?: number;
+};
+
+type ReviewKeyword = {
+ id: string;
+ title: string;
+ iconUri: string;
+ totalUpvotes: number;
+};
+
+export type ClubRanking = {
+ ranking: number;
+ clubId: Club["uuid"];
+ clubName: Club["name"];
+ category: Club["category"];
+};
diff --git a/apps/mobile/src/entities/eventLog.ts b/apps/mobile/src/entities/eventLog.ts
new file mode 100644
index 00000000..6f6988ae
--- /dev/null
+++ b/apps/mobile/src/entities/eventLog.ts
@@ -0,0 +1,40 @@
+/**
+ * Basic Data Logging Format
+ * @link __INTERNAL_DOC_REMOVED__
+ */
+
+export type EventLogParameterType = "view" | "click" | "expose";
+
+export type EventLogParameter = {
+ name: T;
+ parameters: T extends "view"
+ ? ViewParameter
+ : T extends "click"
+ ? ClickParameter
+ : ExposeParameter;
+};
+
+type BaseParameter = {
+ screen_name:
+ | "home_screen"
+ | "search_screen"
+ | "club_list_screen"
+ | "club_detail_screen"
+ | "club_review_screen"
+ | "club_ranking_screen"
+ | "saved_club_list_screen";
+ device_id: string;
+ device_type: "ios" | "android";
+ user_id?: string; // if logged in
+ [key: string]: unknown;
+};
+
+export type ViewParameter = BaseParameter;
+
+export type ClickParameter = BaseParameter & {
+ screen_component_name: string;
+};
+
+export type ExposeParameter = BaseParameter & {
+ expose_type: "swipe" | "scroll" | "infinite_scroll";
+};
diff --git a/apps/mobile/src/entities/recentSearch.ts b/apps/mobile/src/entities/recentSearch.ts
new file mode 100644
index 00000000..4bcb3f71
--- /dev/null
+++ b/apps/mobile/src/entities/recentSearch.ts
@@ -0,0 +1,4 @@
+export type RecentSearch = {
+ query: string;
+ searchedAt: string;
+};
diff --git a/apps/mobile/src/entities/review.ts b/apps/mobile/src/entities/review.ts
new file mode 100644
index 00000000..f57a99f9
--- /dev/null
+++ b/apps/mobile/src/entities/review.ts
@@ -0,0 +1,11 @@
+export type ReviewKeywordCategory = {
+ id: number;
+ title: string;
+ keywords: ReviewKeyword[];
+};
+
+export type ReviewKeyword = {
+ id: string;
+ title: string;
+ iconUri: string;
+};
diff --git a/apps/mobile/src/entities/term.ts b/apps/mobile/src/entities/term.ts
new file mode 100644
index 00000000..ff10881e
--- /dev/null
+++ b/apps/mobile/src/entities/term.ts
@@ -0,0 +1,8 @@
+export type Term = {
+ uuid: string;
+ termsKey: string;
+ title: string;
+ contentUrl: string;
+ version: string;
+ isMandatory: boolean;
+};
diff --git a/apps/mobile/src/entities/user.ts b/apps/mobile/src/entities/user.ts
new file mode 100644
index 00000000..f255e2d2
--- /dev/null
+++ b/apps/mobile/src/entities/user.ts
@@ -0,0 +1,16 @@
+export type User = {
+ id: string;
+ nickname: string;
+ name: string;
+ phone: string;
+ email: string;
+ collegeMajor: CollegeMajor | null;
+ // 학번
+ admissionClass: number | null;
+};
+
+export type CollegeMajor = {
+ id: number;
+ college: string | null;
+ major: string | null;
+};
diff --git a/apps/mobile/src/entities/userNotification.ts b/apps/mobile/src/entities/userNotification.ts
new file mode 100644
index 00000000..dcfd9151
--- /dev/null
+++ b/apps/mobile/src/entities/userNotification.ts
@@ -0,0 +1,22 @@
+export type UserNotificationType =
+ | "CLUB_REGISTRATION_APPROVED"
+ | "CLUB_REGISTRATION_REJECTED"
+ | "MANAGER_REQUEST_APPROVED"
+ | "MANAGER_REQUEST_REJECTED";
+
+export type UserNotificationSourceType = "CLUB" | "CLUB_MANAGER_REQUEST";
+
+export type UserNotificationMetadata = {
+ rejectReason?: string;
+};
+
+export type UserNotification = {
+ id: string;
+ type: UserNotificationType;
+ clubId: string | null;
+ sourceType: UserNotificationSourceType;
+ sourceId: string;
+ metadata?: UserNotificationMetadata | null;
+ readAt: string | null;
+ createdAt: string;
+};
diff --git a/apps/mobile/src/features/club/components/AnnouncementForm/index.tsx b/apps/mobile/src/features/club/components/AnnouncementForm/index.tsx
new file mode 100644
index 00000000..df825309
--- /dev/null
+++ b/apps/mobile/src/features/club/components/AnnouncementForm/index.tsx
@@ -0,0 +1,1797 @@
+import { useContext, useEffect, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ Alert,
+ Image,
+ Modal,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { launchImageLibrary } from "react-native-image-picker";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Icon from "react-native-vector-icons/MaterialIcons";
+
+import {
+ getRecruitmentApplicationRequestFields,
+ getRecruitmentDeadlineFormFields,
+ getRecruitmentDeadlineRequestValue,
+} from "@/features/club/utils/recruitmentPresentation";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { navigation } from "@/shared/utils/navigation";
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+const PRIMARY = "#874fff";
+const BORDER = "#c1c1c1";
+const HELPER_COLOR = "#874fff";
+const FORM_TEXT_COLOR = "#757474";
+const PLACEHOLDER_COLOR = FORM_TEXT_COLOR;
+const BG = "#ffffff";
+const FONT_FAMILY = {
+ regular: typography.bodyMRegular.fontFamily,
+ medium: typography.bodyMMedium.fontFamily,
+ semibold: typography.headerXLSemibold.fontFamily,
+ bold: typography.headerXL.fontFamily,
+} as const;
+const HELPER_TEXT_STYLE = {
+ fontFamily: FONT_FAMILY.regular,
+ fontSize: 14,
+ fontWeight: "400",
+ color: HELPER_COLOR,
+} as const;
+
+const YEARS = Array.from({ length: 8 }, (_, i) =>
+ String(new Date().getFullYear() + 2 - i),
+);
+const MONTHS = Array.from({ length: 12 }, (_, i) =>
+ String(i + 1).padStart(2, "0"),
+);
+const DAYS = Array.from({ length: 31 }, (_, i) =>
+ String(i + 1).padStart(2, "0"),
+);
+const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"));
+const MINUTES = Array.from({ length: 60 }, (_, i) =>
+ String(i).padStart(2, "0"),
+);
+const DAYS_OF_WEEK = [
+ "월요일",
+ "화요일",
+ "수요일",
+ "목요일",
+ "금요일",
+ "토요일",
+ "일요일",
+];
+const MEETING_TIMES = Array.from({ length: 48 }, (_, i) => {
+ const h = Math.floor(i / 2)
+ .toString()
+ .padStart(2, "0");
+ const m = i % 2 === 0 ? "00" : "30";
+ return `${h}:${m}`;
+});
+const DROPDOWN_WIDTHS = {
+ year: 88,
+ datePart: 68,
+ weekday: 92,
+ meetingTime: 90,
+} as const;
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type ImageAsset = {
+ uri: string;
+ type: string;
+ name: string;
+ isRemote?: boolean;
+};
+
+type RegularMeeting = {
+ id: number;
+ day: string;
+ startTime: string;
+ endTime: string;
+};
+
+export type AnnouncementFormProps =
+ | { mode: "create"; clubId: string; onSuccess: () => void }
+ | { mode: "edit"; recruitmentId: number; onSuccess: () => void };
+
+// ─── CustomDropdown ───────────────────────────────────────────────────────────
+
+type DropdownProps = {
+ value: string;
+ options: string[];
+ onChange: (val: string) => void;
+ triggerWidth: number;
+ menuWidth?: number;
+};
+
+type DropdownPosition = {
+ x: number;
+ y: number;
+ width: number;
+};
+
+const isValidLayoutNumber = (value: number) => Number.isFinite(value);
+
+const CustomDropdown = ({
+ value,
+ options,
+ onChange,
+ triggerWidth,
+ menuWidth,
+}: DropdownProps) => {
+ const [open, setOpen] = useState(false);
+ const [pos, setPos] = useState(null);
+ const btnRef = useRef(null);
+
+ const closeDropdown = () => {
+ setOpen(false);
+ setPos(null);
+ };
+
+ const handleOpen = () => {
+ requestAnimationFrame(() => {
+ btnRef.current?.measureInWindow((x, y, w, h) => {
+ const resolvedMenuWidth = menuWidth ?? triggerWidth;
+ const layoutValues = [x, y, w, h, triggerWidth, resolvedMenuWidth];
+
+ if (
+ !layoutValues.every(isValidLayoutNumber) ||
+ h <= 0 ||
+ resolvedMenuWidth <= 0
+ ) {
+ closeDropdown();
+ return;
+ }
+
+ setPos({ x, y: y + h, width: resolvedMenuWidth });
+ setOpen(true);
+ });
+ });
+ };
+
+ return (
+
+
+
+ {value}
+
+
+
+
+
+ true}
+ onResponderGrant={closeDropdown}
+ >
+ {pos && (
+ true}
+ onResponderGrant={() => {}}
+ >
+
+ {options.map((opt) => (
+ {
+ onChange(opt);
+ closeDropdown();
+ }}
+ >
+
+ {opt}
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+};
+
+type LabeledDropdownProps = DropdownProps & {
+ unit: string;
+};
+
+const LabeledDropdown = ({ unit, ...dropdownProps }: LabeledDropdownProps) => (
+
+
+ {unit}
+
+);
+
+// ─── ConfirmModal ─────────────────────────────────────────────────────────────
+
+type ConfirmModalProps = {
+ visible: boolean;
+ title: string;
+ submitLabel: string;
+ onCancel: () => void;
+ onConfirm: () => void;
+};
+
+const ConfirmModal = ({
+ visible,
+ title,
+ submitLabel,
+ onCancel,
+ onConfirm,
+}: ConfirmModalProps) => (
+
+
+
+ {title}
+
+ {"공고는 언제든 동아리 관리 → 공고 관리\n탭에서 수정/등록 가능해요"}
+
+
+
+ 취소
+
+
+ {submitLabel}
+
+
+
+
+
+);
+
+// ─── SuccessModal ─────────────────────────────────────────────────────────────
+
+type SuccessModalProps = {
+ visible: boolean;
+ message: string;
+ onConfirm: () => void;
+};
+
+const SuccessModal = ({ visible, message, onConfirm }: SuccessModalProps) => (
+
+
+
+
+ {message}
+
+
+ 확인
+
+
+
+
+);
+
+// ─── Main Component ───────────────────────────────────────────────────────────
+
+const AnnouncementForm = (props: AnnouncementFormProps) => {
+ const { recruitmentService } = useContext(serviceContext);
+ const initialDeadline = getRecruitmentDeadlineFormFields(
+ new Date().toISOString(),
+ );
+
+ const isEdit = props.mode === "edit";
+ const recruitmentIdForEdit = isEdit ? props.recruitmentId : null;
+
+ // clubId: create 모드엔 props에서, edit 모드엔 detail 로드 시 저장
+ const clubIdRef = useRef(props.mode === "create" ? props.clubId : "");
+
+ // 로딩 (edit 모드만 초기 로딩 있음)
+ const [isLoadingDetail, setIsLoadingDetail] = useState(isEdit);
+
+ // 공고 제목
+ const [title, setTitle] = useState("");
+
+ // 모집 기간
+ const [year, setYear] = useState(initialDeadline.year);
+ const [month, setMonth] = useState(initialDeadline.month);
+ const [day, setDay] = useState(initialDeadline.day);
+ const [hour, setHour] = useState("23");
+ const [minute, setMinute] = useState("59");
+
+ // 필참 활동 여부
+ const [hasRequiredActivity, setHasRequiredActivity] = useState<
+ boolean | null
+ >(null);
+
+ // 정기 모임 일시
+ const [hasRegularMeeting, setHasRegularMeeting] = useState(
+ null,
+ );
+ const [regularMeetings, setRegularMeetings] = useState([
+ { id: 1, day: "월요일", startTime: "09:00", endTime: "10:00" },
+ ]);
+
+ // 활동 장소
+ const [activityLocation, setActivityLocation] = useState<
+ "동방" | "동방 외" | "미정" | null
+ >(null);
+ const [locationText, setLocationText] = useState("");
+
+ // 지원 자격
+ const [qualification, setQualification] = useState<
+ "제한 없음" | "제한 있음" | null
+ >(null);
+ const [qualificationText, setQualificationText] = useState("");
+
+ // 모집 인원
+ const [recruitCount, setRecruitCount] = useState<
+ "제한 없음" | "정원 있음" | null
+ >(null);
+ const [recruitCountText, setRecruitCountText] = useState("");
+
+ // 회비
+ const [hasFee, setHasFee] = useState(null);
+ const [feeText, setFeeText] = useState("");
+
+ // 가입 절차
+ const [joinUrl, setJoinUrl] = useState("");
+ const [joinDescription, setJoinDescription] = useState("");
+
+ // 공고 본문
+ const [existingAnnouncement, setExistingAnnouncement] = useState("");
+
+ // 공고 이미지
+ const [images, setImages] = useState([]);
+
+ // 모달
+ const [showConfirm, setShowConfirm] = useState(false);
+ const [showSuccess, setShowSuccess] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ // 이전 공고 불러오기
+ const [showPrevList, setShowPrevList] = useState(false);
+ const [prevRecruitments, setPrevRecruitments] = useState<
+ { id: number; display_title: string }[]
+ >([]);
+ const [isLoadingPrev, setIsLoadingPrev] = useState(false);
+ const [isLoadingPrevDetail, setIsLoadingPrevDetail] = useState(false);
+
+ // ─── edit 모드: 상세 데이터 불러와서 필드 채우기 ─────────────────────────────
+
+ useEffect(() => {
+ if (recruitmentIdForEdit === null) return;
+
+ const load = async () => {
+ try {
+ const detail = await recruitmentService.getRecruitmentDetail({
+ recruitmentId: recruitmentIdForEdit,
+ });
+ const c = detail.content;
+
+ clubIdRef.current = detail.club_id;
+
+ setTitle(c.title);
+
+ const deadline = getRecruitmentDeadlineFormFields(c.deadline);
+ setYear(deadline.year);
+ setMonth(deadline.month);
+ setDay(deadline.day);
+ setHour(deadline.hour);
+ setMinute(deadline.minute);
+
+ setHasRequiredActivity(c.is_mandatory);
+ setHasRegularMeeting(c.has_regular_meeting);
+
+ if (c.regular_meetings.length > 0) {
+ setRegularMeetings(
+ c.regular_meetings.map((m, i) => ({
+ id: i + 1,
+ day: m.day_of_week,
+ startTime: m.start_time?.slice(0, 5) ?? "",
+ endTime: m.end_time?.slice(0, 5) ?? "",
+ })),
+ );
+ }
+
+ const locType = c.activity_location_type as "동방" | "동방 외" | "미정";
+ setActivityLocation(locType);
+ setLocationText(c.activity_location_text);
+
+ setQualification(c.has_eligibility ? "제한 있음" : "제한 없음");
+ setQualificationText(c.eligibility_text);
+
+ setRecruitCount(c.has_capacity_limit ? "정원 있음" : "제한 없음");
+ setRecruitCountText(c.capacity_limit_text);
+
+ setHasFee(c.has_membership_fee);
+ setFeeText(c.membership_fee_text);
+
+ setJoinUrl(c.application_url);
+ setJoinDescription(c.application_process);
+
+ setExistingAnnouncement(c.full_recruitment_text ?? "");
+
+ if (c.image_urls.length > 0) {
+ setImages(
+ c.image_urls.map((url) => ({
+ uri: url,
+ type: "image/jpeg",
+ name: "",
+ isRemote: true,
+ })),
+ );
+ }
+ } catch {
+ Alert.alert("오류", "공고 정보를 불러오는데 실패했어요.");
+ navigation.goBack();
+ } finally {
+ setIsLoadingDetail(false);
+ }
+ };
+ load();
+ }, [recruitmentIdForEdit, recruitmentService]);
+
+ // ─── 정기 모임 관리 ─────────────────────────────────────────────────────────
+
+ const addRegularMeeting = () => {
+ setRegularMeetings((prev) => [
+ ...prev,
+ { id: Date.now(), day: "월요일", startTime: "09:00", endTime: "10:00" },
+ ]);
+ };
+
+ const removeRegularMeeting = (id: number) => {
+ setRegularMeetings((prev) => prev.filter((m) => m.id !== id));
+ };
+
+ const updateRegularMeeting = (
+ id: number,
+ field: keyof RegularMeeting,
+ value: string,
+ ) => {
+ setRegularMeetings((prev) =>
+ prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)),
+ );
+ };
+
+ // ─── 이미지 ──────────────────────────────────────────────────────────────────
+
+ const handleAddImage = () => {
+ launchImageLibrary(
+ { mediaType: "photo", selectionLimit: 1 },
+ (response) => {
+ const asset = response.assets?.[0];
+ const uri = asset?.uri;
+ if (uri) {
+ setImages((prev) => [
+ ...prev,
+ {
+ uri,
+ type: asset.type ?? "image/jpeg",
+ name: asset.fileName ?? `image_${Date.now()}.jpg`,
+ isRemote: false,
+ },
+ ]);
+ }
+ },
+ );
+ };
+
+ const handleRemoveImage = (index: number) => {
+ setImages((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ // ─── 폼 유효성 ──────────────────────────────────────────────────────────────
+
+ const isFormValid =
+ title.trim().length > 0 &&
+ hasRequiredActivity !== null &&
+ hasRegularMeeting !== null &&
+ activityLocation !== null &&
+ (activityLocation !== "동방 외" || locationText.trim().length > 0) &&
+ qualification !== null &&
+ (qualification !== "제한 있음" || qualificationText.trim().length > 0) &&
+ recruitCount !== null &&
+ (recruitCount !== "정원 있음" || recruitCountText.trim().length > 0) &&
+ hasFee !== null &&
+ (hasFee !== true || feeText.trim().length > 0) &&
+ joinUrl.trim().length > 0 &&
+ joinDescription.trim().length > 0;
+
+ const handleSubmit = () => {
+ setShowConfirm(true);
+ };
+
+ // ─── 제출 ─────────────────────────────────────────────────────────────────────
+
+ const handleConfirm = async () => {
+ setShowConfirm(false);
+ setIsSubmitting(true);
+
+ try {
+ // 기존 remote URL은 그대로 유지, 새 로컬 이미지만 업로드
+ const remoteUrls = images
+ .filter((img) => img.isRemote)
+ .map((img) => img.uri);
+ const newLocalImages = images.filter((img) => !img.isRemote);
+ const newUploadedUrls: string[] = [];
+
+ for (const img of newLocalImages) {
+ const res = await recruitmentService.uploadRecruitmentImage({
+ clubId: clubIdRef.current,
+ uri: img.uri,
+ type: img.type,
+ name: img.name,
+ });
+ newUploadedUrls.push(res.url);
+ }
+
+ const imageUrls = [...remoteUrls, ...newUploadedUrls];
+ const deadline = getRecruitmentDeadlineRequestValue({
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ });
+
+ const payload = {
+ title,
+ deadline,
+ is_mandatory: hasRequiredActivity ?? false,
+ has_regular_meeting: hasRegularMeeting ?? false,
+ regular_meetings:
+ hasRegularMeeting === true
+ ? regularMeetings.map((m) => ({
+ day_of_week: m.day,
+ start_time: m.startTime,
+ end_time: m.endTime,
+ }))
+ : [],
+ activity_location_type: activityLocation ?? "",
+ activity_location_text: locationText,
+ has_eligibility: qualification === "제한 있음",
+ eligibility_text: qualificationText,
+ has_capacity_limit: recruitCount === "정원 있음",
+ capacity_limit_text: recruitCountText,
+ has_membership_fee: hasFee ?? false,
+ membership_fee_text: feeText,
+ ...getRecruitmentApplicationRequestFields(joinUrl),
+ application_process: joinDescription,
+ full_recruitment_text: existingAnnouncement,
+ image_urls: imageUrls,
+ };
+
+ if (props.mode === "create") {
+ await recruitmentService.createRecruitment({
+ clubId: clubIdRef.current,
+ ...payload,
+ });
+ } else {
+ await recruitmentService.updateRecruitment({
+ recruitmentId: props.recruitmentId,
+ ...payload,
+ });
+ }
+
+ setShowSuccess(true);
+ } catch (err: unknown) {
+ if (props.mode === "create") {
+ console.log("[DEV] 공고 등록 실패:", JSON.stringify(err));
+ const status = (err as { status?: number })?.status;
+ const message =
+ status === 409
+ ? "이번 달에 이미 등록된 공고가 있어요.\n공고는 월 1회만 등록할 수 있어요."
+ : "공고 등록 중 오류가 발생했어요. 다시 시도해주세요.";
+ Alert.alert("등록 실패", message);
+ } else {
+ Alert.alert(
+ "수정 실패",
+ "공고 수정 중 오류가 발생했어요. 다시 시도해주세요.",
+ );
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const handleSuccessConfirm = () => {
+ setShowSuccess(false);
+ props.onSuccess();
+ };
+
+ // ─── 이전 공고 불러오기 ──────────────────────────────────────────────────────
+
+ const handleOpenPrevList = async () => {
+ setShowPrevList(true);
+ setIsLoadingPrev(true);
+ try {
+ const data = await recruitmentService.listClubRecruitments({
+ clubId: clubIdRef.current,
+ });
+ setPrevRecruitments(data.recruitments);
+ } catch {
+ Alert.alert("오류", "공고 목록을 불러오는데 실패했어요.");
+ setShowPrevList(false);
+ } finally {
+ setIsLoadingPrev(false);
+ }
+ };
+
+ const handleSelectPrevRecruitment = async (recruitmentId: number) => {
+ setIsLoadingPrevDetail(true);
+ try {
+ const detail = await recruitmentService.getRecruitmentDetail({
+ recruitmentId,
+ });
+ const c = detail.content;
+
+ setTitle(c.title);
+
+ const deadline = getRecruitmentDeadlineFormFields(c.deadline);
+ setYear(deadline.year);
+ setMonth(deadline.month);
+ setDay(deadline.day);
+ setHour(deadline.hour);
+ setMinute(deadline.minute);
+
+ setHasRequiredActivity(c.is_mandatory);
+ setHasRegularMeeting(c.has_regular_meeting);
+
+ if (c.regular_meetings.length > 0) {
+ setRegularMeetings(
+ c.regular_meetings.map((m, i) => ({
+ id: i + 1,
+ day: m.day_of_week,
+ startTime: m.start_time?.slice(0, 5) ?? "",
+ endTime: m.end_time?.slice(0, 5) ?? "",
+ })),
+ );
+ }
+
+ const locType = c.activity_location_type as "동방" | "동방 외" | "미정";
+ setActivityLocation(locType);
+ setLocationText(c.activity_location_text);
+
+ setQualification(c.has_eligibility ? "제한 있음" : "제한 없음");
+ setQualificationText(c.eligibility_text);
+
+ setRecruitCount(c.has_capacity_limit ? "정원 있음" : "제한 없음");
+ setRecruitCountText(c.capacity_limit_text);
+
+ setHasFee(c.has_membership_fee);
+ setFeeText(c.membership_fee_text);
+
+ setJoinUrl(c.application_url);
+ setJoinDescription(c.application_process);
+
+ setExistingAnnouncement(c.full_recruitment_text ?? "");
+
+ if (c.image_urls.length > 0) {
+ setImages(
+ c.image_urls.map((url) => ({
+ uri: url,
+ type: "image/jpeg",
+ name: "",
+ isRemote: true,
+ })),
+ );
+ }
+
+ setShowPrevList(false);
+ } catch {
+ Alert.alert("오류", "공고 정보를 불러오는데 실패했어요.");
+ } finally {
+ setIsLoadingPrevDetail(false);
+ }
+ };
+
+ // ─── mode별 텍스트 ───────────────────────────────────────────────────────────
+
+ const screenTitle = isEdit ? "공고 관리" : "모집 공고를 작성해주세요";
+ const submitLabel = isEdit ? "수정" : "완료";
+ const confirmTitle = isEdit ? "공고를 수정할까요?" : "공고를 등록할까요?";
+ const confirmSubmitLabel = isEdit ? "수정" : "등록";
+ const successMessage = isEdit
+ ? "공고 수정이 정상적으로\n완료되었어요!"
+ : "공고 등록이 정상적으로\n완료되었어요!";
+
+ // ─── edit 모드 로딩 중 ────────────────────────────────────────────────────────
+
+ if (isLoadingDetail) {
+ return (
+
+
+
+ );
+ }
+
+ // ─── 렌더 ────────────────────────────────────────────────────────────────────
+
+ return (
+
+ {/* 헤더 (edit 모드만) */}
+ {isEdit && (
+
+ navigation.goBack()} hitSlop={8}>
+
+
+ {screenTitle}
+
+
+ )}
+
+
+ {/* create 모드: 기존 스타일 타이틀 */}
+ {!isEdit && {screenTitle}}
+
+ {/* 이전 공고 불러오기 (create 모드만) */}
+ {!isEdit && (
+
+ 이전 공고 불러오기
+
+ )}
+
+ {/* 이전 공고 목록 모달 */}
+ !isLoadingPrevDetail && setShowPrevList(false)}
+ >
+ true}
+ onResponderGrant={() =>
+ !isLoadingPrevDetail && setShowPrevList(false)
+ }
+ >
+ true}
+ onResponderGrant={() => {}}
+ >
+
+ 이전 공고 불러오기
+ !isLoadingPrevDetail && setShowPrevList(false)}
+ hitSlop={8}
+ >
+
+
+
+
+ {isLoadingPrev ? (
+
+ ) : isLoadingPrevDetail ? (
+
+
+
+ 공고 불러오는 중...
+
+
+ ) : prevRecruitments.length === 0 ? (
+
+ 등록된 공고가 없어요
+
+ ) : (
+
+ {prevRecruitments.map((item, index) => (
+ handleSelectPrevRecruitment(item.id)}
+ activeOpacity={0.6}
+ >
+
+ {item.display_title}
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* 공고 제목 */}
+
+ *공고 제목
+
+ 공고 제목은 필수 입력 정보예요
+
+
+ {/* 모집 기간 */}
+
+ *모집 기간
+
+
+
+
+
+
+
+
+ 모집 마감
+
+
+
+ {/* 필참 활동 여부 */}
+
+ *필참 활동 여부
+
+ setHasRequiredActivity(true)}
+ >
+
+ 있음
+
+
+ setHasRequiredActivity(false)}
+ >
+
+ 없음
+
+
+
+
+ 필참 활동 여부는 필수 입력 정보예요
+
+
+
+ {/* 정기 모임 일시 */}
+
+ *정기 모임 일시
+
+ setHasRegularMeeting(true)}
+ >
+
+ 정기 모임 있음
+
+
+ setHasRegularMeeting(false)}
+ >
+
+ 정기 모임 없음
+
+
+
+
+ {hasRegularMeeting === true && (
+ <>
+ {regularMeetings.map((meeting) => (
+
+ updateRegularMeeting(meeting.id, "day", v)}
+ triggerWidth={DROPDOWN_WIDTHS.weekday}
+ />
+
+ updateRegularMeeting(meeting.id, "startTime", v)
+ }
+ triggerWidth={DROPDOWN_WIDTHS.meetingTime}
+ />
+ ~
+
+ updateRegularMeeting(meeting.id, "endTime", v)
+ }
+ triggerWidth={DROPDOWN_WIDTHS.meetingTime}
+ />
+ removeRegularMeeting(meeting.id)}
+ disabled={regularMeetings.length === 1}
+ >
+
+
+
+ ))}
+
+ + 시간 추가
+
+ >
+ )}
+
+ 정기 모임 유무는 필수 입력 정보예요
+
+
+
+ {/* 활동 장소 */}
+
+ *활동 장소
+
+ {(["동방", "동방 외", "미정"] as const).map((option) => (
+ setActivityLocation(option)}
+ >
+
+ {option}
+
+
+ ))}
+
+ {activityLocation === "동방 외" && (
+
+
+
+
+ )}
+ 활동 장소는 필수 입력 정보예요
+
+
+ {/* 지원 자격 */}
+
+ *지원 자격
+
+ setQualification("제한 없음")}
+ >
+
+ 제한 없음
+
+
+ setQualification("제한 있음")}
+ >
+
+ 제한 있음
+
+
+
+ {qualification === "제한 있음" && (
+
+ )}
+ 지원 자격은 필수 입력 정보예요
+
+
+ {/* 모집 인원 */}
+
+ *모집 인원
+
+ setRecruitCount("제한 없음")}
+ >
+
+ 제한 없음
+
+
+ setRecruitCount("정원 있음")}
+ >
+
+ 정원 있음
+
+
+
+ {recruitCount === "정원 있음" && (
+
+ )}
+
+ 모집 예정 인원은 필수 입력 정보예요
+
+
+
+ {/* 회비 */}
+
+ *회비
+
+ setHasFee(true)}
+ >
+
+ 회비 O
+
+
+ setHasFee(false)}
+ >
+
+ 회비 X
+
+
+
+ {hasFee === true && (
+
+ )}
+ 회비 유무는 필수 제공 정보예요
+
+
+ {/* 가입 절차 */}
+
+ *가입 절차
+
+
+
+
+
+
+ 동아리 가입 절차는 필수 입력 정보예요
+
+
+
+ {/* 공고 본문 */}
+
+ 공고 본문
+
+
+
+ {/* 공고 이미지 */}
+
+ 공고 이미지
+
+ {images.map((img, idx) => (
+
+
+ handleRemoveImage(idx)}
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+ {/* 하단 버튼 */}
+
+ navigation.goBack()}
+ disabled={isSubmitting}
+ >
+ 이전
+
+
+ {isSubmitting ? (
+
+ ) : (
+ {submitLabel}
+ )}
+
+
+
+
+ {/* 확인 모달 */}
+ setShowConfirm(false)}
+ onConfirm={handleConfirm}
+ />
+
+ {/* 완료 모달 */}
+
+
+ );
+};
+
+export default AnnouncementForm;
+
+// ─── Styles ───────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: BG,
+ },
+ scrollView: {
+ flex: 1,
+ paddingHorizontal: 20,
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ justifyContent: "space-between",
+ paddingHorizontal: 20,
+ paddingBottom: 10,
+ paddingTop: 4,
+ backgroundColor: "#FFFFFF",
+ },
+ headerTitle: {
+ fontSize: 17,
+ fontWeight: "600",
+ color: "#111",
+ textAlign: "center",
+ },
+ screenTitle: {
+ fontSize: 24,
+ fontWeight: "700",
+ color: "#111",
+ marginTop: 24,
+ marginBottom: 20,
+ },
+ loadPreviousButton: {
+ alignSelf: "flex-start",
+ backgroundColor: PRIMARY,
+ padding: 10,
+ borderRadius: 8,
+ marginBottom: 20,
+ },
+ loadPreviousText: {
+ color: "#fafafa",
+ fontSize: 12,
+ fontWeight: "700",
+ },
+ prevModalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.35)",
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 24,
+ },
+ prevModalCard: {
+ width: "100%",
+ backgroundColor: "#fff",
+ borderRadius: 16,
+ paddingTop: 20,
+ paddingBottom: 8,
+ overflow: "hidden",
+ },
+ prevModalHeader: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: 20,
+ marginBottom: 12,
+ },
+ prevModalTitle: {
+ fontSize: 15,
+ fontWeight: "700",
+ color: "#111",
+ },
+ prevRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: 14,
+ paddingHorizontal: 20,
+ },
+ prevRowBorder: {
+ borderBottomWidth: 1,
+ borderBottomColor: "#F3F0F5",
+ },
+ prevRowText: {
+ fontSize: 14,
+ fontWeight: "500",
+ color: "#333",
+ },
+ prevEmptyRow: {
+ paddingVertical: 24,
+ alignItems: "center",
+ },
+ prevEmptyText: {
+ fontSize: 13,
+ color: "#999",
+ },
+ prevLoadingText: {
+ marginTop: 8,
+ fontSize: 13,
+ color: "#999",
+ },
+ section: {
+ marginBottom: 20,
+ },
+ sectionLabel: {
+ fontFamily: FONT_FAMILY.semibold,
+ fontSize: 20,
+ fontWeight: "600",
+ color: FORM_TEXT_COLOR,
+ marginBottom: 5,
+ paddingTop: 10,
+ paddingBottom: 5,
+ },
+ textInput: {
+ backgroundColor: "#fff",
+ borderWidth: 1,
+ borderColor: BORDER,
+ borderRadius: 8,
+ paddingHorizontal: 15,
+ paddingVertical: 18,
+ fontFamily: FONT_FAMILY.medium,
+ fontSize: 16,
+ fontWeight: "500",
+ color: FORM_TEXT_COLOR,
+ minHeight: 60,
+ },
+ helperText: {
+ ...HELPER_TEXT_STYLE,
+ marginTop: 5,
+ paddingLeft: 5,
+ },
+ deadlineLabel: {
+ fontFamily: FONT_FAMILY.semibold,
+ fontSize: 20,
+ fontWeight: "600",
+ lineHeight: 24,
+ color: HELPER_COLOR,
+ marginLeft: 4,
+ },
+
+ // 날짜 행
+ dateRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 8,
+ rowGap: 8,
+ },
+ dateField: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ },
+ dateUnitLabel: {
+ fontFamily: FONT_FAMILY.semibold,
+ fontSize: 20,
+ fontWeight: "600",
+ color: FORM_TEXT_COLOR,
+ },
+ // 드롭다운
+ dropdown: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ backgroundColor: "#fff",
+ borderWidth: 1,
+ borderColor: BORDER,
+ borderRadius: 8,
+ paddingHorizontal: 13,
+ minHeight: 54,
+ },
+ dropdownOpen: {
+ borderColor: PRIMARY,
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ borderBottomWidth: 0,
+ },
+ dropdownText: {
+ fontFamily: FONT_FAMILY.regular,
+ fontSize: 16,
+ fontWeight: "400",
+ color: FORM_TEXT_COLOR,
+ marginRight: 2,
+ },
+ dropdownListOpen: {
+ borderWidth: 1,
+ borderColor: PRIMARY,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ shadowOpacity: 0,
+ elevation: 0,
+ },
+ dropdownList: {
+ backgroundColor: "#fff",
+ borderRadius: 10,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.12,
+ shadowRadius: 10,
+ elevation: 6,
+ overflow: "hidden",
+ },
+ dropdownItem: {
+ paddingHorizontal: 13,
+ paddingVertical: 13,
+ },
+ dropdownItemSelected: {
+ backgroundColor: `${PRIMARY}18`,
+ },
+ dropdownItemText: {
+ fontFamily: FONT_FAMILY.regular,
+ fontSize: 16,
+ fontWeight: "400",
+ color: FORM_TEXT_COLOR,
+ },
+ dropdownItemTextSelected: {
+ color: PRIMARY,
+ fontWeight: "400",
+ },
+
+ // 토글 버튼
+ toggleRow: {
+ flexDirection: "row",
+ gap: 10,
+ },
+ toggleButton: {
+ flex: 1,
+ height: 54,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: BORDER,
+ backgroundColor: "#fff",
+ alignItems: "center",
+ justifyContent: "center",
+ paddingHorizontal: 4,
+ },
+ toggleButtonOn: {
+ backgroundColor: PRIMARY,
+ borderColor: PRIMARY,
+ },
+ toggleText: {
+ fontFamily: FONT_FAMILY.semibold,
+ fontSize: 16,
+ color: FORM_TEXT_COLOR,
+ fontWeight: "600",
+ textAlign: "center",
+ },
+ toggleTextOn: {
+ color: "#fff",
+ fontWeight: "600",
+ textAlign: "center",
+ },
+
+ // 정기 모임 행
+ meetingRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ marginTop: 10,
+ },
+ tilde: {
+ fontSize: 14,
+ color: FORM_TEXT_COLOR,
+ },
+ addTimeButton: {
+ alignSelf: "center",
+ marginTop: 12,
+ },
+ addTimeText: {
+ fontSize: 14,
+ color: PRIMARY,
+ fontWeight: "600",
+ },
+
+ // 아이콘 + 인풋
+ iconInputWrapper: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 5,
+ marginTop: 8,
+ },
+ iconInput: {
+ flex: 1,
+ height: 40,
+ backgroundColor: "#fff",
+ borderWidth: 1,
+ borderColor: BORDER,
+ borderRadius: 8,
+ paddingHorizontal: 15,
+ paddingVertical: 0,
+ fontFamily: FONT_FAMILY.medium,
+ fontSize: 14,
+ fontWeight: "500",
+ lineHeight: 24,
+ textAlignVertical: "center",
+ includeFontPadding: false,
+ color: FORM_TEXT_COLOR,
+ },
+
+ // 이미지
+ imageRow: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: 8,
+ },
+ imageThumbnail: {
+ width: 72,
+ height: 72,
+ borderRadius: 8,
+ overflow: "hidden",
+ position: "relative",
+ },
+ thumbnailImg: {
+ width: "100%",
+ height: "100%",
+ resizeMode: "cover",
+ },
+ deleteImageBtn: {
+ position: "absolute",
+ top: 4,
+ right: 4,
+ backgroundColor: "rgba(0,0,0,0.5)",
+ borderRadius: 10,
+ width: 20,
+ height: 20,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ addImageButton: {
+ width: 72,
+ height: 72,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: BORDER,
+ backgroundColor: "#fff",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+
+ // 하단 버튼
+ bottomBar: {
+ flexDirection: "row",
+ gap: 10,
+ paddingHorizontal: 20,
+ paddingTop: 12,
+ paddingBottom: 32,
+ },
+ prevButton: {
+ flex: 1,
+ height: 44,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: BORDER,
+ backgroundColor: "#fff",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ prevButtonText: {
+ fontSize: 16,
+ color: BORDER,
+ fontWeight: "700",
+ },
+ submitButton: {
+ flex: 1,
+ height: 44,
+ borderRadius: 8,
+ backgroundColor: PRIMARY,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ submitButtonText: {
+ fontSize: 16,
+ color: "#fff",
+ fontWeight: "600",
+ },
+
+ // 확인 모달
+ confirmOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0,0,0,0.2)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ confirmBox: {
+ backgroundColor: "#fff",
+ borderRadius: 12,
+ padding: 24,
+ width: 320,
+ alignItems: "center",
+ },
+ confirmTitle: {
+ fontSize: 16,
+ fontWeight: "700",
+ color: "#000000",
+ textAlign: "center",
+ },
+ confirmDesc: {
+ fontSize: 14,
+ fontWeight: "500",
+ color: "#000000",
+ textAlign: "center",
+ lineHeight: 24,
+ },
+ confirmButtons: {
+ flexDirection: "row",
+ gap: 7,
+ alignSelf: "stretch",
+ },
+ confirmCancel: {
+ flex: 1,
+ height: 44,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: PRIMARY,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ confirmCancelText: {
+ fontSize: 16,
+ color: PRIMARY,
+ fontWeight: "700",
+ },
+ confirmSubmit: {
+ flex: 1,
+ height: 44,
+ borderRadius: 8,
+ backgroundColor: PRIMARY,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ confirmSubmitText: {
+ fontSize: 16,
+ color: "#fff",
+ fontWeight: "600",
+ },
+});
diff --git a/src/features/club/components/ClubDetail/BackgroundCard.tsx b/apps/mobile/src/features/club/components/ClubDetail/BackgroundCard.tsx
similarity index 51%
rename from src/features/club/components/ClubDetail/BackgroundCard.tsx
rename to apps/mobile/src/features/club/components/ClubDetail/BackgroundCard.tsx
index 30082b39..62ba6087 100644
--- a/src/features/club/components/ClubDetail/BackgroundCard.tsx
+++ b/apps/mobile/src/features/club/components/ClubDetail/BackgroundCard.tsx
@@ -1,19 +1,19 @@
-import React from 'react'
-import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'
+import type React from "react";
+import { type StyleProp, StyleSheet, View, type ViewStyle } from "react-native";
-import { Colors } from '@/shared/constants/colors'
-import { ms, s } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { ms, s } from "@/shared/utils/scale";
type Props = {
- children: React.ReactNode
- style?: StyleProp
-}
+ children: React.ReactNode;
+ style?: StyleProp;
+};
const BackgroundCard = ({ children, style }: Props) => {
- return {children}
-}
+ return {children};
+};
-export default BackgroundCard
+export default BackgroundCard;
const styles = StyleSheet.create({
card: {
@@ -26,4 +26,4 @@ const styles = StyleSheet.create({
shadowRadius: 8,
elevation: 1,
},
-})
+});
diff --git a/apps/mobile/src/features/club/components/ClubDetail/ClubDetailTabBar.tsx b/apps/mobile/src/features/club/components/ClubDetail/ClubDetailTabBar.tsx
new file mode 100644
index 00000000..853cb551
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/ClubDetailTabBar.tsx
@@ -0,0 +1,88 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+export const CLUB_DETAIL_TABS = {
+ detail: "상세정보",
+ recruit: "모집공고",
+ review: "활동후기",
+} as const;
+
+export type ClubDetailTabKey = keyof typeof CLUB_DETAIL_TABS;
+export type ClubDetailTabLabel = (typeof CLUB_DETAIL_TABS)[ClubDetailTabKey];
+
+type Props = {
+ activeKey: ClubDetailTabKey;
+ onChange: (key: ClubDetailTabKey) => void;
+};
+
+const ClubDetailTabBar = ({ activeKey, onChange }: Props) => {
+ return (
+
+ {(Object.keys(CLUB_DETAIL_TABS) as ClubDetailTabKey[]).map((key) => {
+ const isActive = key === activeKey;
+ return (
+ onChange(key)}
+ hitSlop={8}
+ >
+
+ {CLUB_DETAIL_TABS[key]}
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default ClubDetailTabBar;
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ backgroundColor: Colors.BACKGROUND_MAIN,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.BODYTEXT_SUB_2,
+ },
+ tab: {
+ flex: 1,
+ alignItems: "center",
+ paddingVertical: vs(12),
+ },
+ label: {
+ marginBottom: vs(4),
+ },
+ labelActive: {
+ ...typography.bodyMSemibold,
+ color: Colors.POINTCOLOR,
+ },
+ labelInactive: {
+ ...typography.bodyMMedium,
+ color: Colors.BODYTEXT_SUB,
+ },
+ underline: {
+ position: "absolute",
+ bottom: -1,
+ left: s(16),
+ right: s(16),
+ height: ms(2),
+ borderRadius: ms(2),
+ backgroundColor: "transparent",
+ },
+ underlineActive: {
+ backgroundColor: Colors.POINTCOLOR,
+ },
+});
diff --git a/apps/mobile/src/features/club/components/ClubDetail/InfoTab.tsx b/apps/mobile/src/features/club/components/ClubDetail/InfoTab.tsx
new file mode 100644
index 00000000..3e51a88e
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/InfoTab.tsx
@@ -0,0 +1,358 @@
+import React, { useCallback, useState } from "react";
+import {
+ Image,
+ type LayoutChangeEvent,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import LinearGradient from "react-native-linear-gradient";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import type { Club } from "@/entities/club";
+import HtmlView from "@/shared/components/HtmlView";
+import ImageViewerModal from "@/shared/components/ImageViewerModal";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
+import BackgroundCard from "./BackgroundCard";
+
+type Props = {
+ club: Club;
+ contentWidth: number;
+};
+
+const COLLAPSED_MAX_HEIGHT = vs(115);
+const FADE_COLORS = ["rgba(255, 255, 255, 0)", Colors.WHITE];
+
+const formatFoundedAt = (value: string): string => {
+ const [year, month, day] = value.split("-").map(Number);
+ return year && month && day ? `${year}년 ${month}월 ${day}일` : value;
+};
+
+const StatePills = ({
+ active,
+ trueLabel = "있음",
+ falseLabel = "없음",
+ falseFirst = false,
+ detail,
+ detailOnNewLine = false,
+}: {
+ active: boolean;
+ trueLabel?: string;
+ falseLabel?: string;
+ falseFirst?: boolean;
+ detail?: string;
+ detailOnNewLine?: boolean;
+}) => {
+ const options = falseFirst
+ ? [
+ { label: falseLabel, value: false },
+ { label: trueLabel, value: true },
+ ]
+ : [
+ { label: trueLabel, value: true },
+ { label: falseLabel, value: false },
+ ];
+
+ return (
+
+
+ {options.map((option) => {
+ const selected = option.value === active;
+
+ return (
+
+
+ {option.label}
+
+
+ );
+ })}
+
+ {!!detail && (
+
+ {detail}
+
+ )}
+
+ );
+};
+
+const InfoTab = ({ club, contentWidth }: Props) => {
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [contentHeight, setContentHeight] = useState(null);
+ const [selectedImageIndex, setSelectedImageIndex] = useState(
+ null,
+ );
+
+ const onContentLayout = useCallback((event: LayoutChangeEvent) => {
+ const height = event.nativeEvent.layout.height;
+ setContentHeight((previous) =>
+ previous === null || height > previous ? height : previous,
+ );
+ }, []);
+
+ const isLong = contentHeight !== null && contentHeight > COLLAPSED_MAX_HEIGHT;
+ const collapsed = !descriptionExpanded && (contentHeight === null || isLong);
+ const introduction = club.introduction?.trim();
+ const minActivityPeriod = club.minActivityPeriod ?? 0;
+ const activityImageUrls = club.activityImageUrls ?? [];
+ const topItems = [
+ {
+ label: "분류",
+ value: `${club.type} 동아리`,
+ icon: "account-group-outline",
+ },
+ { label: "카테고리", value: club.category, icon: "shape-outline" },
+ {
+ label: "모집형태",
+ value: `${club.recruitType} 모집`,
+ icon: "account-plus-outline",
+ },
+ ];
+
+ return (
+
+
+
+
+ {topItems.map((item, index) => (
+
+ {index > 0 && }
+
+
+ {item.label}
+
+ {item.value}
+
+
+
+ ))}
+
+
+
+
+ 동방 유무
+
+
+
+ 최소활동 기간
+ 0}
+ falseFirst
+ detail={
+ minActivityPeriod > 0 ? `${minActivityPeriod}학기` : undefined
+ }
+ />
+
+
+
+ {!!introduction && (
+
+ 상세설명
+
+
+
+
+
+
+ {collapsed && isLong && (
+
+ )}
+
+ {isLong && (
+
+ setDescriptionExpanded((previous) => !previous)
+ }
+ >
+
+
+ )}
+
+ )}
+
+ {(club.foundedAt || (club.activeMemberCount ?? 0) > 0) && (
+
+ {!!club.foundedAt && (
+
+ 설립일
+
+ {formatFoundedAt(club.foundedAt)}
+
+
+ )}
+ {(club.activeMemberCount ?? 0) > 0 && (
+
+ 활동 인원
+
+ 약 {club.activeMemberCount}명
+
+
+ )}
+
+ )}
+
+
+
+ {activityImageUrls.length > 0 && (
+
+ 활동 사진
+
+ {activityImageUrls.map((url, index) => (
+ setSelectedImageIndex(index)}
+ >
+
+
+ ))}
+
+
+ )}
+
+ setSelectedImageIndex(null)}
+ />
+
+ );
+};
+
+export default InfoTab;
+
+const styles = StyleSheet.create({
+ container: { marginTop: vs(16), gap: vs(20) },
+ sections: { gap: vs(22) },
+ iconRow: { flexDirection: "row" },
+ iconCell: {
+ flex: 1,
+ alignItems: "center",
+ gap: vs(5),
+ paddingHorizontal: s(4),
+ },
+ iconDivider: { width: 1, backgroundColor: Colors.TEXTBOX_SELECTED },
+ iconLabel: { ...typography.bodySRegular, color: Colors.BODYTEXT_SUB_2 },
+ iconValue: {
+ ...typography.bodyMMedium,
+ color: Colors.BODYTEXT_SUB,
+ textAlign: "center",
+ },
+ infoList: { gap: vs(14) },
+ infoRow: { gap: vs(8) },
+ label: { ...typography.bodyMRegular, color: Colors.BODYTEXT_SUB_2 },
+ stateContent: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ alignItems: "center",
+ gap: s(6),
+ },
+ stateContentColumn: { flexDirection: "column", alignItems: "flex-start" },
+ statePills: { flexDirection: "row", gap: s(6) },
+ statePill: {
+ height: vs(30),
+ paddingHorizontal: s(15),
+ borderWidth: 0.65,
+ borderColor: Colors.BADGE_UNSELECTED,
+ borderRadius: ms(20),
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ statePillActive: {
+ backgroundColor: Colors.POINTCOLOR,
+ borderColor: Colors.POINTCOLOR,
+ },
+ statePillText: {
+ ...typography.bodySMedium,
+ color: Colors.BADGE_UNSELECTED,
+ lineHeight: vs(17),
+ letterSpacing: ms(-0.24),
+ },
+ statePillTextActive: { ...typography.bodySSemibold, color: Colors.WHITE },
+ detailTag: {
+ paddingHorizontal: s(15),
+ paddingVertical: vs(8),
+ borderWidth: 1,
+ borderColor: Colors.POINTCOLOR,
+ borderRadius: ms(10),
+ backgroundColor: Colors.POINTCOLOR_10,
+ },
+ detailTagText: { ...typography.bodySSemibold, color: Colors.BODYTEXT_SUB },
+ descriptionSection: { gap: vs(8) },
+ descriptionCollapsed: { maxHeight: COLLAPSED_MAX_HEIGHT, overflow: "hidden" },
+ fade: { position: "absolute", left: 0, right: 0, bottom: 0, height: vs(28) },
+ toggle: {
+ minHeight: ms(44),
+ alignItems: "center",
+ justifyContent: "center",
+ marginTop: -vs(8),
+ },
+ plainInfoList: { gap: vs(12) },
+ plainInfoRow: { flexDirection: "row", alignItems: "center" },
+ plainValue: {
+ ...typography.bodyMMedium,
+ color: Colors.BODYTEXT_SUB,
+ marginLeft: "auto",
+ },
+ photoSection: { gap: vs(10) },
+ photoTitle: { ...typography.headerL, color: Colors.BODYTEXT_SUB },
+ photoList: { gap: s(10) },
+ activityImage: {
+ width: s(150),
+ height: s(106),
+ borderRadius: ms(12),
+ backgroundColor: Colors.WHITE,
+ },
+});
diff --git a/src/features/club/components/ClubDetail/LoginBlurOverlay.tsx b/apps/mobile/src/features/club/components/ClubDetail/LoginBlurOverlay.tsx
similarity index 61%
rename from src/features/club/components/ClubDetail/LoginBlurOverlay.tsx
rename to apps/mobile/src/features/club/components/ClubDetail/LoginBlurOverlay.tsx
index e682a12e..f201628e 100644
--- a/src/features/club/components/ClubDetail/LoginBlurOverlay.tsx
+++ b/apps/mobile/src/features/club/components/ClubDetail/LoginBlurOverlay.tsx
@@ -1,17 +1,16 @@
-import { BlurView } from '@react-native-community/blur'
-import React from 'react'
-import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { BlurView } from "@react-native-community/blur";
+import { Pressable, StyleSheet, Text, View } from "react-native";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
-import { ClubDetailTabLabel } from './ClubDetailTabBar'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
+import type { ClubDetailTabLabel } from "./ClubDetailTabBar";
type Props = {
- clubName: string
- tabLabel: ClubDetailTabLabel
- onLoginPress: () => void
-}
+ clubName: string;
+ tabLabel: ClubDetailTabLabel;
+ onLoginPress: () => void;
+};
const LoginBlurOverlay = ({ clubName, tabLabel, onLoginPress }: Props) => (
<>
@@ -28,25 +27,29 @@ const LoginBlurOverlay = ({ clubName, tabLabel, onLoginPress }: Props) => (
{clubName}
- {'의 ' + tabLabel + '를 보려면'}
+ {`의 ${tabLabel}를 보려면`}
로그인이 필요해요!
[styles.loginButton, pressed && styles.loginButtonPressed]}
- onPress={onLoginPress}>
+ style={({ pressed }) => [
+ styles.loginButton,
+ pressed && styles.loginButtonPressed,
+ ]}
+ onPress={onLoginPress}
+ >
로그인 하러 가기
>
-)
+);
-export default LoginBlurOverlay
+export default LoginBlurOverlay;
const styles = StyleSheet.create({
blurWrapper: {
borderRadius: ms(16),
- overflow: 'hidden',
- position: 'absolute',
+ overflow: "hidden",
+ position: "absolute",
top: 0,
left: 0,
bottom: 0,
@@ -54,17 +57,17 @@ const styles = StyleSheet.create({
zIndex: 1,
},
loginOverlay: {
- position: 'absolute',
+ position: "absolute",
left: 0,
right: 0,
zIndex: 2,
top: vs(40),
- alignItems: 'center',
+ alignItems: "center",
},
loginText: {
...typography.bodyMRegular,
color: Colors.BODYTEXT_SUB,
- textAlign: 'center',
+ textAlign: "center",
marginTop: vs(8),
},
clubName: {
@@ -82,6 +85,6 @@ const styles = StyleSheet.create({
loginButtonText: {
...typography.bodyMSemibold,
color: Colors.BODYTEXT_MAIN,
- textDecorationLine: 'underline',
+ textDecorationLine: "underline",
},
-})
+});
diff --git a/apps/mobile/src/features/club/components/ClubDetail/RecruitTab.tsx b/apps/mobile/src/features/club/components/ClubDetail/RecruitTab.tsx
new file mode 100644
index 00000000..3f079c63
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/RecruitTab.tsx
@@ -0,0 +1,196 @@
+import dayjs from "dayjs";
+import "dayjs/locale/ko";
+import { useQuery } from "@tanstack/react-query";
+import { useContext } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import type { Club } from "@/entities/club";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { ms, s, vs } from "@/shared/utils/scale";
+import BackgroundCard from "./BackgroundCard";
+import type { ClubDetailTabLabel } from "./ClubDetailTabBar";
+import LoginBlurOverlay from "./LoginBlurOverlay";
+import RecruitmentDetailCard from "./RecruitmentDetailCard";
+
+dayjs.locale("ko");
+
+type Props = {
+ club: Club;
+ tabLabel: ClubDetailTabLabel;
+ isLoggedIn: boolean;
+ onLoginPress: () => void;
+ onPreviousPress: (representativeRecruitmentId: number | null) => void;
+};
+
+const RecruitTab = ({
+ club,
+ tabLabel,
+ isLoggedIn,
+ onLoginPress,
+ onPreviousPress,
+}: Props) => {
+ const { recruitmentService } = useContext(serviceContext);
+ const representativeQuery = useQuery(
+ ["representativeRecruitment", club.uuid],
+ () =>
+ recruitmentService.getRepresentativeRecruitment({ clubId: club.uuid }),
+ );
+ const representativeId = representativeQuery.data?.id ?? null;
+ const detailQuery = useQuery(
+ ["recruitmentDetail", representativeId],
+ () =>
+ recruitmentService.getRecruitmentDetail({
+ recruitmentId: representativeId as number,
+ }),
+ { enabled: representativeId !== null },
+ );
+ const isLoading =
+ representativeQuery.isLoading ||
+ (representativeId !== null && detailQuery.isLoading);
+ const isError = representativeQuery.isError || detailQuery.isError;
+ const content = detailQuery.data?.content;
+ const representativeUpdatedAt = representativeQuery.data?.updatedAt;
+ const updatedAt = representativeUpdatedAt
+ ? dayjs(representativeUpdatedAt)
+ : null;
+
+ return (
+
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+
+ 모집공고를 불러오지 못했어요.
+
+ {
+ representativeQuery.refetch();
+ if (representativeId !== null) detailQuery.refetch();
+ }}
+ >
+ 다시 시도
+
+
+ ) : content ? (
+ <>
+ {!isLoggedIn && (
+
+ )}
+
+ >
+ ) : (
+
+
+ 아직 등록된 모집공고가 없어요.
+
+
+ )}
+
+
+ {content && updatedAt && (
+
+
+ {updatedAt.format(
+ (updatedAt.year() === dayjs().year() ? "" : "YY년 ") +
+ "M월 D일 dddd A h시에 업데이트 되었어요",
+ )}
+
+
+ )}
+
+
+ [
+ styles.previousButton,
+ pressed && styles.pressed,
+ ]}
+ onPress={() => onPreviousPress(representativeId)}
+ >
+ 이전 공고 확인하기
+
+
+
+ );
+};
+
+export default RecruitTab;
+
+const styles = StyleSheet.create({
+ tabSection: { marginTop: vs(30), gap: vs(20) },
+ articleSection: { gap: vs(10) },
+ articleCard: {
+ minHeight: vs(200),
+ padding: s(20),
+ borderRadius: ms(15),
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.05,
+ shadowRadius: 5,
+ },
+ centerState: {
+ minHeight: vs(160),
+ justifyContent: "center",
+ alignItems: "center",
+ gap: vs(10),
+ },
+ emptyText: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_SUB,
+ textAlign: "center",
+ },
+ retryButton: {
+ paddingHorizontal: s(14),
+ paddingVertical: vs(8),
+ borderRadius: ms(16),
+ backgroundColor: Colors.POINTCOLOR_10,
+ },
+ retryText: { ...typography.bodySMedium, color: Colors.POINTCOLOR },
+ recruitFooter: { paddingHorizontal: s(5) },
+ sectionMeta: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_SUB,
+ lineHeight: vs(20),
+ },
+ previousButton: {
+ height: vs(66),
+ borderRadius: ms(12),
+ backgroundColor: Colors.WHITE,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: s(16),
+ paddingVertical: vs(6),
+ shadowColor: Colors.BLACK,
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.05,
+ shadowRadius: 5,
+ elevation: 1,
+ },
+ previousButtonText: {
+ ...typography.bodyMBold13px,
+ color: Colors.BODYTEXT_DISABLED,
+ },
+ pressed: { opacity: 0.6 },
+});
diff --git a/apps/mobile/src/features/club/components/ClubDetail/RecruitmentDetailCard.tsx b/apps/mobile/src/features/club/components/ClubDetail/RecruitmentDetailCard.tsx
new file mode 100644
index 00000000..a07aae81
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/RecruitmentDetailCard.tsx
@@ -0,0 +1,420 @@
+import { useMemo, useState } from "react";
+import {
+ Image,
+ Linking,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import LinearGradient from "react-native-linear-gradient";
+import type { MixedStyleDeclaration } from "react-native-render-html";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import {
+ formatRecruitmentDeadline,
+ formatRegularMeeting,
+ getRecruitmentApplicationUrl,
+ getRecruitmentTextPreview,
+ shouldStackActivityLocation,
+ shouldStackRegularMeetings,
+} from "@/features/club/utils/recruitmentPresentation";
+import type { RecruitmentContent } from "@/repositories/recruitment";
+import HtmlView from "@/shared/components/HtmlView";
+import ImageViewerModal from "@/shared/components/ImageViewerModal";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ content: RecruitmentContent;
+};
+
+const FULL_TEXT_BASE_STYLE: MixedStyleDeclaration = {
+ ...(typography.bodyMRegular as MixedStyleDeclaration),
+ color: Colors.BODYTEXT_SUB,
+ lineHeight: vs(21),
+};
+
+const BooleanPills = ({
+ active,
+ trueLabel = "있음",
+ falseLabel = "없음",
+ falseFirst = false,
+}: {
+ active: boolean;
+ trueLabel?: string;
+ falseLabel?: string;
+ falseFirst?: boolean;
+}) => {
+ const options = falseFirst
+ ? [
+ { label: falseLabel, value: false },
+ { label: trueLabel, value: true },
+ ]
+ : [
+ { label: trueLabel, value: true },
+ { label: falseLabel, value: false },
+ ];
+
+ return (
+
+ {options.map((option) => {
+ const selected = option.value === active;
+
+ return (
+
+
+ {option.label}
+
+
+ );
+ })}
+
+ );
+};
+
+const DetailTag = ({ text }: { text: string }) => (
+
+ {text}
+
+);
+
+const BooleanDetailRow = ({
+ label,
+ active,
+ detail,
+ trueLabel,
+ falseLabel,
+ falseFirst,
+}: {
+ label: string;
+ active: boolean;
+ detail?: string;
+ trueLabel?: string;
+ falseLabel?: string;
+ falseFirst?: boolean;
+}) => (
+
+ {label}
+
+
+ {!!detail && {detail}}
+
+
+);
+
+const RecruitmentDetailCard = ({ content }: Props) => {
+ const [fullTextExpanded, setFullTextExpanded] = useState(false);
+ const [selectedImageIndex, setSelectedImageIndex] = useState(
+ null,
+ );
+ const meetingTags = content.regular_meetings.map(formatRegularMeeting);
+ const stackMeetings = shouldStackRegularMeetings(meetingTags.length);
+ const stackLocation = shouldStackActivityLocation(
+ content.activity_location_text,
+ );
+ const fullTextPreview = useMemo(
+ () =>
+ content.full_recruitment_text
+ ? getRecruitmentTextPreview(content.full_recruitment_text)
+ : "",
+ [content.full_recruitment_text],
+ );
+ const locationTypes = ["동방", "동방 외", "미정"];
+
+ return (
+
+
+ {content.title}
+
+ {formatRecruitmentDeadline(content.deadline)}
+
+
+
+ {content.image_urls.length > 0 && (
+
+
+ {content.image_urls.map((url, index) => (
+ setSelectedImageIndex(index)}
+ >
+
+
+ ))}
+
+ {content.image_urls.length >= 3 && (
+
+ )}
+
+ )}
+
+
+
+
+
+ 정기모임 일시
+
+
+ {!stackMeetings &&
+ meetingTags.map((tag) => )}
+
+ {stackMeetings && (
+
+ {meetingTags.map((tag) => (
+
+ ))}
+
+ )}
+
+
+
+ 활동 장소
+
+
+ {locationTypes.map((type) => {
+ const selected = type === content.activity_location_type;
+ return (
+
+
+ {type}
+
+
+ );
+ })}
+
+ {!stackLocation && !!content.activity_location_text && (
+
+ )}
+
+ {stackLocation && !!content.activity_location_text && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ 가입절차
+
+ {!!content.application_url && (
+
+ Linking.openURL(
+ getRecruitmentApplicationUrl(content.application_url),
+ )
+ }
+ >
+
+ {content.application_url}
+
+
+ )}
+ {!!content.application_process && (
+ {content.application_process}
+ )}
+
+
+
+
+ {!!content.full_recruitment_text && (
+
+ 공고 본문
+
+ {fullTextExpanded ? (
+
+ ) : (
+
+ {fullTextPreview}
+
+ )}
+ setFullTextExpanded((previous) => !previous)}
+ >
+
+
+
+
+ )}
+
+ setSelectedImageIndex(null)}
+ />
+
+ );
+};
+
+export default RecruitmentDetailCard;
+
+const styles = StyleSheet.create({
+ container: { gap: vs(15) },
+ titleSection: { gap: vs(6) },
+ title: { ...typography.headerXL, color: Colors.BODYTEXT_SUB },
+ deadline: { ...typography.headerL, color: Colors.POINTCOLOR },
+ imageList: { gap: s(10) },
+ recruitmentImage: {
+ width: s(120),
+ height: s(120),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: ms(8),
+ },
+ imageFade: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ width: s(50),
+ },
+ fields: { gap: vs(15) },
+ field: { gap: vs(7) },
+ fieldValue: { gap: vs(10) },
+ fieldLabel: { ...typography.bodyMRegular, color: Colors.BODYTEXT_SUB_2 },
+ valueLine: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ alignItems: "center",
+ gap: s(7),
+ },
+ booleanPills: { flexDirection: "row", gap: s(6) },
+ booleanPill: {
+ height: vs(30),
+ paddingHorizontal: s(15),
+ borderWidth: 0.65,
+ borderColor: Colors.BADGE_UNSELECTED,
+ borderRadius: ms(20),
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ booleanPillActive: {
+ backgroundColor: Colors.POINTCOLOR,
+ borderColor: Colors.POINTCOLOR,
+ },
+ booleanText: {
+ ...typography.bodySMedium,
+ color: Colors.BADGE_UNSELECTED,
+ lineHeight: vs(17),
+ letterSpacing: ms(-0.24),
+ },
+ booleanTextActive: {
+ ...typography.bodySSemibold,
+ color: Colors.WHITE,
+ letterSpacing: 0,
+ },
+ detailTag: {
+ alignSelf: "flex-start",
+ maxWidth: "100%",
+ paddingHorizontal: s(15),
+ paddingVertical: vs(8),
+ borderWidth: 1,
+ borderColor: Colors.POINTCOLOR,
+ borderRadius: ms(10),
+ backgroundColor: Colors.POINTCOLOR_10,
+ },
+ detailTagText: { ...typography.bodySSemibold, color: Colors.BODYTEXT_SUB },
+ wrappedTags: { flexDirection: "row", flexWrap: "wrap", gap: s(7) },
+ locationTypes: { flexDirection: "row", gap: s(7) },
+ locationDetailBelow: { alignSelf: "flex-start" },
+ bodyText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ lineHeight: vs(21),
+ },
+ linkRow: { alignSelf: "flex-start", maxWidth: "100%" },
+ linkText: {
+ ...typography.bodyMMedium,
+ color: Colors.POINTCOLOR,
+ textDecorationLine: "underline",
+ flexShrink: 1,
+ },
+ fullTextContent: { gap: vs(4) },
+ fullTextPreview: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ lineHeight: vs(21),
+ },
+ fullTextToggle: {
+ width: ms(44),
+ height: vs(24),
+ alignSelf: "center",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+});
diff --git a/src/features/club/components/ClubDetail/ReviewKeywordBar.tsx b/apps/mobile/src/features/club/components/ClubDetail/ReviewKeywordBar.tsx
similarity index 50%
rename from src/features/club/components/ClubDetail/ReviewKeywordBar.tsx
rename to apps/mobile/src/features/club/components/ClubDetail/ReviewKeywordBar.tsx
index 5e06e294..f66ed0e7 100644
--- a/src/features/club/components/ClubDetail/ReviewKeywordBar.tsx
+++ b/apps/mobile/src/features/club/components/ClubDetail/ReviewKeywordBar.tsx
@@ -1,19 +1,21 @@
-import React from 'react'
-import { StyleSheet, Text, View } from 'react-native'
-import LinearGradient from 'react-native-linear-gradient'
+import { StyleSheet, Text, View } from "react-native";
+import LinearGradient from "react-native-linear-gradient";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
type Props = {
- keyword: { iconUri: string; title: string; totalUpvotes: number }
- totalReviews: number
- themeColor: string
-}
+ keyword: { iconUri: string; title: string; totalUpvotes: number };
+ totalReviews: number;
+ themeColor: string;
+};
const ReviewKeywordBar = ({ keyword, totalReviews, themeColor }: Props) => {
- const ratio = totalReviews > 0 ? Math.min(Math.max(keyword.totalUpvotes / totalReviews, 0), 1) : 0
+ const ratio =
+ totalReviews > 0
+ ? Math.min(Math.max(keyword.totalUpvotes / totalReviews, 0), 1)
+ : 0;
return (
@@ -26,16 +28,23 @@ const ReviewKeywordBar = ({ keyword, totalReviews, themeColor }: Props) => {
/>
- {keyword.iconUri?.trim()}
-
+
+ {keyword.iconUri?.trim()}
+
+
{keyword.title}
- {keyword.totalUpvotes}
+
+ {keyword.totalUpvotes}
+
- )
-}
+ );
+};
const styles = StyleSheet.create({
bar: {
@@ -43,25 +52,25 @@ const styles = StyleSheet.create({
borderRadius: ms(24),
borderWidth: 0.5,
backgroundColor: Colors.WHITE,
- overflow: 'hidden',
- justifyContent: 'center',
+ overflow: "hidden",
+ justifyContent: "center",
},
fill: {
- position: 'absolute',
+ position: "absolute",
top: 0,
bottom: 0,
left: 0,
},
content: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
paddingLeft: s(15),
paddingRight: s(20),
},
left: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
flexShrink: 1,
},
icon: {
@@ -75,6 +84,6 @@ const styles = StyleSheet.create({
color: Colors.BODYTEXT_SUB,
marginLeft: s(8),
},
-})
+});
-export default ReviewKeywordBar
+export default ReviewKeywordBar;
diff --git a/apps/mobile/src/features/club/components/ClubDetail/ReviewTab.tsx b/apps/mobile/src/features/club/components/ClubDetail/ReviewTab.tsx
new file mode 100644
index 00000000..b21590a6
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/ReviewTab.tsx
@@ -0,0 +1,150 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+
+import type { Club } from "@/entities/club";
+import type { CategoryDetail } from "@/shared/constants/category";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, vs } from "@/shared/utils/scale";
+import BackgroundCard from "./BackgroundCard";
+import type { ClubDetailTabLabel } from "./ClubDetailTabBar";
+import LoginBlurOverlay from "./LoginBlurOverlay";
+import ReviewKeywordBar from "./ReviewKeywordBar";
+
+type Props = {
+ club: Club;
+ tabLabel: ClubDetailTabLabel;
+ categoryDetail: CategoryDetail;
+ isLoggedIn: boolean;
+ onLoginPress: () => void;
+ onWriteReview: () => void;
+};
+
+const ReviewTab = ({
+ club,
+ tabLabel,
+ categoryDetail,
+ isLoggedIn,
+ onLoginPress,
+ onWriteReview,
+}: Props) => {
+ const sortedKeywords = [...club.reviewKeywords].sort(
+ (a, b) => b.totalUpvotes - a.totalUpvotes,
+ );
+
+ return (
+
+
+ {!isLoggedIn && (
+
+ )}
+ 이런 점이 좋았어요
+ {club.totalReviews > 0 && (
+ {`현재까지 ${club.totalReviews}명이 참여했어요`}
+ )}
+ {sortedKeywords.length > 0 ? (
+
+ {sortedKeywords.map((keyword) => (
+
+ ))}
+
+ ) : (
+
+ {`혹시 ${club.name}에서 활동하셨나요?`}
+
+ 다음에 들어올 부원들을 위해 경험을 공유해주세요!
+
+
+ )}
+
+
+
+ {({ pressed }) => (
+
+
+
+ 동아리 활동 후기 남기기
+ {`현재까지 ${club.totalReviews}명이 참여했어요`}
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default ReviewTab;
+
+const styles = StyleSheet.create({
+ tabSection: {
+ marginTop: vs(16),
+ gap: vs(12),
+ },
+ sectionTitle: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_SUB,
+ },
+ sectionMeta: {
+ ...typography.bodySMedium,
+ color: Colors.BODYTEXT_SUB,
+ marginTop: vs(4),
+ },
+ pillsContainer: {
+ marginTop: vs(12),
+ gap: vs(5),
+ },
+ emptyReview: {
+ minHeight: vs(80),
+ justifyContent: "center",
+ alignItems: "center",
+ marginTop: vs(12),
+ },
+ emptyText: {
+ ...typography.bodySMedium,
+ color: Colors.BODYTEXT_SUB,
+ textAlign: "center",
+ },
+ ctaCard: {
+ marginTop: vs(4),
+ },
+ ctaCardPressed: {
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ ctaRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ ctaTitle: {
+ ...typography.bodyMSemibold,
+ color: Colors.POINTCOLOR,
+ },
+ ctaMeta: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_SUB_2,
+ marginTop: vs(4),
+ },
+});
diff --git a/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx b/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx
new file mode 100644
index 00000000..81b47753
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/VerificationMarkButton.tsx
@@ -0,0 +1,43 @@
+import { Pressable, StyleSheet } from "react-native";
+
+import type { OfficialVerificationStatus } from "@/entities/club";
+import VerificationMark, {
+ DEFAULT_VERIFICATION_MARK_SIZE,
+} from "@/features/club/components/VerificationMark";
+import { ms } from "@/shared/utils/scale";
+
+type Props = {
+ status?: OfficialVerificationStatus;
+ onPress: () => void;
+};
+
+const VerificationMarkButton = ({ status, onPress }: Props) => {
+ if (status !== "VERIFIED" && status !== "PENDING") return null;
+
+ return (
+ [styles.button, pressed && styles.pressed]}
+ >
+
+
+ );
+};
+
+export default VerificationMarkButton;
+
+const styles = StyleSheet.create({
+ button: {
+ width: ms(DEFAULT_VERIFICATION_MARK_SIZE),
+ height: ms(DEFAULT_VERIFICATION_MARK_SIZE),
+ justifyContent: "center",
+ alignItems: "center",
+ flexShrink: 0,
+ },
+ pressed: {
+ opacity: 0.5,
+ },
+});
diff --git a/apps/mobile/src/features/club/components/ClubDetail/VerificationOverlay.tsx b/apps/mobile/src/features/club/components/ClubDetail/VerificationOverlay.tsx
new file mode 100644
index 00000000..26b7bdd8
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubDetail/VerificationOverlay.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useRef } from "react";
+import {
+ Animated,
+ Easing,
+ type LayoutChangeEvent,
+ type StyleProp,
+ StyleSheet,
+ Text,
+ type ViewStyle,
+} from "react-native";
+
+import type { OfficialVerificationStatus } from "@/entities/club";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ status?: OfficialVerificationStatus;
+ visible: boolean;
+ style?: StyleProp;
+ onLayout?: (event: LayoutChangeEvent) => void;
+};
+
+const VerificationOverlay = ({ status, visible, style, onLayout }: Props) => {
+ const opacity = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ Animated.timing(opacity, {
+ toValue: visible ? 1 : 0,
+ duration: 300,
+ easing: Easing.inOut(Easing.ease),
+ useNativeDriver: true,
+ }).start();
+ }, [visible, opacity]);
+
+ if (!status || status === "UNVERIFIED") return null;
+
+ return (
+
+ {status === "VERIFIED" ? (
+
+ {"올클 "}
+ 공식인증이 완료
+ {"된 동아리에요!"}
+
+ ) : (
+
+ {"아직 올클 "}
+ 공식인증 심사중
+ {"인 동아리에요."}
+
+ )}
+
+ );
+};
+
+export default VerificationOverlay;
+
+const styles = StyleSheet.create({
+ overlay: {
+ position: "absolute",
+ zIndex: 10,
+ elevation: 10,
+ paddingHorizontal: s(15),
+ paddingVertical: vs(10),
+ borderRadius: s(10),
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ overlayText: {
+ ...typography.bodySMedium,
+ color: Colors.BODYTEXT_SUB,
+ textAlign: "center",
+ },
+ highlightText: {
+ color: Colors.POINTCOLOR,
+ },
+});
diff --git a/apps/mobile/src/features/club/components/ClubList/ClubCard.tsx b/apps/mobile/src/features/club/components/ClubList/ClubCard.tsx
new file mode 100644
index 00000000..7e5ff3a1
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubList/ClubCard.tsx
@@ -0,0 +1,167 @@
+import { Image, StyleSheet, Text, View } from "react-native";
+import { Pressable } from "react-native-gesture-handler";
+import type { Category } from "@/entities/category";
+import type { Club } from "@/entities/club";
+import ReviewKeywordPill from "@/shared/components/ReviewKeywordPill";
+import { CategoryMap } from "@/shared/constants/category";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import useSaveClub from "@/shared/hooks/useSaveClub";
+import { getClubSummaryWithAffiliation } from "@/shared/utils/club";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ club: Club;
+ category?: Category["name"];
+ onPress?: () => void;
+};
+
+const ClubCard = ({ club, category, onPress }: Props) => {
+ const categoryDetail = category ? CategoryMap[category] : undefined;
+ const borderColor = categoryDetail
+ ? categoryDetail.themeColor
+ : Colors.BUTTON_SELECTED;
+ const backgroundColor = categoryDetail
+ ? categoryDetail.backgroundColor
+ : Colors.POINTCOLOR_10;
+
+ const { isSaved, handleToggle } = useSaveClub(club);
+
+ const cardInner = (
+ <>
+
+
+
+
+
+
+ {club.name}
+
+
+ {getClubSummaryWithAffiliation(club)}
+
+
+
+ {club.reviewKeywords && club.reviewKeywords.length > 0 ? (
+ club.reviewKeywords
+ .slice(0, 2)
+ .map((keyword) => (
+
+ ))
+ ) : (
+
+ )}
+
+
+ >
+ );
+
+ return (
+
+ {onPress ? (
+ [
+ styles.cardContent,
+ { opacity: pressed ? 0.5 : 1 },
+ ]}
+ onPress={onPress}
+ >
+ {cardInner}
+
+ ) : (
+ {cardInner}
+ )}
+ [
+ styles.heartButton,
+ { opacity: pressed ? 0.5 : 1 },
+ ]}
+ >
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "row",
+ width: "100%",
+ height: s(90),
+ },
+ cardContent: {
+ flex: 1,
+ flexDirection: "row",
+ },
+ imageWrapper: {
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: Colors.WHITE,
+ width: s(90),
+ height: s(90),
+ marginRight: s(15),
+ borderWidth: 0.5,
+ borderRadius: ms(8),
+ },
+ image: {
+ width: s(70),
+ height: s(70),
+ borderRadius: ms(8),
+ },
+ contentWrapper: {
+ flex: 1,
+ flexDirection: "column",
+ },
+ textGroup: {
+ flex: 1,
+ },
+ title: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_MAIN,
+ width: s(195),
+ marginBottom: vs(4),
+ },
+ description: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ reviewView: {
+ flexDirection: "row",
+ minHeight: ms(21),
+ gap: ms(4),
+ },
+ heartButton: {
+ position: "absolute",
+ right: 0,
+ top: 0,
+ },
+ heartIcon: {
+ width: ms(20),
+ height: ms(20),
+ },
+});
+
+export default ClubCard;
diff --git a/apps/mobile/src/features/club/components/ClubList/ClubList.tsx b/apps/mobile/src/features/club/components/ClubList/ClubList.tsx
new file mode 100644
index 00000000..e2932706
--- /dev/null
+++ b/apps/mobile/src/features/club/components/ClubList/ClubList.tsx
@@ -0,0 +1,99 @@
+import {
+ Image,
+ StyleSheet,
+ Text,
+ useWindowDimensions,
+ View,
+} from "react-native";
+import { FlatList } from "react-native-gesture-handler";
+import type { Category } from "@/entities/category";
+import type { Club } from "@/entities/club";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
+import ClubCard from "./ClubCard";
+import ClubListSkeleton from "./ClubListSkeleton";
+
+type Props = {
+ clubs: Club[] | undefined;
+ category?: Category["name"];
+ openDetailPage: (club: Club) => void;
+ emptyPlaceholder: string;
+ isLoading?: boolean;
+};
+
+const ClubList = ({
+ clubs,
+ category,
+ openDetailPage,
+ emptyPlaceholder,
+ isLoading,
+}: Props) => {
+ const { width } = useWindowDimensions();
+ const normalizedEmptyPlaceholder = emptyPlaceholder.replace(/\\n/g, "\n");
+
+ if (isLoading) return ;
+ if (!clubs) return null;
+ if (clubs.length === 0) {
+ return (
+
+
+ {normalizedEmptyPlaceholder}
+
+ );
+ }
+
+ return (
+ item.id}
+ data={clubs}
+ style={styles.list}
+ contentContainerStyle={styles.listContent}
+ renderItem={({ item }) => (
+
+ openDetailPage(item)}
+ />
+
+ )}
+ initialNumToRender={6}
+ maxToRenderPerBatch={1}
+ updateCellsBatchingPeriod={100}
+ windowSize={7}
+ />
+ );
+};
+
+const styles = StyleSheet.create({
+ emptyContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ emptyImage: {
+ width: s(122),
+ height: s(99),
+ },
+ emptyText: {
+ ...typography.bodySRegular,
+ textAlign: "center",
+ marginTop: vs(20),
+ color: Colors.BODYTEXT_MAIN,
+ },
+ list: {
+ flex: 1,
+ width: "100%",
+ },
+ listContent: {
+ gap: vs(25),
+ paddingTop: vs(8),
+ paddingBottom: vs(20),
+ },
+});
+
+export default ClubList;
diff --git a/src/features/club/components/ClubList/ClubListSkeleton.tsx b/apps/mobile/src/features/club/components/ClubList/ClubListSkeleton.tsx
similarity index 54%
rename from src/features/club/components/ClubList/ClubListSkeleton.tsx
rename to apps/mobile/src/features/club/components/ClubList/ClubListSkeleton.tsx
index 30f41d6b..1236d807 100644
--- a/src/features/club/components/ClubList/ClubListSkeleton.tsx
+++ b/apps/mobile/src/features/club/components/ClubList/ClubListSkeleton.tsx
@@ -1,11 +1,14 @@
-import { View } from 'react-native'
-import SkeletonPlaceholder from 'react-native-skeleton-placeholder'
+import { View } from "react-native";
+import SkeletonPlaceholder from "react-native-skeleton-placeholder";
-import { Colors } from '@/shared/constants/colors'
-import { ms, s, vs } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { ms, s, vs } from "@/shared/utils/scale";
const ClubCardSkeleton = () => (
-
+
(
/>
-
+
-
+
-)
+);
type Props = {
- count?: number
-}
+ count?: number;
+};
const ClubListSkeleton = ({ count = 6 }: Props) => (
{Array.from({ length: count }).map((_, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Skeleton rows are static, stateless placeholders.
))}
-)
+);
-export default ClubListSkeleton
+export default ClubListSkeleton;
diff --git a/src/features/club/components/MinDurationToggle/MinDurationToggle.tsx b/apps/mobile/src/features/club/components/MinDurationToggle/MinDurationToggle.tsx
similarity index 67%
rename from src/features/club/components/MinDurationToggle/MinDurationToggle.tsx
rename to apps/mobile/src/features/club/components/MinDurationToggle/MinDurationToggle.tsx
index aae8d973..198e2068 100644
--- a/src/features/club/components/MinDurationToggle/MinDurationToggle.tsx
+++ b/apps/mobile/src/features/club/components/MinDurationToggle/MinDurationToggle.tsx
@@ -1,31 +1,40 @@
-import React from 'react'
-import { StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { vs } from '@/shared/utils/scale'
-import { MinDurationToggleItem } from './MinDurationToggleItem'
+import {
+ type StyleProp,
+ StyleSheet,
+ Text,
+ View,
+ type ViewStyle,
+} from "react-native";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { vs } from "@/shared/utils/scale";
+import { MinDurationToggleItem } from "./MinDurationToggleItem";
import {
MIN_DURATION_OPTIONS,
+ type MinDurationValue,
THUMB_SIZE,
useMinDurationToggle,
- type MinDurationValue,
-} from './useMinDurationToggle'
+} from "./useMinDurationToggle";
export type MinDurationToggleProps = {
- style?: StyleProp
- value: MinDurationValue
- onChange: (value: MinDurationValue) => void
-}
+ style?: StyleProp;
+ value: MinDurationValue;
+ onChange: (value: MinDurationValue) => void;
+};
-const TRACK_HEIGHT = vs(4)
+const TRACK_HEIGHT = vs(4);
const MinDurationToggleHeader = () => (
최소활동기간
-)
+);
-export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleProps) => {
+export const MinDurationToggle = ({
+ style,
+ value,
+ onChange,
+}: MinDurationToggleProps) => {
const {
labelWidths,
stepCenters,
@@ -35,9 +44,9 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
handleLabelLayout,
trackStart,
trackWidth,
- } = useMinDurationToggle({ value, onChange })
+ } = useMinDurationToggle({ value, onChange });
- const selectedValueSet = new Set(selectedValues)
+ const selectedValueSet = new Set(selectedValues);
return (
@@ -56,9 +65,9 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
/>
{MIN_DURATION_OPTIONS.slice(0, -1).map((option, index) => {
- const nextOption = MIN_DURATION_OPTIONS[index + 1]
- const startCenterX = stepCenters[index]
- const endCenterX = stepCenters[index + 1]
+ const nextOption = MIN_DURATION_OPTIONS[index + 1];
+ const startCenterX = stepCenters[index];
+ const endCenterX = stepCenters[index + 1];
if (
nextOption === undefined ||
@@ -67,7 +76,7 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
startCenterX === undefined ||
endCenterX === undefined
) {
- return null
+ return null;
}
return (
@@ -81,14 +90,14 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
},
]}
/>
- )
+ );
})}
{stepCenters.map((centerX, index) => {
- const option = MIN_DURATION_OPTIONS[index]
+ const option = MIN_DURATION_OPTIONS[index];
if (option === undefined) {
- return null
+ return null;
}
return (
@@ -98,7 +107,7 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
onPress={() => handleToggleStep(option.value)}
selected={selectedValueSet.has(option.value)}
/>
- )
+ );
})}
@@ -106,7 +115,7 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
{MIN_DURATION_OPTIONS.map((option, index) => (
handleLabelLayout(index, event)}
+ onLayout={(event) => handleLabelLayout(index, event)}
style={[
styles.labelSlot,
index === 0
@@ -114,9 +123,12 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
: index === MIN_DURATION_OPTIONS.length - 1
? styles.lastLabelSlot
: {
- left: (stepCenters[index] ?? 0) - (labelWidths[option.value] ?? 0) / 2,
+ left:
+ (stepCenters[index] ?? 0) -
+ (labelWidths[option.value] ?? 0) / 2,
},
- ]}>
+ ]}
+ >
{option.label}
))}
@@ -125,48 +137,48 @@ export const MinDurationToggle = ({ style, value, onChange }: MinDurationToggleP
원하는 기간을 모두 선택해보세요.
- )
-}
+ );
+};
const styles = StyleSheet.create({
container: {
gap: vs(12),
},
header: {
- alignItems: 'flex-start',
+ alignItems: "flex-start",
},
toggleArea: {
gap: vs(8),
- width: '100%',
+ width: "100%",
},
trackArea: {
height: THUMB_SIZE,
- justifyContent: 'center',
- position: 'relative',
- width: '100%',
+ justifyContent: "center",
+ position: "relative",
+ width: "100%",
},
track: {
backgroundColor: Colors.GRAY,
borderRadius: TRACK_HEIGHT / 2,
height: TRACK_HEIGHT,
- position: 'absolute',
+ position: "absolute",
top: (THUMB_SIZE - TRACK_HEIGHT) / 2,
},
connectedTrack: {
backgroundColor: Colors.POINTCOLOR,
borderRadius: TRACK_HEIGHT / 2,
height: TRACK_HEIGHT,
- position: 'absolute',
+ position: "absolute",
top: (THUMB_SIZE - TRACK_HEIGHT) / 2,
},
labelsRow: {
- position: 'relative',
- width: '100%',
+ position: "relative",
+ width: "100%",
height: vs(18),
},
labelSlot: {
- alignItems: 'center',
- position: 'absolute',
+ alignItems: "center",
+ position: "absolute",
top: 0,
},
firstLabelSlot: {
@@ -178,7 +190,7 @@ const styles = StyleSheet.create({
labelText: {
...typography.bodySRegular,
color: Colors.BODYTEXT_SUB,
- textAlign: 'center',
+ textAlign: "center",
},
title: {
...typography.bodySMedium,
@@ -188,6 +200,6 @@ const styles = StyleSheet.create({
description: {
...typography.bodySRegular,
color: Colors.BODYTEXT_SUB,
- textAlign: 'center',
+ textAlign: "center",
},
-})
+});
diff --git a/src/features/club/components/MinDurationToggle/MinDurationToggleItem.tsx b/apps/mobile/src/features/club/components/MinDurationToggle/MinDurationToggleItem.tsx
similarity index 68%
rename from src/features/club/components/MinDurationToggle/MinDurationToggleItem.tsx
rename to apps/mobile/src/features/club/components/MinDurationToggle/MinDurationToggleItem.tsx
index 7fe6b0fb..bece2b17 100644
--- a/src/features/club/components/MinDurationToggle/MinDurationToggleItem.tsx
+++ b/apps/mobile/src/features/club/components/MinDurationToggle/MinDurationToggleItem.tsx
@@ -1,19 +1,18 @@
-import React from 'react'
-import { Pressable, StyleSheet, View } from 'react-native'
-import { Colors } from '@/shared/constants/colors'
-import { ms } from '@/shared/utils/scale'
-import { THUMB_SIZE } from './useMinDurationToggle'
+import { Pressable, StyleSheet, View } from "react-native";
+import { Colors } from "@/shared/constants/colors";
+import { ms } from "@/shared/utils/scale";
+import { THUMB_SIZE } from "./useMinDurationToggle";
type MinDurationToggleItemProps = {
- centerX: number
- selected: boolean
- onPress?: () => void
-}
+ centerX: number;
+ selected: boolean;
+ onPress?: () => void;
+};
-const STEP_DOT_SIZE = ms(12)
-const STEP_DOT_SELECTED_SIZE = ms(16)
-const STEP_DOT_BORDER_WIDTH = 2
-const STEP_DOT_INNER_SIZE = ms(8)
+const STEP_DOT_SIZE = ms(12);
+const STEP_DOT_SELECTED_SIZE = ms(16);
+const STEP_DOT_BORDER_WIDTH = 2;
+const STEP_DOT_INNER_SIZE = ms(8);
export const MinDurationToggleItem = ({
centerX,
@@ -29,21 +28,22 @@ export const MinDurationToggleItem = ({
{
left: centerX - (selected ? STEP_DOT_SELECTED_SIZE : STEP_DOT_SIZE) / 2,
},
- ]}>
+ ]}
+ >
{selected ? : null}
-)
+);
const styles = StyleSheet.create({
stepDot: {
- alignItems: 'center',
+ alignItems: "center",
backgroundColor: Colors.WHITE,
borderColor: Colors.GRAY,
borderRadius: STEP_DOT_SIZE / 2,
borderWidth: STEP_DOT_BORDER_WIDTH,
height: STEP_DOT_SIZE,
- justifyContent: 'center',
- position: 'absolute',
+ justifyContent: "center",
+ position: "absolute",
top: (THUMB_SIZE - STEP_DOT_SIZE) / 2,
width: STEP_DOT_SIZE,
zIndex: 1,
@@ -61,4 +61,4 @@ const styles = StyleSheet.create({
height: STEP_DOT_INNER_SIZE,
width: STEP_DOT_INNER_SIZE,
},
-})
+});
diff --git a/apps/mobile/src/features/club/components/MinDurationToggle/index.ts b/apps/mobile/src/features/club/components/MinDurationToggle/index.ts
new file mode 100644
index 00000000..9e363468
--- /dev/null
+++ b/apps/mobile/src/features/club/components/MinDurationToggle/index.ts
@@ -0,0 +1,3 @@
+export type { MinDurationToggleProps } from "./MinDurationToggle";
+export { MinDurationToggle } from "./MinDurationToggle";
+export type { MinDurationValue } from "./useMinDurationToggle";
diff --git a/apps/mobile/src/features/club/components/MinDurationToggle/useMinDurationToggle.ts b/apps/mobile/src/features/club/components/MinDurationToggle/useMinDurationToggle.ts
new file mode 100644
index 00000000..6a73fd94
--- /dev/null
+++ b/apps/mobile/src/features/club/components/MinDurationToggle/useMinDurationToggle.ts
@@ -0,0 +1,130 @@
+import { useCallback, useMemo, useState } from "react";
+import type { LayoutChangeEvent } from "react-native";
+import { ms } from "@/shared/utils/scale";
+
+export const THUMB_SIZE = ms(10);
+export const MIN_DURATION_OPTIONS = [
+ { value: "0", label: "없음(0학기)" },
+ { value: "1", label: "1학기" },
+ { value: "2", label: "2학기" },
+ { value: "3_plus", label: "3학기 이상" },
+] as const;
+
+export type MinDurationStepValue =
+ (typeof MIN_DURATION_OPTIONS)[number]["value"];
+export type MinDurationValue = MinDurationStepValue[];
+
+const MIN_DURATION_ORDER = MIN_DURATION_OPTIONS.map((option) => option.value);
+const MIN_DURATION_OPTION_COUNT = MIN_DURATION_OPTIONS.length;
+
+type LabelWidths = Partial>;
+type UseMinDurationToggleOptions = {
+ value: MinDurationValue;
+ onChange: (value: MinDurationValue) => void;
+};
+
+const getValidSelectedValues = (value: MinDurationValue) => {
+ if (value.length === 0) {
+ return [];
+ }
+
+ return Array.from(
+ new Set(
+ value.filter((durationValue) =>
+ MIN_DURATION_ORDER.includes(durationValue as MinDurationStepValue),
+ ),
+ ),
+ ).sort(
+ (left, right) =>
+ MIN_DURATION_ORDER.indexOf(left as MinDurationStepValue) -
+ MIN_DURATION_ORDER.indexOf(right as MinDurationStepValue),
+ );
+};
+
+export const useMinDurationToggle = ({
+ value,
+ onChange,
+}: UseMinDurationToggleOptions) => {
+ const selectedValues = getValidSelectedValues(value);
+
+ const [labelsContainerWidth, setLabelsContainerWidth] = useState(0);
+ const [labelWidths, setLabelWidths] = useState({});
+
+ const handleToggleStep = useCallback(
+ (durationValue: MinDurationStepValue) => {
+ const nextSelectedValues = selectedValues.includes(durationValue)
+ ? selectedValues.filter(
+ (selectedValue) => selectedValue !== durationValue,
+ )
+ : [...selectedValues, durationValue].sort(
+ (left, right) =>
+ MIN_DURATION_ORDER.indexOf(left as MinDurationStepValue) -
+ MIN_DURATION_ORDER.indexOf(right as MinDurationStepValue),
+ );
+
+ onChange(nextSelectedValues);
+ },
+ [onChange, selectedValues],
+ );
+
+ const handleLabelsContainerLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const { width } = event.nativeEvent.layout;
+ setLabelsContainerWidth((prev) => (prev === width ? prev : width));
+ },
+ [],
+ );
+
+ const handleLabelLayout = useCallback(
+ (index: number, event: LayoutChangeEvent) => {
+ const { width } = event.nativeEvent.layout;
+ const option = MIN_DURATION_OPTIONS[index];
+
+ if (option === undefined) {
+ return;
+ }
+
+ setLabelWidths((prev) => {
+ if (prev[option.value] === width) {
+ return prev;
+ }
+
+ return {
+ ...prev,
+ [option.value]: width,
+ };
+ });
+ },
+ [],
+ );
+
+ const firstLabelWidth = labelWidths[MIN_DURATION_OPTIONS[0].value] ?? 0;
+ const lastLabelWidth =
+ labelWidths[MIN_DURATION_OPTIONS[MIN_DURATION_OPTION_COUNT - 1].value] ?? 0;
+ const trackStart = firstLabelWidth / 2;
+ const trackEnd = Math.max(
+ labelsContainerWidth - lastLabelWidth / 2,
+ trackStart,
+ );
+ const trackWidth = Math.max(trackEnd - trackStart, 0);
+ const stepCenters = useMemo(
+ () =>
+ MIN_DURATION_OPTIONS.map(
+ (_, index) =>
+ trackStart + (trackWidth * index) / (MIN_DURATION_OPTION_COUNT - 1),
+ ),
+ [trackStart, trackWidth],
+ );
+
+ return {
+ labelWidths,
+ labelsContainerWidth,
+ stepCenters,
+ selectedValues,
+ handleToggleStep,
+ handleLabelsContainerLayout,
+ handleLabelLayout,
+ trackStart,
+ trackWidth,
+ };
+};
diff --git a/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx b/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx
new file mode 100644
index 00000000..145406aa
--- /dev/null
+++ b/apps/mobile/src/features/club/components/OfficialVerificationRequestButton.tsx
@@ -0,0 +1,161 @@
+import { Image, Pressable, StyleSheet, Text, View } from "react-native";
+
+import type { OfficialVerificationStatus } from "@/entities/club";
+import { getOfficialVerificationButtonState } from "@/features/club/utils/officialVerificationRequest";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ status?: OfficialVerificationStatus;
+ isStatusLoading: boolean;
+ isRequesting: boolean;
+ isRetryLimitReached: boolean;
+ isRetryLimitAcknowledged: boolean;
+ onPress: () => void;
+};
+
+const pendingIndicatorSource =
+ require("@/assets/icons/clubInfo/official-verification-pending-indicator.png") as number;
+const completeIndicatorSource =
+ require("@/assets/icons/clubInfo/official-verification-complete-indicator.png") as number;
+
+const PENDING_INDICATOR_SIZE = 13.66;
+const COMPLETE_INDICATOR_FRAME_WIDTH = 10;
+const COMPLETE_INDICATOR_FRAME_HEIGHT = 6.667;
+const COMPLETE_INDICATOR_HORIZONTAL_OVERFLOW = 1;
+const COMPLETE_INDICATOR_VERTICAL_OVERFLOW = 1;
+
+const OfficialVerificationRequestButton = ({
+ status,
+ isStatusLoading,
+ isRequesting,
+ isRetryLimitReached,
+ isRetryLimitAcknowledged,
+ onPress,
+}: Props) => {
+ const buttonState = getOfficialVerificationButtonState({
+ status,
+ isStatusLoading,
+ isRequesting,
+ isRetryLimitReached,
+ isRetryLimitAcknowledged,
+ });
+ const isStatusButton = buttonState.variant === "status";
+ const isFinalRejection = buttonState.variant === "final";
+ const showPendingIndicator =
+ status === "PENDING" || (isRequesting && !isFinalRejection);
+ const showVerifiedIndicator = status === "VERIFIED";
+
+ return (
+ [
+ styles.button,
+ isStatusButton && styles.statusButton,
+ isFinalRejection && styles.finalRejectionButton,
+ pressed && !buttonState.disabled && styles.buttonPressed,
+ ]}
+ >
+ {({ pressed }) => (
+
+
+ {buttonState.label}
+
+ {showPendingIndicator ? (
+
+ ) : null}
+ {showVerifiedIndicator ? (
+
+
+
+ ) : null}
+
+ )}
+
+ );
+};
+
+export default OfficialVerificationRequestButton;
+
+const styles = StyleSheet.create({
+ button: {
+ width: "100%",
+ height: vs(33),
+ paddingHorizontal: s(10),
+ alignItems: "center",
+ justifyContent: "center",
+ borderWidth: 1,
+ borderColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ backgroundColor: Colors.WHITE,
+ },
+ statusButton: {
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ finalRejectionButton: {
+ borderWidth: 0,
+ backgroundColor: Colors.BADGE_UNSELECTED,
+ },
+ buttonPressed: {
+ borderColor: Colors.BUTTON_PUSH,
+ },
+ content: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: s(5),
+ },
+ pendingIndicator: {
+ width: ms(PENDING_INDICATOR_SIZE),
+ height: ms(PENDING_INDICATOR_SIZE),
+ },
+ completeIndicatorFrame: {
+ width: ms(COMPLETE_INDICATOR_FRAME_WIDTH),
+ height: ms(COMPLETE_INDICATOR_FRAME_HEIGHT),
+ },
+ completeIndicator: {
+ position: "absolute",
+ top: ms(-COMPLETE_INDICATOR_VERTICAL_OVERFLOW),
+ left: ms(-COMPLETE_INDICATOR_HORIZONTAL_OVERFLOW),
+ width: ms(
+ COMPLETE_INDICATOR_FRAME_WIDTH +
+ COMPLETE_INDICATOR_HORIZONTAL_OVERFLOW * 2,
+ ),
+ height: ms(
+ COMPLETE_INDICATOR_FRAME_HEIGHT +
+ COMPLETE_INDICATOR_VERTICAL_OVERFLOW * 2,
+ ),
+ },
+ label: {
+ ...typography.bodyMSemibold,
+ color: Colors.POINTCOLOR,
+ },
+ labelPressed: {
+ color: Colors.BUTTON_PUSH,
+ },
+ finalRejectionLabel: {
+ color: Colors.TEXTBOX_SELECTED,
+ },
+});
diff --git a/src/features/club/components/PopularClubs/PopularClubs.tsx b/apps/mobile/src/features/club/components/PopularClubs/PopularClubs.tsx
similarity index 56%
rename from src/features/club/components/PopularClubs/PopularClubs.tsx
rename to apps/mobile/src/features/club/components/PopularClubs/PopularClubs.tsx
index f5daeb54..9f9b7fa9 100644
--- a/src/features/club/components/PopularClubs/PopularClubs.tsx
+++ b/apps/mobile/src/features/club/components/PopularClubs/PopularClubs.tsx
@@ -1,45 +1,50 @@
-import { useQuery } from '@tanstack/react-query'
-import React, { useContext } from 'react'
-import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { useQuery } from "@tanstack/react-query";
+import React, { useContext } from "react";
+import { Pressable, StyleSheet, Text, View } from "react-native";
-import { ClubRanking } from '@/entities/club'
-import { Colors } from '@/shared/constants/colors'
-import { SCREEN_TYPE } from '@/shared/constants/screen'
-import { serviceContext } from '@/shared/contexts/serviceContext'
-import { typography } from '@/shared/constants/typography'
-import useClickEventLog from '@/shared/hooks/useClickEventLog'
-import { navigation } from '@/shared/utils/navigation'
-import { s, vs } from '@/shared/utils/scale'
+import type { ClubRanking } from "@/entities/club";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import useClickEventLog from "@/shared/hooks/useClickEventLog";
+import { navigation } from "@/shared/utils/navigation";
+import { s, vs } from "@/shared/utils/scale";
-const TOP_K = 5
+const TOP_K = 5;
const PLACEHOLDER_ITEMS = Array.from({ length: TOP_K }, (_, i) => ({
ranking: i + 1,
clubId: `placeholder-${i + 1}`,
- clubName: '---',
-}))
+ clubName: "---",
+}));
const PopularClubs = () => {
- const { data: clubRankings, isLoading } = useClubRankings()
- const { logClickEvent } = useClickEventLog()
+ const { data: clubRankings, isLoading } = useClubRankings();
+ const { logClickEvent } = useClickEventLog();
- const handlePress = ({ clubId, ranking, clubName, category }: ClubRanking) => {
+ const handlePress = ({
+ clubId,
+ ranking,
+ clubName,
+ category,
+ }: ClubRanking) => {
logClickEvent({
- screen_name: 'search_screen',
- screen_component_name: 'popularClubs',
+ screen_name: "search_screen",
+ screen_component_name: "popularClubs",
club_name: clubName,
ranking: `${ranking}`,
- })
+ });
navigation.navigate(SCREEN_TYPE.CLUB_DETAIL, {
uuid: clubId,
category,
- entry_point: 'popular_clubs',
- })
- }
+ entry_point: "popular_clubs",
+ });
+ };
- const items = isLoading || !clubRankings ? PLACEHOLDER_ITEMS : clubRankings
- const isInteractive = !isLoading && !!clubRankings
+ const items = isLoading || !clubRankings ? PLACEHOLDER_ITEMS : clubRankings;
+ const isInteractive = !isLoading && !!clubRankings;
return (
@@ -58,7 +63,8 @@ const PopularClubs = () => {
isInteractive && clubRankings
? () => handlePress(clubRankings[idx])
: undefined
- }>
+ }
+ >
{item.ranking}
{item.clubName}
@@ -67,32 +73,36 @@ const PopularClubs = () => {
))}
- )
-}
+ );
+};
const useClubRankings = () => {
- const { clubService } = useContext(serviceContext)
+ const { clubService } = useContext(serviceContext);
- const query = useQuery(['clubs', 'ranked'], () => clubService.listClubRankings({ topK: TOP_K }), {
- keepPreviousData: true,
- select: data => data.rankings,
- })
+ const query = useQuery(
+ ["clubs", "ranked"],
+ () => clubService.listClubRankings({ topK: TOP_K }),
+ {
+ keepPreviousData: true,
+ select: (data) => data.rankings,
+ },
+ );
- return query
-}
+ return query;
+};
const styles = StyleSheet.create({
container: {
- width: '100%',
+ width: "100%",
gap: vs(20),
},
header: {
- width: '100%',
+ width: "100%",
},
headerRow: {
width: s(350),
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
paddingLeft: s(5),
paddingRight: s(12),
paddingVertical: vs(5),
@@ -108,13 +118,13 @@ const styles = StyleSheet.create({
paddingLeft: s(6),
},
list: {
- width: '100%',
+ width: "100%",
gap: vs(12),
paddingHorizontal: s(6),
},
item: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
},
rank: {
width: s(20),
@@ -128,10 +138,10 @@ const styles = StyleSheet.create({
lineHeight: vs(21),
},
separator: {
- width: '100%',
+ width: "100%",
height: StyleSheet.hairlineWidth,
backgroundColor: Colors.BODYTEXT_DISABLED,
},
-})
+});
-export default PopularClubs
+export default PopularClubs;
diff --git a/src/features/club/components/RandomRecommendations/RandomRecommendations.tsx b/apps/mobile/src/features/club/components/RandomRecommendations/RandomRecommendations.tsx
similarity index 67%
rename from src/features/club/components/RandomRecommendations/RandomRecommendations.tsx
rename to apps/mobile/src/features/club/components/RandomRecommendations/RandomRecommendations.tsx
index 35e254bc..e5f0b28a 100644
--- a/src/features/club/components/RandomRecommendations/RandomRecommendations.tsx
+++ b/apps/mobile/src/features/club/components/RandomRecommendations/RandomRecommendations.tsx
@@ -1,33 +1,32 @@
-import React from 'react'
-import { StyleSheet, Text, View } from 'react-native'
+import { StyleSheet, Text, View } from "react-native";
-import { Club } from '@/entities/club'
-import HorizontalCarousel, {
- HORIZONTAL_CAROUSEL_BOTTOM_PADDING,
-} from '@/shared/components/HorizontalCarousel'
+import type { Club } from "@/entities/club";
import {
CLUB_PREVIEW_CARD_HEIGHT,
ClubPreviewCardSkeleton,
-} from '@/shared/components/ClubPreviewCard'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { s, vs } from '@/shared/utils/scale'
+} from "@/shared/components/ClubPreviewCard";
+import HorizontalCarousel, {
+ HORIZONTAL_CAROUSEL_BOTTOM_PADDING,
+} from "@/shared/components/HorizontalCarousel";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
type Props = {
- clubs: Club[]
- onPressClub: (club: Club) => void
-}
+ clubs: Club[];
+ onPressClub: (club: Club) => void;
+};
-const SKELETON_CARD_COUNT = 3
+const SKELETON_CARD_COUNT = 3;
const RECOMMENDATION_CAROUSEL_HEIGHT =
- CLUB_PREVIEW_CARD_HEIGHT + HORIZONTAL_CAROUSEL_BOTTOM_PADDING
+ CLUB_PREVIEW_CARD_HEIGHT + HORIZONTAL_CAROUSEL_BOTTOM_PADDING;
const RandomRecommendationsHeader = () => (
이런 동아리는 어때요?
다양한 동아리를 추천해드려요
-)
+);
export const RandomRecommendationsSkeleton = () => (
@@ -35,16 +34,17 @@ export const RandomRecommendationsSkeleton = () => (
{Array.from({ length: SKELETON_CARD_COUNT }).map((_, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: Skeleton cards are static, stateless placeholders.
))}
-)
+);
const RandomRecommendations = ({ clubs, onPressClub }: Props) => {
if (clubs.length === 0) {
- return null
+ return null;
}
return (
@@ -54,10 +54,10 @@ const RandomRecommendations = ({ clubs, onPressClub }: Props) => {
- )
-}
+ );
+};
-export default RandomRecommendations
+export default RandomRecommendations;
const styles = StyleSheet.create({
container: {
@@ -80,14 +80,14 @@ const styles = StyleSheet.create({
},
carouselSlot: {
height: RECOMMENDATION_CAROUSEL_HEIGHT,
- overflow: 'visible',
+ overflow: "visible",
},
skeletonList: {
- flexDirection: 'row',
- height: '100%',
+ flexDirection: "row",
+ height: "100%",
paddingHorizontal: s(20),
paddingBottom: s(2),
gap: s(10),
- overflow: 'hidden',
+ overflow: "hidden",
},
-})
+});
diff --git a/src/features/club/components/RecentSearches/RecentSearches.tsx b/apps/mobile/src/features/club/components/RecentSearches/RecentSearches.tsx
similarity index 60%
rename from src/features/club/components/RecentSearches/RecentSearches.tsx
rename to apps/mobile/src/features/club/components/RecentSearches/RecentSearches.tsx
index e21ed634..25718df2 100644
--- a/src/features/club/components/RecentSearches/RecentSearches.tsx
+++ b/apps/mobile/src/features/club/components/RecentSearches/RecentSearches.tsx
@@ -1,25 +1,28 @@
-import React from 'react'
-import { Pressable, StyleSheet, Text, View } from 'react-native'
+import { Pressable, StyleSheet, Text, View } from "react-native";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
type Props = {
- searches: string[]
- onPressItem: (query: string) => void
- onClearAll: () => void
-}
+ searches: string[];
+ onPressItem: (query: string) => void;
+ onClearAll: () => void;
+};
const RecentSearches = ({ searches, onPressItem, onClearAll }: Props) => {
- const isEmpty = searches.length === 0
+ const isEmpty = searches.length === 0;
return (
최근 검색어
{!isEmpty && (
-
+
검색내역 지우기
)}
@@ -31,29 +34,33 @@ const RecentSearches = ({ searches, onPressItem, onClearAll }: Props) => {
) : (
- {searches.map(query => (
- onPressItem(query)}>
+ {searches.map((query) => (
+ onPressItem(query)}
+ >
{query}
))}
)}
- )
-}
+ );
+};
const styles = StyleSheet.create({
container: {
- width: '100%',
+ width: "100%",
minHeight: vs(100),
gap: vs(9),
},
headerRow: {
- flexDirection: 'row',
+ flexDirection: "row",
width: s(350),
paddingHorizontal: s(5),
paddingVertical: vs(5),
- alignItems: 'center',
+ alignItems: "center",
gap: s(10),
},
headerText: {
@@ -72,31 +79,31 @@ const styles = StyleSheet.create({
emptyText: {
...typography.bodySRegular,
color: Colors.BODYTEXT_SUB,
- textAlign: 'left',
+ textAlign: "left",
paddingLeft: s(6),
},
chipWrap: {
- flexDirection: 'row',
- flexWrap: 'wrap',
+ flexDirection: "row",
+ flexWrap: "wrap",
},
chip: {
- height: vs(24),
- alignItems: 'center',
- justifyContent: 'center',
+ minHeight: ms(24),
+ alignItems: "center",
+ justifyContent: "center",
marginLeft: s(8),
marginBottom: vs(8),
paddingHorizontal: s(10),
- paddingVertical: vs(6),
+ paddingVertical: ms(5),
borderRadius: ms(20),
borderWidth: 0.3,
borderColor: Colors.POINTCOLOR,
- backgroundColor: 'rgba(135, 79, 255, 0.1)',
+ backgroundColor: "rgba(135, 79, 255, 0.1)",
},
chipText: {
...typography.bodySRegular,
- lineHeight: vs(12),
+ lineHeight: ms(14),
color: Colors.BODYTEXT_SUB,
},
-})
+});
-export default RecentSearches
+export default RecentSearches;
diff --git a/src/features/club/components/SearchBar/SearchBar.tsx b/apps/mobile/src/features/club/components/SearchBar/SearchBar.tsx
similarity index 53%
rename from src/features/club/components/SearchBar/SearchBar.tsx
rename to apps/mobile/src/features/club/components/SearchBar/SearchBar.tsx
index 2f0238c0..8ef00561 100644
--- a/src/features/club/components/SearchBar/SearchBar.tsx
+++ b/apps/mobile/src/features/club/components/SearchBar/SearchBar.tsx
@@ -1,19 +1,26 @@
-import { useState } from 'react'
-import { Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
+import { useState } from "react";
+import {
+ Image,
+ Pressable,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { s, vs } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
type Props = {
- value?: string
- onChangeText?: (text: string) => void
- onSubmit?: (query: string) => void
- placeholder?: string
-}
+ value?: string;
+ onChangeText?: (text: string) => void;
+ onSubmit?: (query: string) => void;
+ placeholder?: string;
+};
-const DEFAULT_PLACEHOLDER = '동아리의 키워드 혹은 소속 학과로 검색해보세요'
-const MAX_LENGTH = 20
+const DEFAULT_PLACEHOLDER = "동아리의 키워드 혹은 소속 학과로 검색해보세요";
+const MAX_LENGTH = 20;
const SearchBar = ({
value: controlledValue,
@@ -21,29 +28,32 @@ const SearchBar = ({
onSubmit,
placeholder = DEFAULT_PLACEHOLDER,
}: Props) => {
- const [internalValue, setInternalValue] = useState('')
- const isControlled = controlledValue !== undefined
- const value = isControlled ? controlledValue : internalValue
+ const [internalValue, setInternalValue] = useState("");
+ const isControlled = controlledValue !== undefined;
+ const value = isControlled ? controlledValue : internalValue;
const handleChangeText = (text: string) => {
- if (!isControlled) setInternalValue(text)
- onChangeText?.(text)
- }
+ if (!isControlled) setInternalValue(text);
+ onChangeText?.(text);
+ };
const handleSubmit = () => {
- const trimmed = value.trim()
- if (trimmed.length === 0) return
- onSubmit?.(trimmed)
- }
+ const trimmed = value.trim();
+ if (trimmed.length === 0) return;
+ onSubmit?.(trimmed);
+ };
const handleClear = () => {
- if (!isControlled) setInternalValue('')
- onChangeText?.('')
- }
+ if (!isControlled) setInternalValue("");
+ onChangeText?.("");
+ };
return (
-
+
/{MAX_LENGTH}
-
+
)}
- )
-}
+ );
+};
-export default SearchBar
+export default SearchBar;
const styles = StyleSheet.create({
container: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
paddingVertical: vs(16),
paddingLeft: s(18),
paddingRight: s(15),
@@ -84,19 +97,19 @@ const styles = StyleSheet.create({
borderRadius: s(10),
},
trailing: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
gap: s(7),
},
icon: {
width: s(15),
height: s(15),
- resizeMode: 'contain',
+ resizeMode: "contain",
},
clearIcon: {
width: s(14),
height: s(14),
- resizeMode: 'contain',
+ resizeMode: "contain",
},
input: {
flex: 1,
@@ -113,4 +126,4 @@ const styles = StyleSheet.create({
...typography.bodyMMedium,
color: Colors.BODYTEXT_DISABLED,
},
-})
+});
diff --git a/apps/mobile/src/features/club/components/SearchBar/SearchFilterBar.tsx b/apps/mobile/src/features/club/components/SearchBar/SearchFilterBar.tsx
new file mode 100644
index 00000000..a585ec11
--- /dev/null
+++ b/apps/mobile/src/features/club/components/SearchBar/SearchFilterBar.tsx
@@ -0,0 +1,119 @@
+import { Pressable, StyleSheet, View } from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import {
+ SearchFilterToggleGroup,
+ type SearchFilterToggleGroupSelection,
+} from "@/features/club/components/SearchFilterToggleGroup";
+import {
+ type ClubSearchFilters,
+ normalizeClubSearchFilters,
+} from "@/features/search/types/clubSearchForm";
+import Checkbox from "@/shared/components/Checkbox";
+import { Colors } from "@/shared/constants/colors";
+import { s } from "@/shared/utils/scale";
+
+const CLUB_TYPE_OPTIONS = [
+ { label: "중앙동아리", value: "중앙동아리" },
+ { label: "학과/단과대동아리", value: "학과/단과대동아리" },
+] as const;
+
+type Props = {
+ filters: ClubSearchFilters;
+ onChange: (value: ClubSearchFilters) => void;
+ onPressFilter: () => void;
+};
+
+const SearchFilterBar = ({ filters, onChange, onPressFilter }: Props) => {
+ const normalizedFilters = normalizeClubSearchFilters(filters);
+ const clubTypeSelection =
+ normalizedFilters.affiliation_types.length === 0
+ ? { kind: "all" as const }
+ : {
+ kind: "values" as const,
+ values: normalizedFilters.affiliation_types,
+ };
+
+ const handleChangeAffiliationType = (
+ selection: SearchFilterToggleGroupSelection,
+ ) => {
+ if (selection.kind !== "values") {
+ onChange({
+ ...normalizedFilters,
+ affiliation_types: [],
+ });
+ return;
+ }
+
+ onChange({
+ ...normalizedFilters,
+ affiliation_types:
+ selection.values as ClubSearchFilters["affiliation_types"],
+ });
+ };
+
+ const handleToggleRecruiting = () => {
+ onChange({
+ ...normalizedFilters,
+ is_recruiting:
+ normalizedFilters.is_recruiting === "true" ? undefined : "true",
+ });
+ };
+
+ return (
+
+ [
+ styles.filterIconContainer,
+ pressed && styles.pressed,
+ ]}
+ >
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SearchFilterBar;
+
+const styles = StyleSheet.create({
+ container: {
+ width: "100%",
+ alignItems: "center",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ },
+ filterIconContainer: {
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ pressed: {
+ opacity: 0.65,
+ },
+ toggleGroup: {
+ flexWrap: "nowrap",
+ gap: s(5),
+ },
+ toggleItem: {
+ paddingHorizontal: s(13),
+ },
+});
diff --git a/src/features/club/components/SearchBar/SearchFilterOverlay.tsx b/apps/mobile/src/features/club/components/SearchBar/SearchFilterOverlay.tsx
similarity index 55%
rename from src/features/club/components/SearchBar/SearchFilterOverlay.tsx
rename to apps/mobile/src/features/club/components/SearchBar/SearchFilterOverlay.tsx
index e6f30df6..cf67487a 100644
--- a/src/features/club/components/SearchBar/SearchFilterOverlay.tsx
+++ b/apps/mobile/src/features/club/components/SearchBar/SearchFilterOverlay.tsx
@@ -1,87 +1,102 @@
-import { Pressable, StyleSheet, Text, View } from 'react-native'
-import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
-
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import { MinDurationToggle } from "@/features/club/components/MinDurationToggle/MinDurationToggle";
import {
SearchFilterToggleGroup,
type SearchFilterToggleGroupSelection,
-} from '@/features/club/components/SearchFilterToggleGroup'
-import { MinDurationToggle } from '@/features/club/components/MinDurationToggle/MinDurationToggle'
+} from "@/features/club/components/SearchFilterToggleGroup";
import {
- normalizeClubSearchFilters,
type ClubSearchFilters,
-} from '@/features/search/types/clubSearchForm'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { s } from '@/shared/utils/scale'
+ normalizeClubSearchFilters,
+} from "@/features/search/types/clubSearchForm";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s } from "@/shared/utils/scale";
type Props = {
- value: ClubSearchFilters
- onChange: (value: ClubSearchFilters) => void
- onReset: () => void
- onClose: () => void
-}
+ value: ClubSearchFilters;
+ onChange: (value: ClubSearchFilters) => void;
+ onReset: () => void;
+ onClose: () => void;
+};
const RECRUITMENT_OPTIONS = [
- { label: '정기모집', value: '정기' },
- { label: '상시모집', value: '상시' },
-] as const
+ { label: "정기모집", value: "정기" },
+ { label: "상시모집", value: "상시" },
+] as const;
-const ROOM_OPTIONS = [{ label: '동방보유', value: 'true' }] as const
+const ROOM_OPTIONS = [{ label: "동방보유", value: "true" }] as const;
-const FEE_OPTIONS = [{ label: '회비없음', value: 'false' }] as const
+const FEE_OPTIONS = [{ label: "회비없음", value: "false" }] as const;
const toSingleSelection = (value?: string): SearchFilterToggleGroupSelection =>
- value ? { kind: 'values', values: [value] } : { kind: 'none' }
+ value ? { kind: "values", values: [value] } : { kind: "none" };
const getSingleSelectionValue = (
selection: SearchFilterToggleGroupSelection,
): string | undefined => {
- if (selection.kind !== 'values') {
- return undefined
+ if (selection.kind !== "values") {
+ return undefined;
}
- return selection.values[0]
-}
+ return selection.values[0];
+};
const SearchFilterOverlay = ({ value, onChange, onReset, onClose }: Props) => {
- const normalizedValue = normalizeClubSearchFilters(value)
- const recruitmentSelection = toSingleSelection(normalizedValue.recruit_type)
- const feeSelection = toSingleSelection(normalizedValue.has_membership_fee)
- const roomSelection = toSingleSelection(normalizedValue.has_dongbang)
+ const normalizedValue = normalizeClubSearchFilters(value);
+ const recruitmentSelection = toSingleSelection(normalizedValue.recruit_type);
+ const feeSelection = toSingleSelection(normalizedValue.has_membership_fee);
+ const roomSelection = toSingleSelection(normalizedValue.has_dongbang);
- const handleChangeRecruitType = (selection: SearchFilterToggleGroupSelection) => {
+ const handleChangeRecruitType = (
+ selection: SearchFilterToggleGroupSelection,
+ ) => {
onChange({
...normalizedValue,
- recruit_type: getSingleSelectionValue(selection) as ClubSearchFilters['recruit_type'],
- })
- }
+ recruit_type: getSingleSelectionValue(
+ selection,
+ ) as ClubSearchFilters["recruit_type"],
+ });
+ };
- const handleChangeMembershipFee = (selection: SearchFilterToggleGroupSelection) => {
+ const handleChangeMembershipFee = (
+ selection: SearchFilterToggleGroupSelection,
+ ) => {
onChange({
...normalizedValue,
has_membership_fee: getSingleSelectionValue(
selection,
- ) as ClubSearchFilters['has_membership_fee'],
- })
- }
+ ) as ClubSearchFilters["has_membership_fee"],
+ });
+ };
- const handleChangeDongbang = (selection: SearchFilterToggleGroupSelection) => {
+ const handleChangeDongbang = (
+ selection: SearchFilterToggleGroupSelection,
+ ) => {
onChange({
...normalizedValue,
- has_dongbang: getSingleSelectionValue(selection) as ClubSearchFilters['has_dongbang'],
- })
- }
+ has_dongbang: getSingleSelectionValue(
+ selection,
+ ) as ClubSearchFilters["has_dongbang"],
+ });
+ };
return (
- 더 자세한 검색을 위해 상세필터를 설정해보세요!
+
+ 더 자세한 검색을 위해 상세필터를 설정해보세요!
+
[styles.resetButton, pressed && styles.pressed]}>
+ style={({ pressed }) => [
+ styles.resetButton,
+ pressed && styles.pressed,
+ ]}
+ >
@@ -108,7 +123,7 @@ const SearchFilterOverlay = ({ value, onChange, onReset, onClose }: Props) => {
+ onChange={(min_activity_period) =>
onChange({
...normalizedValue,
min_activity_period,
@@ -118,10 +133,10 @@ const SearchFilterOverlay = ({ value, onChange, onReset, onClose }: Props) => {
- )
-}
+ );
+};
-export default SearchFilterOverlay
+export default SearchFilterOverlay;
const styles = StyleSheet.create({
wrapper: {
@@ -141,7 +156,7 @@ const styles = StyleSheet.create({
paddingBottom: s(31),
paddingLeft: s(34),
gap: s(18),
- shadowColor: '#000000',
+ shadowColor: "#000000",
shadowOffset: {
width: 0,
height: 4,
@@ -152,16 +167,16 @@ const styles = StyleSheet.create({
zIndex: 1,
},
header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
},
filters: {
gap: s(20),
},
filterRow: {
- flexDirection: 'row',
- alignItems: 'flex-start',
+ flexDirection: "row",
+ alignItems: "flex-start",
gap: s(15),
},
text: {
@@ -171,10 +186,10 @@ const styles = StyleSheet.create({
},
resetButton: {
marginLeft: s(16),
- alignItems: 'center',
- justifyContent: 'center',
+ alignItems: "center",
+ justifyContent: "center",
},
pressed: {
opacity: 0.65,
},
-})
+});
diff --git a/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroup.tsx b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroup.tsx
similarity index 52%
rename from src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroup.tsx
rename to apps/mobile/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroup.tsx
index 2abec050..affcf66b 100644
--- a/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroup.tsx
+++ b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroup.tsx
@@ -1,22 +1,22 @@
-import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'
-import { SearchFilterToggleGroupItem } from './SearchFilterToggleGroupItem'
+import { type StyleProp, StyleSheet, View, type ViewStyle } from "react-native";
+import { SearchFilterToggleGroupItem } from "./SearchFilterToggleGroupItem";
import type {
SearchFilterToggleGroupAllItem,
SearchFilterToggleGroupOption,
SearchFilterToggleGroupSelection,
SearchFilterToggleGroupSelectionMode,
-} from './types'
-import { useSearchFilterToggleGroup } from './useSearchFilterToggleGroup'
+} from "./types";
+import { useSearchFilterToggleGroup } from "./useSearchFilterToggleGroup";
export type SearchFilterToggleGroupProps = {
- options: SearchFilterToggleGroupOption[]
- allItem?: SearchFilterToggleGroupAllItem
- selectionMode?: SearchFilterToggleGroupSelectionMode
- value: SearchFilterToggleGroupSelection
- onChange: (value: SearchFilterToggleGroupSelection) => void
- style?: StyleProp
- itemStyle?: StyleProp
-}
+ options: SearchFilterToggleGroupOption[];
+ allItem?: SearchFilterToggleGroupAllItem;
+ selectionMode?: SearchFilterToggleGroupSelectionMode;
+ value: SearchFilterToggleGroupSelection;
+ onChange: (value: SearchFilterToggleGroupSelection) => void;
+ style?: StyleProp;
+ itemStyle?: StyleProp;
+};
export const SearchFilterToggleGroup = ({
options,
@@ -27,11 +27,12 @@ export const SearchFilterToggleGroup = ({
style,
itemStyle,
}: SearchFilterToggleGroupProps) => {
- const { isAllSelected, isSelected, selectAll, toggle } = useSearchFilterToggleGroup({
- selectionMode,
- value,
- onChange,
- })
+ const { isAllSelected, isSelected, selectAll, toggle } =
+ useSearchFilterToggleGroup({
+ selectionMode,
+ value,
+ onChange,
+ });
return (
@@ -44,7 +45,7 @@ export const SearchFilterToggleGroup = ({
style={itemStyle}
/>
) : null}
- {options.map(option => (
+ {options.map((option) => (
))}
- )
-}
+ );
+};
const styles = StyleSheet.create({
container: {
- flexDirection: 'row',
- flexWrap: 'wrap',
+ flexDirection: "row",
+ flexWrap: "wrap",
gap: 8,
},
-})
+});
diff --git a/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroupItem.tsx b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroupItem.tsx
similarity index 63%
rename from src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroupItem.tsx
rename to apps/mobile/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroupItem.tsx
index beece8cd..3d7c26f2 100644
--- a/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroupItem.tsx
+++ b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/SearchFilterToggleGroupItem.tsx
@@ -1,26 +1,32 @@
-import { useEffect } from 'react'
-import { Pressable, StyleSheet, Text, type StyleProp, type ViewStyle } from 'react-native'
+import { useEffect } from "react";
+import {
+ Pressable,
+ type StyleProp,
+ StyleSheet,
+ Text,
+ type ViewStyle,
+} from "react-native";
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
-} from 'react-native-reanimated'
+} from "react-native-reanimated";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
-const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
-const SELECTED_BACKGROUND_COLOR = Colors.BUTTON_SELECTED
-const UNSELECTED_BACKGROUND_COLOR = 'rgba(255, 255, 255, 0)'
+const SELECTED_BACKGROUND_COLOR = Colors.BUTTON_SELECTED;
+const UNSELECTED_BACKGROUND_COLOR = "rgba(255, 255, 255, 0)";
export type SearchFilterToggleGroupItemProps = {
- label: string
- selected: boolean
- onPress: () => void
- style?: StyleProp
-}
+ label: string;
+ selected: boolean;
+ onPress: () => void;
+ style?: StyleProp;
+};
export const SearchFilterToggleGroupItem = ({
label,
@@ -28,11 +34,11 @@ export const SearchFilterToggleGroupItem = ({
onPress,
style,
}: SearchFilterToggleGroupItemProps) => {
- const progress = useSharedValue(selected ? 1 : 0)
+ const progress = useSharedValue(selected ? 1 : 0);
useEffect(() => {
- progress.value = withTiming(selected ? 1 : 0, { duration: 160 })
- }, [progress, selected])
+ progress.value = withTiming(selected ? 1 : 0, { duration: 160 });
+ }, [progress, selected]);
const animatedStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
@@ -40,7 +46,7 @@ export const SearchFilterToggleGroupItem = ({
[0, 1],
[UNSELECTED_BACKGROUND_COLOR, SELECTED_BACKGROUND_COLOR],
),
- }))
+ }));
return (
-
+ ]}
+ >
+
{label}
- )
-}
+ );
+};
const styles = StyleSheet.create({
container: {
@@ -68,7 +80,7 @@ const styles = StyleSheet.create({
},
selected: {
borderWidth: 1,
- borderColor: 'transparent',
+ borderColor: "transparent",
},
unselected: {
borderWidth: 1,
@@ -84,4 +96,4 @@ const styles = StyleSheet.create({
textUnselected: {
color: Colors.TEXT_BUTTON_UNSELECTED,
},
-})
+});
diff --git a/apps/mobile/src/features/club/components/SearchFilterToggleGroup/index.ts b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/index.ts
new file mode 100644
index 00000000..8a17868a
--- /dev/null
+++ b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/index.ts
@@ -0,0 +1,13 @@
+export type { SearchFilterToggleGroupProps } from "./SearchFilterToggleGroup";
+export { SearchFilterToggleGroup } from "./SearchFilterToggleGroup";
+export type {
+ SearchFilterToggleGroupAllItem,
+ SearchFilterToggleGroupOption,
+ SearchFilterToggleGroupSelection,
+ SearchFilterToggleGroupSelectionMode,
+} from "./types";
+export type {
+ UseSearchFilterToggleGroupParams,
+ UseSearchFilterToggleGroupReturn,
+} from "./useSearchFilterToggleGroup";
+export { useSearchFilterToggleGroup } from "./useSearchFilterToggleGroup";
diff --git a/apps/mobile/src/features/club/components/SearchFilterToggleGroup/types.ts b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/types.ts
new file mode 100644
index 00000000..494fe6e3
--- /dev/null
+++ b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/types.ts
@@ -0,0 +1,15 @@
+export type SearchFilterToggleGroupOption = {
+ label: string;
+ value: string;
+};
+
+export type SearchFilterToggleGroupAllItem = {
+ label: string;
+};
+
+export type SearchFilterToggleGroupSelectionMode = "single" | "multiple";
+
+export type SearchFilterToggleGroupSelection =
+ | { kind: "all" }
+ | { kind: "none" }
+ | { kind: "values"; values: string[] };
diff --git a/apps/mobile/src/features/club/components/SearchFilterToggleGroup/useSearchFilterToggleGroup.ts b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/useSearchFilterToggleGroup.ts
new file mode 100644
index 00000000..6a9d65a3
--- /dev/null
+++ b/apps/mobile/src/features/club/components/SearchFilterToggleGroup/useSearchFilterToggleGroup.ts
@@ -0,0 +1,145 @@
+import { useCallback } from "react";
+import type {
+ SearchFilterToggleGroupSelection,
+ SearchFilterToggleGroupSelectionMode,
+} from "./types";
+
+export type UseSearchFilterToggleGroupParams = {
+ selectionMode?: SearchFilterToggleGroupSelectionMode;
+ value: SearchFilterToggleGroupSelection;
+ onChange: (value: SearchFilterToggleGroupSelection) => void;
+};
+
+export type UseSearchFilterToggleGroupReturn = {
+ selection: SearchFilterToggleGroupSelection;
+ isAllSelected: boolean;
+ isSelected: (value: string) => boolean;
+ toggle: (value: string) => void;
+ selectAll: () => void;
+ reset: () => void;
+};
+
+const NONE_SELECTION: SearchFilterToggleGroupSelection = { kind: "none" };
+const ALL_SELECTION: SearchFilterToggleGroupSelection = { kind: "all" };
+
+const sanitizeExternalSelection = ({
+ selection,
+ selectionMode,
+}: {
+ selection: SearchFilterToggleGroupSelection;
+ selectionMode: SearchFilterToggleGroupSelectionMode;
+}): SearchFilterToggleGroupSelection => {
+ if (selection.kind !== "values") {
+ return selection;
+ }
+
+ const uniqueValues = [...new Set(selection.values)];
+
+ if (uniqueValues.length === 0) {
+ return NONE_SELECTION;
+ }
+
+ if (selectionMode === "single") {
+ return {
+ kind: "values",
+ values: uniqueValues.slice(0, 1),
+ };
+ }
+
+ return {
+ kind: "values",
+ values: uniqueValues,
+ };
+};
+
+const getNextSelection = ({
+ selection,
+ value,
+ selectionMode,
+}: {
+ selection: SearchFilterToggleGroupSelection;
+ value: string;
+ selectionMode: SearchFilterToggleGroupSelectionMode;
+}): SearchFilterToggleGroupSelection => {
+ if (selection.kind !== "values") {
+ return { kind: "values", values: [value] };
+ }
+
+ const isCurrentlySelected = selection.values.includes(value);
+
+ if (selectionMode === "single") {
+ if (isCurrentlySelected) {
+ return NONE_SELECTION;
+ }
+
+ return { kind: "values", values: [value] };
+ }
+
+ if (isCurrentlySelected) {
+ const nextValues = selection.values.filter(
+ (selectedValue) => selectedValue !== value,
+ );
+
+ if (nextValues.length === 0) {
+ return NONE_SELECTION;
+ }
+
+ return { kind: "values", values: nextValues };
+ }
+
+ return {
+ kind: "values",
+ values: [...selection.values, value],
+ };
+};
+
+export const useSearchFilterToggleGroup = ({
+ selectionMode = "multiple",
+ value,
+ onChange,
+}: UseSearchFilterToggleGroupParams): UseSearchFilterToggleGroupReturn => {
+ const selection = sanitizeExternalSelection({
+ selection: value,
+ selectionMode,
+ });
+
+ const updateSelection = useCallback(
+ (nextSelection: SearchFilterToggleGroupSelection) => {
+ onChange(nextSelection);
+ },
+ [onChange],
+ );
+
+ const isSelected = (optionValue: string) =>
+ selection.kind === "values" && selection.values.includes(optionValue);
+
+ const toggle = useCallback(
+ (nextSelectedValue: string) => {
+ const nextSelection = getNextSelection({
+ selection,
+ value: nextSelectedValue,
+ selectionMode,
+ });
+
+ updateSelection(nextSelection);
+ },
+ [selection, selectionMode, updateSelection],
+ );
+
+ const selectAll = () => {
+ updateSelection(ALL_SELECTION);
+ };
+
+ const reset = () => {
+ updateSelection(NONE_SELECTION);
+ };
+
+ return {
+ selection,
+ isAllSelected: selection.kind === "all",
+ isSelected,
+ toggle,
+ selectAll,
+ reset,
+ };
+};
diff --git a/src/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice.tsx b/apps/mobile/src/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice.tsx
similarity index 62%
rename from src/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice.tsx
rename to apps/mobile/src/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice.tsx
index 188062de..2b2e69da 100644
--- a/src/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice.tsx
+++ b/apps/mobile/src/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice.tsx
@@ -1,14 +1,13 @@
-import React from 'react'
-import { Pressable, StyleSheet, Text, View } from 'react-native'
-import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
type Props = {
- correctedQuery: string
- onClose?: () => void
-}
+ correctedQuery: string;
+ onClose?: () => void;
+};
const TypoCorrectionNotice = ({ correctedQuery, onClose }: Props) => {
return (
@@ -20,7 +19,8 @@ const TypoCorrectionNotice = ({ correctedQuery, onClose }: Props) => {
올클이 오타를 감지했어요!
- 유사 검색어 ‘{correctedQuery}’로 검색한 결과예요
+ 유사 검색어 ‘{correctedQuery}
+ ’로 검색한 결과예요
@@ -30,23 +30,23 @@ const TypoCorrectionNotice = ({ correctedQuery, onClose }: Props) => {
) : null}
- )
-}
+ );
+};
const styles = StyleSheet.create({
container: {
- width: '100%',
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ width: "100%",
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
paddingHorizontal: s(17),
paddingVertical: vs(13),
borderRadius: ms(10),
- backgroundColor: 'rgba(243, 240, 245, 0.5)',
+ backgroundColor: "rgba(243, 240, 245, 0.5)",
},
messageGroup: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
gap: s(8),
flex: 1,
},
@@ -54,8 +54,8 @@ const styles = StyleSheet.create({
width: ms(34),
height: ms(34),
borderRadius: ms(17),
- alignItems: 'center',
- justifyContent: 'center',
+ alignItems: "center",
+ justifyContent: "center",
backgroundColor: Colors.WHITE,
},
message: {
@@ -70,11 +70,11 @@ const styles = StyleSheet.create({
width: ms(16),
height: ms(16),
borderRadius: ms(8),
- alignItems: 'center',
- justifyContent: 'center',
+ alignItems: "center",
+ justifyContent: "center",
backgroundColor: Colors.BODYTEXT_DISABLED,
marginLeft: s(12),
},
-})
+});
-export default TypoCorrectionNotice
+export default TypoCorrectionNotice;
diff --git a/apps/mobile/src/features/club/components/VerificationMark.tsx b/apps/mobile/src/features/club/components/VerificationMark.tsx
new file mode 100644
index 00000000..ffbf7feb
--- /dev/null
+++ b/apps/mobile/src/features/club/components/VerificationMark.tsx
@@ -0,0 +1,46 @@
+import { Image, type ImageSourcePropType, StyleSheet } from "react-native";
+
+import type { OfficialVerificationStatus } from "@/entities/club";
+import { ms } from "@/shared/utils/scale";
+
+type Props = {
+ status?: OfficialVerificationStatus;
+ size?: number;
+};
+
+export const DEFAULT_VERIFICATION_MARK_SIZE = 20;
+
+const MARK_SOURCE_BY_STATUS: Partial<
+ Record
+> = {
+ VERIFIED: require("@/assets/icons/clubInfo/verification-mark.png"),
+ PENDING: require("@/assets/icons/clubInfo/verification-pending-mark.png"),
+};
+
+const VerificationMark = ({
+ status,
+ size = DEFAULT_VERIFICATION_MARK_SIZE,
+}: Props) => {
+ const markSource = status ? MARK_SOURCE_BY_STATUS[status] : undefined;
+
+ if (!markSource) return null;
+
+ const scaledSize = ms(size);
+
+ return (
+
+ );
+};
+
+export default VerificationMark;
+
+const styles = StyleSheet.create({
+ mark: {
+ flexShrink: 0,
+ },
+});
diff --git a/apps/mobile/src/features/club/screens/AnnouncementEditScreen/index.tsx b/apps/mobile/src/features/club/screens/AnnouncementEditScreen/index.tsx
new file mode 100644
index 00000000..22f52679
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/AnnouncementEditScreen/index.tsx
@@ -0,0 +1,27 @@
+import { type RouteProp, useRoute } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import AnnouncementForm from "@/features/club/components/AnnouncementForm";
+import type { SCREEN_TYPE, StackParamList } from "@/shared/constants/screen";
+
+type NavigationProp = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.ANNOUNCEMENT_EDIT
+>;
+type ScreenRouteProp = RouteProp;
+
+type Props = {
+ navigation: NavigationProp;
+};
+
+const AnnouncementEditScreen = ({ navigation }: Props) => {
+ const route = useRoute();
+ return (
+ navigation.goBack()}
+ />
+ );
+};
+
+export default AnnouncementEditScreen;
diff --git a/apps/mobile/src/features/club/screens/AnnouncementRegistrationScreen/index.tsx b/apps/mobile/src/features/club/screens/AnnouncementRegistrationScreen/index.tsx
new file mode 100644
index 00000000..d17ae129
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/AnnouncementRegistrationScreen/index.tsx
@@ -0,0 +1,30 @@
+import { type RouteProp, useRoute } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import AnnouncementForm from "@/features/club/components/AnnouncementForm";
+import type { SCREEN_TYPE, StackParamList } from "@/shared/constants/screen";
+
+type NavigationProp = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION
+>;
+type ScreenRouteProp = RouteProp<
+ StackParamList,
+ SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION
+>;
+
+type Props = {
+ navigation: NavigationProp;
+};
+
+const AnnouncementRegistrationScreen = ({ navigation }: Props) => {
+ const route = useRoute();
+ return (
+ navigation.goBack()}
+ />
+ );
+};
+
+export default AnnouncementRegistrationScreen;
diff --git a/src/features/club/screens/ClubDetailScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubDetailScreen/index.tsx
similarity index 51%
rename from src/features/club/screens/ClubDetailScreen/index.tsx
rename to apps/mobile/src/features/club/screens/ClubDetailScreen/index.tsx
index ef846b2b..27ab62f9 100644
--- a/src/features/club/screens/ClubDetailScreen/index.tsx
+++ b/apps/mobile/src/features/club/screens/ClubDetailScreen/index.tsx
@@ -1,27 +1,19 @@
-import { RouteProp } from '@react-navigation/native'
-import { NativeStackNavigationProp } from '@react-navigation/native-stack'
-import { useQuery } from '@tanstack/react-query'
-import { useProfile } from '@/shared/contexts/profileContext'
-import { serviceContext } from '@/shared/contexts/serviceContext'
-import { ENV } from '@/config/ENV'
-import { CategoryMap } from '@/shared/constants/category'
-import { Club } from '@/entities/club'
-import { SCREEN_TYPE, StackParamList } from '@/shared/constants/screen'
-import WithViewEventLog from '@/shared/hocs/WithViewEventLog'
-import useClickEventLog from '@/shared/hooks/useClickEventLog'
-import useRequireLogin from '@/shared/hooks/useRequireLogin'
-import useSaveClub from '@/shared/hooks/useSaveClub'
-import React, { useContext, useState } from 'react'
+import type { RouteProp } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { useQuery } from "@tanstack/react-query";
+import { useContext, useState } from "react";
import {
ActivityIndicator,
Dimensions,
Image,
+ Linking,
Pressable,
Share,
StyleSheet,
Text,
View,
-} from 'react-native'
+} from "react-native";
+import LinearGradient from "react-native-linear-gradient";
import Animated, {
Extrapolation,
interpolate,
@@ -30,34 +22,57 @@ import Animated, {
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
-} from 'react-native-reanimated'
-import LinearGradient from 'react-native-linear-gradient'
-import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
-import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
-import BackHeader from '@/shared/components/BackHeader'
-import ReviewKeywordPill from '@/shared/components/ReviewKeywordPill'
-import BackgroundCard from '@/features/club/components/ClubDetail/BackgroundCard'
+} from "react-native-reanimated";
+import {
+ SafeAreaView,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import { ENV } from "@/config/ENV";
+import type { Club } from "@/entities/club";
+import BackgroundCard from "@/features/club/components/ClubDetail/BackgroundCard";
import ClubDetailTabBar, {
CLUB_DETAIL_TABS,
- ClubDetailTabKey,
-} from '@/features/club/components/ClubDetail/ClubDetailTabBar'
-import InfoTab from '@/features/club/components/ClubDetail/InfoTab'
-import RecruitTab from '@/features/club/components/ClubDetail/RecruitTab'
-import ReviewTab from '@/features/club/components/ClubDetail/ReviewTab'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
+ type ClubDetailTabKey,
+} from "@/features/club/components/ClubDetail/ClubDetailTabBar";
+import InfoTab from "@/features/club/components/ClubDetail/InfoTab";
+import RecruitTab from "@/features/club/components/ClubDetail/RecruitTab";
+import ReviewTab from "@/features/club/components/ClubDetail/ReviewTab";
+import VerificationMarkButton from "@/features/club/components/ClubDetail/VerificationMarkButton";
+import VerificationOverlay from "@/features/club/components/ClubDetail/VerificationOverlay";
+import BackHeader from "@/shared/components/BackHeader";
+import ReviewKeywordPill from "@/shared/components/ReviewKeywordPill";
+import { CategoryMap } from "@/shared/constants/category";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import WithViewEventLog from "@/shared/hocs/WithViewEventLog";
+import useClickEventLog from "@/shared/hooks/useClickEventLog";
+import useRequireLogin from "@/shared/hooks/useRequireLogin";
+import useSaveClub from "@/shared/hooks/useSaveClub";
+import { getClubSummaryWithAffiliation } from "@/shared/utils/club";
+import {
+ getClubSnsUrls,
+ getSnsIcon,
+ getSnsIconSize,
+} from "@/shared/utils/clubSns";
+import { ms, s, vs } from "@/shared/utils/scale";
-type DetailsScreenRouteProp = RouteProp
+type DetailsScreenRouteProp = RouteProp<
+ StackParamList,
+ SCREEN_TYPE.CLUB_DETAIL
+>;
type DetailsScreenNavigationProp = NativeStackNavigationProp<
StackParamList,
SCREEN_TYPE.CLUB_DETAIL
->
+>;
type Props = {
- route: DetailsScreenRouteProp
- navigation: DetailsScreenNavigationProp
-}
+ route: DetailsScreenRouteProp;
+ navigation: DetailsScreenNavigationProp;
+};
/**
* ClubDetailScreen — 로고를 "배경"처럼 보여주는 상세 화면.
@@ -77,43 +92,56 @@ type Props = {
// HtmlView가 폭 측정 라운드트립(빈 프레임 → 내용 pop) 없이 첫 프레임부터 렌더하도록 미리 계산한다.
// tabContent 좌우 패딩 s(16) + 카드 패딩 s(16) 제외.
-const HTML_CONTENT_WIDTH = Dimensions.get('window').width - s(16) * 4
+const HTML_CONTENT_WIDTH = Dimensions.get("window").width - s(16) * 4;
-const HEADER_HEIGHT = vs(56) // BackHeader 높이와 동일 (오버레이/고정 탭바 위치 계산에 사용)
-const LOGO_BANNER_HEIGHT = vs(300) // 로고 배너(배경)의 높이
-const LOGO_HERO_OVERLAP = vs(72) // hero 카드가 로고 하단을 덮고 올라오는 양
+const HEADER_HEIGHT = vs(56); // BackHeader 높이와 동일 (오버레이/고정 탭바 위치 계산에 사용)
+const LOGO_BANNER_HEIGHT = vs(300); // 로고 배너(배경)의 높이
+const LOGO_HERO_OVERLAP = vs(72); // hero 카드가 로고 하단을 덮고 올라오는 양
const ClubDetailScreen = ({ route, navigation }: Props) => {
- const { uuid, category: routeCategory, entry_point } = route.params as DetailsScreenRouteProp['params']
+ const {
+ uuid,
+ category: routeCategory,
+ entry_point,
+ } = route.params as DetailsScreenRouteProp["params"];
- const { logClickEvent } = useClickEventLog()
- const { user } = useProfile()
- const requireLogin = useRequireLogin()
- const { data: club, isLoading } = useClub({ uuid })
- const { isSaved, handleToggle } = useSaveClub(club)
+ const { logClickEvent } = useClickEventLog();
+ const { user } = useProfile();
+ const requireLogin = useRequireLogin();
+ const { data: club, isLoading } = useClub({ uuid });
+ const { isSaved, handleToggle } = useSaveClub(club);
+ const verificationStatus = club?.officialVerificationStatus;
- const [activeTab, setActiveTab] = useState('detail')
+ const [activeTab, setActiveTab] = useState("detail");
+ const [isVerificationOverlayVisible, setIsVerificationOverlayVisible] =
+ useState(false);
+ const [verificationOverlayHeight, setVerificationOverlayHeight] = useState(0);
// 인플로우 탭바가 헤더 하단에 닿아 고정 탭바로 전환됐는지. 헤더 라벨 노출도 이 값으로 함께 제어한다.
- const [isTabBarPinned, setIsTabBarPinned] = useState(false)
+ const [isTabBarPinned, setIsTabBarPinned] = useState(false);
- const insets = useSafeAreaInsets()
+ const insets = useSafeAreaInsets();
// scrollY: 현재 스크롤 위치. tabBarOffset: 콘텐츠 안에서 인플로우 탭바가 놓인 y(아래 onLayout으로 측정).
// tabBarOffset은 측정 전엔 매우 큰 값이라, 그 전까진 헤더가 투명하고 탭바도 고정되지 않는다.
- const scrollY = useSharedValue(0)
- const tabBarOffset = useSharedValue(Number.MAX_SAFE_INTEGER)
- const scrollHandler = useAnimatedScrollHandler(event => {
- scrollY.value = event.contentOffset.y
- })
+ const scrollY = useSharedValue(0);
+ const tabBarOffset = useSharedValue(Number.MAX_SAFE_INTEGER);
+ const scrollHandler = useAnimatedScrollHandler((event) => {
+ scrollY.value = event.contentOffset.y;
+ });
// 헤더 배경은 스크롤 시작(0)부터 "탭바 윗변이 헤더 아래에 닿는 지점(end)"까지 opacity 0→1로 서서히 채워진다.
const headerBgStyle = useAnimatedStyle(() => {
// 콘텐츠 내 탭바 위치에서 헤더 높이를 빼면, 탭바가 헤더 바로 아래에 닿는 스크롤 양이 된다.
- const end = tabBarOffset.value - HEADER_HEIGHT
+ const end = tabBarOffset.value - HEADER_HEIGHT;
return {
- opacity: interpolate(scrollY.value, [0, end], [0, 1], Extrapolation.CLAMP),
- }
- })
+ opacity: interpolate(
+ scrollY.value,
+ [0, end],
+ [0, 1],
+ Extrapolation.CLAMP,
+ ),
+ };
+ });
// 위 임계값(end)을 넘나드는 "순간"에만 React 상태를 토글한다(매 프레임 setState 방지).
// 비교는 UI 스레드에서 하고, 값이 바뀔 때만 runOnJS로 JS 스레드의 setState를 호출한다.
@@ -121,63 +149,83 @@ const ClubDetailScreen = ({ route, navigation }: Props) => {
() => scrollY.value >= tabBarOffset.value - HEADER_HEIGHT,
(pinned, wasPinned) => {
if (pinned !== wasPinned) {
- runOnJS(setIsTabBarPinned)(pinned)
+ runOnJS(setIsTabBarPinned)(pinned);
}
},
- )
+ );
- const currentCategory = routeCategory ?? club?.category
+ const currentCategory = routeCategory ?? club?.category;
if (isLoading && !currentCategory) {
return (
- )
+ );
}
- if (!currentCategory) return null
- const categoryDetail = CategoryMap[currentCategory]
+ if (!currentCategory) return null;
+ const categoryDetail = CategoryMap[currentCategory];
+ const heroDescription = club ? getClubSummaryWithAffiliation(club) : "";
+ const snsUrls = club ? getClubSnsUrls(club) : [];
- const handleBackButton = () => navigation.goBack()
+ const handleBackButton = () => navigation.goBack();
const handleWriteReview = () =>
requireLogin(() =>
- navigation.navigate(SCREEN_TYPE.CLUB_REVIEW, { uuid, category: currentCategory }),
- )
+ navigation.navigate(SCREEN_TYPE.CLUB_REVIEW, {
+ uuid,
+ category: currentCategory,
+ }),
+ );
+ const handlePreviousRecruitments = (
+ representativeRecruitmentId: number | null,
+ ) => {
+ if (!club) return;
+ navigation.navigate(SCREEN_TYPE.PREVIOUS_RECRUITMENTS, {
+ clubId: club.uuid,
+ clubName: club.name,
+ representativeRecruitmentId,
+ });
+ };
const handleShare = async () => {
- if (!club) return
- const shareUrl = `${ENV.WEB_URL}/club/${club.uuid}`
+ if (!club) return;
+ const shareUrl = `${ENV.WEB_URL}/club/${club.uuid}`;
try {
await Share.share({
message: `${club.name}의 동아리 정보를 확인해보세요!\n${shareUrl}`,
url: shareUrl,
- })
+ });
} catch (error) {
- console.error('Error occurred while sharing.', error)
+ console.error("Error occurred while sharing.", error);
}
- }
+ };
const handleLoginRequired = () => {
logClickEvent({
- screen_name: 'club_detail_screen',
- screen_component_name: 'login_required_button',
- })
- requireLogin(() => {})
- }
+ screen_name: "club_detail_screen",
+ screen_component_name: "login_required_button",
+ });
+ requireLogin(() => {});
+ };
return (
-
+ club_name: club?.name ?? "",
+ entry_point: entry_point ?? "",
+ }}
+ >
+
{isLoading && (
-
+
)}
{club && (
// 본문: 화면 전체에 깔려 헤더 뒤까지 스크롤된다(헤더가 투명할 때 뒤로 비치게 하기 위함).
@@ -186,17 +234,22 @@ const ClubDetailScreen = ({ route, navigation }: Props) => {
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
- scrollEventThrottle={16}>
+ scrollEventThrottle={16}
+ >
{/* 로고 배경: 본문의 첫 블록. 투명한 헤더 뒤로 비치며, 상/하단을 페이지 배경색으로 fade 처리한다. */}
-
+
{/* 로고 오버레이: 상/하단은 배경색(#FAFAFA)으로 완전히 덮고, 중앙은 30% 베일을 깔아 로고 색을 살짝 눌러 준다. */}
{
style={styles.logoFade}
/>
-
{/* Hero 카드: marginTop 음수(styles.card)로 로고 하단을 LOGO_HERO_OVERLAP만큼 덮고 올라온다. */}
@@ -213,34 +265,67 @@ const ClubDetailScreen = ({ route, navigation }: Props) => {
{club.name}
+
+ setIsVerificationOverlayVisible((prev) => !prev)
+ }
+ />
({ opacity: pressed ? 0.5 : 1 })}>
+ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
+ >
({ opacity: pressed ? 0.5 : 1 })}>
-
+ style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1 })}
+ >
+
- {club.description}
+
+ setVerificationOverlayHeight(event.nativeEvent.layout.height)
+ }
+ />
+ {!!heroDescription && (
+ {heroDescription}
+ )}
{club.reviewKeywords.length > 0 && (
- {club.reviewKeywords.slice(0, 2).map(keyword => (
+ {club.reviewKeywords.slice(0, 2).map((keyword) => (
{
))}
)}
+ {snsUrls.length > 0 && (
+
+ {snsUrls.map((snsUrl, index) => (
+ Linking.openURL(snsUrl)}
+ >
+
+
+ ))}
+
+ )}
{/* 인플로우 탭바: 본문과 함께 스크롤된다. onLayout으로 콘텐츠 내 위치(y)를 재서 고정 전환 시점 계산에 쓴다. */}
{
- tabBarOffset.value = event.nativeEvent.layout.y
- }}>
+ onLayout={(event) => {
+ tabBarOffset.value = event.nativeEvent.layout.y;
+ }}
+ >
{/* Tab Content */}
- {activeTab === 'detail' && }
+ {activeTab === "detail" && (
+
+ )}
- {activeTab === 'recruit' && (
+ {activeTab === "recruit" && (
)}
- {activeTab === 'review' && (
+ {activeTab === "review" && (
{
{/* 고정 탭바: 스크롤이 임계값을 넘으면 헤더 바로 아래(top: insets.top + HEADER_HEIGHT)에 표시한다.
인플로우 탭바는 이때 헤더 뒤로 올라가 안 보이므로, 같은 탭바를 여기 한 번 더 그려 sticky처럼 보이게 한다. */}
{club && isTabBarPinned && (
-
+
)}
{/* 헤더(오버레이): 본문 위에 항상 떠 있다. 절대배치는 SafeAreaView의 top 패딩을 무시하므로
top을 insets.top으로 직접 줘 노치 아래에 맞춘다. box-none이라 빈 영역 터치는 통과하고 뒤로가기 버튼만 받는다. */}
-
+
{/* 배경판: 스크롤에 따라 opacity 0→1로 채워진다. */}
{/* 뒤로가기 버튼은 항상 불투명하게 둔다. 라벨만 배경이 완전히 불투명해지는(=고정되는) 시점에 노출한다. */}
-
+
- )
-}
+ );
+};
-export default ClubDetailScreen
+export default ClubDetailScreen;
type UseClubProps = {
- uuid: Club['uuid']
-}
+ uuid: Club["uuid"];
+};
const useClub = ({ uuid }: UseClubProps) => {
- const { clubService } = useContext(serviceContext)
- return useQuery(['clubs', uuid], () => clubService.getClub({ uuid }))
-}
+ const { clubService } = useContext(serviceContext);
+ return useQuery(["clubs", uuid], () => clubService.getClub({ uuid }));
+};
const styles = StyleSheet.create({
safeArea: {
@@ -330,8 +445,8 @@ const styles = StyleSheet.create({
},
loadingContainer: {
flex: 1,
- alignItems: 'center',
- justifyContent: 'center',
+ alignItems: "center",
+ justifyContent: "center",
backgroundColor: Colors.BACKGROUND_MAIN,
},
loader: {
@@ -340,25 +455,25 @@ const styles = StyleSheet.create({
// 투명하게 둬야 헤더 뒤로 본문(로고/hero)이 비치고, 빈 곳엔 SafeAreaView 배경색이 보인다.
scrollView: {
flex: 1,
- backgroundColor: 'transparent',
+ backgroundColor: "transparent",
},
contentContainer: {
paddingBottom: vs(40),
},
logoBanner: {
height: LOGO_BANNER_HEIGHT,
- overflow: 'hidden', // cover 이미지가 배너 밖으로 넘치지 않도록
+ overflow: "hidden", // cover 이미지가 배너 밖으로 넘치지 않도록
},
logoImage: {
- width: '100%',
- height: '100%',
+ width: "100%",
+ height: "100%",
},
logoFade: {
...StyleSheet.absoluteFillObject,
},
// top은 노치 인셋(insets.top)을 런타임에 인라인으로 더해 준다.
headerOverlay: {
- position: 'absolute',
+ position: "absolute",
left: 0,
right: 0,
},
@@ -367,34 +482,40 @@ const styles = StyleSheet.create({
},
// top은 insets.top + HEADER_HEIGHT를 런타임에 인라인으로 준다(헤더 바로 아래).
pinnedTabBar: {
- position: 'absolute',
+ position: "absolute",
left: 0,
right: 0,
},
card: {
+ position: "relative",
+ overflow: "visible",
marginTop: -LOGO_HERO_OVERLAP, // 로고 하단을 덮으며 올라오게(overlap)
marginHorizontal: s(16),
marginBottom: vs(12),
},
+ verificationOverlay: {
+ right: s(0),
+ },
cardHeader: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
marginBottom: vs(6),
},
cardTitleRow: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(4),
flexShrink: 1,
},
headerActions: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: ms(12),
+ flexDirection: "row",
+ alignItems: "center",
+ gap: ms(10),
},
savedIcon: {
- width: ms(22),
- height: ms(22),
+ width: ms(18),
+ height: ms(18),
},
clubName: {
...typography.headerXL,
@@ -407,11 +528,24 @@ const styles = StyleSheet.create({
marginBottom: vs(10),
},
keywordRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
+ flexDirection: "row",
+ flexWrap: "wrap",
gap: ms(4),
},
+ snsRow: {
+ flexDirection: "row",
+ gap: s(5),
+ marginTop: vs(10),
+ },
+ snsButton: {
+ width: ms(30),
+ height: ms(30),
+ borderRadius: ms(15),
+ backgroundColor: Colors.POINTCOLOR_10,
+ alignItems: "center",
+ justifyContent: "center",
+ },
tabContent: {
paddingHorizontal: s(16),
},
-})
+});
diff --git a/apps/mobile/src/features/club/screens/ClubInfoEditScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubInfoEditScreen/index.tsx
new file mode 100644
index 00000000..c791382c
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/ClubInfoEditScreen/index.tsx
@@ -0,0 +1,1490 @@
+import { BlurView } from "@react-native-community/blur";
+import { type RouteProp, useRoute } from "@react-navigation/native";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ memo,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import {
+ ActivityIndicator,
+ Dimensions,
+ FlatList,
+ Image,
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { launchImageLibrary } from "react-native-image-picker";
+import {
+ SafeAreaView,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
+import Toast from "react-native-toast-message";
+import Icon from "react-native-vector-icons/MaterialIcons";
+import type { Category } from "@/entities/category";
+import { getManagedClubUpdateErrorContent } from "@/features/club/utils/managedClubUpdateError";
+import type {
+ ManagedClubDetail,
+ UpdateManagedClubRequest,
+} from "@/repositories/club";
+import ClubActivityImagePicker from "@/shared/components/ClubActivityImagePicker";
+import ClubSnsInputList from "@/shared/components/ClubSnsInputList";
+import TextField from "@/shared/components/TextField";
+import { CLUB_CATEGORIES } from "@/shared/constants/category";
+import { CLUB_RECRUIT_TYPES } from "@/shared/constants/club";
+import { Colors } from "@/shared/constants/colors";
+import {
+ buildDepartmentOptions,
+ DEFAULT_DEPARTMENT_OPTIONS,
+} from "@/shared/constants/departments";
+import type { SCREEN_TYPE, StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import type { EditableImage, ImageFile } from "@/shared/types/image";
+import {
+ areValidSnsUrls,
+ getClubSnsUrls,
+ getSnsRequestFields,
+ normalizeSnsUrls,
+} from "@/shared/utils/clubSns";
+import {
+ decrementMinActivityPeriodValue,
+ incrementMinActivityPeriodValue,
+ type MinActivityPeriodMode,
+} from "@/shared/utils/minActivityPeriod";
+import { navigation } from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type RouteProps = RouteProp;
+
+type ClubInfoEditFormData = {
+ clubImage: string | null;
+ clubName: string;
+ category: Category["name"] | "";
+ department: string;
+ shortIntro: string;
+ recruitType: string;
+ minActivityPeriodInput: string;
+ hasDongbang: boolean;
+ dongbangLocation: string;
+ clubSNSUrls: string[];
+ activityImages: EditableImage[];
+ clubDescription: string;
+};
+
+type ComparableClubInfo = {
+ name: string;
+ category: Category["name"] | "";
+ affiliation: string;
+ shortDescription: string;
+ recruitType: string;
+ minActivityPeriod: number;
+ hasDongbang: boolean;
+ dongbangLocation: string;
+ snsUrls: string[];
+ introduction: string;
+};
+
+type SuccessModalProps = {
+ visible: boolean;
+ title: string;
+ onConfirm: () => void;
+};
+
+const TILE_SIZE = (Dimensions.get("window").width - s(20) * 2 - s(12) * 2) / 3;
+
+const initialFormData: ClubInfoEditFormData = {
+ clubImage: null,
+ clubName: "",
+ category: "",
+ department: "",
+ shortIntro: "",
+ recruitType: "",
+ minActivityPeriodInput: "",
+ hasDongbang: false,
+ dongbangLocation: "",
+ clubSNSUrls: [""],
+ activityImages: [],
+ clubDescription: "",
+};
+
+const getClubDepartment = (club: ManagedClubDetail): string => {
+ const collegeDepartment =
+ club.collegeMajor?.major || club.collegeMajor?.college;
+ if (collegeDepartment) {
+ return collegeDepartment;
+ }
+
+ return club.affiliationType && club.affiliationType !== "소속동아리"
+ ? club.affiliationType
+ : "";
+};
+
+const mapClubToFormData = (club: ManagedClubDetail): ClubInfoEditFormData => {
+ const snsUrls = getClubSnsUrls(club);
+ return {
+ clubImage: club.imageUri || null,
+ clubName: club.name ?? "",
+ category: club.category ?? "",
+ department: getClubDepartment(club),
+ shortIntro: club.shortDescription || "",
+ recruitType: CLUB_RECRUIT_TYPES.includes(club.recruitType)
+ ? club.recruitType
+ : "",
+ minActivityPeriodInput:
+ club.minActivityPeriod !== null &&
+ club.minActivityPeriod !== undefined &&
+ club.minActivityPeriod > 0
+ ? String(club.minActivityPeriod)
+ : "",
+ hasDongbang: club.hasDongbang ?? false,
+ dongbangLocation: club.dongbangLocation ?? "",
+ clubSNSUrls: snsUrls.length > 0 ? snsUrls : [""],
+ activityImages: (club.activityImageUrls ?? []).map((uri, index) => ({
+ id: `remote-${index}-${uri}`,
+ uri,
+ })),
+ clubDescription: club.introduction ?? "",
+ };
+};
+
+const getInitialMinActivityPeriodMode = (
+ club: ManagedClubDetail,
+): MinActivityPeriodMode =>
+ (club.minActivityPeriod ?? 0) > 0 ? "number" : "none";
+
+const normalizeComparableClubInfo = (
+ data: ClubInfoEditFormData,
+ minActivityPeriodMode: MinActivityPeriodMode,
+): ComparableClubInfo => ({
+ name: data.clubName.trim(),
+ category: data.category,
+ affiliation: data.department.trim(),
+ shortDescription: data.shortIntro.trim(),
+ recruitType: data.recruitType,
+ minActivityPeriod:
+ minActivityPeriodMode === "number"
+ ? parseInt(data.minActivityPeriodInput, 10) || 0
+ : 0,
+ hasDongbang: data.hasDongbang,
+ dongbangLocation: data.hasDongbang ? data.dongbangLocation.trim() : "",
+ snsUrls: normalizeSnsUrls(data.clubSNSUrls),
+ introduction: data.clubDescription.trim(),
+});
+
+const buildUpdateManagedClubRequest = (
+ uuid: string,
+ current: ComparableClubInfo,
+ initial: ComparableClubInfo,
+): UpdateManagedClubRequest | null => {
+ const request: UpdateManagedClubRequest = { uuid };
+
+ if (current.name !== initial.name) {
+ request.name = current.name;
+ }
+ if (current.category !== initial.category) {
+ request.category = current.category as Category["name"];
+ }
+ if (current.affiliation !== initial.affiliation) {
+ request.affiliation = current.affiliation;
+ }
+ if (current.shortDescription !== initial.shortDescription) {
+ request.short_description = current.shortDescription;
+ }
+ if (current.recruitType !== initial.recruitType) {
+ request.recruit_type = current.recruitType;
+ }
+ if (current.minActivityPeriod !== initial.minActivityPeriod) {
+ request.min_activity_period = current.minActivityPeriod;
+ }
+ if (current.hasDongbang !== initial.hasDongbang) {
+ request.has_dongbang = current.hasDongbang;
+ }
+ if (
+ current.dongbangLocation !== initial.dongbangLocation ||
+ current.hasDongbang !== initial.hasDongbang
+ ) {
+ request.dongbang_location = current.dongbangLocation;
+ }
+ if (JSON.stringify(current.snsUrls) !== JSON.stringify(initial.snsUrls)) {
+ Object.assign(request, getSnsRequestFields(current.snsUrls));
+ }
+ if (current.introduction !== initial.introduction) {
+ request.introduction = current.introduction;
+ }
+
+ return Object.keys(request).length > 1 ? request : null;
+};
+
+const SuccessModal = memo(
+ ({ visible, title, onConfirm }: SuccessModalProps) => (
+
+
+
+ e.stopPropagation()}
+ >
+
+ {title}
+
+
+ 확인
+
+
+
+
+ ),
+);
+
+SuccessModal.displayName = "SuccessModal";
+
+type ClubCategorySectionProps = {
+ category: ClubInfoEditFormData["category"];
+ onChange: (category: ClubInfoEditFormData["category"]) => void;
+};
+
+const ClubCategorySection = memo(
+ ({ category, onChange }: ClubCategorySectionProps) => {
+ return (
+
+ 카테고리
+
+ {CLUB_CATEGORIES.map((categoryOption) => {
+ const isSelected = category === categoryOption;
+ return (
+ onChange(categoryOption)}
+ >
+
+ {categoryOption}
+
+
+ );
+ })}
+
+
+ );
+ },
+);
+
+ClubCategorySection.displayName = "ClubCategorySection";
+
+type ClubRecruitTypeSectionProps = {
+ recruitType: string;
+ onChange: (recruitType: string) => void;
+};
+
+const ClubRecruitTypeSection = memo(
+ ({ recruitType, onChange }: ClubRecruitTypeSectionProps) => {
+ return (
+
+ 모집 형태
+
+ {CLUB_RECRUIT_TYPES.map((recruitTypeOption) => {
+ const isSelected = recruitType === recruitTypeOption;
+ return (
+ onChange(recruitTypeOption)}
+ >
+
+ {recruitTypeOption}
+
+
+ );
+ })}
+
+
+ );
+ },
+);
+
+ClubRecruitTypeSection.displayName = "ClubRecruitTypeSection";
+
+type ClubActivityPeriodSectionProps = {
+ mode: MinActivityPeriodMode;
+ semesters: number;
+ onModeChange: (mode: MinActivityPeriodMode) => void;
+ onDecrement: () => void;
+ onIncrement: () => void;
+};
+
+const ClubActivityPeriodSection = memo(
+ ({
+ mode,
+ semesters,
+ onModeChange,
+ onDecrement,
+ onIncrement,
+ }: ClubActivityPeriodSectionProps) => {
+ return (
+
+ (최소) 활동 기간
+
+
+ onModeChange("none")}
+ >
+
+ 없음
+
+
+ onModeChange("number")}
+ >
+
+ 있음
+
+
+
+
+
+ {semesters}학기
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+ClubActivityPeriodSection.displayName = "ClubActivityPeriodSection";
+
+type ClubDongbangSectionProps = {
+ hasDongbang: boolean;
+ location: string;
+ onHasDongbangChange: (value: boolean) => void;
+ onLocationChange: (value: string) => void;
+};
+
+const ClubDongbangSection = memo(
+ ({
+ hasDongbang,
+ location,
+ onHasDongbangChange,
+ onLocationChange,
+ }: ClubDongbangSectionProps) => {
+ return (
+
+ 동방 보유 여부
+
+ onHasDongbangChange(true)}
+ >
+
+ 보유
+
+
+ onHasDongbangChange(false)}
+ >
+
+ 미보유
+
+
+
+
+ {hasDongbang && (
+
+
+
+
+ )}
+
+ );
+ },
+);
+
+ClubDongbangSection.displayName = "ClubDongbangSection";
+
+type ClubBasicInfoSectionProps = {
+ clubImage: string | null;
+ clubName: string;
+ department: string;
+ shortIntro: string;
+ showDepartmentDropdown: boolean;
+ onSelectImage: () => void;
+ onClubNameChange: (value: string) => void;
+ onToggleDepartmentDropdown: () => void;
+ onShortIntroChange: (value: string) => void;
+};
+
+const ClubBasicInfoSection = memo(
+ ({
+ clubImage,
+ clubName,
+ department,
+ shortIntro,
+ showDepartmentDropdown,
+ onSelectImage,
+ onClubNameChange,
+ onToggleDepartmentDropdown,
+ onShortIntroChange,
+ }: ClubBasicInfoSectionProps) => (
+ <>
+
+ 동아리 대표 이미지
+
+ 4X4 정방형 이미지를 권장드려요
+
+
+ {clubImage ? (
+
+ ) : (
+
+ )}
+
+
+ 이미지 변경
+
+
+
+
+ 동아리명
+
+
+
+
+ 한줄소개
+
+
+
+ {department || "단과대/학과"}
+
+
+
+ 소속
+
+
+
+
+ >
+ ),
+);
+
+ClubBasicInfoSection.displayName = "ClubBasicInfoSection";
+
+type ClubDescriptionSectionProps = {
+ description: string;
+ onChange: (value: string) => void;
+};
+
+const ClubDescriptionSection = memo(
+ ({ description, onChange }: ClubDescriptionSectionProps) => (
+
+ 동아리 추가 설명
+
+
+ ),
+);
+
+ClubDescriptionSection.displayName = "ClubDescriptionSection";
+
+type ClubEditFooterProps = {
+ paddingBottom: number;
+ disabled: boolean;
+ isSubmitting: boolean;
+ submitLabel: string;
+ onSubmit: () => void;
+};
+
+const ClubEditFooter = memo(
+ ({
+ paddingBottom,
+ disabled,
+ isSubmitting,
+ submitLabel,
+ onSubmit,
+ }: ClubEditFooterProps) => (
+
+
+
+ {isSubmitting ? `${submitLabel} 중...` : submitLabel}
+
+
+
+ ),
+);
+
+ClubEditFooter.displayName = "ClubEditFooter";
+
+type DepartmentModalProps = {
+ visible: boolean;
+ search: string;
+ options: string[];
+ selectedDepartment: string;
+ onClose: () => void;
+ onSearchChange: (value: string) => void;
+ onSelect: (department: string) => void;
+};
+
+const DepartmentModal = memo(
+ ({
+ visible,
+ search,
+ options,
+ selectedDepartment,
+ onClose,
+ onSearchChange,
+ onSelect,
+ }: DepartmentModalProps) => (
+
+
+ event.stopPropagation()}
+ >
+ 소속 선택
+
+ department}
+ renderItem={({ item: department }) => {
+ const isSelected = selectedDepartment === department;
+ return (
+ onSelect(department)}
+ >
+
+ {department}
+
+
+ );
+ }}
+ ListEmptyComponent={
+ 검색 결과가 없어요
+ }
+ />
+
+
+
+ ),
+);
+
+DepartmentModal.displayName = "DepartmentModal";
+
+const ClubInfoEditScreen = () => {
+ const route = useRoute();
+ const {
+ clubId,
+ fromClubRegistrationEdit = false,
+ isResubmission = false,
+ managerData,
+ } = route.params;
+ const { clubService, userService } = useContext(serviceContext);
+ const queryClient = useQueryClient();
+ const insets = useSafeAreaInsets();
+
+ const [formData, setFormData] =
+ useState(initialFormData);
+ const [minActivityPeriodMode, setMinActivityPeriodMode] =
+ useState("none");
+ const [showDepartmentDropdown, setShowDepartmentDropdown] = useState(false);
+ const [departmentSearch, setDepartmentSearch] = useState("");
+ const [imageFile, setImageFile] = useState(null);
+ const [showSuccessModal, setShowSuccessModal] = useState(false);
+
+ const { data: club, isLoading } = useQuery({
+ queryKey: ["managedClub", clubId],
+ queryFn: () => clubService.getManagedClubDetail({ uuid: clubId }),
+ });
+ const { data: departmentOptions = DEFAULT_DEPARTMENT_OPTIONS } = useQuery({
+ queryKey: ["collegeMajors", "clubAffiliation"],
+ queryFn: () => userService.listCollegeMajors({ includeNullMajor: true }),
+ select: (data) => buildDepartmentOptions(data.majors),
+ });
+
+ useEffect(() => {
+ if (club) {
+ setFormData(mapClubToFormData(club));
+ setMinActivityPeriodMode(getInitialMinActivityPeriodMode(club));
+ setImageFile(null);
+ }
+ }, [club]);
+
+ const setFormField = useCallback(
+ (
+ key: K,
+ value: ClubInfoEditFormData[K],
+ ) => {
+ setFormData((prev) => ({ ...prev, [key]: value }));
+ },
+ [],
+ );
+
+ const minActivityPeriodSemesters =
+ parseInt(formData.minActivityPeriodInput, 10) || 0;
+ const filteredDepartmentOptions = useMemo(() => {
+ const keyword = departmentSearch.trim();
+ if (!keyword) {
+ return departmentOptions;
+ }
+ return departmentOptions.filter((department) =>
+ department.includes(keyword),
+ );
+ }, [departmentOptions, departmentSearch]);
+
+ const handleSelectDepartment = useCallback(
+ (department: string) => {
+ setFormField("department", department);
+ setShowDepartmentDropdown(false);
+ setDepartmentSearch("");
+ },
+ [setFormField],
+ );
+ const handleCloseDepartmentDropdown = useCallback(
+ () => setShowDepartmentDropdown(false),
+ [],
+ );
+
+ const handleSelectImage = useCallback(async () => {
+ const result = await launchImageLibrary({
+ mediaType: "photo",
+ selectionLimit: 1,
+ includeBase64: false,
+ });
+
+ if (result.didCancel) {
+ return;
+ }
+
+ if (result.errorCode) {
+ Toast.show({
+ type: "error",
+ text1: "이미지를 불러오지 못했어요",
+ text2: result.errorMessage,
+ position: "top",
+ topOffset: 60,
+ visibilityTime: 2000,
+ });
+ return;
+ }
+
+ const asset = result.assets?.[0];
+ if (!asset?.uri) {
+ return;
+ }
+
+ setFormField("clubImage", asset.uri);
+ setImageFile({
+ uri: asset.uri,
+ type: asset.type ?? "image/jpeg",
+ name: asset.fileName ?? `club_${Date.now()}.jpg`,
+ });
+ }, [setFormField]);
+
+ const handleMinActivityPeriodModeChange = useCallback(
+ (mode: MinActivityPeriodMode) => {
+ setMinActivityPeriodMode(mode);
+ setFormField("minActivityPeriodInput", mode === "number" ? "1" : "");
+ },
+ [setFormField],
+ );
+
+ const incrementMinActivityPeriod = useCallback(() => {
+ const next = incrementMinActivityPeriodValue(minActivityPeriodSemesters);
+ setMinActivityPeriodMode(next.mode);
+ setFormField("minActivityPeriodInput", next.value);
+ }, [minActivityPeriodSemesters, setFormField]);
+
+ const decrementMinActivityPeriod = useCallback(() => {
+ const next = decrementMinActivityPeriodValue(minActivityPeriodSemesters);
+ setMinActivityPeriodMode(next.mode);
+ setFormField("minActivityPeriodInput", next.value);
+ }, [minActivityPeriodSemesters, setFormField]);
+
+ const handleSetHasDongbang = useCallback((value: boolean) => {
+ setFormData((prev) => ({
+ ...prev,
+ hasDongbang: value,
+ dongbangLocation: value ? prev.dongbangLocation : "",
+ }));
+ }, []);
+
+ const handleCategoryChange = useCallback(
+ (category: ClubInfoEditFormData["category"]) =>
+ setFormField("category", category),
+ [setFormField],
+ );
+ const handleClubNameChange = useCallback(
+ (clubName: string) => setFormField("clubName", clubName),
+ [setFormField],
+ );
+ const handleShortIntroChange = useCallback(
+ (shortIntro: string) => setFormField("shortIntro", shortIntro),
+ [setFormField],
+ );
+ const handleToggleDepartmentDropdown = useCallback(
+ () => setShowDepartmentDropdown((prev) => !prev),
+ [],
+ );
+ const handleRecruitTypeChange = useCallback(
+ (recruitType: string) => setFormField("recruitType", recruitType),
+ [setFormField],
+ );
+ const handleDongbangLocationChange = useCallback(
+ (location: string) => setFormField("dongbangLocation", location),
+ [setFormField],
+ );
+ const handleSnsUrlsChange = useCallback(
+ (clubSNSUrls: string[]) => setFormField("clubSNSUrls", clubSNSUrls),
+ [setFormField],
+ );
+ const handleActivityImagesChange = useCallback(
+ (activityImages: EditableImage[]) =>
+ setFormField("activityImages", activityImages),
+ [setFormField],
+ );
+ const handleDescriptionChange = useCallback(
+ (clubDescription: string) =>
+ setFormField("clubDescription", clubDescription),
+ [setFormField],
+ );
+
+ const initialComparable = useMemo(() => {
+ if (!club) {
+ return null;
+ }
+ const initialData = mapClubToFormData(club);
+ const initialMinActivityPeriodMode = getInitialMinActivityPeriodMode(club);
+ return normalizeComparableClubInfo(
+ initialData,
+ initialMinActivityPeriodMode,
+ );
+ }, [club]);
+
+ const currentComparable = useMemo(
+ () => normalizeComparableClubInfo(formData, minActivityPeriodMode),
+ [minActivityPeriodMode, formData],
+ );
+
+ const isSNSComplete =
+ JSON.stringify(currentComparable.snsUrls) ===
+ JSON.stringify(initialComparable?.snsUrls) ||
+ areValidSnsUrls(formData.clubSNSUrls);
+
+ const isComplete =
+ !!formData.clubImage &&
+ !!formData.clubName.trim() &&
+ !!formData.category &&
+ !!formData.department.trim() &&
+ !!formData.shortIntro.trim() &&
+ !!formData.recruitType.trim() &&
+ isSNSComplete &&
+ !!formData.clubDescription.trim() &&
+ (minActivityPeriodMode === "none" ||
+ !!formData.minActivityPeriodInput.trim());
+
+ const updateRequest = useMemo(() => {
+ if (!initialComparable) {
+ return null;
+ }
+ return buildUpdateManagedClubRequest(
+ clubId,
+ currentComparable,
+ initialComparable,
+ );
+ }, [clubId, currentComparable, initialComparable]);
+
+ const initialActivityImageUrls = club?.activityImageUrls ?? [];
+ const retainedActivityImageUrls = formData.activityImages
+ .filter((image) => !image.file)
+ .map((image) => image.uri);
+ const newActivityImageFiles = formData.activityImages.flatMap((image) =>
+ image.file ? [image.file] : [],
+ );
+ const activityImagesChanged =
+ newActivityImageFiles.length > 0 ||
+ JSON.stringify(retainedActivityImageUrls) !==
+ JSON.stringify(initialActivityImageUrls);
+ const hasChanges = !!imageFile || !!updateRequest || activityImagesChanged;
+ const hasAnyChanges =
+ hasChanges || managerData !== undefined || isResubmission;
+
+ const { mutate: updateClub, isPending: isSubmitting } = useMutation({
+ mutationFn: async () => {
+ if (imageFile) {
+ await clubService.uploadManagedClubImage({
+ clubId,
+ uri: imageFile.uri,
+ type: imageFile.type,
+ name: imageFile.name,
+ });
+ }
+
+ const request: UpdateManagedClubRequest = updateRequest
+ ? { ...updateRequest }
+ : { uuid: clubId };
+
+ if (activityImagesChanged) {
+ const uploadedImages = await Promise.all(
+ newActivityImageFiles.map((file) =>
+ clubService.uploadClubActivityImage({ clubId, ...file }),
+ ),
+ );
+ request.activity_image_urls = [
+ ...retainedActivityImageUrls,
+ ...uploadedImages.map((image) => image.url),
+ ];
+ }
+
+ if (managerData) {
+ request.manager_data = managerData;
+ }
+
+ if (isResubmission) {
+ request.resubmit = true;
+ }
+
+ if (Object.keys(request).length > 1) {
+ return clubService.updateManagedClub(request);
+ }
+ },
+ onSuccess: async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] }),
+ queryClient.invalidateQueries({ queryKey: ["manageClubs"] }),
+ queryClient.invalidateQueries({ queryKey: ["clubs", clubId] }),
+ queryClient.invalidateQueries({
+ queryKey: ["clubManagerInfo", "clubRegistration", clubId],
+ }),
+ ]);
+ setShowSuccessModal(true);
+ },
+ onError: (error) => {
+ const errorContent = getManagedClubUpdateErrorContent(error);
+ Toast.show({
+ type: "error",
+ text1: errorContent.title,
+ text2: errorContent.description,
+ position: "top",
+ topOffset: 60,
+ visibilityTime: 2000,
+ });
+ },
+ });
+
+ const handleSubmit = useCallback(() => {
+ if (!isComplete || !hasAnyChanges || isSubmitting) {
+ return;
+ }
+ updateClub();
+ }, [hasAnyChanges, isComplete, isSubmitting, updateClub]);
+
+ const handleConfirmSuccess = useCallback(() => {
+ setShowSuccessModal(false);
+ if (fromClubRegistrationEdit) {
+ navigation.setRoot("마이");
+ return;
+ }
+
+ navigation.goBack();
+ }, [fromClubRegistrationEdit]);
+
+ return (
+
+
+ navigation.goBack()}
+ hitSlop={8}
+ >
+
+
+ 동아리 정보 수정
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default ClubInfoEditScreen;
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ header: {
+ height: vs(39),
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: Colors.WHITE,
+ },
+ backBtn: {
+ position: "absolute",
+ left: s(20),
+ width: ms(24),
+ height: ms(24),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ headerTitle: {
+ fontFamily: "Pretendard",
+ fontWeight: "600",
+ fontSize: ms(16),
+ lineHeight: ms(19),
+ letterSpacing: -0.02 * 16,
+ color: Colors.BODYTEXT_SUB,
+ },
+ loadingContent: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ scroll: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ paddingHorizontal: s(20),
+ paddingTop: vs(24),
+ paddingBottom: vs(32),
+ },
+ form: {
+ gap: vs(28),
+ },
+ fieldWrapper: {
+ gap: vs(8),
+ },
+ label: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ },
+ fieldLabel: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ },
+ helperDescription: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ imageUploadBox: {
+ width: s(150),
+ height: s(150),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: s(12),
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: Colors.WHITE,
+ marginTop: vs(4),
+ overflow: "hidden",
+ },
+ uploadedImage: {
+ width: "100%",
+ height: "100%",
+ borderRadius: s(11),
+ },
+ imageChangeButton: {
+ alignSelf: "flex-start",
+ paddingVertical: vs(4),
+ },
+ imageChangeText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ },
+ affiliationRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(16),
+ },
+ affiliationLabel: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ dropdown: {
+ width: "55%",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: s(16),
+ paddingVertical: vs(14),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ dropdownActive: {
+ borderColor: Colors.BUTTON_SELECTED,
+ },
+ dropdownText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ flex: 1,
+ },
+ dropdownSearchInput: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(10),
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.BACKGROUND_SUB,
+ },
+ dropdownList: {
+ maxHeight: vs(360),
+ },
+ dropdownItem: {
+ paddingHorizontal: s(16),
+ paddingVertical: vs(12),
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.BACKGROUND_SUB,
+ },
+ dropdownItemSelected: {
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ dropdownItemText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ dropdownItemTextSelected: {
+ color: Colors.BUTTON_SELECTED,
+ fontWeight: "600",
+ },
+ dropdownEmptyText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_DISABLED,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(12),
+ },
+ departmentModalOverlay: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_DIM,
+ justifyContent: "center",
+ paddingHorizontal: s(20),
+ },
+ departmentModalCard: {
+ backgroundColor: Colors.WHITE,
+ borderRadius: 12,
+ overflow: "hidden",
+ },
+ departmentModalTitle: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingTop: vs(16),
+ paddingBottom: vs(8),
+ },
+ categoryGrid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: s(12),
+ },
+ categoryBlock: {
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ borderRadius: s(12),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ backgroundColor: Colors.WHITE,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ categoryBlockSelected: {
+ backgroundColor: Colors.BUTTON_SELECTED,
+ borderColor: Colors.BUTTON_SELECTED,
+ },
+ categoryText: {
+ ...typography.headerXLSemibold,
+ lineHeight: vs(30),
+ color: Colors.BODYTEXT_DISABLED,
+ textAlign: "center",
+ },
+ categoryTextSelected: {
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+ buttonGroup: {
+ flexDirection: "row",
+ gap: s(8),
+ },
+ typeButton: {
+ flex: 1,
+ paddingVertical: vs(14),
+ paddingHorizontal: s(12),
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ backgroundColor: Colors.WHITE,
+ alignItems: "center",
+ },
+ typeButtonSelected: {
+ backgroundColor: Colors.BUTTON_SELECTED,
+ borderColor: Colors.BUTTON_SELECTED,
+ },
+ typeButtonText: {
+ ...typography.bodyMMedium,
+ color: Colors.BODYTEXT_DISABLED,
+ },
+ typeButtonTextSelected: {
+ color: Colors.WHITE,
+ fontWeight: "600",
+ },
+ periodRow: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ gap: s(16),
+ },
+ modeButtons: {
+ flex: 1,
+ flexDirection: "row",
+ gap: s(8),
+ },
+ periodStepper: {
+ alignItems: "center",
+ gap: vs(6),
+ },
+ semesterValue: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ stepperPill: {
+ flexDirection: "row",
+ alignItems: "center",
+ borderRadius: 8,
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ stepperButton: {
+ paddingHorizontal: s(16),
+ paddingVertical: vs(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ stepperDivider: {
+ width: 1,
+ height: vs(16),
+ backgroundColor: Colors.BODYTEXT_DISABLED,
+ },
+ iconInputRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(8),
+ },
+ iconInput: {
+ flex: 1,
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(8),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ descriptionInput: {
+ minHeight: vs(60),
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(12),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ footer: {
+ paddingTop: vs(24),
+ },
+ submitButton: {
+ height: vs(48),
+ borderRadius: 8,
+ backgroundColor: Colors.BUTTON_SELECTED,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ submitButtonDisabled: {
+ backgroundColor: Colors.BUTTON_UNSELECTED,
+ },
+ submitButtonText: {
+ ...typography.headerL,
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_DIM,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalCard: {
+ width: ms(320),
+ backgroundColor: Colors.WHITE,
+ borderRadius: ms(12),
+ padding: ms(24),
+ gap: ms(24),
+ alignItems: "center",
+ },
+ modalContents: {
+ alignSelf: "stretch",
+ alignItems: "center",
+ gap: ms(8),
+ },
+ modalTitle: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "700",
+ fontSize: ms(16),
+ lineHeight: ms(24),
+ color: Colors.BODYTEXT_MAIN,
+ textAlign: "center",
+ alignSelf: "stretch",
+ },
+ modalSingleBtn: {
+ alignSelf: "stretch",
+ height: ms(44),
+ backgroundColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalConfirmText: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "600",
+ fontSize: ms(16),
+ lineHeight: ms(20),
+ color: Colors.WHITE,
+ },
+});
diff --git a/apps/mobile/src/features/club/screens/ClubListScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubListScreen/index.tsx
new file mode 100644
index 00000000..9a52a6ac
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/ClubListScreen/index.tsx
@@ -0,0 +1,93 @@
+import type { RouteProp } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { useQuery } from "@tanstack/react-query";
+import { useContext } from "react";
+import { SafeAreaView } from "react-native-safe-area-context";
+import type { Category } from "@/entities/category";
+import type { Club } from "@/entities/club";
+import ClubList from "@/features/club/components/ClubList/ClubList";
+import Header from "@/shared/components/BackHeader";
+import { CategoryMap } from "@/shared/constants/category";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import WithViewEventLog from "@/shared/hocs/WithViewEventLog";
+
+type DetailsScreenRouteProp = RouteProp;
+type DetailsScreenNavigationProp = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.CLUB_LIST
+>;
+
+type Props = {
+ route: DetailsScreenRouteProp;
+ navigation: DetailsScreenNavigationProp;
+};
+
+const ClubListScreen = ({ route, navigation }: Props) => {
+ const { name, category } = route.params;
+ const { data: clubs, isLoading } = useCategoryClubs({ name, category });
+
+ if (!category) return null;
+ const categoryDetail = CategoryMap[category];
+ const headerTitle = `${categoryDetail.name} 동아리`;
+
+ const openDetailPage = (club: Club) => {
+ navigation.navigate(SCREEN_TYPE.CLUB_DETAIL, {
+ uuid: club.uuid,
+ category: club.category,
+ entry_point: "club_list",
+ });
+ };
+
+ const handleMoveToHomePage = () => {
+ navigation.navigate(SCREEN_TYPE.HOME);
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default ClubListScreen;
+
+type UseCategoryClubsProps = {
+ name?: Club["name"];
+ category?: Category["name"];
+};
+
+const useCategoryClubs = ({ name, category }: UseCategoryClubsProps) => {
+ const { clubService } = useContext(serviceContext);
+
+ return useQuery(
+ ["clubs", name ?? "name", category ?? "category"],
+ () => clubService.listClubs({ name, category }),
+ {
+ select: (data) => data.clubs,
+ staleTime: Infinity,
+ },
+ );
+};
diff --git a/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx
new file mode 100644
index 00000000..ea697611
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/ClubManagementScreen/index.tsx
@@ -0,0 +1,1037 @@
+import { BlurView } from "@react-native-community/blur";
+import {
+ type RouteProp,
+ useFocusEffect,
+ useRoute,
+} from "@react-navigation/native";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useContext, useState } from "react";
+import {
+ ActivityIndicator,
+ Image,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Toast from "react-native-toast-message";
+import Icon from "react-native-vector-icons/MaterialIcons";
+
+const clubManagementSnuLogo =
+ require("@/assets/images/club-management-snu-logo.png") as number;
+
+import OfficialVerificationRequestButton from "@/features/club/components/OfficialVerificationRequestButton";
+import VerificationMark from "@/features/club/components/VerificationMark";
+import { getOfficialVerificationRequestErrorContent } from "@/features/club/utils/officialVerificationRequest";
+import AlertModal from "@/shared/components/AlertModal";
+import EditPencilButton from "@/shared/components/EditPencilButton";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { getClubSummaryWithAffiliation } from "@/shared/utils/club";
+import { navigation } from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type RouteProps = RouteProp;
+
+const VISIBLE_COUNT = 2;
+
+type DeleteTarget = { id: number; title: string } | null;
+type VerificationConfirmation = "request" | "retry" | null;
+type VerificationSuccess = {
+ isRetry: boolean;
+ retryCount: number;
+ retryLimit: number;
+ remainingRetryCount: number;
+};
+
+const VERIFICATION_PROCESSING_DURATION = "최대 일주일";
+const MANAGEMENT_VERIFICATION_MARK_SIZE = 16;
+
+const ClubManagementScreen = () => {
+ const route = useRoute();
+ const { clubId } = route.params;
+
+ const { clubService, recruitmentService } = useContext(serviceContext);
+ const queryClient = useQueryClient();
+
+ const [showMore, setShowMore] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deletedTitle, setDeletedTitle] = useState(null);
+ const [showEditConfirm, setShowEditConfirm] = useState(false);
+ const [verificationConfirmation, setVerificationConfirmation] =
+ useState(null);
+ const [verificationSuccess, setVerificationSuccess] =
+ useState(null);
+ const [showRetryLimitNotice, setShowRetryLimitNotice] = useState(false);
+ const [isRetryLimitAcknowledged, setIsRetryLimitAcknowledged] =
+ useState(false);
+
+ const {
+ data: club,
+ isLoading: isVerificationStatusLoading,
+ refetch: refetchManagedClub,
+ } = useQuery({
+ queryKey: ["managedClub", clubId],
+ queryFn: () => clubService.getManagedClubDetail({ uuid: clubId }),
+ staleTime: 0,
+ refetchOnWindowFocus: "always",
+ });
+
+ const {
+ data: recruitmentsData,
+ isLoading,
+ refetch: refetchRecruitments,
+ } = useQuery({
+ queryKey: ["clubRecruitments", clubId],
+ queryFn: () => recruitmentService.listClubRecruitments({ clubId }),
+ });
+
+ const {
+ data: representativeRecruitment,
+ isLoading: isRepresentativeLoading,
+ refetch: refetchRepresentativeRecruitment,
+ } = useQuery({
+ queryKey: ["clubRepresentativeRecruitment", clubId],
+ queryFn: () => recruitmentService.getRepresentativeRecruitment({ clubId }),
+ });
+
+ useFocusEffect(
+ useCallback(() => {
+ refetchManagedClub();
+ refetchRecruitments();
+ refetchRepresentativeRecruitment();
+ }, [
+ refetchManagedClub,
+ refetchRecruitments,
+ refetchRepresentativeRecruitment,
+ ]),
+ );
+
+ const { mutate: deleteRecruitment, isPending: isDeleting } = useMutation({
+ mutationFn: (recruitmentId: number) =>
+ recruitmentService.deleteRecruitment({ recruitmentId }),
+ onSuccess: () => {
+ const title = deleteTarget?.title ?? "";
+ setDeleteTarget(null);
+ setDeletedTitle(title);
+ queryClient.invalidateQueries({ queryKey: ["clubRecruitments", clubId] });
+ queryClient.invalidateQueries({
+ queryKey: ["clubRepresentativeRecruitment", clubId],
+ });
+ },
+ onError: () => {
+ setDeleteTarget(null);
+ },
+ });
+
+ const {
+ mutate: requestOfficialVerification,
+ isPending: isRequestingVerification,
+ } = useMutation({
+ mutationFn: (_isRetry: boolean) =>
+ clubService.requestOfficialVerification({ clubId }),
+ onSuccess: (response, isRetry) => {
+ queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] });
+ queryClient.invalidateQueries({ queryKey: ["clubs", clubId] });
+ setVerificationSuccess({
+ isRetry,
+ retryCount: response.data.retry_count,
+ retryLimit:
+ response.data.retry_count + response.data.remaining_retry_count,
+ remainingRetryCount: response.data.remaining_retry_count,
+ });
+ },
+ onError: (error) => {
+ const errorContent = getOfficialVerificationRequestErrorContent(error);
+ queryClient.invalidateQueries({ queryKey: ["managedClub", clubId] });
+ queryClient.invalidateQueries({ queryKey: ["clubs", clubId] });
+ Toast.show({
+ type: "error",
+ text1: errorContent.title,
+ text2: errorContent.description,
+ });
+ },
+ });
+
+ const recruitments = recruitmentsData?.recruitments ?? [];
+ const representativeRecruitmentId = representativeRecruitment?.id ?? null;
+ const currentRecruitment =
+ recruitments.find(
+ (r) => String(r.id) === String(representativeRecruitmentId),
+ ) ??
+ recruitments[0] ??
+ null;
+ const currentRecruitmentId = currentRecruitment?.id ?? null;
+ const previousRecruitments = currentRecruitment
+ ? recruitments.filter((r) => r.id !== currentRecruitmentId)
+ : recruitments;
+ const hasMore = previousRecruitments.length > VISIBLE_COUNT;
+ const isRecruitmentsLoading = isLoading || isRepresentativeLoading;
+ const summary = club ? getClubSummaryWithAffiliation(club) : "";
+ const officialVerificationStatus =
+ club?.officialVerification?.status ??
+ (club?.isOfficialVerified ? "VERIFIED" : undefined);
+ const isRetryLimitReached =
+ club?.officialVerification?.status === "REJECTED" &&
+ club.officialVerification.remainingRetryCount === 0;
+ const isRetryConfirmation = verificationConfirmation === "retry";
+ const verificationConfirmationTitle = isRetryConfirmation
+ ? "올클 공식인증이 반려되었어요."
+ : "공식 인증을 요청할까요?";
+ const verificationConfirmationDescription = isRetryConfirmation
+ ? "세부사항을 확인하고 인증을 재요청해주세요"
+ : "인증된 동아리는 스티커를 부여해드려요.";
+ const verificationSuccessDescription = verificationSuccess?.isRetry
+ ? `재요청은 최대 ${verificationSuccess.retryLimit}회까지 가능해요\n${verificationSuccess.retryCount}/${verificationSuccess.retryLimit}회 (남은 횟수 ${verificationSuccess.remainingRetryCount}회)`
+ : `인증이 완료되기까지는\n${VERIFICATION_PROCESSING_DURATION}이 소요될 수 있어요`;
+
+ return (
+
+ {/* 헤더 */}
+
+ navigation.goBack()}
+ hitSlop={8}
+ >
+
+
+ 동아리 관리
+
+
+
+ {/* 동아리 카드 */}
+
+ {club?.imageUri ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {club?.name ?? ""}
+
+
+
+
+ {summary}
+
+
+
+ setShowEditConfirm(true)}
+ />
+
+
+
+
+ isRetryLimitReached
+ ? setShowRetryLimitNotice(true)
+ : setVerificationConfirmation(
+ officialVerificationStatus === "REJECTED"
+ ? "retry"
+ : "request",
+ )
+ }
+ />
+
+ {/* 공고 관리 */}
+
+
+ 공고 관리
+
+
+
+ {/* 새 공고 작성하기 버튼 */}
+
+ navigation.navigate(SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION, {
+ clubId,
+ })
+ }
+ >
+ 새 공고 작성하기
+
+
+
+ {/* 공고 목록 */}
+ {isRecruitmentsLoading ? (
+
+ ) : recruitments.length === 0 ? (
+
+ 등록된 공고가 없어요
+
+ ) : (
+ <>
+ {/* 대표 공고 - 상단 고정 표시 */}
+ {currentRecruitment && (
+
+
+
+
+ 현재 공고
+
+
+
+ {currentRecruitment.display_title}
+
+
+
+
+ navigation.navigate(SCREEN_TYPE.ANNOUNCEMENT_EDIT, {
+ recruitmentId: currentRecruitment.id,
+ })
+ }
+ >
+
+
+
+ setDeleteTarget({
+ id: currentRecruitment.id,
+ title: currentRecruitment.display_title,
+ })
+ }
+ >
+
+
+
+
+ )}
+
+ {/* 나머지 공고 목록 */}
+ {previousRecruitments.slice(0, VISIBLE_COUNT).map((item) => (
+
+
+
+ {item.display_title}
+
+
+
+
+ navigation.navigate(SCREEN_TYPE.ANNOUNCEMENT_EDIT, {
+ recruitmentId: item.id,
+ })
+ }
+ >
+
+
+
+ setDeleteTarget({
+ id: item.id,
+ title: item.display_title,
+ })
+ }
+ >
+
+
+
+
+ ))}
+
+ {hasMore && (
+ setShowMore((prev) => !prev)}
+ >
+ 이전 공고 더보기
+
+
+ )}
+
+ {showMore &&
+ previousRecruitments.slice(VISIBLE_COUNT).map((item) => (
+
+
+
+ {item.display_title}
+
+
+
+
+ navigation.navigate(SCREEN_TYPE.ANNOUNCEMENT_EDIT, {
+ recruitmentId: item.id,
+ })
+ }
+ >
+
+
+
+ setDeleteTarget({
+ id: item.id,
+ title: item.display_title,
+ })
+ }
+ >
+
+
+
+
+ ))}
+ >
+ )}
+
+
+
+ {/* 운영진 목록 */}
+
+
+ 운영진 목록
+
+
+ {!club?.managers || club.managers.length === 0 ? (
+
+ 등록된 운영진이 없어요
+
+ ) : (
+ club.managers.map((manager) => (
+
+ {manager.name}
+ {!!manager.studentId && (
+
+ {manager.studentId}
+
+ )}
+
+ ))
+ )}
+
+
+
+
+
+ !isRequestingVerification && setVerificationConfirmation(null)
+ }
+ title={verificationConfirmationTitle}
+ titleContent={
+ isRetryConfirmation ? (
+ <>
+ 올클 공식인증이{" "}
+
+ 반려되었어요.
+
+ >
+ ) : (
+ <>
+
+ 공식 인증을 요청
+
+ 할까요?
+ >
+ )
+ }
+ description={verificationConfirmationDescription}
+ buttonLabel={isRetryConfirmation ? "재요청" : "확인"}
+ onButtonPress={() => {
+ const isRetry = verificationConfirmation === "retry";
+ setVerificationConfirmation(null);
+ requestOfficialVerification(isRetry);
+ }}
+ buttonDisabled={isRequestingVerification}
+ hasCancel
+ dismissOnBackdropPress={!isRequestingVerification}
+ />
+
+ setVerificationSuccess(null)}
+ title={
+ verificationSuccess?.isRetry
+ ? "인증 재요청이 정상적으로 완료되었어요!"
+ : "인증요청이 정상적으로 완료되었어요!"
+ }
+ titleStyle={styles.verificationSuccessModalTitle}
+ description={verificationSuccessDescription}
+ descriptionContent={
+ verificationSuccess?.isRetry ? (
+ <>
+ 재요청은 최대 {verificationSuccess.retryLimit}회까지 가능해요
+ {"\n"}
+
+ {verificationSuccess.retryCount}
+
+ /{verificationSuccess.retryLimit}회 (남은 횟수{" "}
+
+ {verificationSuccess.remainingRetryCount}
+
+ 회)
+ >
+ ) : undefined
+ }
+ buttonLabel="확인"
+ onButtonPress={() => setVerificationSuccess(null)}
+ />
+
+ setShowRetryLimitNotice(false)}
+ title={"공식인증 재요청 가능 횟수가\n모두 소진되었어요."}
+ titleStyle={styles.verificationRetryLimitModalTitle}
+ description="아쉽지만 다음학기에 요청해주세요."
+ buttonLabel="확인"
+ onButtonPress={() => {
+ setShowRetryLimitNotice(false);
+ setIsRetryLimitAcknowledged(true);
+ }}
+ hasCancel
+ />
+
+ {/* 동아리 정보 수정 확인 모달 */}
+ setShowEditConfirm(false)}
+ >
+ setShowEditConfirm(false)}
+ >
+
+ e.stopPropagation()}
+ >
+
+ 동아리 정보를 수정할까요?
+
+ 동아리 정보는 동아리 관리 → 수정 탭에서{"\n"}언제든 수정
+ 가능해요
+
+
+
+ setShowEditConfirm(false)}
+ activeOpacity={0.6}
+ >
+ 취소
+
+ {
+ setShowEditConfirm(false);
+ navigation.navigate(SCREEN_TYPE.CLUB_INFO_EDIT, { clubId });
+ }}
+ activeOpacity={0.6}
+ >
+ 수정
+
+
+
+
+
+
+ {/* 삭제 확인 모달 */}
+ !isDeleting && setDeleteTarget(null)}
+ >
+ !isDeleting && setDeleteTarget(null)}
+ >
+
+ e.stopPropagation()}
+ >
+ {/* Contents */}
+
+
+ {deleteTarget?.title ?? ""} 삭제
+
+
+ 공고를 삭제하면 되돌릴 수 없어요.
+
+
+ {/* Actions */}
+
+ setDeleteTarget(null)}
+ disabled={isDeleting}
+ activeOpacity={0.6}
+ >
+ 취소
+
+
+ deleteTarget && deleteRecruitment(deleteTarget.id)
+ }
+ disabled={isDeleting}
+ activeOpacity={0.6}
+ >
+
+ {isDeleting ? "삭제 중..." : "삭제"}
+
+
+
+
+
+
+
+ {/* 삭제 완료 모달 */}
+ setDeletedTitle(null)}
+ >
+ setDeletedTitle(null)}
+ >
+
+ e.stopPropagation()}
+ >
+
+
+ {deletedTitle}
+ {"가 삭제되었어요."}
+
+
+ setDeletedTitle(null)}
+ >
+ 확인
+
+
+
+
+
+ );
+};
+
+export default ClubManagementScreen;
+
+const styles = StyleSheet.create({
+ verificationPointModalTitle: {
+ color: Colors.POINTCOLOR,
+ },
+ verificationSuccessModalTitle: {
+ color: Colors.BLACK,
+ },
+ verificationRetryLimitModalTitle: {
+ color: Colors.BODYTEXT_MAIN,
+ },
+ safeArea: {
+ flex: 1,
+ backgroundColor: "#FFFFFF",
+ },
+
+ // ── 헤더
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: s(20),
+ gap: s(105),
+ height: vs(39),
+ backgroundColor: "#FFFFFF",
+ },
+ backBtn: {
+ width: ms(24),
+ height: ms(24),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ headerTitle: {
+ fontFamily: "Pretendard",
+ fontWeight: "600",
+ fontSize: ms(16),
+ lineHeight: ms(19),
+ letterSpacing: -0.02 * 16,
+ color: "#757474",
+ },
+
+ // ── 스크롤 영역
+ scroll: {
+ flex: 1,
+ backgroundColor: "#F5F4F0",
+ },
+ scrollContent: {
+ paddingHorizontal: s(20),
+ paddingTop: vs(10),
+ paddingBottom: vs(40),
+ gap: vs(20),
+ },
+
+ // ── 동아리 카드
+ clubCard: {
+ borderRadius: ms(12),
+ overflow: "hidden",
+ height: vs(186),
+ },
+ clubCardBg: {
+ position: "absolute",
+ width: "100%",
+ height: "100%",
+ resizeMode: "cover",
+ },
+ clubCardOverlay: {
+ position: "absolute",
+ width: "100%",
+ height: "100%",
+ backgroundColor: "rgba(255,255,255,0.6)",
+ },
+ clubCardContent: {
+ flex: 1,
+ flexDirection: "row",
+ alignItems: "flex-start",
+ justifyContent: "space-between",
+ paddingHorizontal: s(20),
+ paddingVertical: vs(30),
+ },
+ clubDetails: {
+ flex: 1,
+ marginRight: s(16),
+ gap: vs(12),
+ alignItems: "flex-start",
+ },
+ clubLogoImage: {
+ width: ms(40),
+ height: ms(40),
+ borderRadius: ms(20),
+ opacity: 1,
+ resizeMode: "contain",
+ },
+ clubTexts: {
+ alignSelf: "stretch",
+ gap: vs(8),
+ },
+ clubNameRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(4),
+ },
+ clubName: {
+ fontFamily: "Pretendard",
+ fontWeight: "700",
+ fontSize: ms(20),
+ lineHeight: ms(24),
+ letterSpacing: -0.02 * 20,
+ color: "#000000",
+ flexShrink: 1,
+ },
+ clubDesc: {
+ fontFamily: "Pretendard",
+ fontWeight: "500",
+ fontSize: ms(14),
+ lineHeight: ms(17),
+ letterSpacing: -0.02 * 14,
+ color: "#757474",
+ },
+
+ // ── 섹션
+ section: {
+ gap: 0,
+ },
+ sectionLabel: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingTop: vs(5),
+ paddingBottom: vs(5),
+ paddingLeft: s(5),
+ paddingRight: s(12),
+ height: vs(34),
+ },
+ sectionLabelText: {
+ fontFamily: "Pretendard",
+ fontWeight: "500",
+ fontSize: ms(14),
+ lineHeight: ms(24),
+ color: "#757474",
+ flex: 1,
+ },
+
+ // ── 리스트 컨테이너
+ listContainer: {
+ gap: vs(10),
+ },
+
+ // ── 새 공고 작성하기
+ newAnnouncementRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: vs(10),
+ paddingHorizontal: s(15),
+ borderRadius: ms(12),
+ minHeight: vs(34),
+ backgroundColor: "#FFFFFF",
+ borderWidth: 1,
+ borderColor: Colors.POINTCOLOR,
+ },
+ newAnnouncementText: {
+ fontFamily: "Pretendard",
+ fontWeight: "500",
+ fontSize: ms(12),
+ lineHeight: ms(14),
+ letterSpacing: -0.02 * 12,
+ color: Colors.POINTCOLOR,
+ },
+
+ // ── 공고 행 공통
+ row: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingVertical: vs(10),
+ paddingHorizontal: s(15),
+ borderRadius: ms(12),
+ minHeight: vs(34),
+ },
+ rowNormal: {
+ backgroundColor: "#FFFFFF",
+ },
+ rowActive: {
+ backgroundColor: "#FFFFFF",
+ minHeight: vs(48),
+ paddingLeft: s(10),
+ },
+ rowMore: {
+ backgroundColor: "#F3F0F5",
+ },
+ rowLeft: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(10),
+ flex: 1,
+ },
+ rowIcons: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(12),
+ flexShrink: 0,
+ },
+
+ // ── 이전 공고 더보기
+ rowMoreText: {
+ fontFamily: "Pretendard",
+ fontWeight: "500",
+ fontSize: ms(12),
+ lineHeight: ms(14),
+ letterSpacing: -0.02 * 12,
+ color: Colors.POINTCOLOR,
+ },
+
+ // ── 현재 공고 배지
+ badge: {
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: s(10),
+ paddingVertical: vs(8),
+ backgroundColor: Colors.POINTCOLOR,
+ borderRadius: ms(12),
+ minWidth: ms(68),
+ height: ms(28),
+ flexShrink: 0,
+ },
+ badgeText: {
+ fontFamily: "Pretendard",
+ fontWeight: "600",
+ fontSize: ms(10),
+ lineHeight: ms(12),
+ letterSpacing: 0,
+ color: "#FFFFFF",
+ },
+
+ // ── 공고 제목 텍스트
+ rowTextGray: {
+ fontFamily: "Pretendard",
+ fontWeight: "500",
+ fontSize: ms(12),
+ lineHeight: ms(14),
+ letterSpacing: -0.02 * 12,
+ color: "#757474",
+ flex: 1,
+ },
+ managerName: {
+ fontFamily: "Pretendard",
+ fontWeight: "600",
+ fontSize: ms(13),
+ color: "#333",
+ flex: 1,
+ },
+ managerStudentId: {
+ fontFamily: "Pretendard",
+ fontWeight: "400",
+ fontSize: ms(12),
+ color: "#757474",
+ },
+
+ // ── 삭제 확인 모달
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.2)",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalCard: {
+ width: ms(320),
+ backgroundColor: "#FFFFFF",
+ borderRadius: ms(12),
+ padding: ms(24),
+ gap: ms(24),
+ alignItems: "center",
+ },
+ modalContents: {
+ alignSelf: "stretch",
+ alignItems: "center",
+ gap: ms(8),
+ },
+ modalTitle: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "700",
+ fontSize: ms(16),
+ lineHeight: ms(24),
+ color: Colors.POINTCOLOR,
+ textAlign: "center",
+ alignSelf: "stretch",
+ },
+ modalDesc: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "500",
+ fontSize: ms(14),
+ lineHeight: ms(24),
+ color: "#000000",
+ textAlign: "center",
+ alignSelf: "stretch",
+ },
+ modalActions: {
+ flexDirection: "row",
+ alignSelf: "stretch",
+ gap: ms(7),
+ },
+ modalCancelBtn: {
+ width: ms(128),
+ height: ms(44),
+ borderWidth: 1,
+ borderColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalCancelText: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "700",
+ fontSize: ms(16),
+ lineHeight: ms(20),
+ color: Colors.POINTCOLOR,
+ },
+ modalConfirmBtn: {
+ width: ms(128),
+ height: ms(44),
+ backgroundColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ modalTitleBlack: {
+ color: "#000000",
+ },
+ modalConfirmText: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "600",
+ fontSize: ms(16),
+ lineHeight: ms(20),
+ color: "#FFFFFF",
+ },
+ modalSingleBtn: {
+ alignSelf: "stretch",
+ height: ms(44),
+ backgroundColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+});
diff --git a/apps/mobile/src/features/club/screens/ClubReviewScreen/index.tsx b/apps/mobile/src/features/club/screens/ClubReviewScreen/index.tsx
new file mode 100644
index 00000000..d517d0a8
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/ClubReviewScreen/index.tsx
@@ -0,0 +1,347 @@
+import type { RouteProp } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import React, { useContext, useEffect } from "react";
+import {
+ ActivityIndicator,
+ Dimensions,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Toast from "react-native-toast-message";
+import type { Club } from "@/entities/club";
+import type { ReviewKeyword } from "@/entities/review";
+import BackgroundCard from "@/features/club/components/ClubDetail/BackgroundCard";
+import BackHeader from "@/shared/components/BackHeader";
+import { Button } from "@/shared/components/Button";
+import { Colors } from "@/shared/constants/colors";
+import type { SCREEN_TYPE, StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import WithViewEventLog from "@/shared/hocs/WithViewEventLog";
+import useClickEventLog from "@/shared/hooks/useClickEventLog";
+import { navigation } from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type DetailsScreenRouteProp = RouteProp<
+ StackParamList,
+ SCREEN_TYPE.CLUB_REVIEW
+>;
+type DetailsScreenNavigationProp = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.CLUB_REVIEW
+>;
+
+type Props = {
+ route: DetailsScreenRouteProp;
+ navigation: DetailsScreenNavigationProp;
+};
+
+const deviceHeight = Dimensions.get("window").height;
+
+const ClubReviewScreen = ({ route }: Props) => {
+ const { uuid, category } = route.params;
+ const [selectedKeywordIds, setSelectedKeywordIds] = React.useState<
+ ReviewKeyword["id"][]
+ >([]);
+
+ const { data: reviewKeywordCategories } = useReviewKeywordCategories();
+ const { data: club, isLoading } = useClub({ uuid });
+ const { data: myClubReviewIds } = useMyClubReview({ uuid });
+ const { mutate, isLoading: isSubmitting } = useCreateClubReview({
+ uuid,
+ reviewKeywordIds: selectedKeywordIds,
+ });
+ const { logClickEvent } = useClickEventLog();
+
+ useEffect(() => {
+ if (!myClubReviewIds) return;
+
+ setSelectedKeywordIds(myClubReviewIds);
+ }, [myClubReviewIds]);
+
+ const handleBackButton = () => navigation.goBack();
+ const handleKeywordPress = (keywordId: ReviewKeyword["id"]) => {
+ setSelectedKeywordIds((currentIds) =>
+ currentIds.includes(keywordId)
+ ? currentIds.filter((id) => id !== keywordId)
+ : [...currentIds, keywordId],
+ );
+ };
+
+ const handleSaveReview = async () => {
+ logClickEvent({
+ screen_name: "club_review_screen",
+ screen_component_name: "save_review_button",
+ });
+
+ mutate();
+ };
+
+ if (!category || !club) return null;
+
+ const isSubmitDisabled = isSubmitting || selectedKeywordIds.length === 0;
+
+ return (
+
+
+
+ {isLoading && (
+
+
+
+ )}
+ {club && (
+
+
+
+
+ {club.name}
+ {" 에서의 경험을 공유해주세요"}
+
+
+ 현재까지 {club.totalReviews}명이 참여했어요
+
+
+
+
+ {reviewKeywordCategories?.map((kc) => {
+ const columnBreak = Math.ceil(kc.keywords.length / 2);
+ const keywordColumns = [
+ kc.keywords.slice(0, columnBreak),
+ kc.keywords.slice(columnBreak),
+ ].filter((column) => column.length > 0);
+
+ return (
+
+ {kc.title}
+
+ {keywordColumns.map((column) => (
+
+ {column.map((keyword) => {
+ const isSelected = selectedKeywordIds.includes(
+ keyword.id,
+ );
+
+ return (
+ handleKeywordPress(keyword.id)}
+ >
+
+ {keyword.iconUri?.trim()}
+
+
+ {keyword.title}
+
+
+ );
+ })}
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default ClubReviewScreen;
+
+type UseClubProps = {
+ uuid: Club["uuid"];
+};
+
+const useClub = ({ uuid }: UseClubProps) => {
+ const { clubService } = useContext(serviceContext);
+
+ const query = useQuery(["clubs", uuid], () => clubService.getClub({ uuid }));
+
+ return query;
+};
+
+const useReviewKeywordCategories = () => {
+ const { reviewService } = useContext(serviceContext);
+
+ return useQuery(
+ ["reviewKeywords"],
+ () => reviewService.listReviewKeywords(),
+ {
+ select: (data) => data.categories,
+ },
+ );
+};
+
+const useMyClubReview = ({ uuid }: { uuid: Club["uuid"] }) => {
+ const { reviewService } = useContext(serviceContext);
+
+ return useQuery(
+ ["myClubReview", uuid],
+ () => reviewService.getMyClubReview({ uuid }),
+ {
+ select: (data) => data?.reviewKeywordIds,
+ },
+ );
+};
+
+const useCreateClubReview = ({
+ uuid,
+ reviewKeywordIds,
+}: {
+ uuid: Club["uuid"];
+ reviewKeywordIds: ReviewKeyword["id"][];
+}) => {
+ const queryClient = useQueryClient();
+ const { reviewService } = useContext(serviceContext);
+
+ return useMutation(
+ () => reviewService.createClubReviews({ uuid, reviewKeywordIds }),
+ {
+ onSuccess: () => {
+ Toast.show({
+ type: "info",
+ position: "bottom",
+ text1: "🎉 리뷰가 저장되었어요",
+ });
+ queryClient.invalidateQueries();
+ navigation.goBack();
+ },
+ },
+ );
+};
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_MAIN,
+ },
+ scrollView: {
+ paddingHorizontal: s(15),
+ },
+ scrollContent: {
+ paddingTop: vs(13),
+ paddingBottom: vs(24),
+ },
+ reviewCard: {
+ padding: s(20),
+ borderRadius: ms(15),
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.05,
+ shadowRadius: 5,
+ },
+ reviewHeader: {
+ gap: vs(3),
+ marginBottom: vs(25),
+ },
+ title: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_SUB,
+ },
+ clubName: {
+ color: Colors.POINTCOLOR,
+ },
+ participantCount: {
+ ...typography.bodySSmallMedium,
+ color: Colors.BODYTEXT_SUB,
+ },
+ categoryList: { gap: vs(25) },
+ categoryTitle: {
+ ...typography.bodyMMedium,
+ color: Colors.BODYTEXT_SUB,
+ marginBottom: vs(10),
+ },
+ keywordContainer: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "flex-start",
+ },
+ keywordColumn: { alignItems: "flex-start", gap: vs(8) },
+ keyword: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ paddingTop: vs(7),
+ paddingBottom: vs(8),
+ paddingHorizontal: s(10),
+ borderRadius: ms(20),
+ borderWidth: 0.3,
+ borderColor: Colors.POINTCOLOR,
+ backgroundColor: Colors.WHITE,
+ },
+ keywordSelected: {
+ backgroundColor: Colors.POINTCOLOR,
+ borderColor: Colors.POINTCOLOR,
+ },
+ keywordIcon: {
+ ...typography.bodySRegular,
+ lineHeight: vs(12),
+ marginRight: s(2),
+ },
+ keywordTitle: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_SUB,
+ lineHeight: vs(12),
+ },
+ keywordTitleSelected: {
+ color: Colors.WHITE,
+ },
+ submitWrapper: {
+ flexDirection: "row",
+ justifyContent: "center",
+ marginTop: vs(28),
+ },
+ submitButton: { paddingVertical: 0, borderRadius: ms(8) },
+ submitButtonDisabled: { backgroundColor: Colors.BODYTEXT_DISABLED },
+ submitButtonText: { ...typography.bodyMSemibold, color: Colors.WHITE },
+});
diff --git a/apps/mobile/src/features/club/screens/PreviousRecruitmentsScreen/index.tsx b/apps/mobile/src/features/club/screens/PreviousRecruitmentsScreen/index.tsx
new file mode 100644
index 00000000..46c7b18c
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/PreviousRecruitmentsScreen/index.tsx
@@ -0,0 +1,238 @@
+import type { RouteProp } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { useQuery } from "@tanstack/react-query";
+import { useContext, useState } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import BackgroundCard from "@/features/club/components/ClubDetail/BackgroundCard";
+import RecruitmentDetailCard from "@/features/club/components/ClubDetail/RecruitmentDetailCard";
+import { getNextExpandedRecruitmentId } from "@/features/club/utils/recruitmentPresentation";
+import type { RecruitmentSummary } from "@/repositories/recruitment";
+import BackHeader from "@/shared/components/BackHeader";
+import { Colors } from "@/shared/constants/colors";
+import type { SCREEN_TYPE, StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import useRequireLogin from "@/shared/hooks/useRequireLogin";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type ScreenRoute = RouteProp;
+type ScreenNavigation = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.PREVIOUS_RECRUITMENTS
+>;
+
+type Props = {
+ route: ScreenRoute;
+ navigation: ScreenNavigation;
+};
+
+const PreviousRecruitmentItem = ({
+ recruitment,
+ isExpanded,
+ onPress,
+}: {
+ recruitment: RecruitmentSummary;
+ isExpanded: boolean;
+ onPress: () => void;
+}) => {
+ const { recruitmentService } = useContext(serviceContext);
+ const detailQuery = useQuery(
+ ["recruitmentDetail", recruitment.id],
+ () =>
+ recruitmentService.getRecruitmentDetail({
+ recruitmentId: recruitment.id,
+ }),
+ { enabled: isExpanded },
+ );
+
+ return (
+
+
+
+
+ {recruitment.display_title}
+
+
+ {recruitment.title}
+
+
+
+
+
+ {isExpanded && (
+
+ {detailQuery.isLoading ? (
+
+ ) : detailQuery.isError ? (
+ detailQuery.refetch()}
+ >
+
+ 공고를 불러오지 못했어요. 다시 시도
+
+
+ ) : detailQuery.data ? (
+
+ ) : null}
+
+ )}
+
+ );
+};
+
+const PreviousRecruitmentsScreen = ({ route, navigation }: Props) => {
+ const { clubId, clubName, representativeRecruitmentId } = route.params;
+ const { recruitmentService } = useContext(serviceContext);
+ const { user } = useProfile();
+ const requireLogin = useRequireLogin();
+ const [expandedId, setExpandedId] = useState(null);
+ const listQuery = useQuery(["clubRecruitments", clubId], () =>
+ recruitmentService.listClubRecruitments({ clubId }),
+ );
+ const previousRecruitments =
+ listQuery.data?.recruitments.filter(
+ (item) => item.id !== representativeRecruitmentId,
+ ) ?? [];
+
+ return (
+
+ navigation.goBack()} />
+ {!user ? (
+
+
+ 로그인 후 이전 공고를 확인할 수 있어요.
+
+ requireLogin(() => {})}
+ >
+ 로그인하기
+
+
+ ) : listQuery.isLoading ? (
+
+
+
+ ) : listQuery.isError ? (
+
+ 이전 공고를 불러오지 못했어요.
+ listQuery.refetch()}
+ >
+ 다시 시도
+
+
+ ) : previousRecruitments.length === 0 ? (
+
+ 아직 이전 공고가 없어요.
+
+ ) : (
+
+ {clubName}
+
+ {previousRecruitments.map((recruitment) => (
+
+ setExpandedId((current) =>
+ getNextExpandedRecruitmentId(current, recruitment.id),
+ )
+ }
+ />
+ ))}
+
+
+ )}
+
+ );
+};
+
+export default PreviousRecruitmentsScreen;
+
+const styles = StyleSheet.create({
+ safeArea: { flex: 1, backgroundColor: Colors.BACKGROUND_MAIN },
+ content: {
+ paddingHorizontal: s(16),
+ paddingTop: vs(12),
+ paddingBottom: vs(40),
+ },
+ clubName: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ marginBottom: vs(12),
+ },
+ list: { gap: vs(12) },
+ itemCard: { paddingVertical: s(14) },
+ itemHeader: {
+ minHeight: ms(44),
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ itemTitleWrapper: { flex: 1, gap: vs(4), paddingRight: s(12) },
+ itemTitle: { ...typography.headerL, color: Colors.BODYTEXT_SUB },
+ itemTitleExpanded: { color: Colors.POINTCOLOR },
+ itemSubtitle: { ...typography.bodySRegular, color: Colors.BODYTEXT_SUB_2 },
+ expandedContent: {
+ borderTopWidth: 1,
+ borderTopColor: Colors.TEXTBOX_SELECTED,
+ marginTop: vs(12),
+ paddingTop: vs(18),
+ },
+ centerState: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ paddingHorizontal: s(24),
+ gap: vs(12),
+ },
+ emptyTitle: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ textAlign: "center",
+ },
+ retryButton: {
+ paddingHorizontal: s(14),
+ paddingVertical: vs(8),
+ borderRadius: ms(18),
+ backgroundColor: Colors.POINTCOLOR_10,
+ },
+ retryText: { ...typography.bodySMedium, color: Colors.POINTCOLOR },
+ loginButton: {
+ minWidth: s(120),
+ paddingHorizontal: s(20),
+ paddingVertical: vs(12),
+ borderRadius: ms(22),
+ backgroundColor: Colors.POINTCOLOR,
+ alignItems: "center",
+ },
+ loginButtonText: { ...typography.bodyMSemibold, color: Colors.WHITE },
+});
diff --git a/apps/mobile/src/features/club/screens/SavedClubListScreen/index.tsx b/apps/mobile/src/features/club/screens/SavedClubListScreen/index.tsx
new file mode 100644
index 00000000..87490f21
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/SavedClubListScreen/index.tsx
@@ -0,0 +1,67 @@
+import { useQuery } from "@tanstack/react-query";
+import { useContext } from "react";
+import { SafeAreaView } from "react-native-safe-area-context";
+import type { Club } from "@/entities/club";
+import ClubList from "@/features/club/components/ClubList/ClubList";
+import Header from "@/shared/components/BackHeader";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import WithViewEventLog from "@/shared/hocs/WithViewEventLog";
+import { navigation } from "@/shared/utils/navigation";
+
+const SavedClubListScreen = () => {
+ const { data: savedClubs, isLoading } = useSavedClubs();
+
+ const openDetailPage = (club: Club) => {
+ navigation.navigate(SCREEN_TYPE.CLUB_DETAIL, {
+ uuid: club.uuid,
+ category: club.category,
+ entry_point: "saved_club_list",
+ });
+ };
+
+ const handleBack = () => {
+ navigation.goBack();
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default SavedClubListScreen;
+
+const useSavedClubs = () => {
+ const { clubService } = useContext(serviceContext);
+
+ return useQuery(["savedClubs"], () => clubService.listSavedClubs(), {
+ staleTime: Infinity,
+ select: (data) => data.clubs,
+ });
+};
diff --git a/apps/mobile/src/features/club/screens/SearchScreen/index.tsx b/apps/mobile/src/features/club/screens/SearchScreen/index.tsx
new file mode 100644
index 00000000..15cbfd8f
--- /dev/null
+++ b/apps/mobile/src/features/club/screens/SearchScreen/index.tsx
@@ -0,0 +1,381 @@
+import { type RouteProp, useFocusEffect } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { Keyboard, Pressable, StyleSheet, Text, View } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+
+import type { Club } from "@/entities/club";
+import ClubList from "@/features/club/components/ClubList/ClubList";
+import PopularClubs from "@/features/club/components/PopularClubs/PopularClubs";
+import RandomRecommendations, {
+ RandomRecommendationsSkeleton,
+} from "@/features/club/components/RandomRecommendations/RandomRecommendations";
+import RecentSearches from "@/features/club/components/RecentSearches/RecentSearches";
+import SearchBar from "@/features/club/components/SearchBar/SearchBar";
+import SearchFilterBar from "@/features/club/components/SearchBar/SearchFilterBar";
+import SearchFilterOverlay from "@/features/club/components/SearchBar/SearchFilterOverlay";
+import TypoCorrectionNotice from "@/features/club/components/TypoCorrectionNotice/TypoCorrectionNotice";
+import {
+ type ClubSearchFilters,
+ createSearchClubsRequest,
+ DEFAULT_CLUB_SEARCH_FILTERS,
+ resetClubSearchOverlayFilters,
+} from "@/features/search/types/clubSearchForm";
+import type {
+ ListRandomRecommendationsResponse,
+ SearchClubsResponse,
+} from "@/repositories/club";
+import type { ListRecentSearchesResponse } from "@/repositories/recentSearch";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import WithViewEventLog from "@/shared/hocs/WithViewEventLog";
+import { s, vs } from "@/shared/utils/scale";
+
+const RECENT_SEARCHES_QUERY_KEY = ["recentSearches"] as const;
+
+type SearchScreenRouteProp = RouteProp;
+type SearchScreenNavigationProp = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.SEARCH
+>;
+
+type Props = {
+ route: SearchScreenRouteProp;
+ navigation: SearchScreenNavigationProp;
+};
+
+const SearchScreen = ({ navigation }: Props) => {
+ const [inputValue, setInputValue] = useState("");
+ const [submittedQuery, setSubmittedQuery] = useState("");
+ const [submittedSearchId, setSubmittedSearchId] = useState(0);
+ const [isTypoNoticeVisible, setIsTypoNoticeVisible] = useState(true);
+ const [filters, setFilters] = useState(
+ DEFAULT_CLUB_SEARCH_FILTERS,
+ );
+ const [isFilterOverlayVisible, setIsFilterOverlayVisible] = useState(false);
+
+ const queryClient = useQueryClient();
+ const request = useMemo(
+ () => createSearchClubsRequest({ query: submittedQuery, filters }),
+ [filters, submittedQuery],
+ );
+ const { data: searchResult, isFetching } = useSearchClubs({
+ query: submittedQuery,
+ request,
+ });
+ const { data: recentSearches } = useRecentSearches();
+ const { mutate: clearRecentSearches } = useClearRecentSearches();
+ const {
+ data: randomRecommendations,
+ isError: isRandomRecommendationsError,
+ isLoading: isFetchingRandomRecommendations,
+ mutate: fetchRandomRecommendations,
+ reset: resetRandomRecommendations,
+ } = useRandomRecommendations();
+
+ const clubs = searchResult?.clubs;
+
+ const hasSubmittedQuery = submittedQuery.length > 0;
+ const shouldShowRandomRecommendations =
+ hasSubmittedQuery && clubs?.length === 0 && !isFetching;
+ const isRandomRecommendationsLoading =
+ shouldShowRandomRecommendations &&
+ (isFetchingRandomRecommendations ||
+ (!randomRecommendations && !isRandomRecommendationsError));
+ const correctedQuery = searchResult?.isTypoCorrected
+ ? searchResult.correctedQuery
+ : undefined;
+ const shouldShowTypoNotice = isTypoNoticeVisible && !!correctedQuery;
+
+ const resetSearchState = useCallback(() => {
+ setInputValue("");
+ setSubmittedQuery("");
+ setSubmittedSearchId(0);
+ resetRandomRecommendations();
+ setIsTypoNoticeVisible(true);
+ setFilters(DEFAULT_CLUB_SEARCH_FILTERS);
+ setIsFilterOverlayVisible(false);
+ }, [resetRandomRecommendations]);
+
+ const resetToInitialState = useCallback(() => {
+ navigation.reset({
+ index: 0,
+ routes: [{ name: SCREEN_TYPE.SEARCH }],
+ });
+ resetSearchState();
+ }, [navigation, resetSearchState]);
+
+ useEffect(() => {
+ const parent = navigation.getParent();
+ if (!parent) return undefined;
+ // biome-ignore lint/suspicious/noExplicitAny: Parent navigator event typing is unavailable through this nested navigator type.
+ const unsubscribe = (parent as any).addListener("tabPress", () => {
+ if (navigation.isFocused()) {
+ resetToInitialState();
+ }
+ });
+ return unsubscribe;
+ }, [navigation, resetToInitialState]);
+
+ useFocusEffect(
+ useCallback(() => {
+ return () => setIsFilterOverlayVisible(false);
+ }, []),
+ );
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Request changes intentionally trigger recommendation resets.
+ useEffect(() => {
+ resetRandomRecommendations();
+ }, [request, resetRandomRecommendations]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Request submission changes intentionally trigger a refetch.
+ useEffect(() => {
+ if (!shouldShowRandomRecommendations) return;
+
+ fetchRandomRecommendations();
+ }, [
+ fetchRandomRecommendations,
+ request,
+ shouldShowRandomRecommendations,
+ submittedSearchId,
+ ]);
+
+ const handleSubmitQuery = (nextQuery: string) => {
+ queryClient.cancelQueries({ queryKey: ["searchClubs"] });
+ resetRandomRecommendations();
+ setSubmittedQuery(nextQuery);
+ setSubmittedSearchId((prev) => prev + 1);
+ setIsTypoNoticeVisible(true);
+ setIsFilterOverlayVisible(false);
+ };
+
+ const handleChangeFilters = useCallback((nextFilters: ClubSearchFilters) => {
+ Keyboard.dismiss();
+ setFilters(nextFilters);
+ }, []);
+
+ const handleToggleFilterOverlay = useCallback(() => {
+ Keyboard.dismiss();
+ setIsFilterOverlayVisible((prev) => !prev);
+ }, []);
+
+ const handleSelectRecentSearch = (query: string) => {
+ setInputValue(query);
+ handleSubmitQuery(query);
+ };
+
+ const handleClearRecentSearches = () => {
+ clearRecentSearches();
+ };
+
+ const openDetailPage = (club: Club) => {
+ navigation.navigate(SCREEN_TYPE.CLUB_DETAIL, {
+ uuid: club.uuid,
+ category: club.category,
+ entry_point: "search_result",
+ });
+ };
+
+ return (
+
+
+
+
+ 어떤 동아리를 찾아볼까요?
+
+
+
+ {hasSubmittedQuery ? (
+ <>
+
+ {shouldShowTypoNotice && correctedQuery ? (
+ setIsTypoNoticeVisible(false)}
+ />
+ ) : null}
+
+
+
+
+ {shouldShowRandomRecommendations ? (
+ isRandomRecommendationsLoading ? (
+
+ ) : (
+
+ )
+ ) : null}
+ {isFilterOverlayVisible ? (
+
+ setFilters(resetClubSearchOverlayFilters(filters))
+ }
+ onClose={() => setIsFilterOverlayVisible(false)}
+ />
+ ) : null}
+
+ >
+ ) : (
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default SearchScreen;
+
+type UseSearchClubsProps = {
+ query: string;
+ request: ReturnType;
+};
+
+const useSearchClubs = ({ query, request }: UseSearchClubsProps) => {
+ const { clubService } = useContext(serviceContext);
+ const queryClient = useQueryClient();
+
+ return useQuery(
+ ["searchClubs", request],
+ ({ signal }) => clubService.searchClubs(request, signal),
+ {
+ enabled: query.length > 0,
+ keepPreviousData: true,
+ staleTime: 0,
+ onSuccess: () => {
+ queryClient.cancelQueries({ queryKey: RECENT_SEARCHES_QUERY_KEY });
+ queryClient.invalidateQueries(RECENT_SEARCHES_QUERY_KEY);
+ },
+ },
+ );
+};
+
+const useRecentSearches = () => {
+ const { recentSearchService } = useContext(serviceContext);
+
+ return useQuery(
+ RECENT_SEARCHES_QUERY_KEY,
+ () => recentSearchService.listRecentSearches(),
+ {
+ staleTime: 0,
+ select: (data) => data.recentSearches.map((it) => it.query),
+ },
+ );
+};
+
+const useClearRecentSearches = () => {
+ const { recentSearchService } = useContext(serviceContext);
+ const queryClient = useQueryClient();
+
+ return useMutation(() => recentSearchService.deleteAllRecentSearches(), {
+ onMutate: async () => {
+ await queryClient.cancelQueries({ queryKey: RECENT_SEARCHES_QUERY_KEY });
+
+ const previousRecentSearches =
+ queryClient.getQueryData(
+ RECENT_SEARCHES_QUERY_KEY,
+ );
+
+ queryClient.setQueryData(
+ RECENT_SEARCHES_QUERY_KEY,
+ {
+ recentSearches: [],
+ totalSize: 0,
+ },
+ );
+
+ return { previousRecentSearches };
+ },
+ onError: (_error, _variables, context) => {
+ if (context?.previousRecentSearches) {
+ queryClient.setQueryData(
+ RECENT_SEARCHES_QUERY_KEY,
+ context.previousRecentSearches,
+ );
+ }
+ },
+ onSuccess: () => {
+ queryClient.setQueryData(
+ RECENT_SEARCHES_QUERY_KEY,
+ {
+ recentSearches: [],
+ totalSize: 0,
+ },
+ );
+ },
+ });
+};
+
+const useRandomRecommendations = () => {
+ const { clubService } = useContext(serviceContext);
+
+ return useMutation(() =>
+ clubService.listRandomRecommendations(),
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_MAIN,
+ },
+ headerContainer: {
+ paddingHorizontal: s(20),
+ paddingVertical: vs(10),
+ gap: s(15),
+ },
+ headerText: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ paddingTop: vs(10),
+ paddingRight: s(12),
+ paddingBottom: vs(10),
+ paddingLeft: s(5),
+ },
+ searchControls: {
+ width: "100%",
+ gap: vs(14),
+ paddingHorizontal: s(20),
+ paddingTop: vs(14),
+ paddingBottom: vs(10),
+ backgroundColor: Colors.BACKGROUND_MAIN,
+ },
+ contentContainer: {
+ flex: 1,
+ position: "relative",
+ },
+ placeholderContainer: {
+ paddingHorizontal: s(20),
+ paddingTop: vs(14),
+ gap: vs(30),
+ },
+});
diff --git a/apps/mobile/src/features/club/utils/managedClubUpdateError.test.ts b/apps/mobile/src/features/club/utils/managedClubUpdateError.test.ts
new file mode 100644
index 00000000..ce16bc9b
--- /dev/null
+++ b/apps/mobile/src/features/club/utils/managedClubUpdateError.test.ts
@@ -0,0 +1,29 @@
+import { getManagedClubUpdateErrorContent } from "@/features/club/utils/managedClubUpdateError";
+
+const createAxiosError = (status: number) => ({
+ isAxiosError: true,
+ response: { status },
+});
+
+describe("getManagedClubUpdateErrorContent", () => {
+ it.each([
+ [400, "입력한 내용을 확인해주세요"],
+ [401, "로그인이 필요해요"],
+ [403, "수정할 권한이 없어요"],
+ [404, "신청 정보를 찾을 수 없어요"],
+ [409, "운영진 정보를 수정할 수 없어요"],
+ ])("%i 응답에 맞는 오류를 반환한다", (status, title) => {
+ expect(
+ getManagedClubUpdateErrorContent(createAxiosError(status)).title,
+ ).toBe(title);
+ });
+
+ it("응답 상태를 알 수 없으면 재시도 안내를 반환한다", () => {
+ expect(
+ getManagedClubUpdateErrorContent(new Error("network error")),
+ ).toEqual({
+ title: "신청 내용을 수정하지 못했어요",
+ description: "네트워크 상태를 확인한 후 다시 시도해주세요",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/club/utils/managedClubUpdateError.ts b/apps/mobile/src/features/club/utils/managedClubUpdateError.ts
new file mode 100644
index 00000000..f22ad5b0
--- /dev/null
+++ b/apps/mobile/src/features/club/utils/managedClubUpdateError.ts
@@ -0,0 +1,44 @@
+import { getApiErrorStatus } from "@/shared/utils/apiError";
+
+export type ManagedClubUpdateErrorContent = {
+ title: string;
+ description: string;
+};
+
+const DEFAULT_ERROR_CONTENT: ManagedClubUpdateErrorContent = {
+ title: "신청 내용을 수정하지 못했어요",
+ description: "네트워크 상태를 확인한 후 다시 시도해주세요",
+};
+
+const ERROR_CONTENT_BY_STATUS: Partial<
+ Record
+> = {
+ 400: {
+ title: "입력한 내용을 확인해주세요",
+ description: "수정할 내용이 없거나 입력값의 형식이 올바르지 않아요",
+ },
+ 401: {
+ title: "로그인이 필요해요",
+ description: "로그인 정보가 만료되었어요. 다시 로그인해주세요",
+ },
+ 403: {
+ title: "수정할 권한이 없어요",
+ description: "본인의 신규 동아리 등록 신청만 수정할 수 있어요",
+ },
+ 404: {
+ title: "신청 정보를 찾을 수 없어요",
+ description: "동아리 또는 운영진 정보가 존재하지 않아요",
+ },
+ 409: {
+ title: "운영진 정보를 수정할 수 없어요",
+ description:
+ "이미 승인된 동아리의 운영진 정보는 이 화면에서 수정할 수 없어요",
+ },
+};
+
+export const getManagedClubUpdateErrorContent = (
+ error: unknown,
+): ManagedClubUpdateErrorContent => {
+ const status = getApiErrorStatus(error);
+ return (status && ERROR_CONTENT_BY_STATUS[status]) || DEFAULT_ERROR_CONTENT;
+};
diff --git a/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts b/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts
new file mode 100644
index 00000000..824dcd2a
--- /dev/null
+++ b/apps/mobile/src/features/club/utils/officialVerificationRequest.test.ts
@@ -0,0 +1,131 @@
+import {
+ getOfficialVerificationButtonState,
+ getOfficialVerificationRequestErrorContent,
+} from "@/features/club/utils/officialVerificationRequest";
+
+const createAxiosError = (status: number) => ({
+ isAxiosError: true,
+ response: { status },
+});
+
+const createRetryLimitExceededAxiosError = () => ({
+ isAxiosError: true,
+ response: {
+ status: 409,
+ data: "official verification retry limit exceeded",
+ },
+});
+
+describe("getOfficialVerificationButtonState", () => {
+ it("인증 상태를 확인하는 동안 신청할 수 없다", () => {
+ expect(
+ getOfficialVerificationButtonState({
+ status: undefined,
+ isStatusLoading: true,
+ }),
+ ).toEqual({
+ label: "인증 상태 확인 중...",
+ disabled: true,
+ variant: "status",
+ });
+ });
+
+ it("미신청 동아리는 공식 인증을 신청할 수 있다", () => {
+ expect(
+ getOfficialVerificationButtonState({
+ status: "UNVERIFIED",
+ isStatusLoading: false,
+ }),
+ ).toEqual({
+ label: "올클 공식인증 요청하기",
+ disabled: false,
+ variant: "outline",
+ });
+ });
+
+ it.each([
+ ["PENDING", "올클 공식인증 심사중"],
+ ["VERIFIED", "올클 공식인증완료"],
+ ] as const)("%s 상태에서는 중복 신청할 수 없다", (status, label) => {
+ expect(
+ getOfficialVerificationButtonState({ status, isStatusLoading: false }),
+ ).toEqual({
+ label,
+ disabled: true,
+ variant: "status",
+ });
+ });
+
+ it("요청 전송 중에는 버튼을 비활성화한다", () => {
+ expect(
+ getOfficialVerificationButtonState({
+ status: "UNVERIFIED",
+ isStatusLoading: false,
+ isRequesting: true,
+ }),
+ ).toEqual({
+ label: "올클 공식인증 요청 중...",
+ disabled: true,
+ variant: "status",
+ });
+ });
+
+ it("반려된 동아리는 인증을 재요청할 수 있다", () => {
+ expect(
+ getOfficialVerificationButtonState({
+ status: "REJECTED",
+ isStatusLoading: false,
+ }),
+ ).toEqual({
+ label: "올클 공식인증 반려 및 재요청",
+ disabled: false,
+ variant: "outline",
+ });
+ });
+
+ it("재요청 횟수 소진 안내를 확인하면 버튼을 비활성화한다", () => {
+ expect(
+ getOfficialVerificationButtonState({
+ status: "REJECTED",
+ isStatusLoading: false,
+ isRetryLimitReached: true,
+ isRetryLimitAcknowledged: true,
+ }),
+ ).toEqual({
+ label: "올클 공식인증 반려",
+ disabled: true,
+ variant: "final",
+ });
+ });
+});
+
+describe("getOfficialVerificationRequestErrorContent", () => {
+ it("409 응답은 이미 처리 중인 요청으로 안내한다", () => {
+ expect(
+ getOfficialVerificationRequestErrorContent(createAxiosError(409)),
+ ).toEqual({
+ title: "이미 공식 인증을 신청했어요",
+ description: "현재 인증 상태를 다시 확인해주세요",
+ });
+ });
+
+ it("재요청 횟수 초과 응답은 재요청 불가로 안내한다", () => {
+ expect(
+ getOfficialVerificationRequestErrorContent(
+ createRetryLimitExceededAxiosError(),
+ ),
+ ).toEqual({
+ title: "재요청 횟수를 모두 사용했어요",
+ description: "총동연 공식 인증은 더 이상 재요청할 수 없어요",
+ });
+ });
+
+ it("응답 상태를 알 수 없으면 재시도를 안내한다", () => {
+ expect(
+ getOfficialVerificationRequestErrorContent(new Error("network error")),
+ ).toEqual({
+ title: "공식 인증을 신청하지 못했어요",
+ description: "네트워크 상태를 확인한 후 다시 시도해주세요",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/club/utils/officialVerificationRequest.ts b/apps/mobile/src/features/club/utils/officialVerificationRequest.ts
new file mode 100644
index 00000000..199f3fc9
--- /dev/null
+++ b/apps/mobile/src/features/club/utils/officialVerificationRequest.ts
@@ -0,0 +1,137 @@
+import type { OfficialVerificationStatus } from "@/entities/club";
+import { getApiErrorStatus } from "@/shared/utils/apiError";
+
+export type OfficialVerificationButtonVariant = "outline" | "status" | "final";
+
+type OfficialVerificationButtonStateParams = {
+ status?: OfficialVerificationStatus;
+ isStatusLoading: boolean;
+ isRequesting?: boolean;
+ isRetryLimitReached?: boolean;
+ isRetryLimitAcknowledged?: boolean;
+};
+
+type OfficialVerificationButtonState = {
+ label: string;
+ disabled: boolean;
+ variant: OfficialVerificationButtonVariant;
+};
+
+export const getOfficialVerificationButtonState = ({
+ status,
+ isStatusLoading,
+ isRequesting = false,
+ isRetryLimitReached = false,
+ isRetryLimitAcknowledged = false,
+}: OfficialVerificationButtonStateParams): OfficialVerificationButtonState => {
+ if (isRequesting) {
+ return {
+ label: "올클 공식인증 요청 중...",
+ disabled: true,
+ variant: "status",
+ };
+ }
+
+ if (isStatusLoading) {
+ return {
+ label: "인증 상태 확인 중...",
+ disabled: true,
+ variant: "status",
+ };
+ }
+
+ if (status === "PENDING") {
+ return {
+ label: "올클 공식인증 심사중",
+ disabled: true,
+ variant: "status",
+ };
+ }
+
+ if (status === "VERIFIED") {
+ return {
+ label: "올클 공식인증완료",
+ disabled: true,
+ variant: "status",
+ };
+ }
+
+ if (status === "REJECTED") {
+ if (isRetryLimitReached) {
+ return {
+ label: isRetryLimitAcknowledged
+ ? "올클 공식인증 반려"
+ : "올클 공식인증 반려 및 재요청",
+ disabled: isRetryLimitAcknowledged,
+ variant: isRetryLimitAcknowledged ? "final" : "outline",
+ };
+ }
+
+ return {
+ label: "올클 공식인증 반려 및 재요청",
+ disabled: false,
+ variant: "outline",
+ };
+ }
+
+ return {
+ label: "올클 공식인증 요청하기",
+ disabled: false,
+ variant: "outline",
+ };
+};
+
+export type OfficialVerificationRequestErrorContent = {
+ title: string;
+ description: string;
+};
+
+const DEFAULT_ERROR_CONTENT: OfficialVerificationRequestErrorContent = {
+ title: "공식 인증을 신청하지 못했어요",
+ description: "네트워크 상태를 확인한 후 다시 시도해주세요",
+};
+
+const RETRY_LIMIT_EXCEEDED_ERROR_MESSAGE =
+ "official verification retry limit exceeded";
+
+const ERROR_CONTENT_BY_STATUS: Partial<
+ Record
+> = {
+ 401: {
+ title: "로그인이 필요해요",
+ description: "로그인 정보가 만료되었어요. 다시 로그인해주세요",
+ },
+ 403: {
+ title: "공식 인증을 신청할 권한이 없어요",
+ description: "등록된 동아리 운영진만 신청할 수 있어요",
+ },
+ 404: {
+ title: "동아리 정보를 찾을 수 없어요",
+ description: "동아리 정보를 다시 확인해주세요",
+ },
+ 409: {
+ title: "이미 공식 인증을 신청했어요",
+ description: "현재 인증 상태를 다시 확인해주세요",
+ },
+};
+
+export const getOfficialVerificationRequestErrorContent = (
+ error: unknown,
+): OfficialVerificationRequestErrorContent => {
+ const status = getApiErrorStatus(error);
+ if (status === 409 && isRetryLimitExceeded(error)) {
+ return {
+ title: "재요청 횟수를 모두 사용했어요",
+ description: "총동연 공식 인증은 더 이상 재요청할 수 없어요",
+ };
+ }
+
+ return (status && ERROR_CONTENT_BY_STATUS[status]) || DEFAULT_ERROR_CONTENT;
+};
+
+const isRetryLimitExceeded = (error: unknown): boolean => {
+ if (typeof error !== "object" || error === null) return false;
+
+ const response = (error as { response?: { data?: unknown } }).response;
+ return response?.data === RETRY_LIMIT_EXCEEDED_ERROR_MESSAGE;
+};
diff --git a/apps/mobile/src/features/club/utils/recruitmentPresentation.test.ts b/apps/mobile/src/features/club/utils/recruitmentPresentation.test.ts
new file mode 100644
index 00000000..3ce60cea
--- /dev/null
+++ b/apps/mobile/src/features/club/utils/recruitmentPresentation.test.ts
@@ -0,0 +1,118 @@
+import {
+ formatRecruitmentDeadline,
+ formatRegularMeeting,
+ getNextExpandedRecruitmentId,
+ getRecruitmentApplicationRequestFields,
+ getRecruitmentApplicationUrl,
+ getRecruitmentDeadlineFormFields,
+ getRecruitmentDeadlineRequestValue,
+ getRecruitmentTextPreview,
+ shouldStackActivityLocation,
+ shouldStackRegularMeetings,
+} from "./recruitmentPresentation";
+
+describe("recruitment presentation rules", () => {
+ it("formats recruitment deadlines as a short Korean date with the closed label", () => {
+ expect(formatRecruitmentDeadline("2026-07-24T09:30:00.000Z")).toBe(
+ "26.07.24 모집마감",
+ );
+ expect(formatRecruitmentDeadline("2027-01-05T00:05:00.000Z")).toBe(
+ "27.01.05 모집마감",
+ );
+ });
+
+ it("converts a Korean deadline input to UTC for the request", () => {
+ expect(
+ getRecruitmentDeadlineRequestValue({
+ year: "2026",
+ month: "07",
+ day: "24",
+ hour: "18",
+ minute: "30",
+ }),
+ ).toBe("2026-07-24T09:30:00.000Z");
+ });
+
+ it("converts a stored UTC deadline back to Korean form fields", () => {
+ expect(
+ getRecruitmentDeadlineFormFields("2026-07-24T09:30:00.000Z"),
+ ).toEqual({
+ year: "2026",
+ month: "07",
+ day: "24",
+ hour: "18",
+ minute: "30",
+ });
+ });
+
+ it("keeps zero or one regular meeting beside the yes/no row", () => {
+ expect(shouldStackRegularMeetings(0)).toBe(false);
+ expect(shouldStackRegularMeetings(1)).toBe(false);
+ });
+
+ it("stacks two or more regular meetings below the yes/no row", () => {
+ expect(shouldStackRegularMeetings(2)).toBe(true);
+ expect(shouldStackRegularMeetings(3)).toBe(true);
+ });
+
+ it("keeps a nine-character activity location beside its type", () => {
+ expect(shouldStackActivityLocation("123456789")).toBe(false);
+ });
+
+ it("stacks a ten-character activity location below its type", () => {
+ expect(shouldStackActivityLocation("1234567890")).toBe(true);
+ });
+
+ it("formats a meeting without exposing empty time separators", () => {
+ expect(
+ formatRegularMeeting({
+ day_of_week: "금",
+ start_time: "09:00:00",
+ end_time: "11:00:00",
+ }),
+ ).toBe("금요일 9:00~11:00");
+ expect(
+ formatRegularMeeting({
+ day_of_week: "토",
+ start_time: null,
+ end_time: null,
+ }),
+ ).toBe("토요일");
+ });
+
+ it("converts recruitment HTML into compact preview text", () => {
+ expect(
+ getRecruitmentTextPreview(
+ "서울대학교 와플 스튜디오
두 번째 문장 & 안내
",
+ ),
+ ).toBe("서울대학교 와플 스튜디오 두 번째 문장 & 안내");
+ });
+
+ it("opens one previous recruitment at a time and toggles it closed", () => {
+ expect(getNextExpandedRecruitmentId(null, 10)).toBe(10);
+ expect(getNextExpandedRecruitmentId(10, 20)).toBe(20);
+ expect(getNextExpandedRecruitmentId(20, 20)).toBeNull();
+ });
+
+ it("normalizes an application link before opening it", () => {
+ expect(getRecruitmentApplicationUrl("instagram.com/club")).toBe(
+ "https://instagram.com/club",
+ );
+ expect(getRecruitmentApplicationUrl("https://example.com/apply")).toBe(
+ "https://example.com/apply",
+ );
+ });
+
+ it("normalizes an application link before sending it", () => {
+ expect(
+ getRecruitmentApplicationRequestFields("forms.example.com/apply"),
+ ).toEqual({
+ application_url: "https://forms.example.com/apply",
+ });
+ expect(
+ getRecruitmentApplicationRequestFields("https://forms.example.com/apply"),
+ ).toEqual({
+ application_url: "https://forms.example.com/apply",
+ });
+ });
+});
diff --git a/apps/mobile/src/features/club/utils/recruitmentPresentation.ts b/apps/mobile/src/features/club/utils/recruitmentPresentation.ts
new file mode 100644
index 00000000..e417c788
--- /dev/null
+++ b/apps/mobile/src/features/club/utils/recruitmentPresentation.ts
@@ -0,0 +1,116 @@
+import type { RecruitmentRegularMeeting } from "@/repositories/recruitment";
+import { normalizeUrl } from "@/shared/utils/clubSns";
+
+const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000;
+
+export type RecruitmentDeadlineFormFields = {
+ year: string;
+ month: string;
+ day: string;
+ hour: string;
+ minute: string;
+};
+
+const getKoreanDateParts = (value: string | Date) => {
+ const koreanDate = new Date(new Date(value).getTime() + KOREA_TIME_OFFSET_MS);
+
+ return {
+ year: koreanDate.getUTCFullYear(),
+ month: koreanDate.getUTCMonth() + 1,
+ day: koreanDate.getUTCDate(),
+ hour: koreanDate.getUTCHours(),
+ minute: koreanDate.getUTCMinutes(),
+ };
+};
+
+const padTwoDigits = (value: number): string => String(value).padStart(2, "0");
+
+export const formatRecruitmentDeadline = (deadline: string): string => {
+ const deadlineParts = getKoreanDateParts(deadline);
+ const year = String(deadlineParts.year).slice(-2);
+
+ return `${year}.${padTwoDigits(deadlineParts.month)}.${padTwoDigits(
+ deadlineParts.day,
+ )} 모집마감`;
+};
+
+export const getRecruitmentDeadlineRequestValue = ({
+ year,
+ month,
+ day,
+ hour,
+ minute,
+}: RecruitmentDeadlineFormFields): string =>
+ new Date(
+ Date.UTC(
+ Number(year),
+ Number(month) - 1,
+ Number(day),
+ Number(hour) - 9,
+ Number(minute),
+ ),
+ ).toISOString();
+
+export const getRecruitmentDeadlineFormFields = (
+ deadline: string,
+): RecruitmentDeadlineFormFields => {
+ const parts = getKoreanDateParts(deadline);
+
+ return {
+ year: String(parts.year),
+ month: padTwoDigits(parts.month),
+ day: padTwoDigits(parts.day),
+ hour: padTwoDigits(parts.hour),
+ minute: padTwoDigits(parts.minute),
+ };
+};
+
+export const shouldStackRegularMeetings = (meetingCount: number): boolean =>
+ meetingCount >= 2;
+
+export const shouldStackActivityLocation = (location: string): boolean =>
+ Array.from(location.trim()).length >= 10;
+
+const formatTime = (time: string | null): string =>
+ time?.slice(0, 5).replace(/^0/, "") ?? "";
+
+export const formatRegularMeeting = (
+ meeting: RecruitmentRegularMeeting,
+): string => {
+ const day = meeting.day_of_week.endsWith("요일")
+ ? meeting.day_of_week
+ : `${meeting.day_of_week}요일`;
+ const startTime = formatTime(meeting.start_time);
+ const endTime = formatTime(meeting.end_time);
+ const time = [startTime, endTime].filter(Boolean).join("~");
+
+ return time ? `${day} ${time}` : day;
+};
+
+export const getRecruitmentTextPreview = (html: string): string =>
+ html
+ .replace(/
/gi, " ")
+ .replace(/<\/(?:div|h[1-6]|li|p)>/gi, " ")
+ .replace(/<[^>]*>/g, "")
+ .replace(/ | /gi, " ")
+ .replace(/&/gi, "&")
+ .replace(/</gi, "<")
+ .replace(/>/gi, ">")
+ .replace(/"/gi, '"')
+ .replace(/'|'/gi, "'")
+ .replace(/\s+/g, " ")
+ .trim();
+
+export const getNextExpandedRecruitmentId = (
+ currentId: number | null,
+ pressedId: number,
+): number | null => (currentId === pressedId ? null : pressedId);
+
+export const getRecruitmentApplicationUrl = (applicationUrl: string): string =>
+ normalizeUrl(applicationUrl);
+
+export const getRecruitmentApplicationRequestFields = (
+ applicationUrl: string,
+): { application_url: string } => ({
+ application_url: getRecruitmentApplicationUrl(applicationUrl),
+});
diff --git a/apps/mobile/src/features/home/components/CategoryCard.tsx b/apps/mobile/src/features/home/components/CategoryCard.tsx
new file mode 100644
index 00000000..085695fc
--- /dev/null
+++ b/apps/mobile/src/features/home/components/CategoryCard.tsx
@@ -0,0 +1,79 @@
+import { useNavigation } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import {
+ Image,
+ type ImageSourcePropType,
+ Pressable,
+ StyleSheet,
+ Text,
+} from "react-native";
+import type { Category } from "@/entities/category";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import useClickEventLog from "@/shared/hooks/useClickEventLog";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type NavigationProps = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.HOME
+>;
+
+type Props = {
+ category: Category;
+ icon: ImageSourcePropType;
+};
+
+const CategoryCard = ({ category, icon }: Props) => {
+ const { logClickEvent } = useClickEventLog();
+
+ const navigation = useNavigation();
+
+ const handleMoveToClubList = (categoryName: Category["name"]) => {
+ logClickEvent({
+ screen_name: "home_screen",
+ screen_component_name: "category_card",
+ category: categoryName,
+ });
+
+ navigation.navigate(SCREEN_TYPE.CLUB_LIST, { category: categoryName });
+ };
+
+ return (
+ handleMoveToClubList(category.name)}
+ style={({ pressed }) => [
+ styles.container,
+ pressed && styles.containerPressed,
+ ]}
+ >
+
+ {category.name}
+
+ );
+};
+export default CategoryCard;
+
+const styles = StyleSheet.create({
+ container: {
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ width: s(70),
+ height: s(70),
+ borderRadius: ms(10),
+ backgroundColor: Colors.WHITE,
+ },
+ containerPressed: {
+ opacity: 0.6,
+ },
+ icon: {
+ width: s(30),
+ height: s(30),
+ marginVertical: vs(5),
+ },
+ label: {
+ ...typography.bodySMedium,
+ color: Colors.BODYTEXT_SUB,
+ },
+});
diff --git a/src/features/home/components/CategorySection.tsx b/apps/mobile/src/features/home/components/CategorySection.tsx
similarity index 58%
rename from src/features/home/components/CategorySection.tsx
rename to apps/mobile/src/features/home/components/CategorySection.tsx
index a1ddb343..59cbd91e 100644
--- a/src/features/home/components/CategorySection.tsx
+++ b/apps/mobile/src/features/home/components/CategorySection.tsx
@@ -1,17 +1,20 @@
-import { View, Text, Image, StyleSheet } from 'react-native'
-import CategoryCard from '@/features/home/components/CategoryCard'
-import { Colors } from '@/shared/constants/colors'
-import { CategoryMap } from '@/shared/constants/category'
-import { ms, s, vs } from '@/shared/utils/scale'
-import { typography } from '@/shared/constants/typography'
+import { Image, StyleSheet, Text, View } from "react-native";
+import CategoryCard from "@/features/home/components/CategoryCard";
+import { CategoryMap } from "@/shared/constants/category";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
-const categoryList = Object.values(CategoryMap)
+const categoryList = Object.values(CategoryMap);
const CategorySection = () => (
-
+
원하는 활동이 있으신가요?
@@ -19,7 +22,10 @@ const CategorySection = () => (
-
+
{categoryList.slice(1).map(({ name, icon }) => (
@@ -27,9 +33,9 @@ const CategorySection = () => (
))}
-)
+);
-export default CategorySection
+export default CategorySection;
const styles = StyleSheet.create({
container: {
@@ -39,7 +45,7 @@ const styles = StyleSheet.create({
backgroundColor: Colors.BACKGROUND_SUB,
paddingHorizontal: s(24),
paddingVertical: vs(20),
- justifyContent: 'center',
+ justifyContent: "center",
shadowColor: Colors.BLACK,
shadowOffset: {
width: 0,
@@ -50,23 +56,23 @@ const styles = StyleSheet.create({
elevation: 3,
},
titleContainer: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
flexShrink: 1,
},
titleWrapper: {
- flexDirection: 'column',
- justifyContent: 'center',
+ flexDirection: "column",
+ justifyContent: "center",
},
topRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
marginBottom: vs(12),
},
titleIcon: {
- width: s(43.85),
- height: s(42),
+ width: s(47),
+ height: s(46),
marginHorizontal: s(8),
},
subtitle: {
@@ -81,9 +87,9 @@ const styles = StyleSheet.create({
color: Colors.POINTCOLOR,
},
grid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
+ flexDirection: "row",
+ flexWrap: "wrap",
columnGap: s(8),
rowGap: vs(12),
},
-})
+});
diff --git a/apps/mobile/src/features/home/components/LatestClubsSection.tsx b/apps/mobile/src/features/home/components/LatestClubsSection.tsx
new file mode 100644
index 00000000..55099e07
--- /dev/null
+++ b/apps/mobile/src/features/home/components/LatestClubsSection.tsx
@@ -0,0 +1,34 @@
+import { useQuery } from "@tanstack/react-query";
+import { useContext } from "react";
+
+import type { Club } from "@/entities/club";
+import HorizontalCarousel from "@/shared/components/HorizontalCarousel";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+type Props = {
+ openDetailPage: (club: Club) => void;
+};
+
+const LatestClubsSection = ({ openDetailPage }: Props) => {
+ const { data: latestClubs } = useLatestClubs();
+
+ return (
+
+ );
+};
+
+export default LatestClubsSection;
+
+const useLatestClubs = () => {
+ const { clubService } = useContext(serviceContext);
+
+ return useQuery({
+ queryKey: ["clubs", "latest"],
+ queryFn: () => clubService.listLatestClubs(),
+ keepPreviousData: true,
+ select: (data) => data.clubs,
+ });
+};
diff --git a/apps/mobile/src/features/home/screens/HomeScreen/index.tsx b/apps/mobile/src/features/home/screens/HomeScreen/index.tsx
new file mode 100644
index 00000000..60fbe038
--- /dev/null
+++ b/apps/mobile/src/features/home/screens/HomeScreen/index.tsx
@@ -0,0 +1,114 @@
+import type { RouteProp } from "@react-navigation/native";
+import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
+import { Image, StyleSheet, Text, View } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import type { Club } from "@/entities/club";
+import CategorySection from "@/features/home/components/CategorySection";
+import LatestClubsSection from "@/features/home/components/LatestClubsSection";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import WithViewEventLog from "@/shared/hocs/WithViewEventLog";
+import useClickEventLog from "@/shared/hooks/useClickEventLog";
+import { s, vs } from "@/shared/utils/scale";
+
+type DetailsScreenRouteProp = RouteProp;
+type DetailsScreenNavigationProp = NativeStackNavigationProp<
+ StackParamList,
+ SCREEN_TYPE.HOME
+>;
+
+type Props = {
+ route: DetailsScreenRouteProp;
+ navigation: DetailsScreenNavigationProp;
+};
+
+const HomeScreen = ({ navigation }: Props) => {
+ const { logClickEvent } = useClickEventLog();
+
+ const handleMoveToDetailPage = (club: Club) => {
+ logClickEvent({
+ screen_name: "home_screen",
+ screen_component_name: "rolling_banner_card",
+ category: club.category,
+ });
+
+ navigation.navigate(SCREEN_TYPE.CLUB_DETAIL, {
+ uuid: club.uuid,
+ category: club.category,
+ entry_point: "home",
+ });
+ };
+
+ return (
+
+
+
+ 이번 학기엔 동아리까지,
+
+
+
+
+
+ 어떤 동아리든 올클과 함께 찾아봐요
+
+
+
+
+
+
+ 새로운 공고가 올라왔어요
+
+
+
+
+
+ );
+};
+
+export default HomeScreen;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_MAIN,
+ alignItems: "center",
+ paddingTop: vs(12),
+ },
+ headerContainer: {
+ width: s(353),
+ alignItems: "flex-start",
+ marginLeft: s(4),
+ marginBottom: vs(17),
+ },
+ subtitle: {
+ ...typography.bodyMSemibold,
+ color: Colors.POINTCOLOR,
+ },
+ logo: {
+ width: s(119),
+ height: s(40),
+ marginTop: vs(6),
+ resizeMode: "contain",
+ },
+ categoryContainer: {
+ width: s(353),
+ alignItems: "flex-start",
+ marginBottom: vs(50),
+ },
+ sectionTitle: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ marginLeft: s(4),
+ marginBottom: vs(16),
+ },
+ latestClubsContainer: {
+ width: "100%",
+ },
+ latestClubsTitle: {
+ marginLeft: s(20),
+ },
+});
diff --git a/apps/mobile/src/features/mypage/profileUpdate.test.ts b/apps/mobile/src/features/mypage/profileUpdate.test.ts
new file mode 100644
index 00000000..99809c65
--- /dev/null
+++ b/apps/mobile/src/features/mypage/profileUpdate.test.ts
@@ -0,0 +1,39 @@
+import type { User } from "@/entities/user";
+import { buildUpdatedProfile } from "@/features/mypage/profileUpdate";
+
+describe("buildUpdatedProfile", () => {
+ it("reflects the saved college and major in the local profile immediately", () => {
+ const user: User = {
+ id: "user-id",
+ nickname: "기존 닉네임",
+ name: "홍길동",
+ phone: "01012345678",
+ email: "user@example.com",
+ collegeMajor: {
+ id: 1,
+ college: "공과대학",
+ major: "컴퓨터공학부",
+ },
+ admissionClass: 23,
+ };
+
+ expect(
+ buildUpdatedProfile(user, {
+ nickname: "새 닉네임",
+ collegeMajorId: 2,
+ college: "인문대학",
+ major: "국어국문학과",
+ admissionClass: 24,
+ }),
+ ).toEqual({
+ ...user,
+ nickname: "새 닉네임",
+ collegeMajor: {
+ id: 2,
+ college: "인문대학",
+ major: "국어국문학과",
+ },
+ admissionClass: 24,
+ });
+ });
+});
diff --git a/apps/mobile/src/features/mypage/profileUpdate.ts b/apps/mobile/src/features/mypage/profileUpdate.ts
new file mode 100644
index 00000000..a48564f4
--- /dev/null
+++ b/apps/mobile/src/features/mypage/profileUpdate.ts
@@ -0,0 +1,23 @@
+import type { User } from "@/entities/user";
+
+type SavedProfile = {
+ nickname: string;
+ collegeMajorId: number;
+ college: string;
+ major: string;
+ admissionClass: number;
+};
+
+export const buildUpdatedProfile = (
+ user: User,
+ profile: SavedProfile,
+): User => ({
+ ...user,
+ nickname: profile.nickname,
+ collegeMajor: {
+ id: profile.collegeMajorId,
+ college: profile.college,
+ major: profile.major,
+ },
+ admissionClass: profile.admissionClass,
+});
diff --git a/src/features/mypage/screens/EditProfileScreen/index.tsx b/apps/mobile/src/features/mypage/screens/EditProfileScreen/index.tsx
similarity index 52%
rename from src/features/mypage/screens/EditProfileScreen/index.tsx
rename to apps/mobile/src/features/mypage/screens/EditProfileScreen/index.tsx
index 824d8a65..f7a80113 100644
--- a/src/features/mypage/screens/EditProfileScreen/index.tsx
+++ b/apps/mobile/src/features/mypage/screens/EditProfileScreen/index.tsx
@@ -1,12 +1,5 @@
-import { useQuery } from '@tanstack/react-query'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { useProfile } from '@/shared/contexts/profileContext'
-import { serviceContext } from '@/shared/contexts/serviceContext'
-import { ms, s, vs } from '@/shared/utils/scale'
-import { navigation } from '@/shared/utils/navigation'
-import Header from '@/shared/components/BackHeader'
-import React, { useContext, useState } from 'react'
+import { useQuery } from "@tanstack/react-query";
+import { useContext, useState } from "react";
import {
ActivityIndicator,
Keyboard,
@@ -15,84 +8,110 @@ import {
Text,
TextInput,
View,
-} from 'react-native'
-import DropDownPicker from 'react-native-dropdown-picker'
-import { SafeAreaView } from 'react-native-safe-area-context'
-import Toast from 'react-native-toast-message'
-import Icon from 'react-native-vector-icons/MaterialIcons'
-import CommunityIcon from 'react-native-vector-icons/MaterialCommunityIcons'
+} from "react-native";
+import DropDownPicker from "react-native-dropdown-picker";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Toast from "react-native-toast-message";
+import CommunityIcon from "react-native-vector-icons/MaterialCommunityIcons";
+import Icon from "react-native-vector-icons/MaterialIcons";
+import { buildUpdatedProfile } from "@/features/mypage/profileUpdate";
+import Header from "@/shared/components/BackHeader";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { navigation } from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
const EditProfileScreen = () => {
- const { user, setUser } = useProfile()
- const { data: collegeMajors } = useCollegeMajors()
- const { userService } = useContext(serviceContext)
+ const { user, setUser } = useProfile();
+ const { data: collegeMajors } = useCollegeMajors();
+ const { userService } = useContext(serviceContext);
- const [name, setName] = useState(user?.nickname || '')
- const [college, setCollege] = useState(user?.college || '')
- const [major, setMajor] = useState(user?.major || '')
- const [admissionClass, setAdmissionClass] = useState(user?.admissionClass ?? 26)
+ const [name, setName] = useState(user?.nickname || "");
+ const [college, setCollege] = useState(user?.collegeMajor?.college || "");
+ const [major, setMajor] = useState(user?.collegeMajor?.major || "");
+ const [admissionClass, setAdmissionClass] = useState(
+ user?.admissionClass ?? 26,
+ );
- const [openCollegeDropDown, setOpenCollegeDropDown] = useState(false)
- const [openMajorDropDown, setOpenMajorDropDown] = useState(false)
- const [isSubmitting, setIsSubmitting] = useState(false)
+ const [openCollegeDropDown, setOpenCollegeDropDown] = useState(false);
+ const [openMajorDropDown, setOpenMajorDropDown] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
- const isFormValid = !!name && !!college && !!major
+ const isFormValid = !!name && !!college && !!major;
const hasChanges =
- name !== (user?.nickname || '') ||
- college !== (user?.college || '') ||
- major !== (user?.major || '') ||
- admissionClass !== (user?.admissionClass ?? 26)
- const canSubmit = isFormValid && hasChanges
+ name !== (user?.nickname || "") ||
+ college !== (user?.collegeMajor?.college || "") ||
+ major !== (user?.collegeMajor?.major || "") ||
+ admissionClass !== (user?.admissionClass ?? 26);
+ const canSubmit = isFormValid && hasChanges;
const colleges = collegeMajors?.reduce((acc, cur) => {
if (cur.college && !acc.includes(cur.college)) {
- acc.push(cur.college)
+ acc.push(cur.college);
}
- return acc
- }, [] as string[])
+ return acc;
+ }, [] as string[]);
const majors = collegeMajors?.reduce((acc, cur) => {
if (cur.major && !acc.includes(cur.major) && cur.college === college) {
- acc.push(cur.major)
+ acc.push(cur.major);
}
- return acc
- }, [] as string[])
+ return acc;
+ }, [] as string[]);
const handleSubmit = async () => {
try {
- setIsSubmitting(true)
+ setIsSubmitting(true);
const collegeMajorId = collegeMajors?.find(
- cm => cm.college === college && cm.major === major,
- )?.id
+ (cm) => cm.college === college && cm.major === major,
+ )?.id;
if (!collegeMajorId) {
- Toast.show({ type: 'info', text1: '단과대 및 학과를 다시 선택해주세요' })
- return
+ Toast.show({
+ type: "info",
+ text1: "단과대 및 학과를 다시 선택해주세요",
+ });
+ return;
}
- Keyboard.dismiss()
- await userService.updateUser({ nickname: name, collegeMajorId, major, admissionClass })
+ Keyboard.dismiss();
+ await userService.updateUser({
+ nickname: name,
+ collegeMajorId,
+ admissionClass,
+ });
- const updatedUser = await userService.getUser()
- setUser(updatedUser)
- navigation.goBack()
+ if (user) {
+ setUser(
+ buildUpdatedProfile(user, {
+ nickname: name,
+ collegeMajorId,
+ college,
+ major,
+ admissionClass,
+ }),
+ );
+ }
+ navigation.goBack();
- Toast.show({ type: 'info', text1: '프로필이 수정되었어요!' })
+ Toast.show({ type: "info", text1: "프로필이 수정되었어요!" });
} catch {
- Toast.show({ type: 'info', text1: '이런! 문제가 생겼어요!' })
+ Toast.show({ type: "info", text1: "이런! 문제가 생겼어요!" });
} finally {
- setIsSubmitting(false)
+ setIsSubmitting(false);
}
- }
+ };
const closeDropdowns = () => {
- setOpenCollegeDropDown(false)
- setOpenMajorDropDown(false)
- }
+ setOpenCollegeDropDown(false);
+ setOpenMajorDropDown(false);
+ };
return (
-
+
navigation.goBack()} />
@@ -112,17 +131,19 @@ const EditProfileScreen = () => {
단과대 및 학과
{
- setOpenCollegeDropDown(val)
- setOpenMajorDropDown(false)
+ setOpen={(val) => {
+ setOpenCollegeDropDown(val);
+ setOpenMajorDropDown(false);
}}
value={college}
setValue={setCollege}
- onSelectItem={item => {
- if (item.value !== college) setMajor('')
+ onSelectItem={(item) => {
+ if (item.value !== college) setMajor("");
}}
- items={colleges?.map(c => ({ label: c, value: c })) ?? []}
+ items={colleges?.map((c) => ({ label: c, value: c })) ?? []}
placeholder="단과대를 선택해주세요"
style={styles.picker}
dropDownContainerStyle={styles.pickerDropdown}
@@ -134,18 +155,20 @@ const EditProfileScreen = () => {
/>
{
- setOpenCollegeDropDown(false)
- setOpenMajorDropDown(val)
+ setOpen={(val) => {
+ setOpenCollegeDropDown(false);
+ setOpenMajorDropDown(val);
}}
value={major}
setValue={setMajor}
- items={majors?.map(c => ({ label: c, value: c })) ?? []}
+ items={majors?.map((c) => ({ label: c, value: c })) ?? []}
placeholder="학과를 선택해주세요"
- style={[styles.picker, { zIndex: -1 }]}
+ style={styles.picker}
dropDownContainerStyle={styles.pickerDropdown}
placeholderStyle={styles.pickerPlaceholder}
textStyle={styles.pickerText}
@@ -159,16 +182,35 @@ const EditProfileScreen = () => {
학번
[styles.stepperBtn, pressed && styles.pressed]}
- onPress={() => setAdmissionClass(Math.max(admissionClass - 1, 0))}>
-
+ style={({ pressed }) => [
+ styles.stepperBtn,
+ pressed && styles.pressed,
+ ]}
+ onPress={() => setAdmissionClass(Math.max(admissionClass - 1, 0))}
+ >
+
{`${String(admissionClass).padStart(2, '0')}학번`}
+ style={styles.stepperValue}
+ >{`${String(admissionClass).padStart(2, "0")}학번`}
[styles.stepperBtn, pressed && styles.pressed]}
- onPress={() => setAdmissionClass(Math.min(admissionClass + 1, 30))}>
-
+ style={({ pressed }) => [
+ styles.stepperBtn,
+ pressed && styles.pressed,
+ ]}
+ onPress={() =>
+ setAdmissionClass(Math.min(admissionClass + 1, 30))
+ }
+ >
+
@@ -182,7 +224,8 @@ const EditProfileScreen = () => {
styles.submitButton,
(!canSubmit || isSubmitting) && styles.submitButtonDisabled,
pressed && canSubmit && styles.pressed,
- ]}>
+ ]}
+ >
{isSubmitting ? (
) : (
@@ -191,25 +234,37 @@ const EditProfileScreen = () => {
- )
-}
+ );
+};
-export default EditProfileScreen
+export default EditProfileScreen;
const useCollegeMajors = () => {
- const { userService } = useContext(serviceContext)
- return useQuery(['collegeMajors'], () => userService.listCollegeMajors(), {
+ const { userService } = useContext(serviceContext);
+ return useQuery(["collegeMajors"], () => userService.listCollegeMajors(), {
keepPreviousData: true,
- select: data => data.majors,
- })
-}
+ select: (data) => data.majors,
+ });
+};
function renderArrowDownIcon() {
- return
+ return (
+
+ );
}
function renderArrowUpIcon() {
- return
+ return (
+
+ );
}
const styles = StyleSheet.create({
@@ -266,9 +321,9 @@ const styles = StyleSheet.create({
height: vs(8),
},
stepperRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
},
stepperValue: {
...typography.bodyMSemibold,
@@ -288,7 +343,7 @@ const styles = StyleSheet.create({
backgroundColor: Colors.BUTTON_SELECTED,
borderRadius: ms(12),
paddingVertical: vs(16),
- alignItems: 'center',
+ alignItems: "center",
},
submitButtonDisabled: {
opacity: 0.4,
@@ -297,4 +352,4 @@ const styles = StyleSheet.create({
...typography.bodyMSemibold,
color: Colors.WHITE,
},
-})
+});
diff --git a/apps/mobile/src/features/mypage/screens/ManageClubRegistrationScreen/index.tsx b/apps/mobile/src/features/mypage/screens/ManageClubRegistrationScreen/index.tsx
new file mode 100644
index 00000000..683261d5
--- /dev/null
+++ b/apps/mobile/src/features/mypage/screens/ManageClubRegistrationScreen/index.tsx
@@ -0,0 +1,780 @@
+import { type RouteProp, useRoute } from "@react-navigation/native";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useContext, useEffect, useState } from "react";
+import {
+ Image,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import type { Club } from "@/entities/club";
+import { getManagedClubUpdateErrorContent } from "@/features/club/utils/managedClubUpdateError";
+import { FormNavigationButtons } from "@/features/register-club/components/FormNavigationButtons";
+import AlertModal from "@/shared/components/AlertModal";
+import FlowScreenFooter from "@/shared/components/FlowScreenFooter";
+import FlowScreenLayout from "@/shared/components/FlowScreenLayout";
+import TextField from "@/shared/components/TextField";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { getClubSummaryWithAffiliation } from "@/shared/utils/club";
+import {
+ formatPhoneNumberInput,
+ formatStudentIdInput,
+ getPhoneNumberPrefill,
+ getStudentIdPrefill,
+} from "@/shared/utils/managerInfo";
+import { navigation } from "@/shared/utils/navigation";
+import { s, vs } from "@/shared/utils/scale";
+
+type AdminFormData = {
+ name: string;
+ phone: string;
+ studentId: string;
+};
+
+type AdminFormErrors = {
+ name?: string;
+ phone?: string;
+ studentId?: string;
+};
+
+const ManageClubRegistrationScreen = () => {
+ const { clubService } = useContext(serviceContext);
+ const { user } = useProfile();
+ const queryClient = useQueryClient();
+ const route =
+ useRoute<
+ RouteProp
+ >();
+ const editClubId = route.params?.clubId;
+ const editMode = route.params?.editMode;
+ const isResubmission = route.params?.isResubmission ?? false;
+ const isManagerRequestEditMode = editMode === "managerRequest";
+ const isClubRegistrationEditMode = editMode === "clubRegistration";
+ const isEditMode = editClubId !== undefined && editMode !== undefined;
+
+ const [formStep, setFormStep] = useState<"form" | "clubSearch">("form");
+ const [adminForm, setAdminForm] = useState({
+ name: isEditMode ? "" : (user?.name ?? ""),
+ phone: isEditMode ? "" : getPhoneNumberPrefill(user?.phone ?? ""),
+ studentId: isEditMode
+ ? ""
+ : getStudentIdPrefill(user?.admissionClass ?? null),
+ });
+ const [adminFormErrors, setAdminFormErrors] = useState({});
+ const [clubSearchQuery, setClubSearchQuery] = useState("");
+ const [searchResults, setSearchResults] = useState([]);
+ const [selectedClubId, setSelectedClubId] = useState("");
+ const [clubPendingRequest, setClubPendingRequest] = useState(
+ null,
+ );
+ const [isExistingManagerModalVisible, setIsExistingManagerModalVisible] =
+ useState(false);
+ const [
+ isPendingManagerRequestModalVisible,
+ setIsPendingManagerRequestModalVisible,
+ ] = useState(false);
+ const [isSubmittingRequest, setIsSubmittingRequest] = useState(false);
+ const [isSuccessModalVisible, setIsSuccessModalVisible] = useState(false);
+ const [isErrorModalVisible, setIsErrorModalVisible] = useState(false);
+
+ const { data: manageClubs = [] } = useQuery({
+ queryKey: ["manageClubs"],
+ queryFn: () => clubService.listManageClubs(),
+ select: (data) => data.clubs,
+ enabled: !isEditMode,
+ });
+
+ const {
+ data: initialManagerInfo,
+ isLoading: isManagerInfoLoading,
+ isError: isManagerInfoError,
+ error: managerInfoError,
+ refetch: refetchManagerInfo,
+ } = useQuery({
+ queryKey: ["clubManagerInfo", editMode, editClubId],
+ queryFn: () => {
+ if (!editClubId || !editMode) {
+ throw new Error(
+ "Club id and edit mode are required to load manager info",
+ );
+ }
+
+ if (editMode === "clubRegistration") {
+ return clubService.getManagedClubManager({ uuid: editClubId });
+ }
+
+ return clubService.getClubManagerRequest({ clubId: editClubId });
+ },
+ enabled: isEditMode,
+ });
+
+ useEffect(() => {
+ if (!initialManagerInfo) return;
+
+ setAdminForm({
+ name: initialManagerInfo.name,
+ phone: initialManagerInfo.phone,
+ studentId: initialManagerInfo.studentId,
+ });
+ }, [initialManagerInfo]);
+
+ useEffect(() => {
+ if (isManagerInfoError) {
+ setIsErrorModalVisible(true);
+ }
+ }, [isManagerInfoError]);
+
+ useEffect(() => {
+ if (!user || isEditMode) {
+ return;
+ }
+
+ const phone = getPhoneNumberPrefill(user.phone);
+ const studentId = getStudentIdPrefill(user.admissionClass);
+ setAdminForm((prev) => ({
+ ...prev,
+ name: prev.name || user.name,
+ phone: prev.phone || phone,
+ studentId: prev.studentId || studentId,
+ }));
+ }, [isEditMode, user]);
+
+ useEffect(() => {
+ const timer = setTimeout(async () => {
+ if (!clubSearchQuery.trim()) {
+ setSearchResults([]);
+ return;
+ }
+
+ try {
+ const { clubs } = await clubService.searchClubs({
+ query: clubSearchQuery,
+ });
+ setSearchResults(clubs);
+ } catch {
+ setSearchResults([]);
+ }
+ }, 500);
+
+ return () => clearTimeout(timer);
+ }, [clubSearchQuery, clubService]);
+
+ useEffect(() => {
+ if (
+ selectedClubId &&
+ !searchResults.some((club) => club.uuid === selectedClubId)
+ ) {
+ setSelectedClubId("");
+ }
+ }, [searchResults, selectedClubId]);
+
+ const validatePhoneNumber = (phone: string): boolean => {
+ const phoneRegex = /^01[0-9]\d{8}$/;
+ return phoneRegex.test(phone.replace(/-/g, ""));
+ };
+
+ const validateStudentId = (studentId: string): boolean => {
+ const studentIdRegex = /^\d{4}-\d{5}$/;
+ return studentIdRegex.test(studentId);
+ };
+
+ const validateAdminForm = (): boolean => {
+ const errors: AdminFormErrors = {};
+
+ if (!adminForm.name.trim()) {
+ errors.name = "이름을 입력해주세요";
+ }
+
+ if (!adminForm.phone.trim() || !validatePhoneNumber(adminForm.phone)) {
+ errors.phone = "올바른 전화번호 형식으로 입력해주세요";
+ }
+
+ if (
+ !adminForm.studentId.trim() ||
+ !validateStudentId(adminForm.studentId)
+ ) {
+ errors.studentId = "2023-12345 형식으로 입력해주세요";
+ }
+
+ setAdminFormErrors(errors);
+ return Object.keys(errors).length === 0;
+ };
+
+ const isFormFieldsValid =
+ adminForm.name.trim() !== "" &&
+ adminForm.phone.trim() !== "" &&
+ validatePhoneNumber(adminForm.phone) &&
+ adminForm.studentId.trim() !== "" &&
+ validateStudentId(adminForm.studentId);
+ const hasManagerInfoChanges =
+ initialManagerInfo !== undefined &&
+ (adminForm.name !== initialManagerInfo.name ||
+ adminForm.phone !== initialManagerInfo.phone ||
+ adminForm.studentId !== initialManagerInfo.studentId);
+ const isManagerInfoPrefillLoading = isEditMode && isManagerInfoLoading;
+ const managerInfoErrorContent =
+ getManagedClubUpdateErrorContent(managerInfoError);
+
+ const handleBack = () => {
+ if (formStep === "clubSearch") {
+ setFormStep("form");
+ setClubSearchQuery("");
+ setSearchResults([]);
+ setSelectedClubId("");
+ return;
+ }
+
+ navigation.goBack();
+ };
+
+ const handleFormNext = async () => {
+ if (!validateAdminForm()) return;
+
+ if (editClubId && isClubRegistrationEditMode) {
+ const managerData = hasManagerInfoChanges
+ ? {
+ ...(adminForm.name !== initialManagerInfo?.name && {
+ name: adminForm.name,
+ }),
+ ...(adminForm.phone !== initialManagerInfo?.phone && {
+ phone: adminForm.phone,
+ }),
+ ...(adminForm.studentId !== initialManagerInfo?.studentId && {
+ studentId: adminForm.studentId,
+ }),
+ }
+ : undefined;
+
+ navigation.navigate(SCREEN_TYPE.CLUB_INFO_EDIT, {
+ clubId: editClubId,
+ fromClubRegistrationEdit: true,
+ isResubmission,
+ managerData,
+ });
+ return;
+ }
+
+ if (editClubId && isManagerRequestEditMode) {
+ if (isSubmittingRequest || (!isResubmission && !hasManagerInfoChanges))
+ return;
+
+ setIsSubmittingRequest(true);
+ try {
+ await clubService.updateClubManagerRequest({
+ clubId: editClubId,
+ ...(adminForm.name !== initialManagerInfo?.name && {
+ name: adminForm.name,
+ }),
+ ...(adminForm.phone !== initialManagerInfo?.phone && {
+ phone: adminForm.phone,
+ }),
+ ...(adminForm.studentId !== initialManagerInfo?.studentId && {
+ studentId: adminForm.studentId,
+ }),
+ ...(isResubmission && { resubmit: true }),
+ });
+ queryClient.setQueryData(
+ ["clubManagerInfo", editMode, editClubId],
+ adminForm,
+ );
+ setIsSuccessModalVisible(true);
+ } catch {
+ setIsErrorModalVisible(true);
+ } finally {
+ setIsSubmittingRequest(false);
+ }
+ return;
+ }
+
+ setFormStep("clubSearch");
+ };
+
+ const handleClubSelect = (clubId: string) => {
+ setSelectedClubId(clubId);
+ };
+
+ const submitClubRequest = async (club: Club) => {
+ if (isSubmittingRequest) return;
+
+ setIsSubmittingRequest(true);
+ try {
+ await clubService.requestClubManager({
+ clubId: club.uuid,
+ name: adminForm.name,
+ phone: adminForm.phone,
+ studentId: adminForm.studentId,
+ });
+ setIsSuccessModalVisible(true);
+ } catch {
+ setIsErrorModalVisible(true);
+ } finally {
+ setIsSubmittingRequest(false);
+ }
+ };
+
+ const handleClubAddPress = (club: Club) => {
+ setSelectedClubId(club.uuid);
+
+ const hasPendingManagerRequest = manageClubs.some(
+ (managedClub) =>
+ managedClub.uuid === club.uuid &&
+ managedClub.managementStatus === "MANAGER_REQUEST_PENDING",
+ );
+
+ if (hasPendingManagerRequest) {
+ setIsPendingManagerRequestModalVisible(true);
+ return;
+ }
+
+ if (club.hasManager) {
+ setIsExistingManagerModalVisible(true);
+ return;
+ }
+
+ setClubPendingRequest(club);
+ };
+
+ const handleClubRequestConfirm = () => {
+ if (!clubPendingRequest) return;
+
+ const club = clubPendingRequest;
+ setClubPendingRequest(null);
+ void submitClubRequest(club);
+ };
+
+ const handleSuccessConfirm = () => {
+ setIsSuccessModalVisible(false);
+ setFormStep("form");
+ setAdminForm({
+ name: user?.name ?? "",
+ phone: getPhoneNumberPrefill(user?.phone ?? ""),
+ studentId: getStudentIdPrefill(user?.admissionClass ?? null),
+ });
+ setAdminFormErrors({});
+ setClubSearchQuery("");
+ setSearchResults([]);
+ setSelectedClubId("");
+ if (isEditMode) {
+ navigation.goBack();
+ return;
+ }
+
+ navigation.setRoot("마이");
+ };
+
+ const handleRetryManagerInfo = async () => {
+ setIsErrorModalVisible(false);
+ const result = await refetchManagerInfo();
+ if (result.isError) {
+ setIsErrorModalVisible(true);
+ }
+ };
+
+ const renderResultModals = () => (
+ <>
+ setIsSuccessModalVisible(false)}
+ title={
+ isResubmission
+ ? "운영진 권한을\n재신청했어요."
+ : isEditMode
+ ? "운영진 권한 신청 내용이\n수정되었어요!"
+ : "운영진 권한 요청이\n정상적으로 완료되었어요!"
+ }
+ buttonLabel="확인"
+ onButtonPress={handleSuccessConfirm}
+ dismissOnBackdropPress
+ />
+ setIsErrorModalVisible(false)}
+ title={
+ isManagerInfoError
+ ? managerInfoErrorContent.title
+ : "실행이 완료되지 않았어요"
+ }
+ description={
+ isManagerInfoError
+ ? managerInfoErrorContent.description
+ : "네트워크 문제로 실행이 완료되지 않았어요\n네트워크 상태를 확인한 후, 다시 시도해주세요"
+ }
+ buttonLabel={isManagerInfoError ? "다시 시도" : "확인"}
+ onButtonPress={() => {
+ if (isManagerInfoError) {
+ void handleRetryManagerInfo();
+ return;
+ }
+ setIsErrorModalVisible(false);
+ }}
+ dismissOnBackdropPress={false}
+ />
+ >
+ );
+
+ const renderForm = () => (
+
+
+
+ 운영진 기본 정보를
+ 입력해주세요
+
+
+
+
+ 이름
+ {
+ setAdminForm((prev) => ({ ...prev, name: text }));
+ if (adminFormErrors.name) {
+ setAdminFormErrors((prev) => ({
+ ...prev,
+ name: undefined,
+ }));
+ }
+ }}
+ />
+ 이름을 입력해주세요
+
+
+
+ 전화번호
+ {
+ setAdminForm((prev) => ({
+ ...prev,
+ phone: formatPhoneNumberInput(text, prev.phone),
+ }));
+ if (adminFormErrors.phone) {
+ setAdminFormErrors((prev) => ({
+ ...prev,
+ phone: undefined,
+ }));
+ }
+ }}
+ />
+
+ 올바른 전화번호 형식으로 입력해주세요
+
+
+
+
+ 학번
+ {
+ setAdminForm((prev) => ({
+ ...prev,
+ studentId: formatStudentIdInput(text, prev.studentId),
+ }));
+ if (adminFormErrors.studentId) {
+ setAdminFormErrors((prev) => ({
+ ...prev,
+ studentId: undefined,
+ }));
+ }
+ }}
+ />
+
+ 2023-12345 형식으로 입력해주세요
+
+
+
+
+
+ void handleFormNext()}
+ nextLabel={
+ isManagerRequestEditMode
+ ? isResubmission
+ ? "재신청"
+ : "수정"
+ : "다음"
+ }
+ isNextDisabled={
+ !isFormFieldsValid ||
+ isSubmittingRequest ||
+ isManagerInfoPrefillLoading ||
+ (isManagerRequestEditMode &&
+ !isResubmission &&
+ !hasManagerInfoChanges)
+ }
+ />
+ {renderResultModals()}
+
+ );
+
+ const renderClubSearch = () => (
+ }
+ />
+ }
+ >
+
+ 운영진 권한을 요청할{"\n"}동아리를 선택해주세요
+
+
+
+
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map((club) => (
+ handleClubSelect(club.uuid)}
+ >
+ {club.imageUri ? (
+
+ ) : (
+
+ )}
+
+
+ {club.name}
+
+
+ {getClubSummaryWithAffiliation(club)}
+
+
+ handleClubAddPress(club)}
+ disabled={isSubmittingRequest}
+ >
+
+ +
+
+
+
+ ))}
+
+ )}
+
+ setIsExistingManagerModalVisible(false)}
+ title="이미 등록된 운영진이 있어요"
+ description="동아리당 한 명의 운영진만 등록할 수 있어요"
+ buttonLabel="확인"
+ onButtonPress={() => setIsExistingManagerModalVisible(false)}
+ dismissOnBackdropPress
+ />
+ setIsPendingManagerRequestModalVisible(false)}
+ title="이미 운영진 등록을 신청했어요"
+ description="신청이 승인될 때까지 기다려주세요"
+ buttonLabel="확인"
+ onButtonPress={() => setIsPendingManagerRequestModalVisible(false)}
+ dismissOnBackdropPress
+ />
+ setClubPendingRequest(null)}
+ title="운영진 권한을 요청하시겠습니까?"
+ description="실제 동아리 운영진 확인 후 권한이 부여됩니다."
+ buttonLabel="요청"
+ onButtonPress={handleClubRequestConfirm}
+ hasCancel
+ cancelLabel="취소"
+ dismissOnBackdropPress
+ />
+ {renderResultModals()}
+
+ );
+
+ return formStep === "form" ? renderForm() : renderClubSearch();
+};
+
+export default ManageClubRegistrationScreen;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ formContent: {
+ paddingHorizontal: s(20),
+ paddingTop: vs(20),
+ paddingBottom: vs(20),
+ },
+ formHeader: {
+ marginBottom: vs(35),
+ },
+ formTitle: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ form: {
+ gap: vs(20),
+ },
+ title: {
+ fontSize: 25,
+ fontWeight: "600",
+ color: Colors.BODYTEXT_MAIN,
+ lineHeight: 30,
+ marginBottom: 15,
+ },
+ formFieldLabel: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ marginBottom: vs(10),
+ },
+ formHelperText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ marginTop: vs(5),
+ },
+ formInput: {
+ paddingHorizontal: s(15),
+ },
+ searchInputContainer: {
+ backgroundColor: Colors.BACKGROUND_SUB,
+ borderRadius: 10,
+ paddingHorizontal: 20,
+ paddingVertical: 16,
+ height: 49,
+ justifyContent: "center",
+ },
+ searchInput: {
+ flex: 1,
+ padding: 0,
+ fontSize: 14,
+ fontWeight: "500",
+ color: Colors.BODYTEXT_MAIN,
+ },
+ searchResultsContainer: {
+ marginTop: 20,
+ gap: 10,
+ },
+ clubResultItem: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: Colors.WHITE,
+ borderRadius: 10,
+ paddingHorizontal: 15,
+ paddingVertical: 16,
+ borderWidth: 1,
+ borderColor: "transparent",
+ gap: 10,
+ minHeight: 92,
+ },
+ clubResultItemSelected: {
+ borderColor: Colors.POINTCOLOR,
+ borderWidth: 2,
+ },
+ clubIconPlaceholder: {
+ width: 60,
+ height: 60,
+ borderRadius: 8,
+ backgroundColor: Colors.BODYTEXT_DISABLED,
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ flexShrink: 0,
+ },
+ clubInfo: {
+ flex: 1,
+ gap: 4,
+ },
+ clubName: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: Colors.BODYTEXT_MAIN,
+ lineHeight: 19,
+ },
+ clubDescription: {
+ fontSize: 14,
+ fontWeight: "400",
+ color: Colors.BODYTEXT_SUB,
+ lineHeight: 17,
+ },
+ clubAddButton: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: Colors.BODYTEXT_DISABLED,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ clubAddButtonSelected: {
+ backgroundColor: Colors.POINTCOLOR,
+ },
+ clubAddButtonDisabled: {
+ opacity: 0.7,
+ },
+ clubAddButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: Colors.WHITE,
+ lineHeight: 16,
+ },
+ clubAddButtonTextSelected: {
+ color: Colors.WHITE,
+ },
+ clubAddButtonTextDisabled: {
+ opacity: 0.8,
+ },
+ footerSpacer: {
+ width: 128,
+ height: 44,
+ },
+});
diff --git a/apps/mobile/src/features/mypage/screens/MyPageScreen/index.tsx b/apps/mobile/src/features/mypage/screens/MyPageScreen/index.tsx
new file mode 100644
index 00000000..1c8371f9
--- /dev/null
+++ b/apps/mobile/src/features/mypage/screens/MyPageScreen/index.tsx
@@ -0,0 +1,1031 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { BlurView } from "@react-native-community/blur";
+import { useFocusEffect, useIsFocused } from "@react-navigation/native";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useContext, useState } from "react";
+import {
+ Image,
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Toast from "react-native-toast-message";
+import Icon from "react-native-vector-icons/MaterialIcons";
+import type {
+ Club,
+ ManagedClubListItem,
+ ManagedClubManagementStatus,
+} from "@/entities/club";
+import type {
+ UserNotification,
+ UserNotificationType,
+} from "@/entities/userNotification";
+import AlertModal from "@/shared/components/AlertModal";
+import EditPencilButton from "@/shared/components/EditPencilButton";
+import { Colors } from "@/shared/constants/colors";
+import { LOGIN_TOKEN } from "@/shared/constants/localStorage";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { useManageClubBottomSheet } from "@/shared/contexts/manageClubBottomSheet";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { useUserVoiceBottomSheet } from "@/shared/contexts/userVoiceBottomSheetContext";
+import { setToken } from "@/shared/utils/api";
+import { getClubSummaryWithAffiliation } from "@/shared/utils/club";
+import { navigation } from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+const snuLogo =
+ require("@/assets/images/club-management-snu-logo.png") as number;
+
+const isRejectedRequest = (club: ManagedClubListItem) =>
+ club.managementStatus === "REJECTED" ||
+ club.managementStatus === "MANAGER_REQUEST_REJECTED";
+
+const MyPageScreen = () => {
+ const { authService, clubService, userService } = useContext(serviceContext);
+ const { openBottomSheet: openUserVoice } = useUserVoiceBottomSheet();
+ const { openBottomSheet: openManageClub } = useManageClubBottomSheet();
+ const queryClient = useQueryClient();
+ const { user, setUser } = useProfile();
+ const isFocused = useIsFocused();
+
+ const [logoutModalVisible, setLogoutModalVisible] = useState(false);
+ const [leaveModalVisible, setLeaveModalVisible] = useState(false);
+ const [statusBlurEnabled, setStatusBlurEnabled] = useState(true);
+ const [cancelRequestClub, setCancelRequestClub] =
+ useState(null);
+ const [requestRemovalSuccess, setRequestRemovalSuccess] = useState<
+ "cancelled" | "deleted" | null
+ >(null);
+
+ const { data: manageClubsData, refetch: refetchManageClubs } = useQuery({
+ queryKey: ["manageClubs"],
+ queryFn: () => clubService.listManageClubs(),
+ select: (data) => data.clubs,
+ });
+
+ const { data: notificationData, refetch: refetchNotifications } = useQuery({
+ queryKey: ["userNotifications"],
+ queryFn: () => userService.listNotifications(),
+ });
+ const readNotificationMutation = useMutation({
+ mutationFn: (notificationId: UserNotification["id"]) =>
+ userService.readNotification({ id: notificationId }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["userNotifications"] });
+ },
+ });
+
+ useFocusEffect(
+ useCallback(() => {
+ setStatusBlurEnabled(true);
+ refetchManageClubs();
+ refetchNotifications();
+ }, [refetchManageClubs, refetchNotifications]),
+ );
+
+ const manageClubs = manageClubsData ?? [];
+ const currentNotification = notificationData?.notifications.find(
+ (notification) => !notification.readAt,
+ );
+ const notificationModalContent = currentNotification
+ ? getUserNotificationContent(currentNotification, manageClubs)
+ : null;
+ const notificationActionDisabled =
+ currentNotification?.type === "MANAGER_REQUEST_REJECTED" &&
+ getNotificationClub(currentNotification, manageClubs)?.hasManager === true;
+
+ const handleLeave = () => setLeaveModalVisible(true);
+
+ const confirmLeave = async () => {
+ try {
+ await authService.leave();
+ } catch {
+ Toast.show({ type: "info", text1: "탈퇴 처리 중 오류가 발생했어요" });
+ return;
+ }
+ await AsyncStorage.removeItem(LOGIN_TOKEN);
+ setToken(null);
+ setUser(null);
+ queryClient.clear();
+ navigation.navigate(SCREEN_TYPE.HOME);
+ Toast.show({ type: "info", text1: "회원 탈퇴 되었어요!" });
+ };
+
+ const handleLogout = () => setLogoutModalVisible(true);
+
+ const confirmLogout = async () => {
+ await authService.logout().catch(() => {});
+ setToken(null);
+ setUser(null);
+ queryClient.setQueryData(["savedClubs"], { clubs: [], totalSize: 0 });
+ queryClient.invalidateQueries(["recentSearches"]);
+ queryClient.removeQueries(["manageClubs"]);
+ queryClient.removeQueries(["myClubReview"]);
+ navigation.navigate(SCREEN_TYPE.HOME);
+ Toast.show({ type: "info", text1: "로그아웃 되었어요!" });
+ };
+
+ const handleMoveEditProfilePage = () => {
+ navigation.navigate(SCREEN_TYPE.EDIT_PROFILE);
+ };
+
+ const handleOpenTerms = () => {
+ navigation.navigate(SCREEN_TYPE.WEBVIEW, {
+ uri: "https://www.all-clear.cc/terms/terms-of-service",
+ title: "서비스 이용약관",
+ });
+ };
+
+ const handleOpenPrivacyPolicy = () => {
+ navigation.navigate(SCREEN_TYPE.WEBVIEW, {
+ uri: "https://www.all-clear.cc/terms/privacy-policy",
+ title: "개인정보 처리방침",
+ });
+ };
+
+ const handleRegisterAnnouncement = (club: Club) => {
+ setStatusBlurEnabled(false);
+ navigation.navigate(SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION, {
+ clubId: club.uuid,
+ });
+ };
+
+ const handleManageClub = (club: Club) => {
+ setStatusBlurEnabled(false);
+ navigation.navigate(SCREEN_TYPE.CLUB_MANAGEMENT, { clubId: club.uuid });
+ };
+
+ const cancelRequestMutation = useMutation({
+ mutationFn: async (club: ManagedClubListItem) => {
+ if (
+ club.managementStatus === "PENDING" ||
+ club.managementStatus === "REJECTED"
+ ) {
+ return clubService.cancelManagedClubRequest({ uuid: club.uuid });
+ }
+
+ return clubService.cancelClubManagerRequest({ clubId: club.uuid });
+ },
+ onSuccess: (_data, club) => {
+ setCancelRequestClub(null);
+ setRequestRemovalSuccess(
+ isRejectedRequest(club) ? "deleted" : "cancelled",
+ );
+ queryClient.invalidateQueries({ queryKey: ["manageClubs"] });
+ },
+ onError: (_error, club) => {
+ Toast.show({
+ type: "error",
+ text1: isRejectedRequest(club)
+ ? "신청을 삭제하지 못했어요"
+ : "신청을 취소하지 못했어요",
+ text2: "잠시 후 다시 시도해주세요",
+ });
+ },
+ });
+
+ const handleEditRequest = (club: ManagedClubListItem) => {
+ setStatusBlurEnabled(false);
+
+ if (
+ club.managementStatus === "PENDING" ||
+ club.managementStatus === "REJECTED"
+ ) {
+ navigation.navigate(SCREEN_TYPE.MANAGE_CLUB_REGISTRATION, {
+ clubId: club.uuid,
+ editMode: "clubRegistration",
+ isResubmission: club.managementStatus === "REJECTED",
+ });
+ return;
+ }
+
+ navigation.navigate(SCREEN_TYPE.MANAGE_CLUB_REGISTRATION, {
+ clubId: club.uuid,
+ editMode: "managerRequest",
+ isResubmission: club.managementStatus === "MANAGER_REQUEST_REJECTED",
+ });
+ };
+
+ const handleCloseCancelRequestModal = () => {
+ if (!cancelRequestMutation.isPending) {
+ setCancelRequestClub(null);
+ }
+ };
+
+ const handleConfirmCancelRequest = () => {
+ if (!cancelRequestClub || cancelRequestMutation.isPending) return;
+
+ cancelRequestMutation.mutate(cancelRequestClub);
+ };
+
+ const handleReadCurrentNotification = () => {
+ if (!currentNotification) return;
+
+ readNotificationMutation.mutate(currentNotification.id);
+ };
+
+ const handleCurrentNotificationAction = () => {
+ if (!currentNotification) return;
+
+ handleReadCurrentNotification();
+ if (
+ currentNotification.type === "CLUB_REGISTRATION_REJECTED" &&
+ currentNotification.clubId
+ ) {
+ setStatusBlurEnabled(false);
+ navigation.navigate(SCREEN_TYPE.MANAGE_CLUB_REGISTRATION, {
+ clubId: currentNotification.clubId,
+ editMode: "clubRegistration",
+ isResubmission: true,
+ });
+ return;
+ }
+
+ if (
+ currentNotification.type === "MANAGER_REQUEST_REJECTED" &&
+ currentNotification.clubId
+ ) {
+ setStatusBlurEnabled(false);
+ navigation.navigate(SCREEN_TYPE.MANAGE_CLUB_REGISTRATION, {
+ clubId: currentNotification.clubId,
+ editMode: "managerRequest",
+ isResubmission: true,
+ });
+ }
+ };
+
+ const renderManageClubButtons = (club: ManagedClubListItem) => {
+ if (club.managementStatus === "APPROVED") {
+ return (
+
+ handleRegisterAnnouncement(club)}
+ >
+ 공고 등록
+
+ handleManageClub(club)}
+ >
+ 동아리 관리
+
+
+ );
+ }
+
+ if (
+ club.managementStatus === "REJECTED" ||
+ club.managementStatus === "MANAGER_REQUEST_REJECTED"
+ ) {
+ const isResubmissionDisabled =
+ club.managementStatus === "MANAGER_REQUEST_REJECTED" && club.hasManager;
+
+ return (
+
+ setCancelRequestClub(club)}
+ >
+ 삭제
+
+ handleEditRequest(club)}
+ >
+
+ 수정 및 재신청
+
+
+
+ );
+ }
+
+ if (
+ club.managementStatus === "PENDING" ||
+ club.managementStatus === "MANAGER_REQUEST_PENDING"
+ ) {
+ return (
+
+ setCancelRequestClub(club)}
+ >
+ 신청 취소
+
+ handleEditRequest(club)}
+ >
+ 신청 내용 수정
+
+
+ );
+ }
+
+ return null;
+ };
+
+ return (
+
+
+ {/* 프로필 카드 */}
+
+
+
+
+
+
+ {user?.nickname || "이름 정보가 없습니다"}
+
+
+ {user?.collegeMajor?.college
+ ? user.collegeMajor.major
+ ? `${user.collegeMajor.college} ${user.collegeMajor.major}`
+ : user.collegeMajor.college
+ : "단과대 정보가 없습니다"}
+
+
+ {user?.admissionClass != null
+ ? `${String(user.admissionClass).padStart(2, "0")}학번`
+ : "학번 정보가 없습니다"}
+
+
+
+ {/* 동아리 운영진이신가요? 카드 */}
+ [
+ styles.managerCard,
+ pressed && styles.pressed,
+ ]}
+ onPress={openManageClub}
+ >
+
+
+ 동아리 운영진이신가요?
+ 신규 동아리 등록하기
+
+
+
+
+
+ {/* 관리 중인 동아리 가로 스크롤 */}
+ {manageClubs.length > 0 && (
+
+ {manageClubs.map((club) => (
+
+ {/* 동아리 정보 카드 */}
+
+
+
+ {club.imageUri ? (
+
+ ) : null}
+
+
+
+ {club.name}
+
+
+
+ {getClubSummaryWithAffiliation(club)}
+
+
+
+
+
+
+
+
+ {renderManageClubButtons(club)}
+
+ ))}
+
+ )}
+
+ {/* 기타 메뉴 */}
+
+ [
+ styles.menuItem,
+ pressed && styles.pressed,
+ ]}
+ onPress={openUserVoice}
+ >
+ 개발자에게 요청하기
+
+ [
+ styles.menuItem,
+ pressed && styles.pressed,
+ ]}
+ onPress={handleOpenTerms}
+ >
+ 서비스 이용약관
+
+ [
+ styles.menuItem,
+ pressed && styles.pressed,
+ ]}
+ onPress={handleOpenPrivacyPolicy}
+ >
+ 개인정보 처리방침
+
+
+
+
+ [
+ styles.menuItem,
+ pressed && styles.pressed,
+ ]}
+ onPress={handleLogout}
+ >
+ 로그아웃
+
+ [
+ styles.menuItem,
+ pressed && styles.pressed,
+ ]}
+ onPress={handleLeave}
+ >
+ 회원 탈퇴
+
+
+
+
+ setLogoutModalVisible(false)}
+ title="로그아웃"
+ description="로그아웃 하시겠습니까?"
+ buttonLabel="로그아웃"
+ onButtonPress={() => {
+ setLogoutModalVisible(false);
+ confirmLogout();
+ }}
+ hasCancel
+ />
+ setLeaveModalVisible(false)}
+ title="회원 탈퇴"
+ description="정말로 탈퇴하시겠습니까?"
+ buttonLabel="탈퇴"
+ buttonVariant="destructive"
+ onButtonPress={() => {
+ setLeaveModalVisible(false);
+ confirmLeave();
+ }}
+ hasCancel
+ />
+
+ setRequestRemovalSuccess(null)}
+ />
+
+
+ );
+};
+
+export default MyPageScreen;
+
+const RequestRemovalSuccessModal = ({
+ visible,
+ message,
+ onClose,
+}: {
+ visible: boolean;
+ message: string;
+ onClose: () => void;
+}) => (
+
+
+
+ event.stopPropagation()}
+ >
+
+ {message}
+
+
+ 확인
+
+
+
+
+);
+
+type ManagedClubOverlayStatus = Exclude<
+ ManagedClubManagementStatus,
+ "APPROVED"
+>;
+
+const MANAGE_CLUB_STATUS_OVERLAY: Record<
+ ManagedClubOverlayStatus,
+ { label: string; backgroundColor: string }
+> = {
+ PENDING: {
+ label: "승인 대기 중",
+ backgroundColor: "rgba(135, 79, 255, 0.2)",
+ },
+ REJECTED: {
+ label: "미승인(신청 반려)",
+ backgroundColor: Colors.BACKGROUND_DIM,
+ },
+ MANAGER_REQUEST_PENDING: {
+ label: "운영진 권한 승인 대기 중",
+ backgroundColor: "rgba(135, 79, 255, 0.2)",
+ },
+ MANAGER_REQUEST_REJECTED: {
+ label: "미승인(운영진 권한 신청 반려)",
+ backgroundColor: Colors.BACKGROUND_DIM,
+ },
+};
+
+const ManageClubStatusBlurLayer = ({
+ status,
+ blurEnabled,
+}: {
+ status: ManagedClubManagementStatus;
+ blurEnabled: boolean;
+}) => {
+ if (status === "APPROVED") return null;
+
+ const overlay = MANAGE_CLUB_STATUS_OVERLAY[status];
+
+ return (
+
+ {blurEnabled && (
+
+ )}
+
+
+ );
+};
+
+const ManageClubStatusTextLayer = ({
+ status,
+}: {
+ status: ManagedClubManagementStatus;
+}) => {
+ if (status === "APPROVED") return null;
+
+ const overlay = MANAGE_CLUB_STATUS_OVERLAY[status];
+
+ return (
+
+ {overlay.label}
+
+ );
+};
+
+type UserNotificationModalContent = {
+ title: string;
+ description: string;
+ buttonLabel: string;
+ hasCancel?: boolean;
+ cancelLabel?: string;
+};
+
+const USER_NOTIFICATION_CONTENT: Record<
+ UserNotificationType,
+ UserNotificationModalContent
+> = {
+ CLUB_REGISTRATION_APPROVED: {
+ title: "동아리 등록 신청이 승인되었어요!",
+ description:
+ "이제 동아리 공고를 등록/수정하거나,\n동아리 정보를 수정할 수 있어요",
+ buttonLabel: "확인",
+ },
+ CLUB_REGISTRATION_REJECTED: {
+ title: "동아리 등록 신청이 반려되었어요",
+ description: "",
+ buttonLabel: "수정 및 재신청",
+ hasCancel: true,
+ cancelLabel: "취소",
+ },
+ MANAGER_REQUEST_APPROVED: {
+ title: "운영진 등록 신청이 승인되었어요!",
+ description:
+ "이제 동아리 공고를 등록/수정하거나,\n동아리 정보를 수정할 수 있어요",
+ buttonLabel: "확인",
+ },
+ MANAGER_REQUEST_REJECTED: {
+ title: "운영진 등록 신청이 반려되었어요",
+ description: "",
+ buttonLabel: "수정 및 재신청",
+ hasCancel: true,
+ cancelLabel: "취소",
+ },
+};
+
+const getUserNotificationContent = (
+ notification: UserNotification,
+ manageClubs: ManagedClubListItem[],
+): UserNotificationModalContent => {
+ const content = USER_NOTIFICATION_CONTENT[notification.type];
+
+ if (
+ notification.type !== "CLUB_REGISTRATION_REJECTED" &&
+ notification.type !== "MANAGER_REQUEST_REJECTED"
+ ) {
+ return content;
+ }
+
+ const rejectReason = getNotificationRejectReason(notification, manageClubs);
+
+ return {
+ ...content,
+ description: rejectReason ? `${rejectReason}` : "사유가 등록되지 않았어요",
+ };
+};
+
+const getNotificationRejectReason = (
+ notification: UserNotification,
+ manageClubs: ManagedClubListItem[],
+) => {
+ const trimmedMetadataRejectReason =
+ notification.metadata?.rejectReason?.trim();
+
+ if (trimmedMetadataRejectReason) {
+ return trimmedMetadataRejectReason;
+ }
+
+ if (notification.type !== "CLUB_REGISTRATION_REJECTED") {
+ return undefined;
+ }
+
+ return (
+ getNotificationClub(notification, manageClubs)?.rejectReason?.trim() ||
+ undefined
+ );
+};
+
+const getNotificationClub = (
+ notification: UserNotification,
+ manageClubs: ManagedClubListItem[],
+) =>
+ manageClubs.find(
+ (club) =>
+ club.uuid === notification.clubId ||
+ club.id === notification.clubId ||
+ club.uuid === notification.sourceId,
+ );
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ scrollContent: {
+ padding: s(16),
+ paddingBottom: vs(32),
+ gap: vs(12),
+ },
+
+ // 프로필 카드
+ card: {
+ backgroundColor: Colors.WHITE,
+ borderRadius: ms(12),
+ paddingHorizontal: s(24),
+ paddingVertical: s(20),
+ },
+ editButton: {
+ position: "absolute",
+ top: s(20),
+ right: s(20),
+ },
+ profileImage: {
+ width: ms(40),
+ height: ms(40),
+ borderRadius: ms(20),
+ opacity: 1,
+ resizeMode: "contain",
+ },
+ name: {
+ ...typography.headerXL,
+ color: Colors.BODYTEXT_MAIN,
+ marginTop: vs(10),
+ },
+ profileSub: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ marginTop: vs(6),
+ },
+
+ // 운영진 카드
+ managerCard: {
+ backgroundColor: "#FAFAFA",
+ borderRadius: ms(12),
+ paddingHorizontal: s(24),
+ paddingVertical: vs(20),
+ },
+ managerRow: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ },
+ managerTitle: {
+ ...typography.bodyMMedium,
+ color: Colors.POINTCOLOR,
+ letterSpacing: -0.02 * 14,
+ },
+ managerSub: {
+ ...typography.bodySRegular,
+ color: Colors.POINTCOLOR,
+ opacity: 0.4,
+ marginTop: vs(4),
+ letterSpacing: -0.02 * 12,
+ },
+
+ // 관리 동아리 스크롤
+ manageClubsScroll: {
+ gap: s(10),
+ paddingRight: s(4),
+ },
+ manageClubItem: {
+ width: s(330),
+ gap: vs(10),
+ },
+ manageClubInfoCardWrapper: {
+ position: "relative",
+ height: vs(120),
+ },
+ manageClubInfoCard: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingVertical: vs(20),
+ paddingLeft: s(30),
+ paddingRight: s(20),
+ gap: s(30),
+ backgroundColor: "#FAFAFA",
+ borderRadius: ms(10),
+ height: "100%",
+ overflow: "hidden",
+ },
+ manageClubStatusBlurLayer: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 1,
+ },
+ manageClubStatusTextLayer: {
+ ...StyleSheet.absoluteFillObject,
+ justifyContent: "center",
+ alignItems: "center",
+ zIndex: 2,
+ },
+ manageClubStatusOverlayText: {
+ fontFamily: "Pretendard-Bold",
+ fontSize: ms(14),
+ lineHeight: vs(24),
+ letterSpacing: -0.28,
+ textAlign: "center",
+ color: Colors.POINTCOLOR,
+ },
+ manageClubThumbnail: {
+ width: ms(75),
+ height: ms(75),
+ borderRadius: ms(10),
+ backgroundColor: "#D9D9D9",
+ overflow: "hidden",
+ flexShrink: 0,
+ },
+ manageClubThumbnailImage: {
+ width: "100%",
+ height: "100%",
+ },
+ manageClubTexts: {
+ flex: 1,
+ gap: vs(10),
+ },
+ manageClubName: {
+ fontFamily: "Pretendard",
+ fontWeight: "700",
+ fontSize: ms(17),
+ lineHeight: ms(24),
+ letterSpacing: -0.02 * 17,
+ color: "#202020",
+ },
+ manageClubSubTexts: {
+ gap: vs(5),
+ },
+ manageClubSub: {
+ fontFamily: "Pretendard",
+ fontWeight: "400",
+ fontSize: ms(13),
+ lineHeight: ms(15),
+ letterSpacing: -0.02 * 13,
+ color: "#757474",
+ },
+
+ // 버튼 행
+ manageClubButtons: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(10),
+ },
+ manageClubBtnOutline: {
+ flex: 1,
+ height: vs(35),
+ justifyContent: "center",
+ alignItems: "center",
+ borderWidth: 1,
+ borderColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ },
+ manageClubBtnOutlineText: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "600",
+ fontSize: ms(14),
+ lineHeight: ms(24),
+ textAlign: "center",
+ letterSpacing: -0.02 * 14,
+ color: Colors.POINTCOLOR,
+ },
+ manageClubBtnFilled: {
+ flex: 1,
+ height: vs(35),
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: Colors.POINTCOLOR,
+ borderRadius: ms(8),
+ },
+ manageClubBtnFilledText: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "600",
+ fontSize: ms(14),
+ lineHeight: ms(24),
+ textAlign: "center",
+ letterSpacing: -0.02 * 14,
+ color: Colors.WHITE,
+ },
+ manageClubBtnDisabled: {
+ backgroundColor: Colors.TEXTBOX_UNSELECTED,
+ },
+ manageClubBtnDisabledText: {
+ color: Colors.TEXT_BUTTON_UNSELECTED,
+ },
+ // 메뉴
+ menuItem: {
+ paddingVertical: vs(10),
+ },
+ menuText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ pressed: {
+ opacity: 0.5,
+ },
+ cancelSuccessOverlay: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_DIM,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ cancelSuccessCard: {
+ width: ms(320),
+ backgroundColor: Colors.WHITE,
+ borderRadius: ms(12),
+ padding: ms(24),
+ gap: ms(24),
+ alignItems: "center",
+ },
+ cancelSuccessContents: {
+ alignSelf: "stretch",
+ alignItems: "center",
+ gap: ms(8),
+ },
+ cancelSuccessTitle: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "700",
+ fontSize: ms(16),
+ lineHeight: ms(24),
+ color: Colors.BLACK,
+ textAlign: "center",
+ alignSelf: "stretch",
+ },
+ cancelSuccessButton: {
+ alignSelf: "stretch",
+ height: ms(44),
+ backgroundColor: Colors.BUTTON_SELECTED,
+ borderRadius: ms(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ cancelSuccessButtonText: {
+ fontFamily: "Apple SD Gothic Neo",
+ fontWeight: "600",
+ fontSize: ms(16),
+ lineHeight: ms(20),
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+});
diff --git a/apps/mobile/src/features/mypage/testflightQaUi.test.tsx b/apps/mobile/src/features/mypage/testflightQaUi.test.tsx
new file mode 100644
index 00000000..9af0eef3
--- /dev/null
+++ b/apps/mobile/src/features/mypage/testflightQaUi.test.tsx
@@ -0,0 +1,200 @@
+import type { ReactElement, ReactNode } from "react";
+import {
+ Keyboard,
+ Platform,
+ StyleSheet,
+ type TextInputProps,
+ type TextStyle,
+} from "react-native";
+import EditProfileScreen from "@/features/mypage/screens/EditProfileScreen";
+import TextField from "@/shared/components/TextField";
+import UserVoiceView from "@/shared/components/UserVoiceView";
+import { Colors } from "@/shared/constants/colors";
+import { vs } from "@/shared/utils/scale";
+
+jest.mock("react", () => {
+ const actual = jest.requireActual("react");
+ return {
+ ...actual,
+ useContext: () => ({
+ userService: {
+ createUserVoice: jest.fn(),
+ getUser: jest.fn(),
+ listCollegeMajors: jest.fn(),
+ updateUser: jest.fn(),
+ },
+ }),
+ useState: (initialValue: unknown) => [initialValue, jest.fn()],
+ };
+});
+
+jest.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({
+ data: [
+ { id: 1, college: "공과대학", major: "컴퓨터공학부" },
+ { id: 2, college: "공과대학", major: "전기정보공학부" },
+ ],
+ }),
+}));
+
+jest.mock("react-native-dropdown-picker", () => ({
+ __esModule: true,
+ default: "DropDownPicker",
+}));
+
+jest.mock("react-native-safe-area-context", () => ({
+ SafeAreaView: "SafeAreaView",
+}));
+
+jest.mock("react-native-toast-message", () => ({
+ __esModule: true,
+ default: { show: jest.fn() },
+}));
+
+jest.mock("react-native-vector-icons/MaterialCommunityIcons", () => ({
+ __esModule: true,
+ default: "CommunityIcon",
+}));
+
+jest.mock("react-native-vector-icons/MaterialIcons", () => ({
+ __esModule: true,
+ default: "MaterialIcon",
+}));
+
+jest.mock("@gorhom/bottom-sheet", () => ({
+ BottomSheetTextInput: "BottomSheetTextInput",
+}));
+
+jest.mock("@/shared/components/BackHeader", () => ({
+ __esModule: true,
+ default: "BackHeader",
+}));
+
+jest.mock("@/shared/contexts/profileContext", () => ({
+ useProfile: () => ({
+ user: {
+ nickname: "올클",
+ collegeMajor: { college: "공과대학", major: "컴퓨터공학부" },
+ admissionClass: 26,
+ },
+ setUser: jest.fn(),
+ }),
+}));
+
+type ElementWithProps = ReactElement>;
+
+const findAll = (
+ node: ReactNode,
+ predicate: (element: ElementWithProps) => boolean,
+): ElementWithProps[] => {
+ if (node == null || typeof node !== "object" || !("props" in node)) {
+ return [];
+ }
+
+ const element = node as ElementWithProps;
+ const matches = predicate(element) ? [element] : [];
+ const children = element.props.children;
+
+ return [
+ ...matches,
+ ...(Array.isArray(children)
+ ? children.flatMap((child) => findAll(child, predicate))
+ : findAll(children as ReactNode, predicate)),
+ ];
+};
+
+describe("TestFlight QA UI regressions", () => {
+ it("keeps the college dropdown above the major picker", () => {
+ const screen = EditProfileScreen();
+ const pickers = findAll(
+ screen,
+ (element) => element.type === "DropDownPicker",
+ );
+
+ expect(pickers).toHaveLength(2);
+ expect(pickers[0].props).toMatchObject({
+ zIndex: 2000,
+ zIndexInverse: 1000,
+ });
+ expect(pickers[1].props).toMatchObject({
+ zIndex: 1000,
+ zIndexInverse: 2000,
+ });
+ const majorPickerStyle = StyleSheet.flatten(
+ pickers[1].props.style as TextStyle,
+ );
+ const collegePickerStyle = StyleSheet.flatten(
+ pickers[0].props.style as TextStyle,
+ );
+ expect(majorPickerStyle.paddingHorizontal).toBe(
+ collegePickerStyle.paddingHorizontal,
+ );
+ });
+
+ it("dismisses the feedback keyboard and uses a readable placeholder", () => {
+ const dismissSpy = jest.spyOn(Keyboard, "dismiss");
+ const view = UserVoiceView({ closeBottomSheet: jest.fn() });
+ const [input] = findAll(
+ view,
+ (element) =>
+ element.props.placeholder === "여기에 의견을 적어주세요. (1000자 이내)",
+ );
+ const [dismissArea] = findAll(
+ view,
+ (element) =>
+ element.props.accessible === false &&
+ typeof element.props.onPress === "function",
+ );
+
+ expect(input.props).toMatchObject({
+ returnKeyType: "default",
+ submitBehavior: "newline",
+ placeholderTextColor: Colors.BODYTEXT_SUB,
+ });
+ expect(dismissArea).toBeDefined();
+ (dismissArea.props.onPress as () => void)();
+ expect(dismissSpy).toHaveBeenCalled();
+ });
+
+ it("keeps feedback content and its submit button inside the sheet", () => {
+ const view = UserVoiceView({ closeBottomSheet: jest.fn() });
+ const content = view.props.children as ElementWithProps;
+ const contentStyle = StyleSheet.flatten(content.props.style as TextStyle);
+ const [input] = findAll(
+ view,
+ (element) =>
+ element.props.placeholder === "여기에 의견을 적어주세요. (1000자 이내)",
+ );
+ const inputStyle = StyleSheet.flatten(input.props.style as TextStyle);
+ const [submitButton] = findAll(
+ view,
+ (element) =>
+ element.props.disabled === true &&
+ typeof element.props.onPress === "function",
+ );
+ const submitButtonStyle = StyleSheet.flatten(
+ submitButton.props.style as TextStyle,
+ );
+
+ expect(contentStyle.flex).toBe(1);
+ expect(contentStyle.paddingBottom).toBe(vs(8));
+ expect(inputStyle.height).toBe(vs(144));
+ expect(submitButtonStyle.backgroundColor).toBe(Colors.BUTTON_UNSELECTED);
+ expect(submitButton.props.accessibilityState).toEqual({ disabled: true });
+ });
+
+ it("optically centers single-line text on iOS", () => {
+ type TextFieldComponent = (
+ props: TextInputProps & { height?: number },
+ ) => ReactElement<{
+ style: TextStyle;
+ }>;
+ const textFieldComponent = (
+ TextField as unknown as { type: TextFieldComponent }
+ ).type;
+ const input = textFieldComponent({ value: "홍길동", height: 54 });
+ const flattenedStyle = StyleSheet.flatten(input.props.style);
+
+ expect(flattenedStyle.paddingBottom).toBe(Platform.OS === "ios" ? 2 : 0);
+ });
+});
diff --git a/apps/mobile/src/features/register-club/components/FormNavigationButtons.tsx b/apps/mobile/src/features/register-club/components/FormNavigationButtons.tsx
new file mode 100644
index 00000000..c69cfa7a
--- /dev/null
+++ b/apps/mobile/src/features/register-club/components/FormNavigationButtons.tsx
@@ -0,0 +1,113 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ onPrevious?: () => void;
+ onNext?: () => void;
+ nextLabel?: string;
+ previousLabel?: string;
+ isLastStep?: boolean;
+ isNextDisabled?: boolean;
+ /** Step completion ratio (0–1) used to fill the progress bar. */
+ progress?: number;
+};
+
+export const FormNavigationButtons = ({
+ onPrevious,
+ onNext,
+ nextLabel = "다음",
+ previousLabel = "이전",
+ isLastStep = false,
+ isNextDisabled = false,
+ progress = 0,
+}: Props) => {
+ const insets = useSafeAreaInsets();
+ const clampedProgress = Math.max(0, Math.min(1, progress));
+
+ return (
+
+
+
+
+
+
+ {onPrevious && (
+
+ {previousLabel}
+
+ )}
+
+ [
+ styles.nextButton,
+ isNextDisabled && styles.nextButtonDisabled,
+ pressed && !isNextDisabled && styles.nextButtonPressed,
+ ]}
+ onPress={onNext}
+ disabled={isNextDisabled}
+ >
+
+ {isLastStep ? "완료" : nextLabel}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ footer: {
+ backgroundColor: Colors.TEXTBOX_SELECTED,
+ },
+ progressTrack: {
+ height: vs(4),
+ backgroundColor: Colors.TEXTBOX_SELECTED,
+ },
+ progressFill: {
+ height: vs(4),
+ backgroundColor: Colors.BODYTEXT_SUB,
+ borderTopRightRadius: 10,
+ borderBottomRightRadius: 10,
+ },
+ row: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: s(20),
+ paddingVertical: vs(14),
+ },
+ rowNoPrevious: {
+ justifyContent: "flex-end",
+ },
+ previousText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ nextButton: {
+ minWidth: s(110),
+ paddingHorizontal: s(40),
+ paddingVertical: vs(12),
+ borderRadius: 8,
+ backgroundColor: Colors.BUTTON_SELECTED,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ nextButtonDisabled: {
+ backgroundColor: Colors.BUTTON_UNSELECTED,
+ },
+ nextButtonPressed: {
+ backgroundColor: Colors.BUTTON_PUSH,
+ },
+ nextButtonText: {
+ ...typography.headerL,
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+});
diff --git a/apps/mobile/src/features/register-club/components/RegisterClubConfirmModal.tsx b/apps/mobile/src/features/register-club/components/RegisterClubConfirmModal.tsx
new file mode 100644
index 00000000..935b13f1
--- /dev/null
+++ b/apps/mobile/src/features/register-club/components/RegisterClubConfirmModal.tsx
@@ -0,0 +1,121 @@
+import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ visible: boolean;
+ clubName: string;
+ isLoading?: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+};
+
+export const RegisterClubConfirmModal = ({
+ visible,
+ clubName,
+ isLoading = false,
+ onCancel,
+ onConfirm,
+}: Props) => {
+ return (
+
+
+
+
+ {clubName}의 동아리 등록을 요청할까요?
+
+
+ 등록 요청 후 승인까지는{"\n"}최대 일주일이 소요될 수 있어요
+
+
+
+
+ 취소
+
+ [
+ styles.button,
+ styles.confirmButton,
+ pressed && !isLoading && styles.confirmButtonPressed,
+ ]}
+ onPress={onConfirm}
+ disabled={isLoading}
+ >
+ 요청
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_DIM,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: s(20),
+ },
+ card: {
+ width: "100%",
+ backgroundColor: Colors.WHITE,
+ borderRadius: s(20),
+ paddingHorizontal: s(20),
+ paddingVertical: vs(28),
+ alignItems: "center",
+ },
+ title: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_MAIN,
+ textAlign: "center",
+ },
+ message: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ textAlign: "center",
+ marginTop: vs(12),
+ lineHeight: vs(22),
+ },
+ buttonRow: {
+ flexDirection: "row",
+ gap: s(12),
+ marginTop: vs(24),
+ },
+ button: {
+ flex: 1,
+ paddingVertical: vs(14),
+ borderRadius: 12,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ cancelButton: {
+ borderWidth: 1,
+ borderColor: Colors.BUTTON_SELECTED,
+ backgroundColor: Colors.WHITE,
+ },
+ cancelText: {
+ ...typography.headerL,
+ color: Colors.BUTTON_SELECTED,
+ },
+ confirmButton: {
+ backgroundColor: Colors.BUTTON_SELECTED,
+ },
+ confirmButtonPressed: {
+ backgroundColor: Colors.BUTTON_PUSH,
+ },
+ confirmText: {
+ ...typography.headerL,
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+});
diff --git a/apps/mobile/src/features/register-club/hooks/useRegisterClub.ts b/apps/mobile/src/features/register-club/hooks/useRegisterClub.ts
new file mode 100644
index 00000000..c64db5e6
--- /dev/null
+++ b/apps/mobile/src/features/register-club/hooks/useRegisterClub.ts
@@ -0,0 +1,106 @@
+import { useMutation } from "@tanstack/react-query";
+import { useContext } from "react";
+import type { RegisterClubImageFile } from "@/features/register-club/types";
+import type {
+ RegisterClubRequest,
+ RegisterClubResponse,
+} from "@/repositories/club";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+const DEFAULT_ERROR_MESSAGE = "동아리 등록 중 오류가 발생했습니다";
+const MISSING_CLUB_UUID_MESSAGE =
+ "동아리 등록 응답에서 동아리 ID를 찾지 못했어요";
+const IMAGE_UPLOAD_ERROR_MESSAGE =
+ "동아리 등록은 완료됐지만 대표 이미지를 업로드하지 못했어요. 관리 화면에서 다시 등록해주세요";
+const ACTIVITY_IMAGE_UPLOAD_ERROR_MESSAGE =
+ "동아리 등록은 완료됐지만 활동 사진을 업로드하지 못했어요. 관리 화면에서 다시 등록해주세요";
+
+const extractErrorMessage = (error: unknown): string => {
+ if (error instanceof Error) {
+ const axiosError = error as {
+ response?: { status?: number; data?: { message?: string } };
+ };
+ if (axiosError.response?.data?.message) {
+ return axiosError.response.data.message;
+ }
+ if (axiosError.response?.status) {
+ return `Error ${axiosError.response.status}: ${error.message}`;
+ }
+ }
+ return DEFAULT_ERROR_MESSAGE;
+};
+
+type Callbacks = {
+ onSuccess: (message: string) => void;
+ onFailure: (message: string) => void;
+};
+
+type RegisterClubMutationRequest = {
+ request: RegisterClubRequest;
+ imageFile?: RegisterClubImageFile | null;
+ activityImageFiles?: RegisterClubImageFile[];
+};
+
+export const useRegisterClub = ({ onSuccess, onFailure }: Callbacks) => {
+ const { clubService } = useContext(serviceContext);
+
+ return useMutation(
+ async ({
+ request,
+ imageFile,
+ activityImageFiles = [],
+ }: RegisterClubMutationRequest) => {
+ const response = await clubService.registerClub(request);
+
+ if (
+ response?.success !== true ||
+ (!imageFile && activityImageFiles.length === 0)
+ ) {
+ return response;
+ }
+
+ const clubId = response.data?.club?.uuid;
+ if (!clubId) {
+ throw new Error(MISSING_CLUB_UUID_MESSAGE);
+ }
+
+ if (imageFile) {
+ try {
+ await clubService.uploadManagedClubImage({ clubId, ...imageFile });
+ } catch {
+ throw new Error(IMAGE_UPLOAD_ERROR_MESSAGE);
+ }
+ }
+
+ if (activityImageFiles.length > 0) {
+ try {
+ const uploadedImages = await Promise.all(
+ activityImageFiles.map((file) =>
+ clubService.uploadClubActivityImage({ clubId, ...file }),
+ ),
+ );
+ await clubService.updateManagedClub({
+ uuid: clubId,
+ activity_image_urls: uploadedImages.map((image) => image.url),
+ });
+ } catch {
+ throw new Error(ACTIVITY_IMAGE_UPLOAD_ERROR_MESSAGE);
+ }
+ }
+
+ return response;
+ },
+ {
+ onSuccess: (response: RegisterClubResponse) => {
+ if (response?.success === true) {
+ onSuccess(response.message || "동아리 등록이 완료되었습니다!");
+ } else {
+ onFailure(response?.message || DEFAULT_ERROR_MESSAGE);
+ }
+ },
+ onError: (error: unknown) => {
+ onFailure(extractErrorMessage(error));
+ },
+ },
+ );
+};
diff --git a/apps/mobile/src/features/register-club/screens/CategorySelectionScreen/index.tsx b/apps/mobile/src/features/register-club/screens/CategorySelectionScreen/index.tsx
new file mode 100644
index 00000000..264b25b5
--- /dev/null
+++ b/apps/mobile/src/features/register-club/screens/CategorySelectionScreen/index.tsx
@@ -0,0 +1,155 @@
+import {
+ Dimensions,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import type { Category } from "@/entities/category";
+import { FormNavigationButtons } from "@/features/register-club/components/FormNavigationButtons";
+import type { RegisterClubFormData } from "@/features/register-club/types";
+import { CLUB_CATEGORIES } from "@/shared/constants/category";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
+
+const TILE_SIZE = (Dimensions.get("window").width - s(20) * 2 - s(12) * 2) / 3;
+
+type Props = {
+ formData: RegisterClubFormData;
+ onFormDataChange: (data: Partial) => void;
+ onNext: () => void;
+ onPrevious: () => void;
+ progress?: number;
+};
+
+export const CategorySelectionScreen = ({
+ formData,
+ onFormDataChange,
+ onNext,
+ onPrevious,
+ progress,
+}: Props) => {
+ const toggleCategory = (category: Category["name"]) => {
+ const isSelected = formData.selectedCategories.includes(category);
+ const newCategories = isSelected
+ ? formData.selectedCategories.filter((c) => c !== category)
+ : [category];
+ onFormDataChange({ selectedCategories: newCategories });
+ };
+
+ const isComplete = formData.selectedCategories.length > 0;
+
+ return (
+
+
+
+
+ {formData.clubName}
+ 의
+ {"\n"}
+ 카테고리를 선택해주세요
+
+
+
+
+ {CLUB_CATEGORIES.map((category) => {
+ const isSelected = formData.selectedCategories.includes(category);
+ return (
+ toggleCategory(category)}
+ >
+
+ {category}
+
+
+ );
+ })}
+
+
+
+
+ 가장 적절한 한 가지 카테고리를 선택해주세요
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ padding: s(20),
+ paddingBottom: vs(20),
+ },
+ header: {
+ marginBottom: vs(32),
+ },
+ title: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ clubName: {
+ color: Colors.BUTTON_SELECTED,
+ fontWeight: "800",
+ },
+ categoryGrid: {
+ flexDirection: "row",
+ flexWrap: "wrap",
+ gap: s(12),
+ },
+ categoryBlock: {
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ borderRadius: s(12),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ backgroundColor: Colors.WHITE,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ categoryBlockSelected: {
+ backgroundColor: Colors.BUTTON_SELECTED,
+ borderColor: Colors.BUTTON_SELECTED,
+ },
+ footer: {
+ marginTop: vs(16),
+ },
+ helperText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ textAlign: "left",
+ },
+ categoryText: {
+ ...typography.headerXLSemibold,
+ lineHeight: vs(30),
+ color: Colors.BODYTEXT_DISABLED,
+ textAlign: "center",
+ },
+ categoryTextSelected: {
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+});
diff --git a/apps/mobile/src/features/register-club/screens/ClubAffiliationScreen/index.tsx b/apps/mobile/src/features/register-club/screens/ClubAffiliationScreen/index.tsx
new file mode 100644
index 00000000..2580b5b2
--- /dev/null
+++ b/apps/mobile/src/features/register-club/screens/ClubAffiliationScreen/index.tsx
@@ -0,0 +1,298 @@
+import { useQuery } from "@tanstack/react-query";
+import { useContext, useMemo, useState } from "react";
+import {
+ FlatList,
+ Modal,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import MaterialIcons from "react-native-vector-icons/MaterialIcons";
+import { FormNavigationButtons } from "@/features/register-club/components/FormNavigationButtons";
+import type { RegisterClubFormData } from "@/features/register-club/types";
+import TextField from "@/shared/components/TextField";
+import { Colors } from "@/shared/constants/colors";
+import {
+ buildDepartmentOptions,
+ DEFAULT_DEPARTMENT_OPTIONS,
+} from "@/shared/constants/departments";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ formData: RegisterClubFormData;
+ onFormDataChange: (data: Partial) => void;
+ onNext: () => void;
+ onPrevious: () => void;
+ progress?: number;
+};
+
+export const ClubAffiliationScreen = ({
+ formData,
+ onFormDataChange,
+ onNext,
+ onPrevious,
+ progress,
+}: Props) => {
+ const { userService } = useContext(serviceContext);
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [departmentSearch, setDepartmentSearch] = useState("");
+ const isComplete = formData.department.trim() && formData.shortIntro.trim();
+ const { data: departmentOptions = DEFAULT_DEPARTMENT_OPTIONS } = useQuery({
+ queryKey: ["collegeMajors", "clubAffiliation"],
+ queryFn: () => userService.listCollegeMajors({ includeNullMajor: true }),
+ select: (data) => buildDepartmentOptions(data.majors),
+ });
+ const filteredDepartmentOptions = useMemo(() => {
+ const keyword = departmentSearch.trim();
+ if (!keyword) {
+ return departmentOptions;
+ }
+ return departmentOptions.filter((department) =>
+ department.includes(keyword),
+ );
+ }, [departmentOptions, departmentSearch]);
+
+ const handleSelectDepartment = (dept: string) => {
+ onFormDataChange({ department: dept });
+ setShowDropdown(false);
+ setDepartmentSearch("");
+ };
+
+ return (
+
+
+
+
+ {formData.clubName}
+ 의
+ {"\n"}
+ 한줄소개를 완성해주세요
+
+
+ 동아리 소개 최상단에 노출될 간단한 문구예요
+
+
+
+
+ {/* Department Dropdown + 소속 label */}
+
+ setShowDropdown(!showDropdown)}
+ >
+
+ {formData.department || "단과대/학과"}
+
+
+
+ 소속
+
+
+ {/* Short Introduction Input */}
+ onFormDataChange({ shortIntro: text })}
+ maxLength={100}
+ />
+
+
+ 소속을 선택해주세요
+
+ 동아리 주요 활동을 작성해주세요
+
+
+
+
+
+ setShowDropdown(false)}
+ >
+ setShowDropdown(false)}
+ >
+ event.stopPropagation()}
+ >
+ 소속 선택
+
+ dept}
+ renderItem={({ item: dept }) => (
+ handleSelectDepartment(dept)}
+ >
+
+ {dept}
+
+
+ )}
+ ListEmptyComponent={
+ 검색 결과가 없어요
+ }
+ />
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ padding: s(20),
+ paddingBottom: vs(20),
+ },
+ header: {
+ marginBottom: vs(32),
+ },
+ title: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ marginBottom: vs(8),
+ },
+ clubName: {
+ color: Colors.BUTTON_SELECTED,
+ fontWeight: "800",
+ },
+ subtitle: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ form: {
+ gap: vs(12),
+ },
+ affiliationRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(16),
+ },
+ affiliationLabel: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ dropdown: {
+ width: "55%",
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
+ paddingHorizontal: s(16),
+ paddingVertical: vs(14),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ dropdownActive: {
+ borderColor: Colors.BUTTON_SELECTED,
+ },
+ dropdownText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ flex: 1,
+ },
+ dropdownSearchInput: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(10),
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.BACKGROUND_SUB,
+ },
+ dropdownList: {
+ maxHeight: vs(360),
+ },
+ dropdownItem: {
+ paddingHorizontal: s(16),
+ paddingVertical: vs(12),
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.BACKGROUND_SUB,
+ },
+ dropdownItemSelected: {
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ dropdownItemText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ dropdownItemTextSelected: {
+ color: Colors.BUTTON_SELECTED,
+ fontWeight: "600",
+ },
+ dropdownEmptyText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_DISABLED,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(12),
+ },
+ departmentModalOverlay: {
+ flex: 1,
+ backgroundColor: Colors.BACKGROUND_DIM,
+ justifyContent: "center",
+ paddingHorizontal: s(20),
+ },
+ departmentModalCard: {
+ backgroundColor: Colors.WHITE,
+ borderRadius: 12,
+ overflow: "hidden",
+ },
+ departmentModalTitle: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingTop: vs(16),
+ paddingBottom: vs(8),
+ },
+ validationGroup: {
+ gap: vs(8),
+ },
+ validationText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ },
+});
diff --git a/apps/mobile/src/features/register-club/screens/ClubBasicInfoScreen/index.tsx b/apps/mobile/src/features/register-club/screens/ClubBasicInfoScreen/index.tsx
new file mode 100644
index 00000000..406d2b18
--- /dev/null
+++ b/apps/mobile/src/features/register-club/screens/ClubBasicInfoScreen/index.tsx
@@ -0,0 +1,184 @@
+import {
+ Image,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { launchImageLibrary } from "react-native-image-picker";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Toast from "react-native-toast-message";
+import Icon from "react-native-vector-icons/MaterialIcons";
+import { FormNavigationButtons } from "@/features/register-club/components/FormNavigationButtons";
+import type { RegisterClubFormData } from "@/features/register-club/types";
+import TextField from "@/shared/components/TextField";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ formData: RegisterClubFormData;
+ onFormDataChange: (data: Partial) => void;
+ onNext: () => void;
+ onPrevious: () => void;
+ progress?: number;
+};
+
+export const ClubBasicInfoScreen = ({
+ formData,
+ onFormDataChange,
+ onNext,
+ onPrevious,
+ progress,
+}: Props) => {
+ const isComplete = formData.clubName.trim();
+
+ const handleAddImage = async () => {
+ const result = await launchImageLibrary({
+ mediaType: "photo",
+ selectionLimit: 1,
+ includeBase64: false,
+ });
+
+ if (result.didCancel) {
+ return;
+ }
+
+ if (result.errorCode) {
+ Toast.show({
+ type: "error",
+ text1: "이미지를 불러오지 못했어요",
+ text2: result.errorMessage,
+ position: "top",
+ topOffset: 60,
+ visibilityTime: 2000,
+ });
+ return;
+ }
+
+ const asset = result.assets?.[0];
+ if (asset?.uri) {
+ onFormDataChange({
+ clubImage: asset.uri,
+ clubImageFile: {
+ uri: asset.uri,
+ type: asset.type ?? "image/jpeg",
+ name: asset.fileName ?? `club_${Date.now()}.jpg`,
+ },
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ 신규 등록할
+ {"\n"}
+ 동아리 기본 정보를 입력해주세요
+
+
+
+
+
+ 동아리 대표 이미지
+
+ 4X4 정방형 이미지를 권장드려요
+
+
+ {formData.clubImage ? (
+
+ ) : (
+
+ )}
+
+ {!formData.clubImage && (
+
+ 대표 이미지를 등록해주세요
+
+ )}
+
+
+
+ 동아리명
+ onFormDataChange({ clubName: text })}
+ maxLength={100}
+ />
+ 동아리명을 입력해주세요
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ padding: s(20),
+ paddingBottom: vs(20),
+ },
+ header: {
+ marginBottom: vs(32),
+ },
+ title: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ form: {
+ gap: vs(28),
+ },
+ fieldWrapper: {
+ gap: vs(8),
+ },
+ label: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ },
+ helperDescription: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ validationText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ },
+ imageUploadBox: {
+ width: s(150),
+ height: s(150),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: s(12),
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: Colors.WHITE,
+ marginTop: vs(4),
+ },
+ uploadedImage: {
+ width: "100%",
+ height: "100%",
+ borderRadius: s(11),
+ },
+});
diff --git a/apps/mobile/src/features/register-club/screens/ClubDetailsScreen/index.tsx b/apps/mobile/src/features/register-club/screens/ClubDetailsScreen/index.tsx
new file mode 100644
index 00000000..f112cf05
--- /dev/null
+++ b/apps/mobile/src/features/register-club/screens/ClubDetailsScreen/index.tsx
@@ -0,0 +1,435 @@
+import { useState } from "react";
+import {
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ TextInput,
+ View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import MaterialIcons from "react-native-vector-icons/MaterialIcons";
+import { FormNavigationButtons } from "@/features/register-club/components/FormNavigationButtons";
+import type { RegisterClubFormData } from "@/features/register-club/types";
+import { areValidSnsUrls } from "@/features/register-club/validation";
+import ClubActivityImagePicker from "@/shared/components/ClubActivityImagePicker";
+import ClubSnsInputList from "@/shared/components/ClubSnsInputList";
+import { CLUB_RECRUIT_TYPES } from "@/shared/constants/club";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import {
+ decrementMinActivityPeriodValue,
+ incrementMinActivityPeriodValue,
+ type MinActivityPeriodMode,
+} from "@/shared/utils/minActivityPeriod";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ formData: RegisterClubFormData;
+ onFormDataChange: (data: Partial) => void;
+ onComplete: () => void;
+ onPrevious: () => void;
+ isLoading?: boolean;
+ progress?: number;
+};
+
+export const ClubDetailsScreen = ({
+ formData,
+ onFormDataChange,
+ onComplete,
+ onPrevious,
+ isLoading = false,
+ progress,
+}: Props) => {
+ const [minActivityPeriodMode, setMinActivityPeriodMode] =
+ useState(
+ formData.minActivityPeriodInput ? "number" : "none",
+ );
+
+ const minActivityPeriodSemesters =
+ parseInt(formData.minActivityPeriodInput, 10) || 0;
+
+ const setHasDongbang = (value: boolean) => {
+ onFormDataChange(
+ value
+ ? { hasDongbang: true }
+ : { hasDongbang: false, dongbangLocation: "" },
+ );
+ };
+
+ const handleMinActivityPeriodModeChange = (mode: MinActivityPeriodMode) => {
+ setMinActivityPeriodMode(mode);
+ onFormDataChange({
+ minActivityPeriodInput: mode === "number" ? "1" : "",
+ });
+ };
+
+ const incrementMinActivityPeriod = () => {
+ const next = incrementMinActivityPeriodValue(minActivityPeriodSemesters);
+ setMinActivityPeriodMode(next.mode);
+ onFormDataChange({ minActivityPeriodInput: next.value });
+ };
+
+ const decrementMinActivityPeriod = () => {
+ const next = decrementMinActivityPeriodValue(minActivityPeriodSemesters);
+ setMinActivityPeriodMode(next.mode);
+ onFormDataChange({ minActivityPeriodInput: next.value });
+ };
+
+ const isComplete =
+ formData.recruitType.trim() &&
+ areValidSnsUrls(formData.clubSNSUrls) &&
+ formData.clubDescription.trim() &&
+ (minActivityPeriodMode === "none" ||
+ formData.minActivityPeriodInput.trim());
+
+ return (
+
+
+
+
+ {formData.clubName}
+ 의
+ {"\n"}
+ 세부정보를 입력해주세요
+
+
+ 관리하기 탭에서 언제든 수정할 수 있어요
+
+
+
+
+ {/* Recruitment Type */}
+
+ 모집 형태
+
+ {CLUB_RECRUIT_TYPES.map((type) => (
+ onFormDataChange({ recruitType: type })}
+ >
+
+ {type}
+
+
+ ))}
+
+ 모집 형태를 선택해주세요
+
+
+ {/* Activity Period */}
+
+ (최소) 활동 기간
+
+
+ handleMinActivityPeriodModeChange("none")}
+ >
+
+ 없음
+
+
+ handleMinActivityPeriodModeChange("number")}
+ >
+
+ 있음
+
+
+
+
+
+
+ {minActivityPeriodSemesters}학기
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Dongbang */}
+
+ 동방 보유 여부
+
+ setHasDongbang(true)}
+ >
+
+ 보유
+
+
+ setHasDongbang(false)}
+ >
+
+ 미보유
+
+
+
+
+ {formData.hasDongbang && (
+
+
+
+ onFormDataChange({ dongbangLocation: text })
+ }
+ maxLength={100}
+ />
+
+ )}
+
+ 동방 보유 여부를 알려주세요
+
+
+
+ onFormDataChange({ clubSNSUrls })}
+ />
+
+ onFormDataChange({ activityImages })}
+ />
+
+ {/* Description */}
+
+ 동아리 추가 설명
+
+ onFormDataChange({ clubDescription: text })
+ }
+ maxLength={500}
+ multiline
+ textAlignVertical="top"
+ />
+
+ 동아리 추가 설명은 필수 입력 정보예요.
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ padding: s(20),
+ paddingBottom: vs(20),
+ },
+ header: {
+ marginBottom: vs(32),
+ },
+ title: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ marginBottom: vs(8),
+ },
+ clubName: {
+ color: Colors.BUTTON_SELECTED,
+ fontWeight: "800",
+ },
+ subtitle: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ },
+ form: {
+ gap: vs(24),
+ },
+ fieldWrapper: {
+ gap: vs(8),
+ },
+ fieldLabel: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ },
+ buttonGroup: {
+ flexDirection: "row",
+ gap: s(8),
+ },
+ typeButton: {
+ flex: 1,
+ paddingVertical: vs(14),
+ paddingHorizontal: s(12),
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ backgroundColor: Colors.WHITE,
+ alignItems: "center",
+ },
+ typeButtonSelected: {
+ backgroundColor: Colors.BUTTON_SELECTED,
+ borderColor: Colors.BUTTON_SELECTED,
+ },
+ typeButtonText: {
+ ...typography.bodyMMedium,
+ color: Colors.BODYTEXT_DISABLED,
+ },
+ typeButtonTextSelected: {
+ color: Colors.WHITE,
+ fontWeight: "600",
+ },
+ periodRow: {
+ flexDirection: "row",
+ alignItems: "flex-end",
+ gap: s(16),
+ },
+ modeButtons: {
+ flex: 1,
+ flexDirection: "row",
+ gap: s(8),
+ },
+ periodStepper: {
+ alignItems: "center",
+ gap: vs(6),
+ },
+ semesterValue: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ stepperPill: {
+ flexDirection: "row",
+ alignItems: "center",
+ borderRadius: 8,
+ backgroundColor: Colors.BACKGROUND_SUB,
+ },
+ stepperButton: {
+ paddingHorizontal: s(16),
+ paddingVertical: vs(8),
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ stepperDivider: {
+ width: 1,
+ height: vs(16),
+ backgroundColor: Colors.BODYTEXT_DISABLED,
+ },
+ iconInputRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(8),
+ },
+ iconInput: {
+ flex: 1,
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(8),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ descriptionInput: {
+ minHeight: vs(60),
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(12),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ validationText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ },
+});
diff --git a/apps/mobile/src/features/register-club/screens/ManagerInfoScreen/index.tsx b/apps/mobile/src/features/register-club/screens/ManagerInfoScreen/index.tsx
new file mode 100644
index 00000000..af9b06bb
--- /dev/null
+++ b/apps/mobile/src/features/register-club/screens/ManagerInfoScreen/index.tsx
@@ -0,0 +1,149 @@
+import { ScrollView, StyleSheet, Text, View } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { FormNavigationButtons } from "@/features/register-club/components/FormNavigationButtons";
+import type { RegisterClubFormData } from "@/features/register-club/types";
+import {
+ isValidPhoneNumber,
+ isValidStudentId,
+} from "@/features/register-club/validation";
+import TextField from "@/shared/components/TextField";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import {
+ formatPhoneNumberInput,
+ formatStudentIdInput,
+} from "@/shared/utils/managerInfo";
+import { s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ formData: RegisterClubFormData;
+ onFormDataChange: (data: Partial) => void;
+ onNext: () => void;
+ onPrevious?: () => void;
+ progress?: number;
+};
+
+export const ManagerInfoScreen = ({
+ formData,
+ onFormDataChange,
+ onNext,
+ onPrevious,
+ progress,
+}: Props) => {
+ const isComplete =
+ formData.managerName.trim() !== "" &&
+ isValidPhoneNumber(formData.managerPhone) &&
+ isValidStudentId(formData.studentId);
+
+ return (
+
+
+
+ 운영진 기본 정보를
+ 입력해주세요
+
+
+
+
+ 이름
+ onFormDataChange({ managerName: text })}
+ maxLength={50}
+ />
+ 이름을 입력해주세요
+
+
+
+ 전화번호
+
+ onFormDataChange({
+ managerPhone: formatPhoneNumberInput(
+ text,
+ formData.managerPhone,
+ ),
+ })
+ }
+ keyboardType="number-pad"
+ maxLength={13}
+ />
+
+ 올바른 전화번호 형식으로 입력해주세요
+
+
+
+
+ 학번
+
+ onFormDataChange({
+ studentId: formatStudentIdInput(text, formData.studentId),
+ })
+ }
+ keyboardType="number-pad"
+ maxLength={10}
+ />
+
+ 2023-12345 형식으로 입력해주세요
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ paddingHorizontal: s(20),
+ paddingTop: vs(20),
+ paddingBottom: vs(20),
+ },
+ header: {
+ marginBottom: vs(35),
+ },
+ title: {
+ ...typography.headerXXL,
+ color: Colors.BODYTEXT_MAIN,
+ },
+ form: {
+ gap: vs(20),
+ },
+ fieldWrapper: {},
+ label: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ marginBottom: vs(10),
+ },
+ helperText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ marginTop: vs(5),
+ },
+ input: {
+ paddingHorizontal: s(15),
+ },
+});
diff --git a/apps/mobile/src/features/register-club/screens/RegisterClubScreenContainer/index.tsx b/apps/mobile/src/features/register-club/screens/RegisterClubScreenContainer/index.tsx
new file mode 100644
index 00000000..c7958150
--- /dev/null
+++ b/apps/mobile/src/features/register-club/screens/RegisterClubScreenContainer/index.tsx
@@ -0,0 +1,220 @@
+import React, { useEffect, useState } from "react";
+import Toast from "react-native-toast-message";
+import type { User } from "@/entities/user";
+import { RegisterClubConfirmModal } from "@/features/register-club/components/RegisterClubConfirmModal";
+import { useRegisterClub } from "@/features/register-club/hooks/useRegisterClub";
+import { CategorySelectionScreen } from "@/features/register-club/screens/CategorySelectionScreen";
+import { ClubAffiliationScreen } from "@/features/register-club/screens/ClubAffiliationScreen";
+import { ClubBasicInfoScreen } from "@/features/register-club/screens/ClubBasicInfoScreen";
+import { ClubDetailsScreen } from "@/features/register-club/screens/ClubDetailsScreen";
+import { ManagerInfoScreen } from "@/features/register-club/screens/ManagerInfoScreen";
+import {
+ initialFormData,
+ type RegisterClubFormData,
+} from "@/features/register-club/types";
+import { getSnsRequestFields } from "@/features/register-club/validation";
+import type { RegisterClubRequest } from "@/repositories/club";
+import { useLoginBottomSheet } from "@/shared/contexts/loginBottomSheetContext";
+import { useProfile } from "@/shared/contexts/profileContext";
+import {
+ getPhoneNumberPrefill,
+ getStudentIdPrefill,
+} from "@/shared/utils/managerInfo";
+import { navigation } from "@/shared/utils/navigation";
+
+const getInitialFormData = (user: User | null): RegisterClubFormData => ({
+ ...initialFormData,
+ managerName: user?.name ?? "",
+ managerPhone: getPhoneNumberPrefill(user?.phone ?? ""),
+ studentId: getStudentIdPrefill(user?.admissionClass ?? null),
+});
+
+const mapFormDataToRequest = (
+ formData: RegisterClubFormData,
+): RegisterClubRequest => {
+ const clubData: RegisterClubRequest["club_data"] = {
+ name: formData.clubName,
+ type: formData.clubType,
+ category: formData.selectedCategories[0] || "",
+ affiliation: formData.department,
+ short_description: formData.shortIntro,
+ recruit_type: formData.recruitType,
+ min_activity_period: parseInt(formData.minActivityPeriodInput, 10) || 0,
+ has_dongbang: formData.hasDongbang,
+ ...getSnsRequestFields(formData.clubSNSUrls),
+ introduction: formData.clubDescription,
+ activity_image_urls: [],
+ };
+
+ // Send dongbang_location (API key) only when the club has a dongbang
+ if (formData.hasDongbang && formData.dongbangLocation) {
+ clubData.dongbang_location = formData.dongbangLocation;
+ }
+
+ return {
+ club_data: clubData,
+ manager_data: {
+ name: formData.managerName,
+ phone: formData.managerPhone,
+ student_id: formData.studentId,
+ },
+ };
+};
+
+export const RegisterClubScreenContainer = () => {
+ const { user } = useProfile();
+ const [currentStep, setCurrentStep] = useState(0);
+ const [formData, setFormData] = useState(() =>
+ getInitialFormData(user),
+ );
+ const [showConfirm, setShowConfirm] = useState(false);
+ const { openBottomSheet: openLoginSheet } = useLoginBottomSheet();
+
+ useEffect(() => {
+ if (!user) {
+ return;
+ }
+
+ const managerPhone = getPhoneNumberPrefill(user.phone);
+ const studentId = getStudentIdPrefill(user.admissionClass);
+ setFormData((prev) => ({
+ ...prev,
+ managerName: prev.managerName || user.name,
+ managerPhone: prev.managerPhone || managerPhone,
+ studentId: prev.studentId || studentId,
+ }));
+ }, [user]);
+
+ // 등록 요청 후에는 성공/실패와 무관하게 폼을 초기화하고 마이페이지 메인으로 돌아간다.
+ const resetAndExit = () => {
+ setShowConfirm(false);
+ setFormData(getInitialFormData(user));
+ setCurrentStep(0);
+ navigation.navigate("마이");
+ };
+
+ const { mutate: registerClub, isLoading } = useRegisterClub({
+ onSuccess: (message) => {
+ Toast.show({
+ type: "success",
+ text1: message,
+ position: "top",
+ topOffset: 60,
+ visibilityTime: 2000,
+ });
+ resetAndExit();
+ },
+ onFailure: (message) => {
+ Toast.show({
+ type: "error",
+ text1: "동아리 등록 요청 중 문제가 생겼어요",
+ text2: message,
+ position: "top",
+ topOffset: 60,
+ visibilityTime: 2000,
+ });
+ resetAndExit();
+ },
+ });
+
+ const handleFormDataChange = (data: Partial) => {
+ setFormData((prev) => ({ ...prev, ...data }));
+ };
+
+ const handleNext = () => {
+ setCurrentStep((prev) => Math.min(prev + 1, 4));
+ };
+
+ const handlePrevious = () => {
+ setCurrentStep((prev) => Math.max(prev - 1, 0));
+ };
+
+ // 이전 on the first step exits the full-screen flow back to the MyPage tab.
+ const handleExit = () => {
+ setCurrentStep(0);
+ navigation.navigate("마이");
+ };
+
+ const handleOpenConfirm = () => {
+ // Check if user is logged in before showing the confirmation
+ if (!user) {
+ Toast.show({
+ type: "error",
+ text1: "로그인이 필요해요",
+ text2: "동아리를 등록하려면 로그인해주세요",
+ position: "top",
+ topOffset: 60,
+ visibilityTime: 2000,
+ });
+ openLoginSheet();
+ return;
+ }
+ setShowConfirm(true);
+ };
+
+ const handleComplete = () => {
+ registerClub({
+ request: mapFormDataToRequest(formData),
+ imageFile: formData.clubImageFile,
+ activityImageFiles: formData.activityImages.flatMap((image) =>
+ image.file ? [image.file] : [],
+ ),
+ });
+ };
+
+ const screens = [
+ ,
+ ,
+ ,
+ ,
+ ,
+ ];
+
+ // 6 stages total: the 5 form steps plus the final confirmation popup,
+ // so the bar is never fully filled while a form step is on screen.
+ const progress = (currentStep + 1) / (screens.length + 1);
+
+ return (
+ <>
+ {React.cloneElement(screens[currentStep], { progress })}
+ setShowConfirm(false)}
+ onConfirm={handleComplete}
+ />
+ >
+ );
+};
diff --git a/apps/mobile/src/features/register-club/types.test.ts b/apps/mobile/src/features/register-club/types.test.ts
new file mode 100644
index 00000000..b00d517a
--- /dev/null
+++ b/apps/mobile/src/features/register-club/types.test.ts
@@ -0,0 +1,17 @@
+import { initialFormData } from "./types";
+
+describe("club registration form fields", () => {
+ it("collects up to three SNS URLs starting with one input", () => {
+ expect(initialFormData).toHaveProperty("clubSNSUrls", [""]);
+ expect(initialFormData).not.toHaveProperty("clubSNS");
+ });
+
+ it("does not collect founded date or active member count", () => {
+ expect(initialFormData).not.toHaveProperty("foundedAt");
+ expect(initialFormData).not.toHaveProperty("activeMemberCount");
+ });
+
+ it("continues to collect optional activity images", () => {
+ expect(initialFormData).toHaveProperty("activityImages", []);
+ });
+});
diff --git a/apps/mobile/src/features/register-club/types.ts b/apps/mobile/src/features/register-club/types.ts
new file mode 100644
index 00000000..682daf11
--- /dev/null
+++ b/apps/mobile/src/features/register-club/types.ts
@@ -0,0 +1,54 @@
+import type { Category } from "@/entities/category";
+import type { EditableImage, ImageFile } from "@/shared/types/image";
+
+export type RegisterClubImageFile = ImageFile;
+
+export type RegisterClubFormData = {
+ clubType: "교내" | "교외";
+
+ // Page 1: Manager basic info
+ managerName: string;
+ managerPhone: string;
+ studentId: string;
+
+ // Page 2: Club basic info
+ clubImage: string | null;
+ clubImageFile: RegisterClubImageFile | null;
+ clubName: string;
+
+ // Page 3: Categories
+ selectedCategories: Category["name"][];
+
+ // Page 4: Club intro & affiliation
+ department: string;
+ shortIntro: string;
+
+ // Page 5: Club details
+ recruitType: string;
+ minActivityPeriodInput: string;
+ hasDongbang: boolean;
+ dongbangLocation: string;
+ clubSNSUrls: string[];
+ activityImages: EditableImage[];
+ clubDescription: string;
+};
+
+export const initialFormData: RegisterClubFormData = {
+ clubType: "교내",
+ managerName: "",
+ managerPhone: "",
+ studentId: "",
+ clubImage: null,
+ clubImageFile: null,
+ clubName: "",
+ selectedCategories: [],
+ department: "",
+ shortIntro: "",
+ recruitType: "",
+ minActivityPeriodInput: "",
+ hasDongbang: false,
+ dongbangLocation: "",
+ clubSNSUrls: [""],
+ activityImages: [],
+ clubDescription: "",
+};
diff --git a/apps/mobile/src/features/register-club/validation.test.ts b/apps/mobile/src/features/register-club/validation.test.ts
new file mode 100644
index 00000000..fed1bac7
--- /dev/null
+++ b/apps/mobile/src/features/register-club/validation.test.ts
@@ -0,0 +1,46 @@
+import {
+ areValidSnsUrls,
+ getSnsRequestFields,
+ normalizeSnsUrls,
+} from "./validation";
+
+describe("SNS URL validation", () => {
+ it("normalizes every SNS URL", () => {
+ expect(
+ normalizeSnsUrls(["instagram.com/club", " https://youtube.com/@club "]),
+ ).toEqual(["https://instagram.com/club", "https://youtube.com/@club"]);
+ });
+
+ it("accepts one to three valid SNS URLs", () => {
+ expect(areValidSnsUrls(["instagram.com/club"])).toBe(true);
+ expect(
+ areValidSnsUrls([
+ "instagram.com/club",
+ "youtube.com/@club",
+ "facebook.com/club",
+ ]),
+ ).toBe(true);
+ });
+
+ it("sends only the SNS URL list", () => {
+ expect(
+ getSnsRequestFields(["instagram.com/club", "youtube.com/@club"]),
+ ).toEqual({
+ sns_urls: ["https://instagram.com/club", "https://youtube.com/@club"],
+ });
+ });
+
+ it("rejects empty, invalid, or more than three SNS URLs", () => {
+ expect(areValidSnsUrls([])).toBe(false);
+ expect(areValidSnsUrls([""])).toBe(false);
+ expect(areValidSnsUrls(["instagram.com/club", "invalid url"])).toBe(false);
+ expect(
+ areValidSnsUrls([
+ "instagram.com/club",
+ "youtube.com/@club",
+ "facebook.com/club",
+ "x.com/club",
+ ]),
+ ).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/register-club/validation.ts b/apps/mobile/src/features/register-club/validation.ts
new file mode 100644
index 00000000..bc92ab32
--- /dev/null
+++ b/apps/mobile/src/features/register-club/validation.ts
@@ -0,0 +1,16 @@
+export {
+ areValidSnsUrls,
+ getSnsRequestFields,
+ isValidUrl,
+ MAX_CLUB_SNS_URLS,
+ normalizeSnsUrls,
+ normalizeUrl,
+} from "@/shared/utils/clubSns";
+
+// 01012345678 형식 (하이픈은 허용하되 제거 후 검사)
+export const isValidPhoneNumber = (phone: string): boolean =>
+ /^01[0-9]\d{8}$/.test(phone.replace(/-/g, ""));
+
+// 2023-12345 형식
+export const isValidStudentId = (studentId: string): boolean =>
+ /^\d{4}-\d{5}$/.test(studentId.trim());
diff --git a/apps/mobile/src/features/search/hooks/useClubSearchForm.ts b/apps/mobile/src/features/search/hooks/useClubSearchForm.ts
new file mode 100644
index 00000000..adba7a27
--- /dev/null
+++ b/apps/mobile/src/features/search/hooks/useClubSearchForm.ts
@@ -0,0 +1,48 @@
+import { useReducer } from "react";
+import type {
+ ClubSearchFilters,
+ ClubSearchForm,
+} from "@/features/search/types/clubSearchForm";
+import {
+ DEFAULT_CLUB_SEARCH_FILTERS,
+ DEFAULT_CLUB_SEARCH_FORM,
+} from "@/features/search/types/clubSearchForm";
+
+type Action =
+ | { type: "set_query"; value: string }
+ | { type: "set_filters"; value: ClubSearchFilters }
+ | { type: "reset_filters" };
+
+const reducer = (state: ClubSearchForm, action: Action): ClubSearchForm => {
+ switch (action.type) {
+ case "set_query":
+ return {
+ ...state,
+ query: action.value,
+ };
+ case "set_filters":
+ return {
+ ...state,
+ filters: action.value,
+ };
+ case "reset_filters":
+ return {
+ ...state,
+ filters: DEFAULT_CLUB_SEARCH_FILTERS,
+ };
+ default:
+ return state;
+ }
+};
+
+export const useClubSearchForm = () => {
+ const [form, dispatch] = useReducer(reducer, DEFAULT_CLUB_SEARCH_FORM);
+
+ return {
+ form,
+ setQuery: (value: string) => dispatch({ type: "set_query", value }),
+ setFilters: (value: ClubSearchFilters) =>
+ dispatch({ type: "set_filters", value }),
+ resetFilters: () => dispatch({ type: "reset_filters" }),
+ };
+};
diff --git a/src/features/search/types/clubSearchForm.ts b/apps/mobile/src/features/search/types/clubSearchForm.ts
similarity index 58%
rename from src/features/search/types/clubSearchForm.ts
rename to apps/mobile/src/features/search/types/clubSearchForm.ts
index b5edf6ae..68b7c4a8 100644
--- a/src/features/search/types/clubSearchForm.ts
+++ b/apps/mobile/src/features/search/types/clubSearchForm.ts
@@ -4,49 +4,54 @@ import type {
ClubSearchMinActivityPeriod,
ClubSearchRecruitType,
SearchClubsRequest,
-} from '@/repositories/club'
+} from "@/repositories/club";
-type SelectableClubSearchAffiliationType = Exclude
+type SelectableClubSearchAffiliationType = Exclude<
+ ClubSearchAffiliationType,
+ "전체"
+>;
export type ClubSearchFilters = {
- affiliation_types: SelectableClubSearchAffiliationType[]
- is_recruiting?: ClubSearchBooleanString
- recruit_type?: ClubSearchRecruitType
- has_membership_fee?: ClubSearchBooleanString
- has_dongbang?: ClubSearchBooleanString
- is_official_verified?: ClubSearchBooleanString
- min_activity_period: ClubSearchMinActivityPeriod[]
-}
+ affiliation_types: SelectableClubSearchAffiliationType[];
+ is_recruiting?: ClubSearchBooleanString;
+ recruit_type?: ClubSearchRecruitType;
+ has_membership_fee?: ClubSearchBooleanString;
+ has_dongbang?: ClubSearchBooleanString;
+ is_official_verified?: ClubSearchBooleanString;
+ min_activity_period: ClubSearchMinActivityPeriod[];
+};
export type ClubSearchForm = {
- query: string
- filters: ClubSearchFilters
-}
+ query: string;
+ filters: ClubSearchFilters;
+};
export const DEFAULT_CLUB_SEARCH_FILTERS: ClubSearchFilters = {
affiliation_types: [],
min_activity_period: [],
-}
+};
export const DEFAULT_CLUB_SEARCH_FORM: ClubSearchForm = {
- query: '',
+ query: "",
filters: DEFAULT_CLUB_SEARCH_FILTERS,
-}
+};
export const normalizeClubSearchFilters = (
filters: Partial | undefined,
): ClubSearchFilters => ({
...DEFAULT_CLUB_SEARCH_FILTERS,
...filters,
- affiliation_types: filters?.affiliation_types ?? DEFAULT_CLUB_SEARCH_FILTERS.affiliation_types,
+ affiliation_types:
+ filters?.affiliation_types ?? DEFAULT_CLUB_SEARCH_FILTERS.affiliation_types,
min_activity_period:
- filters?.min_activity_period ?? DEFAULT_CLUB_SEARCH_FILTERS.min_activity_period,
-})
+ filters?.min_activity_period ??
+ DEFAULT_CLUB_SEARCH_FILTERS.min_activity_period,
+});
export const resetClubSearchOverlayFilters = (
filters: Partial | undefined,
): ClubSearchFilters => {
- const normalizedFilters = normalizeClubSearchFilters(filters)
+ const normalizedFilters = normalizeClubSearchFilters(filters);
return {
...normalizedFilters,
@@ -55,15 +60,18 @@ export const resetClubSearchOverlayFilters = (
has_dongbang: undefined,
is_official_verified: undefined,
min_activity_period: DEFAULT_CLUB_SEARCH_FILTERS.min_activity_period,
- }
-}
+ };
+};
-export const createSearchClubsRequest = (form: ClubSearchForm): SearchClubsRequest => {
- const query = form.query.trim()
- const normalizedFilters = normalizeClubSearchFilters(form.filters)
- const { affiliation_types } = normalizedFilters
+export const createSearchClubsRequest = (
+ form: ClubSearchForm,
+): SearchClubsRequest => {
+ const query = form.query.trim();
+ const normalizedFilters = normalizeClubSearchFilters(form.filters);
+ const { affiliation_types } = normalizedFilters;
- const affiliation_type = affiliation_types.length === 1 ? affiliation_types[0] : undefined
+ const affiliation_type =
+ affiliation_types.length === 1 ? affiliation_types[0] : undefined;
return {
query,
@@ -77,5 +85,5 @@ export const createSearchClubsRequest = (form: ClubSearchForm): SearchClubsReque
normalizedFilters.min_activity_period.length > 0
? normalizedFilters.min_activity_period
: undefined,
- }
-}
+ };
+};
diff --git a/src/features/webview/screens/WebviewScreen/index.tsx b/apps/mobile/src/features/webview/screens/WebviewScreen/index.tsx
similarity index 53%
rename from src/features/webview/screens/WebviewScreen/index.tsx
rename to apps/mobile/src/features/webview/screens/WebviewScreen/index.tsx
index 03b7917a..4d9b7cfe 100644
--- a/src/features/webview/screens/WebviewScreen/index.tsx
+++ b/apps/mobile/src/features/webview/screens/WebviewScreen/index.tsx
@@ -1,29 +1,29 @@
-import { NativeStackScreenProps } from '@react-navigation/native-stack'
-import { Colors } from '@/shared/constants/colors'
-import { SCREEN_TYPE, StackParamList } from '@/shared/constants/screen'
-import { navigation } from '@/shared/utils/navigation'
-import Header from '@/shared/components/BackHeader'
-import React, { useState } from 'react'
-import { ActivityIndicator, StyleSheet, View } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import WebView, { WebViewMessageEvent } from 'react-native-webview'
+import type { NativeStackScreenProps } from "@react-navigation/native-stack";
+import { useState } from "react";
+import { ActivityIndicator, StyleSheet, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import WebView, { type WebViewMessageEvent } from "react-native-webview";
+import Header from "@/shared/components/BackHeader";
+import { Colors } from "@/shared/constants/colors";
+import type { SCREEN_TYPE, StackParamList } from "@/shared/constants/screen";
+import { navigation } from "@/shared/utils/navigation";
-type WebviewType = NativeStackScreenProps
+type WebviewType = NativeStackScreenProps;
const WebViewScreen = ({ route }: WebviewType) => {
- const { uri, title = '', authorization } = route.params
- const insets = useSafeAreaInsets()
+ const { uri, title = "", authorization } = route.params;
+ const insets = useSafeAreaInsets();
- const [isLoading, setLoading] = useState(true)
+ const [isLoading, setLoading] = useState(true);
const onMessage = (e: WebViewMessageEvent) => {
- const event = JSON.parse(e.nativeEvent.data)
+ const event = JSON.parse(e.nativeEvent.data);
switch (event.method) {
- case 'CLOSE_WEBVIEW':
- return navigation.goBack()
+ case "CLOSE_WEBVIEW":
+ return navigation.goBack();
}
- }
+ };
return (
{
paddingLeft: insets.left,
paddingRight: insets.right,
},
- ]}>
+ ]}
+ >
navigation.goBack()} />
{isLoading && (
@@ -46,7 +47,9 @@ const WebViewScreen = ({ route }: WebviewType) => {
style={styles.webview}
source={{
uri,
- headers: authorization ? { 'x-authorization': `Bearer ${authorization}` } : {},
+ headers: authorization
+ ? { "x-authorization": `Bearer ${authorization}` }
+ : {},
}}
onLoadStart={() => setLoading(true)}
onLoadEnd={() => setLoading(false)}
@@ -61,10 +64,10 @@ const WebViewScreen = ({ route }: WebviewType) => {
overScrollMode="never"
/>
- )
-}
+ );
+};
-export default WebViewScreen
+export default WebViewScreen;
const styles = StyleSheet.create({
container: {
@@ -75,12 +78,12 @@ const styles = StyleSheet.create({
flex: 1,
},
loadingContainer: {
- position: 'absolute',
+ position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
- alignItems: 'center',
- justifyContent: 'center',
+ alignItems: "center",
+ justifyContent: "center",
},
-})
+});
diff --git a/apps/mobile/src/repositories/announcement.ts b/apps/mobile/src/repositories/announcement.ts
new file mode 100644
index 00000000..d0fb6df9
--- /dev/null
+++ b/apps/mobile/src/repositories/announcement.ts
@@ -0,0 +1,27 @@
+import type { Announcement } from "@/entities/announcement";
+import { apiConnector } from "@/shared/utils/api";
+
+export type ListAnnouncementsResponse = {
+ data: Announcement[];
+};
+
+export type DismissAnnouncementsRequest = {
+ announcementUuids: Announcement["uuid"][];
+};
+
+export type AnnouncementRepository = {
+ listAnnouncements: () => Promise;
+ dismissAnnouncements: (request: DismissAnnouncementsRequest) => Promise;
+};
+
+export const getAnnouncementRepository = (): AnnouncementRepository => ({
+ listAnnouncements: async () => {
+ const response =
+ await apiConnector.get("/v2/announcements");
+
+ return response;
+ },
+ dismissAnnouncements: async (request) => {
+ await apiConnector.post("/v2/announcements/dismiss", request);
+ },
+});
diff --git a/src/repositories/appVersion.ts b/apps/mobile/src/repositories/appVersion.ts
similarity index 50%
rename from src/repositories/appVersion.ts
rename to apps/mobile/src/repositories/appVersion.ts
index a00323de..00c1cc08 100644
--- a/src/repositories/appVersion.ts
+++ b/apps/mobile/src/repositories/appVersion.ts
@@ -1,21 +1,21 @@
-import { ClientType, VersionCheckResult } from '@/entities/appVersion'
-import { apiConnector } from '@/shared/utils/api'
+import type { ClientType, VersionCheckResult } from "@/entities/appVersion";
+import { apiConnector } from "@/shared/utils/api";
export type CheckVersionRequest = {
- clientType: ClientType
- appVersion: string
-}
+ clientType: ClientType;
+ appVersion: string;
+};
export type AppVersionRepository = {
- checkVersion: (request: CheckVersionRequest) => Promise
-}
+ checkVersion: (request: CheckVersionRequest) => Promise;
+};
export const getAppVersionRepository = (): AppVersionRepository => ({
- checkVersion: async request => {
+ checkVersion: async (request) => {
const response = await apiConnector.post(
- '/v2/app/version/check',
+ "/v2/app/version/check",
request,
- )
- return response
+ );
+ return response;
},
-})
+});
diff --git a/apps/mobile/src/repositories/auth.ts b/apps/mobile/src/repositories/auth.ts
new file mode 100644
index 00000000..bafdb93d
--- /dev/null
+++ b/apps/mobile/src/repositories/auth.ts
@@ -0,0 +1,77 @@
+import type { AppleRequestResponse } from "@invertase/react-native-apple-authentication";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { KakaoAccessTokenInfo } from "@react-native-seoul/kakao-login";
+import { LOGIN_TOKEN } from "@/shared/constants/localStorage";
+import { apiConnector } from "@/shared/utils/api";
+
+export type CallbackKakaoAuthRequest = {
+ accessToken: KakaoAccessTokenInfo["accessToken"];
+};
+
+export type CallbackKakaoAuthResponse = {
+ token: string;
+};
+
+export type CallbackAppleAuthRequest = {
+ id_token: AppleRequestResponse["identityToken"];
+};
+
+export type CallbackAppleAuthResponse = {
+ token: string;
+};
+
+export type AuthRepository = {
+ callbackKakaoAuth: (
+ request: CallbackKakaoAuthRequest,
+ ) => Promise;
+ callbackAppleAuth: (
+ request: CallbackAppleAuthRequest,
+ ) => Promise;
+ leave: () => Promise;
+};
+
+export const getAuthRepository = (): AuthRepository => ({
+ callbackKakaoAuth: async (request: CallbackKakaoAuthRequest) => {
+ const response = await apiConnector.post(
+ "/v2/auth/kakao/native/callback",
+ {
+ accessToken: request.accessToken,
+ },
+ );
+
+ return {
+ token: response.token,
+ };
+ },
+ callbackAppleAuth: async (request: CallbackAppleAuthRequest) => {
+ const response = await apiConnector.post(
+ "/v2/auth/apple/callback",
+ {
+ id_token: request.id_token,
+ },
+ );
+
+ return {
+ token: response.token,
+ };
+ },
+
+ leave: async () => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ await apiConnector.post(
+ "/v2/auth/leave",
+ {},
+ {
+ headers: {
+ "x-authorization": `Bearer ${token}`,
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ );
+ },
+});
diff --git a/apps/mobile/src/repositories/category.ts b/apps/mobile/src/repositories/category.ts
new file mode 100644
index 00000000..49874d1b
--- /dev/null
+++ b/apps/mobile/src/repositories/category.ts
@@ -0,0 +1,21 @@
+import type { Category } from "@/entities/category";
+import { apiConnector } from "@/shared/utils/api";
+
+export type ListCategoriesResponse = {
+ categories: Category[];
+ totalSize: number;
+};
+
+export type CategoryRepository = {
+ listCategories: () => Promise;
+};
+
+export const getCategoryRepository = (): CategoryRepository => ({
+ listCategories: async () => {
+ const response = await apiConnector.get(
+ "/v2/clubs/categories",
+ );
+
+ return response;
+ },
+});
diff --git a/apps/mobile/src/repositories/club.ts b/apps/mobile/src/repositories/club.ts
new file mode 100644
index 00000000..b256d3cc
--- /dev/null
+++ b/apps/mobile/src/repositories/club.ts
@@ -0,0 +1,504 @@
+import type {
+ Club,
+ ClubRanking,
+ ManagedClubListItem,
+ ManagedOfficialVerification,
+} from "@/entities/club";
+import type { ManagedClubManagerPatch } from "@/repositories/managedClubUpdate";
+import { buildManagedClubUpdateBody } from "@/repositories/managedClubUpdate";
+import { apiConnector } from "@/shared/utils/api";
+
+export type { ManagedClubManagerPatch } from "@/repositories/managedClubUpdate";
+
+export type ClubSearchAffiliationType =
+ | "전체"
+ | "중앙동아리"
+ | "학과/단과대동아리";
+export type ClubSearchRecruitType = "정기" | "상시";
+export type ClubSearchBooleanString = "true" | "false";
+export type ClubSearchMinActivityPeriod = "0" | "1" | "2" | "3_plus";
+
+export type SearchClubsRequest = {
+ query: string;
+ affiliation_type?: ClubSearchAffiliationType;
+ is_recruiting?: ClubSearchBooleanString;
+ recruit_type?: ClubSearchRecruitType;
+ has_membership_fee?: ClubSearchBooleanString;
+ has_dongbang?: ClubSearchBooleanString;
+ is_official_verified?: ClubSearchBooleanString;
+ min_activity_period?: ClubSearchMinActivityPeriod[];
+};
+
+export type SearchClubsResponse = {
+ clubs: Club[];
+ totalSize: number;
+ query: string;
+ correctedQuery: string | null;
+ isTypoCorrected: boolean;
+};
+
+export type ListLatestClubsResponse = {
+ clubs: Club[];
+ totalSize: number;
+};
+
+export type ListClubsRequest = {
+ category?: Club["category"];
+ name?: Club["name"];
+};
+
+export type ListClubsResponse = {
+ clubs: Club[];
+ totalSize: number;
+};
+
+export type GetClubRequest = {
+ uuid: Club["uuid"];
+};
+
+export type ClubManager = {
+ serviceUserId: string;
+ name: string;
+ phone: string;
+ studentId: string;
+};
+
+export type ManagedClubDetail = Club & {
+ managers: ClubManager[];
+ officialVerification: ManagedOfficialVerification;
+};
+
+export type GetManagedClubDetailRequest = {
+ uuid: Club["uuid"];
+};
+
+export type ListManageClubsResponse = {
+ clubs: ManagedClubListItem[];
+ totalSize: number;
+};
+
+type ManagedClubListItemResponse = Omit & {
+ hasManager?: boolean;
+};
+
+type ListManageClubsV2Response = {
+ success: boolean;
+ message: string;
+ data: {
+ total_count: number;
+ clubs: ManagedClubListItemResponse[];
+ };
+};
+
+export type ListClubRankingsRequest = {
+ topK?: number;
+};
+
+export type ListClubRankingsResponse = {
+ rankings: ClubRanking[];
+ totalSize: number;
+};
+
+export type RequestClubmanagerRequest = {
+ clubId: Club["uuid"];
+ name: string;
+ phone: string;
+ studentId: string;
+};
+
+export type UpdateClubManagerRequest = {
+ clubId: Club["uuid"];
+ name?: string;
+ phone?: string;
+ studentId?: string;
+ resubmit?: boolean;
+};
+
+export type ClubRequestTarget = {
+ clubId: Club["uuid"];
+};
+
+export type ClubManagerRequestDetail = {
+ name: string;
+ phone: string;
+ studentId: string;
+};
+
+type ClubManagerRequestDetailResponse = {
+ name: string;
+ phone: string;
+ student_id: string;
+};
+
+export type ManagedClubRequestTarget = {
+ uuid: Club["uuid"];
+};
+
+export type RequestOfficialVerificationRequest = {
+ clubId: Club["uuid"];
+};
+
+export type RequestOfficialVerificationResponse = {
+ success: true;
+ message: string;
+ data: {
+ request_id: number;
+ club_uuid: Club["uuid"];
+ status: "PENDING";
+ created_at: string;
+ attempt_no: number;
+ retry_count: number;
+ remaining_retry_count: number;
+ };
+};
+
+export type ListSavedClubsResponse = {
+ clubs: Club[];
+ totalSize: number;
+};
+
+export type CreateSavedClubRequest = {
+ clubId: Club["uuid"];
+};
+
+export type RemoveSavedClubRequest = {
+ clubId: Club["uuid"];
+};
+
+export type RegisterClubRequest = {
+ club_data: {
+ name: string;
+ type: string;
+ image_uri?: string;
+ category: string;
+ affiliation: string;
+ short_description: string;
+ recruit_type: string;
+ min_activity_period: number;
+ has_dongbang: boolean;
+ dongbang_location?: string;
+ sns_urls: string[];
+ introduction: string;
+ activity_image_urls?: string[];
+ };
+ manager_data: {
+ name: string;
+ phone: string;
+ student_id: string;
+ };
+};
+
+export type RegisterClubResponse = {
+ success: boolean;
+ message: string;
+ data?: {
+ club?: {
+ uuid: Club["uuid"];
+ name: Club["name"];
+ imageUri: Club["imageUri"];
+ status: string;
+ };
+ };
+};
+
+export type UpdateManagedClubRequest = {
+ uuid: Club["uuid"];
+ name?: string;
+ type?: string;
+ image_uri?: string;
+ category?: Club["category"];
+ affiliation?: string;
+ short_description?: string;
+ recruit_type?: string;
+ min_activity_period?: number;
+ has_dongbang?: boolean;
+ dongbang_location?: string;
+ sns_urls?: string[];
+ introduction?: string;
+ activity_image_urls?: string[];
+ manager_data?: ManagedClubManagerPatch;
+ resubmit?: boolean;
+};
+
+export type UpdateManagedClubResponse = {
+ success: boolean;
+ message: string;
+ data: {
+ club_uuid: Club["uuid"];
+ updated_at: string;
+ };
+};
+
+export type UploadManagedClubImageRequest = {
+ clubId: Club["uuid"];
+ uri: string;
+ type: string;
+ name: string;
+};
+
+export type UploadManagedClubImageResponse = {
+ ok: boolean;
+};
+
+export type UploadClubActivityImageRequest = UploadManagedClubImageRequest;
+
+export type UploadClubActivityImageResponse = {
+ url: string;
+};
+
+export type ListMyClubsResponse = {
+ clubs: Club[];
+ totalSize: number;
+};
+
+export type ListRandomRecommendationsResponse = {
+ clubs: Club[];
+ totalSize: number;
+};
+
+export type ClubRepository = {
+ searchClubs: (
+ req: SearchClubsRequest,
+ signal?: AbortSignal,
+ ) => Promise;
+ listLatestClubs: () => Promise;
+ listClubs: (req: ListClubsRequest) => Promise;
+ getClub: (req: GetClubRequest) => Promise;
+ getManagedClubDetail: (
+ req: GetManagedClubDetailRequest,
+ ) => Promise;
+ listManageClubs: () => Promise;
+ listClubRankings: (
+ req: ListClubRankingsRequest,
+ ) => Promise;
+ requestClubManager: (req: RequestClubmanagerRequest) => Promise;
+ getClubManagerRequest: (
+ req: ClubRequestTarget,
+ ) => Promise;
+ updateClubManagerRequest: (req: UpdateClubManagerRequest) => Promise;
+ getManagedClubManager: (
+ req: ManagedClubRequestTarget,
+ ) => Promise;
+ requestOfficialVerification: (
+ req: RequestOfficialVerificationRequest,
+ ) => Promise;
+ cancelClubManagerRequest: (req: ClubRequestTarget) => Promise;
+ cancelManagedClubRequest: (req: ManagedClubRequestTarget) => Promise;
+ listSavedClubs: () => Promise;
+ createSavedClub: (req: CreateSavedClubRequest) => Promise;
+ removeSavedClub: (req: RemoveSavedClubRequest) => Promise;
+ listMyClubs: () => Promise;
+ registerClub: (req: RegisterClubRequest) => Promise;
+ updateManagedClub: (
+ req: UpdateManagedClubRequest,
+ ) => Promise;
+ uploadManagedClubImage: (
+ req: UploadManagedClubImageRequest,
+ ) => Promise;
+ uploadClubActivityImage: (
+ req: UploadClubActivityImageRequest,
+ ) => Promise;
+ listRandomRecommendations: () => Promise;
+};
+
+export const getClubRepository = (): ClubRepository => ({
+ searchClubs: async (req, signal) => {
+ const searchParams = new URLSearchParams();
+ searchParams.append("query", req.query.toLowerCase().trim());
+ if (req.affiliation_type && req.affiliation_type !== "전체") {
+ searchParams.append("affiliation_type", req.affiliation_type);
+ }
+ if (req.is_recruiting) {
+ searchParams.append("is_recruiting", req.is_recruiting);
+ }
+ if (req.recruit_type) {
+ searchParams.append("recruit_type", req.recruit_type);
+ }
+ if (req.has_membership_fee) {
+ searchParams.append("has_membership_fee", req.has_membership_fee);
+ }
+ if (req.has_dongbang) {
+ searchParams.append("has_dongbang", req.has_dongbang);
+ }
+ if (req.is_official_verified) {
+ searchParams.append("is_official_verified", req.is_official_verified);
+ }
+ req.min_activity_period?.forEach((period) => {
+ searchParams.append("min_activity_period", period);
+ });
+
+ const response = await apiConnector.get(
+ "/v2/clubs/search",
+ searchParams,
+ signal,
+ );
+
+ return response;
+ },
+ listLatestClubs: async () => {
+ const response =
+ await apiConnector.get("/v2/clubs/latest");
+
+ return response;
+ },
+ listClubs: async (req) => {
+ const response = await apiConnector.get("/v2/clubs", {
+ ...(req.category && { category: req.category }),
+ });
+
+ return response;
+ },
+ getClub: async (req) => {
+ const club = await apiConnector.get(`/v2/clubs/${req.uuid}`);
+
+ if (!club) {
+ throw new Error("Club not found");
+ }
+ return club;
+ },
+ getManagedClubDetail: async (req) => {
+ const res = await apiConnector.get(
+ `/v2/managers/me/clubs/${req.uuid}`,
+ );
+ return res;
+ },
+ listManageClubs: async () => {
+ const response = await apiConnector.get(
+ "/v2/managers/me/clubs",
+ );
+ return {
+ clubs: response.data.clubs.map((club) => ({
+ ...club,
+ hasManager: club.hasManager ?? false,
+ })),
+ totalSize: response.data.total_count,
+ };
+ },
+ listClubRankings: async (req) => {
+ const response = await apiConnector.get(
+ `/v2/clubs/rankings?topk=${req.topK ?? 5}`,
+ );
+
+ return response;
+ },
+ requestClubManager: async (req) => {
+ await apiConnector.post(`/v2/clubs/${req.clubId}/manager-requests`, {
+ name: req.name,
+ phone: req.phone,
+ student_id: req.studentId,
+ });
+ },
+ getClubManagerRequest: async (req) => {
+ const response = await apiConnector.get(
+ `/v2/clubs/${req.clubId}/manager-requests`,
+ );
+
+ return {
+ name: response.name,
+ phone: response.phone,
+ studentId: response.student_id,
+ };
+ },
+ updateClubManagerRequest: async (req) => {
+ await apiConnector.patch(`/v2/clubs/${req.clubId}/manager-requests`, {
+ ...(req.name !== undefined && { name: req.name }),
+ ...(req.phone !== undefined && { phone: req.phone }),
+ ...(req.studentId !== undefined && { student_id: req.studentId }),
+ ...(req.resubmit && { resubmit: true }),
+ });
+ },
+ getManagedClubManager: async (req) => {
+ const response = await apiConnector.get(
+ `/v2/managers/me/clubs/${req.uuid}/manager`,
+ );
+
+ return {
+ name: response.name,
+ phone: response.phone,
+ studentId: response.student_id,
+ };
+ },
+ requestOfficialVerification: (req) =>
+ apiConnector.post(
+ `/v2/managers/me/clubs/${req.clubId}/verifications`,
+ ),
+ cancelClubManagerRequest: async (req) => {
+ await apiConnector.delete(`/v2/clubs/${req.clubId}/manager-requests`);
+ },
+ cancelManagedClubRequest: async (req) => {
+ await apiConnector.delete(`/v2/managers/me/clubs/${req.uuid}`);
+ },
+ listSavedClubs: async () => {
+ const response = await apiConnector.get(
+ "/v2/users/me/clubs/saved",
+ );
+
+ return response;
+ },
+ createSavedClub: async (req) => {
+ await apiConnector.post(`/v2/clubs/${req.clubId}/saved`);
+ },
+ removeSavedClub: async (req) => {
+ await apiConnector.delete(`/v2/clubs/${req.clubId}/saved`);
+ },
+ listMyClubs: async () => {
+ const response =
+ await apiConnector.get("/v2/users/me/clubs");
+
+ return response;
+ },
+ listRandomRecommendations: async () => {
+ const response = await apiConnector.get(
+ "/v2/clubs/recommendations/random",
+ );
+
+ return response;
+ },
+ registerClub: async (req) => {
+ const response = await apiConnector.post(
+ "/v2/clubs/register",
+ req,
+ );
+
+ return response;
+ },
+ updateManagedClub: async (req) => {
+ const { uuid, manager_data: managerData, resubmit, ...clubData } = req;
+ const response = await apiConnector.patch(
+ `/v2/managers/me/clubs/${uuid}`,
+ buildManagedClubUpdateBody(clubData, managerData, resubmit),
+ );
+
+ return response;
+ },
+ uploadManagedClubImage: async (req) => {
+ const formData = new FormData();
+ formData.append("file", {
+ uri: req.uri,
+ type: req.type,
+ name: req.name,
+ } as unknown as Blob);
+
+ const response = await apiConnector.post(
+ `/v2/managers/me/clubs/${req.clubId}/images`,
+ formData as unknown as object,
+ { timeout: 60000, headers: { "Content-Type": "multipart/form-data" } },
+ );
+
+ return response;
+ },
+ uploadClubActivityImage: async (req) => {
+ const formData = new FormData();
+ formData.append("file", {
+ uri: req.uri,
+ type: req.type,
+ name: req.name,
+ } as unknown as Blob);
+
+ return apiConnector.post(
+ `/v2/managers/me/clubs/${req.clubId}/activity-images`,
+ formData as unknown as object,
+ { timeout: 60000, headers: { "Content-Type": "multipart/form-data" } },
+ );
+ },
+});
diff --git a/apps/mobile/src/repositories/managedClubUpdate.test.ts b/apps/mobile/src/repositories/managedClubUpdate.test.ts
new file mode 100644
index 00000000..feaa38f5
--- /dev/null
+++ b/apps/mobile/src/repositories/managedClubUpdate.test.ts
@@ -0,0 +1,50 @@
+import { buildManagedClubUpdateBody } from "@/repositories/managedClubUpdate";
+
+describe("buildManagedClubUpdateBody", () => {
+ it("동아리 변경 필드만 club_data에 담는다", () => {
+ expect(buildManagedClubUpdateBody({ name: "수정된 동아리명" })).toEqual({
+ club_data: { name: "수정된 동아리명" },
+ });
+ });
+
+ it("운영진 변경 필드만 manager_data에 담고 studentId를 변환한다", () => {
+ expect(
+ buildManagedClubUpdateBody(
+ {},
+ { phone: "010-9876-5432", studentId: "2022-12345" },
+ ),
+ ).toEqual({
+ manager_data: {
+ phone: "010-9876-5432",
+ student_id: "2022-12345",
+ },
+ });
+ });
+
+ it("동아리와 운영진 변경 필드를 한 요청에 담는다", () => {
+ expect(
+ buildManagedClubUpdateBody(
+ { category: "학술" },
+ { name: "홍길동", phone: "010-9876-5432" },
+ ),
+ ).toEqual({
+ club_data: { category: "학술" },
+ manager_data: {
+ name: "홍길동",
+ phone: "010-9876-5432",
+ },
+ });
+ });
+
+ it("변경 필드가 하나도 없으면 요청 생성을 막는다", () => {
+ expect(() => buildManagedClubUpdateBody({}, {})).toThrow(
+ "At least one managed club field is required",
+ );
+ });
+
+ it("재신청은 변경 필드 없이 resubmit만 담을 수 있다", () => {
+ expect(buildManagedClubUpdateBody({}, undefined, true)).toEqual({
+ resubmit: true,
+ });
+ });
+});
diff --git a/apps/mobile/src/repositories/managedClubUpdate.ts b/apps/mobile/src/repositories/managedClubUpdate.ts
new file mode 100644
index 00000000..4dd9e15c
--- /dev/null
+++ b/apps/mobile/src/repositories/managedClubUpdate.ts
@@ -0,0 +1,47 @@
+export type ManagedClubManagerPatch = {
+ name?: string;
+ phone?: string;
+ studentId?: string;
+};
+
+export type ManagedClubManagerData = {
+ name?: string;
+ phone?: string;
+ student_id?: string;
+};
+
+export type ManagedClubUpdateBody = {
+ resubmit?: boolean;
+ club_data?: TClubData;
+ manager_data?: ManagedClubManagerData;
+};
+
+export const buildManagedClubUpdateBody = (
+ clubData: TClubData,
+ managerData?: ManagedClubManagerPatch,
+ resubmit = false,
+): ManagedClubUpdateBody => {
+ const managerPayload = managerData
+ ? {
+ ...(managerData.name !== undefined && { name: managerData.name }),
+ ...(managerData.phone !== undefined && { phone: managerData.phone }),
+ ...(managerData.studentId !== undefined && {
+ student_id: managerData.studentId,
+ }),
+ }
+ : undefined;
+ const body: ManagedClubUpdateBody = {
+ ...(resubmit && { resubmit: true }),
+ ...(Object.keys(clubData).length > 0 && { club_data: clubData }),
+ ...(managerPayload &&
+ Object.keys(managerPayload).length > 0 && {
+ manager_data: managerPayload,
+ }),
+ };
+
+ if (!body.resubmit && !body.club_data && !body.manager_data) {
+ throw new Error("At least one managed club field is required");
+ }
+
+ return body;
+};
diff --git a/apps/mobile/src/repositories/recentSearch.ts b/apps/mobile/src/repositories/recentSearch.ts
new file mode 100644
index 00000000..09018def
--- /dev/null
+++ b/apps/mobile/src/repositories/recentSearch.ts
@@ -0,0 +1,24 @@
+import type { RecentSearch } from "@/entities/recentSearch";
+import { apiConnector } from "@/shared/utils/api";
+
+export type ListRecentSearchesResponse = {
+ recentSearches: RecentSearch[];
+ totalSize: number;
+};
+
+export type RecentSearchRepository = {
+ listRecentSearches: () => Promise;
+ deleteAllRecentSearches: () => Promise;
+};
+
+export const getRecentSearchRepository = (): RecentSearchRepository => ({
+ listRecentSearches: async () => {
+ const response = await apiConnector.get(
+ "/v2/users/me/recent-searches",
+ );
+ return response;
+ },
+ deleteAllRecentSearches: async () => {
+ await apiConnector.delete("/v2/users/me/recent-searches");
+ },
+});
diff --git a/apps/mobile/src/repositories/recruitment.ts b/apps/mobile/src/repositories/recruitment.ts
new file mode 100644
index 00000000..721f0e9e
--- /dev/null
+++ b/apps/mobile/src/repositories/recruitment.ts
@@ -0,0 +1,240 @@
+import { apiConnector } from "@/shared/utils/api";
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export type RegularMeetingPayload = {
+ day_of_week: string;
+ start_time: string;
+ end_time: string;
+};
+
+export type RecruitmentRegularMeeting = {
+ day_of_week: string;
+ start_time: string | null;
+ end_time: string | null;
+};
+
+export type RecruitmentSummary = {
+ id: number;
+ display_title: string;
+ title: string;
+ deadline: string;
+ is_active: boolean;
+};
+
+type ListClubRecruitmentsApiResponse = {
+ success: boolean;
+ message: string;
+ data: {
+ club_name: string;
+ recruitments: RecruitmentSummary[];
+ };
+};
+
+type RepresentativeRecruitmentApiPayload = {
+ id: number | string;
+ updatedAt: string;
+};
+
+type GetRepresentativeRecruitmentApiResponse =
+ | RepresentativeRecruitmentApiPayload
+ | {
+ success: boolean;
+ message: string;
+ data: RepresentativeRecruitmentApiPayload | null;
+ }
+ | null;
+
+export type ListClubRecruitmentsRequest = {
+ clubId: string;
+};
+
+export type ListClubRecruitmentsResponse = {
+ club_name: string;
+ recruitments: RecruitmentSummary[];
+};
+
+export type GetRepresentativeRecruitmentRequest = {
+ clubId: string;
+};
+
+export type GetRepresentativeRecruitmentResponse = {
+ id: number;
+ updatedAt: string;
+} | null;
+
+export type CreateRecruitmentRequest = {
+ clubId: string;
+ title: string;
+ deadline: string;
+ is_mandatory: boolean;
+ has_regular_meeting: boolean;
+ regular_meetings: RegularMeetingPayload[];
+ activity_location_type: string;
+ activity_location_text: string;
+ has_eligibility: boolean;
+ eligibility_text: string;
+ has_capacity_limit: boolean;
+ capacity_limit_text: string;
+ has_membership_fee: boolean;
+ membership_fee_text: string;
+ application_url: string;
+ application_process: string;
+ full_recruitment_text: string;
+ image_urls: string[];
+};
+
+export type CreateRecruitmentResponse = {
+ id: string;
+};
+
+export type UploadRecruitmentImageRequest = {
+ clubId: string;
+ uri: string;
+ type: string;
+ name: string;
+};
+
+export type UploadRecruitmentImageResponse = {
+ url: string;
+};
+
+export type DeleteRecruitmentRequest = {
+ recruitmentId: number;
+};
+
+export type RecruitmentContent = {
+ title: string;
+ deadline: string;
+ is_mandatory: boolean;
+ has_regular_meeting: boolean;
+ regular_meetings: RecruitmentRegularMeeting[];
+ activity_location_type: string;
+ activity_location_text: string;
+ has_eligibility: boolean;
+ eligibility_text: string;
+ has_capacity_limit: boolean;
+ capacity_limit_text: string;
+ has_membership_fee: boolean;
+ membership_fee_text: string;
+ application_url: string;
+ application_process: string;
+ full_recruitment_text: string | null;
+ image_urls: string[];
+};
+
+export type GetRecruitmentDetailRequest = {
+ recruitmentId: number;
+};
+
+type GetRecruitmentDetailApiResponse = {
+ success: boolean;
+ data: {
+ id: number;
+ display_title: string;
+ club_id: string;
+ content: RecruitmentContent;
+ };
+};
+
+export type GetRecruitmentDetailResponse = {
+ id: number;
+ display_title: string;
+ club_id: string;
+ content: RecruitmentContent;
+};
+
+export type UpdateRecruitmentRequest = {
+ recruitmentId: number;
+} & Omit;
+
+export type RecruitmentRepository = {
+ listClubRecruitments: (
+ req: ListClubRecruitmentsRequest,
+ ) => Promise;
+ getRepresentativeRecruitment: (
+ req: GetRepresentativeRecruitmentRequest,
+ ) => Promise;
+ createRecruitment: (
+ req: CreateRecruitmentRequest,
+ ) => Promise;
+ uploadRecruitmentImage: (
+ req: UploadRecruitmentImageRequest,
+ ) => Promise;
+ deleteRecruitment: (req: DeleteRecruitmentRequest) => Promise;
+ getRecruitmentDetail: (
+ req: GetRecruitmentDetailRequest,
+ ) => Promise;
+ updateRecruitment: (req: UpdateRecruitmentRequest) => Promise;
+};
+
+// ─── Implementation ───────────────────────────────────────────────────────────
+
+export const getRecruitmentRepository = (): RecruitmentRepository => ({
+ listClubRecruitments: async (req) => {
+ const res = await apiConnector.get(
+ `/v2/clubs/${req.clubId}/recruitments`,
+ );
+ return {
+ club_name: res.data.club_name,
+ recruitments: res.data.recruitments,
+ };
+ },
+
+ getRepresentativeRecruitment: async (req) => {
+ const res = await apiConnector.get(
+ `/v2/clubs/${req.clubId}/recruitments/representative`,
+ );
+ const representativeRecruitment = res && "data" in res ? res.data : res;
+ if (!representativeRecruitment) return null;
+
+ const id = Number(representativeRecruitment.id);
+ return Number.isNaN(id)
+ ? null
+ : { id, updatedAt: representativeRecruitment.updatedAt };
+ },
+
+ createRecruitment: async (req) => {
+ const { clubId, ...body } = req;
+ return apiConnector.post(
+ `/v2/managers/me/clubs/${clubId}/recruitments`,
+ body,
+ );
+ },
+
+ deleteRecruitment: async (req) => {
+ await apiConnector.delete(
+ `/v2/managers/me/recruitments/${req.recruitmentId}`,
+ );
+ },
+
+ getRecruitmentDetail: async (req) => {
+ const res = await apiConnector.get(
+ `/v2/recruitments/${req.recruitmentId}`,
+ );
+ return res.data;
+ },
+
+ updateRecruitment: async (req) => {
+ const { recruitmentId, ...body } = req;
+ await apiConnector.patch(
+ `/v2/managers/me/recruitments/${recruitmentId}`,
+ body,
+ );
+ },
+
+ uploadRecruitmentImage: async (req) => {
+ const formData = new FormData();
+ formData.append("file", {
+ uri: req.uri,
+ type: req.type,
+ name: req.name,
+ } as unknown as Blob);
+
+ return apiConnector.post(
+ `/v2/managers/me/clubs/${req.clubId}/recruitments/images`,
+ formData as unknown as object,
+ { timeout: 60000, headers: { "Content-Type": "multipart/form-data" } },
+ );
+ },
+});
diff --git a/apps/mobile/src/repositories/review.ts b/apps/mobile/src/repositories/review.ts
new file mode 100644
index 00000000..ee5e232d
--- /dev/null
+++ b/apps/mobile/src/repositories/review.ts
@@ -0,0 +1,53 @@
+import type { Club } from "@/entities/club";
+import type { ReviewKeyword, ReviewKeywordCategory } from "@/entities/review";
+import { apiConnector } from "@/shared/utils/api";
+
+export type CreateClubReviewsRequest = {
+ uuid: Club["uuid"];
+ reviewKeywordIds: ReviewKeyword["id"][];
+};
+
+export type ListReviewKeywordsResponse = {
+ categories: ReviewKeywordCategory[];
+ totalSize: number;
+};
+
+export type GetMyClubReviewRequest = {
+ uuid: Club["uuid"];
+};
+
+export type GetMyClubReviewResponse = {
+ reviewKeywordIds: ReviewKeyword["id"][];
+ createdAt: string;
+ updatedAt: string;
+} | null;
+
+export type ReviewRepository = {
+ createClubReviews: (req: CreateClubReviewsRequest) => Promise;
+ listReviewKeywords: () => Promise;
+ getMyClubReview: (
+ req: GetMyClubReviewRequest,
+ ) => Promise;
+};
+
+export const getReviewRepository = (): ReviewRepository => ({
+ createClubReviews: async (req) => {
+ await apiConnector.post(`/v2/clubs/${req.uuid}/reviews`, {
+ reviewKeywordIds: req.reviewKeywordIds,
+ });
+ },
+ listReviewKeywords: async () => {
+ const response = await apiConnector.get(
+ "/v2/clubs/reviews/keywords",
+ );
+
+ return response;
+ },
+ getMyClubReview: async (req) => {
+ const response = await apiConnector.get(
+ `/v2/clubs/${req.uuid}/reviews/me`,
+ );
+
+ return response;
+ },
+});
diff --git a/apps/mobile/src/repositories/term.ts b/apps/mobile/src/repositories/term.ts
new file mode 100644
index 00000000..b78b557b
--- /dev/null
+++ b/apps/mobile/src/repositories/term.ts
@@ -0,0 +1,40 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { Term } from "@/entities/term";
+import { LOGIN_TOKEN } from "@/shared/constants/localStorage";
+import { apiConnector } from "@/shared/utils/api";
+
+export type ListTermsResponse = {
+ data: Term[];
+};
+
+export type AgreeTermsRequest = {
+ termUuids: Term["uuid"][];
+};
+
+export type TermRepository = {
+ listTerms: () => Promise;
+ agreeTerms: (request: AgreeTermsRequest) => Promise;
+};
+
+export const getTermRepository = (): TermRepository => ({
+ listTerms: async () => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ const response = await apiConnector.get("/v2/terms");
+
+ return response;
+ },
+ agreeTerms: async (request) => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ await apiConnector.post("/v2/terms/agree", request);
+ },
+});
diff --git a/apps/mobile/src/repositories/user.ts b/apps/mobile/src/repositories/user.ts
new file mode 100644
index 00000000..a3b11233
--- /dev/null
+++ b/apps/mobile/src/repositories/user.ts
@@ -0,0 +1,111 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { CollegeMajor, User } from "@/entities/user";
+import type { UserNotification } from "@/entities/userNotification";
+import { LOGIN_TOKEN } from "@/shared/constants/localStorage";
+import { apiConnector } from "@/shared/utils/api";
+
+export type GetUserResponse = {
+ profile: User;
+};
+
+export type UpdateUserRequest = Partial> & {
+ collegeMajorId?: CollegeMajor["id"] | null;
+};
+
+export type CreateUserVoiceRequest = {
+ content: string;
+};
+
+export type ListCollegeMajorsResponse = {
+ majors: CollegeMajor[];
+ totalSize: number;
+};
+
+export type ListCollegeMajorsRequest = {
+ includeNullMajor?: boolean;
+};
+
+export type ListUserNotificationsResponse = {
+ notifications: UserNotification[];
+ totalSize: number;
+ unreadCount: number;
+};
+
+export type ReadUserNotificationRequest = {
+ id: UserNotification["id"];
+};
+
+export type UserRepository = {
+ getUser: () => Promise;
+ updateUser: (request: UpdateUserRequest) => Promise;
+ createUserVoice: (request: CreateUserVoiceRequest) => Promise;
+ listCollegeMajors: (
+ request?: ListCollegeMajorsRequest,
+ ) => Promise;
+ listNotifications: () => Promise;
+ readNotification: (request: ReadUserNotificationRequest) => Promise;
+};
+
+export const getUserRepository = (): UserRepository => ({
+ getUser: async () => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ const response = await apiConnector.get("/v2/users/me");
+
+ return response.profile;
+ },
+ updateUser: async (request) => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ await apiConnector.put("/v2/users/me", request);
+ },
+ createUserVoice: async (request) => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ await apiConnector.post("/v2/users/me/voices", request);
+ },
+ listCollegeMajors: async (request) => {
+ const response = await apiConnector.get(
+ "/v2/users/majors",
+ request?.includeNullMajor ? { includeNullMajor: "true" } : undefined,
+ );
+
+ return response;
+ },
+ listNotifications: async () => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ const response = await apiConnector.get(
+ "/v2/users/me/notifications",
+ );
+
+ return response;
+ },
+ readNotification: async (request) => {
+ const token = await AsyncStorage.getItem(LOGIN_TOKEN);
+
+ if (!token) {
+ throw new Error("No token found");
+ }
+
+ await apiConnector.patch(
+ `/v2/users/me/notifications/${request.id}/read`,
+ );
+ },
+});
diff --git a/apps/mobile/src/shared/components/AlertModal.tsx b/apps/mobile/src/shared/components/AlertModal.tsx
new file mode 100644
index 00000000..4c1bae11
--- /dev/null
+++ b/apps/mobile/src/shared/components/AlertModal.tsx
@@ -0,0 +1,144 @@
+import { BlurView } from "@react-native-community/blur";
+import type { ReactNode } from "react";
+import {
+ Modal,
+ Pressable,
+ type StyleProp,
+ StyleSheet,
+ Text,
+ type TextStyle,
+ View,
+} from "react-native";
+import Button, { type ButtonVariant } from "@/shared/components/Button";
+import { Colors } from "@/shared/constants/colors";
+
+export type AlertModalProps = {
+ visible: boolean;
+ onClose: () => void;
+ title: string;
+ titleStyle?: StyleProp;
+ titleContent?: ReactNode;
+ description?: string;
+ descriptionContent?: ReactNode;
+ buttonLabel: string;
+ onButtonPress: () => void;
+ buttonDisabled?: boolean;
+ buttonVariant?: ButtonVariant;
+ hasCancel?: boolean;
+ cancelLabel?: string;
+ dismissOnBackdropPress?: boolean;
+ overlayColor?: string;
+ blurAmount?: number;
+};
+
+const AlertModal = ({
+ visible,
+ onClose,
+ title,
+ titleStyle,
+ titleContent,
+ description,
+ descriptionContent,
+ buttonLabel,
+ onButtonPress,
+ buttonDisabled = false,
+ buttonVariant = "primary",
+ hasCancel = false,
+ cancelLabel = "취소",
+ dismissOnBackdropPress = true,
+ overlayColor = Colors.BACKGROUND_DIM_STRONG,
+ blurAmount = 1,
+}: AlertModalProps) => {
+ return (
+
+
+
+ e.stopPropagation()}
+ >
+
+ {titleContent ?? title}
+
+ {description || descriptionContent ? (
+
+ {descriptionContent ?? description}
+
+ ) : null}
+
+ {hasCancel && (
+
+ )}
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 24,
+ },
+ blur: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ container: {
+ width: "100%",
+ backgroundColor: Colors.WHITE,
+ borderRadius: 16,
+ padding: 24,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: "700",
+ color: Colors.BLACK,
+ textAlign: "center",
+ },
+ description: {
+ fontSize: 14,
+ fontWeight: "400",
+ color: Colors.BLACK,
+ textAlign: "center",
+ marginTop: 12,
+ },
+ buttonArea: {
+ flexDirection: "row",
+ gap: 8,
+ marginTop: 24,
+ },
+ button: {
+ paddingHorizontal: 16,
+ },
+});
+
+export default AlertModal;
diff --git a/src/shared/components/AnnouncementModal.tsx b/apps/mobile/src/shared/components/AnnouncementModal.tsx
similarity index 59%
rename from src/shared/components/AnnouncementModal.tsx
rename to apps/mobile/src/shared/components/AnnouncementModal.tsx
index 79d6a857..246e8251 100644
--- a/src/shared/components/AnnouncementModal.tsx
+++ b/apps/mobile/src/shared/components/AnnouncementModal.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useState } from 'react'
-import { BlurView } from '@react-native-community/blur'
+import { BlurView } from "@react-native-community/blur";
+import { useEffect, useState } from "react";
import {
Modal,
Pressable,
@@ -8,24 +8,24 @@ import {
Text,
useWindowDimensions,
View,
-} from 'react-native'
-import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
-import Button from '@/shared/components/Button'
-import Checkbox from '@/shared/components/Checkbox'
-import { Colors } from '@/shared/constants/colors'
-import { useProfile } from '@/shared/contexts/profileContext'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import Button from "@/shared/components/Button";
+import Checkbox from "@/shared/components/Checkbox";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { ms, s, vs } from "@/shared/utils/scale";
type Props = {
- visible: boolean
- announcementUuid: string
- title: string
- description: string
- onHide: () => void
- onClose: () => void
-}
+ visible: boolean;
+ announcementUuid: string;
+ title: string;
+ description: string;
+ onHide: () => void;
+ onClose: () => void;
+};
const AnnouncementModal = ({
visible,
@@ -35,29 +35,38 @@ const AnnouncementModal = ({
onHide,
onClose,
}: Props) => {
- const { height: windowHeight } = useWindowDimensions()
- const insets = useSafeAreaInsets()
- const { user } = useProfile()
- const showHideOption = !!user
- const [hideChecked, setHideChecked] = useState(false)
+ const { height: windowHeight } = useWindowDimensions();
+ const insets = useSafeAreaInsets();
+ const { user } = useProfile();
+ const showHideOption = !!user;
+ const [hideChecked, setHideChecked] = useState(false);
- const modalHeight = Math.min(vs(500), windowHeight - insets.top - insets.bottom - vs(40))
+ const modalHeight = Math.min(
+ vs(500),
+ windowHeight - insets.top - insets.bottom - vs(40),
+ );
+ // biome-ignore lint/correctness/useExhaustiveDependencies: A new announcement must reset the local checkbox state.
useEffect(() => {
- setHideChecked(false)
- }, [announcementUuid])
+ setHideChecked(false);
+ }, [announcementUuid]);
const handleDismiss = () => {
if (showHideOption && hideChecked) {
- onHide()
- return
+ onHide();
+ return;
}
- onClose()
- }
+ onClose();
+ };
return (
-
+
공지
-
+
@@ -85,7 +98,8 @@ const AnnouncementModal = ({
style={styles.descriptionScroll}
showsVerticalScrollIndicator={true}
bounces={false}
- contentContainerStyle={styles.descriptionContent}>
+ contentContainerStyle={styles.descriptionContent}
+ >
{description}
@@ -94,26 +108,31 @@ const AnnouncementModal = ({
setHideChecked(prev => !prev)}
+ onPressIn={() => setHideChecked((prev) => !prev)}
style={styles.checkbox}
textStyle={styles.checkboxLabel}
/>
)}
-
+
- )
-}
+ );
+};
const styles = StyleSheet.create({
overlay: {
flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
+ justifyContent: "center",
+ alignItems: "center",
paddingHorizontal: s(20),
},
backdrop: {
@@ -124,7 +143,7 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
},
container: {
- width: '100%',
+ width: "100%",
backgroundColor: Colors.POINTCOLOR_SUB,
borderRadius: ms(28),
paddingHorizontal: s(24),
@@ -135,9 +154,9 @@ const styles = StyleSheet.create({
gap: vs(18),
},
header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "center",
},
badge: {
backgroundColor: Colors.POINTCOLOR_10,
@@ -178,7 +197,7 @@ const styles = StyleSheet.create({
gap: vs(40),
},
checkbox: {
- alignSelf: 'flex-start',
+ alignSelf: "flex-start",
},
checkboxLabel: {
...typography.bodySSmallMedium,
@@ -187,11 +206,11 @@ const styles = StyleSheet.create({
color: Colors.POINTCOLOR,
},
buttonWrapper: {
- alignItems: 'center',
+ alignItems: "center",
},
button: {
borderRadius: ms(12),
},
-})
+});
-export default AnnouncementModal
+export default AnnouncementModal;
diff --git a/apps/mobile/src/shared/components/AppInitializationScreen.tsx b/apps/mobile/src/shared/components/AppInitializationScreen.tsx
new file mode 100644
index 00000000..08e1231b
--- /dev/null
+++ b/apps/mobile/src/shared/components/AppInitializationScreen.tsx
@@ -0,0 +1,108 @@
+import { useMemo } from "react";
+import { Image, StatusBar, StyleSheet, Text, View } from "react-native";
+import { Colors } from "@/shared/constants/colors";
+import useEntrySplashViewport from "@/shared/hooks/useEntrySplashViewport";
+import {
+ ENTRY_SPLASH_SYMBOL_SIZE,
+ getCenteredScaledAssetOrigin,
+} from "@/shared/utils/entrySplash";
+
+const symbolSource =
+ require("@/assets/images/brand/entry-symbol-white.png") as number;
+const wordmarkSource =
+ require("@/assets/images/brand/entry-wordmark-small.png") as number;
+
+const AppInitializationScreen = () => {
+ const { contentTop, contentBottom, scaleX, scaleY } =
+ useEntrySplashViewport();
+
+ const layoutStyles = useMemo(
+ () => ({
+ content: {
+ top: contentTop,
+ bottom: contentBottom,
+ },
+ symbol: {
+ left: getCenteredScaledAssetOrigin({
+ designOrigin: 160.556,
+ designSize: 80.906,
+ renderedSize: ENTRY_SPLASH_SYMBOL_SIZE,
+ scale: scaleX,
+ }),
+ top: getCenteredScaledAssetOrigin({
+ designOrigin: 396.562,
+ designSize: 80.876,
+ renderedSize: ENTRY_SPLASH_SYMBOL_SIZE,
+ scale: scaleY,
+ }),
+ width: ENTRY_SPLASH_SYMBOL_SIZE,
+ height: ENTRY_SPLASH_SYMBOL_SIZE,
+ },
+ tagline: {
+ left: 117.556 * scaleX,
+ top: 739 * scaleY,
+ fontFamily: "Pretendard-Bold",
+ fontSize: 18 * scaleX,
+ lineHeight: 30 * scaleX,
+ letterSpacing: -0.36 * scaleX,
+ },
+ wordmark: {
+ left: 164.556 * scaleX,
+ top: 779 * scaleY,
+ width: 72.497 * scaleX,
+ height: 29.294 * scaleX,
+ },
+ }),
+ [contentBottom, contentTop, scaleX, scaleY],
+ );
+
+ return (
+
+
+
+
+
+ 이번 학기엔 동아리까지,
+
+
+
+
+ );
+};
+
+export default AppInitializationScreen;
+
+const styles = StyleSheet.create({
+ screen: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 100,
+ backgroundColor: Colors.POINTCOLOR,
+ },
+ content: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ },
+ asset: {
+ position: "absolute",
+ resizeMode: "contain",
+ },
+ tagline: {
+ position: "absolute",
+ color: Colors.WHITE,
+ },
+ wordmark: {
+ tintColor: Colors.WHITE,
+ },
+});
diff --git a/src/shared/components/AppModalManager.tsx b/apps/mobile/src/shared/components/AppModalManager.tsx
similarity index 62%
rename from src/shared/components/AppModalManager.tsx
rename to apps/mobile/src/shared/components/AppModalManager.tsx
index a7433467..63c38164 100644
--- a/src/shared/components/AppModalManager.tsx
+++ b/apps/mobile/src/shared/components/AppModalManager.tsx
@@ -1,7 +1,7 @@
-import AnnouncementModal from '@/shared/components/AnnouncementModal'
-import TermsAgreementModal from '@/shared/components/TermsAgreementModal'
-import useAnnouncementModals from '@/shared/hooks/useAnnouncementModals'
-import usePendingTerms from '@/shared/hooks/usePendingTerms'
+import AnnouncementModal from "@/shared/components/AnnouncementModal";
+import TermsAgreementModal from "@/shared/components/TermsAgreementModal";
+import useAnnouncementModals from "@/shared/hooks/useAnnouncementModals";
+import usePendingTerms from "@/shared/hooks/usePendingTerms";
/**
* 약관 동의 / 공지 모달을 화면과 무관하게 전역에서 표시한다.
@@ -9,9 +9,13 @@ import usePendingTerms from '@/shared/hooks/usePendingTerms'
* 약관 동의가 필요하면 약관 모달을 우선 표시하고, 그렇지 않으면 공지 모달을 표시한다.
*/
const AppModalManager = () => {
- const { currentAnnouncement, handleCloseAnnouncement, handleHideAnnouncement } =
- useAnnouncementModals()
- const { pendingTerms, isSubmitting, shouldShowTermsModal, handleAgreeTerms } = usePendingTerms()
+ const {
+ currentAnnouncement,
+ handleCloseAnnouncement,
+ handleHideAnnouncement,
+ } = useAnnouncementModals();
+ const { pendingTerms, isSubmitting, shouldShowTermsModal, handleAgreeTerms } =
+ usePendingTerms();
if (shouldShowTermsModal === true) {
return (
@@ -19,9 +23,9 @@ const AppModalManager = () => {
visible
terms={pendingTerms}
isSubmitting={isSubmitting}
- onAgree={termUuids => handleAgreeTerms({ termUuids })}
+ onAgree={(termUuids) => handleAgreeTerms({ termUuids })}
/>
- )
+ );
}
if (shouldShowTermsModal === false && currentAnnouncement) {
@@ -34,10 +38,10 @@ const AppModalManager = () => {
onHide={handleHideAnnouncement}
onClose={handleCloseAnnouncement}
/>
- )
+ );
}
- return null
-}
+ return null;
+};
-export default AppModalManager
+export default AppModalManager;
diff --git a/apps/mobile/src/shared/components/BackHeader.tsx b/apps/mobile/src/shared/components/BackHeader.tsx
new file mode 100644
index 00000000..b4540c76
--- /dev/null
+++ b/apps/mobile/src/shared/components/BackHeader.tsx
@@ -0,0 +1,65 @@
+import { Pressable, StyleSheet, Text, View } from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ title: string;
+ onBack: () => void;
+ showBackButton?: boolean;
+};
+
+const Header = ({ title, onBack, showBackButton = true }: Props) => {
+ return (
+
+ {showBackButton && (
+
+
+
+ )}
+
+
+
+ {title}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: "relative",
+ flexDirection: "row",
+ alignItems: "center",
+ width: "100%",
+ height: vs(56),
+ paddingHorizontal: s(16),
+ },
+ backButton: {
+ position: "absolute",
+ left: s(16),
+ top: 0,
+ bottom: 0,
+ justifyContent: "center",
+ width: ms(32),
+ zIndex: 1,
+ },
+ titleWrapper: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ title: {
+ ...typography.headerL,
+ color: Colors.BODYTEXT_SUB,
+ },
+});
+
+export default Header;
diff --git a/apps/mobile/src/shared/components/Button.tsx b/apps/mobile/src/shared/components/Button.tsx
new file mode 100644
index 00000000..d9d9a238
--- /dev/null
+++ b/apps/mobile/src/shared/components/Button.tsx
@@ -0,0 +1,161 @@
+import {
+ Pressable,
+ type StyleProp,
+ StyleSheet,
+ Text,
+ type TextStyle,
+ type ViewStyle,
+} from "react-native";
+import { Colors } from "@/shared/constants/colors";
+
+export type ButtonVariant = "primary" | "outline" | "destructive";
+
+export type ButtonProps = {
+ label: string;
+ onPress: () => void;
+ variant?: ButtonVariant;
+ height?: number;
+ width?: number;
+ isSelected?: boolean;
+ disabled?: boolean;
+ style?: StyleProp;
+ textStyle?: StyleProp;
+};
+
+export const Button = ({
+ label,
+ onPress,
+ variant = "primary",
+ height,
+ isSelected = false,
+ disabled = false,
+ width,
+ style,
+ textStyle,
+}: ButtonProps) => {
+ const variantStyle = getVariantStyle(variant, isSelected, disabled);
+
+ const containerStyle: ViewStyle[] = [
+ styles.base,
+ variantStyle.container,
+ height !== undefined && { height },
+ width !== undefined ? { width } : { flex: 1 },
+ style,
+ ].filter((s): s is ViewStyle => !!s);
+
+ const labelStyle: TextStyle[] = [
+ styles.label,
+ variantStyle.text,
+ textStyle,
+ ].filter((s): s is TextStyle => !!s);
+ const pressedStyle = getPressedStyle(variant);
+
+ return (
+ [
+ containerStyle,
+ pressed && pressedStyle.container,
+ ]}
+ onPress={onPress}
+ disabled={disabled}
+ >
+ {({ pressed }) => (
+ {label}
+ )}
+
+ );
+};
+
+const getPressedStyle = (
+ variant: ButtonVariant,
+): { container?: ViewStyle; text?: TextStyle } => {
+ if (variant === "primary") {
+ return { container: { backgroundColor: Colors.BUTTON_PUSH } };
+ }
+
+ if (variant === "outline") {
+ return {
+ container: { borderColor: Colors.BUTTON_PUSH },
+ text: { color: Colors.BUTTON_PUSH },
+ };
+ }
+
+ if (variant === "destructive") {
+ return { container: { opacity: 0.8 } };
+ }
+
+ return {};
+};
+
+const disabledVariantMap: Record = {
+ primary: "primaryDisabled",
+ outline: "outlineDisabled",
+ destructive: "primaryDisabled",
+};
+
+const getVariantStyle = (
+ variant: ButtonVariant,
+ isSelected: boolean,
+ disabled: boolean,
+): { container: ViewStyle; text: TextStyle } => {
+ if (isSelected) {
+ return disabled ? variantStyles.primaryDisabled : variantStyles.primary;
+ }
+
+ if (disabled) {
+ return variantStyles[disabledVariantMap[variant]];
+ }
+
+ return variantStyles[variant];
+};
+
+const borderedContainer: ViewStyle = {
+ backgroundColor: "transparent",
+ borderWidth: 1,
+};
+
+const variantStyles = {
+ primary: {
+ container: { backgroundColor: Colors.BUTTON_SELECTED } as ViewStyle,
+ text: { color: Colors.TEXT_BUTTON_SELECTED } as TextStyle,
+ },
+ primaryDisabled: {
+ container: { backgroundColor: Colors.TEXTBOX_SELECTED } as ViewStyle,
+ text: { color: Colors.BODYTEXT_DISABLED } as TextStyle,
+ },
+ outline: {
+ container: {
+ ...borderedContainer,
+ borderColor: Colors.BUTTON_SELECTED,
+ } as ViewStyle,
+ text: { color: Colors.BUTTON_SELECTED } as TextStyle,
+ },
+ outlineDisabled: {
+ container: {
+ ...borderedContainer,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ } as ViewStyle,
+ text: { color: Colors.BODYTEXT_DISABLED } as TextStyle,
+ },
+ destructive: {
+ container: { backgroundColor: Colors.BUTTON_DESTRUCTIVE } as ViewStyle,
+ text: { color: Colors.WHITE } as TextStyle,
+ },
+};
+
+const styles = StyleSheet.create({
+ base: {
+ borderRadius: 8,
+ paddingHorizontal: 50,
+ paddingVertical: 14,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ label: {
+ fontSize: 16,
+ fontWeight: "600",
+ lineHeight: 20,
+ },
+});
+
+export default Button;
diff --git a/apps/mobile/src/shared/components/Checkbox.tsx b/apps/mobile/src/shared/components/Checkbox.tsx
new file mode 100644
index 00000000..90625de0
--- /dev/null
+++ b/apps/mobile/src/shared/components/Checkbox.tsx
@@ -0,0 +1,72 @@
+import {
+ Pressable,
+ type StyleProp,
+ StyleSheet,
+ Text,
+ type TextStyle,
+ View,
+ type ViewStyle,
+} from "react-native";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+
+type Props = {
+ label: string;
+ checked: boolean;
+ onPressIn: () => void;
+ style?: StyleProp;
+ textStyle?: StyleProp;
+};
+
+const Checkbox = ({ label, checked, onPressIn, style, textStyle }: Props) => {
+ return (
+ [
+ styles.container,
+ pressed && styles.pressed,
+ style,
+ ]}
+ >
+
+
+
+
+ {label}
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: "center",
+ flexDirection: "row",
+ gap: 4,
+ },
+ pressed: {
+ opacity: 0.7,
+ },
+ iconContainer: {
+ alignItems: "center",
+ height: 14,
+ justifyContent: "center",
+ width: 14,
+ },
+ label: {
+ ...typography.bodySSemibold,
+ color: Colors.POINTCOLOR,
+ },
+ labelChecked: {
+ color: Colors.POINTCOLOR,
+ },
+});
+
+export default Checkbox;
diff --git a/apps/mobile/src/shared/components/ClubActivityImagePicker.tsx b/apps/mobile/src/shared/components/ClubActivityImagePicker.tsx
new file mode 100644
index 00000000..e22e7af4
--- /dev/null
+++ b/apps/mobile/src/shared/components/ClubActivityImagePicker.tsx
@@ -0,0 +1,172 @@
+import React from "react";
+import {
+ Image,
+ Pressable,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { launchImageLibrary } from "react-native-image-picker";
+import Toast from "react-native-toast-message";
+import Icon from "react-native-vector-icons/MaterialIcons";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import type { EditableImage } from "@/shared/types/image";
+import { isActivityImageFileSizeAllowed } from "@/shared/utils/activityImage";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+const MAX_ACTIVITY_IMAGES = 5;
+
+type Props = {
+ images: EditableImage[];
+ onChange: (images: EditableImage[]) => void;
+};
+
+const ClubActivityImagePicker = ({ images, onChange }: Props) => {
+ const remainingCount = MAX_ACTIVITY_IMAGES - images.length;
+
+ const handleAddImages = async () => {
+ if (remainingCount <= 0) return;
+
+ const result = await launchImageLibrary({
+ mediaType: "photo",
+ selectionLimit: remainingCount,
+ includeBase64: false,
+ });
+
+ if (result.didCancel) return;
+ if (result.errorCode) {
+ Toast.show({
+ type: "error",
+ text1: "활동 사진을 불러오지 못했어요",
+ text2: result.errorMessage,
+ position: "top",
+ });
+ return;
+ }
+
+ const assets = result.assets ?? [];
+ const oversizedImageCount = assets.filter(
+ (asset) => !isActivityImageFileSizeAllowed(asset.fileSize),
+ ).length;
+ const selectedImages: EditableImage[] = assets.flatMap((asset, index) => {
+ if (!isActivityImageFileSizeAllowed(asset.fileSize)) return [];
+ if (!asset.uri) return [];
+ const name = asset.fileName ?? `club_activity_${Date.now()}_${index}.jpg`;
+ return [
+ {
+ id: `local-${Date.now()}-${index}`,
+ uri: asset.uri,
+ file: {
+ uri: asset.uri,
+ type: asset.type ?? "image/jpeg",
+ name,
+ },
+ },
+ ];
+ });
+
+ if (oversizedImageCount > 0) {
+ Toast.show({
+ type: "error",
+ text1: "10MB 이하 사진만 등록할 수 있어요",
+ text2: `${oversizedImageCount}장의 활동 사진을 제외했어요`,
+ position: "top",
+ });
+ }
+
+ if (selectedImages.length > 0) {
+ onChange([...images, ...selectedImages].slice(0, MAX_ACTIVITY_IMAGES));
+ }
+ };
+
+ return (
+
+
+ 활동 사진 (선택)
+
+ {images.length}/{MAX_ACTIVITY_IMAGES}
+
+
+
+ 동아리 활동 모습을 최대 5장까지 등록할 수 있어요
+
+
+ {images.map((image) => (
+
+
+
+ onChange(images.filter((item) => item.id !== image.id))
+ }
+ >
+
+
+
+ ))}
+ {remainingCount > 0 && (
+
+
+ 사진 추가
+
+ )}
+
+
+ );
+};
+
+export default React.memo(ClubActivityImagePicker);
+
+const styles = StyleSheet.create({
+ wrapper: { gap: vs(8) },
+ labelRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ label: { ...typography.headerXLSemibold, color: Colors.BODYTEXT_SUB },
+ count: { ...typography.bodySRegular, color: Colors.BODYTEXT_SUB_2 },
+ helper: { ...typography.bodySRegular, color: Colors.BODYTEXT_SUB },
+ list: { gap: s(10), paddingVertical: vs(4) },
+ imageWrapper: { width: s(104), height: s(104) },
+ image: { width: "100%", height: "100%", borderRadius: s(10) },
+ removeButton: {
+ position: "absolute",
+ top: s(4),
+ right: s(4),
+ width: ms(28),
+ height: ms(28),
+ borderRadius: ms(14),
+ backgroundColor: Colors.BODYTEXT_MAIN,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ addButton: {
+ width: s(104),
+ height: s(104),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: s(10),
+ alignItems: "center",
+ justifyContent: "center",
+ gap: vs(4),
+ },
+ addText: { ...typography.bodySRegular, color: Colors.BODYTEXT_SUB },
+});
diff --git a/src/shared/components/ClubPreviewCard.tsx b/apps/mobile/src/shared/components/ClubPreviewCard.tsx
similarity index 50%
rename from src/shared/components/ClubPreviewCard.tsx
rename to apps/mobile/src/shared/components/ClubPreviewCard.tsx
index 18faac86..7127b508 100644
--- a/src/shared/components/ClubPreviewCard.tsx
+++ b/apps/mobile/src/shared/components/ClubPreviewCard.tsx
@@ -1,60 +1,74 @@
-import React from 'react'
+import type React from "react";
import {
Image,
- ImageSourcePropType,
- ImageStyle,
- Pressable,
- StyleProp,
+ type ImageSourcePropType,
+ type ImageStyle,
+ type PressableStateCallbackType,
+ type StyleProp,
StyleSheet,
Text,
View,
- ViewStyle,
-} from 'react-native'
-import SkeletonPlaceholder from 'react-native-skeleton-placeholder'
+ type ViewStyle,
+} from "react-native";
+import { Pressable } from "react-native-gesture-handler";
+import SkeletonPlaceholder from "react-native-skeleton-placeholder";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
-export const CLUB_PREVIEW_CARD_WIDTH = s(110)
-export const CLUB_PREVIEW_CARD_TEXT_HEIGHT = vs(54)
-export const CLUB_PREVIEW_CARD_HEIGHT = CLUB_PREVIEW_CARD_WIDTH + CLUB_PREVIEW_CARD_TEXT_HEIGHT
+export const CLUB_PREVIEW_CARD_WIDTH = s(110);
+export const CLUB_PREVIEW_CARD_TEXT_HEIGHT = vs(54);
+export const CLUB_PREVIEW_CARD_HEIGHT =
+ CLUB_PREVIEW_CARD_WIDTH + CLUB_PREVIEW_CARD_TEXT_HEIGHT;
type Props = {
- title: string
- description: string
- imageSource: ImageSourcePropType
- onPress?: () => void
- style?: StyleProp
- imageStyle?: StyleProp
-}
+ title: string;
+ description: string;
+ imageSource: ImageSourcePropType;
+ onPress?: () => void;
+ style?: StyleProp;
+ imageStyle?: StyleProp;
+};
type ClubPreviewCardFrameProps = {
- children: React.ReactNode
- onPress?: () => void
- style?: StyleProp
-}
+ children: React.ReactNode;
+ onPress?: () => void;
+ style?: StyleProp;
+};
-const ClubPreviewCardFrame = ({ children, onPress, style }: ClubPreviewCardFrameProps) => {
- const content = {children}
+const ClubPreviewCardFrame = ({
+ children,
+ onPress,
+ style,
+}: ClubPreviewCardFrameProps) => {
+ const content = {children};
if (!onPress) {
- return {content}
+ return (
+
+ {content}
+
+ );
}
+ const getContainerStyle = ({ pressed }: PressableStateCallbackType) => [
+ styles.shadowContainer,
+ styles.cardWidth,
+ style,
+ pressed && styles.pressed,
+ ];
+
return (
[
- styles.shadowContainer,
- styles.cardWidth,
- style,
- pressed && styles.pressed,
- ]}
- onPress={onPress}>
+ style={getContainerStyle}
+ pressRetentionOffset={{ top: 12, right: 12, bottom: 12, left: 12 }}
+ onPress={onPress}
+ >
{content}
- )
-}
+ );
+};
const ClubPreviewCard = ({
title,
@@ -67,7 +81,11 @@ const ClubPreviewCard = ({
return (
-
+
@@ -78,18 +96,24 @@ const ClubPreviewCard = ({
- )
-}
+ );
+};
export const ClubPreviewCardSkeleton = () => (
-
-
+
+
-
+
(
borderRadius={ms(4)}
marginBottom={vs(3)}
/>
-
+
-)
+);
-export default ClubPreviewCard
+export default ClubPreviewCard;
const styles = StyleSheet.create({
shadowContainer: {
@@ -126,17 +154,17 @@ const styles = StyleSheet.create({
width: CLUB_PREVIEW_CARD_WIDTH,
},
cardContainer: {
- overflow: 'hidden',
+ overflow: "hidden",
backgroundColor: Colors.WHITE,
borderRadius: ms(15),
},
imageWrapper: {
- width: '100%',
+ width: "100%",
aspectRatio: 1,
},
image: {
- width: '100%',
- height: '100%',
+ width: "100%",
+ height: "100%",
},
textWrapper: {
height: CLUB_PREVIEW_CARD_TEXT_HEIGHT,
@@ -155,4 +183,4 @@ const styles = StyleSheet.create({
lineHeight: ms(15),
color: Colors.BODYTEXT_SUB,
},
-})
+});
diff --git a/apps/mobile/src/shared/components/ClubSnsInputList.tsx b/apps/mobile/src/shared/components/ClubSnsInputList.tsx
new file mode 100644
index 00000000..00b5bcea
--- /dev/null
+++ b/apps/mobile/src/shared/components/ClubSnsInputList.tsx
@@ -0,0 +1,136 @@
+import React from "react";
+import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
+import MaterialIcons from "react-native-vector-icons/MaterialIcons";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { MAX_CLUB_SNS_URLS } from "@/shared/utils/clubSns";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ urls: string[];
+ onChange: (urls: string[]) => void;
+};
+
+const ClubSnsInputList = ({ urls, onChange }: Props) => {
+ const updateUrl = (index: number, value: string) => {
+ onChange(urls.map((url, urlIndex) => (urlIndex === index ? value : url)));
+ };
+
+ const removeUrl = (index: number) => {
+ if (urls.length <= 1) {
+ return;
+ }
+ onChange(urls.filter((_, urlIndex) => urlIndex !== index));
+ };
+
+ const addUrl = () => {
+ if (urls.length >= MAX_CLUB_SNS_URLS) {
+ return;
+ }
+ onChange([...urls, ""]);
+ };
+
+ return (
+
+ 동아리 SNS
+ {urls.map((url, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: The index is the row identity while editable URLs may be empty or duplicated.
+
+
+ updateUrl(index, value)}
+ autoCapitalize="none"
+ keyboardType="url"
+ maxLength={200}
+ accessibilityLabel={`동아리 SNS 링크 ${index + 1}`}
+ />
+ {urls.length > 1 && (
+ removeUrl(index)}
+ accessibilityRole="button"
+ accessibilityLabel={`동아리 SNS 링크 ${index + 1} 삭제`}
+ >
+
+
+ )}
+
+ ))}
+ {urls.length < MAX_CLUB_SNS_URLS && (
+
+
+ SNS 링크 추가
+
+ )}
+
+ SNS 링크를 1개 이상 입력해주세요 (최대 3개)
+
+
+ );
+};
+
+export default React.memo(ClubSnsInputList);
+
+const styles = StyleSheet.create({
+ fieldWrapper: {
+ gap: vs(8),
+ },
+ fieldLabel: {
+ ...typography.headerXLSemibold,
+ color: Colors.BODYTEXT_SUB,
+ },
+ inputRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(8),
+ },
+ input: {
+ flex: 1,
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_MAIN,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(8),
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ borderRadius: 8,
+ backgroundColor: Colors.WHITE,
+ },
+ removeButton: {
+ width: ms(32),
+ height: ms(32),
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ addButton: {
+ alignSelf: "flex-start",
+ flexDirection: "row",
+ alignItems: "center",
+ gap: s(4),
+ paddingVertical: vs(4),
+ },
+ addButtonText: {
+ ...typography.bodyMMedium,
+ color: Colors.POINTCOLOR,
+ },
+ helperText: {
+ ...typography.bodyMRegular,
+ color: Colors.POINTCOLOR,
+ },
+});
diff --git a/apps/mobile/src/shared/components/EditPencilButton.tsx b/apps/mobile/src/shared/components/EditPencilButton.tsx
new file mode 100644
index 00000000..6049a9d0
--- /dev/null
+++ b/apps/mobile/src/shared/components/EditPencilButton.tsx
@@ -0,0 +1,67 @@
+import { useState } from "react";
+import { Image, Pressable, StyleSheet } from "react-native";
+
+import { getEditPencilButtonColors } from "@/shared/utils/editPencilButton";
+import { ms } from "@/shared/utils/scale";
+
+const editPencilIcon = require("@/assets/icons/club-edit-pencil.png") as number;
+
+type EditPencilButtonProps = {
+ accessibilityLabel: string;
+ onPress: () => void;
+};
+
+const EditPencilButton = ({
+ accessibilityLabel,
+ onPress,
+}: EditPencilButtonProps) => {
+ const [hovered, setHovered] = useState(false);
+
+ return (
+ setHovered(true)}
+ onHoverOut={() => setHovered(false)}
+ onPress={onPress}
+ style={({ pressed }) => [
+ styles.button,
+ {
+ backgroundColor: getEditPencilButtonColors({ hovered, pressed })
+ .backgroundColor,
+ },
+ ]}
+ >
+ {({ pressed }) => (
+
+ )}
+
+ );
+};
+
+export default EditPencilButton;
+
+const styles = StyleSheet.create({
+ button: {
+ width: ms(28),
+ height: ms(28),
+ borderRadius: ms(14),
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ icon: {
+ width: ms(14),
+ height: ms(14),
+ resizeMode: "contain",
+ },
+});
diff --git a/apps/mobile/src/shared/components/EntryLoginActions.tsx b/apps/mobile/src/shared/components/EntryLoginActions.tsx
new file mode 100644
index 00000000..a2626a3d
--- /dev/null
+++ b/apps/mobile/src/shared/components/EntryLoginActions.tsx
@@ -0,0 +1,207 @@
+import { useMemo } from "react";
+import {
+ ActivityIndicator,
+ Image,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import Animated, {
+ Extrapolation,
+ interpolate,
+ type SharedValue,
+ useAnimatedStyle,
+} from "react-native-reanimated";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+
+type Props = {
+ isLoading: boolean;
+ isReady: boolean;
+ onAppleButtonPress: () => void;
+ onGuestEntryPress: () => void;
+ onKakaoButtonPress: () => void;
+ scaleX: number;
+ scaleY: number;
+ visualProgress: SharedValue;
+};
+
+const EntryLoginActions = ({
+ isLoading,
+ isReady,
+ onAppleButtonPress,
+ onGuestEntryPress,
+ onKakaoButtonPress,
+ scaleX,
+ scaleY,
+ visualProgress,
+}: Props) => {
+ const layoutStyles = useMemo(
+ () => ({
+ actions: {
+ top: 468.44 * scaleY,
+ left: 53.5 * scaleX,
+ width: 295 * scaleX,
+ gap: 12 * scaleY,
+ },
+ button: {
+ height: 48 * scaleY,
+ borderRadius: 12 * scaleX,
+ },
+ icon: {
+ top: 16 * scaleY,
+ left: 16 * scaleX,
+ width: 16 * scaleX,
+ height: 16 * scaleX,
+ },
+ guestAction: {
+ top: 812 * scaleY,
+ left: 53.5 * scaleX,
+ width: 295 * scaleX,
+ },
+ }),
+ [scaleX, scaleY],
+ );
+
+ const animatedStyle = useAnimatedStyle(() => ({
+ opacity: interpolate(
+ visualProgress.value,
+ [2, 3],
+ [0, 1],
+ Extrapolation.CLAMP,
+ ),
+ transform: [
+ {
+ translateY:
+ interpolate(
+ visualProgress.value,
+ [2, 3],
+ [12, 0],
+ Extrapolation.CLAMP,
+ ) * scaleY,
+ },
+ ],
+ }));
+
+ return (
+
+
+ [
+ styles.button,
+ styles.kakaoButton,
+ layoutStyles.button,
+ (pressed || isLoading) && styles.buttonPressed,
+ ]}
+ >
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ 카카오톡으로 계속하기
+ >
+ )}
+
+
+ {Platform.OS === "ios" && (
+ [
+ styles.button,
+ styles.appleButton,
+ layoutStyles.button,
+ (pressed || isLoading) && styles.buttonPressed,
+ ]}
+ >
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ Apple로 계속하기
+ >
+ )}
+
+ )}
+
+
+ [
+ styles.guestAction,
+ layoutStyles.guestAction,
+ (pressed || isLoading) && styles.buttonPressed,
+ ]}
+ >
+ 나중에 로그인 할게요
+
+
+ );
+};
+
+export default EntryLoginActions;
+
+const styles = StyleSheet.create({
+ loginLayer: {
+ ...StyleSheet.absoluteFillObject,
+ },
+ actions: {
+ position: "absolute",
+ },
+ button: {
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ buttonPressed: {
+ opacity: 0.6,
+ },
+ kakaoButton: {
+ backgroundColor: Colors.KAKAO,
+ },
+ appleButton: {
+ backgroundColor: Colors.BLACK,
+ },
+ icon: {
+ position: "absolute",
+ },
+ kakaoText: {
+ ...typography.bodyMMedium,
+ color: Colors.BLACK,
+ },
+ appleText: {
+ ...typography.bodyMMedium,
+ color: Colors.WHITE,
+ },
+ guestAction: {
+ position: "absolute",
+ alignItems: "center",
+ },
+ guestActionText: {
+ ...typography.bodyMRegular,
+ color: Colors.BODYTEXT_SUB,
+ textDecorationLine: "underline",
+ },
+});
diff --git a/apps/mobile/src/shared/components/EntrySplashScreen.tsx b/apps/mobile/src/shared/components/EntrySplashScreen.tsx
new file mode 100644
index 00000000..68d92f1f
--- /dev/null
+++ b/apps/mobile/src/shared/components/EntrySplashScreen.tsx
@@ -0,0 +1,258 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { StatusBar, StyleSheet, View } from "react-native";
+import Animated, {
+ Easing,
+ runOnJS,
+ useReducedMotion,
+ useSharedValue,
+ withDelay,
+ withSequence,
+ withTiming,
+} from "react-native-reanimated";
+import EntryLoginActions from "@/shared/components/EntryLoginActions";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import useEntrySplashAnimatedStyles from "@/shared/hooks/useEntrySplashAnimatedStyles";
+import useEntrySplashViewport from "@/shared/hooks/useEntrySplashViewport";
+import useLoginActions from "@/shared/hooks/useLoginActions";
+import {
+ ENTRY_SPLASH_HOLD_MS,
+ ENTRY_SPLASH_TRANSITION_MS,
+ getEntrySplashStages,
+ shouldExitEntrySplash,
+} from "@/shared/utils/entrySplash";
+
+const FIGMA_EASE_OUT = Easing.bezier(0, 0, 0.58, 1);
+
+const symbolSource =
+ require("@/assets/images/brand/entry-symbol.png") as number;
+const smallWordmarkSource =
+ require("@/assets/images/brand/entry-wordmark-small.png") as number;
+const transitionWordmarkSource =
+ require("@/assets/images/brand/entry-wordmark-transition.png") as number;
+const wordmarkSource =
+ require("@/assets/images/brand/entry-wordmark.png") as number;
+
+type Props = {
+ active: boolean;
+ onComplete: () => void;
+};
+
+const EntrySplashScreen = ({ active, onComplete }: Props) => {
+ const { user, isLoading: isProfileLoading } = useProfile();
+ const reduceMotion = useReducedMotion();
+ const introProgress = useSharedValue(0);
+ const loginProgress = useSharedValue(0);
+ const screenOpacity = useSharedValue(1);
+ const [brandRevealComplete, setBrandRevealComplete] = useState(false);
+ const [loginReady, setLoginReady] = useState(false);
+ const [guestEntryRequested, setGuestEntryRequested] = useState(false);
+ const [finished, setFinished] = useState(false);
+ const { isLoading, onAppleButtonPress, onKakaoButtonPress } =
+ useLoginActions();
+ const isAuthenticated = Boolean(user);
+ const shouldShowLogin = getEntrySplashStages(isAuthenticated).includes(4);
+ const shouldExitSplash = shouldExitEntrySplash({
+ isAuthenticated,
+ guestEntryRequested,
+ });
+ const { contentTop, contentBottom, scaleX, scaleY } =
+ useEntrySplashViewport();
+ const contentStyle = useMemo(
+ () => ({
+ top: contentTop,
+ bottom: contentBottom,
+ }),
+ [contentBottom, contentTop],
+ );
+
+ const {
+ screenStyle,
+ smallWordmarkStyle,
+ symbolStyle,
+ taglineLayoutStyle,
+ taglineStyle,
+ transitionWordmarkStyle,
+ visualProgress,
+ wordmarkStyle,
+ } = useEntrySplashAnimatedStyles({
+ introProgress,
+ loginProgress,
+ scaleX,
+ scaleY,
+ screenOpacity,
+ });
+ const markBrandRevealComplete = useCallback(
+ () => setBrandRevealComplete(true),
+ [],
+ );
+ const markLoginReady = useCallback(() => setLoginReady(true), []);
+ const handleGuestEntryPress = useCallback(
+ () => setGuestEntryRequested(true),
+ [],
+ );
+ const finishEntry = useCallback(() => {
+ setFinished(true);
+ onComplete();
+ }, [onComplete]);
+
+ useEffect(() => {
+ if (!active) return;
+
+ if (reduceMotion) {
+ introProgress.value = 2;
+ setBrandRevealComplete(true);
+ return;
+ }
+
+ introProgress.value = withSequence(
+ withDelay(
+ ENTRY_SPLASH_HOLD_MS,
+ withTiming(1, {
+ duration: ENTRY_SPLASH_TRANSITION_MS,
+ easing: FIGMA_EASE_OUT,
+ }),
+ ),
+ withDelay(
+ ENTRY_SPLASH_HOLD_MS,
+ withTiming(
+ 2,
+ {
+ duration: ENTRY_SPLASH_TRANSITION_MS,
+ easing: FIGMA_EASE_OUT,
+ },
+ (completed) => {
+ if (completed) runOnJS(markBrandRevealComplete)();
+ },
+ ),
+ ),
+ );
+ }, [active, introProgress, markBrandRevealComplete, reduceMotion]);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Login-state transitions intentionally re-evaluate the entry flow.
+ useEffect(() => {
+ if (!active || !brandRevealComplete || isProfileLoading || finished) return;
+
+ if (shouldExitSplash) {
+ const exitDelay =
+ reduceMotion || loginReady || guestEntryRequested
+ ? 0
+ : ENTRY_SPLASH_HOLD_MS;
+ screenOpacity.value = withDelay(
+ exitDelay,
+ withTiming(
+ 0,
+ {
+ duration: reduceMotion ? 0 : ENTRY_SPLASH_TRANSITION_MS,
+ easing: FIGMA_EASE_OUT,
+ },
+ (completed) => {
+ if (completed) runOnJS(finishEntry)();
+ },
+ ),
+ );
+ return;
+ }
+
+ if (loginReady) return;
+
+ if (reduceMotion) {
+ loginProgress.value = 1;
+ markLoginReady();
+ return;
+ }
+
+ loginProgress.value = withDelay(
+ ENTRY_SPLASH_HOLD_MS,
+ withTiming(
+ 1,
+ {
+ duration: ENTRY_SPLASH_TRANSITION_MS,
+ easing: FIGMA_EASE_OUT,
+ },
+ (completed) => {
+ if (completed) runOnJS(markLoginReady)();
+ },
+ ),
+ );
+ }, [
+ active,
+ brandRevealComplete,
+ finishEntry,
+ finished,
+ guestEntryRequested,
+ isProfileLoading,
+ loginReady,
+ loginProgress,
+ markLoginReady,
+ reduceMotion,
+ screenOpacity,
+ shouldExitSplash,
+ shouldShowLogin,
+ ]);
+
+ return (
+
+
+
+
+ 이번 학기엔 동아리까지,
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EntrySplashScreen;
+
+const styles = StyleSheet.create({
+ screen: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 100,
+ backgroundColor: Colors.WHITE,
+ },
+ content: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ },
+ brandAsset: {
+ position: "absolute",
+ resizeMode: "contain",
+ },
+ tagline: {
+ position: "absolute",
+ ...typography.headerL,
+ color: Colors.POINTCOLOR,
+ },
+});
diff --git a/apps/mobile/src/shared/components/FlowScreenFooter/index.tsx b/apps/mobile/src/shared/components/FlowScreenFooter/index.tsx
new file mode 100644
index 00000000..18341039
--- /dev/null
+++ b/apps/mobile/src/shared/components/FlowScreenFooter/index.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from "react";
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import { Colors } from "@/shared/constants/colors";
+
+type Props = {
+ backLabel: string;
+ nextLabel?: string;
+ onBack: () => void;
+ onNext?: () => void;
+ nextDisabled?: boolean;
+ rightSlot?: ReactNode;
+};
+
+const FlowScreenFooter = ({
+ backLabel,
+ nextLabel,
+ onBack,
+ onNext,
+ nextDisabled = false,
+ rightSlot,
+}: Props) => {
+ return (
+
+
+ {backLabel}
+
+ {rightSlot ??
+ (nextLabel && onNext ? (
+
+
+ {nextLabel}
+
+
+ ) : (
+
+ ))}
+
+ );
+};
+
+export default FlowScreenFooter;
+
+const styles = StyleSheet.create({
+ footer: {
+ height: 93,
+ backgroundColor: "#EAEAEA",
+ paddingHorizontal: 20,
+ paddingTop: 16,
+ paddingBottom: 39,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ backButton: {
+ paddingVertical: 10,
+ paddingRight: 16,
+ },
+ backButtonText: {
+ fontSize: 14,
+ fontWeight: "400",
+ color: "#757474",
+ },
+ nextButton: {
+ width: 128,
+ height: 44,
+ borderRadius: 8,
+ backgroundColor: Colors.BUTTON_SELECTED,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ nextButtonDisabled: {
+ backgroundColor: Colors.TEXTBOX_UNSELECTED,
+ },
+ nextButtonText: {
+ fontSize: 16,
+ fontWeight: "600",
+ color: Colors.WHITE,
+ },
+ nextButtonTextDisabled: {
+ color: Colors.TEXT_BUTTON_UNSELECTED,
+ },
+ nextButtonSpacer: {
+ width: 128,
+ height: 44,
+ },
+});
diff --git a/apps/mobile/src/shared/components/FlowScreenLayout/index.tsx b/apps/mobile/src/shared/components/FlowScreenLayout/index.tsx
new file mode 100644
index 00000000..9664d119
--- /dev/null
+++ b/apps/mobile/src/shared/components/FlowScreenLayout/index.tsx
@@ -0,0 +1,81 @@
+import type { ReactNode } from "react";
+import {
+ KeyboardAvoidingView,
+ Platform,
+ ScrollView,
+ StyleSheet,
+ View,
+} from "react-native";
+
+type Props = {
+ children: ReactNode;
+ footer?: ReactNode;
+ backgroundColor?: string;
+ contentTopPadding?: number;
+ contentBottomPadding?: number;
+};
+
+const FlowScreenLayout = ({
+ children,
+ footer,
+ backgroundColor = "#FFFFFF",
+ contentTopPadding = 70,
+ contentBottomPadding = 120,
+}: Props) => {
+ return (
+
+
+
+ {children}
+
+
+ {footer}
+
+
+
+
+ );
+};
+
+export default FlowScreenLayout;
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ },
+ keyboardAvoidingView: {
+ flex: 1,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingHorizontal: 20,
+ },
+ homeIndicatorContainer: {
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 10,
+ alignItems: "center",
+ },
+ homeIndicator: {
+ width: 144,
+ height: 5,
+ borderRadius: 100,
+ backgroundColor: "#000000",
+ },
+});
diff --git a/apps/mobile/src/shared/components/ForceUpdateGate.tsx b/apps/mobile/src/shared/components/ForceUpdateGate.tsx
new file mode 100644
index 00000000..cac91d8e
--- /dev/null
+++ b/apps/mobile/src/shared/components/ForceUpdateGate.tsx
@@ -0,0 +1,107 @@
+import type React from "react";
+import { useCallback, useEffect, useState } from "react";
+import { Linking } from "react-native";
+import SplashScreen from "react-native-splash-screen";
+import AlertModal from "@/shared/components/AlertModal";
+import AppInitializationScreen from "@/shared/components/AppInitializationScreen";
+import EntrySplashScreen from "@/shared/components/EntrySplashScreen";
+import { useProfile } from "@/shared/contexts/profileContext";
+import useForceUpdateCheck from "@/shared/hooks/useForceUpdateCheck";
+import {
+ APP_INITIALIZATION_LOADING_DELAY_MS,
+ shouldShowAppInitializationLoading,
+} from "@/shared/utils/entrySplash";
+
+const SPLASH_MIN_DURATION_MS = 1000;
+
+type Props = {
+ children: React.ReactNode;
+ onEntryComplete: () => void;
+};
+
+const ForceUpdateGate = ({ children, onEntryComplete }: Props) => {
+ const state = useForceUpdateCheck();
+ const { isLoading: isProfileLoading } = useProfile();
+ const [minDelayElapsed, setMinDelayElapsed] = useState(false);
+ const [initializationDelayElapsed, setInitializationDelayElapsed] =
+ useState(false);
+ const [splashHidden, setSplashHidden] = useState(false);
+ const [entryComplete, setEntryComplete] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(
+ () => setMinDelayElapsed(true),
+ SPLASH_MIN_DURATION_MS,
+ );
+ return () => clearTimeout(timer);
+ }, []);
+
+ useEffect(() => {
+ const timer = setTimeout(
+ () => setInitializationDelayElapsed(true),
+ APP_INITIALIZATION_LOADING_DELAY_MS,
+ );
+ return () => clearTimeout(timer);
+ }, []);
+
+ const isUpdateCheckReady = state.status === "ready";
+ const isInitializationReady = isUpdateCheckReady && !isProfileLoading;
+ const showInitializationScreen = shouldShowAppInitializationLoading({
+ delayElapsed: initializationDelayElapsed,
+ isProfileLoading,
+ isUpdateCheckReady,
+ });
+
+ useEffect(() => {
+ const canRevealReactScreen =
+ minDelayElapsed && (isInitializationReady || initializationDelayElapsed);
+
+ if (!splashHidden && canRevealReactScreen) {
+ SplashScreen.hide();
+ setSplashHidden(true);
+ }
+ }, [
+ initializationDelayElapsed,
+ isInitializationReady,
+ minDelayElapsed,
+ splashHidden,
+ ]);
+
+ const handleOpenStore = useCallback((storeUrl: string) => {
+ Linking.openURL(storeUrl).catch(() => {});
+ }, []);
+
+ const noop = useCallback(() => {}, []);
+ const handleEntryComplete = useCallback(() => {
+ setEntryComplete(true);
+ onEntryComplete();
+ }, [onEntryComplete]);
+
+ const showUpdateModal = state.status === "ready" && state.updateRequired;
+
+ return (
+ <>
+ {children}
+ {!entryComplete && isInitializationReady && (
+
+ )}
+ {showInitializationScreen && splashHidden && }
+ {showUpdateModal && (
+ handleOpenStore(state.storeUrl)}
+ dismissOnBackdropPress={false}
+ />
+ )}
+ >
+ );
+};
+
+export default ForceUpdateGate;
diff --git a/apps/mobile/src/shared/components/HeaderProfile.tsx b/apps/mobile/src/shared/components/HeaderProfile.tsx
new file mode 100644
index 00000000..7ce73338
--- /dev/null
+++ b/apps/mobile/src/shared/components/HeaderProfile.tsx
@@ -0,0 +1,54 @@
+import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import { useLoginBottomSheet } from "@/shared/contexts/loginBottomSheetContext";
+import { useProfile } from "@/shared/contexts/profileContext";
+
+const HeaderProfile = () => {
+ const { user } = useProfile();
+
+ const { openBottomSheet } = useLoginBottomSheet();
+
+ return (
+
+ {user ? (
+
+ {user.nickname?.charAt(0) ?? "U"}
+
+ ) : (
+ openBottomSheet()}>
+
+
+
+
+ )}
+
+ );
+};
+
+export default HeaderProfile;
+
+const styles = StyleSheet.create({
+ nicknameContainer: {
+ width: 24,
+ height: 24,
+ borderRadius: 16,
+ backgroundColor: "rgba(0, 0, 0, 0.20)",
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ nickname: {
+ fontSize: 12,
+ fontWeight: "bold",
+ color: "white",
+ textAlign: "center",
+ },
+ defaultImage: {
+ width: 24,
+ height: 24,
+ borderRadius: 16,
+ backgroundColor: "rgba(0, 0, 0, 0.20)",
+ },
+});
diff --git a/apps/mobile/src/shared/components/HorizontalCarousel.tsx b/apps/mobile/src/shared/components/HorizontalCarousel.tsx
new file mode 100644
index 00000000..b62fd8ae
--- /dev/null
+++ b/apps/mobile/src/shared/components/HorizontalCarousel.tsx
@@ -0,0 +1,86 @@
+import { StyleSheet, View } from "react-native";
+import Animated from "react-native-reanimated";
+
+import type { Club } from "@/entities/club";
+import ClubPreviewCard from "@/shared/components/ClubPreviewCard";
+import useAutoScroll from "@/shared/hooks/useAutoScroll";
+import { getClubSummary } from "@/shared/utils/club";
+import { s } from "@/shared/utils/scale";
+
+export const HORIZONTAL_CAROUSEL_BOTTOM_PADDING = s(2);
+
+type Props = {
+ clubs: Club[];
+ onPressClub: (club: Club) => void;
+};
+
+const HorizontalCarousel = ({ clubs, onPressClub }: Props) => {
+ const { listRef, pauseAutoScroll, resumeAutoScroll, ...scrollEventProps } =
+ useAutoScroll(clubs.length);
+
+ const handleScrollBeginDrag = () => {
+ pauseAutoScroll();
+ };
+
+ const handleScrollEndDrag = () => {
+ resumeAutoScroll(350);
+ };
+
+ const handleMomentumScrollBegin = () => {
+ pauseAutoScroll();
+ };
+
+ const handleMomentumScrollEnd = () => {
+ resumeAutoScroll(450);
+ };
+
+ return (
+ resumeAutoScroll(300)}
+ onTouchCancel={() => resumeAutoScroll(300)}
+ >
+ item.id}
+ ItemSeparatorComponent={() => }
+ renderItem={({ item }) => (
+ onPressClub(item)}
+ />
+ )}
+ />
+
+ );
+};
+
+export default HorizontalCarousel;
+
+const styles = StyleSheet.create({
+ list: {
+ width: "100%",
+ overflow: "visible",
+ },
+ contentContainer: {
+ paddingHorizontal: s(20),
+ paddingBottom: HORIZONTAL_CAROUSEL_BOTTOM_PADDING,
+ },
+ itemSeparator: {
+ width: 10,
+ },
+});
diff --git a/apps/mobile/src/shared/components/HtmlView.tsx b/apps/mobile/src/shared/components/HtmlView.tsx
new file mode 100644
index 00000000..faa92e16
--- /dev/null
+++ b/apps/mobile/src/shared/components/HtmlView.tsx
@@ -0,0 +1,52 @@
+import { useCallback, useState } from "react";
+import { type LayoutChangeEvent, View } from "react-native";
+import RenderHtml, {
+ type MixedStyleDeclaration,
+ type RenderHTMLProps,
+} from "react-native-render-html";
+import { Colors } from "../constants/colors";
+import { typography } from "../constants/typography";
+
+type Props = Partial & {
+ html: string;
+};
+
+const BASE_STYLE: MixedStyleDeclaration = {
+ flexWrap: "wrap",
+ whiteSpace: "pre",
+ color: Colors.BODYTEXT_SUB,
+ ...(typography.bodySRegular as MixedStyleDeclaration),
+};
+
+const TAG_STYLES = {
+ p: {
+ marginTop: 0,
+ marginBottom: 0,
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+};
+
+const HtmlView = ({ html, contentWidth, baseStyle, ...props }: Props) => {
+ const [measuredWidth, setMeasuredWidth] = useState(0);
+ const onLayout = useCallback((e: LayoutChangeEvent) => {
+ setMeasuredWidth(e.nativeEvent.layout.width);
+ }, []);
+ const resolvedWidth = contentWidth ?? measuredWidth;
+
+ return (
+
+ {resolvedWidth > 0 && (
+ \n/g, "\n") }}
+ tagsStyles={TAG_STYLES}
+ />
+ )}
+
+ );
+};
+
+export default HtmlView;
diff --git a/apps/mobile/src/shared/components/ImageViewerModal.tsx b/apps/mobile/src/shared/components/ImageViewerModal.tsx
new file mode 100644
index 00000000..a2e6ceff
--- /dev/null
+++ b/apps/mobile/src/shared/components/ImageViewerModal.tsx
@@ -0,0 +1,184 @@
+import { useEffect, useState } from "react";
+import {
+ Modal,
+ Pressable,
+ StatusBar,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import { SafeAreaView } from "react-native-safe-area-context";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import ZoomableImage from "@/shared/components/ZoomableImage";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import {
+ clampImageIndex,
+ getAdjacentImageIndex,
+} from "@/shared/utils/imageViewer";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ imageUrls: string[];
+ initialIndex: number;
+ visible: boolean;
+ onClose: () => void;
+};
+
+const ImageViewerModal = ({
+ imageUrls,
+ initialIndex,
+ visible,
+ onClose,
+}: Props) => {
+ const [currentIndex, setCurrentIndex] = useState(() =>
+ clampImageIndex(initialIndex, imageUrls.length),
+ );
+
+ useEffect(() => {
+ if (visible)
+ setCurrentIndex(clampImageIndex(initialIndex, imageUrls.length));
+ }, [imageUrls.length, initialIndex, visible]);
+
+ if (imageUrls.length === 0) return null;
+
+ const hasPrevious = currentIndex > 0;
+ const hasNext = currentIndex < imageUrls.length - 1;
+ const move = (direction: -1 | 1) => {
+ setCurrentIndex((index) =>
+ getAdjacentImageIndex(index, direction, imageUrls.length),
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+ {currentIndex + 1} / {imageUrls.length}
+
+ [
+ styles.iconButton,
+ pressed && styles.buttonPressed,
+ ]}
+ onPress={onClose}
+ >
+
+
+
+
+
+
+
+
+ {imageUrls.length > 1 && (
+
+ [
+ styles.navigationButton,
+ !hasPrevious && styles.buttonDisabled,
+ pressed && styles.buttonPressed,
+ ]}
+ onPress={() => move(-1)}
+ >
+
+
+ [
+ styles.navigationButton,
+ !hasNext && styles.buttonDisabled,
+ pressed && styles.buttonPressed,
+ ]}
+ onPress={() => move(1)}
+ >
+
+
+
+ )}
+
+
+
+ );
+};
+
+export default ImageViewerModal;
+
+const styles = StyleSheet.create({
+ modalRoot: {
+ flex: 1,
+ backgroundColor: Colors.BLACK,
+ },
+ safeArea: {
+ flex: 1,
+ },
+ header: {
+ minHeight: vs(56),
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: s(12),
+ },
+ headerSpacer: {
+ width: ms(44),
+ height: ms(44),
+ },
+ counter: {
+ ...typography.bodyMMedium,
+ color: Colors.WHITE,
+ },
+ iconButton: {
+ width: ms(44),
+ height: ms(44),
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ viewer: {
+ flex: 1,
+ overflow: "hidden",
+ },
+ controls: {
+ minHeight: vs(64),
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ gap: s(32),
+ paddingHorizontal: s(16),
+ },
+ navigationButton: {
+ width: ms(48),
+ height: ms(48),
+ borderRadius: ms(24),
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: Colors.BODYTEXT_MAIN,
+ },
+ buttonDisabled: {
+ opacity: 0.3,
+ },
+ buttonPressed: {
+ opacity: 0.6,
+ },
+});
diff --git a/apps/mobile/src/shared/components/LoginView.tsx b/apps/mobile/src/shared/components/LoginView.tsx
new file mode 100644
index 00000000..bc8bfc83
--- /dev/null
+++ b/apps/mobile/src/shared/components/LoginView.tsx
@@ -0,0 +1,187 @@
+import { useCallback } from "react";
+import {
+ ActivityIndicator,
+ Image,
+ Platform,
+ Pressable,
+ StyleSheet,
+ Text,
+ View,
+} from "react-native";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import useLoginActions from "@/shared/hooks/useLoginActions";
+import { navigation } from "@/shared/utils/navigation";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ closeBottomSheet: () => void;
+ onSuccess?: () => void;
+};
+
+const LoginView = ({ closeBottomSheet, onSuccess }: Props) => {
+ const handleLoginSuccess = useCallback(() => {
+ closeBottomSheet();
+ onSuccess?.();
+ }, [closeBottomSheet, onSuccess]);
+ const { isLoading, onAppleButtonPress, onKakaoButtonPress } =
+ useLoginActions(handleLoginSuccess);
+
+ return (
+
+
+
+ 로그인
+ 이 필요해요
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ 카카오톡으로 계속하기
+ >
+ )}
+
+ {Platform.OS === "ios" && (
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ Apple로 계속하기
+ >
+ )}
+
+ )}
+
+
+ {
+ closeBottomSheet();
+ navigation.navigate(SCREEN_TYPE.WEBVIEW, {
+ uri: "https://www.all-clear.cc/terms/terms-of-service",
+ title: "서비스 이용약관",
+ });
+ }}
+ style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }]}
+ >
+ 서비스 이용약관
+
+ |
+ {
+ closeBottomSheet();
+ navigation.navigate(SCREEN_TYPE.WEBVIEW, {
+ uri: "https://www.all-clear.cc/terms/privacy-policy",
+ title: "개인정보 처리방침",
+ });
+ }}
+ style={({ pressed }) => [{ opacity: pressed ? 0.5 : 1 }]}
+ >
+ 개인정보 처리방침
+
+
+
+ );
+};
+
+export default LoginView;
+
+const styles = StyleSheet.create({
+ flexRow: {
+ flexDirection: "row",
+ },
+ mainWrapper: {
+ flex: 1,
+ paddingVertical: vs(32),
+ paddingHorizontal: s(24),
+ backgroundColor: Colors.WHITE,
+ },
+ titleWrapper: {
+ justifyContent: "center",
+ alignItems: "center",
+ marginBottom: vs(24),
+ },
+ titleBold: {
+ ...typography.headerL,
+ },
+ titleRegular: {
+ ...typography.headerL,
+ fontFamily: "Pretendard-Regular",
+ },
+ button: {
+ padding: ms(16),
+ borderRadius: ms(12),
+ position: "relative",
+ marginBottom: vs(12),
+ },
+ buttonDisabled: {
+ opacity: 0.6,
+ },
+ icon: {
+ width: s(24),
+ height: vs(24),
+ position: "absolute",
+ top: vs(13),
+ left: s(16),
+ },
+ apple: {
+ backgroundColor: Colors.BLACK,
+ },
+ appleText: {
+ ...typography.bodyMMedium,
+ color: Colors.TEXT_BUTTON_SELECTED,
+ textAlign: "center",
+ },
+ kakao: {
+ backgroundColor: Colors.KAKAO,
+ },
+ kakaoText: {
+ ...typography.bodyMMedium,
+ color: Colors.BLACK,
+ textAlign: "center",
+ },
+ termsRow: {
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: s(8),
+ marginTop: vs(10),
+ },
+ termsDivider: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_DISABLED,
+ },
+ termsLink: {
+ ...typography.bodySRegular,
+ color: Colors.BODYTEXT_DISABLED,
+ },
+});
diff --git a/apps/mobile/src/shared/components/ManageClubView.tsx b/apps/mobile/src/shared/components/ManageClubView.tsx
new file mode 100644
index 00000000..92e7e88d
--- /dev/null
+++ b/apps/mobile/src/shared/components/ManageClubView.tsx
@@ -0,0 +1,3 @@
+import ManageClubView from "./ManageClubView/index";
+
+export default ManageClubView;
diff --git a/apps/mobile/src/shared/components/ManageClubView/index.tsx b/apps/mobile/src/shared/components/ManageClubView/index.tsx
new file mode 100644
index 00000000..712c4d49
--- /dev/null
+++ b/apps/mobile/src/shared/components/ManageClubView/index.tsx
@@ -0,0 +1,181 @@
+import { useState } from "react";
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import AlertModal from "@/shared/components/AlertModal";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { navigation } from "@/shared/utils/navigation";
+
+type Props = {
+ closeBottomSheet: () => void;
+};
+
+const ManageClubView = ({ closeBottomSheet }: Props) => {
+ const [selectedOption, setSelectedOption] = useState("");
+ const [isCampusOnlyModalVisible, setIsCampusOnlyModalVisible] =
+ useState(false);
+
+ const options = [
+ { id: "campus", label: "교내 동아리" },
+ { id: "external", label: "교외 동아리" },
+ { id: "existing", label: "이미 있는 동아리 운영진 등록" },
+ ];
+
+ const isSelectionValid = selectedOption.length > 0;
+
+ const handleSelectionNext = () => {
+ if (!isSelectionValid) return;
+ if (selectedOption === "existing") {
+ // close bottom sheet first, then navigate to full-screen flow
+ closeBottomSheet();
+ setTimeout(() => {
+ navigation.navigate(SCREEN_TYPE.MANAGE_CLUB_REGISTRATION);
+ }, 300);
+ return;
+ }
+
+ if (selectedOption === "external") {
+ setIsCampusOnlyModalVisible(true);
+ return;
+ }
+
+ // 교내: close bottom sheet first, then launch the full-screen registration wizard
+ closeBottomSheet();
+ setTimeout(() => {
+ navigation.navigate("등록");
+ }, 300);
+ };
+
+ const handleCampusOnlyConfirm = () => {
+ setIsCampusOnlyModalVisible(false);
+ closeBottomSheet();
+ };
+
+ return (
+
+ 등록할 동아리의 유형을 선택해주세요
+
+ {options.map((option) => (
+ setSelectedOption(option.id)}
+ >
+
+ {option.label}
+
+
+ ))}
+
+
+
+ 다음
+
+
+
+ setIsCampusOnlyModalVisible(false)}
+ title={"현재 올클 서비스는 교내 동아리\n대상으로만 제공되고 있어요"}
+ description="확장 운영을 위해서 더 노력하는 올클이 될게요"
+ buttonLabel="확인"
+ onButtonPress={handleCampusOnlyConfirm}
+ dismissOnBackdropPress={false}
+ />
+
+ );
+};
+
+export default ManageClubView;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ paddingHorizontal: 20,
+ paddingTop: 30,
+ paddingBottom: 50,
+ borderTopLeftRadius: 12,
+ borderTopRightRadius: 12,
+ backgroundColor: Colors.WHITE,
+ },
+ title: {
+ ...typography.headerXLSemibold,
+ fontSize: 18,
+ lineHeight: 24,
+ color: Colors.BODYTEXT_MAIN,
+ padding: 10,
+ marginBottom: 10,
+ },
+ optionButton: {
+ height: 56,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ alignItems: "flex-start",
+ justifyContent: "center",
+ marginBottom: 10,
+ },
+ optionButtonUnselected: {
+ borderWidth: 1,
+ borderColor: Colors.BODYTEXT_DISABLED,
+ backgroundColor: Colors.WHITE,
+ },
+ optionButtonSelected: {
+ backgroundColor: Colors.TEXTBOX_SELECTED,
+ borderWidth: 0,
+ },
+ optionButtonText: {
+ ...typography.bodyMMedium,
+ fontSize: 16,
+ lineHeight: 24,
+ textAlign: "left",
+ width: "100%",
+ },
+ optionButtonTextUnselected: {
+ color: Colors.BODYTEXT_DISABLED,
+ },
+ optionButtonTextSelected: {
+ color: Colors.BODYTEXT_SUB,
+ },
+ button: {
+ height: 44,
+ paddingHorizontal: 50,
+ paddingVertical: 12,
+ borderRadius: 8,
+ backgroundColor: Colors.BUTTON_SELECTED,
+ alignItems: "center",
+ justifyContent: "center",
+ width: "100%",
+ },
+ buttonDisabled: {
+ backgroundColor: Colors.TEXTBOX_UNSELECTED,
+ },
+ buttonText: {
+ ...typography.headerL,
+ lineHeight: 20,
+ color: Colors.TEXT_BUTTON_SELECTED,
+ },
+ buttonTextDisabled: {
+ color: Colors.TEXT_BUTTON_UNSELECTED,
+ },
+});
diff --git a/src/shared/components/ReviewKeywordPill.tsx b/apps/mobile/src/shared/components/ReviewKeywordPill.tsx
similarity index 53%
rename from src/shared/components/ReviewKeywordPill.tsx
rename to apps/mobile/src/shared/components/ReviewKeywordPill.tsx
index df955fc2..4b479015 100644
--- a/src/shared/components/ReviewKeywordPill.tsx
+++ b/apps/mobile/src/shared/components/ReviewKeywordPill.tsx
@@ -1,15 +1,14 @@
-import React from 'react'
-import { StyleSheet, Text, View } from 'react-native'
+import { StyleSheet, Text, View } from "react-native";
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s } from "@/shared/utils/scale";
type Props = {
- keyword: { iconUri: string; title: string }
- themeColor: string
- backgroundColor: string
-}
+ keyword: { iconUri: string; title: string };
+ themeColor: string;
+ backgroundColor: string;
+};
const ReviewKeywordPill = ({ keyword, themeColor, backgroundColor }: Props) => {
return (
@@ -19,22 +18,25 @@ const ReviewKeywordPill = ({ keyword, themeColor, backgroundColor }: Props) => {
{keyword.title}
- )
-}
+ );
+};
const styles = StyleSheet.create({
pill: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
borderRadius: ms(24),
borderWidth: 0.5,
flexShrink: 1,
- minHeight: vs(20),
+ minHeight: ms(20),
paddingHorizontal: s(6),
- paddingTop: vs(4),
- paddingBottom: vs(5),
+ paddingTop: ms(4),
+ paddingBottom: ms(5),
+ },
+ text: {
+ ...typography.bodyXSRegular,
+ lineHeight: ms(10),
},
- text: typography.bodyXSRegular,
icon: {
fontSize: ms(8.5),
marginRight: s(4),
@@ -43,6 +45,6 @@ const styles = StyleSheet.create({
color: Colors.BODYTEXT_SUB,
flexShrink: 1,
},
-})
+});
-export default ReviewKeywordPill
+export default ReviewKeywordPill;
diff --git a/src/shared/components/TermsAgreementModal.tsx b/apps/mobile/src/shared/components/TermsAgreementModal.tsx
similarity index 59%
rename from src/shared/components/TermsAgreementModal.tsx
rename to apps/mobile/src/shared/components/TermsAgreementModal.tsx
index 1c3d4897..4005ea87 100644
--- a/src/shared/components/TermsAgreementModal.tsx
+++ b/apps/mobile/src/shared/components/TermsAgreementModal.tsx
@@ -1,66 +1,83 @@
+import { BlurView } from "@react-native-community/blur";
+import { useMemo, useState } from "react";
import {
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
- View,
useWindowDimensions,
-} from 'react-native'
-import { BlurView } from '@react-native-community/blur'
-import WebView from 'react-native-webview'
-import Icon from 'react-native-vector-icons/MaterialCommunityIcons'
-import { useMemo, useState } from 'react'
-import BackHeader from '@/shared/components/BackHeader'
-import Button from '@/shared/components/Button'
-import Checkbox from '@/shared/components/Checkbox'
-import { Colors } from '@/shared/constants/colors'
-import { typography } from '@/shared/constants/typography'
-import { ms, s, vs } from '@/shared/utils/scale'
-import { Term } from '@/entities/term'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import Icon from "react-native-vector-icons/MaterialCommunityIcons";
+import WebView from "react-native-webview";
+import type { Term } from "@/entities/term";
+import BackHeader from "@/shared/components/BackHeader";
+import Button from "@/shared/components/Button";
+import Checkbox from "@/shared/components/Checkbox";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { ms, s, vs } from "@/shared/utils/scale";
type Props = {
- visible: boolean
- terms: Term[]
- isSubmitting: boolean
- onAgree: (termUuids: Term['uuid'][]) => void
-}
+ visible: boolean;
+ terms: Term[];
+ isSubmitting: boolean;
+ onAgree: (termUuids: Term["uuid"][]) => void;
+};
-const TermsAgreementModal = ({ visible, terms, isSubmitting, onAgree }: Props) => {
- const { height: windowHeight } = useWindowDimensions()
- const insets = useSafeAreaInsets()
- const modalHeight = Math.min(vs(500), windowHeight - insets.top - insets.bottom - vs(40))
- const [checkedTermUuids, setCheckedTermUuids] = useState([])
- const [viewingTerm, setViewingTerm] = useState(null)
+const TermsAgreementModal = ({
+ visible,
+ terms,
+ isSubmitting,
+ onAgree,
+}: Props) => {
+ const { height: windowHeight } = useWindowDimensions();
+ const insets = useSafeAreaInsets();
+ const modalHeight = Math.min(
+ vs(500),
+ windowHeight - insets.top - insets.bottom - vs(40),
+ );
+ const [checkedTermUuids, setCheckedTermUuids] = useState([]);
+ const [viewingTerm, setViewingTerm] = useState(null);
const mandatoryTermUuids = useMemo(
- () => terms.filter(term => term.isMandatory).map(term => term.uuid),
+ () => terms.filter((term) => term.isMandatory).map((term) => term.uuid),
[terms],
- )
- const allTermUuids = useMemo(() => terms.map(term => term.uuid), [terms])
- const isAllChecked = allTermUuids.length > 0 && checkedTermUuids.length === allTermUuids.length
+ );
+ const allTermUuids = useMemo(() => terms.map((term) => term.uuid), [terms]);
+ const isAllChecked =
+ allTermUuids.length > 0 && checkedTermUuids.length === allTermUuids.length;
const isAgreeButtonEnabled =
- terms.length > 0 && mandatoryTermUuids.every(uuid => checkedTermUuids.includes(uuid))
+ terms.length > 0 &&
+ mandatoryTermUuids.every((uuid) => checkedTermUuids.includes(uuid));
- const handleToggleTerm = (termUuid: Term['uuid']) => {
- setCheckedTermUuids(prev =>
- prev.includes(termUuid) ? prev.filter(uuid => uuid !== termUuid) : [...prev, termUuid],
- )
- }
+ const handleToggleTerm = (termUuid: Term["uuid"]) => {
+ setCheckedTermUuids((prev) =>
+ prev.includes(termUuid)
+ ? prev.filter((uuid) => uuid !== termUuid)
+ : [...prev, termUuid],
+ );
+ };
const handleToggleAll = () => {
- setCheckedTermUuids(isAllChecked ? [] : allTermUuids)
- }
+ setCheckedTermUuids(isAllChecked ? [] : allTermUuids);
+ };
const handleAgree = () => {
- if (!isAgreeButtonEnabled || isSubmitting) return
+ if (!isAgreeButtonEnabled || isSubmitting) return;
- onAgree(checkedTermUuids)
- }
+ onAgree(checkedTermUuids);
+ };
return (
- {}}>
+ {}}
+ >
+ bounces={false}
+ >
{terms.map((term, index) => {
- const isChecked = checkedTermUuids.includes(term.uuid)
+ const isChecked = checkedTermUuids.includes(term.uuid);
return (
+ style={[
+ styles.termRow,
+ index < terms.length - 1 && styles.termRowBorder,
+ ]}
+ >
handleToggleTerm(term.uuid)}
style={styles.termCheckbox}
@@ -111,12 +133,20 @@ const TermsAgreementModal = ({ visible, terms, isSubmitting, onAgree }: Props) =
setViewingTerm(term)}
- style={({ pressed }) => [styles.viewButton, pressed && styles.pressed]}>
+ style={({ pressed }) => [
+ styles.viewButton,
+ pressed && styles.pressed,
+ ]}
+ >
보기
-
+
- )
+ );
})}
@@ -144,7 +174,8 @@ const TermsAgreementModal = ({ visible, terms, isSubmitting, onAgree }: Props) =
setViewingTerm(null)}>
+ onRequestClose={() => setViewingTerm(null)}
+ >
- setViewingTerm(null)} />
+ ]}
+ >
+ setViewingTerm(null)}
+ />
{viewingTerm && (
- )
-}
+ );
+};
-export default TermsAgreementModal
+export default TermsAgreementModal;
const styles = StyleSheet.create({
overlay: {
flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
+ justifyContent: "center",
+ alignItems: "center",
paddingHorizontal: s(20),
},
backdrop: {
@@ -187,7 +222,7 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
},
container: {
- width: '100%',
+ width: "100%",
backgroundColor: Colors.WHITE,
borderRadius: ms(28),
paddingHorizontal: s(24),
@@ -201,8 +236,8 @@ const styles = StyleSheet.create({
gap: vs(24),
},
header: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
},
badge: {
backgroundColor: Colors.POINTCOLOR_10,
@@ -234,9 +269,9 @@ const styles = StyleSheet.create({
height: vs(190),
},
termRow: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
},
termRowBorder: {
borderBottomWidth: 1,
@@ -253,8 +288,8 @@ const styles = StyleSheet.create({
color: Colors.BUTTON_DESTRUCTIVE,
},
viewButton: {
- flexDirection: 'row',
- alignItems: 'center',
+ flexDirection: "row",
+ alignItems: "center",
},
viewButtonText: {
...typography.bodySMedium,
@@ -265,7 +300,7 @@ const styles = StyleSheet.create({
color: Colors.POINTCOLOR,
},
footer: {
- alignItems: 'center',
+ alignItems: "center",
},
button: {
borderRadius: ms(16),
@@ -277,4 +312,4 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: Colors.WHITE,
},
-})
+});
diff --git a/apps/mobile/src/shared/components/TextField.tsx b/apps/mobile/src/shared/components/TextField.tsx
new file mode 100644
index 00000000..a3bef0ac
--- /dev/null
+++ b/apps/mobile/src/shared/components/TextField.tsx
@@ -0,0 +1,101 @@
+import { memo, useState } from "react";
+import {
+ Platform,
+ StyleSheet,
+ TextInput,
+ type TextInputProps,
+ type TextStyle,
+} from "react-native";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+
+const COLORS = {
+ gray300: Colors.BODYTEXT_DISABLED,
+ gray500: Colors.BODYTEXT_SUB,
+} as const;
+
+type TextFieldBorderStyle = {
+ width?: number;
+ color?: string;
+};
+
+type TextFieldValidation = {
+ validate?: (value: string) => boolean;
+ errorMessage?: string;
+};
+
+type Props = Omit & {
+ width?: number;
+ height?: number;
+ border?: TextFieldBorderStyle;
+ validation?: TextFieldValidation;
+ onChangeText?: (value: string, isValid: boolean) => void;
+};
+
+const TextField = ({
+ width,
+ height = 48,
+ border,
+ validation,
+ value = "",
+ onChangeText,
+ placeholderTextColor,
+ style,
+ onFocus,
+ onBlur,
+ ...rest
+}: Props) => {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const isActive = isFocused || (value?.length ?? 0) > 0;
+ const activeColor = isActive ? COLORS.gray500 : COLORS.gray300;
+
+ const handleChangeText = (text: string) => {
+ const isValid = validation?.validate ? validation.validate(text) : true;
+ onChangeText?.(text, isValid);
+ };
+
+ const containerStyle: TextStyle[] = [
+ styles.base,
+ { minHeight: height },
+ { borderWidth: border?.width ?? 1 },
+ { borderColor: border?.color ?? activeColor },
+ width !== undefined ? { width } : { flex: 1 },
+ ];
+
+ const inputStyle: TextStyle[] = [styles.text, { color: activeColor }];
+
+ return (
+ {
+ setIsFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setIsFocused(false);
+ onBlur?.(e);
+ }}
+ placeholderTextColor={placeholderTextColor ?? COLORS.gray300}
+ {...rest}
+ />
+ );
+};
+
+const styles = StyleSheet.create({
+ base: {
+ borderRadius: 8,
+ paddingTop: 0,
+ paddingBottom: Platform.OS === "ios" ? 2 : 0,
+ paddingHorizontal: 20,
+ textAlignVertical: "center",
+ },
+ text: {
+ ...typography.textInputMedium,
+ includeFontPadding: false,
+ },
+});
+
+export default memo(TextField);
diff --git a/apps/mobile/src/shared/components/UserVoiceView.tsx b/apps/mobile/src/shared/components/UserVoiceView.tsx
new file mode 100644
index 00000000..bcfbe1fa
--- /dev/null
+++ b/apps/mobile/src/shared/components/UserVoiceView.tsx
@@ -0,0 +1,157 @@
+import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
+import React, { useContext } from "react";
+import {
+ Keyboard,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ TouchableWithoutFeedback,
+ View,
+} from "react-native";
+import Toast from "react-native-toast-message";
+import { Colors } from "@/shared/constants/colors";
+import { typography } from "@/shared/constants/typography";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { ms, s, vs } from "@/shared/utils/scale";
+
+type Props = {
+ closeBottomSheet: () => void;
+};
+
+const UserVoiceView = ({ closeBottomSheet }: Props) => {
+ const [input, setInput] = React.useState("");
+ const { userService } = useContext(serviceContext);
+ const canSubmit = input.trim().length > 0;
+
+ const handleSubmit = async () => {
+ if (!canSubmit) return;
+
+ try {
+ Keyboard.dismiss();
+ closeBottomSheet();
+ await userService.createUserVoice({ content: input });
+
+ setTimeout(() => {
+ Toast.show({
+ type: "info",
+ text1: "의견이 전송되었어요!",
+ position: "bottom",
+ visibilityTime: 2000,
+ });
+ }, 1000);
+
+ setInput("");
+ } catch {
+ Toast.show({
+ type: "info",
+ text1: `이런! 문제가 생겼어요!`,
+ position: "bottom",
+ visibilityTime: 2000,
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+ 여러분의 의견이 필요해요!
+
+
+ 올클에 건의사항이 있다면 자유롭게 알려주세요😊
+
+
+
+
+
+
+
+
+
+
+ 의견 보내기
+
+
+
+
+ );
+};
+
+export default UserVoiceView;
+
+const styles = StyleSheet.create({
+ mainWrapper: {
+ flex: 1,
+ paddingTop: vs(24),
+ paddingBottom: vs(8),
+ paddingHorizontal: s(24),
+ backgroundColor: Colors.WHITE,
+ },
+
+ titleWrapper: {
+ display: "flex",
+ flex: 0,
+ justifyContent: "center",
+ alignItems: "center",
+ marginBottom: vs(24),
+ },
+
+ title: {
+ ...typography.textInputMedium,
+ color: Colors.BODYTEXT_MAIN,
+ },
+
+ bold: {
+ ...typography.headerL,
+ },
+
+ inputWrapper: {},
+
+ input: {
+ height: vs(144),
+ backgroundColor: Colors.WHITE,
+ paddingHorizontal: s(16),
+ paddingVertical: vs(8),
+ borderRadius: ms(12),
+ ...typography.textInputMedium,
+ color: Colors.BODYTEXT_MAIN,
+ textAlignVertical: "top",
+ },
+
+ buttonWrapper: {
+ marginTop: "auto",
+ width: "100%",
+ },
+
+ button: {
+ backgroundColor: Colors.BODYTEXT_MAIN,
+ padding: s(16),
+ borderRadius: ms(12),
+ },
+ buttonDisabled: {
+ backgroundColor: Colors.BUTTON_UNSELECTED,
+ },
+ buttonText: {
+ ...typography.headerL,
+ color: Colors.WHITE,
+ textAlign: "center",
+ },
+});
diff --git a/apps/mobile/src/shared/components/ZoomableImage.tsx b/apps/mobile/src/shared/components/ZoomableImage.tsx
new file mode 100644
index 00000000..ea1e9fee
--- /dev/null
+++ b/apps/mobile/src/shared/components/ZoomableImage.tsx
@@ -0,0 +1,168 @@
+import { Image, type LayoutChangeEvent, StyleSheet } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from "react-native-reanimated";
+import { clampImageScale, getPanTranslation } from "@/shared/utils/imageViewer";
+
+type Props = {
+ url: string;
+ accessibilityLabel: string;
+};
+
+const DOUBLE_TAP_SCALE = 2;
+
+const ZoomableImage = ({ url, accessibilityLabel }: Props) => {
+ const viewportWidth = useSharedValue(0);
+ const viewportHeight = useSharedValue(0);
+ const scale = useSharedValue(1);
+ const savedScale = useSharedValue(1);
+ const translationX = useSharedValue(0);
+ const translationY = useSharedValue(0);
+ const savedTranslationX = useSharedValue(0);
+ const savedTranslationY = useSharedValue(0);
+ const pinchStartScale = useSharedValue(1);
+ const panStartTranslationX = useSharedValue(0);
+ const panStartTranslationY = useSharedValue(0);
+
+ const handleLayout = (event: LayoutChangeEvent) => {
+ viewportWidth.value = event.nativeEvent.layout.width;
+ viewportHeight.value = event.nativeEvent.layout.height;
+ };
+
+ const resetZoom = () => {
+ "worklet";
+ scale.value = withTiming(1);
+ savedScale.value = 1;
+ translationX.value = withSpring(0);
+ translationY.value = withSpring(0);
+ savedTranslationX.value = 0;
+ savedTranslationY.value = 0;
+ };
+
+ const pinchGesture = Gesture.Pinch()
+ .onStart(() => {
+ pinchStartScale.value = savedScale.value;
+ })
+ .onUpdate((event) => {
+ scale.value = clampImageScale(pinchStartScale.value * event.scale);
+ })
+ .onEnd(() => {
+ savedScale.value = scale.value;
+ if (scale.value <= 1) {
+ resetZoom();
+ return;
+ }
+
+ const clampedTranslation = getPanTranslation({
+ initialTranslation: {
+ x: translationX.value,
+ y: translationY.value,
+ },
+ gestureTranslation: { x: 0, y: 0 },
+ scale: scale.value,
+ viewport: {
+ width: viewportWidth.value,
+ height: viewportHeight.value,
+ },
+ });
+ translationX.value = clampedTranslation.x;
+ translationY.value = clampedTranslation.y;
+ savedTranslationX.value = clampedTranslation.x;
+ savedTranslationY.value = clampedTranslation.y;
+ });
+
+ const panGesture = Gesture.Pan()
+ .averageTouches(true)
+ .onStart(() => {
+ panStartTranslationX.value = savedTranslationX.value;
+ panStartTranslationY.value = savedTranslationY.value;
+ })
+ .onUpdate((event) => {
+ if (scale.value <= 1) return;
+
+ const nextTranslation = getPanTranslation({
+ initialTranslation: {
+ x: panStartTranslationX.value,
+ y: panStartTranslationY.value,
+ },
+ gestureTranslation: {
+ x: event.translationX,
+ y: event.translationY,
+ },
+ scale: scale.value,
+ viewport: {
+ width: viewportWidth.value,
+ height: viewportHeight.value,
+ },
+ });
+ translationX.value = nextTranslation.x;
+ translationY.value = nextTranslation.y;
+ })
+ .onEnd(() => {
+ if (savedScale.value <= 1) {
+ savedTranslationX.value = 0;
+ savedTranslationY.value = 0;
+ return;
+ }
+
+ savedTranslationX.value = translationX.value;
+ savedTranslationY.value = translationY.value;
+ });
+
+ const doubleTapGesture = Gesture.Tap()
+ .numberOfTaps(2)
+ .onEnd((_event, success) => {
+ if (!success) return;
+ if (scale.value > 1) {
+ resetZoom();
+ return;
+ }
+
+ scale.value = withTiming(DOUBLE_TAP_SCALE);
+ savedScale.value = DOUBLE_TAP_SCALE;
+ });
+
+ const gesture = Gesture.Race(
+ doubleTapGesture,
+ Gesture.Simultaneous(pinchGesture, panGesture),
+ );
+ const animatedStyle = useAnimatedStyle(() => ({
+ transform: [
+ { translateX: translationX.value },
+ { translateY: translationY.value },
+ { scale: scale.value },
+ ],
+ }));
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default ZoomableImage;
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ image: {
+ width: "100%",
+ height: "100%",
+ },
+});
diff --git a/apps/mobile/src/shared/constants/category.ts b/apps/mobile/src/shared/constants/category.ts
new file mode 100644
index 00000000..1d812589
--- /dev/null
+++ b/apps/mobile/src/shared/constants/category.ts
@@ -0,0 +1,79 @@
+import type { ImageSourcePropType } from "react-native";
+import type { Category } from "@/entities/category";
+import { Colors } from "@/shared/constants/colors";
+
+export type CategoryDetail = {
+ name: Category["name"];
+ themeColor: string;
+ backgroundColor: string;
+ icon: ImageSourcePropType;
+};
+
+export const CLUB_CATEGORIES: Category["name"][] = [
+ "학술",
+ "종교",
+ "봉사",
+ "공연",
+ "운동",
+ "홍보",
+ "취미",
+ "문화",
+ "진로",
+];
+
+export const CategoryMap: Record = {
+ 학술: {
+ name: "학술",
+ themeColor: Colors.CATEGORY_ACADEMIC,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_ACADEMIC,
+ icon: require("@/assets/icons/category/academic.png"),
+ },
+ 종교: {
+ name: "종교",
+ themeColor: Colors.CATEGORY_RELIGION,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_RELIGION,
+ icon: require("@/assets/icons/category/religion.png"),
+ },
+ 봉사: {
+ name: "봉사",
+ themeColor: Colors.CATEGORY_VOLUNTEER,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_VOLUNTEER,
+ icon: require("@/assets/icons/category/volunteer.png"),
+ },
+ 공연: {
+ name: "공연",
+ themeColor: Colors.CATEGORY_PERFORMANCE,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_PERFORMANCE,
+ icon: require("@/assets/icons/category/performance.png"),
+ },
+ 운동: {
+ name: "운동",
+ themeColor: Colors.CATEGORY_SPORTS,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_WORKOUT,
+ icon: require("@/assets/icons/category/sports.png"),
+ },
+ 홍보: {
+ name: "홍보",
+ themeColor: Colors.CATEGORY_PROMOTION,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_PROMOTION,
+ icon: require("@/assets/icons/category/promotion.png"),
+ },
+ 취미: {
+ name: "취미",
+ themeColor: Colors.CATEGORY_HOBBY,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_HOBBY,
+ icon: require("@/assets/icons/category/hobby.png"),
+ },
+ 문화: {
+ name: "문화",
+ themeColor: Colors.CATEGORY_CULTURE,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_CULTURE,
+ icon: require("@/assets/icons/category/culture.png"),
+ },
+ 진로: {
+ name: "진로",
+ themeColor: Colors.CATEGORY_CAREER,
+ backgroundColor: Colors.CATEGORY_BACKGROUND_CAREER,
+ icon: require("@/assets/icons/category/career.png"),
+ },
+};
diff --git a/apps/mobile/src/shared/constants/club.ts b/apps/mobile/src/shared/constants/club.ts
new file mode 100644
index 00000000..8ccba825
--- /dev/null
+++ b/apps/mobile/src/shared/constants/club.ts
@@ -0,0 +1 @@
+export const CLUB_RECRUIT_TYPES = ["정기", "상시", "미정"];
diff --git a/apps/mobile/src/shared/constants/colors.ts b/apps/mobile/src/shared/constants/colors.ts
new file mode 100644
index 00000000..ba3ab322
--- /dev/null
+++ b/apps/mobile/src/shared/constants/colors.ts
@@ -0,0 +1,68 @@
+export const Colors = {
+ // Default
+ WHITE: "#FFFFFF",
+ BLACK: "#000000",
+ GRAY: "#C1C1C1",
+
+ // Background
+ BACKGROUND_MAIN: "#FAFAFA",
+ BACKGROUND_SUB: "#F3F0F5",
+ BACKGROUND_DIM: "rgba(0, 0, 0, 0.20)",
+ BACKGROUND_DIM_STRONG: "rgba(0, 0, 0, 0.40)",
+
+ // Body Text
+ BODYTEXT_MAIN: "#202020",
+ BODYTEXT_SUB: "#757474",
+ BODYTEXT_SUB_2: "#BCBCBC",
+ BODYTEXT_DISABLED: "#C1C1C1",
+
+ // Button
+ BUTTON_SELECTED: "#874FFF",
+ BUTTON_PUSH: "#4F2E94",
+ BUTTON_UNSELECTED: "#C1C1C1",
+ BADGE_UNSELECTED: "#CBCBCB",
+ BUTTON_DESTRUCTIVE: "#E53935",
+
+ // Text / Button
+ TEXT_BUTTON_SELECTED: "#FFFFFF",
+ TEXT_BUTTON_UNSELECTED: "#C1C1C1",
+
+ // Textbox
+ TEXTBOX_SELECTED: "#EAEAEA",
+ TEXTBOX_UNSELECTED: "#EAEAEA",
+
+ // Point Color
+ POINTCOLOR: "#874FFF",
+ POINTCOLOR_10: "rgba(135, 79, 255, 0.1)",
+ POINTCOLOR_SUB: "#FFFFFF",
+ KAKAO: "#FEE500",
+
+ // Icon
+ ICON_SELECTED: "#202020",
+ ICON_UNSELECTED: "#202020",
+
+ // Footer
+ FOOTER_COLOR: "#FAFAFA",
+
+ // Category Background (pastel)
+ CATEGORY_BACKGROUND_ACADEMIC: "#E5EEFA",
+ CATEGORY_BACKGROUND_RELIGION: "#F7F6F3",
+ CATEGORY_BACKGROUND_VOLUNTEER: "#FAEFE8",
+ CATEGORY_BACKGROUND_PERFORMANCE: "#F8FAEC",
+ CATEGORY_BACKGROUND_WORKOUT: "#EEF7F6",
+ CATEGORY_BACKGROUND_PROMOTION: "#FAF6EB",
+ CATEGORY_BACKGROUND_HOBBY: "#F9EAED",
+ CATEGORY_BACKGROUND_CULTURE: "#F3F5F0",
+ CATEGORY_BACKGROUND_CAREER: "#EEEDED",
+
+ // Category Solid
+ CATEGORY_ACADEMIC: "#2486FF",
+ CATEGORY_RELIGION: "#947C4F",
+ CATEGORY_VOLUNTEER: "#FF8F4A",
+ CATEGORY_PERFORMANCE: "#B7CA2E",
+ CATEGORY_SPORTS: "#2A9D91",
+ CATEGORY_PROMOTION: "#E49E05",
+ CATEGORY_HOBBY: "#F05678",
+ CATEGORY_CULTURE: "#739437",
+ CATEGORY_CAREER: "#847876",
+};
diff --git a/apps/mobile/src/shared/constants/departments.ts b/apps/mobile/src/shared/constants/departments.ts
new file mode 100644
index 00000000..d21f3edb
--- /dev/null
+++ b/apps/mobile/src/shared/constants/departments.ts
@@ -0,0 +1,46 @@
+import type { CollegeMajor } from "@/entities/user";
+
+const CENTRAL_CLUB_OPTION = "중앙동아리";
+const ETC_OPTION = "기타";
+const INTERDISCIPLINARY_MAJOR_PREFIX = "연합전공";
+const LINKED_MAJOR_PREFIX = "연계전공";
+
+const sortDepartmentName = (a: string, b: string) => a.localeCompare(b, "ko");
+
+export const buildDepartmentOptions = (collegeMajors: CollegeMajor[] = []) => {
+ const dynamicDepartments = collegeMajors
+ .map((collegeMajor) => collegeMajor.major || collegeMajor.college)
+ .filter((department): department is string => !!department);
+
+ const uniqueDepartments = Array.from(
+ new Set([CENTRAL_CLUB_OPTION, ...dynamicDepartments, ETC_OPTION]),
+ );
+
+ const regularDepartments = uniqueDepartments
+ .filter(
+ (department) =>
+ department !== CENTRAL_CLUB_OPTION &&
+ department !== ETC_OPTION &&
+ !department.startsWith(INTERDISCIPLINARY_MAJOR_PREFIX) &&
+ !department.startsWith(LINKED_MAJOR_PREFIX),
+ )
+ .sort(sortDepartmentName);
+ const interdisciplinaryMajors = uniqueDepartments
+ .filter((department) =>
+ department.startsWith(INTERDISCIPLINARY_MAJOR_PREFIX),
+ )
+ .sort(sortDepartmentName);
+ const linkedMajors = uniqueDepartments
+ .filter((department) => department.startsWith(LINKED_MAJOR_PREFIX))
+ .sort(sortDepartmentName);
+
+ return [
+ CENTRAL_CLUB_OPTION,
+ ...regularDepartments,
+ ...interdisciplinaryMajors,
+ ...linkedMajors,
+ ETC_OPTION,
+ ];
+};
+
+export const DEFAULT_DEPARTMENT_OPTIONS = buildDepartmentOptions();
diff --git a/apps/mobile/src/shared/constants/localStorage.ts b/apps/mobile/src/shared/constants/localStorage.ts
new file mode 100644
index 00000000..0f17353f
--- /dev/null
+++ b/apps/mobile/src/shared/constants/localStorage.ts
@@ -0,0 +1,5 @@
+export const LOGIN_TOKEN = "token";
+
+export const REVIEW_LAST_ASKED_KEY = "review_last_asked";
+
+export const GUEST_ID = "guest_id";
diff --git a/apps/mobile/src/shared/constants/screen.ts b/apps/mobile/src/shared/constants/screen.ts
new file mode 100644
index 00000000..f15ab799
--- /dev/null
+++ b/apps/mobile/src/shared/constants/screen.ts
@@ -0,0 +1,88 @@
+import type { Club } from "@/entities/club";
+
+export enum SCREEN_TYPE {
+ HOME = "Home",
+
+ SEARCH = "Search",
+
+ CLUB_LIST = "ClubList",
+ SAVED_CLUB_LIST = "SavedClubList",
+
+ CLUB_DETAIL = "ClubDetail",
+ CLUB_REVIEW = "ClubReview",
+ PREVIOUS_RECRUITMENTS = "PreviousRecruitments",
+
+ MYPAGE = "MyPage",
+ EDIT_PROFILE = "EditProfile",
+ MANAGE_CLUB_REGISTRATION = "ManageClubRegistration",
+
+ ANNOUNCEMENT_REGISTRATION = "AnnouncementRegistration",
+ ANNOUNCEMENT_EDIT = "AnnouncementEdit",
+ CLUB_MANAGEMENT = "ClubManagement",
+ CLUB_INFO_EDIT = "ClubInfoEdit",
+
+ WEBVIEW = "WebView",
+}
+
+export type StackParamList = {
+ [SCREEN_TYPE.HOME]: undefined;
+
+ [SCREEN_TYPE.SEARCH]: undefined;
+
+ [SCREEN_TYPE.CLUB_LIST]: {
+ name?: Club["name"];
+ category?: Club["category"];
+ };
+ [SCREEN_TYPE.SAVED_CLUB_LIST]: undefined;
+
+ [SCREEN_TYPE.CLUB_DETAIL]: {
+ uuid: Club["uuid"];
+ category?: Club["category"];
+ entry_point?:
+ | "home"
+ | "search_result"
+ | "club_list"
+ | "club_detail"
+ | "club_review"
+ | "popular_clubs";
+ };
+ [SCREEN_TYPE.CLUB_REVIEW]: {
+ uuid: Club["uuid"];
+ category: Club["category"];
+ };
+ [SCREEN_TYPE.PREVIOUS_RECRUITMENTS]: {
+ clubId: Club["uuid"];
+ clubName: Club["name"];
+ representativeRecruitmentId: number | null;
+ };
+
+ [SCREEN_TYPE.EDIT_PROFILE]: undefined;
+ [SCREEN_TYPE.MYPAGE]: undefined;
+ [SCREEN_TYPE.MANAGE_CLUB_REGISTRATION]:
+ | {
+ clubId: Club["uuid"];
+ editMode: "managerRequest" | "clubRegistration";
+ isResubmission?: boolean;
+ }
+ | undefined;
+
+ [SCREEN_TYPE.ANNOUNCEMENT_REGISTRATION]: { clubId: string };
+ [SCREEN_TYPE.ANNOUNCEMENT_EDIT]: { recruitmentId: number };
+ [SCREEN_TYPE.CLUB_MANAGEMENT]: { clubId: string };
+ [SCREEN_TYPE.CLUB_INFO_EDIT]: {
+ clubId: string;
+ fromClubRegistrationEdit?: boolean;
+ isResubmission?: boolean;
+ managerData?: {
+ name?: string;
+ phone?: string;
+ studentId?: string;
+ };
+ };
+
+ [SCREEN_TYPE.WEBVIEW]: {
+ uri: string;
+ title?: string;
+ authorization?: string;
+ };
+};
diff --git a/apps/mobile/src/shared/constants/typography.ts b/apps/mobile/src/shared/constants/typography.ts
new file mode 100644
index 00000000..a76fa401
--- /dev/null
+++ b/apps/mobile/src/shared/constants/typography.ts
@@ -0,0 +1,62 @@
+import type { TextStyle } from "react-native";
+import { ms, vs } from "@/shared/utils/scale";
+
+type FontWeight =
+ | "100"
+ | "200"
+ | "300"
+ | "400"
+ | "500"
+ | "600"
+ | "700"
+ | "800"
+ | "900";
+
+const fontFamilyByWeight: Record = {
+ "100": "Pretendard-Thin",
+ "200": "Pretendard-ExtraLight",
+ "300": "Pretendard-Light",
+ "400": "Pretendard-Regular",
+ "500": "Pretendard-Medium",
+ "600": "Pretendard-SemiBold",
+ "700": "Pretendard-Bold",
+ "800": "Pretendard-ExtraBold",
+ "900": "Pretendard-Black",
+};
+
+const base = (
+ fontSize: number,
+ fontWeight: FontWeight,
+ lineHeight?: number,
+): TextStyle => ({
+ fontFamily: fontFamilyByWeight[fontWeight],
+ fontSize: ms(fontSize),
+ ...(lineHeight ? { lineHeight: vs(lineHeight) } : {}),
+});
+
+export const typography = {
+ // Header
+ headerXXLSemibold: base(25, "600"),
+ headerXL: base(20, "700"),
+ headerL: base(16, "600"),
+ headerXXL: base(25, "700", 30),
+ headerXLSemibold: base(20, "600", 24),
+ // Body
+ bodyXSRegular: base(10, "400"),
+ bodyXSSemibold: base(10, "600", 14),
+ bodySRegular: base(12, "400", 18),
+ bodySMedium: base(12, "500"),
+ bodySSemibold: base(12, "600", 14),
+ bodySSmallSemibold: base(11, "600", 14),
+ bodySSmallMedium: base(11, "500", 14),
+ bodyMRegular: base(14, "400"),
+ bodyMSemibold: base(14, "600"),
+ bodyMMedium: base(14, "500"),
+ bodyMMedium13px: base(13, "500"),
+ bodyMBold13px: base(13, "700", 22),
+ textInputMedium: {
+ fontFamily: fontFamilyByWeight["500"],
+ fontSize: 16,
+ lineHeight: 24,
+ },
+};
diff --git a/apps/mobile/src/shared/contexts/loginBottomSheetContext.tsx b/apps/mobile/src/shared/contexts/loginBottomSheetContext.tsx
new file mode 100644
index 00000000..61b99b2a
--- /dev/null
+++ b/apps/mobile/src/shared/contexts/loginBottomSheetContext.tsx
@@ -0,0 +1,113 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from "react";
+import { BackHandler, Platform } from "react-native";
+import LoginView from "@/shared/components/LoginView";
+import { vs } from "@/shared/utils/scale";
+
+const LoginBottomSheetContext = createContext<{
+ openBottomSheet: (onSuccess?: () => void) => void;
+ closeBottomSheet: () => void;
+} | null>(null);
+
+export const useLoginBottomSheet = () => {
+ const ctx = useContext(LoginBottomSheetContext);
+ if (!ctx) throw new Error("LoginBottomSheetProvider 안에서 사용해야 합니다");
+ return ctx;
+};
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const LoginBottomSheetProvider = ({ children }: Props) => {
+ const bottomSheetModalRef = useRef(null);
+ const isBottomSheetOpenRef = useRef(false);
+ const onSuccessRef = useRef<(() => void) | undefined>(undefined);
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const openBottomSheet = useCallback((onSuccess?: () => void) => {
+ isBottomSheetOpenRef.current = true;
+ onSuccessRef.current = onSuccess;
+ bottomSheetModalRef.current?.present();
+ }, []);
+
+ const closeBottomSheet = useCallback(() => {
+ isBottomSheetOpenRef.current = false;
+ bottomSheetModalRef.current?.close();
+ }, []);
+
+ const callOnSuccess = useCallback(() => {
+ onSuccessRef.current?.();
+ onSuccessRef.current = undefined;
+ }, []);
+
+ useEffect(() => {
+ if (Platform.OS !== "android") return;
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => {
+ if (!isBottomSheetOpenRef.current) {
+ return false;
+ }
+
+ closeBottomSheet();
+ return true;
+ },
+ );
+
+ return () => subscription.remove();
+ }, [closeBottomSheet]);
+
+ return (
+
+ {children}
+ {
+ isBottomSheetOpenRef.current = false;
+ // 로그인 없이 닫힌 경우 stale 콜백이 다음 로그인에 잘못 실행되지 않도록 정리
+ onSuccessRef.current = undefined;
+ }}
+ backdropComponent={renderBackdrop}
+ >
+
+
+
+
+
+ );
+};
diff --git a/apps/mobile/src/shared/contexts/manageClubBottomSheet.tsx b/apps/mobile/src/shared/contexts/manageClubBottomSheet.tsx
new file mode 100644
index 00000000..2648c361
--- /dev/null
+++ b/apps/mobile/src/shared/contexts/manageClubBottomSheet.tsx
@@ -0,0 +1,119 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from "react";
+import { BackHandler, StyleSheet } from "react-native";
+import ManageClubView from "@/shared/components/ManageClubView";
+import { Colors } from "@/shared/constants/colors";
+
+const ManageClubBottomSheetContext = createContext<{
+ openBottomSheet: () => void;
+ closeBottomSheet: () => void;
+}>({
+ openBottomSheet: () => {},
+ closeBottomSheet: () => {},
+});
+
+export const useManageClubBottomSheet = () =>
+ useContext(ManageClubBottomSheetContext);
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const ManageClubBottomSheetProvider = ({ children }: Props) => {
+ const bottomSheetModalRef = useRef(null);
+ const isBottomSheetOpenRef = useRef(false);
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const openBottomSheet = useCallback(() => {
+ isBottomSheetOpenRef.current = true;
+ bottomSheetModalRef.current?.present();
+ }, []);
+
+ const closeBottomSheet = useCallback(() => {
+ isBottomSheetOpenRef.current = false;
+ bottomSheetModalRef.current?.close();
+ }, []);
+
+ useEffect(() => {
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => {
+ if (!isBottomSheetOpenRef.current) {
+ return false;
+ }
+
+ closeBottomSheet();
+ return true;
+ },
+ );
+
+ return () => subscription.remove();
+ }, [closeBottomSheet]);
+
+ return (
+
+ {children}
+ {
+ isBottomSheetOpenRef.current = false;
+ }}
+ backdropComponent={renderBackdrop}
+ backgroundStyle={styles.background}
+ // Remove the default handle visible on top of the sheet to match Figma
+ handleComponent={() => null}
+ enableHandlePanningGesture={false}
+ handleIndicatorStyle={{ height: 0 }}
+ >
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ background: {
+ backgroundColor: Colors.WHITE,
+ borderTopLeftRadius: 12,
+ borderTopRightRadius: 12,
+ },
+ content: {
+ flex: 1,
+ borderTopLeftRadius: 12,
+ borderTopRightRadius: 12,
+ overflow: "hidden",
+ },
+});
diff --git a/apps/mobile/src/shared/contexts/profileContext.tsx b/apps/mobile/src/shared/contexts/profileContext.tsx
new file mode 100644
index 00000000..ba59615c
--- /dev/null
+++ b/apps/mobile/src/shared/contexts/profileContext.tsx
@@ -0,0 +1,45 @@
+import React, { createContext, useContext, useEffect } from "react";
+import type { User } from "@/entities/user";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+const profileContext = createContext<{
+ user: User | null;
+ isLoading: boolean;
+ setUser: (user: User | null) => void;
+}>({
+ user: null,
+ isLoading: true,
+ setUser: () => {},
+});
+
+export const useProfile = () => useContext(profileContext);
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const ProfileProvider = ({ children }: Props) => {
+ const { userService } = useContext(serviceContext);
+ const [user, setUser] = React.useState(null);
+ const [isLoading, setIsLoading] = React.useState(true);
+
+ useEffect(() => {
+ const getUser = async () => {
+ try {
+ const u = await userService.getUser();
+ setUser(u);
+ } catch {
+ setUser(null);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ getUser();
+ }, [userService]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/apps/mobile/src/shared/contexts/serviceContext.ts b/apps/mobile/src/shared/contexts/serviceContext.ts
new file mode 100644
index 00000000..eafa489c
--- /dev/null
+++ b/apps/mobile/src/shared/contexts/serviceContext.ts
@@ -0,0 +1,30 @@
+import { createContext } from "react";
+import type { AnnouncementService } from "@/usecases/announcement";
+import type { AppVersionService } from "@/usecases/appVersion";
+import type { AuthService } from "@/usecases/auth";
+import type { CategoryService } from "@/usecases/category";
+import type { ClubService } from "@/usecases/club";
+import type { EventLogService } from "@/usecases/eventLog";
+import type { RecentSearchService } from "@/usecases/recentSearch";
+import type { RecruitmentService } from "@/usecases/recruitment";
+import type { ReviewService } from "@/usecases/review";
+import type { TermService } from "@/usecases/term";
+import type { UserService } from "@/usecases/user";
+
+type ServiceContext = {
+ announcementService: AnnouncementService;
+ appVersionService: AppVersionService;
+ authService: AuthService;
+ categoryService: CategoryService;
+ clubService: ClubService;
+ eventLogService: EventLogService;
+ recentSearchService: RecentSearchService;
+ recruitmentService: RecruitmentService;
+ reviewService: ReviewService;
+ termService: TermService;
+ userService: UserService;
+};
+
+export const serviceContext = createContext(
+ {} as ServiceContext,
+);
diff --git a/apps/mobile/src/shared/contexts/userVoiceBottomSheetContext.test.tsx b/apps/mobile/src/shared/contexts/userVoiceBottomSheetContext.test.tsx
new file mode 100644
index 00000000..792107b4
--- /dev/null
+++ b/apps/mobile/src/shared/contexts/userVoiceBottomSheetContext.test.tsx
@@ -0,0 +1,63 @@
+import type { ReactElement, ReactNode } from "react";
+import { Platform } from "react-native";
+import { UserVoiceBottomSheetProvider } from "@/shared/contexts/userVoiceBottomSheetContext";
+
+jest.mock("react", () => {
+ const actual = jest.requireActual("react");
+ return {
+ ...actual,
+ useCallback: (callback: unknown) => callback,
+ useEffect: jest.fn(),
+ useRef: () => ({ current: null }),
+ };
+});
+
+jest.mock("@gorhom/bottom-sheet", () => ({
+ BottomSheetBackdrop: "BottomSheetBackdrop",
+ BottomSheetModal: "BottomSheetModal",
+ BottomSheetView: "BottomSheetView",
+ BottomSheetTextInput: "BottomSheetTextInput",
+}));
+
+jest.mock("@/shared/components/UserVoiceView", () => ({
+ __esModule: true,
+ default: "UserVoiceView",
+}));
+
+type ElementWithProps = ReactElement>;
+
+const findAll = (
+ node: ReactNode,
+ predicate: (element: ElementWithProps) => boolean,
+): ElementWithProps[] => {
+ if (node == null || typeof node !== "object" || !("props" in node)) {
+ return [];
+ }
+
+ const element = node as ElementWithProps;
+ const children = element.props.children;
+
+ return [
+ ...(predicate(element) ? [element] : []),
+ ...(Array.isArray(children)
+ ? children.flatMap((child) => findAll(child, predicate))
+ : findAll(children as ReactNode, predicate)),
+ ];
+};
+
+describe("UserVoiceBottomSheetProvider", () => {
+ it("restores or closes the sheet instead of leaving it partially dragged", () => {
+ const provider = UserVoiceBottomSheetProvider({ children: null });
+ const [modal] = findAll(
+ provider,
+ (element) => element.type === "BottomSheetModal",
+ );
+
+ expect(modal.props).toMatchObject({
+ enablePanDownToClose: true,
+ enableBlurKeyboardOnGesture: true,
+ keyboardBlurBehavior: "restore",
+ snapPoints: [Platform.OS === "ios" ? 440 : 420],
+ });
+ });
+});
diff --git a/apps/mobile/src/shared/contexts/userVoiceBottomSheetContext.tsx b/apps/mobile/src/shared/contexts/userVoiceBottomSheetContext.tsx
new file mode 100644
index 00000000..92e1fb65
--- /dev/null
+++ b/apps/mobile/src/shared/contexts/userVoiceBottomSheetContext.tsx
@@ -0,0 +1,108 @@
+import {
+ BottomSheetBackdrop,
+ type BottomSheetBackdropProps,
+ BottomSheetModal,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import type React from "react";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+} from "react";
+import { BackHandler, Platform, StyleSheet } from "react-native";
+import UserVoiceView from "@/shared/components/UserVoiceView";
+
+const UserVoiceBottomSheetContext = createContext<{
+ openBottomSheet: () => void;
+ closeBottomSheet: () => void;
+}>({
+ openBottomSheet: () => {},
+ closeBottomSheet: () => {},
+});
+
+export const useUserVoiceBottomSheet = () =>
+ useContext(UserVoiceBottomSheetContext);
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const UserVoiceBottomSheetProvider = ({ children }: Props) => {
+ const bottomSheetModalRef = useRef(null);
+ const isBottomSheetOpenRef = useRef(false);
+
+ const renderBackdrop = useCallback(
+ (props: BottomSheetBackdropProps) => (
+
+ ),
+ [],
+ );
+
+ const openBottomSheet = useCallback(() => {
+ isBottomSheetOpenRef.current = true;
+ bottomSheetModalRef.current?.present();
+ }, []);
+
+ const closeBottomSheet = useCallback(() => {
+ isBottomSheetOpenRef.current = false;
+ bottomSheetModalRef.current?.close();
+ }, []);
+
+ useEffect(() => {
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => {
+ if (!isBottomSheetOpenRef.current) {
+ return false;
+ }
+
+ closeBottomSheet();
+ return true;
+ },
+ );
+
+ return () => subscription.remove();
+ }, [closeBottomSheet]);
+
+ return (
+
+ {children}
+ {
+ isBottomSheetOpenRef.current = false;
+ }}
+ backdropComponent={renderBackdrop}
+ >
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ content: {
+ flex: 1,
+ },
+});
diff --git a/apps/mobile/src/shared/hocs/WithViewEventLog.tsx b/apps/mobile/src/shared/hocs/WithViewEventLog.tsx
new file mode 100644
index 00000000..30221750
--- /dev/null
+++ b/apps/mobile/src/shared/hocs/WithViewEventLog.tsx
@@ -0,0 +1,39 @@
+import React, { useContext, useEffect } from "react";
+import { Platform } from "react-native";
+import { getUniqueIdSync } from "react-native-device-info";
+import type { ViewParameter } from "@/entities/eventLog";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+type Props = {
+ params: {
+ screen_name: ViewParameter["screen_name"];
+ [key: string]: string;
+ };
+ children: React.ReactNode;
+};
+
+const WithViewEventLog = ({ params, children }: Props) => {
+ const [isLogged, setIsLogged] = React.useState(false);
+ const { user } = useProfile();
+ const { eventLogService } = useContext(serviceContext);
+
+ useEffect(() => {
+ if (!isLogged) {
+ eventLogService.logEvent({
+ name: "view",
+ parameters: {
+ ...params,
+ device_id: getUniqueIdSync(),
+ device_type: Platform.OS as "ios" | "android",
+ user_id: user?.id ?? "",
+ },
+ });
+ setIsLogged(true);
+ }
+ }, [eventLogService, isLogged, params, user?.id]);
+
+ return <>{children}>;
+};
+
+export default WithViewEventLog;
diff --git a/apps/mobile/src/shared/hooks/useAnnouncementModals.ts b/apps/mobile/src/shared/hooks/useAnnouncementModals.ts
new file mode 100644
index 00000000..0a718db8
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useAnnouncementModals.ts
@@ -0,0 +1,86 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useContext, useEffect, useState } from "react";
+import type { Announcement } from "@/entities/announcement";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import type { AnnouncementService } from "@/usecases/announcement";
+
+type HomeAnnouncementModalItem = {
+ key: string;
+ uuid: string;
+ title: string;
+ description: string;
+};
+
+let hasShownAnnouncementsThisSession = false;
+
+const useAnnouncementModals = () => {
+ const { announcementService } = useContext(serviceContext);
+ const queryClient = useQueryClient();
+ const [modalQueue, setModalQueue] = useState([]);
+ const { data: announcements = [], isSuccess: hasLoadedAnnouncements } =
+ useAnnouncements(announcementService);
+ const dismissAnnouncementsMutation = useDismissAnnouncements(
+ announcementService,
+ queryClient,
+ );
+
+ useEffect(() => {
+ if (!hasLoadedAnnouncements) return;
+ if (hasShownAnnouncementsThisSession) return;
+
+ setModalQueue(announcements.map(createAnnouncementModalItem));
+ hasShownAnnouncementsThisSession = true;
+ }, [announcements, hasLoadedAnnouncements]);
+
+ const handleCloseAnnouncement = () => {
+ setModalQueue((prev) => prev.slice(1));
+ };
+
+ const handleHideAnnouncement = () => {
+ const currentAnnouncement = modalQueue[0];
+
+ if (!currentAnnouncement) return;
+
+ dismissAnnouncementsMutation.mutate([currentAnnouncement.uuid]);
+ setModalQueue((prev) => prev.slice(1));
+ };
+
+ return {
+ currentAnnouncement: modalQueue[0],
+ handleCloseAnnouncement,
+ handleHideAnnouncement,
+ };
+};
+
+export default useAnnouncementModals;
+
+const createAnnouncementModalItem = (
+ announcement: Announcement,
+): HomeAnnouncementModalItem => ({
+ key: `announcement-${announcement.uuid}`,
+ uuid: announcement.uuid,
+ title: announcement.title,
+ description: announcement.content,
+});
+
+const useAnnouncements = (announcementService: AnnouncementService) => {
+ return useQuery({
+ queryKey: ["announcements"],
+ queryFn: () => announcementService.listAnnouncements(),
+ select: (data) => data.data,
+ staleTime: 60 * 1000,
+ });
+};
+
+const useDismissAnnouncements = (
+ announcementService: AnnouncementService,
+ queryClient: ReturnType,
+) => {
+ return useMutation({
+ mutationFn: (announcementUuids: Announcement["uuid"][]) =>
+ announcementService.dismissAnnouncements({ announcementUuids }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["announcements"] });
+ },
+ });
+};
diff --git a/apps/mobile/src/shared/hooks/useAutoScroll.ts b/apps/mobile/src/shared/hooks/useAutoScroll.ts
new file mode 100644
index 00000000..610a7c41
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useAutoScroll.ts
@@ -0,0 +1,121 @@
+import { useFocusEffect } from "@react-navigation/native";
+import { useCallback, useRef } from "react";
+import type { LayoutChangeEvent } from "react-native";
+import type Animated from "react-native-reanimated";
+import {
+ scrollTo,
+ useAnimatedRef,
+ useAnimatedScrollHandler,
+ useFrameCallback,
+ useSharedValue,
+} from "react-native-reanimated";
+
+const AUTO_SCROLL_STEP = 0.5;
+
+/**
+ * 연속 자동 스크롤(마퀴)을 reanimated로 구현한다.
+ * 스크롤 구동(useFrameCallback+scrollTo)과 터치 감지(gesture onBegin)를 같은 UI 스레드에서
+ * 처리해, 자동 스크롤 중 카드를 탭해도 press가 terminate되지 않고 onPress가 발화된다.
+ * 프레임 콜백은 autostart(항상 동작) — 포커스 게이팅 방식은 자동 애니메이션이 시작되지 않는
+ * 회귀가 있어 제거했다.
+ */
+const useAutoScroll = (itemCount: number) => {
+ const listRef = useAnimatedRef>();
+ const offset = useSharedValue(0);
+ const maxOffset = useSharedValue(0);
+ const isInteracting = useSharedValue(false);
+ const latestContentWidth = useRef(0);
+ const latestListWidth = useRef(0);
+ const resumeTimeoutRef = useRef | null>(null);
+
+ useFrameCallback(() => {
+ "worklet";
+ if (itemCount <= 1) return;
+ if (isInteracting.value) return;
+ if (maxOffset.value <= 0) return;
+ if (offset.value >= maxOffset.value) return;
+
+ offset.value = Math.min(offset.value + AUTO_SCROLL_STEP, maxOffset.value);
+ scrollTo(listRef, offset.value, 0, false);
+ }, true);
+
+ const onScroll = useAnimatedScrollHandler({
+ onScroll: (event) => {
+ offset.value = event.contentOffset.x;
+ },
+ onMomentumBegin: () => {
+ isInteracting.value = true;
+ },
+ onMomentumEnd: () => {
+ isInteracting.value = false;
+ },
+ });
+
+ const pauseAutoScroll = () => {
+ if (resumeTimeoutRef.current) {
+ clearTimeout(resumeTimeoutRef.current);
+ resumeTimeoutRef.current = null;
+ }
+
+ isInteracting.value = true;
+ };
+
+ const resumeAutoScroll = (delayMs = 0) => {
+ if (resumeTimeoutRef.current) {
+ clearTimeout(resumeTimeoutRef.current);
+ resumeTimeoutRef.current = null;
+ }
+
+ if (delayMs > 0) {
+ resumeTimeoutRef.current = setTimeout(() => {
+ resumeTimeoutRef.current = null;
+ resumeAutoScroll();
+ }, delayMs);
+ return;
+ }
+
+ isInteracting.value = false;
+ };
+
+ const updateMaxOffset = () => {
+ maxOffset.value = Math.max(
+ latestContentWidth.current - latestListWidth.current,
+ 0,
+ );
+ };
+
+ const handleLayout = (event: LayoutChangeEvent) => {
+ latestListWidth.current = event.nativeEvent.layout.width;
+ updateMaxOffset();
+ };
+
+ const handleContentSizeChange = (width: number) => {
+ latestContentWidth.current = width;
+ updateMaxOffset();
+ };
+
+ // 화면에 다시 진입하면 처음부터 다시 흐르도록 초기화한다.
+ useFocusEffect(
+ // biome-ignore lint/correctness/useExhaustiveDependencies: Reanimated shared values and refs are stable; this reset should run only on focus.
+ useCallback(() => {
+ offset.value = 0;
+ isInteracting.value = false;
+ if (resumeTimeoutRef.current) {
+ clearTimeout(resumeTimeoutRef.current);
+ resumeTimeoutRef.current = null;
+ }
+ listRef.current?.scrollToOffset({ offset: 0, animated: false });
+ }, []),
+ );
+
+ return {
+ listRef,
+ onScroll,
+ onLayout: handleLayout,
+ onContentSizeChange: handleContentSizeChange,
+ pauseAutoScroll,
+ resumeAutoScroll,
+ };
+};
+
+export default useAutoScroll;
diff --git a/apps/mobile/src/shared/hooks/useClickEventLog.tsx b/apps/mobile/src/shared/hooks/useClickEventLog.tsx
new file mode 100644
index 00000000..e91c0fc2
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useClickEventLog.tsx
@@ -0,0 +1,33 @@
+import { useContext } from "react";
+import { Platform } from "react-native";
+import { getUniqueIdSync } from "react-native-device-info";
+import type { ClickParameter } from "@/entities/eventLog";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+const useClickEventLog = () => {
+ const { eventLogService } = useContext(serviceContext);
+ const { user } = useProfile();
+
+ const logClickEvent = (params: {
+ screen_name: ClickParameter["screen_name"];
+ screen_component_name: ClickParameter["screen_component_name"];
+ [key: string]: string;
+ }) => {
+ eventLogService.logEvent({
+ name: "click",
+ parameters: {
+ ...params,
+ device_id: getUniqueIdSync(),
+ device_type: Platform.OS as "ios" | "android",
+ user_id: user?.id ?? "",
+ },
+ });
+ };
+
+ return {
+ logClickEvent,
+ };
+};
+
+export default useClickEventLog;
diff --git a/apps/mobile/src/shared/hooks/useEntrySplashAnimatedStyles.ts b/apps/mobile/src/shared/hooks/useEntrySplashAnimatedStyles.ts
new file mode 100644
index 00000000..157c963f
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useEntrySplashAnimatedStyles.ts
@@ -0,0 +1,189 @@
+import { useMemo } from "react";
+import {
+ Extrapolation,
+ interpolate,
+ type SharedValue,
+ useAnimatedStyle,
+ useDerivedValue,
+} from "react-native-reanimated";
+import {
+ ENTRY_SPLASH_SYMBOL_SIZE,
+ getCenteredScaledAssetOrigin,
+} from "@/shared/utils/entrySplash";
+
+type Params = {
+ introProgress: SharedValue;
+ loginProgress: SharedValue;
+ scaleX: number;
+ scaleY: number;
+ screenOpacity: SharedValue;
+};
+
+const useEntrySplashAnimatedStyles = ({
+ introProgress,
+ loginProgress,
+ scaleX,
+ scaleY,
+ screenOpacity,
+}: Params) => {
+ const visualProgress = useDerivedValue(
+ () => introProgress.value + loginProgress.value,
+ );
+
+ const taglineLayoutStyle = useMemo(
+ () => ({
+ fontFamily: "Pretendard-Bold",
+ fontSize: 18 * scaleX,
+ lineHeight: 30 * scaleX,
+ letterSpacing: -0.36 * scaleX,
+ }),
+ [scaleX],
+ );
+
+ const screenStyle = useAnimatedStyle(() => ({
+ opacity: screenOpacity.value,
+ }));
+
+ const symbolStyle = useAnimatedStyle(() => {
+ const designLeft = interpolate(
+ visualProgress.value,
+ [0, 1, 2, 3],
+ [160.556, 111.03, 79, 78.59],
+ Extrapolation.CLAMP,
+ );
+ const designTop = interpolate(
+ visualProgress.value,
+ [0, 1, 2, 3],
+ [396.561, 396.56, 396.56, 337.56],
+ Extrapolation.CLAMP,
+ );
+
+ return {
+ left: getCenteredScaledAssetOrigin({
+ designOrigin: designLeft,
+ designSize: 80.906,
+ renderedSize: ENTRY_SPLASH_SYMBOL_SIZE,
+ scale: scaleX,
+ }),
+ top: getCenteredScaledAssetOrigin({
+ designOrigin: designTop,
+ designSize: 80.906,
+ renderedSize: ENTRY_SPLASH_SYMBOL_SIZE,
+ scale: scaleY,
+ }),
+ width: ENTRY_SPLASH_SYMBOL_SIZE,
+ height: ENTRY_SPLASH_SYMBOL_SIZE,
+ };
+ });
+
+ const taglineStyle = useAnimatedStyle(() => ({
+ left:
+ interpolate(
+ visualProgress.value,
+ [0, 1, 2, 3],
+ [117.556, 79.44, 79.44, 79.03],
+ Extrapolation.CLAMP,
+ ) * scaleX,
+ top:
+ interpolate(
+ visualProgress.value,
+ [0, 1, 2, 3],
+ [739, 356.56, 356.56, 297.56],
+ Extrapolation.CLAMP,
+ ) * scaleY,
+ }));
+
+ const smallWordmarkStyle = useAnimatedStyle(() => ({
+ left:
+ interpolate(
+ visualProgress.value,
+ [0, 1],
+ [164.556, 194.03],
+ Extrapolation.CLAMP,
+ ) * scaleX,
+ top:
+ interpolate(
+ visualProgress.value,
+ [0, 1],
+ [779, 406.5],
+ Extrapolation.CLAMP,
+ ) * scaleY,
+ width:
+ interpolate(
+ visualProgress.value,
+ [0, 1],
+ [72.497, 96.932],
+ Extrapolation.CLAMP,
+ ) * scaleX,
+ height:
+ interpolate(
+ visualProgress.value,
+ [0, 1],
+ [29.294, 59.489],
+ Extrapolation.CLAMP,
+ ) * scaleX,
+ opacity: interpolate(
+ visualProgress.value,
+ [0, 0.7, 1],
+ [1, 1, 0],
+ Extrapolation.CLAMP,
+ ),
+ }));
+
+ const transitionWordmarkStyle = useAnimatedStyle(() => ({
+ left:
+ interpolate(
+ visualProgress.value,
+ [1, 2],
+ [194.03, 174.91],
+ Extrapolation.CLAMP,
+ ) * scaleX,
+ top: 406.5 * scaleY,
+ width: 96.932 * scaleX,
+ height: 59.489 * scaleX,
+ opacity: interpolate(
+ visualProgress.value,
+ [0.7, 1, 1.7, 2],
+ [0, 1, 1, 0],
+ Extrapolation.CLAMP,
+ ),
+ }));
+
+ const wordmarkStyle = useAnimatedStyle(() => ({
+ left:
+ interpolate(
+ visualProgress.value,
+ [2, 3],
+ [174.91, 174.5],
+ Extrapolation.CLAMP,
+ ) * scaleX,
+ top:
+ interpolate(
+ visualProgress.value,
+ [2, 3],
+ [406.5, 347.5],
+ Extrapolation.CLAMP,
+ ) * scaleY,
+ width: 147.222 * scaleX,
+ height: 59.489 * scaleX,
+ opacity: interpolate(
+ visualProgress.value,
+ [1.7, 2],
+ [0, 1],
+ Extrapolation.CLAMP,
+ ),
+ }));
+
+ return {
+ screenStyle,
+ smallWordmarkStyle,
+ symbolStyle,
+ taglineLayoutStyle,
+ taglineStyle,
+ transitionWordmarkStyle,
+ visualProgress,
+ wordmarkStyle,
+ };
+};
+
+export default useEntrySplashAnimatedStyles;
diff --git a/apps/mobile/src/shared/hooks/useEntrySplashViewport.ts b/apps/mobile/src/shared/hooks/useEntrySplashViewport.ts
new file mode 100644
index 00000000..3dea0061
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useEntrySplashViewport.ts
@@ -0,0 +1,32 @@
+import { useRef } from "react";
+import {
+ useSafeAreaFrame,
+ useSafeAreaInsets,
+} from "react-native-safe-area-context";
+import {
+ type EntrySplashLayoutMetrics,
+ getEntrySplashViewport,
+ resolveEntrySplashLayoutMetrics,
+} from "@/shared/utils/entrySplash";
+
+const useEntrySplashViewport = () => {
+ const frame = useSafeAreaFrame();
+ const insets = useSafeAreaInsets();
+ const initialMetricsRef = useRef(null);
+ const currentMetrics = {
+ width: frame.width,
+ height: frame.height,
+ topInset: insets.top,
+ bottomInset: insets.bottom,
+ };
+ const layoutMetrics = resolveEntrySplashLayoutMetrics(
+ initialMetricsRef.current,
+ currentMetrics,
+ );
+
+ initialMetricsRef.current = layoutMetrics;
+
+ return getEntrySplashViewport(layoutMetrics);
+};
+
+export default useEntrySplashViewport;
diff --git a/apps/mobile/src/shared/hooks/useExposeEventLog.tsx b/apps/mobile/src/shared/hooks/useExposeEventLog.tsx
new file mode 100644
index 00000000..402dbaa1
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useExposeEventLog.tsx
@@ -0,0 +1,33 @@
+import { useContext } from "react";
+import { Platform } from "react-native";
+import { getUniqueIdSync } from "react-native-device-info";
+import type { ExposeParameter } from "@/entities/eventLog";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+const useExposeEventLog = () => {
+ const { eventLogService } = useContext(serviceContext);
+ const { user } = useProfile();
+
+ const logExposeEvent = (params: {
+ screen_name: ExposeParameter["screen_name"];
+ expose_type: ExposeParameter["expose_type"];
+ [key: string]: string;
+ }) => {
+ eventLogService.logEvent({
+ name: "expose",
+ parameters: {
+ ...params,
+ device_id: getUniqueIdSync(),
+ device_type: Platform.OS as "ios" | "android",
+ user_id: user?.id ?? "",
+ },
+ });
+ };
+
+ return {
+ logExposeEvent,
+ };
+};
+
+export default useExposeEventLog;
diff --git a/apps/mobile/src/shared/hooks/useForceUpdateCheck.tsx b/apps/mobile/src/shared/hooks/useForceUpdateCheck.tsx
new file mode 100644
index 00000000..adc33de9
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useForceUpdateCheck.tsx
@@ -0,0 +1,54 @@
+import { useContext, useEffect, useState } from "react";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+
+export type ForceUpdateCheckState =
+ | { status: "checking" }
+ | { status: "ready"; updateRequired: false }
+ | {
+ status: "ready";
+ updateRequired: true;
+ minSupportedVersion: string;
+ storeUrl: string;
+ };
+
+const useForceUpdateCheck = (): ForceUpdateCheckState => {
+ const { appVersionService } = useContext(serviceContext);
+ const [state, setState] = useState({
+ status: "checking",
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = async () => {
+ try {
+ const result = await appVersionService.checkCurrentVersion();
+ if (cancelled) return;
+ if (result.updateRequired) {
+ setState({
+ status: "ready",
+ updateRequired: true,
+ minSupportedVersion: result.minSupportedVersion,
+ storeUrl: result.storeUrl,
+ });
+ } else {
+ setState({ status: "ready", updateRequired: false });
+ }
+ } catch {
+ // 네트워크/서버 장애 시 fail-open: 사용자가 앱을 사용할 수 있도록 통과시킨다.
+ if (cancelled) return;
+ setState({ status: "ready", updateRequired: false });
+ }
+ };
+
+ run();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [appVersionService]);
+
+ return state;
+};
+
+export default useForceUpdateCheck;
diff --git a/apps/mobile/src/shared/hooks/useLoginActions.ts b/apps/mobile/src/shared/hooks/useLoginActions.ts
new file mode 100644
index 00000000..58eb8279
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useLoginActions.ts
@@ -0,0 +1,98 @@
+import appleAuth from "@invertase/react-native-apple-authentication";
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { login as kakaoLogin } from "@react-native-seoul/kakao-login";
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback, useContext, useState } from "react";
+import Toast from "react-native-toast-message";
+import { LOGIN_TOKEN } from "@/shared/constants/localStorage";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import { setToken } from "@/shared/utils/api";
+import { AuthProvider } from "@/usecases/auth";
+
+const useLoginActions = (onSuccess?: () => void) => {
+ const queryClient = useQueryClient();
+ const { setUser } = useProfile();
+ const { authService, userService, clubService, recentSearchService } =
+ useContext(serviceContext);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleLoginSuccess = useCallback(
+ async (token: string) => {
+ await AsyncStorage.setItem(LOGIN_TOKEN, token);
+ setToken(token);
+ const user = await userService.getUser();
+ setUser(user);
+ queryClient.invalidateQueries(["savedClubs"]);
+ await Promise.all([
+ queryClient.prefetchQuery(
+ ["savedClubs"],
+ () => clubService.listSavedClubs(),
+ {
+ staleTime: Infinity,
+ },
+ ),
+ queryClient.prefetchQuery(["recentSearches"], () =>
+ recentSearchService.listRecentSearches(),
+ ),
+ ]);
+ onSuccess?.();
+ queryClient.invalidateQueries(["manageClubs"]);
+ Toast.show({ type: "info", text1: "로그인 되었어요!" });
+ },
+ [
+ clubService,
+ onSuccess,
+ queryClient,
+ recentSearchService,
+ setUser,
+ userService,
+ ],
+ );
+
+ const onAppleButtonPress = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const response = await appleAuth.performRequest({
+ requestedOperation: appleAuth.Operation.LOGIN,
+ requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL],
+ });
+
+ if (!response.identityToken) return;
+
+ const token = await authService.callback(
+ AuthProvider.APPLE,
+ response.identityToken,
+ );
+ await handleLoginSuccess(token);
+ } catch {
+ Toast.show({ type: "info", text1: "로그인에 실패했어요!" });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [authService, handleLoginSuccess]);
+
+ const onKakaoButtonPress = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const result = await kakaoLogin();
+ const token = await authService.callback(
+ AuthProvider.KAKAO,
+ result.accessToken,
+ );
+ await handleLoginSuccess(token);
+ } catch {
+ Toast.show({ type: "info", text1: "로그인에 실패했어요!" });
+ } finally {
+ setIsLoading(false);
+ }
+ }, [authService, handleLoginSuccess]);
+
+ return {
+ isLoading,
+ onAppleButtonPress,
+ onKakaoButtonPress,
+ };
+};
+
+export default useLoginActions;
diff --git a/src/shared/hooks/usePendingTerms.ts b/apps/mobile/src/shared/hooks/usePendingTerms.ts
similarity index 55%
rename from src/shared/hooks/usePendingTerms.ts
rename to apps/mobile/src/shared/hooks/usePendingTerms.ts
index 3f9bd0b5..ddbf273f 100644
--- a/src/shared/hooks/usePendingTerms.ts
+++ b/apps/mobile/src/shared/hooks/usePendingTerms.ts
@@ -1,56 +1,57 @@
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { useContext } from 'react'
-import { useProfile } from '@/shared/contexts/profileContext'
-import { serviceContext } from '@/shared/contexts/serviceContext'
-import { TermService } from '@/usecases/term'
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useContext } from "react";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import type { TermService } from "@/usecases/term";
const usePendingTerms = () => {
- const { termService } = useContext(serviceContext)
- const { user, isLoading: isProfileLoading } = useProfile()
- const queryClient = useQueryClient()
+ const { termService } = useContext(serviceContext);
+ const { user, isLoading: isProfileLoading } = useProfile();
+ const queryClient = useQueryClient();
const {
data: pendingTerms = [],
isFetched: hasFetchedPendingTerms,
isError: hasPendingTermsError,
- } = usePendingTermsQuery(termService, !isProfileLoading && !!user)
- const agreeTermsMutation = useAgreeTerms(termService, queryClient)
+ } = usePendingTermsQuery(termService, !isProfileLoading && !!user);
+ const agreeTermsMutation = useAgreeTerms(termService, queryClient);
const shouldShowTermsModal = isProfileLoading
? null
: !user
? false
: hasFetchedPendingTerms || hasPendingTermsError
? pendingTerms.length > 0
- : null
+ : null;
return {
pendingTerms,
isSubmitting: agreeTermsMutation.isPending,
shouldShowTermsModal,
handleAgreeTerms: agreeTermsMutation.mutate,
- }
-}
+ };
+};
-export default usePendingTerms
+export default usePendingTerms;
const usePendingTermsQuery = (termService: TermService, enabled: boolean) => {
return useQuery({
- queryKey: ['terms'],
+ queryKey: ["terms"],
queryFn: () => termService.listTerms(),
- select: data => data.data,
+ select: (data) => data.data,
enabled,
staleTime: 60 * 1000,
retry: false,
- })
-}
+ });
+};
const useAgreeTerms = (
termService: TermService,
queryClient: ReturnType,
) => {
return useMutation({
- mutationFn: ({ termUuids }: { termUuids: string[] }) => termService.agreeTerms({ termUuids }),
+ mutationFn: ({ termUuids }: { termUuids: string[] }) =>
+ termService.agreeTerms({ termUuids }),
onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['terms'] })
+ queryClient.invalidateQueries({ queryKey: ["terms"] });
},
- })
-}
+ });
+};
diff --git a/apps/mobile/src/shared/hooks/useRequireLogin.ts b/apps/mobile/src/shared/hooks/useRequireLogin.ts
new file mode 100644
index 00000000..9ea5240d
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useRequireLogin.ts
@@ -0,0 +1,17 @@
+import { useLoginBottomSheet } from "@/shared/contexts/loginBottomSheetContext";
+import { useProfile } from "@/shared/contexts/profileContext";
+
+const useRequireLogin = () => {
+ const { user } = useProfile();
+ const { openBottomSheet } = useLoginBottomSheet();
+
+ return (action: () => void) => {
+ if (!user) {
+ openBottomSheet(action);
+ return;
+ }
+ action();
+ };
+};
+
+export default useRequireLogin;
diff --git a/apps/mobile/src/shared/hooks/useSaveClub.ts b/apps/mobile/src/shared/hooks/useSaveClub.ts
new file mode 100644
index 00000000..1909aa70
--- /dev/null
+++ b/apps/mobile/src/shared/hooks/useSaveClub.ts
@@ -0,0 +1,158 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useContext, useEffect, useRef } from "react";
+import Toast from "react-native-toast-message";
+import type { Club } from "@/entities/club";
+import type { ListSavedClubsResponse } from "@/repositories/club";
+import { useProfile } from "@/shared/contexts/profileContext";
+import { serviceContext } from "@/shared/contexts/serviceContext";
+import useRequireLogin from "@/shared/hooks/useRequireLogin";
+
+const selectSavedIds = (data: ListSavedClubsResponse) =>
+ new Set(data.clubs.map((c) => c.uuid)); // 모듈 레벨에서 선언하여 매번 새로 생성하지 않음
+
+const useSaveClub = (club: Club | undefined) => {
+ const { user } = useProfile();
+ const requireLogin = useRequireLogin();
+ const { clubService } = useContext(serviceContext);
+ const queryClient = useQueryClient();
+
+ // 저장 여부의 SoT는 savedClubs 캐시
+ const { data: savedIds } = useQuery(
+ ["savedClubs"],
+ () => clubService.listSavedClubs(),
+ {
+ enabled: !!user,
+ staleTime: Infinity,
+ select: selectSavedIds, // Set 형태로 저장
+ },
+ );
+
+ const isSaved = club ? (savedIds?.has(club.uuid) ?? false) : false; // 저장 여부 계산
+
+ const serverIsSavedRef = useRef(isSaved); // 기존 서버 상태 (skip/롤백용)
+ const pendingShouldSaveRef = useRef(null); // 대기 중인 토글의 최종 목표 상태 (null: 대기 없음)
+
+ const debounceRef = useRef | null>(null);
+ const hasPendingRef = useRef(false); // API 요청이 진행 중인지 여부
+ const callApiRef = useRef<(shouldSave: boolean) => Promise>(); // flush 시점에 최신 callApi 참조용
+
+ // serverIsSavedRef 초기화
+ useEffect(() => {
+ if (!hasPendingRef.current) {
+ serverIsSavedRef.current = club
+ ? (savedIds?.has(club.uuid) ?? false)
+ : false;
+ }
+ }, [savedIds, club]);
+
+ // savedClubs 캐시 업데이트
+ const updateSavedClubsCache = useCallback(
+ async (shouldSave: boolean) => {
+ if (!club) return;
+ // 진행 중인 refetch가 optimistic 값을 덮어쓰지 못하도록 먼저 취소
+ await queryClient.cancelQueries(["savedClubs"]);
+
+ queryClient.setQueriesData(
+ ["savedClubs"],
+ (old: { clubs: Club[]; totalSize: number } | undefined) => {
+ if (!old) return old;
+ const alreadyIn = old.clubs.some((c) => c.uuid === club.uuid);
+ if (shouldSave) {
+ if (alreadyIn) return old;
+ return {
+ ...old,
+ clubs: [...old.clubs, club],
+ totalSize: old.totalSize + 1,
+ };
+ }
+ return {
+ ...old,
+ clubs: old.clubs.filter((c) => c.uuid !== club.uuid),
+ totalSize: alreadyIn ? old.totalSize - 1 : old.totalSize,
+ };
+ },
+ );
+ },
+ [club, queryClient],
+ );
+
+ // API 호출 함수
+ const callApi = useCallback(
+ async (shouldSave: boolean) => {
+ if (!club) return;
+ // 서버 상태와 동일하면(연속 토글로 원상복귀 등) 불필요한 요청 skip
+ if (shouldSave === serverIsSavedRef.current) {
+ hasPendingRef.current = false;
+ pendingShouldSaveRef.current = null;
+ return;
+ }
+ try {
+ if (shouldSave) {
+ await clubService.createSavedClub({ clubId: club.uuid });
+ } else {
+ await clubService.removeSavedClub({ clubId: club.uuid });
+ }
+ serverIsSavedRef.current = shouldSave;
+ } catch {
+ // 실패 시 서버 상태로 롤백
+ await updateSavedClubsCache(serverIsSavedRef.current);
+ Toast.show({ type: "info", text1: "저장에 실패했어요." });
+ } finally {
+ hasPendingRef.current = false;
+ pendingShouldSaveRef.current = null;
+ }
+ },
+ [club, clubService, updateSavedClubsCache],
+ );
+
+ // 토글 핸들러
+ const handleToggle = useCallback(() => {
+ if (!user) {
+ // 로그인 시트만 띄우고 빈 콜백을 넘긴다. requireLogin에 토글 로직을 넘기면
+ // 로그인 성공 직후 자동 저장되는데, 여기서는 의도적으로 그 동작을 하지 않는다.
+ requireLogin(() => {});
+ return;
+ }
+ if (!club) return;
+
+ const displayed = savedIds?.has(club.uuid) ?? false;
+ const baseline =
+ pendingShouldSaveRef.current !== null
+ ? pendingShouldSaveRef.current
+ : displayed;
+ const next = !baseline;
+
+ void updateSavedClubsCache(next);
+ hasPendingRef.current = true;
+ pendingShouldSaveRef.current = next;
+
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ debounceRef.current = setTimeout(() => {
+ debounceRef.current = null;
+ callApi(next);
+ }, 300);
+ }, [user, club, savedIds, requireLogin, callApi, updateSavedClubsCache]);
+
+ // callApiRef를 항상 최신 callApi로 유지
+ useEffect(() => {
+ callApiRef.current = callApi;
+ }, [callApi]);
+
+ // unmount 시 pending debounce가 있으면 취소 대신 즉시 API 호출
+ useEffect(
+ () => () => {
+ if (
+ debounceRef.current !== null &&
+ pendingShouldSaveRef.current !== null
+ ) {
+ clearTimeout(debounceRef.current);
+ callApiRef.current?.(pendingShouldSaveRef.current); // api 호출
+ }
+ },
+ [],
+ );
+
+ return { isSaved, handleToggle };
+};
+
+export default useSaveClub;
diff --git a/apps/mobile/src/shared/types/image.ts b/apps/mobile/src/shared/types/image.ts
new file mode 100644
index 00000000..61f16b03
--- /dev/null
+++ b/apps/mobile/src/shared/types/image.ts
@@ -0,0 +1,11 @@
+export type ImageFile = {
+ uri: string;
+ type: string;
+ name: string;
+};
+
+export type EditableImage = {
+ id: string;
+ uri: string;
+ file?: ImageFile;
+};
diff --git a/apps/mobile/src/shared/utils/activityImage.test.ts b/apps/mobile/src/shared/utils/activityImage.test.ts
new file mode 100644
index 00000000..f3544371
--- /dev/null
+++ b/apps/mobile/src/shared/utils/activityImage.test.ts
@@ -0,0 +1,22 @@
+import {
+ isActivityImageFileSizeAllowed,
+ MAX_ACTIVITY_IMAGE_FILE_SIZE_BYTES,
+} from "@/shared/utils/activityImage";
+
+describe("activity image validation", () => {
+ it("accepts an activity image whose size is exactly 10MB", () => {
+ expect(
+ isActivityImageFileSizeAllowed(MAX_ACTIVITY_IMAGE_FILE_SIZE_BYTES),
+ ).toBe(true);
+ });
+
+ it("rejects an activity image whose size exceeds 10MB", () => {
+ expect(
+ isActivityImageFileSizeAllowed(MAX_ACTIVITY_IMAGE_FILE_SIZE_BYTES + 1),
+ ).toBe(false);
+ });
+
+ it("allows an activity image when the picker does not provide its file size", () => {
+ expect(isActivityImageFileSizeAllowed(undefined)).toBe(true);
+ });
+});
diff --git a/apps/mobile/src/shared/utils/activityImage.ts b/apps/mobile/src/shared/utils/activityImage.ts
new file mode 100644
index 00000000..1905387f
--- /dev/null
+++ b/apps/mobile/src/shared/utils/activityImage.ts
@@ -0,0 +1,9 @@
+export const MAX_ACTIVITY_IMAGE_FILE_SIZE_BYTES = 10 * 1024 * 1024;
+
+export const isActivityImageFileSizeAllowed = (
+ fileSize: number | undefined,
+): boolean =>
+ fileSize === undefined ||
+ (Number.isFinite(fileSize) &&
+ fileSize >= 0 &&
+ fileSize <= MAX_ACTIVITY_IMAGE_FILE_SIZE_BYTES);
diff --git a/apps/mobile/src/shared/utils/api.ts b/apps/mobile/src/shared/utils/api.ts
new file mode 100644
index 00000000..96e94bb8
--- /dev/null
+++ b/apps/mobile/src/shared/utils/api.ts
@@ -0,0 +1,59 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import axios, { type AxiosRequestConfig } from "axios";
+import { ENV } from "@/config/ENV";
+import { LOGIN_TOKEN } from "@/shared/constants/localStorage";
+import { getOrCreateGuestId } from "@/shared/utils/guestId";
+
+let _token: string | null = null;
+
+export const setToken = (token: string | null) => {
+ _token = token;
+};
+
+export const initToken = async () => {
+ _token = await AsyncStorage.getItem(LOGIN_TOKEN);
+};
+
+const _apiInstance = axios.create({
+ baseURL: ENV.API_SERVER_BASE_URL,
+ timeout: 3000,
+ headers: { Accept: "application/json" },
+});
+
+_apiInstance.interceptors.request.use(async (config) => {
+ if (_token) {
+ config.headers.Authorization = `Bearer ${_token}`;
+ config.headers["x-authorization"] = `Bearer ${_token}`;
+ } else {
+ config.headers["x-guest-id"] = await getOrCreateGuestId();
+ }
+ return config;
+});
+
+const request = async (
+ path: string,
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
+ options: AxiosRequestConfig = {},
+): Promise => {
+ const response = await _apiInstance.request({
+ method,
+ url: path,
+ ...options,
+ });
+ return response.data;
+};
+
+export const apiConnector = {
+ get: (
+ path: string,
+ params?: Record | URLSearchParams,
+ signal?: AbortSignal,
+ ) => request(path, "GET", { params, signal }),
+ post: (path: string, body?: object, options?: AxiosRequestConfig) =>
+ request(path, "POST", { data: body, ...options }),
+ put: (path: string, body?: object) =>
+ request(path, "PUT", { data: body }),
+ patch: (path: string, body?: object) =>
+ request(path, "PATCH", { data: body }),
+ delete: (path: string) => request(path, "DELETE", {}),
+};
diff --git a/apps/mobile/src/shared/utils/apiError.ts b/apps/mobile/src/shared/utils/apiError.ts
new file mode 100644
index 00000000..9bb945c3
--- /dev/null
+++ b/apps/mobile/src/shared/utils/apiError.ts
@@ -0,0 +1,9 @@
+import axios from "axios";
+
+export const getApiErrorStatus = (error: unknown): number | undefined => {
+ if (!axios.isAxiosError(error)) {
+ return undefined;
+ }
+
+ return error.response?.status;
+};
diff --git a/apps/mobile/src/shared/utils/club.test.ts b/apps/mobile/src/shared/utils/club.test.ts
new file mode 100644
index 00000000..66755cc3
--- /dev/null
+++ b/apps/mobile/src/shared/utils/club.test.ts
@@ -0,0 +1,48 @@
+import { getClubSummary, getClubSummaryWithAffiliation } from "./club";
+
+describe("club summary presentation", () => {
+ it("shows a summary without affiliation for carousel cards", () => {
+ expect(
+ getClubSummary({
+ description: "기존 소개",
+ shortDescription: "짧은 소개",
+ }),
+ ).toBe("짧은 소개");
+ });
+
+ it("shows affiliation before the short description", () => {
+ expect(
+ getClubSummaryWithAffiliation({
+ affiliationType: "소속동아리",
+ collegeMajor: {
+ id: 1,
+ college: "공과대학",
+ major: "컴퓨터공학부",
+ },
+ description: "기존 소개",
+ shortDescription: "짧은 소개",
+ }),
+ ).toBe("컴퓨터공학부 소속 짧은 소개");
+ });
+
+ it("shows the legacy description without affiliation when short description is absent", () => {
+ expect(
+ getClubSummaryWithAffiliation({
+ affiliationType: "중앙동아리",
+ collegeMajor: null,
+ description: "기존 소개",
+ }),
+ ).toBe("기존 소개");
+ });
+
+ it("shows only the short description when affiliation is 기타", () => {
+ expect(
+ getClubSummaryWithAffiliation({
+ affiliationType: "기타",
+ collegeMajor: null,
+ description: "기존 소개",
+ shortDescription: "짧은 소개",
+ }),
+ ).toBe("짧은 소개");
+ });
+});
diff --git a/apps/mobile/src/shared/utils/club.ts b/apps/mobile/src/shared/utils/club.ts
new file mode 100644
index 00000000..706b6c76
--- /dev/null
+++ b/apps/mobile/src/shared/utils/club.ts
@@ -0,0 +1,31 @@
+import type { Club } from "@/entities/club";
+
+type ClubAffiliation = Pick;
+type ClubSummary = Pick;
+
+export const getClubAffiliationLabel = (club: ClubAffiliation): string => {
+ if (club.affiliationType === "소속동아리") {
+ return club.collegeMajor?.major || club.collegeMajor?.college || "기타";
+ }
+
+ return club.affiliationType || "기타";
+};
+
+export const getClubSummary = (club: ClubSummary): string =>
+ club.shortDescription?.trim() || club.description?.trim() || "";
+
+export const getClubSummaryWithAffiliation = (
+ club: ClubAffiliation & ClubSummary,
+): string => {
+ const shortDescription = club.shortDescription?.trim();
+ if (!shortDescription) {
+ return club.description?.trim() || "";
+ }
+
+ const affiliation = getClubAffiliationLabel(club);
+ if (affiliation === "기타") {
+ return shortDescription;
+ }
+
+ return `${affiliation} 소속 ${shortDescription}`;
+};
diff --git a/apps/mobile/src/shared/utils/clubSns.test.ts b/apps/mobile/src/shared/utils/clubSns.test.ts
new file mode 100644
index 00000000..e400d4ab
--- /dev/null
+++ b/apps/mobile/src/shared/utils/clubSns.test.ts
@@ -0,0 +1,40 @@
+import { getClubSnsUrls, getSnsIcon, getSnsIconSize } from "./clubSns";
+
+describe("club SNS presentation", () => {
+ it("shows up to three SNS URLs from the new response field", () => {
+ expect(
+ getClubSnsUrls({
+ snsUrls: [
+ "https://instagram.com/club",
+ "https://youtube.com/@club",
+ "https://facebook.com/club",
+ "https://x.com/club",
+ ],
+ }),
+ ).toEqual([
+ "https://instagram.com/club",
+ "https://youtube.com/@club",
+ "https://facebook.com/club",
+ ]);
+ });
+
+ it("returns an empty list when SNS URLs are absent", () => {
+ expect(getClubSnsUrls({})).toEqual([]);
+ });
+
+ it("maps supported SNS domains to their icons", () => {
+ expect(getSnsIcon("https://instagram.com/club")).toBe("instagram");
+ expect(getSnsIcon("https://youtube.com/@club")).toBe("youtube");
+ expect(getSnsIcon("https://facebook.com/club")).toBe("facebook");
+ expect(getSnsIcon("https://x.com/club")).toBe("twitter");
+ expect(getSnsIcon("https://example.com")).toBe("link-variant");
+ });
+
+ it("sizes SNS icons to match their proportions in the design", () => {
+ expect(getSnsIconSize("https://instagram.com/club")).toBe(16);
+ expect(getSnsIconSize("https://youtube.com/@club")).toBe(18);
+ expect(getSnsIconSize("https://facebook.com/club")).toBe(16);
+ expect(getSnsIconSize("https://x.com/club")).toBe(16);
+ expect(getSnsIconSize("https://example.com")).toBe(18);
+ });
+});
diff --git a/apps/mobile/src/shared/utils/clubSns.ts b/apps/mobile/src/shared/utils/clubSns.ts
new file mode 100644
index 00000000..79a2182a
--- /dev/null
+++ b/apps/mobile/src/shared/utils/clubSns.ts
@@ -0,0 +1,51 @@
+type ClubSnsData = {
+ snsUrls?: string[];
+};
+
+export const MAX_CLUB_SNS_URLS = 3;
+
+export const normalizeUrl = (raw: string): string => {
+ const trimmed = raw.trim();
+ if (!trimmed) {
+ return "";
+ }
+ return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
+};
+
+const URL_PATTERN = /^https?:\/\/[^\s/$.?#].[^\s]*$/i;
+
+export const isValidUrl = (raw: string): boolean =>
+ URL_PATTERN.test(normalizeUrl(raw));
+
+export const normalizeSnsUrls = (urls: string[]): string[] =>
+ urls.map(normalizeUrl);
+
+export const areValidSnsUrls = (urls: string[]): boolean =>
+ urls.length >= 1 &&
+ urls.length <= MAX_CLUB_SNS_URLS &&
+ urls.every(isValidUrl);
+
+export const getSnsRequestFields = (
+ urls: string[],
+): { sns_urls: string[] } => ({
+ sns_urls: normalizeSnsUrls(urls),
+});
+
+export const getClubSnsUrls = ({ snsUrls }: ClubSnsData): string[] =>
+ (snsUrls ?? [])
+ .map((url) => url.trim())
+ .filter(Boolean)
+ .slice(0, MAX_CLUB_SNS_URLS);
+
+export const getSnsIcon = (url: string): string => {
+ if (url.includes("instagram.com")) return "instagram";
+ if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
+ if (url.includes("facebook.com")) return "facebook";
+ if (url.includes("x.com") || url.includes("twitter.com")) return "twitter";
+ return "link-variant";
+};
+
+export const getSnsIconSize = (url: string): number => {
+ const icon = getSnsIcon(url);
+ return icon === "youtube" || icon === "link-variant" ? 18 : 16;
+};
diff --git a/apps/mobile/src/shared/utils/editPencilButton.test.ts b/apps/mobile/src/shared/utils/editPencilButton.test.ts
new file mode 100644
index 00000000..42ac8e2c
--- /dev/null
+++ b/apps/mobile/src/shared/utils/editPencilButton.test.ts
@@ -0,0 +1,30 @@
+import { getEditPencilButtonColors } from "./editPencilButton";
+
+describe("edit pencil button colors", () => {
+ it("uses the default Figma colors when idle", () => {
+ expect(
+ getEditPencilButtonColors({ hovered: false, pressed: false }),
+ ).toEqual({
+ backgroundColor: "#EAEAEA",
+ iconColor: "#874FFF",
+ });
+ });
+
+ it("uses the selected colors when hovered", () => {
+ expect(
+ getEditPencilButtonColors({ hovered: true, pressed: false }),
+ ).toEqual({
+ backgroundColor: "#874FFF",
+ iconColor: "#FFFFFF",
+ });
+ });
+
+ it("uses the push colors while pressed even when hovered", () => {
+ expect(getEditPencilButtonColors({ hovered: true, pressed: true })).toEqual(
+ {
+ backgroundColor: "#4F2E94",
+ iconColor: "#FFFFFF",
+ },
+ );
+ });
+});
diff --git a/apps/mobile/src/shared/utils/editPencilButton.ts b/apps/mobile/src/shared/utils/editPencilButton.ts
new file mode 100644
index 00000000..c2d90004
--- /dev/null
+++ b/apps/mobile/src/shared/utils/editPencilButton.ts
@@ -0,0 +1,35 @@
+import { Colors } from "@/shared/constants/colors";
+
+type EditPencilButtonState = {
+ hovered: boolean;
+ pressed: boolean;
+};
+
+type EditPencilButtonColors = {
+ backgroundColor: string;
+ iconColor: string;
+};
+
+export const getEditPencilButtonColors = ({
+ hovered,
+ pressed,
+}: EditPencilButtonState): EditPencilButtonColors => {
+ if (pressed) {
+ return {
+ backgroundColor: Colors.BUTTON_PUSH,
+ iconColor: Colors.TEXT_BUTTON_SELECTED,
+ };
+ }
+
+ if (hovered) {
+ return {
+ backgroundColor: Colors.BUTTON_SELECTED,
+ iconColor: Colors.TEXT_BUTTON_SELECTED,
+ };
+ }
+
+ return {
+ backgroundColor: Colors.TEXTBOX_SELECTED,
+ iconColor: Colors.BUTTON_SELECTED,
+ };
+};
diff --git a/apps/mobile/src/shared/utils/entrySplash.test.ts b/apps/mobile/src/shared/utils/entrySplash.test.ts
new file mode 100644
index 00000000..db20bbf7
--- /dev/null
+++ b/apps/mobile/src/shared/utils/entrySplash.test.ts
@@ -0,0 +1,160 @@
+import {
+ APP_INITIALIZATION_LOADING_DELAY_MS,
+ ENTRY_SPLASH_HOLD_MS,
+ ENTRY_SPLASH_SYMBOL_SIZE,
+ ENTRY_SPLASH_TRANSITION_MS,
+ getCenteredScaledAssetOrigin,
+ getEntrySplashStages,
+ getEntrySplashViewport,
+ resolveEntrySplashLayoutMetrics,
+ shouldExitEntrySplash,
+ shouldShowAppInitializationLoading,
+ shouldShowGlobalAppModals,
+} from "@/shared/utils/entrySplash";
+
+describe("entry splash timeline", () => {
+ it("keeps unauthenticated users on the fourth stage with login actions", () => {
+ expect(getEntrySplashStages(false)).toEqual([1, 2, 3, 4]);
+ });
+
+ it("finishes after the brand reveal for authenticated users", () => {
+ expect(getEntrySplashStages(true)).toEqual([1, 2, 3]);
+ });
+
+ it("uses the Figma prototype hold and transition durations", () => {
+ expect(ENTRY_SPLASH_HOLD_MS).toBe(800);
+ expect(ENTRY_SPLASH_TRANSITION_MS).toBe(300);
+ });
+
+ it("excludes the Android system bars from the entry layout viewport", () => {
+ expect(
+ getEntrySplashViewport({
+ width: 402,
+ height: 874,
+ topInset: 24,
+ bottomInset: 48,
+ }),
+ ).toEqual({
+ contentTop: 24,
+ contentBottom: 48,
+ scaleX: 1,
+ scaleY: 802 / 874,
+ });
+ });
+
+ it("keeps the iOS entry layout inside the Dynamic Island and home indicator", () => {
+ const viewport = getEntrySplashViewport({
+ width: 402,
+ height: 874,
+ topInset: 59,
+ bottomInset: 34,
+ });
+
+ expect(viewport).toEqual({
+ contentTop: 59,
+ contentBottom: 34,
+ scaleX: 1,
+ scaleY: 781 / 874,
+ });
+
+ const wordmarkBottom =
+ viewport.contentTop + (779 + 29.294) * viewport.scaleY;
+ expect(wordmarkBottom).toBeLessThanOrEqual(874 - viewport.contentBottom);
+ });
+
+ it("keeps the first native layout when Android system bar metrics update", () => {
+ const initialMetrics = {
+ width: 402,
+ height: 802,
+ topInset: 24,
+ bottomInset: 48,
+ };
+ const updatedMetrics = {
+ width: 402,
+ height: 874,
+ topInset: 24,
+ bottomInset: 48,
+ };
+ const lockedMetrics = resolveEntrySplashLayoutMetrics(null, initialMetrics);
+
+ expect(resolveEntrySplashLayoutMetrics(lockedMetrics, updatedMetrics)).toBe(
+ initialMetrics,
+ );
+ });
+
+ it("renders the app symbol at 81px while preserving its scaled center", () => {
+ expect(ENTRY_SPLASH_SYMBOL_SIZE).toBe(81);
+ expect(
+ getCenteredScaledAssetOrigin({
+ designOrigin: 160.556,
+ designSize: 80.906,
+ renderedSize: ENTRY_SPLASH_SYMBOL_SIZE,
+ scale: 360 / 402,
+ }),
+ ).toBeCloseTo(((160.556 + 80.906 / 2) * 360) / 402 - 81 / 2);
+ });
+
+ it("keeps unauthenticated users on the login screen until they choose guest entry", () => {
+ expect(
+ shouldExitEntrySplash({
+ isAuthenticated: false,
+ guestEntryRequested: false,
+ }),
+ ).toBe(false);
+ expect(
+ shouldExitEntrySplash({
+ isAuthenticated: false,
+ guestEntryRequested: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("exits the entry screen automatically for authenticated users", () => {
+ expect(
+ shouldExitEntrySplash({
+ isAuthenticated: true,
+ guestEntryRequested: false,
+ }),
+ ).toBe(true);
+ });
+
+ it("shows global app modals only after the entry flow has completed", () => {
+ expect(shouldShowGlobalAppModals({ entryFlowComplete: false })).toBe(false);
+ expect(shouldShowGlobalAppModals({ entryFlowComplete: true })).toBe(true);
+ });
+
+ it("waits two seconds before showing the app initialization screen", () => {
+ expect(APP_INITIALIZATION_LOADING_DELAY_MS).toBe(2000);
+ expect(
+ shouldShowAppInitializationLoading({
+ delayElapsed: false,
+ isProfileLoading: true,
+ isUpdateCheckReady: false,
+ }),
+ ).toBe(false);
+ });
+
+ it("shows the initialization screen only while startup work remains after the delay", () => {
+ expect(
+ shouldShowAppInitializationLoading({
+ delayElapsed: true,
+ isProfileLoading: false,
+ isUpdateCheckReady: false,
+ }),
+ ).toBe(true);
+ expect(
+ shouldShowAppInitializationLoading({
+ delayElapsed: true,
+ isProfileLoading: true,
+ isUpdateCheckReady: true,
+ }),
+ ).toBe(true);
+ expect(
+ shouldShowAppInitializationLoading({
+ delayElapsed: true,
+ isProfileLoading: false,
+ isUpdateCheckReady: true,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/shared/utils/entrySplash.ts b/apps/mobile/src/shared/utils/entrySplash.ts
new file mode 100644
index 00000000..8bb5af05
--- /dev/null
+++ b/apps/mobile/src/shared/utils/entrySplash.ts
@@ -0,0 +1,86 @@
+export const APP_INITIALIZATION_LOADING_DELAY_MS = 2000;
+export const ENTRY_SPLASH_DESIGN_HEIGHT = 874;
+export const ENTRY_SPLASH_DESIGN_WIDTH = 402;
+export const ENTRY_SPLASH_HOLD_MS = 800;
+export const ENTRY_SPLASH_SYMBOL_SIZE = 81;
+export const ENTRY_SPLASH_TRANSITION_MS = 300;
+
+export type EntrySplashStage = 1 | 2 | 3 | 4;
+
+export const getEntrySplashStages = (
+ isAuthenticated: boolean,
+): EntrySplashStage[] => (isAuthenticated ? [1, 2, 3] : [1, 2, 3, 4]);
+
+export type EntrySplashLayoutMetrics = {
+ width: number;
+ height: number;
+ topInset: number;
+ bottomInset: number;
+};
+
+export const resolveEntrySplashLayoutMetrics = (
+ initialMetrics: EntrySplashLayoutMetrics | null,
+ currentMetrics: EntrySplashLayoutMetrics,
+): EntrySplashLayoutMetrics => initialMetrics ?? currentMetrics;
+
+export const getEntrySplashViewport = ({
+ width,
+ height,
+ topInset,
+ bottomInset,
+}: EntrySplashLayoutMetrics) => ({
+ contentTop: topInset,
+ contentBottom: bottomInset,
+ scaleX: width / ENTRY_SPLASH_DESIGN_WIDTH,
+ scaleY:
+ Math.max(height - topInset - bottomInset, 1) / ENTRY_SPLASH_DESIGN_HEIGHT,
+});
+
+type CenteredScaledAssetOriginParams = {
+ designOrigin: number;
+ designSize: number;
+ renderedSize: number;
+ scale: number;
+};
+
+export const getCenteredScaledAssetOrigin = ({
+ designOrigin,
+ designSize,
+ renderedSize,
+ scale,
+}: CenteredScaledAssetOriginParams): number => {
+ "worklet";
+
+ return (designOrigin + designSize / 2) * scale - renderedSize / 2;
+};
+
+type EntrySplashExitParams = {
+ isAuthenticated: boolean;
+ guestEntryRequested: boolean;
+};
+
+export const shouldExitEntrySplash = ({
+ isAuthenticated,
+ guestEntryRequested,
+}: EntrySplashExitParams): boolean => isAuthenticated || guestEntryRequested;
+
+type GlobalAppModalVisibilityParams = {
+ entryFlowComplete: boolean;
+};
+
+export const shouldShowGlobalAppModals = ({
+ entryFlowComplete,
+}: GlobalAppModalVisibilityParams): boolean => entryFlowComplete;
+
+type AppInitializationLoadingParams = {
+ delayElapsed: boolean;
+ isProfileLoading: boolean;
+ isUpdateCheckReady: boolean;
+};
+
+export const shouldShowAppInitializationLoading = ({
+ delayElapsed,
+ isProfileLoading,
+ isUpdateCheckReady,
+}: AppInitializationLoadingParams): boolean =>
+ delayElapsed && (isProfileLoading || !isUpdateCheckReady);
diff --git a/apps/mobile/src/shared/utils/guestId.ts b/apps/mobile/src/shared/utils/guestId.ts
new file mode 100644
index 00000000..23bcadc2
--- /dev/null
+++ b/apps/mobile/src/shared/utils/guestId.ts
@@ -0,0 +1,36 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+
+import { GUEST_ID } from "@/shared/constants/localStorage";
+
+const generateUuidV4 = () =>
+ "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
+ const random = (Math.random() * 16) | 0;
+ const value = char === "x" ? random : (random & 0x3) | 0x8;
+ return value.toString(16);
+ });
+
+let cachedGuestId: string | null = null;
+let pendingGuestId: Promise | null = null;
+
+export const getOrCreateGuestId = async (): Promise => {
+ if (cachedGuestId) return cachedGuestId;
+ if (pendingGuestId) return pendingGuestId;
+
+ pendingGuestId = (async () => {
+ const stored = await AsyncStorage.getItem(GUEST_ID);
+ if (stored) {
+ cachedGuestId = stored;
+ return stored;
+ }
+ const fresh = generateUuidV4();
+ await AsyncStorage.setItem(GUEST_ID, fresh);
+ cachedGuestId = fresh;
+ return fresh;
+ })();
+
+ try {
+ return await pendingGuestId;
+ } finally {
+ pendingGuestId = null;
+ }
+};
diff --git a/apps/mobile/src/shared/utils/imageViewer.test.ts b/apps/mobile/src/shared/utils/imageViewer.test.ts
new file mode 100644
index 00000000..a9b8bfae
--- /dev/null
+++ b/apps/mobile/src/shared/utils/imageViewer.test.ts
@@ -0,0 +1,52 @@
+import {
+ clampImageIndex,
+ clampImageScale,
+ getAdjacentImageIndex,
+ getPanTranslation,
+} from "./imageViewer";
+
+describe("image viewer navigation", () => {
+ it("clamps an initial image index to the available image range", () => {
+ expect(clampImageIndex(-1, 3)).toBe(0);
+ expect(clampImageIndex(1, 3)).toBe(1);
+ expect(clampImageIndex(5, 3)).toBe(2);
+ expect(clampImageIndex(0, 0)).toBe(0);
+ });
+
+ it("does not navigate beyond the first or last image", () => {
+ expect(getAdjacentImageIndex(0, -1, 3)).toBe(0);
+ expect(getAdjacentImageIndex(1, -1, 3)).toBe(0);
+ expect(getAdjacentImageIndex(1, 1, 3)).toBe(2);
+ expect(getAdjacentImageIndex(2, 1, 3)).toBe(2);
+ });
+});
+
+describe("image viewer zoom", () => {
+ it("keeps the image scale within the supported zoom range", () => {
+ expect(clampImageScale(0.5)).toBe(1);
+ expect(clampImageScale(3)).toBe(3);
+ expect(clampImageScale(10)).toBe(6);
+ });
+
+ it("moves a zoomed image using the pan gesture translation", () => {
+ expect(
+ getPanTranslation({
+ initialTranslation: { x: 20, y: -10 },
+ gestureTranslation: { x: 60, y: 40 },
+ scale: 2,
+ viewport: { width: 400, height: 600 },
+ }),
+ ).toEqual({ x: 80, y: 30 });
+ });
+
+ it("keeps panning inside the current zoom bounds", () => {
+ expect(
+ getPanTranslation({
+ initialTranslation: { x: 100, y: -100 },
+ gestureTranslation: { x: 500, y: -500 },
+ scale: 2,
+ viewport: { width: 400, height: 600 },
+ }),
+ ).toEqual({ x: 200, y: -300 });
+ });
+});
diff --git a/apps/mobile/src/shared/utils/imageViewer.ts b/apps/mobile/src/shared/utils/imageViewer.ts
new file mode 100644
index 00000000..5c9840dd
--- /dev/null
+++ b/apps/mobile/src/shared/utils/imageViewer.ts
@@ -0,0 +1,63 @@
+export const clampImageIndex = (index: number, imageCount: number): number =>
+ Math.min(Math.max(index, 0), Math.max(imageCount - 1, 0));
+
+export const getAdjacentImageIndex = (
+ currentIndex: number,
+ direction: -1 | 1,
+ imageCount: number,
+): number => clampImageIndex(currentIndex + direction, imageCount);
+
+type Point = {
+ x: number;
+ y: number;
+};
+
+type Size = {
+ width: number;
+ height: number;
+};
+
+type PanTranslationInput = {
+ initialTranslation: Point;
+ gestureTranslation: Point;
+ scale: number;
+ viewport: Size;
+};
+
+export const MIN_IMAGE_SCALE = 1;
+export const MAX_IMAGE_SCALE = 6;
+
+const clamp = (value: number, minimum: number, maximum: number): number => {
+ "worklet";
+ return Math.min(Math.max(value, minimum), maximum);
+};
+
+export const clampImageScale = (scale: number): number => {
+ "worklet";
+ return clamp(scale, MIN_IMAGE_SCALE, MAX_IMAGE_SCALE);
+};
+
+export const getPanTranslation = ({
+ initialTranslation,
+ gestureTranslation,
+ scale,
+ viewport,
+}: PanTranslationInput): Point => {
+ "worklet";
+
+ const maxTranslationX = (viewport.width * (scale - 1)) / 2;
+ const maxTranslationY = (viewport.height * (scale - 1)) / 2;
+
+ return {
+ x: clamp(
+ initialTranslation.x + gestureTranslation.x,
+ -maxTranslationX,
+ maxTranslationX,
+ ),
+ y: clamp(
+ initialTranslation.y + gestureTranslation.y,
+ -maxTranslationY,
+ maxTranslationY,
+ ),
+ };
+};
diff --git a/apps/mobile/src/shared/utils/managerInfo.ts b/apps/mobile/src/shared/utils/managerInfo.ts
new file mode 100644
index 00000000..5492c7ae
--- /dev/null
+++ b/apps/mobile/src/shared/utils/managerInfo.ts
@@ -0,0 +1,67 @@
+const getInputDigits = (value: string, previousValue: string): string => {
+ const isDeletingTrailingHyphen =
+ previousValue.endsWith("-") && value === previousValue.slice(0, -1);
+ const valueWithoutAutoHyphen = isDeletingTrailingHyphen
+ ? value.slice(0, -1)
+ : value;
+
+ return valueWithoutAutoHyphen.replace(/\D/g, "");
+};
+
+export const formatPhoneNumberInput = (
+ value: string,
+ previousValue: string,
+): string => {
+ const digits = getInputDigits(value, previousValue).slice(0, 11);
+
+ if (digits.length < 3) {
+ return digits;
+ }
+ if (digits.length === 3) {
+ return `${digits}-`;
+ }
+ if (digits.length < 7) {
+ return `${digits.slice(0, 3)}-${digits.slice(3)}`;
+ }
+ if (digits.length === 7) {
+ return `${digits.slice(0, 3)}-${digits.slice(3)}-`;
+ }
+
+ return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
+};
+
+const PHONE_NUMBER_PREFILL_PATTERN = /^(?:01\d-\d{4}-\d{4}|01\d{9})$/;
+
+export const getPhoneNumberPrefill = (phone: string): string =>
+ PHONE_NUMBER_PREFILL_PATTERN.test(phone)
+ ? formatPhoneNumberInput(phone, "")
+ : "";
+
+export const formatStudentIdInput = (
+ value: string,
+ previousValue: string,
+): string => {
+ const digits = getInputDigits(value, previousValue).slice(0, 9);
+
+ if (digits.length < 4) {
+ return digits;
+ }
+ if (digits.length === 4) {
+ return `${digits}-`;
+ }
+
+ return `${digits.slice(0, 4)}-${digits.slice(4)}`;
+};
+
+export const getStudentIdPrefill = (admissionClass: number | null): string => {
+ if (
+ !Number.isInteger(admissionClass) ||
+ admissionClass === null ||
+ admissionClass < 0 ||
+ admissionClass > 99
+ ) {
+ return "";
+ }
+
+ return `20${String(admissionClass).padStart(2, "0")}-`;
+};
diff --git a/apps/mobile/src/shared/utils/minActivityPeriod.ts b/apps/mobile/src/shared/utils/minActivityPeriod.ts
new file mode 100644
index 00000000..9c79dbc9
--- /dev/null
+++ b/apps/mobile/src/shared/utils/minActivityPeriod.ts
@@ -0,0 +1,26 @@
+export type MinActivityPeriodMode = "none" | "number";
+
+type MinActivityPeriodState = {
+ mode: MinActivityPeriodMode;
+ value: string;
+};
+
+export const incrementMinActivityPeriodValue = (
+ currentSemesters: number,
+): MinActivityPeriodState => ({
+ mode: "number",
+ value: String(currentSemesters + 1),
+});
+
+export const decrementMinActivityPeriodValue = (
+ currentSemesters: number,
+): MinActivityPeriodState =>
+ currentSemesters > 1
+ ? {
+ mode: "number",
+ value: String(currentSemesters - 1),
+ }
+ : {
+ mode: "none",
+ value: "",
+ };
diff --git a/apps/mobile/src/shared/utils/navigation.ts b/apps/mobile/src/shared/utils/navigation.ts
new file mode 100644
index 00000000..97ce6896
--- /dev/null
+++ b/apps/mobile/src/shared/utils/navigation.ts
@@ -0,0 +1,53 @@
+// NOTE(MG.S): navigation 전역적으로 활용하기 위해 정의한 파일
+import type { NavigationContainerRef } from "@react-navigation/core";
+import {
+ CommonActions,
+ DrawerActions,
+ StackActions,
+} from "@react-navigation/native";
+import React from "react";
+
+// https://reactnavigation.org/docs/navigating-without-navigation-prop/#handling-initialization
+let _isNavigationReady = false;
+export const setIsNavigationReady = (isReady: boolean) => {
+ _isNavigationReady = isReady;
+};
+// biome-ignore lint/suspicious/noExplicitAny: React Navigation's global ref accepts routes from multiple untyped stacks.
+export const _navigationRef = React.createRef>();
+
+export const navigation = {
+ navigate: (name: string, params?: object) => {
+ if (!_isNavigationReady) return;
+ _navigationRef.current?.navigate(name, params);
+ },
+ openDrawer() {
+ if (!_isNavigationReady) return;
+ _navigationRef.current?.dispatch(DrawerActions.openDrawer());
+ },
+ closeDrawer() {
+ if (!_isNavigationReady) return;
+ _navigationRef.current?.dispatch(DrawerActions.closeDrawer());
+ },
+ setRoot(name: string, params?: object) {
+ if (!_isNavigationReady) return;
+ _navigationRef.current?.dispatch(
+ CommonActions.reset({
+ index: 0,
+ routes: [{ name, params }],
+ }),
+ );
+ },
+ push(name: string, params?: object) {
+ if (!_isNavigationReady) return;
+ const pushAction = StackActions.push(name, params);
+ _navigationRef.current?.dispatch(pushAction);
+ },
+ goBack() {
+ if (!_isNavigationReady) return;
+ _navigationRef.current?.goBack();
+ },
+ replace(name: string, params?: object) {
+ if (!_isNavigationReady) return;
+ _navigationRef.current?.dispatch(StackActions.replace(name, params));
+ },
+};
diff --git a/apps/mobile/src/shared/utils/scale.ts b/apps/mobile/src/shared/utils/scale.ts
new file mode 100644
index 00000000..1e908457
--- /dev/null
+++ b/apps/mobile/src/shared/utils/scale.ts
@@ -0,0 +1,13 @@
+import { Dimensions } from "react-native";
+
+const { width, height } = Dimensions.get("window");
+
+const BASE_WIDTH = 390;
+const BASE_HEIGHT = 844;
+
+export const s = (size: number) => (width / BASE_WIDTH) * size;
+export const vs = (size: number) => (height / BASE_HEIGHT) * size;
+export const ms = (size: number, factor = 0.5) =>
+ size + (s(size) - size) * factor;
+export const mvs = (size: number, factor = 0.5) =>
+ size + (vs(size) - size) * factor;
diff --git a/apps/mobile/src/tabs/HomeTab.tsx b/apps/mobile/src/tabs/HomeTab.tsx
new file mode 100644
index 00000000..e4bc6a9a
--- /dev/null
+++ b/apps/mobile/src/tabs/HomeTab.tsx
@@ -0,0 +1,49 @@
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import ClubDetailScreen from "@/features/club/screens/ClubDetailScreen";
+import ClubListScreen from "@/features/club/screens/ClubListScreen";
+import ClubReviewScreen from "@/features/club/screens/ClubReviewScreen";
+import PreviousRecruitmentsScreen from "@/features/club/screens/PreviousRecruitmentsScreen";
+import HomeScreen from "@/features/home/screens/HomeScreen";
+import WebViewScreen from "@/features/webview/screens/WebviewScreen";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+
+const Stack = createNativeStackNavigator();
+
+export function HomeTab() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/tabs/MyPageTab.tsx b/apps/mobile/src/tabs/MyPageTab.tsx
new file mode 100644
index 00000000..7af586b0
--- /dev/null
+++ b/apps/mobile/src/tabs/MyPageTab.tsx
@@ -0,0 +1,73 @@
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import ClubDetailScreen from "@/features/club/screens/ClubDetailScreen";
+import ClubInfoEditScreen from "@/features/club/screens/ClubInfoEditScreen";
+import ClubManagementScreen from "@/features/club/screens/ClubManagementScreen";
+import ClubReviewScreen from "@/features/club/screens/ClubReviewScreen";
+import PreviousRecruitmentsScreen from "@/features/club/screens/PreviousRecruitmentsScreen";
+import SavedClubListScreen from "@/features/club/screens/SavedClubListScreen";
+import EditProfileScreen from "@/features/mypage/screens/EditProfileScreen";
+import ManageClubRegistrationScreen from "@/features/mypage/screens/ManageClubRegistrationScreen";
+import MyPageScreen from "@/features/mypage/screens/MyPageScreen";
+import WebViewScreen from "@/features/webview/screens/WebviewScreen";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+
+const Stack = createNativeStackNavigator();
+
+export function MyPageTab() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/tabs/RegisterClubTab.tsx b/apps/mobile/src/tabs/RegisterClubTab.tsx
new file mode 100644
index 00000000..8d07ca20
--- /dev/null
+++ b/apps/mobile/src/tabs/RegisterClubTab.tsx
@@ -0,0 +1,17 @@
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import { RegisterClubScreenContainer } from "@/features/register-club/screens/RegisterClubScreenContainer";
+
+const Stack = createNativeStackNavigator();
+
+export function RegisterClubTab() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/tabs/SaveTab.tsx b/apps/mobile/src/tabs/SaveTab.tsx
new file mode 100644
index 00000000..766b11c8
--- /dev/null
+++ b/apps/mobile/src/tabs/SaveTab.tsx
@@ -0,0 +1,43 @@
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import ClubDetailScreen from "@/features/club/screens/ClubDetailScreen";
+import ClubReviewScreen from "@/features/club/screens/ClubReviewScreen";
+import PreviousRecruitmentsScreen from "@/features/club/screens/PreviousRecruitmentsScreen";
+import SavedClubListScreen from "@/features/club/screens/SavedClubListScreen";
+import WebViewScreen from "@/features/webview/screens/WebviewScreen";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+
+const Stack = createNativeStackNavigator();
+
+export function SavedTab() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/tabs/SearchTab.tsx b/apps/mobile/src/tabs/SearchTab.tsx
new file mode 100644
index 00000000..2a8410fe
--- /dev/null
+++ b/apps/mobile/src/tabs/SearchTab.tsx
@@ -0,0 +1,44 @@
+import { createNativeStackNavigator } from "@react-navigation/native-stack";
+import ClubDetailScreen from "@/features/club/screens/ClubDetailScreen";
+import ClubReviewScreen from "@/features/club/screens/ClubReviewScreen";
+import PreviousRecruitmentsScreen from "@/features/club/screens/PreviousRecruitmentsScreen";
+import SearchScreen from "@/features/club/screens/SearchScreen";
+import WebViewScreen from "@/features/webview/screens/WebviewScreen";
+import { SCREEN_TYPE, type StackParamList } from "@/shared/constants/screen";
+
+const Stack = createNativeStackNavigator();
+
+export function SearchTab() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/tabs/TabNavigator.tsx b/apps/mobile/src/tabs/TabNavigator.tsx
similarity index 50%
rename from src/tabs/TabNavigator.tsx
rename to apps/mobile/src/tabs/TabNavigator.tsx
index 648a22af..2ff026fa 100644
--- a/src/tabs/TabNavigator.tsx
+++ b/apps/mobile/src/tabs/TabNavigator.tsx
@@ -1,45 +1,50 @@
import {
- createBottomTabNavigator,
type BottomTabNavigationOptions,
-} from '@react-navigation/bottom-tabs'
-import { getFocusedRouteNameFromRoute, type RouteProp } from '@react-navigation/native'
-import { Colors } from '@/shared/constants/colors'
-import { SCREEN_TYPE } from '@/shared/constants/screen'
-import { useProfile } from '@/shared/contexts/profileContext'
-import useRequireLogin from '@/shared/hooks/useRequireLogin'
-import { Image, Pressable, type ImageSourcePropType } from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { HomeTab } from '@/tabs/HomeTab'
-import { MyPageTab } from '@/tabs/MyPageTab'
-import { s, vs } from '@/shared/utils/scale'
-import { navigation } from '@/shared/utils/navigation'
-import { SavedTab } from './SaveTab'
-import { SearchTab } from './SearchTab'
-import { typography } from '@/shared/constants/typography'
+ createBottomTabNavigator,
+} from "@react-navigation/bottom-tabs";
+import {
+ getFocusedRouteNameFromRoute,
+ type RouteProp,
+} from "@react-navigation/native";
+import { Image, type ImageSourcePropType, Pressable } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { Colors } from "@/shared/constants/colors";
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { typography } from "@/shared/constants/typography";
+import { useProfile } from "@/shared/contexts/profileContext";
+import useRequireLogin from "@/shared/hooks/useRequireLogin";
+import { navigation } from "@/shared/utils/navigation";
+import { s, vs } from "@/shared/utils/scale";
+import { HomeTab } from "@/tabs/HomeTab";
+import { MyPageTab } from "@/tabs/MyPageTab";
+import { RegisterClubTab } from "@/tabs/RegisterClubTab";
+import { shouldHideTabBar } from "@/tabs/tabBarVisibility";
+import { SavedTab } from "./SaveTab";
+import { SearchTab } from "./SearchTab";
-const Tab = createBottomTabNavigator()
+const Tab = createBottomTabNavigator();
const renderHomeTabIcon = createTabBarIcon(
- require('@/assets/icons/tab/home-default.png'),
- require('@/assets/icons/tab/home-active.png'),
-)
+ require("@/assets/icons/tab/home-default.png"),
+ require("@/assets/icons/tab/home-active.png"),
+);
const renderExploreTabIcon = createTabBarIcon(
- require('@/assets/icons/tab/explore-default.png'),
- require('@/assets/icons/tab/explore-active.png'),
-)
+ require("@/assets/icons/tab/explore-default.png"),
+ require("@/assets/icons/tab/explore-active.png"),
+);
const renderSavedTabIcon = createTabBarIcon(
- require('@/assets/icons/tab/saved-default.png'),
- require('@/assets/icons/tab/saved-active.png'),
-)
+ require("@/assets/icons/tab/saved-default.png"),
+ require("@/assets/icons/tab/saved-active.png"),
+);
const renderMyPageTabIcon = createTabBarIcon(
- require('@/assets/icons/tab/mypage-default.png'),
- require('@/assets/icons/tab/mypage-active.png'),
-)
+ require("@/assets/icons/tab/mypage-default.png"),
+ require("@/assets/icons/tab/mypage-active.png"),
+);
function createTabBarIcon(
defaultSource: ImageSourcePropType,
activeSource: ImageSourcePropType,
-): BottomTabNavigationOptions['tabBarIcon'] {
+): BottomTabNavigationOptions["tabBarIcon"] {
return function TabBarIcon({ focused }) {
return (
- )
- }
+ );
+ };
}
export function TabNavigator() {
- const { user } = useProfile()
- const requireLogin = useRequireLogin()
- const insets = useSafeAreaInsets()
+ const { user } = useProfile();
+ const requireLogin = useRequireLogin();
+ const insets = useSafeAreaInsets();
// 안드로이드 네비게이션바 있는 경우에만 inset 적용, 나머지는 전부 미적용
- const bottomInset = insets.bottom >= 40 ? insets.bottom : 0
+ const bottomInset = insets.bottom >= 40 ? insets.bottom : 0;
const defaultTabBarStyle = {
// 라벨 밑 ~ 하단바 끝까지의 여백을 16px 늘림 (height/paddingBottom 동시 +16)
@@ -69,16 +74,18 @@ export function TabNavigator() {
borderTopWidth: 0, // iOS 그림자 제거
elevation: 0, // Android 그림자 제거
paddingBottom: vs(10) + vs(16) + bottomInset,
- }
+ };
// 웹뷰처럼 전체 화면으로 떠야 하는 nested 화면에서는 하단 탭바를 숨긴다.
- const getTabBarStyle = (route: RouteProp, string>) => {
- const focusedRouteName = getFocusedRouteNameFromRoute(route)
- if (focusedRouteName === SCREEN_TYPE.WEBVIEW) {
- return { display: 'none' as const }
+ const getTabBarStyle = (
+ route: RouteProp, string>,
+ ) => {
+ const focusedRouteName = getFocusedRouteNameFromRoute(route);
+ if (shouldHideTabBar(focusedRouteName)) {
+ return { display: "none" as const };
}
- return defaultTabBarStyle
- }
+ return defaultTabBarStyle;
+ };
const screenOptions: BottomTabNavigationOptions = {
headerShown: false,
@@ -87,20 +94,21 @@ export function TabNavigator() {
tabBarLabelStyle: {
...typography.bodySMedium,
},
- tabBarButton: props => (
+ tabBarButton: (props) => (
[props.style, { opacity: pressed ? 0.6 : 1 }]}
/>
),
- }
+ };
return (
+ }}
+ >
({
tabBarIcon: renderHomeTabIcon,
@@ -117,6 +125,11 @@ export function TabNavigator() {
name="탐색"
component={SearchTab}
/>
+ null, tabBarStyle: { display: "none" } }}
+ name="등록"
+ component={RegisterClubTab}
+ />
({
tabBarIcon: renderSavedTabIcon,
@@ -125,10 +138,10 @@ export function TabNavigator() {
name="저장"
component={SavedTab}
listeners={{
- tabPress: e => {
+ tabPress: (e) => {
if (!user) {
- e.preventDefault()
- requireLogin(() => navigation.navigate('저장'))
+ e.preventDefault();
+ requireLogin(() => navigation.navigate("저장"));
}
},
}}
@@ -141,12 +154,14 @@ export function TabNavigator() {
name="마이"
component={MyPageTab}
listeners={({ navigation: tabNavigation }) => ({
- tabPress: e => {
- e.preventDefault()
- requireLogin(() => tabNavigation.navigate('마이', { screen: SCREEN_TYPE.MYPAGE }))
+ tabPress: (e) => {
+ e.preventDefault();
+ requireLogin(() =>
+ tabNavigation.navigate("마이", { screen: SCREEN_TYPE.MYPAGE }),
+ );
},
})}
/>
- )
+ );
}
diff --git a/apps/mobile/src/tabs/tabBarVisibility.test.ts b/apps/mobile/src/tabs/tabBarVisibility.test.ts
new file mode 100644
index 00000000..9546683c
--- /dev/null
+++ b/apps/mobile/src/tabs/tabBarVisibility.test.ts
@@ -0,0 +1,18 @@
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+import { shouldHideTabBar } from "@/tabs/tabBarVisibility";
+
+describe("shouldHideTabBar", () => {
+ it.each([SCREEN_TYPE.WEBVIEW, SCREEN_TYPE.MANAGE_CLUB_REGISTRATION])(
+ "hides the tab bar on %s",
+ (routeName) => {
+ expect(shouldHideTabBar(routeName)).toBe(true);
+ },
+ );
+
+ it.each([undefined, SCREEN_TYPE.MYPAGE, SCREEN_TYPE.EDIT_PROFILE])(
+ "uses the normal tab bar on %s",
+ (routeName) => {
+ expect(shouldHideTabBar(routeName)).toBe(false);
+ },
+ );
+});
diff --git a/apps/mobile/src/tabs/tabBarVisibility.ts b/apps/mobile/src/tabs/tabBarVisibility.ts
new file mode 100644
index 00000000..b6e573b2
--- /dev/null
+++ b/apps/mobile/src/tabs/tabBarVisibility.ts
@@ -0,0 +1,5 @@
+import { SCREEN_TYPE } from "@/shared/constants/screen";
+
+export const shouldHideTabBar = (routeName?: string) =>
+ routeName === SCREEN_TYPE.WEBVIEW ||
+ routeName === SCREEN_TYPE.MANAGE_CLUB_REGISTRATION;
diff --git a/src/usecases/announcement.ts b/apps/mobile/src/usecases/announcement.ts
similarity index 53%
rename from src/usecases/announcement.ts
rename to apps/mobile/src/usecases/announcement.ts
index 836c3850..d01380f7 100644
--- a/src/usecases/announcement.ts
+++ b/apps/mobile/src/usecases/announcement.ts
@@ -1,19 +1,21 @@
-import {
+import type {
AnnouncementRepository,
DismissAnnouncementsRequest,
ListAnnouncementsResponse,
-} from '@/repositories/announcement'
+} from "@/repositories/announcement";
export type AnnouncementService = {
- listAnnouncements: () => Promise
- dismissAnnouncements: (request: DismissAnnouncementsRequest) => Promise
-}
+ listAnnouncements: () => Promise;
+ dismissAnnouncements: (request: DismissAnnouncementsRequest) => Promise;
+};
type Deps = {
- repositories: [AnnouncementRepository]
-}
+ repositories: [AnnouncementRepository];
+};
-export const getAnnouncementService = ({ repositories }: Deps): AnnouncementService => ({
+export const getAnnouncementService = ({
+ repositories,
+}: Deps): AnnouncementService => ({
listAnnouncements: repositories[0].listAnnouncements,
dismissAnnouncements: repositories[0].dismissAnnouncements,
-})
+});
diff --git a/apps/mobile/src/usecases/appVersion.ts b/apps/mobile/src/usecases/appVersion.ts
new file mode 100644
index 00000000..dfe71f42
--- /dev/null
+++ b/apps/mobile/src/usecases/appVersion.ts
@@ -0,0 +1,29 @@
+import { Platform } from "react-native";
+import { getVersion } from "react-native-device-info";
+import type { ClientType, VersionCheckResult } from "@/entities/appVersion";
+import type { AppVersionRepository } from "@/repositories/appVersion";
+
+export type AppVersionService = {
+ checkCurrentVersion: () => Promise;
+};
+
+type Deps = {
+ repositories: [AppVersionRepository];
+};
+
+const getCurrentClientType = (): ClientType =>
+ Platform.OS === "ios" ? "ios" : "android";
+
+// Android debug 빌드에는 versionNameSuffix "-DEBUG"가 붙는다.
+// 서버는 semver 형태만 받으므로 suffix를 제거하고 전송한다.
+const getCurrentAppVersion = (): string => getVersion().replace(/-DEBUG$/, "");
+
+export const getAppVersionService = ({
+ repositories,
+}: Deps): AppVersionService => ({
+ checkCurrentVersion: () =>
+ repositories[0].checkVersion({
+ clientType: getCurrentClientType(),
+ appVersion: getCurrentAppVersion(),
+ }),
+});
diff --git a/apps/mobile/src/usecases/auth.ts b/apps/mobile/src/usecases/auth.ts
new file mode 100644
index 00000000..fd8d825c
--- /dev/null
+++ b/apps/mobile/src/usecases/auth.ts
@@ -0,0 +1,44 @@
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import type { AuthRepository } from "@/repositories/auth";
+
+export enum AuthProvider {
+ KAKAO = "kakao",
+ APPLE = "apple",
+}
+
+export type AuthService = {
+ callback(provider: AuthProvider, token: string): Promise;
+ logout: () => Promise;
+ leave: () => Promise;
+};
+
+type Deps = {
+ repositories: [AuthRepository];
+};
+
+export const getAuthService = ({ repositories }: Deps): AuthService => ({
+ callback: async (provider, token) => {
+ switch (provider) {
+ case AuthProvider.KAKAO: {
+ const response = await repositories[0].callbackKakaoAuth({
+ accessToken: token,
+ });
+ return response.token;
+ }
+ case AuthProvider.APPLE: {
+ const response = await repositories[0].callbackAppleAuth({
+ id_token: token,
+ });
+ return response.token;
+ }
+ default:
+ throw new Error("Invalid provider");
+ }
+ },
+ logout: async () => {
+ await AsyncStorage.removeItem("token");
+ },
+ leave: async () => {
+ await repositories[0].leave();
+ },
+});
diff --git a/apps/mobile/src/usecases/category.ts b/apps/mobile/src/usecases/category.ts
new file mode 100644
index 00000000..f74b0516
--- /dev/null
+++ b/apps/mobile/src/usecases/category.ts
@@ -0,0 +1,18 @@
+import type {
+ CategoryRepository,
+ ListCategoriesResponse,
+} from "@/repositories/category";
+
+export type CategoryService = {
+ listCategories: () => Promise;
+};
+
+type Deps = {
+ repositories: [CategoryRepository];
+};
+
+export const getCategoryService = ({
+ repositories,
+}: Deps): CategoryService => ({
+ listCategories: repositories[0].listCategories,
+});
diff --git a/apps/mobile/src/usecases/club.ts b/apps/mobile/src/usecases/club.ts
new file mode 100644
index 00000000..53dd2a98
--- /dev/null
+++ b/apps/mobile/src/usecases/club.ts
@@ -0,0 +1,115 @@
+import type { Club } from "@/entities/club";
+import type {
+ ClubManagerRequestDetail,
+ ClubRepository,
+ ClubRequestTarget,
+ CreateSavedClubRequest,
+ GetClubRequest,
+ GetManagedClubDetailRequest,
+ ListClubRankingsRequest,
+ ListClubRankingsResponse,
+ ListClubsRequest,
+ ListClubsResponse,
+ ListLatestClubsResponse,
+ ListManageClubsResponse,
+ ListMyClubsResponse,
+ ListRandomRecommendationsResponse,
+ ListSavedClubsResponse,
+ ManagedClubDetail,
+ ManagedClubRequestTarget,
+ RegisterClubRequest,
+ RegisterClubResponse,
+ RemoveSavedClubRequest,
+ RequestClubmanagerRequest,
+ RequestOfficialVerificationRequest,
+ RequestOfficialVerificationResponse,
+ SearchClubsRequest,
+ SearchClubsResponse,
+ UpdateClubManagerRequest,
+ UpdateManagedClubRequest,
+ UpdateManagedClubResponse,
+ UploadClubActivityImageRequest,
+ UploadClubActivityImageResponse,
+ UploadManagedClubImageRequest,
+ UploadManagedClubImageResponse,
+} from "@/repositories/club";
+
+export type ClubService = {
+ searchClubs: (
+ req: SearchClubsRequest,
+ signal?: AbortSignal,
+ ) => Promise;
+ listLatestClubs: () => Promise;
+ listClubs: (req: ListClubsRequest) => Promise;
+ getClub: (req: GetClubRequest) => Promise;
+ getManagedClubDetail: (
+ req: GetManagedClubDetailRequest,
+ ) => Promise;
+ listManageClubs: () => Promise;
+ listClubRankings: (
+ req: ListClubRankingsRequest,
+ ) => Promise;
+ requestClubManager: (req: RequestClubmanagerRequest) => Promise;
+ getClubManagerRequest: (
+ req: ClubRequestTarget,
+ ) => Promise;
+ updateClubManagerRequest: (req: UpdateClubManagerRequest) => Promise;
+ getManagedClubManager: (
+ req: ManagedClubRequestTarget,
+ ) => Promise;
+ requestOfficialVerification: (
+ req: RequestOfficialVerificationRequest,
+ ) => Promise;
+ cancelClubManagerRequest: (req: ClubRequestTarget) => Promise;
+ cancelManagedClubRequest: (req: ManagedClubRequestTarget) => Promise;
+ listSavedClubs: () => Promise;
+ createSavedClub: (req: CreateSavedClubRequest) => Promise;
+ removeSavedClub: (req: RemoveSavedClubRequest) => Promise;
+ listMyClubs: () => Promise;
+ registerClub: (req: RegisterClubRequest) => Promise;
+ updateManagedClub: (
+ req: UpdateManagedClubRequest,
+ ) => Promise;
+ uploadManagedClubImage: (
+ req: UploadManagedClubImageRequest,
+ ) => Promise;
+ uploadClubActivityImage: (
+ req: UploadClubActivityImageRequest,
+ ) => Promise;
+ listRandomRecommendations: () => Promise;
+};
+
+type Deps = {
+ repositories: [ClubRepository];
+};
+
+export const getClubService = ({ repositories }: Deps): ClubService => ({
+ searchClubs: (req, signal) => repositories[0].searchClubs(req, signal),
+ listLatestClubs: () => repositories[0].listLatestClubs(),
+ listClubs: (req) => repositories[0].listClubs(req),
+ getClub: (req) => repositories[0].getClub(req),
+ getManagedClubDetail: (req) => repositories[0].getManagedClubDetail(req),
+ listManageClubs: () => repositories[0].listManageClubs(),
+ listClubRankings: (req) => repositories[0].listClubRankings(req),
+ requestClubManager: (req) => repositories[0].requestClubManager(req),
+ getClubManagerRequest: (req) => repositories[0].getClubManagerRequest(req),
+ updateClubManagerRequest: (req) =>
+ repositories[0].updateClubManagerRequest(req),
+ getManagedClubManager: (req) => repositories[0].getManagedClubManager(req),
+ requestOfficialVerification: (req) =>
+ repositories[0].requestOfficialVerification(req),
+ cancelClubManagerRequest: (req) =>
+ repositories[0].cancelClubManagerRequest(req),
+ cancelManagedClubRequest: (req) =>
+ repositories[0].cancelManagedClubRequest(req),
+ listSavedClubs: () => repositories[0].listSavedClubs(),
+ createSavedClub: (req) => repositories[0].createSavedClub(req),
+ removeSavedClub: (req) => repositories[0].removeSavedClub(req),
+ listMyClubs: () => repositories[0].listMyClubs(),
+ registerClub: (req) => repositories[0].registerClub(req),
+ updateManagedClub: (req) => repositories[0].updateManagedClub(req),
+ uploadManagedClubImage: (req) => repositories[0].uploadManagedClubImage(req),
+ uploadClubActivityImage: (req) =>
+ repositories[0].uploadClubActivityImage(req),
+ listRandomRecommendations: () => repositories[0].listRandomRecommendations(),
+});
diff --git a/apps/mobile/src/usecases/eventLog.ts b/apps/mobile/src/usecases/eventLog.ts
new file mode 100644
index 00000000..003af575
--- /dev/null
+++ b/apps/mobile/src/usecases/eventLog.ts
@@ -0,0 +1,13 @@
+import analytics from "@react-native-firebase/analytics";
+import type {
+ EventLogParameter,
+ EventLogParameterType,
+} from "@/entities/eventLog";
+
+export type EventLogService = {
+ logEvent(params: EventLogParameter): void;
+};
+
+export const getEventLogService = (): EventLogService => ({
+ logEvent: (params) => analytics().logEvent(params.name, params.parameters),
+});
diff --git a/apps/mobile/src/usecases/recentSearch.ts b/apps/mobile/src/usecases/recentSearch.ts
new file mode 100644
index 00000000..abea8507
--- /dev/null
+++ b/apps/mobile/src/usecases/recentSearch.ts
@@ -0,0 +1,20 @@
+import type {
+ ListRecentSearchesResponse,
+ RecentSearchRepository,
+} from "@/repositories/recentSearch";
+
+export type RecentSearchService = {
+ listRecentSearches: () => Promise;
+ deleteAllRecentSearches: () => Promise;
+};
+
+type Deps = {
+ repositories: [RecentSearchRepository];
+};
+
+export const getRecentSearchService = ({
+ repositories,
+}: Deps): RecentSearchService => ({
+ listRecentSearches: () => repositories[0].listRecentSearches(),
+ deleteAllRecentSearches: () => repositories[0].deleteAllRecentSearches(),
+});
diff --git a/apps/mobile/src/usecases/recruitment.ts b/apps/mobile/src/usecases/recruitment.ts
new file mode 100644
index 00000000..bf5bc20d
--- /dev/null
+++ b/apps/mobile/src/usecases/recruitment.ts
@@ -0,0 +1,52 @@
+import type {
+ CreateRecruitmentRequest,
+ CreateRecruitmentResponse,
+ DeleteRecruitmentRequest,
+ GetRecruitmentDetailRequest,
+ GetRecruitmentDetailResponse,
+ GetRepresentativeRecruitmentRequest,
+ GetRepresentativeRecruitmentResponse,
+ ListClubRecruitmentsRequest,
+ ListClubRecruitmentsResponse,
+ RecruitmentRepository,
+ UpdateRecruitmentRequest,
+ UploadRecruitmentImageRequest,
+ UploadRecruitmentImageResponse,
+} from "@/repositories/recruitment";
+
+export type RecruitmentService = {
+ listClubRecruitments: (
+ req: ListClubRecruitmentsRequest,
+ ) => Promise;
+ getRepresentativeRecruitment: (
+ req: GetRepresentativeRecruitmentRequest,
+ ) => Promise;
+ createRecruitment: (
+ req: CreateRecruitmentRequest,
+ ) => Promise;
+ uploadRecruitmentImage: (
+ req: UploadRecruitmentImageRequest,
+ ) => Promise;
+ deleteRecruitment: (req: DeleteRecruitmentRequest) => Promise