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"
+ }
+ ]
+ }
+ ]
+}