diff --git a/.env.example b/.env.example index 29828e6..b4c13ff 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ # API 서버 주소 (클라이언트에 공개되는 값만 사용) EXPO_PUBLIC_API_URL= + +# 팀 검토 전용 빌드에서만 true. 정식 서비스에서는 설정하지 않습니다. +EXPO_PUBLIC_TEAM_REVIEW=false diff --git a/.gitignore b/.gitignore index 518bc44..ac274af 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ expo-env.d.ts web-build/ ios/ android/ + +# Vercel local project linkage +.vercel/ +.vercel diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..d2a39d0 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,5 @@ +node_modules +.git +.env* +docs +*.log diff --git a/docs/team-review-deployment.md b/docs/team-review-deployment.md new file mode 100644 index 0000000..b3553ea --- /dev/null +++ b/docs/team-review-deployment.md @@ -0,0 +1,22 @@ +# 팀 검토용 Vercel 임시 배포 + +검토 전용 Vercel 프로젝트 `sudam-team-review`를 사용합니다. GitHub 저장소 `SUDAM-Developer/FrontEnd`의 `develop` 브랜치에 머지·푸시하면 자동 배포되며, 빌드 성공 후 https://sudam-team-review.vercel.app 에 반영됩니다. + +배포 설정 PR은 현재 검토 사이트에 포함된 PR #10(보강 조율), #11(수입·알림·하단 탭) 변경사항에 의존합니다. 해당 PR을 먼저 리뷰·머지한 후 배포 설정 PR을 머지합니다. PR 생성만으로 develop이나 현재 운영 주소가 변경되지는 않습니다. + +- `npm run build`: 일반 웹 빌드. 검토 플래그가 없으면 기존 production 제한 유지 +- `npm run build:review -- --clear`: 검토 빌드. 공개 가능한 예시 데이터와 미리보기 경로 허용 +- `EXPO_PUBLIC_TEAM_REVIEW=true`: 검토 전용 빌드에서만 설정. 공개되는 값이며 보안/인증 수단이 아님 +- `/` 및 이전 `/review` → `/terms`: 앱의 원래 진입 흐름인 약관 → 프로필 → 홈·하단 탭 사용. 별도 화면 목록 페이지는 제공하지 않음 +- 보강 조율, 약관·프로필, 회차 기록·완료, 수입 상태, 배너 모두 확인 가능 +- 배너는 기본 검토 링크에서는 시안만 표시하고, `controls=1`일 때 동작 확인 도구 표시 +- 브라우저 새로고침/직접 경로 접근을 위한 Vercel SPA rewrite 구성 +- 검색 제외 요청을 위한 X-Robots-Tag: noindex, nofollow. 접근 제어를 제공하는 기능은 아님 +- 실제 API·메시지 발송·결제·입금 확인은 연결하지 않음 +- `.env`와 Git 메타데이터 등은 업로드에서 제외. 정식 도메인은 연결하지 않음 + +Vercel에서는 Build Command `npm run build`, Output Directory `dist`, 검토 프로젝트에만 Build 환경 변수 `EXPO_PUBLIC_TEAM_REVIEW=true`를 사용합니다. 로컬에선 `build:review` 스크립트로 같은 빌드를 생성합니다. 검토용 URL의 접근 보호 설정은 Vercel 프로젝트에서 별도로 관리합니다. + +검토 모드에서는 메모리에 검토용 프로필을 초기 설정해 수입 탭 직접 접속·새로고침에도 가입 화면으로 돌아가지 않습니다. 일반 빌드에서는 초기 프로필이 비어 있는 기존 동작을 유지합니다. + +화면 목록 방식은 사용자 요청에 따라 제거했습니다. 이미 구현된 앱 화면을 직접 배포하며, 현재 미구현인 홈·일정·정산 리포트 등의 준비 중 표시는 유지합니다. 화면별 확인용 직접 경로는 남아 있지만 기본 서비스 진입에는 노출하지 않습니다. diff --git a/package.json b/package.json index 0c340d8..b6050f6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "ios": "expo start --ios", "typecheck": "tsc --noEmit", "export:ios": "expo export --platform ios", - "export:android": "expo export --platform android" + "export:android": "expo export --platform android", + "build:review": "node scripts/build-review.mjs" }, "dependencies": { "@expo/metro-runtime": "~58.0.15", diff --git a/scripts/build-review.mjs b/scripts/build-review.mjs new file mode 100644 index 0000000..25e2fe6 --- /dev/null +++ b/scripts/build-review.mjs @@ -0,0 +1,16 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +const cli = fileURLToPath(new URL('../node_modules/expo/bin/cli', import.meta.url)); +const result = spawnSync( + process.execPath, + [cli, 'export', '--platform', 'web', ...process.argv.slice(2)], + { + stdio: 'inherit', + env: { ...process.env, EXPO_PUBLIC_TEAM_REVIEW: 'true' }, + }, +); +if (result.error) { + process.stderr.write(`${result.error.message}\n`); + process.exit(1); +} +process.exit(result.status ?? 1); diff --git a/src/app/(tabs)/income.tsx b/src/app/(tabs)/income.tsx index 76fba3a..a44a94d 100644 --- a/src/app/(tabs)/income.tsx +++ b/src/app/(tabs)/income.tsx @@ -1,3 +1,4 @@ +import { CAN_PREVIEW } from '@/config/review'; import { useState } from 'react'; import { Tabs, useLocalSearchParams } from 'expo-router'; import { IncomeScreen } from '@/features/income/IncomeScreen'; @@ -5,7 +6,7 @@ import { incomePreviewState } from '@/features/income/previewData'; export default function Screen() { const { preview } = useLocalSearchParams<{ preview?: string }>(); const [retried, setRetried] = useState(false); - const state = __DEV__ + const state = CAN_PREVIEW ? incomePreviewState(retried ? 'normal' : preview) : { status: 'unavailable' as const }; return ( diff --git a/src/app/dev/components.tsx b/src/app/dev/components.tsx index c45e4ea..601d9a4 100644 --- a/src/app/dev/components.tsx +++ b/src/app/dev/components.tsx @@ -1,3 +1,4 @@ +import { CAN_PREVIEW } from '@/config/review'; import { useState } from 'react'; import { Redirect } from 'expo-router'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; @@ -22,7 +23,7 @@ export default function ComponentsPreview() { const [chip, setChip] = useState(false); const [search, setSearch] = useState('은행을 검색하세요'); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; return ( diff --git a/src/app/dev/income.tsx b/src/app/dev/income.tsx index f43533e..219c533 100644 --- a/src/app/dev/income.tsx +++ b/src/app/dev/income.tsx @@ -1,3 +1,4 @@ +import { CAN_PREVIEW } from '@/config/review'; import { useEffect } from 'react'; import { Redirect, useLocalSearchParams } from 'expo-router'; import { useOnboarding } from '@/features/onboarding/OnboardingProvider'; @@ -5,9 +6,9 @@ export default function IncomePreview() { const { state } = useLocalSearchParams<{ state?: string }>(); const { profile, setProfile } = useOnboarding(); useEffect(() => { - if (__DEV__ && !profile.name) setProfile({ name: '미리보기', subjects: [] }); + if (CAN_PREVIEW && !profile.name) setProfile({ name: '미리보기', subjects: [] }); }, [profile.name, setProfile]); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; if (!profile.name) return null; return ; } diff --git a/src/app/dev/profile.tsx b/src/app/dev/profile.tsx index 43111d2..990e5c3 100644 --- a/src/app/dev/profile.tsx +++ b/src/app/dev/profile.tsx @@ -1,3 +1,4 @@ +import { CAN_PREVIEW } from '@/config/review'; import { useRef } from 'react'; import { Redirect, useLocalSearchParams } from 'expo-router'; import ProfileScreen from '@/features/profile/ProfileScreen'; @@ -5,7 +6,7 @@ import type { SaveProfile } from '@/features/profile/profile'; export default function ProfilePreview() { const { scenario } = useLocalSearchParams<{ scenario?: string }>(); const attempts = useRef(0); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; const save: SaveProfile = async (_, signal) => { const attempt = ++attempts.current; await new Promise((resolve, reject) => { diff --git a/src/app/dev/push-banner.tsx b/src/app/dev/push-banner.tsx index f793eef..c9bbfcc 100644 --- a/src/app/dev/push-banner.tsx +++ b/src/app/dev/push-banner.tsx @@ -1,5 +1,6 @@ +import { CAN_PREVIEW, IS_TEAM_REVIEW } from '@/config/review'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Redirect } from 'expo-router'; +import { Redirect, useLocalSearchParams } from 'expo-router'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { ActionButton } from '@/components/ActionButton'; @@ -8,6 +9,7 @@ import { semantic as c } from '@/styles/colors'; import { typography as t } from '@/styles/typography'; export default function BannerPreview() { const { show, dismiss } = usePushBanner(); + const { controls } = useLocalSearchParams<{ controls?: string }>(); const ids = useRef([]); const [opened, setOpened] = useState(false); const sample = useCallback( @@ -23,13 +25,15 @@ export default function BannerPreview() { [show], ); useEffect(() => { - if (__DEV__) sample(); + if (CAN_PREVIEW) sample(); const ownIds = ids.current; return () => { ownIds.forEach((id) => dismiss(id)); }; }, [sample, dismiss]); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; + if (IS_TEAM_REVIEW && controls !== '1') return ; + function clear() { ids.current.forEach((id) => dismiss(id)); ids.current.length = 0; diff --git a/src/app/dev/terms.tsx b/src/app/dev/terms.tsx index fa5f7d9..3b2cfc3 100644 --- a/src/app/dev/terms.tsx +++ b/src/app/dev/terms.tsx @@ -1,3 +1,4 @@ +import { CAN_PREVIEW } from '@/config/review'; import { useRef } from 'react'; import { Redirect, useLocalSearchParams } from 'expo-router'; import TermsScreen from '@/features/terms/TermsScreen'; @@ -6,7 +7,7 @@ import type { SaveTerms } from '@/features/terms/terms'; export default function TermsPreview() { const { scenario } = useLocalSearchParams<{ scenario?: string }>(); const attempts = useRef(0); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; const save: SaveTerms = async (_, signal) => { const attempt = ++attempts.current; await new Promise((resolve, reject) => { diff --git a/src/app/review.tsx b/src/app/review.tsx new file mode 100644 index 0000000..5873cd6 --- /dev/null +++ b/src/app/review.tsx @@ -0,0 +1,6 @@ +import { Redirect } from 'expo-router'; + +// 이전 공유 주소도 앱의 기본 진입 화면으로 연결합니다. +export default function ReviewRedirect() { + return ; +} diff --git a/src/config/review.ts b/src/config/review.ts new file mode 100644 index 0000000..d51939a --- /dev/null +++ b/src/config/review.ts @@ -0,0 +1,3 @@ +/** 빌드 시에만 켜는 검토 전용 모드. 일반 배포에서는 예시 데이터·개발 경로를 열지 않습니다. */ +export const IS_TEAM_REVIEW = process.env.EXPO_PUBLIC_TEAM_REVIEW === 'true'; +export const CAN_PREVIEW = __DEV__ || IS_TEAM_REVIEW; diff --git a/src/features/makeup/preview.tsx b/src/features/makeup/preview.tsx index c6b21d5..0eefa4a 100644 --- a/src/features/makeup/preview.tsx +++ b/src/features/makeup/preview.tsx @@ -1,3 +1,4 @@ +import { CAN_PREVIEW } from '@/config/review'; import { useState } from 'react'; import { Redirect, useLocalSearchParams } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -15,7 +16,7 @@ export function MakeupPreview() { }>(); const [visible, setVisible] = useState(true); const [actions] = useState(() => createPreviewActions(params.scenario)); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; const count = Math.min(3, Math.max(1, Number(params.count) || 1)); return ( diff --git a/src/features/onboarding/OnboardingProvider.tsx b/src/features/onboarding/OnboardingProvider.tsx index 28b22f8..87d00ae 100644 --- a/src/features/onboarding/OnboardingProvider.tsx +++ b/src/features/onboarding/OnboardingProvider.tsx @@ -1,3 +1,4 @@ +import { IS_TEAM_REVIEW } from '@/config/review'; import { createContext, useContext, useState } from 'react'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; type Profile = { name: string; subjects: string[] }; @@ -18,7 +19,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { const [agreed, setAgreed] = useState([false, false, false]); const [name, setName] = useState(''); const [selected, setSelected] = useState([]); - const [profile, setProfile] = useState({ name: '', subjects: [] }); + const [profile, setProfile] = useState({ + name: IS_TEAM_REVIEW ? '검토용' : '', + subjects: [], + }); const [editing, setEditing] = useState(false); return ( createPreviewActions(params.scenario)); const recordAttempts = useRef(0); const cancelAttempts = useRef(0); - if (!__DEV__) return ; + if (!CAN_PREVIEW) return ; const variant = params.variant ?? 'first'; const empty = variant === 'empty'; const lesson: LessonContext = { diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..693f1fe --- /dev/null +++ b/vercel.json @@ -0,0 +1,23 @@ +{ + "framework": null, + "installCommand": "npm ci", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "X-Robots-Tag", + "value": "noindex, nofollow" + } + ] + } + ] +}