- Add /api/conversions endpoint (Meta CAPI + GA4 Measurement Protocol) - SHA-256 PII hashing, event_id deduplication, fbp/fbc cookies - Client-side sendConversion() utility in PageShell.astro - Lead event tracking on form submit in home.js - GA4 allow_enhanced_conversions config
284 lines
11 KiB
JavaScript
284 lines
11 KiB
JavaScript
/**
|
|
* MoreminiMore production server.
|
|
*
|
|
* Serves Astro static build (dist/) + handles POST /api/contact via Amazon SES
|
|
* + POST /api/conversions for Meta CAPI + Google Enhanced Conversions.
|
|
*
|
|
* Env vars:
|
|
* SES_ACCESS_KEY_ID — AWS IAM key with ses:SendEmail
|
|
* SES_SECRET_ACCESS_KEY — AWS IAM secret
|
|
* SES_REGION — default: ap-southeast-1
|
|
* PORT — default: 4321
|
|
* META_PIXEL_ID — default: 418349260078648
|
|
* META_ACCESS_TOKEN — required for Meta CAPI
|
|
* GA4_MEASUREMENT_ID — default: G-74BHREDLC3
|
|
* GA4_API_SECRET — required for Google Enhanced Conversions
|
|
*/
|
|
|
|
import express from 'express';
|
|
import cors from 'cors';
|
|
import nodemailer from 'nodemailer';
|
|
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import crypto from 'node:crypto';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const PORT = parseInt(process.env.PORT || '4321', 10);
|
|
const DIST = join(__dirname, 'dist');
|
|
|
|
// ── SES ────────────────────────────────────────────────────────────
|
|
const sesConfigured = !!process.env.SES_ACCESS_KEY_ID;
|
|
|
|
let transporter;
|
|
if (sesConfigured) {
|
|
const ses = new SESv2Client({
|
|
region: process.env.SES_REGION || 'ap-southeast-1',
|
|
credentials: {
|
|
accessKeyId: process.env.SES_ACCESS_KEY_ID,
|
|
secretAccessKey: process.env.SES_SECRET_ACCESS_KEY,
|
|
},
|
|
});
|
|
transporter = nodemailer.createTransport({
|
|
SES: { sesClient: ses, SendEmailCommand },
|
|
});
|
|
}
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
const problemLabels = {
|
|
website_no_leads: 'เว็บมีอยู่แล้ว แต่ไม่ค่อยมีลูกค้าทัก',
|
|
ads_not_worth_it: 'ยิงแอดอยู่ แต่ยอดขายไม่คุ้ม',
|
|
wrong_leads: 'มีคนทักมา แต่ไม่ใช่ลูกค้าที่ใช่',
|
|
slow_or_error_work: 'ทีมงานทำงานช้า หรือผิดพลาดบ่อย',
|
|
ai_not_sure: 'อยากใช้ AI แต่ไม่รู้เริ่มตรงไหน',
|
|
not_sure: 'ยังไม่แน่ใจว่าควรแก้อะไรก่อน',
|
|
};
|
|
|
|
function esc(text) {
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
// ── Routes ─────────────────────────────────────────────────────────
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '16kb' }));
|
|
|
|
// POST /api/contact
|
|
app.post('/api/contact', async (req, res) => {
|
|
try {
|
|
if (!sesConfigured) {
|
|
console.warn('[api/contact] SES not configured — logging payload');
|
|
console.info('[api/contact]', JSON.stringify(req.body, null, 2));
|
|
return res.json({ ok: true, devMode: true });
|
|
}
|
|
|
|
const { name, phone, email, problems = [], message = '', pageUrl = '', userAgent = '', website = '' } = req.body;
|
|
|
|
// Validation
|
|
if (!name || !String(name).trim()) {
|
|
return res.status(400).json({ ok: false, error: 'กรุณากรอกชื่อ' });
|
|
}
|
|
if ((!phone || !String(phone).trim()) && (!email || !String(email).trim())) {
|
|
return res.status(400).json({ ok: false, error: 'กรุณากรอกเบอร์โทรหรืออีเมลอย่างน้อยหนึ่งอย่าง' });
|
|
}
|
|
|
|
// Honeypot
|
|
if (website && String(website).trim().length > 0) {
|
|
console.warn('[api/contact] Honeypot triggered');
|
|
return res.json({ ok: true }); // silent success for bots
|
|
}
|
|
|
|
const problemsText = problems.length
|
|
? problems.map((k) => `- ${problemLabels[k] || k}`).join('\n')
|
|
: '(ไม่ได้เลือก)';
|
|
|
|
const msg = String(message || '(ไม่ได้กรอก)');
|
|
const displayPhone = String(phone || '(ไม่ได้กรอก)');
|
|
const displayEmail = String(email || '(ไม่ได้กรอก)');
|
|
|
|
await transporter.sendMail({
|
|
from: 'MoreminiMore <kunthawat@moreminimore.com>',
|
|
to: 'kunthawat@moreminimore.com',
|
|
replyTo: email && email.includes('@') ? email : undefined,
|
|
subject: `Contact: ${name} — ${problems.length} ปัญหา`,
|
|
text: [
|
|
`ข้อความใหม่จาก ${name}`,
|
|
'',
|
|
`ปัญหาที่เลือก:`,
|
|
problemsText,
|
|
'',
|
|
`รายละเอียด:`,
|
|
msg,
|
|
'',
|
|
`ข้อมูลติดต่อ:`,
|
|
`ชื่อ: ${name}`,
|
|
`เบอร์: ${displayPhone}`,
|
|
`อีเมล: ${displayEmail}`,
|
|
'',
|
|
`---`,
|
|
`ส่งจากหน้า: ${pageUrl}`,
|
|
`UA: ${userAgent}`,
|
|
].join('\n'),
|
|
html: [
|
|
`<h2>ข้อความใหม่จาก ${esc(name)}</h2>`,
|
|
`<h3>ปัญหาที่เลือก</h3>`,
|
|
`<p>${esc(problemsText).replace(/\n/g, '<br>')}</p>`,
|
|
`<h3>รายละเอียด</h3>`,
|
|
`<p>${esc(msg)}</p>`,
|
|
`<h3>ข้อมูลติดต่อ</h3>`,
|
|
`<table>`,
|
|
` <tr><td><strong>ชื่อ</strong></td><td>${esc(name)}</td></tr>`,
|
|
` <tr><td><strong>เบอร์</strong></td><td>${esc(displayPhone)}</td></tr>`,
|
|
` <tr><td><strong>อีเมล</strong></td><td>${esc(displayEmail)}</td></tr>`,
|
|
`</table>`,
|
|
`<hr>`,
|
|
`<p style="color:#888;font-size:12px">ส่งจาก <code>${esc(pageUrl)}</code><br>UA: ${esc(userAgent)}</p>`,
|
|
].join('\n'),
|
|
});
|
|
|
|
console.info(`[api/contact] Sent email from ${name} <${email || 'no email'}>`);
|
|
return res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('[api/contact] Error:', err);
|
|
return res.status(500).json({
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : 'เกิดข้อผิดพลาด กรุณาลองใหม่ภายหลัง',
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Meta CAPI + Google Enhanced Conversions ────────────────────────
|
|
const META_PIXEL_ID = process.env.META_PIXEL_ID || '418349260078648';
|
|
const META_ACCESS_TOKEN = process.env.META_ACCESS_TOKEN || '';
|
|
const GA4_MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID || 'G-74BHREDLC3';
|
|
const GA4_API_SECRET = process.env.GA4_API_SECRET || '';
|
|
|
|
function sha256(str) {
|
|
if (!str) return null;
|
|
return crypto.createHash('sha256')
|
|
.update(String(str).trim().toLowerCase())
|
|
.digest('hex');
|
|
}
|
|
|
|
app.post('/api/conversions', async (req, res) => {
|
|
const { event_name, event_id, event_time, event_source_url,
|
|
user_data = {}, custom_data = {} } = req.body;
|
|
|
|
const results = { meta: null, google: null };
|
|
|
|
// ── Meta CAPI ──
|
|
if (META_ACCESS_TOKEN) {
|
|
try {
|
|
const metaBody = {
|
|
data: [{
|
|
event_name,
|
|
event_time,
|
|
event_source_url,
|
|
action_source: 'website',
|
|
event_id,
|
|
user_data: {
|
|
em: sha256(user_data.em),
|
|
ph: user_data.ph ? sha256(String(user_data.ph).replace(/[^0-9+]/g, '')) : null,
|
|
fn: sha256(user_data.fn),
|
|
ln: sha256(user_data.ln),
|
|
fbp: user_data.fbp,
|
|
fbc: user_data.fbc,
|
|
client_ip_address: req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
|
client_user_agent: req.headers['user-agent'],
|
|
},
|
|
custom_data,
|
|
}],
|
|
};
|
|
|
|
const metaRes = await fetch(
|
|
`https://graph.facebook.com/v22.0/${META_PIXEL_ID}/events?access_token=${META_ACCESS_TOKEN}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(metaBody),
|
|
}
|
|
);
|
|
results.meta = await metaRes.json();
|
|
console.info('[api/conversions] Meta CAPI:', JSON.stringify(results.meta));
|
|
} catch (e) {
|
|
console.error('[api/conversions] Meta CAPI error:', e);
|
|
results.meta = { error: e.message };
|
|
}
|
|
} else {
|
|
console.warn('[api/conversions] META_ACCESS_TOKEN not configured — skipping Meta CAPI');
|
|
}
|
|
|
|
// ── Google Enhanced Conversions (Measurement Protocol) ──
|
|
if (GA4_API_SECRET) {
|
|
try {
|
|
const gaBody = {
|
|
client_id: user_data.fbp || crypto.randomUUID(),
|
|
events: [{
|
|
name: event_name.toLowerCase(),
|
|
params: {
|
|
engagement_time_msec: '100',
|
|
session_id: event_id,
|
|
...(custom_data || {}),
|
|
},
|
|
}],
|
|
};
|
|
|
|
// Add user properties if available
|
|
if (user_data.em || user_data.ph) {
|
|
gaBody.user_properties = {};
|
|
if (user_data.em) gaBody.user_properties.user_email = { value: sha256(user_data.em) };
|
|
if (user_data.ph) gaBody.user_properties.user_phone = { value: sha256(String(user_data.ph).replace(/[^0-9+]/g, '')) };
|
|
}
|
|
|
|
const gaRes = await fetch(
|
|
`https://www.google-analytics.com/mp/collect?measurement_id=${GA4_MEASUREMENT_ID}&api_secret=${GA4_API_SECRET}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(gaBody),
|
|
}
|
|
);
|
|
results.google = { status: gaRes.status, ok: gaRes.ok };
|
|
console.info('[api/conversions] Google MP:', JSON.stringify(results.google));
|
|
} catch (e) {
|
|
console.error('[api/conversions] Google MP error:', e);
|
|
results.google = { error: e.message };
|
|
}
|
|
} else {
|
|
console.warn('[api/conversions] GA4_API_SECRET not configured — skipping Google Enhanced Conversions');
|
|
}
|
|
|
|
res.json({ ok: true, results });
|
|
});
|
|
|
|
// Static files
|
|
if (existsSync(DIST)) {
|
|
app.use(express.static(DIST, { maxAge: '1y', etag: true }));
|
|
|
|
// SPA-style fallback for client-side routes
|
|
// Express 5 + path-to-regexp v8: use /{*splat} syntax instead of '*'
|
|
app.get('/{*splat}', (req, res) => {
|
|
// Don't intercept API or asset requests
|
|
if (req.path.startsWith('/api/') || req.path.includes('.')) {
|
|
return res.status(404).end();
|
|
}
|
|
res.sendFile(join(DIST, '404.html'));
|
|
});
|
|
} else {
|
|
console.warn(`[server] dist/ not found — static files not served`);
|
|
}
|
|
|
|
// ── Start ──────────────────────────────────────────────────────────
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|
console.log(`[server] Listening on http://0.0.0.0:${PORT}`);
|
|
console.log(`[server] SES: ${sesConfigured ? 'configured ✓' : 'NOT configured (dev mode)'}`);
|
|
console.log(`[server] Meta CAPI: ${META_ACCESS_TOKEN ? 'configured ✓' : 'NOT configured'}`);
|
|
console.log(`[server] Google EC: ${GA4_API_SECRET ? 'configured ✓' : 'NOT configured'}`);
|
|
console.log(`[server] Static: ${existsSync(DIST) ? 'dist/ ✓' : 'dist/ NOT found'}`);
|
|
});
|