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

@ -12,8 +12,6 @@ import {
Library,
Users,
Settings,
Sparkles,
Zap,
Headphones,
} from 'lucide-react'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
@ -182,7 +180,7 @@ export function AppLayout(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '20px',
fontWeight: 800,
fontWeight: 600,
letterSpacing: '-0.02em',
backgroundImage: d3roPalette.gradient.logo,
backgroundClip: 'text',
@ -198,7 +196,7 @@ export function AppLayout(): React.ReactElement {
sx={{
fontFamily: d3roFontMono,
fontSize: '10px',
fontWeight: 700,
fontWeight: 500,
letterSpacing: '0.08em',
color: d3roPalette.text.dimLabel,
textTransform: 'uppercase',
@ -401,7 +399,6 @@ export function AppLayout(): React.ReactElement {
onClose={() => setCheckoutModalOpen(false)}
onSuccess={(newTier) => {
setCurrentTier(newTier)
setCheckoutModalOpen(false)
}}
/>
<RewardedQuotaModal

View file

@ -6,7 +6,6 @@ import { useEffect, useState } from 'react'
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
import { Cloud, CloudCheck, Globe, GitBranch } from 'lucide-react'
import { d3roPalette, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
interface CloudSyncState {
authenticated: boolean
@ -22,7 +21,6 @@ interface SyncProgress {
}
export function CloudSyncSection(): React.ReactElement {
const { t: _t } = useI18n()
const [state, setState] = useState<CloudSyncState>({
authenticated: false,
userEmail: null,

View file

@ -63,7 +63,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.body.size,
letterSpacing: d3roTypo.engrave.spacing,
textTransform: 'uppercase',
@ -91,7 +91,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
fontWeight: 500,
letterSpacing: '0.5px',
color: d3roPalette.text.inactive,
minHeight: 44,
@ -119,7 +119,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
'& .MuiAlert-icon': { color: d3roPalette.accent.main },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, mb: 0.5 }}>
(30 )
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
@ -128,7 +128,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
</Alert>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, mb: 1.5, color: d3roPalette.accent.main }}>
[STEP-BY-STEP ]
</Typography>
@ -143,7 +143,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
1단계: OpenAI Platform
</Typography>
<Button
@ -154,7 +154,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -177,7 +177,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
2단계: (Secret Key)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, display: 'block', mb: 1 }}>
@ -204,7 +204,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
3단계: D3RO-Voice STT
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
@ -229,7 +229,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
'& .MuiAlert-icon': { color: d3roPalette.tag.orange },
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, mb: 0.5 }}>
ChatGPT Plus / Pro / Team
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary }}>
@ -238,7 +238,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
</Alert>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, mb: 1, color: d3roPalette.accent.main }}>
[Codex Device Flow ]
</Typography>
@ -252,7 +252,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
mb: 2,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
1. Device Auth
</Typography>
<Box
@ -292,7 +292,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
borderRadius: d3roRadius.sm,
}}
>
<Typography variant="body2" sx={{ fontWeight: 700, mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
2. (Troubleshooting)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary, display: 'block', mb: 0.5 }}>
@ -328,8 +328,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Groq STT</Typography>
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Groq STT</Typography>
<Chip label="초고속 (~200ms) · 무료 티어" size="small" sx={{ bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
LPU Whisper Large V3 0.2 .
@ -360,8 +360,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Deepgram Nova-3</Typography>
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Deepgram Nova-3</Typography>
<Chip label="업계 최고 정확도 · 무료 $200" size="small" sx={{ bgcolor: d3roPalette.tag.purpleBg, color: d3roPalette.tag.purple, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
. .
@ -392,8 +392,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>Google Gemini 2.0 Flash Audio</Typography>
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>Google Gemini 2.0 Flash Audio</Typography>
<Chip label="한국어/아시아 언어 최강" size="small" sx={{ bgcolor: d3roPalette.tag.blueBg, color: d3roPalette.tag.blue, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
Google AI Studio에서 API .
@ -424,8 +424,8 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>AssemblyAI Universal-2</Typography>
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 700 }} />
<Typography variant="subtitle2" sx={{ fontWeight: 500 }}>AssemblyAI Universal-2</Typography>
<Chip label="고도화 음향 모델" size="small" sx={{ bgcolor: d3roPalette.tag.cyanBg, color: d3roPalette.tag.cyan, height: 20, fontSize: '10px', fontWeight: 500 }} />
</Box>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
.
@ -453,7 +453,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
variant="contained"
sx={{
fontFamily: d3roFontMono,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.label.size,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,

View file

@ -20,32 +20,6 @@ import { useI18n } from '@d3ro/i18n'
import type { HotkeyBinding } from '@d3ro/core/types'
import { formatHotkeyLabel, getPlatform, keyCodeToName } from '../utils/format-hotkey'
// ── 키 이름 매핑 (Windows) ──────────────────────────────
const KEY_DISPLAY_MAP: Record<number, string> = {
// Modifier keys
16: 'Shift', 17: 'Ctrl', 18: 'Alt', 91: 'Win', 92: 'Win',
160: 'Left Shift', 161: 'Right Shift',
162: 'Left Ctrl', 163: 'Right Ctrl',
164: 'Left Alt', 165: 'Right Alt',
// Common keys
8: 'Backspace', 9: 'Tab', 13: 'Enter', 19: 'Pause', 20: 'CapsLock',
27: 'Esc', 32: 'Space',
33: 'PgUp', 34: 'PgDn', 35: 'End', 36: 'Home',
37: '←', 38: '↑', 39: '→', 40: '↓',
45: 'Insert', 46: 'Delete',
// F-keys
112: 'F1', 113: 'F2', 114: 'F3', 115: 'F4',
116: 'F5', 117: 'F6', 118: 'F7', 119: 'F8',
120: 'F9', 121: 'F10', 122: 'F11', 123: 'F12',
// Numpad
96: 'Num0', 97: 'Num1', 98: 'Num2', 99: 'Num3', 100: 'Num4',
101: 'Num5', 102: 'Num6', 103: 'Num7', 104: 'Num8', 105: 'Num9',
106: 'Num*', 107: 'Num+', 109: 'Num-', 110: 'Num.', 111: 'Num/',
// Special
186: ';', 187: '=', 188: ',', 189: '-', 190: '.', 191: '/', 192: '`',
219: '[', 220: '\\', 221: ']', 222: "'",
}
// 시스템 예약 조합
const RESERVED_COMBOS = [
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S', 'Ctrl+W',
@ -235,7 +209,7 @@ export function HotkeyRecordModal({
disableAutoFocus
disableRestoreFocus
>
<DialogTitle sx={{ fontWeight: 700, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogTitle sx={{ fontWeight: 500, fontSize: d3roTypo.heading.size }}>{title ?? t('hotkey.title')}</DialogTitle>
<DialogContent>
{/* 녹화 영역 */}
<Box
@ -266,7 +240,7 @@ export function HotkeyRecordModal({
key={`${key.keyCode}-${i}`}
label={key.name}
sx={{
fontWeight: 700,
fontWeight: 500,
fontSize: '14px',
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,

View file

@ -50,7 +50,7 @@ export function LicenseTab(): React.ReactElement {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Sparkles size={18} color={d3roPalette.accent.main} />
<Box sx={{ flex: 1 }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 'bold' }}>
<PhosphorText variant="compact" sx={{ color: d3roPalette.accent.main, fontWeight: 500 }}>
14-Day Reverse Trial (Pro+)
</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
@ -77,7 +77,7 @@ export function LicenseTab(): React.ReactElement {
{/* ── 오프라인 라이선스 키 등록 ── */}
<MetalCard>
<LicenseKeySection t={t} currentTier={currentTier} />
<LicenseKeySection currentTier={currentTier} />
</MetalCard>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
@ -113,7 +113,7 @@ function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): Rea
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}
function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; currentTier: string }): React.ReactElement {
function LicenseKeySection({ currentTier }: { currentTier: string }): React.ReactElement {
const [licenseKeyInput, setLicenseKeyInput] = useState('')
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
const [loading, setLoading] = useState(false)
@ -157,7 +157,7 @@ function LicenseKeySection({ t, currentTier }: { t: (k: string) => string; curre
</Box>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
(D3RO-LIC-... D3RO-PRO-...) .
Ed25519 (D3RO-LIC-...) .
</PhosphorText>
<Box sx={{ display: 'flex', gap: 1 }}>

View file

@ -29,8 +29,6 @@ import {
Play,
Download,
CheckCircle2,
Cpu,
Sparkles,
Zap,
} from 'lucide-react'
import {
@ -315,7 +313,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Typography
sx={{
fontFamily: d3roFontSans,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.heading.size,
color: d3roPalette.text.primary,
}}
@ -393,7 +391,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
fontSize: '11px',
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
fontWeight: 700,
fontWeight: 500,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
@ -412,7 +410,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
{/* ── Step 1: Ollama 설치 및 기동 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 1" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Chip label="STEP 1" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
Ollama
</Typography>
@ -464,7 +462,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 2" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Chip label="STEP 2" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
1-
</Typography>
@ -503,11 +501,11 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: d3roPalette.text.primary }}>
{model.name}
</Typography>
{model.recommended && (
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 700, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 500, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
)}
</Box>
<Chip label={model.size} size="small" sx={{ height: 18, fontSize: '10px', fontFamily: d3roFontMono }} />
@ -562,7 +560,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
py: 0.25,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
@ -593,7 +591,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 3" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Chip label="STEP 3" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
(AI )
</Typography>
@ -622,7 +620,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
minWidth: 100,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
@ -644,7 +642,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
borderRadius: d3roRadius.small,
}}
>
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 700, mb: 0.5 }}>
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 500, mb: 0.5 }}>
AI ({activeModel}):
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
@ -683,4 +681,3 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
</Dialog>
)
}

View file

@ -22,8 +22,6 @@ import {
CheckCircle2,
AlertCircle,
Cloud,
HardDrive,
Lock,
Download,
Play,
RefreshCw,
@ -35,7 +33,7 @@ import {
} from 'lucide-react'
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import type { LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
@ -68,7 +66,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
const [authLoading, setAuthLoading] = useState(false)
// Error State
const [errorMsg, setErrorMsg] = useState('')
const errorMsg = ''
const isVisible = open || internalOpen
@ -277,11 +275,11 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={22} color={d3roPalette.accent.main} />
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
AI
</Typography>
</Box>
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 700, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
</Box>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
<b>Ollama</b> <b>Whisper STT</b> PC .
@ -310,7 +308,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
sx={{
mt: 2.5,
fontFamily: d3roFontSans,
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -344,7 +342,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={22} color={d3roPalette.tag.blue} />
<Typography sx={{ fontWeight: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
</Typography>
</Box>
@ -407,7 +405,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
{isOllamaConnected
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 로컬 엔진 확인 필요'}
@ -438,7 +436,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
startIcon={<Play size={13} />}
onClick={handleStartOllama}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
>
Ollama
</Button>
@ -455,7 +453,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
{/* Ollama 미설치 시 가이드 */}
{!isOllamaConnected && (
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
Ollama가 :
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
@ -476,7 +474,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
{/* 추천 모델 설치 섹션 */}
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
AI (Gemma 2 2B)
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
@ -503,7 +501,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
onClick={() => handlePullModel('gemma2:2b')}
disabled={!isOllamaConnected}
sx={{
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -529,7 +527,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
variant="contained"
onClick={handleCompleteLocalSetup}
disabled={!isOllamaConnected}
sx={{ fontWeight: 700 }}
sx={{ fontWeight: 500 }}
>
</Button>
@ -616,7 +614,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
{phase === 'success' && (
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 700 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 500 }}>
D3RO Voice
</Button>
</DialogActions>
@ -624,4 +622,3 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
</Dialog>
)
}

View file

@ -233,7 +233,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
)}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '12px', mb: 0.2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontSize: '12px', mb: 0.2 }}>
{p.name}
</Typography>
</Box>
@ -244,7 +244,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
mt: 1,
height: 18,
fontSize: '9px',
fontWeight: 700,
fontWeight: 500,
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
@ -268,7 +268,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
{currentProviderInfo?.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
@ -281,7 +281,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
height: 20,
fontSize: '10px',
fontWeight: 700,
fontWeight: 500,
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
}}
@ -335,7 +335,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{selected.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
@ -351,7 +351,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
@ -403,7 +403,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
minWidth: 105,
height: 40,
whiteSpace: 'nowrap',
@ -457,7 +457,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.text.secondary,
letterSpacing: '0.5px',
}}

View file

@ -24,7 +24,7 @@ import {
Stack,
Paper,
} from '@mui/material'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, Sparkles, CheckCircle2 } from 'lucide-react'
import { X, Keyboard, Pencil, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
@ -35,7 +35,6 @@ import { STTTab } from './STTTab'
import { useI18n, LOCALE_META } from '@d3ro/i18n'
import type { Locale } from '@d3ro/i18n'
import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice, LLMModel, LLMStatus } from '@d3ro/core/types'
import { Feature } from '@d3ro/core/types'
interface SettingsModalProps {
open: boolean
@ -79,7 +78,7 @@ function HotkeyDisplay({
label={key}
size="small"
sx={{
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.label.size,
bgcolor: d3roPalette.bg.elevated,
color: d3roPalette.text.primary,
@ -138,7 +137,7 @@ function VoiceModeCard({
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
sx={{ fontWeight: 500, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, letterSpacing: '0.5px' }}
>
{title}
</Typography>
@ -163,7 +162,7 @@ function VoiceModeCard({
size="small"
sx={{
fontSize: '10px', // P4: 토큰에 없는 10px 보존
fontWeight: 700,
fontWeight: 500,
height: 20,
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
@ -189,6 +188,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
const [activeTab, setActiveTab] = useState(initialTab)
const [config, setConfig] = useState<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
const [appVersion, setAppVersion] = useState<string | null>(null)
useEffect(() => {
if (open && initialTab !== undefined) {
@ -196,13 +196,30 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
}
}, [open, initialTab])
useEffect(() => {
if (!open) return
let cancelled = false
void window.electronAPI.system.getVersion()
.then((result) => {
if (!cancelled && result.success) setAppVersion(result.data)
})
.catch(() => {
if (!cancelled) setAppVersion(null)
})
return () => {
cancelled = true
}
}, [open])
// 음성 모드 상태
const [dictationEnabled, setDictationEnabled] = useState(true)
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
const [captionBinding, setCaptionBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
const [, setHotkeyGlobalEnabled] = useState(true)
// 핫키 녹화 모달
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
@ -350,7 +367,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 700,
fontWeight: 500,
fontSize: d3roTypo.body.size,
letterSpacing: d3roTypo.engrave.spacing,
textTransform: 'uppercase',
@ -377,7 +394,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
'& .MuiTab-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 700,
fontWeight: 500,
letterSpacing: '0.5px',
textTransform: 'uppercase',
color: d3roPalette.text.inactive,
@ -700,7 +717,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ fontWeight: 700, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaStatus?.connectionState === 'connected'
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
@ -746,7 +763,7 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
}
}}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 700 }}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
>
{startingOllama ? '실행 중' : 'Ollama 실행'}
</Button>
@ -923,7 +940,9 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.version')}</Typography>
<Typography variant="body2" color="text.secondary">v1.0.0</Typography>
<Typography variant="body2" color="text.secondary">
{appVersion === null ? '—' : `v${appVersion}`}
</Typography>
</Box>
<Box>
@ -973,4 +992,3 @@ export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalPr
</>
)
}

View file

@ -6,7 +6,7 @@ import { Box, Button, IconButton, Dialog, DialogTitle, DialogContent, DialogActi
import { Plus, Trash2, Pencil, Play } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from './shared'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'

View file

@ -1,7 +1,8 @@
// src/renderer/components/TitleBar.tsx
// v2 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
// 콘텐츠 위에 오버레이되며(레퍼런스처럼 컨트롤이 콘텐츠 위에 떠 있음),
// v3 보더리스 UI: 커스텀 타이틀바 — 투명 드래그 스트립 + 우측 윈도우 컨트롤.
// 드래그 영역은 -webkit-app-region: drag, 버튼은 no-drag.
// macOS(hiddenInset)에서는 네이티브 교통신호가 좌측에 표시되므로 커스텀
// 컨트롤을 숨기고 그만큼의 좌측 여백을 확보한다.
import { useState, useEffect, useCallback } from 'react'
import { Box } from '@mui/material'
@ -9,6 +10,7 @@ import { Minus, Square, Copy, X } from 'lucide-react'
import { d3roPalette, d3roRadius } from '@d3ro/ui/theme'
const BAR_HEIGHT = 40
const isDarwin = window.electronAPI?.platform === 'darwin'
export function TitleBar(): React.ReactElement {
const [isMaximized, setIsMaximized] = useState(false)
@ -37,6 +39,8 @@ export function TitleBar(): React.ReactElement {
justifyContent: 'space-between',
alignItems: 'center',
px: 1.5,
// macOS 교통신호(x:16) 전용 여백 — 드래그 영역에는 포함되지 않는다
pl: isDarwin ? '78px' : 1.5,
WebkitAppRegion: 'drag',
userSelect: 'none',
flexShrink: 0,
@ -46,25 +50,27 @@ export function TitleBar(): React.ReactElement {
{/* Drag Region Placeholder */}
<Box sx={{ flex: 1, height: '100%', WebkitAppRegion: 'drag' }} />
{/* Window Controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
gap: '2px',
}}
>
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
<Minus size={14} />
</ControlButton>
<ControlButton onClick={handleMaximize}>
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
</ControlButton>
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
<X size={14} />
</ControlButton>
</Box>
{/* Window Controls — macOS는 네이티브 교통신호 사용 */}
{!isDarwin && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
WebkitAppRegion: 'no-drag',
gap: '2px',
}}
>
<ControlButton onClick={() => window.electronAPI.window.minimize()}>
<Minus size={14} />
</ControlButton>
<ControlButton onClick={handleMaximize}>
{isMaximized ? <Copy size={12} style={{ transform: 'scaleX(-1)' }} /> : <Square size={11} />}
</ControlButton>
<ControlButton danger onClick={() => window.electronAPI.window.close()}>
<X size={14} />
</ControlButton>
</Box>
)}
</Box>
)
}

View file

@ -12,26 +12,13 @@ interface AdBannerProps {
onOpenUpgradeModal: () => void
}
const DEFAULT_CREATIVE: AdCreativePayload = {
id: 'cursor_default',
networkId: 'direct_sponsor',
networkName: 'Direct Partner',
title: 'Cursor AI — Next-Gen AI Code Editor',
description: 'Build software with intelligent voice agents & lightning-speed code search.',
ctaText: 'Learn More',
clickUrl: 'https://cursor.com',
sponsorTag: 'Sponsor',
advertiserName: 'Cursor AI',
bidEcpm: 15.5,
format: 'banner_dock',
}
export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.ReactElement | null {
const [creative, setCreative] = useState<AdCreativePayload>(DEFAULT_CREATIVE)
const [creative, setCreative] = useState<AdCreativePayload | null>(null)
// Fetch highest bidding ad creative via mediation auction
const fetchAdAuction = useCallback(async () => {
if (tier !== 'free') return
setCreative(null)
try {
if (window.electronAPI?.ads?.requestAuction) {
const res = await window.electronAPI.ads.requestAuction({
@ -52,7 +39,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
}
}
} catch {
// fallback to default creative
setCreative(null)
}
}, [tier])
@ -64,7 +51,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
}, [fetchAdAuction])
// Pro, Pro+, Team, Enterprise users are 100% Ad-Free
if (tier !== 'free') {
if (tier !== 'free' || creative === null) {
return null
}
@ -124,7 +111,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
justifyContent: 'center',
flexShrink: 0,
fontSize: '11px',
fontWeight: 800,
fontWeight: 600,
fontFamily: d3roFontMono,
color:
creative.networkId === 'google_ad_manager'
@ -159,7 +146,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 900,
fontWeight: 600,
color: '#000',
bgcolor: '#facc15',
px: 0.6,
@ -177,7 +164,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
sx={{
fontFamily: d3roFontSans,
fontSize: '12px',
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.text.primary,
}}
>
@ -188,7 +175,7 @@ export function AdBanner({ tier, onOpenUpgradeModal }: AdBannerProps): React.Rea
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 700,
fontWeight: 500,
color:
creative.networkId === 'ethical_ads'
? '#4ade80'

View file

@ -1,5 +1,6 @@
// apps/desktop/src/renderer/components/ads/RewardedQuotaModal.tsx
// Dynamic Rewarded Video Modal with Multi-Network Video Auction (Unity/AppLovin/Playwire/ElevenLabs)
// Rewarded ad UI. It renders only a provider-returned creative and grants
// nothing until the main-process verification boundary confirms it.
import React, { useState, useEffect, useCallback } from 'react'
import { Modal, Box, Typography, Button, LinearProgress } from '@mui/material'
@ -13,32 +14,21 @@ interface RewardedQuotaModalProps {
onRewardClaimed: (addedTokens: number) => void
}
const DEFAULT_VIDEO_CREATIVE: AdCreativePayload = {
id: 'elevenlabs_video_default',
networkId: 'direct_sponsor',
networkName: 'ElevenLabs Direct Partner',
title: 'ElevenLabs Voice Synthesizer',
description: 'Human-like AI audio generation & voice cloning for developers and creators.',
ctaText: 'Explore Voice AI',
clickUrl: 'https://elevenlabs.io',
sponsorTag: 'Rewarded Sponsor',
advertiserName: 'ElevenLabs',
bidEcpm: 18.0,
format: 'rewarded_video',
rewardTokens: 50,
durationSeconds: 15,
}
export function RewardedQuotaModal({
open,
onClose,
onRewardClaimed,
}: RewardedQuotaModalProps): React.ReactElement {
const [creative, setCreative] = useState<AdCreativePayload>(DEFAULT_VIDEO_CREATIVE)
const [timeLeft, setTimeLeft] = useState(15)
const [creative, setCreative] = useState<AdCreativePayload | null>(null)
const [timeLeft, setTimeLeft] = useState(0)
const [completed, setCompleted] = useState(false)
const [loading, setLoading] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const fetchRewardedAuction = useCallback(async () => {
setLoading(true)
setErrorMessage(null)
setCreative(null)
try {
if (window.electronAPI?.ads?.requestAuction) {
const res = await window.electronAPI.ads.requestAuction({
@ -57,22 +47,31 @@ export function RewardedQuotaModal({
networkName: res.data.winner.networkName,
earnedEcpm: res.data.winningBidEcpm,
})
return
}
}
setErrorMessage('A verified rewarded ad is not available right now.')
} catch {
// fallback
setErrorMessage('The rewarded ad provider could not be reached.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) {
setTimeLeft(15)
setCreative(null)
setTimeLeft(0)
setCompleted(false)
setErrorMessage(null)
return
}
fetchRewardedAuction()
void fetchRewardedAuction()
}, [open, fetchRewardedAuction])
useEffect(() => {
if (!open || creative === null || timeLeft <= 0 || completed) return undefined
const interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
@ -85,21 +84,52 @@ export function RewardedQuotaModal({
}, 1000)
return () => clearInterval(interval)
}, [open, fetchRewardedAuction])
}, [completed, creative, open, timeLeft])
const handleClaim = async () => {
let tokens = 50
if (creative === null || !completed) return
if (window.electronAPI?.ads?.claimReward) {
const res = await window.electronAPI.ads.claimReward({
adId: creative.id,
networkId: creative.networkId as string,
})
if (res.success && res.data?.tokensAdded) {
tokens = res.data.tokensAdded
if (res.success && res.data?.success && res.data.tokensAdded > 0) {
onRewardClaimed(res.data.tokensAdded)
onClose()
return
}
}
onRewardClaimed(tokens)
onClose()
setErrorMessage('Reward verification failed. No tokens were added.')
}
if (creative === null) {
return (
<Modal open={open} onClose={onClose}>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: { xs: '90%', sm: 440 },
bgcolor: d3roPalette.bg.modal,
borderRadius: d3roRadius.modal,
border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.modal,
p: 3,
outline: 'none',
}}
>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 500, color: d3roPalette.text.primary }}>
Rewarded ad unavailable
</Typography>
<Typography sx={{ mt: 1.25, fontFamily: d3roFontSans, fontSize: 13, color: d3roPalette.text.secondary }}>
{loading ? 'Checking verified ad providers…' : errorMessage ?? 'No verified ad is available.'}
</Typography>
<Button onClick={onClose} sx={{ mt: 2, textTransform: 'none' }}>Close</Button>
</Box>
</Modal>
)
}
const duration = creative.durationSeconds || 15
@ -138,8 +168,8 @@ export function RewardedQuotaModal({
>
<PlayCircle size={18} color={d3roPalette.accent.light} />
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 700, fontSize: '15px', color: d3roPalette.text.primary }}>
Sponsor Video Refill +50 Free AI Tokens
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 500, fontSize: '15px', color: d3roPalette.text.primary }}>
Verified rewarded ad
</Typography>
</Box>
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
@ -180,7 +210,7 @@ export function RewardedQuotaModal({
sx={{
fontFamily: d3roFontMono,
fontSize: '9px',
fontWeight: 900,
fontWeight: 600,
color: '#fff',
bgcolor: '#2563eb',
px: 1,
@ -189,7 +219,7 @@ export function RewardedQuotaModal({
letterSpacing: '0.5px',
}}
>
UNITY ADS TEST MODE
{creative.networkName}
</Typography>
<Typography
sx={{
@ -198,7 +228,7 @@ export function RewardedQuotaModal({
color: 'rgba(255, 255, 255, 0.6)',
}}
>
Placement: Rewarded_Tokens_Refill
Reward verification required
</Typography>
</Box>
@ -217,7 +247,7 @@ export function RewardedQuotaModal({
borderRadius: '4px',
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 700,
fontWeight: 500,
zIndex: 2,
}}
>
@ -243,7 +273,7 @@ export function RewardedQuotaModal({
<Sparkles size={28} color="#60a5fa" />
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 800, fontSize: '15px', color: '#ffffff' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 600, fontSize: '15px', color: '#ffffff' }}>
{creative.title}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: 'rgba(255, 255, 255, 0.75)', mt: 0.5, maxWidth: 360 }}>
@ -269,7 +299,7 @@ export function RewardedQuotaModal({
{/* Footer Actions */}
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
Reward: <span style={{ color: d3roPalette.tag.greenText, fontWeight: 700 }}>+50 Cloud AI Tokens</span>
Reward is added only after server verification.
</Typography>
{completed ? (
@ -279,7 +309,7 @@ export function RewardedQuotaModal({
startIcon={<CheckCircle2 size={16} />}
sx={{
fontFamily: d3roFontSans,
fontWeight: 700,
fontWeight: 500,
fontSize: '12px',
textTransform: 'none',
bgcolor: d3roPalette.tag.greenText,
@ -290,7 +320,7 @@ export function RewardedQuotaModal({
'&:hover': { bgcolor: '#4ade80' },
}}
>
Claim +50 Free Tokens
Verify reward
</Button>
) : (
<Button

View file

@ -33,7 +33,7 @@ import {
MetalCard,
} from '@d3ro/ui/components/ds'
import { isImeComposingEvent } from '../../utils/keyboard'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roRadius } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
MeetingSessionDetail,
@ -364,7 +364,7 @@ export function MeetingDetailTabs({
sx={{
fontFamily: d3roFontSans,
fontSize: '15px',
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.text.primary,
whiteSpace: 'nowrap',
overflow: 'hidden',
@ -432,7 +432,7 @@ export function MeetingDetailTabs({
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5, pb: 1, borderBottom: `1px solid ${d3roPalette.glass.hairline}` }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Users size={16} style={{ color: d3roPalette.accent.light }} />
<PhosphorText variant="heading" sx={{ fontSize: '13px', fontWeight: 700 }}>
<PhosphorText variant="heading" sx={{ fontSize: '13px', fontWeight: 500 }}>
SPEAKER TRANSCRIPT TIMELINE
</PhosphorText>
</Box>
@ -531,7 +531,7 @@ export function MeetingDetailTabs({
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
Granola-style Hybrid Notepad
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}>
@ -573,7 +573,7 @@ export function MeetingDetailTabs({
// Interactive Action Items Checklist Tab
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
Interactive Action Items Checklist
</Typography>
<PhysicalButton

View file

@ -1,67 +1,80 @@
// src/renderer/components/payment/CheckoutModal.tsx
// Multi-PG Checkout & Subscription Modal (Toss Payments + Stripe)
// Server-authoritative Stripe checkout and subscription confirmation modal.
import React, { useState } from 'react'
import {
Modal,
Box,
Typography,
Button,
RadioGroup,
FormControlLabel,
Radio,
TextField,
CircularProgress,
} from '@mui/material'
import { CreditCard, Shield, Lock, CheckCircle2, X } from 'lucide-react'
import React, { useEffect, useRef, useState } from 'react'
import { Alert, Box, Button, CircularProgress, IconButton, Modal, Typography } from '@mui/material'
import { CheckCircle2, CreditCard, ExternalLink, Lock, RefreshCw, Shield, X } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import type { LicenseTier } from '@d3ro/core/types'
import type { CheckoutTier, LicenseTier } from '@d3ro/core/types'
import {
CheckoutFlowController,
INITIAL_CHECKOUT_FLOW_STATE,
checkoutErrorMessage
} from './checkout-flow'
interface CheckoutModalProps {
open: boolean
initialTier?: LicenseTier
onClose: () => void
onSuccess: (tier: LicenseTier) => void
onSuccess: (tier: CheckoutTier) => void
}
function normalizeInitialTier(tier: LicenseTier): CheckoutTier {
return tier === 'pro' ? 'pro' : 'pro_plus'
}
export function CheckoutModal({
open,
initialTier = 'pro_plus',
onClose,
onSuccess,
onSuccess
}: CheckoutModalProps): React.ReactElement {
const [tier, setTier] = useState<LicenseTier>(initialTier === 'free' ? 'pro_plus' : initialTier)
const [currency, setCurrency] = useState<'KRW' | 'USD'>('KRW')
const [isAnnual, setIsAnnual] = useState(true)
const [gateway, setGateway] = useState<'toss' | 'kakao' | 'stripe'>('toss')
const [isProcessing, setIsProcessing] = useState(false)
const [completed, setCompleted] = useState(false)
const [tier, setTier] = useState<CheckoutTier>(() => normalizeInitialTier(initialTier))
const [flowState, setFlowState] = useState(INITIAL_CHECKOUT_FLOW_STATE)
const controllerRef = useRef<CheckoutFlowController | null>(null)
const priceTable = {
KRW: {
pro: isAnnual ? '₩119,000 / 년' : '₩12,900 / 월',
pro_plus: isAnnual ? '₩229,000 / 년' : '₩24,900 / 월',
team: isAnnual ? '₩299,000 / 유저 / 년' : '₩32,000 / 유저 / 월',
},
USD: {
pro: isAnnual ? '$99.00 / yr' : '$9.90 / mo',
pro_plus: isAnnual ? '$199.00 / yr' : '$19.90 / mo',
team: isAnnual ? '$240.00 / user / yr' : '$25.00 / user / mo',
},
if (controllerRef.current === null) {
controllerRef.current = new CheckoutFlowController({
createCheckoutSession: (params) => window.electronAPI.payment.createCheckoutSession(params),
openExternal: (params) => window.electronAPI.system.openExternal(params),
getSubscriptionStatus: () => window.electronAPI.payment.getSubscriptionStatus()
})
}
const controller = controllerRef.current
useEffect(() => controller.subscribe(setFlowState), [controller])
useEffect(() => {
if (open) {
setTier(normalizeInitialTier(initialTier))
controller.reset()
} else {
controller.cancel()
}
}, [controller, initialTier, open])
const handleClose = (): void => {
controller.cancel()
onClose()
}
const handlePay = () => {
setIsProcessing(true)
setTimeout(() => {
setIsProcessing(false)
setCompleted(true)
onSuccess(tier)
}, 1200)
const handleCheckout = (): void => {
void controller.start(tier)
}
const handleVerification = async (): Promise<void> => {
const verifiedTier = await controller.verify()
if (verifiedTier) onSuccess(verifiedTier)
}
const busy = flowState.phase === 'creating' || flowState.phase === 'verifying'
const awaitingConfirmation = flowState.phase === 'awaiting' || flowState.phase === 'verifying'
return (
<Modal open={open} onClose={onClose}>
<Modal open={open} onClose={handleClose} aria-labelledby="checkout-modal-title">
<Box
role="dialog"
aria-modal="true"
sx={{
position: 'absolute',
top: '50%',
@ -77,189 +90,217 @@ export function CheckoutModal({
outline: 'none',
display: 'flex',
flexDirection: 'column',
gap: 2,
gap: 2
}}
>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${d3roPalette.glass.hairline}`, pb: 1.5 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
pb: 1.5
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
<CreditCard size={18} color={d3roPalette.accent.light} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '16px', fontWeight: 800, color: d3roPalette.text.primary }}>
<Typography
id="checkout-modal-title"
sx={{
fontFamily: d3roFontSans,
fontSize: '16px',
fontWeight: 600,
color: d3roPalette.text.primary
}}
>
D3RO Voice Secure Checkout
</Typography>
</Box>
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
<IconButton
onClick={handleClose}
aria-label="결제 창 닫기"
sx={{
color: d3roPalette.text.dimLabel,
'&:hover': { color: d3roPalette.text.primary }
}}
>
<X size={18} />
</Box>
</IconButton>
</Box>
{completed ? (
<Box sx={{ textAlign: 'center', py: 4, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
{flowState.phase === 'complete' && flowState.verifiedTier ? (
<Box
sx={{
textAlign: 'center',
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5
}}
>
<CheckCircle2 size={48} color={d3roPalette.tag.greenText} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 800, color: '#fff' }}>
!
<Typography
sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: '#fff' }}
>
.
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}>
Plan: {tier.toUpperCase()} 100% Ad-Free
<Typography
sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}
>
Plan: {flowState.verifiedTier.toUpperCase()}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary }}>
.
</Typography>
<Button onClick={onClose} sx={{ mt: 2, bgcolor: d3roPalette.accent.main, color: '#fff', px: 3, borderRadius: '8px' }}>
<Button
onClick={handleClose}
sx={{
mt: 2,
bgcolor: d3roPalette.accent.main,
color: '#fff',
px: 3,
borderRadius: '8px'
}}
>
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Currency & Plan Selectors */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
onClick={() => setCurrency('KRW')}
sx={{
px: 1.5,
py: 0.5,
fontSize: '11px',
fontFamily: d3roFontMono,
fontWeight: 700,
color: currency === 'KRW' ? '#fff' : d3roPalette.text.dimLabel,
bgcolor: currency === 'KRW' ? d3roPalette.accent.main : 'rgba(255,255,255,0.05)',
}}
>
KRW ()
</Button>
<Button
size="small"
onClick={() => setCurrency('USD')}
sx={{
px: 1.5,
py: 0.5,
fontSize: '11px',
fontFamily: d3roFontMono,
fontWeight: 700,
color: currency === 'USD' ? '#fff' : d3roPalette.text.dimLabel,
bgcolor: currency === 'USD' ? d3roPalette.accent.main : 'rgba(255,255,255,0.05)',
}}
>
USD ($)
</Button>
</Box>
<Typography
sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary }}
>
Stripe .
</Typography>
<Button
size="small"
onClick={() => setIsAnnual(!isAnnual)}
sx={{ fontSize: '11px', color: d3roPalette.tag.greenText, fontFamily: d3roFontSans, fontWeight: 600 }}
>
{isAnnual ? '연간 결제 (20% 할인)' : '월간 결제'}
</Button>
</Box>
{/* Plan Card */}
<Box sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'rgba(17, 26, 48, 0.7)', border: `1px solid ${d3roPalette.accent.dim}` }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: '#fff' }}>
{tier === 'pro' ? 'Pro Plan' : tier === 'pro_plus' ? 'Pro+ Plan (화자 구분 & 회의록)' : 'Team Plan'}
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '15px', fontWeight: 800, color: d3roPalette.accent.light }}>
{priceTable[currency][tier as 'pro' | 'pro_plus' | 'team']}
</Typography>
</Box>
</Box>
{/* Payment Gateways */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 1 }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1 }}>
{(['pro', 'pro_plus'] as const).map((candidate) => (
<Button
onClick={() => setGateway('toss')}
key={candidate}
onClick={() => setTier(candidate)}
disabled={flowState.phase !== 'idle' || busy}
aria-pressed={tier === candidate}
sx={{
p: 1.5,
borderRadius: '10px',
bgcolor: gateway === 'toss' ? 'rgba(59, 130, 246, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${gateway === 'toss' ? d3roPalette.accent.main : 'rgba(255,255,255,0.08)'}`,
borderRadius: d3roRadius.inner,
border: `1px solid ${tier === candidate ? d3roPalette.accent.main : d3roPalette.glass.hairline}`,
bgcolor:
tier === candidate ? 'rgba(59, 130, 246, 0.18)' : 'rgba(255,255,255,0.03)',
color: '#fff',
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 700,
textTransform: 'none',
fontWeight: 500,
textTransform: 'none'
}}
>
/
{candidate === 'pro' ? 'Pro' : 'Pro+'}
</Button>
<Button
onClick={() => setGateway('kakao')}
sx={{
p: 1.5,
borderRadius: '10px',
bgcolor: gateway === 'kakao' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${gateway === 'kakao' ? '#f59e0b' : 'rgba(255,255,255,0.08)'}`,
color: '#fff',
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 700,
textTransform: 'none',
}}
>
</Button>
<Button
onClick={() => setGateway('stripe')}
sx={{
p: 1.5,
borderRadius: '10px',
bgcolor: gateway === 'stripe' ? 'rgba(6, 182, 212, 0.2)' : 'rgba(255,255,255,0.03)',
border: `1px solid ${gateway === 'stripe' ? '#06b6d4' : 'rgba(255,255,255,0.08)'}`,
color: '#fff',
fontSize: '11px',
fontFamily: d3roFontSans,
fontWeight: 700,
textTransform: 'none',
}}
>
Stripe ()
</Button>
</Box>
))}
</Box>
{/* Input Card Info */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<TextField
size="small"
label="카드 번호 (정기결제 빌링키 등록)"
defaultValue="5361 •••• •••• 9842"
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-root': { fontSize: '12px', fontFamily: d3roFontMono } }}
/>
</Box>
{/* Pay Button */}
<Button
disabled={isProcessing}
onClick={handlePay}
<Box
sx={{
py: 1.25,
bgcolor: d3roPalette.accent.main,
color: '#fff',
fontWeight: 700,
fontSize: '13px',
borderRadius: '10px',
boxShadow: d3roShadow.glowAccent,
'&:hover': { bgcolor: d3roPalette.accent.light },
p: 2,
borderRadius: d3roRadius.inner,
bgcolor: 'rgba(17, 26, 48, 0.7)',
border: `1px solid ${d3roPalette.accent.dim}`,
display: 'flex',
gap: 1.25,
alignItems: 'flex-start'
}}
>
{isProcessing ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} color="inherit" /> ...
</Box>
<Shield size={19} color={d3roPalette.accent.light} />
<Box>
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: '13px',
fontWeight: 500,
color: '#fff'
}}
>
Stripe
</Typography>
<Typography
sx={{
mt: 0.5,
fontFamily: d3roFontSans,
fontSize: '11px',
color: d3roPalette.text.secondary
}}
>
, , Stripe .
</Typography>
</Box>
</Box>
{awaitingConfirmation && (
<Alert severity="info" icon={<ExternalLink size={18} />}>
, .
</Alert>
)}
{flowState.error && (
<Alert severity="error" role="alert">
{checkoutErrorMessage(flowState.error)}
</Alert>
)}
<Box aria-live="polite">
{flowState.phase === 'idle' || flowState.phase === 'creating' ? (
<Button
fullWidth
disabled={busy}
onClick={handleCheckout}
sx={{
py: 1.25,
bgcolor: d3roPalette.accent.main,
color: '#fff',
fontWeight: 500,
fontSize: '13px',
borderRadius: '10px',
boxShadow: d3roShadow.glowAccent,
'&:hover': { bgcolor: d3roPalette.accent.light }
}}
>
{flowState.phase === 'creating' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} color="inherit" /> ...
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Lock size={14} /> Stripe에서
</Box>
)}
</Button>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Lock size={14} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
fullWidth
disabled={busy}
onClick={() => void handleVerification()}
sx={{
py: 1.25,
bgcolor: d3roPalette.accent.main,
color: '#fff',
fontWeight: 500,
fontSize: '13px',
borderRadius: '10px'
}}
>
{flowState.phase === 'verifying' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={14} color="inherit" /> ...
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<RefreshCw size={14} />
</Box>
)}
</Button>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
</Button>
</Box>
)}
</Button>
</Box>
</Box>
)}
</Box>

View file

@ -0,0 +1,203 @@
import type { IPCResult } from '@d3ro/core/errors'
import type {
CheckoutSessionParams,
CheckoutSessionResult,
CheckoutTier,
SubscriptionStatusResult
} from '@d3ro/core/types'
const STRIPE_CHECKOUT_ORIGIN = 'https://checkout.stripe.com'
const STRIPE_CHECKOUT_PATH_PREFIX = '/c/pay/'
export type CheckoutPhase = 'idle' | 'creating' | 'awaiting' | 'verifying' | 'complete'
export type CheckoutErrorCode =
| 'checkout-failed'
| 'unsafe-checkout-response'
| 'open-failed'
| 'network-failed'
| 'verification-failed'
| 'not-confirmed'
| 'invalid-entitlement'
export interface CheckoutFlowState {
phase: CheckoutPhase
error: CheckoutErrorCode | null
verifiedTier: CheckoutTier | null
}
export interface CheckoutFlowPorts {
createCheckoutSession: (
params: CheckoutSessionParams
) => Promise<IPCResult<CheckoutSessionResult>>
openExternal: (params: { url: string }) => Promise<IPCResult<void>>
getSubscriptionStatus: () => Promise<IPCResult<SubscriptionStatusResult>>
}
export const INITIAL_CHECKOUT_FLOW_STATE: CheckoutFlowState = {
phase: 'idle',
error: null,
verifiedTier: null
}
const ERROR_MESSAGES: Record<CheckoutErrorCode, string> = {
'checkout-failed': '결제 세션을 만들지 못했습니다. 잠시 후 다시 시도해 주세요.',
'unsafe-checkout-response': '안전한 Stripe 결제 페이지를 확인하지 못했습니다.',
'open-failed': '결제 페이지를 열지 못했습니다. 잠시 후 다시 시도해 주세요.',
'network-failed': '결제 서비스에 연결하지 못했습니다. 네트워크 상태를 확인해 주세요.',
'verification-failed': '서버 구독 상태를 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.',
'not-confirmed': '서버에서 결제 완료를 아직 확인하지 못했습니다. 결제 후 다시 확인해 주세요.',
'invalid-entitlement': '서버가 유효한 구독 등급을 반환하지 않았습니다.'
}
export function checkoutErrorMessage(error: CheckoutErrorCode): string {
return ERROR_MESSAGES[error]
}
export function isTrustedStripeCheckoutUrl(value: unknown): value is string {
if (typeof value !== 'string') return false
try {
const url = new URL(value)
return (
url.protocol === 'https:' &&
url.origin === STRIPE_CHECKOUT_ORIGIN &&
url.username === '' &&
url.password === '' &&
url.pathname.startsWith(STRIPE_CHECKOUT_PATH_PREFIX) &&
url.pathname.length > STRIPE_CHECKOUT_PATH_PREFIX.length
)
} catch {
return false
}
}
function isPaidTier(value: unknown): value is CheckoutTier {
return value === 'pro' || value === 'pro_plus'
}
export class CheckoutFlowController {
private state: CheckoutFlowState = INITIAL_CHECKOUT_FLOW_STATE
private listener: ((state: CheckoutFlowState) => void) | null = null
private inFlight = false
private generation = 0
constructor(private readonly ports: CheckoutFlowPorts) {}
subscribe(listener: (state: CheckoutFlowState) => void): () => void {
this.listener = listener
listener(this.state)
return () => {
if (this.listener === listener) this.listener = null
}
}
getState(): CheckoutFlowState {
return this.state
}
reset(): void {
this.generation += 1
this.inFlight = false
this.update(INITIAL_CHECKOUT_FLOW_STATE)
}
cancel(): void {
this.reset()
}
async start(tier: CheckoutTier): Promise<boolean> {
if (this.inFlight || this.state.phase === 'awaiting' || this.state.phase === 'complete') {
return false
}
this.inFlight = true
const generation = ++this.generation
let stage: 'checkout' | 'open' = 'checkout'
this.update({ phase: 'creating', error: null, verifiedTier: null })
try {
const session = await this.ports.createCheckoutSession({ tier, provider: 'stripe' })
if (!this.isCurrent(generation)) return false
if (!session.success) {
this.fail('idle', 'checkout-failed')
return false
}
if (
session.data.provider !== 'stripe' ||
session.data.status !== 'pending' ||
!isTrustedStripeCheckoutUrl(session.data.checkoutUrl)
) {
this.fail('idle', 'unsafe-checkout-response')
return false
}
stage = 'open'
const opened = await this.ports.openExternal({ url: session.data.checkoutUrl })
if (!this.isCurrent(generation)) return false
if (!opened.success) {
this.fail('idle', 'open-failed')
return false
}
this.update({ phase: 'awaiting', error: null, verifiedTier: null })
return true
} catch {
if (this.isCurrent(generation)) {
this.fail('idle', stage === 'open' ? 'open-failed' : 'network-failed')
}
return false
} finally {
if (this.isCurrent(generation)) this.inFlight = false
}
}
async verify(): Promise<CheckoutTier | null> {
if (this.inFlight || this.state.phase !== 'awaiting') return null
this.inFlight = true
const generation = ++this.generation
this.update({ phase: 'verifying', error: null, verifiedTier: null })
try {
const subscription = await this.ports.getSubscriptionStatus()
if (!this.isCurrent(generation)) return null
if (!subscription.success) {
this.fail('awaiting', 'verification-failed')
return null
}
if (!subscription.data.valid) {
this.fail('awaiting', 'not-confirmed')
return null
}
if (!isPaidTier(subscription.data.tier)) {
this.fail('awaiting', 'invalid-entitlement')
return null
}
this.update({
phase: 'complete',
error: null,
verifiedTier: subscription.data.tier
})
return subscription.data.tier
} catch {
if (this.isCurrent(generation)) this.fail('awaiting', 'network-failed')
return null
} finally {
if (this.isCurrent(generation)) this.inFlight = false
}
}
private isCurrent(generation: number): boolean {
return generation === this.generation
}
private fail(phase: 'idle' | 'awaiting', error: CheckoutErrorCode): void {
this.update({ phase, error, verifiedTier: null })
}
private update(state: CheckoutFlowState): void {
this.state = state
this.listener?.(state)
}
}

View file

@ -5,7 +5,7 @@ import React from 'react'
import { Box, Typography } from '@mui/material'
import { Inbox } from 'lucide-react'
import { MetalCard } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roTypo } from '@d3ro/ui/theme'
export interface EmptyStateCardProps {
/** Main message / title */

View file

@ -132,7 +132,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${d3roPalette.glass.hairline}`, pb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Headphones size={20} color={d3roPalette.tag.purpleText} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 800, color: d3roPalette.text.primary }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 600, color: d3roPalette.text.primary }}>
D3RO Voice Customer Assistance (CA/CS)
</Typography>
</Box>
@ -263,7 +263,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
px: 2.5,
borderRadius: '10px',
fontFamily: d3roFontSans,
fontWeight: 700,
fontWeight: 500,
fontSize: '12px',
'&:hover': { bgcolor: '#9333ea' },
}}
@ -298,7 +298,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, pr: 0.5 }}>
{filteredFaqs.map((faq, idx) => (
<Box key={idx} sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'rgba(17, 26, 48, 0.7)', border: `1px solid ${d3roPalette.glass.hairline}` }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 700, color: d3roPalette.text.primary, mb: 0.75 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.text.primary, mb: 0.75 }}>
Q. {faq.q}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.secondary, lineHeight: 1.6, whiteSpace: 'pre-line' }}>
@ -316,7 +316,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
{ticketSubmitted ? (
<Box sx={{ textAlign: 'center', py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
<CheckCircle size={48} color={d3roPalette.tag.greenText} />
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 800, color: '#fff' }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: '#fff' }}>
!
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}>
@ -360,7 +360,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
</Typography>
</Box>
<Button type="submit" sx={{ py: 1, bgcolor: d3roPalette.tag.purpleText, color: '#fff', fontWeight: 700, fontSize: '12px', borderRadius: '10px', '&:hover': { bgcolor: '#9333ea' } }}>
<Button type="submit" sx={{ py: 1, bgcolor: d3roPalette.tag.purpleText, color: '#fff', fontWeight: 500, fontSize: '12px', borderRadius: '10px', '&:hover': { bgcolor: '#9333ea' } }}>
</Button>
</form>
@ -372,7 +372,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
{tabIndex === 3 && (
<Box sx={{ height: 420, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', p: 2, bgcolor: 'rgba(10, 14, 28, 0.7)', borderRadius: d3roRadius.inner }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 700, color: d3roPalette.text.primary }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
7 100%
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '12px', color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
@ -381,7 +381,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
{refundStatus === 'eligible' && (
<Box sx={{ p: 2, borderRadius: '10px', bgcolor: 'rgba(16, 185, 129, 0.15)', border: '1px solid rgba(16, 185, 129, 0.3)', display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 700, color: d3roPalette.tag.greenText }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.tag.greenText }}>
100% !
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: '#fff' }}>
@ -392,7 +392,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
</Typography>
<Button
onClick={() => alert('토스페이먼츠 / Stripe 결제 취소가 접수되었습니다. 영업일 기준 2~3일 내 카드사 취소 처리됩니다.')}
sx={{ mt: 1, bgcolor: d3roPalette.tag.greenText, color: '#030712', fontWeight: 700, fontSize: '12px', borderRadius: '8px' }}
sx={{ mt: 1, bgcolor: d3roPalette.tag.greenText, color: '#030712', fontWeight: 500, fontSize: '12px', borderRadius: '8px' }}
>
</Button>
@ -407,7 +407,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
py: 1.2,
bgcolor: d3roPalette.accent.main,
color: '#fff',
fontWeight: 700,
fontWeight: 500,
fontSize: '12px',
borderRadius: '10px',
'&:hover': { bgcolor: d3roPalette.accent.light },

View file

@ -19,14 +19,12 @@ import {
Pencil,
CircleCheck,
Play,
Link,
Zap,
ArrowRight,
Sparkles,
CheckCircle2,
Mic,
Cpu,
CornerDownRight,
ClipboardCopy,
} from 'lucide-react'
import {
@ -38,8 +36,7 @@ import {
TactileBadge,
} from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { TemplateSection } from '../components/TemplateSection'
@ -241,7 +238,7 @@ export function CommandsPage(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '18px',
fontWeight: 700,
fontWeight: 500,
color: activeInstruction ? d3roPalette.text.primary : d3roPalette.text.disabled,
}}
>
@ -431,7 +428,7 @@ export function CommandsPage(): React.ReactElement {
<Box
sx={{
fontSize: d3roTypo.heading.size,
fontWeight: 700,
fontWeight: 500,
fontFamily: d3roFontSans,
color: isActive ? d3roPalette.accent.light : d3roPalette.text.primary,
mb: 0.5,
@ -571,7 +568,7 @@ export function CommandsPage(): React.ReactElement {
},
}}
>
<DialogTitle sx={{ fontWeight: 700, fontFamily: d3roFontSans, color: d3roPalette.text.primary, pt: 2, px: 2.5 }}>
<DialogTitle sx={{ fontWeight: 500, fontFamily: d3roFontSans, color: d3roPalette.text.primary, pt: 2, px: 2.5 }}>
{editId ? t('commands.editTitle') : t('commands.addTitle')}
</DialogTitle>
<DialogContent sx={{ px: 2.5 }}>

View file

@ -3,7 +3,7 @@
// High-End Agency Architecture: DoubleBezel Hero / 4-Tile Bento / Multi-Engine Hub / Telemetry
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box, Button, Typography, Tooltip } from '@mui/material'
import { Box, Typography } from '@mui/material'
import {
Mic,
BookOpen,
@ -15,7 +15,6 @@ import {
Star,
Check,
Cpu,
Sparkles,
Zap,
Activity,
Radio,
@ -34,7 +33,7 @@ import {
AudioVisualizerBar,
} from '@d3ro/ui/components/ds'
import { EmptyStateCard, HistoryEntryCard } from '../components/shared'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow, d3roTypo, typoSx } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { formatHotkeyLabel } from '../utils/format-hotkey'
@ -286,7 +285,7 @@ export function DashboardPage(): React.ReactElement {
boxShadow: d3roShadow.inset,
fontFamily: d3roFontMono,
fontSize: '12px',
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.accent.light,
letterSpacing: '0.04em',
}}
@ -332,7 +331,7 @@ export function DashboardPage(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '34px',
fontWeight: 700,
fontWeight: 500,
lineHeight: 1,
color: d3roPalette.text.primary,
fontVariantNumeric: 'tabular-nums',
@ -402,7 +401,7 @@ export function DashboardPage(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '26px',
fontWeight: 700,
fontWeight: 500,
lineHeight: 1,
color: d3roPalette.text.primary,
fontVariantNumeric: 'tabular-nums',
@ -492,7 +491,7 @@ export function DashboardPage(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '28px',
fontWeight: 800,
fontWeight: 600,
color: d3roPalette.text.primary,
lineHeight: 1,
letterSpacing: '-0.02em',

View file

@ -3,15 +3,13 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, Dialog, DialogTitle, DialogContent, DialogActions, IconButton, Tooltip } from '@mui/material'
import { Plus, Trash2, Pencil, BookA, Sparkles } from 'lucide-react'
import { Plus, Trash2, Pencil, BookA } from 'lucide-react'
import { MetalCard, PhosphorText, PhysicalButton, TactileBadge } from '@d3ro/ui/components/ds'
import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { DictionaryEntry, DictionaryPage as DictPageData } from '@d3ro/core/types'
const PAGE_SIZE = 50
export function DictionaryPage(): React.ReactElement {
const { t } = useI18n()
const [data, setData] = useState<DictPageData | null>(null)
@ -23,7 +21,6 @@ export function DictionaryPage(): React.ReactElement {
const [formPronunciation, setFormPronunciation] = useState('')
const [saving, setSaving] = useState(false)
const isSavingRef = useRef(false)
const isDeletingRef = useRef(false)
const loadData = useCallback(async () => {
setLoading(true)
@ -77,17 +74,6 @@ export function DictionaryPage(): React.ReactElement {
}
}
const handleDelete = async (id: string) => {
if (isDeletingRef.current) return
isDeletingRef.current = true
try {
await window.electronAPI.dictionary.delete({ id })
loadData()
} finally {
isDeletingRef.current = false
}
}
return (
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
<PageHeader
@ -131,7 +117,7 @@ export function DictionaryPage(): React.ReactElement {
<Box
sx={{
fontSize: d3roTypo.heading.size,
fontWeight: 700,
fontWeight: 500,
fontFamily: d3roFontSans,
color: d3roPalette.text.primary,
}}
@ -212,7 +198,7 @@ export function DictionaryPage(): React.ReactElement {
},
}}
>
<DialogTitle sx={{ fontWeight: 700, fontFamily: d3roFontSans, color: d3roPalette.text.primary, pt: 2, px: 2.5 }}>
<DialogTitle sx={{ fontWeight: 500, fontFamily: d3roFontSans, color: d3roPalette.text.primary, pt: 2, px: 2.5 }}>
{editId ? t('dictionary.editTitle') : t('dictionary.addTitle')}
</DialogTitle>
<DialogContent sx={{ px: 2.5 }}>

View file

@ -2,10 +2,10 @@
// High-End Agency Dictation History & Memory Timeline
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Box, Chip, IconButton, Tooltip } from '@mui/material'
import { Download, Trash2, Tag as TagIcon, Clock, Filter } from 'lucide-react'
import { PhosphorText, TactileBadge, DoubleBezelCard, PhysicalButton } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo, d3roFontMono, d3roFontSans, d3roRadius } from '@d3ro/ui/theme'
import { Box, IconButton, Tooltip } from '@mui/material'
import { Download, Trash2, Clock, Filter } from 'lucide-react'
import { PhosphorText, TactileBadge, PhysicalButton } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { getDateKey } from '../utils/formatters'
import { EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard } from '../components/shared'

View file

@ -3,11 +3,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Box, TextField, IconButton, Tooltip, LinearProgress } from '@mui/material'
import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles, CheckCircle2 } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { Plus, Trash2, RefreshCw, Search, Send, FileText, Database, Sparkles } from 'lucide-react'
import { MetalCard, PhosphorText, Led, PhysicalButton, DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { RAGDocument, RAGQueryResult, RAGIndexProgress } from '@d3ro/core/types'

View file

@ -9,8 +9,6 @@ import {
Snackbar,
Alert,
Typography,
IconButton,
Tooltip,
Dialog,
DialogTitle,
DialogContent,
@ -28,8 +26,6 @@ import {
FileText,
Sparkles,
Mic,
Activity,
Trash2,
Tag,
CheckCircle2,
ListTodo,
@ -39,12 +35,10 @@ import {
Settings,
RefreshCw,
Cpu,
SlidersHorizontal,
} from 'lucide-react'
import {
MetalCard,
PhosphorText,
Led,
PhysicalButton,
ScreenPanel,
DoubleBezelCard,
@ -55,7 +49,7 @@ import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
import { EditableSegment } from '../components/meeting/EditableSegment'
import { PageHeader, EmptyStateCard } from '../components/shared'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
MeetingSessionSummary,
@ -91,14 +85,14 @@ export function MeetingModePage(): React.ReactElement {
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
const [totalSessions, setTotalSessions] = useState(0)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const [page] = useState(1)
const pageSize = 20
// 사전 점검 및 기동 상태
const [isStarting, setIsStarting] = useState(false)
const [errorInfo, setErrorInfo] = useState<MeetingErrorInfo | null>(null)
const [sttProvider, setSttProvider] = useState<string>('local')
const [sttEngineState, setSttEngineState] = useState<string>('ready')
const [, setSttEngineState] = useState<string>('ready')
const [micDeviceName, setMicDeviceName] = useState<string>('')
// 녹음 뷰 상태
@ -424,7 +418,7 @@ export function MeetingModePage(): React.ReactElement {
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pb: 1 }}>
<AlertCircle size={22} style={{ color: d3roPalette.tag.red }} />
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 700, fontSize: '17px', color: d3roPalette.text.primary }}>
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 500, fontSize: '17px', color: d3roPalette.text.primary }}>
{errorInfo?.title}
</Typography>
</DialogTitle>
@ -455,7 +449,7 @@ export function MeetingModePage(): React.ReactElement {
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1.2, alignItems: 'flex-start' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 700, color: d3roPalette.tag.orange, minWidth: 65 }}>
<Typography sx={{ fontSize: '12px', fontWeight: 500, color: d3roPalette.tag.orange, minWidth: 65 }}>
</Typography>
<Typography sx={{ fontSize: '12.5px', color: d3roPalette.text.primary, lineHeight: 1.4 }}>
@ -463,7 +457,7 @@ export function MeetingModePage(): React.ReactElement {
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1.2, alignItems: 'flex-start' }}>
<Typography sx={{ fontSize: '12px', fontWeight: 700, color: d3roPalette.tag.green, minWidth: 65 }}>
<Typography sx={{ fontSize: '12px', fontWeight: 500, color: d3roPalette.tag.green, minWidth: 65 }}>
</Typography>
<Typography sx={{ fontSize: '12.5px', color: d3roPalette.text.primary, lineHeight: 1.4 }}>
@ -636,7 +630,7 @@ export function MeetingModePage(): React.ReactElement {
sx={{
fontFamily: d3roFontSans,
fontSize: '15px',
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.text.primary,
lineHeight: 1.35,
display: '-webkit-box',
@ -716,7 +710,7 @@ export function MeetingModePage(): React.ReactElement {
sx={{
fontFamily: d3roFontMono,
fontSize: '20px',
fontWeight: 700,
fontWeight: 500,
color: d3roPalette.text.primary,
fontVariantNumeric: 'tabular-nums',
}}

View file

@ -3,22 +3,18 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Alert, Box, IconButton, Snackbar, TextField, Tooltip, Typography } from '@mui/material'
import { Mic, Square, Send, Trash2, XCircle, Bot, User, Sparkles, Volume2 } from 'lucide-react'
import { Mic, Square, Send, Trash2, XCircle, Bot } from 'lucide-react'
import {
MetalCard,
PhosphorText,
Led,
PhysicalButton,
ScreenPanel,
DoubleBezelCard,
TactileBadge,
AudioVisualizerBar,
} from '@d3ro/ui/components/ds'
import { PageHeader } from '../components/shared'
import { VoiceRecordingPanel } from '../components/voice-conversation/VoiceRecordingPanel'
import { useRealtimeConversation } from '../hooks/useRealtimeConversation'
import { isImeComposingEvent } from '../utils/keyboard'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
ConversationState,
@ -334,7 +330,7 @@ export function VoiceConversationPage(): React.ReactElement {
>
<Bot size={32} />
</Box>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 700, color: d3roPalette.text.primary, mb: 1 }}>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 500, color: d3roPalette.text.primary, mb: 1 }}>
{t('conversation.empty')}
</Typography>
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', color: d3roPalette.text.secondary, maxWidth: 400, mx: 'auto' }}>

View file

@ -64,7 +64,7 @@ body {
.title {
color: var(--d3-text-dimLabel);
font-size: 10px;
font-weight: 700;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 6px 10px 6px;
@ -143,7 +143,7 @@ body {
.item-active-badge {
color: var(--d3-accent-main);
font-size: 10px;
font-weight: 700;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}

View file

@ -126,7 +126,7 @@ body {
color: white;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
font-weight: 500;
}
.error-label {