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} + + + ); + })} + + ))} + + + ); + })} + + + + +