Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
346 lines
13 KiB
TypeScript
346 lines
13 KiB
TypeScript
// src/renderer/components/LicenseModal.tsx
|
|
// 구독 기반 라이선스 모달 — 클라우드 인증 + Payple 결제 연동
|
|
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
Box,
|
|
IconButton,
|
|
Divider,
|
|
LinearProgress,
|
|
} from '@mui/material'
|
|
import { X, CircleCheck, XCircle } from 'lucide-react'
|
|
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 }}>
|
|
<X size={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.main,
|
|
}}>
|
|
{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.main,
|
|
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.main }}>
|
|
{m.limit === -1
|
|
? t('license.unlimited')
|
|
: `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`}
|
|
</PhosphorText>
|
|
</Box>
|
|
{m.limit > 0 && (
|
|
<LinearProgress
|
|
variant="determinate"
|
|
value={0}
|
|
sx={{
|
|
height: 4,
|
|
borderRadius: d3roRadius.xs,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
'& .MuiLinearProgress-bar': {
|
|
bgcolor: d3roPalette.tag.green,
|
|
borderRadius: d3roRadius.xs,
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
))}
|
|
</>
|
|
)}
|
|
</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 === 'enterprise' || tier === 'team'
|
|
? d3roPalette.accent.main
|
|
: tier === 'pro_plus'
|
|
? d3roPalette.tag.purple
|
|
: tier === 'pro'
|
|
? d3roPalette.tag.green
|
|
: d3roPalette.accent.main
|
|
const label = tier === 'enterprise'
|
|
? 'Enterprise'
|
|
: tier === 'team'
|
|
? 'Team'
|
|
: tier === 'pro_plus'
|
|
? t('license.proPlus')
|
|
: tier === 'pro'
|
|
? t('license.pro')
|
|
: t('license.free')
|
|
return <PhosphorText variant="value" sx={{ color }}>{label}</PhosphorText>
|
|
}
|
|
|
|
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 <CircleCheck size={14} style={{ color: d3roPalette.tag.green }} />
|
|
}
|
|
if (value === false) {
|
|
return <XCircle size={14} style={{ color: d3roPalette.text.disabled }} />
|
|
}
|
|
return (
|
|
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.accent.main }}>
|
|
{value}
|
|
</PhosphorText>
|
|
)
|
|
}
|