@cyguin/banner 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 +113 -0
- package/dist/adapters/postgres.d.mts +8 -0
- package/dist/adapters/postgres.d.ts +8 -0
- package/dist/adapters/postgres.js +8 -0
- package/dist/adapters/postgres.mjs +8 -0
- package/dist/adapters/sqlite.d.mts +8 -0
- package/dist/adapters/sqlite.d.ts +8 -0
- package/dist/adapters/sqlite.js +8 -0
- package/dist/adapters/sqlite.mjs +8 -0
- package/dist/chunk-3R4HB5JY.mjs +175 -0
- package/dist/chunk-ATHBJQGY.js +73 -0
- package/dist/chunk-EBO3CZXG.mjs +15 -0
- package/dist/chunk-GW7T5T6B.mjs +69 -0
- package/dist/chunk-LI5BSLUA.js +175 -0
- package/dist/chunk-MCKGQKYU.js +15 -0
- package/dist/chunk-PCYDRYYV.js +69 -0
- package/dist/chunk-Q3CIRMT7.js +37 -0
- package/dist/chunk-SQPUEELE.mjs +73 -0
- package/dist/chunk-UF2PO4UG.mjs +81 -0
- package/dist/chunk-UG6SXPV3.js +81 -0
- package/dist/chunk-YH77VFY5.mjs +37 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +20 -0
- package/dist/index.mjs +20 -0
- package/dist/next.d.mts +13 -0
- package/dist/next.d.ts +13 -0
- package/dist/next.js +7 -0
- package/dist/next.mjs +7 -0
- package/dist/react.d.mts +14 -0
- package/dist/react.d.ts +14 -0
- package/dist/react.js +7 -0
- package/dist/react.mjs +7 -0
- package/dist/types-pufIZ_FB.d.mts +14 -0
- package/dist/types-pufIZ_FB.d.ts +14 -0
- package/package.json +39 -0
- package/src/adapters/postgres.ts +77 -0
- package/src/adapters/sqlite.ts +84 -0
- package/src/components/ConsentBanner.tsx +186 -0
- package/src/components/index.ts +3 -0
- package/src/handlers/route.ts +51 -0
- package/src/index.ts +17 -0
- package/src/types.ts +18 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +14 -0
package/dist/react.d.mts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
export { B as BannerAdapter, C as ConsentDecision, a as ConsentRecordData } from './types-pufIZ_FB.mjs';
|
|
3
|
+
|
|
4
|
+
interface ConsentBannerProps {
|
|
5
|
+
apiBase?: string;
|
|
6
|
+
userId?: string;
|
|
7
|
+
theme?: 'light' | 'dark';
|
|
8
|
+
className?: string;
|
|
9
|
+
onAccept?: () => void;
|
|
10
|
+
onReject?: () => void;
|
|
11
|
+
}
|
|
12
|
+
declare function ConsentBanner({ apiBase, userId, theme, className, onAccept, onReject, }: ConsentBannerProps): react_jsx_runtime.JSX.Element | null;
|
|
13
|
+
|
|
14
|
+
export { ConsentBanner, type ConsentBannerProps };
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
export { B as BannerAdapter, C as ConsentDecision, a as ConsentRecordData } from './types-pufIZ_FB.js';
|
|
3
|
+
|
|
4
|
+
interface ConsentBannerProps {
|
|
5
|
+
apiBase?: string;
|
|
6
|
+
userId?: string;
|
|
7
|
+
theme?: 'light' | 'dark';
|
|
8
|
+
className?: string;
|
|
9
|
+
onAccept?: () => void;
|
|
10
|
+
onReject?: () => void;
|
|
11
|
+
}
|
|
12
|
+
declare function ConsentBanner({ apiBase, userId, theme, className, onAccept, onReject, }: ConsentBannerProps): react_jsx_runtime.JSX.Element | null;
|
|
13
|
+
|
|
14
|
+
export { ConsentBanner, type ConsentBannerProps };
|
package/dist/react.js
ADDED
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type ConsentDecision = 'accept' | 'reject';
|
|
2
|
+
interface ConsentRecordData {
|
|
3
|
+
id: string;
|
|
4
|
+
userId: string | null;
|
|
5
|
+
decision: ConsentDecision;
|
|
6
|
+
categories: string[];
|
|
7
|
+
createdAt: number;
|
|
8
|
+
}
|
|
9
|
+
interface BannerAdapter {
|
|
10
|
+
getConsent(userId?: string): Promise<ConsentRecordData | null>;
|
|
11
|
+
recordConsent(userId: string | undefined, decision: ConsentDecision, categories: string[]): Promise<ConsentRecordData>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type { BannerAdapter as B, ConsentDecision as C, ConsentRecordData as a };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type ConsentDecision = 'accept' | 'reject';
|
|
2
|
+
interface ConsentRecordData {
|
|
3
|
+
id: string;
|
|
4
|
+
userId: string | null;
|
|
5
|
+
decision: ConsentDecision;
|
|
6
|
+
categories: string[];
|
|
7
|
+
createdAt: number;
|
|
8
|
+
}
|
|
9
|
+
interface BannerAdapter {
|
|
10
|
+
getConsent(userId?: string): Promise<ConsentRecordData | null>;
|
|
11
|
+
recordConsent(userId: string | undefined, decision: ConsentDecision, categories: string[]): Promise<ConsentRecordData>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type { BannerAdapter as B, ConsentDecision as C, ConsentRecordData as a };
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cyguin/banner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/cyguin/banner"
|
|
7
|
+
},
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"module": "dist/index.mjs",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.js",
|
|
12
|
+
"./next": "./dist/handlers/route.js",
|
|
13
|
+
"./react": "./dist/components/index.js",
|
|
14
|
+
"./adapters/sqlite": "./dist/adapters/sqlite.js",
|
|
15
|
+
"./adapters/postgres": "./dist/adapters/postgres.js"
|
|
16
|
+
},
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"next": ">=14",
|
|
20
|
+
"react": ">=18",
|
|
21
|
+
"better-sqlite3": ">=11",
|
|
22
|
+
"postgres": ">=3"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/better-sqlite3": "^7.6.11",
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"@types/react": "^18.3.0",
|
|
32
|
+
"@types/react-dom": "^18.3.0",
|
|
33
|
+
"next": "^14.2.0",
|
|
34
|
+
"react": "^18.3.0",
|
|
35
|
+
"react-dom": "^18.3.0",
|
|
36
|
+
"tsup": "^8.3.0",
|
|
37
|
+
"typescript": "^5.5.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { BannerAdapter, ConsentDecision, ConsentRecordData } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface PostgresAdapterOptions {
|
|
4
|
+
connectionString: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function PostgresAdapter(options: PostgresAdapterOptions): BannerAdapter {
|
|
8
|
+
let db: any
|
|
9
|
+
try {
|
|
10
|
+
const postgres = require('porsager/postgres')
|
|
11
|
+
db = postgres(options.connectionString)
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error('porsager/postgres is required. Install with: npm install porsager/postgres')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
async getConsent(userId?: string): Promise<ConsentRecordData | null> {
|
|
18
|
+
let result: any
|
|
19
|
+
if (userId) {
|
|
20
|
+
result = await db`
|
|
21
|
+
SELECT * FROM consent_records
|
|
22
|
+
WHERE user_id = ${userId}
|
|
23
|
+
ORDER BY created_at DESC
|
|
24
|
+
LIMIT 1
|
|
25
|
+
`
|
|
26
|
+
} else {
|
|
27
|
+
result = await db`
|
|
28
|
+
SELECT * FROM consent_records
|
|
29
|
+
ORDER BY created_at DESC
|
|
30
|
+
LIMIT 1
|
|
31
|
+
`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const row = result[0]
|
|
35
|
+
if (!row) return null
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
userId: row.user_id,
|
|
40
|
+
decision: row.decision as ConsentDecision,
|
|
41
|
+
categories: row.categories,
|
|
42
|
+
createdAt: row.created_at,
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async recordConsent(
|
|
47
|
+
userId: string | undefined,
|
|
48
|
+
decision: ConsentDecision,
|
|
49
|
+
categories: string[]
|
|
50
|
+
): Promise<ConsentRecordData> {
|
|
51
|
+
const { nanoid } = require('nanoid')
|
|
52
|
+
const id = nanoid()
|
|
53
|
+
const createdAt = Date.now()
|
|
54
|
+
|
|
55
|
+
const result = await db`
|
|
56
|
+
INSERT INTO consent_records (id, user_id, decision, categories, created_at)
|
|
57
|
+
VALUES (
|
|
58
|
+
${id},
|
|
59
|
+
${userId ?? null},
|
|
60
|
+
${decision},
|
|
61
|
+
${categories},
|
|
62
|
+
${createdAt}
|
|
63
|
+
)
|
|
64
|
+
RETURNING *
|
|
65
|
+
`
|
|
66
|
+
|
|
67
|
+
const row = result[0]
|
|
68
|
+
return {
|
|
69
|
+
id: row.id,
|
|
70
|
+
userId: row.user_id,
|
|
71
|
+
decision: row.decision as ConsentDecision,
|
|
72
|
+
categories: row.categories,
|
|
73
|
+
createdAt: row.created_at,
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { BannerAdapter, ConsentDecision, ConsentRecordData } from '../types'
|
|
2
|
+
|
|
3
|
+
export interface SQLiteAdapterOptions {
|
|
4
|
+
path?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function SQLiteAdapter(options: SQLiteAdapterOptions = {}): BannerAdapter {
|
|
8
|
+
let db: any
|
|
9
|
+
try {
|
|
10
|
+
const Database = require('better-sqlite3')
|
|
11
|
+
const dbPath = (options as any).path ?? './data/banner.db'
|
|
12
|
+
db = new Database(dbPath)
|
|
13
|
+
db.pragma('journal_mode = WAL')
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error('better-sqlite3 is required. Install with: npm install better-sqlite3')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
db.exec(`
|
|
19
|
+
CREATE TABLE IF NOT EXISTS consent_records (
|
|
20
|
+
id TEXT PRIMARY KEY,
|
|
21
|
+
user_id TEXT,
|
|
22
|
+
decision TEXT NOT NULL,
|
|
23
|
+
categories TEXT NOT NULL DEFAULT '[]',
|
|
24
|
+
created_at INTEGER NOT NULL
|
|
25
|
+
)
|
|
26
|
+
`)
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
async getConsent(userId?: string): Promise<ConsentRecordData | null> {
|
|
30
|
+
let stmt: any
|
|
31
|
+
if (userId) {
|
|
32
|
+
stmt = db.prepare(
|
|
33
|
+
'SELECT * FROM consent_records WHERE user_id = ? ORDER BY created_at DESC LIMIT 1'
|
|
34
|
+
)
|
|
35
|
+
const row: any = stmt.get(userId)
|
|
36
|
+
if (!row) return null
|
|
37
|
+
return {
|
|
38
|
+
id: row.id,
|
|
39
|
+
userId: row.user_id,
|
|
40
|
+
decision: row.decision as ConsentDecision,
|
|
41
|
+
categories: JSON.parse(row.categories),
|
|
42
|
+
createdAt: row.created_at,
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
stmt = db.prepare(
|
|
46
|
+
'SELECT * FROM consent_records ORDER BY created_at DESC LIMIT 1'
|
|
47
|
+
)
|
|
48
|
+
const row: any = stmt.get()
|
|
49
|
+
if (!row) return null
|
|
50
|
+
return {
|
|
51
|
+
id: row.id,
|
|
52
|
+
userId: row.user_id,
|
|
53
|
+
decision: row.decision as ConsentDecision,
|
|
54
|
+
categories: JSON.parse(row.categories),
|
|
55
|
+
createdAt: row.created_at,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async recordConsent(
|
|
61
|
+
userId: string | undefined,
|
|
62
|
+
decision: ConsentDecision,
|
|
63
|
+
categories: string[]
|
|
64
|
+
): Promise<ConsentRecordData> {
|
|
65
|
+
const { nanoid } = require('nanoid')
|
|
66
|
+
const id = nanoid()
|
|
67
|
+
const createdAt = Date.now()
|
|
68
|
+
|
|
69
|
+
const stmt = db.prepare(`
|
|
70
|
+
INSERT INTO consent_records (id, user_id, decision, categories, created_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?)
|
|
72
|
+
`)
|
|
73
|
+
stmt.run(id, userId ?? null, decision, JSON.stringify(categories), createdAt)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
userId: userId ?? null,
|
|
78
|
+
decision,
|
|
79
|
+
categories,
|
|
80
|
+
createdAt,
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import type { ConsentDecision, ConsentRecordData, BannerAdapter } from '../types'
|
|
3
|
+
|
|
4
|
+
export type { ConsentDecision, ConsentRecordData, BannerAdapter }
|
|
5
|
+
|
|
6
|
+
export interface ConsentBannerProps {
|
|
7
|
+
apiBase?: string
|
|
8
|
+
userId?: string
|
|
9
|
+
theme?: 'light' | 'dark'
|
|
10
|
+
className?: string
|
|
11
|
+
onAccept?: () => void
|
|
12
|
+
onReject?: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ConsentBanner({
|
|
16
|
+
apiBase = '/api/banner',
|
|
17
|
+
userId,
|
|
18
|
+
theme = 'light',
|
|
19
|
+
className = '',
|
|
20
|
+
onAccept,
|
|
21
|
+
onReject,
|
|
22
|
+
}: ConsentBannerProps) {
|
|
23
|
+
const [visible, setVisible] = useState(false)
|
|
24
|
+
const [loading, setLoading] = useState(true)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const checkConsent = async () => {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(`${apiBase}/[...cyguin]`)
|
|
30
|
+
url.searchParams.set('cyguin', '')
|
|
31
|
+
if (userId) url.searchParams.set('userId', userId)
|
|
32
|
+
const res = await fetch(`${apiBase}/[...cyguin]${url.search}`)
|
|
33
|
+
if (res.ok) {
|
|
34
|
+
const data: ConsentRecordData | null = await res.json()
|
|
35
|
+
if (data) {
|
|
36
|
+
setLoading(false)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// proceed to show banner
|
|
42
|
+
}
|
|
43
|
+
setVisible(true)
|
|
44
|
+
setLoading(false)
|
|
45
|
+
}
|
|
46
|
+
checkConsent()
|
|
47
|
+
}, [apiBase, userId])
|
|
48
|
+
|
|
49
|
+
const handleDecision = async (decision: ConsentDecision) => {
|
|
50
|
+
try {
|
|
51
|
+
await fetch(`${apiBase}/[...cyguin]`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ userId, decision, categories: [] }),
|
|
55
|
+
})
|
|
56
|
+
} catch {
|
|
57
|
+
// best-effort
|
|
58
|
+
}
|
|
59
|
+
setVisible(false)
|
|
60
|
+
if (decision === 'accept') {
|
|
61
|
+
onAccept?.()
|
|
62
|
+
} else {
|
|
63
|
+
onReject?.()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (loading || !visible) return null
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={`cyguin-banner ${className}`}
|
|
72
|
+
data-theme={theme}
|
|
73
|
+
role="region"
|
|
74
|
+
aria-label="Cookie consent"
|
|
75
|
+
>
|
|
76
|
+
<style>{`
|
|
77
|
+
.cyguin-banner {
|
|
78
|
+
--cyguin-bg: #ffffff;
|
|
79
|
+
--cyguin-bg-subtle: #f5f5f5;
|
|
80
|
+
--cyguin-border: #e5e5e5;
|
|
81
|
+
--cyguin-border-focus: #f5a800;
|
|
82
|
+
--cyguin-fg: #0a0a0a;
|
|
83
|
+
--cyguin-fg-muted: #888888;
|
|
84
|
+
--cyguin-accent: #f5a800;
|
|
85
|
+
--cyguin-accent-dark: #c47f00;
|
|
86
|
+
--cyguin-accent-fg: #0a0a0a;
|
|
87
|
+
--cyguin-radius: 6px;
|
|
88
|
+
--cyguin-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
|
89
|
+
position: fixed;
|
|
90
|
+
bottom: 0;
|
|
91
|
+
left: 0;
|
|
92
|
+
right: 0;
|
|
93
|
+
z-index: 99999;
|
|
94
|
+
background: var(--cyguin-bg);
|
|
95
|
+
border-top: 1px solid var(--cyguin-border);
|
|
96
|
+
box-shadow: var(--cyguin-shadow);
|
|
97
|
+
padding: 16px 24px;
|
|
98
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: space-between;
|
|
102
|
+
gap: 16px;
|
|
103
|
+
flex-wrap: wrap;
|
|
104
|
+
}
|
|
105
|
+
.cyguin-banner[data-theme="dark"] {
|
|
106
|
+
--cyguin-bg: #0a0a0a;
|
|
107
|
+
--cyguin-bg-subtle: #1a1a1a;
|
|
108
|
+
--cyguin-border: #2a2a2a;
|
|
109
|
+
--cyguin-border-focus: #f5a800;
|
|
110
|
+
--cyguin-fg: #f5f5f5;
|
|
111
|
+
--cyguin-fg-muted: #888888;
|
|
112
|
+
--cyguin-accent: #f5a800;
|
|
113
|
+
--cyguin-accent-dark: #c47f00;
|
|
114
|
+
--cyguin-accent-fg: #0a0a0a;
|
|
115
|
+
--cyguin-radius: 6px;
|
|
116
|
+
--cyguin-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
|
117
|
+
}
|
|
118
|
+
.cyguin-banner__text {
|
|
119
|
+
color: var(--cyguin-fg);
|
|
120
|
+
font-size: 14px;
|
|
121
|
+
line-height: 1.5;
|
|
122
|
+
margin: 0;
|
|
123
|
+
flex: 1;
|
|
124
|
+
min-width: 200px;
|
|
125
|
+
}
|
|
126
|
+
.cyguin-banner__text a {
|
|
127
|
+
color: var(--cyguin-accent);
|
|
128
|
+
text-decoration: underline;
|
|
129
|
+
}
|
|
130
|
+
.cyguin-banner__actions {
|
|
131
|
+
display: flex;
|
|
132
|
+
gap: 8px;
|
|
133
|
+
flex-shrink: 0;
|
|
134
|
+
}
|
|
135
|
+
.cyguin-banner__btn {
|
|
136
|
+
padding: 8px 16px;
|
|
137
|
+
border-radius: var(--cyguin-radius);
|
|
138
|
+
font-size: 14px;
|
|
139
|
+
font-weight: 500;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
border: 1px solid transparent;
|
|
142
|
+
transition: background 0.15s, border-color 0.15s;
|
|
143
|
+
}
|
|
144
|
+
.cyguin-banner__btn--accept {
|
|
145
|
+
background: var(--cyguin-accent);
|
|
146
|
+
color: var(--cyguin-accent-fg);
|
|
147
|
+
border-color: var(--cyguin-accent);
|
|
148
|
+
}
|
|
149
|
+
.cyguin-banner__btn--accept:hover {
|
|
150
|
+
background: var(--cyguin-accent-dark);
|
|
151
|
+
border-color: var(--cyguin-accent-dark);
|
|
152
|
+
}
|
|
153
|
+
.cyguin-banner__btn--reject {
|
|
154
|
+
background: transparent;
|
|
155
|
+
color: var(--cyguin-fg-muted);
|
|
156
|
+
border-color: var(--cyguin-border);
|
|
157
|
+
}
|
|
158
|
+
.cyguin-banner__btn--reject:hover {
|
|
159
|
+
border-color: var(--cyguin-fg-muted);
|
|
160
|
+
color: var(--cyguin-fg);
|
|
161
|
+
}
|
|
162
|
+
`}</style>
|
|
163
|
+
<p className="cyguin-banner__text">
|
|
164
|
+
We use cookies to improve your experience. By continuing to browse you agree to our cookie policy.
|
|
165
|
+
</p>
|
|
166
|
+
<div className="cyguin-banner__actions">
|
|
167
|
+
<button
|
|
168
|
+
className="cyguin-banner__btn cyguin-banner__btn--reject"
|
|
169
|
+
onClick={() => handleDecision('reject')}
|
|
170
|
+
type="button"
|
|
171
|
+
>
|
|
172
|
+
Reject
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
className="cyguin-banner__btn cyguin-banner__btn--accept"
|
|
176
|
+
onClick={() => handleDecision('accept')}
|
|
177
|
+
type="button"
|
|
178
|
+
>
|
|
179
|
+
Accept
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default ConsentBanner
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import type { BannerAdapter, ConsentDecision } from '../types'
|
|
3
|
+
|
|
4
|
+
export interface BannerHandlerOptions {
|
|
5
|
+
adapter: BannerAdapter
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createBannerHandler(options: BannerHandlerOptions) {
|
|
9
|
+
const { adapter } = options
|
|
10
|
+
|
|
11
|
+
return async function handler(
|
|
12
|
+
request: NextRequest,
|
|
13
|
+
{ params }: { params: { cyguin: string[] } }
|
|
14
|
+
): Promise<NextResponse> {
|
|
15
|
+
const method = request.method
|
|
16
|
+
|
|
17
|
+
if (method === 'GET') {
|
|
18
|
+
const userId = request.nextUrl.searchParams.get('userId') ?? undefined
|
|
19
|
+
const record = await adapter.getConsent(userId)
|
|
20
|
+
if (!record) {
|
|
21
|
+
return NextResponse.json(null, { status: 200 })
|
|
22
|
+
}
|
|
23
|
+
return NextResponse.json(record, { status: 200 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (method === 'POST') {
|
|
27
|
+
const body = await request.json()
|
|
28
|
+
const { userId, decision, categories } = body as {
|
|
29
|
+
userId?: string
|
|
30
|
+
decision: ConsentDecision
|
|
31
|
+
categories?: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!decision || !['accept', 'reject'].includes(decision)) {
|
|
35
|
+
return NextResponse.json(
|
|
36
|
+
{ error: 'Invalid decision. Must be "accept" or "reject".' },
|
|
37
|
+
{ status: 400 }
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const record = await adapter.recordConsent(
|
|
42
|
+
userId,
|
|
43
|
+
decision,
|
|
44
|
+
categories ?? []
|
|
45
|
+
)
|
|
46
|
+
return NextResponse.json(record, { status: 201 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return NextResponse.json({ error: 'Method not allowed' }, { status: 405 })
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
ConsentDecision,
|
|
3
|
+
ConsentRecordData,
|
|
4
|
+
BannerAdapter,
|
|
5
|
+
} from './types'
|
|
6
|
+
|
|
7
|
+
export { ConsentBanner } from './components/ConsentBanner'
|
|
8
|
+
export type { ConsentBannerProps } from './components/ConsentBanner'
|
|
9
|
+
|
|
10
|
+
export { SQLiteAdapter } from './adapters/sqlite'
|
|
11
|
+
export type { SQLiteAdapterOptions } from './adapters/sqlite'
|
|
12
|
+
|
|
13
|
+
export { PostgresAdapter } from './adapters/postgres'
|
|
14
|
+
export type { PostgresAdapterOptions } from './adapters/postgres'
|
|
15
|
+
|
|
16
|
+
export { createBannerHandler } from './handlers/route'
|
|
17
|
+
export type { BannerHandlerOptions } from './handlers/route'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type ConsentDecision = 'accept' | 'reject'
|
|
2
|
+
|
|
3
|
+
export interface ConsentRecordData {
|
|
4
|
+
id: string
|
|
5
|
+
userId: string | null
|
|
6
|
+
decision: ConsentDecision
|
|
7
|
+
categories: string[]
|
|
8
|
+
createdAt: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BannerAdapter {
|
|
12
|
+
getConsent(userId?: string): Promise<ConsentRecordData | null>
|
|
13
|
+
recordConsent(
|
|
14
|
+
userId: string | undefined,
|
|
15
|
+
decision: ConsentDecision,
|
|
16
|
+
categories: string[]
|
|
17
|
+
): Promise<ConsentRecordData>
|
|
18
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["ES2020", "DOM"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"outDir": "dist",
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"forceConsistentCasingInFileNames": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: {
|
|
5
|
+
index: 'src/index.ts',
|
|
6
|
+
next: 'src/handlers/route.ts',
|
|
7
|
+
react: 'src/components/index.ts',
|
|
8
|
+
'adapters/sqlite': 'src/adapters/sqlite.ts',
|
|
9
|
+
'adapters/postgres': 'src/adapters/postgres.ts',
|
|
10
|
+
},
|
|
11
|
+
format: ['esm', 'cjs'],
|
|
12
|
+
dts: true,
|
|
13
|
+
splitting: true,
|
|
14
|
+
})
|