Swap to shadcn components and polish the design

Components (added via the shadcn MCP/CLI):
- Navbar: NavigationMenu on desktop, Sheet on mobile; drops
  Navbar.module.css and the hand-rolled slide-down menu
- Buttons: shadcn Button via a shared ContactButtons component
  (replaces Button.js and its CSS module)
- Card for price cards, testimonials, the business block and plan;
  Table for prices; Input/Textarea/Label/NativeSelect/RadioGroup and
  Alert in the quote form; Separator in the footer
- Components import cn from @/lib/utils (not the new "cn" package);
  accordion moved to the unified radix-ui package

Design tweaks:
- Geist Sans is actually applied (font variables were on <body>, but
  Tailwind reads them on <html>, so the site fell back to system fonts)
- Theme primary set to the existing brand blue; slightly smaller radius
- Balanced, tighter heading tracking; muted secondary text
- 44px contact buttons; 16px FAQ text; tighter homepage spacing
- Full-width selects with 16px text on phones (no iOS zoom)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
asownerytandClaude Opus 5.5 committed 2026-09-30 21:25:07 +13:00
1 parent f6846ebaae
commit f916f058fc
36 files changed
+2888 -823

No files matched your search

+1478 -1
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -9,11 +9,11 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.20",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.49.0", "lucide-react": "^1.49.0",
"next": "^16.3.7", "next": "^16.3.7",
"radix-ui": "^1.6.7",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"sharp": "^0.35.5", "sharp": "^0.35.5",
+1 -1
View File
@@ -68,7 +68,7 @@ export default function AboutPage() {
{areaSlugs[name] ? ( {areaSlugs[name] ? (
<Link <Link
href={`/areas/${areaSlugs[name]}`} href={`/areas/${areaSlugs[name]}`}
className="text-sky-800 hover:underline" className="text-primary hover:underline"
> >
{name} {name}
</Link> </Link>
+7 -16
View File
@@ -1,14 +1,14 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ArrowRight, MessageSquare, Phone } from "lucide-react"; import { ArrowRight } from "lucide-react";
import HeaderSm from "../../components/HeaderSm"; import HeaderSm from "../../components/HeaderSm";
import Button from "../../components/Button"; import ContactButtons from "../../components/ContactButtons";
import CtaBand from "../../components/CtaBand"; import CtaBand from "../../components/CtaBand";
import FaqList from "../../components/FaqList"; import FaqList from "../../components/FaqList";
import JsonLd from "../../components/JsonLd"; import JsonLd from "../../components/JsonLd";
import { areas, getArea } from "@/lib/areas"; import { areas, getArea } from "@/lib/areas";
import { services } from "@/lib/services"; import { services } from "@/lib/services";
import { business, links, pageMetadata, serviceSchema } from "@/lib/site"; import { business, pageMetadata, serviceSchema } from "@/lib/site";
export const dynamicParams = false; export const dynamicParams = false;
@@ -52,16 +52,7 @@ export default async function AreaPage({ params }) {
<section className="container mx-auto max-w-3xl mb-16"> <section className="container mx-auto max-w-3xl mb-16">
<p className="text-lg mb-6">{area.intro}</p> <p className="text-lg mb-6">{area.intro}</p>
<div className="flex flex-col sm:flex-row gap-3"> <ContactButtons />
<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>
</div>
</section> </section>
<section className="container mx-auto max-w-3xl mb-16"> <section className="container mx-auto max-w-3xl mb-16">
@@ -85,7 +76,7 @@ export default async function AreaPage({ params }) {
<li key={slug}> <li key={slug}>
<Link <Link
href={`/services/${slug}`} href={`/services/${slug}`}
className="inline-flex items-center gap-1 font-bold text-sky-800 hover:underline" className="inline-flex items-center gap-1 font-bold text-primary hover:underline"
> >
{service.name} {service.name}
<ArrowRight aria-hidden="true" className="size-4" /> <ArrowRight aria-hidden="true" className="size-4" />
@@ -95,7 +86,7 @@ export default async function AreaPage({ params }) {
</ul> </ul>
<p> <p>
See all prices on the{" "} See all prices on the{" "}
<Link href="/pricing" className="text-sky-800 underline"> <Link href="/pricing" className="text-primary underline">
pricing page pricing page
</Link> </Link>
. I don&apos;t repair Apple devices. . I don&apos;t repair Apple devices.
@@ -118,7 +109,7 @@ export default async function AreaPage({ params }) {
<li key={slug}> <li key={slug}>
<Link <Link
href={`/areas/${slug}`} href={`/areas/${slug}`}
className="text-sky-800 hover:underline" className="text-primary hover:underline"
> >
{name} {name}
</Link> </Link>
+43 -38
View File
@@ -1,14 +1,20 @@
import Link from "next/link"; import Link from "next/link";
import { Check, MessageSquare, Phone } from "lucide-react"; import { Check } from "lucide-react";
import HeaderSm from "../components/HeaderSm"; import HeaderSm from "../components/HeaderSm";
import Button from "../components/Button"; import ContactButtons from "../components/ContactButtons";
import CtaBand from "../components/CtaBand"; import CtaBand from "../components/CtaBand";
import FaqList from "../components/FaqList"; import FaqList from "../components/FaqList";
import JsonLd from "../components/JsonLd"; import JsonLd from "../components/JsonLd";
import { import {
business, Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
businessPlan, businessPlan,
links,
pageMetadata, pageMetadata,
serviceSchema, serviceSchema,
} from "@/lib/site"; } from "@/lib/site";
@@ -77,40 +83,39 @@ export default function BusinessPage() {
I support small businesses around Te Atatū and West Auckland, onsite I support small businesses around Te Atatū and West Auckland, onsite
or remotely. You deal with me directly, every time. or remotely. You deal with me directly, every time.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3"> <ContactButtons />
<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>
</div>
</section> </section>
<section className="container mx-auto max-w-3xl mb-16"> <section className="container mx-auto max-w-3xl mb-16">
<div className="rounded-lg border-2 border-sky-800 p-6 md:p-8"> <Card className="border-primary shadow-none">
<h2 className="text-2xl font-bold mb-1">Business IT plan</h2> <CardHeader>
<p className="mb-4"> <CardTitle>
<span className="text-4xl font-bold text-sky-800"> <h2 className="text-2xl font-bold">Business IT plan</h2>
{businessPlan.price} </CardTitle>
</span>{" "} <CardDescription>
a month <span className="text-4xl font-bold text-primary">
</p> {businessPlan.price}
<ul className="space-y-2 mb-4"> </span>{" "}
{businessPlan.includes.map((item) => ( a month
<li key={item} className="flex gap-2"> </CardDescription>
<Check </CardHeader>
aria-hidden="true" <CardContent>
className="size-5 shrink-0 mt-0.5 text-sky-800" <ul className="space-y-2">
/> {businessPlan.includes.map((item) => (
{item} <li key={item} className="flex gap-2">
</li> <Check
))} aria-hidden="true"
</ul> className="size-5 shrink-0 mt-0.5 text-primary"
<p className="text-slate-600">{businessPlan.terms}</p> />
</div> {item}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">{businessPlan.terms}</p>
</CardFooter>
</Card>
<p className="mt-4"> <p className="mt-4">
Prefer to pay as you go? Support is $35 an hour, onsite or remote. Prefer to pay as you go? Support is $35 an hour, onsite or remote.
</p> </p>
@@ -123,7 +128,7 @@ export default function BusinessPage() {
<li key={item} className="flex gap-2"> <li key={item} className="flex gap-2">
<Check <Check
aria-hidden="true" aria-hidden="true"
className="size-5 shrink-0 mt-0.5 text-sky-800" className="size-5 shrink-0 mt-0.5 text-primary"
/> />
{item} {item}
</li> </li>
@@ -133,14 +138,14 @@ export default function BusinessPage() {
Hardware repairs and upgrades are covered too. Parts are extra. See{" "} Hardware repairs and upgrades are covered too. Parts are extra. See{" "}
<Link <Link
href="/services/it-support" href="/services/it-support"
className="text-sky-800 underline" className="text-primary underline"
> >
IT support IT support
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link <Link
href="/services/computer-repair" href="/services/computer-repair"
className="text-sky-800 underline" className="text-primary underline"
> >
computer repairs computer repairs
</Link> </Link>
-16
View File
@@ -1,16 +0,0 @@
import Link from "next/link";
import styles from "./Button.module.css";
// Renders a link when given href (internal paths use next/link), otherwise a button.
export default function Button({ children, onClick, href, variant }) {
const className = `${styles.button} ${variant ? styles[variant] : ""}`;
if (href?.startsWith("/")) {
return <Link href={href} className={className}>{children}</Link>;
}
if (href) {
return <a href={href} className={className}>{children}</a>;
}
return (
<button className={className} onClick={onClick}>{children}</button>
);
}
-37
View File
@@ -1,37 +0,0 @@
.button {
background-color: white;
border: none;
color: black;
padding: 1em 2em;
text-align: center;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5em;
font-size: 1em;
cursor: pointer;
border-radius: 5px;
}
.button svg {
width: 1em;
height: 1em;
}
.outline {
background-color: transparent;
color: white;
box-shadow: inset 0 0 0 2px white;
}
.primary {
background-color: var(--color-sky-800);
color: white;
}
.outlineDark {
background-color: transparent;
color: var(--color-sky-800);
box-shadow: inset 0 0 0 2px var(--color-sky-800);
}
+56
View File
@@ -0,0 +1,56 @@
import Link from "next/link";
import { MessageCircle, MessageSquare, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { business, links } from "@/lib/site";
// Call / Text (and optionally WhatsApp and Get a quote) buttons.
// Use onDark over photos or the blue band, where the default styles lack contrast.
export default function ContactButtons({
onDark = false,
textLabel = "Text me",
whatsapp = false,
quoteHref,
className,
}) {
// Larger than shadcn's lg size: these are the main tap targets on phones.
const base = "h-11 px-6 text-base";
const secondary = onDark
? "border-white bg-transparent text-white hover:bg-white/10 hover:text-white"
: "border-primary text-primary hover:text-primary";
return (
<div className={cn("flex flex-col gap-3 sm:flex-row sm:flex-wrap", className)}>
<Button
asChild
size="lg"
variant={onDark ? "secondary" : "default"}
className={base}
>
<a href={links.tel}>
<Phone aria-hidden="true" />
Call {business.phone}
</a>
</Button>
<Button asChild size="lg" variant="outline" className={cn(base, secondary)}>
<a href={links.sms}>
<MessageSquare aria-hidden="true" />
{textLabel}
</a>
</Button>
{whatsapp && (
<Button asChild size="lg" variant="outline" className={cn(base, secondary)}>
<a href={links.whatsapp}>
<MessageCircle aria-hidden="true" />
WhatsApp
</a>
</Button>
)}
{quoteHref && (
<Button asChild size="lg" variant="outline" className={cn(base, secondary)}>
<Link href={quoteHref}>Get a quote</Link>
</Button>
)}
</div>
);
}
+126 -73
View File
@@ -1,7 +1,17 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import styles from "./ContactForm.module.css"; import { CircleAlert, CircleCheck } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
NativeSelect,
NativeSelectOption,
} from "@/components/ui/native-select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { business, links } from "@/lib/site"; import { business, links } from "@/lib/site";
import { trackEvent } from "@/lib/analytics"; import { trackEvent } from "@/lib/analytics";
@@ -21,6 +31,10 @@ const problems = [
]; ];
const contactMethods = ["Call", "Text", "Email"]; const contactMethods = ["Call", "Text", "Email"];
function Field({ children }) {
return <div className="grid gap-2">{children}</div>;
}
// Submits to form.taxi with JavaScript for an inline success message; without // Submits to form.taxi with JavaScript for an inline success message; without
// JavaScript the form still posts normally. // JavaScript the form still posts normally.
export default function ContactForm() { export default function ContactForm() {
@@ -46,90 +60,122 @@ export default function ContactForm() {
if (status === "sent") { if (status === "sent") {
return ( return (
<div role="status" className="rounded-lg border-2 border-sky-800 p-6"> <Alert role="status" className="border-primary">
<h2 className="text-2xl font-bold mb-2">Thanks, I&apos;ve got it</h2> <CircleCheck aria-hidden="true" />
<p> <AlertTitle>Thanks, I&apos;ve got it</AlertTitle>
I&apos;ll usually reply the same day during business hours (Monday <AlertDescription>
to Friday, 9am to 6pm). If it&apos;s urgent, call or text{" "} <p>
<a href={links.tel} className="font-bold text-sky-800 underline"> I&apos;ll usually reply the same day during business hours (Monday
{business.phone} to Friday, 9am to 6pm). If it&apos;s urgent, call or text{" "}
</a> <a href={links.tel} className="font-semibold text-primary underline">
. {business.phone}
</p> </a>
</div> .
</p>
</AlertDescription>
</Alert>
); );
} }
return ( return (
<div> <div>
<h2 className="text-3xl font-bold mb-2">Get a quote</h2> <h2 className="text-3xl font-bold mb-2">Get a quote</h2>
<p className="mb-6"> <p className="mb-6 text-muted-foreground">
Tell me what&apos;s going on and I&apos;ll get back to you, usually the Tell me what&apos;s going on and I&apos;ll get back to you, usually the
same day during business hours. same day during business hours.
</p> </p>
<form <form
action={FORM_ACTION} action={FORM_ACTION}
method="POST" method="POST"
className={styles.form} className="grid gap-5"
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<label htmlFor="cf-name">Name*</label> <Field>
<input id="cf-name" type="text" name="Name" autoComplete="name" required /> <Label htmlFor="cf-name">Name*</Label>
<Input id="cf-name" name="Name" autoComplete="name" required />
</Field>
<label htmlFor="cf-email">Email*</label> <div className="grid gap-5 sm:grid-cols-2">
<input <Field>
id="cf-email" <Label htmlFor="cf-email">Email*</Label>
type="email" <Input
name="Email" id="cf-email"
autoComplete="email" type="email"
required name="Email"
/> autoComplete="email"
required
/>
</Field>
<Field>
<Label htmlFor="cf-phone">Phone number</Label>
<Input id="cf-phone" type="tel" name="Phone" autoComplete="tel" />
</Field>
</div>
<label htmlFor="cf-phone">Phone number</label> <fieldset className="grid gap-3">
<input id="cf-phone" type="tel" name="Phone" autoComplete="tel" /> <legend className="mb-3 text-sm font-medium">
Best way to reach you
<fieldset className={styles.fieldset}> </legend>
<legend>Best way to reach you</legend> <RadioGroup
<div className={styles.radios}> name="Preferred contact"
defaultValue="Email"
className="flex gap-6"
>
{contactMethods.map((method) => ( {contactMethods.map((method) => (
<label key={method}> <div key={method} className="flex items-center gap-2">
<input <RadioGroupItem value={method} id={`cf-contact-${method}`} />
type="radio" <Label htmlFor={`cf-contact-${method}`} className="font-normal">
name="Preferred contact" {method}
value={method} </Label>
defaultChecked={method === "Email"} </div>
/>{" "}
{method}
</label>
))} ))}
</div> </RadioGroup>
</fieldset> </fieldset>
<label htmlFor="cf-device">Device</label> <div className="grid gap-5 sm:grid-cols-2">
<select id="cf-device" name="Device" defaultValue="Laptop"> <Field>
{devices.map((device) => ( <Label htmlFor="cf-device">Device</Label>
<option key={device}>{device}</option> <NativeSelect
))} id="cf-device"
</select> name="Device"
defaultValue="Laptop"
wrapperClassName="w-full"
className="text-base md:text-sm"
>
{devices.map((device) => (
<NativeSelectOption key={device}>{device}</NativeSelectOption>
))}
</NativeSelect>
</Field>
<Field>
<Label htmlFor="cf-problem">Problem</Label>
<NativeSelect
id="cf-problem"
name="Problem"
defaultValue=""
wrapperClassName="w-full"
className="text-base md:text-sm"
>
<NativeSelectOption value="" disabled>
Choose one
</NativeSelectOption>
{problems.map((problem) => (
<NativeSelectOption key={problem}>{problem}</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
<label htmlFor="cf-problem">Problem</label> <Field>
<select id="cf-problem" name="Problem" defaultValue=""> <Label htmlFor="cf-message">What&apos;s going on?*</Label>
<option value="" disabled> <Textarea
Choose one id="cf-message"
</option> name="Message"
{problems.map((problem) => ( rows={5}
<option key={problem}>{problem}</option> placeholder="e.g. HP laptop, screen cracked in the corner, still turns on"
))} required
</select> />
</Field>
<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. */} {/* Honeypot: hidden from people, filled in by bots. Enable "_gotcha" in form.taxi's Spam Shield. */}
<input <input
@@ -141,21 +187,28 @@ export default function ContactForm() {
aria-hidden="true" aria-hidden="true"
/> />
<p className={styles.sm}>* required. I don&apos;t repair Apple devices.</p> <p className="text-sm text-muted-foreground">
* required. I don&apos;t repair Apple devices.
</p>
{status === "error" && ( {status === "error" && (
<p role="alert" className="mb-4 font-bold text-red-700"> <Alert variant="destructive" role="alert">
Sorry, that didn&apos;t send. Please try again, or call or text{" "} <CircleAlert aria-hidden="true" />
{business.phone}. <AlertTitle>That didn&apos;t send</AlertTitle>
</p> <AlertDescription>
Please try again, or call or text {business.phone}.
</AlertDescription>
</Alert>
)} )}
<input <Button
type="submit" type="submit"
value={status === "sending" ? "Sending…" : "Send"} size="lg"
disabled={status === "sending"} disabled={status === "sending"}
className="text-white bg-sky-800 hover:bg-sky-700 transition disabled:opacity-60" className="justify-self-start"
/> >
{status === "sending" ? "Sending…" : "Send"}
</Button>
</form> </form>
</div> </div>
); );
-43
View File
@@ -1,43 +0,0 @@
.form input[type="text"],
.form input[type="email"],
.form input[type="tel"],
.form select,
.form textarea {
width: 100%; /* Full width */
padding: 1em;
border: 1px solid #ccc; /* Border */
border-radius: 0.5em; /* Rounded corners */
box-sizing: border-box; /* Make sure that padding and width stays in place */
margin-top: 6px;
margin-bottom: 16px;
resize: vertical; /* Allow the user to vertically resize the textarea (not horizontally) */
}
/* Style the submit button */
.form input[type="submit"] {
padding: 1em 1.75em;
border: none;
border-radius: 4px;
cursor: pointer;
}
.sm {
font-size: 0.75em;
font-style: italic;
}
.fieldset {
margin-bottom: 16px;
}
.radios {
display: flex;
gap: 1.5em;
margin-top: 6px;
}
.radios label {
display: flex;
align-items: center;
gap: 0.35em;
}
+11 -22
View File
@@ -1,33 +1,22 @@
import { MessageCircle, MessageSquare, Phone } from "lucide-react"; import ContactButtons from "./ContactButtons";
import Button from "./Button";
import { business, links } from "@/lib/site";
export default function CtaBand({ quoteHref = "/contact" }) { export default function CtaBand({ quoteHref = "/contact" }) {
return ( return (
<section className="bg-sky-900 text-white py-16"> <section className="bg-sky-900 text-white py-16">
<div className="container mx-auto text-center"> <div className="container mx-auto text-center">
<h2 className="text-3xl font-bold mb-4">Get your computer sorted</h2> <h2 className="text-3xl font-bold tracking-tight mb-3">
<p className="mb-8"> Get your computer sorted
</h2>
<p className="mb-8 text-sky-100">
Call or text now, or send the details and I&apos;ll get back to you Call or text now, or send the details and I&apos;ll get back to you
with a quote. with a quote.
</p> </p>
<div className="flex flex-col sm:flex-row flex-wrap justify-center gap-3"> <ContactButtons
<Button href={links.tel}> onDark
<Phone aria-hidden="true" /> whatsapp
Call {business.phone} quoteHref={quoteHref}
</Button> className="justify-center"
<Button href={links.sms} variant="outline"> />
<MessageSquare aria-hidden="true" />
Text me
</Button>
<Button href={links.whatsapp} variant="outline">
<MessageCircle aria-hidden="true" />
WhatsApp
</Button>
<Button href={quoteHref} variant="outline">
Get a quote
</Button>
</div>
</div> </div>
</section> </section>
); );
+7 -3
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { Mail, MapPin, Phone } from "lucide-react"; import { Mail, MapPin, Phone } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { business, links } from "@/lib/site"; import { business, links } from "@/lib/site";
import { services } from "@/lib/services"; import { services } from "@/lib/services";
import { areas } from "@/lib/areas"; import { areas } from "@/lib/areas";
@@ -86,9 +87,12 @@ export default function Footer() {
</ul> </ul>
</nav> </nav>
</div> </div>
<p className="container mx-auto mt-8 text-sm text-slate-300"> <div className="container mx-auto mt-10">
Copyright © {business.name}, {new Date().getFullYear()} <Separator className="mb-6 bg-slate-700" />
</p> <p className="text-sm text-slate-400">
Copyright © {business.name}, {new Date().getFullYear()}
</p>
</div>
</footer> </footer>
); );
} }
+2 -2
View File
@@ -7,14 +7,14 @@ export default function MobileCallBar() {
<div className="fixed inset-x-0 bottom-0 z-50 grid grid-cols-2 border-t bg-white md:hidden"> <div className="fixed inset-x-0 bottom-0 z-50 grid grid-cols-2 border-t bg-white md:hidden">
<a <a
href={links.tel} href={links.tel}
className="flex items-center justify-center gap-2 bg-sky-800 py-3 font-bold text-white" className="flex items-center justify-center gap-2 bg-primary py-3 font-semibold text-primary-foreground"
> >
<Phone aria-hidden="true" className="size-4" /> <Phone aria-hidden="true" className="size-4" />
Call Call
</a> </a>
<a <a
href={links.sms} href={links.sms}
className="flex items-center justify-center gap-2 py-3 font-bold text-sky-800" className="flex items-center justify-center gap-2 py-3 font-semibold text-primary"
> >
<MessageSquare aria-hidden="true" className="size-4" /> <MessageSquare aria-hidden="true" className="size-4" />
Text Text
+147 -197
View File
@@ -1,213 +1,163 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import styles from "./Navbar.module.css";
import logo from "../assets/logo.svg";
import menu from "../assets/menu.svg";
import close from "../assets/close.svg";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { ChevronDownIcon } from "lucide-react"; import { MenuIcon, Phone } from "lucide-react";
import logo from "../assets/logo.svg";
import { Button } from "@/components/ui/button";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import {
Sheet,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
import { business } from "@/lib/site";
import { services } from "@/lib/services";
const serviceLinks = [
...Object.entries(services).map(([slug, { name }]) => ({
href: `/services/${slug}`,
label: name,
})),
{ href: "/business", label: "Business IT plans" },
{ href: "/windows-10", label: "Windows 10 help" },
];
const pageLinks = [
{ href: "/pricing", label: "Pricing" },
{ href: "/about", label: "About" },
{ href: "/#faq", label: "FAQ" },
];
export default function Navbar() { export default function Navbar() {
const pathname = usePathname(); const pathname = usePathname();
const [isActive, setActive] = useState("false"); const isActive = (href) => pathname === href;
const [isDropdownActive, setDropdownActive] = useState(false); const inServices = serviceLinks.some(({ href }) => isActive(href));
function handleToggle() {
setActive(!isActive);
}
function handleDropdownToggle() {
setDropdownActive(!isDropdownActive);
}
const wrapperRef = useRef(null);
useEffect(() => {
/**
* Close the menu if clicked on outside of element
*/
function handleClickOutside(event) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {
setActive(true);
}
}
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return ( return (
<nav className={`${styles.navbar} shadow-sm`}> <header className="sticky top-0 z-50 border-b bg-white/90 backdrop-blur">
<div className={`${styles.container} container mx-auto`} ref={wrapperRef}> <div className="container mx-auto flex h-16 items-center justify-between gap-4">
<div className={styles.header}> <Link href="/" className="flex items-center gap-3">
<div> <Image src={logo} width={32} height={32} alt="" />
<Link href="/" className={styles.logo}> <span className="font-mono">{business.name}</span>
<Image src={logo} width="32" height="32" alt="" /> </Link>
<p>Te Atatu Computers</p>
</Link> {/* Desktop */}
</div> <NavigationMenu viewport={false} className="hidden lg:flex">
<button <NavigationMenuList>
className={styles.menuIcon} <NavigationMenuItem>
aria-label="Menu" <NavigationMenuLink
onClick={handleToggle} asChild
> active={isActive("/")}
<Image className={navigationMenuTriggerStyle()}
src={isActive ? menu : close} >
width="32" <Link href="/">Home</Link>
height="32" </NavigationMenuLink>
alt="" </NavigationMenuItem>
/> <NavigationMenuItem>
</button> <NavigationMenuTrigger
</div> className={cn(inServices && "text-primary")}
<ul className={`${styles.links} ${isActive ? "" : styles.active}`}>
<li>
<Link
href="/"
className={`link ${pathname === "/" ? styles.active : ""}`}
>
Home
</Link>
</li>
<li
className={`
${styles.dropdown}
${isDropdownActive && !isActive ? styles.dropdownActive : ""}
`}
onMouseEnter={() => setDropdownActive(true)}
onMouseLeave={() => setDropdownActive(false)}
>
<div
data-state={isDropdownActive ? "open" : "close"}
className="flex items-center justify-center gap-1 [&[data-state=open]>svg]:rotate-180"
>
<button
type="button"
aria-expanded={isDropdownActive}
className={`${styles.dropdownTitle}
${pathname.startsWith("/services") ? styles.active : ""}`}
onClick={handleDropdownToggle}
> >
Services Services
</button> </NavigationMenuTrigger>
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <NavigationMenuContent>
</div> <ul className="grid w-64 gap-1">
<ul className={styles.dropdownMenu}> {serviceLinks.map(({ href, label }) => (
<li> <li key={href}>
<Link <NavigationMenuLink asChild active={isActive(href)}>
href="/services/computer-repair" <Link href={href}>{label}</Link>
className={`link ${ </NavigationMenuLink>
pathname === "/services/computer-repair" </li>
? styles.active ))}
: "" </ul>
}`} </NavigationMenuContent>
</NavigationMenuItem>
{pageLinks.map(({ href, label }) => (
<NavigationMenuItem key={href}>
<NavigationMenuLink
asChild
active={isActive(href)}
className={navigationMenuTriggerStyle()}
> >
Computer & Laptop Repairs <Link href={href}>{label}</Link>
</Link> </NavigationMenuLink>
</li> </NavigationMenuItem>
<li> ))}
<Link </NavigationMenuList>
href="/services/computer-upgrades" </NavigationMenu>
className={`link ${ <Button asChild className="hidden lg:inline-flex">
pathname === "/services/computer-upgrades" <Link href="/contact">
? styles.active <Phone aria-hidden="true" />
: "" Contact
}`} </Link>
> </Button>
Computer Upgrades & Speedup
</Link> {/* Mobile */}
</li> <Sheet>
<li> <SheetTrigger asChild>
<Link <Button variant="ghost" size="icon" className="lg:hidden">
href="/services/it-support" <MenuIcon className="size-6" />
className={`link ${ <span className="sr-only">Menu</span>
pathname === "/services/it-support" ? styles.active : "" </Button>
}`} </SheetTrigger>
> <SheetContent side="right" className="w-full max-w-xs overflow-y-auto">
IT Support <SheetHeader>
</Link> <SheetTitle>Menu</SheetTitle>
</li> </SheetHeader>
<li> <nav className="grid gap-6 px-4 pb-8">
<Link <MobileList links={[{ href: "/", label: "Home" }]} isActive={isActive} />
href="/services/virus-removal" <div>
className={`link ${ <p className="mb-2 text-sm text-muted-foreground">Services</p>
pathname === "/services/virus-removal" ? styles.active : "" <MobileList links={serviceLinks} isActive={isActive} />
}`} </div>
> <MobileList links={pageLinks} isActive={isActive} />
Virus Removal <SheetClose asChild>
</Link> <Button asChild size="lg">
</li> <Link href="/contact">
<li> <Phone aria-hidden="true" />
<Link Contact
href="/business" </Link>
className={`link ${ </Button>
pathname === "/business" ? styles.active : "" </SheetClose>
}`} </nav>
> </SheetContent>
Business IT Plans </Sheet>
</Link>
</li>
<li>
<Link
href="/windows-10"
className={`link ${
pathname === "/windows-10" ? styles.active : ""
}`}
>
Windows 10 Help
</Link>
</li>
</ul>
</li>
<li>
<Link
href="/pricing"
className={`link ${pathname === "/pricing" ? styles.active : ""}`}
>
Pricing
</Link>
</li>
<li>
<Link
href="/about"
className={`link ${pathname === "/about" ? styles.active : ""}`}
>
About
</Link>
</li>
<li>
<Link
href="/#faq"
>
FAQ
</Link>
</li>
<li className="bg-sky-900 text-white lg:ml-4">
<Link
href="/contact"
className={`link ${pathname === "/contact" ? styles.active : ""}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<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>
Contact Us
</Link>
</li>
</ul>
</div> </div>
</nav> </header>
);
}
function MobileList({ links, isActive }) {
return (
<ul className="grid gap-1">
{links.map(({ href, label }) => (
<li key={href}>
<SheetClose asChild>
<Link
href={href}
className={cn(
"block rounded-md px-2 py-2 hover:bg-accent",
isActive(href) && "font-semibold text-primary"
)}
>
{label}
</Link>
</SheetClose>
</li>
))}
</ul>
); );
} }
-229
View File
@@ -1,229 +0,0 @@
.navbar {
/* Make navbar stay at top of screen (static) */
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 9999;
color: black;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(2rem);
}
/* Reset default styling on lists */
.navbar .container ul {
list-style-type: none;
margin-left: 0;
margin-bottom: 0;
}
/* Position nav items correctly */
.navbar .container li {
padding-left: 0.75em;
padding-right: 0.75em;
}
/* Reset default styling on links */
.navbar .container li a {
color: inherit;
text-decoration: none;
}
/* Underline links on hover */
.navbar .container li a:hover {
text-decoration: underline;
}
/* Make active link bold */
.navbar .container li a.active {
font-weight: bold;
}
.navbar .container li a svg {
width: 1em;
height: 1em;
}
.navbar .container li a {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25em;
}
.highlight {
color: white;
background-color: var(--accent);
}
.menuIcon {
font-size: 1.5rem;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
/* Add styles for mobile view */
@media (max-width: 899px) {
.navbar {
background-color: white;
}
.links {
display: flex;
flex-direction: column;
border-radius: 0;
/* Hidden by sliding up its own height, so it works for any number of links. */
position: absolute;
top: 4rem;
transform: translateY(-100%);
left: 0;
right: 0;
z-index: -2;
font-size: 1rem;
background-color: white;
padding-top: 0.5em;
padding-bottom: 0.5em;
transition-property: transform, border-radius;
transition-duration: 0.4s;
transition-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
}
.links {
padding-left: 20px;
padding-right: 20px;
}
.links li,
.dropdownTitle {
padding-top: 1rem;
padding-bottom: 1rem;
margin-bottom: 0.5rem;
border-radius: 0.5em;
}
.links.active {
transform: translateY(0);
border-radius: 0 0 0.5em 0.5em;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
padding-bottom: 1rem;
background-color: #fff;
}
.dropdown {
padding: 0 !important;
}
.dropdownMenu {
max-height: 0.5em;
overflow: hidden;
transition: max-height 0.5s cubic-bezier(0.215, 0.61, 0.355, 1);
}
.dropdownActive .dropdownMenu {
max-height: 30em;
transition: max-height 0.5s cubic-bezier(0.215, 0.61, 0.355, 1);
}
}
/* Styles for desktop view */
@media (min-width: 900px) {
.links {
display: flex;
flex-direction: row;
}
.links li {
padding-top: 1rem;
padding-bottom: 1rem;
}
.menuIcon {
display: none;
}
.navbar .container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.dropdownMenu {
position: absolute;
display: none;
background-color: white;
margin-top: 1rem;
border-radius: 0.25rem;
}
.dropdown:hover .dropdownMenu {
display: block;
}
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.logo {
display: flex;
gap: 1em;
flex-flow: row nowrap;
align-items: center;
}
.logo p {
color: black;
margin-bottom: 0;
font-family: var(--font-geist-mono);
}
.dropdown a {
cursor: pointer;
margin-bottom: 0;
}
.dropdown li {
margin-bottom: 0;
}
.dropdownMenu li:last-of-type {
margin-bottom: 0;
}
.dropdown:not(.dropdownActive) {
transition: all 0.3s;
}
.dropdown:not(.dropdownActive) li {
margin: 0 !important;
}
.dropdownActive {
background-color: #f1f1f1;
}
.dropdown:not(.dropdownActive) .dropdownTitle {
margin-bottom: 0;
}
.dropdownTitle {
cursor: pointer;
}
.dropdownTitle.active {
font-weight: bold;
}
+18
View File
@@ -0,0 +1,18 @@
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
export default function PriceTable({ items, className }) {
return (
<Table className={className}>
<TableBody>
{items.map(({ item, price }) => (
<TableRow key={item}>
<TableCell className="py-3 pl-0 whitespace-normal">{item}</TableCell>
<TableCell className="py-3 pr-0 text-right font-semibold whitespace-normal text-primary">
{price}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
+22 -34
View File
@@ -1,11 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, Check, MessageSquare, Phone } from "lucide-react"; import { ArrowRight, Check } from "lucide-react";
import HeaderSm from "./HeaderSm"; import HeaderSm from "./HeaderSm";
import Button from "./Button"; import ContactButtons from "./ContactButtons";
import CtaBand from "./CtaBand"; import CtaBand from "./CtaBand";
import FaqList from "./FaqList"; import FaqList from "./FaqList";
import JsonLd from "./JsonLd"; import JsonLd from "./JsonLd";
import { business, links, pageMetadata, serviceSchema } from "@/lib/site"; import PriceTable from "./PriceTable";
import { Card, CardContent } from "@/components/ui/card";
import { business, pageMetadata, serviceSchema } from "@/lib/site";
import { services } from "@/lib/services"; import { services } from "@/lib/services";
const promises = [ const promises = [
@@ -56,16 +58,7 @@ export default function ServicePage({ slug }) {
{p} {p}
</p> </p>
))} ))}
<div className="flex flex-col sm:flex-row gap-3 mt-6"> <ContactButtons className="mt-6" />
<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>
</div>
</section> </section>
<section className="container mx-auto max-w-3xl mb-16"> <section className="container mx-auto max-w-3xl mb-16">
@@ -75,7 +68,7 @@ export default function ServicePage({ slug }) {
<li key={fix} className="flex gap-2"> <li key={fix} className="flex gap-2">
<Check <Check
aria-hidden="true" aria-hidden="true"
className="size-5 shrink-0 mt-0.5 text-sky-800" className="size-5 shrink-0 mt-0.5 text-primary"
/> />
{fix} {fix}
</li> </li>
@@ -86,25 +79,14 @@ export default function ServicePage({ slug }) {
<section className="container mx-auto max-w-3xl mb-16"> <section className="container mx-auto max-w-3xl mb-16">
<h2 className="text-3xl font-bold mb-4">Pricing</h2> <h2 className="text-3xl font-bold mb-4">Pricing</h2>
<table className="w-full mb-4 text-left"> <PriceTable items={service.prices} className="mb-4" />
<tbody>
{service.prices.map(({ item, price }) => (
<tr key={item} className="border-b">
<th scope="row" className="py-3 pr-4 font-normal">
{item}
</th>
<td className="py-3 font-bold text-sky-800">{price}</td>
</tr>
))}
</tbody>
</table>
<p className="mb-2"> <p className="mb-2">
The diagnosis is $35. If I can&apos;t fix it, you don&apos;t pay it. The diagnosis is $35. If I can&apos;t fix it, you don&apos;t pay it.
Once I know what&apos;s wrong, I&apos;ll tell you what the repair will Once I know what&apos;s wrong, I&apos;ll tell you what the repair will
cost before I start. If you decide not to go ahead, the $35 still cost before I start. If you decide not to go ahead, the $35 still
applies. applies.
</p> </p>
<p className="text-slate-600"> <p className="text-muted-foreground">
Labour is $35 an hour. Parts are extra. Labour is $35 an hour. Parts are extra.
</p> </p>
</section> </section>
@@ -144,12 +126,18 @@ export default function ServicePage({ slug }) {
))} ))}
</ul> </ul>
{testimonial && ( {testimonial && (
<figure className="mt-8 rounded-lg border bg-white p-6"> <Card className="mt-8 shadow-none">
<blockquote className="mb-3"> <CardContent>
&ldquo;{testimonial.quote}&rdquo; <figure>
</blockquote> <blockquote className="mb-3">
<figcaption className="font-bold">{testimonial.name}</figcaption> &ldquo;{testimonial.quote}&rdquo;
</figure> </blockquote>
<figcaption className="font-semibold">
{testimonial.name}
</figcaption>
</figure>
</CardContent>
</Card>
)} )}
</div> </div>
</section> </section>
@@ -168,7 +156,7 @@ export default function ServicePage({ slug }) {
<li key={related}> <li key={related}>
<Link <Link
href={`/services/${related}`} href={`/services/${related}`}
className="inline-flex items-center gap-1 font-bold text-sky-800 hover:underline" className="inline-flex items-center gap-1 font-bold text-primary hover:underline"
> >
{services[related].name} {services[related].name}
<ArrowRight aria-hidden="true" className="size-4" /> <ArrowRight aria-hidden="true" className="size-4" />
+9 -22
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { Clock, Mail, MapPin, MessageCircle, MessageSquare, Phone } from "lucide-react"; import { Clock, Mail, MapPin } from "lucide-react";
import HeaderSm from "../components/HeaderSm"; import HeaderSm from "../components/HeaderSm";
import Button from "../components/Button"; import ContactButtons from "../components/ContactButtons";
import ContactForm from "../components/ContactForm"; import ContactForm from "../components/ContactForm";
import MapEmbed from "../components/MapEmbed"; import MapEmbed from "../components/MapEmbed";
import { business, links, pageMetadata } from "@/lib/site"; import { business, links, pageMetadata } from "@/lib/site";
@@ -24,34 +24,21 @@ export default function ContactPage() {
The quickest way to reach me is a call or text. I&apos;m{" "} 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. {business.ownerName}, and I&apos;ll be the one fixing your computer.
</p> </p>
<div className="flex flex-col sm:flex-row flex-wrap gap-3"> <ContactButtons whatsapp />
<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>
<section className="container mx-auto max-w-5xl mb-16 grid grid-cols-1 gap-8 lg:grid-cols-2"> <section className="container mx-auto max-w-5xl mb-16 grid grid-cols-1 gap-8 lg:grid-cols-2">
<div> <div>
<ul className="space-y-4 mb-6"> <ul className="space-y-4 mb-6">
<li className="flex gap-3"> <li className="flex gap-3">
<Clock aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" /> <Clock aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-primary" />
<span> <span>
<strong className="block">Hours</strong> <strong className="block">Hours</strong>
Monday to Friday, 9am to 6pm. Weekends by appointment. Monday to Friday, 9am to 6pm. Weekends by appointment.
</span> </span>
</li> </li>
<li className="flex gap-3"> <li className="flex gap-3">
<MapPin aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" /> <MapPin aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-primary" />
<span> <span>
<strong className="block">Drop-off by appointment</strong> <strong className="block">Drop-off by appointment</strong>
{address.street}, {address.suburb}, {address.city}{" "} {address.street}, {address.suburb}, {address.city}{" "}
@@ -59,10 +46,10 @@ export default function ContactPage() {
</span> </span>
</li> </li>
<li className="flex gap-3"> <li className="flex gap-3">
<Mail aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" /> <Mail aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-primary" />
<span> <span>
<strong className="block">Email</strong> <strong className="block">Email</strong>
<a href={links.email} className="text-sky-800 hover:underline"> <a href={links.email} className="text-primary hover:underline">
{business.email} {business.email}
</a> </a>
</span> </span>
@@ -75,11 +62,11 @@ export default function ContactPage() {
</p> </p>
<p> <p>
See{" "} See{" "}
<Link href="/pricing" className="text-sky-800 underline"> <Link href="/pricing" className="text-primary underline">
prices prices
</Link>{" "} </Link>{" "}
and{" "} and{" "}
<Link href="/#faq" className="text-sky-800 underline"> <Link href="/#faq" className="text-primary underline">
common questions common questions
</Link> </Link>
. .
+12 -4
View File
@@ -30,14 +30,15 @@
---break--- ---break---
*/ */
:root { :root {
--radius: 0.625rem; --radius: 0.5rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695); --foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695); --card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695); --popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755); /* Brand blue (Tailwind sky-800), matching the existing buttons and bands. */
--primary: oklch(0.443 0.11 240.79);
--primary-foreground: oklch(0.984 0.003 247.858); --primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896); --secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755); --secondary-foreground: oklch(0.208 0.042 265.755);
@@ -48,7 +49,7 @@
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508); --border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508); --input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788); --ring: oklch(0.588 0.158 241.966);
--chart-1: oklch(0.646 0.222 41.116); --chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);
@@ -103,6 +104,8 @@
---break--- ---break---
*/ */
@theme inline { @theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -147,7 +150,12 @@
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground antialiased;
}
h1,
h2,
h3 {
@apply tracking-tight text-balance;
} }
} }
/* Keep the Chatwoot bubble clear of the mobile Call/Text bar. */ /* Keep the Chatwoot bubble clear of the mobile Call/Text bar. */
+2 -4
View File
@@ -27,10 +27,8 @@ export const metadata = {
export default function RootLayout({ children }) { export default function RootLayout({ children }) {
return ( return (
<html lang="en-NZ"> <html lang="en-NZ" className={`${geistSans.variable} ${geistMono.variable}`}>
<body <body className="pb-12 md:pb-0">
className={`${geistSans.variable} ${geistMono.variable} pb-12 md:pb-0`}
>
<JsonLd data={localBusinessSchema()} /> <JsonLd data={localBusinessSchema()} />
<ChatwootWidget /> <ChatwootWidget />
<Navbar /> <Navbar />
+44 -49
View File
@@ -2,8 +2,9 @@ import Image from "next/image";
import Link from "next/link"; 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 ContactButtons from "./components/ContactButtons";
import MapEmbed from "./components/MapEmbed"; import MapEmbed from "./components/MapEmbed";
import { Card, CardContent } from "@/components/ui/card";
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";
@@ -18,10 +19,8 @@ import {
HardDrive, HardDrive,
Laptop, Laptop,
MapPin, MapPin,
MessageSquare,
Monitor, Monitor,
MonitorCog, MonitorCog,
Phone,
Power, Power,
ShieldAlert, ShieldAlert,
Star, Star,
@@ -103,7 +102,7 @@ const steps = [
function SectionCta() { function SectionCta() {
return ( return (
<p className="text-center mt-8"> <p className="text-center mt-8">
<a href={links.tel} className="font-bold text-sky-800 hover:underline"> <a href={links.tel} className="font-semibold text-primary hover:underline">
Call or text {business.phone} Call or text {business.phone}
</a> </a>
</p> </p>
@@ -138,16 +137,7 @@ export default function Home() {
tell you what it costs before I start. Local, honest help tell you what it costs before I start. Local, honest help
for West Auckland. for West Auckland.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3 mb-6"> <ContactButtons onDark className="mb-6" />
<Button href={links.tel}>
<Phone aria-hidden="true" />
Call {business.phone}
</Button>
<Button href={links.sms} variant="outline">
<MessageSquare aria-hidden="true" />
Text me
</Button>
</div>
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm"> <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<Star <Star
aria-hidden="true" aria-hidden="true"
@@ -169,30 +159,31 @@ export default function Home() {
</div> </div>
</section> </section>
<section className="container mx-auto mb-32"> <section className="container mx-auto mb-24">
<h2 className="text-center text-3xl font-bold mb-2"> <h2 className="text-center text-3xl font-bold mb-2">
Common fixes & prices Common fixes & prices
</h2> </h2>
<p className="text-center mb-8 text-slate-600"> <p className="text-center mb-8 text-muted-foreground">
Labour prices. Parts are extra, and I&apos;ll always quote before Labour prices. Parts are extra, and I&apos;ll always quote before
starting. starting.
</p> </p>
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{commonFixes.map(({ icon: Icon, title, price, href }) => ( {commonFixes.map(({ icon: Icon, title, price, href }) => (
<li key={title}> <li key={title}>
<Link <Link href={href} className="group block h-full rounded-xl">
href={href} <Card className="h-full flex-row items-center gap-4 p-4 shadow-none transition-colors group-hover:border-primary">
className="flex h-full items-center gap-4 rounded-lg border p-4 transition hover:border-sky-800 hover:shadow-sm" <Icon
> aria-hidden="true"
<Icon className="size-7 shrink-0 text-primary"
aria-hidden="true" strokeWidth={1.5}
className="size-8 shrink-0 text-sky-800" />
strokeWidth={1.5} <span>
/> <span className="block font-semibold">{title}</span>
<span> <span className="block text-sm text-muted-foreground">
<span className="block font-bold">{title}</span> {price}
<span className="block text-sky-800">{price}</span> </span>
</span> </span>
</Card>
</Link> </Link>
</li> </li>
))} ))}
@@ -200,7 +191,7 @@ export default function Home() {
<SectionCta /> <SectionCta />
<Link <Link
href="/windows-10" href="/windows-10"
className="mt-8 flex items-center justify-between gap-4 rounded-lg bg-sky-900 p-4 text-white hover:bg-sky-800" className="mt-8 flex items-center justify-between gap-4 rounded-lg bg-sky-900 p-4 text-white transition-colors hover:bg-primary"
> >
<span> <span>
<span className="block font-bold">Still on Windows 10?</span> <span className="block font-bold">Still on Windows 10?</span>
@@ -212,15 +203,15 @@ export default function Home() {
</Link> </Link>
</section> </section>
<section className="bg-slate-100 py-16 mb-32"> <section className="bg-slate-100 py-16 mb-24">
<div className="container mx-auto"> <div className="container mx-auto">
<h2 className="text-center text-3xl font-bold mb-8"> <h2 className="text-center text-3xl font-bold mb-8">
How it works How it works
</h2> </h2>
<ol className="grid grid-cols-1 md:grid-cols-3 gap-8"> <ol className="grid grid-cols-1 md:grid-cols-3 gap-8">
{steps.map(({ title, text }, i) => ( {steps.map(({ title, text }, i) => (
<li key={title} className="text-center max-w-sm mx-auto"> <li key={title} className="border-t border-slate-300 pt-4">
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-sky-800 text-xl font-bold text-white"> <span className="mb-3 flex size-8 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
{i + 1} {i + 1}
</span> </span>
<h3 className="text-xl font-bold mb-2">{title}</h3> <h3 className="text-xl font-bold mb-2">{title}</h3>
@@ -232,7 +223,7 @@ export default function Home() {
</div> </div>
</section> </section>
<section className="container mx-auto mb-32 max-w-3xl text-center"> <section className="container mx-auto mb-24 max-w-3xl text-center">
<h2 className="text-3xl font-bold mb-4"> <h2 className="text-3xl font-bold mb-4">
Hi, I&apos;m {business.ownerName} Hi, I&apos;m {business.ownerName}
</h2> </h2>
@@ -249,22 +240,26 @@ export default function Home() {
<SectionCta /> <SectionCta />
</section> </section>
<section className="container mx-auto mb-32"> <section className="container mx-auto mb-24">
<h2 className="text-center text-3xl font-bold mb-8"> <h2 className="text-center text-3xl font-bold mb-8">
What customers say What customers say
</h2> </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{business.testimonials.map(({ name, quote }) => ( {business.testimonials.map(({ name, quote }) => (
<figure key={name} className="rounded-lg border p-6"> <Card key={name} className="shadow-none">
<blockquote className="mb-3">&ldquo;{quote}&rdquo;</blockquote> <CardContent>
<figcaption className="font-bold">{name}</figcaption> <figure>
</figure> <blockquote className="mb-3">&ldquo;{quote}&rdquo;</blockquote>
<figcaption className="font-semibold">{name}</figcaption>
</figure>
</CardContent>
</Card>
))} ))}
</div> </div>
<p className="text-center mt-8"> <p className="text-center mt-8">
<a <a
href={business.googleBusinessProfileUrl} href={business.googleBusinessProfileUrl}
className="inline-flex items-center gap-1 font-bold text-sky-800 hover:underline" className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
> >
Read all reviews on Google Read all reviews on Google
<ArrowRight aria-hidden="true" className="size-4" /> <ArrowRight aria-hidden="true" className="size-4" />
@@ -272,7 +267,7 @@ export default function Home() {
</p> </p>
</section> </section>
<section className="container mx-auto mb-32 flex flex-col gap-8 lg:flex-row"> <section className="container mx-auto mb-24 flex flex-col gap-8 lg:flex-row">
<div className="w-full"> <div className="w-full">
<h2 className="text-3xl font-bold mb-4">Areas I serve</h2> <h2 className="text-3xl font-bold mb-4">Areas I serve</h2>
<p className="mb-4 flex items-start gap-2"> <p className="mb-4 flex items-start gap-2">
@@ -293,7 +288,7 @@ export default function Home() {
{page ? ( {page ? (
<Link <Link
href={`/areas/${page.slug}`} href={`/areas/${page.slug}`}
className="text-sky-800 hover:underline" className="text-primary hover:underline"
> >
{area} {area}
</Link> </Link>
@@ -311,7 +306,7 @@ export default function Home() {
<p className="mt-4"> <p className="mt-4">
<a <a
href={links.tel} href={links.tel}
className="font-bold text-sky-800 hover:underline" className="font-semibold text-primary hover:underline"
> >
Call or text {business.phone} Call or text {business.phone}
</a> </a>
@@ -322,11 +317,11 @@ export default function Home() {
</div> </div>
</section> </section>
<section className="container mx-auto mb-32"> <section className="container mx-auto mb-24">
<div className="rounded-lg border p-6 md:p-8 flex flex-col md:flex-row gap-6 md:items-center"> <Card className="flex-col gap-6 p-6 shadow-none md:flex-row md:items-center md:p-8">
<Briefcase <Briefcase
aria-hidden="true" aria-hidden="true"
className="size-12 shrink-0 text-sky-800" className="size-12 shrink-0 text-primary"
strokeWidth={1.5} strokeWidth={1.5}
/> />
<div className="flex-1"> <div className="flex-1">
@@ -339,16 +334,16 @@ export default function Home() {
</div> </div>
<Link <Link
href="/business" href="/business"
className="inline-flex items-center gap-1 font-bold text-sky-800 hover:underline" className="inline-flex items-center gap-1 font-semibold text-primary hover:underline"
> >
Business IT plans Business IT plans
<ArrowRight aria-hidden="true" className="size-4" /> <ArrowRight aria-hidden="true" className="size-4" />
</Link> </Link>
</div> </Card>
</section> </section>
<section <section
className="container mx-auto max-w-3xl mb-32 scroll-mt-20" className="container mx-auto max-w-3xl mb-24 scroll-mt-20"
id="faq" id="faq"
> >
<h2 className="text-center text-3xl font-bold mb-8"> <h2 className="text-center text-3xl font-bold mb-8">
+3 -13
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import HeaderSm from "../components/HeaderSm"; import HeaderSm from "../components/HeaderSm";
import CtaBand from "../components/CtaBand"; import CtaBand from "../components/CtaBand";
import PriceTable from "../components/PriceTable";
import { business, pageMetadata, priceList } from "@/lib/site"; import { business, pageMetadata, priceList } from "@/lib/site";
export const metadata = pageMetadata({ export const metadata = pageMetadata({
@@ -27,18 +28,7 @@ export default function PricingPage() {
{priceList.map(({ group, items }) => ( {priceList.map(({ group, items }) => (
<section key={group} className="container mx-auto max-w-3xl mb-12"> <section key={group} className="container mx-auto max-w-3xl mb-12">
<h2 className="text-2xl font-bold mb-4">{group}</h2> <h2 className="text-2xl font-bold mb-4">{group}</h2>
<table className="w-full text-left"> <PriceTable items={items} />
<tbody>
{items.map(({ item, price }) => (
<tr key={item} className="border-b">
<th scope="row" className="py-3 pr-4 font-normal">
{item}
</th>
<td className="py-3 font-bold text-sky-800">{price}</td>
</tr>
))}
</tbody>
</table>
</section> </section>
))} ))}
@@ -63,7 +53,7 @@ export default function PricingPage() {
by appointment, has no travel fee. For onsite visits or pickup and by appointment, has no travel fee. For onsite visits or pickup and
return, the travel fee depends on distance. Get in touch with your return, the travel fee depends on distance. Get in touch with your
suburb for a quote. Small businesses can also get{" "} suburb for a quote. Small businesses can also get{" "}
<Link href="/business" className="text-sky-800 underline"> <Link href="/business" className="text-primary underline">
2 hours of support a month for $65 2 hours of support a month for $65
</Link> </Link>
. .
+5 -15
View File
@@ -1,10 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { MessageSquare, Phone } from "lucide-react";
import HeaderSm from "../components/HeaderSm"; import HeaderSm from "../components/HeaderSm";
import Button from "../components/Button"; import ContactButtons from "../components/ContactButtons";
import CtaBand from "../components/CtaBand"; import CtaBand from "../components/CtaBand";
import FaqList from "../components/FaqList"; import FaqList from "../components/FaqList";
import { business, links, pageMetadata } from "@/lib/site"; import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({ export const metadata = pageMetadata({
title: "Still on Windows 10? What to Do Now | Te Atatu Computers", title: "Still on Windows 10? What to Do Now | Te Atatu Computers",
@@ -62,16 +61,7 @@ export default function Windows10Page() {
buy a new computer. Here are your options, and I&apos;m happy to buy a new computer. Here are your options, and I&apos;m happy to
check which one suits you for free. check which one suits you for free.
</p> </p>
<div className="flex flex-col sm:flex-row gap-3"> <ContactButtons textLabel="Text for a free check" />
<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 for a free check
</Button>
</div>
</section> </section>
<section className="container mx-auto max-w-3xl mb-16"> <section className="container mx-auto max-w-3xl mb-16">
@@ -79,7 +69,7 @@ export default function Windows10Page() {
<ol className="space-y-6"> <ol className="space-y-6">
{options.map(({ title, text }, i) => ( {options.map(({ title, text }, i) => (
<li key={title} className="flex gap-4"> <li key={title} className="flex gap-4">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-sky-800 font-bold text-white"> <span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary font-bold text-white">
{i + 1} {i + 1}
</span> </span>
<div> <div>
@@ -92,7 +82,7 @@ export default function Windows10Page() {
<p className="mt-6"> <p className="mt-6">
Not sure? Get in touch and I&apos;ll give you a straight answer. See Not sure? Get in touch and I&apos;ll give you a straight answer. See
all prices on the{" "} all prices on the{" "}
<Link href="/pricing" className="text-sky-800 underline"> <Link href="/pricing" className="text-primary underline">
pricing page pricing page
</Link> </Link>
. .
+3 -3
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion" import { Accordion as AccordionPrimitive } from "radix-ui"
import { ChevronDownIcon } from "lucide-react" import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -34,7 +34,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger <AccordionPrimitive.Trigger
data-slot="accordion-trigger" data-slot="accordion-trigger"
className={cn( className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180", "focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-base font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className className
)} )}
{...props}> {...props}>
@@ -57,7 +57,7 @@ function AccordionContent({
<AccordionPrimitive.Content <AccordionPrimitive.Content
data-slot="accordion-content" data-slot="accordion-content"
forceMount forceMount
className="data-[state=closed]:hidden data-[state=open]:animate-accordion-down overflow-hidden text-sm" className="data-[state=closed]:hidden data-[state=open]:animate-accordion-down overflow-hidden text-base text-muted-foreground"
{...props}> {...props}>
<div className={cn("pt-0 pb-4", className)}>{children}</div> <div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content> </AccordionPrimitive.Content>
+68
View File
@@ -0,0 +1,68 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({
className,
...props
}) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+112
View File
@@ -0,0 +1,112 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
...props
}) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({
className,
...props
}) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({
className,
type,
...props
}) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+23
View File
@@ -0,0 +1,23 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Label as LabelPrimitive } from "radix-ui"
function Label({
className,
...props
}) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NativeSelect({
className,
wrapperClassName,
size = "default",
...props
}) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
wrapperClassName
)}
data-slot="native-select-wrapper"
>
<select
data-slot="native-select"
data-size={size}
className={cn(
"h-9 w-full min-w-0 appearance-none rounded-md border border-input bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed data-[size=sm]:h-8 data-[size=sm]:py-1 dark:bg-input/30 dark:hover:bg-input/50",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
<ChevronDownIcon
className="pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 text-muted-foreground opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
)
}
function NativeSelectOption({
className,
...props
}) {
return (
<option
data-slot="native-select-option"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn("bg-[Canvas] text-[CanvasText]", className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
+165
View File
@@ -0,0 +1,165 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { ChevronDownIcon } from "lucide-react"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
function NavigationMenu({
className,
children,
viewport = true,
...props
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
function NavigationMenuTrigger({
className,
children,
...props
}) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-2 pr-2.5 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
function RadioGroup({
className,
...props
}) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+27
View File
@@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Separator as SeparatorPrimitive } from "radix-ui"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+147
View File
@@ -0,0 +1,147 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
function Sheet({
...props
}) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({
className,
...props
}) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({
className,
...props
}) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+136
View File
@@ -0,0 +1,136 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({
className,
...props
}) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({
className,
...props
}) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({
className,
...props
}) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({
className,
...props
}) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({
className,
...props
}) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({
className,
...props
}) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({
className,
...props
}) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({
className,
...props
}) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }