Several desktop paths quietly substituted defaults or partial results: a config write could fall back to a throwaway in-memory store, speech provider errors were absorbed into empty transcriptions, and meeting exports built file names from raw titles. Writes now fail explicitly when the store is unavailable, provider and model failures reach the UI as errors, and export names pass through one sanitizer. Settings, license, ad, and support surfaces use the shared theme tokens, unused hotkey helpers are gone, and the package gains strict node/renderer typecheck configs plus red-team e2e scenarios for these flows.
471 lines
17 KiB
TypeScript
471 lines
17 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Box,
|
|
Divider,
|
|
LinearProgress,
|
|
TextField,
|
|
} from '@mui/material'
|
|
import { CircleCheck, XCircle, KeyRound, Sparkles } from 'lucide-react'
|
|
import { Led, PhosphorText, PhysicalButton, MetalCard } from '@d3ro/ui/components/ds'
|
|
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
|
|
import { useI18n } from '@d3ro/i18n'
|
|
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
|
|
import { useLicenseState } from '../hooks/useLicenseState'
|
|
|
|
export function LicenseTab(): React.ReactElement {
|
|
const { t } = useI18n()
|
|
const {
|
|
licenseInfo, usage, comparison, cloud,
|
|
currentTier, isFree, isPro,
|
|
handleUpgrade, handleOpenBilling, openCloudSettings,
|
|
} = useLicenseState()
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
{/* ── 현재 플랜 + 계정 상태 ── */}
|
|
<MetalCard>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Led
|
|
color={currentTier === 'free' ? 'amber' : 'green'}
|
|
size={12}
|
|
pulse={currentTier !== 'free'}
|
|
/>
|
|
<Box sx={{ flex: 1 }}>
|
|
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block' }}>
|
|
{t('license.currentTier')}
|
|
</PhosphorText>
|
|
<TierLabel tier={currentTier} t={t} />
|
|
</Box>
|
|
{cloud.authenticated && cloud.userEmail && (
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
|
{cloud.userEmail}
|
|
</PhosphorText>
|
|
)}
|
|
</Box>
|
|
</MetalCard>
|
|
|
|
{/* ── 14일 Reverse-Trial 상태 배너 (활성화 시) ── */}
|
|
{licenseInfo?.isTrial && licenseInfo.trialExpiresAt && (
|
|
<MetalCard sx={{ bgcolor: 'var(--d3-status-warning)', borderColor: d3roPalette.accent.main }}>
|
|
<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: 500 }}>
|
|
14-Day Reverse Trial (Pro+)
|
|
</PhosphorText>
|
|
<PhosphorText variant="small" sx={{ color: d3roPalette.text.secondary }}>
|
|
{Math.max(0, Math.ceil((licenseInfo.trialExpiresAt - Date.now()) / (24 * 60 * 60 * 1000)))}일 남음 — 만료 시 100% 로컬 무료 모드로 안전하게 전환됩니다.
|
|
</PhosphorText>
|
|
</Box>
|
|
</Box>
|
|
</MetalCard>
|
|
)}
|
|
|
|
{/* ── 구독 관리 / 업그레이드 ── */}
|
|
<MetalCard>
|
|
<SubscriptionSection
|
|
t={t}
|
|
authenticated={cloud.authenticated}
|
|
isFree={isFree}
|
|
isPro={isPro}
|
|
currentTier={currentTier}
|
|
onUpgrade={handleUpgrade}
|
|
onManage={handleOpenBilling}
|
|
onSignIn={openCloudSettings}
|
|
/>
|
|
</MetalCard>
|
|
|
|
{/* ── 오프라인 라이선스 키 등록 ── */}
|
|
<MetalCard>
|
|
<LicenseKeySection currentTier={currentTier} />
|
|
</MetalCard>
|
|
|
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
|
|
|
{/* ── 일일 사용량 ── */}
|
|
<UsageSection t={t} usage={usage} currentTier={currentTier} />
|
|
|
|
{/* ── 티어 비교표 ── */}
|
|
<ComparisonTable t={t} comparison={comparison} />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// ── 하위 컴포넌트 ──────────────────────────────────────
|
|
|
|
function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement {
|
|
const color = tier === 'enterprise' || tier === 'team'
|
|
? d3roPalette.accent.main
|
|
: tier === 'pro_plus'
|
|
? d3roPalette.tag.purple
|
|
: tier === 'pro'
|
|
? d3roPalette.tag.green
|
|
: d3roPalette.accent.main
|
|
const label = tier === 'enterprise'
|
|
? 'Enterprise'
|
|
: tier === 'team'
|
|
? 'Team'
|
|
: tier === 'pro_plus'
|
|
? t('license.proPlus')
|
|
: tier === 'pro'
|
|
? t('license.pro')
|
|
: t('license.free')
|
|
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
|
|
}
|
|
|
|
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)
|
|
|
|
const handleActivateKey = async () => {
|
|
if (!licenseKeyInput.trim()) return
|
|
setLoading(true)
|
|
setFeedback(null)
|
|
try {
|
|
const res = await window.electronAPI.license.activate({ licenseKey: licenseKeyInput.trim() })
|
|
if (res.success && res.data.success) {
|
|
setFeedback({ success: true, message: res.data.message || '라이센스가 성공적으로 활성화되었습니다.' })
|
|
setLicenseKeyInput('')
|
|
} else {
|
|
setFeedback({ success: false, message: (res.success && res.data.message) ? res.data.message : '유효하지 않은 라이센스 키입니다.' })
|
|
}
|
|
} catch (err) {
|
|
setFeedback({ success: false, message: `활성화 실패: ${err}` })
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleDeactivate = async () => {
|
|
setLoading(true)
|
|
try {
|
|
await window.electronAPI.license.deactivate()
|
|
setFeedback({ success: true, message: '라이센스가 비활성화되어 Free 티어로 전환되었습니다.' })
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<KeyRound size={16} color={d3roPalette.text.label} />
|
|
<PhosphorText variant="label">
|
|
오프라인 라이센스 키 활성화 (Ed25519)
|
|
</PhosphorText>
|
|
</Box>
|
|
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
|
|
발급받은 Ed25519 디지털 서명 라이센스 키 (D3RO-LIC-...)를 입력하세요.
|
|
</PhosphorText>
|
|
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<TextField
|
|
size="small"
|
|
fullWidth
|
|
placeholder="D3RO-LIC-..."
|
|
value={licenseKeyInput}
|
|
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
|
disabled={loading}
|
|
sx={{
|
|
fontFamily: d3roFontMono,
|
|
'& .MuiOutlinedInput-root': {
|
|
bgcolor: d3roPalette.bg.inset,
|
|
borderRadius: d3roRadius.button,
|
|
fontSize: d3roTypo.small.size,
|
|
fontFamily: d3roFontMono,
|
|
color: d3roPalette.text.primary,
|
|
'& fieldset': { borderColor: d3roPalette.border.subtle },
|
|
'&:hover fieldset': { borderColor: d3roPalette.accent.main },
|
|
},
|
|
}}
|
|
/>
|
|
<PhysicalButton onClick={handleActivateKey} disabled={loading || !licenseKeyInput.trim()} sx={{ px: 2.5, whiteSpace: 'nowrap' }}>
|
|
<PhosphorText variant="compact">등록</PhosphorText>
|
|
</PhysicalButton>
|
|
{currentTier !== 'free' && (
|
|
<PhysicalButton onClick={handleDeactivate} disabled={loading} sx={{ px: 2, whiteSpace: 'nowrap' }}>
|
|
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.red }}>해제</PhosphorText>
|
|
</PhysicalButton>
|
|
)}
|
|
</Box>
|
|
|
|
{feedback && (
|
|
<PhosphorText
|
|
variant="small"
|
|
sx={{ color: feedback.success ? d3roPalette.tag.green : d3roPalette.tag.red }}
|
|
>
|
|
{feedback.message}
|
|
</PhosphorText>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
interface SubscriptionSectionProps {
|
|
t: (k: string) => string
|
|
authenticated: boolean
|
|
isFree: boolean
|
|
isPro: boolean
|
|
currentTier: string
|
|
onUpgrade: (tier: 'pro' | 'pro_plus') => void
|
|
onManage: () => void
|
|
onSignIn: () => void
|
|
}
|
|
|
|
function SubscriptionSection({ t, authenticated, isFree, isPro, onUpgrade, onManage, onSignIn }: SubscriptionSectionProps): React.ReactElement {
|
|
if (!authenticated) {
|
|
return (
|
|
<Box sx={{ textAlign: 'center', py: 1 }}>
|
|
<PhosphorText variant="body" sx={{ mb: 1.5, display: 'block', color: d3roPalette.text.secondary }}>
|
|
{t('license.signInRequired')}
|
|
</PhosphorText>
|
|
<PhysicalButton onClick={onSignIn} sx={{ width: '100%' }}>
|
|
{t('license.signInToUpgrade')}
|
|
</PhysicalButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isFree) {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<PhosphorText variant="label" sx={{ display: 'block' }}>
|
|
{t('license.subscribe')}
|
|
</PhosphorText>
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, mb: 0.5, display: 'block' }}>
|
|
{t('license.upgradeToProDesc')}
|
|
</PhosphorText>
|
|
<UpgradeButton tier="pro" t={t} onUpgrade={onUpgrade} />
|
|
<UpgradeButton tier="pro_plus" t={t} onUpgrade={onUpgrade} />
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isPro) {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<ActivePlanIndicator label={`${t('license.currentPlanActive')} — ${t('license.proPlan')}`} color={d3roPalette.tag.green} />
|
|
<UpgradeButton tier="pro_plus" t={t} onUpgrade={onUpgrade} />
|
|
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
|
|
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
|
|
</PhysicalButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (currentTier === 'team') {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<ActivePlanIndicator label="Team Workspace Plan Active" color={d3roPalette.accent.main} />
|
|
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
|
|
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
|
|
</PhysicalButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (currentTier === 'enterprise') {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<ActivePlanIndicator label="Enterprise Dedicated Plan Active" color={d3roPalette.accent.main} />
|
|
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
|
|
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
|
|
</PhysicalButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// Pro+
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
|
<ActivePlanIndicator label={`${t('license.currentPlanActive')} — ${t('license.proPlusPlan')}`} color={d3roPalette.tag.purple} />
|
|
<PhysicalButton onClick={onManage} sx={{ width: '100%' }}>
|
|
<PhosphorText variant="compact">{t('license.subscriptionManage')}</PhosphorText>
|
|
</PhysicalButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function ActivePlanIndicator({ label, color }: { label: string; color: string }): React.ReactElement {
|
|
return (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Led color="green" size={8} pulse />
|
|
<PhosphorText variant="compact" sx={{ color }}>{label}</PhosphorText>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function UpgradeButton({ tier, t, onUpgrade }: { tier: 'pro' | 'pro_plus'; t: (k: string) => string; onUpgrade: (t: 'pro' | 'pro_plus') => void }): React.ReactElement {
|
|
const planLabel = tier === 'pro_plus' ? t('license.proPlusPlan') : t('license.proPlan')
|
|
return (
|
|
<PhysicalButton onClick={() => onUpgrade(tier)} sx={{ width: '100%' }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Led color="green" size={8} />
|
|
<PhosphorText variant="compact">
|
|
{t('license.upgrade')} — {planLabel}
|
|
</PhosphorText>
|
|
</Box>
|
|
</PhysicalButton>
|
|
)
|
|
}
|
|
|
|
function UsageSection({ t, usage, currentTier }: { t: (k: string) => string; usage: Array<{ feature: string; used: number; limit: number; remaining: number }>; currentTier: string }): React.ReactElement | null {
|
|
if (usage.length === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>
|
|
{t('license.dailyUsage')}
|
|
</PhosphorText>
|
|
|
|
{usage.map((q) => (
|
|
<QuotaBar key={q.feature} t={t} feature={q.feature} used={q.used} limit={q.limit} remaining={q.remaining} />
|
|
))}
|
|
|
|
{/* Premium 모델별 쿼터 */}
|
|
{PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && (
|
|
<>
|
|
<PhosphorText variant="label" sx={{ mt: 1, color: d3roPalette.tag.green }}>
|
|
{t('license.premiumQuota')}
|
|
</PhosphorText>
|
|
{PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => (
|
|
<Box key={m.model}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
<PhosphorText variant="small">{t(m.i18nKey)}</PhosphorText>
|
|
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.main }}>
|
|
{m.limit === -1
|
|
? t('license.unlimited')
|
|
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
|
|
</PhosphorText>
|
|
</Box>
|
|
{m.limit > 0 && (
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={0}
|
|
sx={{
|
|
height: 4,
|
|
borderRadius: d3roRadius.xs,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
'& .MuiLinearProgress-bar': {
|
|
bgcolor: d3roPalette.tag.green,
|
|
borderRadius: d3roRadius.xs,
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
function QuotaBar({ t, feature, used, limit, remaining }: { t: (k: string, p?: Record<string, string>) => string; feature: string; used: number; limit: number; remaining: number }): React.ReactElement {
|
|
return (
|
|
<Box>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
|
<PhosphorText variant="small">
|
|
{t(`license.feature.${feature}`)}
|
|
</PhosphorText>
|
|
<PhosphorText variant="small" sx={{
|
|
color: limit === -1 ? d3roPalette.tag.green : remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.main,
|
|
}}>
|
|
{limit === -1
|
|
? t('license.unlimited')
|
|
: t('license.quotaUsed', { used: String(used), limit: String(limit) })}
|
|
</PhosphorText>
|
|
</Box>
|
|
{limit > 0 && (
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={Math.min(100, (used / limit) * 100)}
|
|
sx={{
|
|
height: 4,
|
|
borderRadius: d3roRadius.xs,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
'& .MuiLinearProgress-bar': {
|
|
bgcolor: used >= limit ? d3roPalette.tag.red : d3roPalette.accent.main,
|
|
borderRadius: d3roRadius.xs,
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
function ComparisonTable({ t, comparison }: { t: (k: string) => string; comparison: Array<{ feature: string; featureLabel: string; free: boolean | string; pro: boolean | string; proPlus: boolean | string }> }): React.ReactElement | null {
|
|
if (comparison.length === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>
|
|
{t('license.tierComparison')}
|
|
</PhosphorText>
|
|
|
|
<Box
|
|
component="table"
|
|
sx={{
|
|
width: '100%',
|
|
borderCollapse: 'collapse',
|
|
fontFamily: d3roFontMono,
|
|
fontSize: d3roTypo.small.size,
|
|
'& th, & td': {
|
|
py: 0.5,
|
|
px: 1,
|
|
textAlign: 'center',
|
|
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
|
},
|
|
'& th': {
|
|
color: d3roPalette.text.label,
|
|
fontWeight: d3roTypo.label.weight,
|
|
letterSpacing: d3roTypo.label.spacing,
|
|
textTransform: 'uppercase',
|
|
},
|
|
'& td:first-of-type': {
|
|
textAlign: 'left',
|
|
color: d3roPalette.text.primary,
|
|
},
|
|
}}
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th>{''}</th>
|
|
<th>{t('license.free')}</th>
|
|
<th style={{ color: d3roPalette.tag.green }}>{t('license.pro')}</th>
|
|
<th style={{ color: d3roPalette.tag.purple }}>{t('license.proPlus')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{comparison.map((row) => (
|
|
<tr key={row.feature}>
|
|
<td>{t(row.featureLabel)}</td>
|
|
<td><TierCell value={row.free} /></td>
|
|
<td><TierCell value={row.pro} /></td>
|
|
<td><TierCell value={row.proPlus} /></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</Box>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function TierCell({ value }: { value: boolean | string }): React.ReactElement {
|
|
if (value === true) {
|
|
return <CircleCheck size={14} style={{ color: d3roPalette.tag.green }} />
|
|
}
|
|
if (value === false) {
|
|
return <XCircle size={14} style={{ color: d3roPalette.text.disabled }} />
|
|
}
|
|
return (
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.accent.main }}>
|
|
{value}
|
|
</PhosphorText>
|
|
)
|
|
}
|