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,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
|
+
© {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
|
+
}
|