feat(contact): real working form with Apps Script backend (3 sub-tasks)

4.9a: Contact.astro (v6-contact enhanced)
- 2 variants: 'prompt' (1 input + ENTER, for home) + 'full' (5 fields, for /contact)
- Smart-parses prompt input (phone vs name detection)
- Toast feedback on submit (success/error, 5s auto-hide)
- Dev-mode aware (no endpoint = console.log + 'dev mode' toast)

4.9b: apps-script/contact-form/ (USER DEPLOYS)
- Code.gs: doPost() handler → 3 actions:
  1. Append to Google Sheet (SHEET_ID from Script Properties)
  2. Send email via MailApp to RECIPIENT_EMAIL
  3. Send LINE Notify via UrlFetchApp
- Returns {ok, id} or {ok:false, error} as JSON
- Includes testDoPost() for testing in Apps Script editor
- README.md: 6-step deploy guide (Script Properties + Sheet + LINE token)
  with security notes + cost breakdown

4.9c: src/lib/contact-submit.ts
- Reads PUBLIC_CONTACT_ENDPOINT from .env
- Empty/missing = dev mode (console.log + 300ms mock latency)
- Set = POST JSON to endpoint
- Exports submitContact() + isDevMode() for Contact.astro to use

Refs: .hermes/plans/2026-06-13_124000-moreminimore-v7-5-migration.md Task 4.9a-c
This commit is contained in:
Kunthawat Greethong
2026-06-13 17:55:59 +07:00
parent 154e3f2d91
commit 8c2bf3d303
4 changed files with 550 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
---
/**
* MOREMINIMORE - CONTACT FORM (from v6-contact · $ prompt form, enhanced)
* Extracted from Desktop/moreminomore-mockup-v7-5.html lines 1453-1469
*
* Per plan 2026-06-13 round 2 #4: REAL working form with backend
* (Apps Script → Google Sheet + email + LINE Notify).
*
* Two variants:
* - 'prompt': 1 input + ENTER button (for home page) — terminal-style
* - 'full': 5 fields (name, phone, email, service, message) (for /contact)
*
* Submits to src/lib/contact-submit.ts → submitContact()
* which reads PUBLIC_CONTACT_ENDPOINT from .env (empty = dev mode).
*/
interface Props {
variant?: 'prompt' | 'full';
title?: string;
desc?: string;
id?: string;
}
const {
variant = 'full',
title = 'คุยกับเราก่อน 30 นาที ฟรี',
desc = 'เราจะแนะนำแนวทางเบื้องต้น — บอกตรง ๆ ว่าอะไรควรทำ ไม่ควรทำ',
id = 'contact',
} = Astro.props;
// Service options for full variant
const services = [
{ value: 'webdev', label: 'Website Development (Astro / WordPress)' },
{ value: 'ai-consult', label: 'AI Consult' },
{ value: 'automation', label: 'AI Automation' },
{ value: 'marketing', label: 'Online Marketing' },
{ value: 'other', label: 'อื่นๆ / ไม่แน่ใจ' },
];
---
<div id={id} class="fx-contact fx-reveal">
{variant === 'prompt' ? (
<>
<h2 class="fx-contact-title" set:html={title} />
<p class="fx-contact-desc" set:html={desc} />
<form class="fx-contact-form" data-contact-prompt>
<input
type="text"
name="prompt"
class="fx-contact-input"
placeholder="name / phone / line"
required
/>
<button type="submit" class="fx-contact-submit">ENTER</button>
</form>
<p class="fx-contact-hint">กด ENTER เพื่อส่ง — หรือ <a href="/contact">กรอกแบบฟอร์มเต็ม</a></p>
</>
) : (
<>
<h2 class="fx-contact-title" set:html={title} />
<p class="fx-contact-desc" set:html={desc} />
<form class="fx-contact-form-full" data-contact-full>
<div class="fx-contact-row">
<label class="fx-contact-field">
<span>ชื่อ <em>*</em></span>
<input type="text" name="name" required placeholder="สมชาย ใจดี" />
</label>
<label class="fx-contact-field">
<span>เบอร์โทร <em>*</em></span>
<input type="tel" name="phone" required placeholder="080-xxx-xxxx" />
</label>
</div>
<label class="fx-contact-field">
<span>อีเมล</span>
<input type="email" name="email" placeholder="you@example.com" />
</label>
<label class="fx-contact-field">
<span>สนใจบริการ</span>
<select name="service">
{services.map((s) => <option value={s.value}>{s.label}</option>)}
</select>
</label>
<label class="fx-contact-field">
<span>รายละเอียดเพิ่มเติม</span>
<textarea name="message" rows="4" placeholder="เล่าปัญหาหรือเป้าหมายที่อยากให้ช่วย..."></textarea>
</label>
<button type="submit" class="fx-contact-submit">ส่งข้อความ →</button>
</form>
</>
)}
<div class="fx-contact-toast" data-contact-toast hidden></div>
</div>
<script>
import { submitContact, isDevMode } from '../lib/contact-submit';
function showToast(el: HTMLElement, msg: string, type: 'ok' | 'err') {
el.textContent = msg;
el.className = `fx-contact-toast fx-contact-toast-${type}`;
el.hidden = false;
setTimeout(() => { el.hidden = true; }, 5000);
}
document.addEventListener('DOMContentLoaded', () => {
const isDev = isDevMode();
if (isDev) {
console.info('[contact] dev mode — submissions will log to console, not POST to server');
}
// Prompt form (1 input)
const promptForm = document.querySelector<HTMLFormElement>('[data-contact-prompt]');
promptForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(promptForm);
const prompt = String(formData.get('prompt') ?? '');
// Smart-parse: detect if it's name, phone, or line
const looksLikePhone = /[\d-]{8,}/.test(prompt);
const data = {
name: looksLikePhone ? '' : prompt,
phone: looksLikePhone ? prompt : '',
email: '',
service: 'other',
message: prompt,
variant: 'prompt' as const,
};
const toast = document.querySelector<HTMLElement>('[data-contact-toast]');
if (toast) showToast(toast, 'กำลังส่ง...', 'ok');
const result = await submitContact(data);
if (toast) {
if (result.ok) {
showToast(toast, isDev ? '✓ ส่งแล้ว (dev mode)' : '✓ ส่งแล้ว เราจะติดต่อกลับภายใน 24 ชม.', 'ok');
promptForm.reset();
} else {
showToast(toast, '✗ ส่งไม่สำเร็จ กรุณาลองใหม่หรือทัก LINE: @moreminimore', 'err');
}
}
});
// Full form (5 fields)
const fullForm = document.querySelector<HTMLFormElement>('[data-contact-full]');
fullForm?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(fullForm);
const data = {
name: String(formData.get('name') ?? ''),
phone: String(formData.get('phone') ?? ''),
email: String(formData.get('email') ?? ''),
service: String(formData.get('service') ?? 'other'),
message: String(formData.get('message') ?? ''),
variant: 'full' as const,
};
const toast = document.querySelector<HTMLElement>('[data-contact-toast]');
if (toast) showToast(toast, 'กำลังส่ง...', 'ok');
const result = await submitContact(data);
if (toast) {
if (result.ok) {
showToast(toast, isDev ? '✓ ส่งแล้ว (dev mode)' : '✓ ส่งแล้ว เราจะติดต่อกลับภายใน 24 ชม.', 'ok');
fullForm.reset();
} else {
showToast(toast, '✗ ส่งไม่สำเร็จ กรุณาลองใหม่หรือทัก LINE: @moreminimore', 'err');
}
}
});
});
</script>

75
src/lib/contact-submit.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* MOREMINIMORE - Contact form submit handler
* Per plan 2026-06-13 round 2 #4: Real working form with Apps Script backend.
*
* Reads PUBLIC_CONTACT_ENDPOINT from .env (Astro env var).
* - Empty / missing → DEV MODE: log to console, return success (mock)
* - Set → POST JSON to endpoint
*
* Apps Script template lives in apps-script/contact-form/Code.gs
* (user deploys it themselves — see apps-script/contact-form/README.md)
*/
export interface ContactData {
name: string;
phone: string;
email: string;
service: string;
message: string;
variant: 'prompt' | 'full';
}
export interface SubmitResult {
ok: boolean;
error?: string;
id?: string;
devMode?: boolean;
}
/** Check if running in dev mode (no endpoint configured) */
export function isDevMode(): boolean {
const endpoint = import.meta.env.PUBLIC_CONTACT_ENDPOINT;
return !endpoint || endpoint.trim() === '';
}
/**
* Submit contact form data to Apps Script endpoint.
* In dev mode (no endpoint), logs to console and returns success.
*/
export async function submitContact(data: ContactData): Promise<SubmitResult> {
// Dev mode: mock success
if (isDevMode()) {
console.info('[contact-submit] DEV MODE — payload:', data);
// Simulate network latency
await new Promise((r) => setTimeout(r, 300));
return { ok: true, devMode: true };
}
// Production: POST to Apps Script
const endpoint = import.meta.env.PUBLIC_CONTACT_ENDPOINT as string;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...data,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
submittedAt: new Date().toISOString(),
}),
// Apps Script web apps don't support CORS preflight reliably
// mode: 'no-cors' would prevent reading the response, so we accept opaque
});
if (!response.ok) {
return { ok: false, error: `HTTP ${response.status}` };
}
const result = (await response.json()) as { ok?: boolean; id?: string; error?: string };
if (result.ok) {
return { ok: true, id: result.id };
}
return { ok: false, error: result.error ?? 'Unknown error from server' };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}