@fayz-ai/plugin-courses 0.2.2 → 0.2.4

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 (44) hide show
  1. package/README.md +2 -0
  2. package/dist/components/CommerceUI.d.ts +39 -0
  3. package/dist/components/CommerceUI.d.ts.map +1 -0
  4. package/dist/connectors/stripe.d.ts +3 -0
  5. package/dist/connectors/stripe.d.ts.map +1 -0
  6. package/dist/dashboardWidgets.d.ts +3 -0
  7. package/dist/dashboardWidgets.d.ts.map +1 -0
  8. package/dist/index.cjs +735 -54
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +738 -57
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/format.d.ts +4 -0
  14. package/dist/lib/format.d.ts.map +1 -0
  15. package/dist/locales/en.d.ts.map +1 -1
  16. package/dist/locales/pt-BR.d.ts.map +1 -1
  17. package/dist/pages/CourseEditorPage.d.ts.map +1 -1
  18. package/dist/pages/FinancialPage.d.ts +6 -0
  19. package/dist/pages/FinancialPage.d.ts.map +1 -0
  20. package/dist/pages/MembersAreaPage.d.ts +6 -0
  21. package/dist/pages/MembersAreaPage.d.ts.map +1 -0
  22. package/dist/pages/ReportsPage.d.ts +6 -0
  23. package/dist/pages/ReportsPage.d.ts.map +1 -0
  24. package/dist/pages/SalesPage.d.ts +6 -0
  25. package/dist/pages/SalesPage.d.ts.map +1 -0
  26. package/dist/pages/SubscriptionsPage.d.ts +6 -0
  27. package/dist/pages/SubscriptionsPage.d.ts.map +1 -0
  28. package/dist/settings/CoursesSettings.d.ts +6 -0
  29. package/dist/settings/CoursesSettings.d.ts.map +1 -0
  30. package/package.json +6 -4
  31. package/src/components/CommerceUI.tsx +77 -0
  32. package/src/connectors/stripe.ts +34 -0
  33. package/src/dashboardWidgets.tsx +51 -0
  34. package/src/index.ts +51 -12
  35. package/src/lib/format.ts +22 -0
  36. package/src/locales/en.ts +109 -0
  37. package/src/locales/pt-BR.ts +109 -0
  38. package/src/pages/CourseEditorPage.tsx +133 -35
  39. package/src/pages/FinancialPage.tsx +76 -0
  40. package/src/pages/MembersAreaPage.tsx +60 -0
  41. package/src/pages/ReportsPage.tsx +59 -0
  42. package/src/pages/SalesPage.tsx +57 -0
  43. package/src/pages/SubscriptionsPage.tsx +56 -0
  44. package/src/settings/CoursesSettings.tsx +55 -0
@@ -1,20 +1,26 @@
1
1
  import * as React from 'react'
2
- import { Button, Input, toast } from '@fayz-ai/ui'
2
+ import { Button, Input, Badge, toast } from '@fayz-ai/ui'
3
3
  import { useTranslation } from '@fayz-ai/core'
4
4
  import {
5
5
  getCoursesProvider,
6
6
  type Course, type Module, type Lesson, type CourseStatus,
7
+ type Offer, type OfferKind,
7
8
  } from '@fayz-ai/courses'
8
9
  import { navigateTo } from '../nav'
10
+ import { formatMoney } from '../lib/format'
9
11
 
10
12
  const STATUSES: CourseStatus[] = ['draft', 'published', 'archived']
11
13
 
14
+ const TABS = ['details', 'curriculum', 'offers', 'members', 'settings', 'checkout', 'links'] as const
15
+ type Tab = (typeof TABS)[number]
16
+
12
17
  export function CourseEditorPage({ id }: { id?: string }) {
13
18
  const t = useTranslation()
14
- const [tab, setTab] = React.useState<'details' | 'curriculum'>('details')
19
+ const [tab, setTab] = React.useState<Tab>('details')
15
20
  const [course, setCourse] = React.useState<Course | null>(null)
16
21
  const [modules, setModules] = React.useState<Module[]>([])
17
22
  const [lessons, setLessons] = React.useState<Lesson[]>([])
23
+ const [offers, setOffers] = React.useState<Offer[]>([])
18
24
 
19
25
  const reload = React.useCallback(async () => {
20
26
  if (!id) return
@@ -22,9 +28,14 @@ export function CourseEditorPage({ id }: { id?: string }) {
22
28
  const c = await provider.getCourse(id)
23
29
  setCourse(c)
24
30
  if (c) {
25
- const [m, l] = await Promise.all([provider.listModules(c.id), provider.listLessons(c.id)])
31
+ const [m, l, o] = await Promise.all([
32
+ provider.listModules(c.id),
33
+ provider.listLessons(c.id),
34
+ provider.listOffers(c.id),
35
+ ])
26
36
  setModules(m)
27
37
  setLessons(l)
38
+ setOffers(o)
28
39
  }
29
40
  }, [id])
30
41
 
@@ -35,8 +46,7 @@ export function CourseEditorPage({ id }: { id?: string }) {
35
46
  }
36
47
 
37
48
  async function saveDetails() {
38
- const provider = getCoursesProvider()
39
- await provider.updateCourse(course!.id, {
49
+ await getCoursesProvider().updateCourse(course!.id, {
40
50
  title: course!.title,
41
51
  subtitle: course!.subtitle ?? undefined,
42
52
  description: course!.description ?? undefined,
@@ -52,10 +62,7 @@ export function CourseEditorPage({ id }: { id?: string }) {
52
62
 
53
63
  // --- module / lesson mutations (persist immediately, then reload) ---------
54
64
  async function addModule() {
55
- await getCoursesProvider().createModule({
56
- courseId: course!.id,
57
- title: t('courses.editor.untitledModule') || 'New module',
58
- })
65
+ await getCoursesProvider().createModule({ courseId: course!.id, title: t('courses.editor.untitledModule') || 'New module' })
59
66
  await reload()
60
67
  }
61
68
  async function renameModule(moduleId: string, title: string) {
@@ -87,6 +94,32 @@ export function CourseEditorPage({ id }: { id?: string }) {
87
94
  await reload()
88
95
  }
89
96
 
97
+ // --- offer mutations ------------------------------------------------------
98
+ async function addOffer() {
99
+ await getCoursesProvider().createOffer({
100
+ courseId: course!.id,
101
+ name: t('courses.offers.newName') || 'New offer',
102
+ price: course!.price,
103
+ kind: 'one_time',
104
+ })
105
+ await reload()
106
+ }
107
+ async function patchOffer(offerId: string, patch: Partial<Offer>) {
108
+ await getCoursesProvider().updateOffer(offerId, {
109
+ name: patch.name,
110
+ price: patch.price,
111
+ kind: patch.kind,
112
+ recurringInterval: patch.recurringInterval,
113
+ isOrderBump: patch.isOrderBump,
114
+ isDefault: patch.isDefault,
115
+ })
116
+ await reload()
117
+ }
118
+ async function deleteOffer(offerId: string) {
119
+ await getCoursesProvider().deleteOffer(offerId)
120
+ await reload()
121
+ }
122
+
90
123
  return (
91
124
  <div className="mx-auto max-w-4xl p-6 md:p-8">
92
125
  <button onClick={() => navigateTo('/courses')} className="mb-4 text-sm text-muted-foreground hover:text-foreground">
@@ -95,16 +128,16 @@ export function CourseEditorPage({ id }: { id?: string }) {
95
128
 
96
129
  <h1 className="mb-1 text-2xl font-bold text-foreground">{course.title}</h1>
97
130
 
98
- <div className="mb-6 mt-4 flex gap-2 border-b border-border">
99
- {(['details', 'curriculum'] as const).map((tk) => (
131
+ <div className="mb-6 mt-4 flex gap-1 overflow-x-auto border-b border-border">
132
+ {TABS.map((tk) => (
100
133
  <button
101
134
  key={tk}
102
135
  onClick={() => setTab(tk)}
103
- className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
104
- tab === tk ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground'
136
+ className={`-mb-px whitespace-nowrap border-b-2 px-3 py-2 text-sm font-medium ${
137
+ tab === tk ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
105
138
  }`}
106
139
  >
107
- {tk === 'details' ? (t('courses.editor.details') || 'Details') : (t('courses.editor.curriculum') || 'Curriculum')}
140
+ {t(`courses.editor.tab.${tk}`) || tk}
108
141
  </button>
109
142
  ))}
110
143
  </div>
@@ -126,11 +159,7 @@ export function CourseEditorPage({ id }: { id?: string }) {
126
159
  </Field>
127
160
  <div className="flex gap-4">
128
161
  <Field label={t('courses.editor.fieldPrice') || 'Price'}>
129
- <Input
130
- type="number"
131
- value={String(course.price)}
132
- onChange={(e) => patchCourse({ price: Number(e.target.value) || 0 })}
133
- />
162
+ <Input type="number" value={String(course.price)} onChange={(e) => patchCourse({ price: Number(e.target.value) || 0 })} />
134
163
  </Field>
135
164
  <Field label={t('courses.editor.fieldStatus') || 'Status'}>
136
165
  <select
@@ -166,22 +195,9 @@ export function CourseEditorPage({ id }: { id?: string }) {
166
195
  <div className="space-y-2">
167
196
  {lessons.filter((l) => l.moduleId === mod.id).map((lesson) => (
168
197
  <div key={lesson.id} className="grid grid-cols-1 gap-2 rounded-md border border-border p-3 md:grid-cols-[1fr_1fr_90px_auto]">
169
- <Input
170
- defaultValue={lesson.title}
171
- placeholder={t('courses.editor.lessonTitle') || 'Lesson title'}
172
- onBlur={(e) => patchLesson(lesson.id, { title: e.target.value })}
173
- />
174
- <Input
175
- defaultValue={lesson.videoUrl}
176
- placeholder={t('courses.editor.lessonVideo') || 'Video URL (embed)'}
177
- onBlur={(e) => patchLesson(lesson.id, { videoUrl: e.target.value })}
178
- />
179
- <Input
180
- type="number"
181
- defaultValue={String(Math.round(lesson.durationSec / 60))}
182
- onBlur={(e) => patchLesson(lesson.id, { durationSec: (Number(e.target.value) || 0) * 60 })}
183
- aria-label={t('courses.editor.lessonDuration') || 'Duration (min)'}
184
- />
198
+ <Input defaultValue={lesson.title} placeholder={t('courses.editor.lessonTitle') || 'Lesson title'} onBlur={(e) => patchLesson(lesson.id, { title: e.target.value })} />
199
+ <Input defaultValue={lesson.videoUrl} placeholder={t('courses.editor.lessonVideo') || 'Video URL (embed)'} onBlur={(e) => patchLesson(lesson.id, { videoUrl: e.target.value })} />
200
+ <Input type="number" defaultValue={String(Math.round(lesson.durationSec / 60))} onBlur={(e) => patchLesson(lesson.id, { durationSec: (Number(e.target.value) || 0) * 60 })} aria-label={t('courses.editor.lessonDuration') || 'Duration (min)'} />
185
201
  <Button variant="ghost" size="sm" onClick={() => deleteLesson(lesson.id)}>
186
202
  {t('courses.editor.deleteLesson') || 'Remove'}
187
203
  </Button>
@@ -196,6 +212,88 @@ export function CourseEditorPage({ id }: { id?: string }) {
196
212
  <Button onClick={addModule}>{t('courses.editor.addModule') || 'Add module'}</Button>
197
213
  </div>
198
214
  )}
215
+
216
+ {tab === 'offers' && (
217
+ <div className="space-y-3">
218
+ <p className="text-sm text-muted-foreground">
219
+ {t('courses.offers.help') || 'Price points a customer can buy. Mark one default; order-bumps show on other checkouts.'}
220
+ </p>
221
+ {offers.map((offer) => (
222
+ <div key={offer.id} className="grid grid-cols-1 gap-2 rounded-xl border border-border bg-card p-3 md:grid-cols-[1fr_120px_130px_auto]">
223
+ <Input defaultValue={offer.name} onBlur={(e) => patchOffer(offer.id, { name: e.target.value })} aria-label={t('courses.offers.name') || 'Offer name'} />
224
+ <Input type="number" defaultValue={String(offer.price)} onBlur={(e) => patchOffer(offer.id, { price: Number(e.target.value) || 0 })} aria-label={t('courses.offers.price') || 'Price'} />
225
+ <select
226
+ className="h-10 rounded-md border border-input bg-background px-2 text-sm"
227
+ value={offer.kind}
228
+ onChange={(e) => patchOffer(offer.id, { kind: e.target.value as OfferKind, recurringInterval: e.target.value === 'subscription' ? (offer.recurringInterval ?? 'month') : null })}
229
+ >
230
+ <option value="one_time">{t('courses.offers.oneTime') || 'One-time'}</option>
231
+ <option value="subscription">{t('courses.offers.subscription') || 'Subscription'}</option>
232
+ </select>
233
+ <div className="flex items-center gap-2">
234
+ {offer.isDefault && <Badge variant="secondary">{t('courses.offers.default') || 'Default'}</Badge>}
235
+ {offer.isOrderBump && <Badge variant="outline">{t('courses.offers.bump') || 'Bump'}</Badge>}
236
+ <Button variant="ghost" size="sm" onClick={() => deleteOffer(offer.id)}>{t('courses.offers.remove') || 'Remove'}</Button>
237
+ </div>
238
+ </div>
239
+ ))}
240
+ <Button onClick={addOffer}>+ {t('courses.offers.add') || 'Add offer'}</Button>
241
+ </div>
242
+ )}
243
+
244
+ {tab === 'members' && (
245
+ <div className="space-y-3 text-sm">
246
+ <p className="text-muted-foreground">
247
+ {t('courses.editor.membersHelp') || 'Buyers of this course are added to its members area automatically. Access groups per offer:'}
248
+ </p>
249
+ <div className="rounded-xl border border-border bg-card divide-y divide-border">
250
+ {offers.map((offer) => (
251
+ <div key={offer.id} className="flex items-center justify-between px-4 py-3">
252
+ <span className="font-medium text-foreground">{offer.name}</span>
253
+ <span className="text-muted-foreground">{formatMoney(offer.price, offer.currency)}</span>
254
+ <Badge variant="secondary">{t('courses.editor.defaultGroup') || 'Default group'}</Badge>
255
+ </div>
256
+ ))}
257
+ </div>
258
+ </div>
259
+ )}
260
+
261
+ {tab === 'settings' && (
262
+ <div className="max-w-xl space-y-4 text-sm">
263
+ <p className="text-muted-foreground">{t('courses.editor.settingsHelp') || 'Payment methods offered at checkout (Stripe).'}</p>
264
+ {[
265
+ ['card', t('courses.editor.payCard') || 'Credit card (up to 12x)'],
266
+ ['pix', t('courses.editor.payPix') || 'Pix'],
267
+ ['boleto', t('courses.editor.payBoleto') || 'Boleto'],
268
+ ].map(([k, label]) => (
269
+ <label key={k} className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
270
+ <input type="checkbox" defaultChecked className="h-4 w-4" />
271
+ <span className="text-foreground">{label}</span>
272
+ </label>
273
+ ))}
274
+ <p className="text-xs text-muted-foreground">{t('courses.editor.settingsNote') || 'Method availability depends on Stripe in your region; wired in Stage 3.'}</p>
275
+ </div>
276
+ )}
277
+
278
+ {tab === 'checkout' && (
279
+ <div className="rounded-xl border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
280
+ {t('courses.editor.checkoutSoon') || 'Custom checkout builder comes in a later milestone. A default checkout is generated per offer.'}
281
+ </div>
282
+ )}
283
+
284
+ {tab === 'links' && (
285
+ <div className="space-y-2">
286
+ <p className="text-sm text-muted-foreground">{t('courses.editor.linksHelp') || 'Shareable checkout links, one per offer.'}</p>
287
+ <div className="rounded-xl border border-border bg-card divide-y divide-border">
288
+ {offers.map((offer) => (
289
+ <div key={offer.id} className="flex flex-col gap-1 px-4 py-3 md:flex-row md:items-center md:justify-between">
290
+ <span className="font-medium text-foreground">{offer.name}</span>
291
+ <code className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground">/checkout/{course.slug}?offer={offer.id}</code>
292
+ </div>
293
+ ))}
294
+ </div>
295
+ </div>
296
+ )}
199
297
  </div>
200
298
  )
201
299
  }
@@ -0,0 +1,76 @@
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
+ export function FinancialPage() {
9
+ const t = useTranslation()
10
+ const [summary, setSummary] = React.useState<FinancialSummary | null>(null)
11
+ const [payouts, setPayouts] = React.useState<Payout[] | null>(null)
12
+ const [account, setAccount] = React.useState<CreatorAccount | null>(null)
13
+
14
+ React.useEffect(() => {
15
+ const p = getCoursesProvider()
16
+ void p.getFinancialSummary().then(setSummary)
17
+ void p.listPayouts().then(setPayouts)
18
+ void p.getCreatorAccount().then(setAccount)
19
+ }, [])
20
+
21
+ const currency = summary?.currency ?? 'BRL'
22
+ const connected = !!account?.stripeAccountId && account.chargesEnabled
23
+
24
+ function connectStripe() {
25
+ // Wired to the real Stripe Connect onboarding edge function in Stage 3.
26
+ toast.info(t('courses.financial.connectSoon') || 'Stripe Connect onboarding wires up in Stage 3.')
27
+ }
28
+
29
+ const columns: SimpleColumn<Payout>[] = [
30
+ { key: 'date', header: t('courses.financial.date') || 'Date', render: (p) => <span className="text-muted-foreground">{formatDate(p.createdAt)}</span> },
31
+ { key: 'amount', header: t('courses.financial.amount') || 'Amount', align: 'right', render: (p) => <span className="font-medium text-foreground">{formatMoney(p.amount, p.currency)}</span> },
32
+ { key: 'status', header: t('courses.financial.status') || 'Status', align: 'right', render: (p) => (
33
+ <Badge variant={p.status === 'paid' ? 'success' : p.status === 'pending' ? 'secondary' : 'destructive'}>
34
+ {t(`courses.payoutStatus.${p.status}`) || p.status}
35
+ </Badge>
36
+ ) },
37
+ ]
38
+
39
+ return (
40
+ <PageContainer
41
+ title={t('courses.financial.title') || 'Financial settings'}
42
+ subtitle={t('courses.financial.subtitle') || 'Balance, payouts and your Stripe connection.'}
43
+ actions={
44
+ <Button variant={connected ? 'outline' : 'default'} onClick={connectStripe}>
45
+ {connected ? (t('courses.financial.connected') || 'Stripe connected') : (t('courses.financial.connect') || 'Connect Stripe')}
46
+ </Button>
47
+ }
48
+ >
49
+ {!connected && (
50
+ <div className="mb-6 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-foreground">
51
+ {t('courses.financial.notConnected') || 'Connect your Stripe account to receive payouts. The platform fee is applied automatically on each sale.'}
52
+ {account && (
53
+ <span className="ml-1 text-muted-foreground">
54
+ ({t('courses.financial.platformFee') || 'Platform fee'}: {(account.platformFeeBps / 100).toFixed(2)}%)
55
+ </span>
56
+ )}
57
+ </div>
58
+ )}
59
+
60
+ <StatGrid>
61
+ <StatCard label={t('courses.financial.available') || 'Available balance'} value={formatMoney(summary?.availableBalance ?? 0, currency)} />
62
+ <StatCard label={t('courses.financial.pending') || 'Pending balance'} value={formatMoney(summary?.pendingBalance ?? 0, currency)} />
63
+ <StatCard label={t('courses.financial.revenue') || 'Total revenue'} value={formatMoney(summary?.totalRevenue ?? 0, currency)} />
64
+ <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} />
65
+ </StatGrid>
66
+
67
+ <h2 className="mb-3 text-sm font-semibold text-foreground">{t('courses.financial.payouts') || 'Payouts'}</h2>
68
+ <SimpleTable
69
+ columns={columns}
70
+ rows={payouts ?? []}
71
+ empty={t('courses.financial.noPayouts') || 'No payouts yet.'}
72
+ />
73
+ </PageContainer>
74
+ )
75
+ }
76
+ FinancialPage.displayName = 'FinancialPage'
@@ -0,0 +1,60 @@
1
+ import * as React from 'react'
2
+ import { Badge } from '@fayz-ai/ui'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import { getCoursesProvider, type Order, type Course } from '@fayz-ai/courses'
5
+ import { PageContainer, SimpleTable, type SimpleColumn } from '../components/CommerceUI'
6
+
7
+ interface Row {
8
+ courseId: string
9
+ title: string
10
+ status: Course['status']
11
+ students: number
12
+ }
13
+
14
+ export function MembersAreaPage() {
15
+ const t = useTranslation()
16
+ const [rows, setRows] = React.useState<Row[] | null>(null)
17
+
18
+ React.useEffect(() => {
19
+ const p = getCoursesProvider()
20
+ void Promise.all([p.listCourses(), p.listOrders({ financialStatus: 'paid' })]).then(([courses, orders]) => {
21
+ setRows(buildMembersAreas(courses, orders))
22
+ })
23
+ }, [])
24
+
25
+ const columns: SimpleColumn<Row>[] = [
26
+ { key: 'title', header: t('courses.membersArea.name') || 'Members area', render: (r) => <span className="font-medium text-foreground">{r.title}</span> },
27
+ { key: 'status', header: t('courses.membersArea.status') || 'Status', render: (r) => (
28
+ <Badge variant={r.status === 'published' ? 'success' : r.status === 'archived' ? 'outline' : 'secondary'}>
29
+ {t(`courses.status.${r.status}`) || r.status}
30
+ </Badge>
31
+ ) },
32
+ { key: 'students', header: t('courses.membersArea.students') || 'Students', align: 'right', render: (r) => <span className="text-foreground">{r.students}</span> },
33
+ ]
34
+
35
+ return (
36
+ <PageContainer title={t('courses.membersArea.title') || 'Members area'} subtitle={t('courses.membersArea.subtitle') || 'One area per course; students land here after purchase.'}>
37
+ <SimpleTable
38
+ columns={columns}
39
+ rows={rows ?? []}
40
+ empty={t('courses.membersArea.empty') || 'No members areas yet.'}
41
+ />
42
+ </PageContainer>
43
+ )
44
+ }
45
+ MembersAreaPage.displayName = 'MembersAreaPage'
46
+
47
+ function buildMembersAreas(courses: Course[], orders: Order[]): Row[] {
48
+ const students = new Map<string, Set<string>>()
49
+ for (const o of orders) {
50
+ const set = students.get(o.courseId) ?? new Set<string>()
51
+ if (o.customerId) set.add(o.customerId)
52
+ students.set(o.courseId, set)
53
+ }
54
+ return courses.map((c) => ({
55
+ courseId: c.id,
56
+ title: c.title,
57
+ status: c.status,
58
+ students: students.get(c.id)?.size ?? 0,
59
+ }))
60
+ }
@@ -0,0 +1,59 @@
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
+ }
@@ -0,0 +1,57 @@
1
+ import * as React from 'react'
2
+ import { Badge } from '@fayz-ai/ui'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import { getCoursesProvider, type Order, type FinancialStatus } 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: FinancialStatus): 'success' | 'secondary' | 'destructive' | 'outline' {
9
+ if (s === 'paid') return 'success'
10
+ if (s === 'pending') return 'secondary'
11
+ if (s === 'refunded' || s === 'chargeback') return 'destructive'
12
+ return 'outline'
13
+ }
14
+
15
+ export function SalesPage() {
16
+ const t = useTranslation()
17
+ const [orders, setOrders] = React.useState<Order[] | null>(null)
18
+
19
+ React.useEffect(() => {
20
+ void getCoursesProvider().listOrders().then(setOrders)
21
+ }, [])
22
+
23
+ const paid = (orders ?? []).filter((o) => o.financialStatus === 'paid')
24
+ const revenue = paid.reduce((s, o) => s + o.total, 0)
25
+ const currency = orders?.[0]?.currency ?? 'BRL'
26
+
27
+ const columns: SimpleColumn<Order>[] = [
28
+ { key: 'date', header: t('courses.sales.date') || 'Date', render: (o) => <span className="text-muted-foreground">{formatDate(o.createdAt)}</span> },
29
+ { key: 'customer', header: t('courses.sales.customer') || 'Customer', render: (o) => (
30
+ <div>
31
+ <div className="font-medium text-foreground">{o.customerName ?? '—'}</div>
32
+ <div className="text-xs text-muted-foreground">{o.customerEmail}</div>
33
+ </div>
34
+ ) },
35
+ { key: 'method', header: t('courses.sales.method') || 'Method', render: (o) => <span className="capitalize">{o.paymentMethod ?? '—'}</span> },
36
+ { key: 'status', header: t('courses.sales.status') || 'Status', render: (o) => (
37
+ <Badge variant={statusVariant(o.financialStatus)}>{t(`courses.financialStatus.${o.financialStatus}`) || o.financialStatus}</Badge>
38
+ ) },
39
+ { key: 'fee', header: t('courses.sales.fee') || 'Platform fee', align: 'right', render: (o) => <span className="text-muted-foreground">{formatMoney(o.platformFee, o.currency)}</span> },
40
+ { key: 'total', header: t('courses.sales.amount') || 'Amount', align: 'right', render: (o) => <span className="font-medium text-foreground">{formatMoney(o.total, o.currency)}</span> },
41
+ ]
42
+
43
+ return (
44
+ <PageContainer title={t('courses.sales.title') || 'Sales'} subtitle={t('courses.sales.subtitle') || 'Every transaction across your courses.'}>
45
+ <StatGrid>
46
+ <StatCard label={t('courses.sales.count') || 'Sales'} value={String(paid.length)} />
47
+ <StatCard label={t('courses.sales.revenue') || 'Revenue'} value={formatMoney(revenue, currency)} />
48
+ </StatGrid>
49
+ <SimpleTable
50
+ columns={columns}
51
+ rows={orders ?? []}
52
+ empty={t('courses.sales.empty') || 'No sales yet.'}
53
+ />
54
+ </PageContainer>
55
+ )
56
+ }
57
+ SalesPage.displayName = 'SalesPage'
@@ -0,0 +1,56 @@
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'
@@ -0,0 +1,55 @@
1
+ import * as React from 'react'
2
+ import { useTranslation } from '@fayz-ai/core'
3
+ import { PluginSettingsPanel, SettingsGroup, SelectRow, ToggleRow } from '@fayz-ai/saas'
4
+
5
+ // The course plugin's Settings tab. Reuses the saas settings shell
6
+ // (PluginSettingsPanel + SettingsGroup/SelectRow/ToggleRow) instead of
7
+ // hand-rolling a settings layout — same chrome as every other plugin. Stripe
8
+ // Connect lives in the "Integrations" tab (a connector), not here.
9
+
10
+ function CoursesGeneralSettings() {
11
+ const t = useTranslation()
12
+ const [currency, setCurrency] = React.useState('BRL')
13
+ const [methods, setMethods] = React.useState({ card: true, pix: true, boleto: true })
14
+
15
+ return (
16
+ <div className="space-y-6">
17
+ <SettingsGroup
18
+ title={t('courses.settings.payments') || 'Payments'}
19
+ description={t('courses.settings.paymentsHelp') || 'Connect Stripe in the Integrations tab; the platform fee applies automatically.'}
20
+ >
21
+ <SelectRow
22
+ label={t('courses.settings.currency') || 'Default currency'}
23
+ value={currency}
24
+ onChange={setCurrency}
25
+ options={[
26
+ { value: 'BRL', label: 'BRL (R$)' },
27
+ { value: 'USD', label: 'USD ($)' },
28
+ { value: 'EUR', label: 'EUR (€)' },
29
+ ]}
30
+ />
31
+ </SettingsGroup>
32
+
33
+ <SettingsGroup title={t('courses.settings.methods') || 'Payment methods'}>
34
+ <ToggleRow label={t('courses.editor.payCard') || 'Credit card'} checked={methods.card} onChange={(v) => setMethods((m) => ({ ...m, card: v }))} />
35
+ <ToggleRow label={t('courses.editor.payPix') || 'Pix'} checked={methods.pix} onChange={(v) => setMethods((m) => ({ ...m, pix: v }))} />
36
+ <ToggleRow label={t('courses.editor.payBoleto') || 'Boleto'} checked={methods.boleto} onChange={(v) => setMethods((m) => ({ ...m, boleto: v }))} />
37
+ </SettingsGroup>
38
+ </div>
39
+ )
40
+ }
41
+ CoursesGeneralSettings.displayName = 'CoursesGeneralSettings'
42
+
43
+ export function CoursesSettingsTab() {
44
+ const t = useTranslation()
45
+ return (
46
+ <PluginSettingsPanel
47
+ title={t('courses.settings.title') || 'Course settings'}
48
+ subtitle={t('courses.settings.subtitle') || 'Payments, currency and checkout methods'}
49
+ generalSettings={<CoursesGeneralSettings />}
50
+ hostPluginId="courses"
51
+ routeBase="/settings/courses"
52
+ />
53
+ )
54
+ }
55
+ CoursesSettingsTab.displayName = 'CoursesSettingsTab'