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:
parent
68881bf0d0
commit
996def683b
17 changed files with 633 additions and 537 deletions
|
|
@ -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' }}>
|
||||
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
87
apps/desktop/src/renderer/hooks/useLicenseState.ts
Normal file
87
apps/desktop/src/renderer/hooks/useLicenseState.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue