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,149 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { NavLink, useNavigate } from 'react-router-dom'
|
|
3
|
+
import { Ticket, Users, BookOpen, GraduationCap, Store, LayoutGrid, User, LogOut, Save } from 'lucide-react'
|
|
4
|
+
import { useAuth } from '@core/contexts/AuthContext'
|
|
5
|
+
import { Button } from '@core/components/ui/button'
|
|
6
|
+
import { Avatar, AvatarFallback } from '@core/components/ui/avatar'
|
|
7
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@core/components/ui/dialog'
|
|
8
|
+
import { Input } from '@core/components/ui/input'
|
|
9
|
+
import { Label } from '@core/components/ui/label'
|
|
10
|
+
import { Separator } from '@core/components/ui/separator'
|
|
11
|
+
|
|
12
|
+
const NAV_ITEMS = [
|
|
13
|
+
{ label: 'Tickets', path: '/portal/tickets', icon: Ticket },
|
|
14
|
+
{ label: 'Knowledge Base', path: '/portal/kb', icon: BookOpen },
|
|
15
|
+
{ label: 'Courses', path: '/portal/courses', icon: GraduationCap },
|
|
16
|
+
{ label: 'Community', path: '/portal/community', icon: Users },
|
|
17
|
+
{ label: 'Marketplace', path: '/portal/marketplace', icon: Store },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function PortalHeader() {
|
|
21
|
+
const { user, logout } = useAuth()
|
|
22
|
+
const navigate = useNavigate()
|
|
23
|
+
const [accountOpen, setAccountOpen] = useState(false)
|
|
24
|
+
const [displayName, setDisplayName] = useState(user?.full_name || user?.email?.split('@')[0] || '')
|
|
25
|
+
|
|
26
|
+
const initials = (displayName || user?.email || 'U')
|
|
27
|
+
.split(' ')
|
|
28
|
+
.map((w: string) => w[0])
|
|
29
|
+
.join('')
|
|
30
|
+
.toUpperCase()
|
|
31
|
+
.slice(0, 2)
|
|
32
|
+
|
|
33
|
+
const handleSave = () => {
|
|
34
|
+
setAccountOpen(false)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const handleSignOut = async () => {
|
|
38
|
+
await logout()
|
|
39
|
+
navigate('/login')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<header className="sticky top-0 z-50 bg-background border-b border-border shadow-sm">
|
|
45
|
+
<div className="flex items-center h-14 px-6 gap-6">
|
|
46
|
+
<NavLink to="/portal" className="flex items-center gap-2 shrink-0 text-foreground hover:text-primary transition-colors">
|
|
47
|
+
<LayoutGrid size={18} className="text-primary" />
|
|
48
|
+
<span className="font-semibold tracking-tight text-sm">Customer Portal</span>
|
|
49
|
+
</NavLink>
|
|
50
|
+
|
|
51
|
+
<nav className="flex items-center gap-1 flex-1">
|
|
52
|
+
{NAV_ITEMS.map(({ label, path, icon: Icon }) => (
|
|
53
|
+
<NavLink
|
|
54
|
+
key={path}
|
|
55
|
+
to={path}
|
|
56
|
+
className={({ isActive }) =>
|
|
57
|
+
[
|
|
58
|
+
'flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
|
59
|
+
isActive
|
|
60
|
+
? 'bg-accent text-accent-foreground'
|
|
61
|
+
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50',
|
|
62
|
+
].join(' ')
|
|
63
|
+
}
|
|
64
|
+
>
|
|
65
|
+
<Icon size={14} />
|
|
66
|
+
{label}
|
|
67
|
+
</NavLink>
|
|
68
|
+
))}
|
|
69
|
+
</nav>
|
|
70
|
+
|
|
71
|
+
<Button
|
|
72
|
+
variant="ghost"
|
|
73
|
+
size="sm"
|
|
74
|
+
className="shrink-0 gap-2"
|
|
75
|
+
onClick={() => setAccountOpen(true)}
|
|
76
|
+
>
|
|
77
|
+
<Avatar className="h-6 w-6">
|
|
78
|
+
<AvatarFallback className="text-xs bg-primary text-primary-foreground">{initials}</AvatarFallback>
|
|
79
|
+
</Avatar>
|
|
80
|
+
<span className="text-sm font-medium hidden sm:inline">{displayName || user?.email}</span>
|
|
81
|
+
</Button>
|
|
82
|
+
</div>
|
|
83
|
+
</header>
|
|
84
|
+
|
|
85
|
+
<Dialog open={accountOpen} onOpenChange={setAccountOpen}>
|
|
86
|
+
<DialogContent className="sm:max-w-md">
|
|
87
|
+
<DialogHeader>
|
|
88
|
+
<DialogTitle>Account</DialogTitle>
|
|
89
|
+
</DialogHeader>
|
|
90
|
+
|
|
91
|
+
<div className="space-y-6 py-2">
|
|
92
|
+
<div className="flex items-center gap-4">
|
|
93
|
+
<Avatar className="h-14 w-14">
|
|
94
|
+
<AvatarFallback className="text-lg bg-primary text-primary-foreground">{initials}</AvatarFallback>
|
|
95
|
+
</Avatar>
|
|
96
|
+
<div>
|
|
97
|
+
<p className="font-medium">{displayName || '—'}</p>
|
|
98
|
+
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<Separator />
|
|
103
|
+
|
|
104
|
+
<div className="space-y-4">
|
|
105
|
+
<div className="space-y-1.5">
|
|
106
|
+
<Label htmlFor="display-name">Display Name</Label>
|
|
107
|
+
<Input
|
|
108
|
+
id="display-name"
|
|
109
|
+
value={displayName}
|
|
110
|
+
onChange={e => setDisplayName(e.target.value)}
|
|
111
|
+
placeholder="Your name"
|
|
112
|
+
/>
|
|
113
|
+
</div>
|
|
114
|
+
<div className="space-y-1.5">
|
|
115
|
+
<Label>Email</Label>
|
|
116
|
+
<Input value={user?.email || ''} disabled className="text-muted-foreground" />
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<Separator />
|
|
121
|
+
|
|
122
|
+
<div className="space-y-1.5">
|
|
123
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Account Info</p>
|
|
124
|
+
<div className="text-sm text-muted-foreground space-y-1">
|
|
125
|
+
<p>Role: <span className="text-foreground font-medium capitalize">{user?.roles?.[0] || 'member'}</span></p>
|
|
126
|
+
<p>Account: <span className="text-foreground font-medium">{user?.account?.display_name || '—'}</span></p>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<DialogFooter className="flex-col sm:flex-row gap-2">
|
|
132
|
+
<Button
|
|
133
|
+
variant="outline"
|
|
134
|
+
className="text-destructive hover:text-destructive gap-2 sm:mr-auto"
|
|
135
|
+
onClick={handleSignOut}
|
|
136
|
+
>
|
|
137
|
+
<LogOut size={14} />
|
|
138
|
+
Sign Out
|
|
139
|
+
</Button>
|
|
140
|
+
<Button onClick={handleSave} className="gap-2">
|
|
141
|
+
<Save size={14} />
|
|
142
|
+
Save Changes
|
|
143
|
+
</Button>
|
|
144
|
+
</DialogFooter>
|
|
145
|
+
</DialogContent>
|
|
146
|
+
</Dialog>
|
|
147
|
+
</>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module PortalSidebar
|
|
3
|
+
* @audience installer
|
|
4
|
+
* @layer frontend-component
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Sidebar for Customer Portal with ticket/content navigation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as React from "react"
|
|
11
|
+
import { PlusIcon, Ticket, BookOpen, GraduationCap, MessageSquare, HelpCircle, Settings, Layout } from "lucide-react"
|
|
12
|
+
import {
|
|
13
|
+
Sidebar,
|
|
14
|
+
SidebarContent,
|
|
15
|
+
SidebarFooter,
|
|
16
|
+
SidebarGroup,
|
|
17
|
+
SidebarGroupContent,
|
|
18
|
+
SidebarGroupLabel,
|
|
19
|
+
SidebarHeader,
|
|
20
|
+
SidebarMenu,
|
|
21
|
+
SidebarMenuButton,
|
|
22
|
+
SidebarMenuItem,
|
|
23
|
+
SidebarRail,
|
|
24
|
+
} from "@core/components/ui/sidebar"
|
|
25
|
+
import { Button } from "@core/components/ui/button"
|
|
26
|
+
|
|
27
|
+
const navItems = [
|
|
28
|
+
{
|
|
29
|
+
title: "Tickets",
|
|
30
|
+
url: "/portal/tickets",
|
|
31
|
+
icon: Ticket,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
title: "Knowledge Base",
|
|
35
|
+
url: "/portal/kb",
|
|
36
|
+
icon: BookOpen,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: "Courses",
|
|
40
|
+
url: "/portal/courses",
|
|
41
|
+
icon: GraduationCap,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: "Community",
|
|
45
|
+
url: "/portal/community",
|
|
46
|
+
icon: MessageSquare,
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const supportItems = [
|
|
51
|
+
{
|
|
52
|
+
title: "Help Center",
|
|
53
|
+
url: "/portal/help",
|
|
54
|
+
icon: HelpCircle,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
title: "Settings",
|
|
58
|
+
url: "/portal/settings",
|
|
59
|
+
icon: Settings,
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export function PortalSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|
64
|
+
return (
|
|
65
|
+
<Sidebar {...props}>
|
|
66
|
+
<SidebarHeader>
|
|
67
|
+
{/* Brand Header */}
|
|
68
|
+
<SidebarMenu>
|
|
69
|
+
<SidebarMenuItem>
|
|
70
|
+
<SidebarMenuButton size="lg" asChild>
|
|
71
|
+
<a href="/portal">
|
|
72
|
+
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
|
73
|
+
<span className="text-sm font-bold">P</span>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="flex flex-col gap-0.5 leading-none">
|
|
76
|
+
<span className="font-semibold">Portal</span>
|
|
77
|
+
<span className="text-xs text-muted-foreground">Support</span>
|
|
78
|
+
</div>
|
|
79
|
+
</a>
|
|
80
|
+
</SidebarMenuButton>
|
|
81
|
+
</SidebarMenuItem>
|
|
82
|
+
</SidebarMenu>
|
|
83
|
+
|
|
84
|
+
{/* Quick Create Button */}
|
|
85
|
+
<SidebarGroup className="py-2">
|
|
86
|
+
<Button className="w-full" size="sm">
|
|
87
|
+
<PlusIcon className="mr-2 h-4 w-4" />
|
|
88
|
+
New Ticket
|
|
89
|
+
</Button>
|
|
90
|
+
</SidebarGroup>
|
|
91
|
+
</SidebarHeader>
|
|
92
|
+
|
|
93
|
+
<SidebarContent>
|
|
94
|
+
<SidebarGroup>
|
|
95
|
+
<SidebarGroupLabel>Support</SidebarGroupLabel>
|
|
96
|
+
<SidebarGroupContent>
|
|
97
|
+
<SidebarMenu>
|
|
98
|
+
{navItems.map((item) => (
|
|
99
|
+
<SidebarMenuItem key={item.title}>
|
|
100
|
+
<SidebarMenuButton asChild>
|
|
101
|
+
<a href={item.url}>
|
|
102
|
+
<item.icon className="h-4 w-4" />
|
|
103
|
+
<span>{item.title}</span>
|
|
104
|
+
</a>
|
|
105
|
+
</SidebarMenuButton>
|
|
106
|
+
</SidebarMenuItem>
|
|
107
|
+
))}
|
|
108
|
+
</SidebarMenu>
|
|
109
|
+
</SidebarGroupContent>
|
|
110
|
+
</SidebarGroup>
|
|
111
|
+
|
|
112
|
+
<SidebarGroup>
|
|
113
|
+
<SidebarGroupLabel>Resources</SidebarGroupLabel>
|
|
114
|
+
<SidebarGroupContent>
|
|
115
|
+
<SidebarMenu>
|
|
116
|
+
{supportItems.map((item) => (
|
|
117
|
+
<SidebarMenuItem key={item.title}>
|
|
118
|
+
<SidebarMenuButton asChild>
|
|
119
|
+
<a href={item.url}>
|
|
120
|
+
<item.icon className="h-4 w-4" />
|
|
121
|
+
<span>{item.title}</span>
|
|
122
|
+
</a>
|
|
123
|
+
</SidebarMenuButton>
|
|
124
|
+
</SidebarMenuItem>
|
|
125
|
+
))}
|
|
126
|
+
</SidebarMenu>
|
|
127
|
+
</SidebarGroupContent>
|
|
128
|
+
</SidebarGroup>
|
|
129
|
+
</SidebarContent>
|
|
130
|
+
|
|
131
|
+
<SidebarFooter className="p-4">
|
|
132
|
+
<div className="text-xs text-muted-foreground">
|
|
133
|
+
Customer Portal
|
|
134
|
+
</div>
|
|
135
|
+
</SidebarFooter>
|
|
136
|
+
|
|
137
|
+
<SidebarRail />
|
|
138
|
+
</Sidebar>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { CheckCircle, Circle } from 'lucide-react'
|
|
3
|
+
import { Button } from '@core/components/ui/button'
|
|
4
|
+
|
|
5
|
+
interface ProgressTrackerProps {
|
|
6
|
+
sequence?: number
|
|
7
|
+
onComplete?: () => void
|
|
8
|
+
completed?: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProgressTracker({ sequence, onComplete, completed = false }: ProgressTrackerProps) {
|
|
12
|
+
const [isCompleted, setIsCompleted] = useState(completed)
|
|
13
|
+
|
|
14
|
+
const handleComplete = () => {
|
|
15
|
+
if (isCompleted) return
|
|
16
|
+
setIsCompleted(true)
|
|
17
|
+
onComplete?.()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex items-center gap-3">
|
|
22
|
+
{sequence && (
|
|
23
|
+
<span className="text-sm text-muted-foreground">Lesson {sequence}</span>
|
|
24
|
+
)}
|
|
25
|
+
|
|
26
|
+
<Button
|
|
27
|
+
variant={isCompleted ? 'default' : 'outline'}
|
|
28
|
+
size="sm"
|
|
29
|
+
onClick={handleComplete}
|
|
30
|
+
disabled={isCompleted}
|
|
31
|
+
className="gap-1.5"
|
|
32
|
+
>
|
|
33
|
+
{isCompleted ? <><CheckCircle size={13} /> Completed</> : <><Circle size={13} /> Mark Complete</>}
|
|
34
|
+
</Button>
|
|
35
|
+
|
|
36
|
+
{isCompleted && (
|
|
37
|
+
<span className="text-xs text-muted-foreground">Progress saved!</span>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Search } from 'lucide-react'
|
|
3
|
+
import { Input } from '@core/components/ui/input'
|
|
4
|
+
|
|
5
|
+
interface SearchFilterBarProps {
|
|
6
|
+
placeholder?: string
|
|
7
|
+
value: string
|
|
8
|
+
onChange: (value: string) => void
|
|
9
|
+
children?: React.ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SearchFilterBar({ placeholder = 'Search…', value, onChange, children }: SearchFilterBarProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div className="relative flex-1">
|
|
15
|
+
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
|
16
|
+
<Input
|
|
17
|
+
type="text"
|
|
18
|
+
value={value}
|
|
19
|
+
onChange={(e) => onChange(e.target.value)}
|
|
20
|
+
placeholder={placeholder}
|
|
21
|
+
className="pl-9 h-9"
|
|
22
|
+
/>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Badge } from '@core/components/ui/badge'
|
|
2
|
+
|
|
3
|
+
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline'
|
|
4
|
+
|
|
5
|
+
const STATUS_VARIANTS: Record<string, BadgeVariant> = {
|
|
6
|
+
new: 'default',
|
|
7
|
+
open: 'default',
|
|
8
|
+
working: 'outline',
|
|
9
|
+
pending: 'outline',
|
|
10
|
+
in_progress: 'outline',
|
|
11
|
+
returned: 'destructive',
|
|
12
|
+
resolved: 'secondary',
|
|
13
|
+
completed: 'secondary',
|
|
14
|
+
closed: 'secondary',
|
|
15
|
+
not_started: 'secondary',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
19
|
+
new: 'New',
|
|
20
|
+
working: 'Working',
|
|
21
|
+
pending: 'Pending',
|
|
22
|
+
returned: 'Returned',
|
|
23
|
+
resolved: 'Resolved',
|
|
24
|
+
closed: 'Closed',
|
|
25
|
+
open: 'Open',
|
|
26
|
+
completed: 'Completed',
|
|
27
|
+
not_started: 'Not Started',
|
|
28
|
+
in_progress: 'In Progress',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface StatusBadgeProps {
|
|
32
|
+
status: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function StatusBadge({ status }: StatusBadgeProps) {
|
|
36
|
+
const variant = STATUS_VARIANTS[status] ?? 'secondary'
|
|
37
|
+
const label = STATUS_LABELS[status] ?? status
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Badge variant={variant} className="text-xs">
|
|
41
|
+
{label}
|
|
42
|
+
</Badge>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Send } from 'lucide-react'
|
|
3
|
+
import { usePortalThreads, usePortalMessages } from '../hooks/usePortalData'
|
|
4
|
+
import { Button } from '@core/components/ui/button'
|
|
5
|
+
import { Input } from '@core/components/ui/input'
|
|
6
|
+
import { Skeleton } from '@core/components/ui/skeleton'
|
|
7
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
8
|
+
|
|
9
|
+
interface ThreadPanelProps {
|
|
10
|
+
itemId: string
|
|
11
|
+
itemType: string
|
|
12
|
+
context: string
|
|
13
|
+
collapsible?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ThreadPanel({ itemId, itemType, context, collapsible = false }: ThreadPanelProps) {
|
|
17
|
+
const [expanded, setExpanded] = useState(!collapsible)
|
|
18
|
+
const [newMessage, setNewMessage] = useState('')
|
|
19
|
+
|
|
20
|
+
const { threads, loading: threadsLoading } = usePortalThreads(itemId)
|
|
21
|
+
const { messages, loading: messagesLoading } = usePortalMessages(threads[0]?.id || '')
|
|
22
|
+
|
|
23
|
+
const handleSendMessage = async () => {
|
|
24
|
+
if (!newMessage.trim()) return
|
|
25
|
+
console.log('Sending message:', newMessage)
|
|
26
|
+
setNewMessage('')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (threadsLoading) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="p-4 space-y-2">
|
|
32
|
+
<Skeleton className="h-10 w-2/3" />
|
|
33
|
+
<Skeleton className="h-10 w-1/2 ml-auto" />
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (threads.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="text-center py-8 text-sm text-muted-foreground">
|
|
41
|
+
No conversation yet. Be the first to start the discussion!
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="space-y-4">
|
|
48
|
+
{collapsible && (
|
|
49
|
+
<div className="flex items-center justify-between">
|
|
50
|
+
<h4 className="font-medium text-sm">Discussion</h4>
|
|
51
|
+
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
|
|
52
|
+
{expanded ? 'Hide' : 'Show'}
|
|
53
|
+
</Button>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{expanded && (
|
|
58
|
+
<>
|
|
59
|
+
<ScrollArea className="max-h-96">
|
|
60
|
+
<div className="space-y-3">
|
|
61
|
+
{messages.map((message) => (
|
|
62
|
+
<div
|
|
63
|
+
key={message.id}
|
|
64
|
+
className={`flex ${message.direction === 'in' ? 'justify-start' : 'justify-end'}`}
|
|
65
|
+
>
|
|
66
|
+
<div className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg text-sm border ${
|
|
67
|
+
message.direction === 'in'
|
|
68
|
+
? 'bg-muted text-foreground border-border'
|
|
69
|
+
: 'bg-primary text-primary-foreground border-primary'
|
|
70
|
+
}`}>
|
|
71
|
+
<div className="flex items-center gap-2 mb-1">
|
|
72
|
+
<span className="text-xs font-medium">
|
|
73
|
+
{(message as any).author_name || (message.direction === 'in' ? 'Customer' : 'Support')}
|
|
74
|
+
</span>
|
|
75
|
+
<span className="text-xs opacity-75">
|
|
76
|
+
{new Date(message.created_at).toLocaleTimeString()}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<p className="whitespace-pre-wrap">{message.content}</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
</div>
|
|
84
|
+
</ScrollArea>
|
|
85
|
+
|
|
86
|
+
<div className="flex gap-2">
|
|
87
|
+
<Input
|
|
88
|
+
value={newMessage}
|
|
89
|
+
onChange={(e) => setNewMessage(e.target.value)}
|
|
90
|
+
placeholder="Type your message…"
|
|
91
|
+
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
|
92
|
+
className="flex-1"
|
|
93
|
+
/>
|
|
94
|
+
<Button onClick={handleSendMessage} disabled={!newMessage.trim()} size="icon">
|
|
95
|
+
<Send size={14} />
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { PortalItem } from '../hooks/usePortalData'
|
|
3
|
+
import { ThreadPanel } from './ThreadPanel'
|
|
4
|
+
import { Button } from '@core/components/ui/button'
|
|
5
|
+
import { Badge } from '@core/components/ui/badge'
|
|
6
|
+
import { StatusBadge } from './StatusBadge'
|
|
7
|
+
|
|
8
|
+
interface UnifiedItemCardProps {
|
|
9
|
+
item: PortalItem
|
|
10
|
+
compact?: boolean
|
|
11
|
+
onVote?: (helpful: boolean) => void
|
|
12
|
+
onProgress?: () => void
|
|
13
|
+
onAIResponse?: () => void
|
|
14
|
+
onClick?: () => void
|
|
15
|
+
showThread?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function UnifiedItemCard({
|
|
19
|
+
item,
|
|
20
|
+
compact = false,
|
|
21
|
+
onVote,
|
|
22
|
+
onProgress,
|
|
23
|
+
onAIResponse,
|
|
24
|
+
onClick,
|
|
25
|
+
showThread = false,
|
|
26
|
+
}: UnifiedItemCardProps) {
|
|
27
|
+
const [expanded, setExpanded] = useState(false)
|
|
28
|
+
|
|
29
|
+
const getContextIcon = (context: string) => {
|
|
30
|
+
switch (context) {
|
|
31
|
+
case 'support': return '🎫'
|
|
32
|
+
case 'community': return '💬'
|
|
33
|
+
case 'kb': return '📚'
|
|
34
|
+
case 'course': return '🎓'
|
|
35
|
+
default: return '📄'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const renderContent = () => {
|
|
40
|
+
if (compact) return null
|
|
41
|
+
const text = String(item.data?.description || item.data?.summary || 'No description provided.')
|
|
42
|
+
return <p className="text-sm text-muted-foreground mt-2">{text}</p>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const renderActions = () => {
|
|
46
|
+
if (compact) return null
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex items-center gap-2 mt-4">
|
|
49
|
+
{item.context === 'support' && onAIResponse && (
|
|
50
|
+
<Button variant="outline" size="sm" onClick={onAIResponse}>Get AI Help</Button>
|
|
51
|
+
)}
|
|
52
|
+
{(item.context === 'community' || item.context === 'kb') && onVote && (
|
|
53
|
+
<>
|
|
54
|
+
<Button variant="ghost" size="sm" onClick={() => onVote(true)}>👍 Helpful</Button>
|
|
55
|
+
<Button variant="ghost" size="sm" onClick={() => onVote(false)}>👎 Not Helpful</Button>
|
|
56
|
+
</>
|
|
57
|
+
)}
|
|
58
|
+
{item.context === 'course' && onProgress && (
|
|
59
|
+
<Button variant="outline" size="sm" onClick={onProgress}>Mark Complete</Button>
|
|
60
|
+
)}
|
|
61
|
+
{showThread && (
|
|
62
|
+
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
|
|
63
|
+
{expanded ? 'Hide' : 'Show'} Discussion
|
|
64
|
+
</Button>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={`border border-border rounded-lg ${compact ? 'p-3' : 'p-4'} hover:shadow-sm transition-shadow cursor-pointer bg-card`}
|
|
73
|
+
onClick={onClick}
|
|
74
|
+
>
|
|
75
|
+
<div className="space-y-3">
|
|
76
|
+
<div className="flex items-start justify-between">
|
|
77
|
+
<div className="flex-1">
|
|
78
|
+
<h3 className="font-medium text-sm">{item.title}</h3>
|
|
79
|
+
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
|
80
|
+
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
|
81
|
+
{getContextIcon(item.context)} {item.context}
|
|
82
|
+
</span>
|
|
83
|
+
<StatusBadge status={item.status} />
|
|
84
|
+
{item.data?.priority && (
|
|
85
|
+
<Badge variant="outline" className="text-xs">
|
|
86
|
+
Priority: {String(item.data.priority)}
|
|
87
|
+
</Badge>
|
|
88
|
+
)}
|
|
89
|
+
<span className="text-xs text-muted-foreground">
|
|
90
|
+
{new Date(item.created_at).toLocaleDateString()}
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{renderContent()}
|
|
97
|
+
{renderActions()}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{expanded && showThread && (
|
|
101
|
+
<div className="mt-4 pt-4 border-t border-border">
|
|
102
|
+
<ThreadPanel itemId={item.id} itemType="content" context="portal" />
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ThumbsUp, ThumbsDown } from 'lucide-react'
|
|
3
|
+
import { Button } from '@core/components/ui/button'
|
|
4
|
+
|
|
5
|
+
interface VotingComponentProps {
|
|
6
|
+
helpfulCount: number
|
|
7
|
+
notHelpfulCount?: number
|
|
8
|
+
onVote?: (helpful: boolean) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function VotingComponent({ helpfulCount, notHelpfulCount = 0, onVote }: VotingComponentProps) {
|
|
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
|
+
return (
|
|
23
|
+
<div className="flex items-center gap-2">
|
|
24
|
+
<Button
|
|
25
|
+
variant={voteType === 'helpful' ? 'default' : 'outline'}
|
|
26
|
+
size="sm"
|
|
27
|
+
onClick={() => handleVote(true)}
|
|
28
|
+
disabled={hasVoted}
|
|
29
|
+
className="gap-1.5"
|
|
30
|
+
>
|
|
31
|
+
<ThumbsUp size={13} /> Helpful {helpfulCount > 0 && `(${helpfulCount})`}
|
|
32
|
+
</Button>
|
|
33
|
+
|
|
34
|
+
{notHelpfulCount > 0 && (
|
|
35
|
+
<Button
|
|
36
|
+
variant={voteType === 'not-helpful' ? 'default' : 'outline'}
|
|
37
|
+
size="sm"
|
|
38
|
+
onClick={() => handleVote(false)}
|
|
39
|
+
disabled={hasVoted}
|
|
40
|
+
className="gap-1.5"
|
|
41
|
+
>
|
|
42
|
+
<ThumbsDown size={13} /> Not Helpful ({notHelpfulCount})
|
|
43
|
+
</Button>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
{hasVoted && (
|
|
47
|
+
<span className="text-xs text-muted-foreground">Thanks for your feedback!</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|