nextjs-cookie-consent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/src/CookieBanner.d.ts +5 -0
- package/dist/src/CookieBanner.d.ts.map +1 -0
- package/dist/src/CookieBanner.js +84 -0
- package/dist/src/types.d.ts +13 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +1 -0
- package/dist/src/useConsent.d.ts +7 -0
- package/dist/src/useConsent.d.ts.map +1 -0
- package/dist/src/useConsent.js +47 -0
- package/package.json +42 -0
- package/readme.md +211 -0
package/dist/index.d.ts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC7D,cAAc,aAAa,CAAC;AAC5B,cAAc,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
{"version":3,"file":"CookieBanner.d.ts","sourceRoot":"","sources":["../../src/CookieBanner.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAmB,MAAM,OAAO,CAAC;AACxC,OAAO,EAAgC,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAuD1E,QAAA,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAsH7C,CAAC;AAEF,eAAe,YAAY,CAAC"}
|
@@ -0,0 +1,84 @@
|
|
1
|
+
'use client';
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
3
|
+
import { useState } from 'react';
|
4
|
+
import { useConsent } from './useConsent';
|
5
|
+
const styles = {
|
6
|
+
cookieBanner: {
|
7
|
+
position: 'fixed',
|
8
|
+
bottom: 0,
|
9
|
+
left: 0,
|
10
|
+
right: 0,
|
11
|
+
background: 'white',
|
12
|
+
padding: '1rem',
|
13
|
+
boxShadow: '0 -2px 8px rgba(0, 0, 0, 0.1)',
|
14
|
+
zIndex: 1000,
|
15
|
+
fontFamily: 'sans-serif'
|
16
|
+
},
|
17
|
+
cookieBannerBasic: {
|
18
|
+
display: 'flex',
|
19
|
+
flexDirection: 'column',
|
20
|
+
gap: '0.75rem'
|
21
|
+
},
|
22
|
+
cookieBannerSettings: {
|
23
|
+
display: 'flex',
|
24
|
+
flexDirection: 'column',
|
25
|
+
gap: '0.75rem'
|
26
|
+
},
|
27
|
+
cookieBannerSettingsLabel: {
|
28
|
+
display: 'flex',
|
29
|
+
alignItems: 'center',
|
30
|
+
gap: '0.5rem'
|
31
|
+
},
|
32
|
+
cookieBannerButtons: {
|
33
|
+
display: 'flex',
|
34
|
+
gap: '1rem',
|
35
|
+
flexWrap: 'wrap'
|
36
|
+
},
|
37
|
+
cookieBannerButton: {
|
38
|
+
backgroundColor: '#111',
|
39
|
+
color: 'white',
|
40
|
+
border: 'none',
|
41
|
+
padding: '0.5rem 1rem',
|
42
|
+
cursor: 'pointer',
|
43
|
+
fontSize: '0.9rem',
|
44
|
+
borderRadius: '4px'
|
45
|
+
},
|
46
|
+
cookieBannerText: {
|
47
|
+
fontSize: '0.95rem',
|
48
|
+
lineHeight: 1.5,
|
49
|
+
marginBottom: '0.5rem'
|
50
|
+
},
|
51
|
+
cookieBannerTextLink: {
|
52
|
+
color: '#0070f3',
|
53
|
+
textDecoration: 'underline'
|
54
|
+
}
|
55
|
+
};
|
56
|
+
const CookieBanner = ({ categories, content, storageStrategy }) => {
|
57
|
+
const categoryKeys = categories.map((c) => c.key);
|
58
|
+
const { consent, isSet, saveConsent } = useConsent(categoryKeys, storageStrategy);
|
59
|
+
const [showSettings, setShowSettings] = useState(false);
|
60
|
+
const [tempConsent, setTempConsent] = useState(consent);
|
61
|
+
if (isSet)
|
62
|
+
return null;
|
63
|
+
const updateTemp = (key, value) => {
|
64
|
+
setTempConsent((prev) => (Object.assign(Object.assign({}, prev), { [key]: value })));
|
65
|
+
};
|
66
|
+
const acceptAll = () => {
|
67
|
+
const allTrue = {};
|
68
|
+
categoryKeys.forEach((key) => {
|
69
|
+
allTrue[key] = true;
|
70
|
+
});
|
71
|
+
saveConsent(Object.assign({ necessary: true }, allTrue));
|
72
|
+
};
|
73
|
+
const saveSelected = () => {
|
74
|
+
saveConsent(Object.assign({ necessary: true }, tempConsent));
|
75
|
+
};
|
76
|
+
const handleButtonHover = (e) => {
|
77
|
+
e.currentTarget.style.backgroundColor = '#333';
|
78
|
+
};
|
79
|
+
const handleButtonLeave = (e) => {
|
80
|
+
e.currentTarget.style.backgroundColor = '#111';
|
81
|
+
};
|
82
|
+
return (_jsx("div", { style: styles.cookieBanner, className: "cookie-banner-container", children: !showSettings ? (_jsxs("div", { style: styles.cookieBannerBasic, className: "cookie-banner-default", children: [_jsx("div", { style: styles.cookieBannerText, children: content || (_jsxs("p", { children: ["We use cookies to improve your experience.", ' ', _jsx("a", { href: "/privacy-policy", target: "_blank", rel: "noopener noreferrer", style: styles.cookieBannerTextLink, children: "Learn more" }), "."] })) }), _jsxs("div", { style: styles.cookieBannerButtons, className: "cookie-banner-buttons", children: [_jsx("button", { className: "cookie-banner-button-necessary", style: styles.cookieBannerButton, onMouseEnter: handleButtonHover, onMouseLeave: handleButtonLeave, onClick: () => saveConsent({ necessary: true }), children: "Only necessary" }), _jsx("button", { className: "cookie-banner-button-settings", style: styles.cookieBannerButton, onMouseEnter: handleButtonHover, onMouseLeave: handleButtonLeave, onClick: () => setShowSettings(true), children: "Settings" }), _jsx("button", { className: "cookie-banner-button-all", style: styles.cookieBannerButton, onMouseEnter: handleButtonHover, onMouseLeave: handleButtonLeave, onClick: acceptAll, children: "Accept all" })] })] })) : (_jsxs("div", { style: styles.cookieBannerSettings, className: "cookie-banner-settings", children: [_jsx("h4", { className: "cookie-banner-settings-heading", children: "Cookie Settings" }), _jsxs("label", { style: styles.cookieBannerSettingsLabel, className: "cookie-banner-category", children: [_jsx("input", { type: "checkbox", checked: true, disabled: true }), "Necessary (always active)"] }), categories.map((cat) => (_jsxs("label", { style: styles.cookieBannerSettingsLabel, className: "cookie-banner-category", children: [_jsx("input", { type: "checkbox", checked: tempConsent[cat.key] || false, onChange: (e) => updateTemp(cat.key, e.target.checked) }), cat.label] }, cat.key))), _jsxs("div", { style: styles.cookieBannerButtons, className: "cookie-banner-buttons", children: [_jsx("button", { className: "cookie-banner-button-save", style: styles.cookieBannerButton, onMouseEnter: handleButtonHover, onMouseLeave: handleButtonLeave, onClick: saveSelected, children: "Save" }), _jsx("button", { className: "cookie-banner-button-back", style: styles.cookieBannerButton, onMouseEnter: handleButtonHover, onMouseLeave: handleButtonLeave, onClick: () => setShowSettings(false), children: "Back" })] })] })) }));
|
83
|
+
};
|
84
|
+
export default CookieBanner;
|
@@ -0,0 +1,13 @@
|
|
1
|
+
import { ReactNode } from 'react';
|
2
|
+
export interface CookieCategory {
|
3
|
+
key: string;
|
4
|
+
label: string;
|
5
|
+
}
|
6
|
+
export type ConsentState = Record<string, boolean>;
|
7
|
+
export type StorageStrategy = 'localStorage' | 'cookie';
|
8
|
+
export interface CookieBannerProps {
|
9
|
+
categories: CookieCategory[];
|
10
|
+
content?: ReactNode;
|
11
|
+
storageStrategy?: StorageStrategy;
|
12
|
+
}
|
13
|
+
//# sourceMappingURL=types.d.ts.map
|
@@ -0,0 +1 @@
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAElC,MAAM,WAAW,cAAc;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,QAAQ,CAAC;AAExD,MAAM,WAAW,iBAAiB;IAC9B,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;CACrC"}
|
@@ -0,0 +1 @@
|
|
1
|
+
export {};
|
@@ -0,0 +1,7 @@
|
|
1
|
+
import { ConsentState, StorageStrategy } from './types';
|
2
|
+
export declare function useConsent(categoryKeys: string[], storageStrategy?: StorageStrategy): {
|
3
|
+
consent: ConsentState;
|
4
|
+
isSet: boolean;
|
5
|
+
saveConsent: (newConsent: ConsentState) => void;
|
6
|
+
};
|
7
|
+
//# sourceMappingURL=useConsent.d.ts.map
|
@@ -0,0 +1 @@
|
|
1
|
+
{"version":3,"file":"useConsent.d.ts","sourceRoot":"","sources":["../../src/useConsent.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAExD,wBAAgB,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,EAAE,eAAe,GAAE,eAAgC;;;8BA6C/D,YAAY;EAOhD"}
|
@@ -0,0 +1,47 @@
|
|
1
|
+
import { useState, useEffect } from 'react';
|
2
|
+
export function useConsent(categoryKeys, storageStrategy = 'localStorage') {
|
3
|
+
const storageKey = 'cookie-consent';
|
4
|
+
const defaultConsent = Object.assign({ necessary: true }, categoryKeys.reduce((acc, key) => {
|
5
|
+
acc[key] = false;
|
6
|
+
return acc;
|
7
|
+
}, {}));
|
8
|
+
const getStoredConsent = () => {
|
9
|
+
try {
|
10
|
+
if (storageStrategy === 'localStorage') {
|
11
|
+
const stored = localStorage.getItem(storageKey);
|
12
|
+
return stored ? JSON.parse(stored) : null;
|
13
|
+
}
|
14
|
+
else {
|
15
|
+
const match = document.cookie.match(new RegExp('(^| )' + storageKey + '=([^;]+)'));
|
16
|
+
return match ? JSON.parse(decodeURIComponent(match[2])) : null;
|
17
|
+
}
|
18
|
+
}
|
19
|
+
catch (_a) {
|
20
|
+
return null;
|
21
|
+
}
|
22
|
+
};
|
23
|
+
const storeConsent = (consent) => {
|
24
|
+
const encoded = JSON.stringify(consent);
|
25
|
+
if (storageStrategy === 'localStorage') {
|
26
|
+
localStorage.setItem(storageKey, encoded);
|
27
|
+
}
|
28
|
+
else {
|
29
|
+
document.cookie = `${storageKey}=${encodeURIComponent(encoded)}; path=/; SameSite=Lax`;
|
30
|
+
}
|
31
|
+
};
|
32
|
+
const [consent, setConsent] = useState(defaultConsent);
|
33
|
+
const [isSet, setIsSet] = useState(false);
|
34
|
+
useEffect(() => {
|
35
|
+
const stored = getStoredConsent();
|
36
|
+
if (stored) {
|
37
|
+
setConsent(stored);
|
38
|
+
setIsSet(true);
|
39
|
+
}
|
40
|
+
}, []);
|
41
|
+
const saveConsent = (newConsent) => {
|
42
|
+
storeConsent(newConsent);
|
43
|
+
setConsent(newConsent);
|
44
|
+
setIsSet(true);
|
45
|
+
};
|
46
|
+
return { consent, isSet, saveConsent };
|
47
|
+
}
|
package/package.json
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
{
|
2
|
+
"name": "nextjs-cookie-consent",
|
3
|
+
"version": "1.0.0",
|
4
|
+
"description": "GDPR-compliant cookie consent banner with fully customizable categories for Next.js",
|
5
|
+
"main": "dist/index.js",
|
6
|
+
"types": "dist/index.d.ts",
|
7
|
+
"exports": {
|
8
|
+
".": {
|
9
|
+
"import": "./dist/index.js",
|
10
|
+
"types": "./dist/index.d.ts"
|
11
|
+
}
|
12
|
+
},
|
13
|
+
"files": [
|
14
|
+
"dist",
|
15
|
+
"README.md"
|
16
|
+
],
|
17
|
+
"scripts": {
|
18
|
+
"build": "tsc"
|
19
|
+
},
|
20
|
+
"keywords": [
|
21
|
+
"cookie",
|
22
|
+
"banner",
|
23
|
+
"nextjs",
|
24
|
+
"consent",
|
25
|
+
"gdpr",
|
26
|
+
"cookie-consent"
|
27
|
+
],
|
28
|
+
"author": "Lukas Schweiger",
|
29
|
+
"license": "MIT",
|
30
|
+
"peerDependencies": {
|
31
|
+
"next": "^15.0.0",
|
32
|
+
"react": "^18.2.0 || ^19.0.0",
|
33
|
+
"react-dom": "^18.2.0 || ^19.0.0"
|
34
|
+
},
|
35
|
+
"devDependencies": {
|
36
|
+
"@types/react": "^18.2.0",
|
37
|
+
"@types/react-dom": "^18.2.0",
|
38
|
+
"react": "^18.2.0",
|
39
|
+
"react-dom": "^18.2.0",
|
40
|
+
"typescript": "^5.4.0"
|
41
|
+
}
|
42
|
+
}
|
package/readme.md
ADDED
@@ -0,0 +1,211 @@
|
|
1
|
+
# ๐ช nextjs-cookie-consent
|
2
|
+
|
3
|
+
A ๐ก๏ธ **GDPR / DSGVO-compliant cookie consent banner for Next.js** with fully customizable categories like `analytics`, `marketing`, or `preferences`. Built for modern apps with easy styling, flexible API, and localStorage- or cookie-based consent handling.
|
4
|
+
|
5
|
+
---
|
6
|
+
|
7
|
+
## โจ Features
|
8
|
+
|
9
|
+
- โ
GDPR / DSGVO-compliant: Only essential cookies are enabled by default
|
10
|
+
- ๐ง Fully configurable categories (e.g. Necessary, Analytics, Marketing, ...)
|
11
|
+
- ๐ฌ Custom text with links to your privacy policy
|
12
|
+
- ๐พ Consent stored in `localStorage` *(or cookies โ configurable!)*
|
13
|
+
- ๐ Dynamic access to user consent via `useConsent()` hook
|
14
|
+
- ๐งฑ Compatible with both **App Router** and **Pages Router**
|
15
|
+
- ๐จ Minimal styling (fully customizable)
|
16
|
+
|
17
|
+
---
|
18
|
+
|
19
|
+
## ๐ Installation
|
20
|
+
|
21
|
+
```bash
|
22
|
+
npm install nextjs-cookie-consent
|
23
|
+
```
|
24
|
+
|
25
|
+
or with Yarn:
|
26
|
+
|
27
|
+
```bash
|
28
|
+
yarn add nextjs-cookie-consent
|
29
|
+
```
|
30
|
+
|
31
|
+
---
|
32
|
+
|
33
|
+
## โ๏ธ Usage
|
34
|
+
|
35
|
+
### โ Import the component
|
36
|
+
|
37
|
+
Import the component and CSS in your root layout or `_app.tsx`.
|
38
|
+
|
39
|
+
#### App Router (`app/layout.tsx`):
|
40
|
+
|
41
|
+
```tsx
|
42
|
+
import { CookieBanner } from 'nextjs-cookie-consent';
|
43
|
+
|
44
|
+
export default function RootLayout({ children }) {
|
45
|
+
return (
|
46
|
+
<html lang="en">
|
47
|
+
<body>
|
48
|
+
{children}
|
49
|
+
<CookieBanner
|
50
|
+
categories={[
|
51
|
+
{ key: 'analytics', label: 'Analytics' },
|
52
|
+
{ key: 'marketing', label: 'Marketing' },
|
53
|
+
]}
|
54
|
+
storageStrategy="cookie" // โ optional: "cookie" or "localStorage" (default)
|
55
|
+
content={
|
56
|
+
<p>
|
57
|
+
We use cookies to improve your experience.{' '}
|
58
|
+
<a href="/privacy-policy" target="_blank" rel="noopener noreferrer">
|
59
|
+
Learn more
|
60
|
+
</a>
|
61
|
+
.
|
62
|
+
</p>
|
63
|
+
}
|
64
|
+
/>
|
65
|
+
</body>
|
66
|
+
</html>
|
67
|
+
);
|
68
|
+
}
|
69
|
+
```
|
70
|
+
|
71
|
+
---
|
72
|
+
|
73
|
+
### ๐ Switch storage to cookies
|
74
|
+
|
75
|
+
By default, consent is saved to `localStorage`.
|
76
|
+
You can enable cookie-based storage like this:
|
77
|
+
|
78
|
+
```tsx
|
79
|
+
<CookieBanner
|
80
|
+
categories={[
|
81
|
+
{ key: 'analytics', label: 'Analytics' },
|
82
|
+
{ key: 'marketing', label: 'Marketing' },
|
83
|
+
]}
|
84
|
+
storageStrategy="cookie"
|
85
|
+
/>
|
86
|
+
```
|
87
|
+
|
88
|
+
Cookies are set with `SameSite=Lax` and `path=/`.
|
89
|
+
|
90
|
+
---
|
91
|
+
|
92
|
+
## ๐ Accessing Consent State
|
93
|
+
|
94
|
+
Use the `useConsent()` hook anywhere in your app to check if a user has accepted a specific category:
|
95
|
+
|
96
|
+
```tsx
|
97
|
+
'use client';
|
98
|
+
|
99
|
+
import { useConsent } from 'nextjs-cookie-consent';
|
100
|
+
|
101
|
+
export default function AnalyticsLoader() {
|
102
|
+
const { consent } = useConsent(['analytics', 'marketing']);
|
103
|
+
|
104
|
+
return (
|
105
|
+
<>
|
106
|
+
{consent.analytics && <p>๐ Analytics enabled</p>}
|
107
|
+
{!consent.analytics && <p>โ Analytics disabled</p>}
|
108
|
+
</>
|
109
|
+
);
|
110
|
+
}
|
111
|
+
```
|
112
|
+
|
113
|
+
---
|
114
|
+
|
115
|
+
## ๐งช Example: Load Google Analytics only after consent
|
116
|
+
|
117
|
+
```tsx
|
118
|
+
'use client';
|
119
|
+
|
120
|
+
import Script from 'next/script';
|
121
|
+
import { useConsent } from 'nextjs-cookie-consent';
|
122
|
+
|
123
|
+
export default function GoogleAnalytics() {
|
124
|
+
const { consent } = useConsent(['analytics']);
|
125
|
+
|
126
|
+
if (!consent.analytics) return null;
|
127
|
+
|
128
|
+
return (
|
129
|
+
<>
|
130
|
+
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX" strategy="afterInteractive" />
|
131
|
+
<Script id="ga-init" strategy="afterInteractive">
|
132
|
+
{`window.dataLayer = window.dataLayer || [];
|
133
|
+
function gtag(){dataLayer.push(arguments);}
|
134
|
+
gtag('js', new Date());
|
135
|
+
gtag('config', 'G-XXXXXXX');`}
|
136
|
+
</Script>
|
137
|
+
</>
|
138
|
+
);
|
139
|
+
}
|
140
|
+
```
|
141
|
+
|
142
|
+
---
|
143
|
+
|
144
|
+
## โ ๏ธ GDPR / DSGVO Tips
|
145
|
+
|
146
|
+
- ๐ Always link to your cookie/privacy policy using the `content` property
|
147
|
+
- ๐ซ Never load external scripts (e.g., Google, Meta) **before consent**
|
148
|
+
- ๐ Use cookies if `localStorage` is not allowed/possible in your compliance context
|
149
|
+
|
150
|
+
---
|
151
|
+
|
152
|
+
## ๐งฐ Props & API
|
153
|
+
|
154
|
+
| Prop | Type | Description |
|
155
|
+
|------------------|-------------------------------------|----------------------------------------------------------------------------------------|
|
156
|
+
| `categories` | `CookieCategory[]` | An array of consent categories to configure |
|
157
|
+
| `content` | `React.ReactNode` | *(optional)* Custom JSX/HTML content shown above the buttons |
|
158
|
+
| `storageStrategy`| `'localStorage'` \| `'cookie'` | *(optional)* Where to store consent. Default: `'localStorage'` |
|
159
|
+
|
160
|
+
```ts
|
161
|
+
interface CookieCategory {
|
162
|
+
key: string; // e.g. "analytics"
|
163
|
+
label: string; // e.g. "Analytics"
|
164
|
+
}
|
165
|
+
```
|
166
|
+
|
167
|
+
---
|
168
|
+
|
169
|
+
## ๐จ Styling
|
170
|
+
|
171
|
+
You can override the styles and adjust them to your liking.
|
172
|
+
Following classes are used:
|
173
|
+
|
174
|
+
| Element | Class name |
|
175
|
+
|---------------------------|-----------------------------------|
|
176
|
+
| Whole Banner | `.cookie-banner-container` |
|
177
|
+
| Default Banner view | `.cookie-banner-default` |
|
178
|
+
| Settings Banner view | `.cookie-banner-settings` |
|
179
|
+
| Button group | `.cookie-banner-buttons` |
|
180
|
+
| "Only necessary"-button | `.cookie-banner-button-necessary` |
|
181
|
+
| "Settings"-button | `.cookie-banner-button-settings` |
|
182
|
+
| "Cookie Settings"-heading | `.cookie-banner-settings-heading` |
|
183
|
+
| Cookie Category labels | `.cookie-banner-category` |
|
184
|
+
| "Save"-button | `.cookie-banner-button-save` |
|
185
|
+
| "Back"-button | `.cookie-banner-button-back` |
|
186
|
+
|
187
|
+
---
|
188
|
+
|
189
|
+
## ๐ก FAQ
|
190
|
+
|
191
|
+
**Can I use cookies instead of localStorage?**
|
192
|
+
> โ
Yes โ set `storageStrategy="cookie"` in the component props.
|
193
|
+
|
194
|
+
**Is this compatible with Server Components?**
|
195
|
+
> โ
Yes โ the banner is a client component and works inside any layout or `_app.tsx`.
|
196
|
+
|
197
|
+
**Is "necessary" always enabled?**
|
198
|
+
> โ
Yes โ the `necessary` flag is always true and cannot be disabled so you can load scripts/cookies which are necessary for your application.
|
199
|
+
|
200
|
+
---
|
201
|
+
|
202
|
+
## ๐ค Contributing
|
203
|
+
|
204
|
+
Pull requests and issues are very welcome! This project is open source โ feel free to improve it ๐ช
|
205
|
+
|
206
|
+
---
|
207
|
+
|
208
|
+
## โ๏ธ License
|
209
|
+
|
210
|
+
MIT โ free to use for personal and commercial projects.
|
211
|
+
Built with โค๏ธ for modern Next.js apps.
|