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 ."
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.49.0",
"next": "^16.3.7",
"radix-ui": "^1.6.7",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"sharp": "^0.35.5",
+1 -1
View File
@@ -68,7 +68,7 @@ export default function AboutPage() {
{areaSlugs[name] ? (
<Link
href={`/areas/${areaSlugs[name]}`}
className="text-sky-800 hover:underline"
className="text-primary hover:underline"
>
{name}
</Link>
+7 -16
View File
@@ -1,14 +1,14 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowRight, MessageSquare, Phone } from "lucide-react";
import { ArrowRight } from "lucide-react";
import HeaderSm from "../../components/HeaderSm";
import Button from "../../components/Button";
import ContactButtons from "../../components/ContactButtons";
import CtaBand from "../../components/CtaBand";
import FaqList from "../../components/FaqList";
import JsonLd from "../../components/JsonLd";
import { areas, getArea } from "@/lib/areas";
import { services } from "@/lib/services";
import { business, links, pageMetadata, serviceSchema } from "@/lib/site";
import { business, pageMetadata, serviceSchema } from "@/lib/site";
export const dynamicParams = false;
@@ -52,16 +52,7 @@ export default async function AreaPage({ params }) {
<section className="container mx-auto max-w-3xl mb-16">
<p className="text-lg mb-6">{area.intro}</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button href={links.tel} variant="primary">
<Phone aria-hidden="true" />
Call {business.phone}
</Button>
<Button href={links.sms} variant="outlineDark">
<MessageSquare aria-hidden="true" />
Text me
</Button>
</div>
<ContactButtons />
</section>
<section className="container mx-auto max-w-3xl mb-16">
@@ -85,7 +76,7 @@ export default async function AreaPage({ params }) {
<li key={slug}>
<Link
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}
<ArrowRight aria-hidden="true" className="size-4" />
@@ -95,7 +86,7 @@ export default async function AreaPage({ params }) {
</ul>
<p>
See all prices on the{" "}
<Link href="/pricing" className="text-sky-800 underline">
<Link href="/pricing" className="text-primary underline">
pricing page
</Link>
. I don&apos;t repair Apple devices.
@@ -118,7 +109,7 @@ export default async function AreaPage({ params }) {
<li key={slug}>
<Link
href={`/areas/${slug}`}
className="text-sky-800 hover:underline"
className="text-primary hover:underline"
>
{name}
</Link>
+43 -38
View File
@@ -1,14 +1,20 @@
import Link from "next/link";
import { Check, MessageSquare, Phone } from "lucide-react";
import { Check } from "lucide-react";
import HeaderSm from "../components/HeaderSm";
import Button from "../components/Button";
import ContactButtons from "../components/ContactButtons";
import CtaBand from "../components/CtaBand";
import FaqList from "../components/FaqList";
import JsonLd from "../components/JsonLd";
import {
business,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
businessPlan,
links,
pageMetadata,
serviceSchema,
} from "@/lib/site";
@@ -77,40 +83,39 @@ export default function BusinessPage() {
I support small businesses around Te Atatū and West Auckland, onsite
or remotely. You deal with me directly, every time.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button href={links.tel} variant="primary">
<Phone aria-hidden="true" />
Call {business.phone}
</Button>
<Button href={links.sms} variant="outlineDark">
<MessageSquare aria-hidden="true" />
Text me
</Button>
</div>
<ContactButtons />
</section>
<section className="container mx-auto max-w-3xl mb-16">
<div className="rounded-lg border-2 border-sky-800 p-6 md:p-8">
<h2 className="text-2xl font-bold mb-1">Business IT plan</h2>
<p className="mb-4">
<span className="text-4xl font-bold text-sky-800">
{businessPlan.price}
</span>{" "}
a month
</p>
<ul className="space-y-2 mb-4">
{businessPlan.includes.map((item) => (
<li key={item} className="flex gap-2">
<Check
aria-hidden="true"
className="size-5 shrink-0 mt-0.5 text-sky-800"
/>
{item}
</li>
))}
</ul>
<p className="text-slate-600">{businessPlan.terms}</p>
</div>
<Card className="border-primary shadow-none">
<CardHeader>
<CardTitle>
<h2 className="text-2xl font-bold">Business IT plan</h2>
</CardTitle>
<CardDescription>
<span className="text-4xl font-bold text-primary">
{businessPlan.price}
</span>{" "}
a month
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2">
{businessPlan.includes.map((item) => (
<li key={item} className="flex gap-2">
<Check
aria-hidden="true"
className="size-5 shrink-0 mt-0.5 text-primary"
/>
{item}
</li>
))}
</ul>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">{businessPlan.terms}</p>
</CardFooter>
</Card>
<p className="mt-4">
Prefer to pay as you go? Support is $35 an hour, onsite or remote.
</p>
@@ -123,7 +128,7 @@ export default function BusinessPage() {
<li key={item} className="flex gap-2">
<Check
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}
</li>
@@ -133,14 +138,14 @@ export default function BusinessPage() {
Hardware repairs and upgrades are covered too. Parts are extra. See{" "}
<Link
href="/services/it-support"
className="text-sky-800 underline"
className="text-primary underline"
>
IT support
</Link>{" "}
and{" "}
<Link
href="/services/computer-repair"
className="text-sky-800 underline"
className="text-primary underline"
>
computer repairs
</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";
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 { trackEvent } from "@/lib/analytics";
@@ -21,6 +31,10 @@ const problems = [
];
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
// JavaScript the form still posts normally.
export default function ContactForm() {
@@ -46,90 +60,122 @@ export default function ContactForm() {
if (status === "sent") {
return (
<div role="status" className="rounded-lg border-2 border-sky-800 p-6">
<h2 className="text-2xl font-bold mb-2">Thanks, I&apos;ve got it</h2>
<p>
I&apos;ll usually reply the same day during business hours (Monday
to Friday, 9am to 6pm). If it&apos;s urgent, call or text{" "}
<a href={links.tel} className="font-bold text-sky-800 underline">
{business.phone}
</a>
.
</p>
</div>
<Alert role="status" className="border-primary">
<CircleCheck aria-hidden="true" />
<AlertTitle>Thanks, I&apos;ve got it</AlertTitle>
<AlertDescription>
<p>
I&apos;ll usually reply the same day during business hours (Monday
to Friday, 9am to 6pm). If it&apos;s urgent, call or text{" "}
<a href={links.tel} className="font-semibold text-primary underline">
{business.phone}
</a>
.
</p>
</AlertDescription>
</Alert>
);
}
return (
<div>
<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
same day during business hours.
</p>
<form
action={FORM_ACTION}
method="POST"
className={styles.form}
className="grid gap-5"
onSubmit={handleSubmit}
>
<label htmlFor="cf-name">Name*</label>
<input id="cf-name" type="text" name="Name" autoComplete="name" required />
<Field>
<Label htmlFor="cf-name">Name*</Label>
<Input id="cf-name" name="Name" autoComplete="name" required />
</Field>
<label htmlFor="cf-email">Email*</label>
<input
id="cf-email"
type="email"
name="Email"
autoComplete="email"
required
/>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<Label htmlFor="cf-email">Email*</Label>
<Input
id="cf-email"
type="email"
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>
<input id="cf-phone" type="tel" name="Phone" autoComplete="tel" />
<fieldset className={styles.fieldset}>
<legend>Best way to reach you</legend>
<div className={styles.radios}>
<fieldset className="grid gap-3">
<legend className="mb-3 text-sm font-medium">
Best way to reach you
</legend>
<RadioGroup
name="Preferred contact"
defaultValue="Email"
className="flex gap-6"
>
{contactMethods.map((method) => (
<label key={method}>
<input
type="radio"
name="Preferred contact"
value={method}
defaultChecked={method === "Email"}
/>{" "}
{method}
</label>
<div key={method} className="flex items-center gap-2">
<RadioGroupItem value={method} id={`cf-contact-${method}`} />
<Label htmlFor={`cf-contact-${method}`} className="font-normal">
{method}
</Label>
</div>
))}
</div>
</RadioGroup>
</fieldset>
<label htmlFor="cf-device">Device</label>
<select id="cf-device" name="Device" defaultValue="Laptop">
{devices.map((device) => (
<option key={device}>{device}</option>
))}
</select>
<div className="grid gap-5 sm:grid-cols-2">
<Field>
<Label htmlFor="cf-device">Device</Label>
<NativeSelect
id="cf-device"
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>
<select id="cf-problem" name="Problem" defaultValue="">
<option value="" disabled>
Choose one
</option>
{problems.map((problem) => (
<option key={problem}>{problem}</option>
))}
</select>
<label htmlFor="cf-message">What&apos;s going on?*</label>
<textarea
id="cf-message"
name="Message"
rows={5}
placeholder="e.g. HP laptop, screen cracked in the corner, still turns on"
required
></textarea>
<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
/>
</Field>
{/* Honeypot: hidden from people, filled in by bots. Enable "_gotcha" in form.taxi's Spam Shield. */}
<input
@@ -141,21 +187,28 @@ export default function ContactForm() {
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" && (
<p role="alert" className="mb-4 font-bold text-red-700">
Sorry, that didn&apos;t send. Please try again, or call or text{" "}
{business.phone}.
</p>
<Alert variant="destructive" role="alert">
<CircleAlert aria-hidden="true" />
<AlertTitle>That didn&apos;t send</AlertTitle>
<AlertDescription>
Please try again, or call or text {business.phone}.
</AlertDescription>
</Alert>
)}
<input
<Button
type="submit"
value={status === "sending" ? "Sending…" : "Send"}
size="lg"
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>
</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 Button from "./Button";
import { business, links } from "@/lib/site";
import ContactButtons from "./ContactButtons";
export default function CtaBand({ quoteHref = "/contact" }) {
return (
<section className="bg-sky-900 text-white py-16">
<div className="container mx-auto text-center">
<h2 className="text-3xl font-bold mb-4">Get your computer sorted</h2>
<p className="mb-8">
<h2 className="text-3xl font-bold tracking-tight mb-3">
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
with a quote.
</p>
<div className="flex flex-col sm:flex-row flex-wrap justify-center gap-3">
<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>
<Button href={links.whatsapp} variant="outline">
<MessageCircle aria-hidden="true" />
WhatsApp
</Button>
<Button href={quoteHref} variant="outline">
Get a quote
</Button>
</div>
<ContactButtons
onDark
whatsapp
quoteHref={quoteHref}
className="justify-center"
/>
</div>
</section>
);
+7 -3
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { Mail, MapPin, Phone } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { business, links } from "@/lib/site";
import { services } from "@/lib/services";
import { areas } from "@/lib/areas";
@@ -86,9 +87,12 @@ export default function Footer() {
</ul>
</nav>
</div>
<p className="container mx-auto mt-8 text-sm text-slate-300">
Copyright © {business.name}, {new Date().getFullYear()}
</p>
<div className="container mx-auto mt-10">
<Separator className="mb-6 bg-slate-700" />
<p className="text-sm text-slate-400">
Copyright © {business.name}, {new Date().getFullYear()}
</p>
</div>
</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">
<a
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" />
Call
</a>
<a
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" />
Text
+147 -197
View File
@@ -1,213 +1,163 @@
"use client";
import { useState, useRef, useEffect } from "react";
import Image from "next/image";
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 { 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() {
const pathname = usePathname();
const [isActive, setActive] = useState("false");
const [isDropdownActive, setDropdownActive] = useState(false);
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);
};
}, []);
const isActive = (href) => pathname === href;
const inServices = serviceLinks.some(({ href }) => isActive(href));
return (
<nav className={`${styles.navbar} shadow-sm`}>
<div className={`${styles.container} container mx-auto`} ref={wrapperRef}>
<div className={styles.header}>
<div>
<Link href="/" className={styles.logo}>
<Image src={logo} width="32" height="32" alt="" />
<p>Te Atatu Computers</p>
</Link>
</div>
<button
className={styles.menuIcon}
aria-label="Menu"
onClick={handleToggle}
>
<Image
src={isActive ? menu : close}
width="32"
height="32"
alt=""
/>
</button>
</div>
<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}
<header className="sticky top-0 z-50 border-b bg-white/90 backdrop-blur">
<div className="container mx-auto flex h-16 items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-3">
<Image src={logo} width={32} height={32} alt="" />
<span className="font-mono">{business.name}</span>
</Link>
{/* Desktop */}
<NavigationMenu viewport={false} className="hidden lg:flex">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink
asChild
active={isActive("/")}
className={navigationMenuTriggerStyle()}
>
<Link href="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger
className={cn(inServices && "text-primary")}
>
Services
</button>
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</div>
<ul className={styles.dropdownMenu}>
<li>
<Link
href="/services/computer-repair"
className={`link ${
pathname === "/services/computer-repair"
? styles.active
: ""
}`}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-64 gap-1">
{serviceLinks.map(({ href, label }) => (
<li key={href}>
<NavigationMenuLink asChild active={isActive(href)}>
<Link href={href}>{label}</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
{pageLinks.map(({ href, label }) => (
<NavigationMenuItem key={href}>
<NavigationMenuLink
asChild
active={isActive(href)}
className={navigationMenuTriggerStyle()}
>
Computer & Laptop Repairs
</Link>
</li>
<li>
<Link
href="/services/computer-upgrades"
className={`link ${
pathname === "/services/computer-upgrades"
? styles.active
: ""
}`}
>
Computer Upgrades & Speedup
</Link>
</li>
<li>
<Link
href="/services/it-support"
className={`link ${
pathname === "/services/it-support" ? styles.active : ""
}`}
>
IT Support
</Link>
</li>
<li>
<Link
href="/services/virus-removal"
className={`link ${
pathname === "/services/virus-removal" ? styles.active : ""
}`}
>
Virus Removal
</Link>
</li>
<li>
<Link
href="/business"
className={`link ${
pathname === "/business" ? styles.active : ""
}`}
>
Business IT Plans
</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>
<Link href={href}>{label}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
<Button asChild className="hidden lg:inline-flex">
<Link href="/contact">
<Phone aria-hidden="true" />
Contact
</Link>
</Button>
{/* Mobile */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden">
<MenuIcon className="size-6" />
<span className="sr-only">Menu</span>
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full max-w-xs overflow-y-auto">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<nav className="grid gap-6 px-4 pb-8">
<MobileList links={[{ href: "/", label: "Home" }]} isActive={isActive} />
<div>
<p className="mb-2 text-sm text-muted-foreground">Services</p>
<MobileList links={serviceLinks} isActive={isActive} />
</div>
<MobileList links={pageLinks} isActive={isActive} />
<SheetClose asChild>
<Button asChild size="lg">
<Link href="/contact">
<Phone aria-hidden="true" />
Contact
</Link>
</Button>
</SheetClose>
</nav>
</SheetContent>
</Sheet>
</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 { ArrowRight, Check, MessageSquare, Phone } from "lucide-react";
import { ArrowRight, Check } from "lucide-react";
import HeaderSm from "./HeaderSm";
import Button from "./Button";
import ContactButtons from "./ContactButtons";
import CtaBand from "./CtaBand";
import FaqList from "./FaqList";
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";
const promises = [
@@ -56,16 +58,7 @@ export default function ServicePage({ slug }) {
{p}
</p>
))}
<div className="flex flex-col sm:flex-row gap-3 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>
<ContactButtons className="mt-6" />
</section>
<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">
<Check
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}
</li>
@@ -86,25 +79,14 @@ export default function ServicePage({ slug }) {
<section className="container mx-auto max-w-3xl mb-16">
<h2 className="text-3xl font-bold mb-4">Pricing</h2>
<table className="w-full mb-4 text-left">
<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>
<PriceTable items={service.prices} className="mb-4" />
<p className="mb-2">
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
cost before I start. If you decide not to go ahead, the $35 still
applies.
</p>
<p className="text-slate-600">
<p className="text-muted-foreground">
Labour is $35 an hour. Parts are extra.
</p>
</section>
@@ -144,12 +126,18 @@ export default function ServicePage({ slug }) {
))}
</ul>
{testimonial && (
<figure className="mt-8 rounded-lg border bg-white p-6">
<blockquote className="mb-3">
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
<figcaption className="font-bold">{testimonial.name}</figcaption>
</figure>
<Card className="mt-8 shadow-none">
<CardContent>
<figure>
<blockquote className="mb-3">
&ldquo;{testimonial.quote}&rdquo;
</blockquote>
<figcaption className="font-semibold">
{testimonial.name}
</figcaption>
</figure>
</CardContent>
</Card>
)}
</div>
</section>
@@ -168,7 +156,7 @@ export default function ServicePage({ slug }) {
<li key={related}>
<Link
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}
<ArrowRight aria-hidden="true" className="size-4" />
+9 -22
View File
@@ -1,7 +1,7 @@
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 Button from "../components/Button";
import ContactButtons from "../components/ContactButtons";
import ContactForm from "../components/ContactForm";
import MapEmbed from "../components/MapEmbed";
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{" "}
{business.ownerName}, and I&apos;ll be the one fixing your computer.
</p>
<div className="flex flex-col sm:flex-row flex-wrap gap-3">
<Button href={links.tel} variant="primary">
<Phone aria-hidden="true" />
Call {business.phone}
</Button>
<Button href={links.sms} variant="outlineDark">
<MessageSquare aria-hidden="true" />
Text me
</Button>
<Button href={links.whatsapp} variant="outlineDark">
<MessageCircle aria-hidden="true" />
WhatsApp
</Button>
</div>
<ContactButtons whatsapp />
</section>
<section className="container mx-auto max-w-5xl mb-16 grid grid-cols-1 gap-8 lg:grid-cols-2">
<div>
<ul className="space-y-4 mb-6">
<li className="flex gap-3">
<Clock aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" />
<Clock aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-primary" />
<span>
<strong className="block">Hours</strong>
Monday to Friday, 9am to 6pm. Weekends by appointment.
</span>
</li>
<li className="flex gap-3">
<MapPin aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-sky-800" />
<MapPin aria-hidden="true" className="size-5 shrink-0 mt-0.5 text-primary" />
<span>
<strong className="block">Drop-off by appointment</strong>
{address.street}, {address.suburb}, {address.city}{" "}
@@ -59,10 +46,10 @@ export default function ContactPage() {
</span>
</li>
<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>
<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}
</a>
</span>
@@ -75,11 +62,11 @@ export default function ContactPage() {
</p>
<p>
See{" "}
<Link href="/pricing" className="text-sky-800 underline">
<Link href="/pricing" className="text-primary underline">
prices
</Link>{" "}
and{" "}
<Link href="/#faq" className="text-sky-800 underline">
<Link href="/#faq" className="text-primary underline">
common questions
</Link>
.
+12 -4
View File
@@ -30,14 +30,15 @@
---break---
*/
:root {
--radius: 0.625rem;
--radius: 0.5rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--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);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
@@ -48,7 +49,7 @@
--destructive: oklch(0.577 0.245 27.325);
--border: 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-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
@@ -103,6 +104,8 @@
---break---
*/
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -147,7 +150,12 @@
@apply border-border outline-ring/50;
}
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. */
+2 -4
View File
@@ -27,10 +27,8 @@ export const metadata = {
export default function RootLayout({ children }) {
return (
<html lang="en-NZ">
<body
className={`${geistSans.variable} ${geistMono.variable} pb-12 md:pb-0`}
>
<html lang="en-NZ" className={`${geistSans.variable} ${geistMono.variable}`}>
<body className="pb-12 md:pb-0">
<JsonLd data={localBusinessSchema()} />
<ChatwootWidget />
<Navbar />
+44 -49
View File
@@ -2,8 +2,9 @@ import Image from "next/image";
import Link from "next/link";
import styles from "./page.module.css";
import bgimage from "./assets/header-bg.jpg";
import Button from "./components/Button";
import ContactButtons from "./components/ContactButtons";
import MapEmbed from "./components/MapEmbed";
import { Card, CardContent } from "@/components/ui/card";
import ContactForm from "./components/ContactForm";
import FaqList from "./components/FaqList";
import CtaBand from "./components/CtaBand";
@@ -18,10 +19,8 @@ import {
HardDrive,
Laptop,
MapPin,
MessageSquare,
Monitor,
MonitorCog,
Phone,
Power,
ShieldAlert,
Star,
@@ -103,7 +102,7 @@ const steps = [
function SectionCta() {
return (
<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}
</a>
</p>
@@ -138,16 +137,7 @@ export default function Home() {
tell you what it costs before I start. Local, honest help
for West Auckland.
</p>
<div className="flex flex-col sm:flex-row gap-3 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>
<ContactButtons onDark className="mb-6" />
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<Star
aria-hidden="true"
@@ -169,30 +159,31 @@ export default function Home() {
</div>
</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">
Common fixes & prices
</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
starting.
</p>
<ul className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{commonFixes.map(({ icon: Icon, title, price, href }) => (
<li key={title}>
<Link
href={href}
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"
className="size-8 shrink-0 text-sky-800"
strokeWidth={1.5}
/>
<span>
<span className="block font-bold">{title}</span>
<span className="block text-sky-800">{price}</span>
</span>
<Link href={href} className="group block h-full rounded-xl">
<Card className="h-full flex-row items-center gap-4 p-4 shadow-none transition-colors group-hover:border-primary">
<Icon
aria-hidden="true"
className="size-7 shrink-0 text-primary"
strokeWidth={1.5}
/>
<span>
<span className="block font-semibold">{title}</span>
<span className="block text-sm text-muted-foreground">
{price}
</span>
</span>
</Card>
</Link>
</li>
))}
@@ -200,7 +191,7 @@ export default function Home() {
<SectionCta />
<Link
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 className="block font-bold">Still on Windows 10?</span>
@@ -212,15 +203,15 @@ export default function Home() {
</Link>
</section>
<section className="bg-slate-100 py-16 mb-32">
<section className="bg-slate-100 py-16 mb-24">
<div className="container mx-auto">
<h2 className="text-center text-3xl font-bold mb-8">
How it works
</h2>
<ol className="grid grid-cols-1 md:grid-cols-3 gap-8">
{steps.map(({ title, text }, i) => (
<li key={title} className="text-center max-w-sm mx-auto">
<span className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-sky-800 text-xl font-bold text-white">
<li key={title} className="border-t border-slate-300 pt-4">
<span className="mb-3 flex size-8 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground">
{i + 1}
</span>
<h3 className="text-xl font-bold mb-2">{title}</h3>
@@ -232,7 +223,7 @@ export default function Home() {
</div>
</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">
Hi, I&apos;m {business.ownerName}
</h2>
@@ -249,22 +240,26 @@ export default function Home() {
<SectionCta />
</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">
What customers say
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl mx-auto">
{business.testimonials.map(({ name, quote }) => (
<figure key={name} className="rounded-lg border p-6">
<blockquote className="mb-3">&ldquo;{quote}&rdquo;</blockquote>
<figcaption className="font-bold">{name}</figcaption>
</figure>
<Card key={name} className="shadow-none">
<CardContent>
<figure>
<blockquote className="mb-3">&ldquo;{quote}&rdquo;</blockquote>
<figcaption className="font-semibold">{name}</figcaption>
</figure>
</CardContent>
</Card>
))}
</div>
<p className="text-center mt-8">
<a
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
<ArrowRight aria-hidden="true" className="size-4" />
@@ -272,7 +267,7 @@ export default function Home() {
</p>
</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">
<h2 className="text-3xl font-bold mb-4">Areas I serve</h2>
<p className="mb-4 flex items-start gap-2">
@@ -293,7 +288,7 @@ export default function Home() {
{page ? (
<Link
href={`/areas/${page.slug}`}
className="text-sky-800 hover:underline"
className="text-primary hover:underline"
>
{area}
</Link>
@@ -311,7 +306,7 @@ export default function Home() {
<p className="mt-4">
<a
href={links.tel}
className="font-bold text-sky-800 hover:underline"
className="font-semibold text-primary hover:underline"
>
Call or text {business.phone}
</a>
@@ -322,11 +317,11 @@ export default function Home() {
</div>
</section>
<section className="container mx-auto mb-32">
<div className="rounded-lg border p-6 md:p-8 flex flex-col md:flex-row gap-6 md:items-center">
<section className="container mx-auto mb-24">
<Card className="flex-col gap-6 p-6 shadow-none md:flex-row md:items-center md:p-8">
<Briefcase
aria-hidden="true"
className="size-12 shrink-0 text-sky-800"
className="size-12 shrink-0 text-primary"
strokeWidth={1.5}
/>
<div className="flex-1">
@@ -339,16 +334,16 @@ export default function Home() {
</div>
<Link
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
<ArrowRight aria-hidden="true" className="size-4" />
</Link>
</div>
</Card>
</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"
>
<h2 className="text-center text-3xl font-bold mb-8">
+3 -13
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import HeaderSm from "../components/HeaderSm";
import CtaBand from "../components/CtaBand";
import PriceTable from "../components/PriceTable";
import { business, pageMetadata, priceList } from "@/lib/site";
export const metadata = pageMetadata({
@@ -27,18 +28,7 @@ export default function PricingPage() {
{priceList.map(({ group, items }) => (
<section key={group} className="container mx-auto max-w-3xl mb-12">
<h2 className="text-2xl font-bold mb-4">{group}</h2>
<table className="w-full text-left">
<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>
<PriceTable items={items} />
</section>
))}
@@ -63,7 +53,7 @@ export default function PricingPage() {
by appointment, has no travel fee. For onsite visits or pickup and
return, the travel fee depends on distance. Get in touch with your
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
</Link>
.
+5 -15
View File
@@ -1,10 +1,9 @@
import Link from "next/link";
import { MessageSquare, Phone } from "lucide-react";
import HeaderSm from "../components/HeaderSm";
import Button from "../components/Button";
import ContactButtons from "../components/ContactButtons";
import CtaBand from "../components/CtaBand";
import FaqList from "../components/FaqList";
import { business, links, pageMetadata } from "@/lib/site";
import { pageMetadata } from "@/lib/site";
export const metadata = pageMetadata({
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
check which one suits you for free.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button href={links.tel} variant="primary">
<Phone aria-hidden="true" />
Call {business.phone}
</Button>
<Button href={links.sms} variant="outlineDark">
<MessageSquare aria-hidden="true" />
Text for a free check
</Button>
</div>
<ContactButtons textLabel="Text for a free check" />
</section>
<section className="container mx-auto max-w-3xl mb-16">
@@ -79,7 +69,7 @@ export default function Windows10Page() {
<ol className="space-y-6">
{options.map(({ title, text }, i) => (
<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}
</span>
<div>
@@ -92,7 +82,7 @@ export default function Windows10Page() {
<p className="mt-6">
Not sure? Get in touch and I&apos;ll give you a straight answer. See
all prices on the{" "}
<Link href="/pricing" className="text-sky-800 underline">
<Link href="/pricing" className="text-primary underline">
pricing page
</Link>
.
+3 -3
View File
@@ -1,7 +1,7 @@
"use client"
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 { cn } from "@/lib/utils"
@@ -34,7 +34,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
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
)}
{...props}>
@@ -57,7 +57,7 @@ function AccordionContent({
<AccordionPrimitive.Content
data-slot="accordion-content"
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}>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</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 }