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

283 lines
10 KiB
TypeScript

import {useEffect, useState, type FormEvent} from 'react';
import {useFetcher} from 'react-router';
import {motion} from 'motion/react';
import {ArrowRight, Sparkles} from 'lucide-react';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
import type {NewsletterSectionCms} from '~/lib/newsletter';
import {MOCK_NEWSLETTER_SECTION_CMS} from '~/lib/newsletter';
/**
* GROQ: `home` singleton → Glow Club newsletter.
* Use with `context.sanity.loadQuery(NEWSLETTER_SECTION_GROQ)` or `loadNewsletterFromSanity`.
*
* Reference has no `<img>`; decorative orbs are CSS-only (no Shopify `Image` needed).
*/
export const NEWSLETTER_SECTION_GROQ = `*[_type == "home" && _id == "home"][0].newsletterSection{
eyebrow,
headlinePlain,
headlineItalic,
body,
emailPlaceholder,
submitButtonLabel,
successTitle,
successSubtitle,
perks
}` as const;
export {MOCK_NEWSLETTER_SECTION_CMS};
export type {NewsletterSectionCms};
/**
* Design tokens match `React/src/app/components/Newsletter.tsx` (pixel-aligned Tailwind).
*/
const BURGUNDY = '#5d0b24';
const CREAM = '#faf8f3';
const CREAM_SOFT = '#f8f4ef';
const GOLD = '#b39763';
const CHARCOAL = '#1a1a1a';
const NEWSLETTER_SUBSCRIBE_ACTION = '/api/newsletter-subscribe';
type SubscribeResponse =
| {ok: true; alreadyExists?: boolean}
| {ok: false; error: string};
function subscribeErrorMessage(code: string): string {
switch (code) {
case 'invalid_email':
return 'Please enter a valid email address.';
case 'not_configured':
return 'Newsletter signup is temporarily unavailable.';
case 'service_unavailable':
case 'shopify_error':
default:
return 'Something went wrong. Please try again in a moment.';
}
}
export type NewsletterProps = {
cms: NewsletterSectionCms;
};
export function Newsletter({cms}: NewsletterProps) {
const fetcher = useFetcher<SubscribeResponse>();
const [email, setEmail] = useState('');
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (fetcher.state !== 'idle' || !fetcher.data) return;
if (fetcher.data.ok) {
setSubmitted(true);
}
}, [fetcher.state, fetcher.data]);
function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = email.trim();
if (!trimmed) return;
fetcher.submit({email: trimmed}, {method: 'POST', action: NEWSLETTER_SUBSCRIBE_ACTION});
}
const submitError =
fetcher.state === 'idle' &&
fetcher.data &&
!fetcher.data.ok &&
'error' in fetcher.data ?
subscribeErrorMessage(fetcher.data.error)
: null;
const isSubmitting = fetcher.state !== 'idle';
return (
<section
className="relative w-full overflow-hidden"
style={{background: BURGUNDY}}
>
<div
aria-hidden
className="pointer-events-none absolute rounded-full bg-[#b39763] opacity-[0.05]"
style={{
width: 400,
height: 400,
left: -85,
top: -85,
}}
/>
<div
aria-hidden
className="pointer-events-none absolute rounded-full bg-[#faf8f3] opacity-[0.05]"
style={{
width: 350,
height: 350,
right: -85,
bottom: -85,
}}
/>
<div className="relative z-10 mx-auto max-w-[1298px] px-6 pb-16 pt-16 sm:px-8 sm:pb-20 sm:pt-20 lg:pb-[119px] lg:pt-[119px]">
<motion.div
initial={false}
transition={{duration: 0.65}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
className="flex flex-col items-center gap-8 text-center lg:gap-[30px]"
>
<div className="flex shrink-0 items-center gap-4">
<div
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center"
style={{
background: 'rgba(184, 160, 66, 0.15)',
color: GOLD,
}}
>
<Sparkles aria-hidden size={20} strokeWidth={1.2} />
</div>
<p
className="m-0 font-['Raleway',sans-serif] text-[12px] font-semibold uppercase not-italic leading-[18.36px] tracking-[3.4272px]"
style={{color: GOLD}}
>
{cms.eyebrow}
</p>
</div>
{/*
Headline + body + form share one grid column. The form uses min-width in vw so the column
can grow up to 649px even when the headline is narrower (100% alone would stay headline-wide).
*/}
<div className="mx-auto inline-grid w-max min-w-0 max-w-full grid-cols-1 justify-items-stretch gap-8 text-center lg:gap-[30px]">
<h2
className={`m-0 lg:whitespace-nowrap ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
style={{color: CREAM}}
>
{cms.headlinePlain}{' '}
<span
className="font-['Cormorant_Garamond',serif] font-light italic"
style={{fontWeight: 300, color: GOLD}}
>
{cms.headlineItalic}
</span>
</h2>
<p
className="m-0 w-full max-w-[623px] justify-self-center font-['Raleway',sans-serif] text-[16px] font-medium not-italic leading-[26.18px]"
style={{color: CREAM_SOFT}}
>
{cms.body}
</p>
{!submitted ? (
<form
className="newsletter-form flex w-full min-w-0 flex-col flex-wrap justify-self-center gap-0 overflow-visible sm:min-h-[61px] sm:flex-row sm:items-start"
onSubmit={handleSubmit}
style={{
maxWidth: 649,
width: '100%',
// Inline so layout never depends on Tailwind arbitrary min() generation
minWidth: 'min(calc(100vw - 3rem), 649px)',
}}
>
<input
required
className="newsletter-email box-border w-full min-w-0 flex-1 border border-solid border-[rgba(248,244,239,0.12)] outline-none placeholder:text-[rgba(248,244,239,0.5)] max-sm:border-b-0 sm:border-r-0 sm:py-0"
onChange={(e) => setEmail(e.target.value)}
placeholder={cms.emailPlaceholder}
style={{
padding: '17px 21.25px',
background: 'rgba(248, 244, 239, 0.07)',
color: CREAM_SOFT,
fontFamily: "'Raleway', sans-serif",
fontSize: '15.64px',
fontWeight: 300,
height: 61,
minHeight: 61,
boxSizing: 'border-box',
}}
type="email"
value={email}
/>
<button
className="newsletter-submit group relative box-border flex w-full shrink-0 cursor-pointer items-center justify-center gap-2 border border-solid border-[rgba(248,244,239,0.12)] transition-opacity duration-200 hover:opacity-[0.92] max-sm:border-t-0 sm:w-[172px] sm:border-l-0 sm:py-0 disabled:cursor-not-allowed disabled:opacity-60"
style={{
background: GOLD,
color: CHARCOAL,
fontFamily: "'Raleway', sans-serif",
fontSize: '12.75px',
fontWeight: 500,
letterSpacing: '2.295px',
textTransform: 'uppercase',
height: 59,
minHeight: 59,
marginTop: 5,
boxSizing: 'border-box',
}}
disabled={isSubmitting}
type="submit"
>
{isSubmitting ? '…' : cms.submitButtonLabel}
<ArrowRight
aria-hidden
className="transition-transform group-hover:translate-x-0.5"
size={13}
strokeWidth={2}
/>
</button>
{submitError ?
<p
className="m-0 w-full basis-full text-left font-['Raleway',sans-serif] text-[0.85rem] font-medium"
role="alert"
style={{color: '#f0c4c4'}}
>
{submitError}
</p>
: null}
</form>
) : (
<motion.div
animate={{opacity: 1, scale: 1}}
className="box-border w-full px-8 py-6"
initial={false}
style={{
background: 'rgba(184, 160, 66, 0.15)',
border: '1px solid rgba(179, 151, 99, 0.35)',
}}
>
<p
className="m-0 font-['Cormorant_Garamond',serif] text-[1.4rem] font-normal not-italic"
style={{
fontStyle: 'italic',
fontWeight: 400,
color: CREAM,
}}
>
{cms.successTitle} <span aria-hidden>✨</span>
</p>
<p
className="m-0 mt-1 font-['Raleway',sans-serif] text-[0.85rem] font-medium not-italic"
style={{color: GOLD}}
>
{cms.successSubtitle}
</p>
</motion.div>
)}
</div>
<div
className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-2 font-['Raleway',sans-serif] text-[12.24px] font-light not-italic leading-[18.36px] tracking-[0.7344px] sm:gap-x-8"
>
{cms.perks.map((perk, i) => (
<span
key={`${perk}-${i}`}
className="inline-flex items-center gap-1 whitespace-nowrap"
>
<span aria-hidden style={{color: GOLD}}>
·
</span>
<span style={{color: CREAM}}>{perk}</span>
</span>
))}
</div>
</motion.div>
</div>
</section>
);
}