refactor(desktop): 라이선스 UI 빅뱅 — 키 입력 제거, 구독 기반 전환

- LicenseTab/LicenseModal: LemonSqueezy 키 입력 삭제, Payple 구독 UI로 전환
- useLicenseState 훅: 라이선스+클라우드 인증 공용 상태 관리 추출
- PREMIUM_MODEL_LIMITS: 3곳 중복 → @d3ro/core/constants 단일 소스
- i18n: LemonSqueezy 전용 키 18개 삭제, 구독 관련 키 13개 추가 (12 locale)
- DashboardPage: dashboard.model* → license.model* 키 통일
This commit is contained in:
윤찬 2026-04-12 19:54:04 +09:00
parent 68881bf0d0
commit 996def683b
17 changed files with 633 additions and 537 deletions

View file

@ -1,91 +1,36 @@
// src/renderer/components/LicenseModal.tsx
// Full-screen license management modal with instrument aesthetic
// 구독 기반 라이선스 모달 — 클라우드 인증 + Payple 결제 연동
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
Box,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Divider,
LinearProgress,
} from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow, d3roFontMono } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type { LicenseInfo, LicenseTier, TierComparison, UsageQuota } from '@d3ro/core/types'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import { useLicenseState } from '../hooks/useLicenseState'
interface LicenseModalProps {
open: boolean
onClose: () => void
}
type LedColor = 'amber' | 'green' | 'red' | 'orange' | 'off'
function tierToLedColor(tier: LicenseTier): LedColor {
switch (tier) {
case 'free': return 'amber'
case 'pro': return 'green'
case 'pro_plus': return 'green'
}
}
function tierToLabel(tier: LicenseTier, t: (k: string) => string): string {
switch (tier) {
case 'free': return t('license.free')
case 'pro': return t('license.pro')
case 'pro_plus': return t('license.proPlus')
}
}
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
const { t } = useI18n()
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
const [tierComparison, setTierComparison] = useState<TierComparison[]>([])
const [usageQuotas, setUsageQuotas] = useState<UsageQuota[]>([])
const loadData = useCallback(() => {
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setLicenseInfo(r.data)
})
window.electronAPI.license.getTierComparison().then((r) => {
if (r.success) setTierComparison(r.data)
})
window.electronAPI.license.getAllUsage().then((r) => {
if (r.success) setUsageQuotas(r.data)
})
}, [])
useEffect(() => {
if (open) {
loadData()
}
}, [open, loadData])
// Subscribe to tier changes
useEffect(() => {
const unsub = window.electronAPI.license.onTierChanged((info) => {
setLicenseInfo(info)
loadData()
})
return unsub
}, [loadData])
const currentTier = licenseInfo?.tier ?? 'free'
const isFree = currentTier === 'free'
const isPro = currentTier === 'pro'
const handleUpgrade = useCallback((tier: 'pro' | 'pro_plus' = 'pro') => {
window.electronAPI.license.openBilling({ tier })
}, [])
const {
cloud, usage, comparison,
currentTier, isFree, isPro,
handleUpgrade, handleOpenBilling, openCloudSettings,
} = useLicenseState()
return (
<Dialog
@ -120,158 +65,163 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3, overflow: 'auto' }}>
{/* ---- Current Tier ---- */}
<DialogContent sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 2.5, overflow: 'auto' }}>
{/* ── 현재 티어 ── */}
<MetalCard sx={{ overflow: 'visible' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Led color={licenseInfo ? tierToLedColor(licenseInfo.tier) : 'off'} size={12} pulse={!isFree} />
<Led
color={currentTier === 'free' ? 'amber' : 'green'}
size={12}
pulse={currentTier !== 'free'}
/>
<Box sx={{ flex: 1 }}>
<PhosphorText variant="meta">{t('license.currentTier')}</PhosphorText>
<PhosphorText variant="value">
{licenseInfo ? tierToLabel(licenseInfo.tier, t) : '...'}
</PhosphorText>
<TierLabel tier={currentTier} t={t} />
</Box>
{cloud.authenticated && cloud.userEmail && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
{cloud.userEmail}
</PhosphorText>
)}
</Box>
</MetalCard>
{/* ---- Subscription Management ---- */}
{/* ── 구독 관리 ── */}
<MetalCard sx={{ overflow: 'visible' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<PhosphorText variant="meta">{t('license.subscribe')}</PhosphorText>
{isFree && (
<>
<PhysicalButton onClick={() => handleUpgrade('pro')} sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="green" size={8} />
<PhosphorText variant="compact">
{t('license.upgrade')} {t('license.proPlan')}
</PhosphorText>
</Box>
</PhysicalButton>
<PhysicalButton onClick={() => handleUpgrade('pro_plus')} sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="green" size={8} />
<PhosphorText variant="compact">
{t('license.upgrade')} {t('license.proPlusPlan')}
</PhosphorText>
</Box>
</PhysicalButton>
</>
)}
{isPro && (
<>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color="green" size={8} pulse />
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.green }}>
{t('license.currentPlan')} {t('license.proPlan')}
</PhosphorText>
</Box>
<PhysicalButton onClick={() => handleUpgrade('pro_plus')} sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Led color="green" size={8} />
<PhosphorText variant="compact">
{t('license.upgrade')} {t('license.proPlusPlan')}
</PhosphorText>
</Box>
</PhysicalButton>
</>
)}
{currentTier === 'pro_plus' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color="green" size={8} pulse />
<PhosphorText variant="compact" sx={{ color: d3roPalette.tag.purple }}>
{t('license.currentPlan')} {t('license.proPlusPlan')}
</PhosphorText>
</Box>
)}
</Box>
<SubscriptionSection
t={t}
authenticated={cloud.authenticated}
isFree={isFree}
isPro={isPro}
onUpgrade={handleUpgrade}
onManage={handleOpenBilling}
onSignIn={() => { onClose(); openCloudSettings() }}
/>
</MetalCard>
{/* ---- Daily Usage ---- */}
{usageQuotas.length > 0 && (
{/* ── 사용량 ── */}
{usage.length > 0 && (
<MetalCard sx={{ overflow: 'visible' }}>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{usageQuotas.map((q) => (
<Box key={q.feature} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<PhosphorText variant="small" sx={{ flex: 1 }}>
{usage.map((q) => (
<Box key={q.feature} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<PhosphorText variant="small">
{t(`license.feature.${q.feature}` as Parameters<typeof t>[0])}
</PhosphorText>
<Box sx={{ flex: 1 }}>
<Box
sx={{
height: 4,
borderRadius: d3roRadius.pill,
bgcolor: d3roPalette.bg.inset,
overflow: 'hidden',
}}
>
<Box
sx={{
height: '100%',
borderRadius: d3roRadius.pill,
bgcolor:
q.limit < 0
? d3roPalette.tag.green
: q.used >= q.limit
? d3roPalette.tag.red
: d3roPalette.accent.amber,
width: q.limit < 0 ? '100%' : `${Math.min(100, (q.used / q.limit) * 100)}%`,
transition: 'width 0.3s ease',
}}
/>
</Box>
</Box>
<PhosphorText variant="dim" sx={{ minWidth: 60, textAlign: 'right' }}>
{q.limit < 0
? t('license.quotaUnlimited')
<PhosphorText variant="small" sx={{
color: q.limit === -1 ? d3roPalette.tag.green : q.remaining === 0 ? d3roPalette.tag.red : d3roPalette.accent.amber,
}}>
{q.limit === -1
? t('license.unlimited')
: t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })}
</PhosphorText>
</Box>
))}
</Box>
{q.limit > 0 && (
<LinearProgress
variant="determinate"
value={Math.min(100, (q.used / q.limit) * 100)}
sx={{
height: 4,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': {
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
borderRadius: d3roRadius.xs,
},
}}
/>
)}
</Box>
))}
{/* Premium 모델별 쿼터 */}
{PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && (
<>
<Divider sx={{ borderColor: d3roPalette.border.subtle, my: 1 }} />
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block', color: d3roPalette.tag.green }}>
{t('license.premiumQuota')}
</PhosphorText>
{PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => (
<Box key={m.model} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.3 }}>
<PhosphorText variant="small">{t(m.i18nKey as Parameters<typeof t>[0])}</PhosphorText>
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.amber }}>
{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>
))}
</>
)}
</MetalCard>
)}
{/* ---- Tier Comparison ---- */}
{tierComparison.length > 0 && (
{/* ── 티어 비교표 ── */}
{comparison.length > 0 && (
<MetalCard sx={{ overflow: 'visible' }}>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.tierComparison')}</PhosphorText>
<TableContainer>
<Table size="small" sx={{ '& td, & th': { borderColor: d3roPalette.border.subtle, py: 0.75 } }}>
<TableHead>
<TableRow>
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing, textTransform: 'uppercase' }}>
&nbsp;
</TableCell>
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.text.label, letterSpacing: d3roTypo.label.spacing }}>
{t('license.free')}
</TableCell>
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.green, letterSpacing: d3roTypo.label.spacing }}>
{t('license.pro')}
</TableCell>
<TableCell align="center" sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, color: d3roPalette.tag.purple, letterSpacing: d3roTypo.label.spacing }}>
{t('license.proPlus')}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tierComparison.map((row) => (
<TableRow key={row.feature}>
<TableCell sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
{t(row.featureLabel as Parameters<typeof t>[0])}
</TableCell>
<TableCell align="center">{renderTierCell(row.free)}</TableCell>
<TableCell align="center">{renderTierCell(row.pro)}</TableCell>
<TableCell align="center">{renderTierCell(row.proPlus)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<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 as Parameters<typeof t>[0])}</td>
<td><TierCell value={row.free} /></td>
<td><TierCell value={row.pro} /></td>
<td><TierCell value={row.proPlus} /></td>
</tr>
))}
</tbody>
</Box>
</MetalCard>
)}
</DialogContent>
@ -279,16 +229,109 @@ export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactE
)
}
function renderTierCell(value: boolean | string): React.ReactElement {
if (typeof value === 'boolean') {
return value ? (
<CheckCircleIcon sx={{ fontSize: 16, color: d3roPalette.tag.green }} />
) : (
<CancelIcon sx={{ fontSize: 16, color: d3roPalette.text.disabled }} />
// ── 공유 하위 컴포넌트 ──────────────────────────────────
function TierLabel({ tier, t }: { tier: string; t: (k: string) => string }): React.ReactElement {
const color = tier === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.amber
const label = tier === 'pro_plus' ? t('license.proPlus') : tier === 'pro' ? t('license.pro') : t('license.free')
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}
interface SubscriptionSectionProps {
t: (k: string) => string
authenticated: boolean
isFree: boolean
isPro: boolean
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="meta">{t('license.subscribe')}</PhosphorText>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, 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>
)
}
return (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.compact.size }}>
<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 TierCell({ value }: { value: boolean | string }): React.ReactElement {
if (value === true) {
return <CheckCircleIcon sx={{ fontSize: 14, color: d3roPalette.tag.green }} />
}
if (value === false) {
return <CancelIcon sx={{ fontSize: 14, color: d3roPalette.text.disabled }} />
}
return (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.accent.amber }}>
{value}
</PhosphorText>
)

View file

@ -1,231 +1,270 @@
// src/renderer/components/LicenseTab.tsx
// Phase 11: Settings License 탭 — 라이선스 키 입력, 사용량, 티어 비교
// 구독 기반 라이선스 탭 — 클라우드 인증 + Payple 결제 연동
import { useState, useEffect, useCallback } from 'react'
import {
Box,
Typography,
TextField,
Button,
Divider,
LinearProgress,
} from '@mui/material'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
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 type {
LicenseInfo,
LicenseTier,
UsageQuota,
TierComparison,
ActivateLicenseResult,
} from '@d3ro/core/types'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import { useLicenseState } from '../hooks/useLicenseState'
export function LicenseTab(): React.ReactElement {
const { t } = useI18n()
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
const [usage, setUsage] = useState<UsageQuota[]>([])
const [comparison, setComparison] = useState<TierComparison[]>([])
const [keyInput, setKeyInput] = useState('')
const [activating, setActivating] = useState(false)
const [message, setMessage] = useState<{ text: string; success: boolean } | null>(null)
const loadData = useCallback(() => {
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setLicenseInfo(r.data)
})
window.electronAPI.license.getAllUsage().then((r) => {
if (r.success) setUsage(r.data)
})
window.electronAPI.license.getTierComparison().then((r) => {
if (r.success) setComparison(r.data)
})
}, [])
useEffect(() => {
loadData()
const unsub = window.electronAPI.license.onTierChanged(() => loadData())
return unsub
}, [loadData])
const handleActivate = useCallback(async () => {
if (!keyInput.trim()) return
setActivating(true)
setMessage(null)
const result = await window.electronAPI.license.activate({ licenseKey: keyInput.trim() })
setActivating(false)
if (result.success) {
const data = result.data as ActivateLicenseResult
if (data.success) {
setMessage({ text: t('license.activated'), success: true })
setKeyInput('')
loadData()
} else {
setMessage({ text: t('license.activateError', { message: data.message }), success: false })
}
}
}, [keyInput, t, loadData])
const handleDeactivate = useCallback(async () => {
await window.electronAPI.license.deactivate()
setMessage({ text: t('license.deactivated'), success: true })
loadData()
}, [t, loadData])
const tierLabel = (tier: LicenseTier): string => {
if (tier === 'pro_plus') return t('license.proPlus')
if (tier === 'pro') return t('license.pro')
return t('license.free')
}
const tierColor = (tier: LicenseTier): string => {
if (tier === 'pro_plus') return d3roPalette.tag.green
if (tier === 'pro') return d3roPalette.accent.amber
return d3roPalette.text.secondary
}
const {
licenseInfo, usage, comparison, cloud,
currentTier, isFree, isPro,
handleUpgrade, handleOpenBilling, openCloudSettings,
} = useLicenseState()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* 현재 플랜 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('license.currentTier')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.title.size,
fontWeight: d3roTypo.title.weight,
color: licenseInfo ? tierColor(licenseInfo.tier) : d3roPalette.text.primary,
}}
>
{licenseInfo ? tierLabel(licenseInfo.tier) : '...'}
</Typography>
{licenseInfo?.activatedAt && (
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
{t('license.activatedAt')}: {new Date(licenseInfo.activatedAt).toLocaleDateString()}
</Typography>
)}
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* 라이선스 키 입력 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('license.keyLabel')}
</Typography>
{licenseInfo?.tier === 'free' ? (
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
placeholder={t('license.keyPlaceholder')}
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
disabled={activating}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.compact.size,
},
}}
{/* ── 현재 플랜 + 계정 상태 ── */}
<MetalCard>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Led
color={currentTier === 'free' ? 'amber' : 'green'}
size={12}
pulse={currentTier !== 'free'}
/>
<Button
variant="contained"
size="small"
onClick={handleActivate}
disabled={activating || !keyInput.trim()}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
bgcolor: d3roPalette.accent.amber,
color: d3roPalette.bg.app,
whiteSpace: 'nowrap',
'&:hover': { bgcolor: d3roPalette.accent.amber, filter: 'brightness(1.1)' },
}}
>
{activating ? t('license.activating') : t('license.activate')}
</Button>
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary }}>
{licenseInfo?.licenseKey ? `${licenseInfo.licenseKey.substring(0, 16)}...` : ''}
</Typography>
<Button
variant="outlined"
size="small"
onClick={handleDeactivate}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
color: d3roPalette.tag.red,
borderColor: d3roPalette.tag.red,
}}
>
{t('license.deactivate')}
</Button>
</Box>
)}
{message && (
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
color: message.success ? d3roPalette.tag.green : d3roPalette.tag.red,
}}
>
{message.text}
</Typography>
)}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* 일일 사용량 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('license.dailyUsage')}
</Typography>
{usage.map((q) => (
<Box key={q.feature}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: d3roPalette.text.primary }}>
{t(`license.feature.${q.feature}` as Parameters<typeof t>[0])}
</Typography>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size, color: q.limit === -1 ? d3roPalette.tag.green : d3roPalette.accent.amber }}>
{q.limit === -1
? t('license.quotaUnlimited')
: t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })}
</Typography>
<Box sx={{ flex: 1 }}>
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block' }}>
{t('license.currentTier')}
</PhosphorText>
<TierLabel tier={currentTier} t={t} />
</Box>
{q.limit > 0 && (
<LinearProgress
variant="determinate"
value={Math.min(100, (q.used / q.limit) * 100)}
sx={{
height: 4,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
'& .MuiLinearProgress-bar': {
bgcolor: q.used >= q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber,
borderRadius: d3roRadius.xs,
},
}}
/>
{cloud.authenticated && cloud.userEmail && (
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size }}>
{cloud.userEmail}
</PhosphorText>
)}
</Box>
))}
</MetalCard>
{/* ── 구독 관리 / 업그레이드 ── */}
<MetalCard>
<SubscriptionSection
t={t}
authenticated={cloud.authenticated}
isFree={isFree}
isPro={isPro}
currentTier={currentTier}
onUpgrade={handleUpgrade}
onManage={handleOpenBilling}
onSignIn={openCloudSettings}
/>
</MetalCard>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* 티어 비교표 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{/* ── 일일 사용량 ── */}
<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 === 'pro_plus'
? d3roPalette.tag.purple
: tier === 'pro'
? d3roPalette.tag.green
: d3roPalette.accent.amber
const label = tier === 'pro_plus' ? t('license.proPlus') : tier === 'pro' ? t('license.pro') : t('license.free')
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
}
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>
)
}
// 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.amber }}>
{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.amber,
}}>
{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.amber,
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')}
</Typography>
</PhosphorText>
<Box
component="table"
@ -256,14 +295,14 @@ export function LicenseTab(): React.ReactElement {
<tr>
<th>{''}</th>
<th>{t('license.free')}</th>
<th>{t('license.pro')}</th>
<th>{t('license.proPlus')}</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 as Parameters<typeof t>[0])}</td>
<td>{t(row.featureLabel)}</td>
<td><TierCell value={row.free} /></td>
<td><TierCell value={row.pro} /></td>
<td><TierCell value={row.proPlus} /></td>
@ -271,14 +310,7 @@ export function LicenseTab(): React.ReactElement {
))}
</tbody>
</Box>
{/* 기기 ID */}
{licenseInfo && (
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.micro.size, color: d3roPalette.text.disabled }}>
{t('license.machineId')}: {licenseInfo.machineId.substring(0, 16)}...
</Typography>
)}
</Box>
</>
)
}
@ -290,8 +322,8 @@ function TierCell({ value }: { value: boolean | string }): React.ReactElement {
return <CancelIcon sx={{ fontSize: 14, color: d3roPalette.text.disabled }} />
}
return (
<Typography sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, color: d3roPalette.accent.amber }}>
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.accent.amber }}>
{value}
</Typography>
</PhosphorText>
)
}

View file

@ -0,0 +1,87 @@
// src/renderer/hooks/useLicenseState.ts
// LicenseTab/LicenseModal 공용 — 라이선스 + 클라우드 인증 상태 관리
import { useState, useEffect, useCallback } from 'react'
import type {
LicenseInfo,
LicenseTier,
UsageQuota,
TierComparison,
} from '@d3ro/core/types'
interface CloudState {
authenticated: boolean
userEmail: string | null
}
export interface LicenseState {
licenseInfo: LicenseInfo | null
usage: UsageQuota[]
comparison: TierComparison[]
cloud: CloudState
currentTier: LicenseTier
isFree: boolean
isPro: boolean
handleUpgrade: (tier: 'pro' | 'pro_plus') => void
handleOpenBilling: () => void
openCloudSettings: () => void
}
export function useLicenseState(): LicenseState {
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null)
const [usage, setUsage] = useState<UsageQuota[]>([])
const [comparison, setComparison] = useState<TierComparison[]>([])
const [cloud, setCloud] = useState<CloudState>({ authenticated: false, userEmail: null })
const loadData = useCallback(() => {
window.electronAPI.license.getInfo().then((r) => {
if (r.success) setLicenseInfo(r.data)
})
window.electronAPI.license.getAllUsage().then((r) => {
if (r.success) setUsage(r.data)
})
window.electronAPI.license.getTierComparison().then((r) => {
if (r.success) setComparison(r.data)
})
window.electronAPI.cloudSync.getState().then((r) => {
if (r.success) setCloud({ authenticated: r.data.authenticated, userEmail: r.data.userEmail })
})
}, [])
useEffect(() => {
loadData()
const unsubTier = window.electronAPI.license.onTierChanged(() => loadData())
const unsubAuth = window.electronAPI.cloudSync.onAuthChanged((payload) => {
setCloud({ authenticated: payload.user !== null, userEmail: payload.user?.email ?? null })
loadData()
})
return () => { unsubTier(); unsubAuth() }
}, [loadData])
const currentTier = licenseInfo?.tier ?? 'free'
const handleUpgrade = useCallback((tier: 'pro' | 'pro_plus') => {
window.electronAPI.license.openBilling({ tier })
}, [])
const handleOpenBilling = useCallback(() => {
window.electronAPI.license.openBilling({ tier: 'pro' })
}, [])
const openCloudSettings = useCallback(() => {
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 'cloud' } }))
}, [])
return {
licenseInfo,
usage,
comparison,
cloud,
currentTier,
isFree: currentTier === 'free',
isPro: currentTier === 'pro',
handleUpgrade,
handleOpenBilling,
openCloudSettings,
}
}

View file

@ -11,25 +11,9 @@ import { useI18n } from '@d3ro/i18n'
import { formatRecordingTime, formatRecordingTimeUnit, formatNumber, getDateKey } from '../utils/formatters'
import { formatHotkeyLabel } from '../utils/format-hotkey'
import { FileDropZone } from '../components/FileDropZone'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import type { StatsSummary, HistoryEntry, HotkeyBinding, CaptionState, LicenseTier, UsageQuota } from '@d3ro/core/types'
// ── Premium 쿼터 모델별 한도 (서버 quota.ts / LicenseService QUOTA_LIMITS 와 동기) ──
const PREMIUM_MODEL_LIMITS: Record<LicenseTier, Array<{ model: string; i18nKey: string; limit: number; period: 'daily' | 'weekly' }>> = {
free: [
{ model: 'llm_haiku', i18nKey: 'dashboard.modelHaiku', limit: 250, period: 'weekly' },
],
pro: [
{ model: 'llm_haiku', i18nKey: 'dashboard.modelHaiku', limit: 1500, period: 'daily' },
{ model: 'llm_sonnet', i18nKey: 'dashboard.modelSonnet', limit: 300, period: 'daily' },
{ model: 'llm_opus', i18nKey: 'dashboard.modelOpus', limit: 50, period: 'daily' },
],
pro_plus: [
{ model: 'llm_haiku', i18nKey: 'dashboard.modelHaiku', limit: -1, period: 'daily' },
{ model: 'llm_sonnet', i18nKey: 'dashboard.modelSonnet', limit: 1500, period: 'daily' },
{ model: 'llm_opus', i18nKey: 'dashboard.modelOpus', limit: 300, period: 'daily' },
],
}
// ── 메인 컴포넌트 ─────────────────────────────────────
export function DashboardPage(): React.ReactElement {
@ -362,7 +346,7 @@ export function DashboardPage(): React.ReactElement {
{premiumStatus?.backend === 'premium' && PREMIUM_MODEL_LIMITS[licenseTier].length > 0 && (
<>
<PhosphorText variant="label" sx={{ mt: 1.5, mb: 0.5, display: 'block', color: d3roPalette.tag.green }}>
{t('dashboard.premiumQuota').toUpperCase()}
{t('license.premiumQuota').toUpperCase()}
</PhosphorText>
{PREMIUM_MODEL_LIMITS[licenseTier].map((m) => (
<Box key={m.model} sx={{ mb: 1 }}>
@ -373,7 +357,7 @@ export function DashboardPage(): React.ReactElement {
<PhosphorText variant="small" sx={{ color: d3roPalette.accent.amber }}>
{m.limit === -1
? t('license.unlimited')
: `${m.limit}/${t(m.period === 'weekly' ? 'dashboard.quotaWeekly' : 'dashboard.quotaDaily')}`}
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
</PhosphorText>
</Box>
{m.limit > 0 && (

View file

@ -50,6 +50,30 @@ export const AUDIO_FORMAT = {
BYTES_PER_SAMPLE: 2
} as const
/** Premium 모델별 쿼터 한도 (LicenseService QUOTA_LIMITS, 서버 quota.ts와 동기) */
export interface PremiumModelQuota {
readonly model: string
readonly i18nKey: string
readonly limit: number // -1 = 무제한
readonly period: 'daily' | 'weekly'
}
export const PREMIUM_MODEL_LIMITS: Record<'free' | 'pro' | 'pro_plus', readonly PremiumModelQuota[]> = {
free: [
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 250, period: 'weekly' },
],
pro: [
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: 1500, period: 'daily' },
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 300, period: 'daily' },
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 50, period: 'daily' },
],
pro_plus: [
{ model: 'llm_haiku', i18nKey: 'license.modelHaiku', limit: -1, period: 'daily' },
{ model: 'llm_sonnet', i18nKey: 'license.modelSonnet', limit: 1500, period: 'daily' },
{ model: 'llm_opus', i18nKey: 'license.modelOpus', limit: 300, period: 'daily' },
],
} as const
/** 윈도우 크기 */
export const WINDOW_SIZE = {
MAIN: { width: 1104, height: 816 },

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "Lokal (Ollama)",
"dashboard.backendPremium": "Premium (Claude)",
"dashboard.loginRequired": "Anmeldung erforderlich",
"dashboard.premiumQuota": "Premium-Kontingent",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Wöchentlich",
"dashboard.quotaDaily": "Täglich",
"history.title": "Transkriptionsverlauf",
"history.search": "Suchen...",
"history.entries": "{{count}} Einträge",

View file

@ -39,12 +39,6 @@
"dashboard.backendLocal": "Local (Ollama)",
"dashboard.backendPremium": "Premium (Claude)",
"dashboard.loginRequired": "Login Required",
"dashboard.premiumQuota": "Premium Quota",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Weekly",
"dashboard.quotaDaily": "Daily",
"history.title": "Transcription History",
"history.search": "Search...",
"history.entries": "{{count}} entries",
@ -283,24 +277,11 @@
"license.free": "FREE",
"license.pro": "PRO",
"license.proPlus": "PRO+",
"license.activate": "Activate License",
"license.deactivate": "Deactivate",
"license.keyPlaceholder": "Enter license key...",
"license.activating": "Activating...",
"license.activated": "Activated",
"license.activateError": "Activation failed: {{message}}",
"license.deactivated": "Deactivated",
"license.machineId": "Machine ID",
"license.activatedAt": "Activated At",
"license.manageLicense": "Manage License",
"license.subscribe": "Subscribe",
"license.upgrade": "Upgrade",
"license.currentPlan": "Current Plan",
"license.proPlan": "Pro — ₩9,900/mo",
"license.proPlusPlan": "Pro+ — ₩29,900/mo",
"license.paymentPending": "Payment integration coming soon (Payple)",
"license.upgradeTitle": "Upgrade to Pro",
"license.upgradeDesc": "Unlock all features",
"license.quotaUsed": "{{used}}/{{limit}} used",
"license.quotaUnlimited": "Unlimited",
"license.tierComparison": "Tier Comparison",
@ -340,7 +321,6 @@
"license.proPlus.required": "PRO+ Required",
"license.included": "Included",
"license.notIncluded": "Not included",
"license.keyLabel": "License Key",
"license.quotaExceeded.title": "You've used all your daily {{feature}}",
"license.quotaExceeded.desc": "Upgrade to Pro for unlimited usage",
"license.tierRequired.title": "{{feature}} is a {{tier}} feature",
@ -360,6 +340,19 @@
"license.unlimited": "Unlimited",
"license.locked": "Locked",
"license.nav": "License",
"license.signInRequired": "Sign in to manage your subscription",
"license.signInToUpgrade": "Sign in to upgrade",
"license.subscriptionActive": "Subscription active",
"license.subscriptionManage": "Manage subscription",
"license.upgradeToProDesc": "Unlock Premium AI models, cloud sync, and more",
"license.currentPlanActive": "Your current plan",
"license.openBillingPage": "Open billing page",
"license.premiumQuota": "Premium Quota",
"license.modelHaiku": "Haiku",
"license.modelSonnet": "Sonnet",
"license.modelOpus": "Opus",
"license.quotaWeekly": "weekly",
"license.quotaDaily": "daily",
"fileTranscription.title": "File Transcription",
"fileTranscription.dropZone": "Drop audio/video file here",
"fileTranscription.dropZoneHint": "Supports MP3, WAV, M4A, MP4, MKV, WEBM",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "Local (Ollama)",
"dashboard.backendPremium": "Premium (Claude)",
"dashboard.loginRequired": "Inicio de sesión requerido",
"dashboard.premiumQuota": "Cuota Premium",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Semanal",
"dashboard.quotaDaily": "Diario",
"history.title": "Historial de transcripciones",
"history.search": "Buscar...",
"history.entries": "{{count}} entradas",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "Local (Ollama)",
"dashboard.backendPremium": "Premium (Claude)",
"dashboard.loginRequired": "Connexion requise",
"dashboard.premiumQuota": "Quota Premium",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Hebdomadaire",
"dashboard.quotaDaily": "Journalier",
"history.title": "Historique des transcriptions",
"history.search": "Rechercher...",
"history.entries": "{{count}} entrées",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "ローカル (Ollama)",
"dashboard.backendPremium": "プレミアム (Claude)",
"dashboard.loginRequired": "ログインが必要",
"dashboard.premiumQuota": "プレミアムクォータ",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "週間",
"dashboard.quotaDaily": "日間",
"history.title": "文字起こし履歴",
"history.search": "検索...",
"history.entries": "{{count}}件",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "로컬 (Ollama)",
"dashboard.backendPremium": "프리미엄 (Claude)",
"dashboard.loginRequired": "로그인 필요",
"dashboard.premiumQuota": "프리미엄 쿼터",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "주간",
"dashboard.quotaDaily": "일간",
"history.title": "전사 기록",
"history.search": "검색...",
"history.entries": "{{count}}건",
@ -284,24 +278,11 @@
"license.free": "FREE",
"license.pro": "PRO",
"license.proPlus": "PRO+",
"license.activate": "라이선스 활성화",
"license.deactivate": "비활성화",
"license.keyPlaceholder": "라이선스 키 입력...",
"license.activating": "활성화 중...",
"license.activated": "활성화 완료",
"license.activateError": "활성화 실패: {{message}}",
"license.deactivated": "비활성화 완료",
"license.machineId": "기기 ID",
"license.activatedAt": "활성화 일시",
"license.manageLicense": "라이선스 관리",
"license.subscribe": "구독하기",
"license.upgrade": "업그레이드",
"license.currentPlan": "현재 구독 중",
"license.proPlan": "Pro — ₩9,900/월",
"license.proPlusPlan": "Pro+ — ₩29,900/월",
"license.paymentPending": "결제 연동 준비 중 (Payple)",
"license.upgradeTitle": "Pro로 업그레이드",
"license.upgradeDesc": "모든 기능을 잠금 해제하세요",
"license.quotaUsed": "{{used}}/{{limit}} 사용",
"license.quotaUnlimited": "무제한",
"license.tierComparison": "등급 비교",
@ -341,7 +322,6 @@
"license.proPlus.required": "PRO+ 필요",
"license.included": "포함",
"license.notIncluded": "미포함",
"license.keyLabel": "라이선스 키",
"license.quotaExceeded.title": "오늘의 {{feature}}을(를) 모두 사용했습니다",
"license.quotaExceeded.desc": "Pro로 업그레이드하면 무제한으로 사용할 수 있습니다",
"license.tierRequired.title": "{{feature}}은(는) {{tier}} 기능입니다",
@ -361,6 +341,19 @@
"license.unlimited": "무제한",
"license.locked": "잠금",
"license.nav": "라이선스",
"license.signInRequired": "구독을 관리하려면 로그인하세요",
"license.signInToUpgrade": "로그인 후 업그레이드",
"license.subscriptionActive": "구독 활성",
"license.subscriptionManage": "구독 관리",
"license.upgradeToProDesc": "프리미엄 AI 모델, 클라우드 동기화 등 모든 기능을 사용하세요",
"license.currentPlanActive": "현재 구독 중",
"license.openBillingPage": "결제 페이지 열기",
"license.premiumQuota": "프리미엄 쿼터",
"license.modelHaiku": "Haiku",
"license.modelSonnet": "Sonnet",
"license.modelOpus": "Opus",
"license.quotaWeekly": "주간",
"license.quotaDaily": "일간",
"fileTranscription.title": "파일 전사",
"fileTranscription.dropZone": "오디오/비디오 파일을 여기에 드래그하세요",
"fileTranscription.dropZoneHint": "MP3, WAV, M4A, MP4, MKV, WEBM 지원",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "Local (Ollama)",
"dashboard.backendPremium": "Premium (Claude)",
"dashboard.loginRequired": "Login necessário",
"dashboard.premiumQuota": "Cota Premium",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Semanal",
"dashboard.quotaDaily": "Diário",
"history.title": "Histórico de transcrições",
"history.search": "Pesquisar...",
"history.entries": "{{count}} entradas",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "Локальный (Ollama)",
"dashboard.backendPremium": "Премиум (Claude)",
"dashboard.loginRequired": "Требуется вход",
"dashboard.premiumQuota": "Премиум квота",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Недельная",
"dashboard.quotaDaily": "Дневная",
"history.title": "История транскрипций",
"history.search": "Поиск...",
"history.entries": "{{count}} записей",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "ภายในเครื่อง (Ollama)",
"dashboard.backendPremium": "พรีเมียม (Claude)",
"dashboard.loginRequired": "ต้องเข้าสู่ระบบ",
"dashboard.premiumQuota": "โควต้าพรีเมียม",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "รายสัปดาห์",
"dashboard.quotaDaily": "รายวัน",
"history.title": "ประวัติการถอดความ",
"history.search": "ค้นหา...",
"history.entries": "{{count}} รายการ",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "Cục bộ (Ollama)",
"dashboard.backendPremium": "Cao cấp (Claude)",
"dashboard.loginRequired": "Cần đăng nhập",
"dashboard.premiumQuota": "Hạn mức Premium",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "Hàng tuần",
"dashboard.quotaDaily": "Hàng ngày",
"history.title": "Lịch sử phiên âm",
"history.search": "Tìm kiếm...",
"history.entries": "{{count}} mục",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "本機 (Ollama)",
"dashboard.backendPremium": "進階 (Claude)",
"dashboard.loginRequired": "需要登入",
"dashboard.premiumQuota": "進階配額",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "每週",
"dashboard.quotaDaily": "每日",
"history.title": "轉錄歷史",
"history.search": "搜尋...",
"history.entries": "{{count}}筆",

View file

@ -40,12 +40,6 @@
"dashboard.backendLocal": "本地 (Ollama)",
"dashboard.backendPremium": "高级 (Claude)",
"dashboard.loginRequired": "需要登录",
"dashboard.premiumQuota": "高级配额",
"dashboard.modelHaiku": "Haiku",
"dashboard.modelSonnet": "Sonnet",
"dashboard.modelOpus": "Opus",
"dashboard.quotaWeekly": "每周",
"dashboard.quotaDaily": "每日",
"history.title": "转录历史",
"history.search": "搜索...",
"history.entries": "{{count}}条",