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,104 @@
1
+ import { useState } from 'react'
2
+ import { Shield } from 'lucide-react'
3
+ import { Card, CardContent, CardHeader } from '@core/components/ui/card'
4
+ import { Button } from '@core/components/ui/button'
5
+ import { Badge } from '@core/components/ui/badge'
6
+ import { Separator } from '@core/components/ui/separator'
7
+
8
+ interface PortalItem {
9
+ id: string
10
+ title: string
11
+ context: string
12
+ data?: Record<string, unknown>
13
+ }
14
+
15
+ interface CommunityModeratorProps {
16
+ post: PortalItem
17
+ onModerated?: (updates: Partial<PortalItem>) => void
18
+ }
19
+
20
+ type ModerationStatus = 'pending' | 'approved' | 'flagged'
21
+
22
+ export function CommunityModerator({ post, onModerated }: CommunityModeratorProps) {
23
+ const [isModerating, setIsModerating] = useState(false)
24
+ const [showDetails, setShowDetails] = useState(false)
25
+
26
+ if (post.context !== 'community') return null
27
+
28
+ const status = (post.data?.moderation_status as ModerationStatus) || 'pending'
29
+
30
+ const handleAction = async (action: 'approve' | 'flag') => {
31
+ setIsModerating(true)
32
+ await new Promise((r) => setTimeout(r, 300))
33
+ onModerated?.({
34
+ ...post,
35
+ data: { ...post.data, moderation_status: action === 'approve' ? 'approved' : 'flagged', moderated_at: new Date().toISOString() },
36
+ })
37
+ setIsModerating(false)
38
+ }
39
+
40
+ const statusVariant: Record<ModerationStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
41
+ approved: 'secondary',
42
+ flagged: 'destructive',
43
+ pending: 'outline',
44
+ }
45
+
46
+ return (
47
+ <Card>
48
+ <CardHeader className="pb-3">
49
+ <div className="flex items-center justify-between">
50
+ <div className="flex items-center gap-2">
51
+ <Shield size={16} className="text-muted-foreground" />
52
+ <div>
53
+ <h3 className="font-medium text-sm">Content Moderation</h3>
54
+ <p className="text-xs text-muted-foreground">AI-powered review for community guidelines</p>
55
+ </div>
56
+ </div>
57
+ <Badge variant={statusVariant[status]}>
58
+ {isModerating ? 'Reviewing…' : status.charAt(0).toUpperCase() + status.slice(1)}
59
+ </Badge>
60
+ </div>
61
+ </CardHeader>
62
+
63
+ <CardContent className="space-y-4">
64
+ <div className="rounded-md border border-border bg-muted/30 p-3">
65
+ <p className="text-xs font-medium text-muted-foreground mb-1">Content Preview</p>
66
+ <p className="text-sm line-clamp-3">{String(post.data?.content || 'No content')}</p>
67
+ </div>
68
+
69
+ {showDetails && (
70
+ <>
71
+ <Separator />
72
+ <div className="grid grid-cols-2 gap-3 text-xs text-muted-foreground">
73
+ <div>Spam: Not detected</div>
74
+ <div>Toxicity: Clean</div>
75
+ <div>Clarity: Good</div>
76
+ <div>Relevance: On-topic</div>
77
+ </div>
78
+ </>
79
+ )}
80
+
81
+ <Separator />
82
+
83
+ <div className="flex items-center justify-between">
84
+ <div className="flex gap-2">
85
+ {status !== 'approved' && (
86
+ <Button size="sm" onClick={() => handleAction('approve')} disabled={isModerating}>Approve</Button>
87
+ )}
88
+ {status !== 'flagged' && (
89
+ <Button size="sm" variant="outline" onClick={() => handleAction('flag')} disabled={isModerating}>Flag</Button>
90
+ )}
91
+ <Button size="sm" variant="ghost" onClick={() => setShowDetails(!showDetails)}>
92
+ {showDetails ? 'Hide' : 'Details'}
93
+ </Button>
94
+ </div>
95
+ {post.data?.moderated_at && (
96
+ <span className="text-xs text-muted-foreground">
97
+ {new Date(String(post.data.moderated_at)).toLocaleString()}
98
+ </span>
99
+ )}
100
+ </div>
101
+ </CardContent>
102
+ </Card>
103
+ )
104
+ }
@@ -0,0 +1,169 @@
1
+ import { useState } from 'react'
2
+ import { CheckCircle, Circle, Lock, Play } from 'lucide-react'
3
+ import { Card, CardContent, CardHeader } from '@core/components/ui/card'
4
+ import { Button } from '@core/components/ui/button'
5
+ import { Badge } from '@core/components/ui/badge'
6
+ import { Separator } from '@core/components/ui/separator'
7
+ import { Progress } from '@core/components/ui/progress'
8
+
9
+ interface PortalItem {
10
+ id: string
11
+ title: string
12
+ status: string
13
+ data?: Record<string, unknown>
14
+ }
15
+
16
+ interface CourseProgressProps {
17
+ lessons: PortalItem[]
18
+ currentLesson?: PortalItem
19
+ onLessonComplete?: (lessonId: string) => void
20
+ onLessonSelect?: (lessonId: string) => void
21
+ }
22
+
23
+ interface ProgressState {
24
+ completedLessons: string[]
25
+ }
26
+
27
+ export function CourseProgress({
28
+ lessons,
29
+ currentLesson,
30
+ onLessonComplete,
31
+ onLessonSelect,
32
+ }: CourseProgressProps) {
33
+ const [progress, setProgress] = useState<ProgressState>({ completedLessons: [] })
34
+ const [isExpanded, setIsExpanded] = useState(false)
35
+
36
+ const sortedLessons = [...lessons].sort((a, b) =>
37
+ (Number(a.data?.sequence) || 0) - (Number(b.data?.sequence) || 0)
38
+ )
39
+
40
+ const completedCount = progress.completedLessons.length
41
+ const totalCount = sortedLessons.length
42
+ const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
43
+
44
+ const currentIndex = currentLesson
45
+ ? sortedLessons.findIndex((l) => l.id === currentLesson.id)
46
+ : -1
47
+
48
+ const getLessonStatus = (lesson: PortalItem) => {
49
+ if (progress.completedLessons.includes(lesson.id)) return 'completed'
50
+ if (currentLesson?.id === lesson.id) return 'current'
51
+ const idx = sortedLessons.findIndex((l) => l.id === lesson.id)
52
+ if (currentIndex >= 0 && idx > currentIndex) return 'locked'
53
+ return 'available'
54
+ }
55
+
56
+ const handleCompleteLesson = (lessonId: string) => {
57
+ if (progress.completedLessons.includes(lessonId)) return
58
+ setProgress((prev) => ({ completedLessons: [...prev.completedLessons, lessonId] }))
59
+ onLessonComplete?.(lessonId)
60
+ }
61
+
62
+ const StatusIcon = ({ status }: { status: string }) => {
63
+ if (status === 'completed') return <CheckCircle size={15} className="text-primary shrink-0" />
64
+ if (status === 'current') return <Play size={15} className="text-primary shrink-0" />
65
+ if (status === 'locked') return <Lock size={15} className="text-muted-foreground/40 shrink-0" />
66
+ return <Circle size={15} className="text-muted-foreground/40 shrink-0" />
67
+ }
68
+
69
+ return (
70
+ <Card>
71
+ <CardHeader className="pb-3">
72
+ <div className="flex items-center justify-between">
73
+ <div>
74
+ <h3 className="font-medium text-sm">Course Progress</h3>
75
+ <p className="text-xs text-muted-foreground mt-0.5">
76
+ {completedCount} of {totalCount} lessons completed
77
+ </p>
78
+ </div>
79
+ <Badge variant="secondary">{progressPct}% Complete</Badge>
80
+ </div>
81
+ </CardHeader>
82
+
83
+ <CardContent className="space-y-4">
84
+ <Progress value={progressPct} className="h-2" />
85
+
86
+ {currentLesson && (
87
+ <div className="rounded-md border border-border bg-muted/30 p-3">
88
+ <div className="flex items-center justify-between">
89
+ <div>
90
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Current Lesson</p>
91
+ <p className="text-sm font-medium mt-0.5">{currentLesson.title}</p>
92
+ {currentLesson.data?.estimated_duration && (
93
+ <p className="text-xs text-muted-foreground">{String(currentLesson.data.estimated_duration)} min</p>
94
+ )}
95
+ </div>
96
+ <Button
97
+ size="sm"
98
+ variant={progress.completedLessons.includes(currentLesson.id) ? 'secondary' : 'default'}
99
+ onClick={() => handleCompleteLesson(currentLesson.id)}
100
+ disabled={progress.completedLessons.includes(currentLesson.id)}
101
+ >
102
+ {progress.completedLessons.includes(currentLesson.id) ? 'Completed' : 'Mark Complete'}
103
+ </Button>
104
+ </div>
105
+ </div>
106
+ )}
107
+
108
+ <div>
109
+ <div className="flex items-center justify-between mb-2">
110
+ <p className="text-xs font-medium">Course Curriculum</p>
111
+ <Button variant="ghost" size="sm" onClick={() => setIsExpanded(!isExpanded)}>
112
+ {isExpanded ? 'Collapse' : 'Expand'}
113
+ </Button>
114
+ </div>
115
+
116
+ <div className={`space-y-1.5 ${isExpanded ? '' : 'max-h-52 overflow-y-auto'}`}>
117
+ {sortedLessons.map((lesson, index) => {
118
+ const status = getLessonStatus(lesson)
119
+ const accessible = status !== 'locked'
120
+ return (
121
+ <div
122
+ key={lesson.id}
123
+ className={`flex items-center gap-2.5 p-2.5 rounded-md border transition-colors ${
124
+ status === 'current'
125
+ ? 'border-primary/40 bg-primary/5'
126
+ : status === 'completed'
127
+ ? 'border-border bg-muted/30'
128
+ : accessible
129
+ ? 'border-border hover:bg-accent/50 cursor-pointer'
130
+ : 'border-border bg-muted/30 opacity-50'
131
+ }`}
132
+ onClick={() => accessible && onLessonSelect?.(lesson.id)}
133
+ >
134
+ <StatusIcon status={status} />
135
+ <div className="flex-1 min-w-0">
136
+ <p className="text-sm truncate">{index + 1}. {lesson.title}</p>
137
+ {lesson.data?.estimated_duration && (
138
+ <p className="text-xs text-muted-foreground">{String(lesson.data.estimated_duration)} min</p>
139
+ )}
140
+ </div>
141
+ <Badge variant={status === 'completed' ? 'secondary' : status === 'current' ? 'default' : 'outline'} className="text-xs">
142
+ {status}
143
+ </Badge>
144
+ </div>
145
+ )
146
+ })}
147
+ </div>
148
+ </div>
149
+
150
+ <Separator />
151
+
152
+ <div className="grid grid-cols-3 gap-3 text-center">
153
+ <div>
154
+ <p className="text-lg font-semibold">{completedCount}</p>
155
+ <p className="text-xs text-muted-foreground">Completed</p>
156
+ </div>
157
+ <div>
158
+ <p className="text-lg font-semibold">{totalCount - completedCount}</p>
159
+ <p className="text-xs text-muted-foreground">Remaining</p>
160
+ </div>
161
+ <div>
162
+ <p className="text-lg font-semibold">{progressPct}%</p>
163
+ <p className="text-xs text-muted-foreground">Progress</p>
164
+ </div>
165
+ </div>
166
+ </CardContent>
167
+ </Card>
168
+ )
169
+ }
@@ -0,0 +1,59 @@
1
+ import { useState } from 'react'
2
+ import { ThumbsUp, ThumbsDown } from 'lucide-react'
3
+ import { Button } from '@core/components/ui/button'
4
+
5
+ interface HelpfulnessRatingProps {
6
+ helpfulCount: number
7
+ notHelpfulCount: number
8
+ onVote?: (helpful: boolean) => void
9
+ }
10
+
11
+ export function HelpfulnessRating({ helpfulCount, notHelpfulCount, onVote }: HelpfulnessRatingProps) {
12
+ const [hasVoted, setHasVoted] = useState(false)
13
+ const [voteType, setVoteType] = useState<'helpful' | 'not-helpful' | null>(null)
14
+
15
+ const handleVote = (helpful: boolean) => {
16
+ if (hasVoted) return
17
+ setHasVoted(true)
18
+ setVoteType(helpful ? 'helpful' : 'not-helpful')
19
+ onVote?.(helpful)
20
+ }
21
+
22
+ const totalVotes = helpfulCount + notHelpfulCount
23
+ const helpfulPercentage = totalVotes > 0 ? Math.round((helpfulCount / totalVotes) * 100) : 0
24
+
25
+ return (
26
+ <div className="flex items-center gap-3">
27
+ <div className="flex items-center gap-2">
28
+ <Button
29
+ variant={voteType === 'helpful' ? 'default' : 'outline'}
30
+ size="sm"
31
+ onClick={() => handleVote(true)}
32
+ disabled={hasVoted}
33
+ className="gap-1.5"
34
+ >
35
+ <ThumbsUp size={13} /> Helpful
36
+ </Button>
37
+
38
+ <Button
39
+ variant={voteType === 'not-helpful' ? 'default' : 'outline'}
40
+ size="sm"
41
+ onClick={() => handleVote(false)}
42
+ disabled={hasVoted}
43
+ className="gap-1.5"
44
+ >
45
+ <ThumbsDown size={13} /> Not Helpful
46
+ </Button>
47
+ </div>
48
+
49
+ <div className="text-sm text-muted-foreground">
50
+ <span className="font-medium">{helpfulPercentage}%</span> found this helpful
51
+ <span className="ml-1 opacity-60">({totalVotes} votes)</span>
52
+ </div>
53
+
54
+ {hasVoted && (
55
+ <span className="text-xs text-muted-foreground">Thanks for your feedback!</span>
56
+ )}
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,159 @@
1
+ import { useState } from 'react'
2
+ import { resolveTypeId } from '../../lib/resolveTypeId'
3
+ import { apiFetch } from '@core/lib/api'
4
+ import { Card, CardContent, CardHeader } from '@core/components/ui/card'
5
+ import { Button } from '@core/components/ui/button'
6
+ import { Input } from '@core/components/ui/input'
7
+ import { Textarea } from '@core/components/ui/textarea'
8
+ import { Label } from '@core/components/ui/label'
9
+ import { Badge } from '@core/components/ui/badge'
10
+ import { Separator } from '@core/components/ui/separator'
11
+
12
+ interface PortalItem {
13
+ id: string
14
+ title: string
15
+ data?: Record<string, unknown>
16
+ }
17
+
18
+ interface KBGeneratorProps {
19
+ ticket: PortalItem
20
+ onGenerated?: (kbArticle: any) => void
21
+ onCancel?: () => void
22
+ }
23
+
24
+ export function KBGenerator({ ticket, onGenerated, onCancel }: KBGeneratorProps) {
25
+ const [isGenerating, setIsGenerating] = useState(false)
26
+ const [generatedArticle, setGeneratedArticle] = useState<any>(null)
27
+ const [isEditing, setIsEditing] = useState(false)
28
+ const [editedContent, setEditedContent] = useState('')
29
+
30
+ const handleGenerate = async () => {
31
+ if (!ticket.data?.content) return
32
+ setIsGenerating(true)
33
+ await new Promise((r) => setTimeout(r, 500))
34
+ const article = { title: `How to: ${ticket.title}`, content: 'Generated content…', tags: ['support', 'how-to'], confidence: 0.87 }
35
+ setGeneratedArticle(article)
36
+ setEditedContent(article.content)
37
+ setIsGenerating(false)
38
+ }
39
+
40
+ const handleSave = async () => {
41
+ if (!generatedArticle) return
42
+ try {
43
+ const kbArticleTypeId = await resolveTypeId('kb_article')
44
+ const res = await apiFetch('/.netlify/functions/admin-data', {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({
48
+ entity: 'items',
49
+ type_id: kbArticleTypeId,
50
+ title: generatedArticle.title,
51
+ status: 'published',
52
+ description: editedContent,
53
+ data: {
54
+ kb_type: 'article',
55
+ priority: 'medium',
56
+ security_level: 'internal',
57
+ tags: generatedArticle.tags,
58
+ source_info: {
59
+ source_type: 'ai_generated',
60
+ source_ticket_id: ticket.id,
61
+ },
62
+ },
63
+ }),
64
+ })
65
+ const json = await res.json()
66
+ onGenerated?.(json.data)
67
+ } catch (err) {
68
+ console.error('Error saving KB article:', err)
69
+ }
70
+ }
71
+
72
+ const handleDiscard = () => {
73
+ setGeneratedArticle(null)
74
+ setEditedContent('')
75
+ setIsEditing(false)
76
+ onCancel?.()
77
+ }
78
+
79
+ if (!generatedArticle) {
80
+ return (
81
+ <Card>
82
+ <CardHeader>
83
+ <div className="flex items-center justify-between">
84
+ <h3 className="font-medium text-sm">Generate Knowledge Base Article</h3>
85
+ <Badge variant="secondary">AI Powered</Badge>
86
+ </div>
87
+ </CardHeader>
88
+ <CardContent className="space-y-4">
89
+ <div className="rounded-md border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
90
+ <p className="font-medium text-foreground mb-1">Source Ticket</p>
91
+ <p>{ticket.title}</p>
92
+ </div>
93
+ <div className="flex gap-2">
94
+ <Button
95
+ onClick={handleGenerate}
96
+ disabled={isGenerating || !ticket.data?.content}
97
+ >
98
+ {isGenerating ? 'Generating…' : 'Generate KB Article'}
99
+ </Button>
100
+ <Button variant="outline" onClick={onCancel}>Cancel</Button>
101
+ </div>
102
+ </CardContent>
103
+ </Card>
104
+ )
105
+ }
106
+
107
+ return (
108
+ <Card>
109
+ <CardHeader>
110
+ <div className="flex items-center justify-between">
111
+ <h3 className="font-medium text-sm">Generated KB Article</h3>
112
+ <Badge variant="secondary">{Math.round(generatedArticle.confidence * 100)}% Confidence</Badge>
113
+ </div>
114
+ </CardHeader>
115
+ <CardContent className="space-y-4">
116
+ <div className="space-y-1.5">
117
+ <Label>Title</Label>
118
+ <Input
119
+ value={generatedArticle.title}
120
+ onChange={(e) => setGeneratedArticle((prev: any) => ({ ...prev, title: e.target.value }))}
121
+ />
122
+ </div>
123
+
124
+ <div className="space-y-1.5">
125
+ <div className="flex items-center justify-between">
126
+ <Label>Content</Label>
127
+ <Button variant="ghost" size="sm" onClick={() => setIsEditing(!isEditing)}>
128
+ {isEditing ? 'Preview' : 'Edit'}
129
+ </Button>
130
+ </div>
131
+ {isEditing ? (
132
+ <Textarea
133
+ value={editedContent}
134
+ onChange={(e) => setEditedContent(e.target.value)}
135
+ rows={8}
136
+ />
137
+ ) : (
138
+ <div className="rounded-md border border-border bg-muted/30 p-3 text-sm whitespace-pre-wrap min-h-24">
139
+ {editedContent}
140
+ </div>
141
+ )}
142
+ </div>
143
+
144
+ <div className="flex flex-wrap gap-1.5">
145
+ {generatedArticle.tags.map((tag: string, i: number) => (
146
+ <Badge key={i} variant="outline" className="text-xs">{tag}</Badge>
147
+ ))}
148
+ </div>
149
+
150
+ <Separator />
151
+
152
+ <div className="flex gap-2">
153
+ <Button onClick={handleSave}>Publish to Knowledge Base</Button>
154
+ <Button variant="outline" onClick={handleDiscard}>Discard</Button>
155
+ </div>
156
+ </CardContent>
157
+ </Card>
158
+ )
159
+ }
@@ -0,0 +1,23 @@
1
+ import { MessageCircle } from 'lucide-react'
2
+
3
+ interface ParticipationIndicatorProps {
4
+ hasParticipation: boolean
5
+ hasUnread?: boolean
6
+ size?: 'sm' | 'md'
7
+ }
8
+
9
+ export function ParticipationIndicator({ hasParticipation, hasUnread = false, size = 'sm' }: ParticipationIndicatorProps) {
10
+ if (!hasParticipation && !hasUnread) return null
11
+
12
+ return (
13
+ <span className="relative inline-flex items-center">
14
+ <MessageCircle
15
+ size={size === 'sm' ? 13 : 15}
16
+ className={hasParticipation ? 'text-primary' : 'text-muted-foreground/30'}
17
+ />
18
+ {hasUnread && (
19
+ <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-destructive rounded-full" />
20
+ )}
21
+ </span>
22
+ )
23
+ }
@@ -0,0 +1,32 @@
1
+ import { Separator } from '@core/components/ui/separator'
2
+
3
+ const FOOTER_LINKS = [
4
+ { label: 'Privacy Policy', href: '#' },
5
+ { label: 'Terms of Service', href: '#' },
6
+ { label: 'Support', href: '#' },
7
+ ]
8
+
9
+ export function PortalFooter() {
10
+ return (
11
+ <footer className="border-t border-border bg-background">
12
+ <div className="flex items-center justify-between px-6 h-12">
13
+ <p className="text-xs text-muted-foreground">
14
+ &copy; {new Date().getFullYear()} Customer Portal
15
+ </p>
16
+ <nav className="flex items-center gap-1">
17
+ {FOOTER_LINKS.map((link, i) => (
18
+ <span key={link.label} className="flex items-center gap-1">
19
+ {i > 0 && <Separator orientation="vertical" className="h-3" />}
20
+ <a
21
+ href={link.href}
22
+ className="text-xs text-muted-foreground hover:text-foreground transition-colors px-1"
23
+ >
24
+ {link.label}
25
+ </a>
26
+ </span>
27
+ ))}
28
+ </nav>
29
+ </div>
30
+ </footer>
31
+ )
32
+ }