snuggly-nudger 0.1.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/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/client/FeedbackModal.d.ts +8 -0
- package/dist/client/FeedbackModal.js +188 -0
- package/dist/client/ModalCloseButton.d.ts +5 -0
- package/dist/client/ModalCloseButton.js +14 -0
- package/dist/client/UpdateAvailableToast.d.ts +6 -0
- package/dist/client/UpdateAvailableToast.js +39 -0
- package/dist/client/index.d.ts +9 -0
- package/dist/client/index.js +6 -0
- package/dist/client/useIsMobile.d.ts +1 -0
- package/dist/client/useIsMobile.js +18 -0
- package/dist/client/useVersionPoll.d.ts +8 -0
- package/dist/client/useVersionPoll.js +70 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +2 -0
- package/dist/server/report.d.ts +11 -0
- package/dist/server/report.js +200 -0
- package/dist/server/version.d.ts +6 -0
- package/dist/server/version.js +12 -0
- package/package.json +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 moonfloss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# snuggly-nudger
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/snuggly-nudger)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
`snuggly-nudger` is a lightweight React + server utility package extracted from [https://catacombs.9lives.quest](CATacombs) for a snuggly approach to:
|
|
7
|
+
|
|
8
|
+
- polling your deployed app version and showing update notifications
|
|
9
|
+
- collecting feedback/bug reports in a reusable modal
|
|
10
|
+
- providing server handlers for `/api/version` and `/api/report`
|
|
11
|
+
|
|
12
|
+
It is designed for modern full-stack apps (for example, Next.js App Router on Vercel), while staying framework-agnostic on the server side through standard Web `Request`/`Response` handlers.
|
|
13
|
+
|
|
14
|
+
The flow the original is built on uses Vercel serverless functions to interact with a Neon psql database (Neon is super cute, btw) on a droplet to post bug reports to a private channel in my discord server so I can click a button to create a github issue in the offending repository. Since I have a bunch of projects I'm prototyping at once, this is how I chose to centralize feedback from all of them. I'm moving it part of it here for re-use in other apps.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install snuggly-nudger react
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Package Exports
|
|
23
|
+
|
|
24
|
+
The main entry `snuggly-nudger/client` re-exports everything for convenience. If you are the kind of person who still side-eyes bundle size reports because you've been hurt before, import only what you need from granular subpaths instead (your bundler should tree-shake the barrel too when configured correctly):
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { useVersionPoll } from 'snuggly-nudger/client/useVersionPoll';
|
|
28
|
+
import { UpdateAvailableToast } from 'snuggly-nudger/client/UpdateAvailableToast';
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Available subpaths: `client/useVersionPoll`, `client/UpdateAvailableToast`, `client/FeedbackModal`, `client/ModalCloseButton`, `client/useIsMobile`.
|
|
32
|
+
|
|
33
|
+
### `snuggly-nudger/client`
|
|
34
|
+
|
|
35
|
+
- `useVersionPoll(currentVersion, options?)`
|
|
36
|
+
- `UpdateAvailableToast`
|
|
37
|
+
- `FeedbackModal`
|
|
38
|
+
- `ModalCloseButton`
|
|
39
|
+
- `useIsMobile(breakpoint?)`
|
|
40
|
+
|
|
41
|
+
Type exports:
|
|
42
|
+
|
|
43
|
+
- `UseVersionPollOptions`
|
|
44
|
+
- `UpdateAvailableToastProps`
|
|
45
|
+
- `FeedbackModalProps`
|
|
46
|
+
- `ModalCloseButtonProps`
|
|
47
|
+
|
|
48
|
+
### `snuggly-nudger/server`
|
|
49
|
+
|
|
50
|
+
- `createVersionHandler({ version })`
|
|
51
|
+
- `createReportHandler({ projectId, reportsApiUrl? })`
|
|
52
|
+
|
|
53
|
+
Type exports:
|
|
54
|
+
|
|
55
|
+
- `VersionHandlerConfig`
|
|
56
|
+
- `ReportHandlerConfig`
|
|
57
|
+
|
|
58
|
+
## Client Usage
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { useState } from 'react';
|
|
62
|
+
import { FeedbackModal, UpdateAvailableToast, useVersionPoll } from 'snuggly-nudger/client';
|
|
63
|
+
|
|
64
|
+
declare const __APP_VERSION__: string;
|
|
65
|
+
|
|
66
|
+
export function App() {
|
|
67
|
+
const [showFeedback, setShowFeedback] = useState(false);
|
|
68
|
+
const { updateAvailable, dismissUpdate } = useVersionPoll(__APP_VERSION__);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
{updateAvailable && (
|
|
73
|
+
<UpdateAvailableToast
|
|
74
|
+
deployedVersion={updateAvailable}
|
|
75
|
+
onRefresh={() => window.location.reload()}
|
|
76
|
+
onDismiss={dismissUpdate}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<button type="button" onClick={() => setShowFeedback(true)}>
|
|
81
|
+
Send Feedback
|
|
82
|
+
</button>
|
|
83
|
+
|
|
84
|
+
{showFeedback && (
|
|
85
|
+
<FeedbackModal
|
|
86
|
+
version={__APP_VERSION__}
|
|
87
|
+
appMetadata={{ route: window.location.pathname }}
|
|
88
|
+
appMetadataLabel="App State"
|
|
89
|
+
onClose={() => setShowFeedback(false)}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `useVersionPoll` options
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
useVersionPoll(currentVersion, {
|
|
101
|
+
apiPath: '/api/version',
|
|
102
|
+
pollIntervalMs: 5 * 60 * 1000,
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
While the tab is **hidden**, scheduled interval polls are skipped; an extra check still runs when the tab becomes visible again. No need to wake up the network just because someone left a tab open next to twelve others.
|
|
107
|
+
|
|
108
|
+
### Lazy-loading `FeedbackModal` (Next.js)
|
|
109
|
+
|
|
110
|
+
To avoid loading the modal until it is actually needed, because not every page load needs the whole kitchen sink:
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
import dynamic from 'next/dynamic';
|
|
114
|
+
|
|
115
|
+
const FeedbackModal = dynamic(
|
|
116
|
+
() => import('snuggly-nudger/client/FeedbackModal').then((m) => m.FeedbackModal),
|
|
117
|
+
{ ssr: false }
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Server Usage
|
|
122
|
+
|
|
123
|
+
### `/api/version`
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { createVersionHandler } from 'snuggly-nudger/server';
|
|
127
|
+
import pkg from '../../../package.json';
|
|
128
|
+
|
|
129
|
+
export const { GET } = createVersionHandler({ version: pkg.version });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### `/api/report`
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
import { createReportHandler } from 'snuggly-nudger/server';
|
|
136
|
+
|
|
137
|
+
export const { POST } = createReportHandler({
|
|
138
|
+
projectId: 'my-app',
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Report Handler Architecture
|
|
143
|
+
|
|
144
|
+
`createReportHandler` validates and sanitizes client payloads before proxying to your reports service. In other words: it tries to keep the useful signal and filter out the nonsense before your backend has to deal with it.
|
|
145
|
+
|
|
146
|
+
Request bodies are limited to **64 KiB** by default (reject with `413`). The upstream `fetch` uses a **15s** timeout by default (response `502` with `Upstream request timed out`). Override via `maxBodyBytes` / `upstreamTimeoutMs` on the handler config if needed.
|
|
147
|
+
|
|
148
|
+
Resolution order for destination base URL:
|
|
149
|
+
|
|
150
|
+
1. `reportsApiUrl` passed to `createReportHandler(...)`
|
|
151
|
+
2. `process.env.REPORTS_API_URL`
|
|
152
|
+
|
|
153
|
+
Final upstream URL:
|
|
154
|
+
|
|
155
|
+
`{reportsApiUrl}/api/reports`
|
|
156
|
+
|
|
157
|
+
Request payload forwarded upstream:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"projectId": "my-app",
|
|
162
|
+
"description": "sanitized message",
|
|
163
|
+
"version": "safe-version-string",
|
|
164
|
+
"category": "bug",
|
|
165
|
+
"metadata": {
|
|
166
|
+
"app": {},
|
|
167
|
+
"browser": {},
|
|
168
|
+
"device": {}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Only `app`, `browser`, and `device` metadata objects are forwarded.
|
|
174
|
+
|
|
175
|
+
## Environment Variables
|
|
176
|
+
|
|
177
|
+
See `.env.example` for expected values. It is brief, on purpose, and does not require a decoding ring:
|
|
178
|
+
|
|
179
|
+
- `REPORTS_API_URL` - Base URL for your reports ingestion service
|
|
180
|
+
|
|
181
|
+
## Notes
|
|
182
|
+
|
|
183
|
+
- `FeedbackModal` sends metadata keys as `app`, `browser`, and `device`.
|
|
184
|
+
- `createReportHandler` validates `type` (`feedback`, `bug`, `other`) and sanitizes message + version before proxying.
|
|
185
|
+
- The package does not implement app-specific state restoration on refresh; pass your own `onRefresh` callback. We provide the nudge, not a full state-management philosophy.
|
|
186
|
+
|
|
187
|
+
## Demo App
|
|
188
|
+
|
|
189
|
+
A runnable demo is available in `demo/` and shows the whole thing working end-to-end, without requiring interpretive dance:
|
|
190
|
+
|
|
191
|
+
- update polling UI
|
|
192
|
+
- feedback modal submission
|
|
193
|
+
- `/api/version`, `/api/report`, and a mock `/api/reports` upstream endpoint
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface FeedbackModalProps {
|
|
2
|
+
version: string;
|
|
3
|
+
appMetadata?: Record<string, unknown> | null;
|
|
4
|
+
appMetadataLabel?: string;
|
|
5
|
+
reportEndpoint?: string;
|
|
6
|
+
onClose: () => void;
|
|
7
|
+
}
|
|
8
|
+
export declare function FeedbackModal({ version, appMetadata, appMetadataLabel, reportEndpoint, onClose, }: FeedbackModalProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { ModalCloseButton } from './ModalCloseButton';
|
|
4
|
+
import { useIsMobile } from './useIsMobile';
|
|
5
|
+
function getBrowserMetadata() {
|
|
6
|
+
if (typeof navigator === 'undefined')
|
|
7
|
+
return {};
|
|
8
|
+
const isMobile = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
9
|
+
? window.matchMedia('(max-width: 640px)').matches
|
|
10
|
+
: false;
|
|
11
|
+
return {
|
|
12
|
+
userAgent: navigator.userAgent,
|
|
13
|
+
language: navigator.language,
|
|
14
|
+
languages: Array.isArray(navigator.languages) ? [...navigator.languages] : [],
|
|
15
|
+
platform: navigator.platform,
|
|
16
|
+
isMobile,
|
|
17
|
+
maxTouchPoints: navigator.maxTouchPoints ?? 0,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function getDeviceMetadata() {
|
|
21
|
+
if (typeof window === 'undefined' || typeof screen === 'undefined')
|
|
22
|
+
return {};
|
|
23
|
+
return {
|
|
24
|
+
viewportWidth: window.innerWidth,
|
|
25
|
+
viewportHeight: window.innerHeight,
|
|
26
|
+
screenWidth: screen.width,
|
|
27
|
+
screenHeight: screen.height,
|
|
28
|
+
devicePixelRatio: window.devicePixelRatio ?? 1,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function sanitizeClientInput(value) {
|
|
32
|
+
const withoutControl = Array.from(value)
|
|
33
|
+
.filter((ch) => {
|
|
34
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
35
|
+
return code >= 32 && code !== 127;
|
|
36
|
+
})
|
|
37
|
+
.join('');
|
|
38
|
+
return withoutControl.slice(0, 1000);
|
|
39
|
+
}
|
|
40
|
+
export function FeedbackModal({ version, appMetadata, appMetadataLabel = 'App', reportEndpoint = '/api/report', onClose, }) {
|
|
41
|
+
const [type, setType] = useState('bug');
|
|
42
|
+
const [message, setMessage] = useState('');
|
|
43
|
+
const [submitting, setSubmitting] = useState(false);
|
|
44
|
+
const [statusText, setStatusText] = useState(null);
|
|
45
|
+
const [includeApp, setIncludeApp] = useState(true);
|
|
46
|
+
const [includeBrowser, setIncludeBrowser] = useState(false);
|
|
47
|
+
const [includeDevice, setIncludeDevice] = useState(false);
|
|
48
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
49
|
+
const isMobile = useIsMobile();
|
|
50
|
+
const remaining = useMemo(() => 1000 - message.length, [message.length]);
|
|
51
|
+
const sanitizedMessage = useMemo(() => sanitizeClientInput(message).trim(), [message]);
|
|
52
|
+
const metadata = useMemo(() => {
|
|
53
|
+
const parts = {};
|
|
54
|
+
if (includeApp && appMetadata)
|
|
55
|
+
parts.app = appMetadata;
|
|
56
|
+
if (includeBrowser)
|
|
57
|
+
parts.browser = getBrowserMetadata();
|
|
58
|
+
if (includeDevice)
|
|
59
|
+
parts.device = getDeviceMetadata();
|
|
60
|
+
return Object.keys(parts).length > 0 ? parts : undefined;
|
|
61
|
+
}, [includeApp, includeBrowser, includeDevice, appMetadata]);
|
|
62
|
+
const requestBody = useMemo(() => {
|
|
63
|
+
const body = {
|
|
64
|
+
type,
|
|
65
|
+
message: sanitizedMessage || '(empty)',
|
|
66
|
+
version,
|
|
67
|
+
};
|
|
68
|
+
if (metadata)
|
|
69
|
+
body.metadata = metadata;
|
|
70
|
+
return body;
|
|
71
|
+
}, [type, sanitizedMessage, version, metadata]);
|
|
72
|
+
const canSubmit = sanitizedMessage.length > 0;
|
|
73
|
+
const submit = async () => {
|
|
74
|
+
if (!canSubmit || submitting)
|
|
75
|
+
return;
|
|
76
|
+
setSubmitting(true);
|
|
77
|
+
setStatusText(null);
|
|
78
|
+
const body = { ...requestBody, message: sanitizedMessage };
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(reportEndpoint, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify(body),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok)
|
|
86
|
+
throw new Error('Failed to send');
|
|
87
|
+
setStatusText('Thanks! Your report was sent.');
|
|
88
|
+
setTimeout(() => onClose(), 700);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
setStatusText('Could not send report. Please try again.');
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
setSubmitting(false);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const previewJson = JSON.stringify(requestBody, null, 2);
|
|
98
|
+
const codeBlockStyle = {
|
|
99
|
+
margin: 0,
|
|
100
|
+
fontSize: 11,
|
|
101
|
+
fontFamily: '"Courier New", monospace',
|
|
102
|
+
color: '#ccc',
|
|
103
|
+
whiteSpace: 'pre-wrap',
|
|
104
|
+
wordBreak: 'break-all',
|
|
105
|
+
};
|
|
106
|
+
const modalContent = (_jsxs(_Fragment, { children: [_jsxs("div", { style: {
|
|
107
|
+
display: 'flex',
|
|
108
|
+
justifyContent: 'space-between',
|
|
109
|
+
alignItems: 'center',
|
|
110
|
+
marginBottom: 14,
|
|
111
|
+
}, children: [_jsx("span", { id: "snuggly-nudger-feedback-title", style: { color: '#00ffcc', fontSize: 14, fontWeight: 'bold' }, children: "Send report" }), _jsx(ModalCloseButton, { onClick: onClose, ariaLabel: "Close feedback form" })] }), _jsxs("fieldset", { style: { border: 'none', margin: 0, padding: 0, marginBottom: 12 }, children: [_jsx("legend", { style: { fontSize: 11, color: '#789', marginBottom: 8 }, children: "Type" }), _jsxs("label", { style: { display: 'block', fontSize: 12, color: '#bbb', marginBottom: 6 }, children: [_jsx("input", { type: "radio", name: "feedback-type", value: "feedback", checked: type === 'feedback', onChange: () => setType('feedback'), style: { marginRight: 8 } }), "Feedback"] }), _jsxs("label", { style: { display: 'block', fontSize: 12, color: '#bbb', marginBottom: 6 }, children: [_jsx("input", { type: "radio", name: "feedback-type", value: "bug", checked: type === 'bug', onChange: () => setType('bug'), style: { marginRight: 8 } }), "Bug report"] }), _jsxs("label", { style: { display: 'block', fontSize: 12, color: '#bbb' }, children: [_jsx("input", { type: "radio", name: "feedback-type", value: "other", checked: type === 'other', onChange: () => setType('other'), style: { marginRight: 8 } }), "Other (explain)"] })] }), _jsx("textarea", { value: message, onChange: (e) => setMessage(sanitizeClientInput(e.target.value)), placeholder: type === 'other' ? 'Please explain...' : 'Tell us what happened...', maxLength: 1000, rows: 7, style: {
|
|
112
|
+
width: '100%',
|
|
113
|
+
resize: 'vertical',
|
|
114
|
+
minHeight: 110,
|
|
115
|
+
background: '#080810',
|
|
116
|
+
border: '1px solid #2a2a50',
|
|
117
|
+
color: '#ccc',
|
|
118
|
+
borderRadius: 6,
|
|
119
|
+
padding: 10,
|
|
120
|
+
fontFamily: 'inherit',
|
|
121
|
+
fontSize: 12,
|
|
122
|
+
boxSizing: 'border-box',
|
|
123
|
+
outline: 'none',
|
|
124
|
+
} }), _jsxs("div", { style: { marginTop: 12, marginBottom: 8 }, children: [_jsx("div", { style: { fontSize: 11, color: '#789', marginBottom: 8 }, children: "Include Data for Debugging Purposes (Optional):" }), appMetadata && (_jsxs("label", { style: { display: 'block', fontSize: 12, color: '#bbb', marginBottom: 6 }, children: [_jsx("input", { type: "checkbox", checked: includeApp, onChange: (e) => setIncludeApp(e.target.checked), style: { marginRight: 8 } }), appMetadataLabel] })), _jsxs("label", { style: { display: 'block', fontSize: 12, color: '#bbb', marginBottom: 6 }, children: [_jsx("input", { type: "checkbox", checked: includeBrowser, onChange: (e) => setIncludeBrowser(e.target.checked), style: { marginRight: 8 } }), "Browser"] }), _jsxs("label", { style: { display: 'block', fontSize: 12, color: '#bbb' }, children: [_jsx("input", { type: "checkbox", checked: includeDevice, onChange: (e) => setIncludeDevice(e.target.checked), style: { marginRight: 8 } }), "Device"] })] }), _jsxs("div", { style: {
|
|
125
|
+
display: 'flex',
|
|
126
|
+
flexWrap: 'wrap',
|
|
127
|
+
gap: 8,
|
|
128
|
+
justifyContent: 'space-between',
|
|
129
|
+
alignItems: 'center',
|
|
130
|
+
marginTop: 8,
|
|
131
|
+
}, children: [_jsxs("span", { style: { fontSize: 11, color: '#667' }, children: [remaining, " chars left"] }), _jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center' }, children: [_jsx("button", { type: "button", onClick: () => setShowPreview(!showPreview), style: {
|
|
132
|
+
background: '#1a1a30',
|
|
133
|
+
border: '1px solid #2a2a50',
|
|
134
|
+
color: '#8cf',
|
|
135
|
+
borderRadius: 4,
|
|
136
|
+
padding: '7px 10px',
|
|
137
|
+
fontSize: 11,
|
|
138
|
+
cursor: 'pointer',
|
|
139
|
+
fontFamily: 'inherit',
|
|
140
|
+
}, children: "Preview Data" }), _jsx("button", { type: "button", onClick: submit, disabled: submitting || !canSubmit, style: {
|
|
141
|
+
background: submitting || !canSubmit ? '#111325' : '#142235',
|
|
142
|
+
border: '1px solid #2a4a6a',
|
|
143
|
+
color: submitting || !canSubmit ? '#556' : '#8cf',
|
|
144
|
+
borderRadius: 4,
|
|
145
|
+
padding: '7px 10px',
|
|
146
|
+
fontSize: 11,
|
|
147
|
+
cursor: submitting || !canSubmit ? 'default' : 'pointer',
|
|
148
|
+
fontFamily: 'inherit',
|
|
149
|
+
}, children: submitting ? 'Sending...' : 'Send report' })] })] }), statusText && (_jsx("div", { style: { marginTop: 10, fontSize: 11, color: '#8cf' }, role: "status", children: statusText })), showPreview && isMobile && (_jsx("div", { style: {
|
|
150
|
+
marginTop: 12,
|
|
151
|
+
padding: 10,
|
|
152
|
+
background: '#080810',
|
|
153
|
+
border: '1px solid #2a2a50',
|
|
154
|
+
borderRadius: 6,
|
|
155
|
+
overflow: 'auto',
|
|
156
|
+
maxHeight: 240,
|
|
157
|
+
}, children: _jsx("pre", { style: codeBlockStyle, children: _jsx("code", { children: previewJson }) }) }))] }));
|
|
158
|
+
const modalWidth = showPreview && !isMobile ? 800 : 420;
|
|
159
|
+
const innerStyle = {
|
|
160
|
+
background: '#0c0c1e',
|
|
161
|
+
border: '1px solid #2a2a50',
|
|
162
|
+
borderRadius: 8,
|
|
163
|
+
padding: 18,
|
|
164
|
+
width: modalWidth,
|
|
165
|
+
maxWidth: '95vw',
|
|
166
|
+
maxHeight: '85vh',
|
|
167
|
+
overflowY: 'auto',
|
|
168
|
+
boxSizing: 'border-box',
|
|
169
|
+
};
|
|
170
|
+
return (_jsx("div", { role: "presentation", style: {
|
|
171
|
+
position: 'fixed',
|
|
172
|
+
inset: 0,
|
|
173
|
+
background: 'rgba(0,0,0,0.88)',
|
|
174
|
+
display: 'flex',
|
|
175
|
+
alignItems: 'center',
|
|
176
|
+
justifyContent: 'center',
|
|
177
|
+
zIndex: 300,
|
|
178
|
+
}, onClick: onClose, children: _jsx("div", { role: "dialog", "aria-modal": "true", "aria-labelledby": "snuggly-nudger-feedback-title", style: innerStyle, onClick: (e) => e.stopPropagation(), children: showPreview && !isMobile ? (_jsxs("div", { style: { display: 'flex', gap: 20, alignItems: 'flex-start' }, children: [_jsx("div", { style: { flex: '0 0 380px', minWidth: 0 }, children: modalContent }), _jsx("div", { style: {
|
|
179
|
+
flex: 1,
|
|
180
|
+
minWidth: 0,
|
|
181
|
+
padding: 10,
|
|
182
|
+
background: '#080810',
|
|
183
|
+
border: '1px solid #2a2a50',
|
|
184
|
+
borderRadius: 6,
|
|
185
|
+
overflow: 'auto',
|
|
186
|
+
maxHeight: '70vh',
|
|
187
|
+
}, children: _jsx("pre", { style: codeBlockStyle, children: _jsx("code", { children: previewJson }) }) })] })) : (modalContent) }) }));
|
|
188
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
export function ModalCloseButton({ onClick, ariaLabel = 'Close dialog' }) {
|
|
3
|
+
return (_jsx("button", { type: "button", "aria-label": ariaLabel, onClick: onClick, style: {
|
|
4
|
+
background: 'none',
|
|
5
|
+
border: '1px solid #2a2a40',
|
|
6
|
+
color: '#666',
|
|
7
|
+
borderRadius: 4,
|
|
8
|
+
cursor: 'pointer',
|
|
9
|
+
fontSize: 14,
|
|
10
|
+
lineHeight: 1,
|
|
11
|
+
padding: '2px 8px',
|
|
12
|
+
fontFamily: 'monospace',
|
|
13
|
+
}, children: "\u2715" }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface UpdateAvailableToastProps {
|
|
2
|
+
deployedVersion: string;
|
|
3
|
+
onRefresh: () => void;
|
|
4
|
+
onDismiss: () => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function UpdateAvailableToast({ deployedVersion, onRefresh, onDismiss, }: UpdateAvailableToastProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
export function UpdateAvailableToast({ deployedVersion, onRefresh, onDismiss, }) {
|
|
3
|
+
return (_jsxs("div", { role: "alert", "aria-live": "polite", style: {
|
|
4
|
+
position: 'fixed',
|
|
5
|
+
top: 12,
|
|
6
|
+
left: '50%',
|
|
7
|
+
transform: 'translateX(-50%)',
|
|
8
|
+
background: 'rgba(10,12,24,0.96)',
|
|
9
|
+
border: '1px solid #2a4a6a',
|
|
10
|
+
borderRadius: 8,
|
|
11
|
+
padding: '10px 16px',
|
|
12
|
+
boxShadow: '0 0 20px rgba(0,180,255,0.2)',
|
|
13
|
+
maxWidth: '90vw',
|
|
14
|
+
zIndex: 300,
|
|
15
|
+
display: 'flex',
|
|
16
|
+
alignItems: 'center',
|
|
17
|
+
gap: 12,
|
|
18
|
+
}, children: [_jsx("span", { style: { fontSize: 18 }, children: "\uD83D\uDD04" }), _jsxs("div", { style: { flex: 1 }, children: [_jsx("div", { style: { fontSize: 12, color: '#8cf', fontWeight: 'bold' }, children: "Update available" }), _jsxs("div", { style: { fontSize: 11, color: '#6a8' }, children: ["v", deployedVersion, " is deployed. Refresh to get the latest."] })] }), _jsx("button", { type: "button", onClick: onRefresh, style: {
|
|
19
|
+
background: '#0d2236',
|
|
20
|
+
border: '1px solid #2a4a6a',
|
|
21
|
+
borderRadius: 4,
|
|
22
|
+
color: '#8cf',
|
|
23
|
+
fontSize: 11,
|
|
24
|
+
padding: '6px 8px',
|
|
25
|
+
cursor: 'pointer',
|
|
26
|
+
fontFamily: 'inherit',
|
|
27
|
+
}, children: "Refresh" }), _jsx("button", { type: "button", "aria-label": "Dismiss update notification", onClick: onDismiss, style: {
|
|
28
|
+
background: 'transparent',
|
|
29
|
+
border: '1px solid #2a4a6a',
|
|
30
|
+
borderRadius: 4,
|
|
31
|
+
color: '#8cf',
|
|
32
|
+
fontSize: 11,
|
|
33
|
+
width: 24,
|
|
34
|
+
height: 24,
|
|
35
|
+
cursor: 'pointer',
|
|
36
|
+
fontFamily: 'inherit',
|
|
37
|
+
lineHeight: 1,
|
|
38
|
+
}, children: "\u00D7" })] }));
|
|
39
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { useVersionPoll } from './useVersionPoll';
|
|
2
|
+
export { UpdateAvailableToast } from './UpdateAvailableToast';
|
|
3
|
+
export { FeedbackModal } from './FeedbackModal';
|
|
4
|
+
export { ModalCloseButton } from './ModalCloseButton';
|
|
5
|
+
export { useIsMobile } from './useIsMobile';
|
|
6
|
+
export type { UseVersionPollOptions } from './useVersionPoll';
|
|
7
|
+
export type { UpdateAvailableToastProps } from './UpdateAvailableToast';
|
|
8
|
+
export type { FeedbackModalProps } from './FeedbackModal';
|
|
9
|
+
export type { ModalCloseButtonProps } from './ModalCloseButton';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
export { useVersionPoll } from './useVersionPoll';
|
|
3
|
+
export { UpdateAvailableToast } from './UpdateAvailableToast';
|
|
4
|
+
export { FeedbackModal } from './FeedbackModal';
|
|
5
|
+
export { ModalCloseButton } from './ModalCloseButton';
|
|
6
|
+
export { useIsMobile } from './useIsMobile';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useIsMobile(breakpoint?: number): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
const DEFAULT_BREAKPOINT = 768;
|
|
3
|
+
function subscribe(query, cb) {
|
|
4
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
5
|
+
return () => { };
|
|
6
|
+
const mql = window.matchMedia(query);
|
|
7
|
+
mql.addEventListener('change', cb);
|
|
8
|
+
return () => mql.removeEventListener('change', cb);
|
|
9
|
+
}
|
|
10
|
+
function getSnapshot(query) {
|
|
11
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
12
|
+
return false;
|
|
13
|
+
return window.matchMedia(query).matches;
|
|
14
|
+
}
|
|
15
|
+
export function useIsMobile(breakpoint = DEFAULT_BREAKPOINT) {
|
|
16
|
+
const query = `(max-width: ${breakpoint - 1}px)`;
|
|
17
|
+
return useSyncExternalStore((cb) => subscribe(query, cb), () => getSnapshot(query), () => false);
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
3
|
+
export function useVersionPoll(currentVersion, options = {}) {
|
|
4
|
+
const [updateAvailable, setUpdateAvailable] = useState(null);
|
|
5
|
+
const dismissedVersionRef = useRef(null);
|
|
6
|
+
const apiPath = options.apiPath ?? '/api/version';
|
|
7
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
if (typeof window === 'undefined')
|
|
10
|
+
return;
|
|
11
|
+
let abortController = null;
|
|
12
|
+
const check = async () => {
|
|
13
|
+
if (document.visibilityState === 'hidden')
|
|
14
|
+
return;
|
|
15
|
+
abortController?.abort();
|
|
16
|
+
abortController = new AbortController();
|
|
17
|
+
const { signal } = abortController;
|
|
18
|
+
try {
|
|
19
|
+
const base = window.location.origin;
|
|
20
|
+
const res = await fetch(`${base}${apiPath}`, { cache: 'no-store', signal });
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
return;
|
|
23
|
+
const data = (await res.json());
|
|
24
|
+
const deployed = data.version?.trim();
|
|
25
|
+
if (!deployed)
|
|
26
|
+
return;
|
|
27
|
+
const current = currentVersion.trim();
|
|
28
|
+
if (!current || deployed === current) {
|
|
29
|
+
setUpdateAvailable(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (dismissedVersionRef.current !== deployed) {
|
|
33
|
+
setUpdateAvailable(deployed);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
const aborted = (err instanceof DOMException && err.name === 'AbortError') ||
|
|
38
|
+
(typeof err === 'object' &&
|
|
39
|
+
err !== null &&
|
|
40
|
+
'name' in err &&
|
|
41
|
+
err.name === 'AbortError');
|
|
42
|
+
if (aborted)
|
|
43
|
+
return;
|
|
44
|
+
// ignore other network errors
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
void check();
|
|
48
|
+
const id = setInterval(() => {
|
|
49
|
+
if (document.visibilityState !== 'hidden') {
|
|
50
|
+
void check();
|
|
51
|
+
}
|
|
52
|
+
}, pollIntervalMs);
|
|
53
|
+
const onVisibilityChange = () => {
|
|
54
|
+
if (document.visibilityState === 'visible') {
|
|
55
|
+
void check();
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
59
|
+
return () => {
|
|
60
|
+
abortController?.abort();
|
|
61
|
+
clearInterval(id);
|
|
62
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
63
|
+
};
|
|
64
|
+
}, [apiPath, currentVersion, pollIntervalMs]);
|
|
65
|
+
const dismissUpdate = () => {
|
|
66
|
+
dismissedVersionRef.current = updateAvailable;
|
|
67
|
+
setUpdateAvailable(null);
|
|
68
|
+
};
|
|
69
|
+
return { updateAvailable, dismissUpdate };
|
|
70
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ReportHandlerConfig {
|
|
2
|
+
projectId: string;
|
|
3
|
+
reportsApiUrl?: string;
|
|
4
|
+
/** Max request body size in bytes (default 65536). */
|
|
5
|
+
maxBodyBytes?: number;
|
|
6
|
+
/** Upstream fetch timeout in ms (default 15000). */
|
|
7
|
+
upstreamTimeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function createReportHandler(config: ReportHandlerConfig): {
|
|
10
|
+
POST(request: Request): Promise<Response>;
|
|
11
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const VALID_TYPES = ['feedback', 'bug', 'other'];
|
|
2
|
+
const DEFAULT_MAX_BODY_BYTES = 65536;
|
|
3
|
+
const DEFAULT_UPSTREAM_TIMEOUT_MS = 15000;
|
|
4
|
+
function jsonResponse(body, status) {
|
|
5
|
+
return new Response(JSON.stringify(body), {
|
|
6
|
+
status,
|
|
7
|
+
headers: { 'Content-Type': 'application/json' },
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
function isAbortOrTimeout(err) {
|
|
11
|
+
if (err instanceof DOMException && (err.name === 'AbortError' || err.name === 'TimeoutError'))
|
|
12
|
+
return true;
|
|
13
|
+
return (typeof err === 'object' &&
|
|
14
|
+
err !== null &&
|
|
15
|
+
'name' in err &&
|
|
16
|
+
err.name === 'TimeoutError');
|
|
17
|
+
}
|
|
18
|
+
async function readReportJsonBody(request, maxBytes) {
|
|
19
|
+
const lenHeader = request.headers.get('content-length');
|
|
20
|
+
if (lenHeader !== null && lenHeader !== '') {
|
|
21
|
+
const n = Number(lenHeader);
|
|
22
|
+
if (!Number.isFinite(n) || n < 0 || n > maxBytes) {
|
|
23
|
+
return { ok: false, response: jsonResponse({ error: 'Request body too large' }, 413) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!request.body) {
|
|
27
|
+
return { ok: false, response: jsonResponse({ error: 'Invalid JSON body' }, 400) };
|
|
28
|
+
}
|
|
29
|
+
const reader = request.body.getReader();
|
|
30
|
+
const chunks = [];
|
|
31
|
+
let total = 0;
|
|
32
|
+
try {
|
|
33
|
+
for (;;) {
|
|
34
|
+
const { done, value } = await reader.read();
|
|
35
|
+
if (done)
|
|
36
|
+
break;
|
|
37
|
+
total += value.byteLength;
|
|
38
|
+
if (total > maxBytes) {
|
|
39
|
+
await reader.cancel();
|
|
40
|
+
return { ok: false, response: jsonResponse({ error: 'Request body too large' }, 413) };
|
|
41
|
+
}
|
|
42
|
+
chunks.push(value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return { ok: false, response: jsonResponse({ error: 'Invalid JSON body' }, 400) };
|
|
47
|
+
}
|
|
48
|
+
if (total === 0) {
|
|
49
|
+
return { ok: false, response: jsonResponse({ error: 'Invalid JSON body' }, 400) };
|
|
50
|
+
}
|
|
51
|
+
const buf = new Uint8Array(total);
|
|
52
|
+
let offset = 0;
|
|
53
|
+
for (const c of chunks) {
|
|
54
|
+
buf.set(c, offset);
|
|
55
|
+
offset += c.byteLength;
|
|
56
|
+
}
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(new TextDecoder().decode(buf));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { ok: false, response: jsonResponse({ error: 'Invalid JSON body' }, 400) };
|
|
63
|
+
}
|
|
64
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
65
|
+
return { ok: false, response: jsonResponse({ error: 'Invalid JSON body' }, 400) };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, body: parsed };
|
|
68
|
+
}
|
|
69
|
+
function asMetadata(value) {
|
|
70
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
71
|
+
return undefined;
|
|
72
|
+
const obj = value;
|
|
73
|
+
const out = {};
|
|
74
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'app') &&
|
|
75
|
+
obj.app !== null &&
|
|
76
|
+
typeof obj.app === 'object' &&
|
|
77
|
+
!Array.isArray(obj.app)) {
|
|
78
|
+
out.app = obj.app;
|
|
79
|
+
}
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'browser') &&
|
|
81
|
+
obj.browser !== null &&
|
|
82
|
+
typeof obj.browser === 'object' &&
|
|
83
|
+
!Array.isArray(obj.browser)) {
|
|
84
|
+
out.browser = obj.browser;
|
|
85
|
+
}
|
|
86
|
+
if (Object.prototype.hasOwnProperty.call(obj, 'device') &&
|
|
87
|
+
obj.device !== null &&
|
|
88
|
+
typeof obj.device === 'object' &&
|
|
89
|
+
!Array.isArray(obj.device)) {
|
|
90
|
+
out.device = obj.device;
|
|
91
|
+
}
|
|
92
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
93
|
+
}
|
|
94
|
+
function sanitizeMessage(input) {
|
|
95
|
+
const withoutControl = Array.from(input)
|
|
96
|
+
.filter((ch) => {
|
|
97
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
98
|
+
return code >= 32 && code !== 127;
|
|
99
|
+
})
|
|
100
|
+
.join('');
|
|
101
|
+
return withoutControl
|
|
102
|
+
.replace(/<[^>]*>/g, '')
|
|
103
|
+
.replace(/\s+/g, ' ')
|
|
104
|
+
.replace(/@/g, '@\u200b')
|
|
105
|
+
.trim()
|
|
106
|
+
.slice(0, 1000);
|
|
107
|
+
}
|
|
108
|
+
function asReportType(value) {
|
|
109
|
+
if (typeof value !== 'string')
|
|
110
|
+
return null;
|
|
111
|
+
return VALID_TYPES.includes(value) ? value : null;
|
|
112
|
+
}
|
|
113
|
+
function asSafeVersion(value) {
|
|
114
|
+
if (typeof value !== 'string')
|
|
115
|
+
return 'unknown';
|
|
116
|
+
return value.replace(/[^0-9A-Za-z.\-_]/g, '').slice(0, 40) || 'unknown';
|
|
117
|
+
}
|
|
118
|
+
export function createReportHandler(config) {
|
|
119
|
+
const maxBodyBytes = config.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
120
|
+
const upstreamTimeoutMs = config.upstreamTimeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS;
|
|
121
|
+
return {
|
|
122
|
+
async POST(request) {
|
|
123
|
+
const reportsApiUrl = config.reportsApiUrl ?? process.env.REPORTS_API_URL;
|
|
124
|
+
if (!reportsApiUrl) {
|
|
125
|
+
return new Response(JSON.stringify({ error: 'Reports API not configured' }), {
|
|
126
|
+
status: 500,
|
|
127
|
+
headers: { 'Content-Type': 'application/json' },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const read = await readReportJsonBody(request, maxBodyBytes);
|
|
131
|
+
if (!read.ok)
|
|
132
|
+
return read.response;
|
|
133
|
+
const body = read.body;
|
|
134
|
+
const type = asReportType(body.type);
|
|
135
|
+
if (!type) {
|
|
136
|
+
return new Response(JSON.stringify({ error: 'Invalid report type' }), {
|
|
137
|
+
status: 400,
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
if (typeof body.message !== 'string') {
|
|
142
|
+
return new Response(JSON.stringify({ error: 'Message is required' }), {
|
|
143
|
+
status: 400,
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const message = sanitizeMessage(body.message);
|
|
148
|
+
if (!message) {
|
|
149
|
+
return new Response(JSON.stringify({ error: 'Message is empty after sanitization' }), {
|
|
150
|
+
status: 400,
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const version = asSafeVersion(body.version);
|
|
155
|
+
const metadata = asMetadata(body.metadata);
|
|
156
|
+
const payload = {
|
|
157
|
+
projectId: config.projectId,
|
|
158
|
+
description: message,
|
|
159
|
+
version,
|
|
160
|
+
category: type,
|
|
161
|
+
};
|
|
162
|
+
if (metadata !== undefined)
|
|
163
|
+
payload.metadata = metadata;
|
|
164
|
+
const url = `${reportsApiUrl.trim().replace(/\/$/, '')}/api/reports`;
|
|
165
|
+
let reportsResponse;
|
|
166
|
+
try {
|
|
167
|
+
reportsResponse = await fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: JSON.stringify(payload),
|
|
171
|
+
signal: AbortSignal.timeout(upstreamTimeoutMs),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (isAbortOrTimeout(err)) {
|
|
176
|
+
return new Response(JSON.stringify({ error: 'Upstream request timed out' }), {
|
|
177
|
+
status: 502,
|
|
178
|
+
headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return new Response(JSON.stringify({ error: 'Upstream request failed' }), {
|
|
182
|
+
status: 502,
|
|
183
|
+
headers: { 'Content-Type': 'application/json' },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (!reportsResponse.ok) {
|
|
187
|
+
return new Response(JSON.stringify({ error: 'Failed to send report' }), {
|
|
188
|
+
status: 502,
|
|
189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
193
|
+
headers: {
|
|
194
|
+
'Content-Type': 'application/json',
|
|
195
|
+
'Cache-Control': 'no-store, max-age=0',
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const headers = {
|
|
2
|
+
'Content-Type': 'application/json',
|
|
3
|
+
'Cache-Control': 'no-store, max-age=0',
|
|
4
|
+
};
|
|
5
|
+
export function createVersionHandler(config) {
|
|
6
|
+
return {
|
|
7
|
+
GET() {
|
|
8
|
+
const version = config.version?.trim() || '0.0.0';
|
|
9
|
+
return new Response(JSON.stringify({ version }), { headers });
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snuggly-nudger",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable client and server utilities for update notifications and in-app feedback reporting.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "moonfloss",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/moonfloss/snuggly-nudger.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/moonfloss/snuggly-nudger",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/moonfloss/snuggly-nudger/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"feedback",
|
|
19
|
+
"bug-report",
|
|
20
|
+
"release-notifications",
|
|
21
|
+
"vercel",
|
|
22
|
+
"nextjs",
|
|
23
|
+
"react"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
"./client": {
|
|
33
|
+
"types": "./dist/client/index.d.ts",
|
|
34
|
+
"import": "./dist/client/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./client/useVersionPoll": {
|
|
37
|
+
"types": "./dist/client/useVersionPoll.d.ts",
|
|
38
|
+
"import": "./dist/client/useVersionPoll.js"
|
|
39
|
+
},
|
|
40
|
+
"./client/UpdateAvailableToast": {
|
|
41
|
+
"types": "./dist/client/UpdateAvailableToast.d.ts",
|
|
42
|
+
"import": "./dist/client/UpdateAvailableToast.js"
|
|
43
|
+
},
|
|
44
|
+
"./client/FeedbackModal": {
|
|
45
|
+
"types": "./dist/client/FeedbackModal.d.ts",
|
|
46
|
+
"import": "./dist/client/FeedbackModal.js"
|
|
47
|
+
},
|
|
48
|
+
"./client/ModalCloseButton": {
|
|
49
|
+
"types": "./dist/client/ModalCloseButton.d.ts",
|
|
50
|
+
"import": "./dist/client/ModalCloseButton.js"
|
|
51
|
+
},
|
|
52
|
+
"./client/useIsMobile": {
|
|
53
|
+
"types": "./dist/client/useIsMobile.d.ts",
|
|
54
|
+
"import": "./dist/client/useIsMobile.js"
|
|
55
|
+
},
|
|
56
|
+
"./server": {
|
|
57
|
+
"types": "./dist/server/index.d.ts",
|
|
58
|
+
"import": "./dist/server/index.js"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "npm run clean && tsc",
|
|
63
|
+
"clean": "rm -rf dist",
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:watch": "vitest",
|
|
66
|
+
"lint": "eslint .",
|
|
67
|
+
"format": "prettier --write .",
|
|
68
|
+
"format:check": "prettier --check .",
|
|
69
|
+
"size-limit": "npm run build && size-limit",
|
|
70
|
+
"prepublishOnly": "npm run build"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"react": ">=18"
|
|
74
|
+
},
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@eslint/js": "^9.39.4",
|
|
77
|
+
"@size-limit/file": "^11.2.0",
|
|
78
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
79
|
+
"@testing-library/react": "^16.3.2",
|
|
80
|
+
"@types/node": "^24.5.2",
|
|
81
|
+
"@types/react": "^19.1.13",
|
|
82
|
+
"eslint": "^9.39.4",
|
|
83
|
+
"eslint-plugin-react-hooks": "^5.2.0",
|
|
84
|
+
"jsdom": "^29.0.1",
|
|
85
|
+
"prettier": "^3.8.1",
|
|
86
|
+
"size-limit": "^11.2.0",
|
|
87
|
+
"typescript": "^5.9.2",
|
|
88
|
+
"typescript-eslint": "^8.58.0",
|
|
89
|
+
"vitest": "^4.1.2"
|
|
90
|
+
}
|
|
91
|
+
}
|