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:
2026-04-21 21:19:32 +07:00
parent d1edc9cd6c
commit c38cc4ae26
19 changed files with 378 additions and 1099 deletions

View File

@@ -522,54 +522,58 @@ const { post } = Astro.props;
2. **Terms of Service** — ใช้ `templates/terms-of-service.md` 2. **Terms of Service** — ใช้ `templates/terms-of-service.md`
3. **Consent System** — ใช้ consent script จาก `consent.moreminimore.com` 3. **ConsentOS + Tracking Scripts** — ใช้ `src/components/TrackingScripts.astro`
**Environment Variables สำหรับ Consent:** **Template มี TrackingScripts.astro ที่รองรับ:**
| Category | Script | ENV Variable |
|----------|--------|-------------|
| Analytics | Google Analytics 4 | `PUBLIC_GA4_ID` |
| Analytics | Google Tag Manager | `PUBLIC_GTM_ID` |
| Analytics | Umami | `PUBLIC_UMAMI_URL`, `PUBLIC_UMAMI_WEBSITE_ID` |
| Analytics | Microsoft Clarity | `PUBLIC_CLARITY_ID` |
| Marketing | Facebook Pixel | `PUBLIC_FB_PIXEL_ID` |
| Marketing | Google Ads | `PUBLIC_GOOGLE_ADS_ID` |
| Marketing | TikTok Pixel | `PUBLIC_TIKTOK_PIXEL_ID` |
| Marketing | LINE Channel Tag | `PUBLIC_LINE_CHANNEL_ID` |
**Environment Variables:**
```bash ```bash
# .env # ConsentOS
CONSENT_SITE_ID=your-site-id-here PUBLIC_CONSENT_SITE_ID=your-consent-site-id
CONSENT_API_BASE=https://consent.moreminimore.com 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
``` ```
**เพิ่ม Consent Script ใน Layout:** **Tracking Flow:**
```astro ```
--- TrackingScripts.astro (data-consent-category attributes)
// src/layouts/Layout.astro
const { title, description } = Astro.props; ConsentOS consent-loader.js (scan + auto-block)
const consentSiteId = import.meta.env.PUBLIC_CONSENT_SITE_ID || 'demo';
const consentApiBase = import.meta.env.PUBLIC_CONSENT_API_BASE || 'https://consent.moreminimore.com'; Scripts execute only after user consent
---
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
</head>
<body>
<slot />
<!-- Consent Script -->
<script
src={`${consentApiBase}/consent-loader.js`}
data-site-id={consentSiteId}
data-api-base={consentApiBase}
></script>
</body>
</html>
``` ```
**Consent Options:** **Consent Categories:**
- Accept All / Reject All / Customize - **Analytics** — GA4, GTM, Umami, Clarity
- ถ้า reject → ไม่ load GA4/marketing scripts - **Marketing** — Facebook Pixel, TikTok, LINE, Google Ads
- Server-side logging ใน consent service ของตัวเอง
**3.1 Right to be Forgotten** **Right to be Forgotten:**
- Consent service มี API สำหรับลบข้อมูล user - ConsentOS มี API สำหรับลบข้อมูล user
--- ---

View File

@@ -6,8 +6,7 @@
# #
# This script migrates websites to Astro + Tina CMS: # This script migrates websites to Astro + Tina CMS:
# - Converts content to Tina CMS format # - Converts content to Tina CMS format
# - Sets up Astro DB for consent logging # - Sets up external consent system integration
# - Adds PDPA-compliant consent system
# - Preserves content and structure # - Preserves content and structure
# #
# Requirements: # Requirements:
@@ -49,8 +48,7 @@ Examples:
Features: Features:
- Detects source website technology (Astro, Next.js, etc.) - Detects source website technology (Astro, Next.js, etc.)
- Converts content to Tina CMS format - Converts content to Tina CMS format
- Sets up Astro DB for consent logging (PDPA compliant) - Sets up external consent system integration
- Adds cookie consent banner with Thai law compliance
- Preserves SEO metadata and content structure - Preserves SEO metadata and content structure
EOF EOF
@@ -160,22 +158,6 @@ migrate_content() {
log_success "Content migration complete" log_success "Content migration complete"
} }
add_consent_system() {
log_info "Adding PDPA-compliant consent system..."
local consent_template="$(dirname "$(dirname "$(readlink -f "$0")")")/templates/consent"
if [ ! -d "$consent_template" ]; then
log_warning "Consent template not found, skipping"
return
fi
# Copy consent files
cp -r "$consent_template"/* "$TARGET_PATH/src/components/consent/" 2>/dev/null || true
log_success "Consent system added"
}
create_tina_schema() { create_tina_schema() {
log_info "Creating Tina CMS schema..." log_info "Creating Tina CMS schema..."
@@ -302,25 +284,14 @@ Self-hosted Git-based CMS for visual content editing.
### ✅ Tailwind CSS 4.x ### ✅ Tailwind CSS 4.x
Latest Tailwind with @tailwindcss/vite plugin. Latest Tailwind with @tailwindcss/vite plugin.
### ✅ Astro DB ### ✅ External Consent System
Built-in database for consent logging and dynamic content. Integration with consent.moreminimore.com for PDPA compliance.
### ✅ PDPA Consent System
Thai Personal Data Protection Act compliant cookie consent:
- Cookie banner with Accept/Reject/Preferences
- Consent logging in Astro DB
- API endpoint for consent management
### ✅ Nano Stores
Lightweight client-side state management.
## Project Structure ## Project Structure
\`\`\` \`\`\`
$TARGET_PATH/ $TARGET_PATH/
├── src/ ├── src/
│ ├── components/
│ │ └── consent/ # PDPA consent system
│ ├── content/ │ ├── content/
│ │ ├── posts/ # Blog posts (Tina managed) │ │ ├── posts/ # Blog posts (Tina managed)
│ │ └── pages/ # Static pages (Tina managed) │ │ └── pages/ # Static pages (Tina managed)
@@ -332,8 +303,6 @@ $TARGET_PATH/
│ └── global.css │ └── global.css
├── .tina/ ├── .tina/
│ └── schema.ts # Tina content schema │ └── schema.ts # Tina content schema
├── db/
│ └── config.ts # Astro DB config
├── Dockerfile ├── Dockerfile
└── AGENTS.md # AI agent instructions └── AGENTS.md # AI agent instructions
\`\`\` \`\`\`
@@ -377,16 +346,6 @@ This will install:
- Auth.js for authentication - Auth.js for authentication
- Database adapter for content storage - Database adapter for content storage
- Git provider for content management - Git provider for content management
## PDPA Compliance
The consent system logs:
- User consent choices (accept/reject)
- Cookie categories (analytics, marketing, functional)
- Timestamp and user agent
- IP address (for compliance auditing)
Logs are stored in Astro DB and can be exported for compliance reporting.
EOF EOF
log_success "Migration report: $TARGET_PATH/MIGRATION_REPORT.md" log_success "Migration report: $TARGET_PATH/MIGRATION_REPORT.md"
@@ -423,7 +382,6 @@ main() {
analyze_source_content analyze_source_content
copy_template copy_template
migrate_content migrate_content
add_consent_system
create_tina_schema create_tina_schema
create_migration_report create_migration_report

View File

@@ -36,8 +36,7 @@ Creates:
- Astro 6.1.7 framework - Astro 6.1.7 framework
- Tailwind CSS 4.x - Tailwind CSS 4.x
- Tina CMS (self-hosted) - Tina CMS (self-hosted)
- Astro DB for consent logging - External consent system integration
- PDPA-compliant consent system
EOF EOF
} }
@@ -105,26 +104,6 @@ copy_template() {
log_success "Template copied" log_success "Template copied"
} }
copy_consent_system() {
log_info "Adding PDPA consent system..."
local consent_template="$SKILL_DIR/templates/consent"
if [ -d "$consent_template" ]; then
mkdir -p "$PROJECT_PATH/src/components/consent"
cp "$consent_template/ConsentBanner.astro" "$PROJECT_PATH/src/components/consent/" 2>/dev/null || true
cp "$consent_template/stores/"* "$PROJECT_PATH/src/stores/" 2>/dev/null || true
mkdir -p "$PROJECT_PATH/src/pages/api"
cp "$consent_template/api/consent.ts" "$PROJECT_PATH/src/pages/api/" 2>/dev/null || true
mkdir -p "$PROJECT_PATH/db"
cp "$consent_template/db/config.ts" "$PROJECT_PATH/db/" 2>/dev/null || true
fi
log_success "Consent system added"
}
copy_legal_templates() { copy_legal_templates() {
log_info "Copying PDPA legal templates..." log_info "Copying PDPA legal templates..."
@@ -163,6 +142,9 @@ setup_environment() {
cat > .env << 'EOF' cat > .env << 'EOF'
PUBLIC_SITE_URL=http://localhost:4321 PUBLIC_SITE_URL=http://localhost:4321
TINA_TOKEN=your-tina-token TINA_TOKEN=your-tina-token
TINA_CLIENT_ID=your-client-id
PUBLIC_CONSENT_SITE_ID=your-consent-site-id
PUBLIC_CONSENT_API_BASE=https://consent.moreminimore.com
EOF EOF
log_success "Created default .env" log_success "Created default .env"
fi fi
@@ -203,7 +185,6 @@ main() {
check_requirements check_requirements
setup_directory setup_directory
copy_template copy_template
copy_consent_system
copy_legal_templates copy_legal_templates
install_dependencies install_dependencies
setup_environment setup_environment

View File

@@ -7,11 +7,36 @@ PUBLIC_SITE_URL=https://your-domain.com
TINA_TOKEN=your-tina-token-from-tina-cloud TINA_TOKEN=your-tina-token-from-tina-cloud
TINA_CLIENT_ID=your-client-id TINA_CLIENT_ID=your-client-id
# Astro DB - Persistent Storage # ConsentOS - Consent Management (moreminimore.com)
# IMPORTANT: Set OUT_DIR to /data for Docker/Easypanel persistent volume PUBLIC_CONSENT_SITE_ID=your-consent-site-id
# This ensures database persists across redeployments PUBLIC_CONSENT_API_BASE=https://consent.moreminimore.com
OUT_DIR=/data
# Optional: External Turso database (if using external DB instead of local SQLite) # ===== TRACKING SCRIPTS =====
# TURSO_DATABASE_URL=libsql://your-db.turso.io # All tracking scripts are blocked by ConsentOS until user gives consent
# TURSO_AUTH_TOKEN=your-auth-token
# --- 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

View File

@@ -1,7 +1,7 @@
# Astro Tina Starter - Agent Knowledge Base # Astro Tina Starter - Agent Knowledge Base
**Generated:** 2026-04-17 **Generated:** 2026-04-17
**Version:** 1.0.0 **Version:** 2.0.0
**Type:** Astro 6 + Tina CMS Starter Template **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 | | Framework | Astro | 6.1.7 |
| CMS | Tina CMS | 2.x | | CMS | Tina CMS | 2.x |
| Styling | Tailwind CSS | 4.x | | Styling | Tailwind CSS | 4.x |
| Database | Astro DB | 0.14.x |
| State | Nano Stores | 0.11.x |
### Key Features ### Key Features
- Self-hosted Tina CMS with schema-based content - Self-hosted Tina CMS with schema-based content
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin - 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 - Thai language support with Noto Sans Thai
- Docker-ready deployment - Docker-ready with nginx
--- ---
@@ -37,9 +35,6 @@ astro-tina-starter/
├── .tina/ ├── .tina/
│ ├── config.ts # Tina CMS configuration │ ├── config.ts # Tina CMS configuration
│ └── schema.ts # Content schema definitions │ └── schema.ts # Content schema definitions
├── db/
│ ├── config.ts # Astro DB schema
│ └── seed.ts # Database seed script
├── src/ ├── src/
│ ├── styles/ │ ├── styles/
│ │ └── global.css # Tailwind v4 styles + @theme │ │ └── global.css # Tailwind v4 styles + @theme
@@ -48,7 +43,8 @@ astro-tina-starter/
│ ├── pages/ │ ├── pages/
│ │ └── index.astro │ │ └── index.astro
│ ├── components/ │ ├── components/
│ │ ── Header.astro │ │ ── Header.astro
│ │ └── TrackingScripts.astro # Tracking scripts (GA4, FB Pixel, etc.)
│ └── content/ │ └── content/
│ ├── config.ts # Astro content collections │ ├── config.ts # Astro content collections
│ ├── posts/ # Blog posts (MDX) │ ├── posts/ # Blog posts (MDX)
@@ -57,6 +53,7 @@ astro-tina-starter/
├── public/ ├── public/
│ └── favicon.svg │ └── favicon.svg
├── Dockerfile ├── Dockerfile
├── nginx.conf
├── astro.config.mjs ├── astro.config.mjs
├── tsconfig.json ├── tsconfig.json
└── package.json └── package.json
@@ -100,9 +97,33 @@ Tina CMS manages content in `src/content/`:
Schema defined in `.tina/schema.ts`. 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_TOKEN` | Tina CMS production authentication |
| `TINA_CLIENT_ID` | Tina CMS client ID | | `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 npm install
# Development # Development
npm run dev # Full dev (Tina + Astro) npm run dev
npm run dev:astro # Astro only
npm run dev:tina # Tina CMS only
# Build # Build
npm run build # Production build npm run build
npm run preview # Preview production build
# Database # Preview
npm run db:push # Push schema to database npm run preview
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(),
},
})
``` ```
--- ---
@@ -181,6 +186,10 @@ docker build -t astro-tina-starter .
docker run -p 8080:80 astro-tina-starter docker run -p 8080:80 astro-tina-starter
``` ```
### Easypanel
Static hosting - no persistent volume needed.
### Manual ### Manual
```bash ```bash

View File

@@ -1,3 +1,4 @@
# Build stage
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
@@ -7,19 +8,17 @@ RUN npm install
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:20-alpine AS runner # Static files stage
FROM nginx:alpine AS runner
WORKDIR /app WORKDIR /app
RUN mkdir -p /data
# Copy static files from builder
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
RUN npm install --omit=dev
ENV HOST=0.0.0.0 # Copy nginx config
ENV PORT=4321 COPY nginx.conf /etc/nginx/http.d/default.conf
ENV NODE_ENV=production
EXPOSE 4321 EXPOSE 80
CMD ["node", "dist/server/entry.mjs"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -4,21 +4,17 @@ Astro 6.1.7 + Tina CMS starter template with Tailwind CSS 4.x
## Tech Stack ## Tech Stack
- **Framework:** Astro 6.1.7 (SSR mode) - **Framework:** Astro 6.1.7 (static mode)
- **CMS:** Tina CMS (self-hosted) - **CMS:** Tina CMS (self-hosted)
- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite` - **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite`
- **Database:** Astro DB (LibSQL/SQLite)
- **Adapter:** @astrojs/node (SSR)
- **State:** Nano Stores + React
- **Language:** TypeScript - **Language:** TypeScript
## Features ## Features
- Self-hosted Tina CMS with schema-based content - Self-hosted Tina CMS with schema-based content
- Tailwind CSS 4.x using `@tailwindcss/vite` plugin - Tailwind CSS 4.x using `@tailwindcss/vite` plugin
- Astro DB for consent logging (PDPA compliant) - ConsentOS + Tracking Scripts (GA4, Facebook Pixel, etc.)
- SSR mode for API routes - Docker-ready with nginx
- Docker-ready with persistent storage
- Thai language support foundation - Thai language support foundation
## Quick Start ## Quick Start
@@ -41,40 +37,36 @@ During development, access Tina CMS at:
For production, you'll need a TINA_TOKEN environment variable. For production, you'll need a TINA_TOKEN environment variable.
## Easypanel Deployment ## ConsentOS + Tracking
This template is designed for **Easypanel** with persistent volume support. This template includes ConsentOS (`consent.moreminimore.com`) for PDPA-compliant consent management and auto-blocking of tracking scripts.
### 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
### Environment Variables ### Environment Variables
```bash ```bash
# Required for persistent storage # ConsentOS
OUT_DIR=/data PUBLIC_CONSENT_SITE_ID=your-site-id
PUBLIC_CONSENT_API_BASE=https://consent.moreminimore.com
# Optional - Tina CMS # Analytics
TINA_TOKEN=your-tina-token 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) # Marketing
# TURSO_DATABASE_URL=libsql://your-db.turso.io PUBLIC_FB_PIXEL_ID=123456789
# TURSO_AUTH_TOKEN=your-auth-token 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 1. `TrackingScripts.astro` contains all tracking codes with `data-consent-category` attributes
2. Set build command: `npm run build` 2. ConsentOS `consent-loader.js` scans and auto-blocks scripts until user consent
3. Set start command: `node dist/server/entry.mjs` 3. Categories: `analytics`, `marketing`
4. Add environment variable: `OUT_DIR=/data`
5. Mount persistent volume to `/data`
## Project Structure ## Project Structure
@@ -83,22 +75,20 @@ astro-tina-starter/
├── .tina/ ├── .tina/
│ ├── config.ts # Tina CMS configuration │ ├── config.ts # Tina CMS configuration
│ └── schema.ts # Content schema definitions │ └── schema.ts # Content schema definitions
├── db/
│ ├── config.ts # Astro DB schema (consent logs)
│ └── seed.ts # Database seed script
├── src/ ├── src/
│ ├── styles/ │ ├── styles/
│ │ └── global.css # Tailwind v4 styles │ │ └── global.css # Tailwind v4 styles
│ ├── layouts/ │ ├── layouts/
│ │ └── Layout.astro │ │ └── Layout.astro
│ ├── pages/ │ ├── pages/
│ │ ── index.astro │ │ ── index.astro
│ │ └── api/ # API routes (consent, etc.)
│ ├── components/ │ ├── components/
│ │ ── Header.astro │ │ ── Header.astro
│ │ └── TrackingScripts.astro # Tracking scripts
│ └── content/ │ └── content/
│ └── config.ts # Tina content collections │ └── config.ts # Tina content collections
├── Dockerfile # Multi-stage Node.js (not nginx) ├── Dockerfile
├── nginx.conf
└── package.json └── 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 # Run
// db/config.ts docker run -p 8080:80 astro-tina
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 } });
``` ```
Database file location: `/data/astro.db` (persistent across redeployments)
## License ## License
MIT MIT

View File

@@ -1,13 +1,13 @@
import { defineConfig } from 'astro/config' import { defineConfig } from 'astro/config'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import tina from 'tinacms' import tina from 'tinacms'
import node from '@astrojs/node'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import path from 'path' import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({ export default defineConfig({
site: 'https://example.com',
integrations: [ integrations: [
tina({ tina({
enabled: !!process.env.TINA_TOKEN, enabled: !!process.env.TINA_TOKEN,
@@ -28,10 +28,7 @@ export default defineConfig({
}, },
}, },
}, },
output: 'server', output: 'static',
adapter: node({
mode: 'standalone'
}),
build: { build: {
assets: '_assets', assets: '_assets',
}, },

View File

@@ -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,
},
});

View File

@@ -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')
}

View File

@@ -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";
}
}

View File

@@ -1,7 +1,7 @@
{ {
"name": "astro-tina-starter", "name": "astro-tina-starter",
"type": "module", "type": "module",
"version": "1.0.0", "version": "2.0.0",
"description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x", "description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x",
"scripts": { "scripts": {
"dev": "tinacms dev --port 3001 & astro dev", "dev": "tinacms dev --port 3001 & astro dev",
@@ -9,19 +9,14 @@
"dev:tina": "tinacms dev --port 3001", "dev:tina": "tinacms dev --port 3001",
"build": "tinacms build && astro build", "build": "tinacms build && astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro"
"db:push": "astro db push",
"db:seed": "astro db seed"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/check": "^0.9.4",
"@astrojs/db": "^0.14.3", "@astrojs/mdx": "^4.0.0",
"@astrojs/node": "^9.0.0",
"@nanostores/react": "^0.7.3",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"astro": "^6.1.7", "astro": "^6.1.7",
"nanostores": "^0.11.3",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",

View File

@@ -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>
)}

View File

@@ -1,5 +1,6 @@
--- ---
import "@/styles/global.css" import "@/styles/global.css"
import TrackingScripts from "@/components/TrackingScripts.astro"
interface Props { interface Props {
title?: string title?: string
@@ -10,6 +11,9 @@ const {
title = "Astro Tina Starter", title = "Astro Tina Starter",
description = "Astro 6 + Tina CMS starter template", description = "Astro 6 + Tina CMS starter template",
} = Astro.props } = 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> <!doctype html>
@@ -23,5 +27,15 @@ const {
</head> </head>
<body class="bg-primary-50 text-primary-900 min-h-screen"> <body class="bg-primary-50 text-primary-900 min-h-screen">
<slot /> <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> </body>
</html> </html>

View File

@@ -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>

View File

@@ -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`

View File

@@ -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' },
});
}
};

View File

@@ -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,
},
});

View File

@@ -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;
}