Files
website-enchun-mgr/apps/frontend/src/pages/contact-us.astro
pkupuk df1efb4881 feat(contact): implement Turnstile protection via API proxy
- Add `pages/api/contact.ts` to proxy n8n webhook and verify Turnstile tokens.
- Update `contact-us.astro` form to include Turnstile widget and validation logic.
- Replace hardcoded sitekey with `PUBLIC_TURNSTILE_SITE_KEY` from environment variables.
- Update `dev.vars` to include Cloudflare Turnstile keys.
2026-03-01 14:06:44 +08:00

391 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
/**
* Contact Page - 聯絡我們頁面
* Pixel-perfect implementation based on Webflow design
* Includes form validation, submission handling, and responsive layout
*/
import Layout from "../layouts/Layout.astro";
import HeaderBg from "../components/HeaderBg.astro";
// Metadata for SEO
const title = "聯絡我們 | 恩群數位行銷";
const description =
"有任何問題嗎?歡迎聯絡恩群數位行銷,我們的專業團隊將竭誠為您服務。電話: 02 5570 0527Email: enchuntaiwan@gmail.com";
---
<Layout title={title} description={description}>
<HeaderBg />
<section class="py-20 bg-[#f4f5f7] scroll-mt-20 lg:py-10" id="contact">
<div
class="grid grid-cols-2 gap-16 items-start max-w-3xl mx-auto px-5 lg:grid-cols-2 lg:gap-10"
>
<!-- Contact Image Side -->
<div class="flex justify-center items-center w-full lg:order-1 pt-4">
<img
src="https://enchun-cms.anlstudio.cc/api/media/file/61f24aa108528b532e942d09_Contact%20us-bro%201.svg"
alt="聯絡我們插圖"
class="w-full h-auto object-contain drop-shadow-sm"
/>
</div>
<!-- Contact Form Side -->
<div class="w-full lg:order-2">
<h2
class="text-4xl font-bold leading-snug text-[#2a5b83] mb-3 lg:text-3xl"
>
與我們聯絡
</h2>
<p
class="text-lg font-medium text-[#2a5b83] mb-2 tracking-wide lg:text-base"
>
任何關於行銷的相關訊息或是詢價,歡迎與我們聯絡
</p>
<p class="text-sm italic text-slate-500 mb-8">
有星號的地方 (*) 是必填欄位
</p>
<!-- Contact Form -->
<form id="contact-form" class="w-full flex flex-col gap-6" novalidate>
<!-- Success Message -->
<div
id="form-success"
class="p-4 px-6 rounded-lg text-center font-medium bg-[#d4edda] text-[#155724] border border-[#c3e6cb] animate-[slideUp_0.3s_ease-out]"
style="display: none;"
>
感謝您的留言!我們會盡快回覆您。
</div>
<!-- Error Message -->
<div
id="form-error"
class="p-4 px-6 rounded-lg text-center font-medium bg-[#f8d7da] text-[#721c24] border border-[#f5c6cb] animate-[slideUp_0.3s_ease-out]"
style="display: none;"
>
送出失敗,請稍後再試或直接來電。
</div>
<!-- Name Field -->
<div class="flex flex-col">
<label
for="Name"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
姓名*
</label>
<input
type="text"
id="Name"
name="Name"
class="w-full px-4 py-2.5 border border-[#4a90e2] rounded-lg text-base bg-white focus:outline-none focus:ring-2 focus:ring-[#4a90e2]/20 transition-all shadow-sm [&.error]:border-[#dc3545]"
required
minlength="2"
maxlength="256"
/>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Name-error"></span>
</div>
<!-- Phone Field -->
<div class="flex flex-col">
<label
for="Phone"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
聯絡電話*
</label>
<input
type="tel"
id="Phone"
name="Phone"
class="w-full px-4 py-2.5 border border-white shadow-sm rounded-lg text-base bg-white focus:outline-none focus:border-[#4a90e2] focus:ring-2 focus:ring-[#4a90e2]/20 transition-all [&.error]:border-[#dc3545]"
required
/>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Phone-error"></span>
</div>
<!-- Email Field -->
<div class="flex flex-col">
<label
for="Email"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
Email *
</label>
<input
type="email"
id="Email"
name="Email"
class="w-full px-4 py-2.5 border border-white shadow-sm rounded-lg text-base bg-white focus:outline-none focus:border-[#4a90e2] focus:ring-2 focus:ring-[#4a90e2]/20 transition-all [&.error]:border-[#dc3545]"
required
/>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Email-error"></span>
</div>
<!-- Message Field -->
<div class="flex flex-col">
<label
for="Message"
class="text-[0.95rem] font-medium text-slate-500 mb-2 block"
>
聯絡訊息
</label>
<textarea
id="Message"
name="Message"
class="w-full px-4 py-3 border border-white shadow-sm rounded-lg text-base bg-white focus:outline-none focus:border-[#4a90e2] focus:ring-2 focus:ring-[#4a90e2]/20 transition-all resize-y min-h-[120px] [&.error]:border-[#dc3545]"
required
minlength="10"
maxlength="5000"></textarea>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="Message-error"></span>
</div>
<!-- Turnstile Widget -->
<div class="flex flex-col mt-2">
<div
class="cf-turnstile"
data-sitekey={import.meta.env.PUBLIC_TURNSTILE_SITE_KEY ||
"0x4AAAAAACkOUZK2u7Fo8IZ-"}
data-theme="light"
>
</div>
<span
class="error-message text-[#dc3545] text-sm mt-1 hidden"
id="turnstile-error"></span>
</div>
<!-- Submit Button -->
<div class="flex justify-end mt-2">
<button
type="submit"
class="bg-[#2a5b83] text-white border-none rounded-lg px-10 py-[0.6rem] text-[1.05rem] font-medium cursor-pointer transition-all duration-200 hover:bg-[#1f4666] shadow-sm transform hover:-translate-y-0.5 min-w-[120px] disabled:bg-slate-400 disabled:cursor-not-allowed disabled:transform-none"
id="submit-btn"
>
<span class="button-text">送出</span>
<span class="button-loading" style="display: none;"
>送出中...</span
>
</button>
</div>
</form>
</div>
</div>
</section>
</Layout>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer
></script>
<script>
// Form validation and submission handler
function initContactForm() {
const form = document.getElementById("contact-form") as HTMLFormElement;
const submitBtn = document.getElementById(
"submit-btn",
) as HTMLButtonElement;
const successMsg = document.getElementById("form-success") as HTMLElement;
const errorMsg = document.getElementById("form-error") as HTMLElement;
const turnstileError = document.getElementById(
"turnstile-error",
) as HTMLElement;
if (!form) return;
// Validation patterns
const patterns = {
Name: /^[\u4e00-\u9fa5a-zA-Z\s]{2,256}$/,
Phone: /^[0-9\-\s\+]{6,20}$/,
Email: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
Message: /^.{10,5000}$/,
};
// Validation function
function validateField(
input: HTMLInputElement | HTMLTextAreaElement,
): boolean {
const name = input.name;
const value = input.value.trim();
const errorSpan = document.getElementById(`${name}-error`) as HTMLElement;
if (!input.hasAttribute("required") && !value) {
clearError(input, errorSpan);
return true;
}
let isValid = true;
let errorMessage = "";
// Required check
if (input.hasAttribute("required") && !value) {
isValid = false;
errorMessage = "此欄位為必填";
}
// Pattern validation
else if (
patterns[name as keyof typeof patterns] &&
!patterns[name as keyof typeof patterns].test(value)
) {
isValid = false;
switch (name) {
case "Name":
errorMessage = "請輸入有效的姓名(至少 2 個字元)";
break;
case "Phone":
errorMessage = "請輸入有效的電話號碼";
break;
case "Email":
errorMessage = "請輸入有效的 Email 格式";
break;
case "Message":
errorMessage = "訊息至少需要 10 個字元";
break;
}
}
// Show/hide error
if (!isValid) {
input.classList.add("error");
if (errorSpan) {
errorSpan.textContent = errorMessage;
errorSpan.style.display = "block";
}
} else {
clearError(input, errorSpan);
}
return isValid;
}
function clearError(
input: HTMLInputElement | HTMLTextAreaElement,
errorSpan: HTMLElement,
) {
input.classList.remove("error");
if (errorSpan) {
errorSpan.textContent = "";
errorSpan.style.display = "none";
}
}
// Real-time validation on blur
form.querySelectorAll("input, textarea").forEach((field) => {
field.addEventListener("blur", () => {
validateField(field as HTMLInputElement | HTMLTextAreaElement);
});
field.addEventListener("input", () => {
const input = field as HTMLInputElement | HTMLTextAreaElement;
if (input.classList.contains("error")) {
validateField(input);
}
});
});
// Form submission
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Validate all fields
const inputs = form.querySelectorAll("input, textarea") as NodeListOf<
HTMLInputElement | HTMLTextAreaElement
>;
let isFormValid = true;
inputs.forEach((input) => {
if (!validateField(input)) {
isFormValid = false;
}
});
// Collect form data to check turnstile token
const formData = new FormData(form);
const turnstileToken = formData.get("cf-turnstile-response");
// Validate Turnstile
if (!turnstileToken) {
isFormValid = false;
if (turnstileError) {
turnstileError.textContent = "請完成驗證";
turnstileError.style.display = "block";
}
} else {
if (turnstileError) {
turnstileError.textContent = "";
turnstileError.style.display = "none";
}
}
if (!isFormValid) {
// Scroll to first error
const firstError = form.querySelector(
".input_field.error, #turnstile-error[style*='display: block']",
) as HTMLElement;
if (firstError) {
firstError.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
// Show loading state
submitBtn.disabled = true;
const buttonText = submitBtn.querySelector(".button-text") as HTMLElement;
const buttonLoading = submitBtn.querySelector(
".button-loading",
) as HTMLElement;
if (buttonText) buttonText.style.display = "none";
if (buttonLoading) buttonLoading.style.display = "inline";
// Hide previous messages
successMsg.style.display = "none";
errorMsg.style.display = "none";
const data = {
name: formData.get("Name"),
phone: formData.get("Phone"),
email: formData.get("Email"),
message: formData.get("Message"),
"cf-turnstile-response": turnstileToken,
};
try {
// Submit to API proxy
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
// Success
successMsg.style.display = "block";
form.reset();
window.scrollTo({ top: 0, behavior: "smooth" });
} else {
throw new Error("Submission failed");
}
} catch (error) {
console.error("Form submission error:", error);
errorMsg.style.display = "block";
} finally {
// Reset button state
submitBtn.disabled = false;
if (buttonText) buttonText.style.display = "inline";
if (buttonLoading) buttonLoading.style.display = "none";
}
});
}
// Initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initContactForm);
if (document.readyState !== "loading") {
initContactForm();
}
</script>