spine-framework-portal 0.1.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.
- package/components/CommunityModerator.tsx +104 -0
- package/components/CourseProgress.tsx +169 -0
- package/components/HelpfulnessRating.tsx +59 -0
- package/components/KBGenerator.tsx +159 -0
- package/components/ParticipationIndicator.tsx +23 -0
- package/components/PortalFooter.tsx +32 -0
- package/components/PortalHeader.tsx +149 -0
- package/components/PortalSidebar.tsx +140 -0
- package/components/ProgressTracker.tsx +41 -0
- package/components/SearchFilterBar.tsx +26 -0
- package/components/StatusBadge.tsx +44 -0
- package/components/ThreadPanel.tsx +102 -0
- package/components/UnifiedItemCard.tsx +107 -0
- package/components/VotingComponent.tsx +51 -0
- package/functions/custom_portal-signals.ts +33 -0
- package/index.tsx +47 -0
- package/manifest.json +59 -0
- package/package.json +30 -0
- package/pages/AccountPage.tsx +115 -0
- package/pages/CommunityPage.tsx +208 -0
- package/pages/ContentPage.tsx +269 -0
- package/pages/CoursesPage.tsx +253 -0
- package/pages/HomePage.tsx +137 -0
- package/pages/IntegrityPage.tsx +289 -0
- package/pages/KnowledgePage.tsx +124 -0
- package/pages/MarketplacePage.tsx +250 -0
- package/pages/TicketsPage.tsx +445 -0
- package/seed/link-types.json +1 -0
- package/seed/triggers.json +17 -0
- package/seed/types.json +136 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useParams, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { usePortalItems, usePipelineIntegration, useItemManagement } from '../hooks/usePortalHooks'
|
|
4
|
+
import { UnifiedItemCard } from '../components/UnifiedItemCard'
|
|
5
|
+
|
|
6
|
+
// Simple UI components to avoid import issues
|
|
7
|
+
function Button({ children, variant = 'primary', onClick, disabled, loading }: {
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
variant?: 'primary' | 'outline' | 'ghost';
|
|
10
|
+
onClick?: () => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
const baseClasses = "px-4 py-2 rounded-md font-medium transition-colors"
|
|
15
|
+
const variantClasses = {
|
|
16
|
+
primary: "bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-400",
|
|
17
|
+
outline: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50",
|
|
18
|
+
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
className={`${baseClasses} ${variantClasses[variant]}`}
|
|
24
|
+
onClick={onClick}
|
|
25
|
+
disabled={disabled || loading}
|
|
26
|
+
>
|
|
27
|
+
{loading ? 'Loading...' : children}
|
|
28
|
+
</button>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function Card({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
|
33
|
+
return (
|
|
34
|
+
<div className={`bg-white rounded-lg shadow border border-gray-200 ${className}`}>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
Card.Header = function({ children }: { children: React.ReactNode }) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="px-6 py-4 border-b border-gray-200">
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Card.Content = function({ children }: { children: React.ReactNode }) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="px-6 py-4">
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function Badge({ children, variant = 'default' }: { children: React.ReactNode; variant?: string }) {
|
|
57
|
+
const variantClasses = {
|
|
58
|
+
default: "bg-gray-100 text-gray-800",
|
|
59
|
+
success: "bg-green-100 text-green-800",
|
|
60
|
+
error: "bg-red-100 text-red-800",
|
|
61
|
+
warning: "bg-yellow-100 text-yellow-800",
|
|
62
|
+
info: "bg-blue-100 text-blue-800"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<span className={`px-2 py-1 rounded-full text-xs font-medium ${variantClasses[variant as keyof typeof variantClasses] || variantClasses.default}`}>
|
|
67
|
+
{children}
|
|
68
|
+
</span>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Mock data for content items
|
|
73
|
+
const mockContentItems = [
|
|
74
|
+
{
|
|
75
|
+
id: 'kb1',
|
|
76
|
+
type_slug: 'kb_article',
|
|
77
|
+
context: 'kb',
|
|
78
|
+
title: 'Getting Started with Spine Portal',
|
|
79
|
+
status: 'published',
|
|
80
|
+
created_at: new Date().toISOString(),
|
|
81
|
+
helpful_count: 12,
|
|
82
|
+
not_helpful_count: 1
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'course1',
|
|
86
|
+
type_slug: 'course_lesson',
|
|
87
|
+
context: 'course',
|
|
88
|
+
title: 'Introduction to Customer Support',
|
|
89
|
+
status: 'published',
|
|
90
|
+
created_at: new Date().toISOString(),
|
|
91
|
+
helpful_count: 8,
|
|
92
|
+
not_helpful_count: 0
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Content Page - Unified view for Knowledge Base articles and Course lessons
|
|
98
|
+
*/
|
|
99
|
+
export function ContentPage() {
|
|
100
|
+
const { id } = useParams()
|
|
101
|
+
const navigate = useNavigate()
|
|
102
|
+
const [filter, setFilter] = useState<'all' | 'kb' | 'course'>('all')
|
|
103
|
+
|
|
104
|
+
// Use the unified portal hooks (automatically switches between mock/real)
|
|
105
|
+
const { items, loading, error, refetch } = usePortalItems('content', {
|
|
106
|
+
context: filter === 'all' ? undefined : filter
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const { triggerPipeline, loading: pipelineLoading } = usePipelineIntegration()
|
|
110
|
+
const { createItem } = useItemManagement()
|
|
111
|
+
|
|
112
|
+
// Find selected item based on URL parameter
|
|
113
|
+
const selectedItem = id ? items.find(item => item.id === id) : null
|
|
114
|
+
|
|
115
|
+
const handleVote = (itemId: string, helpful: boolean) => {
|
|
116
|
+
// TODO: Implement voting functionality
|
|
117
|
+
console.log('Vote for item', itemId, helpful ? 'helpful' : 'not helpful')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const handleProgress = (itemId: string) => {
|
|
121
|
+
// TODO: Implement progress tracking
|
|
122
|
+
console.log('Progress updated for item:', itemId)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const handleGenerateKB = async (sourceItemId: string, sourceType: 'ticket' | 'question') => {
|
|
126
|
+
try {
|
|
127
|
+
const pipelineId = sourceType === 'ticket'
|
|
128
|
+
? 'kb-generation-from-tickets'
|
|
129
|
+
: 'kb-generation-from-questions'
|
|
130
|
+
|
|
131
|
+
const execution = await triggerPipeline(pipelineId, {
|
|
132
|
+
source_item_id: sourceItemId,
|
|
133
|
+
source_type: sourceType,
|
|
134
|
+
triggered_by: 'user_action'
|
|
135
|
+
})
|
|
136
|
+
console.log('KB generation started via core pipeline:', execution)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error('KB generation failed:', error)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const handleCreateArticle = async () => {
|
|
143
|
+
try {
|
|
144
|
+
const newItem = await createItem({
|
|
145
|
+
type_slug: 'kb_article',
|
|
146
|
+
context: 'kb',
|
|
147
|
+
title: 'New KB Article',
|
|
148
|
+
status: 'draft'
|
|
149
|
+
})
|
|
150
|
+
console.log('Created article:', newItem)
|
|
151
|
+
refetch()
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error('Failed to create article:', error)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const handleCreateLesson = async () => {
|
|
158
|
+
try {
|
|
159
|
+
const newItem = await createItem({
|
|
160
|
+
type_slug: 'course_lesson',
|
|
161
|
+
context: 'course',
|
|
162
|
+
title: 'New Course Lesson',
|
|
163
|
+
status: 'draft'
|
|
164
|
+
})
|
|
165
|
+
console.log('Created lesson:', newItem)
|
|
166
|
+
refetch()
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error('Failed to create lesson:', error)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Handle loading state
|
|
173
|
+
if (loading) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="max-w-6xl mx-auto space-y-6">
|
|
176
|
+
<div className="flex justify-center py-8">
|
|
177
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Handle error state
|
|
184
|
+
if (error) {
|
|
185
|
+
return (
|
|
186
|
+
<div className="max-w-6xl mx-auto space-y-6">
|
|
187
|
+
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
188
|
+
<h3 className="text-red-800 font-medium">Error loading content</h3>
|
|
189
|
+
<p className="text-red-600">{error.message}</p>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (selectedItem) {
|
|
196
|
+
return (
|
|
197
|
+
<div className="max-w-4xl mx-auto space-y-6">
|
|
198
|
+
<Button variant="ghost" onClick={() => navigate('/portal/content')}>
|
|
199
|
+
← Back to Content
|
|
200
|
+
</Button>
|
|
201
|
+
|
|
202
|
+
<UnifiedItemCard
|
|
203
|
+
item={selectedItem}
|
|
204
|
+
onVote={(helpful) => handleVote(selectedItem.id, helpful)}
|
|
205
|
+
onProgress={() => handleProgress(selectedItem.id)}
|
|
206
|
+
showThread={true}
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div className="max-w-6xl mx-auto space-y-6">
|
|
214
|
+
<div className="flex justify-between items-center">
|
|
215
|
+
<div>
|
|
216
|
+
<h1 className="text-2xl font-bold text-gray-900">Knowledge Base & Courses</h1>
|
|
217
|
+
<p className="text-gray-600">Articles and learning materials</p>
|
|
218
|
+
</div>
|
|
219
|
+
<div className="flex gap-2">
|
|
220
|
+
<Button onClick={handleCreateArticle}>
|
|
221
|
+
+ New Article
|
|
222
|
+
</Button>
|
|
223
|
+
<Button onClick={handleCreateLesson}>
|
|
224
|
+
+ New Lesson
|
|
225
|
+
</Button>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div className="flex gap-2">
|
|
230
|
+
<Button
|
|
231
|
+
variant={filter === 'all' ? 'primary' : 'outline'}
|
|
232
|
+
onClick={() => setFilter('all')}
|
|
233
|
+
>
|
|
234
|
+
All
|
|
235
|
+
</Button>
|
|
236
|
+
<Button
|
|
237
|
+
variant={filter === 'kb' ? 'primary' : 'outline'}
|
|
238
|
+
onClick={() => setFilter('kb')}
|
|
239
|
+
>
|
|
240
|
+
Knowledge Base
|
|
241
|
+
</Button>
|
|
242
|
+
<Button
|
|
243
|
+
variant={filter === 'course' ? 'primary' : 'outline'}
|
|
244
|
+
onClick={() => setFilter('course')}
|
|
245
|
+
>
|
|
246
|
+
Courses
|
|
247
|
+
</Button>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div className="space-y-4">
|
|
251
|
+
{items.map(item => (
|
|
252
|
+
<UnifiedItemCard
|
|
253
|
+
key={item.id}
|
|
254
|
+
item={item}
|
|
255
|
+
onVote={(helpful) => handleVote(item.id, helpful)}
|
|
256
|
+
onProgress={() => handleProgress(item.id)}
|
|
257
|
+
onClick={() => navigate(`/portal/content/${item.id}`)}
|
|
258
|
+
/>
|
|
259
|
+
))}
|
|
260
|
+
|
|
261
|
+
{items.length === 0 && (
|
|
262
|
+
<div className="text-center py-8 text-gray-500">
|
|
263
|
+
No content found. Create your first KB article or course lesson!
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { CheckCircle, Circle, PlayCircle, GraduationCap, Send } from 'lucide-react'
|
|
3
|
+
import { useCourseLessons, type CourseItem } from '../hooks/useCourses'
|
|
4
|
+
import { usePortalSignal } from '../hooks/usePortalSignal'
|
|
5
|
+
import { useItemProgress, useUpsertProgress } from '../hooks/useItemProgress'
|
|
6
|
+
import { usePortalThread } from '../hooks/usePortalThreads'
|
|
7
|
+
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
8
|
+
import { useAuth } from '@core/contexts/AuthContext'
|
|
9
|
+
import { SearchFilterBar } from '../components/SearchFilterBar'
|
|
10
|
+
import { Button } from '@core/components/ui/button'
|
|
11
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
12
|
+
import { Badge } from '@core/components/ui/badge'
|
|
13
|
+
import { Separator } from '@core/components/ui/separator'
|
|
14
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
15
|
+
import { Progress } from '@core/components/ui/progress'
|
|
16
|
+
import { Textarea } from '@core/components/ui/textarea'
|
|
17
|
+
|
|
18
|
+
const PROGRESS_TYPE_SLUG = 'course_lesson_progress'
|
|
19
|
+
const LAST_LESSON_KEY = 'portal:last-lesson'
|
|
20
|
+
|
|
21
|
+
function groupByCourse(lessons: CourseItem[]): Map<string, CourseItem[]> {
|
|
22
|
+
const map = new Map<string, CourseItem[]>()
|
|
23
|
+
for (const lesson of lessons) {
|
|
24
|
+
const courseKey = (lesson.data?.course_title as string) || (lesson.data?.course_id as string) || 'General'
|
|
25
|
+
if (!map.has(courseKey)) map.set(courseKey, [])
|
|
26
|
+
map.get(courseKey)!.push(lesson)
|
|
27
|
+
}
|
|
28
|
+
for (const [key, list] of map) {
|
|
29
|
+
map.set(key, [...list].sort((a, b) => {
|
|
30
|
+
const sa = (a.data?.sequence as number) ?? 9999
|
|
31
|
+
const sb = (b.data?.sequence as number) ?? 9999
|
|
32
|
+
return sa - sb
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
return map
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function CoursesPage() {
|
|
39
|
+
const { user } = useAuth()
|
|
40
|
+
const [search, setSearch] = useState('')
|
|
41
|
+
const [selectedCourse, setSelectedCourse] = useState<string | null>(null)
|
|
42
|
+
const [selectedId, setSelectedId] = useState<string | null>(() => localStorage.getItem(LAST_LESSON_KEY))
|
|
43
|
+
const [replyText, setReplyText] = useState('')
|
|
44
|
+
const [replying, setReplying] = useState(false)
|
|
45
|
+
const progressTypeIdRef = useRef<string | null>(null)
|
|
46
|
+
|
|
47
|
+
const { lessons, loading, error } = useCourseLessons()
|
|
48
|
+
const lessonIds = lessons.map((l) => l.id)
|
|
49
|
+
const { progressMap, refetch: refetchProgress } = useItemProgress(user?.id ?? null, lessonIds)
|
|
50
|
+
const { upsert } = useUpsertProgress()
|
|
51
|
+
const { messages, reply } = usePortalThread('items', selectedId)
|
|
52
|
+
const { sendSignal } = usePortalSignal()
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!selectedId || lessons.length === 0) return
|
|
56
|
+
const lesson = lessons.find((l) => l.id === selectedId)
|
|
57
|
+
if (lesson) {
|
|
58
|
+
const key = (lesson.data?.course_title as string) || (lesson.data?.course_id as string) || 'General'
|
|
59
|
+
setSelectedCourse(key)
|
|
60
|
+
}
|
|
61
|
+
}, [lessons, selectedId])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
getTypeIdAsync(PROGRESS_TYPE_SLUG).then((id) => { progressTypeIdRef.current = id })
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
const courseMap = groupByCourse(lessons)
|
|
68
|
+
const courseNames = Array.from(courseMap.keys())
|
|
69
|
+
const filteredCourses = courseNames.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
|
|
70
|
+
const chaptersForCourse = selectedCourse ? (courseMap.get(selectedCourse) ?? []) : []
|
|
71
|
+
const selected = lessons.find((l) => l.id === selectedId) ?? null
|
|
72
|
+
|
|
73
|
+
const getStatus = (id: string) => progressMap.get(id)?.status ?? 'not_started'
|
|
74
|
+
|
|
75
|
+
const handleSelectLesson = async (lessonId: string) => {
|
|
76
|
+
setSelectedId(lessonId)
|
|
77
|
+
localStorage.setItem(LAST_LESSON_KEY, lessonId)
|
|
78
|
+
const typeId = progressTypeIdRef.current
|
|
79
|
+
if (!typeId || !user?.id || !user?.account_id) return
|
|
80
|
+
const existing = progressMap.get(lessonId)
|
|
81
|
+
if (!existing || existing.status === 'not_started') {
|
|
82
|
+
await upsert({ personId: user.id, itemId: lessonId, typeId, accountId: user.account_id, status: 'in_progress' })
|
|
83
|
+
refetchProgress()
|
|
84
|
+
sendSignal('lesson_start', 'Started a course lesson')
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const handleComplete = async () => {
|
|
89
|
+
if (!selectedId || !user?.id || !user?.account_id) return
|
|
90
|
+
const typeId = progressTypeIdRef.current
|
|
91
|
+
if (!typeId) return
|
|
92
|
+
await upsert({ personId: user.id, itemId: selectedId, typeId, accountId: user.account_id, status: 'completed' })
|
|
93
|
+
refetchProgress()
|
|
94
|
+
sendSignal('lesson_complete', 'Completed a course lesson')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const handleReply = async () => {
|
|
98
|
+
if (!replyText.trim()) return
|
|
99
|
+
setReplying(true)
|
|
100
|
+
try { await reply(replyText.trim()); setReplyText('') }
|
|
101
|
+
finally { setReplying(false) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const isCompleted = selectedId ? getStatus(selectedId) === 'completed' : false
|
|
105
|
+
const videoUrl = selected?.data?.video_url as string | undefined
|
|
106
|
+
const content = selected?.data?.content as string | undefined
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className="flex flex-col h-full">
|
|
110
|
+
<div className="flex items-center gap-3 px-4 py-3 border-b border-border bg-background shrink-0">
|
|
111
|
+
<SearchFilterBar placeholder="Search courses…" value={search} onChange={setSearch} />
|
|
112
|
+
</div>
|
|
113
|
+
{error && <div className="px-4 py-2 text-sm text-destructive border-b border-border shrink-0">{error}</div>}
|
|
114
|
+
<div className="flex flex-1 min-h-0">
|
|
115
|
+
|
|
116
|
+
{/* Col 1 — course list */}
|
|
117
|
+
<div className="w-48 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
118
|
+
<div className="px-4 py-2 border-b border-border shrink-0">
|
|
119
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Courses</p>
|
|
120
|
+
</div>
|
|
121
|
+
<div className="flex-1 overflow-y-auto">
|
|
122
|
+
{loading ? (
|
|
123
|
+
<div className="p-4 space-y-2">{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
|
124
|
+
) : filteredCourses.length === 0 ? (
|
|
125
|
+
<div className="p-4 text-sm text-muted-foreground">No courses found.</div>
|
|
126
|
+
) : filteredCourses.map((courseName) => {
|
|
127
|
+
const cl = courseMap.get(courseName) ?? []
|
|
128
|
+
const done = cl.filter((l) => getStatus(l.id) === 'completed').length
|
|
129
|
+
const pct = cl.length > 0 ? Math.round((done / cl.length) * 100) : 0
|
|
130
|
+
return (
|
|
131
|
+
<button key={courseName}
|
|
132
|
+
onClick={() => { setSelectedCourse(courseName); setSelectedId(null); sendSignal('course_view', `Viewed course: ${courseName}`) }}
|
|
133
|
+
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 transition-colors ${selectedCourse === courseName ? 'bg-accent border-l-2 border-l-primary' : ''}`}
|
|
134
|
+
>
|
|
135
|
+
<p className={`text-sm font-medium truncate ${selectedCourse === courseName ? 'text-primary' : ''}`}>{courseName}</p>
|
|
136
|
+
<div className="mt-1.5 space-y-0.5">
|
|
137
|
+
<Progress value={pct} className="h-1" />
|
|
138
|
+
<p className="text-xs text-muted-foreground">{done}/{cl.length} done</p>
|
|
139
|
+
</div>
|
|
140
|
+
</button>
|
|
141
|
+
)
|
|
142
|
+
})}
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Col 2 — chapters */}
|
|
147
|
+
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
|
|
148
|
+
<div className="px-4 py-2 border-b border-border shrink-0">
|
|
149
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Chapters</p>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="flex-1 overflow-y-auto">
|
|
152
|
+
{!selectedCourse ? (
|
|
153
|
+
<div className="p-4 text-sm text-muted-foreground">Select a course</div>
|
|
154
|
+
) : chaptersForCourse.length === 0 ? (
|
|
155
|
+
<div className="p-4 text-sm text-muted-foreground">No chapters.</div>
|
|
156
|
+
) : chaptersForCourse.map((lesson) => {
|
|
157
|
+
const seq = (lesson.data?.sequence as number) ?? null
|
|
158
|
+
const status = getStatus(lesson.id)
|
|
159
|
+
return (
|
|
160
|
+
<button key={lesson.id} onClick={() => handleSelectLesson(lesson.id)}
|
|
161
|
+
className={`w-full text-left px-4 py-3 border-b border-border hover:bg-accent/50 flex items-center gap-2 transition-colors ${selectedId === lesson.id ? 'bg-accent border-l-2 border-l-primary' : ''}`}
|
|
162
|
+
>
|
|
163
|
+
{status === 'completed'
|
|
164
|
+
? <CheckCircle size={14} className="text-primary shrink-0" />
|
|
165
|
+
: status === 'in_progress'
|
|
166
|
+
? <PlayCircle size={14} className="text-amber-500 shrink-0" />
|
|
167
|
+
: <Circle size={14} className="text-muted-foreground/40 shrink-0" />
|
|
168
|
+
}
|
|
169
|
+
<p className={`text-sm truncate ${selectedId === lesson.id ? 'text-primary font-medium' : ''}`}>
|
|
170
|
+
{seq != null ? `${seq}. ` : ''}{lesson.title}
|
|
171
|
+
</p>
|
|
172
|
+
</button>
|
|
173
|
+
)
|
|
174
|
+
})}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Col 3 — content + discussion */}
|
|
179
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
180
|
+
{!selected ? (
|
|
181
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
182
|
+
<GraduationCap size={32} className="opacity-30" />
|
|
183
|
+
<p className="text-sm">{selectedCourse ? 'Select a chapter to begin' : 'Select a course to get started'}</p>
|
|
184
|
+
</div>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
<div className="px-6 py-3 border-b border-border flex items-center justify-between shrink-0">
|
|
188
|
+
<div>
|
|
189
|
+
<h2 className="text-base font-semibold">{selected.title}</h2>
|
|
190
|
+
{selected.description && <p className="text-xs text-muted-foreground mt-0.5">{selected.description}</p>}
|
|
191
|
+
</div>
|
|
192
|
+
<div className="flex items-center gap-2">
|
|
193
|
+
{isCompleted
|
|
194
|
+
? <Badge variant="secondary" className="gap-1"><CheckCircle size={12} /> Completed</Badge>
|
|
195
|
+
: <Button size="sm" onClick={handleComplete}>Mark Complete</Button>
|
|
196
|
+
}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<ScrollArea className="flex-1 border-b border-border">
|
|
201
|
+
<div className="px-6 py-5 max-w-2xl space-y-6">
|
|
202
|
+
{videoUrl && (
|
|
203
|
+
<div className="aspect-video bg-muted rounded-lg overflow-hidden flex items-center justify-center border border-border">
|
|
204
|
+
<a href={videoUrl} target="_blank" rel="noopener noreferrer"
|
|
205
|
+
className="flex flex-col items-center gap-2 text-muted-foreground hover:text-foreground transition-colors">
|
|
206
|
+
<PlayCircle size={48} />
|
|
207
|
+
<span className="text-sm">Watch Video</span>
|
|
208
|
+
</a>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
{content && (<><Separator /><div className="text-sm leading-relaxed whitespace-pre-wrap">{content}</div></>)}
|
|
212
|
+
{!videoUrl && !content && (
|
|
213
|
+
<p className="text-sm text-muted-foreground italic">No content available for this chapter.</p>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</ScrollArea>
|
|
217
|
+
|
|
218
|
+
{/* Discussion panel */}
|
|
219
|
+
<div className="shrink-0 border-t border-border flex flex-col" style={{ maxHeight: '260px' }}>
|
|
220
|
+
<div className="px-4 py-2 border-b border-border shrink-0">
|
|
221
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Discussion</p>
|
|
222
|
+
</div>
|
|
223
|
+
<ScrollArea className="flex-1 px-4 py-2">
|
|
224
|
+
{messages.length === 0 ? (
|
|
225
|
+
<p className="text-xs text-muted-foreground py-2">No discussion yet. Be the first to comment.</p>
|
|
226
|
+
) : messages.map((msg) => (
|
|
227
|
+
<div key={msg.id} className="mb-3">
|
|
228
|
+
<p className="text-xs text-muted-foreground mb-0.5">{new Date(msg.created_at).toLocaleString()}</p>
|
|
229
|
+
<p className="text-sm">{msg.content}</p>
|
|
230
|
+
</div>
|
|
231
|
+
))}
|
|
232
|
+
</ScrollArea>
|
|
233
|
+
<div className="px-4 py-2 flex gap-2 shrink-0">
|
|
234
|
+
<Textarea
|
|
235
|
+
value={replyText}
|
|
236
|
+
onChange={(e) => setReplyText(e.target.value)}
|
|
237
|
+
placeholder="Add a comment…"
|
|
238
|
+
className="text-sm resize-none"
|
|
239
|
+
rows={2}
|
|
240
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleReply() }}
|
|
241
|
+
/>
|
|
242
|
+
<Button size="sm" onClick={handleReply} disabled={replying || !replyText.trim()} className="self-end">
|
|
243
|
+
<Send size={14} />
|
|
244
|
+
</Button>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useNavigate } from 'react-router-dom'
|
|
2
|
+
import { Ticket, Users, BookOpen, GraduationCap, Store, ArrowRight } from 'lucide-react'
|
|
3
|
+
import { useAuth } from '@core/contexts/AuthContext'
|
|
4
|
+
import { Card, CardContent, CardHeader } from '@core/components/ui/card'
|
|
5
|
+
import { Button } from '@core/components/ui/button'
|
|
6
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
7
|
+
import { Badge } from '@core/components/ui/badge'
|
|
8
|
+
import { useTickets } from '../hooks/useTickets'
|
|
9
|
+
import { useKBArticles } from '../hooks/useKBArticles'
|
|
10
|
+
import { useCourseLessons } from '../hooks/useCourses'
|
|
11
|
+
import { useCommunityPosts } from '../hooks/useCommunity'
|
|
12
|
+
|
|
13
|
+
function StatSkeleton() {
|
|
14
|
+
return <Skeleton className="h-4 w-20 mt-1" />
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function HomePage() {
|
|
18
|
+
const { user } = useAuth()
|
|
19
|
+
const navigate = useNavigate()
|
|
20
|
+
|
|
21
|
+
const { tickets, loading: ticketsLoading } = useTickets()
|
|
22
|
+
const { articles, loading: articlesLoading } = useKBArticles()
|
|
23
|
+
const { lessons, loading: lessonsLoading } = useCourseLessons()
|
|
24
|
+
const { posts, loading: postsLoading } = useCommunityPosts()
|
|
25
|
+
|
|
26
|
+
const openTickets = tickets.filter(t => t.status !== 'closed' && t.status !== 'resolved').length
|
|
27
|
+
const firstName = user?.full_name?.split(' ')[0] || user?.email?.split('@')[0] || 'there'
|
|
28
|
+
|
|
29
|
+
const SECTIONS = [
|
|
30
|
+
{
|
|
31
|
+
icon: Ticket,
|
|
32
|
+
label: 'Tickets',
|
|
33
|
+
path: '/portal/tickets',
|
|
34
|
+
description: 'Submit and track your support requests. Get help from our team and stay updated on your issues.',
|
|
35
|
+
stat: ticketsLoading ? null : `${openTickets} open ticket${openTickets !== 1 ? 's' : ''}`,
|
|
36
|
+
loading: ticketsLoading,
|
|
37
|
+
badgeVariant: openTickets > 0 ? 'default' : 'secondary',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
icon: Users,
|
|
41
|
+
label: 'Community',
|
|
42
|
+
path: '/portal/community',
|
|
43
|
+
description: 'Join discussions, ask questions, and connect with other users in our community forums.',
|
|
44
|
+
stat: postsLoading ? null : `${posts.length} discussion${posts.length !== 1 ? 's' : ''}`,
|
|
45
|
+
loading: postsLoading,
|
|
46
|
+
badgeVariant: 'secondary',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
icon: GraduationCap,
|
|
50
|
+
label: 'Courses',
|
|
51
|
+
path: '/portal/courses',
|
|
52
|
+
description: 'Learn at your own pace with guided courses and lessons tailored to help you get the most out of the platform.',
|
|
53
|
+
stat: lessonsLoading ? null : `${lessons.length} lesson${lessons.length !== 1 ? 's' : ''} available`,
|
|
54
|
+
loading: lessonsLoading,
|
|
55
|
+
badgeVariant: 'secondary',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
icon: BookOpen,
|
|
59
|
+
label: 'Knowledge Base',
|
|
60
|
+
path: '/portal/kb',
|
|
61
|
+
description: 'Browse articles, guides, and documentation to find answers quickly without waiting for support.',
|
|
62
|
+
stat: articlesLoading ? null : `${articles.length} article${articles.length !== 1 ? 's' : ''}`,
|
|
63
|
+
loading: articlesLoading,
|
|
64
|
+
badgeVariant: 'secondary',
|
|
65
|
+
},
|
|
66
|
+
] as const
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col min-h-full">
|
|
70
|
+
<div className="flex-1 max-w-4xl mx-auto w-full px-6 py-10 space-y-10">
|
|
71
|
+
{/* Hero */}
|
|
72
|
+
<div className="space-y-2">
|
|
73
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
74
|
+
Welcome back, {firstName}
|
|
75
|
+
</h1>
|
|
76
|
+
<p className="text-muted-foreground">
|
|
77
|
+
Your portal for support, learning, and community. What do you need today?
|
|
78
|
+
</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* 2×2 grid */}
|
|
82
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
83
|
+
{SECTIONS.map(({ icon: Icon, label, path, description, stat, loading, badgeVariant }) => (
|
|
84
|
+
<Card
|
|
85
|
+
key={path}
|
|
86
|
+
className="group cursor-pointer hover:border-primary/40 hover:shadow-sm transition-all"
|
|
87
|
+
onClick={() => navigate(path)}
|
|
88
|
+
>
|
|
89
|
+
<CardHeader className="pb-2">
|
|
90
|
+
<div className="flex items-start justify-between">
|
|
91
|
+
<div className="flex items-center gap-2.5">
|
|
92
|
+
<div className="p-2 rounded-md bg-primary/10 text-primary">
|
|
93
|
+
<Icon size={18} />
|
|
94
|
+
</div>
|
|
95
|
+
<span className="font-semibold text-base">{label}</span>
|
|
96
|
+
</div>
|
|
97
|
+
<ArrowRight size={16} className="text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity mt-1" />
|
|
98
|
+
</div>
|
|
99
|
+
</CardHeader>
|
|
100
|
+
<CardContent className="space-y-3">
|
|
101
|
+
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
|
|
102
|
+
{loading ? (
|
|
103
|
+
<StatSkeleton />
|
|
104
|
+
) : (
|
|
105
|
+
<Badge variant={badgeVariant as any} className="text-xs">
|
|
106
|
+
{stat}
|
|
107
|
+
</Badge>
|
|
108
|
+
)}
|
|
109
|
+
</CardContent>
|
|
110
|
+
</Card>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Marketplace — full width */}
|
|
115
|
+
<Card
|
|
116
|
+
className="group cursor-pointer hover:border-primary/40 hover:shadow-sm transition-all border-dashed"
|
|
117
|
+
onClick={() => navigate('/portal/marketplace')}
|
|
118
|
+
>
|
|
119
|
+
<CardContent className="flex items-center gap-6 py-6">
|
|
120
|
+
<div className="p-3 rounded-md bg-primary/10 text-primary shrink-0">
|
|
121
|
+
<Store size={22} />
|
|
122
|
+
</div>
|
|
123
|
+
<div className="flex-1 min-w-0">
|
|
124
|
+
<p className="font-semibold text-base">Marketplace</p>
|
|
125
|
+
<p className="text-sm text-muted-foreground mt-0.5">
|
|
126
|
+
Browse plugins, integrations, and apps to extend your portal experience.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
<Button variant="outline" size="sm" className="shrink-0 gap-1.5">
|
|
130
|
+
Explore <ArrowRight size={14} />
|
|
131
|
+
</Button>
|
|
132
|
+
</CardContent>
|
|
133
|
+
</Card>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|