@ansiversa/components 0.0.136 → 0.0.138
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/index.ts +1 -0
- package/package.json +1 -1
- package/src/Summary/FlashNoteSummary.astro +14 -1
- package/src/Summary/PortfolioCreatorSummary.astro +14 -1
- package/src/Summary/QuizSummary.astro +14 -1
- package/src/Summary/ResumeBuilderSummary.astro +14 -1
- package/src/components/Admin/FaqManager.astro +631 -0
package/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export { default as ResumeBuilderSummary } from './src/Summary/ResumeBuilderSumm
|
|
|
39
39
|
export { default as PortfolioCreatorSummary } from './src/Summary/PortfolioCreatorSummary.astro';
|
|
40
40
|
export { default as AvImageUploader } from "./src/components/media/AvImageUploader.astro";
|
|
41
41
|
export { default as AvAiAssist } from "./src/components/Ai/AvAiAssist.astro";
|
|
42
|
+
export { default as FaqManager } from "./src/components/Admin/FaqManager.astro";
|
|
42
43
|
export { AppLogo } from "./src/Logo";
|
|
43
44
|
export type { AppLogoProps } from "./src/Logo";
|
|
44
45
|
export { default as ResumeBuilderShell } from './src/resume-templates/ResumeBuilderShell.astro';
|
package/package.json
CHANGED
|
@@ -17,6 +17,13 @@ const formatDateTime = (value: string | null, fallback = "Not yet") => {
|
|
|
17
17
|
|
|
18
18
|
const flashnoteBaseUrl = buildAppUrl(summary.appId);
|
|
19
19
|
const decksUrl = buildAppUrl(summary.appId, "/decks");
|
|
20
|
+
const trackOpenPayload = JSON.stringify({
|
|
21
|
+
id: summary.appId,
|
|
22
|
+
key: summary.appId,
|
|
23
|
+
name: "FlashNote",
|
|
24
|
+
description: "FlashNote",
|
|
25
|
+
url: flashnoteBaseUrl,
|
|
26
|
+
});
|
|
20
27
|
---
|
|
21
28
|
|
|
22
29
|
<section class="av-auth-stack-lg">
|
|
@@ -25,7 +32,13 @@ const decksUrl = buildAppUrl(summary.appId, "/decks");
|
|
|
25
32
|
<p class="av-text-soft">Last study: {formatDateTime(summary.lastStudyAt)}</p>
|
|
26
33
|
</div>
|
|
27
34
|
<div class="av-row-wrap">
|
|
28
|
-
<AvButton
|
|
35
|
+
<AvButton
|
|
36
|
+
href={flashnoteBaseUrl}
|
|
37
|
+
size="sm"
|
|
38
|
+
onclick={`window.dispatchEvent(new CustomEvent('ansiversa:app-opened', { detail: ${trackOpenPayload} }))`}
|
|
39
|
+
>
|
|
40
|
+
Open App ->
|
|
41
|
+
</AvButton>
|
|
29
42
|
<AvButton href={decksUrl} size="sm" variant="ghost">View decks</AvButton>
|
|
30
43
|
</div>
|
|
31
44
|
</div>
|
|
@@ -19,6 +19,13 @@ const portfolioBaseUrl = buildAppUrl(summary.appId);
|
|
|
19
19
|
const portfoliosUrl = buildAppUrl(summary.appId, "/app/portfolios");
|
|
20
20
|
const completionLabel =
|
|
21
21
|
typeof summary.completionHint === "number" ? `${summary.completionHint}%` : "—";
|
|
22
|
+
const trackOpenPayload = JSON.stringify({
|
|
23
|
+
id: summary.appId,
|
|
24
|
+
key: summary.appId,
|
|
25
|
+
name: "Portfolio Creator",
|
|
26
|
+
description: "Portfolio Creator",
|
|
27
|
+
url: portfolioBaseUrl,
|
|
28
|
+
});
|
|
22
29
|
---
|
|
23
30
|
|
|
24
31
|
<section class="av-auth-stack-lg">
|
|
@@ -27,7 +34,13 @@ const completionLabel =
|
|
|
27
34
|
<p class="av-text-soft">Last updated: {formatDateTime(summary.lastUpdatedAt)}</p>
|
|
28
35
|
</div>
|
|
29
36
|
<div class="av-row-wrap">
|
|
30
|
-
<AvButton
|
|
37
|
+
<AvButton
|
|
38
|
+
href={portfolioBaseUrl}
|
|
39
|
+
size="sm"
|
|
40
|
+
onclick={`window.dispatchEvent(new CustomEvent('ansiversa:app-opened', { detail: ${trackOpenPayload} }))`}
|
|
41
|
+
>
|
|
42
|
+
Open App ->
|
|
43
|
+
</AvButton>
|
|
31
44
|
<AvButton href={portfoliosUrl} size="sm" variant="ghost">View portfolios</AvButton>
|
|
32
45
|
</div>
|
|
33
46
|
</div>
|
|
@@ -20,6 +20,13 @@ const attemptsDelta = typeof attemptsPrevious7d === "number"
|
|
|
20
20
|
? summary.kpis.attemptsLast7d - attemptsPrevious7d
|
|
21
21
|
: null;
|
|
22
22
|
const quizBaseUrl = buildAppUrl(summary.appId);
|
|
23
|
+
const trackOpenPayload = JSON.stringify({
|
|
24
|
+
id: summary.appId,
|
|
25
|
+
key: summary.appId,
|
|
26
|
+
name: "Quiz",
|
|
27
|
+
description: "Quiz",
|
|
28
|
+
url: quizBaseUrl,
|
|
29
|
+
});
|
|
23
30
|
---
|
|
24
31
|
|
|
25
32
|
<section class="av-auth-stack-lg">
|
|
@@ -28,7 +35,13 @@ const quizBaseUrl = buildAppUrl(summary.appId);
|
|
|
28
35
|
<p class="av-text-soft">Last attempt: {formatDateTime(summary.kpis.lastAttemptAt)}</p>
|
|
29
36
|
</div>
|
|
30
37
|
<div class="av-row-wrap">
|
|
31
|
-
<AvButton
|
|
38
|
+
<AvButton
|
|
39
|
+
href={quizBaseUrl}
|
|
40
|
+
size="sm"
|
|
41
|
+
onclick={`window.dispatchEvent(new CustomEvent('ansiversa:app-opened', { detail: ${trackOpenPayload} }))`}
|
|
42
|
+
>
|
|
43
|
+
Open App ->
|
|
44
|
+
</AvButton>
|
|
32
45
|
<AvButton href={buildAppUrl(summary.appId, "/results")} size="sm" variant="ghost">View Results</AvButton>
|
|
33
46
|
</div>
|
|
34
47
|
</div>
|
|
@@ -20,6 +20,13 @@ const resumesUrl = buildAppUrl(summary.appId, "/app/resumes");
|
|
|
20
20
|
const templatesLabel = summary.templatesUsed.length > 0 ? summary.templatesUsed.join(", ") : "No templates yet";
|
|
21
21
|
const completionLabel =
|
|
22
22
|
typeof summary.completionHint === "number" ? `${summary.completionHint}%` : "—";
|
|
23
|
+
const trackOpenPayload = JSON.stringify({
|
|
24
|
+
id: summary.appId,
|
|
25
|
+
key: summary.appId,
|
|
26
|
+
name: "Resume Builder",
|
|
27
|
+
description: "Resume Builder",
|
|
28
|
+
url: resumeBaseUrl,
|
|
29
|
+
});
|
|
23
30
|
---
|
|
24
31
|
|
|
25
32
|
<section class="av-auth-stack-lg">
|
|
@@ -28,7 +35,13 @@ const completionLabel =
|
|
|
28
35
|
<p class="av-text-soft">Last updated: {formatDateTime(summary.lastUpdatedAt)}</p>
|
|
29
36
|
</div>
|
|
30
37
|
<div class="av-row-wrap">
|
|
31
|
-
<AvButton
|
|
38
|
+
<AvButton
|
|
39
|
+
href={resumeBaseUrl}
|
|
40
|
+
size="sm"
|
|
41
|
+
onclick={`window.dispatchEvent(new CustomEvent('ansiversa:app-opened', { detail: ${trackOpenPayload} }))`}
|
|
42
|
+
>
|
|
43
|
+
Open App ->
|
|
44
|
+
</AvButton>
|
|
32
45
|
<AvButton href={resumesUrl} size="sm" variant="ghost">View resumes</AvButton>
|
|
33
46
|
</div>
|
|
34
47
|
</div>
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AvButton from "../../AvButton.astro";
|
|
3
|
+
import AvCard from "../../AvCard.astro";
|
|
4
|
+
import AvDrawer from "../../AvDrawer.astro";
|
|
5
|
+
import AvInput from "../../AvInput.astro";
|
|
6
|
+
import AvLoading from "../../AvLoading.astro";
|
|
7
|
+
import AvSelect from "../../AvSelect.astro";
|
|
8
|
+
import AvTable from "../../AvTable.astro";
|
|
9
|
+
import AvTableToolbar from "../../AvTableToolbar.astro";
|
|
10
|
+
import AvTextarea from "../../AvTextarea.astro";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
basePath?: string;
|
|
14
|
+
defaultAudience?: "user" | "admin";
|
|
15
|
+
showAudienceToggle?: boolean;
|
|
16
|
+
title?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
basePath = "",
|
|
21
|
+
defaultAudience = "user",
|
|
22
|
+
showAudienceToggle = true,
|
|
23
|
+
title = "FAQs",
|
|
24
|
+
} = Astro.props as Props;
|
|
25
|
+
|
|
26
|
+
const normalizedBasePath = String(basePath).replace(/\/$/, "");
|
|
27
|
+
const initialAudience = defaultAudience === "admin" ? "admin" : "user";
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
<div
|
|
31
|
+
class="av-admin-section av-auth-stack-lg"
|
|
32
|
+
x-data={`avFaqManager(${JSON.stringify({
|
|
33
|
+
basePath: normalizedBasePath,
|
|
34
|
+
defaultAudience: initialAudience,
|
|
35
|
+
showAudienceToggle,
|
|
36
|
+
title,
|
|
37
|
+
})})`}
|
|
38
|
+
x-init="init()"
|
|
39
|
+
>
|
|
40
|
+
<AvCard variant="soft" className="av-auth-stack-sm">
|
|
41
|
+
<AvTableToolbar title={title}>
|
|
42
|
+
<div slot="title" class="av-admin-titlewrap">
|
|
43
|
+
<h1 class="av-table-toolbar__title" x-text="title"></h1>
|
|
44
|
+
<span class="av-admin-count-inline" x-text="`(${faqs.length})`"></span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div slot="search">
|
|
48
|
+
<div class="av-admin-toolbar-left">
|
|
49
|
+
<div class="av-admin-toolbar-top">
|
|
50
|
+
<p class="av-table-toolbar__subtitle av-m-0">Manage frequently asked questions.</p>
|
|
51
|
+
<div class="av-admin-toolbar-actions">
|
|
52
|
+
<AvButton
|
|
53
|
+
size="sm"
|
|
54
|
+
variant="ghost"
|
|
55
|
+
type="button"
|
|
56
|
+
@click.prevent="fetchFaqs()"
|
|
57
|
+
:disabled="loading || saving"
|
|
58
|
+
>
|
|
59
|
+
Refresh
|
|
60
|
+
</AvButton>
|
|
61
|
+
<AvButton
|
|
62
|
+
size="sm"
|
|
63
|
+
variant="primary"
|
|
64
|
+
type="button"
|
|
65
|
+
@click.prevent="openCreate()"
|
|
66
|
+
:disabled="loading || saving"
|
|
67
|
+
>
|
|
68
|
+
Add FAQ
|
|
69
|
+
</AvButton>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div class="av-admin-filters" x-show="showAudienceToggle" x-cloak>
|
|
74
|
+
<div class="av-admin-filter av-admin-filter--sort">
|
|
75
|
+
<AvSelect label="Audience" name="faq-audience" x-model="audience" @change="setAudience($event.target.value)">
|
|
76
|
+
<option value="user">User</option>
|
|
77
|
+
<option value="admin">Admin</option>
|
|
78
|
+
</AvSelect>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</AvTableToolbar>
|
|
84
|
+
|
|
85
|
+
<template x-if="error">
|
|
86
|
+
<div class="av-alert av-alert-danger" role="status" x-text="error"></div>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<template x-if="notice">
|
|
90
|
+
<div class="av-alert av-alert-success" role="status" x-text="notice"></div>
|
|
91
|
+
</template>
|
|
92
|
+
</AvCard>
|
|
93
|
+
|
|
94
|
+
<template x-if="!loading && !faqs.length">
|
|
95
|
+
<AvCard variant="soft" className="av-auth-stack-sm">
|
|
96
|
+
<p class="av-text-soft av-m-0">No FAQs found for this audience.</p>
|
|
97
|
+
</AvCard>
|
|
98
|
+
</template>
|
|
99
|
+
|
|
100
|
+
<template x-if="faqs.length">
|
|
101
|
+
<AvCard variant="soft" className="av-auth-stack-sm">
|
|
102
|
+
<AvTable stickyHeader>
|
|
103
|
+
<thead>
|
|
104
|
+
<tr>
|
|
105
|
+
<th class="av-table__th-order">Order</th>
|
|
106
|
+
<th x-show="showAudienceColumn" x-cloak>Audience</th>
|
|
107
|
+
<th x-show="showCategoryColumn" x-cloak>Category</th>
|
|
108
|
+
<th>Question</th>
|
|
109
|
+
<th>Answer</th>
|
|
110
|
+
<th>Published</th>
|
|
111
|
+
<th x-show="showUpdatedColumn" x-cloak>Updated</th>
|
|
112
|
+
<th class="av-table__th-actions">Actions</th>
|
|
113
|
+
</tr>
|
|
114
|
+
</thead>
|
|
115
|
+
|
|
116
|
+
<tbody>
|
|
117
|
+
<template x-for="(faq, index) in faqs" :key="faq.id || index">
|
|
118
|
+
<tr>
|
|
119
|
+
<td>
|
|
120
|
+
<div class="av-faq-manager__order-cell">
|
|
121
|
+
<input
|
|
122
|
+
class="av-input av-faq-manager__order-input"
|
|
123
|
+
type="number"
|
|
124
|
+
min="0"
|
|
125
|
+
x-model.number="faq.sort_order"
|
|
126
|
+
:disabled="loading || saving"
|
|
127
|
+
aria-label="Sort order"
|
|
128
|
+
/>
|
|
129
|
+
<AvButton
|
|
130
|
+
size="sm"
|
|
131
|
+
variant="ghost"
|
|
132
|
+
type="button"
|
|
133
|
+
@click.prevent="saveSort(faq)"
|
|
134
|
+
:disabled="loading || saving || !faq.id"
|
|
135
|
+
>
|
|
136
|
+
Save
|
|
137
|
+
</AvButton>
|
|
138
|
+
</div>
|
|
139
|
+
</td>
|
|
140
|
+
<td x-show="showAudienceColumn" x-cloak>
|
|
141
|
+
<span class="av-table__cell-muted" x-text="faq.audience || '-'" ></span>
|
|
142
|
+
</td>
|
|
143
|
+
<td x-show="showCategoryColumn" x-cloak>
|
|
144
|
+
<span class="av-table__cell-muted" x-text="faq.category || '-'" ></span>
|
|
145
|
+
</td>
|
|
146
|
+
<td>
|
|
147
|
+
<div class="av-table__cell-strong" x-text="faq.question || '-'" ></div>
|
|
148
|
+
</td>
|
|
149
|
+
<td class="av-table__cell-muted">
|
|
150
|
+
<span x-text="previewAnswer(faq.answer_md)"></span>
|
|
151
|
+
</td>
|
|
152
|
+
<td>
|
|
153
|
+
<AvButton
|
|
154
|
+
size="sm"
|
|
155
|
+
variant="ghost"
|
|
156
|
+
type="button"
|
|
157
|
+
@click.prevent="togglePublished(faq)"
|
|
158
|
+
:disabled="loading || saving || !faq.id"
|
|
159
|
+
x-text="faq.is_published ? 'Published' : 'Draft'"
|
|
160
|
+
/>
|
|
161
|
+
</td>
|
|
162
|
+
<td x-show="showUpdatedColumn" x-cloak>
|
|
163
|
+
<span class="av-table__cell-muted" x-text="formatUpdatedAt(faq.updated_at)"></span>
|
|
164
|
+
</td>
|
|
165
|
+
<td class="av-table__td-actions">
|
|
166
|
+
<div class="av-table__cell-actions">
|
|
167
|
+
<AvButton
|
|
168
|
+
size="sm"
|
|
169
|
+
variant="ghost"
|
|
170
|
+
type="button"
|
|
171
|
+
@click.prevent="openEdit(faq)"
|
|
172
|
+
:disabled="loading || saving"
|
|
173
|
+
>
|
|
174
|
+
Edit
|
|
175
|
+
</AvButton>
|
|
176
|
+
<AvButton
|
|
177
|
+
size="sm"
|
|
178
|
+
variant="ghost"
|
|
179
|
+
type="button"
|
|
180
|
+
@click.prevent="removeFaq(faq)"
|
|
181
|
+
:disabled="loading || saving || !faq.id"
|
|
182
|
+
>
|
|
183
|
+
Delete
|
|
184
|
+
</AvButton>
|
|
185
|
+
</div>
|
|
186
|
+
</td>
|
|
187
|
+
</tr>
|
|
188
|
+
</template>
|
|
189
|
+
</tbody>
|
|
190
|
+
</AvTable>
|
|
191
|
+
</AvCard>
|
|
192
|
+
</template>
|
|
193
|
+
|
|
194
|
+
<template x-if="drawerOpen">
|
|
195
|
+
<div
|
|
196
|
+
class="av-drawer-overlay"
|
|
197
|
+
@click.self="closeDrawer()"
|
|
198
|
+
@keydown.escape.window="closeDrawer()"
|
|
199
|
+
@close-drawer="closeDrawer()"
|
|
200
|
+
x-cloak
|
|
201
|
+
>
|
|
202
|
+
<AvDrawer title="Manage FAQ" description="Create or update a frequently asked question.">
|
|
203
|
+
<form class="av-auth-stack-md" @submit.prevent="submitDrawer()">
|
|
204
|
+
<AvInput label="Question" name="question" required placeholder="Enter question" x-model="draftQuestion" />
|
|
205
|
+
|
|
206
|
+
<AvTextarea
|
|
207
|
+
label="Answer (Markdown)"
|
|
208
|
+
name="answer_md"
|
|
209
|
+
rows={7}
|
|
210
|
+
required
|
|
211
|
+
placeholder="Write answer in markdown"
|
|
212
|
+
x-model="draftAnswerMd"
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
<div x-show="showAudienceToggle" x-cloak>
|
|
216
|
+
<AvSelect label="Audience" name="draft-audience" x-model="draftAudience">
|
|
217
|
+
<option value="user">User</option>
|
|
218
|
+
<option value="admin">Admin</option>
|
|
219
|
+
</AvSelect>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<AvInput label="Category (optional)" name="category" placeholder="General" x-model="draftCategory" />
|
|
223
|
+
|
|
224
|
+
<AvInput
|
|
225
|
+
label="Sort order"
|
|
226
|
+
type="number"
|
|
227
|
+
min="0"
|
|
228
|
+
name="sort_order"
|
|
229
|
+
x-model.number="draftSortOrder"
|
|
230
|
+
/>
|
|
231
|
+
|
|
232
|
+
<div class="av-faq-manager__publish-toggle">
|
|
233
|
+
<label class="av-label" for="faq-draft-published">Published</label>
|
|
234
|
+
<input
|
|
235
|
+
id="faq-draft-published"
|
|
236
|
+
type="checkbox"
|
|
237
|
+
x-model="draftIsPublished"
|
|
238
|
+
:disabled="saving"
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<template x-if="drawerError">
|
|
243
|
+
<div class="av-alert av-alert-danger" role="status" x-text="drawerError"></div>
|
|
244
|
+
</template>
|
|
245
|
+
</form>
|
|
246
|
+
|
|
247
|
+
<div slot="footer">
|
|
248
|
+
<AvButton variant="ghost" type="button" @click.prevent="closeDrawer()" :disabled="saving">Cancel</AvButton>
|
|
249
|
+
<AvButton type="button" @click.prevent="submitDrawer()" :disabled="saving">
|
|
250
|
+
<span x-show="!saving" x-text="drawerMode === 'edit' ? 'Update' : 'Create'"></span>
|
|
251
|
+
<span x-show="saving">Saving...</span>
|
|
252
|
+
</AvButton>
|
|
253
|
+
</div>
|
|
254
|
+
</AvDrawer>
|
|
255
|
+
</div>
|
|
256
|
+
</template>
|
|
257
|
+
|
|
258
|
+
<AvLoading x-show="loading" x-cloak label="Loading FAQs..." />
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<script is:inline>
|
|
262
|
+
if (typeof window !== "undefined" && !window.avFaqManager) {
|
|
263
|
+
window.avFaqManager = (config) => ({
|
|
264
|
+
title: typeof config.title === "string" && config.title.trim() ? config.title.trim() : "FAQs",
|
|
265
|
+
basePath: typeof config.basePath === "string" ? config.basePath.replace(/\/$/, "") : "",
|
|
266
|
+
showAudienceToggle: Boolean(config.showAudienceToggle),
|
|
267
|
+
audience: config.defaultAudience === "admin" ? "admin" : "user",
|
|
268
|
+
faqs: [],
|
|
269
|
+
loading: false,
|
|
270
|
+
saving: false,
|
|
271
|
+
error: "",
|
|
272
|
+
notice: "",
|
|
273
|
+
drawerOpen: false,
|
|
274
|
+
drawerMode: "create",
|
|
275
|
+
drawerError: "",
|
|
276
|
+
draftId: null,
|
|
277
|
+
draftAudience: "user",
|
|
278
|
+
draftCategory: "",
|
|
279
|
+
draftQuestion: "",
|
|
280
|
+
draftAnswerMd: "",
|
|
281
|
+
draftSortOrder: 0,
|
|
282
|
+
draftIsPublished: true,
|
|
283
|
+
|
|
284
|
+
get showAudienceColumn() {
|
|
285
|
+
if (this.showAudienceToggle) return true;
|
|
286
|
+
return this.faqs.some((faq) => typeof faq.audience === "string" && faq.audience.trim());
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
get showCategoryColumn() {
|
|
290
|
+
return this.faqs.some((faq) => typeof faq.category === "string" && faq.category.trim());
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
get showUpdatedColumn() {
|
|
294
|
+
return this.faqs.some((faq) => Boolean(faq.updated_at));
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
init() {
|
|
298
|
+
this.draftAudience = this.audience;
|
|
299
|
+
this.fetchFaqs();
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
endpoint(path) {
|
|
303
|
+
return `${this.basePath}${path}`;
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
async parseJson(response) {
|
|
307
|
+
const text = await response.text();
|
|
308
|
+
if (!text) return null;
|
|
309
|
+
try {
|
|
310
|
+
return JSON.parse(text);
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
mapError(status, payload, fallback) {
|
|
317
|
+
if (payload && typeof payload.error === "string" && payload.error.trim()) {
|
|
318
|
+
return payload.error.trim();
|
|
319
|
+
}
|
|
320
|
+
if (status === 400) return "Invalid request.";
|
|
321
|
+
if (status === 401) return "Please sign in again.";
|
|
322
|
+
if (status === 403) return "You do not have permission for this action.";
|
|
323
|
+
if (status === 404) return "FAQ endpoint not found.";
|
|
324
|
+
return fallback;
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
normalizeFaq(item, index) {
|
|
328
|
+
const source = item && typeof item === "object" ? item : {};
|
|
329
|
+
const parsedOrder = Number(source.sort_order);
|
|
330
|
+
const answerMd = typeof source.answer_md === "string"
|
|
331
|
+
? source.answer_md
|
|
332
|
+
: typeof source.answer === "string"
|
|
333
|
+
? source.answer
|
|
334
|
+
: "";
|
|
335
|
+
return {
|
|
336
|
+
id: source.id ?? null,
|
|
337
|
+
audience: source.audience === "admin" ? "admin" : source.audience === "user" ? "user" : this.audience,
|
|
338
|
+
category: typeof source.category === "string" ? source.category : "",
|
|
339
|
+
question: typeof source.question === "string" ? source.question : "",
|
|
340
|
+
answer_md: answerMd,
|
|
341
|
+
sort_order: Number.isFinite(parsedOrder) ? parsedOrder : index + 1,
|
|
342
|
+
is_published: source.is_published !== false,
|
|
343
|
+
updated_at: typeof source.updated_at === "string" ? source.updated_at : "",
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
previewAnswer(value) {
|
|
348
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
349
|
+
if (!text) return "-";
|
|
350
|
+
return text.length > 120 ? `${text.slice(0, 120)}...` : text;
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
formatUpdatedAt(value) {
|
|
354
|
+
if (!value) return "-";
|
|
355
|
+
const date = new Date(value);
|
|
356
|
+
if (Number.isNaN(date.getTime())) return "-";
|
|
357
|
+
return date.toLocaleDateString();
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
setAudience(value) {
|
|
361
|
+
this.audience = value === "admin" ? "admin" : "user";
|
|
362
|
+
this.fetchFaqs();
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
clearMessages() {
|
|
366
|
+
this.error = "";
|
|
367
|
+
this.notice = "";
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async fetchFaqs() {
|
|
371
|
+
this.loading = true;
|
|
372
|
+
this.clearMessages();
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
const response = await fetch(
|
|
376
|
+
this.endpoint(`/api/admin/faqs.json?audience=${encodeURIComponent(this.audience)}`),
|
|
377
|
+
{
|
|
378
|
+
method: "GET",
|
|
379
|
+
credentials: "include",
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const payload = await this.parseJson(response);
|
|
384
|
+
|
|
385
|
+
if (!response.ok) {
|
|
386
|
+
throw new Error(this.mapError(response.status, payload, "Failed to load FAQs."));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const items = Array.isArray(payload)
|
|
390
|
+
? payload
|
|
391
|
+
: Array.isArray(payload?.items)
|
|
392
|
+
? payload.items
|
|
393
|
+
: [];
|
|
394
|
+
|
|
395
|
+
this.faqs = items.map((item, index) => this.normalizeFaq(item, index));
|
|
396
|
+
this.faqs.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
|
|
397
|
+
} catch (loadError) {
|
|
398
|
+
this.faqs = [];
|
|
399
|
+
this.error = loadError?.message || "Failed to load FAQs.";
|
|
400
|
+
} finally {
|
|
401
|
+
this.loading = false;
|
|
402
|
+
}
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
resetDraft() {
|
|
406
|
+
this.draftId = null;
|
|
407
|
+
this.draftAudience = this.audience;
|
|
408
|
+
this.draftCategory = "";
|
|
409
|
+
this.draftQuestion = "";
|
|
410
|
+
this.draftAnswerMd = "";
|
|
411
|
+
this.draftSortOrder = this.faqs.length + 1;
|
|
412
|
+
this.draftIsPublished = true;
|
|
413
|
+
this.drawerError = "";
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
openCreate() {
|
|
417
|
+
this.clearMessages();
|
|
418
|
+
this.drawerMode = "create";
|
|
419
|
+
this.resetDraft();
|
|
420
|
+
this.drawerOpen = true;
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
openEdit(faq) {
|
|
424
|
+
this.clearMessages();
|
|
425
|
+
this.drawerMode = "edit";
|
|
426
|
+
this.draftId = faq?.id ?? null;
|
|
427
|
+
this.draftAudience = faq?.audience === "admin" ? "admin" : "user";
|
|
428
|
+
this.draftCategory = faq?.category ?? "";
|
|
429
|
+
this.draftQuestion = faq?.question ?? "";
|
|
430
|
+
this.draftAnswerMd = faq?.answer_md ?? "";
|
|
431
|
+
this.draftSortOrder = Number.isFinite(Number(faq?.sort_order)) ? Number(faq.sort_order) : 0;
|
|
432
|
+
this.draftIsPublished = faq?.is_published !== false;
|
|
433
|
+
this.drawerError = "";
|
|
434
|
+
this.drawerOpen = true;
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
closeDrawer() {
|
|
438
|
+
if (this.saving) return;
|
|
439
|
+
this.drawerOpen = false;
|
|
440
|
+
this.drawerError = "";
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
buildPayload() {
|
|
444
|
+
return {
|
|
445
|
+
audience: this.draftAudience === "admin" ? "admin" : "user",
|
|
446
|
+
category: String(this.draftCategory || "").trim(),
|
|
447
|
+
question: String(this.draftQuestion || "").trim(),
|
|
448
|
+
answer_md: String(this.draftAnswerMd || "").trim(),
|
|
449
|
+
sort_order: Number(this.draftSortOrder || 0),
|
|
450
|
+
is_published: Boolean(this.draftIsPublished),
|
|
451
|
+
};
|
|
452
|
+
},
|
|
453
|
+
|
|
454
|
+
async submitDrawer() {
|
|
455
|
+
const payload = this.buildPayload();
|
|
456
|
+
|
|
457
|
+
if (!payload.question || !payload.answer_md) {
|
|
458
|
+
this.drawerError = "Question and answer are required.";
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
this.saving = true;
|
|
463
|
+
this.drawerError = "";
|
|
464
|
+
this.error = "";
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const isEdit = this.drawerMode === "edit";
|
|
468
|
+
const endpoint = isEdit
|
|
469
|
+
? this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(this.draftId || ""))}.json`)
|
|
470
|
+
: this.endpoint("/api/admin/faqs.json");
|
|
471
|
+
|
|
472
|
+
const response = await fetch(endpoint, {
|
|
473
|
+
method: isEdit ? "PATCH" : "POST",
|
|
474
|
+
credentials: "include",
|
|
475
|
+
headers: {
|
|
476
|
+
"Content-Type": "application/json",
|
|
477
|
+
},
|
|
478
|
+
body: JSON.stringify(payload),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const responsePayload = await this.parseJson(response);
|
|
482
|
+
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
throw new Error(this.mapError(response.status, responsePayload, "Failed to save FAQ."));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
this.drawerOpen = false;
|
|
488
|
+
this.notice = isEdit ? "FAQ updated." : "FAQ created.";
|
|
489
|
+
await this.fetchFaqs();
|
|
490
|
+
} catch (saveError) {
|
|
491
|
+
this.drawerError = saveError?.message || "Failed to save FAQ.";
|
|
492
|
+
} finally {
|
|
493
|
+
this.saving = false;
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
async removeFaq(faq) {
|
|
498
|
+
if (!faq?.id) return;
|
|
499
|
+
if (!window.confirm("Delete this FAQ? This action cannot be undone.")) return;
|
|
500
|
+
|
|
501
|
+
this.saving = true;
|
|
502
|
+
this.error = "";
|
|
503
|
+
this.notice = "";
|
|
504
|
+
|
|
505
|
+
try {
|
|
506
|
+
const response = await fetch(
|
|
507
|
+
this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
|
|
508
|
+
{
|
|
509
|
+
method: "DELETE",
|
|
510
|
+
credentials: "include",
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
const responsePayload = await this.parseJson(response);
|
|
515
|
+
|
|
516
|
+
if (!response.ok) {
|
|
517
|
+
throw new Error(this.mapError(response.status, responsePayload, "Failed to delete FAQ."));
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
this.notice = "FAQ deleted.";
|
|
521
|
+
await this.fetchFaqs();
|
|
522
|
+
} catch (deleteError) {
|
|
523
|
+
this.error = deleteError?.message || "Failed to delete FAQ.";
|
|
524
|
+
} finally {
|
|
525
|
+
this.saving = false;
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
async togglePublished(faq) {
|
|
530
|
+
if (!faq?.id) return;
|
|
531
|
+
|
|
532
|
+
this.saving = true;
|
|
533
|
+
this.error = "";
|
|
534
|
+
this.notice = "";
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
const response = await fetch(
|
|
538
|
+
this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
|
|
539
|
+
{
|
|
540
|
+
method: "PATCH",
|
|
541
|
+
credentials: "include",
|
|
542
|
+
headers: {
|
|
543
|
+
"Content-Type": "application/json",
|
|
544
|
+
},
|
|
545
|
+
body: JSON.stringify({ is_published: !faq.is_published }),
|
|
546
|
+
},
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const responsePayload = await this.parseJson(response);
|
|
550
|
+
|
|
551
|
+
if (!response.ok) {
|
|
552
|
+
throw new Error(this.mapError(response.status, responsePayload, "Failed to update publish status."));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
this.notice = "Publish status updated.";
|
|
556
|
+
await this.fetchFaqs();
|
|
557
|
+
} catch (toggleError) {
|
|
558
|
+
this.error = toggleError?.message || "Failed to update publish status.";
|
|
559
|
+
} finally {
|
|
560
|
+
this.saving = false;
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
async saveSort(faq) {
|
|
565
|
+
if (!faq?.id) return;
|
|
566
|
+
|
|
567
|
+
const nextSortOrder = Number(faq.sort_order);
|
|
568
|
+
if (!Number.isFinite(nextSortOrder)) {
|
|
569
|
+
this.error = "Sort order must be a valid number.";
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this.saving = true;
|
|
574
|
+
this.error = "";
|
|
575
|
+
this.notice = "";
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const response = await fetch(
|
|
579
|
+
this.endpoint(`/api/admin/faqs/${encodeURIComponent(String(faq.id))}.json`),
|
|
580
|
+
{
|
|
581
|
+
method: "PATCH",
|
|
582
|
+
credentials: "include",
|
|
583
|
+
headers: {
|
|
584
|
+
"Content-Type": "application/json",
|
|
585
|
+
},
|
|
586
|
+
body: JSON.stringify({ sort_order: nextSortOrder }),
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const responsePayload = await this.parseJson(response);
|
|
591
|
+
|
|
592
|
+
if (!response.ok) {
|
|
593
|
+
throw new Error(this.mapError(response.status, responsePayload, "Failed to update sort order."));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
this.notice = "Sort order updated.";
|
|
597
|
+
await this.fetchFaqs();
|
|
598
|
+
} catch (sortError) {
|
|
599
|
+
this.error = sortError?.message || "Failed to update sort order.";
|
|
600
|
+
} finally {
|
|
601
|
+
this.saving = false;
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
</script>
|
|
607
|
+
|
|
608
|
+
<style>
|
|
609
|
+
.av-faq-manager__order-cell {
|
|
610
|
+
display: inline-flex;
|
|
611
|
+
gap: 0.35rem;
|
|
612
|
+
align-items: center;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.av-faq-manager__order-input {
|
|
616
|
+
width: 88px;
|
|
617
|
+
min-height: 2.25rem;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.av-faq-manager__publish-toggle {
|
|
621
|
+
display: flex;
|
|
622
|
+
align-items: center;
|
|
623
|
+
justify-content: space-between;
|
|
624
|
+
gap: 0.75rem;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
.av-faq-manager__publish-toggle input[type="checkbox"] {
|
|
628
|
+
width: 1rem;
|
|
629
|
+
height: 1rem;
|
|
630
|
+
}
|
|
631
|
+
</style>
|