@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.
Files changed (47) hide show
  1. package/.github/workflows/publish.yml +27 -0
  2. package/LICENSE +7 -0
  3. package/README.md +113 -0
  4. package/dist/adapters/postgres.d.mts +8 -0
  5. package/dist/adapters/postgres.d.ts +8 -0
  6. package/dist/adapters/postgres.js +8 -0
  7. package/dist/adapters/postgres.mjs +8 -0
  8. package/dist/adapters/sqlite.d.mts +8 -0
  9. package/dist/adapters/sqlite.d.ts +8 -0
  10. package/dist/adapters/sqlite.js +8 -0
  11. package/dist/adapters/sqlite.mjs +8 -0
  12. package/dist/chunk-3R4HB5JY.mjs +175 -0
  13. package/dist/chunk-ATHBJQGY.js +73 -0
  14. package/dist/chunk-EBO3CZXG.mjs +15 -0
  15. package/dist/chunk-GW7T5T6B.mjs +69 -0
  16. package/dist/chunk-LI5BSLUA.js +175 -0
  17. package/dist/chunk-MCKGQKYU.js +15 -0
  18. package/dist/chunk-PCYDRYYV.js +69 -0
  19. package/dist/chunk-Q3CIRMT7.js +37 -0
  20. package/dist/chunk-SQPUEELE.mjs +73 -0
  21. package/dist/chunk-UF2PO4UG.mjs +81 -0
  22. package/dist/chunk-UG6SXPV3.js +81 -0
  23. package/dist/chunk-YH77VFY5.mjs +37 -0
  24. package/dist/index.d.mts +7 -0
  25. package/dist/index.d.ts +7 -0
  26. package/dist/index.js +20 -0
  27. package/dist/index.mjs +20 -0
  28. package/dist/next.d.mts +13 -0
  29. package/dist/next.d.ts +13 -0
  30. package/dist/next.js +7 -0
  31. package/dist/next.mjs +7 -0
  32. package/dist/react.d.mts +14 -0
  33. package/dist/react.d.ts +14 -0
  34. package/dist/react.js +7 -0
  35. package/dist/react.mjs +7 -0
  36. package/dist/types-pufIZ_FB.d.mts +14 -0
  37. package/dist/types-pufIZ_FB.d.ts +14 -0
  38. package/package.json +39 -0
  39. package/src/adapters/postgres.ts +77 -0
  40. package/src/adapters/sqlite.ts +84 -0
  41. package/src/components/ConsentBanner.tsx +186 -0
  42. package/src/components/index.ts +3 -0
  43. package/src/handlers/route.ts +51 -0
  44. package/src/index.ts +17 -0
  45. package/src/types.ts +18 -0
  46. package/tsconfig.json +18 -0
  47. package/tsup.config.ts +14 -0
@@ -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 };
@@ -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
@@ -0,0 +1,7 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true});
2
+
3
+ var _chunkLI5BSLUAjs = require('./chunk-LI5BSLUA.js');
4
+ require('./chunk-MCKGQKYU.js');
5
+
6
+
7
+ exports.ConsentBanner = _chunkLI5BSLUAjs.ConsentBanner;
package/dist/react.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import {
2
+ ConsentBanner
3
+ } from "./chunk-3R4HB5JY.mjs";
4
+ import "./chunk-EBO3CZXG.mjs";
5
+ export {
6
+ ConsentBanner
7
+ };
@@ -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,3 @@
1
+ export { ConsentBanner } from './ConsentBanner'
2
+ export type { ConsentBannerProps } from './ConsentBanner'
3
+ export type { ConsentDecision, ConsentRecordData, BannerAdapter } from '../types'
@@ -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
+ })