feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
203
apps/desktop/src/renderer/components/payment/checkout-flow.ts
Normal file
203
apps/desktop/src/renderer/components/payment/checkout-flow.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue