@aravindc26/velu 0.11.15 → 0.11.16
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/package.json
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
buildPublicFeedbackPayload,
|
|
5
|
+
PUBLIC_API_BASE_URL,
|
|
6
|
+
PUBLIC_FEEDBACK_ENDPOINT_PATH,
|
|
7
|
+
resolvePublicFeedbackEndpoint,
|
|
8
|
+
submitPublicFeedback,
|
|
9
|
+
} from './page-feedback-api';
|
|
10
|
+
|
|
11
|
+
test('buildPublicFeedbackPayload returns contract body with optional fields', () => {
|
|
12
|
+
const payload = buildPublicFeedbackPayload({
|
|
13
|
+
pageUrl: 'https://docs.example.com/guide',
|
|
14
|
+
helpful: true,
|
|
15
|
+
reasonText: 'The guide worked as expected',
|
|
16
|
+
details: ' Extra details ',
|
|
17
|
+
email: ' user@example.com ',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
assert.deepEqual(payload, {
|
|
21
|
+
page_url: 'https://docs.example.com/guide',
|
|
22
|
+
helpful: true,
|
|
23
|
+
reason_text: 'The guide worked as expected',
|
|
24
|
+
details: 'Extra details',
|
|
25
|
+
email: 'user@example.com',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('resolvePublicFeedbackEndpoint appends the fixed endpoint path', () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
resolvePublicFeedbackEndpoint(),
|
|
32
|
+
`${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('submitPublicFeedback posts the expected payload and headers', async () => {
|
|
37
|
+
let receivedUrl = '';
|
|
38
|
+
let receivedInit: RequestInit | undefined;
|
|
39
|
+
|
|
40
|
+
const fetchImpl: typeof fetch = async (input, init) => {
|
|
41
|
+
receivedUrl = String(input);
|
|
42
|
+
receivedInit = init;
|
|
43
|
+
return new Response(null, { status: 204 });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const result = await submitPublicFeedback({
|
|
47
|
+
pageUrl: 'https://docs.example.com/page',
|
|
48
|
+
helpful: false,
|
|
49
|
+
reasonText: 'Update this documentation',
|
|
50
|
+
details: '',
|
|
51
|
+
email: undefined,
|
|
52
|
+
siteHost: 'docs.example.com',
|
|
53
|
+
fetchImpl,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(result, { ok: true });
|
|
57
|
+
assert.equal(receivedUrl, `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`);
|
|
58
|
+
assert.equal(receivedInit?.method, 'POST');
|
|
59
|
+
assert.equal(receivedInit?.credentials, 'include');
|
|
60
|
+
assert.equal(receivedInit?.body, JSON.stringify({
|
|
61
|
+
page_url: 'https://docs.example.com/page',
|
|
62
|
+
helpful: false,
|
|
63
|
+
reason_text: 'Update this documentation',
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const headers = receivedInit?.headers as Record<string, string>;
|
|
67
|
+
assert.deepEqual(headers, {
|
|
68
|
+
'Content-Type': 'application/json',
|
|
69
|
+
'x-velu-site-host': 'docs.example.com',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('submitPublicFeedback reports non-2xx responses as request_failed', async () => {
|
|
74
|
+
const result = await submitPublicFeedback({
|
|
75
|
+
pageUrl: 'https://docs.example.com/page',
|
|
76
|
+
helpful: true,
|
|
77
|
+
reasonText: 'Something else',
|
|
78
|
+
siteHost: 'docs.example.com',
|
|
79
|
+
fetchImpl: async () => new Response(null, { status: 500 }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(result, { ok: false, reason: 'request_failed', status: 500 });
|
|
83
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const PUBLIC_FEEDBACK_ENDPOINT_PATH = '/api/v1/public/feedback';
|
|
2
|
+
export const PUBLIC_API_BASE_URL = 'https://api.getvelu.com';
|
|
3
|
+
|
|
4
|
+
export interface PublicFeedbackPayload {
|
|
5
|
+
page_url: string;
|
|
6
|
+
helpful: boolean;
|
|
7
|
+
reason_text: string;
|
|
8
|
+
details?: string;
|
|
9
|
+
email?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface BuildPayloadInput {
|
|
13
|
+
pageUrl: string;
|
|
14
|
+
helpful: boolean;
|
|
15
|
+
reasonText: string;
|
|
16
|
+
details?: string;
|
|
17
|
+
email?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SubmitPublicFeedbackInput extends BuildPayloadInput {
|
|
21
|
+
siteHost: string;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type SubmitErrorReason = 'invalid_payload' | 'request_failed' | 'network_error';
|
|
26
|
+
|
|
27
|
+
export type SubmitPublicFeedbackResult =
|
|
28
|
+
| { ok: true }
|
|
29
|
+
| { ok: false; reason: SubmitErrorReason; status?: number };
|
|
30
|
+
|
|
31
|
+
function trimOptional(value: string | undefined): string | undefined {
|
|
32
|
+
if (typeof value !== 'string') return undefined;
|
|
33
|
+
const trimmed = value.trim();
|
|
34
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolvePublicFeedbackEndpoint(): string {
|
|
38
|
+
return `${PUBLIC_API_BASE_URL}${PUBLIC_FEEDBACK_ENDPOINT_PATH}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildPublicFeedbackPayload(input: BuildPayloadInput): PublicFeedbackPayload | null {
|
|
42
|
+
const pageUrl = trimOptional(input.pageUrl);
|
|
43
|
+
const reasonText = trimOptional(input.reasonText);
|
|
44
|
+
if (!pageUrl || !reasonText) return null;
|
|
45
|
+
|
|
46
|
+
const payload: PublicFeedbackPayload = {
|
|
47
|
+
page_url: pageUrl,
|
|
48
|
+
helpful: input.helpful,
|
|
49
|
+
reason_text: reasonText,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const details = trimOptional(input.details);
|
|
53
|
+
if (details) payload.details = details;
|
|
54
|
+
|
|
55
|
+
const email = trimOptional(input.email);
|
|
56
|
+
if (email) payload.email = email;
|
|
57
|
+
|
|
58
|
+
return payload;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function submitPublicFeedback(input: SubmitPublicFeedbackInput): Promise<SubmitPublicFeedbackResult> {
|
|
62
|
+
const payload = buildPublicFeedbackPayload(input);
|
|
63
|
+
if (!payload) return { ok: false, reason: 'invalid_payload' };
|
|
64
|
+
|
|
65
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetchImpl(resolvePublicFeedbackEndpoint(), {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
credentials: 'include',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
'x-velu-site-host': input.siteHost,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(payload),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
reason: 'request_failed',
|
|
81
|
+
status: response.status,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { ok: true };
|
|
86
|
+
} catch {
|
|
87
|
+
return { ok: false, reason: 'network_error' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, type MouseEvent as ReactMouseEvent } from 'react';
|
|
4
4
|
import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
|
5
|
+
import { submitPublicFeedback } from '@/components/page-feedback-api';
|
|
5
6
|
|
|
6
7
|
type Vote = 'yes' | 'no';
|
|
7
8
|
|
|
@@ -26,10 +27,13 @@ export function PageFeedback() {
|
|
|
26
27
|
const [selectedReason, setSelectedReason] = useState<string>('');
|
|
27
28
|
const [details, setDetails] = useState('');
|
|
28
29
|
const [email, setEmail] = useState('');
|
|
30
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
31
|
+
const [submitError, setSubmitError] = useState(false);
|
|
29
32
|
|
|
30
33
|
const showForm = vote !== null;
|
|
31
34
|
const options = vote === 'yes' ? YES_OPTIONS : NO_OPTIONS;
|
|
32
35
|
const showOptionalInputs = selectedReason === 'Something else';
|
|
36
|
+
const canSubmit = vote !== null && selectedReason.trim().length > 0 && !isSubmitting;
|
|
33
37
|
|
|
34
38
|
const onChooseVote = (value: Vote) => {
|
|
35
39
|
if (vote === value) return;
|
|
@@ -37,6 +41,7 @@ export function PageFeedback() {
|
|
|
37
41
|
setSelectedReason('');
|
|
38
42
|
setDetails('');
|
|
39
43
|
setEmail('');
|
|
44
|
+
setSubmitError(false);
|
|
40
45
|
};
|
|
41
46
|
|
|
42
47
|
const stopEvent = (event: ReactMouseEvent) => {
|
|
@@ -49,11 +54,32 @@ export function PageFeedback() {
|
|
|
49
54
|
setSelectedReason('');
|
|
50
55
|
setDetails('');
|
|
51
56
|
setEmail('');
|
|
57
|
+
setIsSubmitting(false);
|
|
58
|
+
setSubmitError(false);
|
|
52
59
|
};
|
|
53
60
|
|
|
54
|
-
const onSubmit = () => {
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
const onSubmit = async () => {
|
|
62
|
+
if (!vote || !selectedReason.trim() || isSubmitting) return;
|
|
63
|
+
|
|
64
|
+
setIsSubmitting(true);
|
|
65
|
+
setSubmitError(false);
|
|
66
|
+
|
|
67
|
+
const result = await submitPublicFeedback({
|
|
68
|
+
helpful: vote === 'yes',
|
|
69
|
+
reasonText: selectedReason,
|
|
70
|
+
details: showOptionalInputs ? details : undefined,
|
|
71
|
+
email: showOptionalInputs ? email : undefined,
|
|
72
|
+
pageUrl: window.location.href,
|
|
73
|
+
siteHost: window.location.host,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (result.ok) {
|
|
77
|
+
onCancel();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setSubmitError(true);
|
|
82
|
+
setIsSubmitting(false);
|
|
57
83
|
};
|
|
58
84
|
|
|
59
85
|
return (
|
|
@@ -65,6 +91,7 @@ export function PageFeedback() {
|
|
|
65
91
|
type="button"
|
|
66
92
|
className={['velu-page-feedback-btn', vote === 'yes' ? 'is-active' : ''].filter(Boolean).join(' ')}
|
|
67
93
|
aria-label="Mark page as helpful"
|
|
94
|
+
disabled={isSubmitting}
|
|
68
95
|
onClick={(event) => {
|
|
69
96
|
stopEvent(event);
|
|
70
97
|
onChooseVote('yes');
|
|
@@ -77,6 +104,7 @@ export function PageFeedback() {
|
|
|
77
104
|
type="button"
|
|
78
105
|
className={['velu-page-feedback-btn', vote === 'no' ? 'is-active' : ''].filter(Boolean).join(' ')}
|
|
79
106
|
aria-label="Mark page as not helpful"
|
|
107
|
+
disabled={isSubmitting}
|
|
80
108
|
onClick={(event) => {
|
|
81
109
|
stopEvent(event);
|
|
82
110
|
onChooseVote('no');
|
|
@@ -103,10 +131,12 @@ export function PageFeedback() {
|
|
|
103
131
|
type="button"
|
|
104
132
|
role="radio"
|
|
105
133
|
aria-checked={checked}
|
|
134
|
+
disabled={isSubmitting}
|
|
106
135
|
className={['velu-page-feedback-option', checked ? 'is-checked' : ''].filter(Boolean).join(' ')}
|
|
107
136
|
onClick={(event) => {
|
|
108
137
|
stopEvent(event);
|
|
109
138
|
setSelectedReason(option);
|
|
139
|
+
setSubmitError(false);
|
|
110
140
|
}}
|
|
111
141
|
>
|
|
112
142
|
<span className="velu-page-feedback-radio" aria-hidden="true" />
|
|
@@ -123,6 +153,7 @@ export function PageFeedback() {
|
|
|
123
153
|
rows={3}
|
|
124
154
|
placeholder="(Optional) Could you share more about your experience?"
|
|
125
155
|
value={details}
|
|
156
|
+
disabled={isSubmitting}
|
|
126
157
|
onChange={(event) => setDetails(event.target.value)}
|
|
127
158
|
/>
|
|
128
159
|
<input
|
|
@@ -130,6 +161,7 @@ export function PageFeedback() {
|
|
|
130
161
|
type="email"
|
|
131
162
|
placeholder="(Optional) Email"
|
|
132
163
|
value={email}
|
|
164
|
+
disabled={isSubmitting}
|
|
133
165
|
onChange={(event) => setEmail(event.target.value)}
|
|
134
166
|
/>
|
|
135
167
|
</div>
|
|
@@ -139,6 +171,7 @@ export function PageFeedback() {
|
|
|
139
171
|
<button
|
|
140
172
|
type="button"
|
|
141
173
|
className="velu-page-feedback-cancel"
|
|
174
|
+
disabled={isSubmitting}
|
|
142
175
|
onClick={(event) => {
|
|
143
176
|
stopEvent(event);
|
|
144
177
|
onCancel();
|
|
@@ -149,9 +182,12 @@ export function PageFeedback() {
|
|
|
149
182
|
<button
|
|
150
183
|
type="button"
|
|
151
184
|
className="velu-page-feedback-submit"
|
|
185
|
+
disabled={!canSubmit}
|
|
186
|
+
aria-busy={isSubmitting}
|
|
187
|
+
title={submitError ? 'Unable to submit feedback right now. Please try again.' : undefined}
|
|
152
188
|
onClick={(event) => {
|
|
153
189
|
stopEvent(event);
|
|
154
|
-
onSubmit();
|
|
190
|
+
void onSubmit();
|
|
155
191
|
}}
|
|
156
192
|
>
|
|
157
193
|
Submit feedback
|