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:
asownerytandClaude Opus 5.5 committed 2026-09-30 20:48:56 +13:00
1 parent 9f5cf387dc
commit 4d901b351e
9 files changed
+293 -68

No files matched your search

+18
View File
@@ -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;
}
+142 -49
View File
@@ -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() { 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&apos;ve got it</h2>
<p>
I&apos;ll usually reply the same day during business hours (Monday
to Friday, 9am to 6pm). If it&apos;s urgent, call or text{" "}
<a href={links.tel} className="font-bold text-sky-800 underline">
{business.phone}
</a>
.
</p>
</div>
);
}
return ( return (
<div> <div>
<h2 className="text-3xl font-bold mb-4">Contact Us</h2> <h2 className="text-3xl font-bold mb-2">Get a quote</h2>
<div className={styles.contactDetails}> <p className="mb-6">
<h3 className="text-lg font-bold hover:underline"> Tell me what&apos;s going on and I&apos;ll get back to you, usually the
<svg same day during business hours.
xmlns="http://www.w3.org/2000/svg" </p>
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>
<form <form
action="https://form.taxi/s/es9ddva1" action={FORM_ACTION}
method="POST" method="POST"
className={styles.form} className={styles.form}
onSubmit={handleSubmit}
> >
<label htmlFor="Name">Name*</label> <label htmlFor="cf-name">Name*</label>
<input type="text" name="Name" required /> <input id="cf-name" type="text" name="Name" autoComplete="name" required />
<label htmlFor="Phone">Phone Number </label> <label htmlFor="cf-email">Email*</label>
<input type="tel" name="Phone" /> <input
id="cf-email"
type="email"
name="Email"
autoComplete="email"
required
/>
<label htmlFor="Email">Email*</label> <label htmlFor="cf-phone">Phone number</label>
<input type="email" name="Email" required /> <input id="cf-phone" type="tel" name="Phone" autoComplete="tel" />
<label htmlFor="firstname">Message*</label> <fieldset className={styles.fieldset}>
<textarea name="Message"></textarea> <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&apos;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&apos;t repair Apple devices.</p>
{status === "error" && (
<p role="alert" className="mb-4 font-bold text-red-700">
Sorry, that didn&apos;t send. Please try again, or call or text{" "}
{business.phone}.
</p>
)}
<input <input
type="submit" type="submit"
value="Submit" value={status === "sending" ? "Sending…" : "Send"}
required disabled={status === "sending"}
className="text-white bg-sky-800 hover:bg-sky-700 transition" className="text-white bg-sky-800 hover:bg-sky-700 transition disabled:opacity-60"
/> />
</form> </form>
</div> </div>
+9 -7
View File
@@ -26,16 +26,18 @@
font-style: italic; font-style: italic;
} }
.contactDetails { .fieldset {
margin-bottom: 2em; margin-bottom: 16px;
} }
.contactDetails h3 { .radios {
display: flex; display: flex;
gap: 0.5em; gap: 1.5em;
align-items: center; margin-top: 6px;
} }
.contactDetails h3 a { .radios label {
color: black; display: flex;
align-items: center;
gap: 0.35em;
} }
+17
View File
@@ -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
View File
@@ -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 HeaderSm from "../components/HeaderSm";
import Button from "../components/Button";
import ContactForm from "../components/ContactForm"; 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({ export const metadata = pageMetadata({
title: "Contact & Book a Repair | Te Atatu Computers", title: "Contact & Book a Repair | Te Atatu Computers",
@@ -9,11 +13,82 @@ export const metadata = pageMetadata({
path: "/contact", path: "/contact",
}); });
export default function Home() { export default function ContactPage() {
const { address } = business;
return ( return (
<div> <div>
<HeaderSm text="Contact" /> <HeaderSm text="Contact & book a repair" />
<section className="container mx-auto mb-64">
<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&apos;m{" "}
{business.ownerName}, and I&apos;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 /> <ContactForm />
</section> </section>
</div> </div>
+2
View File
@@ -5,6 +5,7 @@ import Footer from "./components/Footer";
import ChatwootWidget from "./components/ChatwootWidget"; import ChatwootWidget from "./components/ChatwootWidget";
import JsonLd from "./components/JsonLd"; import JsonLd from "./components/JsonLd";
import MobileCallBar from "./components/MobileCallBar"; import MobileCallBar from "./components/MobileCallBar";
import AnalyticsEvents from "./components/AnalyticsEvents";
import { SITE_URL, business, localBusinessSchema } from "@/lib/site"; import { SITE_URL, business, localBusinessSchema } from "@/lib/site";
const geistSans = localFont({ const geistSans = localFont({
@@ -36,6 +37,7 @@ export default function RootLayout({ children }) {
{children} {children}
<Footer /> <Footer />
<MobileCallBar /> <MobileCallBar />
<AnalyticsEvents />
</body> </body>
</html> </html>
); );
+2 -8
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import styles from "./page.module.css"; import styles from "./page.module.css";
import bgimage from "./assets/header-bg.jpg"; import bgimage from "./assets/header-bg.jpg";
import Button from "./components/Button"; import Button from "./components/Button";
import { GoogleMapsEmbed } from "@next/third-parties/google"; import MapEmbed from "./components/MapEmbed";
import ContactForm from "./components/ContactForm"; import ContactForm from "./components/ContactForm";
import FaqList from "./components/FaqList"; import FaqList from "./components/FaqList";
import CtaBand from "./components/CtaBand"; import CtaBand from "./components/CtaBand";
@@ -319,13 +319,7 @@ export default function Home() {
</p> </p>
</div> </div>
<div className="w-full"> <div className="w-full">
<GoogleMapsEmbed <MapEmbed />
apiKey="AIzaSyDbgQY55TQY5CefsCUwKbc8RfoOUe8U5gY"
width="100%"
height="400px"
mode="place"
q="place_id:ChIJz0ZoDYhADW0R0TDgWoWMq0M"
/>
</div> </div>
</section> </section>
+6
View File
@@ -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);
}
+18
View File
@@ -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;
}