@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aravindc26/velu",
3
- "version": "0.11.15",
3
+ "version": "0.11.16",
4
4
  "description": "A modern documentation site generator powered by Markdown and JSON configuration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- // Placeholder for analytics/reporting integration.
56
- onCancel();
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