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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user