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() {
|
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 (
|
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's going on and I'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'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
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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 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'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 />
|
<ContactForm />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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