feat(V2-6/V2-7/V2-8): Mobile MVP + Teams + Billing 스캐폴딩

V2-6 — Mobile (Expo) MVP
- apps/mobile/ 신규, npm workspace에서 제외 (Expo deps 부담 회피)
- Expo SDK 51 + Expo Router + AsyncStorage Supabase 클라이언트
- 화면: index/login/(tabs)/{meetings,record,profile}
- expo-av로 녹음 → Edge Function stt-proxy 호출
- expo-web-browser + expo-linking으로 OAuth 콜백 처리
- README에 setup/EAS build 가이드
- 루트 package.json workspaces를 명시 나열로 변경 (apps/mobile 제외)

V2-7 — 팀 기능 (Web)
- apps/web/src/app/teams/page.tsx — 가입한 팀 카드 그리드
- apps/web/src/app/teams/[id]/page.tsx — 멤버 + 공유 회의
- components/teams/create-team-form.tsx — 팀 생성 + owner 자동 team_members
- components/teams/invite-member-form.tsx — user_id 직접 초대 (V2-7b에서 invite flow)
- Sidebar에 Teams/Billing 메뉴 + 아이콘
- ko.json에 nav.teams/nav.billing 키 추가
- V2-2 teams/team_members RLS 활용

V2-8 — 결제 스캐폴딩
- apps/web/src/app/billing/page.tsx — Free/Pro/Team 가격표 + 현재 구독
- components/billing/checkout-button.tsx — Edge Function 호출 후 redirect
- server/supabase/functions/stripe-checkout/index.ts:
  - JWT 인증 -> 기존 customer 조회/생성 -> Checkout Session 생성
  - subscriptions 테이블에 customer_id upsert
- server/supabase/functions/stripe-webhook/index.ts:
  - checkout.session.completed -> subscriptions tier=pro/team active
  - customer.subscription.updated/created -> status/period 업데이트
  - customer.subscription.deleted -> tier=free, status=canceled
  - signature 검증은 placeholder (V2-8b에서 정식)
- server/supabase/config.toml에 두 함수 등록 (webhook verify_jwt=false)

검증:
- desktop typecheck OK (회귀 없음)
- web typecheck OK
- web next build OK (11 라우트)
- mobile은 별도 install 필요 (workspace 제외)

memory/project_status.md 갱신 — V2 마스터 플랜 전 페이즈 로컬 완료
This commit is contained in:
yunchan8804 2026-04-09 16:39:54 +09:00
parent 5c0f4a2b98
commit 61a96b3e9b
136 changed files with 2641 additions and 251 deletions

View file

@ -0,0 +1,35 @@
// apps/mobile/app/(tabs)/_layout.tsx
// 탭 네비게이션 — Meetings / Record / Profile
import { Tabs } from 'expo-router'
import { useEffect } from 'react'
import { useRouter } from 'expo-router'
import { useAuth } from '../../lib/auth-context'
export default function TabsLayout(): React.ReactElement {
const router = useRouter()
const { user, loading } = useAuth()
useEffect(() => {
if (!loading && !user) {
router.replace('/login')
}
}, [user, loading, router])
return (
<Tabs
screenOptions={{
tabBarStyle: { backgroundColor: '#19191b', borderTopColor: '#2a2a2d' },
tabBarActiveTintColor: '#f25b29',
tabBarInactiveTintColor: '#8e8e93',
headerStyle: { backgroundColor: '#19191b' },
headerTintColor: '#f25b29',
headerTitleStyle: { fontWeight: '300', letterSpacing: 1 }
}}
>
<Tabs.Screen name="meetings" options={{ title: 'Meetings' }} />
<Tabs.Screen name="record" options={{ title: 'Record' }} />
<Tabs.Screen name="profile" options={{ title: 'Profile' }} />
</Tabs>
)
}

View file

@ -0,0 +1,92 @@
// apps/mobile/app/(tabs)/meetings.tsx
// 회의 리스트 — Supabase에서 fetch
import { useEffect, useState } from 'react'
import { View, Text, FlatList, Pressable, StyleSheet, ActivityIndicator, RefreshControl } from 'react-native'
import { supabase } from '../../lib/supabase'
interface Meeting {
id: string
title: string | null
started_at: string
status: string
}
export default function MeetingsScreen(): React.ReactElement {
const [meetings, setMeetings] = useState<Meeting[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
async function load(): Promise<void> {
try {
const { data, error } = await supabase
.from('meetings')
.select('id, title, started_at, status')
.order('started_at', { ascending: false })
.limit(50)
if (!error && data) {
setMeetings(data as Meeting[])
}
} finally {
setLoading(false)
setRefreshing(false)
}
}
useEffect(() => {
void load()
}, [])
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#f25b29" />
</View>
)
}
return (
<FlatList
data={meetings}
keyExtractor={(item) => item.id}
contentContainerStyle={meetings.length === 0 ? styles.center : { padding: 16 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => {
setRefreshing(true)
void load()
}}
tintColor="#f25b29"
/>
}
ListEmptyComponent={
<Text style={styles.empty}> . Record .</Text>
}
renderItem={({ item }) => (
<Pressable style={styles.card}>
<Text style={styles.title}>{item.title ?? '(제목 없음)'}</Text>
<Text style={styles.meta}>
{new Date(item.started_at).toLocaleString('ko-KR')} · {item.status}
</Text>
</Pressable>
)}
/>
)
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32 },
empty: { color: '#8e8e93', textAlign: 'center', fontSize: 13 },
card: {
backgroundColor: '#242427',
padding: 16,
borderRadius: 12,
marginBottom: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)'
},
title: { color: '#ffffff', fontSize: 14, fontWeight: '500', marginBottom: 4 },
meta: { color: '#8e8e93', fontSize: 11 }
})

View file

@ -0,0 +1,71 @@
// apps/mobile/app/(tabs)/profile.tsx
// 프로필 — 사용자 정보 + 로그아웃
import { View, Text, Pressable, StyleSheet, Alert } from 'react-native'
import { useAuth } from '../../lib/auth-context'
import { supabase } from '../../lib/supabase'
export default function ProfileScreen(): React.ReactElement {
const { user } = useAuth()
async function handleLogout(): Promise<void> {
Alert.alert('로그아웃', '정말 로그아웃하시겠습니까?', [
{ text: '취소', style: 'cancel' },
{
text: '로그아웃',
style: 'destructive',
onPress: async () => {
await supabase.auth.signOut()
}
}
])
}
return (
<View style={styles.container}>
<View style={styles.section}>
<Text style={styles.label}></Text>
<Text style={styles.value}>{user?.email ?? '—'}</Text>
</View>
<View style={styles.section}>
<Text style={styles.label}>User ID</Text>
<Text style={styles.value}>{user?.id ?? '—'}</Text>
</View>
<Pressable style={styles.logoutButton} onPress={() => void handleLogout()}>
<Text style={styles.logoutText}></Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24 },
section: {
backgroundColor: '#242427',
padding: 16,
borderRadius: 12,
marginBottom: 16,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)'
},
label: {
color: '#8e8e93',
fontSize: 11,
fontWeight: '700',
letterSpacing: 1.5,
textTransform: 'uppercase',
marginBottom: 4
},
value: { color: '#ffffff', fontSize: 14 },
logoutButton: {
marginTop: 16,
paddingVertical: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: '#ef4444',
alignItems: 'center'
},
logoutText: { color: '#ef4444', fontSize: 14, fontWeight: '600' }
})

View file

@ -0,0 +1,179 @@
// apps/mobile/app/(tabs)/record.tsx
// 녹음 — expo-av Audio.Recording 사용
import { useState, useRef } from 'react'
import { View, Text, Pressable, StyleSheet, Alert, ActivityIndicator } from 'react-native'
import { Audio } from 'expo-av'
import { supabase, isSupabaseConfigured } from '../../lib/supabase'
import Constants from 'expo-constants'
type RecordingState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
export default function RecordScreen(): React.ReactElement {
const [state, setState] = useState<RecordingState>('idle')
const [transcript, setTranscript] = useState<string>('')
const [error, setError] = useState<string | null>(null)
const recordingRef = useRef<Audio.Recording | null>(null)
async function startRecording(): Promise<void> {
setError(null)
setTranscript('')
try {
const perm = await Audio.requestPermissionsAsync()
if (perm.status !== 'granted') {
setError('마이크 권한이 거부되었습니다')
setState('error')
return
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true
})
const recording = new Audio.Recording()
await recording.prepareToRecordAsync(Audio.RecordingOptionsPresets.HIGH_QUALITY)
await recording.startAsync()
recordingRef.current = recording
setState('recording')
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to start')
setState('error')
}
}
async function stopRecording(): Promise<void> {
if (!recordingRef.current) return
setState('processing')
try {
await recordingRef.current.stopAndUnloadAsync()
const uri = recordingRef.current.getURI()
recordingRef.current = null
if (!uri) {
throw new Error('녹음 파일 URI를 받지 못했습니다')
}
if (!isSupabaseConfigured()) {
setError('Supabase가 설정되지 않아 전사할 수 없습니다')
setState('error')
return
}
// Edge Function 호출
const {
data: { session }
} = await supabase.auth.getSession()
if (!session) {
setError('로그인이 필요합니다')
setState('error')
return
}
const formData = new FormData()
// RN의 FormData는 { uri, name, type } 형태
formData.append('audio', {
uri,
name: 'recording.m4a',
type: 'audio/m4a'
} as unknown as Blob)
formData.append('language_code', 'ko-KR')
const url = (Constants.expoConfig?.extra?.supabaseUrl as string) ?? ''
const response = await fetch(`${url}/functions/v1/stt-proxy`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.access_token}` },
body: formData
})
if (!response.ok) {
throw new Error(`STT 실패: ${response.status}`)
}
const result = (await response.json()) as { transcript: string }
setTranscript(result.transcript)
setState('done')
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error')
setState('error')
}
}
async function cancelRecording(): Promise<void> {
if (recordingRef.current) {
try {
await recordingRef.current.stopAndUnloadAsync()
} catch {
// ignore
}
recordingRef.current = null
}
setState('idle')
setTranscript('')
setError(null)
}
return (
<View style={styles.container}>
<Text style={styles.title}>{state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'}</Text>
{state === 'processing' && <ActivityIndicator size="large" color="#f25b29" style={styles.spinner} />}
{transcript && state === 'done' && (
<View style={styles.transcriptBox}>
<Text style={styles.transcript}>{transcript}</Text>
</View>
)}
{error && (
<View style={styles.errorBox}>
<Text style={styles.error}>{error}</Text>
</View>
)}
<View style={styles.buttons}>
{(state === 'idle' || state === 'done' || state === 'error') && (
<Pressable style={styles.bigButton} onPress={() => void startRecording()}>
<Text style={styles.bigButtonText}>{state === 'done' ? '새 녹음' : '녹음 시작'}</Text>
</Pressable>
)}
{state === 'recording' && (
<View style={{ gap: 12 }}>
<Pressable style={[styles.bigButton, styles.stop]} onPress={() => void stopRecording()}>
<Text style={styles.bigButtonText}></Text>
</Pressable>
<Pressable style={styles.cancelButton} onPress={() => void cancelRecording()}>
<Text style={styles.cancelText}></Text>
</Pressable>
</View>
)}
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 32, alignItems: 'center', justifyContent: 'center' },
title: { color: '#f25b29', fontSize: 20, fontWeight: '600', letterSpacing: 2, marginBottom: 32 },
spinner: { marginVertical: 24 },
transcriptBox: { backgroundColor: '#242427', padding: 16, borderRadius: 8, marginBottom: 24, width: '100%' },
transcript: { color: '#ffffff', fontSize: 14, lineHeight: 20 },
errorBox: { borderWidth: 1, borderColor: '#ef4444', padding: 12, borderRadius: 8, marginBottom: 24 },
error: { color: '#ef4444', fontSize: 12 },
buttons: { width: '100%', alignItems: 'center' },
bigButton: {
backgroundColor: '#f25b29',
paddingVertical: 18,
paddingHorizontal: 64,
borderRadius: 12,
minWidth: 200,
alignItems: 'center'
},
bigButtonText: { color: '#ffffff', fontSize: 16, fontWeight: '600', letterSpacing: 1 },
stop: { backgroundColor: '#ef4444' },
cancelButton: { paddingVertical: 12, alignItems: 'center' },
cancelText: { color: '#8e8e93', fontSize: 13 }
})

View file

@ -0,0 +1,31 @@
// apps/mobile/app/_layout.tsx
// 루트 레이아웃 — Stack + AuthProvider
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { AuthProvider } from '../lib/auth-context'
export default function RootLayout(): React.ReactElement {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AuthProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#19191b' },
headerTintColor: '#f25b29',
contentStyle: { backgroundColor: '#19191b' }
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: '로그인' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
)
}

28
apps/mobile/app/index.tsx Normal file
View file

@ -0,0 +1,28 @@
// apps/mobile/app/index.tsx
// 진입점 — 로그인 상태에 따라 리다이렉트
import { useEffect } from 'react'
import { ActivityIndicator, View } from 'react-native'
import { useRouter } from 'expo-router'
import { useAuth } from '../lib/auth-context'
export default function Index(): React.ReactElement {
const router = useRouter()
const { user, loading } = useAuth()
useEffect(() => {
if (!loading) {
if (user) {
router.replace('/(tabs)/meetings')
} else {
router.replace('/login')
}
}
}, [user, loading, router])
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#19191b' }}>
<ActivityIndicator size="large" color="#f25b29" />
</View>
)
}

138
apps/mobile/app/login.tsx Normal file
View file

@ -0,0 +1,138 @@
// apps/mobile/app/login.tsx
// OAuth 로그인 화면
import { useState } from 'react'
import { View, Text, Pressable, Alert, StyleSheet, ActivityIndicator } from 'react-native'
import * as WebBrowser from 'expo-web-browser'
import * as Linking from 'expo-linking'
import { supabase, isSupabaseConfigured } from '../lib/supabase'
WebBrowser.maybeCompleteAuthSession()
export default function LoginScreen(): React.ReactElement {
const [busy, setBusy] = useState(false)
const configured = isSupabaseConfigured()
async function signInWithProvider(provider: 'google' | 'github'): Promise<void> {
if (!configured) {
Alert.alert('미설정', 'Supabase 환경변수가 설정되지 않았습니다.')
return
}
setBusy(true)
try {
const redirectTo = Linking.createURL('auth-callback')
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo, skipBrowserRedirect: true }
})
if (error || !data.url) {
Alert.alert('로그인 실패', error?.message ?? 'OAuth URL을 받지 못했습니다')
return
}
const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo)
if (result.type === 'success' && result.url) {
const url = new URL(result.url)
const code = url.searchParams.get('code')
if (code) {
const { error: exchangeErr } = await supabase.auth.exchangeCodeForSession(code)
if (exchangeErr) {
Alert.alert('세션 교환 실패', exchangeErr.message)
}
}
}
} finally {
setBusy(false)
}
}
return (
<View style={styles.container}>
<Text style={styles.title}>D3RO VOICE</Text>
<Text style={styles.subtitle}>AI </Text>
{!configured && (
<Text style={styles.warning}>
Supabase가 . app.json의 extra .
</Text>
)}
<View style={styles.buttons}>
<Pressable
style={[styles.button, styles.google, (!configured || busy) && styles.disabled]}
onPress={() => void signInWithProvider('google')}
disabled={!configured || busy}
>
<Text style={styles.buttonText}>Google로 </Text>
</Pressable>
<Pressable
style={[styles.button, styles.github, (!configured || busy) && styles.disabled]}
onPress={() => void signInWithProvider('github')}
disabled={!configured || busy}
>
<Text style={styles.buttonText}>GitHub로 </Text>
</Pressable>
</View>
{busy && <ActivityIndicator size="small" color="#f25b29" style={{ marginTop: 16 }} />}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#19191b',
justifyContent: 'center',
padding: 32
},
title: {
color: '#f25b29',
fontSize: 36,
fontWeight: '300',
textAlign: 'center',
letterSpacing: 2,
marginBottom: 8
},
subtitle: {
color: '#8e8e93',
fontSize: 14,
textAlign: 'center',
marginBottom: 48
},
warning: {
color: '#f59e0b',
fontSize: 12,
textAlign: 'center',
marginBottom: 24,
padding: 12,
borderWidth: 1,
borderColor: '#f59e0b',
borderRadius: 8
},
buttons: {
gap: 12
},
button: {
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center'
},
google: {
backgroundColor: '#f25b29'
},
github: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#8e8e93'
},
disabled: {
opacity: 0.5
},
buttonText: {
color: '#ffffff',
fontSize: 14,
fontWeight: '600'
}
})