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