feat(storefront): update FAQ and legal documentation

- Added new FAQ sections for account security, ordering and checkout, returns, shipping, and contact information.
- Introduced legal documents including privacy policy, terms of service, data protection, and general terms and conditions.
- Updated package dependencies to include gray-matter and remark-gfm for enhanced markdown support.
This commit is contained in:
2026-03-13 21:39:25 +03:00
parent f1dbf0b6ee
commit c8f5d8d096
52 changed files with 2273 additions and 261 deletions

View File

@@ -0,0 +1,230 @@
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import type { Key } from "react-aria";
import {
Form,
TextField,
Label,
Input,
TextArea,
FieldError,
Button,
Spinner,
toast,
Select,
ListBox,
} from "@heroui/react";
const TOPIC_OPTIONS = [
{ key: "products", label: "Products" },
{ key: "orders", label: "Orders" },
{ key: "support", label: "Support" },
{ key: "other", label: "Other" },
] as const;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_NAME = 200;
const MAX_EMAIL = 254;
const MAX_MESSAGE = 5000;
export function ContactForm() {
const submitMessage = useMutation(api.messages.submit);
const [isSubmitting, setIsSubmitting] = useState(false);
const [topicKey, setTopicKey] = useState<Key | null>(null);
const [topicError, setTopicError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setTopicError(null);
const form = e.currentTarget;
const formData = new FormData(form);
const fullName = (formData.get("fullName") as string)?.trim() ?? "";
const email = (formData.get("email") as string)?.trim() ?? "";
const message = (formData.get("message") as string)?.trim() ?? "";
// Client-side validation
if (!fullName) {
toast.danger("Please enter your full name.");
return;
}
if (fullName.length > MAX_NAME) {
toast.danger(`Full name must be at most ${MAX_NAME} characters.`);
return;
}
if (!email) {
toast.danger("Please enter your work email.");
return;
}
if (!EMAIL_REGEX.test(email)) {
toast.danger("Please enter a valid email address.");
return;
}
if (email.length > MAX_EMAIL) {
toast.danger("Email must be at most 254 characters.");
return;
}
const topic = topicKey as string | null;
if (!topic || !TOPIC_OPTIONS.some((o) => o.key === topic)) {
setTopicError("Please select a topic.");
toast.danger("Please select a topic.");
return;
}
if (!message) {
toast.danger("Please enter your message.");
return;
}
if (message.length > MAX_MESSAGE) {
toast.danger(`Message must be at most ${MAX_MESSAGE} characters.`);
return;
}
setIsSubmitting(true);
try {
await submitMessage({
fullName,
email,
topic: topic as "products" | "orders" | "support" | "other",
message,
});
toast.success("Thank you! We've received your message and will get back to you soon.");
form.reset();
setTopicKey(null);
setTopicError(null);
} catch (err: unknown) {
const messageErr = err instanceof Error ? err.message : "Something went wrong. Please try again.";
toast.danger(messageErr);
} finally {
setIsSubmitting(false);
}
};
return (
<Form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
<TextField
isRequired
name="fullName"
maxLength={MAX_NAME}
className="flex flex-col gap-1"
aria-required="true"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Full name <span className="text-danger">*</span>
</Label>
<Input
placeholder="First and last name"
className="bg-[var(--surface)]"
autoComplete="name"
disabled={isSubmitting}
aria-required="true"
/>
<FieldError className="text-xs text-danger" />
</TextField>
<TextField
isRequired
name="email"
type="email"
maxLength={MAX_EMAIL}
validate={(val: string) => {
if (val && !EMAIL_REGEX.test(val)) return "Please enter a valid email address.";
}}
className="flex flex-col gap-1"
aria-required="true"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Work email address <span className="text-danger">*</span>
</Label>
<Input
type="email"
placeholder="me@company.com"
className="bg-[var(--surface)]"
autoComplete="email"
disabled={isSubmitting}
aria-required="true"
/>
<FieldError className="text-xs text-danger" />
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium text-[var(--foreground)]">
Topic <span className="text-danger">*</span>
</Label>
<Select
aria-label="Select a topic"
aria-required="true"
placeholder="Select a topic"
value={topicKey}
onChange={(value) => {
setTopicKey(value ?? null);
setTopicError(null);
}}
isDisabled={isSubmitting}
className="w-full"
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover className="rounded-lg">
<ListBox>
{TOPIC_OPTIONS.map((opt) => (
<ListBox.Item key={opt.key} id={opt.key} textValue={opt.label}>
{opt.label}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
{topicError && (
<p className="text-xs text-danger mt-1" role="alert">
{topicError}
</p>
)}
</div>
<TextField
isRequired
name="message"
maxLength={MAX_MESSAGE}
className="flex flex-col gap-1"
aria-required="true"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Your message <span className="text-danger">*</span>
</Label>
<TextArea
rows={5}
placeholder="Write your message"
className="bg-[var(--surface)]"
disabled={isSubmitting}
aria-required="true"
/>
<FieldError className="text-xs text-danger" />
</TextField>
<Button
type="submit"
isDisabled={isSubmitting}
isPending={isSubmitting}
className="bg-[#f4a13a] text-[#1a2e2d] font-medium w-full md:w-auto md:self-start mt-1"
aria-busy={isSubmitting}
>
{({ isPending }: { isPending: boolean }) =>
isPending ? (
<>
<Spinner color="current" size="sm" />
Submitting
</>
) : (
"Submit"
)
}
</Button>
</Form>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
const PROMOS = [
{
id: "free-shipping",
message: "Free delivery on orders over £40. Automatically applied at checkout.",
href: "/shop",
},
{
id: "first-order",
message: "Sign up to our newsletter to get 10% off your first order.",
href: "/#newsletter",
},
{
id: "reorders",
message: "5% off on re-orders over £30. Automatically applied at checkout.",
href: "/shop",
},
] as const;
const ROTATION_INTERVAL_MS = 6000;
export function AnnouncementBar() {
const [index, setIndex] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setIndex((i) => (i + 1) % PROMOS.length);
}, ROTATION_INTERVAL_MS);
return () => clearInterval(id);
}, []);
const promo = PROMOS[index];
return (
<div
className="w-full border-b border-[#e8e8e8] bg-[#f4a13a]"
role="region"
aria-label="Promotional offers"
>
<div className="mx-auto flex max-w-[1400px] items-center justify-center px-4 py-2.5">
<Link
href={promo.href}
className="text-center font-sans text-xs font-medium text-[#3d5554] transition-colors hover:text-[#236f6b] md:text-sm"
>
<span className="text-[#f2705a]">{promo.id === "free-shipping" ? "★ " : ""}</span>
{promo.message}
</Link>
</div>
</div>
);
}

View File

@@ -8,10 +8,14 @@ interface BrandLogoProps {
export function BrandLogo({ size = 28, textClassName }: BrandLogoProps) {
return (
<Link href="/" className="flex shrink-0 items-center gap-2">
<Link
href="/"
className="flex shrink-0 flex-row items-center gap-2"
aria-label="The Pet Loft - Home"
>
<Image
src="/branding/logo.svg"
alt=""
alt="The Pet Loft"
width={size}
height={size}
className="shrink-0"

View File

@@ -47,48 +47,15 @@ function TwitterIcon() {
}
const shopLinks = [
{ label: "All Products", href: "/shop" },
{ label: "Dog Food", href: "/shop/dogs/dry-food" },
{ label: "Cat Food", href: "/shop/cats/dry-food" },
{ label: "Treats & Snacks", href: "/shop/dogs/treats" },
{ label: "Toys", href: "/shop/dogs/toys" },
{ label: "Beds & Baskets", href: "/shop/dogs/beds-and-baskets" },
{ label: "Grooming & Care", href: "/shop/dogs/grooming-and-care" },
{ label: "Leads & Collars", href: "/shop/dogs/leads-and-collars" },
{ label: "Bowls & Feeders", href: "/shop/dogs/bowls-and-feeders" },
{ label: "Flea & Worming", href: "/shop/dogs/flea-tick-and-worming-treatments" },
{ label: "Clothing", href: "/shop/dogs/clothing" },
{ label: "Pet Toys", href: "/shop/toys" },
{ label: "Pet Treats", href: "/shop/treats" },
{ label: "Cats Food", href: "/shop/cats/cat-dry-food" },
{ label: "Dogs Food", href: "/shop/dogs/dog-dry-food" },
{ label: "Cat Grooming & Care", href: "/shop/cats/cat-feliway-care" },
{ label: "Dogs Grooming & Care", href: "/shop/dogs/dog-grooming-care" },
];
const specialtyGroups = [
{
heading: "Brands",
links: [
{ label: "Almo Nature", href: "/brands/almo-nature" },
{ label: "Applaws", href: "/brands/applaws" },
{ label: "Arden Grange", href: "/brands/arden-grange" },
{ label: "Shop All", href: "/shop" },
],
},
{
heading: "Accessories",
links: [
{ label: "Crates & Travel", href: "/shop/dogs/crates-and-travel-accessories" },
{ label: "Kennels & Gates", href: "/shop/dogs/kennels-flaps-and-gates" },
{ label: "Trees & Scratching", href: "/shop/cats/trees-and-scratching-posts" },
],
},
];
const engagementGroups = [
{
heading: "Community",
links: [
{ label: "Adopt a Pet", href: "/community/adopt" },
{ label: "Pet Pharmacy", href: "/pharmacy" },
{ label: "Pet Services", href: "/services" },
],
},
{
heading: "Promotions",
links: [
@@ -99,30 +66,34 @@ const engagementGroups = [
},
];
const utilityGroups = [
{
heading: "Content",
links: [
{ label: "Blog", href: "/blog" },
{ label: "Tips & Tricks", href: "/tips" },
{ label: "Pet Guides", href: "/guides" },
],
},
const engagementGroups = [
{
heading: "Support",
links: [
{ label: "Order Tracking", href: "/account/orders" },
{ label: "Shipping Info", href: "/support/shipping" },
{ label: "Shipping", href: "/support/shipping" },
{ label: "Returns & Refunds", href: "/support/returns" },
{ label: "Payment Security", href: "/support/payment-security" },
{ label: "FAQs", href: "/support/faqs" },
],
},
];
const utilityGroups = [
// {
// heading: "Content",
// links: [
// { label: "Blog", href: "/blog" },
// { label: "Tips & Tricks", href: "/tips" },
// { label: "Pet Guides", href: "/guides" },
// ],
// },
{
heading: "Company",
links: [
{ label: "About Us", href: "/about" },
{ label: "Contact Us", href: "/contact" },
{ label: "Careers", href: "/careers" },
// { label: "About Us", href: "/about" },
{ label: "Contact Us", href: "/support/contact-us" },
{ label: "General Terms and Conditions", href: "/legal/general-terms-and-conditions" },
],
},
];
@@ -163,10 +134,7 @@ export function Footer() {
{/* Brand & Social */}
<div className="space-y-6">
<div>
<BrandLogo
size={30}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<BrandLogo size={40} />
<p className="mt-3 text-sm leading-relaxed text-[#3d5554]">
Your trusted partner for premium pet supplies. Healthy pets,
happy homes from nutrition to play, we&apos;ve got it all.
@@ -219,12 +187,6 @@ export function Footer() {
</li>
))}
</ul>
<a
href="/special-offers"
className="mt-3 inline-block text-sm font-semibold text-[#f2705a] transition-colors hover:text-[#e05a42]"
>
Sale
</a>
</div>
{/* Column 2 — Specialty */}
@@ -300,20 +262,26 @@ export function Footer() {
&copy; {new Date().getFullYear()} The Pet Loft. All rights reserved.
</p>
<div className="flex items-center gap-6">
<a
href="/terms"
<a
href="/legal/return-and-refund-policy"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Terms of Use
Return & Refund Policy
</a>
<a
href="/privacy"
href="/legal/terms-of-service"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Terms of Service
</a>
<a
href="/legal/privacy-policy"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Privacy Policy
</a>
<a
href="/data-protection"
href="/legal/data-protection"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Data Protection

View File

@@ -46,7 +46,7 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
className={
isDesktop
? "relative flex w-full max-w-2xl items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] p-1.5 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] py-3 px-3 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
}
>
{/* Category picker */}
@@ -144,7 +144,7 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
className={
isDesktop
? "flex-1 bg-transparent py-3 pl-4 pr-5 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
: "flex-1 border-none bg-transparent pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
: "min-h-[44px] flex-1 border-none bg-transparent py-2 pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
}
role="combobox"
aria-expanded={search.isOpen && search.showResults}

View File

@@ -22,7 +22,7 @@ export function CoreBrandBar() {
<div className="w-full bg-white">
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-8 px-6 py-4">
{/* Logo */}
<BrandLogo size={32} />
<BrandLogo size={56} />
{/* Search Bar */}
<HeaderSearchBar variant="desktop" />

View File

@@ -1,11 +1,9 @@
import { TopUtilityBar } from "./TopUtilityBar";
import { CoreBrandBar } from "./CoreBrandBar";
import { BottomNav } from "./BottomNav";
export function DesktopHeader() {
return (
<header className="sticky top-0 z-50 w-full shadow-sm">
<TopUtilityBar />
<CoreBrandBar />
<BottomNav />
</header>

View File

@@ -1,74 +0,0 @@
export function TopUtilityBar() {
return (
<div className="w-full bg-[#f5f5f5] border-b border-[#e8e8e8]">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-1.5 text-xs">
{/* Domain */}
<span className="tracking-wide text-[#3d5554]">www.thepetloft.com</span>
{/* Promo */}
<div className="flex items-center gap-1.5 font-medium text-[#f2705a]">
<span><strong className="text-[13px]"> 10% </strong>
off your first order</span>
<span></span>
<span><strong className="text-[13px]"> 5% </strong>
off on all Re-orders over <strong>£30</strong></span>
<span></span>
<span>Free shipping on orders over <strong>£40</strong></span>
</div>
{/* Utility links */}
<div className="flex items-center gap-5 text-[#3d5554]">
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
<path d="M14.05 2a9 9 0 0 1 8 7.94" />
<path d="M14.05 6A5 5 0 0 1 18 10" />
</svg>
<span>Contact</span>
</button>
<div className="h-3 w-px bg-[#ccc]" />
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span>EN</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -23,10 +23,7 @@ export function MobileCoreBrandBar() {
{/* Logo and Actions Row */}
<div className="mb-4 flex items-center justify-between">
{/* Logo */}
<BrandLogo
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<BrandLogo size={44} />
{/* Actions */}
<div className="flex items-center gap-4">

View File

@@ -1,7 +1,6 @@
"use client";
import { useRef } from "react";
import { MobileUtilityBar } from "./MobileUtilityBar";
import { MobileNavButtons } from "./MobileNavButtons";
import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
import Link from "next/link";
@@ -22,14 +21,10 @@ export function MobileHeader() {
return (
<>
{/* In-flow: utility bar + logo row scroll away with the page */}
{/* In-flow: logo row scrolls away with the page */}
<div className="w-full max-w-full min-w-0 overflow-x-hidden bg-white">
<MobileUtilityBar />
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-[#e8f7f6] px-4 py-3">
<BrandLogo
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<BrandLogo size={44} />
<div className="flex shrink-0 items-center gap-3">
<Link
href="/wishlist"

View File

@@ -1,21 +0,0 @@
"use client";
const PROMO_TEXT =
"☞ 10% off your first order ★ ☞ 5% off on all Re-orders over £30 ★ Free shipping on orders over £40 ★ ";
export function MobileUtilityBar() {
return (
<div className="relative flex min-h-[2.5rem] w-full max-w-full items-center overflow-hidden border-b border-[#e8e8e8] bg-[#f5f5f5]">
<div className="absolute inset-0 flex items-center overflow-hidden" aria-hidden>
<div className="flex animate-marquee whitespace-nowrap">
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { getLegalDoc, type LegalSlug } from "@/lib/legal/getLegalDoc";
import Link from "next/link";
import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
const LEGAL_LINKS: { slug: LegalSlug; label: string }[] = [
{ slug: "terms-of-service", label: "Terms of Service" },
{ slug: "privacy-policy", label: "Privacy Policy" },
{ slug: "data-protection", label: "Data Protection" },
{ slug: "general-terms-and-conditions", label: "General Terms and Conditions" },
{ slug: "return-and-refund-policy", label: "Return and Refund Policy" },
];
const defaultTitles: Record<LegalSlug, string> = {
"terms-of-service": "Terms of Service",
"privacy-policy": "Privacy Policy",
"data-protection": "Data Protection",
"general-terms-and-conditions": "General Terms and Conditions",
"return-and-refund-policy": "Return and Refund Policy",
};
type LegalDocPageProps = {
slug: LegalSlug;
};
export function LegalDocPage({ slug }: LegalDocPageProps) {
const doc = getLegalDoc(slug);
if (!doc) notFound();
const title = doc.data.title ?? defaultTitles[slug];
const others = LEGAL_LINKS.filter((l) => l.slug !== slug);
return (
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
<Link
href="/shop"
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
>
Back to Shop
</Link>
<article className="prose prose-[#1a2e2d] max-w-none">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
{title}
</h1>
{doc.data.lastUpdated && (
<p className="mt-2 text-[#3d5554]">
Last updated: {doc.data.lastUpdated}
</p>
)}
<div className="mt-6 [&_h2]:font-[family-name:var(--font-fraunces)] [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[#236f6b] [&_h2]:mt-8 [&_h2]:first:mt-0 [&_p]:text-[#1a2e2d] [&_p]:leading-relaxed [&_p]:mb-3 [&_a]:font-medium [&_a]:text-[#38a99f] [&_a]:underline [&_a]:transition-colors [&_a:hover]:text-[#236f6b] [&_ol]:mb-3 [&_ol]:list-inside [&_ol]:list-decimal [&_ul]:mb-3 [&_ul]:list-inside [&_ul]:list-disc">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
{children}
</h2>
),
}}
>
{doc.content}
</ReactMarkdown>
</div>
<div className="mt-10 space-y-2 border-t border-[#236f6b]/20 pt-6">
{others.length > 0 && (
<p className="text-sm text-[#3d5554]">
Other policies:{" "}
{others.map((l, i) => (
<span key={l.slug}>
{i > 0 && " · "}
<Link
href={`/${l.slug}`}
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
{l.label}
</Link>
</span>
))}
</p>
)}
<p className="text-sm text-[#3d5554]">
Need help?{" "}
<Link
href="/contact"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</div>
</article>
</main>
);
}

View File

@@ -1,14 +1,23 @@
"use client";
import Link from "next/link";
/**
* Customer confidence booster: trust badges (Free Shipping, Easy Returns, Secure Checkout).
* Rendered below product grids on shop pages and homepage. PetPaws theme, mobile-first.
* Each badge links to its corresponding policy page.
*/
export function CustomerConfidenceBooster() {
const items: { title: string; subheading: string; icon: React.ReactNode }[] = [
export function TrustAndCredibilitySection() {
const items: {
title: string;
subheading: string;
href: string;
icon: React.ReactNode;
}[] = [
{
title: "Free Shipping",
subheading: "No extra costs (T&C apply)",
href: "/support/shipping",
icon: (
<svg
className="size-8 shrink-0"
@@ -33,6 +42,7 @@ export function CustomerConfidenceBooster() {
{
title: "Easy Returns",
subheading: "Return with ease",
href: "/support/returns",
icon: (
<svg
className="size-8 shrink-0"
@@ -55,6 +65,7 @@ export function CustomerConfidenceBooster() {
{
title: "Secure Checkout",
subheading: "Secure payment",
href: "/support/payment-security",
icon: (
<svg
className="size-8 shrink-0"
@@ -75,31 +86,28 @@ export function CustomerConfidenceBooster() {
return (
<section
aria-label="Why shop with us"
className="w-full border border-[#d9e8e7] bg-gradient-to-r from-[#e8f7f6] to-[#f0f8f7] px-4 py-6 md:px-6 md:py-8 m-16 max-w-7xl mx-auto rounded-xl"
className="w-full border border-[#f4a13a] bg-gradient-to-r from-[#e8f7f6] to-[#f0f8f7] px-4 py-6 md:px-6 md:py-8 m-16 max-w-7xl mx-auto rounded-xl"
>
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 md:gap-8">
{items.map(({ title, subheading, icon }) => (
<div
{items.map(({ title, subheading, href, icon }) => (
<Link
key={title}
className="flex flex-col items-center gap-3 text-center"
href={href}
className="flex flex-col items-center gap-3 text-center transition-colors hover:text-[#236f6b] focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2 focus:ring-offset-[#e8f7f6] rounded-lg"
>
<div
className="flex size-12 items-center justify-center rounded-full bg-white text-[#236f6b] shadow-sm ring-1 ring-[#d9e8e7]"
className="flex size-12 items-center justify-center rounded-full bg-white text-[#236f6b] shadow-sm ring-1 ring-[#d9e8e7] transition-colors"
aria-hidden
>
{icon}
</div>
<div className="flex flex-col gap-0.5">
<span
className="block font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg"
>
<span className="block font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg">
{title}
</span>
<span className="block text-sm text-[#3d5554]">
{subheading}
</span>
<span className="block text-sm text-[#3d5554]">{subheading}</span>
</div>
</div>
</Link>
))}
</div>
</section>

View File

@@ -44,25 +44,56 @@ export function CtaSection() {
className="mt-1 font-[family-name:var(--font-fraunces)] text-4xl font-bold leading-tight tracking-tight text-[var(--foreground)] drop-shadow-sm md:text-5xl lg:text-6xl lg:leading-tight"
>
<span className="relative inline-block border-b-4 border-[var(--warm)] pb-1">
45% OFF
25% OFF
</span>
</h2>
<p className="mt-3 font-sans text-base text-[var(--foreground)] md:text-lg">
Thousands of pet favourites
Thousands of pet essentials
</p>
<Link
href="/shop"
className="mt-6 inline-flex w-fit items-center gap-1 rounded-full bg-[var(--warm)] px-6 py-3 font-sans text-sm font-medium text-[var(--neutral-900)] shadow-sm transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[var(--brand)] focus:ring-offset-2"
className="mt-6 inline-flex min-h-[48px] w-fit items-center justify-center gap-2 rounded-full bg-[#e89120] px-6 py-3 font-sans text-base font-semibold text-white shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#d97f0f] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2 md:px-8 md:py-4"
>
Shop Pet Deals
<span aria-hidden></span>
</Link>
</div>
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)] text-sm" aria-hidden>
<span>Healthy pets, happy homes.</span>
</div>
</section>
{/* Doggy CTA */}
<section
aria-labelledby="cta-doggy"
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
>
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
<div className="relative z-10">
<h2
id="cta-doggy"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-2xl"
>
Shop Dog Essentials
</h2>
<Link
href="/shop/dogs"
className="mt-3 inline-flex min-h-[44px] w-fit items-center justify-center gap-1.5 rounded-full bg-[#f4a13a] px-4 py-2.5 font-sans text-sm font-bold text-[#1a2e2d] shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#f5ad4d] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:min-h-[48px] md:px-8 md:py-4 md:text-base"
>
Shop Now
<span aria-hidden></span>
</Link>
</div>
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)]" aria-hidden>
<span></span>
<span></span>
<span></span>
<span className="opacity-60"></span>
<div className="absolute inset-0">
<Image
src={CTA_IMAGES.kitty}
alt=""
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
</div>
</section>
@@ -75,18 +106,15 @@ export function CtaSection() {
<div className="relative z-10">
<h2
id="cta-kitty"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-2xl"
>
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
<span>Kitty</span>
<br />
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
Shop Cat Essentials
</h2>
<Link
href="/shop/cats"
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
className="mt-3 inline-flex min-h-[44px] w-fit items-center justify-center gap-1.5 rounded-full bg-[#f4a13a] px-4 py-2.5 font-sans text-sm font-bold text-[#1a2e2d] shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#f5ad4d] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:min-h-[48px] md:px-8 md:py-4 md:text-base"
>
Shop here
Shop Now
<span aria-hidden></span>
</Link>
</div>
@@ -101,40 +129,7 @@ export function CtaSection() {
</div>
</section>
{/* Doggy CTA */}
<section
aria-labelledby="cta-doggy"
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
>
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
<div className="relative z-10">
<h2
id="cta-doggy"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
>
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
<span>Doggy</span>
<br />
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
</h2>
<Link
href="/shop/dogs"
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
>
Shop here
<span aria-hidden></span>
</Link>
</div>
<div className="absolute inset-0">
<Image
src={CTA_IMAGES.kitty}
alt=""
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
</div>
</section>
</div>
</div>
</section>

View File

@@ -17,6 +17,7 @@ function EnvelopeIcon({ className }: { className?: string }) {
export function NewsletterSection() {
return (
<section
id="newsletter"
aria-label="Newsletter signup"
className="relative max-w-7xl mx-auto w-full px-4 md:px-8 mb-12"
>

View File

@@ -25,7 +25,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
const SORT_OPTIONS: SortOption[] = [
{ value: "newest", label: "Newest" },
@@ -194,7 +194,7 @@ export function PetCategoryPage({ slug }: { slug: PetCategorySlug }) {
/>
)}
<div className="mt-8">
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import {
filterStateFromSearchParams,
mergeFilterStateIntoSearchParams,
@@ -164,7 +164,7 @@ export function RecentlyAddedPage() {
/>
)}
<div className="mt-8">
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
</div>
</div>
</div>

View File

@@ -14,7 +14,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import {
filterStateFromSearchParams,
filterStateToSearchParams,
@@ -164,7 +164,7 @@ export function ShopIndexContent() {
/>
)}
<div className="mt-8">
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
</div>
</div>
</div>

View File

@@ -16,7 +16,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import {
filterStateFromSearchParams,
filterStateToSearchParams,
@@ -211,7 +211,7 @@ export function SubCategoryPageContent({
/>
)}
<div className="mt-8">
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import {
filterStateFromSearchParams,
mergeFilterStateIntoSearchParams,
@@ -170,7 +170,7 @@ export function TagShopPage({
/>
)}
<div className="mt-8">
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
</div>
</div>
</div>

View File

@@ -15,7 +15,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import {
PET_CATEGORY_SLUGS,
TOP_CATEGORY_SLUGS,
@@ -249,7 +249,7 @@ export function TopCategoryPage({ slug }: { slug: TopCategorySlug }) {
/>
)}
<div className="mt-8">
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
</div>
</div>
</div>

View File

@@ -0,0 +1,132 @@
"use client";
import { Accordion } from "@heroui/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { FaqSection } from "@/lib/faq/getFaqSections";
type FaqPageViewProps = {
sections: FaqSection[];
lastUpdated?: string;
};
function FaqAnswer({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) =>
href?.startsWith("/") ? (
<Link
href={href}
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
{children}
</Link>
) : (
<a
href={href}
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}
export function FaqPageView({ sections, lastUpdated }: FaqPageViewProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedSection = useMemo(
() => (selectedId ? sections.find((s) => s.title === selectedId) ?? null : null),
[sections, selectedId]
);
const sectionId = (title: string) => title.replace(/\s+/g, "-").toLowerCase();
return (
<div className="space-y-8">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{sections.map((section) => {
const id = sectionId(section.title);
const isSelected = selectedId === section.title;
return (
<button
key={id}
type="button"
onClick={() => setSelectedId(isSelected ? null : section.title)}
className="flex flex-col items-start rounded-lg border-2 border-[#236f6b]/20 bg-[#f0f8f7] p-5 text-left transition-colors hover:border-[#38a99f] hover:bg-[#e8f7f6] focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2"
aria-expanded={isSelected}
data-selected={isSelected ? "" : undefined}
style={
isSelected
? { borderColor: "#236f6b", backgroundColor: "#e8f7f6" }
: undefined
}
>
<span className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#1a2e2d]">
{section.title}
</span>
{section.subtitle && (
<span className="mt-1 text-sm text-[#3d5554]">
{section.subtitle}
</span>
)}
</button>
);
})}
</div>
{selectedSection && selectedSection.items.length > 0 && (
<section
id={`faq-${sectionId(selectedSection.title)}`}
aria-labelledby={`faq-heading-${sectionId(selectedSection.title)}`}
className="rounded-lg border border-[#236f6b]/20 bg-white p-4 md:p-6"
>
<h2
id={`faq-heading-${sectionId(selectedSection.title)}`}
className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#236f6b]"
>
{selectedSection.title}
</h2>
<Accordion
allowsMultipleExpanded
className="mt-4 w-full"
hideSeparator={false}
>
{selectedSection.items.map((item, index) => (
<Accordion.Item
key={`${sectionId(selectedSection.title)}-${index}`}
id={`${sectionId(selectedSection.title)}-q-${index}`}
>
<Accordion.Heading>
<Accordion.Trigger className="text-left text-sm font-medium text-[#1a2e2d]">
{item.question}
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body className="pb-4 pt-1 text-[#1a2e2d] leading-relaxed [&_a]:font-medium [&_a]:text-[#38a99f] [&_a]:underline [&_a:hover]:text-[#236f6b]">
<FaqAnswer content={item.answer} />
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</section>
)}
{lastUpdated && (
<p className="text-sm text-[#3d5554]">Last updated: {lastUpdated}</p>
)}
</div>
);
}