feat: update website-creator to static mode with ConsentOS + tracking scripts
- Remove Astro DB (no longer needed for consent logging) - Change from SSR to static output mode - Add TrackingScripts.astro with GA4, GTM, Umami, Clarity, FB Pixel, Google Ads, TikTok, LINE - Use ConsentOS consent-loader.js for auto-blocking tracking scripts - Update Dockerfile to nginx static hosting - Remove old consent template (custom consent no longer needed) - Update SKILL.md, AGENTS.md, README.md documentation - Add nginx.conf for static hosting
This commit is contained in:
@@ -7,11 +7,36 @@ PUBLIC_SITE_URL=https://your-domain.com
|
||||
TINA_TOKEN=your-tina-token-from-tina-cloud
|
||||
TINA_CLIENT_ID=your-client-id
|
||||
|
||||
# Astro DB - Persistent Storage
|
||||
# IMPORTANT: Set OUT_DIR to /data for Docker/Easypanel persistent volume
|
||||
# This ensures database persists across redeployments
|
||||
OUT_DIR=/data
|
||||
# ConsentOS - Consent Management (moreminimore.com)
|
||||
PUBLIC_CONSENT_SITE_ID=your-consent-site-id
|
||||
PUBLIC_CONSENT_API_BASE=https://consent.moreminimore.com
|
||||
|
||||
# Optional: External Turso database (if using external DB instead of local SQLite)
|
||||
# TURSO_DATABASE_URL=libsql://your-db.turso.io
|
||||
# TURSO_AUTH_TOKEN=your-auth-token
|
||||
# ===== TRACKING SCRIPTS =====
|
||||
# All tracking scripts are blocked by ConsentOS until user gives consent
|
||||
|
||||
# --- Analytics ---
|
||||
# Google Analytics 4
|
||||
PUBLIC_GA4_ID=G-XXXXXXXXXX
|
||||
|
||||
# Google Tag Manager
|
||||
PUBLIC_GTM_ID=GTM-XXXXXXX
|
||||
|
||||
# Umami (self-hosted analytics)
|
||||
PUBLIC_UMAMI_URL=https://umami.example.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
|
||||
# Microsoft Clarity (heatmaps)
|
||||
PUBLIC_CLARITY_ID=xxxxxxxxxx
|
||||
|
||||
# --- Marketing / Advertising ---
|
||||
# Facebook Pixel
|
||||
PUBLIC_FB_PIXEL_ID=123456789
|
||||
|
||||
# Google Ads Conversion
|
||||
PUBLIC_GOOGLE_ADS_ID=AW-123456789
|
||||
|
||||
# TikTok Pixel
|
||||
PUBLIC_TIKTOK_PIXEL_ID=XXXXXXXX
|
||||
|
||||
# LINE Channel Tag (Thailand)
|
||||
PUBLIC_LINE_CHANNEL_ID=1234567890
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Astro Tina Starter - Agent Knowledge Base
|
||||
|
||||
**Generated:** 2026-04-17
|
||||
**Version:** 1.0.0
|
||||
**Generated:** 2026-04-17
|
||||
**Version:** 2.0.0
|
||||
**Type:** Astro 6 + Tina CMS Starter Template
|
||||
|
||||
---
|
||||
@@ -17,16 +17,14 @@ Starter template for building websites with Astro 6, Tina CMS, and Tailwind CSS
|
||||
| Framework | Astro | 6.1.7 |
|
||||
| CMS | Tina CMS | 2.x |
|
||||
| Styling | Tailwind CSS | 4.x |
|
||||
| Database | Astro DB | 0.14.x |
|
||||
| State | Nano Stores | 0.11.x |
|
||||
|
||||
### Key Features
|
||||
|
||||
- Self-hosted Tina CMS with schema-based content
|
||||
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
|
||||
- Astro DB for consent logging (PDPA compliant)
|
||||
- External consent system integration
|
||||
- Thai language support with Noto Sans Thai
|
||||
- Docker-ready deployment
|
||||
- Docker-ready with nginx
|
||||
|
||||
---
|
||||
|
||||
@@ -37,9 +35,6 @@ astro-tina-starter/
|
||||
├── .tina/
|
||||
│ ├── config.ts # Tina CMS configuration
|
||||
│ └── schema.ts # Content schema definitions
|
||||
├── db/
|
||||
│ ├── config.ts # Astro DB schema
|
||||
│ └── seed.ts # Database seed script
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ └── global.css # Tailwind v4 styles + @theme
|
||||
@@ -48,7 +43,8 @@ astro-tina-starter/
|
||||
│ ├── pages/
|
||||
│ │ └── index.astro
|
||||
│ ├── components/
|
||||
│ │ └── Header.astro
|
||||
│ │ ├── Header.astro
|
||||
│ │ └── TrackingScripts.astro # Tracking scripts (GA4, FB Pixel, etc.)
|
||||
│ └── content/
|
||||
│ ├── config.ts # Astro content collections
|
||||
│ ├── posts/ # Blog posts (MDX)
|
||||
@@ -57,6 +53,7 @@ astro-tina-starter/
|
||||
├── public/
|
||||
│ └── favicon.svg
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
├── astro.config.mjs
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
@@ -100,9 +97,33 @@ Tina CMS manages content in `src/content/`:
|
||||
|
||||
Schema defined in `.tina/schema.ts`.
|
||||
|
||||
### Astro DB Schema
|
||||
### ConsentOS + Tracking System
|
||||
|
||||
Consent log table for PDPA compliance in `db/config.ts`.
|
||||
ConsentOS (`consent.moreminimore.com`) manages consent and blocking:
|
||||
|
||||
```bash
|
||||
# ConsentOS
|
||||
PUBLIC_CONSENT_SITE_ID=your-site-id
|
||||
PUBLIC_CONSENT_API_BASE=https://consent.moreminimore.com
|
||||
|
||||
# Analytics
|
||||
PUBLIC_GA4_ID=G-XXXXXXXXXX
|
||||
PUBLIC_GTM_ID=GTM-XXXXXXX
|
||||
PUBLIC_UMAMI_URL=https://umami.example.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
PUBLIC_CLARITY_ID=xxxxxxxxxx
|
||||
|
||||
# Marketing
|
||||
PUBLIC_FB_PIXEL_ID=123456789
|
||||
PUBLIC_GOOGLE_ADS_ID=AW-123456789
|
||||
PUBLIC_TIKTOK_PIXEL_ID=XXXXXXXX
|
||||
PUBLIC_LINE_CHANNEL_ID=1234567890
|
||||
```
|
||||
|
||||
**Tracking Scripts:**
|
||||
- `TrackingScripts.astro` - contains all tracking scripts
|
||||
- Scripts are auto-blocked by ConsentOS until user consent
|
||||
- Categories: `analytics`, `marketing`
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +137,17 @@ No external API credentials required for this template.
|
||||
|----------|-------------|
|
||||
| `TINA_TOKEN` | Tina CMS production authentication |
|
||||
| `TINA_CLIENT_ID` | Tina CMS client ID |
|
||||
| `DATABASE_URL` | Custom database connection (optional) |
|
||||
| `PUBLIC_CONSENT_SITE_ID` | ConsentOS site ID |
|
||||
| `PUBLIC_CONSENT_API_BASE` | ConsentOS API base URL |
|
||||
| `PUBLIC_GA4_ID` | Google Analytics 4 |
|
||||
| `PUBLIC_GTM_ID` | Google Tag Manager |
|
||||
| `PUBLIC_UMAMI_URL` | Umami analytics URL |
|
||||
| `PUBLIC_UMAMI_WEBSITE_ID` | Umami website ID |
|
||||
| `PUBLIC_CLARITY_ID` | Microsoft Clarity |
|
||||
| `PUBLIC_FB_PIXEL_ID` | Facebook Pixel |
|
||||
| `PUBLIC_GOOGLE_ADS_ID` | Google Ads conversion ID |
|
||||
| `PUBLIC_TIKTOK_PIXEL_ID` | TikTok Pixel |
|
||||
| `PUBLIC_LINE_CHANNEL_ID` | LINE Channel Tag |
|
||||
|
||||
---
|
||||
|
||||
@@ -127,39 +158,13 @@ No external API credentials required for this template.
|
||||
npm install
|
||||
|
||||
# Development
|
||||
npm run dev # Full dev (Tina + Astro)
|
||||
npm run dev:astro # Astro only
|
||||
npm run dev:tina # Tina CMS only
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build # Production build
|
||||
npm run preview # Preview production build
|
||||
npm run build
|
||||
|
||||
# Database
|
||||
npm run db:push # Push schema to database
|
||||
npm run db:seed # Seed database
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PDPA COMPLIANCE
|
||||
|
||||
Template includes consent logging via Astro DB:
|
||||
|
||||
```typescript
|
||||
// db/config.ts
|
||||
export const ConsentLog = defineTable({
|
||||
columns: {
|
||||
action: text(),
|
||||
purpose: text(),
|
||||
analytics: boolean(),
|
||||
marketing: boolean(),
|
||||
functional: boolean(),
|
||||
userAgent: text(),
|
||||
ip: text(),
|
||||
timestamp: text(),
|
||||
},
|
||||
})
|
||||
# Preview
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
@@ -181,6 +186,10 @@ docker build -t astro-tina-starter .
|
||||
docker run -p 8080:80 astro-tina-starter
|
||||
```
|
||||
|
||||
### Easypanel
|
||||
|
||||
Static hosting - no persistent volume needed.
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
@@ -7,19 +8,17 @@ RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
# Static files stage
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Copy static files from builder
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
ENV NODE_ENV=production
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/http.d/default.conf
|
||||
|
||||
EXPOSE 4321
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "dist/server/entry.mjs"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -4,21 +4,17 @@ Astro 6.1.7 + Tina CMS starter template with Tailwind CSS 4.x
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Astro 6.1.7 (SSR mode)
|
||||
- **Framework:** Astro 6.1.7 (static mode)
|
||||
- **CMS:** Tina CMS (self-hosted)
|
||||
- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite`
|
||||
- **Database:** Astro DB (LibSQL/SQLite)
|
||||
- **Adapter:** @astrojs/node (SSR)
|
||||
- **State:** Nano Stores + React
|
||||
- **Language:** TypeScript
|
||||
|
||||
## Features
|
||||
|
||||
- Self-hosted Tina CMS with schema-based content
|
||||
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin
|
||||
- Astro DB for consent logging (PDPA compliant)
|
||||
- SSR mode for API routes
|
||||
- Docker-ready with persistent storage
|
||||
- ConsentOS + Tracking Scripts (GA4, Facebook Pixel, etc.)
|
||||
- Docker-ready with nginx
|
||||
- Thai language support foundation
|
||||
|
||||
## Quick Start
|
||||
@@ -41,40 +37,36 @@ During development, access Tina CMS at:
|
||||
|
||||
For production, you'll need a TINA_TOKEN environment variable.
|
||||
|
||||
## Easypanel Deployment
|
||||
## ConsentOS + Tracking
|
||||
|
||||
This template is designed for **Easypanel** with persistent volume support.
|
||||
|
||||
### Important: Persistent Storage
|
||||
|
||||
**Astro DB stores SQLite at `/data/`** - this directory is mounted as a persistent volume in Easypanel.
|
||||
|
||||
When deploying:
|
||||
1. Set environment variable `OUT_DIR=/data`
|
||||
2. Mount persistent volume to `/data` in Easypanel
|
||||
3. Database will persist across redeployments
|
||||
This template includes ConsentOS (`consent.moreminimore.com`) for PDPA-compliant consent management and auto-blocking of tracking scripts.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```bash
|
||||
# Required for persistent storage
|
||||
OUT_DIR=/data
|
||||
# ConsentOS
|
||||
PUBLIC_CONSENT_SITE_ID=your-site-id
|
||||
PUBLIC_CONSENT_API_BASE=https://consent.moreminimore.com
|
||||
|
||||
# Optional - Tina CMS
|
||||
TINA_TOKEN=your-tina-token
|
||||
# Analytics
|
||||
PUBLIC_GA4_ID=G-XXXXXXXXXX
|
||||
PUBLIC_GTM_ID=GTM-XXXXXXX
|
||||
PUBLIC_UMAMI_URL=https://umami.example.com
|
||||
PUBLIC_UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
PUBLIC_CLARITY_ID=xxxxxxxxxx
|
||||
|
||||
# Optional - External database (instead of local SQLite)
|
||||
# TURSO_DATABASE_URL=libsql://your-db.turso.io
|
||||
# TURSO_AUTH_TOKEN=your-auth-token
|
||||
# Marketing
|
||||
PUBLIC_FB_PIXEL_ID=123456789
|
||||
PUBLIC_GOOGLE_ADS_ID=AW-123456789
|
||||
PUBLIC_TIKTOK_PIXEL_ID=XXXXXXXX
|
||||
PUBLIC_LINE_CHANNEL_ID=1234567890
|
||||
```
|
||||
|
||||
### Easypanel Setup
|
||||
### How It Works
|
||||
|
||||
1. Create new service from Git repo
|
||||
2. Set build command: `npm run build`
|
||||
3. Set start command: `node dist/server/entry.mjs`
|
||||
4. Add environment variable: `OUT_DIR=/data`
|
||||
5. Mount persistent volume to `/data`
|
||||
1. `TrackingScripts.astro` contains all tracking codes with `data-consent-category` attributes
|
||||
2. ConsentOS `consent-loader.js` scans and auto-blocks scripts until user consent
|
||||
3. Categories: `analytics`, `marketing`
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -83,22 +75,20 @@ astro-tina-starter/
|
||||
├── .tina/
|
||||
│ ├── config.ts # Tina CMS configuration
|
||||
│ └── schema.ts # Content schema definitions
|
||||
├── db/
|
||||
│ ├── config.ts # Astro DB schema (consent logs)
|
||||
│ └── seed.ts # Database seed script
|
||||
├── src/
|
||||
│ ├── styles/
|
||||
│ │ └── global.css # Tailwind v4 styles
|
||||
│ ├── layouts/
|
||||
│ │ └── Layout.astro
|
||||
│ ├── pages/
|
||||
│ │ ├── index.astro
|
||||
│ │ └── api/ # API routes (consent, etc.)
|
||||
│ │ └── index.astro
|
||||
│ ├── components/
|
||||
│ │ └── Header.astro
|
||||
│ │ ├── Header.astro
|
||||
│ │ └── TrackingScripts.astro # Tracking scripts
|
||||
│ └── content/
|
||||
│ └── config.ts # Tina content collections
|
||||
├── Dockerfile # Multi-stage Node.js (not nginx)
|
||||
├── Dockerfile
|
||||
├── nginx.conf
|
||||
└── package.json
|
||||
```
|
||||
|
||||
@@ -116,34 +106,16 @@ The configuration is done via CSS `@theme` block in `src/styles/global.css`.
|
||||
}
|
||||
```
|
||||
|
||||
## Astro DB
|
||||
## Docker
|
||||
|
||||
The template includes a consent-log table for PDPA compliance:
|
||||
```bash
|
||||
# Build
|
||||
docker build -t astro-tina .
|
||||
|
||||
```ts
|
||||
// db/config.ts
|
||||
import { defineDb, defineTable, column } from 'astro:db';
|
||||
|
||||
const ConsentLog = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
action: column.text(),
|
||||
purpose: column.text(),
|
||||
analytics: column.boolean({ default: false }),
|
||||
marketing: column.boolean({ default: false }),
|
||||
functional: column.boolean({ default: false }),
|
||||
userAgent: column.text({ optional: true }),
|
||||
ip: column.text({ optional: true }),
|
||||
timestamp: column.date(),
|
||||
sessionId: column.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineDb({ tables: { ConsentLog } });
|
||||
# Run
|
||||
docker run -p 8080:80 astro-tina
|
||||
```
|
||||
|
||||
Database file location: `/data/astro.db` (persistent across redeployments)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defineConfig } from 'astro/config'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import tina from 'tinacms'
|
||||
import node from '@astrojs/node'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://example.com',
|
||||
integrations: [
|
||||
tina({
|
||||
enabled: !!process.env.TINA_TOKEN,
|
||||
@@ -28,10 +28,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
output: 'server',
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
}),
|
||||
output: 'static',
|
||||
build: {
|
||||
assets: '_assets',
|
||||
},
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { defineDb, defineTable, column } from 'astro:db';
|
||||
|
||||
const ConsentLog = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
action: column.text(),
|
||||
purpose: column.text(),
|
||||
analytics: column.boolean({ default: false }),
|
||||
marketing: column.boolean({ default: false }),
|
||||
functional: column.boolean({ default: false }),
|
||||
userAgent: column.text({ optional: true }),
|
||||
ip: column.text({ optional: true }),
|
||||
timestamp: column.date(),
|
||||
sessionId: column.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineDb({
|
||||
tables: {
|
||||
ConsentLog,
|
||||
},
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { db } from 'astro:db'
|
||||
import { sql } from 'astro/db'
|
||||
|
||||
export default async function seed() {
|
||||
// Seed default settings if needed
|
||||
console.log('Database seeded successfully')
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /app/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html =404;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "astro-tina-starter",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x",
|
||||
"scripts": {
|
||||
"dev": "tinacms dev --port 3001 & astro dev",
|
||||
@@ -9,19 +9,14 @@
|
||||
"dev:tina": "tinacms dev --port 3001",
|
||||
"build": "tinacms build && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"db:push": "astro db push",
|
||||
"db:seed": "astro db seed"
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/db": "^0.14.3",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@nanostores/react": "^0.7.3",
|
||||
"@astrojs/mdx": "^4.0.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"astro": "^6.1.7",
|
||||
"nanostores": "^0.11.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
const ga4Id = import.meta.env.PUBLIC_GA4_ID
|
||||
const gtmId = import.meta.env.PUBLIC_GTM_ID
|
||||
const umamiUrl = import.meta.env.PUBLIC_UMAMI_URL
|
||||
const umamiWebsiteId = import.meta.env.PUBLIC_UMAMI_WEBSITE_ID
|
||||
const clarityId = import.meta.env.PUBLIC_CLARITY_ID
|
||||
const fbPixelId = import.meta.env.PUBLIC_FB_PIXEL_ID
|
||||
const googleAdsId = import.meta.env.PUBLIC_GOOGLE_ADS_ID
|
||||
const tiktokPixelId = import.meta.env.PUBLIC_TIKTOK_PIXEL_ID
|
||||
const lineChannelId = import.meta.env.PUBLIC_LINE_CHANNEL_ID
|
||||
---
|
||||
|
||||
<!-- Google Analytics 4 -->
|
||||
{ga4Id && (
|
||||
<script
|
||||
data-consent-category="analytics"
|
||||
async
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${ga4Id}`}
|
||||
></script>
|
||||
)}
|
||||
{ga4Id && (
|
||||
<script
|
||||
data-consent-category="analytics"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${ga4Id}');
|
||||
`
|
||||
}}
|
||||
></script>
|
||||
)}
|
||||
|
||||
<!-- Google Tag Manager -->
|
||||
{gtmId && (
|
||||
<script
|
||||
data-consent-category="analytics"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','${gtmId}');
|
||||
`
|
||||
}}
|
||||
></script>
|
||||
)}
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
{umamiUrl && umamiWebsiteId && (
|
||||
<script
|
||||
data-consent-category="analytics"
|
||||
async
|
||||
src={`${umamiUrl}/script.js`}
|
||||
data-website-id={umamiWebsiteId}
|
||||
></script>
|
||||
)}
|
||||
|
||||
<!-- Microsoft Clarity -->
|
||||
{clarityId && (
|
||||
<script
|
||||
data-consent-category="analytics"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function(c,l,a,r,i,t,y){
|
||||
a[q]=a[q]||function(){(a[q].q=a[q].q||[]).push(arguments)};
|
||||
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
|
||||
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
|
||||
})(window, document, "clarity", "script", "${clarityId}");
|
||||
`
|
||||
}}
|
||||
></script>
|
||||
)}
|
||||
|
||||
<!-- Facebook Pixel -->
|
||||
{fbPixelId && (
|
||||
<script
|
||||
data-consent-category="marketing"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
!function(f,b,e,v,n,t,s)
|
||||
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
||||
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
|
||||
n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];
|
||||
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
||||
'https://connect.facebook.net/en_US/fbevents.js');
|
||||
fbq('init', '${fbPixelId}');
|
||||
fbq('track', 'PageView');
|
||||
`
|
||||
}}
|
||||
></script>
|
||||
)}
|
||||
{fbPixelId && (
|
||||
<noscript data-consent-category="marketing">
|
||||
<img
|
||||
height="1"
|
||||
width="1"
|
||||
style="display:none"
|
||||
src={`https://www.facebook.com/tr?id=${fbPixelId}&ev=PageView&noscript=1`}
|
||||
alt=""
|
||||
/>
|
||||
</noscript>
|
||||
)}
|
||||
|
||||
<!-- Google Ads Conversion -->
|
||||
{googleAdsId && (
|
||||
<script
|
||||
data-consent-category="marketing"
|
||||
async
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${googleAdsId}`}
|
||||
></script>
|
||||
)}
|
||||
|
||||
<!-- TikTok Pixel -->
|
||||
{tiktokPixelId && (
|
||||
<script
|
||||
data-consent-category="marketing"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
!function (w, d, t) {
|
||||
w.TiktokAnalyticsObject = t;
|
||||
var ttq = w[t] = w[t] || [];
|
||||
ttq.methods = ["page", "track", "identify", "instances", "debug", "on", "off", "once", "ready", "alias", "group", "enableCookie", "disableCookie"];
|
||||
ttq.setAndDefer = function (t, e) { t[e] = function () { t.push([e].concat(Array.prototype.slice.call(arguments, 0))) } };
|
||||
for (var i = 0; i < ttq.methods.length; i++) ttq.setAndDefer(ttq, ttq.methods[i]);
|
||||
ttq.instance = function (t) {
|
||||
var e = t.slice(0);
|
||||
return ttq.push([e]), ttq
|
||||
};
|
||||
for (var i = 0; i < ttq.methods.length; i++) {
|
||||
var e = ttq.methods[i];
|
||||
ttq[e] = ttq.instance.bind(ttq, e)
|
||||
}
|
||||
ttq.load = function (t, e) {
|
||||
var n = "https://analytics.tiktok.com/i18n/pixel/events.js";
|
||||
n = n + "?sdkid=" + t + "&lib=" + e;
|
||||
var i = d.createElement("script");
|
||||
i.type = "text/javascript";
|
||||
i.src = n;
|
||||
d.getElementsByTagName("head")[0].appendChild(i)
|
||||
};
|
||||
ttq.load("${tiktokPixelId}", "exc");
|
||||
ttq.page()
|
||||
}(window, document, 'ttq');
|
||||
`
|
||||
}}
|
||||
></script>
|
||||
)}
|
||||
|
||||
<!-- LINE Channel Tag -->
|
||||
{lineChannelId && (
|
||||
<script
|
||||
data-consent-category="marketing"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${lineChannelId}');
|
||||
`
|
||||
}}
|
||||
></script>
|
||||
)}
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import "@/styles/global.css"
|
||||
import TrackingScripts from "@/components/TrackingScripts.astro"
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@@ -10,6 +11,9 @@ const {
|
||||
title = "Astro Tina Starter",
|
||||
description = "Astro 6 + Tina CMS starter template",
|
||||
} = Astro.props
|
||||
|
||||
const consentSiteId = import.meta.env.PUBLIC_CONSENT_SITE_ID || 'demo'
|
||||
const consentApiBase = import.meta.env.PUBLIC_CONSENT_API_BASE || 'https://consent.moreminimore.com'
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
@@ -23,5 +27,15 @@ const {
|
||||
</head>
|
||||
<body class="bg-primary-50 text-primary-900 min-h-screen">
|
||||
<slot />
|
||||
|
||||
<!-- Tracking Scripts (ConsentOS will auto-block until consent) -->
|
||||
<TrackingScripts />
|
||||
|
||||
<!-- ConsentOS - Consent Management -->
|
||||
<script
|
||||
src={`${consentApiBase}/consent-loader.js`}
|
||||
data-site-id={consentSiteId}
|
||||
data-api-base={consentApiBase}
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* PDPA Consent Banner Component for Astro + Tina
|
||||
* Replaces cookie-banner.tsx from Next.js+Payload
|
||||
*
|
||||
* Usage: Import and add <ConsentBanner /> to your layout
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
/** Optional: Custom privacy policy URL */
|
||||
privacyPolicyUrl?: string;
|
||||
}
|
||||
|
||||
const { privacyPolicyUrl = "/privacy-policy" } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
id="pdpa-consent-banner"
|
||||
class="consent-banner"
|
||||
role="dialog"
|
||||
aria-label="Cookie Consent Banner"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="consent-banner__content">
|
||||
<!-- Main Banner -->
|
||||
<div id="consent-main" class="consent-banner__main">
|
||||
<h3 class="consent-banner__title">
|
||||
🍪 การยินยอมตาม พ.ร.บ.คุ้มครองข้อมูลส่วนบุคคล
|
||||
</h3>
|
||||
<p class="consent-banner__text">
|
||||
เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ การเข้าชมเว็บไซต์ต่อถือว่าคุณยินยอมให้เราใช้คุกกี้{' '}
|
||||
<a href={privacyPolicyUrl} class="consent-banner__link">เรียนรู้เพิ่มเติม</a>
|
||||
</p>
|
||||
|
||||
<div class="consent-banner__buttons">
|
||||
<button
|
||||
id="consent-accept-all"
|
||||
class="consent-btn consent-btn--accept"
|
||||
type="button"
|
||||
>
|
||||
ยอมรับทั้งหมด
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="consent-reject-all"
|
||||
class="consent-btn consent-btn--reject"
|
||||
type="button"
|
||||
>
|
||||
ปฏิเสธทั้งหมด
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="consent-show-preferences"
|
||||
class="consent-btn consent-btn--preferences"
|
||||
type="button"
|
||||
>
|
||||
ตั้งค่าคุกกี้
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Panel -->
|
||||
<div id="consent-preferences" class="consent-banner__preferences" style="display: none;">
|
||||
<h3 class="consent-banner__title">ตั้งค่าคุกกี้</h3>
|
||||
|
||||
<p class="consent-banner__text" style="margin-bottom: 1rem; color: #555; font-size: 0.875rem;">
|
||||
จัดการการตั้งค่าคุกกี้ของคุณด้านล่าง
|
||||
</p>
|
||||
|
||||
<div class="consent-banner__options">
|
||||
<!-- Functional Cookies -->
|
||||
<div class="consent-option consent-option--disabled">
|
||||
<div class="consent-option__header">
|
||||
<div>
|
||||
<h4 class="consent-option__title">คุกกี้ที่จำเป็น</h4>
|
||||
<p class="consent-option__desc">
|
||||
จำเป็นสำหรับการทำงานของเว็บไซต์ ไม่สามารปิดได้
|
||||
</p>
|
||||
</div>
|
||||
<span class="consent-option__badge">เปิดอยู่เสมอ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Cookies -->
|
||||
<div class="consent-option">
|
||||
<div class="consent-option__header">
|
||||
<div>
|
||||
<h4 class="consent-option__title">คุกกี้วิเคราะห์</h4>
|
||||
<p class="consent-option__desc">
|
||||
ช่วยเราเข้าใจว่าผู้เยี่ยมชมใช้งานเว็บไซต์ของเราอย่างไร
|
||||
</p>
|
||||
</div>
|
||||
<label class="consent-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="consent-analytics"
|
||||
name="analytics"
|
||||
class="consent-checkbox__input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketing Cookies -->
|
||||
<div class="consent-option">
|
||||
<div class="consent-option__header">
|
||||
<div>
|
||||
<h4 class="consent-option__title">คุกกี้การตลาด</h4>
|
||||
<p class="consent-option__desc">
|
||||
ใช้ติดตามผู้เยี่ยมชมข้ามเว็บไซต์เพื่อการโฆษณา
|
||||
</p>
|
||||
</div>
|
||||
<label class="consent-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="consent-marketing"
|
||||
name="marketing"
|
||||
class="consent-checkbox__input"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="consent-banner__buttons">
|
||||
<button
|
||||
id="consent-save-preferences"
|
||||
class="consent-btn consent-btn--save"
|
||||
type="button"
|
||||
>
|
||||
บันทึกการตั้งค่า
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="consent-back"
|
||||
class="consent-btn consent-btn--back"
|
||||
type="button"
|
||||
>
|
||||
กลับ
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.consent-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
|
||||
padding: 1.5rem;
|
||||
z-index: 9999;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.consent-banner__content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.consent-banner__title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.consent-banner__text {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #555;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.consent-banner__link {
|
||||
color: #0066cc;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.consent-banner__link:hover {
|
||||
color: #004499;
|
||||
}
|
||||
|
||||
.consent-banner__buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.consent-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.consent-btn--accept {
|
||||
background-color: #22c55e;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.consent-btn--accept:hover {
|
||||
background-color: #16a34a;
|
||||
}
|
||||
|
||||
.consent-btn--reject {
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.consent-btn--reject:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.consent-btn--preferences {
|
||||
background-color: transparent;
|
||||
color: #0066cc;
|
||||
border: 1px solid #0066cc;
|
||||
}
|
||||
|
||||
.consent-btn--preferences:hover {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
.consent-btn--save {
|
||||
background-color: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.consent-btn--save:hover {
|
||||
background-color: #004499;
|
||||
}
|
||||
|
||||
.consent-btn--back {
|
||||
background-color: transparent;
|
||||
color: #666;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.consent-btn--back:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Preferences Panel */
|
||||
.consent-banner__options {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.consent-option {
|
||||
padding: 1rem;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.consent-option--disabled {
|
||||
background-color: #f9f9f9;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.consent-option__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.consent-option__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.consent-option__desc {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.consent-option__badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: #e5e5e5;
|
||||
color: #666;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.consent-checkbox__input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hide initially via JS */
|
||||
.consent-banner[hidden] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { consentStore, type ConsentState } from './stores/consent';
|
||||
|
||||
// DOM Elements
|
||||
const banner = document.getElementById('pdpa-consent-banner');
|
||||
const mainPanel = document.getElementById('consent-main');
|
||||
const prefsPanel = document.getElementById('consent-preferences');
|
||||
const analyticsCheckbox = document.getElementById('consent-analytics') as HTMLInputElement;
|
||||
const marketingCheckbox = document.getElementById('consent-marketing') as HTMLInputElement;
|
||||
|
||||
// Button handlers
|
||||
const acceptAllBtn = document.getElementById('consent-accept-all');
|
||||
const rejectAllBtn = document.getElementById('consent-reject-all');
|
||||
const showPrefsBtn = document.getElementById('consent-show-preferences');
|
||||
const savePrefsBtn = document.getElementById('consent-save-preferences');
|
||||
const backBtn = document.getElementById('consent-back');
|
||||
|
||||
// Default consent state
|
||||
const defaultConsent: ConsentState = {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'pdpa_consent';
|
||||
|
||||
// Save consent to localStorage and server
|
||||
async function saveConsent(newConsent: ConsentState) {
|
||||
// Save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent));
|
||||
|
||||
// Update nanostore
|
||||
consentStore.set(newConsent);
|
||||
|
||||
// Hide banner
|
||||
if (banner) {
|
||||
banner.setAttribute('hidden', 'true');
|
||||
}
|
||||
|
||||
// Log to server
|
||||
try {
|
||||
await fetch('/api/consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: newConsent.hasConsented ? 'accept' : 'reject',
|
||||
purpose: 'all',
|
||||
analytics: newConsent.analytics,
|
||||
marketing: newConsent.marketing,
|
||||
functional: newConsent.functional,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to log consent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept all cookies
|
||||
acceptAllBtn?.addEventListener('click', () => {
|
||||
saveConsent({
|
||||
analytics: true,
|
||||
marketing: true,
|
||||
functional: true,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Reject all cookies
|
||||
rejectAllBtn?.addEventListener('click', () => {
|
||||
saveConsent({
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Show preferences panel
|
||||
showPrefsBtn?.addEventListener('click', () => {
|
||||
if (mainPanel && prefsPanel) {
|
||||
mainPanel.style.display = 'none';
|
||||
prefsPanel.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Save custom preferences
|
||||
savePrefsBtn?.addEventListener('click', () => {
|
||||
saveConsent({
|
||||
analytics: analyticsCheckbox?.checked ?? false,
|
||||
marketing: marketingCheckbox?.checked ?? false,
|
||||
functional: true, // Always on
|
||||
hasConsented: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
// Back to main panel
|
||||
backBtn?.addEventListener('click', () => {
|
||||
if (mainPanel && prefsPanel) {
|
||||
prefsPanel.style.display = 'none';
|
||||
mainPanel.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Check for existing consent on load
|
||||
function initBanner() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Already consented - hide banner
|
||||
if (banner) {
|
||||
banner.setAttribute('hidden', 'true');
|
||||
}
|
||||
// Sync with nanostore
|
||||
consentStore.set(parsed);
|
||||
} catch {
|
||||
// No valid consent - show banner
|
||||
if (banner) {
|
||||
banner.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No consent yet - show banner
|
||||
if (banner) {
|
||||
banner.removeAttribute('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
initBanner();
|
||||
</script>
|
||||
@@ -1,147 +0,0 @@
|
||||
# PDPA Consent Logging Template
|
||||
|
||||
Template สำหรับเพิ่ม PDPA consent logging ใน Astro + Tina (Astro DB)
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
consent/
|
||||
├── ConsentBanner.astro # Consent banner component
|
||||
├── api/
|
||||
│ └── consent.ts # API endpoints (GET, POST, DELETE)
|
||||
├── db/
|
||||
│ └── config.ts # Astro DB schema (defineTable)
|
||||
├── stores/
|
||||
│ └── consent.ts # Nano Stores for client state
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## วิธีใช้ (Astro)
|
||||
|
||||
### 1. เพิ่ม Astro DB Schema
|
||||
|
||||
Copy `db/config.ts` ไปที่ `src/db/config.ts`:
|
||||
|
||||
```ts
|
||||
// src/db/config.ts
|
||||
import { defineTable, column } from 'astro:db';
|
||||
|
||||
export const ConsentLog = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
action: column.text(),
|
||||
purpose: column.text(),
|
||||
analytics: column.boolean({ default: false }),
|
||||
marketing: column.boolean({ default: false }),
|
||||
functional: column.boolean({ default: false }),
|
||||
userAgent: column.text({ optional: true }),
|
||||
ip: column.text({ optional: true }),
|
||||
timestamp: column.date(),
|
||||
sessionId: column.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. สร้าง API Endpoint
|
||||
|
||||
Copy `api/consent.ts` ไปที่ `src/pages/api/consent.ts`
|
||||
|
||||
### 3. เพิ่ม ConsentBanner Component
|
||||
|
||||
Copy `ConsentBanner.astro` ไปที่ `src/components/consent/ConsentBanner.astro`
|
||||
|
||||
### 4. เพิ่มใน Layout
|
||||
|
||||
เพิ่ม `<ConsentBanner />` ใน `src/layouts/Layout.astro`:
|
||||
|
||||
```astro
|
||||
---
|
||||
import ConsentBanner from '../components/consent/ConsentBanner.astro';
|
||||
---
|
||||
|
||||
<html lang="th">
|
||||
<body>
|
||||
<slot />
|
||||
<ConsentBanner />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### POST /api/consent
|
||||
|
||||
บันทึก consent action
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"action": "accept",
|
||||
"purpose": "all",
|
||||
"analytics": true,
|
||||
"marketing": false,
|
||||
"functional": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"doc": {
|
||||
"id": 1,
|
||||
"action": "accept",
|
||||
"purpose": "all",
|
||||
"analytics": true,
|
||||
"marketing": false,
|
||||
"functional": true,
|
||||
"userAgent": "Mozilla/5.0...",
|
||||
"ip": "127.0.0.1",
|
||||
"timestamp": "2026-04-10T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/consent
|
||||
|
||||
ดึง consent logs
|
||||
|
||||
```bash
|
||||
curl "http://localhost:4321/api/consent"
|
||||
```
|
||||
|
||||
### DELETE /api/consent
|
||||
|
||||
Right to be forgotten (ลบข้อมูลตาม พ.ร.บ.)
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:4321/api/consent?sessionId=xxx"
|
||||
```
|
||||
|
||||
## Nano Stores Usage
|
||||
|
||||
```ts
|
||||
import { consentStore, hasAnalyticsConsent, hasMarketingConsent } from './stores/consent';
|
||||
|
||||
// Subscribe to changes
|
||||
consentStore.subscribe((state) => {
|
||||
console.log('Consent changed:', state);
|
||||
});
|
||||
|
||||
// Check consent
|
||||
if (hasAnalyticsConsent()) {
|
||||
// Load analytics
|
||||
}
|
||||
```
|
||||
|
||||
## UX
|
||||
|
||||
- **ยอมรับทั้งหมด** - เปิดทุกคุกกี้
|
||||
- **ปฏิเสธทั้งหมด** - ปิดทุกคุกกี้ (ยกเว้น functional)
|
||||
- **ตั้งค่าคุกกี้** - แผงปรับแต่งเอง
|
||||
|
||||
## ⚠️ Pitfalls สำคัญ
|
||||
|
||||
1. **Astro DB ต้องรันบน server-side** - ใช้ `APIRoute` import
|
||||
2. **Nano Stores รันบน client-side** - ใช้ `<script>` tag ใน Astro
|
||||
3. **import ถูกต้อง** - ใช้ `import { db } from 'astro:db'` ไม่ใช่ `defineDb`
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { db, eq } from 'astro:db';
|
||||
import { ConsentLog } from '../db/config';
|
||||
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const {
|
||||
action = 'accept',
|
||||
purpose = 'all',
|
||||
analytics = false,
|
||||
marketing = false,
|
||||
functional = false,
|
||||
} = body;
|
||||
|
||||
const ip = clientAddress || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || 'unknown';
|
||||
|
||||
const doc = await db.insert(ConsentLog).values({
|
||||
action,
|
||||
purpose,
|
||||
analytics,
|
||||
marketing,
|
||||
functional,
|
||||
ip,
|
||||
userAgent,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
doc,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Consent API error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to log consent',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
|
||||
let docs;
|
||||
if (sessionId) {
|
||||
docs = await db.select().from(ConsentLog).where(
|
||||
eq(ConsentLog.sessionId, sessionId)
|
||||
);
|
||||
} else {
|
||||
docs = await db.select().from(ConsentLog);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
docs,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Consent GET error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to retrieve consent logs',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DELETE: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'sessionId is required',
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const deleted = await db.delete(ConsentLog).where(
|
||||
eq(ConsentLog.sessionId, sessionId)
|
||||
);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
deleted,
|
||||
message: 'All consent records for this session have been deleted',
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Right to be forgotten error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: 'Failed to delete consent records',
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { defineDb, defineTable, column } from 'astro:db';
|
||||
|
||||
const ConsentLog = defineTable({
|
||||
columns: {
|
||||
id: column.number({ primaryKey: true }),
|
||||
action: column.text(),
|
||||
purpose: column.text(),
|
||||
analytics: column.boolean({ default: false }),
|
||||
marketing: column.boolean({ default: false }),
|
||||
functional: column.boolean({ default: false }),
|
||||
userAgent: column.text({ optional: true }),
|
||||
ip: column.text({ optional: true }),
|
||||
timestamp: column.date(),
|
||||
sessionId: column.text({ optional: true }),
|
||||
},
|
||||
});
|
||||
|
||||
export type ConsentAction = 'accept' | 'reject' | 'update';
|
||||
export type ConsentPurpose = 'analytics' | 'marketing' | 'functional' | 'all';
|
||||
|
||||
export interface ConsentRow {
|
||||
id: number;
|
||||
action: ConsentAction;
|
||||
purpose: ConsentPurpose;
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export default defineDb({
|
||||
tables: {
|
||||
ConsentLog,
|
||||
},
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { map } from 'nanostores';
|
||||
|
||||
export interface ConsentState {
|
||||
analytics: boolean;
|
||||
marketing: boolean;
|
||||
functional: boolean;
|
||||
hasConsented: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ConsentLogData extends ConsentState {
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export const defaultConsent: ConsentState = {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
functional: false,
|
||||
hasConsented: false,
|
||||
};
|
||||
|
||||
export const consentStore = map<ConsentState>(defaultConsent);
|
||||
export const STORAGE_KEY = 'pdpa_consent';
|
||||
|
||||
export function loadConsent(): ConsentState {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return defaultConsent;
|
||||
}
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as ConsentState;
|
||||
consentStore.set(parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
return defaultConsent;
|
||||
}
|
||||
}
|
||||
return defaultConsent;
|
||||
}
|
||||
|
||||
export function saveConsentLocally(state: ConsentState): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
consentStore.set(state);
|
||||
}
|
||||
|
||||
export function hasAnalyticsConsent(): boolean {
|
||||
const state = consentStore.get();
|
||||
return state.hasConsented && state.analytics;
|
||||
}
|
||||
|
||||
export function hasMarketingConsent(): boolean {
|
||||
const state = consentStore.get();
|
||||
return state.hasConsented && state.marketing;
|
||||
}
|
||||
|
||||
export function hasFunctionalConsent(): boolean {
|
||||
const state = consentStore.get();
|
||||
return state.hasConsented;
|
||||
}
|
||||
|
||||
export function resetConsent(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
consentStore.set(defaultConsent);
|
||||
}
|
||||
|
||||
export function hasConsented(): boolean {
|
||||
return consentStore.get().hasConsented;
|
||||
}
|
||||
Reference in New Issue
Block a user