Phase 5: contact, conversion and tracking hooks
- Contact page: Call/Text/WhatsApp at the top, hours, drop-off address, service area, map, and the quote form - Quote form: device, problem, preferred contact; email required, phone optional; labels wired to inputs; _gotcha honeypot; AJAX submit to form.taxi with an inline "same day during business hours" message and an error fallback (still posts normally without JavaScript) - Analytics hooks for click_call, click_text, click_email, submit_quote_form and click_google_reviews; no-ops until Plausible is installed - /review redirects (307) to the Google review link - Maps embed moved to one MapEmbed component Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9f5cf387dc
commit
4d901b351e
9 files changed
+293
-68
No files matched your search
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { eventForHref, trackEvent } from "@/lib/analytics";
|
||||
|
||||
// One site-wide listener, so every call/text/email/review link is tracked.
|
||||
export default function AnalyticsEvents() {
|
||||
useEffect(() => {
|
||||
function handleClick(event) {
|
||||
const link = event.target.closest?.("a[href]");
|
||||
const name = link && eventForHref(link.getAttribute("href"));
|
||||
if (name) trackEvent(name);
|
||||
}
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -1,67 +1,160 @@
|
||||
import styles from "./ContactForm.module.css";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import styles from "./ContactForm.module.css";
|
||||
import { business, links } from "@/lib/site";
|
||||
import { trackEvent } from "@/lib/analytics";
|
||||
|
||||
const FORM_ACTION = "https://form.taxi/s/es9ddva1";
|
||||
|
||||
const devices = ["Laptop", "Desktop PC", "Chromebook", "Other"];
|
||||
const problems = [
|
||||
"Won't turn on or won't start",
|
||||
"Cracked or broken screen",
|
||||
"Running slow / upgrade",
|
||||
"Virus, pop-ups or scam",
|
||||
"Battery or charging",
|
||||
"Moving files to a new computer",
|
||||
"Windows 10 / Windows 11",
|
||||
"Business IT support",
|
||||
"Something else",
|
||||
];
|
||||
const contactMethods = ["Call", "Text", "Email"];
|
||||
|
||||
// Submits to form.taxi with JavaScript for an inline success message; without
|
||||
// JavaScript the form still posts normally.
|
||||
export default function ContactForm() {
|
||||
const [status, setStatus] = useState("idle");
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus("sending");
|
||||
try {
|
||||
const response = await fetch(FORM_ACTION, {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
body: new FormData(event.currentTarget),
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !result.success) throw new Error("Submit failed");
|
||||
setStatus("sent");
|
||||
trackEvent("submit_quote_form");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
if (status === "sent") {
|
||||
return (
|
||||
<div role="status" className="rounded-lg border-2 border-sky-800 p-6">
|
||||
<h2 className="text-2xl font-bold mb-2">Thanks, I've got it</h2>
|
||||
<p>
|
||||
I'll usually reply the same day during business hours (Monday
|
||||
to Friday, 9am to 6pm). If it's urgent, call or text{" "}
|
||||
<a href={links.tel} className="font-bold text-sky-800 underline">
|
||||
{business.phone}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-4">Contact Us</h2>
|
||||
<div className={styles.contactDetails}>
|
||||
<h3 className="text-lg font-bold hover:underline">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M2 3.5A1.5 1.5 0 0 1 3.5 2h1.148a1.5 1.5 0 0 1 1.465 1.175l.716 3.223a1.5 1.5 0 0 1-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 0 0 6.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 0 1 1.767-1.052l3.223.716A1.5 1.5 0 0 1 18 15.352V16.5a1.5 1.5 0 0 1-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 0 1 2.43 8.326 13.019 13.019 0 0 1 2 5V3.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<a href="tel:+64211077483">021 107 7483</a>
|
||||
</h3>
|
||||
<h3 className="text-lg font-bold hover:underline">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.404 14.596A6.5 6.5 0 1 1 16.5 10a1.25 1.25 0 0 1-2.5 0 4 4 0 1 0-.571 2.06A2.75 2.75 0 0 0 18 10a8 8 0 1 0-2.343 5.657.75.75 0 0 0-1.06-1.06 6.5 6.5 0 0 1-9.193 0ZM10 7.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<a href="mailto:[email protected]">
|
||||
info@teatatucomputers.com
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-2">Get a quote</h2>
|
||||
<p className="mb-6">
|
||||
Tell me what's going on and I'll get back to you, usually the
|
||||
same day during business hours.
|
||||
</p>
|
||||
<form
|
||||
action="https://form.taxi/s/es9ddva1"
|
||||
action={FORM_ACTION}
|
||||
method="POST"
|
||||
className={styles.form}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<label htmlFor="Name">Name*</label>
|
||||
<input type="text" name="Name" required />
|
||||
<label htmlFor="cf-name">Name*</label>
|
||||
<input id="cf-name" type="text" name="Name" autoComplete="name" required />
|
||||
|
||||
<label htmlFor="Phone">Phone Number </label>
|
||||
<input type="tel" name="Phone" />
|
||||
<label htmlFor="cf-email">Email*</label>
|
||||
<input
|
||||
id="cf-email"
|
||||
type="email"
|
||||
name="Email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="Email">Email*</label>
|
||||
<input type="email" name="Email" required />
|
||||
<label htmlFor="cf-phone">Phone number</label>
|
||||
<input id="cf-phone" type="tel" name="Phone" autoComplete="tel" />
|
||||
|
||||
<label htmlFor="firstname">Message*</label>
|
||||
<textarea name="Message"></textarea>
|
||||
<fieldset className={styles.fieldset}>
|
||||
<legend>Best way to reach you</legend>
|
||||
<div className={styles.radios}>
|
||||
{contactMethods.map((method) => (
|
||||
<label key={method}>
|
||||
<input
|
||||
type="radio"
|
||||
name="Preferred contact"
|
||||
value={method}
|
||||
defaultChecked={method === "Email"}
|
||||
/>{" "}
|
||||
{method}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<p className={styles.sm}>* required</p>
|
||||
<label htmlFor="cf-device">Device</label>
|
||||
<select id="cf-device" name="Device" defaultValue="Laptop">
|
||||
{devices.map((device) => (
|
||||
<option key={device}>{device}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label htmlFor="cf-problem">Problem</label>
|
||||
<select id="cf-problem" name="Problem" defaultValue="">
|
||||
<option value="" disabled>
|
||||
Choose one
|
||||
</option>
|
||||
{problems.map((problem) => (
|
||||
<option key={problem}>{problem}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label htmlFor="cf-message">What's going on?*</label>
|
||||
<textarea
|
||||
id="cf-message"
|
||||
name="Message"
|
||||
rows={5}
|
||||
placeholder="e.g. HP laptop, screen cracked in the corner, still turns on"
|
||||
required
|
||||
></textarea>
|
||||
|
||||
{/* Honeypot: hidden from people, filled in by bots. Enable "_gotcha" in form.taxi's Spam Shield. */}
|
||||
<input
|
||||
type="text"
|
||||
name="_gotcha"
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<p className={styles.sm}>* required. I don't repair Apple devices.</p>
|
||||
|
||||
{status === "error" && (
|
||||
<p role="alert" className="mb-4 font-bold text-red-700">
|
||||
Sorry, that didn't send. Please try again, or call or text{" "}
|
||||
{business.phone}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="submit"
|
||||
value="Submit"
|
||||
required
|
||||
className="text-white bg-sky-800 hover:bg-sky-700 transition"
|
||||
value={status === "sending" ? "Sending…" : "Send"}
|
||||
disabled={status === "sending"}
|
||||
className="text-white bg-sky-800 hover:bg-sky-700 transition disabled:opacity-60"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -26,16 +26,18 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.contactDetails {
|
||||
margin-bottom: 2em;
|
||||
.fieldset {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.contactDetails h3 {
|
||||
.radios {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
gap: 1.5em;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.contactDetails h3 a {
|
||||
color: black;
|
||||
.radios label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { GoogleMapsEmbed } from "@next/third-parties/google";
|
||||
|
||||
// This key is public by design; restrict it by HTTP referrer in Google Cloud Console.
|
||||
const MAPS_API_KEY = "AIzaSyDbgQY55TQY5CefsCUwKbc8RfoOUe8U5gY";
|
||||
const PLACE_ID = "ChIJz0ZoDYhADW0R0TDgWoWMq0M";
|
||||
|
||||
export default function MapEmbed({ height = "400px" }) {
|
||||
return (
|
||||
<GoogleMapsEmbed
|
||||
apiKey={MAPS_API_KEY}
|
||||
width="100%"
|
||||
height={height}
|
||||
mode="place"
|
||||
q={`place_id:${PLACE_ID}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+79
-4
@@ -1,6 +1,10 @@
|
||||
import Link from "next/link";
|
||||
import { Clock, Mail, MapPin, MessageCircle, MessageSquare, Phone } from "lucide-react";
|
||||
import HeaderSm from "../components/HeaderSm";
|
||||
import Button from "../components/Button";
|
||||
import ContactForm from "../components/ContactForm";
|
||||
import { pageMetadata } from "@/lib/site";
|
||||
import MapEmbed from "../components/MapEmbed";
|
||||
import { business, links, pageMetadata } from "@/lib/site";
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: "Contact & Book a Repair | Te Atatu Computers",
|
||||
@@ -9,11 +13,82 @@ export const metadata = pageMetadata({
|
||||
path: "/contact",
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
export default function ContactPage() {
|
||||
const { address } = business;
|
||||
return (
|
||||
<div>
|
||||
<HeaderSm text="Contact" />
|
||||
<section className="container mx-auto mb-64">
|
||||
<HeaderSm text="Contact & book a repair" />
|
||||
|
||||
<section className="container mx-auto max-w-5xl mb-12">
|
||||
<p className="text-lg mb-6">
|
||||
The quickest way to reach me is a call or text. I'm{" "}
|
||||
{business.ownerName}, and I'll be the one fixing your computer.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3">
|
||||
<Button href={links.tel} variant="primary">
|
||||
<Phone aria-hidden="true" />
|
||||
Call {business.phone}
|
||||
</Button>
|
||||
<Button href={links.sms} variant="outlineDark">
|
||||
<MessageSquare aria-hidden="true" />
|
||||
Text me
|
||||
</Button>
|
||||
<Button href={links.whatsapp} variant="outlineDark">
|
||||
<MessageCircle aria-hidden="true" />
|
||||
WhatsApp
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-5xl mb-16 grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<div>
|
||||
<ul className="space-y-4 mb-6">
|
||||
<li className="flex gap-3">
|
||||
<Clock aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" />
|
||||
<span>
|
||||
<strong className="block">Hours</strong>
|
||||
Monday to Friday, 9am to 6pm. Weekends by appointment.
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<MapPin aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" />
|
||||
<span>
|
||||
<strong className="block">Drop-off by appointment</strong>
|
||||
{address.street}, {address.suburb}, {address.city}{" "}
|
||||
{address.postcode}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<Mail aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" />
|
||||
<span>
|
||||
<strong className="block">Email</strong>
|
||||
<a href={links.email} className="text-sky-800 hover:underline">
|
||||
{business.email}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-2">
|
||||
<strong>Onsite visits and pickup</strong> across{" "}
|
||||
{business.serviceArea.join(", ")}. The travel fee depends on
|
||||
distance.
|
||||
</p>
|
||||
<p>
|
||||
See{" "}
|
||||
<Link href="/pricing" className="text-sky-800 underline">
|
||||
prices
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/#faq" className="text-sky-800 underline">
|
||||
common questions
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<MapEmbed height="360px" />
|
||||
</section>
|
||||
|
||||
<section id="quote" className="container mx-auto max-w-2xl mb-32 scroll-mt-20">
|
||||
<ContactForm />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Footer from "./components/Footer";
|
||||
import ChatwootWidget from "./components/ChatwootWidget";
|
||||
import JsonLd from "./components/JsonLd";
|
||||
import MobileCallBar from "./components/MobileCallBar";
|
||||
import AnalyticsEvents from "./components/AnalyticsEvents";
|
||||
import { SITE_URL, business, localBusinessSchema } from "@/lib/site";
|
||||
|
||||
const geistSans = localFont({
|
||||
@@ -36,6 +37,7 @@ export default function RootLayout({ children }) {
|
||||
{children}
|
||||
<Footer />
|
||||
<MobileCallBar />
|
||||
<AnalyticsEvents />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+2
-8
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import styles from "./page.module.css";
|
||||
import bgimage from "./assets/header-bg.jpg";
|
||||
import Button from "./components/Button";
|
||||
import { GoogleMapsEmbed } from "@next/third-parties/google";
|
||||
import MapEmbed from "./components/MapEmbed";
|
||||
import ContactForm from "./components/ContactForm";
|
||||
import FaqList from "./components/FaqList";
|
||||
import CtaBand from "./components/CtaBand";
|
||||
@@ -319,13 +319,7 @@ export default function Home() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<GoogleMapsEmbed
|
||||
apiKey="AIzaSyDbgQY55TQY5CefsCUwKbc8RfoOUe8U5gY"
|
||||
width="100%"
|
||||
height="400px"
|
||||
mode="place"
|
||||
q="place_id:ChIJz0ZoDYhADW0R0TDgWoWMq0M"
|
||||
/>
|
||||
<MapEmbed />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { business } from "@/lib/site";
|
||||
|
||||
// Short link to text customers after a job: teatatucomputers.com/review
|
||||
export function GET() {
|
||||
return Response.redirect(business.googleReviewUrl, 307);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Sends a custom event to Plausible if its script is installed; otherwise does nothing.
|
||||
// Events: click_call, click_text, click_email, submit_quote_form, click_google_reviews.
|
||||
export function trackEvent(name) {
|
||||
if (typeof window !== "undefined" && typeof window.plausible === "function") {
|
||||
window.plausible(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Maps a clicked link to its event name, or null if it isn't a contact link.
|
||||
export function eventForHref(href = "") {
|
||||
if (href.startsWith("tel:")) return "click_call";
|
||||
if (href.startsWith("sms:") || href.startsWith("https://wa.me/"))
|
||||
return "click_text";
|
||||
if (href.startsWith("mailto:")) return "click_email";
|
||||
if (href.startsWith("https://share.google/") || href.startsWith("https://g.page/"))
|
||||
return "click_google_reviews";
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user