@fayz-ai/plugin-courses 0.9.0 → 0.9.1
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.
- package/README.md +3 -3
- package/dist/components/CommerceUI.d.ts +19 -2
- package/dist/components/CommerceUI.d.ts.map +1 -1
- package/dist/components/dashboardWidgets.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +342 -253
- package/dist/index.js.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -0
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/public/createPublicCheckoutPlugin.d.ts +1 -1
- package/dist/public/createPublicCheckoutPlugin.d.ts.map +1 -1
- package/dist/public/{data.d.ts → data/index.d.ts} +1 -1
- package/dist/public/data/index.d.ts.map +1 -0
- package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
- package/dist/public/data/supabase.d.ts.map +1 -0
- package/dist/public/index.d.ts +4 -4
- package/dist/public/index.d.ts.map +1 -1
- package/dist/public/index.js +353 -0
- package/dist/public/index.js.map +1 -0
- package/dist/public/{CheckoutScreen.d.ts → views/CheckoutScreen.d.ts} +1 -1
- package/dist/public/views/CheckoutScreen.d.ts.map +1 -0
- package/dist/views/CourseEditorPage.d.ts.map +1 -0
- package/dist/views/CourseOrderDetailView.d.ts +4 -3
- package/dist/views/CourseOrderDetailView.d.ts.map +1 -1
- package/dist/{pages → views}/CoursesListPage.d.ts.map +1 -1
- package/dist/{pages → views}/FinancialPage.d.ts.map +1 -1
- package/dist/{pages → views}/ReportsPage.d.ts.map +1 -1
- package/dist/views/SalesPage.d.ts +19 -0
- package/dist/views/SalesPage.d.ts.map +1 -0
- package/dist/views/SubscriptionsPage.d.ts +7 -0
- package/dist/views/SubscriptionsPage.d.ts.map +1 -0
- package/package.json +6 -9
- package/dist/dashboardWidgets.d.ts.map +0 -1
- package/dist/nav.d.ts.map +0 -1
- package/dist/pages/CourseEditorPage.d.ts.map +0 -1
- package/dist/pages/SalesPage.d.ts +0 -6
- package/dist/pages/SalesPage.d.ts.map +0 -1
- package/dist/pages/SubscriptionsPage.d.ts +0 -6
- package/dist/pages/SubscriptionsPage.d.ts.map +0 -1
- package/dist/public/CheckoutScreen.d.ts.map +0 -1
- package/dist/public/data.d.ts.map +0 -1
- package/dist/public/data.supabase.d.ts.map +0 -1
- package/src/components/CommerceUI.tsx +0 -153
- package/src/connectors/stripe.ts +0 -34
- package/src/dashboardWidgets.tsx +0 -64
- package/src/index.ts +0 -150
- package/src/lib/format.ts +0 -22
- package/src/locales/en.ts +0 -203
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -203
- package/src/nav.ts +0 -9
- package/src/pages/CourseEditorPage.tsx +0 -1122
- package/src/pages/CoursesListPage.tsx +0 -137
- package/src/pages/FinancialPage.tsx +0 -81
- package/src/pages/ReportsPage.tsx +0 -59
- package/src/pages/SalesPage.tsx +0 -86
- package/src/pages/SubscriptionsPage.tsx +0 -56
- package/src/public/CheckoutScreen.tsx +0 -279
- package/src/public/createPublicCheckoutPlugin.tsx +0 -88
- package/src/public/data.supabase.ts +0 -63
- package/src/public/data.ts +0 -89
- package/src/public/index.ts +0 -26
- package/src/settings/CoursesSettings.tsx +0 -55
- package/src/views/CourseOrderDetailView.tsx +0 -132
- /package/dist/{dashboardWidgets.d.ts → components/dashboardWidgets.d.ts} +0 -0
- /package/dist/{nav.d.ts → lib/nav.d.ts} +0 -0
- /package/dist/{pages → views}/CourseEditorPage.d.ts +0 -0
- /package/dist/{pages → views}/CoursesListPage.d.ts +0 -0
- /package/dist/{pages → views}/FinancialPage.d.ts +0 -0
- /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
|
-
}
|
package/src/pages/SalesPage.tsx
DELETED
|
@@ -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'
|