From 996def683ba05a16cf703a0cdb6db50c961dcc17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sun, 12 Apr 2026 19:54:04 +0900 Subject: [PATCH] =?UTF-8?q?refactor(desktop):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=84=A0=EC=8A=A4=20UI=20=EB=B9=85=EB=B1=85=20=E2=80=94=20?= =?UTF-8?q?=ED=82=A4=20=EC=9E=85=EB=A0=A5=20=EC=A0=9C=EA=B1=B0,=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EA=B8=B0=EB=B0=98=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LicenseTab/LicenseModal: LemonSqueezy 키 입력 삭제, Payple 구독 UI로 전환 - useLicenseState 훅: 라이선스+클라우드 인증 공용 상태 관리 추출 - PREMIUM_MODEL_LIMITS: 3곳 중복 → @d3ro/core/constants 단일 소스 - i18n: LemonSqueezy 전용 키 18개 삭제, 구독 관련 키 13개 추가 (12 locale) - DashboardPage: dashboard.model* → license.model* 키 통일 --- .../src/renderer/components/LicenseModal.tsx | 447 +++++++++-------- .../src/renderer/components/LicenseTab.tsx | 464 ++++++++++-------- .../src/renderer/hooks/useLicenseState.ts | 87 ++++ .../src/renderer/pages/DashboardPage.tsx | 22 +- packages/core/src/constants.ts | 24 + packages/i18n/src/locales/de.json | 6 - packages/i18n/src/locales/en.json | 33 +- packages/i18n/src/locales/es.json | 6 - packages/i18n/src/locales/fr.json | 6 - packages/i18n/src/locales/ja.json | 6 - packages/i18n/src/locales/ko.json | 33 +- packages/i18n/src/locales/pt.json | 6 - packages/i18n/src/locales/ru.json | 6 - packages/i18n/src/locales/th.json | 6 - packages/i18n/src/locales/vi.json | 6 - packages/i18n/src/locales/zh-TW.json | 6 - packages/i18n/src/locales/zh.json | 6 - 17 files changed, 633 insertions(+), 537 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/useLicenseState.ts diff --git a/apps/desktop/src/renderer/components/LicenseModal.tsx b/apps/desktop/src/renderer/components/LicenseModal.tsx index 686264b..87e779e 100644 --- a/apps/desktop/src/renderer/components/LicenseModal.tsx +++ b/apps/desktop/src/renderer/components/LicenseModal.tsx @@ -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(null) - const [tierComparison, setTierComparison] = useState([]) - const [usageQuotas, setUsageQuotas] = useState([]) - - 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 ( - - {/* ---- Current Tier ---- */} + + {/* ── 현재 티어 ── */} - + {t('license.currentTier')} - - {licenseInfo ? tierToLabel(licenseInfo.tier, t) : '...'} - + + {cloud.authenticated && cloud.userEmail && ( + + {cloud.userEmail} + + )} - {/* ---- Subscription Management ---- */} + {/* ── 구독 관리 ── */} - - {t('license.subscribe')} - - {isFree && ( - <> - handleUpgrade('pro')} sx={{ width: '100%' }}> - - - - {t('license.upgrade')} — {t('license.proPlan')} - - - - handleUpgrade('pro_plus')} sx={{ width: '100%' }}> - - - - {t('license.upgrade')} — {t('license.proPlusPlan')} - - - - - )} - - {isPro && ( - <> - - - - {t('license.currentPlan')} — {t('license.proPlan')} - - - handleUpgrade('pro_plus')} sx={{ width: '100%' }}> - - - - {t('license.upgrade')} — {t('license.proPlusPlan')} - - - - - )} - - {currentTier === 'pro_plus' && ( - - - - {t('license.currentPlan')} — {t('license.proPlusPlan')} - - - )} - + { onClose(); openCloudSettings() }} + /> - {/* ---- Daily Usage ---- */} - {usageQuotas.length > 0 && ( + {/* ── 사용량 ── */} + {usage.length > 0 && ( {t('license.dailyUsage')} - - {usageQuotas.map((q) => ( - - + {usage.map((q) => ( + + + {t(`license.feature.${q.feature}` as Parameters[0])} - - - = 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', - }} - /> - - - - {q.limit < 0 - ? t('license.quotaUnlimited') + + {q.limit === -1 + ? t('license.unlimited') : t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })} - ))} - + {q.limit > 0 && ( + = q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber, + borderRadius: d3roRadius.xs, + }, + }} + /> + )} + + ))} + + {/* Premium 모델별 쿼터 */} + {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && ( + <> + + + {t('license.premiumQuota')} + + {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => ( + + + {t(m.i18nKey as Parameters[0])} + + {m.limit === -1 + ? t('license.unlimited') + : `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`} + + + {m.limit > 0 && ( + + )} + + ))} + + )} )} - {/* ---- Tier Comparison ---- */} - {tierComparison.length > 0 && ( + {/* ── 티어 비교표 ── */} + {comparison.length > 0 && ( {t('license.tierComparison')} - - - - - -   - - - {t('license.free')} - - - {t('license.pro')} - - - {t('license.proPlus')} - - - - - {tierComparison.map((row) => ( - - - {t(row.featureLabel as Parameters[0])} - - {renderTierCell(row.free)} - {renderTierCell(row.pro)} - {renderTierCell(row.proPlus)} - - ))} - -
-
+ + + + {''} + {t('license.free')} + {t('license.pro')} + {t('license.proPlus')} + + + + {comparison.map((row) => ( + + {t(row.featureLabel as Parameters[0])} + + + + + ))} + +
)}
@@ -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 ? ( - - ) : ( - +// ── 공유 하위 컴포넌트 ────────────────────────────────── + +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 {label} +} + +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 ( + + + {t('license.signInRequired')} + + + {t('license.signInToUpgrade')} + + ) } + + if (isFree) { + return ( + + {t('license.subscribe')} + + {t('license.upgradeToProDesc')} + + + + + ) + } + + if (isPro) { + return ( + + + + + {t('license.subscriptionManage')} + + + ) + } + return ( - + + + + {t('license.subscriptionManage')} + + + ) +} + +function ActivePlanIndicator({ label, color }: { label: string; color: string }): React.ReactElement { + return ( + + + {label} + + ) +} + +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 ( + onUpgrade(tier)} sx={{ width: '100%' }}> + + + + {t('license.upgrade')} — {planLabel} + + + + ) +} + +function TierCell({ value }: { value: boolean | string }): React.ReactElement { + if (value === true) { + return + } + if (value === false) { + return + } + return ( + {value} ) diff --git a/apps/desktop/src/renderer/components/LicenseTab.tsx b/apps/desktop/src/renderer/components/LicenseTab.tsx index 498a2fb..6b20bbd 100644 --- a/apps/desktop/src/renderer/components/LicenseTab.tsx +++ b/apps/desktop/src/renderer/components/LicenseTab.tsx @@ -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(null) - const [usage, setUsage] = useState([]) - const [comparison, setComparison] = useState([]) - 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 ( - {/* 현재 플랜 */} - - {t('license.currentTier')} - - - - - {licenseInfo ? tierLabel(licenseInfo.tier) : '...'} - - {licenseInfo?.activatedAt && ( - - {t('license.activatedAt')}: {new Date(licenseInfo.activatedAt).toLocaleDateString()} - - )} - - - - - {/* 라이선스 키 입력 */} - - {t('license.keyLabel')} - - - {licenseInfo?.tier === 'free' ? ( - - setKeyInput(e.target.value)} - disabled={activating} - sx={{ - '& .MuiInputBase-root': { - fontFamily: d3roFontMono, - fontSize: d3roTypo.compact.size, - }, - }} + {/* ── 현재 플랜 + 계정 상태 ── */} + + + - - - ) : ( - - - {licenseInfo?.licenseKey ? `${licenseInfo.licenseKey.substring(0, 16)}...` : ''} - - - - )} - - {message && ( - - {message.text} - - )} - - - - {/* 일일 사용량 */} - - {t('license.dailyUsage')} - - - {usage.map((q) => ( - - - - {t(`license.feature.${q.feature}` as Parameters[0])} - - - {q.limit === -1 - ? t('license.quotaUnlimited') - : t('license.quotaUsed', { used: String(q.used), limit: String(q.limit) })} - + + + {t('license.currentTier')} + + - {q.limit > 0 && ( - = q.limit ? d3roPalette.tag.red : d3roPalette.accent.amber, - borderRadius: d3roRadius.xs, - }, - }} - /> + {cloud.authenticated && cloud.userEmail && ( + + {cloud.userEmail} + )} - ))} + + + {/* ── 구독 관리 / 업그레이드 ── */} + + + - {/* 티어 비교표 */} - + {/* ── 일일 사용량 ── */} + + + {/* ── 티어 비교표 ── */} + + + ) +} + +// ── 하위 컴포넌트 ────────────────────────────────────── + +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 {label} +} + +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 ( + + + {t('license.signInRequired')} + + + {t('license.signInToUpgrade')} + + + ) + } + + if (isFree) { + return ( + + + {t('license.subscribe')} + + + {t('license.upgradeToProDesc')} + + + + + ) + } + + if (isPro) { + return ( + + + + + {t('license.subscriptionManage')} + + + ) + } + + // Pro+ + return ( + + + + {t('license.subscriptionManage')} + + + ) +} + +function ActivePlanIndicator({ label, color }: { label: string; color: string }): React.ReactElement { + return ( + + + {label} + + ) +} + +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 ( + onUpgrade(tier)} sx={{ width: '100%' }}> + + + + {t('license.upgrade')} — {planLabel} + + + + ) +} + +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 ( + <> + + {t('license.dailyUsage')} + + + {usage.map((q) => ( + + ))} + + {/* Premium 모델별 쿼터 */} + {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS]?.length > 0 && ( + <> + + {t('license.premiumQuota')} + + {PREMIUM_MODEL_LIMITS[currentTier as keyof typeof PREMIUM_MODEL_LIMITS].map((m) => ( + + + {t(m.i18nKey)} + + {m.limit === -1 + ? t('license.unlimited') + : `${m.limit}/${t(m.period === 'weekly' ? 'license.quotaWeekly' : 'license.quotaDaily')}`} + + + {m.limit > 0 && ( + + )} + + ))} + + )} + + + + ) +} + +function QuotaBar({ t, feature, used, limit, remaining }: { t: (k: string, p?: Record) => string; feature: string; used: number; limit: number; remaining: number }): React.ReactElement { + return ( + + + + {t(`license.feature.${feature}`)} + + + {limit === -1 + ? t('license.unlimited') + : t('license.quotaUsed', { used: String(used), limit: String(limit) })} + + + {limit > 0 && ( + = limit ? d3roPalette.tag.red : d3roPalette.accent.amber, + borderRadius: d3roRadius.xs, + }, + }} + /> + )} + + ) +} + +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 ( + <> + {t('license.tierComparison')} - + {''} {t('license.free')} - {t('license.pro')} - {t('license.proPlus')} + {t('license.pro')} + {t('license.proPlus')} {comparison.map((row) => ( - {t(row.featureLabel as Parameters[0])} + {t(row.featureLabel)} @@ -271,14 +310,7 @@ export function LicenseTab(): React.ReactElement { ))} - - {/* 기기 ID */} - {licenseInfo && ( - - {t('license.machineId')}: {licenseInfo.machineId.substring(0, 16)}... - - )} - + ) } @@ -290,8 +322,8 @@ function TierCell({ value }: { value: boolean | string }): React.ReactElement { return } return ( - + {value} - + ) } diff --git a/apps/desktop/src/renderer/hooks/useLicenseState.ts b/apps/desktop/src/renderer/hooks/useLicenseState.ts new file mode 100644 index 0000000..692ad95 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useLicenseState.ts @@ -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(null) + const [usage, setUsage] = useState([]) + const [comparison, setComparison] = useState([]) + const [cloud, setCloud] = useState({ 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, + } +} diff --git a/apps/desktop/src/renderer/pages/DashboardPage.tsx b/apps/desktop/src/renderer/pages/DashboardPage.tsx index a495d74..0665b2d 100644 --- a/apps/desktop/src/renderer/pages/DashboardPage.tsx +++ b/apps/desktop/src/renderer/pages/DashboardPage.tsx @@ -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> = { - 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 && ( <> - {t('dashboard.premiumQuota').toUpperCase()} + {t('license.premiumQuota').toUpperCase()} {PREMIUM_MODEL_LIMITS[licenseTier].map((m) => ( @@ -373,7 +357,7 @@ export function DashboardPage(): React.ReactElement { {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')}`} {m.limit > 0 && ( diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index fe52583..4d509f4 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -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 }, diff --git a/packages/i18n/src/locales/de.json b/packages/i18n/src/locales/de.json index aecc44b..b4dc361 100644 --- a/packages/i18n/src/locales/de.json +++ b/packages/i18n/src/locales/de.json @@ -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", diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 3b5bb80..e60e727 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -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", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index cf83d0f..a736540 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -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", diff --git a/packages/i18n/src/locales/fr.json b/packages/i18n/src/locales/fr.json index 2b36f7c..2832366 100644 --- a/packages/i18n/src/locales/fr.json +++ b/packages/i18n/src/locales/fr.json @@ -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", diff --git a/packages/i18n/src/locales/ja.json b/packages/i18n/src/locales/ja.json index 9eb8c2e..2a47aec 100644 --- a/packages/i18n/src/locales/ja.json +++ b/packages/i18n/src/locales/ja.json @@ -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}}件", diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index fdcff39..cde26fe 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -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 지원", diff --git a/packages/i18n/src/locales/pt.json b/packages/i18n/src/locales/pt.json index e992716..bf5e8c7 100644 --- a/packages/i18n/src/locales/pt.json +++ b/packages/i18n/src/locales/pt.json @@ -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", diff --git a/packages/i18n/src/locales/ru.json b/packages/i18n/src/locales/ru.json index 5b966ff..a7f5177 100644 --- a/packages/i18n/src/locales/ru.json +++ b/packages/i18n/src/locales/ru.json @@ -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}} записей", diff --git a/packages/i18n/src/locales/th.json b/packages/i18n/src/locales/th.json index 807af7d..12fada2 100644 --- a/packages/i18n/src/locales/th.json +++ b/packages/i18n/src/locales/th.json @@ -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}} รายการ", diff --git a/packages/i18n/src/locales/vi.json b/packages/i18n/src/locales/vi.json index 605b8e6..c0faa61 100644 --- a/packages/i18n/src/locales/vi.json +++ b/packages/i18n/src/locales/vi.json @@ -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", diff --git a/packages/i18n/src/locales/zh-TW.json b/packages/i18n/src/locales/zh-TW.json index 5da9828..9a42e35 100644 --- a/packages/i18n/src/locales/zh-TW.json +++ b/packages/i18n/src/locales/zh-TW.json @@ -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}}筆", diff --git a/packages/i18n/src/locales/zh.json b/packages/i18n/src/locales/zh.json index 1cda7a5..533308d 100644 --- a/packages/i18n/src/locales/zh.json +++ b/packages/i18n/src/locales/zh.json @@ -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}}条",