d3ro-voice/apps/desktop/src/renderer/components/LicenseModal.tsx
윤찬 996def683b 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* 키 통일
2026-04-12 19:54:04 +09:00

338 lines
13 KiB
TypeScript

// src/renderer/components/LicenseModal.tsx
// 구독 기반 라이선스 모달 — 클라우드 인증 + Payple 결제 연동
import {
Dialog,
DialogTitle,
DialogContent,
Box,
IconButton,
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, d3roTypo, d3roRadius, d3roShadow, d3roFontMono } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { PREMIUM_MODEL_LIMITS } from '@d3ro/core/constants'
import { useLicenseState } from '../hooks/useLicenseState'
interface LicenseModalProps {
open: boolean
onClose: () => void
}
export function LicenseModal({ open, onClose }: LicenseModalProps): React.ReactElement {
const { t } = useI18n()
const {
cloud, usage, comparison,
currentTier, isFree, isPro,
handleUpgrade, handleOpenBilling, openCloudSettings,
} = useLicenseState()
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.app,
backgroundImage: 'none',
borderRadius: d3roRadius.card,
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: d3roShadow.card,
maxHeight: '85vh',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
px: 3,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<PhosphorText variant="meta">{t('license.title')}</PhosphorText>
<IconButton size="small" onClick={onClose} sx={{ color: d3roPalette.text.inactive }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</DialogTitle>
<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={currentTier === 'free' ? 'amber' : 'green'}
size={12}
pulse={currentTier !== 'free'}
/>
<Box sx={{ flex: 1 }}>
<PhosphorText variant="meta">{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>
{/* ── 구독 관리 ── */}
<MetalCard sx={{ overflow: 'visible' }}>
<SubscriptionSection
t={t}
authenticated={cloud.authenticated}
isFree={isFree}
isPro={isPro}
onUpgrade={handleUpgrade}
onManage={handleOpenBilling}
onSignIn={() => { onClose(); openCloudSettings() }}
/>
</MetalCard>
{/* ── 사용량 ── */}
{usage.length > 0 && (
<MetalCard sx={{ overflow: 'visible' }}>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{t('license.dailyUsage')}</PhosphorText>
{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>
<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>
{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>
)}
{/* ── 티어 비교표 ── */}
{comparison.length > 0 && (
<MetalCard sx={{ overflow: 'visible' }}>
<PhosphorText variant="meta" sx={{ mb: 1.5 }}>{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 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>
</Dialog>
)
}
// ── 공유 하위 컴포넌트 ──────────────────────────────────
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 (
<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>
)
}