feat: V2-7 Admin 콘솔 리디자인 + 3단계 권한 + SaaS 전환 + 코드 정리

- Admin CRM: D3RO Console 스타일 전체 적용 (panelSx/tableSx/filterBtnSx)
- 3단계 권한: manager/admin/super_admin (DB + Edge Functions + Frontend)
- 랜딩 페이지: 1회 결제 → 월간/연간 구독 SaaS 모델 (10개 언어)
- SSE 스트리밍: VoiceConversation Premium LLM 라우팅 + fallback
- Supabase 클라이언트: packages/api-client 공통 추출 (browser+server)
- RPC 함수 타입: 9개 정의 (admin_usage_by_feature 등)
- callAdminApi 401 버그 수정 (getUser() 선행 토큰 갱신)
This commit is contained in:
윤찬 2026-04-13 01:28:10 +09:00
parent d0e854c255
commit 8af75a0a1e
50 changed files with 2185 additions and 950 deletions

View file

@ -1,11 +1,10 @@
// apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
// 감사로그 상세 — before/after diff 뷰
// D3RO Console — Audit log detail
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { requireAdmin } from '@/lib/admin-guard'
import { requireManager } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
@ -15,7 +14,7 @@ interface PageProps {
}
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
await requireAdmin()
await requireManager()
const { id } = await params
const supabase = await getSupabaseServerClient()
@ -38,59 +37,93 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
const adminName = (adminProfile as { name: string | null } | null)?.name ?? 'Unknown'
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">AUDIT LOG #{id}</PhosphorText>
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Audit Log #{id}
</Box>
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
{'<'} Back
</Box>
</Link>
</Box>
</Box>
{/* Content */}
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
<Grid container spacing={2} sx={{ mb: 3 }}>
{/* Details card */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DETAILS</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Row label="ACTION" value={typedLog.action as string} />
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Details
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
<Row label="ACTION" value={typedLog.action as string} valueColor={C.accent} />
<Row label="ADMIN" value={adminName} />
<Row label="TARGET TYPE" value={typedLog.target_type as string} />
<Row label="TARGET ID" value={typedLog.target_id as string} />
<Row label="DATE" value={new Date(typedLog.created_at as string).toLocaleString()} />
</Box>
</Box>
</MetalCard>
</Grid>
{/* Memo card */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>MEMO</PhosphorText>
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
{typedLog.memo as string}
</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Memo
</Box>
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.text, whiteSpace: 'pre-wrap' }}>
{typedLog.memo as string}
</Box>
</Box>
</MetalCard>
</Grid>
</Grid>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CHANGES (DIFF)</PhosphorText>
{/* Diff viewer */}
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Changes (Diff)
</Box>
<AuditDiffViewer
beforeData={typedLog.before_data as Record<string, unknown> | null}
afterData={typedLog.after_data as Record<string, unknown> | null}
/>
</Box>
</MetalCard>
</Box>
</Box>
</>
)
}
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box component="span" sx={{ color: C.dim }}>{label}</Box>
<Box component="span" sx={{ color: valueColor ?? C.text }}>{value}</Box>
</Box>
)
}

View file

@ -1,11 +1,10 @@
// apps/admin/src/app/(admin)/audit-log/page.tsx
// 감사로그 목록
// D3RO Console — Audit log list
import { Box } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx, tableSx, filterBtnSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { requireAdmin } from '@/lib/admin-guard'
import { requireManager } from '@/lib/admin-guard'
import Link from 'next/link'
interface PageProps {
@ -13,7 +12,7 @@ interface PageProps {
}
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
await requireAdmin()
await requireManager()
const params = await searchParams
const targetTypeFilter = params.target_type ?? 'all'
const page = parseInt(params.page ?? '1', 10)
@ -54,41 +53,65 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
}
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>AUDIT LOG</PhosphorText>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Audit Log
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px', letterSpacing: '0.1em',
color: C.dim, bgcolor: C.border, px: 1, py: 0.5, borderRadius: '4px',
}}>
{count ?? 0} TOTAL
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{['all', 'subscription', 'profile'].map((t) => (
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: targetTypeFilter === t ? d3roPalette.bg.inset : 'transparent',
color: targetTypeFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
<Box sx={filterBtnSx(targetTypeFilter === t)}>
{t.toUpperCase()}
</PhosphorText>
</Box>
</Link>
))}
</Box>
</Box>
<MetalCard sx={{ overflow: 'auto' }}>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
<thead><tr><th>DATE</th><th>ADMIN</th><th>ACTION</th><th>TARGET</th><th>MEMO</th><th>DETAIL</th></tr></thead>
{/* Table */}
<Box sx={{ ...panelSx, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ flex: 1, overflow: 'auto', p: 3, position: 'relative', zIndex: 1 }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th>Date</th>
<th>Admin</th>
<th>Action</th>
<th>Target</th>
<th>Memo</th>
<th style={{ width: 60 }}>Detail</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No audit logs</td></tr>
<tr><td colSpan={6} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No audit logs</td></tr>
) : (
logs.map((log) => (
<tr key={log.id as number}>
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
<td style={{ whiteSpace: 'nowrap' }}>
{new Date(log.created_at as string).toLocaleString()}
</td>
<td>{adminMap[log.admin_id as string] ?? (log.admin_id as string).substring(0, 8)}</td>
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
<td style={{ color: C.accent }}>{log.action as string}</td>
<td>
<Link
href={
@ -96,7 +119,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
? `/subscriptions/${log.target_id as string}`
: `/users/${log.target_id as string}`
}
style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}
style={{ color: C.accent, textDecoration: 'none' }}
>
{(log.target_id as string).substring(0, 8)}...
</Link>
@ -105,7 +128,7 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
{log.memo as string}
</td>
<td>
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
<Link href={`/audit-log/${log.id as number}`} style={{ color: C.accent, textDecoration: 'none' }}>
View
</Link>
</td>
@ -114,24 +137,36 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
)}
</tbody>
</Box>
</MetalCard>
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
{Array.from({ length: totalPages }, (_, i) => i + 1).slice(0, 10).map((p) => (
<Box sx={{
display: 'flex', justifyContent: 'center', gap: 0.5,
py: 2, borderTop: `1px solid ${C.border}`,
position: 'relative', zIndex: 1,
}}>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => i + 1).map((p) => (
<Link key={p} href={`/audit-log?target_type=${targetTypeFilter}&page=${p}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1, py: 0.25, borderRadius: 0.5,
bgcolor: p === page ? d3roPalette.bg.inset : 'transparent',
color: p === page ? d3roPalette.accent.amber : d3roPalette.text.secondary,
<Box sx={{
fontFamily: FONT, fontSize: '11px', fontWeight: 500,
width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '4px',
bgcolor: p === page ? C.accent : 'transparent',
color: p === page ? C.bright : C.dim,
border: `1px solid ${p === page ? C.accent : C.border}`,
cursor: 'pointer',
'&:hover': { borderColor: C.dim },
transition: 'all 0.15s',
}}>
{p}
</PhosphorText>
</Box>
</Link>
))}
</Box>
)}
</Box>
</>
)
}

View file

@ -1,21 +1,37 @@
// apps/admin/src/app/(admin)/layout.tsx
// Admin 레이아웃 — requireAdmin() 가드 + Sidebar
// Admin 레이아웃 — D3RO Console 스타일
import { Box } from '@mui/material'
import { requireAdmin } from '@/lib/admin-guard'
import { requireManager } from '@/lib/admin-guard'
import { AdminSidebar } from '@/components/admin-sidebar'
import { C } from '@/lib/console-theme'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}): Promise<React.ReactElement> {
await requireAdmin()
await requireManager()
return (
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
<Box sx={{
display: 'flex',
width: '100vw',
height: '100vh',
overflow: 'hidden',
p: 2,
gap: 2,
bgcolor: C.base,
}}>
<AdminSidebar />
<Box component="main" sx={{ flex: 1, overflow: 'auto', p: 4 }}>
<Box component="main" sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 0,
overflow: 'hidden',
}}>
{children}
</Box>
</Box>

View file

@ -1,18 +1,22 @@
// apps/admin/src/app/(admin)/page.tsx
// CRM 대시보드 — 요약 카드 4개
// D3RO Console — Dashboard Overview
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette } from '@d3ro/ui/theme'
import { Box } from '@mui/material'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { C, FONT, panelSx, tableSx } from '@/lib/console-theme'
import Link from 'next/link'
interface StatCard {
interface StatData {
label: string
value: string | number
color?: string
value: number
color: string
glowClass: string
borderColor: string
badge: string
badgeColor?: string
}
async function loadStats(): Promise<StatCard[]> {
async function loadStats(): Promise<StatData[]> {
const supabase = await getSupabaseServerClient()
const [profilesRes, paidRes, usageRes, expiringRes] = await Promise.all([
@ -30,35 +34,230 @@ async function loadStats(): Promise<StatCard[]> {
?.reduce((sum, r) => sum + (r.count ?? 0), 0) ?? 0
return [
{ label: 'TOTAL USERS', value: profilesRes.count ?? 0 },
{ label: 'PAID SUBSCRIBERS', value: paidRes.count ?? 0, color: d3roPalette.tag.green },
{ label: 'TODAY API CALLS', value: todayUsage, color: d3roPalette.accent.amber },
{ label: 'EXPIRING (7D)', value: expiringRes.count ?? 0, color: d3roPalette.tag.red },
{ label: 'Total Users', value: profilesRes.count ?? 0, color: C.bright, glowClass: 'glow-white', borderColor: C.bright, badge: 'ALL TIME' },
{ label: 'Paid Subscribers', value: paidRes.count ?? 0, color: C.green400, glowClass: 'glow-green', borderColor: C.green, badge: 'ACTIVE' },
{ label: 'Today API Calls', value: todayUsage, color: C.orange, glowClass: 'glow-orange', borderColor: C.orange, badge: '24H VOL', badgeColor: C.orange400 },
{ label: 'Expiring (7D)', value: expiringRes.count ?? 0, color: C.red, glowClass: 'glow-red', borderColor: C.red, badge: 'WARNING' },
]
}
async function loadRecentAuditLogs(): Promise<Array<Record<string, unknown>>> {
const supabase = await getSupabaseServerClient()
const { data } = await supabase
.from('audit_log')
.select('*')
.order('created_at', { ascending: false })
.limit(10)
return (data ?? []) as Array<Record<string, unknown>>
}
function MiniBarChart({ value, maxVal, color }: { value: number; maxVal: number; color: string }): React.ReactElement {
const heights = [25, 50, 33, 75, maxVal > 0 ? Math.max(10, (value / Math.max(maxVal, 1)) * 100) : 5]
return (
<Box sx={{ display: 'flex', gap: '3px', alignItems: 'flex-end', height: 32 }}>
{heights.map((h, i) => (
<Box key={i} sx={{
width: 6,
height: `${h}%`,
bgcolor: i === 4 ? color : C.borderHl,
...(i === 4 ? { boxShadow: `0 0 8px ${color}80` } : {}),
}} />
))}
</Box>
)
}
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
const stats = await loadStats()
const logs = await loadRecentAuditLogs()
const maxStatVal = Math.max(...stats.map((s) => s.value), 1)
return (
<>
{/* Header bar */}
<Box sx={{
...panelSx,
height: 88, flexShrink: 0,
display: 'flex', alignItems: 'center',
px: 4, justifyContent: 'space-between',
}}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.03, pointerEvents: 'none' }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative', zIndex: 1 }}>
<Box sx={{ width: 4, height: 32, bgcolor: C.accent, borderRadius: 4 }} />
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>OVERVIEW</PhosphorText>
<Grid container spacing={2}>
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '20px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase',
color: C.bright, m: 0,
}}>
Dashboard Overview
</Box>
<Box component="p" sx={{
fontFamily: FONT, fontSize: '12px',
letterSpacing: '0.15em', color: C.dim, mt: 0.5, m: 0,
}}>
REAL-TIME TELEMETRY
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, position: 'relative', zIndex: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '9px', letterSpacing: '0.2em', textTransform: 'uppercase', color: C.dim, mb: 0.5 }}>
Server Status
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', fontWeight: 500, letterSpacing: '0.1em', color: C.green400 }}>
NOMINAL
</Box>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke={C.green400}><path strokeLinecap="square" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>
</Box>
</Box>
<Box sx={{ height: 32, width: '1px', bgcolor: C.borderHl }} />
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '9px', letterSpacing: '0.2em', textTransform: 'uppercase', color: C.dim, mb: 0.5 }}>
Region
</Box>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', fontWeight: 500, letterSpacing: '0.1em', color: C.bright }}>
AP-SEOUL
</Box>
</Box>
</Box>
</Box>
{/* Main content */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, overflow: 'hidden' }}>
{/* Left: Stats cards */}
<Box sx={{ width: 400, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto', pr: 0.5 }}>
{stats.map((stat) => (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={stat.label}>
<MetalCard>
<Box sx={{ textAlign: 'center', py: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block', color: d3roPalette.text.label }}>
<Box key={stat.label} sx={panelSx}>
<Box sx={{ p: 3, position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minHeight: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box component="h3" sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 400,
letterSpacing: '0.25em', textTransform: 'uppercase',
color: C.text, m: 0,
borderLeft: `2px solid ${stat.borderColor}`,
pl: 1,
}}>
{stat.label}
</PhosphorText>
<PhosphorText variant="hero" sx={{ color: stat.color ?? d3roPalette.text.primary }}>
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px',
color: stat.badgeColor ?? C.dim,
bgcolor: C.border,
px: 1, py: 0.5, borderRadius: '4px',
}}>
{stat.badge}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', mt: 2 }}>
<MiniBarChart value={stat.value} maxVal={maxStatVal} color={stat.color} />
<Box
className={stat.glowClass}
component="span"
sx={{
fontFamily: FONT,
fontSize: '3.75rem',
fontWeight: 300,
lineHeight: 1,
color: stat.color,
letterSpacing: '-0.05em',
fontVariantNumeric: 'tabular-nums',
}}
>
{stat.value}
</PhosphorText>
</Box>
</MetalCard>
</Grid>
</Box>
</Box>
</Box>
))}
</Grid>
</Box>
{/* Right: Activity Log */}
<Box sx={{ ...panelSx, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 400 }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
{/* Log header */}
<Box sx={{
px: 3, py: 2,
borderBottom: `1px solid ${C.border}`,
bgcolor: `${C.base}4D`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
position: 'relative', zIndex: 1,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke={C.dim}><path strokeLinecap="square" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" /></svg>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '12px', fontWeight: 500,
letterSpacing: '0.2em', textTransform: 'uppercase', color: C.bright,
}}>
System Activity Log
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase',
px: 1.5, py: 0.5, borderRadius: '4px',
bgcolor: C.borderHl, color: C.bright,
cursor: 'pointer',
'&:hover': { bgcolor: C.dim },
transition: 'background 0.15s',
}}>
View All
</Box>
</Link>
</Box>
</Box>
{/* Log table */}
<Box sx={{ flex: 1, overflow: 'auto', p: 3, position: 'relative', zIndex: 1 }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th style={{ width: 140 }}>Timestamp</th>
<th style={{ width: 120 }}>Action</th>
<th style={{ width: 100 }}>Target</th>
<th>Memo</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>
No activity records yet
</td>
</tr>
) : (
logs.map((log) => {
const action = log.action as string
const actionColor = action.includes('delete') ? C.red400
: action.includes('create') ? C.green400
: action.includes('role') ? C.purple400
: C.orange400
return (
<tr key={log.id as number}>
<td style={{ color: C.dim, whiteSpace: 'nowrap' }}>
{new Date(log.created_at as string).toLocaleTimeString()}
</td>
<td style={{ color: actionColor }}>
{action.toUpperCase().replace('.', '_')}
</td>
<td>{(log.target_type as string).toUpperCase()}</td>
<td style={{ color: C.dim }}>
{((log.memo as string) ?? '').substring(0, 60)}
</td>
</tr>
)
})
)}
</tbody>
</Box>
</Box>
</Box>
</Box>
</>
)
}

View file

@ -17,7 +17,10 @@ type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
interface SubscriptionDetailClientProps {
userId: string
hasSub: boolean
isSuperAdmin: boolean
/** manager 이상: 구독 수정 가능 */
canEdit: boolean
/** admin 이상: 구독 생성/삭제 가능 */
canCreateDelete: boolean
initialSub?: {
tier: Tier
status: SubStatus
@ -28,7 +31,7 @@ interface SubscriptionDetailClientProps {
}
export function SubscriptionDetailClient({
userId, hasSub, isSuperAdmin, initialSub,
userId, hasSub, canEdit, canCreateDelete, initialSub,
}: SubscriptionDetailClientProps): React.ReactElement {
const router = useRouter()
const [deleteOpen, setDeleteOpen] = useState(false)
@ -50,7 +53,8 @@ export function SubscriptionDetailClient({
}
}
if (!isSuperAdmin) {
// 수정도 생성/삭제도 안 되면 빈 박스
if (!canEdit && !canCreateDelete) {
return <Box />
}
@ -58,12 +62,18 @@ export function SubscriptionDetailClient({
<Box>
{hasSub && initialSub ? (
<>
{/* manager 이상: 수정 가능 */}
{canEdit && (
<SubscriptionForm
mode="edit"
userId={userId}
initial={initialSub}
onSuccess={() => router.refresh()}
/>
)}
{/* admin 이상: 삭제 가능 */}
{canCreateDelete && (
<>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="outlined"
@ -83,13 +93,15 @@ export function SubscriptionDetailClient({
loading={deleteLoading}
/>
</>
) : (
)}
</>
) : canCreateDelete ? (
<SubscriptionForm
mode="create"
userId={userId}
onSuccess={() => router.refresh()}
/>
)}
) : null}
</Box>
)
}

View file

@ -1,11 +1,10 @@
// apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
// 구독 상세 + 수정/삭제 (super_admin) — [id]는 user_id
// D3RO Console — Subscription detail
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx, tableSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { requireAdmin } from '@/lib/admin-guard'
import { requireManager, hasMinRole } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { SubscriptionDetailClient } from './client'
@ -16,7 +15,7 @@ interface PageProps {
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id: userId } = await params
const admin = await requireAdmin()
const admin = await requireManager()
const supabase = await getSupabaseServerClient()
const [subRes, profileRes, auditRes] = await Promise.all([
@ -37,33 +36,62 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
const auditLogs = (auditRes.data ?? []) as Array<Record<string, unknown>>
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">SUBSCRIPTION DETAIL</PhosphorText>
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Subscription Detail
</Box>
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
{'<'} Back
</Box>
</Link>
</Box>
</Box>
{/* Content */}
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
<Grid container spacing={2} sx={{ mb: 3 }}>
{/* User card */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USER</PhosphorText>
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
User
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
<Row label="NAME" value={(profile.name as string) ?? '-'} />
<Row label="ID" value={userId} />
<Row label="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
</Box>
</Box>
</MetalCard>
</Grid>
{/* Current subscription card */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CURRENT SUBSCRIPTION</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Current Subscription
</Box>
{sub ? (
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
<Row label="TIER" value={((sub.tier as string) ?? 'free').toUpperCase()} />
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} />
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
@ -72,10 +100,9 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
<Row label="NOTE" value={(sub.admin_note as string) ?? '-'} />
</Box>
) : (
<PhosphorText variant="dim">No subscription record</PhosphorText>
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No subscription record</Box>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
@ -83,7 +110,8 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
<SubscriptionDetailClient
userId={userId}
hasSub={!!sub}
isSuperAdmin={admin.role === 'super_admin'}
canEdit={true}
canCreateDelete={hasMinRole(admin, 'admin')}
initialSub={sub ? ({
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
@ -94,28 +122,28 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
/>
{/* Audit trail */}
<Box sx={{ mt: 3 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>AUDIT TRAIL</PhosphorText>
{auditLogs.length === 0 ? (
<PhosphorText variant="dim">No audit records</PhosphorText>
) : (
<MetalCard sx={{ overflow: 'auto' }}>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label },
<Box sx={{ ...panelSx, p: 2.5, mt: 3 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
<thead><tr><th>DATE</th><th>ACTION</th><th>MEMO</th><th>DETAIL</th></tr></thead>
Audit Trail
</Box>
{auditLogs.length === 0 ? (
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No audit records</Box>
) : (
<Box component="table" sx={tableSx}>
<thead><tr><th>Date</th><th>Action</th><th>Memo</th><th>Detail</th></tr></thead>
<tbody>
{auditLogs.map((log) => (
<tr key={log.id as number}>
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
<td style={{ whiteSpace: 'nowrap' }}>
{new Date(log.created_at as string).toLocaleString()}
</td>
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
<td style={{ color: C.accent }}>{log.action as string}</td>
<td>{(log.memo as string).substring(0, 50)}</td>
<td>
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
<Link href={`/audit-log/${log.id as number}`} style={{ color: C.accent, textDecoration: 'none' }}>
View
</Link>
</td>
@ -123,18 +151,19 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
))}
</tbody>
</Box>
</MetalCard>
)}
</Box>
</Box>
</Box>
</>
)
}
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box component="span" sx={{ color: C.dim }}>{label}</Box>
<Box component="span" sx={{ color: C.text }}>{value}</Box>
</Box>
)
}

View file

@ -1,9 +1,9 @@
// apps/admin/src/app/(admin)/subscriptions/new/page.tsx
// 새 구독 생성 (VIP 부여) — super_admin 전용
// D3RO Console — New subscription (VIP grant)
import { Box } from '@mui/material'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { requireSuperAdmin } from '@/lib/admin-guard'
import { C, FONT, panelSx } from '@/lib/console-theme'
import { requireAdmin } from '@/lib/admin-guard'
import Link from 'next/link'
import { NewSubscriptionClient } from './client'
@ -12,19 +12,41 @@ interface PageProps {
}
export default async function NewSubscriptionPage({ searchParams }: PageProps): Promise<React.ReactElement> {
await requireSuperAdmin()
await requireAdmin()
const params = await searchParams
const userId = params.userId ?? ''
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">NEW SUBSCRIPTION</PhosphorText>
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
New Subscription
</Box>
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
{'<'} Back
</Box>
</Link>
</Box>
</Box>
{/* Content */}
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
<NewSubscriptionClient initialUserId={userId} />
</Box>
</Box>
</>
)
}

View file

@ -1,9 +1,8 @@
// apps/admin/src/app/(admin)/subscriptions/page.tsx
// 구독 목록 — active/canceled/past_due/expired 필터
// D3RO Console — Subscriptions list
import { Box } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import Link from 'next/link'
@ -34,9 +33,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
.order('current_period_end', { ascending: true })
.limit(100)
if (statusFilter !== 'all') {
subQuery.eq('status', statusFilter)
}
if (statusFilter !== 'all') subQuery.eq('status', statusFilter)
const { data: rawSubs } = await subQuery
const rawSubsArr = (rawSubs ?? []) as Array<Record<string, unknown>>
@ -61,72 +58,112 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
profile_name: profileMap.get(row.user_id as string) ?? null,
}))
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<PhosphorText variant="title">SUBSCRIPTIONS</PhosphorText>
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: d3roPalette.accent.amber,
color: d3roPalette.bg.app,
}}>
+ NEW
</PhosphorText>
</Link>
</Box>
const statusColor = (s: string): 'green' | 'red' | 'orange' =>
s === 'active' ? 'green' : s === 'expired' ? 'red' : 'orange'
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
return (
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Subscriptions
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
<Link key={s} href={`/subscriptions?status=${s}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: statusFilter === s ? d3roPalette.bg.inset : 'transparent',
color: statusFilter === s ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
<Box sx={filterBtnSx(statusFilter === s)}>
{s.toUpperCase().replace('_', ' ')}
</PhosphorText>
</Box>
</Link>
))}
<Box sx={{ height: 20, width: '1px', bgcolor: C.borderHl, mx: 0.5 }} />
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase',
px: 1.5, py: 0.5, borderRadius: '4px',
bgcolor: C.accent, color: C.bright,
cursor: 'pointer',
'&:hover': { opacity: 0.9 },
transition: 'opacity 0.15s',
}}>
+ NEW
</Box>
</Link>
</Box>
</Box>
<MetalCard sx={{ overflow: 'auto' }}>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.75, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}>
{/* Table */}
<Box sx={{ ...panelSx, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ flex: 1, overflow: 'auto', p: 3, position: 'relative', zIndex: 1 }}>
<Box component="table" sx={tableSx}>
<thead>
<tr><th>USER</th><th>TIER</th><th>STATUS</th><th>PROVIDER</th><th>EXPIRES</th><th>CANCEL</th><th>FAILS</th><th>EDIT</th></tr>
<tr>
<th>User</th>
<th style={{ width: 80 }}>Tier</th>
<th style={{ width: 90 }}>Status</th>
<th style={{ width: 90 }}>Provider</th>
<th style={{ width: 100 }}>Expires</th>
<th style={{ width: 100 }}>Cancel</th>
<th style={{ width: 50 }}>Fails</th>
<th style={{ width: 50, textAlign: 'right' }}>Edit</th>
</tr>
</thead>
<tbody>
{subs.map((s) => (
<tr key={s.id}>
<td>
<Link href={`/users/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
<Link href={`/users/${s.user_id}`} style={{ color: C.accent, textDecoration: 'none' }}>
{s.profile_name ?? s.user_id.substring(0, 8)}
</Link>
</td>
<td style={{ color: s.tier === 'pro_plus' ? d3roPalette.tag.purple : s.tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary }}>
{s.tier === 'pro_plus' ? 'PRO+' : s.tier.toUpperCase()}
</td>
<td style={{ color: s.status === 'active' ? d3roPalette.tag.green : s.status === 'expired' ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
{s.status.toUpperCase()}
</td>
<td>{s.payment_provider}</td>
<td style={{ color: d3roPalette.text.muted }}>{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'}</td>
<td style={{ color: s.cancel_at ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'}</td>
<td style={{ color: s.renewal_failures > 0 ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.renewal_failures}</td>
<td>
<Link href={`/subscriptions/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
Edit
<Box component="span" sx={statusBadgeSx(
s.tier === 'pro_plus' ? 'purple' : s.tier === 'pro' ? 'green' : 'blue'
)}>
{s.tier === 'pro_plus' ? 'PRO+' : s.tier.toUpperCase()}
</Box>
</td>
<td>
<Box component="span" sx={statusBadgeSx(statusColor(s.status))}>
{s.status.toUpperCase()}
</Box>
</td>
<td style={{ color: C.dim }}>{s.payment_provider}</td>
<td style={{ color: C.dim, whiteSpace: 'nowrap' }}>
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'}
</td>
<td style={{ color: s.cancel_at ? C.red400 : C.dim, whiteSpace: 'nowrap' }}>
{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'}
</td>
<td style={{ color: s.renewal_failures > 0 ? C.red400 : C.dim }}>
{s.renewal_failures}
</td>
<td style={{ textAlign: 'right' }}>
<Link href={`/subscriptions/${s.user_id}`} style={{ color: C.accent, textDecoration: 'none', fontSize: '11px' }}>
EDIT
</Link>
</td>
</tr>
))}
{subs.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No subscriptions found</td></tr>
)}
</tbody>
</Box>
</MetalCard>
</Box>
</Box>
</>
)
}

View file

@ -1,9 +1,8 @@
// apps/admin/src/app/(admin)/usage/page.tsx
// 사용량 — feature별 차트 + DAU + Top users + 테이블
// D3RO Console — Usage analytics
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx, tableSx, filterBtnSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import Link from 'next/link'
import { FeatureUsageChart } from '@/components/charts/feature-usage-chart'
@ -56,105 +55,139 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
.sort((a, b) => b.total - a.total)
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>USAGE</PhosphorText>
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Usage
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{[7, 14, 30].map((d) => (
<Link key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: days === d ? d3roPalette.bg.inset : 'transparent',
color: days === d ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>{d}D</PhosphorText>
<Box sx={filterBtnSx(days === d)}>
{d}D
</Box>
</Link>
))}
</Box>
</Box>
{/* Content */}
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
{/* Summary cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
{summaries.map((s) => (
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
<MetalCard>
<Box sx={{ textAlign: 'center', py: 1 }}>
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block', color: d3roPalette.text.label }}>{s.feature.toUpperCase()}</PhosphorText>
<PhosphorText variant="value" sx={{ color: d3roPalette.accent.amber }}>{s.total.toLocaleString()}</PhosphorText>
<PhosphorText variant="dim" sx={{ display: 'block', mt: 0.5 }}>{s.uniqueUsers} users</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5, textAlign: 'center' }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 1,
}}>
{s.feature.toUpperCase()}
</Box>
<Box sx={{ fontFamily: FONT, fontSize: '24px', fontWeight: 300, color: C.accent }}>
{s.total.toLocaleString()}
</Box>
<Box sx={{ fontFamily: FONT, fontSize: '10px', color: C.dim, mt: 0.5 }}>
{s.uniqueUsers} users
</Box>
</Box>
</MetalCard>
</Grid>
))}
</Grid>
{/* Feature usage stacked bar chart */}
<MetalCard sx={{ mb: 3 }}>
<Box sx={{ p: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>FEATURE USAGE (DAILY)</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5, mb: 3 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Feature Usage (Daily)
</Box>
{featureData.length > 0 ? (
<FeatureUsageChart data={featureData} />
) : (
<PhosphorText variant="dim">No data</PhosphorText>
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No data</Box>
)}
</Box>
</MetalCard>
<Grid container spacing={2} sx={{ mb: 3 }}>
{/* DAU chart */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY ACTIVE USERS</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Daily Active Users
</Box>
{dauData.length > 0 ? (
<DauChart data={dauData} />
) : (
<PhosphorText variant="dim">No data</PhosphorText>
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No data</Box>
)}
</Box>
</MetalCard>
</Grid>
{/* Top users chart */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>TOP USERS</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Top Users
</Box>
{topUsersData.length > 0 ? (
<TopUsersChart data={topUsersData} />
) : (
<PhosphorText variant="dim">No data</PhosphorText>
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No data</Box>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
{/* Daily breakdown table */}
<MetalCard sx={{ overflow: 'auto' }}>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY BREAKDOWN</PhosphorText>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th><th>UNIQUE USERS</th></tr></thead>
Daily Breakdown
</Box>
<Box component="table" sx={tableSx}>
<thead><tr><th>Date</th><th>Feature</th><th>Calls</th><th>Unique Users</th></tr></thead>
<tbody>
{featureData.length > 0 ? (
[...featureData].reverse().map((r, i) => (
<tr key={i}>
<td style={{ color: d3roPalette.text.muted }}>{r.date}</td>
<td>{r.date}</td>
<td>{r.feature}</td>
<td style={{ color: d3roPalette.accent.amber }}>{r.total_count.toLocaleString()}</td>
<td style={{ color: d3roPalette.text.secondary }}>{r.unique_users}</td>
<td style={{ color: C.accent }}>{r.total_count.toLocaleString()}</td>
<td>{r.unique_users}</td>
</tr>
))
) : (
<tr><td colSpan={4} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No usage data</td></tr>
<tr><td colSpan={4} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No usage data</td></tr>
)}
</tbody>
</Box>
</Box>
</MetalCard>
</Box>
</Box>
</>
)
}

View file

@ -1,11 +1,10 @@
// apps/admin/src/app/(admin)/users/[id]/page.tsx
// 유저 상세 — 프로필 + 구독 + 30일 사용량
// D3RO Console — User detail
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { requireAdmin } from '@/lib/admin-guard'
import { requireManager, isAdmin } from '@/lib/admin-guard'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { RoleChangeButton } from './role-change-button'
@ -17,7 +16,7 @@ interface PageProps {
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id } = await params
const admin = await requireAdmin()
const admin = await requireManager()
const supabase = await getSupabaseServerClient()
const [profileRes, subRes, usageRes] = await Promise.all([
@ -35,102 +34,151 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
const sub = subRes.data as Record<string, unknown> | null
const usage = (usageRes.data ?? []) as Array<Record<string, unknown>>
const tier = (profile.tier as string) ?? 'free'
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
const tierBadge: 'purple' | 'green' | 'orange' = tier === 'pro_plus' ? 'purple' : tier === 'pro' ? 'green' : 'orange'
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'admin' | 'super_admin'
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'manager' | 'admin' | 'super_admin'
const roleColor = userRole === 'super_admin' ? C.purple400 : userRole === 'admin' ? C.green400 : userRole === 'manager' ? C.orange400 : C.dim
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<PhosphorText variant="title">USER DETAIL</PhosphorText>
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
User Detail
</Box>
<Link href="/users" style={{ textDecoration: 'none' }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
{'<'} Back
</Box>
</Link>
</Box>
<RoleChangeButton
userId={id}
userName={(profile.name as string) ?? null}
currentRole={userRole}
isSuperAdmin={admin.role === 'super_admin'}
callerRole={admin.role}
isAdminOrAbove={isAdmin(admin)}
/>
</Box>
{/* Content */}
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
<Grid container spacing={2} sx={{ mb: 3 }}>
{/* Profile card */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PROFILE</PhosphorText>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Profile
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
<Row label="ID" value={id} />
<Row label="NAME" value={(profile.name as string) ?? '-'} />
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()} valueColor={tierColor} />
<Row label="ROLE" value={userRole.toUpperCase()} valueColor={userRole === 'super_admin' ? d3roPalette.tag.purple : userRole === 'admin' ? d3roPalette.tag.green : d3roPalette.text.secondary} />
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()}>
<Box component="span" sx={statusBadgeSx(tierBadge)}>
{tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()}
</Box>
</Row>
<Row label="ROLE" value={userRole.toUpperCase()} valueColor={roleColor} />
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
</Box>
</Box>
</MetalCard>
</Grid>
{/* Subscription card */}
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Subscription
</Box>
{sub ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} valueColor={(sub.status as string) === 'active' ? d3roPalette.tag.green : d3roPalette.tag.red} />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} valueColor={(sub.status as string) === 'active' ? C.green400 : C.red400} />
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? '-').toUpperCase()} />
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
<Row label="CANCEL AT" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : '-'} />
<Box sx={{ mt: 0.5 }}>
<Link href={`/subscriptions/${id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none', fontSize: 11 }}>
<Box sx={{ mt: 1 }}>
<Link href={`/subscriptions/${id}`} style={{ color: C.accent, textDecoration: 'none', fontFamily: FONT, fontSize: '11px' }}>
Edit Subscription {'->'}
</Link>
</Box>
</Box>
) : (
<PhosphorText variant="dim">No subscription</PhosphorText>
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No subscription</Box>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USAGE (30 DAYS)</PhosphorText>
{usage.length === 0 ? (
<PhosphorText variant="dim">No usage data</PhosphorText>
) : (
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
{/* Usage table */}
<Box sx={{ ...panelSx, p: 2.5, mb: 3 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
<thead><tr><th>DATE</th><th>FEATURE</th><th>COUNT</th></tr></thead>
Usage (30 Days)
</Box>
{usage.length === 0 ? (
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No usage data</Box>
) : (
<Box component="table" sx={tableSx}>
<thead><tr><th>Date</th><th>Feature</th><th>Count</th></tr></thead>
<tbody>
{usage.map((row, i) => (
<tr key={i}>
<td>{row.date as string}</td>
<td>{row.feature as string}</td>
<td style={{ color: d3roPalette.accent.amber }}>{row.count as number}</td>
<td style={{ color: C.accent }}>{row.count as number}</td>
</tr>
))}
</tbody>
</Box>
)}
</Box>
</MetalCard>
{/* Payment History */}
<Box sx={{ mt: 3 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT HISTORY</PhosphorText>
<Box sx={{ ...panelSx, p: 2.5 }}>
<Box sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
}}>
Payment History
</Box>
<PaymentHistory userId={id} />
</Box>
</Box>
</Box>
</>
)
}
function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement {
function Row({ label, value, valueColor, children }: {
label: string
value: string
valueColor?: string
children?: React.ReactNode
}): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: valueColor ?? d3roPalette.text.primary }}>{value}</span>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box component="span" sx={{ color: C.dim }}>{label}</Box>
{children ?? <Box component="span" sx={{ color: valueColor ?? C.text }}>{value}</Box>}
</Box>
)
}

View file

@ -5,24 +5,25 @@
import { useState } from 'react'
import { Button } from '@mui/material'
import { d3roFontMono } from '@d3ro/ui/theme'
import { FONT } from '@/lib/console-theme'
import { RoleChangeDialog } from '@/components/role-change-dialog'
import { useRouter } from 'next/navigation'
interface RoleChangeButtonProps {
userId: string
userName: string | null
currentRole: 'user' | 'admin' | 'super_admin'
isSuperAdmin: boolean
currentRole: 'user' | 'manager' | 'admin' | 'super_admin'
callerRole: 'manager' | 'admin' | 'super_admin'
isAdminOrAbove: boolean
}
export function RoleChangeButton({
userId, userName, currentRole, isSuperAdmin,
userId, userName, currentRole, callerRole, isAdminOrAbove,
}: RoleChangeButtonProps): React.ReactElement | null {
const [open, setOpen] = useState(false)
const router = useRouter()
if (!isSuperAdmin) return null
if (!isAdminOrAbove) return null
return (
<>
@ -30,7 +31,7 @@ export function RoleChangeButton({
variant="outlined"
size="small"
onClick={() => setOpen(true)}
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
sx={{ fontFamily: FONT, fontSize: 11 }}
>
Change Role
</Button>
@ -39,6 +40,7 @@ export function RoleChangeButton({
userId={userId}
userName={userName}
currentRole={currentRole}
callerRole={callerRole}
onClose={() => setOpen(false)}
onSuccess={() => {
setOpen(false)

View file

@ -1,9 +1,8 @@
// apps/admin/src/app/(admin)/users/page.tsx
// 유저 목록 — profiles + subscriptions, 검색/필터/페이지네이션
// D3RO Console — Users list
import { Box } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import { C, FONT, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import Link from 'next/link'
@ -21,7 +20,6 @@ interface UserRow {
async function loadUsers(page: number, search: string, tierFilter: string): Promise<{ users: UserRow[]; total: number }> {
const supabase = await getSupabaseServerClient()
const from = page * PAGE_SIZE
const to = from + PAGE_SIZE - 1
@ -31,12 +29,8 @@ async function loadUsers(page: number, search: string, tierFilter: string): Prom
.order('created_at', { ascending: false })
.range(from, to)
if (search) {
profileQuery.ilike('name', `%${search}%`)
}
if (tierFilter && tierFilter !== 'all') {
profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus')
}
if (search) profileQuery.ilike('name', `%${search}%`)
if (tierFilter && tierFilter !== 'all') profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus')
const { data: rawProfiles, count } = await profileQuery
const profiles = (rawProfiles ?? []) as Array<Record<string, unknown>>
@ -84,72 +78,124 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
const { users, total } = await loadUsers(page, search, tierFilter)
const totalPages = Math.ceil(total / PAGE_SIZE)
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>USERS</PhosphorText>
const roleColor = (r: string): string =>
r === 'super_admin' ? C.purple400 : r === 'admin' ? C.green400 : r === 'manager' ? C.orange400 : C.dim
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>{total} USERS</PhosphorText>
<Box sx={{ flex: 1 }} />
return (
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Users
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px', letterSpacing: '0.1em',
color: C.dim, bgcolor: C.border, px: 1, py: 0.5, borderRadius: '4px',
}}>
{total} TOTAL
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{['all', 'free', 'pro', 'pro_plus'].map((t) => (
<Link key={t} href={`/users?tier=${t}&search=${search}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1.5, py: 0.5, borderRadius: 1,
bgcolor: tierFilter === t ? d3roPalette.bg.inset : 'transparent',
color: tierFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
}}>
<Box sx={filterBtnSx(tierFilter === t)}>
{t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()}
</PhosphorText>
</Box>
</Link>
))}
</Box>
</Box>
<MetalCard sx={{ overflow: 'auto' }}>
<Box component="table" sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 1, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, textTransform: 'uppercase' },
}}>
{/* Table */}
<Box sx={{ ...panelSx, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ flex: 1, overflow: 'auto', p: 3, position: 'relative', zIndex: 1 }}>
<Box component="table" sx={tableSx}>
<thead>
<tr><th>NAME</th><th>TIER</th><th>ROLE</th><th>STATUS</th><th>PROVIDER</th><th>JOINED</th></tr>
<tr>
<th>Name</th>
<th style={{ width: 80 }}>Tier</th>
<th style={{ width: 100 }}>Role</th>
<th style={{ width: 100 }}>Status</th>
<th style={{ width: 100 }}>Provider</th>
<th style={{ width: 110 }}>Joined</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/users/${u.id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
<Link href={`/users/${u.id}`} style={{ color: C.accent, textDecoration: 'none' }}>
{u.name ?? u.id.substring(0, 8)}
</Link>
</td>
<td style={{ color: u.tier === 'pro_plus' ? d3roPalette.tag.purple : u.tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary }}>
<td>
<Box component="span" sx={statusBadgeSx(
u.tier === 'pro_plus' ? 'purple' : u.tier === 'pro' ? 'green' : 'blue'
)}>
{u.tier === 'pro_plus' ? 'PRO+' : u.tier.toUpperCase()}
</Box>
</td>
<td style={{ color: u.role === 'admin' ? d3roPalette.tag.purple : d3roPalette.text.secondary }}>{u.role.toUpperCase()}</td>
<td style={{ color: u.subscription_status === 'active' ? d3roPalette.tag.green : d3roPalette.text.muted }}>{u.subscription_status?.toUpperCase() ?? '-'}</td>
<td style={{ color: d3roPalette.text.secondary }}>{u.payment_provider ?? '-'}</td>
<td style={{ color: d3roPalette.text.muted }}>{new Date(u.created_at).toLocaleDateString()}</td>
<td style={{ color: roleColor(u.role) }}>{u.role.toUpperCase()}</td>
<td>
{u.subscription_status ? (
<Box component="span" sx={statusBadgeSx(
u.subscription_status === 'active' ? 'green' : u.subscription_status === 'expired' ? 'red' : 'orange'
)}>
{u.subscription_status.toUpperCase()}
</Box>
) : (
<span style={{ color: C.dim }}>-</span>
)}
</td>
<td style={{ color: C.dim }}>{u.payment_provider ?? '-'}</td>
<td style={{ color: C.dim, whiteSpace: 'nowrap' }}>{new Date(u.created_at).toLocaleDateString()}</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: '16px 0' }}>No users found</td></tr>
<tr><td colSpan={6} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No users found</td></tr>
)}
</tbody>
</Box>
</MetalCard>
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
<Box sx={{
display: 'flex', justifyContent: 'center', gap: 0.5,
py: 2, borderTop: `1px solid ${C.border}`,
position: 'relative', zIndex: 1,
}}>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
<Link key={i} href={`/users?page=${i}&tier=${tierFilter}&search=${search}`} style={{ textDecoration: 'none' }}>
<PhosphorText variant="label" sx={{
px: 1, py: 0.5, borderRadius: 1,
bgcolor: page === i ? d3roPalette.accent.amber : d3roPalette.bg.inset,
color: page === i ? d3roPalette.bg.app : d3roPalette.text.secondary,
}}>{i + 1}</PhosphorText>
<Box sx={{
fontFamily: FONT, fontSize: '11px', fontWeight: 500,
width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '4px',
bgcolor: page === i ? C.accent : 'transparent',
color: page === i ? C.bright : C.dim,
border: `1px solid ${page === i ? C.accent : C.border}`,
cursor: 'pointer',
'&:hover': { borderColor: C.dim },
transition: 'all 0.15s',
}}>
{i + 1}
</Box>
</Link>
))}
</Box>
)}
</Box>
</>
)
}

View file

@ -0,0 +1,84 @@
/* D3RO Console — Global Styles */
:root {
--sys-base: #000000;
--sys-panel: #09090b;
--sys-panel-hover: #121214;
--sys-border: #1f1f22;
--sys-border-hl: #27272a;
--text-dim: #71717a;
--text-base: #a1a1aa;
--text-bright: #ffffff;
--accent: #ff5c28;
}
body {
background-color: var(--sys-base);
color: var(--text-bright);
font-family: 'JetBrains Mono', ui-monospace, monospace;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scanline */
.console-scanline {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(to bottom, transparent, rgba(255, 92, 40, 0.2), transparent);
opacity: 0.5;
animation: scan 8s linear infinite;
pointer-events: none;
z-index: 9999;
}
@keyframes scan {
0% { transform: translateY(-100%); }
100% { transform: translateY(100vh); }
}
/* Grid pattern overlay */
.bg-grid {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Glow effects */
.glow-white { text-shadow: 0 0 15px rgba(255, 255, 255, 0.5), 0 0 30px rgba(255, 255, 255, 0.2); }
.glow-green { text-shadow: 0 0 15px rgba(34, 197, 94, 0.6), 0 0 30px rgba(34, 197, 94, 0.3); }
.glow-orange { text-shadow: 0 0 15px rgba(249, 115, 22, 0.6), 0 0 30px rgba(249, 115, 22, 0.3); }
.glow-red { text-shadow: 0 0 15px rgba(239, 68, 68, 0.6), 0 0 30px rgba(239, 68, 68, 0.3); }
.glow-accent { text-shadow: 0 0 15px rgba(255, 92, 40, 0.6), 0 0 30px rgba(255, 92, 40, 0.3); }
/* Scrollbar */
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--sys-border-hl); border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* Pulse animation for status dot */
@keyframes pulse-ring {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(2); opacity: 0; }
}
.status-dot-pulse {
position: relative;
}
.status-dot-pulse::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: #22c55e;
animation: pulse-ring 1.5s ease-out infinite;
}
/* Selection */
::selection {
background: var(--accent);
color: white;
}

View file

@ -1,12 +1,13 @@
'use client'
// apps/admin/src/app/layout.tsx
// Admin CRM Root Layout — 항상 dark 모드
// Admin CRM Root Layout — D3RO Console 스타일
import { useMemo } from 'react'
import { ThemeProvider, CssBaseline } from '@mui/material'
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'
import { getTheme } from '@d3ro/ui/theme'
import './globals.css'
export default function RootLayout({
children,
@ -17,7 +18,13 @@ export default function RootLayout({
return (
<html lang="ko">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet" />
</head>
<body>
<div className="console-scanline" />
<AppRouterCacheProvider options={{ key: 'mui' }}>
<ThemeProvider theme={theme}>
<CssBaseline />

View file

@ -1,31 +1,41 @@
'use client'
// apps/admin/src/components/admin-sidebar.tsx
// Admin CRM 사이드바
// D3RO Console 사이드바
import { usePathname, useRouter } from 'next/navigation'
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Button } from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import PeopleIcon from '@mui/icons-material/People'
import SubscriptionsIcon from '@mui/icons-material/Subscriptions'
import BarChartIcon from '@mui/icons-material/BarChart'
import HistoryIcon from '@mui/icons-material/History'
import ApiIcon from '@mui/icons-material/Api'
import LogoutIcon from '@mui/icons-material/Logout'
import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { Box } from '@mui/material'
import { C, FONT } from '@/lib/console-theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
const NAV_ITEMS = [
{ key: 'overview', path: '/', label: 'Overview', icon: <DashboardIcon /> },
{ key: 'users', path: '/users', label: 'Users', icon: <PeopleIcon /> },
{ key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: <SubscriptionsIcon /> },
{ key: 'usage', path: '/usage', label: 'Usage', icon: <BarChartIcon /> },
{ key: 'audit-log', path: '/audit-log', label: 'Audit Log', icon: <HistoryIcon /> },
]
interface NavItem {
key: string
path: string
label: string
icon: React.ReactElement
}
const EXTERNAL_LINKS = [
{ key: 'swagger', href: '/admin-swagger/', label: 'API Docs', icon: <ApiIcon /> },
const NAV_ITEMS: NavItem[] = [
{
key: 'overview', path: '/', label: 'Overview',
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" /></svg>,
},
{
key: 'users', path: '/users', label: 'Users',
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /></svg>,
},
{
key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions',
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>,
},
{
key: 'usage', path: '/usage', label: 'Usage Data',
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>,
},
{
key: 'audit-log', path: '/audit-log', label: 'Audit Log',
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>,
},
]
export function AdminSidebar(): React.ReactElement {
@ -38,100 +48,212 @@ export function AdminSidebar(): React.ReactElement {
router.replace('/login')
}
const isActive = (path: string): boolean =>
path === '/' ? pathname === '/' : pathname.startsWith(path)
return (
<Box sx={{
width: 220,
minHeight: '100vh',
bgcolor: d3roPalette.bg.sidebar,
borderRight: `1px solid ${d3roPalette.border.default}`,
width: 280,
height: '100%',
display: 'flex',
flexDirection: 'column',
bgcolor: C.panel,
border: `1px solid ${C.border}`,
borderRadius: '16px',
flexShrink: 0,
position: 'relative',
overflow: 'hidden',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.6)',
}}>
<Box sx={{ p: 2.5, borderBottom: `1px solid ${d3roPalette.border.default}` }}>
<PhosphorText variant="heading">D3RO ADMIN</PhosphorText>
<PhosphorText variant="dim" sx={{ display: 'block', mt: 0.5, fontSize: 10 }}>CRM Console</PhosphorText>
{/* Grid pattern overlay */}
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.03, pointerEvents: 'none' }} />
{/* Header */}
<Box sx={{
px: 3, py: 4,
borderBottom: `1px solid ${C.border}`,
position: 'relative',
}}>
{/* Top accent line */}
<Box sx={{
position: 'absolute', top: 0, left: 0, width: '100%', height: '1px',
background: `linear-gradient(to right, transparent, ${C.accent}, transparent)`,
opacity: 0.5,
}} />
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '14px', fontWeight: 700,
letterSpacing: '0.2em', textTransform: 'uppercase',
color: C.bright,
}}>
D3RO // ADMIN
</Box>
{/* Status dot */}
<Box sx={{ position: 'relative', width: 8, height: 8 }}>
<Box sx={{
position: 'absolute', inset: 0, borderRadius: '50%',
bgcolor: C.green, opacity: 0.75,
animation: 'pulse-ring 1.5s ease-out infinite',
'@keyframes pulse-ring': {
'0%': { transform: 'scale(0.8)', opacity: 1 },
'100%': { transform: 'scale(2.5)', opacity: 0 },
},
}} />
<Box sx={{ position: 'relative', width: 8, height: 8, borderRadius: '50%', bgcolor: C.green }} />
</Box>
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.15em', textTransform: 'uppercase',
color: C.dim,
}}>
SYS.CONSOLE.v2
</Box>
</Box>
<List sx={{ flex: 1, py: 1 }}>
{/* Nav */}
<Box component="nav" sx={{
flex: 1, overflow: 'auto', py: 2, px: 1.5,
display: 'flex', flexDirection: 'column', gap: 0.5,
position: 'relative', zIndex: 1,
}}>
{NAV_ITEMS.map((item) => {
const active = item.path === '/'
? pathname === '/'
: pathname.startsWith(item.path)
const active = isActive(item.path)
return (
<ListItem key={item.key} disablePadding>
<ListItemButton
selected={active}
<Box
key={item.key}
onClick={() => router.push(item.path)}
sx={{
fontFamily: d3roFontMono,
'&.Mui-selected': {
bgcolor: d3roPalette.bg.inset,
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
display: 'flex', alignItems: 'center',
px: 2, py: 1.5,
borderRadius: '8px',
cursor: 'pointer',
position: 'relative',
border: `1px solid ${active ? C.borderHl : 'transparent'}`,
bgcolor: active ? `${C.borderHl}80` : 'transparent',
color: active ? C.bright : C.text,
transition: 'all 0.15s',
'&:hover': {
bgcolor: active ? `${C.borderHl}80` : C.panelHover,
borderColor: active ? C.borderHl : C.border,
color: C.bright,
'& svg': { color: active ? C.accent : C.bright },
},
}}
>
<ListItemIcon sx={{ minWidth: 36, color: active ? d3roPalette.accent.amber : d3roPalette.text.inactive }}>
{/* Active indicator bar */}
{active && (
<Box sx={{
position: 'absolute', left: 0, top: '50%', transform: 'translateY(-50%)',
width: 3, height: '50%', bgcolor: C.accent, borderRadius: '0 4px 4px 0',
}} />
)}
<Box sx={{
mr: 2, display: 'flex', alignItems: 'center',
color: active ? C.accent : C.dim,
transition: 'color 0.15s',
}}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontFamily: d3roFontMono,
fontSize: 13,
color: active ? d3roPalette.text.primary : d3roPalette.text.secondary,
}}
/>
</ListItemButton>
</ListItem>
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '12px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase',
}}>
{item.label}
</Box>
</Box>
)
})}
<ListItem disablePadding sx={{ mt: 1 }}>
<ListItemText
primary="EXTERNAL"
primaryTypographyProps={{
fontFamily: d3roFontMono,
fontSize: 10,
color: d3roPalette.text.label,
px: 2,
pt: 1,
}}
/>
</ListItem>
{EXTERNAL_LINKS.map((item) => (
<ListItem key={item.key} disablePadding>
<ListItemButton
{/* External Links Section */}
<Box sx={{ mt: 4, mb: 1, px: 2 }}>
<Box sx={{ height: '1px', width: '100%', bgcolor: C.border, mb: 2 }} />
<Box component="span" sx={{
fontFamily: FONT, fontSize: '9px', fontWeight: 500,
letterSpacing: '0.2em', textTransform: 'uppercase',
color: C.dim,
}}>
External Links
</Box>
</Box>
<Box
component="a"
href={item.href}
href="/admin-swagger/"
target="_blank"
rel="noopener noreferrer"
sx={{ fontFamily: d3roFontMono }}
>
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.inactive }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{
fontFamily: d3roFontMono,
fontSize: 13,
color: d3roPalette.text.secondary,
sx={{
display: 'flex', alignItems: 'center',
px: 2, py: 1.5, borderRadius: '8px',
cursor: 'pointer', textDecoration: 'none',
border: '1px solid transparent',
color: C.text,
transition: 'all 0.15s',
'&:hover': {
bgcolor: C.panelHover,
borderColor: C.border,
color: C.bright,
'& svg': { color: C.bright },
},
}}
/>
</ListItemButton>
</ListItem>
))}
</List>
<Box sx={{ p: 2, borderTop: `1px solid ${d3roPalette.border.default}` }}>
<Button
fullWidth
size="small"
startIcon={<LogoutIcon />}
onClick={() => void handleLogout()}
sx={{ fontFamily: d3roFontMono, fontSize: 12, color: d3roPalette.text.secondary }}
>
Logout
</Button>
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center', color: C.dim, transition: 'color 0.15s' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '12px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase',
}}>
API Docs
</Box>
</Box>
</Box>
{/* User card footer */}
<Box sx={{
p: 2,
bgcolor: `${C.base}80`,
borderTop: `1px solid ${C.border}`,
backdropFilter: 'blur(8px)',
position: 'relative', zIndex: 1,
}}>
<Box
onClick={() => void handleLogout()}
sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
p: 1.5, borderRadius: '8px',
border: `1px solid ${C.borderHl}`,
bgcolor: C.panel,
cursor: 'pointer',
transition: 'border-color 0.15s',
'&:hover': {
borderColor: C.dim,
'& .logout-icon': { color: C.accent },
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Box sx={{
width: 32, height: 32, borderRadius: '4px',
bgcolor: C.borderHl,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: `1px solid ${C.dim}4D`,
}}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', fontWeight: 700, color: C.bright }}>A</Box>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '11px', fontWeight: 500, letterSpacing: '0.05em', color: C.bright }}>
Admin User
</Box>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '9px', letterSpacing: '0.15em', textTransform: 'uppercase', color: C.dim }}>
SUPER_ADMIN
</Box>
</Box>
</Box>
<Box className="logout-icon" sx={{ color: C.dim, display: 'flex', transition: 'color 0.15s' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
</Box>
</Box>
</Box>
</Box>
)

View file

@ -20,19 +20,21 @@ import { PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
import { callAdminApi } from '@/lib/admin-api'
type Role = 'user' | 'admin' | 'super_admin'
type Role = 'user' | 'manager' | 'admin' | 'super_admin'
interface RoleChangeDialogProps {
open: boolean
userId: string
userName: string | null
currentRole: Role
/** 현재 로그인한 admin의 role */
callerRole?: Role
onClose: () => void
onSuccess: () => void
}
export function RoleChangeDialog({
open, userId, userName, currentRole, onClose, onSuccess,
open, userId, userName, currentRole, callerRole = 'admin', onClose, onSuccess,
}: RoleChangeDialogProps): React.ReactElement {
const [newRole, setNewRole] = useState<Role>(currentRole)
const [memo, setMemo] = useState('')
@ -83,8 +85,9 @@ export function RoleChangeDialog({
sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.primary }}
>
<MenuItem value="user">user</MenuItem>
<MenuItem value="admin">admin</MenuItem>
<MenuItem value="super_admin">super_admin</MenuItem>
<MenuItem value="manager">manager</MenuItem>
{callerRole === 'super_admin' && <MenuItem value="admin">admin</MenuItem>}
{callerRole === 'super_admin' && <MenuItem value="super_admin">super_admin</MenuItem>}
</Select>
</FormControl>

View file

@ -14,10 +14,16 @@ export async function callAdminApi<T = Record<string, unknown>>(
options: AdminApiOptions = {}
): Promise<T> {
const supabase = getSupabaseBrowserClient()
const { data: { session } } = await supabase.auth.getSession()
// getUser()로 토큰 갱신을 트리거한 뒤 세션에서 access_token 획득
const { data: { user }, error: userError } = await supabase.auth.getUser()
if (userError || !user) {
throw new Error('Not authenticated — please re-login')
}
const { data: { session } } = await supabase.auth.getSession()
if (!session?.access_token) {
throw new Error('Not authenticated')
throw new Error('Not authenticated — session expired')
}
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {

View file

@ -1,10 +1,17 @@
// apps/admin/src/lib/admin-guard.ts
// RSC용 admin 가드 — app_metadata.role = 'admin' | 'super_admin'
// RSC용 3단계 권한 가드 — manager < admin < super_admin
import { redirect } from 'next/navigation'
import { getSupabaseServerClient } from './supabase-server'
export type AdminRole = 'admin' | 'super_admin'
export type AdminRole = 'manager' | 'admin' | 'super_admin'
const ROLE_LEVEL: Record<string, number> = {
user: 0,
manager: 1,
admin: 2,
super_admin: 3,
}
export interface AdminUser {
id: string
@ -13,8 +20,8 @@ export interface AdminUser {
role: AdminRole
}
/** admin 이상 (admin, super_admin) */
export async function requireAdmin(): Promise<AdminUser> {
/** manager 이상 (manager, admin, super_admin) — CRM 접근 최소 권한 */
export async function requireManager(): Promise<AdminUser> {
const supabase = await getSupabaseServerClient()
const { data: { user } } = await supabase.auth.getUser()
@ -23,7 +30,7 @@ export async function requireAdmin(): Promise<AdminUser> {
}
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
if (role !== 'admin' && role !== 'super_admin') {
if ((ROLE_LEVEL[role ?? ''] ?? 0) < ROLE_LEVEL.manager) {
redirect('/unauthorized')
}
@ -41,16 +48,35 @@ export async function requireAdmin(): Promise<AdminUser> {
}
}
/** super_admin 전용 */
export async function requireSuperAdmin(): Promise<AdminUser> {
const adminUser = await requireAdmin()
if (adminUser.role !== 'super_admin') {
/** admin 이상 (admin, super_admin) */
export async function requireAdmin(): Promise<AdminUser> {
const adminUser = await requireManager()
if ((ROLE_LEVEL[adminUser.role] ?? 0) < ROLE_LEVEL.admin) {
redirect('/unauthorized')
}
return adminUser
}
/** super_admin 전용 */
export async function requireSuperAdmin(): Promise<AdminUser> {
const adminUser = await requireManager()
if ((ROLE_LEVEL[adminUser.role] ?? 0) < ROLE_LEVEL.super_admin) {
redirect('/unauthorized')
}
return adminUser
}
/** 최소 role 레벨 체크 */
export function hasMinRole(user: AdminUser, minRole: AdminRole): boolean {
return (ROLE_LEVEL[user.role] ?? 0) >= (ROLE_LEVEL[minRole] ?? 0)
}
/** role이 super_admin인지 체크 */
export function isSuperAdmin(user: AdminUser): boolean {
return user.role === 'super_admin'
}
/** role이 admin 이상인지 체크 */
export function isAdmin(user: AdminUser): boolean {
return (ROLE_LEVEL[user.role] ?? 0) >= ROLE_LEVEL.admin
}

View file

@ -0,0 +1,114 @@
// apps/admin/src/lib/console-theme.ts
// D3RO Console 디자인 토큰 — 터미널/콘솔 스타일
export const C = {
// backgrounds
base: '#000000',
panel: '#09090b',
panelHover: '#121214',
// borders
border: '#1f1f22',
borderHl: '#27272a',
// text
dim: '#71717a',
text: '#a1a1aa',
bright: '#ffffff',
// accent
accent: '#ff5c28',
// semantic
green: '#22c55e',
green400: '#4ade80',
orange: '#f97316',
orange400: '#fb923c',
red: '#ef4444',
red400: '#f87171',
blue: '#3b82f6',
blue400: '#60a5fa',
purple: '#a855f7',
purple400: '#c084fc',
} as const
export const FONT = '"JetBrains Mono", ui-monospace, monospace'
/** 공통 패널 스타일 */
export const panelSx = {
bgcolor: C.panel,
border: `1px solid ${C.border}`,
borderRadius: '16px',
position: 'relative' as const,
overflow: 'hidden',
'&:hover': { borderColor: C.borderHl },
transition: 'border-color 0.2s',
}
/** 테이블 공통 스타일 */
export const tableSx = {
width: '100%',
borderCollapse: 'collapse' as const,
fontFamily: FONT,
fontSize: '12px',
'& th': {
pb: 1.5,
fontWeight: 400,
textTransform: 'uppercase' as const,
letterSpacing: '0.1em',
color: C.dim,
textAlign: 'left' as const,
borderBottom: `1px solid ${C.borderHl}`,
},
'& td': {
py: 1.5,
textAlign: 'left' as const,
color: C.text,
borderBottom: `1px solid ${C.border}50`,
},
'& tr:hover td': {
bgcolor: `${C.borderHl}33`,
},
}
/** 필터 버튼 스타일 */
export const filterBtnSx = (active: boolean) => ({
px: 1.5,
py: 0.5,
borderRadius: '4px',
fontFamily: FONT,
fontSize: '10px',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase' as const,
cursor: 'pointer',
border: `1px solid ${active ? C.borderHl : 'transparent'}`,
bgcolor: active ? C.borderHl : 'transparent',
color: active ? C.bright : C.text,
'&:hover': {
bgcolor: C.panelHover,
borderColor: C.border,
},
transition: 'all 0.15s',
})
/** 상태 뱃지 스타일 */
export function statusBadgeSx(variant: 'green' | 'red' | 'orange' | 'blue' | 'purple') {
const colorMap = {
green: { bg: 'rgba(34, 197, 94, 0.1)', fg: C.green400, border: 'rgba(34, 197, 94, 0.2)' },
red: { bg: 'rgba(239, 68, 68, 0.1)', fg: C.red400, border: 'rgba(239, 68, 68, 0.2)' },
orange: { bg: 'rgba(249, 115, 22, 0.1)', fg: C.orange400, border: 'rgba(249, 115, 22, 0.2)' },
blue: { bg: 'rgba(59, 130, 246, 0.1)', fg: C.blue400, border: 'rgba(59, 130, 246, 0.2)' },
purple: { bg: 'rgba(168, 85, 247, 0.1)', fg: C.purple400, border: 'rgba(168, 85, 247, 0.2)' },
}
const c = colorMap[variant]
return {
display: 'inline-block',
px: 1,
py: 0.25,
borderRadius: '4px',
fontSize: '10px',
fontFamily: FONT,
fontWeight: 500,
letterSpacing: '0.05em',
bgcolor: c.bg,
color: c.fg,
border: `1px solid ${c.border}`,
}
}

View file

@ -1,17 +1,4 @@
// apps/admin/src/lib/supabase-browser.ts
// 클라이언트 컴포넌트용 Supabase 클라이언트
// re-export from shared package
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
if (cachedClient) return cachedClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
cachedClient = createBrowserClient<Database>(url, key)
return cachedClient
}
export { getSupabaseBrowserClient, isSupabaseConfigured } from '@d3ro/api-client/supabase-browser'

View file

@ -1,30 +1,10 @@
// apps/admin/src/lib/supabase-server.ts
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션)
// Next.js cookies() 주입 래퍼 — 실제 로직은 @d3ro/api-client
import { cookies } from 'next/headers'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
import { createSupabaseServerClient } from '@d3ro/api-client/supabase-server'
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
export async function getSupabaseServerClient() {
const cookieStore = await cookies()
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
return createServerClient<Database>(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// RSC에서 set은 실패 가능
}
}
}
})
return createSupabaseServerClient(cookieStore)
}

View file

@ -269,18 +269,16 @@ class VoiceConversationService extends EventEmitter {
this._setState('thinking')
try {
// Ollama /api/chat 호출 (스트리밍)
const llmService = getLocalLLMService()
const chatMessages = this._buildChatMessages()
// LLM 백엔드 선택: premium 설정 + 사용 가능 → PremiumLLM, 아니면 LocalLLM
const { generator: chatGenerator, backend } = await this._createChatStream()
logger.info(`Conversation LLM backend: ${backend}`)
const assistantMsgId = crypto.randomUUID()
let accumulated = ''
const ttsSentences: string[] = []
let sentenceBuffer = ''
const generator = llmService.chatStream(chatMessages)
for await (const token of generator) {
for await (const token of chatGenerator) {
accumulated += token
// 렌더러에 델타 전송
@ -359,6 +357,34 @@ class VoiceConversationService extends EventEmitter {
}
}
/**
* LLM + chatStream .
* premium + PremiumLLM, LocalLLM fallback.
*/
private async _createChatStream(): Promise<{
generator: AsyncGenerator<string, string>
backend: 'local' | 'premium'
}> {
const chatMessages = this._buildChatMessages()
const backend = configGet('llmBackend')
if (backend === 'premium') {
try {
const { getPremiumLLMService } = await import('./PremiumLLMService')
const premium = getPremiumLLMService()
if (premium.isAvailable()) {
return { generator: premium.chatStream(chatMessages), backend: 'premium' }
}
logger.warn('Premium LLM not available for conversation, falling back to local')
} catch (err) {
logger.warn('Premium LLM init failed for conversation:', err instanceof Error ? err.message : String(err))
}
}
const local = getLocalLLMService()
return { generator: local.chatStream(chatMessages), backend: 'local' }
}
private _buildChatMessages(): Array<{ role: string; content: string }> {
const chatMsgs: Array<{ role: string; content: string }> = [
{ role: 'system', content: SYSTEM_PROMPT },

View file

@ -1,26 +1,6 @@
// apps/web/src/lib/supabase-browser.ts
// 브라우저 컴포넌트용 Supabase 클라이언트.
'use client'
// apps/web/src/lib/supabase-browser.ts
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합)
// re-export from shared package
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
if (cachedClient) return cachedClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
cachedClient = createBrowserClient<Database>(url, key)
return cachedClient
}
export function isSupabaseConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
}
export { getSupabaseBrowserClient, isSupabaseConfigured } from '@d3ro/api-client/supabase-browser'

View file

@ -1,35 +1,12 @@
// apps/web/src/lib/supabase-server.ts
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션).
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합).
// Next.js cookies() 주입 래퍼 — 실제 로직은 @d3ro/api-client
import { cookies } from 'next/headers'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import type { Database } from '@d3ro/api-client'
import { createSupabaseServerClient, isSupabaseConfiguredServer } from '@d3ro/api-client/supabase-server'
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
export { isSupabaseConfiguredServer }
export async function getSupabaseServerClient() {
const cookieStore = await cookies()
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
return createServerClient<Database>(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// RSC에서 set은 실패하지만 route handler에서는 성공
}
}
}
})
}
export function isSupabaseConfiguredServer(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
return createSupabaseServerClient(cookieStore)
}

View file

@ -1,8 +1,60 @@
# D3RO-VOICE 프로젝트 현황
> 마지막 갱신: 2026-04-13 09:30 (Phase V2-6 Admin CRM 고도화)
> 마지막 갱신: 2026-04-13 (Admin 콘솔 리디자인 + 랜딩 SaaS 전환)
> 규칙 13: 작업 완료 즉시 이 파일 갱신 의무
## Admin 콘솔 리디자인 + 랜딩 SaaS 전환 (2026-04-13) ✅
### Admin CRM 콘솔 스타일 리디자인
- 전체 페이지 D3RO Console 스타일 적용 (Overview, Users, Subscriptions, Audit Log + 모든 상세)
- `console-theme.ts` 디자인 토큰 시스템 (C 색상, FONT, panelSx, tableSx, filterBtnSx, statusBadgeSx)
- `globals.css` — 스캔라인 애니메이션, 그리드 패턴, 글로우 효과, 커스텀 스크롤바
- JetBrains Mono 폰트, 검정 배경, 라운드 패널, 오렌지 액센트
- MetalCard/PhosphorText → Box + sx 전환 (MUI는 유지, d3ro DS 컴포넌트 미사용)
### SSE 스트리밍 Premium LLM 연결
- VoiceConversationService에 Premium/Local LLM 라우팅 추가
- `_createChatStream()`: configGet('llmBackend') 기반 분기, Premium 실패 시 Local fallback
- PremiumLLMService.chatStream() → Anthropic SSE → 토큰 단위 yield → TTS 파이프라인
### 코드 정리
- **Supabase 클라이언트 공통화**: packages/api-client에 supabase-browser.ts, supabase-server.ts 추출
- Browser: 싱글톤 캐싱 + isSupabaseConfigured() 완전 공유
- Server: CookieStore 인터페이스 팩토리 패턴 (next/headers 의존성 분리)
- apps/web, apps/admin → 3줄 re-export 래퍼로 축소
- **RPC 함수 타입 9개 추가**: packages/api-client/src/types.ts Functions 섹션
- generate_invite_token, user_team_ids, user_admin_team_ids, match_knowledge_chunks
- consume_quota, increment_daily_usage, admin_usage_by_feature, admin_top_users, admin_dau
### 랜딩 페이지 SaaS 구독 모델 전환
- Pricing: 1회 결제 → 월간($9.9/$19.9) / 연간($99/$199) 토글
- 10개 언어 i18n 전체 동기화 (pricing, FAQ, CTA)
- FAQ: 구독 관련 답변 업데이트
- CTA: "구독 없음" → "프리미엄 기능, 당신의 조건으로"
- 다운로드 URL: placeholder (#) — 추후 실제 URL 제공 시 교체
---
## V2-6.1: 3단계 권한 체계 (2026-04-12) ✅
### 변경 내역
- **DB**: `profiles_role_check``manager` 추가, RLS 정책 7개 재작성 (manager 읽기, admin 쓰기, manager 구독 UPDATE)
- **Edge Functions 4개**: `requireManager`/`requireAdmin` 3단계 분기 적용 + 배포 완료
- **Frontend**: `admin-guard.ts` 3단계 가드, UI 컴포넌트 권한별 표시 분기
- **토큰 갱신 버그 수정**: `callAdminApi`에서 `getSession()``getUser()` 선행 호출
### 권한 체계
| 기능 | manager | admin | super_admin |
|------|---------|-------|-------------|
| 조회 전체 | ✅ | ✅ | ✅ |
| 구독 수정 + 메모 | ✅ | ✅ | ✅ |
| 구독 생성 (VIP) | ❌ | ✅ | ✅ |
| 구독 삭제 | ❌ | ✅ | ✅ |
| role 변경 (user↔manager) | ❌ | ✅ | ✅ |
| role 변경 (→admin/super_admin) | ❌ | ❌ | ✅ |
---
## Phase V2-6: Admin CRM 고도화 (2026-04-13 09:30) ✅
### DB 마이그레이션 (`20260413000004_admin_enhancement.sql`)

View file

@ -34,10 +34,26 @@
"./usage": {
"types": "./src/usage.ts",
"default": "./src/usage.ts"
},
"./supabase-browser": {
"types": "./src/supabase-browser.ts",
"default": "./src/supabase-browser.ts"
},
"./supabase-server": {
"types": "./src/supabase-server.ts",
"default": "./src/supabase-server.ts"
}
},
"dependencies": {
"@d3ro/core": "*",
"@supabase/supabase-js": "^2.45.0"
},
"peerDependencies": {
"@supabase/ssr": ">=0.10.0"
},
"peerDependenciesMeta": {
"@supabase/ssr": {
"optional": true
}
}
}

View file

@ -15,3 +15,5 @@ export * from './auth'
export * from './meetings'
export * from './history'
export * from './usage'
export * from './supabase-browser'
export * from './supabase-server'

View file

@ -0,0 +1,22 @@
// packages/api-client/src/supabase-browser.ts
// 브라우저 컴포넌트용 Supabase 클라이언트 (캐싱 싱글턴).
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합).
import { createBrowserClient } from '@supabase/ssr'
import type { Database } from './types'
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
if (cachedClient) return cachedClient
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
cachedClient = createBrowserClient<Database>(url, key)
return cachedClient
}
export function isSupabaseConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
}

View file

@ -0,0 +1,41 @@
// packages/api-client/src/supabase-server.ts
// RSC/route handler용 Supabase 서버 클라이언트 팩토리.
// next/headers에 직접 의존하지 않고, 호출부에서 cookieStore를 주입.
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합).
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import type { Database } from './types'
/** next/headers cookies()가 반환하는 객체의 최소 인터페이스 */
export interface CookieStore {
getAll(): Array<{ name: string; value: string }>
set(name: string, value: string, options: CookieOptions): void
}
export function createSupabaseServerClient(
cookieStore: CookieStore,
): ReturnType<typeof createServerClient<Database>> {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
return createServerClient<Database>(url, key, {
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// RSC에서 set은 실패하지만 route handler에서는 성공
}
},
},
})
}
export function isSupabaseConfiguredServer(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
}

View file

@ -311,6 +311,84 @@ export type Database = {
>
}
Views: Record<string, never>
Functions: Record<string, never>
Functions: {
generate_invite_token: {
Args: Record<string, never>
Returns: string
}
user_team_ids: {
Args: { uid: string }
Returns: Array<{ team_id: string }>
}
user_admin_team_ids: {
Args: { uid: string }
Returns: Array<{ team_id: string }>
}
match_knowledge_chunks: {
Args: {
query_embedding: string
match_count?: number
similarity_threshold?: number
}
Returns: Array<{
id: string
document_id: string
chunk_index: number
content: string
similarity: number
}>
}
consume_quota: {
Args: {
p_user_id: string
p_feature: string
p_base_limit: number
}
Returns: Record<string, unknown>
}
increment_daily_usage: {
Args: {
p_user_id: string
p_feature: string
p_amount?: number
}
Returns: number
}
admin_usage_by_feature: {
Args: {
p_from: string
p_to: string
}
Returns: Array<{
date: string
feature: string
total_count: number
unique_users: number
}>
}
admin_top_users: {
Args: {
p_from: string
p_to: string
p_limit?: number
}
Returns: Array<{
user_id: string
name: string
total_count: number
feature_count: number
}>
}
admin_dau: {
Args: {
p_from: string
p_to: string
}
Returns: Array<{
date: string
active_users: number
}>
}
}
}
}

View file

@ -1,20 +1,43 @@
// server/supabase/functions/_shared/admin-auth.ts
// Admin/Super-admin 권한 검증 — requireUser 확장
// 3단계 권한 검증: manager < admin < super_admin
// @ts-expect-error — Deno 런타임 import
import type { User } from 'https://esm.sh/@supabase/supabase-js@2.39.7'
import { requireUser, type AuthError } from './auth.ts'
export type AdminRole = 'admin' | 'super_admin'
export type AdminRole = 'manager' | 'admin' | 'super_admin'
const ROLE_LEVEL: Record<string, number> = {
user: 0,
manager: 1,
admin: 2,
super_admin: 3,
}
function getUserRole(user: User): string {
return ((user.app_metadata as Record<string, unknown>)?.role as string) ?? 'user'
}
/**
* manager (manager, admin, super_admin).
*/
export async function requireManager(req: Request): Promise<User> {
const user = await requireUser(req)
const role = getUserRole(user)
if ((ROLE_LEVEL[role] ?? 0) < ROLE_LEVEL.manager) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw { status: 403, message: 'Manager access required' } as AuthError
}
return user
}
/**
* admin (admin, super_admin).
* AuthError throw.
*/
export async function requireAdmin(req: Request): Promise<User> {
const user = await requireUser(req)
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
if (role !== 'admin' && role !== 'super_admin') {
const role = getUserRole(user)
if ((ROLE_LEVEL[role] ?? 0) < ROLE_LEVEL.admin) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw { status: 403, message: 'Admin access required' } as AuthError
}
@ -23,12 +46,11 @@ export async function requireAdmin(req: Request): Promise<User> {
/**
* super_admin .
* AuthError throw.
*/
export async function requireSuperAdmin(req: Request): Promise<User> {
const user = await requireUser(req)
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
if (role !== 'super_admin') {
const role = getUserRole(user)
if ((ROLE_LEVEL[role] ?? 0) < ROLE_LEVEL.super_admin) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw { status: 403, message: 'Super admin access required' } as AuthError
}
@ -36,10 +58,18 @@ export async function requireSuperAdmin(req: Request): Promise<User> {
}
/**
* admin role . admin이 null.
* admin role . manager null.
*/
export function getAdminRole(user: User): AdminRole | null {
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
if (role === 'admin' || role === 'super_admin') return role
const role = getUserRole(user)
if (role === 'manager' || role === 'admin' || role === 'super_admin') return role as AdminRole
return null
}
/**
* .
*/
export function hasMinRole(user: User, minRole: AdminRole): boolean {
const role = getUserRole(user)
return (ROLE_LEVEL[role] ?? 0) >= (ROLE_LEVEL[minRole] ?? 0)
}

View file

@ -1,9 +1,9 @@
// server/supabase/functions/admin-audit-log/index.ts
// 감사로그 조회 — admin 이상
// 감사로그 조회 — manager 이상
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { requireAdmin } from '../_shared/admin-auth.ts'
import { requireManager } from '../_shared/admin-auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[], status = 200): Response {
@ -23,7 +23,7 @@ Deno.serve(async (req: Request) => {
}
try {
await requireAdmin(req)
await requireManager(req)
const url = new URL(req.url)
const serviceClient = createServiceRoleClient()

View file

@ -3,7 +3,7 @@
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { requireAdmin } from '../_shared/admin-auth.ts'
import { requireManager } from '../_shared/admin-auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import { getPaypleConfig, paypleAuth } from '../_shared/payple.ts'
@ -24,7 +24,7 @@ Deno.serve(async (req: Request) => {
}
try {
await requireAdmin(req)
await requireManager(req)
const url = new URL(req.url)
const userId = url.searchParams.get('userId')
const source = url.searchParams.get('source') // 'db' | 'payple' | null(=db)

View file

@ -1,9 +1,9 @@
// server/supabase/functions/admin-subscriptions/index.ts
// 구독 CRUD — admin: 조회 / super_admin: 생성/수정/삭제
// 구독 CRUD — manager: 조회+수정 / admin+: 생성/수정/삭제
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
import { requireManager, requireAdmin } from '../_shared/admin-auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import { writeAuditLog } from '../_shared/audit.ts'
@ -45,9 +45,9 @@ Deno.serve(async (req: Request) => {
const url = new URL(req.url)
const serviceClient = createServiceRoleClient()
// ── GET: 목록/상세 ──
// ── GET: 목록/상세 (manager 이상) ──
if (req.method === 'GET') {
await requireAdmin(req)
await requireManager(req)
const userId = url.searchParams.get('userId')
@ -95,9 +95,9 @@ Deno.serve(async (req: Request) => {
return jsonResponse({ subscriptions: data ?? [], total: count ?? 0, page, limit })
}
// ── POST: 생성 (super_admin) ──
// ── POST: 생성 (admin 이상) ──
if (req.method === 'POST') {
const admin = await requireSuperAdmin(req)
const admin = await requireAdmin(req)
const body = (await req.json()) as CreateBody
if (!body.userId || !body.tier || !body.memo) {
@ -155,9 +155,9 @@ Deno.serve(async (req: Request) => {
return jsonResponse({ success: true, subscription: created as unknown as Record<string, unknown> }, 201)
}
// ── PATCH: 수정 (super_admin) ──
// ── PATCH: 수정 (manager 이상) ──
if (req.method === 'PATCH') {
const admin = await requireSuperAdmin(req)
const admin = await requireManager(req)
const userId = url.searchParams.get('userId')
if (!userId) return jsonResponse({ error: 'userId query param required' }, 400)
@ -211,9 +211,9 @@ Deno.serve(async (req: Request) => {
return jsonResponse({ success: true, subscription: after as unknown as Record<string, unknown> })
}
// ── DELETE: 소프트 삭제 (super_admin) ──
// ── DELETE: 소프트 삭제 (admin 이상) ──
if (req.method === 'DELETE') {
const admin = await requireSuperAdmin(req)
const admin = await requireAdmin(req)
const userId = url.searchParams.get('userId')
if (!userId) return jsonResponse({ error: 'userId query param required' }, 400)

View file

@ -3,7 +3,7 @@
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { requireAdmin, requireSuperAdmin } from '../_shared/admin-auth.ts'
import { requireManager, requireAdmin, requireSuperAdmin, hasMinRole } from '../_shared/admin-auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import { writeAuditLog } from '../_shared/audit.ts'
@ -19,7 +19,7 @@ function jsonResponse(body: Record<string, unknown> | Record<string, unknown>[],
interface RoleChangeBody {
userId: string
newRole: 'user' | 'admin' | 'super_admin'
newRole: 'user' | 'manager' | 'admin' | 'super_admin'
memo: string
}
@ -31,9 +31,9 @@ Deno.serve(async (req: Request) => {
try {
const url = new URL(req.url)
// ── GET: 유저 목록/상세 ──
// ── GET: 유저 목록/상세 (manager 이상) ──
if (req.method === 'GET') {
const admin = await requireAdmin(req)
const admin = await requireManager(req)
const serviceClient = createServiceRoleClient()
const userId = url.searchParams.get('userId')
@ -86,9 +86,11 @@ Deno.serve(async (req: Request) => {
return jsonResponse({ profiles: profiles ?? [], total: count ?? 0, page, limit })
}
// ── PATCH: role 변경 (super_admin 전용) ──
// ── PATCH: role 변경 ──
// admin: user↔manager 변경 가능
// super_admin: 모든 role 변경 가능 (→admin 포함)
if (req.method === 'PATCH') {
const admin = await requireSuperAdmin(req)
const admin = await requireAdmin(req)
const body = (await req.json()) as RoleChangeBody
const serviceClient = createServiceRoleClient()
@ -96,11 +98,16 @@ Deno.serve(async (req: Request) => {
return jsonResponse({ error: 'userId, newRole, memo are required' }, 400)
}
const validRoles = ['user', 'admin', 'super_admin']
const validRoles = ['user', 'manager', 'admin', 'super_admin']
if (!validRoles.includes(body.newRole)) {
return jsonResponse({ error: `Invalid role. Must be one of: ${validRoles.join(', ')}` }, 400)
}
// admin은 user↔manager만 변경 가능, admin/super_admin 변경은 super_admin만
if (!hasMinRole(admin, 'super_admin') && (body.newRole === 'admin' || body.newRole === 'super_admin')) {
return jsonResponse({ error: 'Only super_admin can assign admin or super_admin roles' }, 403)
}
// 현재 프로필 조회 (before 스냅샷)
const { data: before } = await serviceClient
.from('profiles')
@ -110,6 +117,12 @@ Deno.serve(async (req: Request) => {
if (!before) return jsonResponse({ error: 'User not found' }, 404)
// admin이 admin/super_admin 유저의 role을 변경하려는 시도 차단
const targetRole = (before as Record<string, unknown>).role as string
if (!hasMinRole(admin, 'super_admin') && (targetRole === 'admin' || targetRole === 'super_admin')) {
return jsonResponse({ error: 'Only super_admin can modify admin or super_admin users' }, 403)
}
// 1. auth.users.raw_app_meta_data.role 변경
const { error: authError } = await serviceClient.auth.admin.updateUserById(body.userId, {
app_metadata: { role: body.newRole },

View file

@ -0,0 +1,48 @@
-- Phase V2-6.1: 3단계 권한 체계 — manager 역할 추가
-- manager: CS 업무 (조회 + 구독 수정/메모)
-- admin: 운영 전권 (구독 CRUD, 삭제, manager 관리)
-- super_admin: admin 계정 관리 (승격/강등)
-- 1. profiles.role CHECK 확장
ALTER TABLE public.profiles DROP CONSTRAINT profiles_role_check;
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_role_check
CHECK (role IN ('user', 'manager', 'admin', 'super_admin'));
-- 2. RLS: manager도 읽기 허용
DROP POLICY IF EXISTS admin_read_all_profiles ON public.profiles;
CREATE POLICY admin_read_all_profiles ON public.profiles FOR SELECT
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
DROP POLICY IF EXISTS admin_read_all_subscriptions ON public.subscriptions;
CREATE POLICY admin_read_all_subscriptions ON public.subscriptions FOR SELECT
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
DROP POLICY IF EXISTS admin_read_all_daily_usage ON public.daily_usage;
CREATE POLICY admin_read_all_daily_usage ON public.daily_usage FOR SELECT
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
DROP POLICY IF EXISTS admin_read_audit_log ON public.audit_log;
CREATE POLICY admin_read_audit_log ON public.audit_log FOR SELECT
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('manager', 'admin', 'super_admin'));
-- 3. RLS: admin도 subscriptions/profiles 쓰기 허용 (기존 super_admin 전용 → admin 이상)
DROP POLICY IF EXISTS super_admin_write_subscriptions ON public.subscriptions;
CREATE POLICY admin_write_subscriptions ON public.subscriptions
FOR ALL
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'))
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
DROP POLICY IF EXISTS super_admin_update_profiles ON public.profiles;
CREATE POLICY admin_update_profiles ON public.profiles
FOR UPDATE
USING ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'))
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') IN ('admin', 'super_admin'));
-- 4. RLS: manager는 subscriptions 수정만 허용 (생성/삭제 불가)
-- manager가 UPDATE를 수행할 수 있도록 별도 정책 (위의 admin_write_subscriptions은 admin 이상만)
-- 주의: 위 정책이 FOR ALL이므로 admin/super_admin은 이미 커버. manager만 UPDATE 추가.
CREATE POLICY manager_update_subscriptions ON public.subscriptions
FOR UPDATE
USING ((auth.jwt() -> 'app_metadata' ->> 'role') = 'manager')
WITH CHECK ((auth.jwt() -> 'app_metadata' ->> 'role') = 'manager');

View file

@ -106,12 +106,17 @@ export interface Translations {
pro: string
proPlus: string
forever: string
oneTime: string
monthly: string
annual: string
perMonth: string
popular: string
savePercent: string
downloadFree: string
getPro: string
getProPlus: string
taxNote: string
billingToggleMonthly: string
billingToggleAnnual: string
rows: [PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow, PricingFeatureRow]
}
faq: {

View file

@ -85,18 +85,23 @@ export const de: Translations = {
},
pricing: {
index: '04 / PREISE',
title: 'Kein Abo. Niemals.',
subtitle: 'Keine Cloud bedeutet keine monatlichen Kosten. Einmalkauf, lebenslange Nutzung.',
title: 'Alle Funktionen freischalten.',
subtitle: 'Kostenlos starten, upgraden wenn notig. Jederzeit kundbar.',
featureLabel: 'Funktion',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'fur immer gratis',
oneTime: 'einmalig',
monthly: '/Monat',
annual: '/Jahr',
perMonth: '/Monat',
savePercent: '17% sparen',
billingToggleMonthly: 'Monatlich',
billingToggleAnnual: 'Jahrlich',
popular: 'Beliebt',
downloadFree: 'Gratis herunterladen',
getPro: 'Pro holen',
getProPlus: 'Pro+ holen',
getPro: 'Pro abonnieren',
getProPlus: 'Pro+ abonnieren',
taxNote: 'Alle Preise zzgl. MwSt. Sichere Zahlung uber Payple.',
rows: [
{ feature: 'Sprachdiktat', free: '15/Tag', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const de: Translations = {
{ q: 'Welche GPU brauche ich?', a: 'Keine GPU erforderlich - funktioniert nur mit CPU. Eine NVIDIA GPU (CUDA) beschleunigt die Transkription 5-10x. Mit dem base-Modell ist Echtzeit-Transkription auf CPU moglich.' },
{ q: 'Funktioniert es komplett offline?', a: 'Ja. Sobald Sie die Whisper- und Ollama-Modelle heruntergeladen haben, funktioniert alles ohne Internet. Die Lizenzverifizierung ist nur bei der Erstaktivierung online, danach 30 Tage Offline-Karenzzeit.' },
{ q: 'Wie genau ist die Spracherkennung?', a: 'Whisper large-v3 bietet hervorragende Genauigkeit fur uber 99 Sprachen. Die benutzerdefinierte Worterbuchfunktion verbessert die Genauigkeit fur Fachterminologie zusatzlich.' },
{ q: 'Ist das ein Abonnement?', a: 'Nein. Pro und Pro+ sind Einmalkaufe. Einmal zahlen, fur immer nutzen. Wichtige Updates inklusive. Ohne Cloud-Serverkosten brauchen wir kein Abomodell.' },
{ q: 'Ist das ein Abonnement?', a: 'Pro und Pro+ sind monatliche oder jahrliche Abonnements. Sparen Sie etwa 17% bei jahrlicher Abrechnung. Jederzeit kundbar. Lokale Verarbeitung bedeutet keine Cloud-Kosten, aber Abonnements finanzieren kontinuierliche Updates und Premium-Funktionen.' },
{ q: 'Was ist mit macOS und Linux?', a: 'Derzeit nur Windows. Auf Electron aufgebaut, ist macOS/Linux-Unterstutzung technisch machbar und je nach Nachfrage geplant.' },
],
},
cta: {
title1: 'Sprechen.',
title2: 'KI schreibt.',
subtitle1: 'Keine Cloud. Kein Abo. Keine Datenschutzbedenken.',
subtitle1: 'Keine Cloud. Premium-Funktionen, Ihre Bedingungen. Keine Datenschutzbedenken.',
subtitle2: 'Jetzt starten.',
downloadBtn: 'Fur Windows herunterladen',
systemReq: 'Windows 10/11 \u00b7 64-Bit \u00b7 ~200MB \u00b7 In Sekunden bereit',

View file

@ -85,18 +85,23 @@ export const en: Translations = {
},
pricing: {
index: '04 / PRICING',
title: 'No Subscription. Ever.',
subtitle: 'No cloud means no monthly bills. One-time purchase, lifetime use.',
title: 'Unlock the Full Power.',
subtitle: 'Start free, upgrade when you need more. Cancel anytime.',
featureLabel: 'Feature',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'forever',
oneTime: 'one-time',
monthly: '/mo',
annual: '/yr',
perMonth: '/mo',
savePercent: 'Save 17%',
billingToggleMonthly: 'Monthly',
billingToggleAnnual: 'Annual',
popular: 'Popular',
downloadFree: 'Download Free',
getPro: 'Get Pro',
getProPlus: 'Get Pro+',
getPro: 'Subscribe to Pro',
getProPlus: 'Subscribe to Pro+',
taxNote: 'All prices exclude tax. Secure payment via Payple.',
rows: [
{ feature: 'Voice Dictation', free: '15/day', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const en: Translations = {
{ q: 'What GPU do I need?', a: 'No GPU required - it works on CPU alone. An NVIDIA GPU (CUDA) accelerates transcription 5-10x. With the base model, CPU real-time transcription is possible.' },
{ q: 'Does it work completely offline?', a: 'Yes. Once you download the Whisper and Ollama models, everything works without internet. License verification is online only for initial activation, then 30-day offline grace period.' },
{ q: 'How accurate is the speech recognition?', a: 'Whisper large-v3 provides excellent accuracy for 99+ languages. The custom dictionary feature further improves accuracy for domain-specific terminology.' },
{ q: 'Is this a subscription?', a: 'No. Pro and Pro+ are one-time purchases. Pay once, use forever. Major updates included. Since there are no cloud server costs, we do not need a subscription model.' },
{ q: 'Is this a subscription?', a: 'Pro and Pro+ are monthly or annual subscriptions. Save about 17% with annual billing. Cancel anytime. Local processing means no cloud costs, but subscriptions fund continuous updates and premium features.' },
{ q: 'What about macOS and Linux?', a: 'Currently Windows only. Built on Electron so macOS/Linux support is technically feasible and planned based on demand.' },
],
},
cta: {
title1: 'Speak.',
title2: 'AI Writes.',
subtitle1: 'No cloud. No subscription. No privacy concerns.',
subtitle1: 'No cloud. Premium features, your terms. No privacy concerns.',
subtitle2: 'Start now.',
downloadBtn: 'Download for Windows',
systemReq: 'Windows 10/11 \u00b7 64-bit \u00b7 ~200MB \u00b7 Ready in seconds',

View file

@ -85,18 +85,23 @@ export const es: Translations = {
},
pricing: {
index: '04 / PRECIOS',
title: 'Sin suscripcion. Nunca.',
subtitle: 'Sin nube significa sin facturas mensuales. Compra unica, uso de por vida.',
title: 'Desbloquea todo el poder.',
subtitle: 'Empieza gratis, mejora cuando lo necesites. Cancela cuando quieras.',
featureLabel: 'Funcion',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'gratis siempre',
oneTime: 'pago unico',
monthly: '/mes',
annual: '/ano',
perMonth: '/mes',
savePercent: 'Ahorra 17%',
billingToggleMonthly: 'Mensual',
billingToggleAnnual: 'Anual',
popular: 'Popular',
downloadFree: 'Descargar gratis',
getPro: 'Obtener Pro',
getProPlus: 'Obtener Pro+',
getPro: 'Suscribirse a Pro',
getProPlus: 'Suscribirse a Pro+',
taxNote: 'Todos los precios sin impuestos. Pago seguro via Payple.',
rows: [
{ feature: 'Dictado por voz', free: '15/dia', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const es: Translations = {
{ q: 'Que GPU necesito?', a: 'No se requiere GPU, funciona solo con CPU. Una GPU NVIDIA (CUDA) acelera la transcripcion 5-10x. Con el modelo base, la transcripcion en tiempo real por CPU es posible.' },
{ q: 'Funciona completamente offline?', a: 'Si. Una vez descargados los modelos de Whisper y Ollama, todo funciona sin internet. La verificacion de licencia es online solo para la activacion inicial, luego 30 dias de gracia offline.' },
{ q: 'Que tan preciso es el reconocimiento de voz?', a: 'Whisper large-v3 ofrece excelente precision para mas de 99 idiomas. La funcion de diccionario personalizado mejora aun mas la precision para terminologia especializada.' },
{ q: 'Es una suscripcion?', a: 'No. Pro y Pro+ son compras unicas. Paga una vez, usa para siempre. Actualizaciones principales incluidas. Sin costos de servidor en la nube, no necesitamos modelo de suscripcion.' },
{ q: 'Es una suscripcion?', a: 'Pro y Pro+ son suscripciones mensuales o anuales. Ahorra aproximadamente un 17% con la facturacion anual. Cancela en cualquier momento. El procesamiento local significa sin costos de nube, pero las suscripciones financian actualizaciones continuas y funciones premium.' },
{ q: 'Y macOS y Linux?', a: 'Actualmente solo Windows. Construido con Electron, el soporte para macOS/Linux es tecnicamente factible y esta planeado segun la demanda.' },
],
},
cta: {
title1: 'Habla.',
title2: 'La IA escribe.',
subtitle1: 'Sin nube. Sin suscripcion. Sin preocupaciones de privacidad.',
subtitle1: 'Sin nube. Funciones premium, a tu manera. Sin preocupaciones de privacidad.',
subtitle2: 'Empieza ahora.',
downloadBtn: 'Descargar para Windows',
systemReq: 'Windows 10/11 \u00b7 64-bit \u00b7 ~200MB \u00b7 Listo en segundos',

View file

@ -85,18 +85,23 @@ export const fr: Translations = {
},
pricing: {
index: '04 / TARIFS',
title: 'Pas d\'abonnement. Jamais.',
subtitle: 'Pas de cloud signifie pas de factures mensuelles. Achat unique, utilisation a vie.',
title: 'Debloquez toute la puissance.',
subtitle: 'Commencez gratuitement, passez a la version superieure quand vous voulez. Annulez a tout moment.',
featureLabel: 'Fonction',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'gratuit a vie',
oneTime: 'paiement unique',
monthly: '/mois',
annual: '/an',
perMonth: '/mois',
savePercent: 'Economisez 17%',
billingToggleMonthly: 'Mensuel',
billingToggleAnnual: 'Annuel',
popular: 'Populaire',
downloadFree: 'Telecharger gratuit',
getPro: 'Obtenir Pro',
getProPlus: 'Obtenir Pro+',
getPro: 'S\'abonner a Pro',
getProPlus: 'S\'abonner a Pro+',
taxNote: 'Tous les prix hors taxes. Paiement securise via Payple.',
rows: [
{ feature: 'Dictee vocale', free: '15/jour', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const fr: Translations = {
{ q: 'Quel GPU me faut-il ?', a: 'Aucun GPU requis - ca fonctionne uniquement sur CPU. Un GPU NVIDIA (CUDA) accelere la transcription 5-10x. Avec le modele base, la transcription en temps reel sur CPU est possible.' },
{ q: 'Ca fonctionne completement hors ligne ?', a: 'Oui. Une fois les modeles Whisper et Ollama telecharges, tout fonctionne sans internet. La verification de licence est en ligne uniquement pour l\'activation initiale, puis 30 jours de grace hors ligne.' },
{ q: 'Quelle est la precision de la reconnaissance vocale ?', a: 'Whisper large-v3 offre une excellente precision pour plus de 99 langues. La fonction de dictionnaire personnalise ameliore encore la precision pour la terminologie specialisee.' },
{ q: 'C\'est un abonnement ?', a: 'Non. Pro et Pro+ sont des achats uniques. Payez une fois, utilisez pour toujours. Mises a jour majeures incluses. Sans couts de serveur cloud, nous n\'avons pas besoin de modele d\'abonnement.' },
{ q: 'C\'est un abonnement ?', a: 'Pro et Pro+ sont des abonnements mensuels ou annuels. Economisez environ 17% avec la facturation annuelle. Annulez a tout moment. Le traitement local signifie aucun cout cloud, mais les abonnements financent les mises a jour continues et les fonctionnalites premium.' },
{ q: 'Et macOS et Linux ?', a: 'Actuellement Windows uniquement. Construit avec Electron, le support macOS/Linux est techniquement faisable et prevu selon la demande.' },
],
},
cta: {
title1: 'Parlez.',
title2: 'L\'IA ecrit.',
subtitle1: 'Pas de cloud. Pas d\'abonnement. Aucun souci de vie privee.',
subtitle1: 'Pas de cloud. Fonctionnalites premium, a vos conditions. Aucun souci de vie privee.',
subtitle2: 'Commencez maintenant.',
downloadBtn: 'Telecharger pour Windows',
systemReq: 'Windows 10/11 \u00b7 64-bit \u00b7 ~200 Mo \u00b7 Pret en secondes',

View file

@ -85,18 +85,23 @@ export const ja: Translations = {
},
pricing: {
index: '04 / 料金',
title: 'サブスクリプションなし、永久に。',
subtitle: 'クラウドなしだから月額料金もなし。一度の購入で永久使用。',
title: 'すべての機能をアンロック。',
subtitle: '無料で始めて、必要な時にアップグレード。いつでもキャンセル可能。',
featureLabel: '機能',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: '永久無料',
oneTime: '買い切り',
monthly: '/月',
annual: '/年',
perMonth: '/月',
savePercent: '17%お得',
billingToggleMonthly: '月額',
billingToggleAnnual: '年額',
popular: '人気',
downloadFree: '無料ダウンロード',
getPro: 'Proを購入',
getProPlus: 'Pro+を購入',
getPro: 'Proを購',
getProPlus: 'Pro+を購',
taxNote: '表示価格は税抜きです。Paypleによる安全な決済。',
rows: [
{ feature: '音声ディクテーション', free: '15回/日', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const ja: Translations = {
{ q: 'どんなGPUが必要ですか', a: 'GPUは不要 - CPUだけで動作します。NVIDIA GPUCUDAがあれば文字起こしが5-10倍高速化。baseモデルならCPUでリアルタイム文字起こしが可能です。' },
{ q: '完全にオフラインで動作しますか?', a: 'はい。WhisperとOllamaのモデルをダウンロードすれば、すべてがインターネットなしで動作します。ライセンス認証は初回アクティベーション時のみオンラインが必要で、その後30日間のオフライン猶予期間があります。' },
{ q: '音声認識の精度はどうですか?', a: 'Whisper large-v3は99以上の言語で優れた精度を提供します。カスタム辞書機能により、専門用語の認識精度をさらに向上できます。' },
{ q: 'サブスクリプションですか?', a: 'いいえ。ProとPro+は買い切りです。一度購入すれば永久使用。メジャーアップデート込み。クラウドサーバーのコストがないため、サブスクリプションモデルは不要です。' },
{ q: 'サブスクリプションですか?', a: 'ProとPro+は月額または年額のサブスクリプションです。年額払いで約17%お得。いつでもキャンセル可能。ローカル処理ベースなのでクラウドコストはありませんが、継続的なアップデートとプレミアム機能のためのサブスクリプションです。' },
{ q: 'macOSとLinuxには対応していますか', a: '現在はWindows専用です。ElectronベースなのでmacOS/Linuxサポートは技術的に可能で、需要に応じて対応予定です。' },
],
},
cta: {
title1: '話す。',
title2: 'AIが書く。',
subtitle1: 'クラウドなし。サブスクなし。プライバシーの心配なし。',
subtitle1: 'クラウドなし。プレミアム機能はあなたの条件で。プライバシーの心配なし。',
subtitle2: '今すぐ始めましょう。',
downloadBtn: 'Windows版をダウンロード',
systemReq: 'Windows 10/11 \u00b7 64ビット \u00b7 約200MB \u00b7 数秒で準備完了',

View file

@ -85,18 +85,23 @@ export const ko: Translations = {
},
pricing: {
index: '04 / 가격',
title: '구독 없음. 영원히.',
subtitle: '클라우드가 없으니 월 요금도 없습니다. 한 번 구매, 평생 사용.',
title: '전체 기능을 잠금 해제하세요.',
subtitle: '무료로 시작하고, 필요할 때 업그레이드. 언제든 취소 가능.',
featureLabel: '기능',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: '영구 무료',
oneTime: '1회 결제',
monthly: '/월',
annual: '/연',
perMonth: '/월',
savePercent: '17% 할인',
billingToggleMonthly: '월간',
billingToggleAnnual: '연간',
popular: '인기',
downloadFree: '무료 다운로드',
getPro: 'Pro 구매',
getProPlus: 'Pro+ 구매',
getPro: 'Pro 구',
getProPlus: 'Pro+ 구',
taxNote: '모든 가격은 세금 별도입니다. Payple를 통한 안전한 결제.',
rows: [
{ feature: '음성 받아쓰기', free: '15회/일', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const ko: Translations = {
{ q: '어떤 GPU가 필요한가요?', a: 'GPU가 없어도 CPU만으로 작동합니다. NVIDIA GPU(CUDA)가 있으면 전사 속도가 5-10배 빨라집니다. base 모델 기준 CPU 실시간 전사가 가능합니다.' },
{ q: '완전히 오프라인으로 작동하나요?', a: '네. Whisper와 Ollama 모델을 다운로드하면 인터넷 없이 모든 것이 작동합니다. 라이선스 검증은 최초 활성화 시에만 온라인이 필요하며, 이후 30일 오프라인 유예 기간이 제공됩니다.' },
{ q: '음성 인식 정확도는 어떤가요?', a: 'Whisper large-v3는 99개 이상의 언어에서 뛰어난 정확도를 제공합니다. 커스텀 사전 기능으로 전문 용어의 인식률을 더욱 높일 수 있습니다.' },
{ q: '구독 모델인가요?', a: '아닙니다. Pro와 Pro+는 1회 결제입니다. 한 번 구매하면 영구 사용. 주요 업데이트 포함. 클라우드 서버 비용이 없기 때문에 구독 모델이 필요 없습니다.' },
{ q: '구독 모델인가요?', a: 'Pro와 Pro+는 월간/연간 구독입니다. 연간 결제 시 약 17% 할인. 언제든 취소 가능. 로컬 처리 기반이라 클라우드 비용은 없지만, 지속적인 업데이트와 프리미엄 기능을 위한 구독입니다.' },
{ q: 'macOS와 Linux는 지원하나요?', a: '현재는 Windows 전용입니다. Electron 기반이므로 macOS/Linux 지원이 기술적으로 가능하며, 수요에 따라 지원할 예정입니다.' },
],
},
cta: {
title1: '말하세요.',
title2: 'AI가 씁니다.',
subtitle1: '클라우드 없음. 구독 없음. 프라이버시 걱정 없음.',
subtitle1: '클라우드 없음. 프리미엄 기능, 당신의 조건. 프라이버시 걱정 없음.',
subtitle2: '지금 시작하세요.',
downloadBtn: 'Windows용 다운로드',
systemReq: 'Windows 10/11 \u00b7 64비트 \u00b7 ~200MB \u00b7 몇 초면 준비 완료',

View file

@ -85,18 +85,23 @@ export const pt: Translations = {
},
pricing: {
index: '04 / PRECOS',
title: 'Sem assinatura. Nunca.',
subtitle: 'Sem nuvem significa sem contas mensais. Compra unica, uso vitalicio.',
title: 'Desbloqueie todo o poder.',
subtitle: 'Comece gratis, faca upgrade quando precisar. Cancele a qualquer momento.',
featureLabel: 'Recurso',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'gratis para sempre',
oneTime: 'pagamento unico',
monthly: '/mes',
annual: '/ano',
perMonth: '/mes',
savePercent: 'Economize 17%',
billingToggleMonthly: 'Mensal',
billingToggleAnnual: 'Anual',
popular: 'Popular',
downloadFree: 'Baixar gratis',
getPro: 'Obter Pro',
getProPlus: 'Obter Pro+',
getPro: 'Assinar Pro',
getProPlus: 'Assinar Pro+',
taxNote: 'Todos os precos excluem impostos. Pagamento seguro via Payple.',
rows: [
{ feature: 'Ditado por voz', free: '15/dia', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const pt: Translations = {
{ q: 'Qual GPU preciso?', a: 'Nenhuma GPU necessaria - funciona apenas com CPU. Uma GPU NVIDIA (CUDA) acelera a transcricao 5-10x. Com o modelo base, transcricao em tempo real por CPU e possivel.' },
{ q: 'Funciona completamente offline?', a: 'Sim. Apos baixar os modelos Whisper e Ollama, tudo funciona sem internet. A verificacao de licenca e online apenas na ativacao inicial, depois 30 dias de carencia offline.' },
{ q: 'Qual a precisao do reconhecimento de voz?', a: 'Whisper large-v3 oferece excelente precisao para mais de 99 idiomas. O recurso de dicionario personalizado melhora ainda mais a precisao para terminologia especializada.' },
{ q: 'E uma assinatura?', a: 'Nao. Pro e Pro+ sao compras unicas. Pague uma vez, use para sempre. Atualizacoes principais incluidas. Sem custos de servidor na nuvem, nao precisamos de modelo de assinatura.' },
{ q: 'E uma assinatura?', a: 'Pro e Pro+ sao assinaturas mensais ou anuais. Economize cerca de 17% com a cobranca anual. Cancele a qualquer momento. O processamento local significa sem custos de nuvem, mas as assinaturas financiam atualizacoes continuas e recursos premium.' },
{ q: 'E o macOS e Linux?', a: 'Atualmente apenas Windows. Construido com Electron, o suporte macOS/Linux e tecnicamente viavel e planejado conforme a demanda.' },
],
},
cta: {
title1: 'Fale.',
title2: 'A IA escreve.',
subtitle1: 'Sem nuvem. Sem assinatura. Sem preocupacoes com privacidade.',
subtitle1: 'Sem nuvem. Recursos premium, nos seus termos. Sem preocupacoes com privacidade.',
subtitle2: 'Comece agora.',
downloadBtn: 'Baixar para Windows',
systemReq: 'Windows 10/11 \u00b7 64-bit \u00b7 ~200MB \u00b7 Pronto em segundos',

View file

@ -85,18 +85,23 @@ export const ru: Translations = {
},
pricing: {
index: '04 / ЦЕНЫ',
title: 'Без подписки. Навсегда.',
subtitle: 'Нет облака \u2014 нет ежемесячных платежей. Одноразовая покупка, пожизненное использование.',
title: 'Разблокируйте все возможности.',
subtitle: 'Начните бесплатно, обновитесь когда нужно. Отмена в любое время.',
featureLabel: 'Функция',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'навсегда бесплатно',
oneTime: 'разовый платеж',
monthly: '/мес',
annual: '/год',
perMonth: '/мес',
savePercent: 'Скидка 17%',
billingToggleMonthly: 'Ежемесячно',
billingToggleAnnual: 'Ежегодно',
popular: 'Популярно',
downloadFree: 'Скачать бесплатно',
getPro: 'Получить Pro',
getProPlus: 'Получить Pro+',
getPro: 'Подписаться на Pro',
getProPlus: 'Подписаться на Pro+',
taxNote: 'Все цены без учета налогов. Безопасная оплата через Payple.',
rows: [
{ feature: 'Голосовая диктовка', free: '15/день', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const ru: Translations = {
{ q: 'Какая GPU нужна?', a: 'GPU не требуется \u2014 работает только на CPU. NVIDIA GPU (CUDA) ускоряет транскрипцию в 5-10 раз. С моделью base транскрипция в реальном времени на CPU возможна.' },
{ q: 'Работает ли полностью офлайн?', a: 'Да. После загрузки моделей Whisper и Ollama все работает без интернета. Проверка лицензии онлайн только при первой активации, затем 30 дней офлайн-льготного периода.' },
{ q: 'Насколько точно распознавание речи?', a: 'Whisper large-v3 обеспечивает отличную точность для более чем 99 языков. Функция пользовательского словаря дополнительно повышает точность для специализированной терминологии.' },
{ q: 'Это подписка?', a: 'Нет. Pro и Pro+ \u2014 разовые покупки. Заплатите один раз, пользуйтесь навсегда. Крупные обновления включены. Без расходов на облачные серверы нам не нужна модель подписки.' },
{ q: 'Это подписка?', a: 'Pro и Pro+ \u2014 это ежемесячные или ежегодные подписки. Экономьте около 17% при ежегодной оплате. Отмена в любое время. Локальная обработка означает отсутствие облачных расходов, но подписки финансируют постоянные обновления и премиум-функции.' },
{ q: 'А macOS и Linux?', a: 'Пока только Windows. Построено на Electron, поддержка macOS/Linux технически возможна и планируется в зависимости от спроса.' },
],
},
cta: {
title1: 'Говорите.',
title2: 'ИИ пишет.',
subtitle1: 'Без облака. Без подписки. Без проблем с приватностью.',
subtitle1: 'Без облака. Премиум-функции на ваших условиях. Без проблем с приватностью.',
subtitle2: 'Начните сейчас.',
downloadBtn: 'Скачать для Windows',
systemReq: 'Windows 10/11 \u00b7 64-бит \u00b7 ~200МБ \u00b7 Готово за секунды',

View file

@ -85,18 +85,23 @@ export const vi: Translations = {
},
pricing: {
index: '04 / GIA CA',
title: 'Khong dang ky. Mai mai.',
subtitle: 'Khong dam may nghia la khong hoa don hang thang. Mua mot lan, dung ca doi.',
title: 'Mo khoa toan bo suc manh.',
subtitle: 'Bat dau mien phi, nang cap khi can. Huy bat cu luc nao.',
featureLabel: 'Tinh nang',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: 'mien phi mai mai',
oneTime: 'mot lan',
monthly: '/thang',
annual: '/nam',
perMonth: '/thang',
savePercent: 'Tiet kiem 17%',
billingToggleMonthly: 'Hang thang',
billingToggleAnnual: 'Hang nam',
popular: 'Pho bien',
downloadFree: 'Tai mien phi',
getPro: 'Mua Pro',
getProPlus: 'Mua Pro+',
getPro: 'Dang ky Pro',
getProPlus: 'Dang ky Pro+',
taxNote: 'Tat ca gia chua bao gom thue. Thanh toan an toan qua Payple.',
rows: [
{ feature: 'Chinh ta giong noi', free: '15/ngay', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const vi: Translations = {
{ q: 'Toi can GPU nao?', a: 'Khong can GPU - hoat dong chi voi CPU. GPU NVIDIA (CUDA) tang toc phien am 5-10 lan. Voi mo hinh base, phien am thoi gian thuc tren CPU la kha thi.' },
{ q: 'Co hoat dong hoan toan ngoai tuyen khong?', a: 'Co. Sau khi tai cac mo hinh Whisper va Ollama, moi thu hoat dong khong can internet. Xac minh giay phep chi truc tuyen khi kich hoat lan dau, sau do 30 ngay an han ngoai tuyen.' },
{ q: 'Do chinh xac nhan dien giong noi the nao?', a: 'Whisper large-v3 cung cap do chinh xac tuyet voi cho hon 99 ngon ngu. Tinh nang tu dien tuy chinh cai thien them do chinh xac cho thuat ngu chuyen nganh.' },
{ q: 'Day co phai dang ky khong?', a: 'Khong. Pro va Pro+ la mua mot lan. Tra mot lan, dung mai mai. Bao gom cac ban cap nhat lon. Khong co chi phi may chu dam may, chung toi khong can mo hinh dang ky.' },
{ q: 'Day co phai dang ky khong?', a: 'Pro va Pro+ la dang ky hang thang hoac hang nam. Tiet kiem khoang 17% voi thanh toan hang nam. Huy bat cu luc nao. Xu ly cuc bo nghia la khong co chi phi dam may, nhung dang ky tai tro cho cac ban cap nhat lien tuc va tinh nang cao cap.' },
{ q: 'Con macOS va Linux thi sao?', a: 'Hien tai chi ho tro Windows. Duoc xay dung tren Electron nen ho tro macOS/Linux la kha thi ve mat ky thuat va duoc len ke hoach theo nhu cau.' },
],
},
cta: {
title1: 'Noi.',
title2: 'AI viet.',
subtitle1: 'Khong dam may. Khong dang ky. Khong lo ngai ve quyen rieng tu.',
subtitle1: 'Khong dam may. Tinh nang cao cap, theo dieu kien cua ban. Khong lo ngai ve quyen rieng tu.',
subtitle2: 'Bat dau ngay.',
downloadBtn: 'Tai xuong cho Windows',
systemReq: 'Windows 10/11 \u00b7 64-bit \u00b7 ~200MB \u00b7 San sang trong vai giay',

View file

@ -85,18 +85,23 @@ export const zh: Translations = {
},
pricing: {
index: '04 / 价格',
title: '永不订阅.',
subtitle: '没有云端意味着没有月费。一次购买,终身使用。',
title: '解锁全部功能.',
subtitle: '免费开始,按需升级。随时取消。',
featureLabel: '功能',
free: 'Free',
pro: 'Pro',
proPlus: 'Pro+',
forever: '永久免费',
oneTime: '一次性',
monthly: '/月',
annual: '/年',
perMonth: '/月',
savePercent: '省17%',
billingToggleMonthly: '月付',
billingToggleAnnual: '年付',
popular: '热门',
downloadFree: '免费下载',
getPro: '获取 Pro',
getProPlus: '获取 Pro+',
getPro: '订阅 Pro',
getProPlus: '订阅 Pro+',
taxNote: '所有价格不含税。通过 Payple 安全支付。',
rows: [
{ feature: '语音听写', free: '15次/天', pro: true, proPlus: true },
@ -124,14 +129,14 @@ export const zh: Translations = {
{ q: '需要什么 GPU', a: '无需 GPU - 仅用 CPU 即可运行。NVIDIA GPUCUDA可将转录速度提升 5-10 倍。使用 base 模型时CPU 实时转录完全可行。' },
{ q: '能完全离线工作吗?', a: '是的。下载 Whisper 和 Ollama 模型后,一切都可以在没有互联网的情况下运行。许可证验证仅在首次激活时需要在线,之后有 30 天的离线宽限期。' },
{ q: '语音识别准确率如何?', a: 'Whisper large-v3 支持 99 种以上语言,准确率极高。自定义词典功能可进一步提升专业术语的识别精度。' },
{ q: '这是订阅制吗?', a: '不是。Pro 和 Pro+ 为一次性购买。买一次,终身使用。包含重大更新。由于没有云服务器成本,我们不需要订阅模式。' },
{ q: '这是订阅制吗?', a: 'Pro 和 Pro+ 是月度或年度订阅。年付可节省约17%。随时可取消。基于本地处理,没有云端成本,但订阅用于持续更新和高级功能。' },
{ q: '支持 macOS 和 Linux 吗?', a: '目前仅支持 Windows。基于 Electron 构建macOS/Linux 支持在技术上可行,将根据需求推出。' },
],
},
cta: {
title1: '开口说。',
title2: 'AI来写。',
subtitle1: '无云端。无订阅。无隐私顾虑。',
subtitle1: '无云端。高级功能,由你做主。无隐私顾虑。',
subtitle2: '立即开始。',
downloadBtn: '下载 Windows 版',
systemReq: 'Windows 10/11 \u00b7 64位 \u00b7 约200MB \u00b7 几秒即可就绪',

View file

@ -27,7 +27,7 @@ export function CTA() {
{t.cta.subtitle2}
</p>
<GlowButton href="https://github.com/user/D3ROVoice/releases" variant="primary" size="lg">
<GlowButton href="#" variant="primary" size="lg">
{t.cta.downloadBtn}
</GlowButton>

View file

@ -48,7 +48,7 @@ export function Hero() {
<div className="flex flex-col sm:flex-row items-start gap-4 mb-16">
<a
href="https://github.com/user/D3ROVoice/releases"
href="#"
className="glow-btn inline-flex items-center gap-2 px-8 py-4 font-mono text-sm font-medium uppercase tracking-wider text-white bg-brand-amber rounded-panel"
>
<span className="relative z-10 flex items-center gap-2">

View file

@ -1,3 +1,4 @@
import { useState } from 'react'
import { SectionHeader } from '../components/SectionHeader'
import { Container } from '../components/Container'
import { GlowButton } from '../components/GlowButton'
@ -21,8 +22,18 @@ function CellValue({ value }: { value: string | boolean }) {
return <span className="font-mono text-xs text-neutral-300">{value}</span>
}
const PRICES = {
pro: { monthly: 9.9, annual: 99 },
proPlus: { monthly: 19.9, annual: 199 },
}
export function Pricing() {
const { t } = useI18n()
const [isAnnual, setIsAnnual] = useState(true)
const proPrice = isAnnual ? PRICES.pro.annual : PRICES.pro.monthly
const proPlusPrice = isAnnual ? PRICES.proPlus.annual : PRICES.proPlus.monthly
const periodLabel = isAnnual ? t.pricing.annual : t.pricing.monthly
return (
<section id="pricing" className="relative py-24 md:py-32">
@ -33,6 +44,35 @@ export function Pricing() {
subtitle={t.pricing.subtitle}
/>
{/* Billing toggle */}
<div className="flex items-center justify-center gap-3 mb-10">
<button
onClick={() => setIsAnnual(false)}
className={`font-mono text-xs uppercase tracking-widest px-4 py-2 rounded-panel transition-all ${
!isAnnual
? 'bg-surface-500 text-neutral-100 border border-white/[0.12]'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{t.pricing.billingToggleMonthly}
</button>
<button
onClick={() => setIsAnnual(true)}
className={`font-mono text-xs uppercase tracking-widest px-4 py-2 rounded-panel transition-all relative ${
isAnnual
? 'bg-surface-500 text-neutral-100 border border-brand-amber/30'
: 'text-neutral-500 hover:text-neutral-300'
}`}
>
{t.pricing.billingToggleAnnual}
{isAnnual && (
<span className="absolute -top-2.5 -right-2 px-1.5 py-0.5 rounded-full bg-brand-amber text-white text-[9px] font-mono font-semibold uppercase tracking-wider">
{t.pricing.savePercent}
</span>
)}
</button>
</div>
{/* Pricing table */}
<div className="overflow-x-auto -mx-5 md:mx-0">
<table className="w-full min-w-[640px] border-collapse">
@ -54,13 +94,27 @@ export function Pricing() {
</span>
</div>
<div className="font-mono text-nano uppercase tracking-widest text-brand-amber mt-4 mb-1">{t.pricing.pro}</div>
<div className="font-display text-xl font-bold text-neutral-100">$29</div>
<div className="font-mono text-nano text-neutral-600">{t.pricing.oneTime}</div>
<div className="font-display text-xl font-bold text-neutral-100">
${proPrice}
<span className="text-sm font-normal text-neutral-500">{periodLabel}</span>
</div>
{isAnnual && (
<div className="font-mono text-nano text-neutral-600">
${(PRICES.pro.annual / 12).toFixed(1)}{t.pricing.perMonth}
</div>
)}
</th>
<th className="text-center py-4 px-4 w-[20%]">
<div className="font-mono text-nano uppercase tracking-widest text-neutral-500 mb-1">{t.pricing.proPlus}</div>
<div className="font-display text-xl font-bold text-neutral-300">$49</div>
<div className="font-mono text-nano text-neutral-600">{t.pricing.oneTime}</div>
<div className="font-display text-xl font-bold text-neutral-300">
${proPlusPrice}
<span className="text-sm font-normal text-neutral-500">{periodLabel}</span>
</div>
{isAnnual && (
<div className="font-mono text-nano text-neutral-600">
${(PRICES.proPlus.annual / 12).toFixed(1)}{t.pricing.perMonth}
</div>
)}
</th>
</tr>
</thead>
@ -95,7 +149,7 @@ export function Pricing() {
{/* CTA row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-8">
<div className="flex justify-center">
<GlowButton href="https://github.com/user/D3ROVoice/releases" variant="secondary" size="md">
<GlowButton href="#" variant="secondary" size="md">
{t.pricing.downloadFree}
</GlowButton>
</div>