fi-edback 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,377 @@
1
+ // src/lib/validation.ts
2
+ import { z } from "zod";
3
+ function getFeedbackSchema() {
4
+ return z.object({
5
+ projectSlug: z.string().min(1).max(100),
6
+ pageUrl: z.string().url(),
7
+ x: z.number().finite(),
8
+ y: z.number().finite(),
9
+ message: z.string().min(1, "Message is required").max(2e3),
10
+ name: z.string().max(100).optional(),
11
+ email: z.string().max(200).refine((v) => v === "" || z.string().email().safeParse(v).success, {
12
+ message: "Must be a valid email address"
13
+ }).optional(),
14
+ sessionId: z.string().min(1).max(128),
15
+ userAgent: z.string().max(500).optional(),
16
+ // Honeypot field — must be an empty string; bots fill it in
17
+ website: z.literal("")
18
+ });
19
+ }
20
+
21
+ // src/lib/db/client.ts
22
+ var clientCache = /* @__PURE__ */ new Map();
23
+ async function getNeonClient(databaseUrl) {
24
+ const cached = clientCache.get(databaseUrl);
25
+ if (cached) return cached;
26
+ const { neon } = await import("@neondatabase/serverless");
27
+ const client = neon(databaseUrl);
28
+ clientCache.set(databaseUrl, client);
29
+ return client;
30
+ }
31
+
32
+ // src/lib/config.ts
33
+ var RATE_LIMIT_MAX = 5;
34
+ var RATE_LIMIT_WINDOW_SECONDS = 60;
35
+
36
+ // src/lib/db/queries.ts
37
+ async function insertFeedback(sql, payload) {
38
+ const rows = await sql`
39
+ INSERT INTO fi_feedback (
40
+ project_slug,
41
+ page_url,
42
+ x,
43
+ y,
44
+ message,
45
+ name,
46
+ email,
47
+ session_id,
48
+ user_agent,
49
+ ip_address
50
+ ) VALUES (
51
+ ${payload.projectSlug},
52
+ ${payload.pageUrl},
53
+ ${payload.x},
54
+ ${payload.y},
55
+ ${payload.message},
56
+ ${payload.name ?? null},
57
+ ${payload.email ?? null},
58
+ ${payload.sessionId},
59
+ ${payload.userAgent ?? null},
60
+ ${payload.ipAddress ?? null}
61
+ )
62
+ RETURNING
63
+ id,
64
+ project_slug as "projectSlug",
65
+ page_url as "pageUrl",
66
+ x,
67
+ y,
68
+ message,
69
+ name,
70
+ email,
71
+ session_id as "sessionId",
72
+ user_agent as "userAgent",
73
+ ip_address as "ipAddress",
74
+ created_at as "createdAt"
75
+ `;
76
+ const row = rows[0];
77
+ return {
78
+ ...row,
79
+ createdAt: new Date(row.createdAt)
80
+ };
81
+ }
82
+ async function getFeedbackForPage(sql, projectSlug, pageUrl, sessionId) {
83
+ const rows = await sql`
84
+ SELECT
85
+ id,
86
+ project_slug as "projectSlug",
87
+ page_url as "pageUrl",
88
+ x,
89
+ y,
90
+ message,
91
+ name,
92
+ email,
93
+ session_id as "sessionId",
94
+ user_agent as "userAgent",
95
+ ip_address as "ipAddress",
96
+ created_at as "createdAt"
97
+ FROM fi_feedback
98
+ WHERE project_slug = ${projectSlug}
99
+ AND page_url = ${pageUrl}
100
+ ORDER BY created_at DESC
101
+ `;
102
+ const feedback = rows.map((row) => ({
103
+ ...row,
104
+ createdAt: new Date(row.createdAt)
105
+ }));
106
+ if (feedback.length > 0) {
107
+ const feedbackIds = feedback.map((f) => f.id);
108
+ const reactionsMap = await getReactionsForFeedback(
109
+ sql,
110
+ feedbackIds,
111
+ sessionId
112
+ );
113
+ for (const item of feedback) {
114
+ item.reactions = reactionsMap.get(item.id) || [];
115
+ }
116
+ }
117
+ return feedback;
118
+ }
119
+ async function deleteFeedback(sql, id) {
120
+ await sql`
121
+ DELETE FROM fi_feedback
122
+ WHERE id = ${id}
123
+ `;
124
+ return true;
125
+ }
126
+ async function isRateLimited(sql, sessionId) {
127
+ const windowStart = new Date(
128
+ Date.now() - RATE_LIMIT_WINDOW_SECONDS * 1e3
129
+ ).toISOString();
130
+ const rows = await sql`
131
+ SELECT COUNT(*)::int AS count
132
+ FROM fi_feedback
133
+ WHERE session_id = ${sessionId}
134
+ AND created_at > ${windowStart}::timestamptz
135
+ `;
136
+ const count = rows[0].count;
137
+ return count >= RATE_LIMIT_MAX;
138
+ }
139
+ async function getReactionsForFeedback(sql, feedbackIds, currentSessionId) {
140
+ if (feedbackIds.length === 0) {
141
+ return /* @__PURE__ */ new Map();
142
+ }
143
+ const rows = await sql`
144
+ SELECT
145
+ feedback_id as "feedbackId",
146
+ reaction,
147
+ COUNT(*)::int as count,
148
+ BOOL_OR(session_id = ${currentSessionId}) as "hasReacted"
149
+ FROM fi_feedback_reactions
150
+ WHERE feedback_id = ANY(${feedbackIds})
151
+ GROUP BY feedback_id, reaction
152
+ ORDER BY count DESC
153
+ `;
154
+ const map = /* @__PURE__ */ new Map();
155
+ for (const row of rows) {
156
+ const typed = row;
157
+ if (!map.has(typed.feedbackId)) {
158
+ map.set(typed.feedbackId, []);
159
+ }
160
+ map.get(typed.feedbackId).push({
161
+ reaction: typed.reaction,
162
+ count: typed.count,
163
+ hasReacted: typed.hasReacted
164
+ });
165
+ }
166
+ return map;
167
+ }
168
+ async function toggleReaction(sql, feedbackId, reaction, sessionId) {
169
+ const existing = await sql`
170
+ SELECT id
171
+ FROM fi_feedback_reactions
172
+ WHERE feedback_id = ${feedbackId}
173
+ AND reaction = ${reaction}
174
+ AND session_id = ${sessionId}
175
+ `;
176
+ if (existing.length > 0) {
177
+ await sql`
178
+ DELETE FROM fi_feedback_reactions
179
+ WHERE feedback_id = ${feedbackId}
180
+ AND reaction = ${reaction}
181
+ AND session_id = ${sessionId}
182
+ `;
183
+ return false;
184
+ } else {
185
+ await sql`
186
+ INSERT INTO fi_feedback_reactions (feedback_id, reaction, session_id)
187
+ VALUES (${feedbackId}, ${reaction}, ${sessionId})
188
+ ON CONFLICT (feedback_id, reaction, session_id) DO NOTHING
189
+ `;
190
+ return true;
191
+ }
192
+ }
193
+ async function updateFeedbackPosition(sql, feedbackId, x, y) {
194
+ await sql`
195
+ UPDATE fi_feedback
196
+ SET x = ${x}, y = ${y}
197
+ WHERE id = ${feedbackId}
198
+ `;
199
+ return true;
200
+ }
201
+
202
+ // src/server/route-handler.ts
203
+ function getIpAddress(request) {
204
+ const forwarded = request.headers.get("x-forwarded-for");
205
+ if (forwarded) {
206
+ return forwarded.split(",")[0].trim();
207
+ }
208
+ return request.headers.get("x-real-ip") ?? void 0;
209
+ }
210
+ function createFeedbackRouteHandler() {
211
+ const GET = async (request) => {
212
+ const databaseUrl = process.env.DATABASE_URL;
213
+ if (!databaseUrl) {
214
+ console.error("[fi-edback] DATABASE_URL is not set");
215
+ return Response.json({ error: "Server misconfigured" }, { status: 500 });
216
+ }
217
+ const url = new URL(request.url);
218
+ const projectSlug = url.searchParams.get("projectSlug");
219
+ const pageUrl = url.searchParams.get("pageUrl");
220
+ const sessionId = url.searchParams.get("sessionId") || "";
221
+ if (!projectSlug || !pageUrl) {
222
+ return Response.json(
223
+ { error: "Missing projectSlug or pageUrl query parameter" },
224
+ { status: 400 }
225
+ );
226
+ }
227
+ const sql = await getNeonClient(databaseUrl);
228
+ try {
229
+ const feedback = await getFeedbackForPage(
230
+ sql,
231
+ projectSlug,
232
+ pageUrl,
233
+ sessionId
234
+ );
235
+ return Response.json({ feedback });
236
+ } catch (error) {
237
+ console.error("[fi-edback] Database error:", error);
238
+ return Response.json(
239
+ { error: "Failed to fetch feedback" },
240
+ { status: 500 }
241
+ );
242
+ }
243
+ };
244
+ const POST = async (request) => {
245
+ const databaseUrl = process.env.DATABASE_URL;
246
+ if (!databaseUrl) {
247
+ console.error("[fi-edback] DATABASE_URL is not set");
248
+ return Response.json({ error: "Server misconfigured" }, { status: 500 });
249
+ }
250
+ let body;
251
+ try {
252
+ body = await request.json();
253
+ } catch {
254
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
255
+ }
256
+ const parsed = getFeedbackSchema().safeParse(body);
257
+ if (!parsed.success) {
258
+ return Response.json(
259
+ {
260
+ error: "Validation failed",
261
+ issues: parsed.error.flatten().fieldErrors
262
+ },
263
+ { status: 400 }
264
+ );
265
+ }
266
+ if (body.website !== "") {
267
+ return Response.json({ ok: true });
268
+ }
269
+ const sql = await getNeonClient(databaseUrl);
270
+ try {
271
+ const limited = await isRateLimited(sql, parsed.data.sessionId);
272
+ if (limited) {
273
+ return Response.json(
274
+ { error: "Too many submissions \u2014 please wait before trying again." },
275
+ { status: 429 }
276
+ );
277
+ }
278
+ const feedback = await insertFeedback(sql, {
279
+ projectSlug: parsed.data.projectSlug,
280
+ pageUrl: parsed.data.pageUrl,
281
+ x: parsed.data.x,
282
+ y: parsed.data.y,
283
+ message: parsed.data.message,
284
+ name: parsed.data.name,
285
+ email: parsed.data.email || void 0,
286
+ sessionId: parsed.data.sessionId,
287
+ userAgent: request.headers.get("user-agent") ?? void 0,
288
+ ipAddress: getIpAddress(request)
289
+ });
290
+ return Response.json({ feedback });
291
+ } catch (error) {
292
+ console.error("[fi-edback] Database error:", error);
293
+ return Response.json(
294
+ { error: "Failed to save feedback" },
295
+ { status: 500 }
296
+ );
297
+ }
298
+ };
299
+ const PATCH = async (request) => {
300
+ const databaseUrl = process.env.DATABASE_URL;
301
+ if (!databaseUrl) {
302
+ console.error("[fi-edback] DATABASE_URL is not set");
303
+ return Response.json({ error: "Server misconfigured" }, { status: 500 });
304
+ }
305
+ let body;
306
+ try {
307
+ body = await request.json();
308
+ } catch {
309
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
310
+ }
311
+ const bodyObj = body;
312
+ const sql = await getNeonClient(databaseUrl);
313
+ if (typeof bodyObj.x === "number" && typeof bodyObj.y === "number") {
314
+ const { feedbackId: feedbackId2, x, y } = bodyObj;
315
+ if (!feedbackId2) {
316
+ return Response.json({ error: "Missing feedbackId" }, { status: 400 });
317
+ }
318
+ try {
319
+ await updateFeedbackPosition(sql, feedbackId2, x, y);
320
+ return Response.json({ ok: true });
321
+ } catch (error) {
322
+ console.error("[fi-edback] Database error:", error);
323
+ return Response.json(
324
+ { error: "Failed to update position" },
325
+ { status: 500 }
326
+ );
327
+ }
328
+ }
329
+ const { feedbackId, reaction, sessionId } = bodyObj;
330
+ if (!feedbackId || !reaction || !sessionId) {
331
+ return Response.json(
332
+ { error: "Missing feedbackId, reaction, or sessionId" },
333
+ { status: 400 }
334
+ );
335
+ }
336
+ try {
337
+ const added = await toggleReaction(sql, feedbackId, reaction, sessionId);
338
+ return Response.json({ added });
339
+ } catch (error) {
340
+ console.error("[fi-edback] Database error:", error);
341
+ return Response.json(
342
+ { error: "Failed to toggle reaction" },
343
+ { status: 500 }
344
+ );
345
+ }
346
+ };
347
+ const DELETE = async (request) => {
348
+ const databaseUrl = process.env.DATABASE_URL;
349
+ if (!databaseUrl) {
350
+ console.error("[fi-edback] DATABASE_URL is not set");
351
+ return Response.json({ error: "Server misconfigured" }, { status: 500 });
352
+ }
353
+ const url = new URL(request.url);
354
+ const id = url.searchParams.get("id");
355
+ if (!id) {
356
+ return Response.json(
357
+ { error: "Missing id query parameter" },
358
+ { status: 400 }
359
+ );
360
+ }
361
+ const sql = await getNeonClient(databaseUrl);
362
+ try {
363
+ await deleteFeedback(sql, id);
364
+ return Response.json({ ok: true });
365
+ } catch (error) {
366
+ console.error("[fi-edback] Database error:", error);
367
+ return Response.json(
368
+ { error: "Failed to delete feedback" },
369
+ { status: 500 }
370
+ );
371
+ }
372
+ };
373
+ return { GET, POST, PATCH, DELETE };
374
+ }
375
+ export {
376
+ createFeedbackRouteHandler
377
+ };
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "fi-edback",
3
+ "version": "0.2.0",
4
+ "description": "Reusable visual feedback widget for Next.js Vercel preview deployments",
5
+ "author": "Studio Fi<fi@studio-fi.com>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/studiofi/fi-edback.git"
9
+ },
10
+ "keywords": [
11
+ "nextjs",
12
+ "feedback",
13
+ "preview",
14
+ "widget",
15
+ "visual-feedback"
16
+ ],
17
+ "license": "MIT",
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.cjs",
23
+ "module": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "require": "./dist/index.cjs",
29
+ "import": "./dist/index.js",
30
+ "default": "./dist/index.cjs"
31
+ },
32
+ "./client": {
33
+ "types": "./dist/client.d.ts",
34
+ "require": "./dist/client.cjs",
35
+ "import": "./dist/client.js",
36
+ "default": "./dist/client.cjs"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "pack": "npm pack",
42
+ "dev": "cd dev && npm run dev",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "dependencies": {
46
+ "@neondatabase/serverless": "^0.9.0",
47
+ "zod": "~3.23.8"
48
+ },
49
+ "overrides": {
50
+ "zod": "~3.23.8"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^20.19.43",
54
+ "@types/react": "^19.2.17",
55
+ "@types/react-dom": "^19.2.3",
56
+ "tsup": "^8.0.0",
57
+ "typescript": "^5"
58
+ },
59
+ "peerDependencies": {
60
+ "next": ">=15.0.0",
61
+ "react": ">=19.0.0",
62
+ "react-dom": ">=19.0.0"
63
+ }
64
+ }