@fayz-ai/plugin-courses 0.2.4 → 0.8.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 +1 -1
- package/dist/dashboardWidgets.d.ts +8 -1
- package/dist/dashboardWidgets.d.ts.map +1 -1
- package/dist/index.cjs +46 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -29
- package/dist/index.js.map +1 -1
- package/dist/pages/CoursesListPage.d.ts.map +1 -1
- package/dist/pages/FinancialPage.d.ts.map +1 -1
- package/package.json +9 -6
- package/src/dashboardWidgets.tsx +18 -5
- package/src/index.ts +58 -19
- package/src/pages/CoursesListPage.tsx +19 -6
- package/src/pages/FinancialPage.tsx +5 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CoursesListPage.d.ts","sourceRoot":"","sources":["../../src/pages/CoursesListPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"CoursesListPage.d.ts","sourceRoot":"","sources":["../../src/pages/CoursesListPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAa9B,wBAAgB,eAAe,sBAuG9B;yBAvGe,eAAe"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FinancialPage.d.ts","sourceRoot":"","sources":["../../src/pages/FinancialPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"FinancialPage.d.ts","sourceRoot":"","sources":["../../src/pages/FinancialPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAY9B,wBAAgB,aAAa,sBAmE5B;yBAnEe,aAAa"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fayz-ai/plugin-courses",
|
|
3
|
-
"
|
|
3
|
+
"fayz": {
|
|
4
|
+
"status": "preview"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.8.1",
|
|
4
7
|
"description": "[experimental] Fayz SDK — courses admin management plugin",
|
|
5
8
|
"type": "module",
|
|
6
9
|
"main": "./dist/index.cjs",
|
|
@@ -23,12 +26,12 @@
|
|
|
23
26
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
24
27
|
},
|
|
25
28
|
"dependencies": {
|
|
26
|
-
"@tanstack/react-table": "^8.20.0",
|
|
27
29
|
"lucide-react": "^0.400.0",
|
|
28
|
-
"@
|
|
29
|
-
"@fayz-ai/
|
|
30
|
-
"@fayz-ai/
|
|
31
|
-
"@fayz-ai/
|
|
30
|
+
"@tanstack/react-table": "^8.20.0",
|
|
31
|
+
"@fayz-ai/saas": "^0.8.2",
|
|
32
|
+
"@fayz-ai/core": "^0.8.2",
|
|
33
|
+
"@fayz-ai/ui": "^0.8.1",
|
|
34
|
+
"@fayz-ai/courses": "^0.8.0"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/react": "^18.3.0",
|
package/src/dashboardWidgets.tsx
CHANGED
|
@@ -41,11 +41,24 @@ function FeesKpi() {
|
|
|
41
41
|
return <KpiCard label={t('courses.kpi.fees') || 'Platform fees'} icon="Percent" loading={!s} value={s ? formatMoney(s.platformFeeTotal, s.currency) : undefined} />
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** Commerce KPIs are contributed only for the modules the host opted into —
|
|
45
|
+
* a host embedding courses as a lightweight members feature must not get
|
|
46
|
+
* revenue/MRR cards broadcast onto its home dashboard (FAY-1247 rule). */
|
|
47
|
+
export function createCoursesDashboardWidgets(
|
|
48
|
+
modules: { sales?: boolean; subscriptions?: boolean; financial?: boolean } = {},
|
|
49
|
+
): DashboardWidgetDef[] {
|
|
45
50
|
return [
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
...(modules.sales
|
|
52
|
+
? [
|
|
53
|
+
defineKpiWidget({ id: 'courses.kpi.revenue', title: 'courses.kpi.revenue', domain: 'courses', defaultOrder: 0, component: RevenueKpi }),
|
|
54
|
+
defineKpiWidget({ id: 'courses.kpi.sales', title: 'courses.kpi.sales', domain: 'courses', defaultOrder: 1, component: SalesKpi }),
|
|
55
|
+
]
|
|
56
|
+
: []),
|
|
57
|
+
...(modules.subscriptions
|
|
58
|
+
? [defineKpiWidget({ id: 'courses.kpi.mrr', title: 'courses.kpi.mrr', domain: 'courses', defaultOrder: 2, component: MrrKpi })]
|
|
59
|
+
: []),
|
|
60
|
+
...(modules.financial
|
|
61
|
+
? [defineKpiWidget({ id: 'courses.kpi.fees', title: 'courses.kpi.fees', domain: 'courses', defaultOrder: 3, component: FeesKpi })]
|
|
62
|
+
: []),
|
|
50
63
|
]
|
|
51
64
|
}
|
package/src/index.ts
CHANGED
|
@@ -33,8 +33,30 @@ export interface CoursesPluginOptions {
|
|
|
33
33
|
navPosition?: number
|
|
34
34
|
navSection?: 'main' | 'secondary' | 'settings'
|
|
35
35
|
navLabel?: string
|
|
36
|
+
/** Nav labels for the module entries (default English). */
|
|
37
|
+
labels?: {
|
|
38
|
+
membersArea?: string
|
|
39
|
+
sales?: string
|
|
40
|
+
subscriptions?: string
|
|
41
|
+
financial?: string
|
|
42
|
+
reports?: string
|
|
43
|
+
}
|
|
36
44
|
scope?: PluginScope
|
|
37
45
|
verticalId?: VerticalId
|
|
46
|
+
/**
|
|
47
|
+
* Commerce-vertical modules — ALL OFF by default (the one architectural
|
|
48
|
+
* rule: shared-surface contributions are opt-in, never broadcast — FAY-1247).
|
|
49
|
+
* course-admin (the vertical owner) enables everything via its PluginRef
|
|
50
|
+
* config; a host embedding courses as a lightweight members feature (e.g.
|
|
51
|
+
* agency-os's "Memberships") gets only the base nav entry + editor.
|
|
52
|
+
*/
|
|
53
|
+
modules?: {
|
|
54
|
+
membersArea?: boolean
|
|
55
|
+
sales?: boolean
|
|
56
|
+
subscriptions?: boolean
|
|
57
|
+
financial?: boolean
|
|
58
|
+
reports?: boolean
|
|
59
|
+
}
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
const asComp = (c: unknown) => c as React.ComponentType<unknown>
|
|
@@ -56,6 +78,31 @@ export function createCoursesPlugin(options?: CoursesPluginOptions): PluginManif
|
|
|
56
78
|
|
|
57
79
|
const base = options?.navPosition ?? 1
|
|
58
80
|
const section = options?.navSection ?? 'main'
|
|
81
|
+
const modules = options?.modules ?? {}
|
|
82
|
+
|
|
83
|
+
// Opt-in nav/routes per module. Position offsets stay stable relative to the
|
|
84
|
+
// enabled set so hosts control exactly where the block sits.
|
|
85
|
+
const labels = options?.labels ?? {}
|
|
86
|
+
const navigation = [
|
|
87
|
+
{ section, position: base, label: options?.navLabel ?? 'Courses', route: '/courses', icon: 'BookOpen', permission: read('courses') },
|
|
88
|
+
...(modules.membersArea ? [{ section, position: base + 1, label: labels.membersArea ?? 'Members area', route: '/courses/members', icon: 'GraduationCap', permission: read('courses') }] : []),
|
|
89
|
+
...(modules.sales ? [{ section, position: base + 2, label: labels.sales ?? 'Sales', route: '/courses/sales', icon: 'TrendingUp', permission: read('courses.sales') }] : []),
|
|
90
|
+
...(modules.subscriptions ? [{ section, position: base + 3, label: labels.subscriptions ?? 'Subscriptions', route: '/courses/subscriptions', icon: 'Repeat', permission: read('courses.sales') }] : []),
|
|
91
|
+
...(modules.financial ? [{ section, position: base + 4, label: labels.financial ?? 'Financial', route: '/courses/financial', icon: 'CreditCard', permission: read('courses.finance') }] : []),
|
|
92
|
+
...(modules.reports ? [{ section, position: base + 5, label: labels.reports ?? 'Reports', route: '/courses/reports', icon: 'BarChart3', permission: read('courses.sales') }] : []),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const routes = [
|
|
96
|
+
// Static segments outscore the `:id` param in the saas router, so these
|
|
97
|
+
// resolve before the editor route (AdminShell sorts by routeScore).
|
|
98
|
+
{ path: '/courses', component: asComp(CoursesListPage), permission: read('courses') },
|
|
99
|
+
...(modules.membersArea ? [{ path: '/courses/members', component: asComp(MembersAreaPage), permission: read('courses') }] : []),
|
|
100
|
+
...(modules.sales ? [{ path: '/courses/sales', component: asComp(SalesPage), permission: read('courses.sales') }] : []),
|
|
101
|
+
...(modules.subscriptions ? [{ path: '/courses/subscriptions', component: asComp(SubscriptionsPage), permission: read('courses.sales') }] : []),
|
|
102
|
+
...(modules.financial ? [{ path: '/courses/financial', component: asComp(FinancialPage), permission: read('courses.finance') }] : []),
|
|
103
|
+
...(modules.reports ? [{ path: '/courses/reports', component: asComp(ReportsPage), permission: read('courses.sales') }] : []),
|
|
104
|
+
{ path: '/courses/:id', component: asComp(CourseEditorPage) },
|
|
105
|
+
]
|
|
59
106
|
|
|
60
107
|
return {
|
|
61
108
|
id: 'courses',
|
|
@@ -64,6 +111,10 @@ export function createCoursesPlugin(options?: CoursesPluginOptions): PluginManif
|
|
|
64
111
|
version: '1.0.0',
|
|
65
112
|
scope: options?.scope ?? 'vertical',
|
|
66
113
|
verticalId: options?.verticalId,
|
|
114
|
+
// LMS is its own business model with opt-in commerce modules
|
|
115
|
+
// (members/sales/subscriptions — see modules.* below) — targets the
|
|
116
|
+
// saas/admin Panel world rather than the ecommerce storefront.
|
|
117
|
+
scaffolds: ['saas'],
|
|
67
118
|
defaultEnabled: true,
|
|
68
119
|
dependencies: [],
|
|
69
120
|
declaredFeatures: [
|
|
@@ -71,27 +122,15 @@ export function createCoursesPlugin(options?: CoursesPluginOptions): PluginManif
|
|
|
71
122
|
{ id: 'courses.sales', label: 'Sales', group: 'Courses' },
|
|
72
123
|
{ id: 'courses.finance', label: 'Financial', group: 'Courses' },
|
|
73
124
|
],
|
|
74
|
-
|
|
75
|
-
{
|
|
76
|
-
{ section, position: base + 1, label: 'Members area', route: '/courses/members', icon: 'GraduationCap', permission: read('courses') },
|
|
77
|
-
{ section, position: base + 2, label: 'Sales', route: '/courses/sales', icon: 'TrendingUp', permission: read('courses.sales') },
|
|
78
|
-
{ section, position: base + 3, label: 'Subscriptions', route: '/courses/subscriptions', icon: 'Repeat', permission: read('courses.sales') },
|
|
79
|
-
{ section, position: base + 4, label: 'Financial', route: '/courses/financial', icon: 'CreditCard', permission: read('courses.finance') },
|
|
80
|
-
{ section, position: base + 5, label: 'Reports', route: '/courses/reports', icon: 'BarChart3', permission: read('courses.sales') },
|
|
81
|
-
],
|
|
82
|
-
routes: [
|
|
83
|
-
// Static segments outscore the `:id` param in the saas router, so these
|
|
84
|
-
// resolve before the editor route (AdminShell sorts by routeScore).
|
|
85
|
-
{ path: '/courses', component: asComp(CoursesListPage), permission: read('courses') },
|
|
86
|
-
{ path: '/courses/members', component: asComp(MembersAreaPage), permission: read('courses') },
|
|
87
|
-
{ path: '/courses/sales', component: asComp(SalesPage), permission: read('courses.sales') },
|
|
88
|
-
{ path: '/courses/subscriptions', component: asComp(SubscriptionsPage), permission: read('courses.sales') },
|
|
89
|
-
{ path: '/courses/financial', component: asComp(FinancialPage), permission: read('courses.finance') },
|
|
90
|
-
{ path: '/courses/reports', component: asComp(ReportsPage), permission: read('courses.sales') },
|
|
91
|
-
{ path: '/courses/:id', component: asComp(CourseEditorPage) },
|
|
125
|
+
declaredLimits: [
|
|
126
|
+
{ key: 'courses', label: 'Courses', table: 'plg_courses_courses' },
|
|
92
127
|
],
|
|
128
|
+
navigation,
|
|
129
|
+
routes,
|
|
93
130
|
widgets: [],
|
|
94
|
-
|
|
131
|
+
// Commerce KPIs on the shared 'home' surface follow the same opt-in rule:
|
|
132
|
+
// no module enabled → no widgets broadcast into a host's dashboard.
|
|
133
|
+
dashboardWidgets: createCoursesDashboardWidgets(modules),
|
|
95
134
|
connectors: [createStripeConnector()],
|
|
96
135
|
settings: [
|
|
97
136
|
{ id: 'courses', label: 'Courses', icon: 'BookOpen', component: asComp(CoursesSettingsTab), order: 20, permission: read('courses') },
|
|
@@ -2,6 +2,7 @@ import * as React from 'react'
|
|
|
2
2
|
import { Button, Badge, PageHeaderActions, useModuleLayout } from '@fayz-ai/ui'
|
|
3
3
|
import { useTranslation } from '@fayz-ai/core'
|
|
4
4
|
import { getCoursesProvider, type Course } from '@fayz-ai/courses'
|
|
5
|
+
import { useLimitGuard, invalidateLimit, PermissionGate, usePermissionOptional } from '@fayz-ai/saas'
|
|
5
6
|
import { navigateTo } from '../nav'
|
|
6
7
|
|
|
7
8
|
function statusVariant(status: Course['status']): 'success' | 'secondary' | 'outline' {
|
|
@@ -17,6 +18,8 @@ export function CoursesListPage() {
|
|
|
17
18
|
const shellOwnsHeader = useModuleLayout() === 'tabs'
|
|
18
19
|
const [courses, setCourses] = React.useState<Course[] | null>(null)
|
|
19
20
|
const [creating, setCreating] = React.useState(false)
|
|
21
|
+
const guardCourses = useLimitGuard('courses')
|
|
22
|
+
const can = usePermissionOptional()
|
|
20
23
|
|
|
21
24
|
const load = React.useCallback(async () => {
|
|
22
25
|
setCourses(await getCoursesProvider().listCourses())
|
|
@@ -27,12 +30,18 @@ export function CoursesListPage() {
|
|
|
27
30
|
}, [load])
|
|
28
31
|
|
|
29
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
|
|
30
38
|
setCreating(true)
|
|
31
39
|
try {
|
|
32
40
|
const course = await getCoursesProvider().createCourse({
|
|
33
41
|
title: t('courses.editor.untitledCourse') || 'Untitled course',
|
|
34
42
|
status: 'draft',
|
|
35
43
|
})
|
|
44
|
+
invalidateLimit('courses')
|
|
36
45
|
navigateTo(`/courses/${course.id}`)
|
|
37
46
|
} finally {
|
|
38
47
|
setCreating(false)
|
|
@@ -43,9 +52,11 @@ export function CoursesListPage() {
|
|
|
43
52
|
<div className="mx-auto max-w-6xl p-6 md:p-8">
|
|
44
53
|
{shellOwnsHeader ? (
|
|
45
54
|
<PageHeaderActions>
|
|
46
|
-
<
|
|
47
|
-
{
|
|
48
|
-
|
|
55
|
+
<PermissionGate feature="courses" action="create">
|
|
56
|
+
<Button onClick={onCreate} disabled={creating}>
|
|
57
|
+
{t('courses.new') || 'New course'}
|
|
58
|
+
</Button>
|
|
59
|
+
</PermissionGate>
|
|
49
60
|
</PageHeaderActions>
|
|
50
61
|
) : (
|
|
51
62
|
<div className="mb-6 flex items-center justify-between">
|
|
@@ -57,9 +68,11 @@ export function CoursesListPage() {
|
|
|
57
68
|
</p>
|
|
58
69
|
)}
|
|
59
70
|
</div>
|
|
60
|
-
<
|
|
61
|
-
{
|
|
62
|
-
|
|
71
|
+
<PermissionGate feature="courses" action="create">
|
|
72
|
+
<Button onClick={onCreate} disabled={creating}>
|
|
73
|
+
{t('courses.new') || 'New course'}
|
|
74
|
+
</Button>
|
|
75
|
+
</PermissionGate>
|
|
63
76
|
</div>
|
|
64
77
|
)}
|
|
65
78
|
|
|
@@ -5,6 +5,11 @@ import { getCoursesProvider, type Payout, type FinancialSummary, type CreatorAcc
|
|
|
5
5
|
import { PageContainer, StatCard, StatGrid, SimpleTable, type SimpleColumn } from '../components/CommerceUI'
|
|
6
6
|
import { formatMoney, formatDate, formatPercent } from '../lib/format'
|
|
7
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.
|
|
8
13
|
export function FinancialPage() {
|
|
9
14
|
const t = useTranslation()
|
|
10
15
|
const [summary, setSummary] = React.useState<FinancialSummary | null>(null)
|