Compare commits

...

2 Commits

Author SHA1 Message Date
Kunthawat
30aeb0f418 chore: Add Nixpacks configuration and deployment guide
- Nixpacks.toml for auto-detection build
- Complete deployment documentation for Easypanel
- Environment variables setup
- Database persistence instructions
- Troubleshooting guide

Optimized for Nixpacks deployment (no Dockerfile needed).
2026-03-10 13:16:15 +07:00
Kunthawat
305e2bd217 feat: Add PDPA compliance features to Astro project
- Cookie consent banner with Thai language
- Consent logging API with SQLite database
- Admin dashboard for viewing consent logs
- PDPA-compliant privacy policy
- Environment configuration template

PDPA compliance as per website-creator skill specifications.
Build: npm install, Astro project ready for Docker deployment.
2026-03-10 13:09:17 +07:00
7 changed files with 1116 additions and 1 deletions

View File

@@ -0,0 +1,4 @@
PUBLIC_UMAMI_WEBSITE_ID=your-website-id-here
PUBLIC_UMAMI_DOMAIN=https://analytics.moreminimore.com
ADMIN_PASSWORD=changeme
ASTRO_DB_REMOTE_URL=file:./data/consent.db

View File

@@ -1 +1,9 @@
.node-version
node_modules/
dist/
.astro/
data/*.db
.env
.env.*
*.log
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,272 @@
# 🚀 Easypanel Deployment Guide (Nixpacks)
## Overview
This Astro website is configured for **automatic deployment on Easypanel** using **Nixpacks**.
---
## ✅ Auto-Detection
Easypanel will automatically detect this as a Node.js project and use Nixpacks to build it.
**No Dockerfile required!**
---
## 📋 Configuration
### Easypanel Settings:
1. **Project Name:** `dealplustech-astro`
2. **Service Name:** `dealplustech-website`
3. **Build Type:** `Nixpacks` (auto-detected)
4. **Source:** Git repository
5. **Branch:** `main`
6. **Port:** `4321` (or use `$PORT` env variable)
7. **Auto-Deploy:** ✅ Enabled
---
## 🔐 Environment Variables
Set these in Easypanel → Settings → Environment:
```bash
# Database (SQLite file - Nixpacks will persist this)
ASTRO_DB_REMOTE_URL=file:/data/consent.db
# Admin Dashboard
ADMIN_PASSWORD=your-secure-password-here
# Umami Analytics (optional)
PUBLIC_UMAMI_WEBSITE_ID=your-website-id
PUBLIC_UMAMI_DOMAIN=https://analytics.moreminimore.com
# Site URL
PUBLIC_SITE_URL=https://your-domain.com
```
**⚠️ IMPORTANT:** Change `ADMIN_PASSWORD` from the default!
---
## 🗄️ Database Persistence
The consent logging database needs persistent storage.
### Option A: Volume Mount (Recommended)
1. In Easypanel, go to Service → Volumes
2. Add a new volume:
- **Path:** `/data`
- **Size:** `1 GB` (minimum)
3. Set environment variable:
```bash
ASTRO_DB_REMOTE_URL=file:/data/consent.db
```
### Option B: Turso (Production)
For managed database:
1. Create account at https://turso.tech
2. Create database
3. Get connection URL
4. Set environment:
```bash
ASTRO_DB_REMOTE_URL=libsql://your-db.turso.io
ASTRO_DB_APP_TOKEN=your-token
```
---
## 🔄 Deployment Workflow
### Automatic (Git Push):
1. **Make changes locally**
```bash
# Edit files
npm run build # Test locally
git add .
git commit -m "Update content"
git push origin main
```
2. **Easypanel auto-deploys:**
- Detects push to `main`
- Runs Nixpacks build
- Deploys new version
- Health checks pass
- Traffic switches
3. **Verify:**
- Check Easypanel dashboard
- Visit website URL
- Test cookie consent
### Manual (Force Deploy):
1. Go to Service in Easypanel
2. Click "Deploy"
3. Select latest commit
4. Deploy
---
## 📊 Monitoring
### Health Check
Nixpacks will automatically health check the service.
**Endpoint:** `http://localhost:4321/`
**Expected:** Status 200 OK
### Logs
View in Easypanel:
- Service → Logs
- Real-time deployment logs
- Runtime logs
### Metrics
Easypanel provides:
- CPU usage
- Memory usage
- Network traffic
- Request count
---
## 🔐 Security Checklist
- [ ] Change `ADMIN_PASSWORD` from default
- [ ] Enable HTTPS (Easypanel provides SSL automatically)
- [ ] Set up firewall rules (if needed)
- [ ] Configure database persistence
- [ ] Regular backups of consent logs
---
## 🐛 Troubleshooting
### Build Fails
**Check:**
1. `npm run build` works locally
2. `package.json` scripts are correct
3. All dependencies installed
4. Node version is 20.x
**Fix:**
```bash
npm install
npm run build
git push origin main
```
### Database Errors
**Check:**
1. Volume is mounted at `/data`
2. Environment variable `ASTRO_DB_REMOTE_URL` is set
3. Database file has write permissions
**Fix:**
```bash
# In Easypanel:
# 1. Add volume at /data
# 2. Set ASTRO_DB_REMOTE_URL=file:/data/consent.db
# 3. Redeploy
```
### Cookie Consent Not Working
**Check:**
1. CookieConsentBanner component is imported
2. BASE_LAYOUT includes the component
3. No console errors
**Fix:**
```bash
# Verify component is included in layout
# Check browser console for errors
# Clear cache and reload
```
### Admin Dashboard 404
**Check:**
1. Route is `/admin/consent-logs`
2. Service is running
3. No build errors
**Fix:**
```bash
# Access: https://your-domain.com/admin/consent-logs
# Login with ADMIN_PASSWORD
# Check server logs for errors
```
---
## 📈 Performance Optimization
### Recommended Resources:
- **CPU:** `0.5` core (minimum)
- **Memory:** `512 MB` (minimum)
- **Disk:** `1 GB` for database
### Caching:
Nixpacks automatically caches:
- `node_modules/`
- Build output
### CDN (Optional):
For better performance, add Cloudflare:
1. Point DNS to Easypanel
2. Enable Cloudflare proxy
3. Configure caching rules
---
## 📞 Support
**Documentation:**
- Astro: https://docs.astro.build
- Nixpacks: https://nixpacks.com
- Easypanel: https://easypanel.io/docs
**Admin Dashboard:**
- URL: `/admin/consent-logs`
- Password: Set via `ADMIN_PASSWORD` env
**Contact:**
- Email: info@dealplustech.co.th
- LINE: @dealplustech
---
## ✅ Post-Deployment Checklist
- [ ] Website loads correctly
- [ ] Cookie consent appears on first visit
- [ ] Consent is logged to database
- [ ] Admin dashboard accessible
- [ ] Umami Analytics loading (if configured)
- [ ] All pages working
- [ ] Mobile responsive
- [ ] HTTPS enabled (automatic with Easypanel)
- [ ] Database backed up regularly
---
**Last Updated:** 2026-03-10
**Version:** 1.0.0
**Deployment:** Nixpacks on Easypanel

View File

@@ -0,0 +1,227 @@
---
// Cookie Consent Banner Component
// Displays on first visit, allows users to accept/reject cookie categories
---
<div
id="cookie-consent-banner"
class="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-primary-600 shadow-2xl p-6 md:p-8 transform translate-y-full transition-transform duration-300 ease-in-out"
role="region"
aria-labelledby="consent-title"
aria-describedby="consent-description"
>
<div class="container mx-auto max-w-7xl">
<div class="flex flex-col lg:flex-row gap-6 items-start lg:items-center justify-between">
<!-- Consent Content -->
<div class="flex-1">
<h2 id="consent-title" class="text-xl md:text-2xl font-bold text-secondary-900 mb-3">
เรายึดถือความเป็นส่วนตัวของคุณ
</h2>
<p id="consent-description" class="text-base md:text-lg text-secondary-700 leading-relaxed mb-4">
เราใช้คุกกี้เพื่อปรับปรุงประสบการณ์การใช้งาน วิเคราะห์การเข้าใช้งาน และแสดงเนื้อหาที่ตรงใจคุณ
คุณสามารถเลือกยอมรับหรือปฏิเสธคุกกี้ที่ไม่จำเป็นได้
</p>
<!-- Cookie Categories -->
<div class="space-y-3 mt-4">
<!-- Essential Cookies (Always On) -->
<div class="flex items-center gap-3 bg-secondary-50 p-3 rounded-lg">
<input
type="checkbox"
id="consent-essential"
checked
disabled
class="w-5 h-5 accent-primary-600 rounded"
/>
<label for="consent-essential" class="flex-1 cursor-pointer">
<span class="font-semibold text-secondary-900">คุกกี้จำเป็น</span>
<span class="text-sm text-secondary-600 block">ใช้สำหรับการทำงานของเว็บไซต์ ไม่สามารถปิดได้</span>
</label>
<span class="text-xs px-2 py-1 bg-primary-600 text-white rounded">จำเป็น</span>
</div>
<!-- Analytics Cookies -->
<div class="flex items-center gap-3 bg-secondary-50 p-3 rounded-lg">
<input
type="checkbox"
id="consent-analytics"
class="w-5 h-5 accent-primary-600 rounded consent-checkbox"
/>
<label for="consent-analytics" class="flex-1 cursor-pointer">
<span class="font-semibold text-secondary-900">คุกกี้วิเคราะห์ข้อมูล</span>
<span class="text-sm text-secondary-600 block">ช่วยให้เราเข้าใจพฤติกรรมการใช้งาน</span>
</label>
</div>
<!-- Marketing Cookies -->
<div class="flex items-center gap-3 bg-secondary-50 p-3 rounded-lg">
<input
type="checkbox"
id="consent-marketing"
class="w-5 h-5 accent-primary-600 rounded consent-checkbox"
/>
<label for="consent-marketing" class="flex-1 cursor-pointer">
<span class="font-semibold text-secondary-900">คุกกี้การตลาด</span>
<span class="text-sm text-secondary-600 block">ใช้สำหรับแสดงโฆษณาที่เกี่ยวข้อง</span>
</label>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-3 flex-shrink-0">
<button
id="consent-reject"
class="bg-secondary-800 hover:bg-secondary-900 text-white px-6 py-3 rounded-lg font-semibold transition-colors"
>
ปฏิเสธทั้งหมด
</button>
<button
id="consent-accept"
class="bg-primary-600 hover:bg-primary-700 text-white px-6 py-3 rounded-lg font-semibold transition-colors"
>
ยอมรับทั้งหมด
</button>
</div>
</div>
<!-- Privacy Policy Link -->
<div class="mt-6 pt-6 border-t border-secondary-200 text-center">
<p class="text-sm text-secondary-600">
การใช้งานคุกกี้ของเราเป็นไปตาม
<a href="/privacy-policy" class="text-primary-600 hover:underline font-medium">นโยบายความเป็นส่วนตัว</a>
และ
<a href="/terms-and-conditions" class="text-primary-600 hover:underline font-medium">ข้อกำหนดการใช้งาน</a>
</p>
</div>
</div>
</div>
<script>
interface ConsentPreferences {
essential: boolean;
analytics: boolean;
marketing: boolean;
timestamp: string;
policyVersion: string;
}
const POLICY_VERSION = '1.0.0';
const CONSENT_STORAGE_KEY = 'consent-preferences';
const CONSENT_LOG_API = '/api/consent';
function generateSessionId(): string {
return 'ses_' + Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
}
function getStoredConsent(): ConsentPreferences | null {
const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
}
async function saveConsent(consent: ConsentPreferences) {
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(consent));
try {
const sessionId = sessionStorage.getItem('consent_session_id') || generateSessionId();
sessionStorage.setItem('consent_session_id', sessionId);
await fetch(CONSENT_LOG_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId,
consent,
policyVersion: POLICY_VERSION,
}),
});
} catch (error) {
console.error('Failed to log consent:', error);
}
}
function showBanner() {
const banner = document.getElementById('cookie-consent-banner')!;
banner.classList.remove('translate-y-full');
}
function hideBanner() {
const banner = document.getElementById('cookie-consent-banner')!;
banner.classList.add('translate-y-full');
}
function initConsent() {
const stored = getStoredConsent();
if (stored) {
const analyticsCheckbox = document.getElementById('consent-analytics') as HTMLInputElement;
const marketingCheckbox = document.getElementById('consent-marketing') as HTMLInputElement;
if (analyticsCheckbox) analyticsCheckbox.checked = stored.analytics;
if (marketingCheckbox) marketingCheckbox.checked = stored.marketing;
loadConsentedScripts(stored);
return;
}
setTimeout(showBanner, 500);
}
function loadConsentedScripts(consent: ConsentPreferences) {
if (consent.analytics && import.meta.env.PUBLIC_UMAMI_WEBSITE_ID) {
const umamiScript = document.createElement('script');
umamiScript.defer = true;
umamiScript.src = 'https://analytics.moreminimore.com/script.js';
umamiScript.setAttribute('data-website-id', import.meta.env.PUBLIC_UMAMI_WEBSITE_ID);
document.head.appendChild(umamiScript);
}
}
function handleAccept() {
const analytics = (document.getElementById('consent-analytics') as HTMLInputElement)?.checked ?? false;
const marketing = (document.getElementById('consent-marketing') as HTMLInputElement)?.checked ?? false;
const consent: ConsentPreferences = {
essential: true,
analytics: analytics || true,
marketing: marketing || true,
timestamp: new Date().toISOString(),
policyVersion: POLICY_VERSION,
};
saveConsent(consent);
loadConsentedScripts(consent);
hideBanner();
}
function handleReject() {
const consent: ConsentPreferences = {
essential: true,
analytics: false,
marketing: false,
timestamp: new Date().toISOString(),
policyVersion: POLICY_VERSION,
};
saveConsent(consent);
hideBanner();
}
document.addEventListener('DOMContentLoaded', () => {
initConsent();
document.getElementById('consent-accept')?.addEventListener('click', handleAccept);
document.getElementById('consent-reject')?.addEventListener('click', handleReject);
});
declare global {
interface Window {
openConsentPreferences: () => void;
}
}
window.openConsentPreferences = () => {
showBanner();
};
</script>

View File

@@ -0,0 +1,309 @@
---
import { createClient } from '@libsql/client';
import BaseLayout from '../../layouts/BaseLayout.astro';
const client = createClient({
url: import.meta.env.ASTRO_DB_REMOTE_URL || 'file:./data/consent.db',
authToken: import.meta.env.ASTRO_DB_APP_TOKEN,
});
const ADMIN_PASSWORD = import.meta.env.ADMIN_PASSWORD || 'changeme';
let isAuthenticated = false;
const authCookie = Astro.cookies.get('admin_auth')?.value;
if (authCookie === 'true') {
isAuthenticated = true;
}
if (Astro.request.method === 'POST') {
const formData = await Astro.request.formData();
const action = formData.get('action');
if (action === 'login') {
const password = formData.get('password');
if (password === ADMIN_PASSWORD) {
Astro.cookies.set('admin_auth', 'true', {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
maxAge: 60 * 60 * 2
});
isAuthenticated = true;
}
} else if (action === 'logout') {
Astro.cookies.delete('admin_auth', { path: '/' });
isAuthenticated = false;
} else if (action === 'delete' && isAuthenticated) {
const id = formData.get('id');
if (id) {
await client.execute({
sql: 'DELETE FROM consent_logs WHERE id = ?',
args: [Number(id)],
});
}
} else if (action === 'delete-all' && isAuthenticated) {
await client.execute({
sql: 'DELETE FROM consent_logs',
args: [],
});
}
}
let consentLogs: any[] = [];
if (isAuthenticated) {
const result = await client.execute({
sql: 'SELECT * FROM consent_logs ORDER BY created_at DESC LIMIT 100',
args: [],
});
consentLogs = result.rows || [];
}
---
<BaseLayout title="Consent Logs Admin" description="Admin dashboard for viewing consent logs">
<main class="min-h-screen bg-secondary-50 py-12">
<div class="container mx-auto px-4 max-w-7xl">
<div class="bg-white rounded-2xl shadow-lg p-6 md:p-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-3xl md:text-4xl font-bold text-secondary-900 mb-2">
Consent Logs Admin
</h1>
<p class="text-secondary-600">
View and manage user consent records (PDPA compliance)
</p>
</div>
{isAuthenticated ? (
<form method="POST">
<input type="hidden" name="action" value="logout" />
<button type="submit" class="bg-secondary-800 hover:bg-secondary-900 text-white px-4 py-2 rounded-lg">
Logout
</button>
</form>
) : null}
</div>
{!isAuthenticated ? (
<div class="max-w-md mx-auto">
<div class="bg-secondary-50 rounded-xl p-6 border-2 border-secondary-200">
<h2 class="text-xl font-bold text-secondary-900 mb-4">Admin Login</h2>
<form method="POST" class="space-y-4">
<input type="hidden" name="action" value="login" />
<div>
<label for="password" class="block text-sm font-semibold text-secondary-700 mb-2">
Password
</label>
<input
type="password"
id="password"
name="password"
class="w-full px-4 py-2 border-2 border-secondary-300 rounded-lg focus:outline-none focus:border-primary-600"
required
/>
</div>
<button type="submit" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg w-full">
Login
</button>
</form>
</div>
</div>
) : (
<div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="bg-primary-50 rounded-xl p-6 border-2 border-primary-200">
<div class="text-sm font-semibold text-primary-700 mb-1">Total Consents</div>
<div class="text-3xl font-bold text-primary-900">{consentLogs.length}</div>
</div>
<div class="bg-secondary-50 rounded-xl p-6 border-2 border-secondary-200">
<div class="text-sm font-semibold text-secondary-700 mb-1">Analytics Accepted</div>
<div class="text-3xl font-bold text-secondary-900">
{consentLogs.filter(l => l.analytics === 1).length}
</div>
</div>
<div class="bg-accent-50 rounded-xl p-6 border-2 border-accent-200">
<div class="text-sm font-semibold text-accent-700 mb-1">Marketing Accepted</div>
<div class="text-3xl font-bold text-accent-900">
{consentLogs.filter(l => l.marketing === 1).length}
</div>
</div>
<div class="bg-secondary-50 rounded-xl p-6 border-2 border-secondary-200">
<div class="text-sm font-semibold text-secondary-700 mb-1">Acceptance Rate</div>
<div class="text-3xl font-bold text-secondary-900">
{consentLogs.length > 0
? Math.round((consentLogs.filter(l => l.analytics === 1).length / consentLogs.length) * 100)
: 0}%
</div>
</div>
</div>
<div class="flex gap-3 mb-6">
<form method="POST" id="delete-all-form" class="inline">
<input type="hidden" name="action" value="delete-all" />
<button
type="submit"
id="delete-all-btn"
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg font-semibold"
>
Delete All Logs
</button>
</form>
<button
type="button"
id="export-btn"
class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded-lg font-semibold"
>
Export to CSV
</button>
</div>
<div class="overflow-x-auto">
<table class="w-full border-collapse">
<thead>
<tr class="bg-secondary-800 text-white">
<th class="px-4 py-3 text-left text-sm font-semibold">ID</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Session ID</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Timestamp</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Locale</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Essential</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Analytics</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Marketing</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Policy Version</th>
<th class="px-4 py-3 text-left text-sm font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{consentLogs.map((log, index) => (
<tr class={index % 2 === 0 ? 'bg-white' : 'bg-secondary-50'}>
<td class="px-4 py-3 text-sm text-secondary-700">{log.id}</td>
<td class="px-4 py-3 text-sm text-secondary-700 font-mono">{log.session_id}</td>
<td class="px-4 py-3 text-sm text-secondary-700">{new Date(log.timestamp).toLocaleString('th-TH')}</td>
<td class="px-4 py-3 text-sm text-secondary-700">{log.locale}</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 bg-primary-100 text-primary-800 rounded text-xs font-semibold">
{log.essential === 1 ? 'Yes' : 'No'}
</span>
</td>
<td class="px-4 py-3 text-sm">
{log.analytics === 1 ? (
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-semibold">Accepted</span>
) : (
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs font-semibold">Rejected</span>
)}
</td>
<td class="px-4 py-3 text-sm">
{log.marketing === 1 ? (
<span class="px-2 py-1 bg-green-100 text-green-800 rounded text-xs font-semibold">Accepted</span>
) : (
<span class="px-2 py-1 bg-red-100 text-red-800 rounded text-xs font-semibold">Rejected</span>
)}
</td>
<td class="px-4 py-3 text-sm text-secondary-700 font-mono">{log.policy_version}</td>
<td class="px-4 py-3 text-sm">
<form method="POST" class="inline delete-form">
<input type="hidden" name="action" value="delete" />
<input type="hidden" name="id" value={log.id} />
<button
type="submit"
class="delete-btn text-red-600 hover:text-red-800 hover:underline"
>
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
{consentLogs.length === 0 && (
<div class="text-center py-12">
<p class="text-secondary-600 text-lg">No consent logs found</p>
</div>
)}
<script id="logs-data" type="application/json">
{JSON.stringify(consentLogs)}
</script>
</div>
)}
</div>
</div>
</main>
</BaseLayout>
<script>
interface ConsentLog {
id: number;
session_id: string;
timestamp: string;
locale: string;
essential: number;
analytics: number;
marketing: number;
policy_version: string;
ip_hash: string;
created_at: string;
}
function convertToCSV(logs: ConsentLog[]): string {
const headers = ['ID', 'Session ID', 'Timestamp', 'Locale', 'Essential', 'Analytics', 'Marketing', 'Policy Version', 'IP Hash', 'Created At'];
const rows = logs.map((log: ConsentLog) => [
log.id,
log.session_id,
log.timestamp,
log.locale,
log.essential === 1 ? 'Yes' : 'No',
log.analytics === 1 ? 'Yes' : 'No',
log.marketing === 1 ? 'Yes' : 'No',
log.policy_version,
log.ip_hash,
log.created_at,
]);
return [headers, ...rows].map(row => row.join(',')).join('\n');
}
document.addEventListener('DOMContentLoaded', () => {
const deleteAllBtn = document.getElementById('delete-all-btn');
const deleteAllForm = document.getElementById('delete-all-form') as HTMLFormElement;
if (deleteAllBtn && deleteAllForm) {
deleteAllBtn.addEventListener('click', (e) => {
const confirmDelete = confirm('Delete all consent logs? This cannot be undone.');
if (!confirmDelete) {
e.preventDefault();
deleteAllForm.reset();
}
});
}
const exportBtn = document.getElementById('export-btn');
const logsData = document.getElementById('logs-data');
const consentLogs: ConsentLog[] = logsData ? JSON.parse(logsData.textContent || '[]') : [];
if (exportBtn) {
exportBtn.addEventListener('click', () => {
const csv = convertToCSV(consentLogs);
const blob = new Blob([csv], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `consent-logs-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
});
}
document.querySelectorAll('.delete-form').forEach((form) => {
const button = form.querySelector('.delete-btn');
if (button) {
button.addEventListener('click', (e) => {
const confirmDelete = confirm('Delete this consent log?');
if (!confirmDelete) {
e.preventDefault();
}
});
}
});
});
</script>

View File

@@ -0,0 +1,92 @@
import { createClient } from '@libsql/client';
import type { APIRoute } from 'astro';
const client = createClient({
url: import.meta.env.ASTRO_DB_REMOTE_URL || 'file:./data/consent.db',
authToken: import.meta.env.ASTRO_DB_APP_TOKEN,
});
await client.execute({
sql: `
CREATE TABLE IF NOT EXISTS consent_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT UNIQUE NOT NULL,
timestamp TEXT NOT NULL,
locale TEXT DEFAULT 'th',
essential INTEGER NOT NULL DEFAULT 1,
analytics INTEGER NOT NULL DEFAULT 0,
marketing INTEGER NOT NULL DEFAULT 0,
policy_version TEXT NOT NULL,
ip_hash TEXT,
user_agent TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`,
});
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { sessionId, consent, policyVersion } = body;
if (!sessionId || !consent) {
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const ip = request.headers.get('x-forwarded-for') || 'unknown';
const ipHash = await hashIP(ip);
const userAgent = request.headers.get('user-agent') || 'unknown';
const acceptLanguage = request.headers.get('accept-language') || 'th';
const locale = acceptLanguage.startsWith('th') ? 'th' : 'en';
await client.execute({
sql: `
INSERT OR REPLACE INTO consent_logs (
session_id,
timestamp,
locale,
essential,
analytics,
marketing,
policy_version,
ip_hash,
user_agent
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
args: [
sessionId,
consent.timestamp,
locale,
consent.essential ? 1 : 0,
consent.analytics ? 1 : 0,
consent.marketing ? 1 : 0,
policyVersion,
ipHash,
userAgent,
],
});
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Consent API error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};
async function hashIP(ip: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(ip);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex.substring(0, 16);
}

View File

@@ -0,0 +1,203 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
const POLICY_VERSION = '1.0.0';
const LAST_UPDATED = '2026-03-10';
---
<BaseLayout
title="นโยบายความเป็นส่วนตัว"
description="นโยบายความเป็นส่วนตัวของดีล พลัส เทค ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล พ.ศ. 2562 (PDPA)"
>
<main class="min-h-screen py-12 bg-secondary-50">
<article class="container mx-auto px-4 max-w-4xl">
<div class="bg-white rounded-2xl shadow-lg p-6 md:p-12">
<header class="mb-12 pb-8 border-b-2 border-secondary-200">
<h1 class="text-4xl md:text-5xl font-bold text-secondary-900 mb-4">
นโยบายความเป็นส่วนตัว
</h1>
<p class="text-lg text-secondary-600">
Privacy Policy (Personal Data Protection Policy)
</p>
<div class="mt-4 text-sm text-secondary-500">
<p>Version: {POLICY_VERSION}</p>
<p>Last Updated: {new Date(LAST_UPDATED).toLocaleDateString('th-TH', { year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
</header>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
1. ข้อมูลของผู้ควบคุมข้อมูลส่วนบุคคล
</h2>
<div class="bg-secondary-50 rounded-xl p-6 border-l-4 border-primary-600">
<p class="text-secondary-700 mb-4">
<strong class="text-secondary-900">บริษัท ดีล พลัส เทค จำกัด</strong> เป็นผู้ควบคุมข้อมูลส่วนบุคคล ตาม พ.ร.บ. คุ้มครองข้อมูลส่วนบุคคล พ.ศ. 2562 (PDPA)
</p>
<ul class="space-y-2 text-secondary-700">
<li><strong>ที่อยู่:</strong> 9/70 ซอยนครลุง 17 แขวงบางไผ่ เขตบางแค กทม. 10160</li>
<li><strong>โทรศัพท์:</strong> 090-555-1415</li>
<li><strong>อีเมล:</strong> info@dealplustech.co.th</li>
</ul>
</div>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
2. ประเภทของข้อมูลที่เก็บรวบรวม
</h2>
<ul class="space-y-3">
<li class="flex items-start gap-3">
<span class="text-primary-600 mt-1">✓</span>
<div>
<strong class="text-secondary-900">ข้อมูลประจำตัว:</strong>
<span class="text-secondary-700"> ชื่อ, นามสกุล, ที่อยู่อีเมล, เบอร์โทรศัพท์</span>
</div>
</li>
<li class="flex items-start gap-3">
<span class="text-primary-600 mt-1">✓</span>
<div>
<strong class="text-secondary-900">ข้อมูลการใช้งาน:</strong>
<span class="text-secondary-700"> IP Address, ข้อมูลเบราว์เซอร์, อุปกรณ์ที่ใช้</span>
</div>
</li>
<li class="flex items-start gap-3">
<span class="text-primary-600 mt-1">✓</span>
<div>
<strong class="text-secondary-900">ข้อมูลคุกกี้:</strong>
<span class="text-secondary-700"> การตั้งค่าคุกกี้, Session ID</span>
</div>
</li>
</ul>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
3. วัตถุประสงค์ในการประมวลผลข้อมูล
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-secondary-50 p-4 rounded-lg">
<strong class="text-secondary-900 block mb-1">การให้บริการ</strong>
<span class="text-secondary-600 text-sm">ตอบสนองคำขอ, ให้บริการลูกค้า</span>
</div>
<div class="bg-secondary-50 p-4 rounded-lg">
<strong class="text-secondary-900 block mb-1">การติดต่อกลับ</strong>
<span class="text-secondary-600 text-sm">ตอบคำถาม, ให้ข้อมูลผลิตภัณฑ์</span>
</div>
<div class="bg-secondary-50 p-4 rounded-lg">
<strong class="text-secondary-900 block mb-1">การวิเคราะห์</strong>
<span class="text-secondary-600 text-sm">ปรับปรุงเว็บไซต์, ประสบการณ์ผู้ใช้</span>
</div>
<div class="bg-secondary-50 p-4 rounded-lg">
<strong class="text-secondary-900 block mb-1">ตามกฎหมาย</strong>
<span class="text-secondary-600 text-sm">ปฏิบัติตามข้อบังคับทางกฎหมาย</span>
</div>
</div>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
4. ฐานกฎหมายในการประมวลผลข้อมูล
</h2>
<div class="space-y-4">
<div class="border-l-4 border-primary-600 pl-6">
<h3 class="font-bold text-secondary-900 mb-2">4.1 การยินยอม (Consent)</h3>
<p class="text-secondary-700">สำหรับการใช้คุกกี้ที่ไม่จำเป็น, การตลาด</p>
</div>
<div class="border-l-4 border-primary-600 pl-6">
<h3 class="font-bold text-secondary-900 mb-2">4.2 การ履行合同 (Contract)</h3>
<p class="text-secondary-700">เพื่อการให้บริการและดำเนินการตามคำขอของคุณ</p>
</div>
<div class="border-l-4 border-primary-600 pl-6">
<h3 class="font-bold text-secondary-900 mb-2">4.3 ข้อบังคับทางกฎหมาย (Legal Obligation)</h3>
<p class="text-secondary-700">เพื่อปฏิบัติตามกฎหมายและระเบียบที่เกี่ยวข้อง</p>
</div>
</div>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
5. ระยะเวลาการเก็บรักษาข้อมูล
</h2>
<div class="bg-primary-50 rounded-xl p-6 border-2 border-primary-200">
<ul class="space-y-3">
<li class="flex justify-between items-center">
<span class="text-secondary-700">ข้อมูลการใช้งานเว็บไซต์</span>
<span class="font-semibold text-primary-900">10 ปี</span>
</li>
<li class="flex justify-between items-center">
<span class="text-secondary-700">บันทึกการยินยอมคุกกี้</span>
<span class="font-semibold text-primary-900">10 ปี</span>
</li>
<li class="flex justify-between items-center">
<span class="text-secondary-700">ข้อมูลติดต่อลูกค้า</span>
<span class="font-semibold text-primary-900">5 ปี</span>
</li>
</ul>
</div>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
6. สิทธิของเจ้าของข้อมูลส่วนบุคคล
</h2>
<p class="text-secondary-700 mb-4">
ภายใต้ PDPA คุณมีสิทธิดังนี้:
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-secondary-50 p-4 rounded-lg">
<h3 class="font-bold text-secondary-900 mb-2">สิทธิขอเข้าถึง</h3>
<p class="text-sm text-secondary-600">ขอรับสำเนาข้อมูลส่วนบุคคล</p>
</div>
<div class="bg-secondary-50 p-4 rounded-lg">
<h3 class="font-bold text-secondary-900 mb-2">สิทธิขอแก้ไข</h3>
<p class="text-sm text-secondary-600">ขอให้แก้ไขข้อมูลที่ไม่ถูกต้อง</p>
</div>
<div class="bg-secondary-50 p-4 rounded-lg">
<h3 class="font-bold text-secondary-900 mb-2">สิทธิขอลบ</h3>
<p class="text-sm text-secondary-600">ขอให้ลบข้อมูลส่วนบุคคล</p>
</div>
<div class="bg-secondary-50 p-4 rounded-lg">
<h3 class="font-bold text-secondary-900 mb-2">สิทธิเพิกถอน</h3>
<p class="text-sm text-secondary-600">เพิกถอนความยินยอมเมื่อใดก็ได้</p>
</div>
</div>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
7. คุกกี้และเทคโนโลยีการติดตาม
</h2>
<ul class="space-y-4">
<li class="bg-secondary-50 p-4 rounded-lg border-l-4 border-primary-600">
<h3 class="font-bold text-secondary-900 mb-2">คุกกี้จำเป็น (Essential Cookies)</h3>
<p class="text-sm text-secondary-600">จำเป็นสำหรับการทำงานของเว็บไซต์ ไม่สามารถปิดได้</p>
</li>
<li class="bg-secondary-50 p-4 rounded-lg border-l-4 border-accent-500">
<h3 class="font-bold text-secondary-900 mb-2">คุกกี้วิเคราะห์ (Analytics Cookies)</h3>
<p class="text-sm text-secondary-600">帮助我们了解网站使用情况 (Umami Analytics)</p>
</li>
<li class="bg-secondary-50 p-4 rounded-lg border-l-4 border-secondary-400">
<h3 class="font-bold text-secondary-900 mb-2">คุกกี้การตลาด (Marketing Cookies)</h3>
<p class="text-sm text-secondary-600">ใช้สำหรับแสดงโฆษณาที่เกี่ยวข้อง</p>
</li>
</ul>
</section>
<section class="mb-12">
<h2 class="text-2xl md:text-3xl font-bold text-secondary-900 mb-6">
8. การติดต่อ
</h2>
<div class="bg-primary-50 rounded-xl p-6 border-2 border-primary-200">
<p class="text-secondary-700 mb-4">
หากคุณมีคำถามหรือต้องการใช้สิทธิของคุณ กรุณาติดต่อ:
</p>
<div class="space-y-2">
<p class="text-secondary-900"><strong>อีเมล:</strong> info@dealplustech.co.th</p>
<p class="text-secondary-900"><strong>โทรศัพท์:</strong> 090-555-1415</p>
</div>
</div>
</section>
</div>
</article>
</main>
</BaseLayout>