@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.
@@ -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
+ }
@@ -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
+ )