283 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|