feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
62
apps/web/src/components/billing/billing-checkout-options.tsx
Normal file
62
apps/web/src/components/billing/billing-checkout-options.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Alert, Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { CheckoutButton } from './checkout-button'
|
||||
import { PaypleCheckoutButton } from './payple-checkout-button'
|
||||
import type { PaypleTier } from './payple-client'
|
||||
import type { BillingCatalogPrice, WebBillingProvider } from '@/lib/billing-catalog'
|
||||
|
||||
export function BillingCheckoutOptions({
|
||||
tier,
|
||||
prices
|
||||
}: {
|
||||
tier: PaypleTier
|
||||
prices: BillingCatalogPrice[]
|
||||
}): React.ReactElement {
|
||||
const availableProviders = useMemo(() => new Set(prices.map((price) => price.provider)), [prices])
|
||||
const initialProvider: WebBillingProvider = availableProviders.has('payple') ? 'payple' : 'stripe'
|
||||
const [provider, setProvider] = useState<WebBillingProvider>(initialProvider)
|
||||
const payplePrice = prices.find((price): price is BillingCatalogPrice & { provider: 'payple' } => (
|
||||
price.provider === 'payple'
|
||||
))
|
||||
|
||||
return (
|
||||
<Box data-testid={`billing-checkout-${tier}`}>
|
||||
<Typography sx={{ mb: 1, color: 'var(--d3-text-label)', fontSize: 11 }}>
|
||||
결제 수단을 선택해 주세요. 한 구독에는 하나의 결제사만 사용할 수 있습니다.
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
size="small"
|
||||
value={provider}
|
||||
onChange={(_event, value: WebBillingProvider | null) => {
|
||||
if (value !== null && availableProviders.has(value)) setProvider(value)
|
||||
}}
|
||||
aria-label="결제사 선택"
|
||||
sx={{ mb: 1.5 }}
|
||||
>
|
||||
<ToggleButton value="payple" aria-label="Payple 국내 카드" disabled={!availableProviders.has('payple')}>
|
||||
국내 카드 · Payple
|
||||
</ToggleButton>
|
||||
<ToggleButton value="stripe" aria-label="Stripe 해외 카드" disabled={!availableProviders.has('stripe')}>
|
||||
해외 카드 · Stripe
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{provider === 'payple' ? (
|
||||
payplePrice ? <PaypleCheckoutButton tier={tier} catalogPrice={payplePrice} /> : (
|
||||
<Alert severity="error">Payple 가격을 확인할 수 없습니다.</Alert>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Alert severity="info" variant="outlined" sx={{ mb: 1, fontSize: 11 }}>
|
||||
Stripe Checkout으로 이동해 해외 발급 카드를 결제합니다.
|
||||
</Alert>
|
||||
<CheckoutButton tier={tier} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,9 +6,10 @@
|
|||
import { useState } from 'react'
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import type { PaypleTier } from './payple-client'
|
||||
|
||||
interface CheckoutButtonProps {
|
||||
tier: 'pro' | 'team'
|
||||
tier: PaypleTier
|
||||
}
|
||||
|
||||
export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElement {
|
||||
|
|
@ -21,16 +22,21 @@ export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElemen
|
|||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
data: { session },
|
||||
error: sessionError
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
if (sessionError || !session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
if (!baseUrl) throw new Error('결제 서버 설정이 완료되지 않았습니다')
|
||||
const endpoint = new URL('/functions/v1/stripe-checkout', baseUrl).toString()
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/stripe-checkout`,
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -39,23 +45,21 @@ export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElemen
|
|||
},
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
idempotency_key: `stripe-checkout:${crypto.randomUUID()}`,
|
||||
success_url: `${window.location.origin}/billing?success=1`,
|
||||
cancel_url: `${window.location.origin}/billing?canceled=1`
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const txt = await response.text()
|
||||
throw new Error(`Checkout 시작 실패: ${response.status} ${txt}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
} else {
|
||||
setError('Checkout URL을 받지 못했습니다')
|
||||
const data = (await response.json().catch(() => null)) as { url?: unknown } | null
|
||||
if (!response.ok) throw new Error('Stripe Checkout을 시작하지 못했습니다')
|
||||
if (typeof data?.url !== 'string') throw new Error('Checkout URL을 받지 못했습니다')
|
||||
const checkoutUrl = new URL(data.url)
|
||||
if (checkoutUrl.protocol !== 'https:' || checkoutUrl.hostname !== 'checkout.stripe.com') {
|
||||
throw new Error('Checkout URL을 신뢰할 수 없습니다')
|
||||
}
|
||||
window.location.assign(checkoutUrl.toString())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
|
|
@ -69,11 +73,12 @@ export function CheckoutButton({ tier }: CheckoutButtonProps): React.ReactElemen
|
|||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
data-testid={`stripe-upgrade-${tier}`}
|
||||
onClick={() => void handleCheckout()}
|
||||
disabled={busy}
|
||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
업그레이드
|
||||
{tier === 'pro_plus' ? 'PRO+' : 'PRO'} Stripe로 업그레이드
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
|
|
|
|||
|
|
@ -1,145 +1,176 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/billing/payple-checkout-button.tsx
|
||||
// Payple 결제창 호출 → 빌링키 획득 → payple-checkout Edge Function 호출
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button, CircularProgress, Alert, Box } from '@mui/material'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Alert, Box, Button, CircularProgress } from '@mui/material'
|
||||
import Script from 'next/script'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
import {
|
||||
assertSuccessfulCheckoutResponse,
|
||||
clearPaypleIdempotencyKey,
|
||||
getOrCreatePaypleIdempotencyKey,
|
||||
PaypleClientError,
|
||||
runPaypleRegistration,
|
||||
type PaypleAuthRequest,
|
||||
type PaypleCatalogPrice,
|
||||
type PaypleTier
|
||||
} from './payple-client'
|
||||
|
||||
// Payple JS SDK 타입 (전역 함수)
|
||||
declare global {
|
||||
interface Window {
|
||||
PaypleCpayAuthCheck: (obj: Record<string, unknown>) => void
|
||||
PaypleCpayAuthCheck: (request: PaypleAuthRequest) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface PaypleCheckoutButtonProps {
|
||||
tier: 'pro' | 'pro_plus'
|
||||
tier: PaypleTier
|
||||
catalogPrice: PaypleCatalogPrice
|
||||
}
|
||||
|
||||
// 테스트 환경 감지: 환경변수가 없거나 test면 테스트 모드
|
||||
const PAYPLE_CLIENT_KEY = process.env.NEXT_PUBLIC_PAYPLE_CLIENT_KEY ?? 'test_DF55F29DA654A8CBC0F0A9DD4B556486'
|
||||
const IS_TEST = PAYPLE_CLIENT_KEY.startsWith('test_')
|
||||
const SDK_URL = IS_TEST
|
||||
const PAYPLE_CLIENT_KEY = process.env.NEXT_PUBLIC_PAYPLE_CLIENT_KEY?.trim() ?? ''
|
||||
const PAYPLE_CONFIGURED = PAYPLE_CLIENT_KEY.length > 0
|
||||
const SDK_URL = PAYPLE_CLIENT_KEY.startsWith('test_')
|
||||
? 'https://democpay.payple.kr/js/v1/payment.js'
|
||||
: 'https://cpay.payple.kr/js/v1/payment.js'
|
||||
|
||||
export function PaypleCheckoutButton({ tier }: PaypleCheckoutButtonProps): React.ReactElement {
|
||||
function checkoutErrorMessage(error: unknown): string {
|
||||
if (error instanceof PaypleClientError) return error.message
|
||||
return '결제를 완료하지 못했습니다. 결제 내역을 확인한 뒤 다시 시도해 주세요.'
|
||||
}
|
||||
|
||||
function checkoutEndpoint(): string {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
if (!baseUrl) {
|
||||
throw new PaypleClientError('not_configured', '결제 서버 설정이 완료되지 않았습니다.')
|
||||
}
|
||||
try {
|
||||
return new URL('/functions/v1/payple-checkout', baseUrl).toString()
|
||||
} catch {
|
||||
throw new PaypleClientError('not_configured', '결제 서버 설정이 올바르지 않습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
export function PaypleCheckoutButton({ tier, catalogPrice }: PaypleCheckoutButtonProps): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [sdkReady, setSdkReady] = useState(false)
|
||||
|
||||
// SDK가 이미 로드되어 있는지 확인
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && typeof window.PaypleCpayAuthCheck === 'function') {
|
||||
if (PAYPLE_CONFIGURED && typeof window.PaypleCpayAuthCheck === 'function') {
|
||||
setSdkReady(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCheckout = useCallback(async () => {
|
||||
const handleCheckout = useCallback(async (): Promise<void> => {
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
if (!sdkReady || typeof window.PaypleCpayAuthCheck !== 'function') {
|
||||
setError('결제 모듈 로딩 중입니다. 잠시 후 다시 시도해주세요.')
|
||||
if (!PAYPLE_CONFIGURED) {
|
||||
setError('결제 설정이 완료되지 않았습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
setError('로그인이 필요합니다')
|
||||
if (!sdkReady || typeof window.PaypleCpayAuthCheck !== 'function') {
|
||||
setError('결제 모듈을 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
// Payple 결제창 호출 (SPA 콜백 패턴)
|
||||
const obj: Record<string, unknown> = {
|
||||
clientKey: PAYPLE_CLIENT_KEY,
|
||||
PCD_PAY_TYPE: 'card',
|
||||
PCD_PAY_WORK: 'AUTH', // 카드 등록만 (빌링키 발급)
|
||||
PCD_CARD_VER: '01', // 정기결제용
|
||||
PCD_PAY_GOODS: tier === 'pro_plus' ? 'D3RO Voice Pro+' : 'D3RO Voice Pro',
|
||||
PCD_PAY_TOTAL: tier === 'pro_plus' ? 29900 : 9900,
|
||||
PCD_PAYER_NO: session.user.id,
|
||||
PCD_PAYER_EMAIL: session.user.email ?? '',
|
||||
PCD_RST_URL: '/billing', // 상대 경로 → callbackFunction 사용
|
||||
callbackFunction: async (result: Record<string, string>) => {
|
||||
try {
|
||||
if (result['PCD_PAY_RST'] !== 'success') {
|
||||
setError(result['PCD_PAY_MSG'] ?? '결제가 취소되었습니다')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
const payerId = result['PCD_PAYER_ID']
|
||||
if (!payerId) {
|
||||
setError('빌링키를 받지 못했습니다')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Edge Function 호출: 빌링키로 첫 결제 실행
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/payple-checkout`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
payer_id: payerId,
|
||||
tier,
|
||||
pcd_pay_cardname: result['PCD_PAY_CARDNAME'],
|
||||
pcd_pay_cardnum: result['PCD_PAY_CARDNUM'],
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const txt = await response.text()
|
||||
throw new Error(`결제 처리 실패: ${response.status} ${txt}`)
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
// 2초 후 페이지 리로드하여 구독 상태 반영
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 2000)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '알 수 없는 오류')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data, error: sessionError } = await supabase.auth.getSession()
|
||||
if (sessionError || !data.session) {
|
||||
throw new PaypleClientError('invalid_user', '로그인이 필요합니다.')
|
||||
}
|
||||
const session = data.session
|
||||
const idempotencyKey = getOrCreatePaypleIdempotencyKey(
|
||||
window.sessionStorage,
|
||||
session.user.id,
|
||||
tier
|
||||
)
|
||||
|
||||
window.PaypleCpayAuthCheck(obj)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '알 수 없는 오류')
|
||||
await runPaypleRegistration({
|
||||
clientKey: PAYPLE_CLIENT_KEY,
|
||||
userId: session.user.id,
|
||||
email: session.user.email,
|
||||
tier,
|
||||
catalogPrice,
|
||||
resultUrl: `${window.location.origin}/billing`,
|
||||
openSdk: (request) => window.PaypleCpayAuthCheck(request),
|
||||
chargeBillingKey: async (payerId, result) => {
|
||||
const response = await fetch(checkoutEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
payer_id: payerId,
|
||||
tier,
|
||||
idempotency_key: idempotencyKey,
|
||||
pcd_pay_cardname: typeof result['PCD_PAY_CARDNAME'] === 'string'
|
||||
? result['PCD_PAY_CARDNAME']
|
||||
: undefined,
|
||||
pcd_pay_cardnum: typeof result['PCD_PAY_CARDNUM'] === 'string'
|
||||
? result['PCD_PAY_CARDNUM']
|
||||
: undefined
|
||||
})
|
||||
})
|
||||
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = await response.json()
|
||||
} catch {
|
||||
throw new PaypleClientError('checkout_response_invalid', '결제 서버 응답을 확인할 수 없습니다.')
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new PaypleClientError(
|
||||
'checkout_response_invalid',
|
||||
response.status === 409
|
||||
? '결제 확인이 필요합니다. 중복 결제하지 말고 고객센터에 문의해 주세요.'
|
||||
: '결제 서버에서 요청을 완료하지 못했습니다.'
|
||||
)
|
||||
}
|
||||
assertSuccessfulCheckoutResponse(payload, tier, catalogPrice.unitAmount)
|
||||
clearPaypleIdempotencyKey(window.sessionStorage, session.user.id, tier, idempotencyKey)
|
||||
}
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
window.setTimeout(() => window.location.reload(), 2000)
|
||||
} catch (checkoutError) {
|
||||
setError(checkoutErrorMessage(checkoutError))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [sdkReady, tier])
|
||||
}, [catalogPrice, sdkReady, tier])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Script
|
||||
src={SDK_URL}
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => setSdkReady(true)}
|
||||
onError={() => setError('결제 모듈 로드 실패')}
|
||||
/>
|
||||
{PAYPLE_CONFIGURED && (
|
||||
<Script
|
||||
src={SDK_URL}
|
||||
strategy="afterInteractive"
|
||||
onLoad={() => {
|
||||
const ready = typeof window.PaypleCpayAuthCheck === 'function'
|
||||
setSdkReady(ready)
|
||||
if (!ready) setError('결제 모듈 초기화에 실패했습니다.')
|
||||
}}
|
||||
onError={() => {
|
||||
setSdkReady(false)
|
||||
setError('결제 모듈 로드에 실패했습니다.')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!PAYPLE_CONFIGURED && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mb: 1, fontSize: 11 }}>
|
||||
결제 설정이 완료되지 않았습니다.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<Alert severity="success" variant="filled" sx={{ fontSize: 13 }}>
|
||||
결제가 완료되었습니다! 잠시 후 새로고침됩니다.
|
||||
<Alert data-testid={`payple-success-${tier}`} severity="success" variant="filled" sx={{ fontSize: 13 }}>
|
||||
결제가 완료되었습니다. 구독 상태를 갱신하고 있습니다.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -147,14 +178,15 @@ export function PaypleCheckoutButton({ tier }: PaypleCheckoutButtonProps): React
|
|||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
data-testid={`payple-upgrade-${tier}`}
|
||||
onClick={() => void handleCheckout()}
|
||||
disabled={busy || !sdkReady}
|
||||
disabled={busy || !sdkReady || !PAYPLE_CONFIGURED}
|
||||
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}
|
||||
>
|
||||
{busy ? '처리 중...' : '업그레이드'}
|
||||
{busy ? '처리 중...' : `${tier === 'pro_plus' ? 'PRO+' : 'PRO'} Payple로 업그레이드`}
|
||||
</Button>
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
<Alert data-testid={`payple-error-${tier}`} severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
324
apps/web/src/components/billing/payple-client.ts
Normal file
324
apps/web/src/components/billing/payple-client.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
export type PaypleTier = 'pro' | 'pro_plus'
|
||||
|
||||
export interface PaypleCatalogPrice {
|
||||
provider: 'payple'
|
||||
unitAmount: number
|
||||
currency: string
|
||||
interval: string
|
||||
intervalCount: number
|
||||
}
|
||||
|
||||
export interface PaypleAuthRequest extends Record<string, unknown> {
|
||||
clientKey: string
|
||||
PCD_PAY_TYPE: 'card'
|
||||
PCD_PAY_WORK: 'AUTH'
|
||||
PCD_CARD_VER: '01'
|
||||
PCD_PAY_GOODS: string
|
||||
PCD_PAY_TOTAL: number
|
||||
PCD_PAYER_NO: string
|
||||
PCD_PAYER_EMAIL: string
|
||||
PCD_RST_URL: string
|
||||
callbackFunction: (result: unknown) => void
|
||||
}
|
||||
|
||||
export type PaypleClientErrorCode =
|
||||
| 'invalid_user'
|
||||
| 'crypto_unavailable'
|
||||
| 'not_configured'
|
||||
| 'invalid_result_url'
|
||||
| 'cancelled'
|
||||
| 'missing_billing_key'
|
||||
| 'sdk_failed'
|
||||
| 'sdk_timeout'
|
||||
| 'checkout_response_invalid'
|
||||
| 'idempotency_unavailable'
|
||||
|
||||
export class PaypleClientError extends Error {
|
||||
constructor(
|
||||
public readonly code: PaypleClientErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PaypleClientError'
|
||||
}
|
||||
}
|
||||
|
||||
interface PaypleRegistrationOptions {
|
||||
clientKey: string
|
||||
userId: string
|
||||
email: string | null | undefined
|
||||
tier: PaypleTier
|
||||
catalogPrice: PaypleCatalogPrice
|
||||
resultUrl: string
|
||||
openSdk: (request: PaypleAuthRequest) => void
|
||||
chargeBillingKey: (payerId: string, result: Record<string, unknown>) => Promise<void>
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
const PAYER_NUMBER_MODULUS = 1_000_000_000_000_000_000n
|
||||
const PAYER_NUMBER_PATTERN = /^\d{18}$/
|
||||
const DEFAULT_SDK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
export const PAYPLE_PENDING_CHECKOUT_STORAGE_KEY = 'd3ro.payple.pending-checkout.v1'
|
||||
|
||||
const TIER_GOODS: Record<PaypleTier, string> = {
|
||||
pro: 'D3RO Voice Pro',
|
||||
pro_plus: 'D3RO Voice Pro+'
|
||||
}
|
||||
|
||||
interface PendingCheckoutRecord {
|
||||
userId: string
|
||||
tier: PaypleTier
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export interface PayplePendingStorage {
|
||||
getItem: (key: string) => string | null
|
||||
setItem: (key: string, value: string) => void
|
||||
removeItem: (key: string) => void
|
||||
}
|
||||
|
||||
function requireUserId(userId: string): void {
|
||||
if (!UUID_PATTERN.test(userId)) {
|
||||
throw new PaypleClientError('invalid_user', '결제 계정 식별자가 올바르지 않습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function validIdempotencyKey(value: unknown): value is string {
|
||||
return typeof value === 'string'
|
||||
&& /^payple-checkout:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
}
|
||||
|
||||
export function getOrCreatePaypleIdempotencyKey(
|
||||
storage: PayplePendingStorage,
|
||||
userId: string,
|
||||
tier: PaypleTier,
|
||||
randomUuid: () => string = () => globalThis.crypto.randomUUID()
|
||||
): string {
|
||||
requireUserId(userId)
|
||||
try {
|
||||
const raw = storage.getItem(PAYPLE_PENDING_CHECKOUT_STORAGE_KEY)
|
||||
if (raw) {
|
||||
const stored = JSON.parse(raw) as Partial<PendingCheckoutRecord>
|
||||
if (
|
||||
stored.userId === userId
|
||||
&& stored.tier === tier
|
||||
&& validIdempotencyKey(stored.idempotencyKey)
|
||||
) {
|
||||
return stored.idempotencyKey
|
||||
}
|
||||
}
|
||||
|
||||
const idempotencyKey = `payple-checkout:${randomUuid()}`
|
||||
if (!validIdempotencyKey(idempotencyKey)) {
|
||||
throw new Error('invalid random UUID')
|
||||
}
|
||||
storage.setItem(PAYPLE_PENDING_CHECKOUT_STORAGE_KEY, JSON.stringify({
|
||||
userId,
|
||||
tier,
|
||||
idempotencyKey
|
||||
} satisfies PendingCheckoutRecord))
|
||||
return idempotencyKey
|
||||
} catch (error) {
|
||||
if (error instanceof PaypleClientError) throw error
|
||||
throw new PaypleClientError(
|
||||
'idempotency_unavailable',
|
||||
'안전한 결제 재시도 키를 준비할 수 없습니다.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPaypleIdempotencyKey(
|
||||
storage: PayplePendingStorage,
|
||||
userId: string,
|
||||
tier: PaypleTier,
|
||||
confirmedKey: string
|
||||
): void {
|
||||
try {
|
||||
const raw = storage.getItem(PAYPLE_PENDING_CHECKOUT_STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const stored = JSON.parse(raw) as Partial<PendingCheckoutRecord>
|
||||
if (
|
||||
stored.userId === userId
|
||||
&& stored.tier === tier
|
||||
&& stored.idempotencyKey === confirmedKey
|
||||
) {
|
||||
storage.removeItem(PAYPLE_PENDING_CHECKOUT_STORAGE_KEY)
|
||||
}
|
||||
} catch {
|
||||
// A confirmed charge must not be presented as failed only because local
|
||||
// cleanup was blocked. A stale key is safe: the server remains idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
function requireResultUrl(resultUrl: string): void {
|
||||
try {
|
||||
const parsed = new URL(resultUrl)
|
||||
if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
|
||||
throw new Error('insecure result URL')
|
||||
}
|
||||
} catch {
|
||||
throw new PaypleClientError('invalid_result_url', '결제 결과 주소가 올바르지 않습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function resultRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new PaypleClientError('cancelled', '결제 인증 결과를 확인할 수 없습니다.')
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function resultString(result: Record<string, unknown>, key: string): string | null {
|
||||
const value = result[key]
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null
|
||||
}
|
||||
|
||||
/** 서버 `payplePayerNumber`와 byte-for-byte 동일한 브라우저 구현이다. */
|
||||
export async function payplePayerNumber(
|
||||
userId: string,
|
||||
subtle: SubtleCrypto | null | undefined = globalThis.crypto?.subtle
|
||||
): Promise<string> {
|
||||
requireUserId(userId)
|
||||
if (!subtle) {
|
||||
throw new PaypleClientError('crypto_unavailable', '안전한 결제 식별자를 생성할 수 없습니다.')
|
||||
}
|
||||
|
||||
const bytes = new TextEncoder().encode(`d3ro-payple:${userId}`)
|
||||
const digest = await subtle.digest('SHA-256', bytes)
|
||||
const hex = Array.from(new Uint8Array(digest))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const payerNumber = (BigInt(`0x${hex}`) % PAYER_NUMBER_MODULUS)
|
||||
.toString()
|
||||
.padStart(18, '0')
|
||||
|
||||
if (!PAYER_NUMBER_PATTERN.test(payerNumber)) {
|
||||
throw new PaypleClientError('crypto_unavailable', '안전한 결제 식별자를 생성할 수 없습니다.')
|
||||
}
|
||||
return payerNumber
|
||||
}
|
||||
|
||||
export async function createPaypleAuthRequest(
|
||||
input: Omit<PaypleRegistrationOptions, 'openSdk' | 'chargeBillingKey' | 'timeoutMs'>
|
||||
): Promise<PaypleAuthRequest> {
|
||||
const clientKey = input.clientKey.trim()
|
||||
if (!clientKey) {
|
||||
throw new PaypleClientError('not_configured', '결제 설정이 완료되지 않았습니다.')
|
||||
}
|
||||
requireResultUrl(input.resultUrl)
|
||||
|
||||
const price = input.catalogPrice
|
||||
if (
|
||||
price.provider !== 'payple'
|
||||
|| price.currency !== 'KRW'
|
||||
|| price.interval !== 'month'
|
||||
|| price.intervalCount !== 1
|
||||
|| !Number.isSafeInteger(price.unitAmount)
|
||||
|| price.unitAmount < 1
|
||||
) {
|
||||
throw new PaypleClientError('checkout_response_invalid', '검증된 Payple 가격을 확인할 수 없습니다.')
|
||||
}
|
||||
return {
|
||||
clientKey,
|
||||
PCD_PAY_TYPE: 'card',
|
||||
PCD_PAY_WORK: 'AUTH',
|
||||
PCD_CARD_VER: '01',
|
||||
PCD_PAY_GOODS: TIER_GOODS[input.tier],
|
||||
PCD_PAY_TOTAL: price.unitAmount,
|
||||
PCD_PAYER_NO: await payplePayerNumber(input.userId),
|
||||
PCD_PAYER_EMAIL: input.email?.trim() ?? '',
|
||||
PCD_RST_URL: input.resultUrl,
|
||||
callbackFunction: () => undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payple SDK 인증부터 서버의 첫 결제 확정까지 한 번만 완료한다.
|
||||
* 취소, 잘못된 SDK 응답, 중복 callback, SDK 예외/무응답은 성공으로 처리하지 않는다.
|
||||
*/
|
||||
export async function runPaypleRegistration(options: PaypleRegistrationOptions): Promise<void> {
|
||||
const request = await createPaypleAuthRequest(options)
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_SDK_TIMEOUT_MS
|
||||
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
|
||||
throw new PaypleClientError('sdk_timeout', '결제 인증 제한 시간이 올바르지 않습니다.')
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let callbackClaimed = false
|
||||
let completed = false
|
||||
const finish = (error?: unknown): void => {
|
||||
if (completed) return
|
||||
completed = true
|
||||
clearTimeout(timer)
|
||||
if (error === undefined) resolve()
|
||||
else reject(error)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
finish(new PaypleClientError('sdk_timeout', '결제 인증 시간이 만료되었습니다. 다시 시도해 주세요.'))
|
||||
}, timeoutMs)
|
||||
|
||||
request.callbackFunction = (rawResult: unknown): void => {
|
||||
if (callbackClaimed || completed) return
|
||||
callbackClaimed = true
|
||||
clearTimeout(timer)
|
||||
|
||||
let result: Record<string, unknown>
|
||||
try {
|
||||
result = resultRecord(rawResult)
|
||||
if (resultString(result, 'PCD_PAY_RST') !== 'success') {
|
||||
throw new PaypleClientError(
|
||||
'cancelled',
|
||||
resultString(result, 'PCD_PAY_MSG') ?? '결제가 취소되었거나 승인되지 않았습니다.'
|
||||
)
|
||||
}
|
||||
const payerId = resultString(result, 'PCD_PAYER_ID')
|
||||
if (!payerId || payerId.length > 255) {
|
||||
throw new PaypleClientError('missing_billing_key', '빌링키를 받지 못했습니다.')
|
||||
}
|
||||
void options.chargeBillingKey(payerId, result).then(
|
||||
() => finish(),
|
||||
(error: unknown) => finish(error)
|
||||
)
|
||||
} catch (error) {
|
||||
finish(error)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
options.openSdk(request)
|
||||
} catch (error) {
|
||||
if (!callbackClaimed) {
|
||||
finish(new PaypleClientError(
|
||||
'sdk_failed',
|
||||
error instanceof Error ? error.message : '결제 모듈 실행에 실패했습니다.'
|
||||
))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function assertSuccessfulCheckoutResponse(
|
||||
value: unknown,
|
||||
expectedTier: PaypleTier,
|
||||
expectedAmount: number
|
||||
): void {
|
||||
if (!Number.isSafeInteger(expectedAmount) || expectedAmount < 1) {
|
||||
throw new PaypleClientError('checkout_response_invalid', '검증된 Payple 가격을 확인할 수 없습니다.')
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new PaypleClientError('checkout_response_invalid', '결제 서버 응답을 확인할 수 없습니다.')
|
||||
}
|
||||
const response = value as Record<string, unknown>
|
||||
if (
|
||||
response['success'] !== true
|
||||
|| response['tier'] !== expectedTier
|
||||
|| typeof response['order_id'] !== 'string'
|
||||
|| response['order_id'].length === 0
|
||||
|| typeof response['amount'] !== 'number'
|
||||
|| !Number.isFinite(response['amount'])
|
||||
|| response['amount'] !== expectedAmount
|
||||
) {
|
||||
throw new PaypleClientError('checkout_response_invalid', '결제가 서버에서 확정되지 않았습니다.')
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
// apps/web/src/components/billing/payple-manage-button.tsx
|
||||
// Payple 구독 관리 — payple-manage Edge Function 호출 (구독 취소)
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, CircularProgress, Alert, Box, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions } from '@mui/material'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
|
@ -13,6 +13,9 @@ export function PaypleManageButton(): React.ReactElement {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => setReady(true), [])
|
||||
|
||||
async function handleCancel(): Promise<void> {
|
||||
setConfirmOpen(false)
|
||||
|
|
@ -20,15 +23,18 @@ export function PaypleManageButton(): React.ReactElement {
|
|||
setBusy(true)
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
if (sessionError || !session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
if (!baseUrl) throw new Error('결제 서버 설정이 완료되지 않았습니다')
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/payple-manage`,
|
||||
new URL('/functions/v1/payple-manage', baseUrl).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -39,16 +45,21 @@ export function PaypleManageButton(): React.ReactElement {
|
|||
}
|
||||
)
|
||||
|
||||
const data = await response.json().catch(() => null) as { success?: unknown; cancel_at?: unknown } | null
|
||||
if (!response.ok) {
|
||||
const txt = await response.text()
|
||||
throw new Error(`구독 취소 실패: ${response.status} ${txt}`)
|
||||
throw new Error(response.status === 409
|
||||
? '구독 취소 확인이 필요합니다. 다시 요청하지 말고 고객센터에 문의해 주세요.'
|
||||
: '구독 취소를 완료하지 못했습니다.')
|
||||
}
|
||||
|
||||
const data = await response.json() as { success?: boolean; cancel_at?: string }
|
||||
if (data.success) {
|
||||
setSuccess(true)
|
||||
setTimeout(() => window.location.reload(), 2000)
|
||||
if (
|
||||
data?.success !== true
|
||||
|| typeof data.cancel_at !== 'string'
|
||||
|| !Number.isFinite(Date.parse(data.cancel_at))
|
||||
) {
|
||||
throw new Error('구독 취소 결과를 확인할 수 없습니다.')
|
||||
}
|
||||
setSuccess(true)
|
||||
window.setTimeout(() => window.location.reload(), 2000)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '알 수 없는 오류')
|
||||
} finally {
|
||||
|
|
@ -58,7 +69,7 @@ export function PaypleManageButton(): React.ReactElement {
|
|||
|
||||
if (success) {
|
||||
return (
|
||||
<Alert severity="info" variant="outlined" sx={{ fontSize: 12 }}>
|
||||
<Alert data-testid="payple-manage-success" severity="info" variant="outlined" sx={{ fontSize: 12 }}>
|
||||
구독이 취소되었습니다. 현재 결제 기간이 끝날 때까지 이용 가능합니다.
|
||||
</Alert>
|
||||
)
|
||||
|
|
@ -70,8 +81,9 @@ export function PaypleManageButton(): React.ReactElement {
|
|||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={busy ? <CircularProgress size={14} /> : <SettingsIcon />}
|
||||
data-testid="payple-manage-open"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={busy}
|
||||
disabled={busy || !ready}
|
||||
>
|
||||
구독 관리
|
||||
</Button>
|
||||
|
|
@ -85,14 +97,14 @@ export function PaypleManageButton(): React.ReactElement {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setConfirmOpen(false)}>돌아가기</Button>
|
||||
<Button onClick={() => void handleCancel()} color="error" variant="contained">
|
||||
<Button data-testid="payple-manage-confirm" onClick={() => void handleCancel()} color="error" variant="contained">
|
||||
구독 취소
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
<Alert data-testid="payple-manage-error" severity="error" variant="outlined" sx={{ mt: 1, fontSize: 11 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,16 +18,20 @@ export function PortalButton(): React.ReactElement {
|
|||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
data: { session },
|
||||
error: sessionError
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
if (sessionError || !session) {
|
||||
setError('로그인이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim()
|
||||
if (!baseUrl) throw new Error('결제 서버 설정이 완료되지 않았습니다')
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/stripe-portal`,
|
||||
new URL('/functions/v1/stripe-portal', baseUrl).toString(),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -40,15 +44,14 @@ export function PortalButton(): React.ReactElement {
|
|||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const txt = await response.text()
|
||||
throw new Error(`Portal 열기 실패: ${response.status} ${txt}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { url?: string }
|
||||
if (data.url) {
|
||||
window.location.href = data.url
|
||||
const data = await response.json().catch(() => null) as { url?: unknown } | null
|
||||
if (!response.ok) throw new Error('Stripe 구독 관리 페이지를 열지 못했습니다')
|
||||
if (typeof data?.url !== 'string') throw new Error('구독 관리 URL을 받지 못했습니다')
|
||||
const portalUrl = new URL(data.url)
|
||||
if (portalUrl.protocol !== 'https:' || portalUrl.hostname !== 'billing.stripe.com') {
|
||||
throw new Error('구독 관리 URL을 신뢰할 수 없습니다')
|
||||
}
|
||||
window.location.assign(portalUrl.toString())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
} finally {
|
||||
|
|
@ -61,6 +64,7 @@ export function PortalButton(): React.ReactElement {
|
|||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
data-testid="stripe-portal-open"
|
||||
startIcon={busy ? <CircularProgress size={14} /> : <SettingsIcon />}
|
||||
onClick={() => void handleOpenPortal()}
|
||||
disabled={busy}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/chat/chat-panel.tsx
|
||||
// LLM 채팅 패널 — llm-proxy 호출 (SSE 스트리밍 대신 non-streaming JSON 첫 버전)
|
||||
// V1의 VoiceConversationService 흐름을 web에 이식한 MVP
|
||||
// D3RO-VOICE AI 음성 대화 (Talk) 패널
|
||||
// Design Reference: docs/v3/designs/talk.html & apps/desktop/src/renderer/pages/VoiceConversationPage.tsx
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, Button, TextField, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import SendIcon from '@mui/icons-material/Send'
|
||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
|
||||
import { useRef, useState, useEffect } from 'react'
|
||||
import { Alert, Box, Typography, IconButton } from '@mui/material'
|
||||
import { Send, Trash2 } from 'lucide-react'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function ChatPanel(): React.ReactElement {
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: 'welcome',
|
||||
role: 'assistant',
|
||||
content: '안녕하세요! D3RO-VOICE 음성 대화 엔진입니다. 무엇을 도와드릴까요?',
|
||||
timestamp: '9:30 AM'
|
||||
}
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -33,30 +39,37 @@ export function ChatPanel(): React.ReactElement {
|
|||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [messages])
|
||||
|
||||
async function handleSend(): Promise<void> {
|
||||
const text = input.trim()
|
||||
if (!text || busy) return
|
||||
|
||||
const userMsg: Message = { id: `u_${Date.now()}`, role: 'user', content: text }
|
||||
const userMsg: Message = {
|
||||
id: 'u_' + Date.now(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
const nextMessages = [...messages, userMsg]
|
||||
setMessages(nextMessages)
|
||||
setInput('')
|
||||
setError(null)
|
||||
setBusy(true)
|
||||
scrollToBottom()
|
||||
let requestTimeout: number | null = null
|
||||
|
||||
try {
|
||||
if (!isSupabaseConfigured()) {
|
||||
throw new Error('Supabase가 설정되지 않아 LLM 호출이 불가능합니다')
|
||||
throw new Error('Supabase 미설정')
|
||||
}
|
||||
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
throw new Error('로그인 필요')
|
||||
}
|
||||
|
||||
const payload = {
|
||||
|
|
@ -65,30 +78,39 @@ export function ChatPanel(): React.ReactElement {
|
|||
stream: true
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
requestTimeout = window.setTimeout(() => controller.abort(), 45_000)
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/llm-proxy`,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL + '/functions/v1/llm-proxy',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
Authorization: 'Bearer ' + session.access_token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errTxt = await response.text()
|
||||
throw new Error(`LLM 호출 실패: ${response.status} ${errTxt}`)
|
||||
const body = await response.json().catch(() => null) as { error?: unknown } | null
|
||||
const code = typeof body?.error === 'string' ? body.error : `http_${response.status}`
|
||||
throw new Error(code)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('응답 body가 없습니다')
|
||||
}
|
||||
if (!response.body) throw new Error('empty_response')
|
||||
|
||||
// SSE 스트림 파싱 — Anthropic content_block_delta 이벤트의 text_delta 누적
|
||||
const assistantId = `a_${Date.now()}`
|
||||
setMessages((prev) => [...prev, { id: assistantId, role: 'assistant', content: '' }])
|
||||
const assistantId = 'a_' + Date.now()
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
])
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
|
@ -114,142 +136,240 @@ export function ChatPanel(): React.ReactElement {
|
|||
if (data === '[DONE]' || data === '') continue
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data) as {
|
||||
type?: string
|
||||
delta?: { type?: string; text?: string }
|
||||
}
|
||||
const event = JSON.parse(data)
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||||
accumulated += event.delta.text ?? ''
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantId ? { ...m, content: accumulated } : m))
|
||||
)
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch {
|
||||
// 불완전한 JSON 무시
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error')
|
||||
if (!accumulated.trim()) throw new Error('empty_response')
|
||||
} catch (candidate) {
|
||||
setMessages(messages)
|
||||
setInput(text)
|
||||
const timedOut = candidate instanceof DOMException && candidate.name === 'AbortError'
|
||||
const code = candidate instanceof Error ? candidate.message : 'request_failed'
|
||||
setError(code === 'quota_exceeded'
|
||||
? '오늘 사용할 수 있는 AI 대화 한도를 모두 사용했습니다.'
|
||||
: code === 'provider_unavailable'
|
||||
? 'AI 공급자가 설정되지 않았거나 현재 사용할 수 없습니다.'
|
||||
: timedOut
|
||||
? 'AI 응답 시간이 초과되었습니다.'
|
||||
: 'AI 요청을 완료하지 못했습니다. 입력을 유지했으니 다시 시도할 수 있습니다.')
|
||||
} finally {
|
||||
if (requestTimeout !== null) window.clearTimeout(requestTimeout)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear(): void {
|
||||
setMessages([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ height: 'calc(100vh - 120px)' }}>
|
||||
{/* 메시지 영역 */}
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 760,
|
||||
mx: 'auto',
|
||||
bgcolor: 'var(--d3-bg-card)',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
boxShadow: '0 0 50px rgba(0,0,0,0.5)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100dvh - 180px)',
|
||||
minHeight: 560
|
||||
}}
|
||||
>
|
||||
{/* ── 1. 헤더 ────────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--d3-border-default)',
|
||||
bgcolor: 'var(--d3-bg-card)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--d3-accent-main)',
|
||||
boxShadow: '0 0 8px var(--d3-accent-main)'
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.2em',
|
||||
fontWeight: 500,
|
||||
color: 'var(--d3-text-label)'
|
||||
}}
|
||||
>
|
||||
TALK
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: 'var(--d3-accent-main)' }}>
|
||||
{error ? 'ERROR' : 'READY'}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: error ? '#f87171' : 'var(--d3-tag-green)',
|
||||
boxShadow: error ? '0 0 5px #f87171' : '0 0 5px var(--d3-tag-green)',
|
||||
animation: 'pulse 1s infinite'
|
||||
}}
|
||||
/>
|
||||
<IconButton size="small" onClick={() => setMessages([])} sx={{ color: 'var(--d3-text-label)', ml: 1 }}>
|
||||
<Trash2 size={15} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined" role="alert" sx={{ mx: 2.5, mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── 2. 메시지 스크롤 영역 ──────────────────────────── */}
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 2
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2.5
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
color: d3roPalette.text.muted,
|
||||
fontSize: 13,
|
||||
mt: 4
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '82%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
메시지를 입력해 대화를 시작하세요.
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: msg.role === 'user' ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
bgcolor: msg.role === 'user' ? 'rgba(59,130,246,0.1)' : 'var(--d3-bg-elevated)',
|
||||
border: msg.role === 'user' ? '1px solid rgba(59,130,246,0.3)' : '1px solid var(--d3-border-default)',
|
||||
color: msg.role === 'user' ? 'var(--d3-accent-main)' : 'var(--d3-text-secondary)',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.6
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '9px',
|
||||
color: 'var(--d3-text-label)',
|
||||
mt: 0.5,
|
||||
textAlign: msg.role === 'user' ? 'right' : 'left',
|
||||
px: 0.5
|
||||
}}
|
||||
>
|
||||
{msg.role === 'user' ? 'YOU' : 'CLAUDE'} · {msg.timestamp}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{busy && (
|
||||
<Box sx={{ alignSelf: 'flex-start', display: 'flex', alignItems: 'center', gap: 1, p: 2 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: 'var(--d3-accent-main)', animation: 'bounce 0.6s infinite' }} />
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: 'var(--d3-accent-main)', animation: 'bounce 0.6s infinite', animationDelay: '0.15s' }} />
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: 'var(--d3-accent-main)', animation: 'bounce 0.6s infinite', animationDelay: '0.3s' }} />
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
alignSelf: msg.role === 'user' ? 'flex-end' : 'flex-start',
|
||||
maxWidth: '80%'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ ...typoSx('label'), color: d3roPalette.text.label, mb: 0.5 }}>
|
||||
{msg.role === 'user' ? 'YOU' : 'ASSISTANT'}
|
||||
</Box>
|
||||
<MetalCard
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: msg.role === 'user' ? d3roPalette.bg.elevated : d3roPalette.bg.card
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
...typoSx('body'),
|
||||
color: d3roPalette.text.primary,
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
))}
|
||||
{busy && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={16} color="warning" />
|
||||
<PhosphorText variant="label" color="label">
|
||||
응답 생성 중...
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 입력 영역 */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
maxRows={4}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
{/* ── 3. 입력 바 ─────────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
bgcolor: 'var(--d3-bg-card)',
|
||||
borderTop: '1px solid var(--d3-border-default)'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
bgcolor: 'var(--d3-bg-inset)',
|
||||
borderRadius: '14px',
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
p: 1,
|
||||
gap: 1
|
||||
}}
|
||||
placeholder="메시지 입력... (Enter: 전송, Shift+Enter: 줄바꿈)"
|
||||
disabled={busy}
|
||||
size="small"
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SendIcon />}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={busy || !input.trim()}
|
||||
>
|
||||
전송
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DeleteSweepIcon />}
|
||||
onClick={handleClear}
|
||||
disabled={busy || messages.length === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="메시지를 입력하거나 말하세요..."
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'var(--d3-text-secondary)',
|
||||
fontSize: '14px',
|
||||
paddingLeft: '12px'
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => void handleSend()}
|
||||
disabled={busy || !input.trim()}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: 'rgba(59,130,246,0.15)',
|
||||
color: 'var(--d3-accent-main)',
|
||||
'&:hover': { bgcolor: 'var(--d3-accent-main)', color: '#fff' }
|
||||
}}
|
||||
>
|
||||
<Send size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1.5, px: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ width: 6, height: 6, borderRadius: '50%', bgcolor: 'var(--d3-tag-green)', boxShadow: '0 0 5px var(--d3-tag-green)' }} />
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: 'var(--d3-text-label)' }}>
|
||||
CLOUD CLAUDE
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '9px', color: 'var(--d3-text-label)', opacity: 0.5 }}>
|
||||
PRECISION DATA LINK
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
'use client'
|
||||
|
||||
// apps/web/src/components/layout/sidebar.tsx
|
||||
// 대시보드 좌측 사이드바
|
||||
// D3RO VOICE 대시보드 좌측 사이드바 — 정본 네비게이션
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
|
|
@ -18,12 +18,11 @@ import {
|
|||
type SelectChangeEvent
|
||||
} from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import ChatIcon from '@mui/icons-material/Chat'
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'
|
||||
import BookIcon from '@mui/icons-material/Book'
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'
|
||||
import GroupsIcon from '@mui/icons-material/Groups'
|
||||
import PaymentIcon from '@mui/icons-material/Payment'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import PaletteIcon from '@mui/icons-material/Palette'
|
||||
|
|
@ -55,49 +54,43 @@ export function Sidebar(): React.ReactElement {
|
|||
{
|
||||
key: 'dashboard',
|
||||
path: '/dashboard',
|
||||
label: t('nav.dashboard') ?? 'Dashboard',
|
||||
label: t('nav.dashboard') ?? '대시보드',
|
||||
icon: <DashboardIcon />
|
||||
},
|
||||
{
|
||||
key: 'meetings',
|
||||
path: '/meetings',
|
||||
label: t('nav.meetings') ?? 'Meetings',
|
||||
icon: <MeetingRoomIcon />
|
||||
},
|
||||
{
|
||||
key: 'record',
|
||||
path: '/record',
|
||||
label: t('nav.record') ?? 'Record',
|
||||
label: t('nav.record') ?? '실시간 녹음',
|
||||
icon: <MicIcon />
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
path: '/history',
|
||||
label: t('nav.history') ?? '전사 히스토리',
|
||||
icon: <HistoryIcon />
|
||||
},
|
||||
{
|
||||
key: 'chat',
|
||||
path: '/chat',
|
||||
label: t('nav.chat') ?? 'Chat',
|
||||
label: t('nav.chat') ?? '음성 대화',
|
||||
icon: <ChatIcon />
|
||||
},
|
||||
{
|
||||
key: 'knowledge',
|
||||
path: '/knowledge',
|
||||
label: t('nav.knowledge') ?? 'Knowledge',
|
||||
icon: <LibraryBooksIcon />
|
||||
key: 'dictionary',
|
||||
path: '/dictionary',
|
||||
label: t('nav.dictionary') ?? '커스텀 사전',
|
||||
icon: <BookIcon />
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
path: '/actions',
|
||||
label: t('nav.actions') ?? 'Actions',
|
||||
key: 'commands',
|
||||
path: '/commands',
|
||||
label: t('nav.commands') ?? '명령어',
|
||||
icon: <AutoAwesomeIcon />
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
path: '/teams',
|
||||
label: t('nav.teams') ?? 'Teams',
|
||||
icon: <GroupsIcon />
|
||||
},
|
||||
{
|
||||
key: 'billing',
|
||||
path: '/billing',
|
||||
label: t('nav.billing') ?? 'Billing',
|
||||
label: t('nav.billing') ?? '구독 & 업그레이드',
|
||||
icon: <PaymentIcon />
|
||||
}
|
||||
]
|
||||
|
|
@ -112,17 +105,42 @@ export function Sidebar(): React.ReactElement {
|
|||
<Box
|
||||
sx={{
|
||||
width: 240,
|
||||
minHeight: '100vh',
|
||||
minHeight: '100dvh',
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.default}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 3, borderBottom: `1px solid ${d3roPalette.border.default}` }}>
|
||||
<PhosphorText variant="heading">
|
||||
D3RO VOICE
|
||||
</PhosphorText>
|
||||
<Box sx={{ p: 3, borderBottom: `1px solid ${d3roPalette.border.default}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 0.75 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--d3-accent-main)',
|
||||
boxShadow: '0 0 8px var(--d3-accent-main)'
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--d3-tag-green)',
|
||||
boxShadow: '0 0 6px var(--d3-tag-green)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<PhosphorText variant="heading" sx={{ letterSpacing: '0.1em' }}>
|
||||
D3RO VOICE
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ fontSize: '10px', fontFamily: 'ui-monospace, monospace', color: d3roPalette.text.muted }}>
|
||||
v1.1.0
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<List sx={{ flex: 1, py: 2 }}>
|
||||
|
|
@ -134,18 +152,21 @@ export function Sidebar(): React.ReactElement {
|
|||
selected={active}
|
||||
onClick={() => router.push(item.path)}
|
||||
sx={{
|
||||
py: 1.25,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderLeft: `3px solid ${d3roPalette.accent.main}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: active ? d3roPalette.accent.main : d3roPalette.text.label }}>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: active ? d3roPalette.accent.main : d3roPalette.text.label }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 600 : 400,
|
||||
color: active ? d3roPalette.text.primary : d3roPalette.text.secondary
|
||||
}}
|
||||
/>
|
||||
|
|
@ -183,12 +204,12 @@ export function Sidebar(): React.ReactElement {
|
|||
<List sx={{ borderTop: `1px solid ${d3roPalette.border.default}` }}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => void handleLogout()}>
|
||||
<ListItemIcon sx={{ color: d3roPalette.text.label }}>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.label }}>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('nav.logout') ?? 'Logout'}
|
||||
primaryTypographyProps={{ color: d3roPalette.text.secondary }}
|
||||
primary={t('nav.logout') ?? '로그아웃'}
|
||||
primaryTypographyProps={{ fontSize: 13, color: d3roPalette.text.secondary }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export function MarkdownPreview({ content }: MarkdownPreviewProps): React.ReactE
|
|||
lineHeight: 1.6,
|
||||
'& h1, & h2, & h3, & h4': {
|
||||
color: d3roPalette.text.primary,
|
||||
fontWeight: 700,
|
||||
fontWeight: 500,
|
||||
marginTop: '1.2em',
|
||||
marginBottom: '0.4em'
|
||||
},
|
||||
|
|
@ -120,7 +120,7 @@ export function MarkdownPreview({ content }: MarkdownPreviewProps): React.ReactE
|
|||
padding: '6px 10px',
|
||||
textAlign: 'left'
|
||||
},
|
||||
'& th': { bgcolor: d3roPalette.bg.inset, fontWeight: 700 },
|
||||
'& th': { bgcolor: d3roPalette.bg.inset, fontWeight: 500 },
|
||||
'& hr': { border: 'none', borderTop: `1px solid ${d3roPalette.border.default}`, margin: '1.2em 0' },
|
||||
'& .d3ro-mermaid-source': {
|
||||
fontFamily: 'ui-monospace, Menlo, Consolas, monospace',
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/record/mic-recorder.tsx
|
||||
// getUserMedia + MediaRecorder로 오디오 캡처 → Edge Function(stt-proxy)로 전송
|
||||
// D3RO-VOICE Canonical Precision Audio Recorder
|
||||
// Design Reference: docs/v3/designs/recording.html
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import StopIcon from '@mui/icons-material/Stop'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
|
||||
import { Box, Typography, Alert } from '@mui/material'
|
||||
import { Mic, Square, X } from 'lucide-react'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
||||
import { transcribeWebAudio, WebSttError } from '@/lib/web-stt-client'
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
|
||||
|
||||
interface SttResponse {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language_code: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export function MicRecorder(): React.ReactElement {
|
||||
const [state, setState] = useState<RecorderState>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -36,15 +28,19 @@ export function MicRecorder(): React.ReactElement {
|
|||
const startAtRef = useRef<number>(0)
|
||||
const elapsedTimerRef = useRef<number | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
const transcriptionAbortRef = useRef<AbortController | null>(null)
|
||||
const transcriptionGenerationRef = useRef(0)
|
||||
|
||||
const configured = isSupabaseConfigured()
|
||||
|
||||
// cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopStream()
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
|
||||
transcriptionGenerationRef.current += 1
|
||||
transcriptionAbortRef.current?.abort()
|
||||
transcriptionAbortRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -68,7 +64,7 @@ export function MicRecorder(): React.ReactElement {
|
|||
sum += n * n
|
||||
}
|
||||
const rms = Math.sqrt(sum / buf.length)
|
||||
setLevel(Math.min(1, rms * 2.5))
|
||||
setLevel(Math.min(1, rms * 3.0))
|
||||
|
||||
rafRef.current = requestAnimationFrame(updateLevel)
|
||||
}, [])
|
||||
|
|
@ -84,12 +80,11 @@ export function MicRecorder(): React.ReactElement {
|
|||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
noiseSuppression: true,
|
||||
}
|
||||
})
|
||||
streamRef.current = stream
|
||||
|
||||
// 오디오 레벨 미터
|
||||
const AudioCtx = window.AudioContext
|
||||
const audioContext = new AudioCtx()
|
||||
audioContextRef.current = audioContext
|
||||
|
|
@ -100,7 +95,6 @@ export function MicRecorder(): React.ReactElement {
|
|||
analyserRef.current = analyser
|
||||
rafRef.current = requestAnimationFrame(updateLevel)
|
||||
|
||||
// MediaRecorder
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm'
|
||||
|
|
@ -123,7 +117,7 @@ export function MicRecorder(): React.ReactElement {
|
|||
|
||||
setState('recording')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start recording')
|
||||
setError(e instanceof Error ? e.message : '마이크 권한을 확인해주세요.')
|
||||
setState('error')
|
||||
stopStream()
|
||||
}
|
||||
|
|
@ -137,204 +131,308 @@ export function MicRecorder(): React.ReactElement {
|
|||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' })
|
||||
stopStream()
|
||||
|
||||
const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'
|
||||
|
||||
// 1. Try D3RO Cloud API STT or Supabase STT Proxy
|
||||
const generation = ++transcriptionGenerationRef.current
|
||||
const controller = new AbortController()
|
||||
transcriptionAbortRef.current?.abort()
|
||||
transcriptionAbortRef.current = controller
|
||||
try {
|
||||
if (configured) {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim() ?? ''
|
||||
if (!configured || !supabaseUrl) throw new WebSttError('not_configured')
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
|
||||
if (sessionError || !session) throw new WebSttError('auth_required')
|
||||
|
||||
if (session) {
|
||||
const formData = new FormData()
|
||||
formData.append('audio', blob, 'recording.webm')
|
||||
formData.append('sample_rate', '16000')
|
||||
formData.append('language_code', 'ko-KR')
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/stt-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as SttResponse
|
||||
setTranscript(data.transcript)
|
||||
|
||||
// Save to meetings if possible
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
const audioKey = `${user.id}/${Date.now()}.webm`
|
||||
const { error: uploadErr } = await supabase.storage
|
||||
.from('audio')
|
||||
.upload(audioKey, blob, { contentType: 'audio/webm' })
|
||||
|
||||
await supabase.from('meetings').insert({
|
||||
user_id: user.id,
|
||||
team_id: null,
|
||||
title: `녹음 ${new Date().toLocaleString('ko-KR')}`,
|
||||
status: 'completed',
|
||||
duration_ms: Math.round(data.duration_seconds * 1000),
|
||||
raw_transcript: data.transcript,
|
||||
audio_storage_key: uploadErr ? null : audioKey,
|
||||
stt_model: 'cloud-stt',
|
||||
ended_at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore meeting save failure
|
||||
}
|
||||
|
||||
setState('done')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Direct D3RO Cloud API Fallback
|
||||
const cloudFormData = new FormData()
|
||||
cloudFormData.append('file', blob, 'recording.webm')
|
||||
cloudFormData.append('language', 'ko')
|
||||
|
||||
const cloudRes = await fetch(`${apiBase}/api/stt/transcribe`, {
|
||||
method: 'POST',
|
||||
body: cloudFormData,
|
||||
const result = await transcribeWebAudio({
|
||||
audio: blob,
|
||||
accessToken: session.access_token,
|
||||
supabaseUrl,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!cloudRes.ok) {
|
||||
const errText = await cloudRes.text()
|
||||
throw new Error(`D3RO Cloud STT Error (${cloudRes.status}): ${errText}`)
|
||||
}
|
||||
|
||||
const cloudData = await cloudRes.json()
|
||||
setTranscript(cloudData.text ?? '전사 완료')
|
||||
if (generation !== transcriptionGenerationRef.current) return
|
||||
setTranscript(result.transcript)
|
||||
setState('done')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'STT 처리 실패')
|
||||
setState('error')
|
||||
} catch (transcriptionError) {
|
||||
if (generation !== transcriptionGenerationRef.current) return
|
||||
setTranscript('')
|
||||
const code = transcriptionError instanceof WebSttError ? transcriptionError.code : 'network'
|
||||
const messages: Record<string, string> = {
|
||||
cancelled: '전사를 취소했습니다.',
|
||||
auth_required: '로그인 세션을 확인한 뒤 다시 시도해 주세요.',
|
||||
quota_exceeded: '이번 사용 기간의 음성 인식 한도를 모두 사용했습니다.',
|
||||
payload_too_large: '녹음 파일이 너무 큽니다.',
|
||||
unsupported_audio: '지원하지 않는 오디오 형식입니다.',
|
||||
provider_unavailable: '현재 사용할 수 있는 음성 인식 공급자가 없습니다.',
|
||||
upstream_failed: '음성 인식 공급자 요청에 실패했습니다.',
|
||||
not_configured: '음성 인식 서버 설정이 완료되지 않았습니다.',
|
||||
invalid_audio: '녹음된 오디오가 비어 있습니다.',
|
||||
invalid_response: '음성 인식 서버 응답을 확인할 수 없습니다.',
|
||||
network: '네트워크 오류로 음성 인식을 완료하지 못했습니다.'
|
||||
}
|
||||
setError(messages[code] ?? messages['network'])
|
||||
setState(code === 'cancelled' ? 'idle' : 'error')
|
||||
} finally {
|
||||
if (generation === transcriptionGenerationRef.current) transcriptionAbortRef.current = null
|
||||
}
|
||||
}, [configured, stopStream])
|
||||
|
||||
const cancelProcessing = useCallback((): void => {
|
||||
transcriptionGenerationRef.current += 1
|
||||
transcriptionAbortRef.current?.abort()
|
||||
transcriptionAbortRef.current = null
|
||||
setTranscript('')
|
||||
setError('전사를 취소했습니다.')
|
||||
setState('idle')
|
||||
}, [])
|
||||
|
||||
const stopRecording = useCallback((): void => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancelRecording = useCallback((): void => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.onstop = null
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
|
||||
stopStream()
|
||||
setState('idle')
|
||||
setTranscript('')
|
||||
setElapsed(0)
|
||||
setError(null)
|
||||
}, [stopStream])
|
||||
|
||||
const elapsedText = `${String(Math.floor(elapsed / 60000)).padStart(2, '0')}:${String(
|
||||
const elapsedText = String(Math.floor(elapsed / 60000)).padStart(2, '0') + ':' + String(
|
||||
Math.floor((elapsed / 1000) % 60)
|
||||
).padStart(2, '0')}`
|
||||
).padStart(2, '0')
|
||||
|
||||
// 9개 웨이브 바 (Speakly 스타일)
|
||||
const barHeights = Array.from({ length: 9 }, (_, i) => {
|
||||
const dist = Math.abs(i - 4) / 4
|
||||
const base = 1 - dist * 0.5
|
||||
return state === 'recording' ? Math.max(0.15, level * base) : 0.15
|
||||
const barCount = 15
|
||||
const barHeights = Array.from({ length: barCount }, (_, i) => {
|
||||
const dist = Math.abs(i - Math.floor(barCount / 2)) / (barCount / 2)
|
||||
const base = 1 - dist * 0.4
|
||||
return state === 'recording' ? Math.max(0.12, Math.min(1, (level * base * 1.5) + (Math.sin(i + elapsed / 200) * 0.15 + 0.15))) : 0.12
|
||||
})
|
||||
|
||||
return (
|
||||
<MetalCard sx={{ p: 4, maxWidth: 600, mx: 'auto' }}>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
<PhosphorText variant="heading">
|
||||
{state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'}
|
||||
</PhosphorText>
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 720,
|
||||
mx: 'auto',
|
||||
bgcolor: 'var(--d3-bg-card)',
|
||||
borderRadius: '24px',
|
||||
border: '1px solid var(--d3-border-default)',
|
||||
boxShadow: '0 0 50px rgba(0,0,0,0.5)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{/* ── 1. 세션 헤더 ───────────────────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid var(--d3-border-default)',
|
||||
bgcolor: 'var(--d3-bg-card)'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-text-label)',
|
||||
boxShadow: state === 'recording' ? '0 0 8px var(--d3-accent-main)' : 'none',
|
||||
animation: state === 'recording' ? 'pulse 1s infinite' : 'none'
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
letterSpacing: '0.2em',
|
||||
fontWeight: 500,
|
||||
color: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-text-label)'
|
||||
}}
|
||||
>
|
||||
{state === 'recording' ? 'REC_SESSION' : 'VOICE_RECORDER'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* 웨이브 바 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '12px',
|
||||
color: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-text-label)'
|
||||
}}
|
||||
>
|
||||
{elapsedText}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'var(--d3-tag-green)',
|
||||
boxShadow: '0 0 5px var(--d3-tag-green)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ── 2. 인셋 웨이브폼 스테이지 ──────────────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
height: 120,
|
||||
bgcolor: 'var(--d3-bg-inset)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottom: '1px solid #1a1a1c',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
height: 60,
|
||||
width: '100%'
|
||||
gap: 1.25,
|
||||
height: 64,
|
||||
px: 3
|
||||
}}
|
||||
>
|
||||
{barHeights.map((h, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 8,
|
||||
height: `${h * 100}%`,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
borderRadius: 1,
|
||||
transition: state === 'recording' ? 'height 50ms linear' : 'height 200ms'
|
||||
width: 6,
|
||||
height: (h * 100) + '%',
|
||||
bgcolor: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-border-default)',
|
||||
borderRadius: '4px',
|
||||
transition: 'height 80ms ease-out',
|
||||
boxShadow: state === 'recording' && h > 0.4 ? '0 0 8px rgba(59,130,246,0.5)' : 'none'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{state === 'recording' && (
|
||||
<Box sx={{ ...typoSx("title"), color: d3roPalette.text.primary, fontFamily: 'monospace' }}>
|
||||
{elapsedText}
|
||||
</Box>
|
||||
)}
|
||||
{/* ── 3. 실시간 트랜스크립트 콘솔 ────────────────────── */}
|
||||
<Box sx={{ p: 4, minHeight: 280, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: 'var(--d3-text-label)', lineHeight: 1.8 }}>
|
||||
> initializing voice recording protocol...<br />
|
||||
> establishing local AI link...<br />
|
||||
> connection secure.
|
||||
</Box>
|
||||
|
||||
{state === 'processing' && <CircularProgress size={32} />}
|
||||
|
||||
{state === 'done' && transcript && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1,
|
||||
color: d3roPalette.text.primary,
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{transcript && (
|
||||
<Box sx={{ color: 'var(--d3-text-secondary)', fontSize: '16px', lineHeight: 1.7, mt: 1 }}>
|
||||
{transcript}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === 'recording' && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, color: 'var(--d3-accent-main)', fontSize: '15px' }}>
|
||||
<span>음성을 듣고 실시간 전사 중입니다...</span>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 18,
|
||||
bgcolor: 'var(--d3-accent-main)',
|
||||
animation: 'pulse 0.8s infinite'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<Box sx={{ color: 'var(--d3-accent-main)', fontFamily: d3roFontMono, fontSize: '13px' }}>
|
||||
> processing STT audio chunk with AI model...
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%' }} variant="outlined">
|
||||
<Alert severity="error" sx={{ mt: 2 }} variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
{state === 'idle' || state === 'done' || state === 'error' ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<MicIcon />}
|
||||
onClick={() => void startRecording()}
|
||||
>
|
||||
{state === 'done' ? '새 녹음' : '녹음 시작'}
|
||||
</Button>
|
||||
{/* ── 4. 하단 텔레메트리 바 & 레코딩 버튼 ─────────────── */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 3,
|
||||
bgcolor: 'var(--d3-bg-card)',
|
||||
borderTop: '1px solid var(--d3-border-default)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-text-label)',
|
||||
animation: state === 'recording' ? 'pulse 1s infinite' : 'none'
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '10px',
|
||||
color: state === 'recording' ? 'var(--d3-accent-main)' : 'var(--d3-text-label)',
|
||||
letterSpacing: '0.1em'
|
||||
}}
|
||||
>
|
||||
{state === 'recording' ? 'LISTENING_' : 'STANDBY'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="button"
|
||||
onClick={() => {
|
||||
if (state === 'processing') {
|
||||
cancelProcessing()
|
||||
} else if (state === 'recording') {
|
||||
stopRecording()
|
||||
} else {
|
||||
void startRecording()
|
||||
}
|
||||
}}
|
||||
aria-label={state === 'processing' ? '전사 취소' : state === 'recording' ? '녹음 중지' : '녹음 시작'}
|
||||
sx={{
|
||||
width: 54,
|
||||
height: 54,
|
||||
borderRadius: '50%',
|
||||
bgcolor: state === 'recording' ? 'var(--d3-bg-elevated)' : 'var(--d3-accent-main)',
|
||||
border: state === 'recording' ? '3px solid var(--d3-accent-main)' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 0 20px rgba(59,130,246,0.4)',
|
||||
transition: 'transform 0.15s ease, background-color 0.15s ease',
|
||||
'&:hover': { transform: 'scale(1.05)' }
|
||||
}}
|
||||
>
|
||||
{state === 'processing' ? (
|
||||
<X size={22} color="var(--d3-bg-card)" strokeWidth={2.5} />
|
||||
) : state === 'recording' ? (
|
||||
<>
|
||||
<Button variant="contained" color="error" startIcon={<StopIcon />} onClick={stopRecording}>
|
||||
정지
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<CloseIcon />} onClick={cancelRecording}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
<Square size={20} color="var(--d3-accent-main)" fill="var(--d3-accent-main)" />
|
||||
) : (
|
||||
<Mic size={24} color="var(--d3-bg-card)" strokeWidth={2.5} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '9px',
|
||||
color: 'var(--d3-text-label)',
|
||||
letterSpacing: '0.15em',
|
||||
opacity: 0.6
|
||||
}}
|
||||
>
|
||||
REAL-TIME TRANSCRIPT
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export function InviteMemberForm({ teamId }: InviteMemberFormProps): React.React
|
|||
size="small"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="example@d3ro.dev"
|
||||
placeholder="example@d3ro.chanpaca.net"
|
||||
fullWidth
|
||||
autoFocus
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue