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

@@ -3,6 +3,7 @@ import { DM_Sans, Fraunces } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ConvexClientProvider } from "@repo/convex";
import { CartUIProvider } from "../components/cart/CartUIProvider";
import { AnnouncementBar } from "../components/layout/AnnouncementBar";
import { Header } from "../components/layout/header/Header";
import { SessionCartMerge } from "../lib/session/SessionCartMerge";
import { StoreUserSync } from "../lib/session/StoreUserSync";
@@ -18,7 +19,7 @@ const dmSans = DM_Sans({
const fraunces = Fraunces({
subsets: ["latin"],
weight: ["400", "600", "700"],
weight: ["100", "400", "600", "700"],
variable: "--font-fraunces",
});
@@ -45,6 +46,7 @@ export default function RootLayout({
<SessionCartMerge />
<StoreUserSync />
<CartUIProvider>
<AnnouncementBar />
<Header />
{children}
<Footer />

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("data-protection");
return {
title: doc?.data.title ?? "Data Protection",
description: doc?.data.description,
};
}
export default function DataProtectionPage() {
return <LegalDocPage slug="data-protection" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("general-terms-and-conditions");
return {
title: doc?.data.title ?? "General Terms and Conditions",
description: doc?.data.description,
};
}
export default function GeneralTermsAndConditionsPage() {
return <LegalDocPage slug="general-terms-and-conditions" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("privacy-policy");
return {
title: doc?.data.title ?? "Privacy Policy",
description: doc?.data.description,
};
}
export default function PrivacyPolicyPage() {
return <LegalDocPage slug="privacy-policy" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("return-and-refund-policy");
return {
title: doc?.data.title ?? "Return and Refund Policy",
description: doc?.data.description,
};
}
export default function ReturnAndRefundPolicyPage() {
return <LegalDocPage slug="return-and-refund-policy" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("terms");
return {
title: doc?.data.title ?? "Terms of Service",
description: doc?.data.description,
};
}
export default function TermsOfServicePage() {
return <LegalDocPage slug="terms-of-service" />;
}

View File

@@ -7,7 +7,7 @@ import { RecentlyAddedSection } from "../components/sections/hompepage/products-
import { SpecialOffersSection } from "../components/sections/hompepage/products-sections/special-offers/SpecialOffersSection";
import { TopPicksSection } from "../components/sections/hompepage/products-sections/top-picks/TopPicsSection";
import { WishlistSection } from "../components/sections/hompepage/wishlist/WishlistSection";
import { CustomerConfidenceBooster } from "../components/sections/CustomerConfidenceBooster";
import { TrustAndCredibilitySection } from "../components/sections/TrustAndCredibility";
import { Toast } from "@heroui/react";
export default function HomePage() {
@@ -21,7 +21,7 @@ export default function HomePage() {
<RecentlyAddedSection />
<SpecialOffersSection />
<TopPicksSection />
<CustomerConfidenceBooster />
<TrustAndCredibilitySection />
<NewsletterSection />
</main>
);

View File

@@ -0,0 +1,152 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ContactForm } from "@/components/contact/ContactForm";
export const metadata: Metadata = {
title: "Contact Us",
description:
"Get in touch with The Pet Loft. Postal address, support and service emails, and send us an inquiry.",
};
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
);
}
function InstagramIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
}
function TwitterIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
const socialLinks = [
{ href: "https://facebook.com", label: "Facebook", icon: FacebookIcon },
{ href: "https://instagram.com", label: "Instagram", icon: InstagramIcon },
{ href: "https://twitter.com", label: "Twitter / X", icon: TwitterIcon },
];
export default function ContactUsPage() {
return (
<main className="mx-auto min-w-0 max-w-[1400px] px-4 py-8 md:px-6 md:py-12">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Contact Us
</h1>
<p className="mt-2 text-[#3d5554]">
We&apos;d love to hear from you. Use the form to send an inquiry or find our details below.
</p>
<div className="mt-8 grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-16">
{/* Left column: address, emails, socials */}
<section className="flex flex-col gap-8" aria-label="Contact details">
<section aria-labelledby="postal-heading">
<h2
id="postal-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Postal address
</h2>
<address className="mt-2 not-italic text-[#1a2e2d] leading-relaxed">
The Pet Loft
<br />
123 High Street
<br />
London, SW1A 1AA
<br />
United Kingdom
</address>
</section>
<section aria-labelledby="emails-heading">
<h2
id="emails-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Support and service emails
</h2>
<ul className="mt-2 space-y-1 text-[#1a2e2d]">
<li>
<a
href="mailto:support@thepetloft.com"
className="text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
support@thepetloft.com
</a>
<span className="ml-1 text-[#3d5554]"> general support</span>
</li>
<li>
<a
href="mailto:service@thepetloft.com"
className="text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
service@thepetloft.com
</a>
<span className="ml-1 text-[#3d5554]"> orders &amp; delivery</span>
</li>
</ul>
</section>
<section aria-labelledby="follow-heading">
<h2
id="follow-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Follow us
</h2>
<div className="mt-2 flex items-center gap-3">
{socialLinks.map(({ href, label, icon: Icon }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f0f8f7] text-[#3d5554] transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
<Icon />
</a>
))}
</div>
</section>
</section>
{/* Right column: Inquiries form */}
<section aria-labelledby="inquiries-heading">
<h2
id="inquiries-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Inquiries
</h2>
<p className="mt-1 text-sm text-[#3d5554]">
Send us a message and we&apos;ll get back to you as soon as we can.
</p>
<div className="mt-5">
<ContactForm />
</div>
</section>
</div>
<p className="mt-10 text-sm text-[#3d5554]">
<Link
href="/shop"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Back to shop
</Link>
</p>
</main>
);
}

View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import { FaqPageView } from "@/components/support/FaqPageView";
import { getFaqSections } from "@/lib/faq/getFaqSections";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "FAQs",
description:
"Frequently asked questions about ordering, shipping, returns, your account, and how to contact The Pet Loft.",
};
export default function FaqsPage() {
const sections = getFaqSections();
return (
<main className="mx-auto max-w-4xl 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>
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Frequently asked questions
</h1>
<p className="mt-2 text-[#3d5554]">
Select a topic below to see common questions and answers.
</p>
<div className="mt-8">
<FaqPageView sections={sections} lastUpdated="March 2025" />
</div>
<p className="mt-10 text-sm text-[#3d5554]">
Can&apos;t find what you need?{" "}
<Link
href="/support/contact-us"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</main>
);
}

View File

@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Payment Security",
description:
"The Pet Loft payment security: how we keep your payment details safe with industry-standard encryption.",
};
export default function PaymentSecurityPage() {
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">
Payment Security
</h1>
<p className="mt-2 text-[#3d5554]">Last updated: March 2025</p>
<section className="mt-6 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Secure Checkout
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
All payments are processed securely through Stripe, a PCI-compliant
payment provider trusted by millions of businesses worldwide. We
never store your full card details on our servers.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Industry Standard Protection
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Our checkout uses TLS encryption to protect your data in transit.
Stripe is certified to PCI Service Provider Level 1, the highest
standard in the payments industry. Your card information is
tokenised and never exposed to our systems.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Accepted Payment Methods
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
We accept major credit and debit cards (Visa, Mastercard, Discover),
Apple Pay, Google Pay, and Klarna. Choose your preferred method at
checkout.
</p>
</section>
<p className="mt-10 text-sm text-[#3d5554]">
Questions about security?{" "}
<Link
href="/contact"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,131 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Returns & Refunds Policy",
description:
"The Pet Loft returns policy: how to return items, refund process, and conditions for easy returns.",
};
export default function ReturnsPolicyPage() {
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">
Returns & Refunds Policy
</h1>
<p className="mt-2 text-[#3d5554]">Last updated: 02 February 2026</p>
<section className="mt-6 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Right of Withdrawal
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
You have the right to withdraw from your contract within 14 days
without giving any reason. The withdrawal period expires 14 days
from the day on which you (or a third party indicated by you, other
than the carrier) acquire physical possession of the goods.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
How to withdraw
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
To exercise the right of withdrawal, you must inform us at The Pet
Loft UK, Customer Services, 39a Walton Road, Woking, GU21 5DL, or by
email at{" "}
<a
href="mailto:service@thepetloft.co.uk"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
service@thepetloft.co.uk
</a>{" "}
of your decision by an unequivocal statement (e.g. a letter by post
or email). You may use the model withdrawal form, but it is not
obligatory. To meet the deadline, it is sufficient for you to send
your communication before the withdrawal period has expired.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Effects of withdrawal
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
If you withdraw, we shall reimburse all payments received from you,
including delivery costs (except supplementary costs from choosing a
delivery type other than our least expensive standard option),
without undue delay and in any event not later than 14 days from the
day we are informed of your withdrawal. The cost of returning the
goods is borne by you and will be deducted from the refund amount.
We will use the same means of payment as you used for the initial
transaction unless you have expressly agreed otherwise. We may
withhold reimbursement until we have received the goods back or you
have supplied evidence of having sent them back, whichever is the
earliest.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Return address
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Please send the goods back to: <strong>Fanaaka Ltd</strong>, 39a
Walton Road, Woking, GU21 5DL without undue delay and in any event
not later than 14 days from the day you communicate your withdrawal.
The deadline is met if you send the goods back before the 14-day
period has expired. You incur the cost of returning the goods; this
amount will be deducted from your refund. You are only liable for
any diminished value resulting from handling
beyond what is necessary to establish the nature, characteristics,
and functioning of the goods.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Exclusions
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
The right of withdrawal does not apply to:
</p>
<ul className="list-inside list-disc space-y-1 text-[#1a2e2d]">
<li>
Goods made to order or clearly tailored to your personal
requirements
</li>
<li>
Goods that may perish quickly or whose use-by date would expire
rapidly
</li>
<li>
Goods not suitable for return for reasons of health or hygiene if
their seal has been broken after delivery
</li>
<li>Goods that were, after delivery, inseparably mixed with other goods</li>
</ul>
</section>
<p className="mt-10 text-sm text-[#3d5554]">
Need help?{" "}
<Link
href="/support/contact-us"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Shipping Policy",
description:
"The Pet Loft shipping policy: free delivery on orders over £40, delivery times, and carrier information.",
};
export default function ShippingPolicyPage() {
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">
Shipping Policy
</h1>
<p className="mt-2 text-[#3d5554]">Last updated: March 2025</p>
<section className="mt-6 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Free Delivery on Orders Over £40
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
We offer free standard delivery on all orders over £40. The discount
is automatically applied at checkout no code required.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Delivery Times
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Standard delivery typically takes 35 working days for UK mainland.
We partner with trusted carriers including DPD and Evri to get your
pet supplies to you safely and on time.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Terms & Conditions
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Free shipping applies to UK mainland only. Orders under £40 may
incur a delivery charge. Delivery times are estimates and may vary
during peak periods. We reserve the right to exclude certain items
from free delivery promotions.
</p>
</section>
<p className="mt-10 text-sm text-[#3d5554]">
Questions?{" "}
<Link
href="/contact"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</article>
</main>
);
}

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>
);
}

View File

@@ -0,0 +1,78 @@
import fs from "fs";
import matter from "gray-matter";
import path from "path";
export type FaqItem = {
question: string;
answer: string;
};
export type FaqSection = {
title: string;
subtitle: string;
order: number;
items: FaqItem[];
};
function getFaqContentDir(): string {
const cwd = process.cwd();
const direct = path.join(cwd, "content", "faq");
if (fs.existsSync(direct)) return direct;
const fromStorefront = path.join(cwd, "apps", "storefront", "content", "faq");
if (fs.existsSync(fromStorefront)) return fromStorefront;
return direct;
}
/**
* Parses FAQ markdown body: each "### Question" heading starts a new Q&A;
* the following lines (until the next ### or EOF) are the answer.
*/
function parseFaqBody(body: string): FaqItem[] {
const trimmed = body.trim();
if (!trimmed) return [];
const blocks = trimmed.split(/\n(?=### )/);
const items: FaqItem[] = [];
for (const block of blocks) {
const firstNewline = block.indexOf("\n");
const firstLine = firstNewline === -1 ? block : block.slice(0, firstNewline);
const rest = firstNewline === -1 ? "" : block.slice(firstNewline + 1).trim();
const question = firstLine.replace(/^###\s*/, "").trim();
if (question) {
items.push({ question, answer: rest });
}
}
return items;
}
/**
* Reads and parses all FAQ section markdown files. Server-only; use in Server Components.
* Returns sections sorted by frontmatter `order`.
*/
export function getFaqSections(): FaqSection[] {
const contentDir = getFaqContentDir();
if (!fs.existsSync(contentDir)) return [];
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".md"));
const sections: FaqSection[] = [];
for (const file of files) {
const filePath = path.join(contentDir, file);
try {
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
const title = (data.title as string) ?? path.basename(file, ".md");
const subtitle = (data.subtitle as string) ?? "";
const order = typeof data.order === "number" ? data.order : 999;
const items = parseFaqBody(content);
sections.push({ title, subtitle, order, items });
} catch {
// skip invalid files
}
}
sections.sort((a, b) => a.order - b.order);
return sections;
}

View File

@@ -0,0 +1,58 @@
import fs from "fs";
import matter from "gray-matter";
import path from "path";
export const LEGAL_SLUGS = [
"return-and-refund-policy",
"terms-of-service",
"privacy-policy",
"data-protection",
"general-terms-and-conditions",
] as const;
export type LegalSlug = (typeof LEGAL_SLUGS)[number];
export type LegalDocData = {
title?: string;
description?: string;
lastUpdated?: string;
};
export type LegalDoc = {
data: LegalDocData;
content: string;
};
function getContentDir(): string {
const cwd = process.cwd();
const direct = path.join(cwd, "content", "legal");
if (fs.existsSync(direct)) return direct;
const fromRoot = path.join(cwd, "apps", "storefront", "content", "legal");
if (fs.existsSync(fromRoot)) return fromRoot;
return direct;
}
/**
* Reads and parses a legal markdown file. Server-only; use in Server Components.
* Returns null if slug is invalid or file is missing (call notFound() in the page).
*/
export function getLegalDoc(slug: string): LegalDoc | null {
if (!LEGAL_SLUGS.includes(slug as LegalSlug)) return null;
const contentDir = getContentDir();
const filePath = path.join(contentDir, `${slug}.md`);
if (!fs.existsSync(filePath)) return null;
try {
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
data: {
title: data.title as string | undefined,
description: data.description as string | undefined,
lastUpdated: data.lastUpdated as string | undefined,
},
content: content.trim(),
};
} catch {
return null;
}
}