feat: migrate website-creator from Next.js+Payload to Astro+Tina CMS

Major changes:
- Replace Payload CMS with Tina CMS (self-hosted)
- Add Astro DB for consent logging (PDPA compliant)
- Update Tailwind v3 to v4 (@tailwindcss/vite plugin)
- Add astro-tina-starter template
- Rewrite consent template for Astro (ConsentBanner.astro, Astro DB, Nano Stores)
- Add install-tina-backend.sh for self-hosted Tina per customer
- Rename convert-astro.sh to migrate-tina.sh
- Add AGENTS.md template for generated websites
- Delete all Payload/Next.js files

Technical updates:
- Astro DB using defineDb with eq operators for queries
- Tailwind v4 with @theme block
- Tina CMS local development mode
- Proper Astro API routes for consent

Research-verified with official documentation (April 2026)
This commit is contained in:
2026-04-17 14:52:59 +07:00
parent ce8483e546
commit 628298183a
74 changed files with 3536 additions and 11431 deletions

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'tinacms';
import { schema } from './schema';
export default defineConfig({
schema,
ui: {
navigation: {
'content/posts': { label: 'Posts' },
'content/pages': { label: 'Pages' },
},
},
media: {
tina: {
publicFolder: 'public',
mediaRoot: 'uploads',
},
},
});

View File

@@ -0,0 +1,91 @@
import { defineSchema } from 'tinacms'
export const schema = defineSchema({
collections: [
{
name: 'post',
label: 'Posts',
path: 'src/content/posts',
format: 'mdx',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
required: true,
},
{
type: 'string',
name: 'description',
label: 'Description',
},
{
type: 'datetime',
name: 'publishedAt',
label: 'Published At',
},
{
type: 'string',
name: 'category',
label: 'Category',
options: ['news', 'blog', 'tutorial'],
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
{
name: 'page',
label: 'Pages',
path: 'src/content/pages',
format: 'mdx',
fields: [
{
type: 'string',
name: 'title',
label: 'Title',
required: true,
},
{
type: 'string',
name: 'description',
label: 'Description',
},
{
type: 'rich-text',
name: 'body',
label: 'Body',
isBody: true,
},
],
},
{
name: 'settings',
label: 'Settings',
path: 'src/content/settings',
format: 'json',
fields: [
{
type: 'string',
name: 'siteName',
label: 'Site Name',
},
{
type: 'string',
name: 'siteDescription',
label: 'Site Description',
},
{
type: 'string',
name: 'language',
label: 'Language',
options: ['th', 'en', 'th-en'],
},
],
},
],
})

View File

@@ -0,0 +1,198 @@
# Astro Tina Starter - Agent Knowledge Base
**Generated:** 2026-04-17
**Version:** 1.0.0
**Type:** Astro 6 + Tina CMS Starter Template
---
## OVERVIEW
Starter template for building websites with Astro 6, Tina CMS, and Tailwind CSS 4.x.
### Tech Stack
| Component | Technology | Version |
|-----------|------------|---------|
| 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)
- Thai language support with Noto Sans Thai
- Docker-ready deployment
---
## PROJECT STRUCTURE
```
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
│ ├── layouts/
│ │ └── Layout.astro
│ ├── pages/
│ │ └── index.astro
│ ├── components/
│ │ └── Header.astro
│ └── content/
│ ├── config.ts # Astro content collections
│ ├── posts/ # Blog posts (MDX)
│ ├── pages/ # Static pages (MDX)
│ └── settings/ # Site settings (JSON)
├── public/
│ └── favicon.svg
├── Dockerfile
├── astro.config.mjs
├── tsconfig.json
└── package.json
```
---
## IMPORTANT CONVENTIONS
### Tailwind CSS 4.x Setup
**CRITICAL:** This template uses `@tailwindcss/vite` plugin, NOT `@astrojs/tailwind`.
```javascript
// astro.config.mjs
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
})
```
```css
/* src/styles/global.css */
@import "tailwindcss";
@theme {
--color-primary: #1a1a1a;
--color-accent: #3b82f6;
}
```
### Tina CMS Content
Tina CMS manages content in `src/content/`:
- `posts/` - Blog posts (MDX format)
- `pages/` - Static pages (MDX format)
- `settings/` - Site settings (JSON format)
Schema defined in `.tina/schema.ts`.
### Astro DB Schema
Consent log table for PDPA compliance in `db/config.ts`.
---
## CREDENTIALS
No external API credentials required for this template.
### Optional Environment Variables
| Variable | Description |
|----------|-------------|
| `TINA_TOKEN` | Tina CMS production authentication |
| `TINA_CLIENT_ID` | Tina CMS client ID |
| `DATABASE_URL` | Custom database connection (optional) |
---
## COMMANDS
```bash
# Install dependencies
npm install
# Development
npm run dev # Full dev (Tina + Astro)
npm run dev:astro # Astro only
npm run dev:tina # Tina CMS only
# Build
npm run build # Production build
npm run preview # Preview production 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(),
},
})
```
---
## ANTI-PATTERNS
- **NEVER** use `@astrojs/tailwind` (deprecated)
- **ALWAYS** use `@tailwindcss/vite` for Tailwind v4
- **NEVER** commit environment files (.env)
---
## DEPLOYMENT
### Docker
```bash
docker build -t astro-tina-starter .
docker run -p 8080:80 astro-tina-starter
```
### Manual
```bash
npm install
npm run build
# Serve dist/ folder with any static server
```
---
## NOTES
- Tina CMS admin: http://localhost:4321/admin
- Astro default port: 4321
- Tina dev server: 3001

View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,104 @@
# Astro Tina Starter
Astro 6.1.7 + Tina CMS starter template with Tailwind CSS 4.x
## Tech Stack
- **Framework:** Astro 6.1.7
- **CMS:** Tina CMS (self-hosted)
- **Styling:** Tailwind CSS 4.x with `@tailwindcss/vite`
- **Database:** Astro DB (LibSQL)
- **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)
- Nano Stores for client-side state management
- Thai language support foundation
- Docker-ready deployment
## Quick Start
```bash
# Install dependencies
npm install
# Start development
npm run dev
# Build for production
npm run build
```
## Tina CMS Access
During development, access Tina CMS at:
- http://localhost:4321/admin
For production, you'll need a TINA_TOKEN environment variable.
## Project Structure
```
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
│ ├── components/
│ │ └── Header.astro
│ └── content/
│ └── config.ts # Tina content collections
├── Dockerfile
└── package.json
```
## Tailwind CSS 4.x
This template uses Tailwind CSS 4.x with the `@tailwindcss/vite` plugin.
The configuration is done via CSS `@theme` block in `src/styles/global.css`.
```css
@import "tailwindcss";
@theme {
--color-primary: #1a1a1a;
--color-accent: #3b82f6;
}
```
## Astro DB
The template includes a consent-log table for PDPA compliance:
```ts
// db/config.ts
export const ConsentLog = defineTable({
columns: {
action: text(),
purpose: text(),
analytics: boolean(),
marketing: boolean(),
functional: boolean(),
userAgent: text(),
ip: text(),
timestamp: text(),
},
})
```
## License
MIT

View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'astro/config'
import tailwindcss from '@tailwindcss/vite'
import tina from 'tinacms'
import { fileURLToPath } from 'url'
import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
integrations: [
tina({
enabled: !!process.env.TINA_TOKEN,
sidebar: {
partials: [],
},
}),
],
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components'),
'@layouts': path.resolve(__dirname, './src/layouts'),
'@styles': path.resolve(__dirname, './src/styles'),
'@content': path.resolve(__dirname, './src/content'),
},
},
},
output: 'static',
build: {
assets: '_assets',
},
server: {
port: 4321,
},
})

View File

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,7 @@
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,38 @@
{
"name": "astro-tina-starter",
"type": "module",
"version": "1.0.0",
"description": "Astro 6 + Tina CMS starter template with Tailwind CSS 4.x",
"scripts": {
"dev": "tinacms dev --port 3001 & astro dev",
"dev:astro": "astro dev",
"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"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/db": "^0.14.3",
"@nanostores/react": "^0.7.3",
"@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",
"tina": "^2.1.4",
"tinacms": "^2.2.4",
"typescript": "^5.6.3"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#1d4ed8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="6" fill="url(#grad)"/>
<text x="16" y="22" font-family="Arial, sans-serif" font-size="16" font-weight="bold" fill="white" text-anchor="middle">A</text>
</svg>

After

Width:  |  Height:  |  Size: 473 B

View File

@@ -0,0 +1,27 @@
---
interface Props {
siteName?: string
}
const { siteName = "Astro Tina Starter" } = Astro.props
---
<header class="sticky top-0 z-50 bg-white/80 backdrop-blur-md border-b border-primary-200">
<nav class="max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<a href="/" class="font-bold text-xl text-primary-900 hover:text-accent-600 transition-colors">
{siteName}
</a>
<div class="flex items-center gap-6">
<a href="/" class="text-primary-600 hover:text-primary-900 transition-colors">
Home
</a>
<a href="/blog" class="text-primary-600 hover:text-primary-900 transition-colors">
Blog
</a>
<a href="/about" class="text-primary-600 hover:text-primary-900 transition-colors">
About
</a>
</div>
</nav>
</header>

View File

@@ -0,0 +1,34 @@
import { defineCollection, z } from "astro:content"
const postCollection = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string().optional(),
publishedAt: z.date().optional(),
category: z.enum(["news", "blog", "tutorial"]).optional(),
}),
})
const pageCollection = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string().optional(),
}),
})
const settingsCollection = defineCollection({
type: "data",
schema: z.object({
siteName: z.string(),
siteDescription: z.string(),
language: z.enum(["th", "en", "th-en"]).default("th"),
}),
})
export const collections = {
posts: postCollection,
pages: pageCollection,
settings: settingsCollection,
}

View File

@@ -0,0 +1,17 @@
---
title: Welcome to Astro Tina Starter
description: A modern starter template with Astro 6, Tina CMS, and Thai language support.
publishedAt: 2026-04-17
category: blog
---
Welcome to our new blog built with Astro and Tina CMS!
## Features
- **Tina CMS** - Self-hosted content management
- **Tailwind CSS v4** - Latest styling with @tailwindcss/vite
- **Astro DB** - Built-in database support
- **Thai Support** - Ready for Thai language content
Stay tuned for more updates!

View File

@@ -0,0 +1,5 @@
{
"siteName": "Astro Tina Starter",
"siteDescription": "Astro 6 + Tina CMS starter template with Thai language support",
"language": "th"
}

View File

@@ -0,0 +1,27 @@
---
import "@/styles/global.css"
interface Props {
title?: string
description?: string
}
const {
title = "Astro Tina Starter",
description = "Astro 6 + Tina CMS starter template",
} = Astro.props
---
<!doctype html>
<html lang="th">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-primary-50 text-primary-900 min-h-screen">
<slot />
</body>
</html>

View File

@@ -0,0 +1,47 @@
---
import Layout from "@/layouts/Layout.astro"
---
<Layout>
<main>
<section class="px-6 py-24 max-w-4xl mx-auto">
<h1 class="text-4xl md:text-5xl font-bold tracking-tight mb-6">
Welcome to Astro Tina Starter
</h1>
<p class="text-lg text-primary-600 mb-8 max-w-2xl">
A modern starter template with Astro 6, Tina CMS, Tailwind CSS 4.x,
and Thai language support.
</p>
<div class="grid gap-6 md:grid-cols-2">
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Tina CMS</h2>
<p class="text-primary-600">
Self-hosted content management with schema-based editing.
</p>
</div>
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Tailwind v4</h2>
<p class="text-primary-600">
Latest Tailwind CSS with @tailwindcss/vite plugin.
</p>
</div>
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Astro DB</h2>
<p class="text-primary-600">
Built-in database for consent logging and more.
</p>
</div>
<div class="p-6 bg-white rounded-xl border border-primary-200">
<h2 class="text-xl font-semibold mb-3">Thai Support</h2>
<p class="text-primary-600">
Ready for Thai language content with Noto Sans Thai.
</p>
</div>
</div>
</section>
</main>
</Layout>

View File

@@ -0,0 +1,57 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme {
--font-sans: "Inter", "Noto Sans Thai", system-ui, sans-serif;
--font-serif: "Merriweather", Georgia, serif;
--color-primary-50: #f8fafc;
--color-primary-100: #f1f5f9;
--color-primary-200: #e2e8f0;
--color-primary-300: #cbd5e1;
--color-primary-400: #94a3b8;
--color-primary-500: #64748b;
--color-primary-600: #475569;
--color-primary-700: #334155;
--color-primary-800: #1e293b;
--color-primary-900: #0f172a;
--color-primary-950: #020617;
--color-accent-50: #eff6ff;
--color-accent-100: #dbeafe;
--color-accent-200: #bfdbfe;
--color-accent-300: #93c5fd;
--color-accent-400: #60a5fa;
--color-accent-500: #3b82f6;
--color-accent-600: #2563eb;
--color-accent-700: #1d4ed8;
--color-accent-800: #1e40af;
--color-accent-900: #1e3a8a;
--color-success-500: #22c55e;
--color-warning-500: #f59e0b;
--color-error-500: #ef4444;
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-2xl: 1.5rem;
--radius-full: 9999px;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background-color: var(--color-accent-200);
color: var(--color-primary-900);
}

View File

@@ -0,0 +1,17 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@layouts/*": ["./src/layouts/*"],
"@styles/*": ["./src/styles/*"],
"@content/*": ["./src/content/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "react"
},
"include": ["src/**/*", ".tina/**/*", "db/**/*"],
"exclude": ["node_modules", "dist", ".astro"]
}

View File

@@ -0,0 +1,447 @@
---
/**
* 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,61 +1,70 @@
# PDPA Consent Logging Template
Template สำหรับเพิ่ม PDPA consent logging ใน Next.js + Payload CMS (MongoDB)
Template สำหรับเพิ่ม PDPA consent logging ใน Astro + Tina (Astro DB)
## Files
```
consent/
├── collections/
│ └── ConsentLogs.ts # Payload collection สำหรับ consent logs
├── ConsentBanner.astro # Consent banner component
├── api/
│ └── route.ts # API endpoint สำหรับบันทึก consent
├── cookie-banner.tsx # CookieBanner component
└── README.md
│ └── 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. เพิ่ม ConsentLogs Collection
### 1. เพิ่ม Astro DB Schema
Copy `collections/ConsentLogs.ts` ไปที่ `src/collections/` ของ project
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/route.ts` ไปที่ `src/app/api/consent/route.ts`
Copy `api/consent.ts` ไปที่ `src/pages/api/consent.ts`
### 3. เพิ่ม CookieBanner Component
### 3. เพิ่ม ConsentBanner Component
Copy `cookie-banner.tsx` ไปที่ `src/components/`
Copy `ConsentBanner.astro` ไปที่ `src/components/consent/ConsentBanner.astro`
### 4. เพิ่มใน Layout
เพิ่ม `<CookieBanner />` ใน `src/app/(frontend)/layout.tsx`:
เพิ่ม `<ConsentBanner />` ใน `src/layouts/Layout.astro`:
```tsx
import { CookieBanner } from '@/components/cookie-banner'
```astro
---
import ConsentBanner from '../components/consent/ConsentBanner.astro';
---
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<CookieBanner />
</body>
</html>
)
}
```
### 5. เพิ่ม Collection ใน payload.config.ts
```ts
import ConsentLogs from './collections/ConsentLogs'
export default buildConfig({
collections: [Users, Media, Snacks, Orders, ConsentLogs],
// ...
})
<html lang="th">
<body>
<slot />
<ConsentBanner />
</body>
</html>
```
## API
@@ -80,7 +89,7 @@ export default buildConfig({
{
"success": true,
"doc": {
"id": "...",
"id": 1,
"action": "accept",
"purpose": "all",
"analytics": true,
@@ -93,7 +102,46 @@ export default buildConfig({
}
```
### 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. **ใช้ `mongooseAdapter` ไม่ใช่ `mongodbAdapter`**
2. **ConsentLogs ต้องใช้ `export default`** ไม่ใช่ named export
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

@@ -0,0 +1,120 @@
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,39 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@/payload.config'
/**
* DELETE /api/consent - Right to be forgotten (GDPR/PDPA)
*
* Deletes all consent records for a given session or user
*/
export async function DELETE(request: NextRequest) {
try {
const payloadConfig = await config
const payload = await getPayload({ config: payloadConfig })
const { searchParams } = new URL(request.url)
const sessionId = searchParams.get('sessionId')
if (!sessionId) {
return NextResponse.json({ error: 'sessionId is required' }, { status: 400 })
}
// Find and delete all consent logs for this session
const result = await payload.delete({
collection: 'consent-logs',
where: {
sessionId: { equals: sessionId },
},
})
return NextResponse.json({
success: true,
deleted: result.deletedDocs?.length || 0,
message: 'All consent records for this session have been deleted'
})
} catch (error) {
console.error('Right to be forgotten error:', error)
return NextResponse.json({ error: 'Failed to delete consent records' }, { status: 500 })
}
}

View File

@@ -1,188 +0,0 @@
import { CollectionConfig, Field } from 'payload'
// Consent Log Collection - เก็บ log การยินยอมของ users
export const ConsentLog: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'sessionId',
defaultColumns: ['sessionId', 'consentType', 'granted', 'createdAt'],
description: 'บันทึกการยินยอมของผู้ใช้ตาม PDPA',
},
access: {
// ทุกคนสามารถสร้าง log ได้ (public)
create: () => true,
// แต่ดูได้เฉพาะ admin
read: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
// แก้ไขได้เฉพาะ admin
update: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
// ลบได้เฉพาะ admin
delete: ({ req: { user } }) => {
if (!user) return false
return user.role === 'admin'
},
},
fields: [
{
name: 'sessionId',
type: 'text',
required: true,
admin: {
description: 'Session ID ของผู้ใช้',
},
},
{
name: 'consentType',
type: 'select',
required: true,
options: [
{ label: 'Essential', value: 'essential' },
{ label: 'Analytics', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
{ label: 'Functional', value: 'functional' },
{ label: 'All Accepted', value: 'accept_all' },
{ label: 'All Rejected', value: 'reject_all' },
],
},
{
name: 'granted',
type: 'checkbox',
required: true,
defaultValue: false,
admin: {
description: 'ยินยอมหรือไม่',
},
},
{
name: 'ipAddress',
type: 'text',
admin: {
description: 'IP Address ของผู้ใช้',
readOnly: true,
},
},
{
name: 'userAgent',
type: 'text',
admin: {
description: 'Browser User Agent',
readOnly: true,
},
},
{
name: 'metadata',
type: 'json',
admin: {
description: 'ข้อมูลเพิ่มเติม',
},
},
{
name: 'createdAt',
type: 'date',
required: true,
admin: {
description: 'วันที่และเวลาที่ยินยอม',
readOnly: true,
},
},
],
hooks: {
beforeChange: [
({ data }) => {
// เพิ่ม timestamp อัตโนมัติ
if (!data.createdAt) {
data.createdAt = new Date().toISOString()
}
return data
},
],
},
}
// Consent Settings Collection - เก็บ settings ของ consent banner
export const ConsentSettings: CollectionConfig = {
slug: 'consent-settings',
admin: {
useAsTitle: 'title',
description: 'ตั้งค่า Cookie Consent Banner',
},
access: {
read: () => true, // Public read
create: ({ req: { user } }) => !!user && user.role === 'admin',
update: ({ req: { user } }) => !!user && user.role === 'admin',
delete: ({ req: { user } }) => !!user && user.role === 'admin',
},
fields: [
{
name: 'title',
type: 'text',
required: true,
defaultValue: 'นโยบายคุกกี้',
},
{
name: 'description',
type: 'textarea',
required: true,
defaultValue: 'เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งานเว็บไซต์ของคุณ คุณสามารถเลือกได้ว่าจะอนุญาตคุกกี้ประเภทใด',
},
{
name: 'position',
type: 'select',
defaultValue: 'bottom',
options: [
{ label: 'ด้านล่าง (Bottom)', value: 'bottom' },
{ label: 'ด้านบน (Top)', value: 'top' },
],
},
{
name: 'theme',
type: 'select',
defaultValue: 'light',
options: [
{ label: 'Light Mode', value: 'light' },
{ label: 'Dark Mode', value: 'dark' },
],
},
{
name: 'essentialCookies',
type: 'json',
admin: {
description: 'รายชื่อ essential cookies ที่จำเป็นต้องมี',
},
},
{
name: 'analyticsCookies',
type: 'json',
admin: {
description: 'รายชื่อ analytics cookies',
},
},
{
name: 'marketingCookies',
type: 'json',
admin: {
description: 'รายชื่อ marketing cookies',
},
},
{
name: 'functionalCookies',
type: 'json',
admin: {
description: 'รายชื่อ functional cookies',
},
},
{
name: 'isActive',
type: 'checkbox',
defaultValue: true,
admin: {
description: 'แสดง consent banner หรือไม่',
},
},
],
}

View File

@@ -1,122 +0,0 @@
import type { CollectionConfig } from 'payload'
export interface ConsentLogData {
action: 'accept' | 'reject' | 'update'
purpose: 'analytics' | 'marketing' | 'functional' | 'all'
userAgent?: string
ip?: string
timestamp: string
previousConsent?: Record<string, boolean>
newConsent?: Record<string, boolean>
}
const ConsentLogs: CollectionConfig = {
slug: 'consent-logs',
admin: {
useAsTitle: 'timestamp',
defaultColumns: ['timestamp', 'action', 'purpose', 'ip'],
description: 'Log of all consent actions for PDPA compliance',
},
access: {
create: () => true, // Allow anyone to create consent logs (public endpoint)
read: () => true, // Allow reading for compliance purposes
update: () => false, // Consent logs should not be modified
delete: () => false, // Consent logs should not be deleted
},
fields: [
{
name: 'action',
type: 'select',
required: true,
options: [
{ label: 'Accept', value: 'accept' },
{ label: 'Reject', value: 'reject' },
{ label: 'Update', value: 'update' },
],
admin: {
description: 'The type of consent action',
},
},
{
name: 'purpose',
type: 'select',
required: true,
options: [
{ label: 'Analytics', value: 'analytics' },
{ label: 'Marketing', value: 'marketing' },
{ label: 'Functional', value: 'functional' },
{ label: 'All', value: 'all' },
],
admin: {
description: 'The purpose of the consent',
},
},
{
name: 'analytics',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Consent for analytics cookies',
},
},
{
name: 'marketing',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Consent for marketing cookies',
},
},
{
name: 'functional',
type: 'checkbox',
defaultValue: false,
admin: {
description: 'Consent for functional cookies',
},
},
{
name: 'userAgent',
type: 'text',
admin: {
readOnly: true,
description: 'Browser user agent string',
},
},
{
name: 'ip',
type: 'text',
admin: {
readOnly: true,
description: 'IP address of the user',
},
},
{
name: 'timestamp',
type: 'date',
required: true,
admin: {
readOnly: true,
description: 'When the consent was given',
},
},
{
name: 'previousConsent',
type: 'json',
admin: {
readOnly: true,
description: 'Previous consent state (for updates)',
},
},
{
name: 'newConsent',
type: 'json',
admin: {
readOnly: true,
description: 'New consent state',
},
},
],
}
export default ConsentLogs

View File

@@ -1,316 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
interface ConsentState {
analytics: boolean
marketing: boolean
functional: boolean
hasConsented: boolean
timestamp?: string
}
const defaultConsent: ConsentState = {
analytics: false,
marketing: false,
functional: false,
hasConsented: false,
}
const STORAGE_KEY = 'pdpa_consent'
export function CookieBanner() {
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
const [showBanner, setShowBanner] = useState(false)
const [showPreferences, setShowPreferences] = useState(false)
// Load consent from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
try {
const parsed = JSON.parse(stored)
setConsent(parsed)
setShowBanner(false)
} catch {
setShowBanner(true)
}
} else {
setShowBanner(true)
}
}, [])
// Save consent to localStorage
const saveConsent = async (newConsent: ConsentState) => {
// Save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(newConsent))
setConsent(newConsent)
setShowBanner(false)
setShowPreferences(false)
// 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',
...newConsent,
}),
})
} catch (error) {
console.error('Failed to log consent:', error)
}
}
// Accept all cookies
const acceptAll = () => {
saveConsent({
analytics: true,
marketing: true,
functional: true,
hasConsented: true,
timestamp: new Date().toISOString(),
})
}
// Reject all cookies (only functional)
const rejectAll = () => {
saveConsent({
analytics: false,
marketing: false,
functional: false,
hasConsented: true,
timestamp: new Date().toISOString(),
})
}
// Save custom preferences
const savePreferences = () => {
saveConsent({
...consent,
hasConsented: true,
timestamp: new Date().toISOString(),
})
}
// Update individual preference
const updatePreference = (key: keyof Pick<ConsentState, 'analytics' | 'marketing' | 'functional'>, value: boolean) => {
setConsent(prev => ({ ...prev, [key]: value }))
}
// If no banner to show, return null
if (!showBanner) return null
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
backgroundColor: '#ffffff',
boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.15)',
padding: '1.5rem',
zIndex: 9999,
borderTop: '1px solid #e5e5e5',
}}
role="dialog"
aria-label="Cookie Consent Banner"
>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
{!showPreferences ? (
// Main banner
<div>
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
🍪 PDPA Cookie Consent
</h3>
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.9375rem', lineHeight: 1.5 }}>
We use cookies to enhance your experience. By continuing to visit this site, you agree to our use of cookies.{' '}
<a href="/privacy-policy" style={{ color: '#0066cc' }}>
Learn more
</a>
</p>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
onClick={acceptAll}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: '#22c55e',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Accept All Cookies
</button>
<button
onClick={rejectAll}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Reject All
</button>
<button
onClick={() => setShowPreferences(true)}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: 'transparent',
color: '#0066cc',
border: '1px solid #0066cc',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Cookie Preferences
</button>
</div>
</div>
) : (
// Preferences panel
<div>
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1.125rem', fontWeight: 600 }}>
Cookie Preferences
</h3>
<p style={{ margin: '0 0 1rem 0', color: '#555', fontSize: '0.875rem' }}>
Manage your cookie preferences below.
</p>
<div style={{ marginBottom: '1rem' }}>
{/* Functional Cookies */}
<div style={{
padding: '1rem',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
marginBottom: '0.75rem',
border: '1px solid #e5e5e5'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Functional Cookies</h4>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
Essential for the website to function properly. Cannot be disabled.
</p>
</div>
<div style={{
padding: '0.25rem 0.75rem',
backgroundColor: '#e5e5e5',
color: '#666',
borderRadius: '4px',
fontSize: '0.75rem',
fontWeight: 500,
}}>
Always Active
</div>
</div>
</div>
{/* Analytics Cookies */}
<div style={{
padding: '1rem',
backgroundColor: '#fff',
borderRadius: '8px',
marginBottom: '0.75rem',
border: '1px solid #e5e5e5'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Analytics Cookies</h4>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
Help us understand how visitors interact with our website.
</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={consent.analytics}
onChange={(e) => updatePreference('analytics', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
</label>
</div>
</div>
{/* Marketing Cookies */}
<div style={{
padding: '1rem',
backgroundColor: '#fff',
borderRadius: '8px',
marginBottom: '0.75rem',
border: '1px solid #e5e5e5'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h4 style={{ margin: 0, fontSize: '0.9375rem', fontWeight: 600 }}>Marketing Cookies</h4>
<p style={{ margin: '0.25rem 0 0 0', fontSize: '0.8125rem', color: '#666' }}>
Used to track visitors across websites for advertising purposes.
</p>
</div>
<label style={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }}>
<input
type="checkbox"
checked={consent.marketing}
onChange={(e) => updatePreference('marketing', e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
</label>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
onClick={savePreferences}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '0.9375rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
Save Preferences
</button>
<button
onClick={() => setShowPreferences(false)}
style={{
padding: '0.625rem 1.25rem',
backgroundColor: 'transparent',
color: '#666',
border: 'none',
borderRadius: '6px',
fontSize: '0.9375rem',
cursor: 'pointer',
}}
>
Back
</button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
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

@@ -0,0 +1,75 @@
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;
}

View File

@@ -1,9 +0,0 @@
node_modules
.next
out
dist
build
*.log
.env*.local
.DS_Store
*.pem

View File

@@ -1,5 +0,0 @@
# Payload CMS
PAYLOAD_SECRET=your-secret-key-here-change-in-production
# Database (PostgreSQL) - database name must match POSTGRES_DB in docker-compose
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/payload

View File

@@ -1,39 +0,0 @@
# Multi-stage Dockerfile for Next.js + Payload CMS with PostgreSQL
# Requires `output: 'standalone'` in next.config.ts
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install --frozen-lockfile
FROM deps AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,40 +0,0 @@
version: '3'
services:
payload:
image: node:22-alpine
ports:
- '3000:3000'
volumes:
- .:/home/node/app
- node_modules:/home/node/app/node_modules
working_dir: /home/node/app/
command: sh -c "corepack enable && corepack prepare pnpm@9.0.0 --activate && pnpm install && pnpm dev"
depends_on:
- postgres
env_file:
- .env
networks:
- payload-network
postgres:
restart: always
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- '5432:5432'
environment:
POSTGRES_USER: payload
POSTGRES_PASSWORD: payloadpass
POSTGRES_DB: payload
networks:
- payload-network
networks:
payload-network:
driver: bridge
volumes:
pgdata:
node_modules:

View File

@@ -1,31 +0,0 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
const nextConfig: NextConfig = {
images: {
localPatterns: [
{
pathname: '/api/media/file/**',
},
],
},
output: 'standalone',
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
turbopack: {
root: path.resolve(dirname),
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

View File

@@ -1,45 +0,0 @@
{
"name": "nextjs-payload-starter",
"version": "1.0.0",
"description": "Next.js + Payload CMS starter template with PostgreSQL",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation next lint",
"docker:dev": "docker compose up -d",
"docker:dev:logs": "docker compose logs -f",
"docker:down": "docker compose down"
},
"dependencies": {
"@payloadcms/next": "^3.82.1",
"@payloadcms/richtext-lexical": "^3.82.1",
"@payloadcms/ui": "^3.82.1",
"@payloadcms/db-postgres": "^3.82.1",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"next": "^16.2.3",
"payload": "^3.82.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.34.2"
},
"devDependencies": {
"@types/node": "^22.19.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"eslint": "^9.16.0",
"eslint-config-next": "^16.2.3",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
}
}

View File

@@ -1,41 +0,0 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: #333;
background-color: #fafafa;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.2;
}
main {
min-height: calc(100vh - 200px);
}
header {
background: white;
border-bottom: 1px solid #eee;
}
footer {
background: #f5f5f5;
padding: 2rem;
text-align: center;
color: #666;
}

View File

@@ -1,22 +0,0 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: {
default: 'Next.js + Payload CMS',
template: '%s | Next.js + Payload CMS',
},
description: 'A website built with Next.js and Payload CMS',
}
export default function FrontendLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="th">
<body>{children}</body>
</html>
)
}

View File

@@ -1,84 +0,0 @@
import { getPayload } from 'payload'
import Link from 'next/link'
import config from '@payload-config'
export const dynamic = 'force-dynamic'
export default async function HomePage() {
let posts: any[] = []
try {
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
limit: 10,
sort: '-createdAt',
})
posts = docs
} catch (e) {
// Table might not exist yet - that's OK for initial setup
console.warn('Could not fetch posts:', e)
}
return (
<main style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<header style={{ marginBottom: '3rem', borderBottom: '1px solid #eee', paddingBottom: '1rem' }}>
<h1 style={{ fontSize: '2.5rem', marginBottom: '0.5rem' }}>
Next.js + Payload CMS
</h1>
<p style={{ color: '#666' }}>
Welcome to your new website. Edit <code>src/app/(frontend)/page.tsx</code> to customize.
</p>
</header>
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>Recent Posts</h2>
{posts.length === 0 ? (
<p style={{ color: '#888' }}>
No posts yet. Go to{' '}
<Link href="/admin" style={{ color: '#0070f3' }}>
Admin Panel
</Link>{' '}
to create your first post.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{posts.map((post) => (
<li
key={post.id}
style={{
padding: '1rem',
marginBottom: '1rem',
border: '1px solid #eee',
borderRadius: '8px',
}}
>
<Link
href={`/posts/${post.slug || post.id}`}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<h3 style={{ margin: 0, marginBottom: '0.5rem' }}>{post.title as string}</h3>
{post.createdAt && (
<p style={{ margin: 0, fontSize: '0.875rem', color: '#888' }}>
{new Date(post.createdAt).toLocaleDateString('th-TH', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
</Link>
</li>
))}
</ul>
)}
</section>
<footer style={{ marginTop: '4rem', paddingTop: '2rem', borderTop: '1px solid #eee' }}>
<Link href="/admin" style={{ color: '#0070f3' }}>
Go to Admin Panel
</Link>
</footer>
</main>
)
}

View File

@@ -1,23 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@@ -1,2 +0,0 @@
/* THIS FILE IS GENERATED BY PAYLOAD - RUN `pnpm generate:importmap` AFTER CHANGING COLLECTIONS */
export const importMap = {}

View File

@@ -1,18 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,6 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* Run `pnpm generate:importmap` to regenerate */
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@@ -1,6 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1 +0,0 @@
/* Custom styles for Payload admin - add your overrides here */

View File

@@ -1,30 +0,0 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

View File

@@ -1,16 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
],
upload: true,
}

View File

@@ -1,70 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
description: 'URL-friendly version (e.g. "about-us")',
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
description: 'Control publication status',
},
},
{
name: 'content',
type: 'richText',
label: 'Page Content',
admin: {
description: 'Main page content — use the visual editor',
},
},
{
name: 'updatedAt',
type: 'date',
admin: {
readOnly: true,
date: {
pickerAppearance: 'dayAndTime',
},
},
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9ก-๙]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}

View File

@@ -1,73 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Posts: CollectionConfig = {
slug: 'posts',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'slug', 'createdAt'],
},
access: {
read: () => true,
create: () => true,
update: () => true,
delete: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'slug',
type: 'text',
required: true,
admin: {
description: 'URL-friendly version of the title',
},
},
{
name: 'content',
type: 'richText',
},
{
name: 'featuredImage',
type: 'upload',
relationTo: 'media',
},
{
name: 'publishedAt',
type: 'date',
admin: {
date: {
pickerAppearance: 'dayAndTime',
},
},
},
{
name: 'status',
type: 'select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
defaultValue: 'draft',
admin: {
description: 'Control publication status',
},
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data.title && !data.slug) {
data.slug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
return data
},
],
},
}

View File

@@ -1,12 +0,0 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
},
auth: true,
fields: [
// Email added by default
],
}

View File

@@ -1 +0,0 @@
export { default as config } from './payload.config'

View File

@@ -1,41 +0,0 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Posts } from './collections/Posts'
import { Pages } from './collections/Pages'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
},
collections: [
Users,
Media,
Posts,
Pages,
],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL || '',
},
}),
sharp,
plugins: [],
})

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}