@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.
@@ -1 +1 @@
1
- {"version":3,"file":"CoursesListPage.d.ts","sourceRoot":"","sources":["../../src/pages/CoursesListPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAY9B,wBAAgB,eAAe,sBA2F9B;yBA3Fe,eAAe"}
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;AAO9B,wBAAgB,aAAa,sBAmE5B;yBAnEe,aAAa"}
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
- "version": "0.2.4",
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
- "@fayz-ai/core": "^0.6.0",
29
- "@fayz-ai/ui": "^0.6.0",
30
- "@fayz-ai/saas": "^0.6.0",
31
- "@fayz-ai/courses": "^0.7.1"
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",
@@ -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
- export function createCoursesDashboardWidgets(): DashboardWidgetDef[] {
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
- defineKpiWidget({ id: 'courses.kpi.revenue', title: 'courses.kpi.revenue', domain: 'courses', defaultOrder: 0, component: RevenueKpi }),
47
- defineKpiWidget({ id: 'courses.kpi.sales', title: 'courses.kpi.sales', domain: 'courses', defaultOrder: 1, component: SalesKpi }),
48
- defineKpiWidget({ id: 'courses.kpi.mrr', title: 'courses.kpi.mrr', domain: 'courses', defaultOrder: 2, component: MrrKpi }),
49
- defineKpiWidget({ id: 'courses.kpi.fees', title: 'courses.kpi.fees', domain: 'courses', defaultOrder: 3, component: FeesKpi }),
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
- navigation: [
75
- { section, position: base, label: options?.navLabel ?? 'Courses', route: '/courses', icon: 'BookOpen', permission: read('courses') },
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
- dashboardWidgets: createCoursesDashboardWidgets(),
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
- <Button onClick={onCreate} disabled={creating}>
47
- {t('courses.new') || 'New course'}
48
- </Button>
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
- <Button onClick={onCreate} disabled={creating}>
61
- {t('courses.new') || 'New course'}
62
- </Button>
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)