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:
@@ -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 />
|
||||
|
||||
15
apps/storefront/src/app/legal/data-protection/page.tsx
Normal file
15
apps/storefront/src/app/legal/data-protection/page.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
15
apps/storefront/src/app/legal/privacy-policy/page.tsx
Normal file
15
apps/storefront/src/app/legal/privacy-policy/page.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
15
apps/storefront/src/app/legal/terms-of-service/page.tsx
Normal file
15
apps/storefront/src/app/legal/terms-of-service/page.tsx
Normal 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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
152
apps/storefront/src/app/support/contact-us/page.tsx
Normal file
152
apps/storefront/src/app/support/contact-us/page.tsx
Normal 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'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 & 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'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>
|
||||
);
|
||||
}
|
||||
44
apps/storefront/src/app/support/faqs/page.tsx
Normal file
44
apps/storefront/src/app/support/faqs/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
72
apps/storefront/src/app/support/payment-security/page.tsx
Normal file
72
apps/storefront/src/app/support/payment-security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/storefront/src/app/support/returns/page.tsx
Normal file
131
apps/storefront/src/app/support/returns/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/storefront/src/app/support/shipping/page.tsx
Normal file
71
apps/storefront/src/app/support/shipping/page.tsx
Normal 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 3–5 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>
|
||||
);
|
||||
}
|
||||
230
apps/storefront/src/components/contact/ContactForm.tsx
Normal file
230
apps/storefront/src/components/contact/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/storefront/src/components/layout/AnnouncementBar.tsx
Normal file
55
apps/storefront/src/components/layout/AnnouncementBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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'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() {
|
||||
© {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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
98
apps/storefront/src/components/legal/LegalDocPage.tsx
Normal file
98
apps/storefront/src/components/legal/LegalDocPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
132
apps/storefront/src/components/support/FaqPageView.tsx
Normal file
132
apps/storefront/src/components/support/FaqPageView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/storefront/src/lib/faq/getFaqSections.ts
Normal file
78
apps/storefront/src/lib/faq/getFaqSections.ts
Normal 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;
|
||||
}
|
||||
58
apps/storefront/src/lib/legal/getLegalDoc.ts
Normal file
58
apps/storefront/src/lib/legal/getLegalDoc.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user