AbiirSkincare/Hydrogen/web/app/components/AbiirStorePages.tsx

685 lines
25 KiB
TypeScript

import {useState} from 'react';
import {useFetcher, Link} from 'react-router';
import {
ChevronDown,
Clock,
Instagram,
Mail,
MessageCircle,
Package,
RotateCcw,
Truck,
Youtube,
X,
} from 'lucide-react';
import type {
AbiirContactPageCms,
AbiirContactReachRowCms,
AbiirLegalPageCms,
AbiirFaqPageCms,
AbiirInfoPageCms,
AbiirPressItemCms,
AbiirPressPageCms,
AbiirShippingPageCms,
} from '~/lib/abiirStaticPages';
import {
abiirFormErrorTextClass,
abiirFormPrimaryButtonClass,
abiirFormPrimaryButtonStyle,
abiirFormStackClass,
contactInputClass,
contactLabelClass,
} from '~/lib/abiirFormTheme';
const accent = '#c17d5c';
const burgundy = '#5d0b24';
/**
* Static pages: center-aligned hero band + full-width main column (body copy stays left in children).
*/
export function Shell({
title,
eyebrow = 'Help',
subtitle,
eyebrowTone = 'default',
children,
}: {
title: string;
/** Upper label above the title — matches collection “eyebrow” (e.g. Our Collection) */
eyebrow?: string;
/** Figma: Raleway 16 / #5a5550 (FAQ band) */
subtitle?: string;
/** `accent` = Support label (#c17d5c, Jost); `default` = Help band (burgundy Raleway) */
eyebrowTone?: 'default' | 'accent';
children: React.ReactNode;
}) {
const eyebrowClass =
eyebrowTone === 'accent'
? "m-0 font-['Jost',sans-serif] text-[11px] font-medium uppercase not-italic leading-[1.1] tracking-[2.76px] text-[#c17d5c]"
: "m-0 font-['Raleway',sans-serif] text-[0.75rem] font-medium uppercase not-italic leading-[1.1] tracking-[0.32em] text-[#5d0b24]";
return (
<div className="w-full min-h-screen bg-[#f8f4ef]">
<header className="w-full border-b border-[rgba(26,26,24,0.08)] bg-[#ede8e0] pt-[40px] pb-10 sm:pt-[48px] sm:pb-12">
<div className="mx-auto flex w-full max-w-[1710px] flex-col items-center px-6 text-center sm:px-10 lg:px-16 2xl:px-[206px]">
<p className={eyebrowClass}>{eyebrow}</p>
<h1 className="m-0 mt-3 w-full text-center font-['Cormorant_Garamond',serif] text-[2.5rem] font-light not-italic leading-[1.08] tracking-[-0.02em] text-[#1a1a18] sm:text-[3rem] lg:text-[4.25rem] lg:leading-[1.02]">
{title}
</h1>
{subtitle ? (
<p className="m-0 mt-4 w-full max-w-[800px] self-center text-balance text-center font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.5] text-[#5a5550]">
{subtitle}
</p>
) : null}
</div>
</header>
<div className="mx-auto w-full max-w-[1710px] px-6 pb-20 pt-10 sm:px-10 sm:pt-12 lg:px-16 lg:pt-[42.5px] 2xl:px-[206px]">
{children}
</div>
</div>
);
}
export function AbiirFaqPageView({cms}: {cms: AbiirFaqPageCms}) {
const [openId, setOpenId] = useState<string | null>(null);
return (
<Shell
eyebrow="Support"
eyebrowTone="accent"
subtitle={cms.subtitle}
title={cms.title}
>
<div className="mx-auto flex w-full max-w-[800px] flex-col text-left">
{cms.sections.map((section, sIdx) => (
<section className={sIdx > 0 ? 'mt-[51px]' : undefined} key={sIdx}>
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.75rem] not-italic leading-tight text-[#1a1a18] [font-weight:350]">
{section.sectionTitle}
</h3>
<div className="mt-[25.5px] space-y-0 border-t border-[rgba(26,26,24,0.08)]">
{section.items.map((item, iIdx) => {
const id = `${sIdx}-${iIdx}`;
const isOpen = openId === id;
return (
<div
className="border-b border-[rgba(26,26,24,0.08)]"
key={id}
>
<button
aria-expanded={isOpen}
className="flex min-h-[64px] w-full cursor-pointer items-center justify-between gap-4 border-0 bg-transparent py-0 text-left"
onClick={() =>
setOpenId((p) => (p === id ? null : id))
}
type="button"
>
<span className="flex-1 py-5 pr-2 font-['Jost',sans-serif] text-[1.125rem] font-normal not-italic leading-[1.35] text-[#1a1a18]">
{item.question}
</span>
<ChevronDown
aria-hidden
className={`h-[15px] w-[15px] shrink-0 text-[#1a1a18] transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
strokeWidth={1.75}
/>
</button>
{isOpen ? (
<div className="pb-6 pt-0">
<p className="m-0 w-full max-w-full whitespace-pre-wrap font-['Raleway',sans-serif] text-[1rem] font-medium not-italic leading-[1.7] text-[#5a5550]">
{item.answer}
</p>
</div>
) : null}
</div>
);
})}
</div>
</section>
))}
</div>
</Shell>
);
}
const returnPolicyIcons = [RotateCcw, Package, Clock] as const;
const ordersShippingIcons = [Truck, Package, Clock] as const;
export function AbiirShippingPageView({cms}: {cms: AbiirShippingPageCms}) {
return (
<Shell
eyebrow="Help"
subtitle={cms.intro || undefined}
title={cms.title}
>
<div className="mx-auto w-full max-w-[800px] text-left">
<div className="flex flex-col gap-[25.5px] pt-0.5 sm:pt-2">
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.9rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.9125rem]">
Orders & shipping
</h3>
<div className="flex flex-col gap-[25.5px]">
{cms.shippingOptions.map((row, i) => {
const Icon = ordersShippingIcons[i % ordersShippingIcons.length];
return (
<div
className="flex gap-4 bg-[#ede8e0] p-[25.5px] sm:gap-5"
key={`${row.title}-${i}`}
>
<div className="shrink-0 pt-0.5">
<Icon
aria-hidden
className="h-5 w-5 text-[#c17d5c]"
strokeWidth={1.5}
/>
</div>
<div className="abiir-shipping-qa-stack min-w-0 flex-1">
<h3 className="abiir-shipping-qa-title font-['Jost',sans-serif] text-[0.83rem] font-medium uppercase not-italic leading-tight tracking-[0.1em] text-[#1a1a18]">
{row.title}
</h3>
<div className="abiir-shipping-qa-subtitles">
<p className="font-['Jost',sans-serif] text-[0.765rem] font-medium not-italic leading-[1.4] text-[#c17d5c]">
{row.leadTime}
</p>
<p className="font-['Jost',sans-serif] text-[0.765rem] font-normal not-italic leading-[1.4] text-[#1a1a18]">
{row.priceLine}
</p>
</div>
<p className="abiir-shipping-qa-body whitespace-pre-line font-['Jost',sans-serif] text-[0.88rem] font-light not-italic leading-[1.75] text-[#7a7068]">
<LegalMailAndShippingLinks text={row.detail} />
</p>
</div>
</div>
);
})}
</div>
</div>
<div className="mt-[68px] flex flex-col gap-[25.5px]">
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.9rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.9125rem]">
Returns & refunds
</h3>
<div className="flex flex-col gap-[25.5px]">
{cms.returnsPolicy.map((row, i) => {
const Icon = returnPolicyIcons[i % returnPolicyIcons.length];
return (
<div
className="flex gap-4 bg-[#ede8e0] p-[25.5px] sm:gap-5"
key={`${row.title}-${i}`}
>
<div className="shrink-0 pt-0.5">
<Icon
aria-hidden
className="h-5 w-5 text-[#c17d5c]"
strokeWidth={1.5}
/>
</div>
<div className="abiir-shipping-qa-stack min-w-0 flex-1">
<h3 className="abiir-shipping-qa-title font-['Jost',sans-serif] text-[0.83rem] font-medium uppercase not-italic leading-tight tracking-[0.1em] text-[#1a1a18]">
{row.title}
</h3>
<p className="abiir-shipping-qa-body whitespace-pre-line font-['Jost',sans-serif] text-[0.88rem] font-light not-italic leading-[1.75] text-[#7a7068]">
<LegalMailAndShippingLinks text={row.body} />
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="abiir-shipping-support-banner mt-[68px] -mx-6 bg-[#1a1a18] px-6 py-8 text-center sm:-mx-10 sm:px-10 sm:py-[34px] lg:-mx-16 lg:px-16 2xl:-mx-[206px] 2xl:px-[206px]">
<p className="m-0 font-['Cormorant_Garamond',serif] text-[1.17rem] font-light italic leading-snug text-[#f8f4ef]">
{cms.bannerHeadline}
</p>
<p className="m-0 mt-2 font-['Jost',sans-serif] text-[0.8rem] font-light not-italic leading-[1.8] text-[rgba(248,244,239,0.6)]">
<LegalMailAndShippingLinks
emailLinkClassName="abiir-shipping-support-banner-email transition-opacity hover:opacity-85"
text={`${cms.bannerTextBeforeEmail}${cms.supportEmail}${cms.bannerTextAfterEmail}`}
/>
</p>
</div>
</Shell>
);
}
export function AbiirInfoTextPageView({cms}: {cms: AbiirInfoPageCms}) {
return (
<Shell eyebrow="Help" title={cms.title}>
{cms.intro ? (
<p className="m-0 mb-8 w-full text-left font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.4] text-[#5a5550]">
{cms.intro}
</p>
) : null}
<div className="w-full space-y-6 text-left">
{cms.paragraphs.map((p, i) => (
<p
className="m-0 w-full max-w-full font-['Raleway',sans-serif] text-[1rem] font-medium leading-[1.8] text-[#5a5550]"
key={i}
>
{p}
</p>
))}
</div>
</Shell>
);
}
const EMAIL_IN_TEXT = /([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})/gi;
const SHIPPING_LINK_PHRASE = 'Shipping & Returns page';
function LegalMailAndShippingLinks({
text,
emailLinkClassName = 'text-[#c17d5c] no-underline transition-opacity hover:opacity-90',
}: {
text: string;
emailLinkClassName?: string;
}) {
if (text.includes(SHIPPING_LINK_PHRASE)) {
const [a, b] = text.split(SHIPPING_LINK_PHRASE);
return (
<>
{LegalMailAndShippingLinks({text: a || '', emailLinkClassName})}
<Link
className="text-[#c17d5c] no-underline transition-opacity hover:opacity-90"
to="/shipping"
>
{SHIPPING_LINK_PHRASE}
</Link>
{LegalMailAndShippingLinks({text: b || '', emailLinkClassName})}
</>
);
}
const parts = text.split(EMAIL_IN_TEXT);
return (
<>
{parts.map((part, i) =>
i % 2 === 1 ? (
<a
className={emailLinkClassName}
href={`mailto:${part}`}
key={`e-${i}-${part}`}
>
{part}
</a>
) : (
<span key={`t-${i}`}>{part}</span>
),
)}
</>
);
}
function LegalParagraphBlock({text}: {text: string}) {
if (text.includes('\n')) {
return (
<p className="m-0 whitespace-pre-line">
<LegalMailAndShippingLinks text={text} />
</p>
);
}
return (
<p className="m-0">
<LegalMailAndShippingLinks text={text} />
</p>
);
}
export function AbiirLegalPageView({cms}: {cms: AbiirLegalPageCms}) {
return (
<Shell
eyebrow={cms.heroEyebrow}
eyebrowTone="accent"
subtitle={cms.lastUpdatedLine}
title={cms.title}
>
<div className="mx-auto flex w-full max-w-[800px] flex-col gap-[42.5px] text-left">
<p className="m-0 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068]">
<LegalMailAndShippingLinks text={cms.lead} />
</p>
<div className="flex w-full flex-col gap-10 sm:gap-[40px]">
{cms.sections.map((section, sIdx) => (
<section className="w-full" key={`${sIdx}-${section.title}`}>
<h3 className="m-0 [font-weight:350] font-['Cormorant_Garamond',serif] text-[1.75rem] not-italic leading-tight tracking-[-0.02em] text-[#1a1a18]">
{section.title}
</h3>
<div className="mt-4 space-y-3.5 sm:mt-[17px] sm:space-y-[12.75px]">
{section.paragraphs.map((p, pIdx) => (
<div
className="font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068]"
key={`${sIdx}-p-${pIdx}`}
>
<LegalParagraphBlock text={p} />
</div>
))}
{section.listItems?.length ? (
<ul className="m-0 list-outside list-disc space-y-2.5 pl-5 font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068] marker:text-[#7a7068]">
{section.listItems.map((item) => (
<li className="pl-1" key={item}>
<LegalMailAndShippingLinks text={item} />
</li>
))}
</ul>
) : null}
{section.trailingParagraphs?.map((p, tpIdx) => (
<div
className="font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.48] text-[#7a7068]"
key={`${sIdx}-t-${tpIdx}`}
>
<LegalParagraphBlock text={p} />
</div>
))}
</div>
</section>
))}
</div>
</div>
</Shell>
);
}
type ContactViewProps = {cms: AbiirContactPageCms};
function contactReachRowIcon(
row: AbiirContactReachRowCms,
) {
const c = 'h-4 w-4 text-[#c17d5c]';
if (row.icon === 'message') {
return <MessageCircle aria-hidden className={c} strokeWidth={1.5} />;
}
if (row.icon === 'clock') {
return <Clock aria-hidden className={c} strokeWidth={1.5} />;
}
return <Mail aria-hidden className={c} strokeWidth={1.5} />;
}
function socialIcon(network: 'instagram' | 'youtube') {
const c = 'h-[15px] w-[15px] shrink-0 text-[#1a1a18]';
return network === 'youtube' ? (
<Youtube aria-hidden className={c} strokeWidth={1.4} />
) : (
<Instagram aria-hidden className={c} strokeWidth={1.4} />
);
}
function ContactFormBlock({cms}: ContactViewProps) {
const fetcher = useFetcher<{ok?: boolean; error?: string}>();
const err = fetcher.data && !fetcher.data.ok;
const p = cms.formPlaceholders;
return (
<div className="flex w-full min-w-0 flex-col text-left">
{fetcher.data?.ok ? (
<p className="m-0 text-center font-['Raleway',sans-serif] text-[0.95rem] leading-[1.6] text-[#5a5550] sm:text-left">
{cms.formSuccessText}
</p>
) : (
<fetcher.Form
className={abiirFormStackClass}
method="post"
action="/api/contact"
>
{cms.formHeadline ? (
<h3 className="m-0 font-['Raleway',sans-serif] text-[0.8rem] font-semibold uppercase tracking-[0.1em] text-[#1a1a18]">
{cms.formHeadline}
</h3>
) : null}
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-name">
<span className={contactLabelClass}>Your name</span>
<input
className={contactInputClass}
id="contact-name"
name="name"
placeholder={p.name}
required
type="text"
/>
</label>
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-email">
<span className={contactLabelClass}>Email address</span>
<input
className={contactInputClass}
id="contact-email"
name="email"
placeholder={p.email}
required
type="email"
/>
</label>
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-subject">
<span className={contactLabelClass}>Subject</span>
<input
className={contactInputClass}
id="contact-subject"
name="subject"
placeholder={p.subject}
type="text"
/>
</label>
</div>
<div className="flex flex-col gap-2">
<label className="flex flex-col gap-2" htmlFor="contact-message">
<span className={contactLabelClass}>Message</span>
<textarea
className={`${contactInputClass} min-h-[166px] resize-y leading-[1.6]`}
id="contact-message"
name="message"
placeholder={p.message}
required
/>
</label>
</div>
{err ?
<p className={abiirFormErrorTextClass}>
Something went wrong. Please try again.
</p>
: null}
<button
className={abiirFormPrimaryButtonClass}
disabled={fetcher.state !== 'idle'}
style={abiirFormPrimaryButtonStyle}
type="submit"
>
{fetcher.state !== 'idle' ? 'Sending…' : cms.formSubmitLabel}
</button>
</fetcher.Form>
)}
</div>
);
}
export function AbiirContactPageView({cms}: ContactViewProps) {
return (
<Shell
eyebrow={cms.heroEyebrow}
eyebrowTone="accent"
subtitle={cms.intro}
title={cms.title}
>
<div className="mx-auto grid w-full max-w-[998px] grid-cols-1 items-start gap-[48px] text-left sm:gap-[68px] lg:grid-cols-2">
<div className="flex min-w-0 flex-col">
<h3 className="m-0 font-['Cormorant_Garamond',serif] text-[1.7rem] font-light not-italic leading-tight tracking-[-0.02em] text-[#1a1a18] sm:text-[1.9125rem]">
{cms.howToReachHeading}
</h3>
<div className="mt-8 flex flex-col gap-[25.5px] sm:mt-[4.25rem]">
{cms.reachRows.map((row) => {
const isEmail = row.icon === 'mail' || row.label.toLowerCase() === 'email';
return (
<div className="flex gap-[17px]" key={row.label}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center bg-[#ede8e0]">
{contactReachRowIcon(row)}
</div>
<div className="min-w-0">
<p className="m-0 font-['Jost',sans-serif] text-[0.69rem] font-normal uppercase not-italic leading-tight tracking-[0.1em] text-[#7a7068]">
{row.label}
</p>
{isEmail ? (
<a
className="mt-1.5 block font-['Cormorant_Garamond',serif] text-[1.0625rem] font-medium not-italic text-[#1a1a18] no-underline transition-opacity hover:opacity-80"
href={`mailto:${cms.email}`}
>
{row.primary}
</a>
) : (
<p className="m-0 mt-1.5 font-['Cormorant_Garamond',serif] text-[1.0625rem] font-medium not-italic text-[#1a1a18]">
{row.primary}
</p>
)}
<p className="m-0 mt-0.5 font-['Jost',sans-serif] text-[0.765rem] font-light not-italic leading-[1.45] text-[#7a7068]">
{row.note}
</p>
</div>
</div>
);
})}
</div>
<div className="mt-10 border-t border-[rgba(26,26,24,0.08)] pt-10 sm:mt-11 sm:pt-11">
<p className="m-0 font-['Jost',sans-serif] text-[10.2px] font-medium uppercase not-italic leading-tight tracking-[0.2em] text-[#7a7068]">
{cms.followUsLabel}
</p>
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-[17px]">
{cms.socialLinks.map((s) => (
<a
className="inline-flex items-center gap-2.5 text-[#1a1a18] no-underline transition-opacity hover:opacity-75"
href={s.href}
key={s.label + s.network}
rel="noreferrer"
target="_blank"
>
{socialIcon(s.network)}
<span className="font-['Jost',sans-serif] text-[0.765rem] font-normal not-italic">
{s.label}
</span>
</a>
))}
</div>
</div>
</div>
{cms.showContactForm ? <ContactFormBlock cms={cms} /> : null}
</div>
</Shell>
);
}
function PressCard({
item,
onOpen,
}: {
item: AbiirPressItemCms;
onOpen: () => void;
}) {
return (
<button
className="group flex w-full cursor-pointer flex-col overflow-hidden border border-[rgba(26,26,24,0.08)] bg-white/50 text-left transition-opacity hover:opacity-92"
onClick={onOpen}
type="button"
>
<div
className="relative w-full"
style={{aspectRatio: '4/3', background: 'rgba(26,26,24,0.04)'}}
>
{item.image?.url ? (
<img
alt={item.image.alt || item.title}
className="h-full w-full object-cover"
height={item.image.height}
src={item.image.url}
width={item.image.width}
/>
) : (
<div
className="flex h-full w-full items-center justify-center font-['Cormorant_Garamond',serif] text-[2rem] font-light"
style={{color: accent}}
>
{item.title.charAt(0)}
</div>
)}
</div>
<div className="p-4">
<p
className="m-0 mb-1 font-['Jost',sans-serif] text-[0.7rem] uppercase tracking-wide"
style={{color: accent}}
>
{item.listCaption}
</p>
<p className="m-0 font-['Raleway',sans-serif] text-[0.95rem] font-semibold text-[#1a1a18] group-hover:underline">
{item.title}
</p>
</div>
</button>
);
}
export function AbiirPressPageView({cms}: {cms: AbiirPressPageCms}) {
const [open, setOpen] = useState<AbiirPressItemCms | null>(null);
return (
<Shell eyebrow="Press" title={cms.title}>
{cms.intro ? (
<p className="m-0 mb-10 w-full text-left font-['Raleway',sans-serif] text-base font-medium not-italic leading-[1.4] text-[#5a5550]">
{cms.intro}
</p>
) : null}
<div className="grid w-full grid-cols-1 gap-6 text-left sm:grid-cols-2 lg:grid-cols-3">
{cms.items.map((item, i) => (
<PressCard
item={item}
key={i}
onOpen={() => setOpen(item)}
/>
))}
</div>
{open ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{background: 'rgba(26,26,24,0.55)'}}
onClick={() => setOpen(null)}
onKeyDown={(e) => e.key === 'Escape' && setOpen(null)}
role="dialog"
aria-modal
tabIndex={-1}
>
<div
className="relative max-h-[90vh] w-full max-w-[640px] overflow-y-auto rounded-sm bg-white p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<button
aria-label="Close"
className="absolute right-3 top-3 border-none bg-transparent p-1 text-[#7a7068] hover:opacity-70"
onClick={() => setOpen(null)}
type="button"
>
<X size={22} />
</button>
{open.image?.url ? (
<div
className="relative -mx-2 mb-4"
style={{aspectRatio: '16/9'}}
>
<img
alt={open.image.alt || open.title}
className="h-full w-full object-cover"
src={open.image.url}
/>
</div>
) : null}
<h2 className="m-0 mb-2 pr-8 font-['Cormorant_Garamond',serif] text-[1.6rem] font-light text-[#1a1a18]">
{open.title}
</h2>
{open.listCaption ? (
<p className="m-0 mb-3 font-['Jost',sans-serif] text-[0.75rem] uppercase tracking-wide" style={{color: accent}}>
{open.listCaption}
</p>
) : null}
<p className="m-0 whitespace-pre-wrap font-['Raleway',sans-serif] text-[0.95rem] font-medium leading-[1.75] text-[#5a5550]">
{open.modalDescription}
</p>
</div>
</div>
) : null}
</Shell>
);
}