feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

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

View file

@ -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 }}>

View file

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

View 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', '결제가 서버에서 확정되지 않았습니다.')
}
}

View file

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

View file

@ -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}

View file

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

View file

@ -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>

View file

@ -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',

View file

@ -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 }}>
&gt; initializing voice recording protocol...<br />
&gt; establishing local AI link...<br />
&gt; 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' }}>
&gt; 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>
)
}

View file

@ -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
/>