d3ro-voice/apps/mobile/app/(tabs)/record.tsx
yunchan8804 61a96b3e9b 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 마스터 플랜 전 페이즈 로컬 완료
2026-04-09 16:39:54 +09:00

179 lines
5.8 KiB
TypeScript

// 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 }
})