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

364 lines
13 KiB
TypeScript

import {Image} from '@shopify/hydrogen';
import {Link} from 'react-router';
import {motion} from 'motion/react';
import {ArrowLeft, Clock, Tag} from 'lucide-react';
import {MOCK_ABIIR_JOURNAL_POSTS} from '~/lib/journal';
import type {AbiirJournalPostCms} from '~/lib/journal';
import {abiirJournalImageIsRemote} from '~/lib/journal';
import {PAGE_CONTENT_MAX_WIDTH_CLASS} from '~/lib/pageTitle';
import {PAGE_TITLE_TYPOGRAPHY_CLASS} from '~/lib/pageTitle';
// ─── Sanity: GROQ projection (single `journalPost` document) ─────────────────
/**
* GROQ field projection for one journal article. Reused by `buildJournalPostGroq`.
* Sanity schema: `journalPost` in `Hydrogen/studio` (title, slug, content, image, etc.).
*/
export const JOURNAL_POST_GROQ_PROJECTION = /* groq */ `{
"slug": slug.current,
title,
subtitle,
excerpt,
cardExcerpt,
content,
author,
authorRole,
date,
readTime,
category,
featured,
tags,
image{
alt,
asset->{
_id,
url,
metadata{dimensions{width,height}}
}
}
}` as const;
/**
* Builds a **parameterized** GROQ query for a single post by `slug` (JSON-stringified, safe for GROQ).
* Use in the route `loader` with `context.sanity.loadQuery(buildJournalPostGroq(params.slug))`.
*/
export function buildJournalPostGroq(slug: string): string {
const safe = JSON.stringify(slug);
return `*[_type == "journalPost" && slug.current == ${safe}][0]${JOURNAL_POST_GROQ_PROJECTION}`;
}
/**
* Static **example** query (illustrates shape only). Prefer `buildJournalPostGroq` in production.
*/
export const JOURNAL_POST_PAGE_GROQ_EXAMPLE = `*[_type == "journalPost" && slug.current == "ancient-art-ayurvedic-skincare"][0]${JOURNAL_POST_GROQ_PROJECTION}` as const;
// ─── Mock: visual / Storybook (matches `React` blog post shape) ─────────────
function buildMockRelated(post: AbiirJournalPostCms): AbiirJournalPostCms[] {
return MOCK_ABIIR_JOURNAL_POSTS.filter(
(p) => p.id !== post.id && p.category === post.category,
).slice(0, 3);
}
/** `JournalPostPage` with first seed post + related — for local preview or design QA. */
export const MOCK_JOURNAL_POST_PAGE_PROPS: {
post: AbiirJournalPostCms;
relatedPosts: AbiirJournalPostCms[];
} = {
post: MOCK_ABIIR_JOURNAL_POSTS[0]!,
relatedPosts: buildMockRelated(MOCK_ABIIR_JOURNAL_POSTS[0]!),
};
// ─── Images: Shopify Hydrogen <Image /> (local Vite + Sanity CDN) ───────────
function HeroImage({url, alt}: {url: string; alt: string}) {
if (abiirJournalImageIsRemote(url)) {
return (
<Image
alt={alt}
className="h-full w-full object-cover brightness-[0.85]"
data={{
url,
altText: alt,
width: 1920,
height: 1080,
}}
height="auto"
loader={({src}) => src ?? ''}
sizes="100vw"
width="100%"
/>
);
}
return (
<Image
alt={alt}
className="h-full w-full object-cover brightness-[0.85]"
height="auto"
sizes="100vw"
src={url}
width="100%"
/>
);
}
function RelatedThumb({url, alt}: {url: string; alt: string}) {
const imgClass =
'block h-full w-full min-h-0 object-cover transition-transform duration-500 group-hover:scale-105';
if (abiirJournalImageIsRemote(url)) {
return (
<Image
alt={alt}
className={imgClass}
data={{
url,
altText: alt,
width: 1200,
height: 800,
}}
height="auto"
loader={({src}) => src ?? ''}
sizes="(min-width: 640px) 33vw, 100vw"
width="100%"
/>
);
}
return (
<Image
alt={alt}
className={imgClass}
height="auto"
sizes="(min-width: 640px) 33vw, 100vw"
src={url}
width="100%"
/>
);
}
// ─── Page: blog detail (1:1 with `React/src/app/pages/JournalPost.tsx`) ─────
export type JournalPostPageProps = {
/** Single article (from GROQ / loader) or mock. */
post: AbiirJournalPostCms;
relatedPosts: AbiirJournalPostCms[];
};
export function JournalPostPage({post, relatedPosts}: JournalPostPageProps) {
const paragraphs = post.content
.split('\n\n')
.map((p) => p.trim())
.filter(Boolean);
return (
<div className="min-h-screen bg-[#F8F4EF]">
{/* Breadcrumb — Figma: px-6 py-4, border 6% black */}
<div className="border-b border-[rgba(26,26,24,0.06)] px-[24px] py-[16px] lg:px-[48px]">
<div
className={`mx-auto flex items-center gap-[8px] ${PAGE_CONTENT_MAX_WIDTH_CLASS}`}
>
<Link
className="flex items-center gap-[6px] font-['Jost',sans-serif] text-[0.65rem] font-normal uppercase leading-none tracking-[0.1em] text-[#7A7068] no-underline transition-opacity hover:opacity-60"
to="/journal"
>
<ArrowLeft
className="shrink-0"
size={11}
strokeWidth={1.5}
/>
Journal
</Link>
<span className="select-none text-[0.65rem] leading-none text-[rgba(26,26,24,0.2)]">
/
</span>
<span className="font-['Jost',sans-serif] text-[0.65rem] font-normal uppercase leading-none tracking-[0.1em] text-[#1A1A18]">
{post.category}
</span>
</div>
</div>
{/* Hero: 55vh, min 360px (reference) */}
<div className="h-[55vh] min-h-[360px] w-full overflow-hidden">
<HeroImage
alt={post.title}
url={post.image}
/>
</div>
<div
className={`mx-auto px-[24px] py-[56px] lg:px-[48px] ${PAGE_CONTENT_MAX_WIDTH_CLASS}`}
>
<div>
{/* Category & meta — gap-4, mb-6 = 16px / 24px */}
<div className="mb-[24px] flex flex-wrap items-center gap-[16px]">
<span className="bg-[#1A1A18] px-[12px] py-[4px] font-['Jost',sans-serif] text-[0.55rem] font-medium uppercase not-italic leading-none tracking-[0.15em] text-[#F8F4EF]">
{post.category}
</span>
<div className="flex items-center gap-[6px]">
<Clock
className="shrink-0 text-[#7A7068]"
size={12}
strokeWidth={1.5}
/>
<span className="font-['Jost',sans-serif] text-[0.65rem] font-light not-italic leading-none text-[#7A7068]">
{post.readTime}
</span>
</div>
<span className="font-['Jost',sans-serif] text-[0.65rem] font-light not-italic leading-none text-[#7A7068]">
{post.date}
</span>
</div>
<motion.h1
className="m-0 mb-[12px] font-['Cormorant_Garamond',serif] text-[2.8rem] font-normal not-italic leading-[1.15] tracking-[-1.19px] text-[#1A1A18]"
initial={{opacity: 0, y: 12}}
transition={{duration: 0.55}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
{post.title}
</motion.h1>
<p className="mb-[32px] font-['Cormorant_Garamond',serif] text-[clamp(1rem,2vw,1.3rem)] font-light italic leading-normal text-[#7A7068]">
{post.subtitle}
</p>
{/* Author — border-bottom, gap-3, padding bottom 8 */}
<div className="mb-[32px] flex items-center gap-[12px] border-b border-[rgba(26,26,24,0.08)] pb-[32px]">
<div className="flex h-[40px] w-[40px] shrink-0 items-center justify-center font-['Cormorant_Garamond',serif] text-[0.9rem] not-italic leading-none text-[#C17D5C] [background-color:#EDE8E0]">
{post.author
.split(' ')
.map((n) => n[0])
.join('')}
</div>
<div>
<p className="font-['Jost',sans-serif] text-[0.75rem] font-medium not-italic leading-none text-[#1A1A18]">
{post.author}
</p>
<p className="mt-[2px] font-['Jost',sans-serif] text-[0.62rem] font-light not-italic leading-none tracking-[0.05em] text-[#7A7068]">
{post.authorRole}
</p>
</div>
</div>
{/* Content — space-y-5 = 20px; body Jost 0.92rem /1.9; Cormorant “bold” lines 1.15rem/1.5 */}
<div className="space-y-5">
{paragraphs.map((para, i) => {
const isBold = para.startsWith('**') && para.includes('**');
if (isBold) {
const boldText = para.replace(/\*\*(.*?)\*\*/g, '$1');
return (
<p
key={i}
className="font-['Cormorant_Garamond',serif] text-[1.15rem] font-medium not-italic leading-[1.5] text-[#1A1A18]"
>
{boldText}
</p>
);
}
return (
<p
key={i}
className="font-['Jost',sans-serif] text-[0.92rem] font-light not-italic leading-[1.9] text-[#4A4845]"
>
{para}
</p>
);
})}
</div>
{/* Tags — border-top, pt-10 mt-10 */}
<div className="mt-[40px] flex flex-wrap gap-[8px] border-t border-[rgba(26,26,24,0.08)] pt-[40px]">
<div className="mr-[8px] flex items-center gap-[8px]">
<Tag
className="shrink-0 text-[#7A7068]"
size={12}
strokeWidth={1.5}
/>
<span className="font-['Jost',sans-serif] text-[0.6rem] font-normal uppercase not-italic leading-none tracking-[0.12em] text-[#7A7068]">
Tags:
</span>
</div>
{post.tags.map((tag) => (
<span
key={tag}
className="border border-[rgba(26,26,24,0.12)] px-[12px] py-[4px] font-['Jost',sans-serif] text-[0.58rem] font-normal uppercase not-italic leading-none tracking-[0.1em] text-[#7A7068]"
>
{tag}
</span>
))}
</div>
</div>
</div>
{/* Continue reading — #EDE8E0, py-16 / lg:py-20, max 1400 */}
{relatedPosts.length > 0 && (
<section className="bg-[#EDE8E0] py-[64px] lg:py-[80px]">
<div className="mx-auto max-w-[1400px] px-[24px] lg:px-[48px]">
<motion.h2
className={`m-0 mb-[40px] w-full text-[#1A1A18] ${PAGE_TITLE_TYPOGRAPHY_CLASS}`}
initial={{opacity: 0, y: 12}}
transition={{duration: 0.55}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
Continue Reading
</motion.h2>
<div className="grid grid-cols-1 gap-[32px] sm:grid-cols-3">
{relatedPosts.map((relPost, i) => (
<motion.div
key={relPost.id}
className="min-w-0"
initial={{opacity: 0, y: 20}}
transition={{duration: 0.5, delay: i * 0.1}}
viewport={{once: true}}
whileInView={{opacity: 1, y: 0}}
>
<Link
className="group block no-underline"
prefetch="intent"
to={`/journal/${relPost.id}`}
>
<div className="relative mb-[16px] aspect-[4/3] w-full overflow-hidden [background-color:#F8F4EF]">
<RelatedThumb
alt={relPost.title}
url={relPost.image}
/>
</div>
<span className="font-['Jost',sans-serif] text-[0.55rem] font-medium uppercase not-italic leading-none tracking-[0.12em] text-[#C17D5C]">
{relPost.category}
</span>
<h3 className="mt-1 font-['Cormorant_Garamond',serif] text-[1.15rem] font-normal not-italic leading-[1.3] text-[#1A1A18] transition-opacity group-hover:opacity-70">
{relPost.title}
</h3>
<div className="mt-3 flex items-center gap-1">
<span className="font-['Jost',sans-serif] text-[0.62rem] font-normal uppercase not-italic leading-none tracking-[0.1em] text-[#7A7068] transition-colors group-hover:text-[#1A1A18]">
Read →
</span>
</div>
</Link>
</motion.div>
))}
</div>
</div>
</section>
)}
</div>
);
}
export function JournalPostNotFound() {
return (
<div className="flex min-h-screen items-center justify-center bg-[#F8F4EF]">
<div className="text-center">
<p className="font-['Cormorant_Garamond',serif] text-[1.5rem] not-italic text-[#7A7068]">
Article not found
</p>
<Link
className="mt-4 inline-block border-b border-[#C17D5C] font-['Jost',sans-serif] text-[0.7rem] font-normal uppercase leading-none tracking-[0.15em] text-[#C17D5C] no-underline"
to="/journal"
>
Back to Journal
</Link>
</div>
</div>
);
}