Spruce up contact and repair request forms, fix Docker build timeouts
Some checks failed
Build and Deploy / deploy (push) Has been cancelled

- Contact page: two-column layout with rover image and email card sidebar
- Repair request: hero section, "How it works" steps sidebar, return address
  field, warranty/charges checkbox
- Reduce static generation concurrency to prevent Docker build timeouts
- Bump staticPageGenerationTimeout to 300s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tim Hadwen
2026-03-06 23:01:06 +10:00
parent bb2a56e7c1
commit 4b2757237e
3 changed files with 399 additions and 270 deletions

View File

@@ -2,7 +2,11 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
staticPageGenerationTimeout: 120, staticPageGenerationTimeout: 300,
experimental: {
staticGenerationMaxConcurrency: 8,
staticGenerationMinPagesPerWorker: 25,
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { Container } from "@/components/layout/Container"; import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { FormField } from "@/components/ui/FormField"; import { FormField } from "@/components/ui/FormField";
@@ -34,7 +35,7 @@ export default function ContactForm() {
if (submitted) { if (submitted) {
return ( return (
<section className="bg-white py-16 sm:py-24"> <section className="bg-muted py-16 sm:py-24">
<Container> <Container>
<div className="mx-auto max-w-2xl text-center"> <div className="mx-auto max-w-2xl text-center">
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"> <h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
@@ -53,113 +54,150 @@ export default function ContactForm() {
"w-full rounded-lg border border-border bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20"; "w-full rounded-lg border border-border bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20";
return ( return (
<section className="bg-white py-16 sm:py-24"> <section className="bg-muted py-16 sm:py-24">
<Container> <Container>
<div className="mx-auto max-w-2xl"> <div className="mb-10">
<div className="mb-12"> <h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"> Contact us.
Contact us. </h1>
</h1> <p className="mt-3 text-foreground-light">
<p className="mt-4 text-foreground-light"> Have a question or need a quote? Fill in the form and we&apos;ll get back to you.
Email:{" "} </p>
<a </div>
href="mailto:contact@micromelon.com.au"
className="text-brand-dark underline hover:text-foreground" <div className="grid gap-12 md:grid-cols-2">
> {/* Form */}
contact@micromelon.com.au <div>
</a> <div className="rounded-2xl border border-border bg-white p-6 shadow-sm sm:p-8">
</p> <form onSubmit={handleSubmit} className="space-y-6">
<FormField
label="Your Name"
name="name"
type="text"
required
placeholder="Your full name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<FormField
label="Email"
name="email"
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<FormField
label="Phone Number"
name="phone"
type="tel"
placeholder="Your phone number"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
{/* School search combobox */}
<div ref={institutionRef} className="relative">
<label htmlFor="institution" className="mb-2 block text-sm font-medium text-foreground">
School / Organisation
</label>
<input
type="text"
id="institution"
value={institution}
onChange={(e) => {
setInstitution(e.target.value);
setInstitutionOpen(true);
}}
onFocus={() => setInstitutionOpen(true)}
placeholder="Search or type your school name"
className={inputClasses}
autoComplete="off"
/>
{institutionOpen && institution.length > 0 && (() => {
const query = institution.toLowerCase();
const matches = KNOWN_SCHOOLS.filter((s) => s.toLowerCase().includes(query));
const exactMatch = KNOWN_SCHOOLS.some((s) => s.toLowerCase() === query);
if (matches.length === 0 && !exactMatch) return null;
if (matches.length === 1 && matches[0].toLowerCase() === query) return null;
return (
<ul className="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-white shadow-lg">
{matches.map((school) => (
<li key={school}>
<button
type="button"
onClick={() => {
setInstitution(school);
setInstitutionOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted"
>
{school}
</button>
</li>
))}
</ul>
);
})()}
</div>
<FormField
label="Message"
name="message"
type="textarea"
required
placeholder="How can we help?"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<div>
<Button type="submit" variant="primary">
SEND
</Button>
</div>
</form>
</div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> {/* Sidebar */}
<FormField <div>
label="Your Name" <div className="sticky top-24 space-y-6">
name="name" <div className="overflow-hidden rounded-2xl">
type="text" <Image
required src="/images/products/rover-candid.png"
placeholder="Your full name" alt="Micromelon Rover"
value={name} width={600}
onChange={(e) => setName(e.target.value)} height={400}
/> className="w-full object-cover"
<FormField />
label="Email" </div>
name="email"
type="email"
required
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<FormField
label="Phone Number"
name="phone"
type="tel"
placeholder="Your phone number"
value={phone}
onChange={(e) => setPhone(e.target.value)}
/>
{/* School search combobox */} <div className="rounded-2xl border border-border bg-white p-5 shadow-sm">
<div ref={institutionRef} className="relative"> <div className="flex items-start gap-3">
<label htmlFor="institution" className="mb-2 block text-sm font-medium text-foreground"> <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-brand/10">
School / Organisation <svg className="h-5 w-5 text-brand" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
</label> <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
<input </svg>
type="text" </div>
id="institution" <div>
value={institution} <p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Email</p>
onChange={(e) => { <a
setInstitution(e.target.value); href="mailto:contact@micromelon.com.au"
setInstitutionOpen(true); className="text-sm font-medium text-brand-dark hover:underline"
}} >
onFocus={() => setInstitutionOpen(true)} contact@micromelon.com.au
placeholder="Search or type your school name" </a>
className={inputClasses} </div>
autoComplete="off" </div>
/> </div>
{institutionOpen && institution.length > 0 && (() => {
const query = institution.toLowerCase(); <p className="text-center text-sm text-muted-foreground">
const matches = KNOWN_SCHOOLS.filter((s) => s.toLowerCase().includes(query)); We typically respond within 1 business day.
const exactMatch = KNOWN_SCHOOLS.some((s) => s.toLowerCase() === query); </p>
if (matches.length === 0 && !exactMatch) return null;
if (matches.length === 1 && matches[0].toLowerCase() === query) return null;
return (
<ul className="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-white shadow-lg">
{matches.map((school) => (
<li key={school}>
<button
type="button"
onClick={() => {
setInstitution(school);
setInstitutionOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted"
>
{school}
</button>
</li>
))}
</ul>
);
})()}
</div> </div>
</div>
<FormField
label="Message"
name="message"
type="textarea"
required
placeholder="How can we help?"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<div>
<Button type="submit" variant="primary">
SEND
</Button>
</div>
</form>
</div> </div>
</Container> </Container>
</section> </section>

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { Container } from "@/components/layout/Container"; import { Container } from "@/components/layout/Container";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { SectionHeading } from "@/components/ui/SectionHeading";
import { KNOWN_SCHOOLS } from "@/data/schools"; import { KNOWN_SCHOOLS } from "@/data/schools";
interface RoverEntry { interface RoverEntry {
@@ -23,6 +23,8 @@ export default function RepairRequestForm() {
const [rovers, setRovers] = useState<RoverEntry[]>([ const [rovers, setRovers] = useState<RoverEntry[]>([
{ id: nextId++, serialNumber: "", issue: "" }, { id: nextId++, serialNumber: "", issue: "" },
]); ]);
const [returnAddress, setReturnAddress] = useState("");
const [agreedToCharges, setAgreedToCharges] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const institutionRef = useRef<HTMLDivElement>(null); const institutionRef = useRef<HTMLDivElement>(null);
@@ -63,6 +65,7 @@ export default function RepairRequestForm() {
email, email,
phone, phone,
institution, institution,
returnAddress,
rovers: rovers.map((r) => ({ rovers: rovers.map((r) => ({
serialNumber: r.serialNumber, serialNumber: r.serialNumber,
issue: r.issue, issue: r.issue,
@@ -79,7 +82,7 @@ export default function RepairRequestForm() {
if (submitted) { if (submitted) {
return ( return (
<section className="bg-white py-16 sm:py-24"> <section className="bg-muted py-16 sm:py-24">
<Container> <Container>
<div className="mx-auto max-w-2xl text-center"> <div className="mx-auto max-w-2xl text-center">
<h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"> <h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
@@ -97,183 +100,267 @@ export default function RepairRequestForm() {
const inputClasses = const inputClasses =
"w-full rounded-lg border border-border bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20"; "w-full rounded-lg border border-border bg-white px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand/20";
const steps = [
{ number: 1, title: "Submit your request", description: "Fill in the form with your rover details and the issues you're experiencing." },
{ number: 2, title: "Receive confirmation", description: "You'll receive a confirmation email acknowledging your repair request." },
{ number: 3, title: "Return Order PDF", description: "We'll email you a Return Order PDF with instructions on how and where to ship your rover." },
{ number: 4, title: "We repair your rovers", description: "Repairs normally take 12 weeks. If your rovers are out of warranty, you'll receive a quote before we proceed." },
{ number: 5, title: "Repaired & on its way", description: "Once repairs are complete, you'll receive an email letting you know your rover is on its way back." },
];
return ( return (
<section className="bg-white py-16 sm:py-24"> <>
<Container> {/* Hero section */}
<div className="mx-auto max-w-2xl"> <section className="bg-muted">
<SectionHeading <Container>
title="Rover Repair Request" <div className="grid items-center gap-8 py-16 sm:py-20 md:grid-cols-2">
subtitle="Let us know which rovers need repair and we'll organise getting them fixed."
centered={false}
/>
<form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-foreground"> <h1 className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
Name <span className="text-brand">*</span> Rover Repair Request
</label> </h1>
<input <p className="mt-4 text-lg text-muted-foreground">
type="text" Let us know which rovers need repair and we&apos;ll organise getting them fixed.
id="name" </p>
required </div>
placeholder="Your full name" <div className="flex justify-center md:justify-end">
value={name} <Image
onChange={(e) => setName(e.target.value)} src="/images/products/rover-candid.png"
className={inputClasses} alt="Micromelon Rover"
width={420}
height={280}
className="rounded-2xl object-cover"
/> />
</div> </div>
</div>
</Container>
</section>
{/* Form + sidebar */}
<section className="bg-white py-16 sm:py-20">
<Container>
<div className="grid gap-12 md:grid-cols-2">
{/* Form */}
<div> <div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground"> <form onSubmit={handleSubmit} className="space-y-6">
Email <span className="text-brand">*</span> <div>
</label> <label htmlFor="name" className="mb-2 block text-sm font-medium text-foreground">
<input Name <span className="text-brand">*</span>
type="email" </label>
id="email" <input
required type="text"
placeholder="you@example.com" id="name"
value={email} required
onChange={(e) => setEmail(e.target.value)} placeholder="Your full name"
className={inputClasses} value={name}
/> onChange={(e) => setName(e.target.value)}
</div> className={inputClasses}
/>
</div>
<div> <div>
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-foreground"> <label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
Phone Email <span className="text-brand">*</span>
</label> </label>
<input <input
type="tel" type="email"
id="phone" id="email"
placeholder="Your phone number" required
value={phone} placeholder="you@example.com"
onChange={(e) => setPhone(e.target.value)} value={email}
className={inputClasses} onChange={(e) => setEmail(e.target.value)}
/> className={inputClasses}
</div> />
</div>
{/* School search combobox */} <div>
<div ref={institutionRef} className="relative"> <label htmlFor="phone" className="mb-2 block text-sm font-medium text-foreground">
<label htmlFor="institution" className="mb-2 block text-sm font-medium text-foreground"> Phone
School / Organisation <span className="text-brand">*</span> </label>
</label> <input
<input type="tel"
type="text" id="phone"
id="institution" placeholder="Your phone number"
required value={phone}
value={institution} onChange={(e) => setPhone(e.target.value)}
onChange={(e) => { className={inputClasses}
setInstitution(e.target.value); />
setInstitutionOpen(true); </div>
}}
onFocus={() => setInstitutionOpen(true)} {/* School search combobox */}
placeholder="Search or type your school name" <div ref={institutionRef} className="relative">
className={inputClasses} <label htmlFor="institution" className="mb-2 block text-sm font-medium text-foreground">
autoComplete="off" School / Organisation <span className="text-brand">*</span>
/> </label>
{institutionOpen && institution.length > 0 && (() => { <input
const query = institution.toLowerCase(); type="text"
const matches = KNOWN_SCHOOLS.filter((s) => s.toLowerCase().includes(query)); id="institution"
const exactMatch = KNOWN_SCHOOLS.some((s) => s.toLowerCase() === query); required
if (matches.length === 0 && !exactMatch) return null; value={institution}
if (matches.length === 1 && matches[0].toLowerCase() === query) return null; onChange={(e) => {
return ( setInstitution(e.target.value);
<ul className="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-white shadow-lg"> setInstitutionOpen(true);
{matches.map((school) => ( }}
<li key={school}> onFocus={() => setInstitutionOpen(true)}
<button placeholder="Search or type your school name"
type="button" className={inputClasses}
onClick={() => { autoComplete="off"
setInstitution(school); />
setInstitutionOpen(false); {institutionOpen && institution.length > 0 && (() => {
}} const query = institution.toLowerCase();
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted" const matches = KNOWN_SCHOOLS.filter((s) => s.toLowerCase().includes(query));
> const exactMatch = KNOWN_SCHOOLS.some((s) => s.toLowerCase() === query);
{school} if (matches.length === 0 && !exactMatch) return null;
</button> if (matches.length === 1 && matches[0].toLowerCase() === query) return null;
</li> return (
<ul className="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-border bg-white shadow-lg">
{matches.map((school) => (
<li key={school}>
<button
type="button"
onClick={() => {
setInstitution(school);
setInstitutionOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted"
>
{school}
</button>
</li>
))}
</ul>
);
})()}
</div>
{/* Rovers */}
<div>
<label className="mb-3 block text-sm font-medium text-foreground">
Rovers for Repair <span className="text-brand">*</span>
</label>
<div className="space-y-3">
{rovers.map((rover, index) => (
<div
key={rover.id}
className="rounded-xl border border-border bg-muted/50 p-4"
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Rover {index + 1}
</span>
{rovers.length > 1 && (
<button
type="button"
onClick={() => removeRover(rover.id)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Remove
</button>
)}
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-foreground">
Serial Number
</label>
<input
type="text"
placeholder="00001234"
inputMode="numeric"
pattern="[0-9]{8}"
maxLength={8}
value={rover.serialNumber}
onChange={(e) => updateRover(rover.id, "serialNumber", e.target.value)}
className={inputClasses}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-foreground">
Issue <span className="text-brand">*</span>
</label>
<textarea
required
rows={2}
placeholder="Describe what's wrong with this rover"
value={rover.issue}
onChange={(e) => updateRover(rover.id, "issue", e.target.value)}
className={inputClasses}
/>
</div>
</div>
</div>
))} ))}
</ul>
);
})()}
</div>
{/* Rovers */}
<div>
<label className="mb-3 block text-sm font-medium text-foreground">
Rovers for Repair <span className="text-brand">*</span>
</label>
<div className="space-y-3">
{rovers.map((rover, index) => (
<div
key={rover.id}
className="rounded-xl border border-border bg-muted/50 p-4"
>
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Rover {index + 1}
</span>
{rovers.length > 1 && (
<button
type="button"
onClick={() => removeRover(rover.id)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Remove
</button>
)}
</div>
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-foreground">
Serial Number
</label>
<input
type="text"
placeholder="00001234"
inputMode="numeric"
pattern="[0-9]{8}"
maxLength={8}
value={rover.serialNumber}
onChange={(e) => updateRover(rover.id, "serialNumber", e.target.value)}
className={inputClasses}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-foreground">
Issue <span className="text-brand">*</span>
</label>
<textarea
required
rows={2}
placeholder="Describe what's wrong with this rover"
value={rover.issue}
onChange={(e) => updateRover(rover.id, "issue", e.target.value)}
className={inputClasses}
/>
</div>
</div>
</div> </div>
))} <button
</div> type="button"
<button onClick={addRover}
type="button" className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm font-medium text-foreground-light hover:border-foreground-light hover:text-foreground transition-colors"
onClick={addRover} >
className="mt-3 flex items-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm font-medium text-foreground-light hover:border-foreground-light hover:text-foreground transition-colors" <span className="text-lg leading-none">+</span>
> Add another rover
<span className="text-lg leading-none">+</span> </button>
Add another rover </div>
</button>
<div>
<label htmlFor="returnAddress" className="mb-2 block text-sm font-medium text-foreground">
Return Address <span className="text-brand">*</span>
</label>
<textarea
id="returnAddress"
required
rows={3}
placeholder="Street address, suburb, state, postcode"
value={returnAddress}
onChange={(e) => setReturnAddress(e.target.value)}
className={inputClasses}
/>
</div>
<div className="flex items-start gap-3">
<input
type="checkbox"
id="agree-charges"
required
checked={agreedToCharges}
onChange={(e) => setAgreedToCharges(e.target.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded border-border text-brand accent-brand focus:ring-brand/20"
/>
<label htmlFor="agree-charges" className="text-sm text-muted-foreground">
I understand that rover repairs within the standard 1-year warranty period are covered at no cost for manufacturing defects. Repairs outside the warranty period, or for damage caused by misuse, intentional damage, or neglect, are subject to a $50 service fee plus the cost of replacement parts. Micromelon reserves the right to assess the rover and provide a quote before proceeding with any chargeable repairs. <span className="text-brand">*</span>
</label>
</div>
<div>
<Button type="submit" variant="primary" disabled={submitting || !agreedToCharges}>
{submitting ? "Submitting..." : "Submit Repair Request"}
</Button>
</div>
</form>
</div> </div>
{/* Sidebar */}
<div> <div>
<Button type="submit" variant="primary" disabled={submitting}> <div className="sticky top-24">
{submitting ? "Submitting..." : "Submit Repair Request"} <div className="rounded-2xl border border-border bg-muted p-6">
</Button> <h3 className="text-lg font-bold text-foreground">How it works</h3>
<div className="mt-5 space-y-8">
{steps.map((step) => (
<div key={step.number} className="flex gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand text-sm font-bold text-white">
{step.number}
</div>
<div>
<p className="font-semibold text-foreground">{step.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{step.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div> </div>
</form> </div>
</div> </Container>
</Container> </section>
</section> </>
); );
} }