@fayz-ai/plugin-courses 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +3 -3
  2. package/dist/components/CommerceUI.d.ts +19 -2
  3. package/dist/components/CommerceUI.d.ts.map +1 -1
  4. package/dist/components/dashboardWidgets.d.ts.map +1 -0
  5. package/dist/index.d.ts +26 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +773 -309
  8. package/dist/index.js.map +1 -1
  9. package/dist/integrations/kiwify/connectorDef.d.ts +34 -0
  10. package/dist/integrations/kiwify/connectorDef.d.ts.map +1 -0
  11. package/dist/integrations/kiwify/data.d.ts +58 -0
  12. package/dist/integrations/kiwify/data.d.ts.map +1 -0
  13. package/dist/lib/nav.d.ts.map +1 -0
  14. package/dist/locales/en.d.ts.map +1 -1
  15. package/dist/locales/pt-BR.d.ts.map +1 -1
  16. package/dist/public/createPublicCheckoutPlugin.d.ts +1 -1
  17. package/dist/public/createPublicCheckoutPlugin.d.ts.map +1 -1
  18. package/dist/public/{data.d.ts → data/index.d.ts} +1 -1
  19. package/dist/public/data/index.d.ts.map +1 -0
  20. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  21. package/dist/public/data/supabase.d.ts.map +1 -0
  22. package/dist/public/index.d.ts +4 -4
  23. package/dist/public/index.d.ts.map +1 -1
  24. package/dist/public/index.js +353 -0
  25. package/dist/public/index.js.map +1 -0
  26. package/dist/public/{CheckoutScreen.d.ts → views/CheckoutScreen.d.ts} +1 -1
  27. package/dist/public/views/CheckoutScreen.d.ts.map +1 -0
  28. package/dist/views/CourseEditorPage.d.ts.map +1 -0
  29. package/dist/views/CourseOrderDetailView.d.ts +4 -3
  30. package/dist/views/CourseOrderDetailView.d.ts.map +1 -1
  31. package/dist/{pages → views}/CoursesListPage.d.ts.map +1 -1
  32. package/dist/views/FinancialPage.d.ts.map +1 -0
  33. package/dist/{pages → views}/ReportsPage.d.ts.map +1 -1
  34. package/dist/views/SalesPage.d.ts +19 -0
  35. package/dist/views/SalesPage.d.ts.map +1 -0
  36. package/dist/views/SubscriptionsPage.d.ts +7 -0
  37. package/dist/views/SubscriptionsPage.d.ts.map +1 -0
  38. package/package.json +12 -12
  39. package/dist/connectors/stripe.d.ts +0 -3
  40. package/dist/connectors/stripe.d.ts.map +0 -1
  41. package/dist/dashboardWidgets.d.ts.map +0 -1
  42. package/dist/nav.d.ts.map +0 -1
  43. package/dist/pages/CourseEditorPage.d.ts.map +0 -1
  44. package/dist/pages/FinancialPage.d.ts.map +0 -1
  45. package/dist/pages/SalesPage.d.ts +0 -6
  46. package/dist/pages/SalesPage.d.ts.map +0 -1
  47. package/dist/pages/SubscriptionsPage.d.ts +0 -6
  48. package/dist/pages/SubscriptionsPage.d.ts.map +0 -1
  49. package/dist/public/CheckoutScreen.d.ts.map +0 -1
  50. package/dist/public/data.d.ts.map +0 -1
  51. package/dist/public/data.supabase.d.ts.map +0 -1
  52. package/src/components/CommerceUI.tsx +0 -153
  53. package/src/connectors/stripe.ts +0 -34
  54. package/src/dashboardWidgets.tsx +0 -64
  55. package/src/index.ts +0 -150
  56. package/src/lib/format.ts +0 -22
  57. package/src/locales/en.ts +0 -203
  58. package/src/locales/index.ts +0 -7
  59. package/src/locales/pt-BR.ts +0 -203
  60. package/src/nav.ts +0 -9
  61. package/src/pages/CourseEditorPage.tsx +0 -1122
  62. package/src/pages/CoursesListPage.tsx +0 -137
  63. package/src/pages/FinancialPage.tsx +0 -81
  64. package/src/pages/ReportsPage.tsx +0 -59
  65. package/src/pages/SalesPage.tsx +0 -86
  66. package/src/pages/SubscriptionsPage.tsx +0 -56
  67. package/src/public/CheckoutScreen.tsx +0 -279
  68. package/src/public/createPublicCheckoutPlugin.tsx +0 -88
  69. package/src/public/data.supabase.ts +0 -63
  70. package/src/public/data.ts +0 -89
  71. package/src/public/index.ts +0 -26
  72. package/src/settings/CoursesSettings.tsx +0 -55
  73. package/src/views/CourseOrderDetailView.tsx +0 -132
  74. /package/dist/{dashboardWidgets.d.ts → components/dashboardWidgets.d.ts} +0 -0
  75. /package/dist/{nav.d.ts → lib/nav.d.ts} +0 -0
  76. /package/dist/{pages → views}/CourseEditorPage.d.ts +0 -0
  77. /package/dist/{pages → views}/CoursesListPage.d.ts +0 -0
  78. /package/dist/{pages → views}/FinancialPage.d.ts +0 -0
  79. /package/dist/{pages → views}/ReportsPage.d.ts +0 -0
@@ -1,137 +0,0 @@
1
- import * as React from 'react'
2
- import { Button, Badge, PageHeaderActions, useModuleLayout, Skeleton } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import { getCoursesProvider, type Course } from '@fayz-ai/courses'
5
- import { useLimitGuard, invalidateLimit, PermissionGate, usePermissionOptional } from '@fayz-ai/saas'
6
- import { navigateTo } from '../nav'
7
-
8
- function statusVariant(status: Course['status']): 'success' | 'secondary' | 'outline' {
9
- if (status === 'published') return 'success'
10
- if (status === 'archived') return 'outline'
11
- return 'secondary'
12
- }
13
-
14
- export function CoursesListPage() {
15
- const t = useTranslation()
16
- // When the app shell owns the page header (sidebar/GHL-style layouts), don't
17
- // render our own title — and project the action button into the top bar.
18
- const shellOwnsHeader = useModuleLayout() === 'tabs'
19
- const [courses, setCourses] = React.useState<Course[] | null>(null)
20
- const [creating, setCreating] = React.useState(false)
21
- const guardCourses = useLimitGuard('courses')
22
- const can = usePermissionOptional()
23
-
24
- const load = React.useCallback(async () => {
25
- setCourses(await getCoursesProvider().listCourses())
26
- }, [])
27
-
28
- React.useEffect(() => {
29
- void load()
30
- }, [load])
31
-
32
- async function onCreate() {
33
- // Defense in depth: never create without the role's `create` action.
34
- if (!can('courses', 'create')) return
35
- // Plan quantity guard (client-side, before the provider call). Opens the
36
- // global UpgradeModal and aborts when the plan's course cap is reached.
37
- if ((await guardCourses()) === 'blocked') return
38
- setCreating(true)
39
- try {
40
- const course = await getCoursesProvider().createCourse({
41
- title: t('courses.editor.untitledCourse') || 'Untitled course',
42
- status: 'draft',
43
- })
44
- invalidateLimit('courses')
45
- navigateTo(`/courses/${course.id}`)
46
- } finally {
47
- setCreating(false)
48
- }
49
- }
50
-
51
- return (
52
- <div className="mx-auto max-w-6xl p-6 md:p-8">
53
- {shellOwnsHeader ? (
54
- <PageHeaderActions>
55
- <PermissionGate feature="courses" action="create">
56
- <Button onClick={onCreate} disabled={creating}>
57
- {t('courses.new') || 'New course'}
58
- </Button>
59
- </PermissionGate>
60
- </PageHeaderActions>
61
- ) : (
62
- <div className="mb-6 flex items-center justify-between">
63
- <div>
64
- <h1 className="text-2xl font-bold text-foreground">{t('courses.title') || 'Courses'}</h1>
65
- {courses && (
66
- <p className="mt-1 text-sm text-muted-foreground">
67
- {(t('courses.count') || '{count} courses').replace('{count}', String(courses.length))}
68
- </p>
69
- )}
70
- </div>
71
- <PermissionGate feature="courses" action="create">
72
- <Button onClick={onCreate} disabled={creating}>
73
- {t('courses.new') || 'New course'}
74
- </Button>
75
- </PermissionGate>
76
- </div>
77
- )}
78
-
79
- {courses && courses.length === 0 && (
80
- <div className="rounded-xl border border-dashed border-border p-12 text-center text-muted-foreground">
81
- {t('courses.empty') || 'No courses yet. Create your first one.'}
82
- </div>
83
- )}
84
-
85
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
86
- {courses === null &&
87
- Array.from({ length: 6 }).map((_, i) => <CourseCardSkeleton key={i} />)}
88
- {courses?.map((course) => (
89
- <button
90
- key={course.id}
91
- onClick={() => navigateTo(`/courses/${course.id}`)}
92
- className="group overflow-hidden rounded-xl border border-border bg-card text-left transition hover:border-primary hover:shadow-md"
93
- >
94
- <div className="aspect-video w-full overflow-hidden bg-muted">
95
- {course.thumbnailUrl && (
96
- // eslint-disable-next-line @next/next/no-img-element
97
- <img src={course.thumbnailUrl} alt="" className="h-full w-full object-cover" />
98
- )}
99
- </div>
100
- <div className="space-y-2 p-4">
101
- <div className="flex items-start justify-between gap-2">
102
- <h3 className="font-semibold text-foreground line-clamp-2">{course.title}</h3>
103
- <Badge variant={statusVariant(course.status)}>
104
- {t(`courses.status.${course.status}`) || course.status}
105
- </Badge>
106
- </div>
107
- {course.subtitle && (
108
- <p className="text-sm text-muted-foreground line-clamp-2">{course.subtitle}</p>
109
- )}
110
- <p className="text-sm font-medium text-foreground">
111
- {course.price > 0 ? `${course.currency} ${course.price.toFixed(2)}` : '—'}
112
- </p>
113
- </div>
114
- </button>
115
- ))}
116
- </div>
117
- </div>
118
- )
119
- }
120
- CoursesListPage.displayName = 'CoursesListPage'
121
-
122
- /** Loading placeholder mirroring the course card layout. */
123
- function CourseCardSkeleton() {
124
- return (
125
- <div className="overflow-hidden rounded-xl border border-border bg-card">
126
- <Skeleton className="aspect-video w-full rounded-none" />
127
- <div className="space-y-3 p-4">
128
- <div className="flex items-start justify-between gap-2">
129
- <Skeleton className="h-4 w-2/3" />
130
- <Skeleton className="h-5 w-16 rounded-full" />
131
- </div>
132
- <Skeleton className="h-3 w-full" />
133
- <Skeleton className="h-4 w-20" />
134
- </div>
135
- </div>
136
- )
137
- }
@@ -1,81 +0,0 @@
1
- import * as React from 'react'
2
- import { Badge, Button, toast } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import { getCoursesProvider, type Payout, type FinancialSummary, type CreatorAccount } from '@fayz-ai/courses'
5
- import { PageContainer, StatCard, StatGrid, SimpleTable, type SimpleColumn } from '../components/CommerceUI'
6
- import { formatMoney, formatDate, formatPercent } from '../lib/format'
7
-
8
- // No plan gate here on purpose. The page is registered as a shell ROUTE with
9
- // `permission: read('courses.finance')`, so the route guard already resolves
10
- // role AND plan for it — and whether `courses.finance` is a paid feature is the
11
- // APP's call (its billing.ts), never the plugin's. Hardcoding a gate here would
12
- // bake one product's packaging into the SDK.
13
- export function FinancialPage() {
14
- const t = useTranslation()
15
- const [summary, setSummary] = React.useState<FinancialSummary | null>(null)
16
- const [payouts, setPayouts] = React.useState<Payout[] | null>(null)
17
- const [account, setAccount] = React.useState<CreatorAccount | null>(null)
18
-
19
- React.useEffect(() => {
20
- const p = getCoursesProvider()
21
- void p.getFinancialSummary().then(setSummary)
22
- void p.listPayouts().then(setPayouts)
23
- void p.getCreatorAccount().then(setAccount)
24
- }, [])
25
-
26
- const currency = summary?.currency ?? 'BRL'
27
- const connected = !!account?.stripeAccountId && account.chargesEnabled
28
-
29
- function connectStripe() {
30
- // Wired to the real Stripe Connect onboarding edge function in Stage 3.
31
- toast.info(t('courses.financial.connectSoon') || 'Stripe Connect onboarding wires up in Stage 3.')
32
- }
33
-
34
- const columns: SimpleColumn<Payout>[] = [
35
- { key: 'date', header: t('courses.financial.date') || 'Date', render: (p) => <span className="text-muted-foreground">{formatDate(p.createdAt)}</span> },
36
- { key: 'amount', header: t('courses.financial.amount') || 'Amount', align: 'right', render: (p) => <span className="font-medium text-foreground">{formatMoney(p.amount, p.currency)}</span> },
37
- { key: 'status', header: t('courses.financial.status') || 'Status', align: 'right', render: (p) => (
38
- <Badge variant={p.status === 'paid' ? 'success' : p.status === 'pending' ? 'secondary' : 'destructive'}>
39
- {t(`courses.payoutStatus.${p.status}`) || p.status}
40
- </Badge>
41
- ) },
42
- ]
43
-
44
- return (
45
- <PageContainer
46
- title={t('courses.financial.title') || 'Financial settings'}
47
- subtitle={t('courses.financial.subtitle') || 'Balance, payouts and your Stripe connection.'}
48
- actions={
49
- <Button variant={connected ? 'outline' : 'default'} onClick={connectStripe}>
50
- {connected ? (t('courses.financial.connected') || 'Stripe connected') : (t('courses.financial.connect') || 'Connect Stripe')}
51
- </Button>
52
- }
53
- >
54
- {!connected && (
55
- <div className="mb-6 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-foreground">
56
- {t('courses.financial.notConnected') || 'Connect your Stripe account to receive payouts. The platform fee is applied automatically on each sale.'}
57
- {account && (
58
- <span className="ml-1 text-muted-foreground">
59
- ({t('courses.financial.platformFee') || 'Platform fee'}: {(account.platformFeeBps / 100).toFixed(2)}%)
60
- </span>
61
- )}
62
- </div>
63
- )}
64
-
65
- <StatGrid>
66
- <StatCard label={t('courses.financial.available') || 'Available balance'} value={formatMoney(summary?.availableBalance ?? 0, currency)} />
67
- <StatCard label={t('courses.financial.pending') || 'Pending balance'} value={formatMoney(summary?.pendingBalance ?? 0, currency)} />
68
- <StatCard label={t('courses.financial.revenue') || 'Total revenue'} value={formatMoney(summary?.totalRevenue ?? 0, currency)} />
69
- <StatCard label={t('courses.financial.feeCollected') || 'Platform fees'} value={formatMoney(summary?.platformFeeTotal ?? 0, currency)} hint={summary ? formatPercent(summary.refundRate, 1) + ' ' + (t('courses.financial.refundRate') || 'refund rate') : undefined} />
70
- </StatGrid>
71
-
72
- <h2 className="mb-3 text-sm font-semibold text-foreground">{t('courses.financial.payouts') || 'Payouts'}</h2>
73
- <SimpleTable
74
- columns={columns}
75
- rows={payouts ?? []}
76
- empty={t('courses.financial.noPayouts') || 'No payouts yet.'}
77
- />
78
- </PageContainer>
79
- )
80
- }
81
- FinancialPage.displayName = 'FinancialPage'
@@ -1,59 +0,0 @@
1
- import * as React from 'react'
2
- import { useTranslation } from '@fayz-ai/core'
3
- import { getCoursesProvider, type Order, type Course } from '@fayz-ai/courses'
4
- import { PageContainer, SimpleTable, type SimpleColumn } from '../components/CommerceUI'
5
- import { formatMoney } from '../lib/format'
6
-
7
- interface Row {
8
- courseId: string
9
- title: string
10
- sales: number
11
- revenue: number
12
- currency: string
13
- }
14
-
15
- export function ReportsPage() {
16
- const t = useTranslation()
17
- const [rows, setRows] = React.useState<Row[] | null>(null)
18
-
19
- React.useEffect(() => {
20
- const p = getCoursesProvider()
21
- void Promise.all([p.listCourses(), p.listOrders({ financialStatus: 'paid' })]).then(([courses, orders]) => {
22
- setRows(buildRevenueByCourse(courses, orders))
23
- })
24
- }, [])
25
-
26
- const columns: SimpleColumn<Row>[] = [
27
- { key: 'title', header: t('courses.reports.course') || 'Course', render: (r) => <span className="font-medium text-foreground">{r.title}</span> },
28
- { key: 'sales', header: t('courses.reports.sales') || 'Sales', align: 'right', render: (r) => <span>{r.sales}</span> },
29
- { key: 'revenue', header: t('courses.reports.revenue') || 'Revenue', align: 'right', render: (r) => <span className="font-medium text-foreground">{formatMoney(r.revenue, r.currency)}</span> },
30
- ]
31
-
32
- return (
33
- <PageContainer title={t('courses.reports.title') || 'Reports'} subtitle={t('courses.reports.subtitle') || 'Revenue by course.'}>
34
- <h2 className="mb-3 text-sm font-semibold text-foreground">{t('courses.reports.byCourse') || 'Revenue by course'}</h2>
35
- <SimpleTable
36
- columns={columns}
37
- rows={rows ?? []}
38
- empty={t('courses.reports.empty') || 'No revenue yet.'}
39
- />
40
- </PageContainer>
41
- )
42
- }
43
- ReportsPage.displayName = 'ReportsPage'
44
-
45
- function buildRevenueByCourse(courses: Course[], orders: Order[]): Row[] {
46
- const byCourse = new Map<string, { sales: number; revenue: number; currency: string }>()
47
- for (const o of orders) {
48
- const agg = byCourse.get(o.courseId) ?? { sales: 0, revenue: 0, currency: o.currency }
49
- agg.sales += 1
50
- agg.revenue = Math.round((agg.revenue + o.total) * 100) / 100
51
- byCourse.set(o.courseId, agg)
52
- }
53
- return courses
54
- .map((c) => {
55
- const agg = byCourse.get(c.id)
56
- return { courseId: c.id, title: c.title, sales: agg?.sales ?? 0, revenue: agg?.revenue ?? 0, currency: agg?.currency ?? c.currency }
57
- })
58
- .sort((a, b) => b.revenue - a.revenue)
59
- }
@@ -1,86 +0,0 @@
1
- import * as React from 'react'
2
- import { Badge } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import { OrderListView, type OrderListColumn } from '@fayz-ai/saas'
5
- import { getCoursesProvider, type Order, type FinancialStatus } from '@fayz-ai/courses'
6
- import { formatMoney, formatDate } from '../lib/format'
7
- import { navigateTo } from '../nav'
8
-
9
- function statusVariant(s: FinancialStatus): 'success' | 'secondary' | 'destructive' | 'outline' {
10
- if (s === 'paid') return 'success'
11
- if (s === 'pending') return 'secondary'
12
- if (s === 'refunded' || s === 'chargeback') return 'destructive'
13
- return 'outline'
14
- }
15
-
16
- // Cache (fora do componente) — trocar de aba remonta a página; sem cache refaz a
17
- // consulta toda vez. Cache-first evita o refetch/skeleton ao voltar pra aba.
18
- let salesCache: Order[] | null = null
19
-
20
- export function SalesPage() {
21
- const t = useTranslation()
22
- const [orders, setOrders] = React.useState<Order[] | null>(salesCache)
23
-
24
- React.useEffect(() => {
25
- if (salesCache) return // cache hit → sem request
26
- let alive = true
27
- const p = getCoursesProvider()
28
- // public.orders (via listCoreSales) é a fonte da verdade; listOrders é a
29
- // projeção nativa do courses. Dedupe por id: uma venda espelhada aparece 1x,
30
- // com a linha canônica (orders) ganhando.
31
- void Promise.all([
32
- p.listCoreSales().catch(() => [] as Order[]),
33
- p.listOrders().catch(() => [] as Order[]),
34
- ]).then(([core, store]) => {
35
- if (!alive) return
36
- const byId = new Map<string, Order>()
37
- for (const o of store) byId.set(o.id, o)
38
- for (const o of core) byId.set(o.id, o)
39
- const list = [...byId.values()].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
40
- salesCache = list
41
- setOrders(list)
42
- })
43
- return () => { alive = false }
44
- }, [])
45
-
46
- const paid = (orders ?? []).filter((o) => o.financialStatus === 'paid')
47
- const revenue = paid.reduce((s, o) => s + o.total, 0)
48
- const currency = orders?.[0]?.currency ?? 'BRL'
49
-
50
- const columns: OrderListColumn<Order>[] = [
51
- { key: 'date', header: t('courses.sales.date') || 'Data', render: (o) => <span className="text-muted-foreground">{formatDate(o.createdAt)}</span> },
52
- { key: 'customer', header: t('courses.sales.customer') || 'Cliente', render: (o) => (
53
- <div>
54
- <div className="font-medium text-foreground">{o.customerName ?? '—'}</div>
55
- <div className="text-xs text-muted-foreground">{o.customerEmail}</div>
56
- </div>
57
- ) },
58
- { key: 'method', header: t('courses.sales.method') || 'Forma', render: (o) => (
59
- <span className="capitalize">
60
- {o.paymentMethod ?? '—'}{o.installmentCount && o.installmentCount > 1 ? ` · ${o.installmentCount}x` : ''}
61
- </span>
62
- ) },
63
- { key: 'status', header: t('courses.sales.status') || 'Status', render: (o) => (
64
- <Badge variant={statusVariant(o.financialStatus)}>{t(`courses.financialStatus.${o.financialStatus}`) || o.financialStatus}</Badge>
65
- ) },
66
- { key: 'fee', header: t('courses.sales.fee') || 'Taxa', align: 'right', render: (o) => <span className="text-muted-foreground">{formatMoney(o.platformFee, o.currency)}</span> },
67
- { key: 'total', header: t('courses.sales.amount') || 'Valor', align: 'right', render: (o) => <span className="font-medium text-foreground">{formatMoney(o.total, o.currency)}</span> },
68
- ]
69
-
70
- return (
71
- <OrderListView<Order>
72
- title={t('courses.sales.title') || 'Vendas'}
73
- subtitle={t('courses.sales.subtitle') || 'Todas as transações dos seus cursos.'}
74
- stats={[
75
- { label: t('courses.sales.count') || 'Vendas', value: String(paid.length) },
76
- { label: t('courses.sales.revenue') || 'Receita', value: formatMoney(revenue, currency) },
77
- ]}
78
- columns={columns}
79
- rows={orders ?? []}
80
- loading={orders === null}
81
- empty={t('courses.sales.empty') || 'Nenhuma venda ainda.'}
82
- onRowClick={(o) => navigateTo(`/courses/sales/${o.id}`)}
83
- />
84
- )
85
- }
86
- SalesPage.displayName = 'SalesPage'
@@ -1,56 +0,0 @@
1
- import * as React from 'react'
2
- import { Badge } from '@fayz-ai/ui'
3
- import { useTranslation } from '@fayz-ai/core'
4
- import { getCoursesProvider, type Subscription, type SubscriptionStatus } from '@fayz-ai/courses'
5
- import { PageContainer, StatCard, StatGrid, SimpleTable, type SimpleColumn } from '../components/CommerceUI'
6
- import { formatMoney, formatDate } from '../lib/format'
7
-
8
- function statusVariant(s: SubscriptionStatus): 'success' | 'secondary' | 'destructive' {
9
- if (s === 'active') return 'success'
10
- if (s === 'past_due') return 'secondary'
11
- return 'destructive'
12
- }
13
-
14
- export function SubscriptionsPage() {
15
- const t = useTranslation()
16
- const [subs, setSubs] = React.useState<Subscription[] | null>(null)
17
-
18
- React.useEffect(() => {
19
- void getCoursesProvider().listSubscriptions().then(setSubs)
20
- }, [])
21
-
22
- const active = (subs ?? []).filter((s) => s.status === 'active')
23
- const mrr = active.reduce((sum, s) => sum + (s.interval === 'year' ? s.netValue / 12 : s.netValue), 0)
24
- const currency = subs?.[0]?.currency ?? 'BRL'
25
-
26
- const columns: SimpleColumn<Subscription>[] = [
27
- { key: 'start', header: t('courses.subs.start') || 'Start date', render: (s) => <span className="text-muted-foreground">{formatDate(s.startedAt)}</span> },
28
- { key: 'customer', header: t('courses.subs.customer') || 'Customer', render: (s) => (
29
- <div>
30
- <div className="font-medium text-foreground">{s.customerName ?? '—'}</div>
31
- <div className="text-xs text-muted-foreground">{s.customerEmail}</div>
32
- </div>
33
- ) },
34
- { key: 'status', header: t('courses.subs.status') || 'Status', render: (s) => (
35
- <Badge variant={statusVariant(s.status)}>{t(`courses.subStatus.${s.status}`) || s.status}</Badge>
36
- ) },
37
- { key: 'value', header: t('courses.subs.value') || 'Net value', align: 'right', render: (s) => (
38
- <span className="font-medium text-foreground">{formatMoney(s.netValue, s.currency)}<span className="text-xs text-muted-foreground"> / {t(`courses.interval.${s.interval}`) || s.interval}</span></span>
39
- ) },
40
- ]
41
-
42
- return (
43
- <PageContainer title={t('courses.subs.title') || 'Subscriptions'} subtitle={t('courses.subs.subtitle') || 'Recurring plans and MRR.'}>
44
- <StatGrid>
45
- <StatCard label={t('courses.subs.active') || 'Active subscriptions'} value={String(active.length)} />
46
- <StatCard label={t('courses.subs.mrr') || 'Monthly recurring revenue'} value={formatMoney(mrr, currency)} />
47
- </StatGrid>
48
- <SimpleTable
49
- columns={columns}
50
- rows={subs ?? []}
51
- empty={t('courses.subs.empty') || 'No subscriptions yet.'}
52
- />
53
- </PageContainer>
54
- )
55
- }
56
- SubscriptionsPage.displayName = 'SubscriptionsPage'