@alliance-droid/status-feedback-system 0.1.1 → 0.2.0
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/dist/api.d.ts +38 -1
- package/dist/api.js +24 -0
- package/dist/components/FeedbackBoard.svelte +842 -0
- package/dist/components/FeedbackBoard.svelte.d.ts +19 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* const { systems, incidents } = await client.getStatus();
|
|
13
13
|
*/
|
|
14
|
-
import type { System, Incident, StatusHistoryEntry, Feedback } from './types.js';
|
|
14
|
+
import type { System, Incident, StatusHistoryEntry, Feedback, FeedbackResponse } from './types.js';
|
|
15
15
|
export interface StatusClientConfig {
|
|
16
16
|
/** Base URL of the status-feedback-admin app */
|
|
17
17
|
apiUrl: string;
|
|
@@ -30,6 +30,26 @@ export interface IncidentData {
|
|
|
30
30
|
export interface UptimeData {
|
|
31
31
|
history: Record<string, StatusHistoryEntry[]>;
|
|
32
32
|
}
|
|
33
|
+
export interface BoardCategory {
|
|
34
|
+
name: string;
|
|
35
|
+
count: number;
|
|
36
|
+
}
|
|
37
|
+
export interface BoardItem {
|
|
38
|
+
id: string;
|
|
39
|
+
title: string | null;
|
|
40
|
+
category: string | null;
|
|
41
|
+
message: string;
|
|
42
|
+
status: string;
|
|
43
|
+
votes: number;
|
|
44
|
+
adminResponse: string | null;
|
|
45
|
+
responseCount: number;
|
|
46
|
+
createdAt: number;
|
|
47
|
+
}
|
|
48
|
+
export interface BoardData {
|
|
49
|
+
items: BoardItem[];
|
|
50
|
+
total: number;
|
|
51
|
+
categories: BoardCategory[];
|
|
52
|
+
}
|
|
33
53
|
export interface StatusClient {
|
|
34
54
|
/** Get current system statuses */
|
|
35
55
|
getStatus(): Promise<StatusData>;
|
|
@@ -45,11 +65,28 @@ export interface StatusClient {
|
|
|
45
65
|
/** Submit user feedback */
|
|
46
66
|
submitFeedback(data: {
|
|
47
67
|
message: string;
|
|
68
|
+
title?: string;
|
|
48
69
|
category?: string;
|
|
49
70
|
email?: string;
|
|
50
71
|
pageUrl?: string;
|
|
51
72
|
metadata?: Record<string, unknown>;
|
|
52
73
|
}): Promise<Feedback>;
|
|
74
|
+
/** Get public feedback board */
|
|
75
|
+
getBoard(options?: {
|
|
76
|
+
category?: string;
|
|
77
|
+
status?: 'open' | 'resolved' | 'all';
|
|
78
|
+
sort?: 'votes' | 'newest';
|
|
79
|
+
limit?: number;
|
|
80
|
+
offset?: number;
|
|
81
|
+
}): Promise<BoardData>;
|
|
82
|
+
/** Vote on a feedback item */
|
|
83
|
+
vote(feedbackId: string, direction?: 'up' | 'down'): Promise<{
|
|
84
|
+
votes: number;
|
|
85
|
+
}>;
|
|
86
|
+
/** Get responses for a feedback item */
|
|
87
|
+
getResponses(feedbackId: string): Promise<{
|
|
88
|
+
responses: FeedbackResponse[];
|
|
89
|
+
}>;
|
|
53
90
|
}
|
|
54
91
|
/**
|
|
55
92
|
* Create a status API client instance
|
package/dist/api.js
CHANGED
|
@@ -72,5 +72,29 @@ export function createStatusClient(config) {
|
|
|
72
72
|
body: JSON.stringify(data),
|
|
73
73
|
});
|
|
74
74
|
},
|
|
75
|
+
async getBoard(options) {
|
|
76
|
+
const params = new URLSearchParams();
|
|
77
|
+
if (options?.category)
|
|
78
|
+
params.set('category', options.category);
|
|
79
|
+
if (options?.status)
|
|
80
|
+
params.set('status', options.status);
|
|
81
|
+
if (options?.sort)
|
|
82
|
+
params.set('sort', options.sort);
|
|
83
|
+
if (options?.limit)
|
|
84
|
+
params.set('limit', String(options.limit));
|
|
85
|
+
if (options?.offset)
|
|
86
|
+
params.set('offset', String(options.offset));
|
|
87
|
+
const qs = params.toString();
|
|
88
|
+
return request(`/api/board${qs ? `?${qs}` : ''}`);
|
|
89
|
+
},
|
|
90
|
+
async vote(feedbackId, direction = 'up') {
|
|
91
|
+
return request('/api/board/vote', {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
body: JSON.stringify({ feedbackId, direction }),
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
async getResponses(feedbackId) {
|
|
97
|
+
return request(`/api/board/${feedbackId}/responses`);
|
|
98
|
+
},
|
|
75
99
|
};
|
|
76
100
|
}
|
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { createStatusClient, type BoardItem, type BoardCategory } from '../api.js';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
apiUrl: string;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
/** Category labels and icons — order determines sidebar order */
|
|
8
|
+
categoryConfig?: Record<string, { label?: string; icon?: string; description?: string }>;
|
|
9
|
+
/** Title shown at the top */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Show the submit form inline */
|
|
12
|
+
showSubmit?: boolean;
|
|
13
|
+
/** Available categories for the submit form */
|
|
14
|
+
submitCategories?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
apiUrl,
|
|
19
|
+
apiKey,
|
|
20
|
+
categoryConfig = {},
|
|
21
|
+
title = 'Feedback Board',
|
|
22
|
+
showSubmit = true,
|
|
23
|
+
submitCategories = [],
|
|
24
|
+
}: Props = $props();
|
|
25
|
+
|
|
26
|
+
let client = $derived(createStatusClient({ apiUrl, apiKey }));
|
|
27
|
+
let items = $state<BoardItem[]>([]);
|
|
28
|
+
let categories = $state<BoardCategory[]>([]);
|
|
29
|
+
let total = $state(0);
|
|
30
|
+
let loading = $state(true);
|
|
31
|
+
let error = $state('');
|
|
32
|
+
|
|
33
|
+
let activeCategory = $state<string | null>(null);
|
|
34
|
+
let statusFilter = $state<'open' | 'resolved' | 'all'>('open');
|
|
35
|
+
let sortBy = $state<'votes' | 'newest'>('votes');
|
|
36
|
+
|
|
37
|
+
// Expanded item for viewing responses
|
|
38
|
+
let expandedId = $state<string | null>(null);
|
|
39
|
+
let responses = $state<Record<string, { message: string; author: string | null; createdAt: number }[]>>({});
|
|
40
|
+
let loadingResponses = $state<Record<string, boolean>>({});
|
|
41
|
+
|
|
42
|
+
// Submit form
|
|
43
|
+
let showForm = $state(false);
|
|
44
|
+
let submitTitle = $state('');
|
|
45
|
+
let submitMessage = $state('');
|
|
46
|
+
let submitCategory = $state('');
|
|
47
|
+
let submitEmail = $state('');
|
|
48
|
+
let submitting = $state(false);
|
|
49
|
+
let submitSuccess = $state(false);
|
|
50
|
+
|
|
51
|
+
// Voting state (tracks which items the user voted on this session)
|
|
52
|
+
let votedItems = $state<Record<string, 'up' | 'down'>>({});
|
|
53
|
+
|
|
54
|
+
async function load() {
|
|
55
|
+
loading = true;
|
|
56
|
+
error = '';
|
|
57
|
+
try {
|
|
58
|
+
const data = await client.getBoard({
|
|
59
|
+
category: activeCategory || undefined,
|
|
60
|
+
status: statusFilter,
|
|
61
|
+
sort: sortBy,
|
|
62
|
+
});
|
|
63
|
+
items = data.items;
|
|
64
|
+
total = data.total;
|
|
65
|
+
categories = data.categories;
|
|
66
|
+
} catch (e) {
|
|
67
|
+
error = e instanceof Error ? e.message : 'Failed to load';
|
|
68
|
+
} finally {
|
|
69
|
+
loading = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
$effect(() => {
|
|
74
|
+
// Re-fetch when filters change
|
|
75
|
+
void activeCategory;
|
|
76
|
+
void statusFilter;
|
|
77
|
+
void sortBy;
|
|
78
|
+
load();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function handleVote(itemId: string, direction: 'up' | 'down') {
|
|
82
|
+
const prev = votedItems[itemId];
|
|
83
|
+
try {
|
|
84
|
+
const result = await client.vote(itemId, direction);
|
|
85
|
+
// Update local state
|
|
86
|
+
const item = items.find((i) => i.id === itemId);
|
|
87
|
+
if (item) item.votes = result.votes;
|
|
88
|
+
|
|
89
|
+
if (prev === direction) {
|
|
90
|
+
// Toggled off
|
|
91
|
+
delete votedItems[itemId];
|
|
92
|
+
votedItems = { ...votedItems };
|
|
93
|
+
} else {
|
|
94
|
+
votedItems = { ...votedItems, [itemId]: direction };
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// silently fail
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function toggleExpand(itemId: string) {
|
|
102
|
+
if (expandedId === itemId) {
|
|
103
|
+
expandedId = null;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
expandedId = itemId;
|
|
107
|
+
if (!responses[itemId]) {
|
|
108
|
+
loadingResponses = { ...loadingResponses, [itemId]: true };
|
|
109
|
+
try {
|
|
110
|
+
const data = await client.getResponses(itemId);
|
|
111
|
+
responses = { ...responses, [itemId]: data.responses };
|
|
112
|
+
} catch {
|
|
113
|
+
responses = { ...responses, [itemId]: [] };
|
|
114
|
+
} finally {
|
|
115
|
+
loadingResponses = { ...loadingResponses, [itemId]: false };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function handleSubmit() {
|
|
121
|
+
if (!submitMessage.trim()) return;
|
|
122
|
+
submitting = true;
|
|
123
|
+
try {
|
|
124
|
+
await client.submitFeedback({
|
|
125
|
+
title: submitTitle.trim() || undefined,
|
|
126
|
+
message: submitMessage.trim(),
|
|
127
|
+
category: submitCategory || undefined,
|
|
128
|
+
email: submitEmail.trim() || undefined,
|
|
129
|
+
pageUrl: window.location.href,
|
|
130
|
+
});
|
|
131
|
+
submitSuccess = true;
|
|
132
|
+
submitTitle = '';
|
|
133
|
+
submitMessage = '';
|
|
134
|
+
submitCategory = '';
|
|
135
|
+
submitEmail = '';
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
submitSuccess = false;
|
|
138
|
+
showForm = false;
|
|
139
|
+
}, 2000);
|
|
140
|
+
// Reload board
|
|
141
|
+
await load();
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore
|
|
144
|
+
} finally {
|
|
145
|
+
submitting = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getCategoryIcon(name: string): string {
|
|
150
|
+
return categoryConfig[name]?.icon || 'fa-solid fa-circle';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getCategoryLabel(name: string): string {
|
|
154
|
+
return categoryConfig[name]?.label || name;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getCategoryDescription(name: string): string {
|
|
158
|
+
return categoryConfig[name]?.description || '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function timeAgo(timestamp: number): string {
|
|
162
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
163
|
+
if (seconds < 60) return 'just now';
|
|
164
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
165
|
+
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
166
|
+
const days = Math.floor(seconds / 86400);
|
|
167
|
+
if (days < 30) return `${days}d ago`;
|
|
168
|
+
return new Date(timestamp).toLocaleDateString();
|
|
169
|
+
}
|
|
170
|
+
</script>
|
|
171
|
+
|
|
172
|
+
<div class="sf-board">
|
|
173
|
+
<div class="sf-board-layout">
|
|
174
|
+
<!-- Sidebar -->
|
|
175
|
+
<aside class="sf-board-sidebar">
|
|
176
|
+
<button
|
|
177
|
+
class="sf-sidebar-item"
|
|
178
|
+
class:sf-sidebar-active={activeCategory === null}
|
|
179
|
+
onclick={() => (activeCategory = null)}
|
|
180
|
+
>
|
|
181
|
+
<i class="fa-solid fa-layer-group"></i>
|
|
182
|
+
<span>All</span>
|
|
183
|
+
<span class="sf-sidebar-count">{total}</span>
|
|
184
|
+
</button>
|
|
185
|
+
|
|
186
|
+
{#each categories as cat}
|
|
187
|
+
<button
|
|
188
|
+
class="sf-sidebar-item"
|
|
189
|
+
class:sf-sidebar-active={activeCategory === cat.name}
|
|
190
|
+
onclick={() => (activeCategory = activeCategory === cat.name ? null : cat.name)}
|
|
191
|
+
>
|
|
192
|
+
<i class={getCategoryIcon(cat.name)}></i>
|
|
193
|
+
<span>{getCategoryLabel(cat.name)}</span>
|
|
194
|
+
<span class="sf-sidebar-count">{cat.count}</span>
|
|
195
|
+
</button>
|
|
196
|
+
{/each}
|
|
197
|
+
|
|
198
|
+
<hr class="sf-sidebar-divider" />
|
|
199
|
+
|
|
200
|
+
<div class="sf-sidebar-section">
|
|
201
|
+
<label class="sf-sidebar-label">Status</label>
|
|
202
|
+
<select bind:value={statusFilter} class="sf-select">
|
|
203
|
+
<option value="open">Open</option>
|
|
204
|
+
<option value="resolved">Resolved</option>
|
|
205
|
+
<option value="all">All</option>
|
|
206
|
+
</select>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div class="sf-sidebar-section">
|
|
210
|
+
<label class="sf-sidebar-label">Sort By</label>
|
|
211
|
+
<select bind:value={sortBy} class="sf-select">
|
|
212
|
+
<option value="votes">Most Votes</option>
|
|
213
|
+
<option value="newest">Newest</option>
|
|
214
|
+
</select>
|
|
215
|
+
</div>
|
|
216
|
+
</aside>
|
|
217
|
+
|
|
218
|
+
<!-- Main content -->
|
|
219
|
+
<main class="sf-board-main">
|
|
220
|
+
<div class="sf-board-header">
|
|
221
|
+
<div>
|
|
222
|
+
<h2 class="sf-board-title">
|
|
223
|
+
{#if activeCategory}
|
|
224
|
+
<i class={getCategoryIcon(activeCategory)}></i>
|
|
225
|
+
{getCategoryLabel(activeCategory)}
|
|
226
|
+
{:else}
|
|
227
|
+
{title}
|
|
228
|
+
{/if}
|
|
229
|
+
</h2>
|
|
230
|
+
{#if activeCategory && getCategoryDescription(activeCategory)}
|
|
231
|
+
<p class="sf-board-subtitle">{getCategoryDescription(activeCategory)}</p>
|
|
232
|
+
{/if}
|
|
233
|
+
</div>
|
|
234
|
+
{#if showSubmit}
|
|
235
|
+
<button class="sf-submit-btn" onclick={() => (showForm = !showForm)}>
|
|
236
|
+
<i class="fa-solid fa-plus"></i> Submit
|
|
237
|
+
</button>
|
|
238
|
+
{/if}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<!-- Submit form -->
|
|
242
|
+
{#if showForm}
|
|
243
|
+
<div class="sf-submit-form">
|
|
244
|
+
{#if submitSuccess}
|
|
245
|
+
<div class="sf-success">
|
|
246
|
+
<i class="fa-solid fa-check-circle"></i> Submitted! It will appear once reviewed.
|
|
247
|
+
</div>
|
|
248
|
+
{:else}
|
|
249
|
+
<input
|
|
250
|
+
type="text"
|
|
251
|
+
bind:value={submitTitle}
|
|
252
|
+
placeholder="Title (optional)"
|
|
253
|
+
class="sf-input"
|
|
254
|
+
/>
|
|
255
|
+
<textarea
|
|
256
|
+
bind:value={submitMessage}
|
|
257
|
+
placeholder="Describe your feedback..."
|
|
258
|
+
rows="3"
|
|
259
|
+
class="sf-textarea"
|
|
260
|
+
></textarea>
|
|
261
|
+
<div class="sf-submit-row">
|
|
262
|
+
{#if submitCategories.length > 0}
|
|
263
|
+
<select bind:value={submitCategory} class="sf-select">
|
|
264
|
+
<option value="">Category...</option>
|
|
265
|
+
{#each submitCategories as cat}
|
|
266
|
+
<option value={cat}>{getCategoryLabel(cat)}</option>
|
|
267
|
+
{/each}
|
|
268
|
+
</select>
|
|
269
|
+
{/if}
|
|
270
|
+
<input
|
|
271
|
+
type="email"
|
|
272
|
+
bind:value={submitEmail}
|
|
273
|
+
placeholder="Email (optional)"
|
|
274
|
+
class="sf-input sf-input-sm"
|
|
275
|
+
/>
|
|
276
|
+
<button
|
|
277
|
+
class="sf-submit-btn"
|
|
278
|
+
disabled={submitting || !submitMessage.trim()}
|
|
279
|
+
onclick={handleSubmit}
|
|
280
|
+
>
|
|
281
|
+
{submitting ? 'Sending...' : 'Submit'}
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
{/if}
|
|
285
|
+
</div>
|
|
286
|
+
{/if}
|
|
287
|
+
|
|
288
|
+
<!-- Items -->
|
|
289
|
+
{#if loading}
|
|
290
|
+
<div class="sf-empty">
|
|
291
|
+
<i class="fa-solid fa-spinner fa-spin"></i>
|
|
292
|
+
<p>Loading...</p>
|
|
293
|
+
</div>
|
|
294
|
+
{:else if error}
|
|
295
|
+
<div class="sf-empty">
|
|
296
|
+
<i class="fa-solid fa-exclamation-triangle"></i>
|
|
297
|
+
<p>{error}</p>
|
|
298
|
+
</div>
|
|
299
|
+
{:else if items.length === 0}
|
|
300
|
+
<div class="sf-empty">
|
|
301
|
+
<i class="fa-solid fa-inbox"></i>
|
|
302
|
+
<p>No feedback yet</p>
|
|
303
|
+
<p class="sf-empty-sub">Be the first to submit!</p>
|
|
304
|
+
</div>
|
|
305
|
+
{:else}
|
|
306
|
+
<div class="sf-items">
|
|
307
|
+
{#each items as item (item.id)}
|
|
308
|
+
<div class="sf-item">
|
|
309
|
+
<!-- Vote column -->
|
|
310
|
+
<div class="sf-vote-col">
|
|
311
|
+
<button
|
|
312
|
+
class="sf-vote-btn"
|
|
313
|
+
class:sf-voted={votedItems[item.id] === 'up'}
|
|
314
|
+
onclick={() => handleVote(item.id, 'up')}
|
|
315
|
+
aria-label="Upvote"
|
|
316
|
+
>
|
|
317
|
+
<i class="fa-solid fa-chevron-up"></i>
|
|
318
|
+
</button>
|
|
319
|
+
<span class="sf-vote-count">{item.votes}</span>
|
|
320
|
+
<button
|
|
321
|
+
class="sf-vote-btn"
|
|
322
|
+
class:sf-voted={votedItems[item.id] === 'down'}
|
|
323
|
+
onclick={() => handleVote(item.id, 'down')}
|
|
324
|
+
aria-label="Downvote"
|
|
325
|
+
>
|
|
326
|
+
<i class="fa-solid fa-chevron-down"></i>
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
|
|
330
|
+
<!-- Content -->
|
|
331
|
+
<div class="sf-item-content">
|
|
332
|
+
<div class="sf-item-header">
|
|
333
|
+
{#if item.category}
|
|
334
|
+
<span class="sf-item-category">
|
|
335
|
+
<i class={getCategoryIcon(item.category)}></i>
|
|
336
|
+
</span>
|
|
337
|
+
{/if}
|
|
338
|
+
<h3 class="sf-item-title">
|
|
339
|
+
{item.title || item.message.slice(0, 80)}
|
|
340
|
+
</h3>
|
|
341
|
+
<span class="sf-item-status" data-status={item.status}>
|
|
342
|
+
{item.status === 'new' ? 'Open' : item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
|
343
|
+
</span>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
{#if item.title}
|
|
347
|
+
<p class="sf-item-message">{item.message}</p>
|
|
348
|
+
{/if}
|
|
349
|
+
|
|
350
|
+
<div class="sf-item-footer">
|
|
351
|
+
<span class="sf-item-time">{timeAgo(item.createdAt)}</span>
|
|
352
|
+
{#if item.responseCount > 0}
|
|
353
|
+
<button class="sf-item-responses" onclick={() => toggleExpand(item.id)}>
|
|
354
|
+
<i class="fa-solid fa-comment"></i>
|
|
355
|
+
{item.responseCount} {item.responseCount === 1 ? 'response' : 'responses'}
|
|
356
|
+
</button>
|
|
357
|
+
{/if}
|
|
358
|
+
{#if item.adminResponse}
|
|
359
|
+
<span class="sf-item-badge">
|
|
360
|
+
<i class="fa-solid fa-shield-check"></i> Official response
|
|
361
|
+
</span>
|
|
362
|
+
{/if}
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
<!-- Expanded responses -->
|
|
366
|
+
{#if expandedId === item.id}
|
|
367
|
+
<div class="sf-responses">
|
|
368
|
+
{#if loadingResponses[item.id]}
|
|
369
|
+
<p class="sf-responses-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</p>
|
|
370
|
+
{:else if responses[item.id]?.length}
|
|
371
|
+
{#each responses[item.id] as resp}
|
|
372
|
+
<div class="sf-response">
|
|
373
|
+
<div class="sf-response-header">
|
|
374
|
+
<span class="sf-response-author">
|
|
375
|
+
{#if resp.author}
|
|
376
|
+
<i class="fa-solid fa-shield"></i> {resp.author}
|
|
377
|
+
{:else}
|
|
378
|
+
Team
|
|
379
|
+
{/if}
|
|
380
|
+
</span>
|
|
381
|
+
<span class="sf-response-time">{timeAgo(resp.createdAt)}</span>
|
|
382
|
+
</div>
|
|
383
|
+
<p class="sf-response-message">{resp.message}</p>
|
|
384
|
+
</div>
|
|
385
|
+
{/each}
|
|
386
|
+
{:else}
|
|
387
|
+
<p class="sf-responses-empty">No responses yet.</p>
|
|
388
|
+
{/if}
|
|
389
|
+
</div>
|
|
390
|
+
{/if}
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
{/each}
|
|
394
|
+
</div>
|
|
395
|
+
{/if}
|
|
396
|
+
</main>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<style>
|
|
401
|
+
.sf-board {
|
|
402
|
+
--sf-bg: var(--color-surface, #0f172a);
|
|
403
|
+
--sf-bg-secondary: var(--color-surface-secondary, #1e293b);
|
|
404
|
+
--sf-bg-hover: var(--color-surface-hover, #334155);
|
|
405
|
+
--sf-text: var(--color-foreground, #e2e8f0);
|
|
406
|
+
--sf-text-secondary: var(--color-foreground-secondary, #94a3b8);
|
|
407
|
+
--sf-text-tertiary: var(--color-foreground-tertiary, #64748b);
|
|
408
|
+
--sf-border: var(--color-border, #334155);
|
|
409
|
+
--sf-primary: var(--color-primary, #3b82f6);
|
|
410
|
+
--sf-primary-text: var(--color-primary-foreground, #ffffff);
|
|
411
|
+
--sf-success: #22c55e;
|
|
412
|
+
--sf-radius: 0.5rem;
|
|
413
|
+
font-family: inherit;
|
|
414
|
+
color: var(--sf-text);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.sf-board-layout {
|
|
418
|
+
display: flex;
|
|
419
|
+
gap: 1.5rem;
|
|
420
|
+
min-height: 400px;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* Sidebar */
|
|
424
|
+
.sf-board-sidebar {
|
|
425
|
+
width: 220px;
|
|
426
|
+
flex-shrink: 0;
|
|
427
|
+
display: flex;
|
|
428
|
+
flex-direction: column;
|
|
429
|
+
gap: 0.25rem;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
.sf-sidebar-item {
|
|
433
|
+
display: flex;
|
|
434
|
+
align-items: center;
|
|
435
|
+
gap: 0.5rem;
|
|
436
|
+
padding: 0.5rem 0.75rem;
|
|
437
|
+
border-radius: var(--sf-radius);
|
|
438
|
+
border: none;
|
|
439
|
+
background: transparent;
|
|
440
|
+
color: var(--sf-text-secondary);
|
|
441
|
+
cursor: pointer;
|
|
442
|
+
font-size: 0.875rem;
|
|
443
|
+
text-align: left;
|
|
444
|
+
width: 100%;
|
|
445
|
+
transition: all 0.15s;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.sf-sidebar-item:hover {
|
|
449
|
+
background: var(--sf-bg-hover);
|
|
450
|
+
color: var(--sf-text);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.sf-sidebar-active {
|
|
454
|
+
background: var(--sf-bg-secondary);
|
|
455
|
+
color: var(--sf-text);
|
|
456
|
+
font-weight: 600;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.sf-sidebar-count {
|
|
460
|
+
margin-left: auto;
|
|
461
|
+
background: var(--sf-bg-hover);
|
|
462
|
+
padding: 0.125rem 0.5rem;
|
|
463
|
+
border-radius: 999px;
|
|
464
|
+
font-size: 0.75rem;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.sf-sidebar-divider {
|
|
468
|
+
border: none;
|
|
469
|
+
border-top: 1px solid var(--sf-border);
|
|
470
|
+
margin: 0.5rem 0;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.sf-sidebar-section {
|
|
474
|
+
padding: 0.25rem 0.75rem;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.sf-sidebar-label {
|
|
478
|
+
display: block;
|
|
479
|
+
font-size: 0.75rem;
|
|
480
|
+
color: var(--sf-text-tertiary);
|
|
481
|
+
margin-bottom: 0.25rem;
|
|
482
|
+
text-transform: uppercase;
|
|
483
|
+
letter-spacing: 0.05em;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* Main */
|
|
487
|
+
.sf-board-main {
|
|
488
|
+
flex: 1;
|
|
489
|
+
min-width: 0;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.sf-board-header {
|
|
493
|
+
display: flex;
|
|
494
|
+
align-items: flex-start;
|
|
495
|
+
justify-content: space-between;
|
|
496
|
+
margin-bottom: 1rem;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
.sf-board-title {
|
|
500
|
+
font-size: 1.25rem;
|
|
501
|
+
font-weight: 700;
|
|
502
|
+
margin: 0;
|
|
503
|
+
display: flex;
|
|
504
|
+
align-items: center;
|
|
505
|
+
gap: 0.5rem;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.sf-board-subtitle {
|
|
509
|
+
font-size: 0.875rem;
|
|
510
|
+
color: var(--sf-text-secondary);
|
|
511
|
+
margin: 0.25rem 0 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* Items */
|
|
515
|
+
.sf-items {
|
|
516
|
+
display: flex;
|
|
517
|
+
flex-direction: column;
|
|
518
|
+
gap: 0.5rem;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.sf-item {
|
|
522
|
+
display: flex;
|
|
523
|
+
gap: 0.75rem;
|
|
524
|
+
padding: 1rem;
|
|
525
|
+
background: var(--sf-bg-secondary);
|
|
526
|
+
border: 1px solid var(--sf-border);
|
|
527
|
+
border-radius: var(--sf-radius);
|
|
528
|
+
transition: border-color 0.15s;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.sf-item:hover {
|
|
532
|
+
border-color: var(--sf-text-tertiary);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/* Vote column */
|
|
536
|
+
.sf-vote-col {
|
|
537
|
+
display: flex;
|
|
538
|
+
flex-direction: column;
|
|
539
|
+
align-items: center;
|
|
540
|
+
gap: 0.125rem;
|
|
541
|
+
min-width: 2.5rem;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.sf-vote-btn {
|
|
545
|
+
background: none;
|
|
546
|
+
border: 1px solid transparent;
|
|
547
|
+
color: var(--sf-text-tertiary);
|
|
548
|
+
cursor: pointer;
|
|
549
|
+
padding: 0.25rem;
|
|
550
|
+
border-radius: 0.25rem;
|
|
551
|
+
font-size: 0.75rem;
|
|
552
|
+
transition: all 0.15s;
|
|
553
|
+
line-height: 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.sf-vote-btn:hover {
|
|
557
|
+
color: var(--sf-primary);
|
|
558
|
+
border-color: var(--sf-primary);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.sf-voted {
|
|
562
|
+
color: var(--sf-primary) !important;
|
|
563
|
+
border-color: var(--sf-primary) !important;
|
|
564
|
+
background: color-mix(in srgb, var(--sf-primary) 10%, transparent);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.sf-vote-count {
|
|
568
|
+
font-size: 0.875rem;
|
|
569
|
+
font-weight: 700;
|
|
570
|
+
color: var(--sf-text);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/* Item content */
|
|
574
|
+
.sf-item-content {
|
|
575
|
+
flex: 1;
|
|
576
|
+
min-width: 0;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
.sf-item-header {
|
|
580
|
+
display: flex;
|
|
581
|
+
align-items: center;
|
|
582
|
+
gap: 0.5rem;
|
|
583
|
+
flex-wrap: wrap;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.sf-item-category {
|
|
587
|
+
font-size: 0.875rem;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.sf-item-title {
|
|
591
|
+
font-size: 0.9375rem;
|
|
592
|
+
font-weight: 600;
|
|
593
|
+
margin: 0;
|
|
594
|
+
flex: 1;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
.sf-item-status {
|
|
598
|
+
font-size: 0.75rem;
|
|
599
|
+
padding: 0.125rem 0.5rem;
|
|
600
|
+
border-radius: 999px;
|
|
601
|
+
font-weight: 500;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
.sf-item-status[data-status='new'],
|
|
605
|
+
.sf-item-status[data-status='reviewed'] {
|
|
606
|
+
background: color-mix(in srgb, var(--sf-success) 15%, transparent);
|
|
607
|
+
color: var(--sf-success);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
.sf-item-status[data-status='resolved'] {
|
|
611
|
+
background: color-mix(in srgb, var(--sf-text-tertiary) 15%, transparent);
|
|
612
|
+
color: var(--sf-text-tertiary);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.sf-item-message {
|
|
616
|
+
font-size: 0.875rem;
|
|
617
|
+
color: var(--sf-text-secondary);
|
|
618
|
+
margin: 0.5rem 0 0;
|
|
619
|
+
line-height: 1.5;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
.sf-item-footer {
|
|
623
|
+
display: flex;
|
|
624
|
+
align-items: center;
|
|
625
|
+
gap: 0.75rem;
|
|
626
|
+
margin-top: 0.5rem;
|
|
627
|
+
font-size: 0.8125rem;
|
|
628
|
+
color: var(--sf-text-tertiary);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.sf-item-responses {
|
|
632
|
+
background: none;
|
|
633
|
+
border: none;
|
|
634
|
+
color: var(--sf-text-tertiary);
|
|
635
|
+
cursor: pointer;
|
|
636
|
+
font-size: 0.8125rem;
|
|
637
|
+
display: flex;
|
|
638
|
+
align-items: center;
|
|
639
|
+
gap: 0.25rem;
|
|
640
|
+
padding: 0;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.sf-item-responses:hover {
|
|
644
|
+
color: var(--sf-text);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.sf-item-badge {
|
|
648
|
+
font-size: 0.75rem;
|
|
649
|
+
color: var(--sf-primary);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/* Responses */
|
|
653
|
+
.sf-responses {
|
|
654
|
+
margin-top: 0.75rem;
|
|
655
|
+
padding-top: 0.75rem;
|
|
656
|
+
border-top: 1px solid var(--sf-border);
|
|
657
|
+
display: flex;
|
|
658
|
+
flex-direction: column;
|
|
659
|
+
gap: 0.5rem;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
.sf-response {
|
|
663
|
+
padding: 0.5rem 0.75rem;
|
|
664
|
+
background: var(--sf-bg);
|
|
665
|
+
border-radius: var(--sf-radius);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
.sf-response-header {
|
|
669
|
+
display: flex;
|
|
670
|
+
align-items: center;
|
|
671
|
+
justify-content: space-between;
|
|
672
|
+
margin-bottom: 0.25rem;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.sf-response-author {
|
|
676
|
+
font-size: 0.8125rem;
|
|
677
|
+
font-weight: 600;
|
|
678
|
+
color: var(--sf-primary);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
.sf-response-time {
|
|
682
|
+
font-size: 0.75rem;
|
|
683
|
+
color: var(--sf-text-tertiary);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.sf-response-message {
|
|
687
|
+
font-size: 0.875rem;
|
|
688
|
+
color: var(--sf-text-secondary);
|
|
689
|
+
margin: 0;
|
|
690
|
+
line-height: 1.5;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.sf-responses-loading,
|
|
694
|
+
.sf-responses-empty {
|
|
695
|
+
font-size: 0.8125rem;
|
|
696
|
+
color: var(--sf-text-tertiary);
|
|
697
|
+
margin: 0;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/* Empty state */
|
|
701
|
+
.sf-empty {
|
|
702
|
+
text-align: center;
|
|
703
|
+
padding: 3rem 1rem;
|
|
704
|
+
color: var(--sf-text-tertiary);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.sf-empty i {
|
|
708
|
+
font-size: 2rem;
|
|
709
|
+
margin-bottom: 0.5rem;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.sf-empty p {
|
|
713
|
+
margin: 0.25rem 0;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
.sf-empty-sub {
|
|
717
|
+
font-size: 0.875rem;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/* Form elements */
|
|
721
|
+
.sf-select {
|
|
722
|
+
width: 100%;
|
|
723
|
+
padding: 0.375rem 0.5rem;
|
|
724
|
+
background: var(--sf-bg-secondary);
|
|
725
|
+
border: 1px solid var(--sf-border);
|
|
726
|
+
border-radius: 0.375rem;
|
|
727
|
+
color: var(--sf-text);
|
|
728
|
+
font-size: 0.8125rem;
|
|
729
|
+
cursor: pointer;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.sf-submit-btn {
|
|
733
|
+
display: inline-flex;
|
|
734
|
+
align-items: center;
|
|
735
|
+
gap: 0.375rem;
|
|
736
|
+
padding: 0.5rem 1rem;
|
|
737
|
+
background: var(--sf-primary);
|
|
738
|
+
color: var(--sf-primary-text);
|
|
739
|
+
border: none;
|
|
740
|
+
border-radius: var(--sf-radius);
|
|
741
|
+
font-size: 0.875rem;
|
|
742
|
+
font-weight: 600;
|
|
743
|
+
cursor: pointer;
|
|
744
|
+
transition: opacity 0.15s;
|
|
745
|
+
white-space: nowrap;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.sf-submit-btn:hover {
|
|
749
|
+
opacity: 0.9;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.sf-submit-btn:disabled {
|
|
753
|
+
opacity: 0.5;
|
|
754
|
+
cursor: not-allowed;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
.sf-submit-form {
|
|
758
|
+
padding: 1rem;
|
|
759
|
+
margin-bottom: 1rem;
|
|
760
|
+
background: var(--sf-bg-secondary);
|
|
761
|
+
border: 1px solid var(--sf-border);
|
|
762
|
+
border-radius: var(--sf-radius);
|
|
763
|
+
display: flex;
|
|
764
|
+
flex-direction: column;
|
|
765
|
+
gap: 0.5rem;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.sf-input,
|
|
769
|
+
.sf-textarea {
|
|
770
|
+
width: 100%;
|
|
771
|
+
padding: 0.5rem 0.75rem;
|
|
772
|
+
background: var(--sf-bg);
|
|
773
|
+
border: 1px solid var(--sf-border);
|
|
774
|
+
border-radius: 0.375rem;
|
|
775
|
+
color: var(--sf-text);
|
|
776
|
+
font-size: 0.875rem;
|
|
777
|
+
font-family: inherit;
|
|
778
|
+
resize: vertical;
|
|
779
|
+
box-sizing: border-box;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.sf-input:focus,
|
|
783
|
+
.sf-textarea:focus {
|
|
784
|
+
outline: none;
|
|
785
|
+
border-color: var(--sf-primary);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.sf-input-sm {
|
|
789
|
+
flex: 1;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
.sf-submit-row {
|
|
793
|
+
display: flex;
|
|
794
|
+
gap: 0.5rem;
|
|
795
|
+
align-items: center;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
.sf-submit-row .sf-select {
|
|
799
|
+
width: auto;
|
|
800
|
+
min-width: 120px;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
.sf-success {
|
|
804
|
+
color: var(--sf-success);
|
|
805
|
+
text-align: center;
|
|
806
|
+
padding: 0.5rem;
|
|
807
|
+
font-weight: 600;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/* Responsive */
|
|
811
|
+
@media (max-width: 768px) {
|
|
812
|
+
.sf-board-layout {
|
|
813
|
+
flex-direction: column;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
.sf-board-sidebar {
|
|
817
|
+
width: 100%;
|
|
818
|
+
flex-direction: row;
|
|
819
|
+
flex-wrap: wrap;
|
|
820
|
+
gap: 0.25rem;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
.sf-sidebar-divider {
|
|
824
|
+
display: none;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
.sf-sidebar-section {
|
|
828
|
+
display: flex;
|
|
829
|
+
align-items: center;
|
|
830
|
+
gap: 0.5rem;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
.sf-sidebar-label {
|
|
834
|
+
margin: 0;
|
|
835
|
+
white-space: nowrap;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
.sf-submit-row {
|
|
839
|
+
flex-wrap: wrap;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
apiUrl: string;
|
|
3
|
+
apiKey: string;
|
|
4
|
+
/** Category labels and icons — order determines sidebar order */
|
|
5
|
+
categoryConfig?: Record<string, {
|
|
6
|
+
label?: string;
|
|
7
|
+
icon?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
}>;
|
|
10
|
+
/** Title shown at the top */
|
|
11
|
+
title?: string;
|
|
12
|
+
/** Show the submit form inline */
|
|
13
|
+
showSubmit?: boolean;
|
|
14
|
+
/** Available categories for the submit form */
|
|
15
|
+
submitCategories?: string[];
|
|
16
|
+
}
|
|
17
|
+
declare const FeedbackBoard: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type FeedbackBoard = ReturnType<typeof FeedbackBoard>;
|
|
19
|
+
export default FeedbackBoard;
|
package/dist/index.d.ts
CHANGED
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
* Usage:
|
|
8
8
|
* import { StatusPage, FeedbackButton, createStatusClient } from '@alliance-droid/status-feedback-system';
|
|
9
9
|
*/
|
|
10
|
-
export type { Project, System, Incident, IncidentUpdate, Feedback, StatusHistoryEntry, SystemStatus, IncidentStatus, IncidentSeverity, FeedbackStatus, ApiResponse, } from './types.js';
|
|
10
|
+
export type { Project, System, Incident, IncidentUpdate, Feedback, FeedbackResponse, FeedbackVisibility, StatusHistoryEntry, SystemStatus, IncidentStatus, IncidentSeverity, FeedbackStatus, ApiResponse, } from './types.js';
|
|
11
11
|
export { createStatusClient } from './api.js';
|
|
12
|
-
export type { StatusClient, StatusClientConfig, StatusData, IncidentData, UptimeData } from './api.js';
|
|
12
|
+
export type { StatusClient, StatusClientConfig, StatusData, IncidentData, UptimeData, BoardData, BoardItem, BoardCategory } from './api.js';
|
|
13
13
|
export { default as StatusPage } from './components/StatusPage.svelte';
|
|
14
14
|
export { default as StatusBanner } from './components/StatusBanner.svelte';
|
|
15
15
|
export { default as FeedbackForm } from './components/FeedbackForm.svelte';
|
|
16
|
+
export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
|
|
16
17
|
export { default as FeedbackButton } from './components/FeedbackButton.svelte';
|
|
17
18
|
export { default as StatusIndicator } from './components/shared/StatusIndicator.svelte';
|
|
18
19
|
export { default as UptimeBar } from './components/shared/UptimeBar.svelte';
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { createStatusClient } from './api.js';
|
|
|
13
13
|
export { default as StatusPage } from './components/StatusPage.svelte';
|
|
14
14
|
export { default as StatusBanner } from './components/StatusBanner.svelte';
|
|
15
15
|
export { default as FeedbackForm } from './components/FeedbackForm.svelte';
|
|
16
|
+
export { default as FeedbackBoard } from './components/FeedbackBoard.svelte';
|
|
16
17
|
export { default as FeedbackButton } from './components/FeedbackButton.svelte';
|
|
17
18
|
// ── Shared Components ──
|
|
18
19
|
export { default as StatusIndicator } from './components/shared/StatusIndicator.svelte';
|
package/dist/types.d.ts
CHANGED
|
@@ -42,9 +42,18 @@ export interface IncidentUpdate {
|
|
|
42
42
|
message: string;
|
|
43
43
|
createdAt: number;
|
|
44
44
|
}
|
|
45
|
+
export type FeedbackVisibility = 'public' | 'private';
|
|
46
|
+
export interface FeedbackResponse {
|
|
47
|
+
id: string;
|
|
48
|
+
feedbackId: string;
|
|
49
|
+
message: string;
|
|
50
|
+
author: string | null;
|
|
51
|
+
createdAt: number;
|
|
52
|
+
}
|
|
45
53
|
export interface Feedback {
|
|
46
54
|
id: string;
|
|
47
55
|
projectId: string;
|
|
56
|
+
title: string | null;
|
|
48
57
|
category: string | null;
|
|
49
58
|
message: string;
|
|
50
59
|
email: string | null;
|
|
@@ -52,6 +61,10 @@ export interface Feedback {
|
|
|
52
61
|
pageUrl: string | null;
|
|
53
62
|
metadata: Record<string, unknown> | null;
|
|
54
63
|
status: FeedbackStatus;
|
|
64
|
+
visibility: FeedbackVisibility;
|
|
65
|
+
votes: number;
|
|
66
|
+
adminResponse: string | null;
|
|
67
|
+
responses?: FeedbackResponse[];
|
|
55
68
|
createdAt: number;
|
|
56
69
|
}
|
|
57
70
|
export interface StatusHistoryEntry {
|
package/package.json
CHANGED