spine-framework-portal 0.2.21 → 0.2.22
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/KBGenerator.tsx +1 -3
- package/hooks/useCommunity.ts +1 -5
- package/hooks/useItemProgress.ts +2 -2
- package/hooks/usePortalThreads.ts +3 -13
- package/hooks/useTickets.ts +1 -5
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/pages/CoursesPage.tsx +4 -13
- package/hooks/useTypeRegistry.ts +0 -74
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
|
-
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
3
2
|
import { apiFetch } from '@core/lib/api'
|
|
4
3
|
import { Card, CardContent, CardHeader } from '@core/components/ui/card'
|
|
5
4
|
import { Button } from '@core/components/ui/button'
|
|
@@ -40,13 +39,12 @@ export function KBGenerator({ ticket, onGenerated, onCancel }: KBGeneratorProps)
|
|
|
40
39
|
const handleSave = async () => {
|
|
41
40
|
if (!generatedArticle) return
|
|
42
41
|
try {
|
|
43
|
-
const kbArticleTypeId = await getTypeIdAsync('kb_article')
|
|
44
42
|
const res = await apiFetch('/.netlify/functions/admin-data', {
|
|
45
43
|
method: 'POST',
|
|
46
44
|
headers: { 'Content-Type': 'application/json' },
|
|
47
45
|
body: JSON.stringify({
|
|
48
46
|
entity: 'items',
|
|
49
|
-
|
|
47
|
+
type_slug: 'kb_article',
|
|
50
48
|
title: generatedArticle.title,
|
|
51
49
|
status: 'published',
|
|
52
50
|
description: editedContent,
|
package/hooks/useCommunity.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { apiFetch } from '@core/lib/api'
|
|
3
|
-
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
3
|
|
|
5
4
|
export interface CommunityPost {
|
|
6
5
|
id: string
|
|
@@ -59,12 +58,9 @@ export function useCreatePost() {
|
|
|
59
58
|
setLoading(true)
|
|
60
59
|
setError(null)
|
|
61
60
|
try {
|
|
62
|
-
const typeId = await getTypeIdAsync('community_post')
|
|
63
|
-
if (!typeId) throw new Error('community_post type not found')
|
|
64
|
-
|
|
65
61
|
return await fetchJSON('/.netlify/functions/admin-data', {
|
|
66
62
|
method: 'POST',
|
|
67
|
-
body: JSON.stringify({ entity: 'items',
|
|
63
|
+
body: JSON.stringify({ entity: 'items', type_slug: 'community_post', ...fields }),
|
|
68
64
|
})
|
|
69
65
|
} catch (e: any) {
|
|
70
66
|
setError(e.message)
|
package/hooks/useItemProgress.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function useUpsertProgress() {
|
|
|
76
76
|
const upsert = useCallback(async (params: {
|
|
77
77
|
personId: string
|
|
78
78
|
itemId: string
|
|
79
|
-
|
|
79
|
+
typeSlug: string
|
|
80
80
|
accountId: string
|
|
81
81
|
appId?: string
|
|
82
82
|
status?: string
|
|
@@ -92,7 +92,7 @@ export function useUpsertProgress() {
|
|
|
92
92
|
body: JSON.stringify({
|
|
93
93
|
person_id: params.personId,
|
|
94
94
|
item_id: params.itemId,
|
|
95
|
-
|
|
95
|
+
type_slug: params.typeSlug,
|
|
96
96
|
account_id: params.accountId,
|
|
97
97
|
app_id: params.appId,
|
|
98
98
|
status: params.status,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { apiFetch } from '@core/lib/api'
|
|
3
|
-
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
3
|
|
|
5
4
|
export interface PortalMessage {
|
|
6
5
|
id: string
|
|
@@ -63,11 +62,8 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
63
62
|
setLoading(true)
|
|
64
63
|
setError(null)
|
|
65
64
|
try {
|
|
66
|
-
const threadTypeId = await getTypeIdAsync(threadTypeSlug)
|
|
67
|
-
if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
|
|
68
|
-
|
|
69
65
|
const threads = await fetchJSON(
|
|
70
|
-
`/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}&
|
|
66
|
+
`/.netlify/functions/admin-data?entity=threads&target_type=${targetType}&target_id=${targetId}&type_slug=${threadTypeSlug}`
|
|
71
67
|
)
|
|
72
68
|
const found: PortalThread | null = Array.isArray(threads) ? (threads[0] ?? null) : null
|
|
73
69
|
setThread(found)
|
|
@@ -92,14 +88,11 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
92
88
|
let activeThread = thread
|
|
93
89
|
|
|
94
90
|
if (!activeThread?.id) {
|
|
95
|
-
const threadTypeId = await getTypeIdAsync(threadTypeSlug)
|
|
96
|
-
if (!threadTypeId) throw new Error(`${threadTypeSlug} type not found`)
|
|
97
|
-
|
|
98
91
|
activeThread = await fetchJSON('/.netlify/functions/admin-data', {
|
|
99
92
|
method: 'POST',
|
|
100
93
|
body: JSON.stringify({
|
|
101
94
|
entity: 'threads',
|
|
102
|
-
|
|
95
|
+
type_slug: threadTypeSlug,
|
|
103
96
|
target_type: targetType,
|
|
104
97
|
target_id: targetId,
|
|
105
98
|
status: 'open',
|
|
@@ -108,9 +101,6 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
108
101
|
setThread(activeThread)
|
|
109
102
|
}
|
|
110
103
|
|
|
111
|
-
const messageTypeId = await getTypeIdAsync(messageTypeSlug)
|
|
112
|
-
if (!messageTypeId) throw new Error(`${messageTypeSlug} type not found`)
|
|
113
|
-
|
|
114
104
|
// Calculate next sequence based on current messages to avoid stale state
|
|
115
105
|
const nextSequence = messages.reduce((max, m) => Math.max(max, m.sequence || 0), 0) + 1
|
|
116
106
|
|
|
@@ -118,7 +108,7 @@ export function usePortalThread(targetType: string, targetId: string | null, dom
|
|
|
118
108
|
method: 'POST',
|
|
119
109
|
body: JSON.stringify({
|
|
120
110
|
entity: 'messages',
|
|
121
|
-
|
|
111
|
+
type_slug: messageTypeSlug,
|
|
122
112
|
thread_id: activeThread!.id,
|
|
123
113
|
content,
|
|
124
114
|
direction: 'inbound',
|
package/hooks/useTickets.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback } from 'react'
|
|
2
2
|
import { apiFetch } from '@core/lib/api'
|
|
3
|
-
import { getTypeIdAsync } from './useTypeRegistry'
|
|
4
3
|
|
|
5
4
|
export interface Ticket {
|
|
6
5
|
id: string
|
|
@@ -77,12 +76,9 @@ export function useCreateTicket() {
|
|
|
77
76
|
setLoading(true)
|
|
78
77
|
setError(null)
|
|
79
78
|
try {
|
|
80
|
-
const typeId = await getTypeIdAsync('support_ticket')
|
|
81
|
-
if (!typeId) throw new Error('support_ticket type not found')
|
|
82
|
-
|
|
83
79
|
return await fetchJSON('/.netlify/functions/admin-data', {
|
|
84
80
|
method: 'POST',
|
|
85
|
-
body: JSON.stringify({ entity: 'items',
|
|
81
|
+
body: JSON.stringify({ entity: 'items', type_slug: 'support_ticket', ...fields }),
|
|
86
82
|
})
|
|
87
83
|
} catch (e: any) {
|
|
88
84
|
setError(e.message)
|
package/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "Portal",
|
|
3
3
|
"slug": "portal",
|
|
4
4
|
"description": "Self-service portal for customers to access tickets, knowledge base, courses, and community",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.22",
|
|
6
6
|
"app_type": "full",
|
|
7
7
|
"route_prefix": "/portal",
|
|
8
8
|
"required_roles": ["member", "member_admin"],
|
package/package.json
CHANGED
package/pages/CoursesPage.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { useState, useEffect
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
2
|
import { CheckCircle, Circle, PlayCircle, GraduationCap, Send } from 'lucide-react'
|
|
3
3
|
import { useCourseLessons, type CourseItem } from '../hooks/useCourses'
|
|
4
4
|
import { usePortalSignal } from '../hooks/usePortalSignal'
|
|
5
5
|
import { useItemProgress, useUpsertProgress } from '../hooks/useItemProgress'
|
|
6
6
|
import { usePortalThread } from '../hooks/usePortalThreads'
|
|
7
|
-
import { getTypeIdAsync } from '../hooks/useTypeRegistry'
|
|
8
7
|
import { useAuth } from '@core/contexts/AuthContext'
|
|
9
8
|
import { SearchFilterBar } from '../components/SearchFilterBar'
|
|
10
9
|
import { Button } from '@core/components/ui/button'
|
|
@@ -42,7 +41,6 @@ export function CoursesPage() {
|
|
|
42
41
|
const [selectedId, setSelectedId] = useState<string | null>(() => localStorage.getItem(LAST_LESSON_KEY))
|
|
43
42
|
const [replyText, setReplyText] = useState('')
|
|
44
43
|
const [replying, setReplying] = useState(false)
|
|
45
|
-
const progressTypeIdRef = useRef<string | null>(null)
|
|
46
44
|
|
|
47
45
|
const { lessons, loading, error } = useCourseLessons()
|
|
48
46
|
const lessonIds = lessons.map((l) => l.id)
|
|
@@ -60,10 +58,6 @@ export function CoursesPage() {
|
|
|
60
58
|
}
|
|
61
59
|
}, [lessons, selectedId])
|
|
62
60
|
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
getTypeIdAsync(PROGRESS_TYPE_SLUG).then((id) => { progressTypeIdRef.current = id })
|
|
65
|
-
}, [])
|
|
66
|
-
|
|
67
61
|
const courseMap = groupByCourse(lessons)
|
|
68
62
|
const courseNames = Array.from(courseMap.keys())
|
|
69
63
|
const filteredCourses = courseNames.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
|
|
@@ -75,11 +69,10 @@ export function CoursesPage() {
|
|
|
75
69
|
const handleSelectLesson = async (lessonId: string) => {
|
|
76
70
|
setSelectedId(lessonId)
|
|
77
71
|
localStorage.setItem(LAST_LESSON_KEY, lessonId)
|
|
78
|
-
|
|
79
|
-
if (!typeId || !user?.id || !user?.account_id) return
|
|
72
|
+
if (!user?.id || !user?.account_id) return
|
|
80
73
|
const existing = progressMap.get(lessonId)
|
|
81
74
|
if (!existing || existing.status === 'not_started') {
|
|
82
|
-
await upsert({ personId: user.id, itemId: lessonId,
|
|
75
|
+
await upsert({ personId: user.id, itemId: lessonId, typeSlug: PROGRESS_TYPE_SLUG, accountId: user.account_id, status: 'in_progress' })
|
|
83
76
|
refetchProgress()
|
|
84
77
|
sendSignal('lesson_start', 'Started a course lesson')
|
|
85
78
|
}
|
|
@@ -87,9 +80,7 @@ export function CoursesPage() {
|
|
|
87
80
|
|
|
88
81
|
const handleComplete = async () => {
|
|
89
82
|
if (!selectedId || !user?.id || !user?.account_id) return
|
|
90
|
-
|
|
91
|
-
if (!typeId) return
|
|
92
|
-
await upsert({ personId: user.id, itemId: selectedId, typeId, accountId: user.account_id, status: 'completed' })
|
|
83
|
+
await upsert({ personId: user.id, itemId: selectedId, typeSlug: PROGRESS_TYPE_SLUG, accountId: user.account_id, status: 'completed' })
|
|
93
84
|
refetchProgress()
|
|
94
85
|
sendSignal('lesson_complete', 'Completed a course lesson')
|
|
95
86
|
}
|
package/hooks/useTypeRegistry.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect } from 'react'
|
|
2
|
-
import { supabase } from '@core/lib/supabase'
|
|
3
|
-
|
|
4
|
-
interface TypeRecord {
|
|
5
|
-
id: string
|
|
6
|
-
slug: string
|
|
7
|
-
name: string
|
|
8
|
-
description?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
// Module-level cache - persists across renders, shared across hooks
|
|
12
|
-
let typeCache: Map<string, TypeRecord> | null = null
|
|
13
|
-
let loadPromise: Promise<Map<string, TypeRecord>> | null = null
|
|
14
|
-
|
|
15
|
-
async function fetchTypes(): Promise<Map<string, TypeRecord>> {
|
|
16
|
-
if (typeCache) return typeCache
|
|
17
|
-
if (loadPromise) return loadPromise
|
|
18
|
-
|
|
19
|
-
loadPromise = (async () => {
|
|
20
|
-
try {
|
|
21
|
-
// Query Supabase directly - types is a config table, not exposed via admin-data
|
|
22
|
-
const { data: types, error } = await supabase
|
|
23
|
-
.from('types')
|
|
24
|
-
.select('id, slug, name, description')
|
|
25
|
-
.eq('is_active', true)
|
|
26
|
-
.limit(100)
|
|
27
|
-
|
|
28
|
-
if (error) throw error
|
|
29
|
-
|
|
30
|
-
typeCache = new Map((types || []).map(t => [t.slug, t]))
|
|
31
|
-
return typeCache
|
|
32
|
-
} catch (e) {
|
|
33
|
-
console.error('Failed to load types:', e)
|
|
34
|
-
typeCache = new Map() // Empty cache on error
|
|
35
|
-
return typeCache
|
|
36
|
-
} finally {
|
|
37
|
-
loadPromise = null
|
|
38
|
-
}
|
|
39
|
-
})()
|
|
40
|
-
|
|
41
|
-
return loadPromise
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function useTypeRegistry() {
|
|
45
|
-
const [types, setTypes] = useState<Map<string, TypeRecord>>(typeCache || new Map())
|
|
46
|
-
const [loading, setLoading] = useState(!typeCache)
|
|
47
|
-
const [error, setError] = useState<string | null>(null)
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (typeCache) return
|
|
51
|
-
|
|
52
|
-
fetchTypes()
|
|
53
|
-
.then(cache => {
|
|
54
|
-
setTypes(cache)
|
|
55
|
-
setLoading(false)
|
|
56
|
-
})
|
|
57
|
-
.catch(e => {
|
|
58
|
-
setError(e.message)
|
|
59
|
-
setLoading(false)
|
|
60
|
-
})
|
|
61
|
-
}, [])
|
|
62
|
-
|
|
63
|
-
const getTypeId = (slug: string): string | null => {
|
|
64
|
-
return types.get(slug)?.id || null
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return { types, loading, error, getTypeId }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Synchronous version for use inside async functions
|
|
71
|
-
export async function getTypeIdAsync(slug: string): Promise<string | null> {
|
|
72
|
-
const cache = await fetchTypes()
|
|
73
|
-
return cache.get(slug)?.id || null
|
|
74
|
-
}
|