@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.
Files changed (72) hide show
  1. package/README.md +3 -3
  2. package/dist/components/CommerceUI.d.ts +19 -2
  3. package/dist/components/CommerceUI.d.ts.map +1 -1
  4. package/dist/components/dashboardWidgets.d.ts.map +1 -0
  5. package/dist/index.d.ts +26 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +342 -253
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/nav.d.ts.map +1 -0
  10. package/dist/locales/en.d.ts.map +1 -1
  11. package/dist/locales/pt-BR.d.ts.map +1 -1
  12. package/dist/public/createPublicCheckoutPlugin.d.ts +1 -1
  13. package/dist/public/createPublicCheckoutPlugin.d.ts.map +1 -1
  14. package/dist/public/{data.d.ts → data/index.d.ts} +1 -1
  15. package/dist/public/data/index.d.ts.map +1 -0
  16. package/dist/public/{data.supabase.d.ts → data/supabase.d.ts} +2 -2
  17. package/dist/public/data/supabase.d.ts.map +1 -0
  18. package/dist/public/index.d.ts +4 -4
  19. package/dist/public/index.d.ts.map +1 -1
  20. package/dist/public/index.js +353 -0
  21. package/dist/public/index.js.map +1 -0
  22. package/dist/public/{CheckoutScreen.d.ts → views/CheckoutScreen.d.ts} +1 -1
  23. package/dist/public/views/CheckoutScreen.d.ts.map +1 -0
  24. package/dist/views/CourseEditorPage.d.ts.map +1 -0
  25. package/dist/views/CourseOrderDetailView.d.ts +4 -3
  26. package/dist/views/CourseOrderDetailView.d.ts.map +1 -1
  27. package/dist/{pages → views}/CoursesListPage.d.ts.map +1 -1
  28. package/dist/{pages → views}/FinancialPage.d.ts.map +1 -1
  29. package/dist/{pages → views}/ReportsPage.d.ts.map +1 -1
  30. package/dist/views/SalesPage.d.ts +19 -0
  31. package/dist/views/SalesPage.d.ts.map +1 -0
  32. package/dist/views/SubscriptionsPage.d.ts +7 -0
  33. package/dist/views/SubscriptionsPage.d.ts.map +1 -0
  34. package/package.json +6 -9
  35. package/dist/dashboardWidgets.d.ts.map +0 -1
  36. package/dist/nav.d.ts.map +0 -1
  37. package/dist/pages/CourseEditorPage.d.ts.map +0 -1
  38. package/dist/pages/SalesPage.d.ts +0 -6
  39. package/dist/pages/SalesPage.d.ts.map +0 -1
  40. package/dist/pages/SubscriptionsPage.d.ts +0 -6
  41. package/dist/pages/SubscriptionsPage.d.ts.map +0 -1
  42. package/dist/public/CheckoutScreen.d.ts.map +0 -1
  43. package/dist/public/data.d.ts.map +0 -1
  44. package/dist/public/data.supabase.d.ts.map +0 -1
  45. package/src/components/CommerceUI.tsx +0 -153
  46. package/src/connectors/stripe.ts +0 -34
  47. package/src/dashboardWidgets.tsx +0 -64
  48. package/src/index.ts +0 -150
  49. package/src/lib/format.ts +0 -22
  50. package/src/locales/en.ts +0 -203
  51. package/src/locales/index.ts +0 -7
  52. package/src/locales/pt-BR.ts +0 -203
  53. package/src/nav.ts +0 -9
  54. package/src/pages/CourseEditorPage.tsx +0 -1122
  55. package/src/pages/CoursesListPage.tsx +0 -137
  56. package/src/pages/FinancialPage.tsx +0 -81
  57. package/src/pages/ReportsPage.tsx +0 -59
  58. package/src/pages/SalesPage.tsx +0 -86
  59. package/src/pages/SubscriptionsPage.tsx +0 -56
  60. package/src/public/CheckoutScreen.tsx +0 -279
  61. package/src/public/createPublicCheckoutPlugin.tsx +0 -88
  62. package/src/public/data.supabase.ts +0 -63
  63. package/src/public/data.ts +0 -89
  64. package/src/public/index.ts +0 -26
  65. package/src/settings/CoursesSettings.tsx +0 -55
  66. package/src/views/CourseOrderDetailView.tsx +0 -132
  67. /package/dist/{dashboardWidgets.d.ts → components/dashboardWidgets.d.ts} +0 -0
  68. /package/dist/{nav.d.ts → lib/nav.d.ts} +0 -0
  69. /package/dist/{pages → views}/CourseEditorPage.d.ts +0 -0
  70. /package/dist/{pages → views}/CoursesListPage.d.ts +0 -0
  71. /package/dist/{pages → views}/FinancialPage.d.ts +0 -0
  72. /package/dist/{pages → views}/ReportsPage.d.ts +0 -0
@@ -1,1122 +0,0 @@
1
- import * as React from 'react'
2
- import {
3
- Button, Input, Badge, toast, Skeleton,
4
- Tabs, TabsList, TabsTrigger, TabsContent,
5
- Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
6
- SegmentedControl, type SegmentedOption,
7
- Checkbox, ConfirmDialog, useSaveBar,
8
- } from '@fayz-ai/ui'
9
- import { useTranslation, getSupabaseClientOptional } from '@fayz-ai/core'
10
- import {
11
- getCoursesProvider,
12
- type Course, type Module, type Lesson, type CourseStatus,
13
- type Offer, type OfferKind, type RecurringInterval, type Order,
14
- } from '@fayz-ai/courses'
15
-
16
- /** Derived from the Course model (the package doesn't re-export the union name). */
17
- type CourseDeliveryType = Course['deliveryType']
18
- import {
19
- ArrowLeft, Plus, Trash2, Check, ImagePlus, UploadCloud,
20
- Layers, Tag, Users, ShoppingCart, Loader2, Clock,
21
- GripVertical, ChevronDown, Video, FileText, PlayCircle, Lock, Info,
22
- } from 'lucide-react'
23
- import {
24
- DndContext, closestCenter, PointerSensor, KeyboardSensor,
25
- useSensor, useSensors, type DragEndEvent,
26
- } from '@dnd-kit/core'
27
- import {
28
- SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy,
29
- useSortable, arrayMove,
30
- } from '@dnd-kit/sortable'
31
- import { CSS } from '@dnd-kit/utilities'
32
- import { navigateTo } from '../nav'
33
- import { formatMoney } from '../lib/format'
34
- import { EmptyState, SectionCard, InfoNote } from '../components/CommerceUI'
35
-
36
- /** Local alias for the translate function used across the tree builder below. */
37
- type TFn = (key: string) => string
38
-
39
- /** A distinct enrolled student for this course, derived from its paid orders. */
40
- interface CourseStudent {
41
- key: string
42
- name: string
43
- email: string | null
44
- since: string | null
45
- }
46
-
47
- /** Dedupe paid orders into one row per buyer (latest purchase kept). */
48
- function buildCourseStudents(orders: Order[]): CourseStudent[] {
49
- const byKey = new Map<string, CourseStudent>()
50
- for (const o of orders) {
51
- const key = o.customerId ?? o.customerEmail ?? o.id
52
- const existing = byKey.get(key)
53
- if (!existing || (o.createdAt && existing.since && o.createdAt > existing.since)) {
54
- byKey.set(key, {
55
- key,
56
- name: o.customerName ?? o.customerEmail ?? '—',
57
- email: o.customerEmail ?? null,
58
- since: o.createdAt ?? null,
59
- })
60
- }
61
- }
62
- return [...byKey.values()].sort((a, b) => (b.since ?? '').localeCompare(a.since ?? ''))
63
- }
64
-
65
- const STATUSES: CourseStatus[] = ['draft', 'published', 'archived']
66
- const DELIVERY_TYPES: CourseDeliveryType[] = ['online', 'in_person', 'hybrid']
67
-
68
- const TABS = ['details', 'curriculum', 'offers', 'members'] as const
69
- type Tab = (typeof TABS)[number]
70
-
71
- const TAB_ICON: Record<Tab, React.ElementType> = {
72
- details: Tag,
73
- curriculum: Layers,
74
- offers: ShoppingCart,
75
- members: Users,
76
- }
77
-
78
- /** The editable subset of a course — used to detect unsaved changes on the Geral tab. */
79
- function editSnapshot(c: Course): string {
80
- return JSON.stringify({
81
- title: c.title, subtitle: c.subtitle, description: c.description,
82
- price: c.price, status: c.status, deliveryType: c.deliveryType, thumbnailUrl: c.thumbnailUrl,
83
- })
84
- }
85
-
86
- export function CourseEditorPage({ id }: { id?: string }) {
87
- const t = useTranslation()
88
- const [tab, setTab] = React.useState<Tab>('details')
89
- const [course, setCourse] = React.useState<Course | null>(null)
90
- const [modules, setModules] = React.useState<Module[]>([])
91
- const [lessons, setLessons] = React.useState<Lesson[]>([])
92
- const [offers, setOffers] = React.useState<Offer[]>([])
93
- const [students, setStudents] = React.useState<CourseStudent[]>([])
94
- const [loading, setLoading] = React.useState(true)
95
-
96
- // Geral tab: explicit-save model with dirty tracking.
97
- const [savedSnap, setSavedSnap] = React.useState('')
98
- const [saving, setSaving] = React.useState(false)
99
- const [uploading, setUploading] = React.useState(false)
100
- const fileRef = React.useRef<HTMLInputElement>(null)
101
-
102
- // Delete confirmation for the curriculum tab.
103
- const [pendingDelete, setPendingDelete] = React.useState<
104
- { kind: 'module' | 'lesson'; id: string } | null
105
- >(null)
106
- // Id of a just-created module/lesson whose title input should grab focus.
107
- const [autoFocusId, setAutoFocusId] = React.useState<string | null>(null)
108
-
109
- const reload = React.useCallback(async () => {
110
- if (!id) return
111
- const provider = getCoursesProvider()
112
- const c = await provider.getCourse(id)
113
- setCourse(c)
114
- if (c) {
115
- setSavedSnap(editSnapshot(c))
116
- const [m, l, o, paidOrders] = await Promise.all([
117
- provider.listModules(c.id),
118
- provider.listLessons(c.id),
119
- provider.listOffers(c.id),
120
- provider.listOrders({ courseId: c.id, financialStatus: 'paid' }),
121
- ])
122
- setModules(m)
123
- setLessons(l)
124
- setOffers(o)
125
- setStudents(buildCourseStudents(paidOrders))
126
- }
127
- setLoading(false)
128
- }, [id])
129
-
130
- React.useEffect(() => { void reload() }, [reload])
131
-
132
- const dirty = !!course && editSnapshot(course) !== savedSnap
133
-
134
- // Persist the Geral tab through the app's generic floating unsaved-changes bar
135
- // (the same SaveBar the rest of the CRUD uses) instead of an inline button.
136
- useSaveBar({
137
- dirty,
138
- saving,
139
- onSave: () => { void saveDetails() },
140
- onDiscard: () => { void reload() },
141
- saveLabel: t('courses.editor.save') || 'Salvar',
142
- })
143
-
144
- if (loading || !course) return <EditorSkeleton />
145
-
146
- function patchCourse(patch: Partial<Course>) {
147
- setCourse((c) => (c ? { ...c, ...patch } : c))
148
- }
149
-
150
- async function saveDetails() {
151
- if (!course) return
152
- setSaving(true)
153
- try {
154
- await getCoursesProvider().updateCourse(course.id, {
155
- title: course.title,
156
- subtitle: course.subtitle ?? undefined,
157
- description: course.description ?? undefined,
158
- thumbnailUrl: course.thumbnailUrl ?? undefined,
159
- price: course.price,
160
- status: course.status,
161
- deliveryType: course.deliveryType,
162
- })
163
- setSavedSnap(editSnapshot(course))
164
- toast.success(t('courses.editor.saved') || 'Course saved')
165
- } catch {
166
- toast.error(t('courses.editor.saveFailed') || 'Could not save the course')
167
- } finally {
168
- setSaving(false)
169
- }
170
- }
171
-
172
- async function onPickThumbnail(e: React.ChangeEvent<HTMLInputElement>) {
173
- const file = e.target.files?.[0]
174
- if (!file || !course) return
175
- const supabase = getSupabaseClientOptional() as {
176
- storage?: {
177
- from: (b: string) => {
178
- upload: (p: string, f: File, o?: unknown) => Promise<{ error: unknown }>
179
- getPublicUrl: (p: string) => { data: { publicUrl: string } }
180
- }
181
- }
182
- } | null
183
- if (!supabase?.storage) {
184
- toast.error(t('courses.editor.uploadUnavailable') || 'Upload unavailable — paste an image URL instead.')
185
- if (fileRef.current) fileRef.current.value = ''
186
- return
187
- }
188
- setUploading(true)
189
- try {
190
- const ext = file.name.split('.').pop() || 'jpg'
191
- const path = `${course.id}/cover-${Date.now()}.${ext}`
192
- const bucket = supabase.storage.from('course-media')
193
- const { error } = await bucket.upload(path, file, { upsert: true, contentType: file.type })
194
- if (error) throw error
195
- const { data } = bucket.getPublicUrl(path)
196
- patchCourse({ thumbnailUrl: data.publicUrl })
197
- toast.success(t('courses.editor.thumbnailUploaded') || 'Image uploaded — remember to save.')
198
- } catch {
199
- toast.error(t('courses.editor.uploadFailed') || 'Could not upload the image')
200
- } finally {
201
- setUploading(false)
202
- if (fileRef.current) fileRef.current.value = ''
203
- }
204
- }
205
-
206
- // --- module / lesson mutations (persist immediately, then reload) ---------
207
- async function addModule() {
208
- const mod = await getCoursesProvider().createModule({ courseId: course!.id, title: t('courses.editor.untitledModule') || 'New module' })
209
- toast.success(t('courses.editor.moduleAdded') || 'Module added')
210
- await reload()
211
- setAutoFocusId(mod.id)
212
- }
213
- async function renameModule(moduleId: string, title: string) {
214
- await getCoursesProvider().updateModule(moduleId, { title })
215
- }
216
- async function addLesson(moduleId: string) {
217
- // Optimistic: drop the row at the END of the module's list immediately,
218
- // then persist in the background (swap the temp id for the real one, keeping
219
- // any edits made meanwhile). No full reload → no flash.
220
- const tempId = `tmp-${crypto.randomUUID()}`
221
- const nextOrder = lessons
222
- .filter((l) => l.moduleId === moduleId)
223
- .reduce((max, l) => Math.max(max, l.sortOrder), -1) + 1
224
- const optimistic: Lesson = {
225
- id: tempId,
226
- courseId: course!.id,
227
- moduleId,
228
- title: t('courses.editor.untitledLesson') || 'New lesson',
229
- description: null,
230
- videoUrl: '',
231
- durationSec: 0,
232
- sortOrder: nextOrder,
233
- }
234
- setLessons((ls) => [...ls, optimistic])
235
- setAutoFocusId(tempId)
236
- try {
237
- const real = await getCoursesProvider().createLesson({
238
- courseId: course!.id,
239
- moduleId,
240
- title: optimistic.title,
241
- videoUrl: '',
242
- })
243
- setLessons((ls) => ls.map((l) => (l.id === tempId ? { ...l, id: real.id } : l)))
244
- setAutoFocusId((cur) => (cur === tempId ? real.id : cur))
245
- } catch {
246
- setLessons((ls) => ls.filter((l) => l.id !== tempId))
247
- toast.error(t('courses.editor.saveFailed') || 'Could not add the lesson')
248
- }
249
- }
250
- async function patchLesson(lessonId: string, patch: Partial<Lesson>) {
251
- await getCoursesProvider().updateLesson(lessonId, {
252
- title: patch.title,
253
- videoUrl: patch.videoUrl,
254
- durationSec: patch.durationSec,
255
- description: patch.description ?? undefined,
256
- })
257
- }
258
-
259
- // Persist a new module order (optimistic — reload re-sorts by sort_order).
260
- async function reorderModules(orderedIds: string[]) {
261
- const prev = modules
262
- setModules(orderedIds.map((mid, i) => ({ ...prev.find((m) => m.id === mid)!, sortOrder: i })))
263
- try {
264
- await Promise.all(orderedIds.map((mid, i) => getCoursesProvider().updateModule(mid, { sortOrder: i })))
265
- } catch {
266
- toast.error(t('courses.editor.reorderFailed') || 'Could not reorder')
267
- } finally {
268
- await reload()
269
- }
270
- }
271
- // Persist a new lesson order within one module.
272
- async function reorderLessons(moduleId: string, orderedIds: string[]) {
273
- const prev = lessons
274
- const others = prev.filter((l) => l.moduleId !== moduleId)
275
- const reordered = orderedIds.map((lid, i) => ({ ...prev.find((l) => l.id === lid)!, sortOrder: i }))
276
- setLessons([...others, ...reordered])
277
- try {
278
- await Promise.all(orderedIds.map((lid, i) => getCoursesProvider().updateLesson(lid, { sortOrder: i })))
279
- } catch {
280
- toast.error(t('courses.editor.reorderFailed') || 'Could not reorder')
281
- } finally {
282
- await reload()
283
- }
284
- }
285
-
286
- async function confirmDelete() {
287
- if (!pendingDelete) return
288
- const provider = getCoursesProvider()
289
- if (pendingDelete.kind === 'module') await provider.deleteModule(pendingDelete.id)
290
- else await provider.deleteLesson(pendingDelete.id)
291
- toast.success(t('courses.editor.deleted') || 'Removed')
292
- setPendingDelete(null)
293
- await reload()
294
- }
295
-
296
- // --- offer mutations ------------------------------------------------------
297
- async function addOffer() {
298
- await getCoursesProvider().createOffer({
299
- courseId: course!.id,
300
- name: t('courses.offers.newName') || 'New offer',
301
- price: course!.price,
302
- kind: 'one_time',
303
- })
304
- toast.success(t('courses.offers.added') || 'Offer added')
305
- await reload()
306
- }
307
- async function patchOffer(offerId: string, patch: Partial<Offer>) {
308
- await getCoursesProvider().updateOffer(offerId, {
309
- name: patch.name,
310
- price: patch.price,
311
- kind: patch.kind,
312
- recurringInterval: patch.recurringInterval,
313
- isOrderBump: patch.isOrderBump,
314
- isDefault: patch.isDefault,
315
- })
316
- await reload()
317
- }
318
- async function deleteOffer(offerId: string) {
319
- await getCoursesProvider().deleteOffer(offerId)
320
- toast.success(t('courses.offers.removed') || 'Offer removed')
321
- await reload()
322
- }
323
-
324
- const deliveryOptions: SegmentedOption<CourseDeliveryType>[] = DELIVERY_TYPES.map((d) => ({
325
- value: d,
326
- label: t(`courses.editor.delivery.${d}`) || d,
327
- }))
328
-
329
- return (
330
- <div className="mx-auto max-w-4xl p-6 md:p-8">
331
- <button
332
- onClick={() => navigateTo('/courses')}
333
- className="mb-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
334
- >
335
- <ArrowLeft className="h-4 w-4" /> {t('courses.editor.back') || 'Back to courses'}
336
- </button>
337
-
338
- <div className="mb-6 flex flex-wrap items-center gap-x-3 gap-y-2">
339
- <h1 className="text-2xl font-bold text-foreground">{course.title || (t('courses.editor.untitledCourse') || 'Untitled course')}</h1>
340
- <Badge variant={course.status === 'published' ? 'success' : course.status === 'archived' ? 'secondary' : 'outline'}>
341
- {t(`courses.status.${course.status}`) || course.status}
342
- </Badge>
343
- <Badge variant="outline">{t(`courses.editor.delivery.${course.deliveryType}`) || course.deliveryType}</Badge>
344
- </div>
345
-
346
- <Tabs value={tab} onValueChange={(v) => setTab(v as Tab)}>
347
- <div className="-mx-1 mb-6 overflow-x-auto px-1 pb-1">
348
- <TabsList className="h-auto flex-nowrap">
349
- {TABS.map((tk) => {
350
- const Icon = TAB_ICON[tk]
351
- return (
352
- <TabsTrigger key={tk} value={tk} className="gap-1.5 whitespace-nowrap py-1.5">
353
- <Icon className="h-3.5 w-3.5" />
354
- {t(`courses.editor.tab.${tk}`) || tk}
355
- </TabsTrigger>
356
- )
357
- })}
358
- </TabsList>
359
- </div>
360
-
361
- {/* ---- Geral ---------------------------------------------------- */}
362
- <TabsContent value="details" className="space-y-5">
363
- <SectionCard title={t('courses.editor.sectionBasics') || 'Basics'}>
364
- <div className="space-y-4">
365
- <Field label={t('courses.editor.fieldTitle') || 'Title'}>
366
- <Input value={course.title} onChange={(e) => patchCourse({ title: e.target.value })} />
367
- </Field>
368
- <Field label={t('courses.editor.fieldSubtitle') || 'Subtitle'}>
369
- <Input value={course.subtitle ?? ''} onChange={(e) => patchCourse({ subtitle: e.target.value })} />
370
- </Field>
371
- <Field label={t('courses.editor.fieldDescription') || 'Description'}>
372
- <Textarea
373
- value={course.description ?? ''}
374
- onChange={(e) => patchCourse({ description: e.target.value })}
375
- placeholder={t('courses.editor.descriptionPlaceholder') || 'What will students learn?'}
376
- />
377
- </Field>
378
- <Field label={t('courses.editor.fieldDelivery') || 'Format'} hint={t('courses.editor.deliveryHint') || 'How this course is delivered.'}>
379
- <SegmentedControl
380
- size="md"
381
- options={deliveryOptions}
382
- value={course.deliveryType}
383
- onChange={(v) => patchCourse({ deliveryType: v })}
384
- aria-label={t('courses.editor.fieldDelivery') || 'Format'}
385
- />
386
- </Field>
387
- </div>
388
- </SectionCard>
389
-
390
- <SectionCard title={t('courses.editor.sectionThumbnail') || 'Cover image'} description={t('courses.editor.thumbnailHint') || 'Shown on the storefront and in listings. 1280×720 recommended.'}>
391
- <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
392
- <div className="aspect-video w-full shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:w-56">
393
- {course.thumbnailUrl ? (
394
- // eslint-disable-next-line @next/next/no-img-element
395
- <img src={course.thumbnailUrl} alt="" className="h-full w-full object-cover" />
396
- ) : (
397
- <div className="flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground">
398
- <ImagePlus className="h-6 w-6" />
399
- <span className="text-xs">{t('courses.editor.thumbnailEmpty') || 'No image'}</span>
400
- </div>
401
- )}
402
- </div>
403
- <div className="flex-1 space-y-3">
404
- <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={onPickThumbnail} />
405
- <Button variant="outline" size="sm" disabled={uploading} onClick={() => fileRef.current?.click()}>
406
- {uploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <UploadCloud className="h-4 w-4" />}
407
- {uploading ? (t('courses.editor.uploading') || 'Uploading…') : (t('courses.editor.thumbnailUpload') || 'Upload image')}
408
- </Button>
409
- <Field label={t('courses.editor.thumbnailUrl') || 'Or paste an image URL'}>
410
- <Input
411
- value={course.thumbnailUrl ?? ''}
412
- placeholder="https://…"
413
- onChange={(e) => patchCourse({ thumbnailUrl: e.target.value || null })}
414
- />
415
- </Field>
416
- </div>
417
- </div>
418
- </SectionCard>
419
-
420
- <SectionCard title={t('courses.editor.sectionPricing') || 'Pricing & status'}>
421
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
422
- <Field label={t('courses.editor.fieldPrice') || 'Price'} hint={course.currency}>
423
- <Input
424
- type="number"
425
- min={0}
426
- step="0.01"
427
- inputMode="decimal"
428
- value={String(course.price)}
429
- onChange={(e) => patchCourse({ price: Number(e.target.value) || 0 })}
430
- />
431
- </Field>
432
- <Field label={t('courses.editor.fieldStatus') || 'Status'}>
433
- <Select value={course.status} onValueChange={(v) => patchCourse({ status: v as CourseStatus })}>
434
- <SelectTrigger>
435
- <SelectValue />
436
- </SelectTrigger>
437
- <SelectContent>
438
- {STATUSES.map((s) => (
439
- <SelectItem key={s} value={s}>{t(`courses.status.${s}`) || s}</SelectItem>
440
- ))}
441
- </SelectContent>
442
- </Select>
443
- </Field>
444
- </div>
445
- </SectionCard>
446
- </TabsContent>
447
-
448
- {/* ---- Conteúdo ------------------------------------------------- */}
449
- <TabsContent value="curriculum" className="space-y-4">
450
- <CurriculumBuilder
451
- t={t}
452
- modules={modules}
453
- lessons={lessons}
454
- autoFocusId={autoFocusId}
455
- onAutoFocused={() => setAutoFocusId(null)}
456
- onAddModule={addModule}
457
- onRenameModule={renameModule}
458
- onAddLesson={addLesson}
459
- onPatchLesson={patchLesson}
460
- onRequestDelete={(kind, delId) => setPendingDelete({ kind, id: delId })}
461
- onReorderModules={reorderModules}
462
- onReorderLessons={reorderLessons}
463
- />
464
- </TabsContent>
465
-
466
- {/* ---- Ofertas -------------------------------------------------- */}
467
- <TabsContent value="offers" className="space-y-4">
468
- <InfoNote icon={<Info className="h-4 w-4" />}>
469
- {t('courses.offers.help') || 'Price points for this course. Sales run through orders and the Financeiro module.'}
470
- </InfoNote>
471
- {offers.length === 0 ? (
472
- <EmptyState
473
- icon={<Tag className="h-5 w-5" />}
474
- title={t('courses.offers.empty') || 'No offers yet'}
475
- description={t('courses.offers.emptyHint') || 'Create an offer to sell this course.'}
476
- action={<Button onClick={addOffer}><Plus className="h-4 w-4" />{t('courses.offers.firstOffer') || 'Create first offer'}</Button>}
477
- />
478
- ) : (
479
- <>
480
- {offers.map((offer) => (
481
- <div key={offer.id} className="rounded-xl border border-border bg-card p-4 shadow-sm md:p-5">
482
- <div className="mb-4 flex items-start justify-between gap-3">
483
- <Badge variant="outline" className="gap-1">
484
- {offer.kind === 'subscription'
485
- ? (t('courses.offers.subscription') || 'Subscription')
486
- : (t('courses.offers.oneTime') || 'One-time')}
487
- </Badge>
488
- <button
489
- onClick={() => deleteOffer(offer.id)}
490
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive"
491
- aria-label={t('courses.offers.remove') || 'Remove'}
492
- >
493
- <Trash2 className="h-4 w-4" />
494
- </button>
495
- </div>
496
-
497
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_140px_160px]">
498
- <Field label={t('courses.offers.name') || 'Offer name'}>
499
- <Input defaultValue={offer.name} onBlur={(e) => patchOffer(offer.id, { name: e.target.value })} />
500
- </Field>
501
- <Field label={t('courses.offers.price') || 'Price'} hint={offer.currency}>
502
- <Input type="number" min={0} step="0.01" inputMode="decimal" defaultValue={String(offer.price)} onBlur={(e) => patchOffer(offer.id, { price: Number(e.target.value) || 0 })} />
503
- </Field>
504
- <Field label={t('courses.offers.kind') || 'Type'}>
505
- <Select
506
- value={offer.kind}
507
- onValueChange={(v) => patchOffer(offer.id, { kind: v as OfferKind, recurringInterval: v === 'subscription' ? (offer.recurringInterval ?? 'month') : null })}
508
- >
509
- <SelectTrigger><SelectValue /></SelectTrigger>
510
- <SelectContent>
511
- <SelectItem value="one_time">{t('courses.offers.oneTime') || 'One-time'}</SelectItem>
512
- <SelectItem value="subscription">{t('courses.offers.subscription') || 'Subscription'}</SelectItem>
513
- </SelectContent>
514
- </Select>
515
- </Field>
516
- </div>
517
-
518
- {offer.kind === 'subscription' && (
519
- <div className="mt-3 max-w-[200px]">
520
- <Field label={t('courses.offers.interval') || 'Billing interval'}>
521
- <Select
522
- value={offer.recurringInterval ?? 'month'}
523
- onValueChange={(v) => patchOffer(offer.id, { recurringInterval: v as RecurringInterval })}
524
- >
525
- <SelectTrigger><SelectValue /></SelectTrigger>
526
- <SelectContent>
527
- <SelectItem value="month">{t('courses.interval.month') || 'month'}</SelectItem>
528
- <SelectItem value="year">{t('courses.interval.year') || 'year'}</SelectItem>
529
- </SelectContent>
530
- </Select>
531
- </Field>
532
- </div>
533
- )}
534
-
535
- {/* Storefront-only flags — de-emphasized: they persist but only take
536
- effect once the public checkout ships (deferred / bridge scope). */}
537
- <div className="mt-4 rounded-lg border border-dashed border-border bg-muted/30 p-3">
538
- <p className="mb-2.5 flex items-center gap-1.5 text-xs text-muted-foreground">
539
- <Clock className="h-3.5 w-3.5" />
540
- {t('courses.offers.checkoutFlagsNote') || 'Apply once the public checkout ships (coming soon).'}
541
- </p>
542
- <div className="flex flex-wrap items-center gap-x-5 gap-y-2">
543
- <label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
544
- <Checkbox checked={offer.isDefault} onChange={(v) => patchOffer(offer.id, { isDefault: v })} />
545
- {t('courses.offers.default') || 'Default'}
546
- </label>
547
- <label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
548
- <Checkbox checked={offer.isOrderBump} onChange={(v) => patchOffer(offer.id, { isOrderBump: v })} />
549
- {t('courses.offers.bump') || 'Order bump'}
550
- </label>
551
- </div>
552
- </div>
553
- </div>
554
- ))}
555
- <Button variant="outline" onClick={addOffer} className="w-full border-dashed">
556
- <Plus className="h-4 w-4" />{t('courses.offers.add') || 'Add offer'}
557
- </Button>
558
- </>
559
- )}
560
- </TabsContent>
561
-
562
- {/* ---- Área de membros ----------------------------------------- */}
563
- <TabsContent value="members" className="space-y-4">
564
- <InfoNote icon={<Users className="h-4 w-4" />}>
565
- {t('courses.editor.membersHelp') || 'Buyers are enrolled automatically through orders and get access to all of this course’s content.'}
566
- </InfoNote>
567
-
568
- {/* Enrolled students for THIS course (the actual "área de membros"). */}
569
- <div className="space-y-2">
570
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
571
- {(t('courses.editor.enrolledStudents') || 'Enrolled students')} ({students.length})
572
- </p>
573
- {students.length === 0 ? (
574
- <EmptyState
575
- icon={<Users className="h-5 w-5" />}
576
- title={t('courses.editor.membersEmpty') || 'No students yet'}
577
- description={t('courses.editor.membersEmptyHint') || 'Buyers are enrolled here automatically after a paid order.'}
578
- />
579
- ) : (
580
- <div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
581
- {students.map((s) => (
582
- <div key={s.key} className="flex items-center gap-3 px-4 py-3">
583
- <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold uppercase text-muted-foreground" aria-hidden>
584
- {(s.name || s.email || '?').charAt(0)}
585
- </span>
586
- <span className="flex min-w-0 flex-1 flex-col">
587
- <span className="truncate font-medium text-foreground">{s.name}</span>
588
- {s.email && <span className="truncate text-xs text-muted-foreground">{s.email}</span>}
589
- </span>
590
- {s.since && (
591
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
592
- {new Date(s.since).toLocaleDateString()}
593
- </span>
594
- )}
595
- </div>
596
- ))}
597
- </div>
598
- )}
599
- </div>
600
-
601
- {/* Which offers grant access — kept as secondary context. */}
602
- {offers.length > 0 && (
603
- <div className="space-y-2">
604
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
605
- {t('courses.editor.accessUnlockedBy') || 'Access is unlocked by'}
606
- </p>
607
- <div className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
608
- {offers.map((offer) => (
609
- <div key={offer.id} className="flex items-center gap-3 px-4 py-3">
610
- <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground" aria-hidden>
611
- <Lock className="h-4 w-4" />
612
- </span>
613
- <span className="min-w-0 flex-1 truncate font-medium text-foreground">{offer.name}</span>
614
- <span className="shrink-0 text-sm tabular-nums text-muted-foreground">{formatMoney(offer.price, offer.currency)}</span>
615
- </div>
616
- ))}
617
- </div>
618
- </div>
619
- )}
620
- </TabsContent>
621
- </Tabs>
622
-
623
- <ConfirmDialog
624
- open={pendingDelete !== null}
625
- variant="destructive"
626
- title={pendingDelete?.kind === 'module'
627
- ? (t('courses.editor.deleteModuleConfirm') || 'Delete this module?')
628
- : (t('courses.editor.deleteLessonConfirm') || 'Remove this lesson?')}
629
- description={pendingDelete?.kind === 'module'
630
- ? (t('courses.editor.deleteModuleConfirmBody') || 'Its lessons will be removed too. This cannot be undone.')
631
- : (t('courses.editor.deleteLessonConfirmBody') || 'This cannot be undone.')}
632
- confirmLabel={t('courses.editor.confirmDelete') || 'Delete'}
633
- cancelLabel={t('courses.editor.cancel') || 'Cancel'}
634
- onConfirm={confirmDelete}
635
- onCancel={() => setPendingDelete(null)}
636
- />
637
- </div>
638
- )
639
- }
640
- CourseEditorPage.displayName = 'CourseEditorPage'
641
-
642
- // ===========================================================================
643
- // Curriculum tree builder (module → lesson), Kiwify/Hotmart style.
644
- // ===========================================================================
645
-
646
- interface CurriculumBuilderProps {
647
- t: TFn
648
- modules: Module[]
649
- lessons: Lesson[]
650
- autoFocusId: string | null
651
- onAutoFocused: () => void
652
- onAddModule: () => void
653
- onRenameModule: (moduleId: string, title: string) => void
654
- onAddLesson: (moduleId: string) => void
655
- onPatchLesson: (lessonId: string, patch: Partial<Lesson>) => void
656
- onRequestDelete: (kind: 'module' | 'lesson', id: string) => void
657
- onReorderModules: (orderedIds: string[]) => void
658
- onReorderLessons: (moduleId: string, orderedIds: string[]) => void
659
- }
660
-
661
- /** Human-friendly total duration, e.g. "8 min" or "1 h 20 min". */
662
- function formatDuration(sec: number, t: TFn): string {
663
- const min = Math.max(0, Math.round(sec / 60))
664
- const mLabel = t('courses.editor.minShort') || 'min'
665
- if (min < 60) return `${min} ${mLabel}`
666
- const h = Math.floor(min / 60)
667
- const rem = min % 60
668
- const hLabel = t('courses.editor.hourShort') || 'h'
669
- return rem ? `${h} ${hLabel} ${rem} ${mLabel}` : `${h} ${hLabel}`
670
- }
671
-
672
- function CurriculumBuilder({
673
- t, modules, lessons, autoFocusId, onAutoFocused,
674
- onAddModule, onRenameModule, onAddLesson, onPatchLesson,
675
- onRequestDelete, onReorderModules, onReorderLessons,
676
- }: CurriculumBuilderProps) {
677
- // Track only the *collapsed* modules so freshly-created ones default to open.
678
- const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set())
679
- const sensors = useSensors(
680
- useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
681
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
682
- )
683
-
684
- function toggleModule(id: string) {
685
- setCollapsed((prev) => {
686
- const next = new Set(prev)
687
- if (next.has(id)) next.delete(id)
688
- else next.add(id)
689
- return next
690
- })
691
- }
692
-
693
- function handleModuleDragEnd(e: DragEndEvent) {
694
- const { active, over } = e
695
- if (!over || active.id === over.id) return
696
- const ids = modules.map((m) => m.id)
697
- const from = ids.indexOf(String(active.id))
698
- const to = ids.indexOf(String(over.id))
699
- if (from < 0 || to < 0) return
700
- onReorderModules(arrayMove(ids, from, to))
701
- }
702
-
703
- if (modules.length === 0) {
704
- return (
705
- <div className="space-y-4">
706
- <AutosaveHint t={t} />
707
- <EmptyState
708
- icon={<Layers className="h-5 w-5" />}
709
- title={t('courses.editor.modulesEmpty') || 'No modules yet'}
710
- description={t('courses.editor.modulesEmptyHint') || 'Create a module to start organizing your lessons.'}
711
- action={<Button onClick={onAddModule}><Plus className="h-4 w-4" />{t('courses.editor.firstModule') || 'Create first module'}</Button>}
712
- />
713
- </div>
714
- )
715
- }
716
-
717
- const totalLessons = lessons.length
718
-
719
- return (
720
- <div className="space-y-4">
721
- <div className="flex flex-wrap items-center justify-between gap-2">
722
- <AutosaveHint t={t} />
723
- <span className="text-xs text-muted-foreground tabular-nums">
724
- {modules.length} {modules.length === 1 ? (t('courses.editor.moduleLabel') || 'module') : (t('courses.editor.moduleLabelPlural') || 'modules')}
725
- {' · '}
726
- {totalLessons} {totalLessons === 1 ? (t('courses.editor.lessonUnitOne') || 'lesson') : (t('courses.editor.lessonUnitOther') || 'lessons')}
727
- </span>
728
- </div>
729
-
730
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleModuleDragEnd}>
731
- <SortableContext items={modules.map((m) => m.id)} strategy={verticalListSortingStrategy}>
732
- <div className="space-y-3">
733
- {modules.map((mod, i) => (
734
- <SortableModuleCard
735
- key={mod.id}
736
- t={t}
737
- index={i}
738
- mod={mod}
739
- lessons={lessons.filter((l) => l.moduleId === mod.id)}
740
- expanded={!collapsed.has(mod.id)}
741
- onToggle={() => toggleModule(mod.id)}
742
- autoFocusId={autoFocusId}
743
- onAutoFocused={onAutoFocused}
744
- onRenameModule={onRenameModule}
745
- onAddLesson={onAddLesson}
746
- onPatchLesson={onPatchLesson}
747
- onRequestDelete={onRequestDelete}
748
- onReorderLessons={onReorderLessons}
749
- />
750
- ))}
751
- </div>
752
- </SortableContext>
753
- </DndContext>
754
-
755
- <Button variant="outline" onClick={onAddModule} className="w-full border-dashed">
756
- <Plus className="h-4 w-4" />{t('courses.editor.newModule') || 'New module'}
757
- </Button>
758
- </div>
759
- )
760
- }
761
-
762
- interface SortableModuleCardProps {
763
- t: TFn
764
- index: number
765
- mod: Module
766
- lessons: Lesson[]
767
- expanded: boolean
768
- onToggle: () => void
769
- autoFocusId: string | null
770
- onAutoFocused: () => void
771
- onRenameModule: (moduleId: string, title: string) => void
772
- onAddLesson: (moduleId: string) => void
773
- onPatchLesson: (lessonId: string, patch: Partial<Lesson>) => void
774
- onRequestDelete: (kind: 'module' | 'lesson', id: string) => void
775
- onReorderLessons: (moduleId: string, orderedIds: string[]) => void
776
- }
777
-
778
- function SortableModuleCard({
779
- t, index, mod, lessons, expanded, onToggle, autoFocusId, onAutoFocused,
780
- onRenameModule, onAddLesson, onPatchLesson, onRequestDelete, onReorderLessons,
781
- }: SortableModuleCardProps) {
782
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: mod.id })
783
- const sensors = useSensors(
784
- useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
785
- useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
786
- )
787
- const style: React.CSSProperties = {
788
- transform: CSS.Transform.toString(transform),
789
- transition,
790
- opacity: isDragging ? 0.6 : 1,
791
- zIndex: isDragging ? 20 : undefined,
792
- }
793
-
794
- const count = lessons.length
795
- const totalSec = lessons.reduce((s, l) => s + (l.durationSec || 0), 0)
796
-
797
- function handleLessonDragEnd(e: DragEndEvent) {
798
- const { active, over } = e
799
- if (!over || active.id === over.id) return
800
- const ids = lessons.map((l) => l.id)
801
- const from = ids.indexOf(String(active.id))
802
- const to = ids.indexOf(String(over.id))
803
- if (from < 0 || to < 0) return
804
- onReorderLessons(mod.id, arrayMove(ids, from, to))
805
- }
806
-
807
- return (
808
- <div
809
- ref={setNodeRef}
810
- style={style}
811
- className="overflow-hidden rounded-xl border border-border bg-card shadow-sm"
812
- >
813
- {/* Module header */}
814
- <div className="flex items-center gap-1.5 px-2 py-2 sm:gap-2 sm:px-3">
815
- <button
816
- {...attributes}
817
- {...listeners}
818
- className="hidden h-8 w-6 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/40 transition-colors hover:text-muted-foreground active:cursor-grabbing sm:flex"
819
- aria-label={t('courses.editor.dragToReorder') || 'Drag to reorder'}
820
- >
821
- <GripVertical className="h-4 w-4" />
822
- </button>
823
-
824
- <button
825
- onClick={onToggle}
826
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
827
- aria-label={expanded ? (t('courses.editor.collapse') || 'Collapse') : (t('courses.editor.expand') || 'Expand')}
828
- aria-expanded={expanded}
829
- >
830
- <ChevronDown className={'h-4 w-4 transition-transform ' + (expanded ? '' : '-rotate-90')} />
831
- </button>
832
-
833
- <span className="hidden shrink-0 items-center rounded-md bg-muted px-2 py-1 text-xs font-semibold tabular-nums text-muted-foreground sm:inline-flex">
834
- {String(index + 1).padStart(2, '0')}
835
- </span>
836
-
837
- <TitleField
838
- value={mod.title}
839
- autoFocus={autoFocusId === mod.id}
840
- onAutoFocused={onAutoFocused}
841
- onCommit={(v) => onRenameModule(mod.id, v)}
842
- placeholder={t('courses.editor.moduleTitle') || 'Module title'}
843
- ariaLabel={t('courses.editor.moduleTitle') || 'Module title'}
844
- className="text-sm font-semibold"
845
- />
846
-
847
- <span className="hidden shrink-0 text-xs text-muted-foreground tabular-nums md:inline">
848
- {count > 0
849
- ? `${count} ${count === 1 ? (t('courses.editor.lessonUnitOne') || 'lesson') : (t('courses.editor.lessonUnitOther') || 'lessons')} · ${formatDuration(totalSec, t)}`
850
- : (t('courses.editor.noLessons') || 'No lessons')}
851
- </span>
852
-
853
- <button
854
- onClick={() => onRequestDelete('module', mod.id)}
855
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive"
856
- aria-label={t('courses.editor.deleteModule') || 'Delete module'}
857
- >
858
- <Trash2 className="h-4 w-4" />
859
- </button>
860
- </div>
861
-
862
- {/* Module body */}
863
- {expanded && (
864
- <div className="border-t border-border/60 bg-muted/20 p-2 sm:p-3">
865
- {count === 0 ? (
866
- <button
867
- onClick={() => onAddLesson(mod.id)}
868
- className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-3 py-4 text-sm text-muted-foreground transition-colors hover:border-primary/40 hover:bg-card hover:text-foreground"
869
- >
870
- <Plus className="h-4 w-4" /> {t('courses.editor.firstLesson') || 'Add the first lesson'}
871
- </button>
872
- ) : (
873
- <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleLessonDragEnd}>
874
- <SortableContext items={lessons.map((l) => l.id)} strategy={verticalListSortingStrategy}>
875
- <div className="space-y-1.5">
876
- {lessons.map((lesson) => (
877
- <SortableLessonRow
878
- key={lesson.id}
879
- t={t}
880
- lesson={lesson}
881
- autoFocusId={autoFocusId}
882
- onAutoFocused={onAutoFocused}
883
- onPatchLesson={onPatchLesson}
884
- onRequestDelete={onRequestDelete}
885
- />
886
- ))}
887
- </div>
888
- </SortableContext>
889
- </DndContext>
890
- )}
891
-
892
- {count > 0 && (
893
- <button
894
- onClick={() => onAddLesson(mod.id)}
895
- className="mt-2 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/10"
896
- >
897
- <Plus className="h-4 w-4" /> {t('courses.editor.newLesson') || 'New lesson'}
898
- </button>
899
- )}
900
- </div>
901
- )}
902
- </div>
903
- )
904
- }
905
-
906
- interface SortableLessonRowProps {
907
- t: TFn
908
- lesson: Lesson
909
- autoFocusId: string | null
910
- onAutoFocused: () => void
911
- onPatchLesson: (lessonId: string, patch: Partial<Lesson>) => void
912
- onRequestDelete: (kind: 'module' | 'lesson', id: string) => void
913
- }
914
-
915
- function SortableLessonRow({ t, lesson, autoFocusId, onAutoFocused, onPatchLesson, onRequestDelete }: SortableLessonRowProps) {
916
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: lesson.id })
917
- const [open, setOpen] = React.useState(false)
918
- // Local mirrors so the type icon + duration chip refresh on blur without a reload.
919
- const [videoUrl, setVideoUrl] = React.useState(lesson.videoUrl)
920
- const [durationSec, setDurationSec] = React.useState(lesson.durationSec)
921
- React.useEffect(() => { setVideoUrl(lesson.videoUrl) }, [lesson.videoUrl])
922
- React.useEffect(() => { setDurationSec(lesson.durationSec) }, [lesson.durationSec])
923
-
924
- const hasVideo = videoUrl.trim().length > 0
925
- const style: React.CSSProperties = {
926
- transform: CSS.Transform.toString(transform),
927
- transition,
928
- opacity: isDragging ? 0.6 : 1,
929
- zIndex: isDragging ? 10 : undefined,
930
- }
931
-
932
- return (
933
- <div
934
- ref={setNodeRef}
935
- style={style}
936
- className={
937
- 'group rounded-lg border bg-card transition-colors ' +
938
- (open ? 'border-border' : 'border-transparent hover:border-border')
939
- }
940
- >
941
- <div className="flex items-center gap-1.5 px-1.5 py-1.5 sm:gap-2 sm:px-2">
942
- <button
943
- {...attributes}
944
- {...listeners}
945
- className="hidden h-8 w-5 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground/40 transition-colors hover:text-muted-foreground active:cursor-grabbing sm:flex"
946
- aria-label={t('courses.editor.dragToReorder') || 'Drag to reorder'}
947
- >
948
- <GripVertical className="h-4 w-4" />
949
- </button>
950
-
951
- <span
952
- className={
953
- 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ' +
954
- (hasVideo ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground')
955
- }
956
- aria-hidden
957
- >
958
- {hasVideo ? <Video className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
959
- </span>
960
-
961
- <TitleField
962
- value={lesson.title}
963
- autoFocus={autoFocusId === lesson.id}
964
- onAutoFocused={onAutoFocused}
965
- onCommit={(v) => onPatchLesson(lesson.id, { title: v })}
966
- placeholder={t('courses.editor.lessonTitle') || 'Lesson title'}
967
- ariaLabel={t('courses.editor.lessonTitle') || 'Lesson title'}
968
- className="text-sm"
969
- />
970
-
971
- <span className="hidden shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-xs tabular-nums text-muted-foreground sm:inline-flex">
972
- <Clock className="h-3 w-3" />
973
- {Math.round(durationSec / 60)}
974
- {' '}
975
- {t('courses.editor.minShort') || 'min'}
976
- </span>
977
-
978
- <button
979
- onClick={() => setOpen((v) => !v)}
980
- className={
981
- 'inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-1 text-xs font-medium transition-colors ' +
982
- (hasVideo
983
- ? 'border-border text-muted-foreground hover:text-foreground'
984
- : 'border-dashed border-border text-muted-foreground hover:border-primary/40 hover:text-foreground') +
985
- ' opacity-100 sm:opacity-0 sm:group-hover:opacity-100 ' + (open ? 'sm:opacity-100' : '')
986
- }
987
- aria-label={t('courses.editor.editLesson') || 'Edit lesson'}
988
- aria-expanded={open}
989
- >
990
- <PlayCircle className="h-3.5 w-3.5" />
991
- <span className="hidden sm:inline">
992
- {hasVideo ? (t('courses.editor.videoSet') || 'Video') : (t('courses.editor.addVideo') || 'Add video')}
993
- </span>
994
- </button>
995
-
996
- <button
997
- onClick={() => onRequestDelete('lesson', lesson.id)}
998
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100"
999
- aria-label={t('courses.editor.deleteLesson') || 'Remove lesson'}
1000
- >
1001
- <Trash2 className="h-4 w-4" />
1002
- </button>
1003
- </div>
1004
-
1005
- {open && (
1006
- <div className="space-y-3 border-t border-border/60 px-3 py-3 sm:pl-11">
1007
- <div className="space-y-1.5">
1008
- <label className="text-xs font-medium text-muted-foreground">
1009
- {t('courses.editor.videoUrlLabel') || 'Video URL'}
1010
- </label>
1011
- <Input
1012
- defaultValue={lesson.videoUrl}
1013
- placeholder={t('courses.editor.videoUrlPlaceholder') || 'Paste the video link (YouTube, Vimeo…)'}
1014
- onBlur={(e) => { setVideoUrl(e.target.value); onPatchLesson(lesson.id, { videoUrl: e.target.value }) }}
1015
- aria-label={t('courses.editor.videoUrlLabel') || 'Video URL'}
1016
- />
1017
- </div>
1018
- <div className="max-w-[160px] space-y-1.5">
1019
- <label className="text-xs font-medium text-muted-foreground">
1020
- {t('courses.editor.lessonDuration') || 'Duration (min)'}
1021
- </label>
1022
- <Input
1023
- type="number"
1024
- min={0}
1025
- inputMode="numeric"
1026
- defaultValue={String(Math.round(lesson.durationSec / 60))}
1027
- onBlur={(e) => { const s = (Number(e.target.value) || 0) * 60; setDurationSec(s); onPatchLesson(lesson.id, { durationSec: s }) }}
1028
- aria-label={t('courses.editor.lessonDuration') || 'Duration (min)'}
1029
- />
1030
- </div>
1031
- </div>
1032
- )}
1033
- </div>
1034
- )
1035
- }
1036
-
1037
- /**
1038
- * Borderless, text-like inline title input. Uncontrolled (defaultValue + onBlur
1039
- * autosave) so it plays nicely with the reload-after-mutation flow, and focuses
1040
- * itself when it is the freshly-created item.
1041
- */
1042
- function TitleField({ value, autoFocus, onAutoFocused, onCommit, placeholder, ariaLabel, className }: {
1043
- value: string
1044
- autoFocus?: boolean
1045
- onAutoFocused?: () => void
1046
- onCommit: (v: string) => void
1047
- placeholder?: string
1048
- ariaLabel?: string
1049
- className?: string
1050
- }) {
1051
- const ref = React.useRef<HTMLInputElement>(null)
1052
- React.useEffect(() => {
1053
- if (autoFocus && ref.current) {
1054
- ref.current.focus()
1055
- ref.current.select()
1056
- onAutoFocused?.()
1057
- }
1058
- // eslint-disable-next-line react-hooks/exhaustive-deps
1059
- }, [autoFocus])
1060
- return (
1061
- <input
1062
- ref={ref}
1063
- defaultValue={value}
1064
- placeholder={placeholder}
1065
- aria-label={ariaLabel}
1066
- onBlur={(e) => onCommit(e.target.value)}
1067
- onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
1068
- className={
1069
- 'min-w-0 flex-1 rounded-md bg-transparent px-2 py-1.5 text-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/60 focus:bg-background focus:ring-2 focus:ring-ring ' +
1070
- (className ?? '')
1071
- }
1072
- />
1073
- )
1074
- }
1075
-
1076
- function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
1077
- return (
1078
- <div className="space-y-1.5">
1079
- <div className="flex items-baseline justify-between gap-2">
1080
- <label className="text-sm font-medium text-foreground">{label}</label>
1081
- {hint && <span className="text-xs text-muted-foreground">{hint}</span>}
1082
- </div>
1083
- {children}
1084
- </div>
1085
- )
1086
- }
1087
-
1088
- function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
1089
- const { className, ...rest } = props
1090
- return (
1091
- <textarea
1092
- className={
1093
- 'flex min-h-[96px] w-full rounded-input border border-input bg-card px-2 py-1.5 text-sm text-foreground shadow-[inset_0_1px_0_rgb(0_0_0_/0.06)] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ' +
1094
- (className ?? '')
1095
- }
1096
- {...rest}
1097
- />
1098
- )
1099
- }
1100
-
1101
- function AutosaveHint({ t }: { t: (k: string) => string }) {
1102
- return (
1103
- <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
1104
- <Check className="h-3.5 w-3.5 text-success" />
1105
- {t('courses.editor.autosaved') || 'Changes are saved automatically.'}
1106
- </p>
1107
- )
1108
- }
1109
-
1110
- function EditorSkeleton() {
1111
- return (
1112
- <div className="mx-auto max-w-4xl p-6 md:p-8">
1113
- <Skeleton className="mb-4 h-4 w-28" />
1114
- <Skeleton className="mb-6 h-8 w-64" />
1115
- <Skeleton className="mb-6 h-10 w-full" />
1116
- <div className="space-y-4">
1117
- <Skeleton className="h-40 w-full rounded-xl" />
1118
- <Skeleton className="h-40 w-full rounded-xl" />
1119
- </div>
1120
- </div>
1121
- )
1122
- }