@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.
- package/.github/workflows/publish.yml +27 -0
- package/LICENSE +7 -0
- package/README.md +136 -0
- package/dist/adapters/in-memory.cjs +6 -0
- package/dist/adapters/in-memory.d.cts +23 -0
- package/dist/adapters/in-memory.d.ts +23 -0
- package/dist/adapters/in-memory.js +6 -0
- package/dist/adapters/supabase.cjs +6 -0
- package/dist/adapters/supabase.d.cts +27 -0
- package/dist/adapters/supabase.d.ts +27 -0
- package/dist/adapters/supabase.js +6 -0
- package/dist/chunk-3CF2TRVK.js +202 -0
- package/dist/chunk-5Q4LNXS7.cjs +46 -0
- package/dist/chunk-CEOUAA46.cjs +202 -0
- package/dist/chunk-F4UO2IK4.cjs +74 -0
- package/dist/chunk-IZIGPLKR.js +74 -0
- package/dist/chunk-KXORPGTI.js +57 -0
- package/dist/chunk-NKCO24NR.js +46 -0
- package/dist/chunk-SRH4NGAR.cjs +57 -0
- package/dist/index.cjs +18 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +18 -0
- package/dist/react.cjs +6 -0
- package/dist/react.d.cts +7 -0
- package/dist/react.d.ts +7 -0
- package/dist/react.js +6 -0
- package/dist/server.cjs +6 -0
- package/dist/server.d.cts +20 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +6 -0
- package/dist/types-CMsn8uhD.d.cts +49 -0
- package/dist/types-CMsn8uhD.d.ts +49 -0
- package/package.json +66 -0
- package/src/adapters/in-memory.ts +58 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/supabase.ts +88 -0
- package/src/app/api/feedback/[...cyguin]/route.ts +20 -0
- package/src/components/FeedbackWidget.tsx +215 -0
- package/src/components/index.ts +2 -0
- package/src/handlers/route.ts +100 -0
- package/src/index.ts +11 -0
- package/src/server.ts +1 -0
- package/src/types.ts +50 -0
- package/tsconfig.json +18 -0
- 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,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,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';
|