Complete Astro migration - PDPA compliant website

- Migrated all pages from Next.js to Astro
- Added PDPA-compliant Privacy Policy (Thai)
- Added PDPA-compliant Terms & Conditions (Thai)
- Added Cookie Policy with disclosure (Thai)
- Implemented cookie consent banner (client-side)
- Integrated Umami Analytics placeholder
- Blog system with 3 posts
- Optimized Docker configuration for production
- Static site build (184KB, 11 pages)
- Ready for Easypanel deployment

Backup: /Users/kunthawatgreethong/Gitea/dealplustech-backup-nextjs-20260309.tar.gz
This commit is contained in:
Kunthawat Greethong
2026-03-09 18:28:01 +07:00
parent 668f69048f
commit 6402d885f9
6183 changed files with 463899 additions and 1913 deletions

345
dealplustech-astro/node_modules/astro-consent/dist/cli.cjs generated vendored Executable file
View File

@@ -0,0 +1,345 @@
#!/usr/bin/env node
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("node:fs"));
const path = __importStar(require("node:path"));
const process = __importStar(require("node:process"));
const CWD = process.cwd();
const args = process.argv.slice(2);
const command = args[0] ?? "install";
const ASTRO_CONFIG_FILES = [
"astro.config.mjs",
"astro.config.ts",
"astro.config.js"
];
function findAstroConfig() {
for (const file of ASTRO_CONFIG_FILES) {
const fullPath = path.join(CWD, file);
if (fs.existsSync(fullPath))
return fullPath;
}
return null;
}
function exitWith(message, code = 1) {
console.error(`\n${message}\n`);
process.exit(code);
}
/* ─────────────────────────────────────
Locate astro.config
───────────────────────────────────── */
const configPath = findAstroConfig();
if (!configPath) {
exitWith("No astro.config.(mjs|ts|js) found. Run this in the root of an Astro project.");
}
let source = fs.readFileSync(configPath, "utf8");
/* ─────────────────────────────────────
REMOVE MODE
───────────────────────────────────── */
if (command === "remove") {
source = source
.replace(/\s*astroConsent\s*\([\s\S]*?\),?/gm, "")
.replace(/import\s+astroConsent\s+from\s+["']astro-consent["'];?\n?/, "");
fs.writeFileSync(configPath, source.trim() + "\n", "utf8");
const cssFile = path.join(CWD, "src", "cookiebanner.css");
if (fs.existsSync(cssFile))
fs.unlinkSync(cssFile);
console.log("\n🧹 astro-consent fully removed\n");
process.exit(0);
}
/* ─────────────────────────────────────
INSTALL MODE
───────────────────────────────────── */
const cssDir = path.join(CWD, "src");
const cssFile = path.join(cssDir, "cookiebanner.css");
if (!fs.existsSync(cssDir)) {
fs.mkdirSync(cssDir, { recursive: true });
}
/* ─────────────────────────────────────
Create CSS file ONCE (theme + structure)
───────────────────────────────────── */
if (!fs.existsSync(cssFile)) {
fs.writeFileSync(cssFile, `/* =========================================================
astro-consent — FULL THEME + STRUCTURE
This file is NEVER overwritten.
========================================================= */
:root {
--cb-font: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--cb-bg: var(--color-surface, #0c1220);
--cb-border: var(--color-border, rgba(255,255,255,0.08));
--cb-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
--cb-text: var(--color-text, #ffffff);
--cb-muted: var(--color-muted, #9ca3af);
--cb-link: var(--color-primary, #60a5fa);
--cb-radius: 16px;
--cb-btn-radius: 999px;
--cb-btn-padding: 10px 18px;
--cb-accept-bg: var(--color-cta, #22c55e);
--cb-accept-text: #ffffff;
--cb-reject-bg: var(--cb-border);
--cb-reject-text: var(--cb-text);
--cb-manage-bg: transparent;
--cb-manage-text: var(--cb-text);
--cb-manage-border: var(--cb-border);
--cb-modal-bg: var(--cb-bg);
--cb-modal-backdrop: rgba(15, 23, 42, 0.45);
--cb-modal-radius: 18px;
--cb-modal-width: 480px;
--cb-toggle-off-bg: var(--cb-border);
--cb-toggle-on-bg: var(--color-accent, #22c55e);
--cb-toggle-knob: #ffffff;
}
/* ===============================
Banner
=============================== */
#astro-consent-banner {
position: fixed;
left: 16px;
right: 16px;
bottom: 16px;
z-index: 9999;
font-family: var(--cb-font);
}
.cb-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
display: flex;
gap: 24px;
justify-content: space-between;
align-items: center;
background: var(--cb-bg);
border-radius: var(--cb-radius);
border: 1px solid var(--cb-border);
box-shadow: var(--cb-shadow);
color: var(--cb-text);
}
.cb-title {
font-size: 16px;
font-weight: 600;
}
.cb-desc {
margin-top: 4px;
font-size: 14px;
color: var(--cb-muted);
}
.cb-desc a {
color: var(--cb-link);
text-decoration: none;
}
.cb-desc a:hover {
text-decoration: underline;
}
/* ===============================
Buttons
=============================== */
.cb-actions {
display: flex;
gap: 10px;
}
.cb-actions button {
padding: var(--cb-btn-padding);
border-radius: var(--cb-btn-radius);
border: 1px solid transparent;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.cb-accept {
background: var(--cb-accept-bg);
color: var(--cb-accept-text);
}
.cb-reject {
background: var(--cb-reject-bg);
color: var(--cb-reject-text);
}
.cb-manage {
background: var(--cb-manage-bg);
color: var(--cb-manage-text);
border: 1px solid var(--cb-manage-border);
}
/* ===============================
Modal
=============================== */
#astro-consent-modal {
position: fixed;
inset: 0;
background: var(--cb-modal-backdrop);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.cb-modal {
width: 100%;
max-width: var(--cb-modal-width);
background: var(--cb-modal-bg);
border-radius: var(--cb-modal-radius);
padding: 28px;
color: var(--cb-text);
border: 1px solid var(--cb-border);
}
/* ===============================
Modal rows (SPACING FIXED)
=============================== */
.cb-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 0;
border-bottom: 1px solid var(--cb-border);
}
.cb-row:last-of-type {
border-bottom: 0;
padding-bottom: 32px;
}
/* ===============================
Modal actions
=============================== */
.cb-modal .cb-actions {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--cb-border);
display: flex;
justify-content: flex-end;
}
/* ===============================
Toggles
=============================== */
.cb-toggle {
width: 44px;
height: 24px;
background: var(--cb-toggle-off-bg);
border-radius: 999px;
position: relative;
cursor: pointer;
flex-shrink: 0;
}
.cb-toggle span {
position: absolute;
width: 18px;
height: 18px;
background: var(--cb-toggle-knob);
border-radius: 50%;
top: 3px;
left: 3px;
transition: transform 0.2s ease;
}
.cb-toggle.active {
background: var(--cb-toggle-on-bg);
}
.cb-toggle.active span {
transform: translateX(20px);
}
/* ===============================
Mobile
=============================== */
@media (max-width: 640px) {
.cb-container {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.cb-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
}
`, "utf8");
console.log("🎨 Created src/cookiebanner.css");
}
/* ─────────────────────────────────────
Inject Astro integration
───────────────────────────────────── */
if (!source.includes(`from "astro-consent"`)) {
source = `import astroConsent from "astro-consent";\n${source}`;
}
if (!source.includes("astroConsent(")) {
source = source.replace(/integrations\s*:\s*\[/, match => `${match}
astroConsent({
siteName: "My Website",
policyUrl: "/privacy",
consent: {
days: 30,
storageKey: "astro-consent"
}
}),
`);
}
fs.writeFileSync(configPath, source, "utf8");
console.log("\n🎉 astro-consent installed successfully");
console.log("👉 Style everything via src/cookiebanner.css");
console.log("👉 Run `astro-consent remove` to uninstall\n");

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
export {};

View File

@@ -0,0 +1,13 @@
import type { CookieBannerConfig } from "../types/config.js";
/**
* DEFAULT_CONFIG
* ---------------------------------------------------------
* Fallback values ONLY.
*
* These are used:
* - when the user omits a value
* - during internal normalisation
*
* User-provided config ALWAYS takes priority.
*/
export declare const DEFAULT_CONFIG: CookieBannerConfig;

View File

@@ -0,0 +1,40 @@
/**
* DEFAULT_CONFIG
* ---------------------------------------------------------
* Fallback values ONLY.
*
* These are used:
* - when the user omits a value
* - during internal normalisation
*
* User-provided config ALWAYS takes priority.
*/
export const DEFAULT_CONFIG = {
siteName: "This website",
policyUrl: "/privacy",
consent: {
enabled: true,
// Number of days consent remains valid
days: 30,
// Must match runtime + frontend API
storageKey: "astro-consent"
},
categories: {
essential: {
label: "Essential",
description: "Required for the website to function correctly",
enabled: true,
readonly: true
},
analytics: {
label: "Analytics",
description: "Helps us understand how visitors use the site",
enabled: false
},
marketing: {
label: "Marketing",
description: "Used to deliver personalised ads",
enabled: false
}
}
};

View File

@@ -0,0 +1,7 @@
import type { CookieBannerConfig } from "../types/config.js";
/**
* Safely loads the user config file and merges it with defaults.
* User values always override defaults.
* Cache-busted to ensure updates are picked up during dev.
*/
export declare function loadUserConfig(projectRoot: string): Promise<CookieBannerConfig>;

View File

@@ -0,0 +1,65 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { pathToFileURL } from "node:url";
import { DEFAULT_CONFIG } from "./defaults.js";
/**
* Safely loads the user config file and merges it with defaults.
* User values always override defaults.
* Cache-busted to ensure updates are picked up during dev.
*/
export async function loadUserConfig(projectRoot) {
const configPath = path.join(projectRoot, "src", "astro-consent", "config.ts");
let userConfig = {};
try {
const stat = fs.statSync(configPath);
const cacheBuster = `?v=${stat.mtimeMs}`;
const imported = await import(
/* @vite-ignore */
pathToFileURL(configPath).href + cacheBuster);
userConfig = imported?.default ?? {};
}
catch (err) {
console.warn("[astro-consent] Failed to load user config, falling back to defaults");
}
return {
siteName: userConfig.siteName ?? DEFAULT_CONFIG.siteName,
policyUrl: userConfig.policyUrl ?? DEFAULT_CONFIG.policyUrl,
consent: {
enabled: userConfig.consent?.enabled ??
DEFAULT_CONFIG.consent.enabled,
days: userConfig.consent?.days ??
DEFAULT_CONFIG.consent.days,
storageKey: userConfig.consent?.storageKey ??
DEFAULT_CONFIG.consent.storageKey
},
categories: mergeCategories(userConfig.categories, DEFAULT_CONFIG.categories)
};
}
/**
* Merge category config safely.
* - Defaults are preserved
* - User overrides win
* - Custom categories are supported
*/
function mergeCategories(userCategories, defaultCategories) {
const merged = {};
// Start with defaults
for (const key of Object.keys(defaultCategories)) {
merged[key] = {
...defaultCategories[key],
...(userCategories?.[key] ?? {})
};
}
// Add user-defined custom categories safely
if (userCategories) {
for (const key of Object.keys(userCategories)) {
if (!merged[key]) {
merged[key] = {
...userCategories[key],
enabled: userCategories[key].enabled ?? false
};
}
}
}
return merged;
}

View File

@@ -0,0 +1,15 @@
import type { AstroIntegration } from "astro";
export interface AstroConsentOptions {
siteName?: string;
policyUrl?: string;
consent?: {
days?: number;
storageKey?: string;
};
categories?: {
essential?: boolean;
analytics?: boolean;
marketing?: boolean;
};
}
export default function astroConsent(options?: AstroConsentOptions): AstroIntegration;

View File

@@ -0,0 +1,173 @@
export default function astroConsent(options = {}) {
const siteName = options.siteName ?? "This website";
const policyUrl = options.policyUrl ?? "/privacy";
const consentDays = options.consent?.days ?? 30;
const storageKey = options.consent?.storageKey ?? "astro-consent";
const defaultCategories = {
essential: true,
analytics: false,
marketing: false,
...options.categories
};
const ttl = consentDays * 24 * 60 * 60 * 1000;
return {
name: "astro-consent",
hooks: {
"astro:config:setup": ({ injectScript }) => {
/* ─────────────────────────────────────
LOAD USER CSS (required)
───────────────────────────────────── */
injectScript("head-inline", `
(() => {
const id = "astro-consent-css";
if (document.getElementById(id)) return;
const link = document.createElement("link");
link.id = id;
link.rel = "stylesheet";
link.href = "/src/cookiebanner.css";
document.head.appendChild(link);
})();
`);
/* ─────────────────────────────────────
Consent runtime (NO CSS)
───────────────────────────────────── */
injectScript("page", `
(() => {
const KEY = "${storageKey}";
const TTL = ${ttl};
function now() { return Date.now(); }
function read() {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (data.expiresAt < now()) {
localStorage.removeItem(KEY);
return null;
}
return data;
} catch {
return null;
}
}
function write(categories) {
localStorage.setItem(KEY, JSON.stringify({
updatedAt: now(),
expiresAt: now() + TTL,
categories
}));
}
window.astroConsent = {
get: read,
set: write,
reset() {
localStorage.removeItem(KEY);
location.reload();
}
};
})();
`);
/* ─────────────────────────────────────
Banner + modal UI (NO CSS)
───────────────────────────────────── */
injectScript("page", `
(() => {
if (window.astroConsent.get()) return;
const state = { ...${JSON.stringify(defaultCategories)} };
const banner = document.createElement("div");
banner.id = "astro-consent-banner";
banner.innerHTML = \`
<div class="cb-container">
<div>
<div class="cb-title">${siteName} uses cookies</div>
<div class="cb-desc">
Choose how your data is used.
<a href="${policyUrl}">Learn more</a>
</div>
</div>
<div class="cb-actions">
<button class="cb-manage">Manage</button>
<button class="cb-reject">Reject</button>
<button class="cb-accept">Accept all</button>
</div>
</div>
\`;
document.body.appendChild(banner);
banner.querySelector(".cb-accept").onclick = () => {
window.astroConsent.set({ essential: true, analytics: true, marketing: true });
banner.remove();
};
banner.querySelector(".cb-reject").onclick = () => {
window.astroConsent.set({ essential: true });
banner.remove();
};
banner.querySelector(".cb-manage").onclick = openModal;
function openModal() {
const modal = document.createElement("div");
modal.id = "astro-consent-modal";
modal.innerHTML = \`
<div class="cb-modal">
<h3>Cookie preferences</h3>
<div class="cb-row">
<span>Essential</span>
<strong>Always on</strong>
</div>
<div class="cb-row">
<span>Analytics</span>
<div class="cb-toggle" data-key="analytics"><span></span></div>
</div>
<div class="cb-row">
<span>Marketing</span>
<div class="cb-toggle" data-key="marketing"><span></span></div>
</div>
<div class="cb-actions">
<button class="cb-accept">Save preferences</button>
</div>
</div>
\`;
document.body.appendChild(modal);
modal.querySelectorAll(".cb-toggle").forEach(toggle => {
const key = toggle.getAttribute("data-key");
if (state[key]) toggle.classList.add("active");
toggle.onclick = () => {
state[key] = !state[key];
toggle.classList.toggle("active");
};
});
modal.querySelector(".cb-accept").onclick = () => {
window.astroConsent.set({ essential: true, ...state });
modal.remove();
banner.remove();
};
modal.onclick = e => {
if (e.target === modal) modal.remove();
};
}
})();
`);
}
}
};
}

View File

@@ -0,0 +1 @@
export declare const DEFAULT_CSS = "\n:root {\n /* Layout */\n --cb-z-index: 9999;\n --cb-max-width: 960px;\n --cb-padding: 16px;\n --cb-gap: 12px;\n --cb-radius: 10px;\n\n /* Colours */\n --cb-bg: #111827;\n --cb-surface: #1f2933;\n --cb-text: #ffffff;\n --cb-muted: #9ca3af;\n --cb-border: #374151;\n --cb-accent: #6366f1;\n\n /* Buttons */\n --cb-btn-bg: var(--cb-accent);\n --cb-btn-text: #ffffff;\n --cb-btn-secondary-bg: transparent;\n --cb-btn-secondary-text: var(--cb-text);\n --cb-btn-border: var(--cb-border);\n\n /* Typography */\n --cb-font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;\n --cb-title-size: 1rem;\n --cb-text-size: 0.875rem;\n}\n\n#astro-consent-banner {\n position: fixed;\n bottom: 0;\n left: 0;\n right: 0;\n z-index: var(--cb-z-index);\n background: var(--cb-bg);\n color: var(--cb-text);\n font-family: var(--cb-font-family);\n border-top: 1px solid var(--cb-border);\n}\n\n#astro-consent-banner > * {\n max-width: var(--cb-max-width);\n margin: 0 auto;\n padding: var(--cb-padding);\n}\n\n#astro-consent-banner h2 {\n margin: 0 0 4px;\n font-size: var(--cb-title-size);\n}\n\n#astro-consent-banner p {\n margin: 0;\n font-size: var(--cb-text-size);\n color: var(--cb-muted);\n}\n\n#astro-consent-banner a {\n color: var(--cb-accent);\n text-decoration: underline;\n}\n\n.astro-consent-categories {\n display: grid;\n gap: var(--cb-gap);\n margin-top: var(--cb-gap);\n}\n\n.astro-consent-category {\n background: var(--cb-surface);\n padding: 12px;\n border-radius: var(--cb-radius);\n border: 1px solid var(--cb-border);\n}\n\n.astro-consent-label {\n display: flex;\n align-items: center;\n gap: 8px;\n font-weight: 600;\n}\n\n.astro-consent-description {\n margin: 4px 0 0 26px;\n font-size: 0.8rem;\n color: var(--cb-muted);\n}\n\n.astro-consent-actions {\n display: flex;\n gap: 8px;\n justify-content: flex-end;\n margin-top: var(--cb-gap);\n flex-wrap: wrap;\n}\n\n.astro-consent-actions button {\n padding: 8px 14px;\n border-radius: var(--cb-radius);\n font-size: 0.875rem;\n cursor: pointer;\n border: 1px solid var(--cb-btn-border);\n}\n\n.astro-consent-actions button:first-child {\n background: var(--cb-btn-secondary-bg);\n color: var(--cb-btn-secondary-text);\n}\n\n.astro-consent-actions button:last-child {\n background: var(--cb-btn-bg);\n color: var(--cb-btn-text);\n border-color: var(--cb-btn-bg);\n}\n";

View File

@@ -0,0 +1,117 @@
export const DEFAULT_CSS = `
:root {
/* Layout */
--cb-z-index: 9999;
--cb-max-width: 960px;
--cb-padding: 16px;
--cb-gap: 12px;
--cb-radius: 10px;
/* Colours */
--cb-bg: #111827;
--cb-surface: #1f2933;
--cb-text: #ffffff;
--cb-muted: #9ca3af;
--cb-border: #374151;
--cb-accent: #6366f1;
/* Buttons */
--cb-btn-bg: var(--cb-accent);
--cb-btn-text: #ffffff;
--cb-btn-secondary-bg: transparent;
--cb-btn-secondary-text: var(--cb-text);
--cb-btn-border: var(--cb-border);
/* Typography */
--cb-font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
--cb-title-size: 1rem;
--cb-text-size: 0.875rem;
}
#astro-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: var(--cb-z-index);
background: var(--cb-bg);
color: var(--cb-text);
font-family: var(--cb-font-family);
border-top: 1px solid var(--cb-border);
}
#astro-consent-banner > * {
max-width: var(--cb-max-width);
margin: 0 auto;
padding: var(--cb-padding);
}
#astro-consent-banner h2 {
margin: 0 0 4px;
font-size: var(--cb-title-size);
}
#astro-consent-banner p {
margin: 0;
font-size: var(--cb-text-size);
color: var(--cb-muted);
}
#astro-consent-banner a {
color: var(--cb-accent);
text-decoration: underline;
}
.astro-consent-categories {
display: grid;
gap: var(--cb-gap);
margin-top: var(--cb-gap);
}
.astro-consent-category {
background: var(--cb-surface);
padding: 12px;
border-radius: var(--cb-radius);
border: 1px solid var(--cb-border);
}
.astro-consent-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.astro-consent-description {
margin: 4px 0 0 26px;
font-size: 0.8rem;
color: var(--cb-muted);
}
.astro-consent-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: var(--cb-gap);
flex-wrap: wrap;
}
.astro-consent-actions button {
padding: 8px 14px;
border-radius: var(--cb-radius);
font-size: 0.875rem;
cursor: pointer;
border: 1px solid var(--cb-btn-border);
}
.astro-consent-actions button:first-child {
background: var(--cb-btn-secondary-bg);
color: var(--cb-btn-secondary-text);
}
.astro-consent-actions button:last-child {
background: var(--cb-btn-bg);
color: var(--cb-btn-text);
border-color: var(--cb-btn-bg);
}
`;

View File

@@ -0,0 +1,56 @@
export interface CookieCategory {
/**
* Whether this category is enabled by default
* (may be overridden by stored consent)
*/
enabled: boolean;
/**
* If true, this category cannot be disabled by the user
* and should always be treated as enabled
*/
readonly?: boolean;
/**
* Human-readable category label
*/
label: string;
/**
* Optional explanation shown to the user
*/
description?: string;
/**
* Optional UI hint:
* hide this category from the preferences UI
*/
hidden?: boolean;
}
export interface CookieBannerConfig {
/**
* Human-readable site name used in banner copy
*/
siteName: string;
/**
* URL to privacy / cookie policy
*/
policyUrl: string;
/**
* Consent storage configuration
*/
consent: {
/**
* Globally enable or disable the consent system
*/
enabled: boolean;
/**
* Number of days consent remains valid
*/
days: number;
/**
* LocalStorage key used to store consent
*/
storageKey: string;
};
/**
* Cookie categories (built-in or user-defined)
*/
categories: Record<string, CookieCategory>;
}

View File

@@ -0,0 +1 @@
export {};