@cyguin/feedback 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.
Files changed (46) hide show
  1. package/.github/workflows/publish.yml +27 -0
  2. package/LICENSE +7 -0
  3. package/README.md +136 -0
  4. package/dist/adapters/in-memory.cjs +6 -0
  5. package/dist/adapters/in-memory.d.cts +23 -0
  6. package/dist/adapters/in-memory.d.ts +23 -0
  7. package/dist/adapters/in-memory.js +6 -0
  8. package/dist/adapters/supabase.cjs +6 -0
  9. package/dist/adapters/supabase.d.cts +27 -0
  10. package/dist/adapters/supabase.d.ts +27 -0
  11. package/dist/adapters/supabase.js +6 -0
  12. package/dist/chunk-3CF2TRVK.js +202 -0
  13. package/dist/chunk-5Q4LNXS7.cjs +46 -0
  14. package/dist/chunk-CEOUAA46.cjs +202 -0
  15. package/dist/chunk-F4UO2IK4.cjs +74 -0
  16. package/dist/chunk-IZIGPLKR.js +74 -0
  17. package/dist/chunk-KXORPGTI.js +57 -0
  18. package/dist/chunk-NKCO24NR.js +46 -0
  19. package/dist/chunk-SRH4NGAR.cjs +57 -0
  20. package/dist/index.cjs +18 -0
  21. package/dist/index.d.cts +8 -0
  22. package/dist/index.d.ts +8 -0
  23. package/dist/index.js +18 -0
  24. package/dist/react.cjs +6 -0
  25. package/dist/react.d.cts +7 -0
  26. package/dist/react.d.ts +7 -0
  27. package/dist/react.js +6 -0
  28. package/dist/server.cjs +6 -0
  29. package/dist/server.d.cts +20 -0
  30. package/dist/server.d.ts +20 -0
  31. package/dist/server.js +6 -0
  32. package/dist/types-CMsn8uhD.d.cts +49 -0
  33. package/dist/types-CMsn8uhD.d.ts +49 -0
  34. package/package.json +66 -0
  35. package/src/adapters/in-memory.ts +58 -0
  36. package/src/adapters/index.ts +2 -0
  37. package/src/adapters/supabase.ts +88 -0
  38. package/src/app/api/feedback/[...cyguin]/route.ts +20 -0
  39. package/src/components/FeedbackWidget.tsx +215 -0
  40. package/src/components/index.ts +2 -0
  41. package/src/handlers/route.ts +100 -0
  42. package/src/index.ts +11 -0
  43. package/src/server.ts +1 -0
  44. package/src/types.ts +50 -0
  45. package/tsconfig.json +18 -0
  46. package/tsup.config.ts +16 -0
@@ -0,0 +1,49 @@
1
+ type FeedbackType = 'thumbs' | 'rating' | 'text';
2
+ interface FeedbackRecord {
3
+ id: string;
4
+ userId?: string;
5
+ type: FeedbackType;
6
+ body: string;
7
+ url: string;
8
+ createdAt: number;
9
+ reviewed: boolean;
10
+ }
11
+ interface FeedbackAdapter {
12
+ list(opts?: {
13
+ url?: string;
14
+ reviewed?: boolean;
15
+ limit?: number;
16
+ }): Promise<{
17
+ data: FeedbackRecord[];
18
+ total: number;
19
+ }>;
20
+ create(record: {
21
+ userId?: string;
22
+ type: FeedbackType;
23
+ body: string;
24
+ url: string;
25
+ createdAt: number;
26
+ }): Promise<FeedbackRecord>;
27
+ setReviewed(id: string, reviewed: boolean): Promise<FeedbackRecord>;
28
+ }
29
+ interface FeedbackWidgetProps {
30
+ type: 'thumbs' | 'rating' | 'text';
31
+ url?: string;
32
+ userId?: string;
33
+ theme?: 'light' | 'dark';
34
+ className?: string;
35
+ onSubmit?: (feedback: FeedbackData) => void;
36
+ maxStars?: number;
37
+ placeholder?: string;
38
+ }
39
+ interface FeedbackData {
40
+ id: string;
41
+ userId?: string;
42
+ type: FeedbackType;
43
+ body: string;
44
+ url: string;
45
+ createdAt: number;
46
+ reviewed: boolean;
47
+ }
48
+
49
+ export type { FeedbackAdapter as F, FeedbackData as a, FeedbackRecord as b, FeedbackType as c, FeedbackWidgetProps as d };
@@ -0,0 +1,49 @@
1
+ type FeedbackType = 'thumbs' | 'rating' | 'text';
2
+ interface FeedbackRecord {
3
+ id: string;
4
+ userId?: string;
5
+ type: FeedbackType;
6
+ body: string;
7
+ url: string;
8
+ createdAt: number;
9
+ reviewed: boolean;
10
+ }
11
+ interface FeedbackAdapter {
12
+ list(opts?: {
13
+ url?: string;
14
+ reviewed?: boolean;
15
+ limit?: number;
16
+ }): Promise<{
17
+ data: FeedbackRecord[];
18
+ total: number;
19
+ }>;
20
+ create(record: {
21
+ userId?: string;
22
+ type: FeedbackType;
23
+ body: string;
24
+ url: string;
25
+ createdAt: number;
26
+ }): Promise<FeedbackRecord>;
27
+ setReviewed(id: string, reviewed: boolean): Promise<FeedbackRecord>;
28
+ }
29
+ interface FeedbackWidgetProps {
30
+ type: 'thumbs' | 'rating' | 'text';
31
+ url?: string;
32
+ userId?: string;
33
+ theme?: 'light' | 'dark';
34
+ className?: string;
35
+ onSubmit?: (feedback: FeedbackData) => void;
36
+ maxStars?: number;
37
+ placeholder?: string;
38
+ }
39
+ interface FeedbackData {
40
+ id: string;
41
+ userId?: string;
42
+ type: FeedbackType;
43
+ body: string;
44
+ url: string;
45
+ createdAt: number;
46
+ reviewed: boolean;
47
+ }
48
+
49
+ export type { FeedbackAdapter as F, FeedbackData as a, FeedbackRecord as b, FeedbackType as c, FeedbackWidgetProps as d };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@cyguin/feedback",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in user feedback widget for Next.js \u2014 thumbs, rating, or text",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/cyguin/feedback"
8
+ },
9
+ "type": "module",
10
+ "main": "dist/index.js",
11
+ "module": "dist/index.mjs",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ },
17
+ "./server": {
18
+ "import": "./dist/server.js",
19
+ "types": "./dist/server.d.ts"
20
+ },
21
+ "./react": {
22
+ "import": "./dist/components/index.js",
23
+ "types": "./dist/components/index.d.ts"
24
+ },
25
+ "./adapters/in-memory": {
26
+ "import": "./dist/adapters/in-memory.js",
27
+ "types": "./dist/adapters/in-memory.d.ts"
28
+ },
29
+ "./adapters/supabase": {
30
+ "import": "./dist/adapters/supabase.js",
31
+ "types": "./dist/adapters/supabase.d.ts"
32
+ }
33
+ },
34
+ "types": "dist/index.d.ts",
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "dev": "tsup --watch"
38
+ },
39
+ "peerDependencies": {
40
+ "next": ">=14",
41
+ "react": ">=18"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@supabase/supabase-js": {
45
+ "optional": true
46
+ },
47
+ "next": {
48
+ "optional": true
49
+ },
50
+ "react": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@supabase/supabase-js": "^2.0.0",
56
+ "@types/node": "^20.0.0",
57
+ "@types/react": "^18.0.0",
58
+ "next": "^14.0.0",
59
+ "react": "^18.3.1",
60
+ "tsup": "^8.3.0",
61
+ "typescript": "^5.5.0"
62
+ },
63
+ "dependencies": {
64
+ "nanoid": "^5.0.7"
65
+ }
66
+ }
@@ -0,0 +1,58 @@
1
+ import type { FeedbackAdapter, FeedbackRecord } from '../types.js';
2
+ import { nanoid } from 'nanoid';
3
+
4
+ export class InMemoryFeedbackAdapter implements FeedbackAdapter {
5
+ private store: Map<string, FeedbackRecord> = new Map();
6
+
7
+ async list(opts?: {
8
+ url?: string;
9
+ reviewed?: boolean;
10
+ limit?: number;
11
+ }): Promise<{ data: FeedbackRecord[]; total: number }> {
12
+ let records = Array.from(this.store.values());
13
+
14
+ if (opts?.url !== undefined) {
15
+ records = records.filter((r) => r.url === opts.url);
16
+ }
17
+ if (opts?.reviewed !== undefined) {
18
+ records = records.filter((r) => r.reviewed === opts.reviewed);
19
+ }
20
+
21
+ records.sort((a, b) => b.createdAt - a.createdAt);
22
+
23
+ if (opts?.limit !== undefined) {
24
+ records = records.slice(0, opts.limit);
25
+ }
26
+
27
+ return { data: records, total: records.length };
28
+ }
29
+
30
+ async create(record: {
31
+ userId?: string;
32
+ type: import('../types.js').FeedbackType;
33
+ body: string;
34
+ url: string;
35
+ createdAt: number;
36
+ }): Promise<FeedbackRecord> {
37
+ const feedbackRecord: FeedbackRecord = {
38
+ id: nanoid(),
39
+ userId: record.userId,
40
+ type: record.type,
41
+ body: record.body,
42
+ url: record.url,
43
+ createdAt: record.createdAt,
44
+ reviewed: false,
45
+ };
46
+ this.store.set(feedbackRecord.id, feedbackRecord);
47
+ return feedbackRecord;
48
+ }
49
+
50
+ async setReviewed(id: string, reviewed: boolean): Promise<FeedbackRecord> {
51
+ const record = this.store.get(id);
52
+ if (!record) {
53
+ throw new Error(`Feedback record ${id} not found`);
54
+ }
55
+ record.reviewed = reviewed;
56
+ return record;
57
+ }
58
+ }
@@ -0,0 +1,2 @@
1
+ export { InMemoryFeedbackAdapter } from './in-memory.js';
2
+ export { SupabaseFeedbackAdapter } from './supabase.js';
@@ -0,0 +1,88 @@
1
+ import type { FeedbackAdapter, FeedbackRecord } from '../types.js';
2
+ import type { SupabaseClient } from '@supabase/supabase-js';
3
+ import { nanoid } from 'nanoid';
4
+
5
+ export class SupabaseFeedbackAdapter implements FeedbackAdapter {
6
+ private client: SupabaseClient;
7
+ private tableName: string;
8
+
9
+ constructor(client: SupabaseClient, tableName = 'feedback') {
10
+ this.client = client;
11
+ this.tableName = tableName;
12
+ }
13
+
14
+ private mapRow(row: Record<string, unknown>): FeedbackRecord {
15
+ return {
16
+ id: row.id as string,
17
+ userId: row.user_id as string | undefined,
18
+ type: row.type as import('../types.js').FeedbackType,
19
+ body: row.body as string,
20
+ url: row.url as string,
21
+ createdAt: row.created_at as number,
22
+ reviewed: Boolean(row.reviewed),
23
+ };
24
+ }
25
+
26
+ async list(opts?: {
27
+ url?: string;
28
+ reviewed?: boolean;
29
+ limit?: number;
30
+ }): Promise<{ data: FeedbackRecord[]; total: number }> {
31
+ let query = this.client.from(this.tableName).select('*', { count: 'exact' });
32
+
33
+ if (opts?.url !== undefined) {
34
+ query = query.eq('url', opts.url);
35
+ }
36
+ if (opts?.reviewed !== undefined) {
37
+ query = query.eq('reviewed', opts.reviewed ? 1 : 0);
38
+ }
39
+
40
+ query = query.order('created_at', { ascending: false });
41
+
42
+ if (opts?.limit !== undefined) {
43
+ query = query.limit(opts.limit);
44
+ }
45
+
46
+ const { data, count, error } = await query;
47
+
48
+ if (error) throw error;
49
+ return { data: (data ?? []).map((r: Record<string, unknown>) => this.mapRow(r)), total: count ?? 0 };
50
+ }
51
+
52
+ async create(record: {
53
+ userId?: string;
54
+ type: import('../types.js').FeedbackType;
55
+ body: string;
56
+ url: string;
57
+ createdAt: number;
58
+ }): Promise<FeedbackRecord> {
59
+ const { data, error } = await this.client
60
+ .from(this.tableName)
61
+ .insert({
62
+ id: nanoid(),
63
+ user_id: record.userId ?? null,
64
+ type: record.type,
65
+ body: record.body,
66
+ url: record.url,
67
+ created_at: record.createdAt,
68
+ reviewed: 0,
69
+ })
70
+ .select()
71
+ .single();
72
+
73
+ if (error) throw error;
74
+ return this.mapRow(data as Record<string, unknown>);
75
+ }
76
+
77
+ async setReviewed(id: string, reviewed: boolean): Promise<FeedbackRecord> {
78
+ const { data, error } = await this.client
79
+ .from(this.tableName)
80
+ .update({ reviewed: reviewed ? 1 : 0 })
81
+ .eq('id', id)
82
+ .select()
83
+ .single();
84
+
85
+ if (error) throw error;
86
+ return this.mapRow(data as Record<string, unknown>);
87
+ }
88
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { createFeedbackHandler } from '../../handlers/route.js';
3
+ import { InMemoryFeedbackAdapter } from '../../adapters/in-memory.js';
4
+
5
+ const adapter = new InMemoryFeedbackAdapter();
6
+ const secret = process.env.FEEDBACK_SECRET ?? '';
7
+
8
+ const handler = createFeedbackHandler({ adapter, secret });
9
+
10
+ export async function GET(req: NextRequest) {
11
+ return handler(req);
12
+ }
13
+
14
+ export async function POST(req: NextRequest) {
15
+ return handler(req);
16
+ }
17
+
18
+ export async function PATCH(req: NextRequest) {
19
+ return handler(req);
20
+ }
@@ -0,0 +1,215 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+ import type { FeedbackWidgetProps, FeedbackData } from '../types.js';
5
+
6
+ export type { FeedbackWidgetProps, FeedbackData };
7
+
8
+ const THUMBS_UP = 'thumbs_up';
9
+ const THUMBS_DOWN = 'thumbs_down';
10
+
11
+ function ThumbsUpIcon() {
12
+ return (
13
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
14
+ <path d="M7 10v12" />
15
+ <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
16
+ </svg>
17
+ );
18
+ }
19
+
20
+ function ThumbsDownIcon() {
21
+ return (
22
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
23
+ <path d="M17 14V2" />
24
+ <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" />
25
+ </svg>
26
+ );
27
+ }
28
+
29
+ function StarIcon({ filled }: { filled: boolean }) {
30
+ return (
31
+ <svg width="20" height="20" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
32
+ <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
33
+ </svg>
34
+ );
35
+ }
36
+
37
+ const lightTheme = {
38
+ '--cyguin-bg': '#ffffff',
39
+ '--cyguin-bg-subtle': '#f5f5f5',
40
+ '--cyguin-border': '#e5e5e5',
41
+ '--cyguin-border-focus': '#f5a800',
42
+ '--cyguin-fg': '#0a0a0a',
43
+ '--cyguin-fg-muted': '#888888',
44
+ '--cyguin-accent': '#f5a800',
45
+ '--cyguin-accent-dark': '#c47f00',
46
+ '--cyguin-accent-fg': '#0a0a0a',
47
+ '--cyguin-radius': '6px',
48
+ '--cyguin-shadow': '0 1px 4px rgba(0,0,0,0.08)',
49
+ };
50
+
51
+ const darkTheme = {
52
+ '--cyguin-bg': '#0a0a0a',
53
+ '--cyguin-bg-subtle': '#1a1a1a',
54
+ '--cyguin-border': '#2a2a2a',
55
+ '--cyguin-border-focus': '#f5a800',
56
+ '--cyguin-fg': '#f5f5f5',
57
+ '--cyguin-fg-muted': '#888888',
58
+ '--cyguin-accent': '#f5a800',
59
+ '--cyguin-accent-dark': '#c47f00',
60
+ '--cyguin-accent-fg': '#0a0a0a',
61
+ '--cyguin-radius': '6px',
62
+ '--cyguin-shadow': '0 1px 4px rgba(0,0,0,0.4)',
63
+ };
64
+
65
+ export function FeedbackWidget({
66
+ type,
67
+ url: urlProp,
68
+ userId,
69
+ theme = 'light',
70
+ className = '',
71
+ onSubmit,
72
+ maxStars = 5,
73
+ placeholder = 'Share your feedback...',
74
+ }: FeedbackWidgetProps) {
75
+ const [submitted, setSubmitted] = useState(false);
76
+ const [textValue, setTextValue] = useState('');
77
+ const [loading, setLoading] = useState(false);
78
+
79
+ const getUrl = () => {
80
+ if (urlProp) return urlProp;
81
+ if (typeof window !== 'undefined') return window.location.href;
82
+ return '';
83
+ };
84
+
85
+ const handleSubmit = useCallback(
86
+ async (body: string) => {
87
+ setLoading(true);
88
+ try {
89
+ const res = await fetch('/api/feedback', {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({ type, body, url: getUrl(), userId }),
93
+ });
94
+ const json = await res.json();
95
+ if (res.ok && json.data) {
96
+ setSubmitted(true);
97
+ onSubmit?.(json.data);
98
+ setTimeout(() => setSubmitted(false), 3000);
99
+ }
100
+ } finally {
101
+ setLoading(false);
102
+ }
103
+ },
104
+ [type, userId, onSubmit],
105
+ );
106
+
107
+ if (submitted) {
108
+ return (
109
+ <div
110
+ data-theme={theme}
111
+ className={className}
112
+ style={{ ...(theme === 'dark' ? darkTheme : lightTheme), padding: '12px', borderRadius: 'var(--cyguin-radius)', background: 'var(--cyguin-bg)', border: '1px solid var(--cyguin-border)', boxShadow: 'var(--cyguin-shadow)', fontFamily: 'system-ui, sans-serif', color: 'var(--cyguin-fg)', textAlign: 'center' }}
113
+ >
114
+ Thanks for your feedback!
115
+ </div>
116
+ );
117
+ }
118
+
119
+ if (type === 'thumbs') {
120
+ return (
121
+ <div
122
+ data-theme={theme}
123
+ className={className}
124
+ style={{ ...(theme === 'dark' ? darkTheme : lightTheme), display: 'flex', gap: '8px', padding: '12px', borderRadius: 'var(--cyguin-radius)', background: 'var(--cyguin-bg)', border: '1px solid var(--cyguin-border)', boxShadow: 'var(--cyguin-shadow)', fontFamily: 'system-ui, sans-serif' }}
125
+ >
126
+ <button
127
+ onClick={() => handleSubmit(THUMBS_UP)}
128
+ disabled={loading}
129
+ aria-label="Thumbs up"
130
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cyguin-fg)', padding: '4px', borderRadius: 'var(--cyguin-radius)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
131
+ >
132
+ <ThumbsUpIcon />
133
+ </button>
134
+ <button
135
+ onClick={() => handleSubmit(THUMBS_DOWN)}
136
+ disabled={loading}
137
+ aria-label="Thumbs down"
138
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--cyguin-fg)', padding: '4px', borderRadius: 'var(--cyguin-radius)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
139
+ >
140
+ <ThumbsDownIcon />
141
+ </button>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ if (type === 'rating') {
147
+ const [selected, setSelected] = useState<number | null>(null);
148
+
149
+ const handleStarClick = (star: number) => {
150
+ setSelected(star);
151
+ handleSubmit(String(star));
152
+ };
153
+
154
+ return (
155
+ <div
156
+ data-theme={theme}
157
+ className={className}
158
+ style={{ ...(theme === 'dark' ? darkTheme : lightTheme), display: 'flex', gap: '4px', padding: '12px', borderRadius: 'var(--cyguin-radius)', background: 'var(--cyguin-bg)', border: '1px solid var(--cyguin-border)', boxShadow: 'var(--cyguin-shadow)', fontFamily: 'system-ui, sans-serif' }}
159
+ >
160
+ {Array.from({ length: maxStars }, (_, i) => {
161
+ const star = i + 1;
162
+ const filled = selected !== null && star <= selected;
163
+ return (
164
+ <button
165
+ key={star}
166
+ onClick={() => handleStarClick(star)}
167
+ disabled={loading}
168
+ aria-label={`${star} star${star > 1 ? 's' : ''}`}
169
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: filled ? 'var(--cyguin-accent)' : 'var(--cyguin-fg-muted)', padding: '2px', borderRadius: 'var(--cyguin-radius)', display: 'flex', alignItems: 'center', transition: 'color 0.15s' }}
170
+ >
171
+ <StarIcon filled={filled} />
172
+ </button>
173
+ );
174
+ })}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ if (type === 'text') {
180
+ const handleTextSubmit = (e: React.FormEvent) => {
181
+ e.preventDefault();
182
+ if (textValue.trim()) {
183
+ handleSubmit(textValue.trim());
184
+ setTextValue('');
185
+ }
186
+ };
187
+
188
+ return (
189
+ <div
190
+ data-theme={theme}
191
+ className={className}
192
+ style={{ ...(theme === 'dark' ? darkTheme : lightTheme), padding: '12px', borderRadius: 'var(--cyguin-radius)', background: 'var(--cyguin-bg)', border: '1px solid var(--cyguin-border)', boxShadow: 'var(--cyguin-shadow)', fontFamily: 'system-ui, sans-serif' }}
193
+ >
194
+ <form onSubmit={handleTextSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
195
+ <textarea
196
+ value={textValue}
197
+ onChange={(e) => setTextValue(e.target.value)}
198
+ placeholder={placeholder}
199
+ rows={3}
200
+ style={{ width: '100%', padding: '8px', borderRadius: 'var(--cyguin-radius)', border: '1px solid var(--cyguin-border)', background: 'var(--cyguin-bg-subtle)', color: 'var(--cyguin-fg)', fontSize: '14px', resize: 'vertical', outline: 'none', boxSizing: 'border-box', fontFamily: 'inherit' }}
201
+ />
202
+ <button
203
+ type="submit"
204
+ disabled={loading || !textValue.trim()}
205
+ style={{ alignSelf: 'flex-end', padding: '6px 16px', borderRadius: 'var(--cyguin-radius)', border: 'none', background: 'var(--cyguin-accent)', color: 'var(--cyguin-accent-fg)', fontWeight: 600, fontSize: '14px', cursor: 'pointer', opacity: loading || !textValue.trim() ? 0.5 : 1 }}
206
+ >
207
+ {loading ? 'Sending...' : 'Submit'}
208
+ </button>
209
+ </form>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ return null;
215
+ }
@@ -0,0 +1,2 @@
1
+ export { FeedbackWidget } from './FeedbackWidget.js';
2
+ export type { FeedbackWidgetProps, FeedbackData } from './FeedbackWidget.js';
@@ -0,0 +1,100 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import type { FeedbackAdapter, FeedbackType } from '../types.js';
3
+
4
+ interface HandlerOptions {
5
+ adapter: FeedbackAdapter;
6
+ secret: string;
7
+ }
8
+
9
+ function parseJsonBody<T>(req: NextRequest): Promise<T> {
10
+ return req.json();
11
+ }
12
+
13
+ function bearerToken(req: NextRequest, secret: string): boolean {
14
+ const auth = req.headers.get('Authorization') ?? '';
15
+ return auth === `Bearer ${secret}`;
16
+ }
17
+
18
+ export function createFeedbackHandler({ adapter, secret }: HandlerOptions) {
19
+ return async function handler(req: NextRequest) {
20
+ const url = req.nextUrl.clone();
21
+ const pathname = url.pathname;
22
+ const segments = pathname.split('/').filter(Boolean);
23
+ const cyguinIndex = segments.indexOf('cyguin');
24
+
25
+ if (cyguinIndex === -1) {
26
+ return NextResponse.json({ error: 'Invalid route' }, { status: 400 });
27
+ }
28
+
29
+ const remaining = segments.slice(cyguinIndex + 1);
30
+
31
+ try {
32
+ if (req.method === 'GET') {
33
+ if (!bearerToken(req, secret)) {
34
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
35
+ }
36
+
37
+ const urlParam = url.searchParams.get('url') ?? undefined;
38
+ const reviewedParam = url.searchParams.get('reviewed');
39
+ const limitParam = url.searchParams.get('limit');
40
+
41
+ const reviewed =
42
+ reviewedParam !== null
43
+ ? reviewedParam === '1' || reviewedParam === 'true'
44
+ : undefined;
45
+ const limit = limitParam !== null ? parseInt(limitParam, 10) : undefined;
46
+
47
+ const result = await adapter.list({ url: urlParam, reviewed, limit });
48
+ return NextResponse.json({ data: result.data, total: result.total });
49
+ }
50
+
51
+ if (req.method === 'POST') {
52
+ if (!bearerToken(req, secret)) {
53
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
54
+ }
55
+
56
+ const body = await parseJsonBody<{
57
+ type: FeedbackType;
58
+ body: string;
59
+ url: string;
60
+ userId?: string;
61
+ }>(req);
62
+
63
+ if (!body.type || !body.body || !body.url) {
64
+ return NextResponse.json(
65
+ { error: 'type, body, and url are required' },
66
+ { status: 400 }
67
+ );
68
+ }
69
+
70
+ const record = await adapter.create({
71
+ type: body.type,
72
+ body: body.body,
73
+ url: body.url,
74
+ userId: body.userId,
75
+ createdAt: Date.now(),
76
+ });
77
+ return NextResponse.json({ data: record }, { status: 201 });
78
+ }
79
+
80
+ if (req.method === 'PATCH') {
81
+ if (!bearerToken(req, secret)) {
82
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
83
+ }
84
+
85
+ if (remaining.length < 1) {
86
+ return NextResponse.json({ error: 'id is required' }, { status: 400 });
87
+ }
88
+
89
+ const id = remaining[0];
90
+ await adapter.setReviewed(id, true);
91
+ return NextResponse.json({ data: { id, reviewed: true } });
92
+ }
93
+
94
+ return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : 'Unknown error';
97
+ return NextResponse.json({ error: message }, { status: 500 });
98
+ }
99
+ };
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type {
2
+ FeedbackAdapter,
3
+ FeedbackRecord,
4
+ FeedbackType,
5
+ } from './types.js';
6
+
7
+ export { createFeedbackHandler } from './server.js';
8
+ export { InMemoryFeedbackAdapter, SupabaseFeedbackAdapter } from './adapters/index.js';
9
+
10
+ export { FeedbackWidget } from './components/FeedbackWidget.js';
11
+ export type { FeedbackWidgetProps, FeedbackData } from './components/FeedbackWidget.js';
package/src/server.ts ADDED
@@ -0,0 +1 @@
1
+ export { createFeedbackHandler } from './handlers/route.js';