@fayz-ai/portal 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.
- package/LICENSE +21 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/components/MemberHeader.d.ts +3 -0
- package/dist/components/MemberHeader.d.ts.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/createMemberApp.d.ts +12 -0
- package/dist/createMemberApp.d.ts.map +1 -0
- package/dist/index.cjs +542 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +476 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/AccountPage.d.ts +3 -0
- package/dist/pages/AccountPage.d.ts.map +1 -0
- package/dist/pages/CoursePlayerPage.d.ts +6 -0
- package/dist/pages/CoursePlayerPage.d.ts.map +1 -0
- package/dist/pages/MyCoursesPage.d.ts +3 -0
- package/dist/pages/MyCoursesPage.d.ts.map +1 -0
- package/dist/router.d.ts +283 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/scaffold.d.ts +12 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/session.d.ts +23 -0
- package/dist/session.d.ts.map +1 -0
- package/package.json +54 -0
- package/src/auth.ts +98 -0
- package/src/components/MemberHeader.tsx +57 -0
- package/src/config.ts +39 -0
- package/src/createMemberApp.tsx +66 -0
- package/src/index.ts +45 -0
- package/src/pages/AccountPage.tsx +39 -0
- package/src/pages/CoursePlayerPage.tsx +126 -0
- package/src/pages/MyCoursesPage.tsx +117 -0
- package/src/router.ts +45 -0
- package/src/scaffold.tsx +68 -0
- package/src/session.ts +26 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useMemberConfig } from '../config'
|
|
3
|
+
import { useMemberSession } from '../session'
|
|
4
|
+
import { signOutMember } from '../auth'
|
|
5
|
+
import { Link, navigateTo } from '../router'
|
|
6
|
+
|
|
7
|
+
export function MemberHeader() {
|
|
8
|
+
const config = useMemberConfig()
|
|
9
|
+
const session = useMemberSession()
|
|
10
|
+
const [menuOpen, setMenuOpen] = React.useState(false)
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<header className="sticky top-0 z-30 border-b border-border bg-card/95 backdrop-blur">
|
|
14
|
+
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
|
|
15
|
+
<Link to="/" className="flex items-center gap-2 font-bold text-foreground" data-testid="member-logo">
|
|
16
|
+
{config.logoUrl ? (
|
|
17
|
+
<img src={config.logoUrl} alt={config.name} className="h-7" />
|
|
18
|
+
) : (
|
|
19
|
+
<span>{config.name}</span>
|
|
20
|
+
)}
|
|
21
|
+
</Link>
|
|
22
|
+
|
|
23
|
+
{session.customerId ? (
|
|
24
|
+
<div className="relative">
|
|
25
|
+
<button
|
|
26
|
+
data-testid="member-usermenu"
|
|
27
|
+
onClick={() => setMenuOpen((v) => !v)}
|
|
28
|
+
className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted"
|
|
29
|
+
>
|
|
30
|
+
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
|
|
31
|
+
{(session.email ?? '?').charAt(0).toUpperCase()}
|
|
32
|
+
</span>
|
|
33
|
+
<span className="hidden sm:inline">{session.name || session.email}</span>
|
|
34
|
+
</button>
|
|
35
|
+
{menuOpen && (
|
|
36
|
+
<div className="absolute right-0 mt-2 w-44 overflow-hidden rounded-lg border border-border bg-card shadow-lg">
|
|
37
|
+
<button
|
|
38
|
+
onClick={() => { setMenuOpen(false); navigateTo('/account') }}
|
|
39
|
+
className="block w-full px-4 py-2.5 text-left text-sm text-foreground hover:bg-muted"
|
|
40
|
+
>
|
|
41
|
+
Minha conta
|
|
42
|
+
</button>
|
|
43
|
+
<button
|
|
44
|
+
data-testid="member-header-signout"
|
|
45
|
+
onClick={() => { setMenuOpen(false); void signOutMember(); navigateTo('/') }}
|
|
46
|
+
className="block w-full px-4 py-2.5 text-left text-sm text-foreground hover:bg-muted"
|
|
47
|
+
>
|
|
48
|
+
Sair
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
54
|
+
</div>
|
|
55
|
+
</header>
|
|
56
|
+
)
|
|
57
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// MemberConfig — the declarative surface for the learner portal. Pure data so
|
|
5
|
+
// it round-trips through an AppManifest (defineMember ↔ MemberScaffold).
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface MemberConfig {
|
|
9
|
+
name: string
|
|
10
|
+
/** Logo image URL shown in the header (falls back to the name). */
|
|
11
|
+
logoUrl?: string
|
|
12
|
+
locale?: string
|
|
13
|
+
/** Accent color (CSS color) for the player progress + active states. */
|
|
14
|
+
accent?: string
|
|
15
|
+
/** Auth adapter: 'mock' (default) | 'supabase' | a bring-your-own adapter. */
|
|
16
|
+
auth?: { adapter?: 'mock' | 'supabase' | unknown }
|
|
17
|
+
supabaseUrl?: string
|
|
18
|
+
supabaseAnonKey?: string
|
|
19
|
+
/** Grant the logged-in learner every published course (demo behaviour). When
|
|
20
|
+
* false, the learner only sees the courses they were explicitly enrolled in. */
|
|
21
|
+
autoEnroll?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ResolvedMemberConfig extends MemberConfig {
|
|
25
|
+
locale: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveConfig(config: MemberConfig): ResolvedMemberConfig {
|
|
29
|
+
return { ...config, locale: config.locale ?? 'pt-BR' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MemberConfigContext = React.createContext<ResolvedMemberConfig | null>(null)
|
|
33
|
+
export const MemberConfigProvider = MemberConfigContext.Provider
|
|
34
|
+
|
|
35
|
+
export function useMemberConfig(): ResolvedMemberConfig {
|
|
36
|
+
const ctx = React.useContext(MemberConfigContext)
|
|
37
|
+
if (!ctx) throw new Error('useMemberConfig must be used inside a MemberConfigProvider')
|
|
38
|
+
return ctx
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { MemberConfigProvider, resolveConfig, useMemberConfig } from './config'
|
|
3
|
+
import type { MemberConfig } from './config'
|
|
4
|
+
import { initMemberAuth, resolveAuthAdapter } from './auth'
|
|
5
|
+
import { useHashPath, matchPath } from './router'
|
|
6
|
+
import { MemberHeader } from './components/MemberHeader'
|
|
7
|
+
import { MyCoursesPage } from './pages/MyCoursesPage'
|
|
8
|
+
import { CoursePlayerPage } from './pages/CoursePlayerPage'
|
|
9
|
+
import { AccountPage } from './pages/AccountPage'
|
|
10
|
+
|
|
11
|
+
function RouteSwitch() {
|
|
12
|
+
const path = useHashPath()
|
|
13
|
+
|
|
14
|
+
const lesson = matchPath('/course/:slug/lesson/:id', path)
|
|
15
|
+
if (lesson?.slug) return <CoursePlayerPage slug={lesson.slug} lessonId={lesson.id} />
|
|
16
|
+
|
|
17
|
+
const course = matchPath('/course/:slug', path)
|
|
18
|
+
if (course?.slug) return <CoursePlayerPage slug={course.slug} />
|
|
19
|
+
|
|
20
|
+
if (matchPath('/account', path)) return <AccountPage />
|
|
21
|
+
|
|
22
|
+
return <MyCoursesPage />
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Side-effect runtime init shared by the factory and the manifest scaffold:
|
|
26
|
+
* wires learner auth (+ optional Supabase client). Idempotent. */
|
|
27
|
+
export function initMemberRuntime(config: MemberConfig): void {
|
|
28
|
+
// The courses data provider is owned by the host app (mock or Supabase). The
|
|
29
|
+
// portal only wires learner auth here; a Supabase-backed app sets the global
|
|
30
|
+
// client + provider before mounting.
|
|
31
|
+
initMemberAuth(
|
|
32
|
+
resolveAuthAdapter(config.auth?.adapter, { url: config.supabaseUrl, anonKey: config.supabaseAnonKey }),
|
|
33
|
+
{ autoEnroll: config.autoEnroll ?? true },
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Inner portal UI — reads everything from config context, so it is shared by
|
|
38
|
+
* createMemberApp and the manifest-driven MemberScaffold. */
|
|
39
|
+
export function MemberShell() {
|
|
40
|
+
const config = useMemberConfig()
|
|
41
|
+
return (
|
|
42
|
+
<div className="min-h-screen bg-background text-foreground" data-portal={config.name}>
|
|
43
|
+
{config.accent && (
|
|
44
|
+
<style>{`:root{--primary:${config.accent};}`}</style>
|
|
45
|
+
)}
|
|
46
|
+
<MemberHeader />
|
|
47
|
+
<RouteSwitch />
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Declarative member-portal factory — sugar over the manifest path (equivalent
|
|
53
|
+
* to renderApp(defineMember(config))). */
|
|
54
|
+
export function createMemberApp(config: MemberConfig): React.ComponentType {
|
|
55
|
+
const resolved = resolveConfig(config)
|
|
56
|
+
initMemberRuntime(config)
|
|
57
|
+
function MemberApp() {
|
|
58
|
+
return (
|
|
59
|
+
<MemberConfigProvider value={resolved}>
|
|
60
|
+
<MemberShell />
|
|
61
|
+
</MemberConfigProvider>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
MemberApp.displayName = 'MemberApp'
|
|
65
|
+
return MemberApp
|
|
66
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @fayz-ai/portal — the authenticated member/learner surface (counterpart of
|
|
2
|
+
// @fayz-ai/shop). Importing this package registers the 'member' scaffold.
|
|
3
|
+
export type { MemberConfig, ResolvedMemberConfig } from './config'
|
|
4
|
+
export { MemberConfigProvider, useMemberConfig, resolveConfig } from './config'
|
|
5
|
+
|
|
6
|
+
export { createMemberApp, initMemberRuntime, MemberShell } from './createMemberApp'
|
|
7
|
+
export { defineMember, MemberScaffold } from './scaffold'
|
|
8
|
+
|
|
9
|
+
export { useMemberSession } from './session'
|
|
10
|
+
export type { MemberSessionState } from './session'
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
initMemberAuth,
|
|
14
|
+
establishMemberSession,
|
|
15
|
+
signUpMember,
|
|
16
|
+
signOutMember,
|
|
17
|
+
customerIdForEmail,
|
|
18
|
+
resolveAuthAdapter,
|
|
19
|
+
} from './auth'
|
|
20
|
+
|
|
21
|
+
export { useHashPath, matchPath, navigateTo, Link } from './router'
|
|
22
|
+
|
|
23
|
+
export { MyCoursesPage } from './pages/MyCoursesPage'
|
|
24
|
+
export { CoursePlayerPage } from './pages/CoursePlayerPage'
|
|
25
|
+
export { AccountPage } from './pages/AccountPage'
|
|
26
|
+
export { MemberHeader } from './components/MemberHeader'
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Front-door re-exports: member apps depend on @fayz-ai/portal alone.
|
|
30
|
+
// Runtime (renderApp/defineApp) from @fayz-ai/core; the courses provider API
|
|
31
|
+
// from @fayz-ai/courses, so plugins.generated.ts wires providers through the
|
|
32
|
+
// portal front door. Mirrors the @fayz-ai/saas front door.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
export { renderApp, defineApp } from '@fayz-ai/core'
|
|
35
|
+
export {
|
|
36
|
+
setCoursesProvider,
|
|
37
|
+
getCoursesProvider,
|
|
38
|
+
getCoursesProviderOptional,
|
|
39
|
+
MockCoursesProvider,
|
|
40
|
+
createMockCoursesProvider,
|
|
41
|
+
useMyCourses,
|
|
42
|
+
useCourse,
|
|
43
|
+
useCourseProgress,
|
|
44
|
+
} from '@fayz-ai/courses'
|
|
45
|
+
export type { CoursesProvider, MockCoursesSeed } from '@fayz-ai/courses'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useMemberSession } from '../session'
|
|
3
|
+
import { signOutMember } from '../auth'
|
|
4
|
+
import { navigateTo } from '../router'
|
|
5
|
+
|
|
6
|
+
export function AccountPage() {
|
|
7
|
+
const session = useMemberSession()
|
|
8
|
+
|
|
9
|
+
if (!session.customerId) {
|
|
10
|
+
navigateTo('/')
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<main className="mx-auto max-w-xl px-4 py-10 sm:px-6">
|
|
16
|
+
<h1 className="mb-6 text-2xl font-bold text-foreground">Minha conta</h1>
|
|
17
|
+
<div className="space-y-4 rounded-xl border border-border bg-card p-6">
|
|
18
|
+
<Row label="Nome" value={session.name || '—'} />
|
|
19
|
+
<Row label="E-mail" value={session.email || '—'} />
|
|
20
|
+
<button
|
|
21
|
+
data-testid="account-signout"
|
|
22
|
+
onClick={() => { void signOutMember(); navigateTo('/') }}
|
|
23
|
+
className="mt-2 rounded-md border border-border bg-card px-4 py-2 text-sm font-medium text-foreground hover:bg-muted"
|
|
24
|
+
>
|
|
25
|
+
Sair
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
</main>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Row({ label, value }: { label: string; value: string }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="flex justify-between border-b border-border pb-3 last:border-0 last:pb-0">
|
|
35
|
+
<span className="text-sm text-muted-foreground">{label}</span>
|
|
36
|
+
<span className="text-sm font-medium text-foreground">{value}</span>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useCourse, useCourseProgress, getCoursesProvider } from '@fayz-ai/courses'
|
|
3
|
+
import { useMemberSession } from '../session'
|
|
4
|
+
import { navigateTo } from '../router'
|
|
5
|
+
|
|
6
|
+
export function CoursePlayerPage({ slug, lessonId }: { slug: string; lessonId?: string }) {
|
|
7
|
+
const session = useMemberSession()
|
|
8
|
+
const { course, modules, lessons, loading } = useCourse(slug)
|
|
9
|
+
const [enrollmentId, setEnrollmentId] = React.useState<string | null>(null)
|
|
10
|
+
|
|
11
|
+
// Resolve this learner's enrollment for the course → drives progress.
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
let active = true
|
|
14
|
+
if (!course || !session.customerId) return
|
|
15
|
+
;(async () => {
|
|
16
|
+
const provider = getCoursesProvider()
|
|
17
|
+
const enrollments = await provider.listEnrollments(session.customerId!)
|
|
18
|
+
let enrollment = enrollments.find((e) => e.courseId === course.id)
|
|
19
|
+
if (!enrollment) enrollment = await provider.enroll(course.id, session.customerId!)
|
|
20
|
+
if (active) setEnrollmentId(enrollment.id)
|
|
21
|
+
})()
|
|
22
|
+
return () => { active = false }
|
|
23
|
+
}, [course, session.customerId])
|
|
24
|
+
|
|
25
|
+
const { isCompleted, markLessonComplete } = useCourseProgress(enrollmentId)
|
|
26
|
+
|
|
27
|
+
if (loading || !course) {
|
|
28
|
+
return <main className="px-4 py-12 text-center text-muted-foreground">Carregando…</main>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Flatten lessons in true curriculum order (module order, then lesson order).
|
|
32
|
+
// The provider sorts lessons by their per-module sortOrder, so a plain flat
|
|
33
|
+
// list interleaves modules — walk modules to get the real sequence.
|
|
34
|
+
const orderedLessons = modules.flatMap((m) => lessons.filter((l) => l.moduleId === m.id))
|
|
35
|
+
|
|
36
|
+
const activeLesson = (lessonId && orderedLessons.find((l) => l.id === lessonId)) || orderedLessons[0]
|
|
37
|
+
if (!activeLesson) {
|
|
38
|
+
return <main className="px-4 py-12 text-center text-muted-foreground">Curso sem aulas ainda.</main>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const idx = orderedLessons.findIndex((l) => l.id === activeLesson.id)
|
|
42
|
+
const nextLesson = idx >= 0 && idx < orderedLessons.length - 1 ? orderedLessons[idx + 1] : null
|
|
43
|
+
|
|
44
|
+
function open(lid: string) {
|
|
45
|
+
navigateTo(`/course/${course!.slug}/lesson/${lid}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function completeAndAdvance() {
|
|
49
|
+
await markLessonComplete(activeLesson!.id, true)
|
|
50
|
+
if (nextLesson) open(nextLesson.id)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<main className="mx-auto grid max-w-6xl grid-cols-1 gap-6 px-4 py-6 sm:px-6 lg:grid-cols-[1fr_320px]">
|
|
55
|
+
{/* Player + lesson body */}
|
|
56
|
+
<div>
|
|
57
|
+
<button onClick={() => navigateTo('/')} className="mb-3 text-sm text-muted-foreground hover:text-foreground">
|
|
58
|
+
← Meus cursos
|
|
59
|
+
</button>
|
|
60
|
+
<div className="aspect-video w-full overflow-hidden rounded-xl bg-black">
|
|
61
|
+
<iframe
|
|
62
|
+
key={activeLesson.id}
|
|
63
|
+
data-testid="lesson-video"
|
|
64
|
+
src={activeLesson.videoUrl}
|
|
65
|
+
title={activeLesson.title}
|
|
66
|
+
className="h-full w-full"
|
|
67
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
68
|
+
allowFullScreen
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="mt-4 flex items-start justify-between gap-4">
|
|
72
|
+
<div>
|
|
73
|
+
<p className="text-xs text-muted-foreground">{course.title}</p>
|
|
74
|
+
<h1 data-testid="lesson-title" className="text-xl font-bold text-foreground">{activeLesson.title}</h1>
|
|
75
|
+
</div>
|
|
76
|
+
<button
|
|
77
|
+
data-testid="mark-complete"
|
|
78
|
+
onClick={completeAndAdvance}
|
|
79
|
+
className={`shrink-0 rounded-md px-4 py-2 text-sm font-semibold ${
|
|
80
|
+
isCompleted(activeLesson.id)
|
|
81
|
+
? 'border border-border bg-card text-muted-foreground'
|
|
82
|
+
: 'bg-primary text-primary-foreground'
|
|
83
|
+
}`}
|
|
84
|
+
>
|
|
85
|
+
{isCompleted(activeLesson.id) ? '✓ Concluída' : 'Marcar como concluída'}
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
{activeLesson.description && (
|
|
89
|
+
<p className="mt-3 text-sm text-muted-foreground">{activeLesson.description}</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Curriculum sidebar */}
|
|
94
|
+
<aside data-testid="lesson-sidebar" className="rounded-xl border border-border bg-card p-3">
|
|
95
|
+
{modules.map((mod) => (
|
|
96
|
+
<div key={mod.id} className="mb-3">
|
|
97
|
+
<p className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{mod.title}</p>
|
|
98
|
+
<ul className="space-y-0.5">
|
|
99
|
+
{lessons.filter((l) => l.moduleId === mod.id).map((lesson) => {
|
|
100
|
+
const done = isCompleted(lesson.id)
|
|
101
|
+
const current = lesson.id === activeLesson.id
|
|
102
|
+
return (
|
|
103
|
+
<li key={lesson.id}>
|
|
104
|
+
<button
|
|
105
|
+
data-testid="sidebar-lesson"
|
|
106
|
+
data-completed={done ? 'true' : 'false'}
|
|
107
|
+
onClick={() => open(lesson.id)}
|
|
108
|
+
className={`flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm ${
|
|
109
|
+
current ? 'bg-muted font-medium text-foreground' : 'text-muted-foreground hover:bg-muted/60'
|
|
110
|
+
}`}
|
|
111
|
+
>
|
|
112
|
+
<span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] ${
|
|
113
|
+
done ? 'border-primary bg-primary text-primary-foreground' : 'border-border'
|
|
114
|
+
}`}>{done ? '✓' : ''}</span>
|
|
115
|
+
<span className="line-clamp-2">{lesson.title}</span>
|
|
116
|
+
</button>
|
|
117
|
+
</li>
|
|
118
|
+
)
|
|
119
|
+
})}
|
|
120
|
+
</ul>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</aside>
|
|
124
|
+
</main>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { useMyCourses } from '@fayz-ai/courses'
|
|
3
|
+
import { useMemberSession } from '../session'
|
|
4
|
+
import { establishMemberSession } from '../auth'
|
|
5
|
+
import { useMemberConfig } from '../config'
|
|
6
|
+
import { navigateTo } from '../router'
|
|
7
|
+
|
|
8
|
+
function AuthForm() {
|
|
9
|
+
const config = useMemberConfig()
|
|
10
|
+
const [email, setEmail] = useState('')
|
|
11
|
+
const [password, setPassword] = useState('')
|
|
12
|
+
const [busy, setBusy] = useState(false)
|
|
13
|
+
const [error, setError] = useState<string | null>(null)
|
|
14
|
+
|
|
15
|
+
async function submit(e: React.FormEvent) {
|
|
16
|
+
e.preventDefault()
|
|
17
|
+
setError(null)
|
|
18
|
+
if (!email.trim() || !password) return
|
|
19
|
+
setBusy(true)
|
|
20
|
+
try {
|
|
21
|
+
await establishMemberSession(email, { password })
|
|
22
|
+
} catch (err) {
|
|
23
|
+
setError(err instanceof Error ? err.message : 'Não foi possível entrar.')
|
|
24
|
+
} finally {
|
|
25
|
+
setBusy(false)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="mx-auto max-w-sm rounded-xl border border-border bg-card p-7 shadow-sm">
|
|
31
|
+
<h2 className="text-xl font-semibold text-foreground">{config.name}</h2>
|
|
32
|
+
<p className="mt-1 text-sm text-muted-foreground">Entre para acessar seus cursos.</p>
|
|
33
|
+
<form className="mt-5 space-y-3" onSubmit={submit}>
|
|
34
|
+
<input
|
|
35
|
+
data-testid="member-email"
|
|
36
|
+
type="email"
|
|
37
|
+
required
|
|
38
|
+
placeholder="voce@exemplo.com"
|
|
39
|
+
value={email}
|
|
40
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
41
|
+
className="w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm"
|
|
42
|
+
/>
|
|
43
|
+
<input
|
|
44
|
+
data-testid="member-password"
|
|
45
|
+
type="password"
|
|
46
|
+
required
|
|
47
|
+
placeholder="Senha"
|
|
48
|
+
value={password}
|
|
49
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
50
|
+
className="w-full rounded-md border border-input bg-background px-3 py-2.5 text-sm"
|
|
51
|
+
/>
|
|
52
|
+
{error && <p data-testid="member-auth-error" className="text-sm text-destructive">{error}</p>}
|
|
53
|
+
<button
|
|
54
|
+
type="submit"
|
|
55
|
+
data-testid="member-signin"
|
|
56
|
+
disabled={busy}
|
|
57
|
+
className="w-full rounded-md bg-primary py-2.5 font-semibold text-primary-foreground disabled:opacity-60"
|
|
58
|
+
>
|
|
59
|
+
{busy ? 'Entrando…' : 'Entrar'}
|
|
60
|
+
</button>
|
|
61
|
+
</form>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function MyCoursesPage() {
|
|
67
|
+
const session = useMemberSession()
|
|
68
|
+
const { courses, loading } = useMyCourses(session.customerId)
|
|
69
|
+
|
|
70
|
+
if (!session.customerId) {
|
|
71
|
+
return (
|
|
72
|
+
<main className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
|
73
|
+
<AuthForm />
|
|
74
|
+
</main>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<main className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
|
80
|
+
<h1 className="mb-1 text-2xl font-bold tracking-tight text-foreground">Meus cursos</h1>
|
|
81
|
+
<p className="mb-6 text-sm text-muted-foreground">{session.email}</p>
|
|
82
|
+
|
|
83
|
+
{loading ? (
|
|
84
|
+
<div className="py-16 text-center text-muted-foreground">Carregando…</div>
|
|
85
|
+
) : courses.length === 0 ? (
|
|
86
|
+
<div data-testid="member-empty" className="rounded-xl border border-border bg-card py-16 text-center text-muted-foreground">
|
|
87
|
+
Você ainda não tem cursos.
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<div data-testid="member-courses" className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
91
|
+
{courses.map(({ course, progressPercent, completedLessons, totalLessons }) => (
|
|
92
|
+
<button
|
|
93
|
+
key={course.id}
|
|
94
|
+
data-testid="member-course-card"
|
|
95
|
+
data-slug={course.slug}
|
|
96
|
+
onClick={() => navigateTo(`/course/${course.slug}`)}
|
|
97
|
+
className="group overflow-hidden rounded-xl border border-border bg-card text-left transition hover:border-primary hover:shadow-md"
|
|
98
|
+
>
|
|
99
|
+
<div className="aspect-video w-full overflow-hidden bg-muted">
|
|
100
|
+
{course.thumbnailUrl && <img src={course.thumbnailUrl} alt="" className="h-full w-full object-cover" />}
|
|
101
|
+
</div>
|
|
102
|
+
<div className="space-y-2 p-4">
|
|
103
|
+
<h3 className="font-semibold text-foreground line-clamp-2">{course.title}</h3>
|
|
104
|
+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
105
|
+
<div className="h-full rounded-full bg-primary transition-all" style={{ width: `${progressPercent}%` }} />
|
|
106
|
+
</div>
|
|
107
|
+
<p className="text-xs text-muted-foreground">
|
|
108
|
+
{completedLessons}/{totalLessons} aulas · {progressPercent}%
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</main>
|
|
116
|
+
)
|
|
117
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import { hashRouterAdapter } from '@fayz-ai/core'
|
|
3
|
+
|
|
4
|
+
// Minimal hash router for the member portal — the same lightweight approach the
|
|
5
|
+
// storefront uses (no react-router; avoids singleton hazards under source-alias).
|
|
6
|
+
|
|
7
|
+
const adapter = hashRouterAdapter()
|
|
8
|
+
|
|
9
|
+
export function navigateTo(to: string): void {
|
|
10
|
+
adapter.navigate(to)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useHashPath(): string {
|
|
14
|
+
const [path, setPath] = useState(() => adapter.getCurrentPath() || '/')
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
return adapter.onPathChange((p) => {
|
|
17
|
+
setPath(p || '/')
|
|
18
|
+
if (typeof window !== 'undefined') window.scrollTo(0, 0)
|
|
19
|
+
})
|
|
20
|
+
}, [])
|
|
21
|
+
return path
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function matchPath(pattern: string, path: string): Record<string, string> | null {
|
|
25
|
+
const cleanPath = (path.split('?')[0] ?? '').replace(/\/+$/, '') || '/'
|
|
26
|
+
const patternParts = pattern.split('/').filter(Boolean)
|
|
27
|
+
const pathParts = cleanPath.split('/').filter(Boolean)
|
|
28
|
+
if (patternParts.length !== pathParts.length) return null
|
|
29
|
+
const params: Record<string, string> = {}
|
|
30
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
31
|
+
const pat = patternParts[i]!
|
|
32
|
+
const part = pathParts[i]!
|
|
33
|
+
if (pat.startsWith(':')) params[pat.slice(1)] = decodeURIComponent(part)
|
|
34
|
+
else if (pat !== part) return null
|
|
35
|
+
}
|
|
36
|
+
return params
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
40
|
+
to: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Link({ to, children, ...rest }: LinkProps) {
|
|
44
|
+
return React.createElement('a', { href: `#${to}`, ...rest }, children)
|
|
45
|
+
}
|
package/src/scaffold.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { registerScaffold, defineApp } from '@fayz-ai/core'
|
|
3
|
+
import type { AppManifest } from '@fayz-ai/core'
|
|
4
|
+
import { MemberConfigProvider, resolveConfig } from './config'
|
|
5
|
+
import type { MemberConfig } from './config'
|
|
6
|
+
import { initMemberRuntime, MemberShell } from './createMemberApp'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Member scaffold — renders the learner portal from a pure-data AppManifest.
|
|
10
|
+
// Mirrors the storefront scaffold's dual path: defineMember (config → manifest)
|
|
11
|
+
// and MemberScaffold (manifest → render). This is the SDK's third surface type
|
|
12
|
+
// (authenticated member area), alongside 'admin' and 'storefront'.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
function slug(name: string): string {
|
|
16
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'app'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function defineMember(config: MemberConfig): AppManifest {
|
|
20
|
+
return defineApp({
|
|
21
|
+
id: slug(config.name),
|
|
22
|
+
name: config.name,
|
|
23
|
+
backend: config.supabaseUrl ? { provider: 'supabase', url: config.supabaseUrl } : { provider: 'mock' },
|
|
24
|
+
locale: { default: config.locale ?? 'pt-BR', supported: [config.locale ?? 'pt-BR'] },
|
|
25
|
+
surfaces: {
|
|
26
|
+
member: {
|
|
27
|
+
scaffold: 'member',
|
|
28
|
+
options: {
|
|
29
|
+
logoUrl: config.logoUrl,
|
|
30
|
+
accent: config.accent,
|
|
31
|
+
autoEnroll: config.autoEnroll ?? true,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function manifestToMemberConfig(manifest: AppManifest, surfaceName: string): MemberConfig {
|
|
39
|
+
const o = (manifest.surfaces[surfaceName]?.options ?? {}) as Record<string, unknown>
|
|
40
|
+
return {
|
|
41
|
+
name: manifest.name,
|
|
42
|
+
locale: (manifest.locale as { default?: string } | undefined)?.default,
|
|
43
|
+
logoUrl: o.logoUrl as string | undefined,
|
|
44
|
+
accent: o.accent as string | undefined,
|
|
45
|
+
autoEnroll: (o.autoEnroll as boolean | undefined) ?? true,
|
|
46
|
+
supabaseUrl: manifest.backend?.provider === 'supabase' ? manifest.backend.url : undefined,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function MemberScaffold({ manifest, surface }: { manifest: AppManifest; surface: string }) {
|
|
51
|
+
const config = React.useMemo(() => manifestToMemberConfig(manifest, surface), [manifest, surface])
|
|
52
|
+
const resolved = React.useMemo(() => resolveConfig(config), [config])
|
|
53
|
+
const inited = React.useRef(false)
|
|
54
|
+
if (!inited.current) {
|
|
55
|
+
initMemberRuntime(config)
|
|
56
|
+
inited.current = true
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<MemberConfigProvider value={resolved}>
|
|
60
|
+
<MemberShell />
|
|
61
|
+
</MemberConfigProvider>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
MemberScaffold.displayName = 'MemberScaffold'
|
|
65
|
+
|
|
66
|
+
// Self-register so renderApp(manifest, { surface: 'member' }) resolves once this
|
|
67
|
+
// package is imported.
|
|
68
|
+
registerScaffold('member', MemberScaffold, { source: 'sdk', label: 'Member' })
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { persist } from 'zustand/middleware'
|
|
3
|
+
|
|
4
|
+
// Learner session — mirrors the auth identity for synchronous UI access. The
|
|
5
|
+
// customerId is the key courses enrollments/progress are stored against.
|
|
6
|
+
|
|
7
|
+
export interface MemberSessionState {
|
|
8
|
+
customerId: string | null
|
|
9
|
+
email: string | null
|
|
10
|
+
name: string | null
|
|
11
|
+
setSession(s: { customerId: string; email: string; name?: string | null }): void
|
|
12
|
+
signOut(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useMemberSession = create<MemberSessionState>()(
|
|
16
|
+
persist(
|
|
17
|
+
(set) => ({
|
|
18
|
+
customerId: null,
|
|
19
|
+
email: null,
|
|
20
|
+
name: null,
|
|
21
|
+
setSession: ({ customerId, email, name }) => set({ customerId, email, name: name ?? null }),
|
|
22
|
+
signOut: () => set({ customerId: null, email: null, name: null }),
|
|
23
|
+
}),
|
|
24
|
+
{ name: 'fayz.portal.session.v1' },
|
|
25
|
+
),
|
|
26
|
+
)
|