@fayz-ai/plugin-courses 0.2.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.
@@ -0,0 +1,33 @@
1
+ export const en: Record<string, string> = {
2
+ 'courses.nav': 'Courses',
3
+ 'courses.title': 'Courses',
4
+ 'courses.new': 'New course',
5
+ 'courses.empty': 'No courses yet. Create your first one.',
6
+ 'courses.count': '{count} courses',
7
+ 'courses.students': 'students',
8
+ 'courses.lessons': 'lessons',
9
+ 'courses.status.draft': 'Draft',
10
+ 'courses.status.published': 'Published',
11
+ 'courses.status.archived': 'Archived',
12
+ 'courses.editor.back': 'Back to courses',
13
+ 'courses.editor.details': 'Details',
14
+ 'courses.editor.curriculum': 'Curriculum',
15
+ 'courses.editor.fieldTitle': 'Title',
16
+ 'courses.editor.fieldSubtitle': 'Subtitle',
17
+ 'courses.editor.fieldDescription': 'Description',
18
+ 'courses.editor.fieldPrice': 'Price',
19
+ 'courses.editor.fieldStatus': 'Status',
20
+ 'courses.editor.save': 'Save',
21
+ 'courses.editor.saved': 'Course saved',
22
+ 'courses.editor.addModule': 'Add module',
23
+ 'courses.editor.moduleTitle': 'Module title',
24
+ 'courses.editor.addLesson': 'Add lesson',
25
+ 'courses.editor.lessonTitle': 'Lesson title',
26
+ 'courses.editor.lessonVideo': 'Video URL (embed)',
27
+ 'courses.editor.lessonDuration': 'Duration (min)',
28
+ 'courses.editor.deleteModule': 'Delete module',
29
+ 'courses.editor.deleteLesson': 'Remove',
30
+ 'courses.editor.untitledCourse': 'Untitled course',
31
+ 'courses.editor.untitledModule': 'New module',
32
+ 'courses.editor.untitledLesson': 'New lesson',
33
+ }
@@ -0,0 +1,7 @@
1
+ import { en } from './en'
2
+ import { ptBR } from './pt-BR'
3
+
4
+ export const coursesLocales: Record<string, Record<string, string>> = {
5
+ en,
6
+ 'pt-BR': ptBR,
7
+ }
@@ -0,0 +1,33 @@
1
+ export const ptBR: Record<string, string> = {
2
+ 'courses.nav': 'Cursos',
3
+ 'courses.title': 'Cursos',
4
+ 'courses.new': 'Novo curso',
5
+ 'courses.empty': 'Nenhum curso ainda. Crie o primeiro.',
6
+ 'courses.count': '{count} cursos',
7
+ 'courses.students': 'alunos',
8
+ 'courses.lessons': 'aulas',
9
+ 'courses.status.draft': 'Rascunho',
10
+ 'courses.status.published': 'Publicado',
11
+ 'courses.status.archived': 'Arquivado',
12
+ 'courses.editor.back': 'Voltar aos cursos',
13
+ 'courses.editor.details': 'Detalhes',
14
+ 'courses.editor.curriculum': 'Conteúdo',
15
+ 'courses.editor.fieldTitle': 'Título',
16
+ 'courses.editor.fieldSubtitle': 'Subtítulo',
17
+ 'courses.editor.fieldDescription': 'Descrição',
18
+ 'courses.editor.fieldPrice': 'Preço',
19
+ 'courses.editor.fieldStatus': 'Status',
20
+ 'courses.editor.save': 'Salvar',
21
+ 'courses.editor.saved': 'Curso salvo',
22
+ 'courses.editor.addModule': 'Adicionar módulo',
23
+ 'courses.editor.moduleTitle': 'Título do módulo',
24
+ 'courses.editor.addLesson': 'Adicionar aula',
25
+ 'courses.editor.lessonTitle': 'Título da aula',
26
+ 'courses.editor.lessonVideo': 'URL do vídeo (embed)',
27
+ 'courses.editor.lessonDuration': 'Duração (min)',
28
+ 'courses.editor.deleteModule': 'Excluir módulo',
29
+ 'courses.editor.deleteLesson': 'Remover',
30
+ 'courses.editor.untitledCourse': 'Curso sem título',
31
+ 'courses.editor.untitledModule': 'Novo módulo',
32
+ 'courses.editor.untitledLesson': 'Nova aula',
33
+ }
package/src/nav.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { hashRouterAdapter } from '@fayz-ai/core'
2
+
3
+ // Shared hash-router adapter — the same singleton the admin shell drives, so
4
+ // navigateTo here updates the shell's active route.
5
+ const adapter = hashRouterAdapter()
6
+
7
+ export function navigateTo(to: string): void {
8
+ adapter.navigate(to)
9
+ }
@@ -0,0 +1,211 @@
1
+ import * as React from 'react'
2
+ import { Button, Input, toast } from '@fayz-ai/ui'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import {
5
+ getCoursesProvider,
6
+ type Course, type Module, type Lesson, type CourseStatus,
7
+ } from '@fayz-ai/courses'
8
+ import { navigateTo } from '../nav'
9
+
10
+ const STATUSES: CourseStatus[] = ['draft', 'published', 'archived']
11
+
12
+ export function CourseEditorPage({ id }: { id?: string }) {
13
+ const t = useTranslation()
14
+ const [tab, setTab] = React.useState<'details' | 'curriculum'>('details')
15
+ const [course, setCourse] = React.useState<Course | null>(null)
16
+ const [modules, setModules] = React.useState<Module[]>([])
17
+ const [lessons, setLessons] = React.useState<Lesson[]>([])
18
+
19
+ const reload = React.useCallback(async () => {
20
+ if (!id) return
21
+ const provider = getCoursesProvider()
22
+ const c = await provider.getCourse(id)
23
+ setCourse(c)
24
+ if (c) {
25
+ const [m, l] = await Promise.all([provider.listModules(c.id), provider.listLessons(c.id)])
26
+ setModules(m)
27
+ setLessons(l)
28
+ }
29
+ }, [id])
30
+
31
+ React.useEffect(() => { void reload() }, [reload])
32
+
33
+ if (!course) {
34
+ return <div className="p-8 text-muted-foreground">…</div>
35
+ }
36
+
37
+ async function saveDetails() {
38
+ const provider = getCoursesProvider()
39
+ await provider.updateCourse(course!.id, {
40
+ title: course!.title,
41
+ subtitle: course!.subtitle ?? undefined,
42
+ description: course!.description ?? undefined,
43
+ price: course!.price,
44
+ status: course!.status,
45
+ })
46
+ toast.success(t('courses.editor.saved') || 'Course saved')
47
+ }
48
+
49
+ function patchCourse(patch: Partial<Course>) {
50
+ setCourse((c) => (c ? { ...c, ...patch } : c))
51
+ }
52
+
53
+ // --- module / lesson mutations (persist immediately, then reload) ---------
54
+ async function addModule() {
55
+ await getCoursesProvider().createModule({
56
+ courseId: course!.id,
57
+ title: t('courses.editor.untitledModule') || 'New module',
58
+ })
59
+ await reload()
60
+ }
61
+ async function renameModule(moduleId: string, title: string) {
62
+ await getCoursesProvider().updateModule(moduleId, { title })
63
+ }
64
+ async function deleteModule(moduleId: string) {
65
+ await getCoursesProvider().deleteModule(moduleId)
66
+ await reload()
67
+ }
68
+ async function addLesson(moduleId: string) {
69
+ await getCoursesProvider().createLesson({
70
+ courseId: course!.id,
71
+ moduleId,
72
+ title: t('courses.editor.untitledLesson') || 'New lesson',
73
+ videoUrl: 'https://www.youtube.com/embed/dQw4w9WgXcQ',
74
+ })
75
+ await reload()
76
+ }
77
+ async function patchLesson(lessonId: string, patch: Partial<Lesson>) {
78
+ await getCoursesProvider().updateLesson(lessonId, {
79
+ title: patch.title,
80
+ videoUrl: patch.videoUrl,
81
+ durationSec: patch.durationSec,
82
+ description: patch.description ?? undefined,
83
+ })
84
+ }
85
+ async function deleteLesson(lessonId: string) {
86
+ await getCoursesProvider().deleteLesson(lessonId)
87
+ await reload()
88
+ }
89
+
90
+ return (
91
+ <div className="mx-auto max-w-4xl p-6 md:p-8">
92
+ <button onClick={() => navigateTo('/courses')} className="mb-4 text-sm text-muted-foreground hover:text-foreground">
93
+ ← {t('courses.editor.back') || 'Back to courses'}
94
+ </button>
95
+
96
+ <h1 className="mb-1 text-2xl font-bold text-foreground">{course.title}</h1>
97
+
98
+ <div className="mb-6 mt-4 flex gap-2 border-b border-border">
99
+ {(['details', 'curriculum'] as const).map((tk) => (
100
+ <button
101
+ key={tk}
102
+ 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'
105
+ }`}
106
+ >
107
+ {tk === 'details' ? (t('courses.editor.details') || 'Details') : (t('courses.editor.curriculum') || 'Curriculum')}
108
+ </button>
109
+ ))}
110
+ </div>
111
+
112
+ {tab === 'details' && (
113
+ <div className="max-w-xl space-y-4">
114
+ <Field label={t('courses.editor.fieldTitle') || 'Title'}>
115
+ <Input value={course.title} onChange={(e) => patchCourse({ title: e.target.value })} />
116
+ </Field>
117
+ <Field label={t('courses.editor.fieldSubtitle') || 'Subtitle'}>
118
+ <Input value={course.subtitle ?? ''} onChange={(e) => patchCourse({ subtitle: e.target.value })} />
119
+ </Field>
120
+ <Field label={t('courses.editor.fieldDescription') || 'Description'}>
121
+ <textarea
122
+ className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
123
+ value={course.description ?? ''}
124
+ onChange={(e) => patchCourse({ description: e.target.value })}
125
+ />
126
+ </Field>
127
+ <div className="flex gap-4">
128
+ <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
+ />
134
+ </Field>
135
+ <Field label={t('courses.editor.fieldStatus') || 'Status'}>
136
+ <select
137
+ className="h-10 rounded-md border border-input bg-background px-3 text-sm"
138
+ value={course.status}
139
+ onChange={(e) => patchCourse({ status: e.target.value as CourseStatus })}
140
+ >
141
+ {STATUSES.map((s) => (
142
+ <option key={s} value={s}>{t(`courses.status.${s}`) || s}</option>
143
+ ))}
144
+ </select>
145
+ </Field>
146
+ </div>
147
+ <Button onClick={saveDetails}>{t('courses.editor.save') || 'Save'}</Button>
148
+ </div>
149
+ )}
150
+
151
+ {tab === 'curriculum' && (
152
+ <div className="space-y-6">
153
+ {modules.map((mod) => (
154
+ <div key={mod.id} className="rounded-xl border border-border bg-card p-4">
155
+ <div className="mb-3 flex items-center gap-2">
156
+ <Input
157
+ className="font-medium"
158
+ defaultValue={mod.title}
159
+ onBlur={(e) => renameModule(mod.id, e.target.value)}
160
+ aria-label={t('courses.editor.moduleTitle') || 'Module title'}
161
+ />
162
+ <Button variant="ghost" size="sm" onClick={() => deleteModule(mod.id)}>
163
+ {t('courses.editor.deleteModule') || 'Delete module'}
164
+ </Button>
165
+ </div>
166
+ <div className="space-y-2">
167
+ {lessons.filter((l) => l.moduleId === mod.id).map((lesson) => (
168
+ <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
+ />
185
+ <Button variant="ghost" size="sm" onClick={() => deleteLesson(lesson.id)}>
186
+ {t('courses.editor.deleteLesson') || 'Remove'}
187
+ </Button>
188
+ </div>
189
+ ))}
190
+ <Button variant="outline" size="sm" onClick={() => addLesson(mod.id)}>
191
+ + {t('courses.editor.addLesson') || 'Add lesson'}
192
+ </Button>
193
+ </div>
194
+ </div>
195
+ ))}
196
+ <Button onClick={addModule}>{t('courses.editor.addModule') || 'Add module'}</Button>
197
+ </div>
198
+ )}
199
+ </div>
200
+ )
201
+ }
202
+ CourseEditorPage.displayName = 'CourseEditorPage'
203
+
204
+ function Field({ label, children }: { label: string; children: React.ReactNode }) {
205
+ return (
206
+ <div className="space-y-1.5">
207
+ <label className="text-sm font-medium text-foreground">{label}</label>
208
+ {children}
209
+ </div>
210
+ )
211
+ }
@@ -0,0 +1,105 @@
1
+ import * as React from 'react'
2
+ import { Button, Badge, PageHeaderActions, useModuleLayout } from '@fayz-ai/ui'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import { getCoursesProvider, type Course } from '@fayz-ai/courses'
5
+ import { navigateTo } from '../nav'
6
+
7
+ function statusVariant(status: Course['status']): 'success' | 'secondary' | 'outline' {
8
+ if (status === 'published') return 'success'
9
+ if (status === 'archived') return 'outline'
10
+ return 'secondary'
11
+ }
12
+
13
+ export function CoursesListPage() {
14
+ const t = useTranslation()
15
+ // When the app shell owns the page header (sidebar/GHL-style layouts), don't
16
+ // render our own title — and project the action button into the top bar.
17
+ const shellOwnsHeader = useModuleLayout() === 'tabs'
18
+ const [courses, setCourses] = React.useState<Course[] | null>(null)
19
+ const [creating, setCreating] = React.useState(false)
20
+
21
+ const load = React.useCallback(async () => {
22
+ setCourses(await getCoursesProvider().listCourses())
23
+ }, [])
24
+
25
+ React.useEffect(() => {
26
+ void load()
27
+ }, [load])
28
+
29
+ async function onCreate() {
30
+ setCreating(true)
31
+ try {
32
+ const course = await getCoursesProvider().createCourse({
33
+ title: t('courses.editor.untitledCourse') || 'Untitled course',
34
+ status: 'draft',
35
+ })
36
+ navigateTo(`/courses/${course.id}`)
37
+ } finally {
38
+ setCreating(false)
39
+ }
40
+ }
41
+
42
+ return (
43
+ <div className="mx-auto max-w-6xl p-6 md:p-8">
44
+ {shellOwnsHeader ? (
45
+ <PageHeaderActions>
46
+ <Button onClick={onCreate} disabled={creating}>
47
+ {t('courses.new') || 'New course'}
48
+ </Button>
49
+ </PageHeaderActions>
50
+ ) : (
51
+ <div className="mb-6 flex items-center justify-between">
52
+ <div>
53
+ <h1 className="text-2xl font-bold text-foreground">{t('courses.title') || 'Courses'}</h1>
54
+ {courses && (
55
+ <p className="mt-1 text-sm text-muted-foreground">
56
+ {(t('courses.count') || '{count} courses').replace('{count}', String(courses.length))}
57
+ </p>
58
+ )}
59
+ </div>
60
+ <Button onClick={onCreate} disabled={creating}>
61
+ {t('courses.new') || 'New course'}
62
+ </Button>
63
+ </div>
64
+ )}
65
+
66
+ {courses && courses.length === 0 && (
67
+ <div className="rounded-xl border border-dashed border-border p-12 text-center text-muted-foreground">
68
+ {t('courses.empty') || 'No courses yet. Create your first one.'}
69
+ </div>
70
+ )}
71
+
72
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
73
+ {courses?.map((course) => (
74
+ <button
75
+ key={course.id}
76
+ onClick={() => navigateTo(`/courses/${course.id}`)}
77
+ className="group overflow-hidden rounded-xl border border-border bg-card text-left transition hover:border-primary hover:shadow-md"
78
+ >
79
+ <div className="aspect-video w-full overflow-hidden bg-muted">
80
+ {course.thumbnailUrl && (
81
+ // eslint-disable-next-line @next/next/no-img-element
82
+ <img src={course.thumbnailUrl} alt="" className="h-full w-full object-cover" />
83
+ )}
84
+ </div>
85
+ <div className="space-y-2 p-4">
86
+ <div className="flex items-start justify-between gap-2">
87
+ <h3 className="font-semibold text-foreground line-clamp-2">{course.title}</h3>
88
+ <Badge variant={statusVariant(course.status)}>
89
+ {t(`courses.status.${course.status}`) || course.status}
90
+ </Badge>
91
+ </div>
92
+ {course.subtitle && (
93
+ <p className="text-sm text-muted-foreground line-clamp-2">{course.subtitle}</p>
94
+ )}
95
+ <p className="text-sm font-medium text-foreground">
96
+ {course.price > 0 ? `${course.currency} ${course.price.toFixed(2)}` : '—'}
97
+ </p>
98
+ </div>
99
+ </button>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ )
104
+ }
105
+ CoursesListPage.displayName = 'CoursesListPage'