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:
parent
5c0f4a2b98
commit
61a96b3e9b
136 changed files with 2641 additions and 251 deletions
179
apps/mobile/app/(tabs)/record.tsx
Normal file
179
apps/mobile/app/(tabs)/record.tsx
Normal 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 }
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue