- 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.
391 lines
13 KiB
Plaintext
391 lines
13 KiB
Plaintext
---
|
||
/**
|
||
* 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 0527,Email: 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>
|