feat: add blog pages, components, and update homepage sections
This commit is contained in:
42
src/app/blog/[slug]/page.tsx
Normal file
42
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import ArticleView from '@/components/blog/ArticleView';
|
||||||
|
import { ARTICLES, getArticle, relatedTo } from '@/content/articles';
|
||||||
|
|
||||||
|
/** Every article is known at build time, so all of them prerender as static. */
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return ARTICLES.map(a => ({ slug: a.slug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamicParams = false;
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||||
|
const { slug } = await params;
|
||||||
|
const article = getArticle(slug);
|
||||||
|
if (!article) return { title: 'Article not found | Loyaly.ai' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${article.title} | Loyaly.ai`,
|
||||||
|
description: article.excerpt,
|
||||||
|
openGraph: {
|
||||||
|
title: article.title,
|
||||||
|
description: article.excerpt,
|
||||||
|
type: 'article',
|
||||||
|
images: [{ url: article.heroImage }],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: article.title,
|
||||||
|
description: article.excerpt,
|
||||||
|
images: [article.heroImage],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const article = getArticle(slug);
|
||||||
|
if (!article) notFound();
|
||||||
|
|
||||||
|
return <ArticleView article={article} related={relatedTo(slug)} />;
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Link from 'next/link';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
import { useReveal } from '@/hooks/useReveal';
|
import { useReveal } from '@/hooks/useReveal';
|
||||||
import GSAPAnimator from '@/components/GSAPAnimator';
|
import GSAPAnimator from '@/components/GSAPAnimator';
|
||||||
|
import FilterBar from '@/components/blog/FilterBar';
|
||||||
|
import FeaturedArticle from '@/components/blog/FeaturedArticle';
|
||||||
|
import ArticleCard from '@/components/blog/ArticleCard';
|
||||||
|
import SplitHeading from '@/components/blog/SplitHeading';
|
||||||
|
import { metaFor, BLOG_CANVAS } from '@/components/blog/topics';
|
||||||
|
import { ARTICLES, articleHref } from '@/content/articles';
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: number }) {
|
||||||
const { ref, visible } = useReveal();
|
const { ref, visible } = useReveal();
|
||||||
@@ -17,67 +26,6 @@ function Reveal({ children, delay = 0 }: { children: React.ReactNode; delay?: nu
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_META: Record<string, { color: string; icon: React.ReactNode }> = {
|
|
||||||
Analytics: {
|
|
||||||
color: '#f4be28',
|
|
||||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18M8 17V9m5 8V5m5 12v-6" />,
|
|
||||||
},
|
|
||||||
Strategy: {
|
|
||||||
color: '#a78bfa',
|
|
||||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />,
|
|
||||||
},
|
|
||||||
Technology: {
|
|
||||||
color: '#34d399',
|
|
||||||
icon: (
|
|
||||||
<>
|
|
||||||
<rect x="3" y="4" width="18" height="14" rx="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 21h8M12 18v3" />
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Growth: {
|
|
||||||
color: '#3b82f6',
|
|
||||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 17l6-6 4 4 8-8M21 7v6M21 7h-6" />,
|
|
||||||
},
|
|
||||||
Future: {
|
|
||||||
color: '#ec4899',
|
|
||||||
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z" />,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ARTICLES = [
|
|
||||||
{
|
|
||||||
n: '01', tag: 'Analytics', readTime: '6 min',
|
|
||||||
title: 'Why Footfall Data is the New Gold',
|
|
||||||
excerpt: "In an era where every click is tracked online, offline retailers are sitting on untapped behavioural gold, and most don't even know it.",
|
|
||||||
featured: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
n: '02', tag: 'Strategy', readTime: '5 min',
|
|
||||||
title: 'The End of Blind Discounts',
|
|
||||||
excerpt: "Discounting without data is just burning margin. Here's how AI is changing the way smart retailers think about price reductions.",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
n: '03', tag: 'Technology', readTime: '8 min',
|
|
||||||
title: 'AI in Offline Retail',
|
|
||||||
excerpt: 'From footfall heatmaps to purchase prediction, a deep look at how artificial intelligence is rewriting the rules for physical stores.',
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
n: '04', tag: 'Growth', readTime: '4 min',
|
|
||||||
title: 'How to Increase Repeat Customers',
|
|
||||||
excerpt: 'Acquiring a new customer costs 5× more than retaining one. The playbook for building genuine loyalty in offline retail.',
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
n: '05', tag: 'Future', readTime: '7 min',
|
|
||||||
title: 'The Future of Connected Commerce',
|
|
||||||
excerpt: 'Online and offline are no longer separate worlds. The retailers who win will be the ones who blur the line completely.',
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const FILTERS = ['All', 'Analytics', 'Strategy', 'Technology', 'Growth', 'Future'];
|
const FILTERS = ['All', 'Analytics', 'Strategy', 'Technology', 'Growth', 'Future'];
|
||||||
|
|
||||||
const NEWSLETTER_PERKS = [
|
const NEWSLETTER_PERKS = [
|
||||||
@@ -88,19 +36,46 @@ const NEWSLETTER_PERKS = [
|
|||||||
|
|
||||||
export default function BlogMainPage() {
|
export default function BlogMainPage() {
|
||||||
const [activeFilter, setActiveFilter] = useState('All');
|
const [activeFilter, setActiveFilter] = useState('All');
|
||||||
const [hovered, setHovered] = useState<number | null>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const filtered = activeFilter === 'All' ? ARTICLES : ARTICLES.filter(a => a.tag === activeFilter);
|
const filtered = activeFilter === 'All' ? ARTICLES : ARTICLES.filter(a => a.tag === activeFilter);
|
||||||
const featured = filtered.find(a => a.featured) || filtered[0];
|
const featured = filtered.find(a => a.featured) || filtered[0];
|
||||||
const rest = filtered.filter(a => a !== featured);
|
const rest = filtered.filter(a => a !== featured);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const c: Record<string, number> = { All: ARTICLES.length };
|
||||||
|
for (const f of FILTERS) {
|
||||||
|
if (f === 'All') continue;
|
||||||
|
c[f] = ARTICLES.filter(a => a.tag === f).length;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-run the list entrance whenever the filter changes, since the cards are
|
||||||
|
// remounted and the page-level GSAPAnimator only sweeps the DOM once.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
gsap.fromTo(Array.from(list.children),
|
||||||
|
{ y: 40, opacity: 0 },
|
||||||
|
{ y: 0, opacity: 1, duration: 0.7, ease: 'power3.out', stagger: 0.07, overwrite: true }
|
||||||
|
);
|
||||||
|
}, list);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [activeFilter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{ background: '#ffffff', minHeight: '100vh', overflowX: 'hidden' }}>
|
<main style={{ background: BLOG_CANVAS, minHeight: '100vh', overflowX: 'hidden' }}>
|
||||||
<GSAPAnimator />
|
<GSAPAnimator />
|
||||||
|
|
||||||
{/* ── HERO ── */}
|
{/* ── HERO ── */}
|
||||||
<section style={{
|
<section style={{
|
||||||
padding: '120px 8% 90px', background: 'radial-gradient(ellipse 120% 90% at 50% 0%, #2c2350 0%, #211c3e 38%, #181429 100%)',
|
// Symmetric vertical padding + flex centring: the content sits exactly
|
||||||
|
// in the middle of the hero rather than 30px above it.
|
||||||
|
padding: 'clamp(96px, 9vw, 128px) 8%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'radial-gradient(ellipse 120% 90% at 50% 0%, #2c2350 0%, #211c3e 38%, #181429 100%)',
|
||||||
margin: 'var(--hero-top) var(--sec-gutter) var(--sec-gap) var(--sec-gutter)', borderRadius: '32px',
|
margin: 'var(--hero-top) var(--sec-gutter) var(--sec-gap) var(--sec-gutter)', borderRadius: '32px',
|
||||||
position: 'relative', overflow: 'hidden',
|
position: 'relative', overflow: 'hidden',
|
||||||
}}>
|
}}>
|
||||||
@@ -113,7 +88,7 @@ export default function BlogMainPage() {
|
|||||||
<div style={{ position: 'absolute', bottom: '-16%', right: '4%', width: 420, height: 420, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.18) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
<div style={{ position: 'absolute', bottom: '-16%', right: '4%', width: 420, height: 420, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.18) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '60px 60px', pointerEvents: 'none' }} />
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '60px 60px', pointerEvents: 'none' }} />
|
||||||
|
|
||||||
<div style={{ position: 'relative', zIndex: 2, display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
|
<div style={{ position: 'relative', zIndex: 2, width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
|
||||||
<div className="bm-hero-badge" style={{
|
<div className="bm-hero-badge" style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 26,
|
display: 'inline-flex', alignItems: 'center', gap: 9, marginBottom: 26,
|
||||||
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(244,190,40,0.3)',
|
background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(244,190,40,0.3)',
|
||||||
@@ -123,14 +98,23 @@ export default function BlogMainPage() {
|
|||||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#f4be28', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>The Loyaly Journal</span>
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f4be28', fontFamily: 'Sora, sans-serif', letterSpacing: '0.1em', textTransform: 'uppercase' }}>The Loyaly Journal</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ maxWidth: 760 }}>
|
{/* Must fit the headline at its largest clamped size (~876px), or the
|
||||||
<h1 data-gsap="heading" style={{ fontSize: 'clamp(48px, 8vw, 120px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.9, color: '#fff', margin: '0 0 28px', fontFamily: 'Sora, sans-serif' }}>
|
deliberate two-line break collapses into orphaned words on wide screens. */}
|
||||||
Insights for the<br />
|
<div style={{ maxWidth: 'min(920px, 100%)' }}>
|
||||||
<span style={{
|
<h1 style={{ fontSize: 'clamp(48px, 8vw, 120px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.9, color: '#fff', margin: '0 0 28px', fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
<SplitHeading text="Insights for the" delay={0.1} />
|
||||||
|
<br />
|
||||||
|
<SplitHeading
|
||||||
|
text="Future of Retail."
|
||||||
|
delay={0.4}
|
||||||
|
gradientStyle={{
|
||||||
background: 'linear-gradient(105deg,#f4be28 0%,#fff8e0 45%,#f4be28 60%,#ff9500 100%)',
|
background: 'linear-gradient(105deg,#f4be28 0%,#fff8e0 45%,#f4be28 60%,#ff9500 100%)',
|
||||||
backgroundSize: '200% auto', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
backgroundSize: '200% auto',
|
||||||
|
WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
animation: 'bmShimmer 3s linear infinite',
|
animation: 'bmShimmer 3s linear infinite',
|
||||||
}}>Future of Retail.</span>
|
}}
|
||||||
|
/>
|
||||||
</h1>
|
</h1>
|
||||||
<p data-gsap="text" style={{ fontSize: 'clamp(16px, 1.5vw, 20px)', lineHeight: 1.75, color: 'rgba(255,255,255,0.55)', fontFamily: 'Sora, sans-serif', maxWidth: 520, margin: '0 auto 48px' }}>
|
<p data-gsap="text" style={{ fontSize: 'clamp(16px, 1.5vw, 20px)', lineHeight: 1.75, color: 'rgba(255,255,255,0.55)', fontFamily: 'Sora, sans-serif', maxWidth: 520, margin: '0 auto 48px' }}>
|
||||||
Explore ideas, trends, and strategies shaping modern retail, from our team to yours.
|
Explore ideas, trends, and strategies shaping modern retail, from our team to yours.
|
||||||
@@ -153,146 +137,82 @@ export default function BlogMainPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── FILTERS ── */}
|
{/* ── EDITORIAL BAR + FILTERS ── */}
|
||||||
<section style={{ padding: '48px 8% 0', background: '#fff' }}>
|
<section style={{ padding: '72px 8% 0' }}>
|
||||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
<div className="bm-bar" style={{
|
||||||
{FILTERS.map(f => {
|
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||||||
const meta = TAG_META[f];
|
gap: 32, paddingBottom: 26, borderBottom: '1px solid rgba(0,0,0,0.1)', marginBottom: 30,
|
||||||
const active = activeFilter === f;
|
|
||||||
return (
|
|
||||||
<button key={f} onClick={() => setActiveFilter(f)} style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '10px 20px 10px 16px', borderRadius: 100,
|
|
||||||
background: active ? '#0a0a0f' : '#fafafa',
|
|
||||||
color: active ? (meta?.color || '#f4be28') : 'rgba(0,0,0,0.45)',
|
|
||||||
fontSize: 13, fontWeight: 700, fontFamily: 'Sora, sans-serif',
|
|
||||||
border: `1.5px solid ${active ? '#0a0a0f' : 'rgba(0,0,0,0.08)'}`,
|
|
||||||
cursor: 'pointer', transition: 'all 0.25s cubic-bezier(0.22,1,0.36,1)',
|
|
||||||
}}>
|
}}>
|
||||||
{meta && (
|
<div>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={active ? meta.color : 'rgba(0,0,0,0.35)'} strokeWidth="2">{meta.icon}</svg>
|
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.35)', fontFamily: 'Sora, sans-serif', marginBottom: 12 }}>
|
||||||
)}
|
Latest writing
|
||||||
{f}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 data-gsap="heading" style={{ fontSize: 'clamp(30px, 3.6vw, 48px)', fontWeight: 800, letterSpacing: '-0.045em', lineHeight: 1, color: '#0a0a0f', margin: 0, fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
The Journal
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif', fontVariantNumeric: 'tabular-nums', whiteSpace: 'nowrap', paddingBottom: 4 }}>
|
||||||
|
<strong style={{ color: '#0a0a0f', fontWeight: 800 }}>{String(filtered.length).padStart(2, '0')}</strong>
|
||||||
|
{' '}/ {String(ARTICLES.length).padStart(2, '0')} articles
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
filters={FILTERS}
|
||||||
|
active={activeFilter}
|
||||||
|
counts={counts}
|
||||||
|
colorFor={f => (f === 'All' ? '#f4be28' : metaFor(f).color)}
|
||||||
|
iconFor={f => metaFor(f).icon}
|
||||||
|
onChange={setActiveFilter}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── FEATURED ARTICLE ── */}
|
{/* ── FEATURED ARTICLE ── */}
|
||||||
{featured && (
|
{featured && (
|
||||||
<section style={{ padding: '64px 8%', background: '#fff' }}>
|
<section style={{ padding: '38px 8% 0' }}>
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<Link href="/blog_single_page" style={{ textDecoration: 'none', display: 'block' }}>
|
<FeaturedArticle
|
||||||
<div className="bm-featured-grid" style={{
|
key={featured.slug}
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6vw',
|
article={featured}
|
||||||
padding: '64px', background: '#07070d', borderRadius: 28,
|
href={articleHref(featured.slug)}
|
||||||
alignItems: 'center', cursor: 'pointer', position: 'relative', overflow: 'hidden',
|
color={metaFor(featured.tag).color}
|
||||||
border: `1px solid ${(TAG_META[featured.tag]?.color || '#f4be28')}30`,
|
icon={metaFor(featured.tag).icon}
|
||||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
/>
|
||||||
}}>
|
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(255,255,255,0.02) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.02) 1px,transparent 1px)', backgroundSize: '48px 48px', pointerEvents: 'none' }} />
|
|
||||||
{/* Left — article info */}
|
|
||||||
<div style={{ position: 'relative', zIndex: 2 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 32, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
fontSize: 11, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase',
|
|
||||||
color: TAG_META[featured.tag]?.color || '#f4be28', fontFamily: 'Sora, sans-serif',
|
|
||||||
background: `${TAG_META[featured.tag]?.color || '#f4be28'}18`, padding: '5px 12px 5px 8px', borderRadius: 100,
|
|
||||||
border: `1px solid ${TAG_META[featured.tag]?.color || '#f4be28'}35`,
|
|
||||||
}}>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={TAG_META[featured.tag]?.color || '#f4be28'} strokeWidth="2">{TAG_META[featured.tag]?.icon}</svg>
|
|
||||||
{featured.tag}
|
|
||||||
</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.3)', fontFamily: 'Sora, sans-serif' }}>{featured.readTime} read</span>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#f4be28', fontFamily: 'Sora, sans-serif', background: 'rgba(244,190,40,0.08)', padding: '4px 10px', borderRadius: 100 }}>Featured</span>
|
|
||||||
</div>
|
|
||||||
<h2 style={{ fontSize: 'clamp(28px, 3.5vw, 52px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 1.05, color: '#fff', margin: '0 0 20px', fontFamily: 'Sora, sans-serif' }}>{featured.title}</h2>
|
|
||||||
<p style={{ fontSize: 16, lineHeight: 1.75, color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', margin: '0 0 36px' }}>{featured.excerpt}</p>
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 15, fontWeight: 700, color: '#f4be28', fontFamily: 'Sora, sans-serif', borderBottom: '1px solid rgba(244,190,40,0.3)', paddingBottom: 4 }}>
|
|
||||||
Read Article →
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Right — visual */}
|
|
||||||
<div style={{
|
|
||||||
aspectRatio: '4/3', borderRadius: 20, position: 'relative', zIndex: 2,
|
|
||||||
background: `linear-gradient(150deg, ${TAG_META[featured.tag]?.color || '#f4be28'}20 0%, transparent 70%)`,
|
|
||||||
border: `1px solid ${TAG_META[featured.tag]?.color || '#f4be28'}25`,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
<svg width="30%" height="30%" viewBox="0 0 24 24" fill="none" stroke={TAG_META[featured.tag]?.color || '#f4be28'} strokeWidth="1.2" style={{ opacity: 0.5 }}>{TAG_META[featured.tag]?.icon}</svg>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', bottom: -14, right: -6,
|
|
||||||
fontSize: 'clamp(80px, 12vw, 160px)', fontWeight: 900,
|
|
||||||
color: 'transparent', fontFamily: 'Sora, sans-serif',
|
|
||||||
WebkitTextStroke: `1px ${TAG_META[featured.tag]?.color || '#f4be28'}30`,
|
|
||||||
letterSpacing: '-0.06em', lineHeight: 1,
|
|
||||||
userSelect: 'none',
|
|
||||||
}}>{featured.n}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── ARTICLE GRID ── */}
|
{/* ── ARTICLE GRID ── */}
|
||||||
<section style={{ padding: '0 8% 140px', background: '#fff' }}>
|
<section style={{ padding: '20px 8% 140px' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(340px,1fr))', gap: 20 }}>
|
{rest.length > 0 && (
|
||||||
{rest.map((article, i) => {
|
<div style={{
|
||||||
const meta = TAG_META[article.tag];
|
display: 'flex', alignItems: 'center', gap: 18,
|
||||||
return (
|
margin: '46px 0 26px',
|
||||||
<Reveal key={article.n} delay={i * 0.08}>
|
|
||||||
<Link href="/blog_single_page" style={{ textDecoration: 'none', display: 'block', height: '100%' }}>
|
|
||||||
<div
|
|
||||||
onMouseEnter={() => setHovered(i)}
|
|
||||||
onMouseLeave={() => setHovered(null)}
|
|
||||||
style={{
|
|
||||||
padding: '36px', borderRadius: 22,
|
|
||||||
background: '#fff',
|
|
||||||
border: `1.5px solid ${hovered === i ? `${meta?.color}45` : 'rgba(0,0,0,0.08)'}`,
|
|
||||||
transition: 'all 0.35s cubic-bezier(0.22,1,0.36,1)',
|
|
||||||
transform: hovered === i ? 'translateY(-6px)' : 'translateY(0)',
|
|
||||||
boxShadow: hovered === i ? `0 24px 60px -16px ${meta?.color}30` : '0 2px 12px rgba(0,0,0,0.04)',
|
|
||||||
cursor: 'pointer', position: 'relative', overflow: 'hidden',
|
|
||||||
display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minHeight: 300,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 22 }}>
|
|
||||||
<span style={{
|
|
||||||
width: 40, height: 40, borderRadius: 12, flexShrink: 0,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: `${meta?.color}14`, border: `1px solid ${meta?.color}35`,
|
|
||||||
}}>
|
}}>
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={meta?.color} strokeWidth="2">{meta?.icon}</svg>
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.35)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap' }}>
|
||||||
|
More reading
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 26, fontWeight: 900, color: 'rgba(0,0,0,0.06)', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.02em' }}>{article.n}</span>
|
<span data-gsap="line" style={{ flex: 1, height: 1, background: 'rgba(0,0,0,0.1)', display: 'block' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
)}
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: meta?.color, fontFamily: 'Sora, sans-serif' }}>{article.tag}</span>
|
|
||||||
<span style={{ width: 3, height: 3, borderRadius: '50%', background: 'rgba(0,0,0,0.2)', display: 'block' }} />
|
<div ref={listRef} style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(320px,1fr))', gap: 20 }}>
|
||||||
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.35)', fontFamily: 'Sora, sans-serif' }}>{article.readTime} read</span>
|
{rest.map(article => (
|
||||||
</div>
|
<ArticleCard
|
||||||
<h3 style={{ fontSize: 'clamp(20px, 2vw, 26px)', fontWeight: 800, letterSpacing: '-0.03em', lineHeight: 1.22, color: '#0a0a0f', margin: '0 0 14px', fontFamily: 'Sora, sans-serif' }}>{article.title}</h3>
|
key={article.slug}
|
||||||
<p style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif', margin: 0 }}>{article.excerpt}</p>
|
article={article}
|
||||||
</div>
|
href={articleHref(article.slug)}
|
||||||
<span style={{
|
color={metaFor(article.tag).color}
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6, width: 'fit-content',
|
icon={metaFor(article.tag).icon}
|
||||||
fontSize: 13, fontWeight: 700, color: hovered === i ? meta?.color : 'rgba(0,0,0,0.35)',
|
/>
|
||||||
fontFamily: 'Sora, sans-serif', marginTop: 28, transition: 'color 0.25s, transform 0.25s',
|
))}
|
||||||
transform: hovered === i ? 'translateX(4px)' : 'translateX(0)',
|
|
||||||
}}>
|
|
||||||
Read Article →
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</Reveal>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p style={{ fontSize: 15, color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif', padding: '60px 0' }}>
|
||||||
|
No articles in this topic yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── NEWSLETTER CTA ── */}
|
{/* ── NEWSLETTER CTA ── */}
|
||||||
@@ -355,10 +275,20 @@ export default function BlogMainPage() {
|
|||||||
<style>{`
|
<style>{`
|
||||||
@keyframes bmShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
@keyframes bmShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||||
@keyframes bmPulse { 0%,100%{box-shadow:0 0 0 0 rgba(244,190,40,0.5)} 50%{box-shadow:0 0 0 5px rgba(244,190,40,0)} }
|
@keyframes bmPulse { 0%,100%{box-shadow:0 0 0 0 rgba(244,190,40,0.5)} 50%{box-shadow:0 0 0 5px rgba(244,190,40,0)} }
|
||||||
|
.bm-filter:hover { color: #0a0a0f !important; border-color: rgba(0,0,0,0.28) !important; }
|
||||||
|
.bm-filter[aria-selected="true"]:hover { border-color: transparent !important; }
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.bm-featured-grid { grid-template-columns: 1fr !important; }
|
||||||
|
.bm-featured-art { order: -1; min-height: 260px !important; border-left: none !important; border-bottom: 1px solid rgba(0,0,0,0.07); }
|
||||||
|
}
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.bm-featured-grid { grid-template-columns: 1fr !important; padding: 36px 28px !important; gap: 36px !important; }
|
.bm-bar { flex-direction: column; align-items: flex-start !important; gap: 18px !important; }
|
||||||
.bm-newsletter-grid { grid-template-columns: 1fr !important; gap: 40px !important; }
|
.bm-newsletter-grid { grid-template-columns: 1fr !important; gap: 40px !important; }
|
||||||
}
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bm-char { transform: none !important; opacity: 1 !important; }
|
||||||
|
.bm-featured, .bm-card { transition: none !important; }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,144 +1,12 @@
|
|||||||
'use client';
|
import { permanentRedirect } from 'next/navigation';
|
||||||
|
import { ARTICLES, articleHref } from '@/content/articles';
|
||||||
|
|
||||||
import React from 'react';
|
/**
|
||||||
import Link from 'next/link';
|
* Legacy route. Articles now live at /blog/[slug]; this URL predates them and
|
||||||
import Image from 'next/image';
|
* may still be linked or indexed, so it forwards to the lead article rather
|
||||||
|
* than 404ing.
|
||||||
const ARTICLE = {
|
*/
|
||||||
tag: 'Analytics',
|
export default function BlogSinglePageRedirect() {
|
||||||
readTime: '6 min',
|
const lead = ARTICLES.find(a => a.featured) ?? ARTICLES[0];
|
||||||
title: 'Why Footfall Data is the New Gold',
|
permanentRedirect(articleHref(lead.slug));
|
||||||
date: 'June 2026',
|
|
||||||
author: 'Loyaly.ai Team',
|
|
||||||
excerpt: 'In an era where every online click is tracked, offline retailers are sitting on untapped behavioural gold, and most don\'t even know it exists.',
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
heading: 'The data gap in offline retail',
|
|
||||||
body: 'E-commerce has always had an advantage: every click, scroll, and abandoned cart is tracked and analysed. Offline retail never had this luxury, until now. Footfall data is rapidly becoming the most valuable asset a physical store can own. It tells you not just how many people walk in, but when, where they go, how long they stay, and whether they convert.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'What footfall data actually tells you',
|
|
||||||
body: 'A modern footfall intelligence system captures far more than a head count. It maps movement patterns through your store, identifies dwell zones, flags bottlenecks, and connects visit behaviour to purchase outcomes. When you overlay this with loyalty data: who the customer is, what they\'ve bought before, how long since their last visit, you get a complete picture that was previously impossible for offline businesses.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'Turning insights into revenue',
|
|
||||||
body: 'The real power of footfall data emerges when it drives action. If you know that 60% of visitors who enter your electronics section but leave without buying spend more than 12 minutes near the display stands, that\'s a cue for a targeted offer. Loyaly\'s Behavision AI does exactly this: it detects intent signals and triggers personalised engagements in real time, closing the gap between visit and purchase.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
heading: 'The competitive advantage is closing',
|
|
||||||
body: 'Retailers who adopt footfall intelligence today are building a data moat that will be very hard for competitors to close later. Every day without this data is a day of lost insight. The good news: AI has made footfall analytics accessible to stores of all sizes — not just enterprise chains with seven-figure tech budgets. Loyaly brings this capability to any merchant who wants it.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
relatedArticles: [
|
|
||||||
{ n: '02', tag: 'Strategy', title: 'The End of Blind Discounts', readTime: '5 min' },
|
|
||||||
{ n: '03', tag: 'Technology', title: 'AI in Offline Retail', readTime: '8 min' },
|
|
||||||
{ n: '04', tag: 'Growth', title: 'How to Increase Repeat Customers', readTime: '4 min' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BlogSinglePage() {
|
|
||||||
return (
|
|
||||||
<main style={{ background: '#ffffff', minHeight: '100vh', overflowX: 'hidden' }}>
|
|
||||||
|
|
||||||
{/* Hero */}
|
|
||||||
<section style={{
|
|
||||||
padding: '140px 8% 100px', background: 'radial-gradient(ellipse 120% 90% at 50% 0%, #2c2350 0%, #211c3e 38%, #181429 100%)',
|
|
||||||
margin: 'var(--hero-top) var(--sec-gutter) var(--sec-gap) var(--sec-gutter)', borderRadius: '32px',
|
|
||||||
position: 'relative', overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
{/* Background photo */}
|
|
||||||
<Image src="/assets/images/stock/blog-article.jpg" alt="" fill priority style={{ objectFit: 'cover', opacity: 0.9 }} />
|
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(24,20,41,0.1) 0%, rgba(24,20,41,0.55) 85%)' }} />
|
|
||||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 65% 70% at 50% 48%, rgba(18,14,30,0.78) 0%, rgba(18,14,30,0.35) 60%, transparent 100%)' }} />
|
|
||||||
<div style={{ position: 'absolute', top: '-14%', right: '8%', width: 460, height: 460, borderRadius: '50%', background: 'radial-gradient(circle, rgba(244,190,40,0.2) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
|
||||||
<div style={{ position: 'absolute', bottom: '-18%', left: '4%', width: 400, height: 400, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.18) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '60px 60px', pointerEvents: 'none' }} />
|
|
||||||
<div style={{ position: 'relative', zIndex: 2, maxWidth: 820, margin: '0 auto', display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginBottom: 32 }}>
|
|
||||||
<Link href="/blog_main" style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', fontFamily: 'Sora, sans-serif', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
← Market Insights
|
|
||||||
</Link>
|
|
||||||
<span style={{ color: 'rgba(255,255,255,0.2)' }}>/</span>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#f4be28', background: 'rgba(244,190,40,0.1)', border: '1px solid rgba(244,190,40,0.2)', padding: '3px 10px', borderRadius: 100, fontFamily: 'Sora, sans-serif', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{ARTICLE.tag}</span>
|
|
||||||
</div>
|
|
||||||
<h1 style={{ fontSize: 'clamp(36px, 6vw, 88px)', fontWeight: 800, letterSpacing: '-0.05em', lineHeight: 0.92, color: '#fff', margin: '0 0 28px', fontFamily: 'Sora, sans-serif' }}>
|
|
||||||
{ARTICLE.title}
|
|
||||||
</h1>
|
|
||||||
<p style={{ fontSize: 'clamp(16px, 1.5vw, 20px)', lineHeight: 1.75, color: 'rgba(255,255,255,0.55)', fontFamily: 'Sora, sans-serif', maxWidth: 600, margin: '0 0 40px' }}>
|
|
||||||
{ARTICLE.excerpt}
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 20 }}>
|
|
||||||
<div style={{ width: 40, height: 40, borderRadius: '50%', background: 'rgba(244,190,40,0.15)', border: '1.5px solid rgba(244,190,40,0.35)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, color: '#f4be28' }}>✦</div>
|
|
||||||
<div style={{ textAlign: 'left' }}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>{ARTICLE.author}</div>
|
|
||||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.35)', fontFamily: 'Sora, sans-serif' }}>{ARTICLE.date} · {ARTICLE.readTime} read</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Article body */}
|
|
||||||
<section style={{ padding: '80px 8%', background: '#fff', margin: '0 var(--sec-gutter) var(--sec-gap)', borderRadius: 24 }}>
|
|
||||||
<div style={{ maxWidth: 740, margin: '0 auto' }}>
|
|
||||||
{ARTICLE.sections.map((s, i) => (
|
|
||||||
<div key={i} style={{ marginBottom: 56 }}>
|
|
||||||
<h2 style={{ fontSize: 'clamp(22px, 2.5vw, 32px)', fontWeight: 800, letterSpacing: '-0.03em', color: '#0a0a0f', margin: '0 0 18px', fontFamily: 'Sora, sans-serif' }}>
|
|
||||||
{s.heading}
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: 17, lineHeight: 1.9, color: 'rgba(0,0,0,0.6)', fontFamily: 'Sora, sans-serif', margin: 0 }}>
|
|
||||||
{s.body}
|
|
||||||
</p>
|
|
||||||
{i < ARTICLE.sections.length - 1 && (
|
|
||||||
<div style={{ height: 1, background: 'rgba(0,0,0,0.06)', marginTop: 56 }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Pull quote */}
|
|
||||||
<blockquote style={{
|
|
||||||
margin: '56px 0', padding: '32px 40px',
|
|
||||||
borderLeft: '4px solid #f4be28', borderRadius: '0 16px 16px 0',
|
|
||||||
background: '#fff8e0',
|
|
||||||
}}>
|
|
||||||
<p style={{ fontSize: 'clamp(18px, 2.2vw, 26px)', fontWeight: 800, color: '#0a0a0f', fontFamily: 'Sora, sans-serif', lineHeight: 1.4, margin: 0, letterSpacing: '-0.02em' }}>
|
|
||||||
"Every day without footfall data is a day of lost insight."
|
|
||||||
</p>
|
|
||||||
<footer style={{ fontSize: 13, color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif', marginTop: 12 }}>Loyaly.ai, 2026</footer>
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<section style={{ padding: '80px 8%', background: '#07070d', margin: '0 var(--sec-gutter) var(--sec-gap)', borderRadius: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 32 }}>
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontSize: 'clamp(28px, 3.5vw, 52px)', fontWeight: 800, letterSpacing: '-0.04em', color: '#fff', margin: '0 0 12px', fontFamily: 'Sora, sans-serif' }}>
|
|
||||||
Ready to see your store's data?
|
|
||||||
</h2>
|
|
||||||
<p style={{ fontSize: 16, color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', margin: 0 }}>Book a 30-minute demo and see Behavision AI in action.</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/contacts" style={{ display: 'inline-flex', alignItems: 'center', gap: 10, padding: '16px 36px', background: '#f4be28', color: '#111', fontSize: 15, fontWeight: 800, borderRadius: 100, textDecoration: 'none', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap', boxShadow: '0 8px 28px rgba(244,190,40,0.35)' }}>
|
|
||||||
Book a Demo →
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Related articles */}
|
|
||||||
<section style={{ padding: '80px 8%', background: '#fff', margin: '0 var(--sec-gutter) var(--sec-gap)', borderRadius: 24 }}>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: '#f4be28', fontFamily: 'Sora, sans-serif', marginBottom: 32 }}>More from Market Insights</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(240px,1fr))', gap: 16 }}>
|
|
||||||
{ARTICLE.relatedArticles.map((a) => (
|
|
||||||
<Link key={a.n} href="/blog_main" style={{ textDecoration: 'none', display: 'block', padding: '28px 24px', borderRadius: 20, background: '#fafafa', border: '1px solid rgba(0,0,0,0.07)', transition: 'all 0.25s ease' }}
|
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLAnchorElement).style.background = '#fff8e0'; (e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(244,190,40,0.3)'; }}
|
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLAnchorElement).style.background = '#fafafa'; (e.currentTarget as HTMLAnchorElement).style.borderColor = 'rgba(0,0,0,0.07)'; }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
||||||
<span style={{ fontSize: 10, fontWeight: 700, color: '#f4be28', background: 'rgba(244,190,40,0.1)', border: '1px solid rgba(244,190,40,0.2)', padding: '3px 9px', borderRadius: 100, fontFamily: 'Sora, sans-serif', textTransform: 'uppercase', letterSpacing: '0.08em' }}>{a.tag}</span>
|
|
||||||
<span style={{ fontSize: 11, color: 'rgba(0,0,0,0.3)', fontFamily: 'Sora, sans-serif' }}>{a.readTime} read</span>
|
|
||||||
</div>
|
|
||||||
<h3 style={{ fontSize: 17, fontWeight: 800, color: '#0a0a0f', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.02em', lineHeight: 1.3, margin: 0 }}>{a.title}</h3>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export default function GetInTouch() {
|
|||||||
<input type="text" name="name" required placeholder="Jane Smith" className="git-input" style={inputStyle} />
|
<input type="text" name="name" required placeholder="Jane Smith" className="git-input" style={inputStyle} />
|
||||||
</label>
|
</label>
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
Email Address <span aria-hidden="true" style={{ color: '#f4be28' }}>*</span>
|
<span>Email Address <span aria-hidden="true" style={{ color: '#f4be28' }}>*</span></span>
|
||||||
<input type="email" name="email" required placeholder="jane@company.com" className="git-input" style={inputStyle} />
|
<input type="email" name="email" required placeholder="jane@company.com" className="git-input" style={inputStyle} />
|
||||||
</label>
|
</label>
|
||||||
<label style={labelStyle}>
|
<label style={labelStyle}>
|
||||||
|
|||||||
127
src/components/blog/ArticleCard.tsx
Normal file
127
src/components/blog/ArticleCard.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import TopicCanvas, { TopicVariant } from './TopicCanvas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid article. At rest the artwork sits quiet behind a hairline; on hover it
|
||||||
|
* energises, the border takes the topic colour and the title shifts up to make
|
||||||
|
* room for the arrow.
|
||||||
|
*/
|
||||||
|
export default function ArticleCard({
|
||||||
|
article,
|
||||||
|
href,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
article: { n: string; tag: string; readTime: string; title: string; excerpt: string };
|
||||||
|
href: string;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
|
||||||
|
const onMove = (e: React.PointerEvent) => {
|
||||||
|
const card = cardRef.current;
|
||||||
|
if (!card) return;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
card.style.setProperty('--mx', `${((e.clientX - r.left) / r.width) * 100}%`);
|
||||||
|
card.style.setProperty('--my', `${((e.clientY - r.top) / r.height) * 100}%`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} style={{ textDecoration: 'none', display: 'block', height: '100%' }} aria-label={`Read: ${article.title}`}>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerEnter={() => setHover(true)}
|
||||||
|
onPointerLeave={() => setHover(false)}
|
||||||
|
className="bm-card"
|
||||||
|
style={{
|
||||||
|
position: 'relative', height: '100%', borderRadius: 22, overflow: 'hidden',
|
||||||
|
background: '#fff',
|
||||||
|
border: `1px solid ${hover ? `${color}66` : 'rgba(0,0,0,0.09)'}`,
|
||||||
|
boxShadow: hover
|
||||||
|
? `0 2px 6px rgba(16,16,32,0.06), 0 30px 60px -24px ${color}80`
|
||||||
|
: '0 1px 2px rgba(16,16,32,0.05), 0 10px 28px -12px rgba(16,16,32,0.16)',
|
||||||
|
transform: hover ? 'translateY(-6px)' : 'translateY(0)',
|
||||||
|
transition: 'transform 0.5s cubic-bezier(0.22,1,0.36,1), box-shadow 0.5s ease, border-color 0.4s ease',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
// Cursor-following wash, only visible on hover.
|
||||||
|
['--mx' as string]: '50%', ['--my' as string]: '50%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 3,
|
||||||
|
background: `radial-gradient(340px circle at var(--mx) var(--my), ${color}0d, transparent 70%)`,
|
||||||
|
opacity: hover ? 1 : 0, transition: 'opacity 0.45s ease',
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Artwork strip */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', height: 150, overflow: 'hidden', flexShrink: 0,
|
||||||
|
background: `linear-gradient(160deg, ${color}0f 0%, transparent 80%)`,
|
||||||
|
borderBottom: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
}}>
|
||||||
|
<TopicCanvas
|
||||||
|
variant={article.tag as TopicVariant}
|
||||||
|
color={color}
|
||||||
|
intensity={hover ? 1 : 0}
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
/>
|
||||||
|
<span aria-hidden="true" style={{
|
||||||
|
position: 'absolute', top: 14, right: 18,
|
||||||
|
fontSize: 40, fontWeight: 900, lineHeight: 1,
|
||||||
|
color: 'transparent', WebkitTextStroke: `1px ${color}45`,
|
||||||
|
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.05em', userSelect: 'none',
|
||||||
|
}}>{article.n}</span>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 14, left: 18,
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase',
|
||||||
|
color, fontFamily: 'Sora, sans-serif',
|
||||||
|
background: '#ffffffd9', backdropFilter: 'blur(6px)',
|
||||||
|
padding: '5px 10px', borderRadius: 100, border: `1px solid ${color}30`,
|
||||||
|
}}>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2">{icon}</svg>
|
||||||
|
{article.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Copy */}
|
||||||
|
<div style={{ padding: '26px 28px 28px', display: 'flex', flexDirection: 'column', flex: 1, position: 'relative', zIndex: 2 }}>
|
||||||
|
<h3 style={{
|
||||||
|
fontSize: 'clamp(19px, 1.5vw, 23px)', fontWeight: 800, letterSpacing: '-0.03em',
|
||||||
|
lineHeight: 1.22, color: '#0a0a0f', margin: '0 0 12px', fontFamily: 'Sora, sans-serif',
|
||||||
|
}}>{article.title}</h3>
|
||||||
|
<p style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif', margin: '0 0 24px' }}>
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'auto', paddingTop: 18, borderTop: '1px solid rgba(0,0,0,0.07)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif', fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{article.readTime} read
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
width: 34, height: 34, borderRadius: '50%',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: hover ? color : 'transparent',
|
||||||
|
border: `1px solid ${hover ? color : 'rgba(0,0,0,0.12)'}`,
|
||||||
|
transform: hover ? 'translateX(3px)' : 'translateX(0)',
|
||||||
|
transition: 'background 0.35s ease, border-color 0.35s ease, transform 0.35s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
}}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={hover ? '#0a0a0f' : 'rgba(0,0,0,0.45)'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transition: 'stroke 0.35s ease' }}>
|
||||||
|
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/blog/ArticleToc.tsx
Normal file
128
src/components/blog/ArticleToc.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export type TocItem = { id: string; label: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky contents rail with scroll spy. The active item is whichever heading
|
||||||
|
* sits closest above the reading line (a third down the viewport), which keeps
|
||||||
|
* the highlight stable through short sections where several headings share the
|
||||||
|
* screen.
|
||||||
|
*
|
||||||
|
* Like the progress bar, the active state is written to the DOM as an
|
||||||
|
* `aria-current` attribute and styled via CSS, rather than re-rendering on
|
||||||
|
* every scroll event.
|
||||||
|
*/
|
||||||
|
/** Viewport offset the rail pins to. */
|
||||||
|
const STICKY_TOP = 120;
|
||||||
|
|
||||||
|
export default function ArticleToc({
|
||||||
|
items,
|
||||||
|
targetRef,
|
||||||
|
color = '#f4be28',
|
||||||
|
}: {
|
||||||
|
items: TocItem[];
|
||||||
|
/** The article the rail belongs to. Once its end scrolls past, the rail hides. */
|
||||||
|
targetRef?: React.RefObject<HTMLElement | null>;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nav = navRef.current;
|
||||||
|
if (!nav) return;
|
||||||
|
|
||||||
|
const compute = () => {
|
||||||
|
const line = window.innerHeight * 0.33;
|
||||||
|
let current = items[0]?.id ?? '';
|
||||||
|
for (const it of items) {
|
||||||
|
const el = document.getElementById(it.id);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.getBoundingClientRect().top <= line) current = it.id;
|
||||||
|
}
|
||||||
|
nav.querySelectorAll<HTMLAnchorElement>('[data-toc-link]').forEach(a => {
|
||||||
|
const on = a.dataset.tocLink === current;
|
||||||
|
if (on) a.setAttribute('aria-current', 'true');
|
||||||
|
else a.removeAttribute('aria-current');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Retire the rail once the article's end reaches it — there's nothing left
|
||||||
|
// to navigate. The threshold is the point where sticky would otherwise
|
||||||
|
// start dragging the rail up out of the viewport, so it fades out while
|
||||||
|
// still pinned instead of visibly sliding away first.
|
||||||
|
const target = targetRef?.current;
|
||||||
|
if (target) {
|
||||||
|
const releaseLine = STICKY_TOP + nav.getBoundingClientRect().height + 24;
|
||||||
|
const done = target.getBoundingClientRect().bottom <= releaseLine;
|
||||||
|
nav.style.opacity = done ? '0' : '1';
|
||||||
|
nav.style.transform = done ? 'translateY(-10px)' : 'translateY(0)';
|
||||||
|
nav.style.pointerEvents = done ? 'none' : 'auto';
|
||||||
|
nav.setAttribute('aria-hidden', done ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
compute();
|
||||||
|
window.addEventListener('scroll', compute, { passive: true });
|
||||||
|
window.addEventListener('resize', compute);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', compute);
|
||||||
|
window.removeEventListener('resize', compute);
|
||||||
|
};
|
||||||
|
}, [items, targetRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
ref={navRef}
|
||||||
|
className="bm-toc"
|
||||||
|
aria-label="On this page"
|
||||||
|
style={{
|
||||||
|
position: 'sticky', top: STICKY_TOP, alignSelf: 'start',
|
||||||
|
transition: 'opacity 0.4s ease, transform 0.4s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
['--toc-accent' as string]: color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase',
|
||||||
|
color: 'rgba(0,0,0,0.35)', fontFamily: 'Sora, sans-serif', marginBottom: 18,
|
||||||
|
}}>
|
||||||
|
Contents
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<li key={it.id}>
|
||||||
|
<a href={`#${it.id}`} data-toc-link={it.id} className="bm-toc-link">
|
||||||
|
<span className="bm-toc-num">{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
<span className="bm-toc-label">{it.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.bm-toc-link {
|
||||||
|
display: grid; grid-template-columns: 18px 1fr; gap: 10px; align-items: start;
|
||||||
|
padding: 9px 12px; border-radius: 10px; text-decoration: none;
|
||||||
|
background: transparent; box-shadow: none;
|
||||||
|
transition: background 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.bm-toc-link:hover { background: rgba(255,255,255,0.6); }
|
||||||
|
.bm-toc-link[aria-current="true"] {
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 2px rgba(16,16,32,0.05), 0 6px 18px -10px rgba(16,16,32,0.25);
|
||||||
|
}
|
||||||
|
.bm-toc-num {
|
||||||
|
font-size: 10px; font-weight: 700; font-family: Sora, sans-serif; line-height: 18px;
|
||||||
|
color: rgba(0,0,0,0.3); font-variant-numeric: tabular-nums; transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
.bm-toc-link[aria-current="true"] .bm-toc-num { color: var(--toc-accent); }
|
||||||
|
.bm-toc-label {
|
||||||
|
font-size: 12.5px; line-height: 1.45; font-family: Sora, sans-serif;
|
||||||
|
font-weight: 500; color: rgba(0,0,0,0.45); transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
.bm-toc-link[aria-current="true"] .bm-toc-label { color: #0a0a0f; font-weight: 700; }
|
||||||
|
`}</style>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
381
src/components/blog/ArticleView.tsx
Normal file
381
src/components/blog/ArticleView.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import GSAPAnimator from '@/components/GSAPAnimator';
|
||||||
|
import SplitHeading from '@/components/blog/SplitHeading';
|
||||||
|
import ReadingProgress from '@/components/blog/ReadingProgress';
|
||||||
|
import ArticleToc from '@/components/blog/ArticleToc';
|
||||||
|
import ArticleCard from '@/components/blog/ArticleCard';
|
||||||
|
import TopicCanvas, { TopicVariant } from '@/components/blog/TopicCanvas';
|
||||||
|
import { metaFor, BLOG_CANVAS } from '@/components/blog/topics';
|
||||||
|
import { Article, articleHref } from '@/content/articles';
|
||||||
|
|
||||||
|
export const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||||
|
|
||||||
|
/** Splits a title into two roughly even lines so the hero never sets one long run. */
|
||||||
|
function titleLines(title: string): [string, string] {
|
||||||
|
const words = title.split(' ');
|
||||||
|
if (words.length < 3) return [title, ''];
|
||||||
|
const mid = Math.ceil(words.length / 2);
|
||||||
|
return [words.slice(0, mid).join(' '), words.slice(mid).join(' ')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full article page. Every /blog/[slug] renders this, so all articles share
|
||||||
|
* one layout, one motion system, and one set of fixes.
|
||||||
|
*/
|
||||||
|
export default function ArticleView({ article, related }: { article: Article; related: Article[] }) {
|
||||||
|
const articleRef = useRef<HTMLElement>(null);
|
||||||
|
const parallaxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const meta = metaFor(article.tag);
|
||||||
|
const accent = meta.color;
|
||||||
|
|
||||||
|
const toc = article.sections.map(s => ({ id: slugify(s.heading), label: s.heading }));
|
||||||
|
const [lineOne, lineTwo] = titleLines(article.title);
|
||||||
|
|
||||||
|
// Hero parallax. Driven straight off scroll rather than a scroll-linked
|
||||||
|
// animation library so it stays a single transform write per event.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = parallaxRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const shift = Math.min(window.scrollY * 0.2, 180);
|
||||||
|
el.style.transform = `translate3d(0, ${shift}px, 0) scale(1.16)`;
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// overflowX must be `clip`, not `hidden`: `hidden` computes overflow-y to
|
||||||
|
// `auto`, turning <main> into a scroll container, which silently breaks
|
||||||
|
// position:sticky for the contents rail. `clip` crops without creating one.
|
||||||
|
<main style={{ background: BLOG_CANVAS, minHeight: '100vh', overflowX: 'clip' }}>
|
||||||
|
<GSAPAnimator />
|
||||||
|
<ReadingProgress targetRef={articleRef} color={accent} />
|
||||||
|
|
||||||
|
{/* ── HERO ── */}
|
||||||
|
<section style={{
|
||||||
|
// Symmetric vertical padding + flex centring keeps the content exactly
|
||||||
|
// mid-hero instead of riding high.
|
||||||
|
padding: 'clamp(96px, 11vw, 150px) 8%',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'radial-gradient(ellipse 120% 90% at 50% 0%, #2c2350 0%, #211c3e 38%, #181429 100%)',
|
||||||
|
margin: 'var(--hero-top) var(--sec-gutter) var(--sec-gap) var(--sec-gutter)',
|
||||||
|
borderRadius: 32, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div ref={parallaxRef} style={{ position: 'absolute', inset: 0, willChange: 'transform' }}>
|
||||||
|
<Image src={article.heroImage} alt="" fill priority sizes="100vw" style={{ objectFit: 'cover', opacity: 0.55 }} />
|
||||||
|
</div>
|
||||||
|
{/* Legibility scrim — the photo must never fight the headline. */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(18,14,30,0.62) 0%, rgba(18,14,30,0.80) 55%, rgba(18,14,30,0.94) 100%)' }} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 70% 60% at 50% 45%, ${accent}14 0%, transparent 70%)` }} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.03) 1px,transparent 1px)', backgroundSize: '60px 60px' }} />
|
||||||
|
|
||||||
|
{/* Wide enough for the longest title line at the max clamped size
|
||||||
|
("Connected Commerce" needs ~1000px), so no headline orphans a word. */}
|
||||||
|
<div style={{ position: 'relative', zIndex: 2, width: '100%', maxWidth: 'min(1060px, 100%)', margin: '0 auto', textAlign: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginBottom: 30, flexWrap: 'wrap' }}>
|
||||||
|
<Link href="/blog_main" className="bm-back" style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, fontWeight: 600,
|
||||||
|
color: 'rgba(255,255,255,0.6)', fontFamily: 'Sora, sans-serif', textDecoration: 'none',
|
||||||
|
padding: '7px 14px', borderRadius: 100, border: '1px solid rgba(255,255,255,0.14)',
|
||||||
|
background: 'rgba(255,255,255,0.05)', backdropFilter: 'blur(10px)',
|
||||||
|
transition: 'color 0.3s ease, border-color 0.3s ease, background 0.3s ease',
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 12H5M11 18l-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
The Journal
|
||||||
|
</Link>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase',
|
||||||
|
color: accent, fontFamily: 'Sora, sans-serif',
|
||||||
|
background: `${accent}1a`, border: `1px solid ${accent}45`,
|
||||||
|
padding: '6px 13px', borderRadius: 100, backdropFilter: 'blur(10px)',
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={accent} strokeWidth="2">{meta.icon}</svg>
|
||||||
|
{article.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 style={{
|
||||||
|
fontSize: 'clamp(38px, 6.2vw, 92px)', fontWeight: 800, letterSpacing: '-0.05em',
|
||||||
|
lineHeight: 0.94, color: '#fff', margin: '0 0 30px', fontFamily: 'Sora, sans-serif',
|
||||||
|
}}>
|
||||||
|
<SplitHeading key={`${article.slug}-1`} text={lineOne} delay={0.1} />
|
||||||
|
{lineTwo && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<SplitHeading key={`${article.slug}-2`} text={lineTwo} delay={0.1 + lineOne.length * 0.018} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p data-gsap="text" style={{
|
||||||
|
fontSize: 'clamp(16px, 1.5vw, 20px)', lineHeight: 1.7, color: 'rgba(255,255,255,0.62)',
|
||||||
|
fontFamily: 'Sora, sans-serif', maxWidth: 620, margin: '0 auto 44px',
|
||||||
|
}}>
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 14, padding: '10px 20px 10px 10px', borderRadius: 100, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', backdropFilter: 'blur(12px)' }}>
|
||||||
|
<span style={{
|
||||||
|
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `linear-gradient(135deg, ${accent}, #ff9500)`, color: '#111',
|
||||||
|
fontSize: 15, fontWeight: 900, fontFamily: 'Sora, sans-serif',
|
||||||
|
}}>L</span>
|
||||||
|
<span style={{ textAlign: 'left' }}>
|
||||||
|
<span style={{ display: 'block', fontSize: 13.5, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif' }}>{article.author}</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 11.5, color: 'rgba(255,255,255,0.42)', fontFamily: 'Sora, sans-serif', marginTop: 2 }}>
|
||||||
|
{article.date} · {article.readTime} read
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── BODY ── */}
|
||||||
|
<section style={{ padding: '0 var(--sec-gutter) clamp(40px, 6vw, 80px)' }}>
|
||||||
|
<div className="bm-body-grid" style={{
|
||||||
|
maxWidth: 1280, margin: '0 auto', display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0,230px) minmax(0,1fr)', gap: 'clamp(24px, 3vw, 56px)',
|
||||||
|
alignItems: 'start',
|
||||||
|
}}>
|
||||||
|
<ArticleToc items={toc} targetRef={articleRef} color={accent} />
|
||||||
|
|
||||||
|
<article ref={articleRef} className="bm-sheet" style={{
|
||||||
|
background: '#fff', borderRadius: 24, border: '1px solid rgba(0,0,0,0.09)',
|
||||||
|
boxShadow: '0 1px 2px rgba(16,16,32,0.05), 0 18px 48px -18px rgba(16,16,32,0.20)',
|
||||||
|
padding: 'clamp(30px, 4.4vw, 76px)', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{/* Signature strip — the topic's generative artwork, same system as the listing */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', height: 128, borderRadius: 16, overflow: 'hidden',
|
||||||
|
border: `1px solid ${accent}30`, marginBottom: 'clamp(32px, 4vw, 52px)',
|
||||||
|
background: `linear-gradient(150deg, ${accent}14 0%, transparent 75%)`,
|
||||||
|
}}>
|
||||||
|
<TopicCanvas variant={article.tag as TopicVariant} color={accent} intensity={0.35} style={{ position: 'absolute', inset: 0 }} />
|
||||||
|
<span aria-hidden="true" style={{
|
||||||
|
position: 'absolute', top: 10, right: 20, fontSize: 62, fontWeight: 900, lineHeight: 1,
|
||||||
|
color: 'transparent', WebkitTextStroke: `1px ${accent}40`, letterSpacing: '-0.06em',
|
||||||
|
fontFamily: 'Sora, sans-serif', userSelect: 'none',
|
||||||
|
}}>{article.n}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{article.sections.map((s, i) => (
|
||||||
|
<div key={s.heading} id={slugify(s.heading)} style={{ scrollMarginTop: 110, marginBottom: 'clamp(36px, 4vw, 54px)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 800, color: accent, fontFamily: 'Sora, sans-serif',
|
||||||
|
fontVariantNumeric: 'tabular-nums', letterSpacing: '0.1em',
|
||||||
|
}}>{String(i + 1).padStart(2, '0')}</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: `linear-gradient(90deg, ${accent}40, rgba(0,0,0,0.06))` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 data-gsap="heading" style={{
|
||||||
|
fontSize: 'clamp(23px, 2.6vw, 34px)', fontWeight: 800, letterSpacing: '-0.035em',
|
||||||
|
lineHeight: 1.15, color: '#0a0a0f', margin: '0 0 18px', fontFamily: 'Sora, sans-serif',
|
||||||
|
}}>
|
||||||
|
{s.heading}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className={i === 0 ? 'bm-lede' : undefined} style={{
|
||||||
|
fontSize: 17, lineHeight: 1.85, color: 'rgba(0,0,0,0.62)',
|
||||||
|
fontFamily: 'Sora, sans-serif', margin: 0,
|
||||||
|
}}>
|
||||||
|
{s.body}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{i === 0 && article.stats.length > 0 && (
|
||||||
|
<div className="bm-stats" style={{
|
||||||
|
display: 'grid', gridTemplateColumns: `repeat(${article.stats.length}, 1fr)`, gap: 1,
|
||||||
|
background: 'rgba(0,0,0,0.08)', border: '1px solid rgba(0,0,0,0.08)',
|
||||||
|
borderRadius: 16, overflow: 'hidden', marginTop: 36,
|
||||||
|
}}>
|
||||||
|
{article.stats.map(st => (
|
||||||
|
<div key={st.lbl} style={{ background: '#fbfbfd', padding: '22px 18px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 'clamp(24px, 2.6vw, 34px)', fontWeight: 900, color: '#0a0a0f', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.04em', lineHeight: 1 }}>
|
||||||
|
<span data-gsap="counter" data-count={st.val} data-suffix={st.suffix}>{st.val}{st.suffix}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'rgba(0,0,0,0.42)', fontFamily: 'Sora, sans-serif', marginTop: 8, lineHeight: 1.4 }}>{st.lbl}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{i === 1 && (
|
||||||
|
<figure className="bm-quote" style={{
|
||||||
|
margin: 'clamp(36px, 4vw, 52px) 0 0', padding: 'clamp(28px, 3.4vw, 44px)',
|
||||||
|
background: '#0a0a0f', borderRadius: 20, position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', inset: 0, background: `radial-gradient(ellipse 70% 90% at 88% 8%, ${accent}26 0%, transparent 62%)` }} />
|
||||||
|
<span aria-hidden="true" style={{
|
||||||
|
position: 'absolute', top: -22, left: 18, fontSize: 150, lineHeight: 1,
|
||||||
|
color: `${accent}22`, fontFamily: 'Sora, sans-serif', fontWeight: 900, userSelect: 'none',
|
||||||
|
}}>“</span>
|
||||||
|
<blockquote style={{ margin: 0, position: 'relative', zIndex: 2 }}>
|
||||||
|
<p style={{
|
||||||
|
fontSize: 'clamp(19px, 2.2vw, 28px)', fontWeight: 800, color: '#fff',
|
||||||
|
fontFamily: 'Sora, sans-serif', lineHeight: 1.38, margin: 0, letterSpacing: '-0.03em',
|
||||||
|
}}>
|
||||||
|
{article.quote.text}
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
<figcaption style={{ position: 'relative', zIndex: 2, display: 'flex', alignItems: 'center', gap: 10, marginTop: 22 }}>
|
||||||
|
<span style={{ width: 22, height: 1, background: accent }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)', fontFamily: 'Sora, sans-serif' }}>{article.quote.cite}</span>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Takeaways */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'clamp(36px, 4vw, 52px)', padding: 'clamp(26px, 3vw, 36px)',
|
||||||
|
borderRadius: 20, background: `${accent}0d`, border: `1px solid ${accent}33`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 10.5, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.45)', fontFamily: 'Sora, sans-serif', marginBottom: 18 }}>
|
||||||
|
Key takeaways
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{article.takeaways.map(t => (
|
||||||
|
<li key={t} style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, marginTop: 2,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `${accent}26`, border: `1px solid ${accent}55`,
|
||||||
|
}}>
|
||||||
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={accent === '#f4be28' ? '#8a6a00' : accent} strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 14.5, lineHeight: 1.65, color: 'rgba(0,0,0,0.68)', fontFamily: 'Sora, sans-serif' }}>{t}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Author footer */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 'clamp(36px, 4vw, 52px)', paddingTop: 30, borderTop: '1px solid rgba(0,0,0,0.09)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 46, height: 46, borderRadius: '50%', flexShrink: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: `linear-gradient(135deg, ${accent}, #ff9500)`, color: '#111',
|
||||||
|
fontSize: 18, fontWeight: 900, fontFamily: 'Sora, sans-serif',
|
||||||
|
}}>L</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 180 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: '#0a0a0f', fontFamily: 'Sora, sans-serif' }}>{article.author}</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'rgba(0,0,0,0.42)', fontFamily: 'Sora, sans-serif', marginTop: 3 }}>
|
||||||
|
Writing about AI, footfall, and the future of physical retail.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/blog_main" className="bm-more-link" style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, fontWeight: 700,
|
||||||
|
color: '#0a0a0f', fontFamily: 'Sora, sans-serif', textDecoration: 'none',
|
||||||
|
padding: '11px 18px', borderRadius: 100, border: '1px solid rgba(0,0,0,0.14)',
|
||||||
|
transition: 'background 0.3s ease, border-color 0.3s ease, color 0.3s ease',
|
||||||
|
}}>
|
||||||
|
All articles
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── CTA ── */}
|
||||||
|
<section style={{
|
||||||
|
padding: 'clamp(48px, 6vw, 80px) 8%', background: '#07070d',
|
||||||
|
margin: '0 var(--sec-gutter) var(--sec-gap)', borderRadius: 28,
|
||||||
|
position: 'relative', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{ position: 'absolute', top: '-30%', left: '6%', width: 420, height: 420, borderRadius: '50%', background: `radial-gradient(circle, ${accent}1f 0%, transparent 70%)` }} />
|
||||||
|
<div style={{ position: 'absolute', bottom: '-40%', right: '4%', width: 380, height: 380, borderRadius: '50%', background: 'radial-gradient(circle, rgba(167,139,250,0.12) 0%, transparent 70%)' }} />
|
||||||
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(244,190,40,0.025) 1px,transparent 1px),linear-gradient(90deg,rgba(244,190,40,0.025) 1px,transparent 1px)', backgroundSize: '56px 56px' }} />
|
||||||
|
<div className="bm-cta" style={{
|
||||||
|
position: 'relative', zIndex: 2, display: 'flex', justifyContent: 'space-between',
|
||||||
|
alignItems: 'center', gap: 36, flexWrap: 'wrap', maxWidth: 1180, margin: '0 auto',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: accent, fontFamily: 'Sora, sans-serif', marginBottom: 14 }}>
|
||||||
|
See it on your floor
|
||||||
|
</div>
|
||||||
|
<h2 data-gsap="heading" style={{ fontSize: 'clamp(28px, 3.6vw, 52px)', fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 1.02, color: '#fff', margin: '0 0 14px', fontFamily: 'Sora, sans-serif' }}>
|
||||||
|
Ready to see your store's data?
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 16, color: 'rgba(255,255,255,0.45)', fontFamily: 'Sora, sans-serif', margin: 0, maxWidth: 440, lineHeight: 1.7 }}>
|
||||||
|
Book a 30-minute demo and watch Behavision AI read your footfall in real time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/contacts" className="bm-cta-btn" style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 10, padding: '17px 34px',
|
||||||
|
background: `linear-gradient(135deg, ${accent}, #ff9500)`, color: '#111',
|
||||||
|
fontSize: 15, fontWeight: 800, borderRadius: 100, textDecoration: 'none',
|
||||||
|
fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap',
|
||||||
|
boxShadow: `0 14px 36px ${accent}4d`,
|
||||||
|
transition: 'transform 0.3s cubic-bezier(0.22,1,0.36,1), box-shadow 0.3s ease',
|
||||||
|
}}>
|
||||||
|
Book a demo
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── RELATED ── */}
|
||||||
|
{related.length > 0 && (
|
||||||
|
<section style={{ padding: 'clamp(56px, 7vw, 96px) 8% clamp(80px, 9vw, 130px)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 18, marginBottom: 30 }}>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif', whiteSpace: 'nowrap' }}>
|
||||||
|
Keep reading
|
||||||
|
</span>
|
||||||
|
<span data-gsap="line" style={{ flex: 1, height: 1, background: 'rgba(0,0,0,0.14)', display: 'block' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(300px,1fr))', gap: 20 }}>
|
||||||
|
{related.map(a => (
|
||||||
|
<div key={a.slug} data-gsap="card">
|
||||||
|
<ArticleCard article={a} href={articleHref(a.slug)} color={metaFor(a.tag).color} icon={metaFor(a.tag).icon} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.bm-lede::first-letter {
|
||||||
|
float: left;
|
||||||
|
font-size: 3.6em;
|
||||||
|
line-height: 0.82;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0.06em 0.09em 0 0;
|
||||||
|
color: #0a0a0f;
|
||||||
|
font-family: Sora, sans-serif;
|
||||||
|
}
|
||||||
|
.bm-back:hover { color: #fff !important; border-color: rgba(255,255,255,0.35) !important; background: rgba(255,255,255,0.1) !important; }
|
||||||
|
.bm-more-link:hover { background: #0a0a0f !important; border-color: #0a0a0f !important; color: #fff !important; }
|
||||||
|
.bm-cta-btn:hover { transform: translateY(-2px); }
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.bm-body-grid { grid-template-columns: minmax(0,1fr) !important; }
|
||||||
|
.bm-toc { display: none !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.bm-stats { grid-template-columns: 1fr !important; }
|
||||||
|
.bm-lede::first-letter { font-size: 3em; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bm-char { transform: none !important; opacity: 1 !important; }
|
||||||
|
.bm-cta-btn:hover { transform: none; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
src/components/blog/FeaturedArticle.tsx
Normal file
153
src/components/blog/FeaturedArticle.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import TopicCanvas, { TopicVariant } from './TopicCanvas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lead article. The whole card is one link; the arrow badge is magnetic
|
||||||
|
* and the card tilts a few degrees toward the cursor, both driven off a
|
||||||
|
* single pointermove and applied via transforms only.
|
||||||
|
*/
|
||||||
|
export default function FeaturedArticle({
|
||||||
|
article,
|
||||||
|
href,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
article: { n: string; tag: string; readTime: string; title: string; excerpt: string };
|
||||||
|
href: string;
|
||||||
|
color: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const magnetRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
|
||||||
|
const onMove = (e: React.PointerEvent) => {
|
||||||
|
const card = cardRef.current;
|
||||||
|
if (!card) return;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
const px = (e.clientX - r.left) / r.width - 0.5;
|
||||||
|
const py = (e.clientY - r.top) / r.height - 0.5;
|
||||||
|
card.style.transform = `perspective(1400px) rotateX(${-py * 2.4}deg) rotateY(${px * 2.4}deg) translateY(-4px)`;
|
||||||
|
const magnet = magnetRef.current;
|
||||||
|
if (magnet) {
|
||||||
|
const m = magnet.getBoundingClientRect();
|
||||||
|
const dx = e.clientX - (m.left + m.width / 2);
|
||||||
|
const dy = e.clientY - (m.top + m.height / 2);
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const pull = Math.max(0, 1 - dist / 260);
|
||||||
|
magnet.style.transform = `translate(${dx * pull * 0.32}px, ${dy * pull * 0.32}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setHover(false);
|
||||||
|
if (cardRef.current) cardRef.current.style.transform = '';
|
||||||
|
if (magnetRef.current) magnetRef.current.style.transform = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} style={{ textDecoration: 'none', display: 'block' }} aria-label={`Read: ${article.title}`}>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerEnter={() => setHover(true)}
|
||||||
|
onPointerLeave={reset}
|
||||||
|
className="bm-featured"
|
||||||
|
style={{
|
||||||
|
position: 'relative', borderRadius: 28, overflow: 'hidden',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid rgba(0,0,0,0.09)',
|
||||||
|
boxShadow: hover
|
||||||
|
? `0 2px 8px rgba(16,16,32,0.06), 0 44px 90px -36px ${color}80`
|
||||||
|
: '0 1px 2px rgba(16,16,32,0.05), 0 18px 48px -18px rgba(16,16,32,0.20)',
|
||||||
|
transition: 'transform 0.5s cubic-bezier(0.22,1,0.36,1), box-shadow 0.5s ease, border-color 0.4s ease',
|
||||||
|
borderColor: hover ? `${color}66` : 'rgba(0,0,0,0.09)',
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="bm-featured-grid" style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '1.02fr 0.98fr', alignItems: 'stretch',
|
||||||
|
}}>
|
||||||
|
{/* Left — editorial copy */}
|
||||||
|
<div className="bm-featured-copy" style={{ padding: 'clamp(36px, 4vw, 64px)', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
fontSize: 11, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase',
|
||||||
|
color, fontFamily: 'Sora, sans-serif',
|
||||||
|
background: `${color}14`, padding: '6px 12px', borderRadius: 100,
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
}}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2">{icon}</svg>
|
||||||
|
{article.tag}
|
||||||
|
</span>
|
||||||
|
<span style={{ width: 3, height: 3, borderRadius: '50%', background: 'rgba(0,0,0,0.2)' }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif' }}>{article.readTime} read</span>
|
||||||
|
<span style={{ width: 3, height: 3, borderRadius: '50%', background: 'rgba(0,0,0,0.2)' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.4)', fontFamily: 'Sora, sans-serif' }}>Featured</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="bm-featured-title" style={{
|
||||||
|
fontSize: 'clamp(32px, 4.2vw, 62px)', fontWeight: 800, letterSpacing: '-0.045em',
|
||||||
|
lineHeight: 1.02, color: '#0a0a0f', margin: '0 0 22px', fontFamily: 'Sora, sans-serif',
|
||||||
|
}}>
|
||||||
|
{article.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 16, lineHeight: 1.75, color: 'rgba(0,0,0,0.5)', fontFamily: 'Sora, sans-serif', margin: '0 0 40px', maxWidth: 460 }}>
|
||||||
|
{article.excerpt}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<span ref={magnetRef} style={{
|
||||||
|
width: 52, height: 52, borderRadius: '50%', flexShrink: 0,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: hover ? color : 'transparent',
|
||||||
|
border: `1px solid ${hover ? color : 'rgba(0,0,0,0.15)'}`,
|
||||||
|
transition: 'background 0.4s ease, border-color 0.4s ease, transform 0.35s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
willChange: 'transform',
|
||||||
|
}}>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke={hover ? '#0a0a0f' : 'rgba(0,0,0,0.5)'} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transition: 'stroke 0.4s ease' }}>
|
||||||
|
<path d="M5 12h14M13 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: '#0a0a0f', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.01em' }}>
|
||||||
|
Read article
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — generative artwork */}
|
||||||
|
<div className="bm-featured-art" style={{
|
||||||
|
position: 'relative', overflow: 'hidden', minHeight: 380,
|
||||||
|
background: `linear-gradient(150deg, ${color}12 0%, ${color}04 55%, transparent 100%)`,
|
||||||
|
borderLeft: '1px solid rgba(0,0,0,0.07)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
backgroundImage: 'linear-gradient(rgba(0,0,0,0.028) 1px,transparent 1px),linear-gradient(90deg,rgba(0,0,0,0.028) 1px,transparent 1px)',
|
||||||
|
backgroundSize: '44px 44px',
|
||||||
|
}} />
|
||||||
|
<TopicCanvas
|
||||||
|
variant={article.tag as TopicVariant}
|
||||||
|
color={color}
|
||||||
|
intensity={hover ? 1 : 0}
|
||||||
|
style={{ position: 'absolute', inset: 0 }}
|
||||||
|
/>
|
||||||
|
<div aria-hidden="true" style={{
|
||||||
|
position: 'absolute', bottom: 'clamp(-18px, -1.4vw, -8px)', right: 24,
|
||||||
|
fontSize: 'clamp(96px, 11vw, 190px)', fontWeight: 900, lineHeight: 0.78,
|
||||||
|
color: 'transparent', WebkitTextStroke: `1px ${color}38`,
|
||||||
|
fontFamily: 'Sora, sans-serif', letterSpacing: '-0.07em', userSelect: 'none',
|
||||||
|
transform: hover ? 'translateY(-6px)' : 'translateY(0)',
|
||||||
|
transition: 'transform 0.7s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
}}>{article.n}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/blog/FilterBar.tsx
Normal file
111
src/components/blog/FilterBar.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Topic filter with an indicator that slides between tabs rather than
|
||||||
|
* popping. The indicator is positioned from the measured geometry of the
|
||||||
|
* active button, so it stays correct across wrapping and resizes.
|
||||||
|
*/
|
||||||
|
export default function FilterBar({
|
||||||
|
filters,
|
||||||
|
active,
|
||||||
|
counts,
|
||||||
|
colorFor,
|
||||||
|
iconFor,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
filters: string[];
|
||||||
|
active: string;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
colorFor: (f: string) => string;
|
||||||
|
iconFor: (f: string) => React.ReactNode;
|
||||||
|
onChange: (f: string) => void;
|
||||||
|
}) {
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
const [pill, setPill] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
const btn = btnRefs.current[active];
|
||||||
|
if (!wrap || !btn) return;
|
||||||
|
const w = wrap.getBoundingClientRect();
|
||||||
|
const b = btn.getBoundingClientRect();
|
||||||
|
setPill({ x: b.left - w.left, y: b.top - w.top, w: b.width, h: b.height });
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => { measure(); }, [measure]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return;
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(wrap);
|
||||||
|
// Fonts settle after hydration and shift button widths.
|
||||||
|
document.fonts?.ready.then(measure).catch(() => {});
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [measure]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Filter articles by topic"
|
||||||
|
style={{ position: 'relative', display: 'flex', gap: 6, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
{pill && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', left: 0, top: 0, borderRadius: 100,
|
||||||
|
width: pill.w, height: pill.h,
|
||||||
|
transform: `translate3d(${pill.x}px, ${pill.y}px, 0)`,
|
||||||
|
background: '#0a0a0f',
|
||||||
|
transition: 'transform 0.5s cubic-bezier(0.22,1,0.36,1), width 0.5s cubic-bezier(0.22,1,0.36,1), height 0.5s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
pointerEvents: 'none', zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filters.map(f => {
|
||||||
|
const isActive = active === f;
|
||||||
|
const color = colorFor(f);
|
||||||
|
const count = counts[f] ?? 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
ref={el => { btnRefs.current[f] = el; }}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => onChange(f)}
|
||||||
|
className="bm-filter"
|
||||||
|
style={{
|
||||||
|
position: 'relative', zIndex: 1,
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||||
|
padding: '11px 18px', borderRadius: 100,
|
||||||
|
background: 'transparent',
|
||||||
|
color: isActive ? color : 'rgba(0,0,0,0.5)',
|
||||||
|
fontSize: 13, fontWeight: 700, fontFamily: 'Sora, sans-serif',
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
border: `1px solid ${isActive ? 'transparent' : 'rgba(0,0,0,0.16)'}`,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'color 0.3s ease, border-color 0.3s ease',
|
||||||
|
WebkitTapHighlightColor: 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={isActive ? color : 'rgba(0,0,0,0.4)'} strokeWidth="2" style={{ transition: 'stroke 0.3s ease' }}>
|
||||||
|
{iconFor(f)}
|
||||||
|
</svg>
|
||||||
|
{f}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 10, fontWeight: 700, fontVariantNumeric: 'tabular-nums',
|
||||||
|
color: isActive ? color : 'rgba(0,0,0,0.3)',
|
||||||
|
opacity: isActive ? 0.8 : 0.7, transition: 'color 0.3s ease',
|
||||||
|
}}>{count}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/blog/ReadingProgress.tsx
Normal file
113
src/components/blog/ReadingProgress.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const R = 15;
|
||||||
|
const CIRC = 2 * Math.PI * R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll-linked reading progress for a target element: a hairline bar across
|
||||||
|
* the top plus a percentage puck that fades in once reading starts.
|
||||||
|
*
|
||||||
|
* Progress is measured against the article rather than the document, so the
|
||||||
|
* hero and footer don't count as "read". Updates are written straight to the
|
||||||
|
* DOM instead of through state — this runs on every scroll event, and a React
|
||||||
|
* render per event would be wasted work.
|
||||||
|
*/
|
||||||
|
export default function ReadingProgress({
|
||||||
|
targetRef,
|
||||||
|
color = '#f4be28',
|
||||||
|
}: {
|
||||||
|
targetRef: React.RefObject<HTMLElement | null>;
|
||||||
|
color?: string;
|
||||||
|
}) {
|
||||||
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
const puckRef = useRef<HTMLDivElement>(null);
|
||||||
|
const ringRef = useRef<SVGCircleElement>(null);
|
||||||
|
const labelRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const compute = () => {
|
||||||
|
const el = targetRef.current;
|
||||||
|
const bar = barRef.current;
|
||||||
|
if (!el || !bar) return;
|
||||||
|
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const start = rect.top + window.scrollY;
|
||||||
|
const line = window.innerHeight * 0.35;
|
||||||
|
// Fully read once the article's end reaches the reading line.
|
||||||
|
const span = Math.max(1, rect.height - line);
|
||||||
|
const pct = Math.min(1, Math.max(0, (window.scrollY - start + line) / span));
|
||||||
|
|
||||||
|
bar.style.transform = `scaleX(${pct})`;
|
||||||
|
bar.style.opacity = pct > 0 ? '1' : '0';
|
||||||
|
|
||||||
|
const puck = puckRef.current;
|
||||||
|
if (puck) {
|
||||||
|
const show = pct > 0.001 && pct < 0.999;
|
||||||
|
puck.style.opacity = show ? '1' : '0';
|
||||||
|
puck.style.transform = show ? 'translateY(0)' : 'translateY(12px)';
|
||||||
|
}
|
||||||
|
if (ringRef.current) ringRef.current.style.strokeDashoffset = String(CIRC * (1 - pct));
|
||||||
|
if (labelRef.current) labelRef.current.textContent = `${Math.round(pct * 100)}% read`;
|
||||||
|
};
|
||||||
|
|
||||||
|
compute();
|
||||||
|
window.addEventListener('scroll', compute, { passive: true });
|
||||||
|
window.addEventListener('resize', compute);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', compute);
|
||||||
|
window.removeEventListener('resize', compute);
|
||||||
|
};
|
||||||
|
}, [targetRef]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div aria-hidden="true" style={{ position: 'fixed', top: 0, left: 0, right: 0, height: 3, zIndex: 60, pointerEvents: 'none' }}>
|
||||||
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
data-testid="reading-bar"
|
||||||
|
style={{
|
||||||
|
height: '100%', width: '100%',
|
||||||
|
transform: 'scaleX(0)', transformOrigin: 'left center',
|
||||||
|
background: `linear-gradient(90deg, ${color}, #ff9500)`,
|
||||||
|
boxShadow: `0 0 14px ${color}90`,
|
||||||
|
opacity: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={puckRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="reading-puck"
|
||||||
|
style={{
|
||||||
|
position: 'fixed', right: 22, bottom: 22, zIndex: 60,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 9,
|
||||||
|
padding: '9px 14px', borderRadius: 100,
|
||||||
|
background: 'rgba(10,10,15,0.82)', backdropFilter: 'blur(14px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)',
|
||||||
|
opacity: 0, transform: 'translateY(12px)',
|
||||||
|
transition: 'opacity 0.35s ease, transform 0.35s cubic-bezier(0.22,1,0.36,1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 36 36" style={{ transform: 'rotate(-90deg)' }}>
|
||||||
|
<circle cx="18" cy="18" r={R} fill="none" stroke="rgba(255,255,255,0.16)" strokeWidth="4" />
|
||||||
|
<circle
|
||||||
|
ref={ringRef}
|
||||||
|
cx="18" cy="18" r={R} fill="none" stroke={color} strokeWidth="4" strokeLinecap="round"
|
||||||
|
strokeDasharray={CIRC} strokeDashoffset={CIRC}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
ref={labelRef}
|
||||||
|
data-testid="reading-label"
|
||||||
|
style={{ fontSize: 11, fontWeight: 700, color: '#fff', fontFamily: 'Sora, sans-serif', fontVariantNumeric: 'tabular-nums', letterSpacing: '0.02em' }}
|
||||||
|
>
|
||||||
|
0% read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/blog/SplitHeading.tsx
Normal file
79
src/components/blog/SplitHeading.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useLayoutEffect, useRef } from 'react';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a heading into per-character spans and reveals them from behind a
|
||||||
|
* per-word mask. Each word is its own overflow-hidden box, so characters wipe
|
||||||
|
* up into place without the mask clipping descenders across the whole line.
|
||||||
|
*/
|
||||||
|
export default function SplitHeading({
|
||||||
|
text,
|
||||||
|
delay = 0,
|
||||||
|
gradientStyle,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
delay?: number;
|
||||||
|
/**
|
||||||
|
* Styles for a `background-clip: text` gradient line. Pass this instead of
|
||||||
|
* putting the gradient in `style`: background-clip only clips to text the
|
||||||
|
* element paints itself, so a gradient on the wrapper never reaches glyphs
|
||||||
|
* living in split child spans — the line renders invisible. When set, the
|
||||||
|
* line stays unsplit and reveals as one unit.
|
||||||
|
*/
|
||||||
|
gradientStyle?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const root = ref.current;
|
||||||
|
if (!root) return;
|
||||||
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const chars = root.querySelectorAll('.bm-char');
|
||||||
|
gsap.fromTo(
|
||||||
|
chars,
|
||||||
|
{ yPercent: 115, opacity: 0 },
|
||||||
|
{ yPercent: 0, opacity: 1, duration: 1, ease: 'power4.out', stagger: chars.length > 1 ? 0.018 : 0, delay }
|
||||||
|
);
|
||||||
|
}, root);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, [text, delay, gradientStyle]);
|
||||||
|
|
||||||
|
if (gradientStyle) {
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={className} style={{ display: 'inline-block', ...style }}>
|
||||||
|
<span style={{ display: 'inline-block', overflow: 'hidden', verticalAlign: 'bottom', paddingBottom: '0.06em' }}>
|
||||||
|
{/* One element holds both the gradient and the text, so background-clip has glyphs to clip to. */}
|
||||||
|
<span className="bm-char" style={{ display: 'inline-block', willChange: 'transform', ...gradientStyle }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const words = text.split(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={className} style={{ display: 'inline-block', ...style }}>
|
||||||
|
{words.map((word, wi) => (
|
||||||
|
<React.Fragment key={wi}>
|
||||||
|
<span style={{ display: 'inline-block', overflow: 'hidden', verticalAlign: 'bottom', paddingBottom: '0.06em' }}>
|
||||||
|
{word.split('').map((ch, ci) => (
|
||||||
|
<span key={ci} className="bm-char" style={{ display: 'inline-block', willChange: 'transform' }}>{ch}</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{/* A real space text node, so the heading still reads and copies as
|
||||||
|
words rather than one run-on string. */}
|
||||||
|
{wi < words.length - 1 && ' '}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/components/blog/TopicCanvas.tsx
Normal file
361
src/components/blog/TopicCanvas.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export type TopicVariant = 'Analytics' | 'Strategy' | 'Technology' | 'Growth' | 'Future';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generative accent artwork for a blog topic. Each variant paints a different
|
||||||
|
* abstract system in the topic's colour, so every article reads as distinct
|
||||||
|
* without needing a photograph.
|
||||||
|
*
|
||||||
|
* The canvas idles at a low frame cost: it stops entirely when scrolled out of
|
||||||
|
* view, and renders a single static frame when the user prefers reduced motion.
|
||||||
|
* `intensity` is driven by hover — 0 is at rest, 1 is fully energised.
|
||||||
|
*/
|
||||||
|
export default function TopicCanvas({
|
||||||
|
variant,
|
||||||
|
color,
|
||||||
|
intensity = 0,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
variant: TopicVariant;
|
||||||
|
color: string;
|
||||||
|
intensity?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
// Read by the animation loop so hover changes never restart it.
|
||||||
|
const intensityRef = useRef(intensity);
|
||||||
|
const easedRef = useRef(intensity);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
intensityRef.current = intensity;
|
||||||
|
}, [intensity]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
let width = 0;
|
||||||
|
let height = 0;
|
||||||
|
let frame = 0;
|
||||||
|
let t = 0;
|
||||||
|
let onScreen = true;
|
||||||
|
|
||||||
|
const rgb = hexToRgb(color);
|
||||||
|
const stroke = (a: number) => `rgba(${rgb.r},${rgb.g},${rgb.b},${a})`;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) return;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
width = rect.width;
|
||||||
|
height = rect.height;
|
||||||
|
canvas.width = Math.round(width * dpr);
|
||||||
|
canvas.height = Math.round(height * dpr);
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
if (!width || !height) return;
|
||||||
|
// Ease the hover intensity so entering/leaving the card is never abrupt.
|
||||||
|
easedRef.current += (intensityRef.current - easedRef.current) * 0.07;
|
||||||
|
const k = easedRef.current;
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
PAINTERS[variant](ctx, width, height, t, k, stroke);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loop = () => {
|
||||||
|
t += 0.016 * (0.6 + easedRef.current * 0.9);
|
||||||
|
draw();
|
||||||
|
frame = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (frame || reduceMotion) return;
|
||||||
|
frame = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
if (!frame) return;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
frame = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
draw();
|
||||||
|
// Start eagerly rather than waiting on the observer: the loop must run even
|
||||||
|
// if the first intersection callback is delayed. The observer below only
|
||||||
|
// pauses and resumes it.
|
||||||
|
start();
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
resize();
|
||||||
|
draw();
|
||||||
|
});
|
||||||
|
ro.observe(canvas);
|
||||||
|
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
onScreen = entry.isIntersecting;
|
||||||
|
if (onScreen) start();
|
||||||
|
else stop();
|
||||||
|
},
|
||||||
|
{ threshold: 0 }
|
||||||
|
);
|
||||||
|
io.observe(canvas);
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.hidden || !onScreen) stop();
|
||||||
|
else start();
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
ro.disconnect();
|
||||||
|
io.disconnect();
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
};
|
||||||
|
}, [variant, color]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} aria-hidden="true" className={className} style={{ display: 'block', width: '100%', height: '100%', ...style }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stroke = (alpha: number) => string;
|
||||||
|
type Painter = (ctx: CanvasRenderingContext2D, w: number, h: number, t: number, k: number, stroke: Stroke) => void;
|
||||||
|
|
||||||
|
/** Deterministic pseudo-random so every render of a variant looks the same. */
|
||||||
|
function rand(seed: number) {
|
||||||
|
const x = Math.sin(seed * 127.1) * 43758.5453;
|
||||||
|
return x - Math.floor(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string) {
|
||||||
|
const clean = hex.replace('#', '');
|
||||||
|
const full = clean.length === 3 ? clean.split('').map(c => c + c).join('') : clean;
|
||||||
|
const int = parseInt(full, 16);
|
||||||
|
return { r: (int >> 16) & 255, g: (int >> 8) & 255, b: int & 255 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAINTERS: Record<TopicVariant, Painter> = {
|
||||||
|
// Breathing bar field over a baseline — footfall counts over time.
|
||||||
|
Analytics: (ctx, w, h, t, k, stroke) => {
|
||||||
|
const count = 26;
|
||||||
|
const pad = w * 0.12;
|
||||||
|
const usable = w - pad * 2;
|
||||||
|
const gap = usable / count;
|
||||||
|
const barW = gap * 0.42;
|
||||||
|
const base = h * 0.78;
|
||||||
|
const maxH = h * 0.5;
|
||||||
|
|
||||||
|
ctx.strokeStyle = stroke(0.14 + k * 0.1);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad, base + 0.5);
|
||||||
|
ctx.lineTo(w - pad, base + 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const seed = rand(i + 1);
|
||||||
|
const wave = Math.sin(t * (0.8 + seed * 0.7) + i * 0.45);
|
||||||
|
const amp = 0.28 + seed * 0.5;
|
||||||
|
const bh = maxH * amp * (0.62 + 0.38 * ((wave + 1) / 2)) * (0.8 + k * 0.28);
|
||||||
|
const x = pad + gap * i + (gap - barW) / 2;
|
||||||
|
ctx.fillStyle = stroke(0.16 + amp * 0.3 + k * 0.22);
|
||||||
|
roundRect(ctx, x, base - bh, barW, bh, Math.min(barW / 2, 3));
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Two orbits crossing a decision point — routes converging on a choice.
|
||||||
|
Strategy: (ctx, w, h, t, k, stroke) => {
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h / 2;
|
||||||
|
const r = Math.min(w, h) * 0.3;
|
||||||
|
|
||||||
|
for (let ring = 0; ring < 3; ring++) {
|
||||||
|
const rr = r * (0.55 + ring * 0.32);
|
||||||
|
ctx.strokeStyle = stroke(0.1 + k * 0.08);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, rr, rr * 0.42, (t * 0.12) + ring * 1.05, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const a = t * (0.5 + ring * 0.22) + ring * 2.1;
|
||||||
|
const ex = Math.cos(a) * rr;
|
||||||
|
const ey = Math.sin(a) * rr * 0.42;
|
||||||
|
const rot = (t * 0.12) + ring * 1.05;
|
||||||
|
const px = cx + ex * Math.cos(rot) - ey * Math.sin(rot);
|
||||||
|
const py = cy + ex * Math.sin(rot) + ey * Math.cos(rot);
|
||||||
|
ctx.fillStyle = stroke(0.5 + k * 0.4);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(px, py, 2.6 + k * 1.4, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pulse = (Math.sin(t * 1.6) + 1) / 2;
|
||||||
|
ctx.fillStyle = stroke(0.28 + pulse * 0.3 + k * 0.3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, 4 + pulse * 2, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Node graph with travelling packets — connected systems.
|
||||||
|
Technology: (ctx, w, h, t, k, stroke) => {
|
||||||
|
const nodes = 9;
|
||||||
|
const pts: { x: number; y: number }[] = [];
|
||||||
|
for (let i = 0; i < nodes; i++) {
|
||||||
|
const sx = rand(i * 3 + 1);
|
||||||
|
const sy = rand(i * 7 + 2);
|
||||||
|
const drift = 0.012 + k * 0.01;
|
||||||
|
pts.push({
|
||||||
|
x: w * (0.14 + sx * 0.72) + Math.sin(t * 0.5 + i) * w * drift,
|
||||||
|
y: h * (0.16 + sy * 0.68) + Math.cos(t * 0.42 + i * 1.4) * h * drift,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < nodes; i++) {
|
||||||
|
for (let j = i + 1; j < nodes; j++) {
|
||||||
|
const dx = pts[i].x - pts[j].x;
|
||||||
|
const dy = pts[i].y - pts[j].y;
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
const reach = Math.min(w, h) * 0.46;
|
||||||
|
if (dist > reach) continue;
|
||||||
|
const fade = 1 - dist / reach;
|
||||||
|
ctx.strokeStyle = stroke(fade * (0.16 + k * 0.16));
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[i].x, pts[i].y);
|
||||||
|
ctx.lineTo(pts[j].x, pts[j].y);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const travel = ((t * 0.4 + rand(i * j + 5)) % 1);
|
||||||
|
ctx.fillStyle = stroke(fade * (0.5 + k * 0.45));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pts[i].x + -dx * travel, pts[i].y + -dy * travel, 1.5, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pts.forEach((p, i) => {
|
||||||
|
const pulse = (Math.sin(t * 1.4 + i * 0.9) + 1) / 2;
|
||||||
|
ctx.fillStyle = stroke(0.42 + pulse * 0.28 + k * 0.3);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, 2.4 + pulse * 1.2 + k, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rising trend line with an area wash — compounding growth.
|
||||||
|
Growth: (ctx, w, h, t, k, stroke) => {
|
||||||
|
const pad = w * 0.12;
|
||||||
|
const usable = w - pad * 2;
|
||||||
|
const base = h * 0.8;
|
||||||
|
const top = h * 0.24;
|
||||||
|
const steps = 60;
|
||||||
|
const pts: { x: number; y: number }[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= steps; i++) {
|
||||||
|
const p = i / steps;
|
||||||
|
const x = pad + usable * p;
|
||||||
|
const climb = Math.pow(p, 1.5);
|
||||||
|
const noise = Math.sin(p * 7 + t * 1.1) * 0.05 + Math.sin(p * 15 - t * 0.7) * 0.025;
|
||||||
|
const y = base - (base - top) * (climb + noise) * (0.9 + k * 0.12);
|
||||||
|
pts.push({ x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0].x, base);
|
||||||
|
pts.forEach(p => ctx.lineTo(p.x, p.y));
|
||||||
|
ctx.lineTo(pts[pts.length - 1].x, base);
|
||||||
|
ctx.closePath();
|
||||||
|
const grad = ctx.createLinearGradient(0, top, 0, base);
|
||||||
|
grad.addColorStop(0, stroke(0.22 + k * 0.14));
|
||||||
|
grad.addColorStop(1, stroke(0));
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
pts.forEach((p, i) => (i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)));
|
||||||
|
ctx.strokeStyle = stroke(0.55 + k * 0.35);
|
||||||
|
ctx.lineWidth = 1.6;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
const head = pts[pts.length - 1];
|
||||||
|
const pulse = (Math.sin(t * 2) + 1) / 2;
|
||||||
|
ctx.fillStyle = stroke(0.18 + k * 0.16);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(head.x, head.y, 5 + pulse * 4 + k * 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.fillStyle = stroke(0.85);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(head.x, head.y, 3, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Radar sweep with expanding pings — sensing what's ahead.
|
||||||
|
Future: (ctx, w, h, t, k, stroke) => {
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h * 0.56;
|
||||||
|
const maxR = Math.min(w, h) * 0.42;
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const rr = maxR * (0.28 + i * 0.24);
|
||||||
|
ctx.strokeStyle = stroke(0.1 + k * 0.06);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const p = ((t * 0.32 + i / 3) % 1);
|
||||||
|
const rr = maxR * p;
|
||||||
|
ctx.strokeStyle = stroke((1 - p) * (0.4 + k * 0.3));
|
||||||
|
ctx.lineWidth = 1.4;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweep = t * 0.7;
|
||||||
|
const grad = ctx.createConicGradient ? ctx.createConicGradient(sweep, cx, cy) : null;
|
||||||
|
if (grad) {
|
||||||
|
grad.addColorStop(0, stroke(0.24 + k * 0.16));
|
||||||
|
grad.addColorStop(0.12, stroke(0));
|
||||||
|
grad.addColorStop(1, stroke(0));
|
||||||
|
ctx.fillStyle = grad;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, maxR, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const a = rand(i + 3) * Math.PI * 2;
|
||||||
|
const d = maxR * (0.3 + rand(i + 11) * 0.62);
|
||||||
|
const blip = Math.max(0, Math.sin(t * 0.7 - i * 1.2));
|
||||||
|
ctx.fillStyle = stroke(0.25 + blip * 0.5 + k * 0.2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + Math.cos(a) * d, cy + Math.sin(a) * d, 2 + blip * 1.6, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
|
||||||
|
const rr = Math.min(r, w / 2, h / 2);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + rr, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
||||||
|
ctx.arcTo(x, y + h, x, y, rr);
|
||||||
|
ctx.arcTo(x, y, x + w, y, rr);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
48
src/components/blog/topics.tsx
Normal file
48
src/components/blog/topics.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/** Accent colour + glyph for each editorial topic. Shared by the listing and
|
||||||
|
* the article page so a topic looks identical wherever it appears. */
|
||||||
|
export const TAG_META: Record<string, { color: string; icon: React.ReactNode }> = {
|
||||||
|
Analytics: {
|
||||||
|
color: '#f4be28',
|
||||||
|
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 3v18h18M8 17V9m5 8V5m5 12v-6" />,
|
||||||
|
},
|
||||||
|
Strategy: {
|
||||||
|
color: '#a78bfa',
|
||||||
|
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />,
|
||||||
|
},
|
||||||
|
Technology: {
|
||||||
|
color: '#34d399',
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="4" width="18" height="14" rx="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 21h8M12 18v3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Growth: {
|
||||||
|
color: '#3b82f6',
|
||||||
|
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M3 17l6-6 4 4 8-8M21 7v6M21 7h-6" />,
|
||||||
|
},
|
||||||
|
Future: {
|
||||||
|
color: '#ec4899',
|
||||||
|
icon: <path strokeLinecap="round" strokeLinejoin="round" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ALL_META = {
|
||||||
|
color: '#0a0a0f',
|
||||||
|
icon: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metaFor = (tag: string) => TAG_META[tag] ?? ALL_META;
|
||||||
|
|
||||||
|
/** The neutral the blog pages sit on. White cards need a toned surface to read against. */
|
||||||
|
export const BLOG_CANVAS = '#ededf2';
|
||||||
@@ -128,14 +128,14 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
if (id === 1) {
|
if (id === 1) {
|
||||||
const cells = Array.from({ length: 48 });
|
const cells = Array.from({ length: 48 });
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '86%', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div className="pv-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<span style={{ fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', fontWeight: 600 }}>Live footfall</span>
|
<span style={{ fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', fontWeight: 600 }}>Live footfall</span>
|
||||||
<span style={{ fontSize: 11, color: accent, fontFamily: 'Sora, sans-serif', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 5 }}>
|
<span style={{ fontSize: 11, color: accent, fontFamily: 'Sora, sans-serif', fontWeight: 700, display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span className="pv-live-dot" style={{ width: 6, height: 6, borderRadius: '50%', background: '#4ade80', display: 'block' }} />2,847
|
<span className="pv-live-dot" style={{ width: 6, height: 6, borderRadius: '50%', background: '#4ade80', display: 'block' }} />2,847
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8,1fr)', gap: 5 }}>
|
<div className="pv-heat">
|
||||||
{cells.map((_, i) => {
|
{cells.map((_, i) => {
|
||||||
const intensity = Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6));
|
const intensity = Math.abs(Math.sin(i * 1.7) * Math.cos(i * 0.6));
|
||||||
const alpha = (0.08 + intensity * 0.7).toFixed(3);
|
const alpha = (0.08 + intensity * 0.7).toFixed(3);
|
||||||
@@ -151,11 +151,11 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div className="pv-stats">
|
||||||
{[['Peak', '6–8 PM'], ['Dwell', '14 min'], ['Zones', '12']].map(([k, v]) => (
|
{[['Peak', '6–8 PM'], ['Dwell', '14 min'], ['Zones', '12']].map(([k, v]) => (
|
||||||
<div key={k} style={{ flex: 1, padding: '10px 12px', borderRadius: 12, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
<div key={k} className="pv-stat">
|
||||||
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif' }}>{k}</div>
|
<div className="pv-stat-k">{k}</div>
|
||||||
<div style={{ fontSize: 15, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif', marginTop: 2 }}>{v}</div>
|
<div className="pv-stat-v">{v}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -170,7 +170,7 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
const w = 280, h = 120;
|
const w = 280, h = 120;
|
||||||
const path = pts.map((p, i) => `${(i / (pts.length - 1)) * w},${h - (p / max) * h}`).join(' ');
|
const path = pts.map((p, i) => `${(i / (pts.length - 1)) * w},${h - (p / max) * h}`).join(' ');
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '86%', display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div className="pv-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', fontWeight: 600 }}>Avg basket value</div>
|
<div style={{ fontSize: 11, letterSpacing: '0.12em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif', fontWeight: 600 }}>Avg basket value</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginTop: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, marginTop: 4 }}>
|
||||||
@@ -205,7 +205,7 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
// 3 — Dyscount: smart offer + conversion uplift
|
// 3 — Dyscount: smart offer + conversion uplift
|
||||||
if (id === 3) {
|
if (id === 3) {
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '82%', position: 'relative', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div className="pv-wrap pv-wrap-narrow" style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
{/* Live campaign badge, floating on the card edge */}
|
{/* Live campaign badge, floating on the card edge */}
|
||||||
<div className="pv-streak-badge" style={{ position: 'absolute', top: -34, right: 18, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 99, background: '#171512', border: `1px solid ${accent}55`, boxShadow: '0 8px 20px -8px rgba(0,0,0,0.6)' }}>
|
<div className="pv-streak-badge" style={{ position: 'absolute', top: -34, right: 18, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 99, background: '#171512', border: `1px solid ${accent}55`, boxShadow: '0 8px 20px -8px rgba(0,0,0,0.6)' }}>
|
||||||
<span className="pv-live-dot" style={{ width: 6, height: 6, borderRadius: '50%', background: accent, display: 'block' }} />
|
<span className="pv-live-dot" style={{ width: 6, height: 6, borderRadius: '50%', background: accent, display: 'block' }} />
|
||||||
@@ -215,17 +215,17 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
{/* Coupon-style offer card with perforated divider */}
|
{/* Coupon-style offer card with perforated divider */}
|
||||||
<div style={{ borderRadius: 16, background: `linear-gradient(135deg, ${accent} 0%, #c0392b 100%)`, position: 'relative', overflow: 'hidden', display: 'flex' }}>
|
<div style={{ borderRadius: 16, background: `linear-gradient(135deg, ${accent} 0%, #c0392b 100%)`, position: 'relative', overflow: 'hidden', display: 'flex' }}>
|
||||||
<div className="pv-sheen" />
|
<div className="pv-sheen" />
|
||||||
<div style={{ flex: 1, padding: '16px 18px' }}>
|
<div className="pv-coupon-body" style={{ flex: 1, minWidth: 0, padding: '16px 18px' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.6)', fontFamily: 'Sora, sans-serif' }}>Smart Offer</div>
|
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: 'rgba(0,0,0,0.6)', fontFamily: 'Sora, sans-serif' }}>Smart Offer</div>
|
||||||
<div style={{ fontSize: 30, fontWeight: 900, color: '#fff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.02em', marginTop: 2 }}>15% OFF</div>
|
<div className="pv-coupon-amount" style={{ fontWeight: 900, color: '#fff', fontFamily: 'Sora, sans-serif', letterSpacing: '-0.02em', marginTop: 2 }}>15% OFF</div>
|
||||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.85)', fontFamily: 'Sora, sans-serif', marginTop: 2 }}>Targeted · high-intent shoppers</div>
|
<div className="pv-coupon-sub" style={{ color: 'rgba(255,255,255,0.85)', fontFamily: 'Sora, sans-serif', marginTop: 2 }}>Targeted · high-intent shoppers</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Perforated tear-off edge */}
|
{/* Perforated tear-off edge */}
|
||||||
<div style={{ position: 'relative', width: 0, borderLeft: '2px dashed rgba(0,0,0,0.25)' }}>
|
<div style={{ position: 'relative', width: 0, borderLeft: '2px dashed rgba(0,0,0,0.25)' }}>
|
||||||
<span style={{ position: 'absolute', top: -8, left: -7, width: 14, height: 14, borderRadius: '50%', background: '#171512' }} />
|
<span style={{ position: 'absolute', top: -8, left: -7, width: 14, height: 14, borderRadius: '50%', background: '#171512' }} />
|
||||||
<span style={{ position: 'absolute', bottom: -8, left: -7, width: 14, height: 14, borderRadius: '50%', background: '#171512' }} />
|
<span style={{ position: 'absolute', bottom: -8, left: -7, width: 14, height: 14, borderRadius: '50%', background: '#171512' }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div className="pv-coupon-rail" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, fontWeight: 800, color: 'rgba(0,0,0,0.55)', fontFamily: 'Sora, sans-serif', writingMode: 'vertical-rl', letterSpacing: '0.1em' }}>DYSCOUNT</span>
|
<span style={{ fontSize: 11, fontWeight: 800, color: 'rgba(0,0,0,0.55)', fontFamily: 'Sora, sans-serif', writingMode: 'vertical-rl', letterSpacing: '0.1em' }}>DYSCOUNT</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,11 +241,11 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mini stat grid */}
|
{/* Mini stat grid */}
|
||||||
<div style={{ display: 'flex', gap: 10 }}>
|
<div className="pv-stats">
|
||||||
{[['Margin protected', '100%'], ['Waste', '0'], ['Redemptions', '1.2k']].map(([k, v]) => (
|
{[['Margin protected', '100%'], ['Waste', '0'], ['Redemptions', '1.2k']].map(([k, v]) => (
|
||||||
<div key={k} style={{ flex: 1, padding: '10px 12px', borderRadius: 12, background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
<div key={k} className="pv-stat">
|
||||||
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.4)', fontFamily: 'Sora, sans-serif' }}>{k}</div>
|
<div className="pv-stat-k">{k}</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#fff', fontFamily: 'Sora, sans-serif', marginTop: 2 }}>{v}</div>
|
<div className="pv-stat-v">{v}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -259,7 +259,7 @@ function ProductVisual({ product }: { product: Product }) {
|
|||||||
['Nike Store', '+85', '#374151'],
|
['Nike Store', '+85', '#374151'],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '82%', borderRadius: 24, padding: 22, background: 'linear-gradient(165deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)', border: '1px solid rgba(255,255,255,0.1)', boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06)', position: 'relative', display: 'flex', flexDirection: 'column', gap: 18 }}>
|
<div className="pv-wrap pv-wrap-narrow" style={{ borderRadius: 24, padding: 22, background: 'linear-gradient(165deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)', border: '1px solid rgba(255,255,255,0.1)', boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.06)', position: 'relative', display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||||
{/* Streak badge, floating on the card edge */}
|
{/* Streak badge, floating on the card edge */}
|
||||||
<div className="pv-streak-badge" style={{ position: 'absolute', top: -14, right: 18, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 99, background: '#171512', border: `1px solid ${accent}55`, boxShadow: '0 8px 20px -8px rgba(0,0,0,0.6)' }}>
|
<div className="pv-streak-badge" style={{ position: 'absolute', top: -14, right: 18, display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px', borderRadius: 99, background: '#171512', border: `1px solid ${accent}55`, boxShadow: '0 8px 20px -8px rgba(0,0,0,0.6)' }}>
|
||||||
<span style={{ fontSize: 12 }}>🔥</span>
|
<span style={{ fontSize: 12 }}>🔥</span>
|
||||||
@@ -378,13 +378,9 @@ function ProductCard({ product, index }: { product: Product; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'clamp(11px, 1.5vh, 16px)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'clamp(11px, 1.5vh, 16px)' }}>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div className="products-tags">
|
||||||
{product.tags.map((tag) => (
|
{product.tags.map((tag) => (
|
||||||
<span key={tag} style={{
|
<span key={tag} className="products-tag">{tag}</span>
|
||||||
padding: '5px 13px', borderRadius: '99px', fontSize: '0.7rem', fontWeight: 600,
|
|
||||||
color: 'rgba(255,255,255,0.7)', background: 'rgba(255,255,255,0.05)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)', fontFamily: 'Sora, sans-serif',
|
|
||||||
}}>{tag}</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -569,6 +565,37 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
<style>{`
|
<style>{`
|
||||||
@keyframes prodShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
@keyframes prodShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
|
||||||
|
|
||||||
|
/* ── Product-visual building blocks ──
|
||||||
|
These live in CSS rather than inline styles specifically so the
|
||||||
|
phone media query below can override them without !important. */
|
||||||
|
.pv-wrap { width: 86%; }
|
||||||
|
.pv-wrap-narrow { width: 82%; }
|
||||||
|
.pv-heat { display: grid; grid-template-columns: repeat(8, 1fr); gap: 5px; }
|
||||||
|
.pv-stats { display: flex; gap: 10px; }
|
||||||
|
.pv-stat {
|
||||||
|
flex: 1; min-width: 0;
|
||||||
|
padding: 10px 12px; border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
.pv-stat-k { font-size: 10px; color: rgba(255,255,255,0.4); font-family: Sora, sans-serif; }
|
||||||
|
.pv-stat-v {
|
||||||
|
font-size: 15px; font-weight: 800; color: #fff; margin-top: 2px;
|
||||||
|
font-family: Sora, sans-serif;
|
||||||
|
/* Values are short — never let one wrap and knock the row out of line. */
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pv-coupon-amount { font-size: 30px; }
|
||||||
|
.pv-coupon-sub { font-size: 12px; }
|
||||||
|
.pv-coupon-rail { width: 64px; }
|
||||||
|
|
||||||
|
.products-tags { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.products-tag {
|
||||||
|
padding: 5px 13px; border-radius: 99px; font-size: 0.7rem; font-weight: 600;
|
||||||
|
color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1); font-family: Sora, sans-serif;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Product-visual loop animations ── */
|
/* ── Product-visual loop animations ── */
|
||||||
.pv-cell { animation: pvCellPulse 3.6s ease-in-out infinite; }
|
.pv-cell { animation: pvCellPulse 3.6s ease-in-out infinite; }
|
||||||
@keyframes pvCellPulse {
|
@keyframes pvCellPulse {
|
||||||
@@ -724,6 +751,67 @@ export default function ProductsSection({ isLoaded = false }: ProductsSectionPro
|
|||||||
}
|
}
|
||||||
.products-progress-wrap { position: relative; bottom: auto; transform: none; left: auto; margin: 20px auto 0; }
|
.products-progress-wrap { position: relative; bottom: auto; transform: none; left: auto; margin: 20px auto 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Phones ──
|
||||||
|
The 900px block above turns the pinned rail into a swipe track; it
|
||||||
|
does not retune the card interior, which breaks down on a narrow
|
||||||
|
screen. Measured at 320px: the card ran 801px tall (taller than the
|
||||||
|
viewport), the Dyscount visual overflowed its box by 13px and was
|
||||||
|
clipped, "6-8 PM" wrapped inside a 50px-wide stat box, and three
|
||||||
|
tags stacked into a 101px-tall column. This block is that retune. */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
/* Give the card a consistent gutter and predictable snap. At this
|
||||||
|
width the card nearly fills the track, and centre-snapping left it
|
||||||
|
resting at scrollLeft: 18 on load — a visibly off-register start. */
|
||||||
|
.products-card {
|
||||||
|
width: calc(100vw - (var(--sec-gutter) * 2) - 32px) !important;
|
||||||
|
max-width: 380px;
|
||||||
|
scroll-snap-align: start !important;
|
||||||
|
}
|
||||||
|
.products-track {
|
||||||
|
gap: 14px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
scroll-padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reclaim vertical space: the card was taller than the screen. */
|
||||||
|
.products-card-content { padding: 20px 18px !important; gap: 16px !important; }
|
||||||
|
.products-card-visual {
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 22px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Narrow screens need proportionally more of the panel, not less —
|
||||||
|
this is what stops the Dyscount visual overflowing. */
|
||||||
|
.pv-wrap, .pv-wrap-narrow { width: 92%; }
|
||||||
|
|
||||||
|
/* Drop the heatmap from 6 rows to 4. Same read, ~66px less card. */
|
||||||
|
.pv-heat { gap: 4px; }
|
||||||
|
.pv-heat > :nth-child(n+33) { display: none; }
|
||||||
|
|
||||||
|
/* Tighter boxes + smaller type so no stat wraps or clips.
|
||||||
|
"Redemptions" is the widest label and sets the floor here. */
|
||||||
|
.pv-stats { gap: 6px; }
|
||||||
|
.pv-stat { padding: 8px 7px; border-radius: 10px; }
|
||||||
|
.pv-stat-k { font-size: 8.5px; line-height: 1.3; overflow-wrap: anywhere; }
|
||||||
|
.pv-stat-v { font-size: 12.5px; }
|
||||||
|
|
||||||
|
.pv-coupon-amount { font-size: 24px; }
|
||||||
|
.pv-coupon-sub { font-size: 11px; }
|
||||||
|
.pv-coupon-rail { width: 40px; }
|
||||||
|
|
||||||
|
/* Two tags per row, not one. The longest tag must stay under half the
|
||||||
|
row: at 320px that is (230 - 6) / 2 = 112px. */
|
||||||
|
.products-tags { gap: 6px; }
|
||||||
|
.products-tag { padding: 4px 8px; font-size: 0.6rem; }
|
||||||
|
|
||||||
|
/* Heading: "Four agents. One AI." is nowrap inline and only just fits
|
||||||
|
at 320px — it would clip on anything narrower. Let it wrap. */
|
||||||
|
.products-heading { padding: 32px 16px 20px !important; }
|
||||||
|
.products-heading h2 { white-space: normal !important; }
|
||||||
|
.products-progress-wrap { width: min(420px, 84vw); }
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,6 @@ function StageCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
data-stage-card={index}
|
|
||||||
className={`wwd-card relative group ${isYellow ? 'wwd-card-yellow' : 'wwd-card-white'} ${active ? 'wwd-card-active' : ''}`}
|
className={`wwd-card relative group ${isYellow ? 'wwd-card-yellow' : 'wwd-card-white'} ${active ? 'wwd-card-active' : ''}`}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
@@ -170,46 +169,8 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cards: modern 3D lift + unblur reveal, smoothly staggered across the row
|
// The cards have no reveal animation by design — they render in place.
|
||||||
const cards = gsap.utils.toArray<HTMLElement>('.wwd-card');
|
// Hover interactions live in StageCard.
|
||||||
const grid = document.querySelector('.wwd-grid-container');
|
|
||||||
|
|
||||||
if (prefersReducedMotion) {
|
|
||||||
gsap.fromTo(
|
|
||||||
cards,
|
|
||||||
{ opacity: 0 },
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
duration: 0.5,
|
|
||||||
stagger: 0.08,
|
|
||||||
scrollTrigger: { trigger: grid, start: 'top 85%', toggleActions: 'play none none reset' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
gsap.fromTo(
|
|
||||||
cards,
|
|
||||||
{
|
|
||||||
opacity: 0,
|
|
||||||
y: 72,
|
|
||||||
scale: 0.92,
|
|
||||||
rotationX: 14,
|
|
||||||
transformOrigin: '50% 100%',
|
|
||||||
transformPerspective: 900,
|
|
||||||
filter: 'blur(10px)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
scale: 1,
|
|
||||||
rotationX: 0,
|
|
||||||
filter: 'blur(0px)',
|
|
||||||
duration: 1,
|
|
||||||
ease: 'power3.out',
|
|
||||||
stagger: 0.13,
|
|
||||||
scrollTrigger: { trigger: grid, start: 'top 82%', toggleActions: 'play none none reset' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, containerRef);
|
}, containerRef);
|
||||||
|
|
||||||
return () => ctx.revert();
|
return () => ctx.revert();
|
||||||
@@ -252,7 +213,7 @@ export default function WhatWeDo({ isLoaded = false }: WhatWeDoProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4-up grid */}
|
{/* 4-up grid */}
|
||||||
<div className="relative z-10 wwd-grid-container max-w-6xl mx-auto">
|
<div className="relative z-10 max-w-6xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-7">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-7">
|
||||||
{STAGES.map((stage, i) => (
|
{STAGES.map((stage, i) => (
|
||||||
<StageCard key={stage.title} stage={stage} index={i} active={active === i} onHover={setActive} />
|
<StageCard key={stage.title} stage={stage} index={i} active={active === i} onHover={setActive} />
|
||||||
|
|||||||
278
src/content/articles.ts
Normal file
278
src/content/articles.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* The Journal — single source of truth for every article.
|
||||||
|
*
|
||||||
|
* Both the listing (/blog_main) and the article pages (/blog/[slug]) read from
|
||||||
|
* here, so a title, topic, or excerpt is only ever written once.
|
||||||
|
*
|
||||||
|
* NOTE ON FIGURES: the numbers in `stats` and quoted inside body copy are
|
||||||
|
* illustrative industry ballparks written to demonstrate the layout. Replace
|
||||||
|
* them with sourced figures (and cite them) before this goes to production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TopicName = 'Analytics' | 'Strategy' | 'Technology' | 'Growth' | 'Future';
|
||||||
|
|
||||||
|
export type ArticleSection = { heading: string; body: string };
|
||||||
|
export type ArticleStat = { val: string; suffix: string; lbl: string };
|
||||||
|
|
||||||
|
export type Article = {
|
||||||
|
slug: string;
|
||||||
|
n: string;
|
||||||
|
tag: TopicName;
|
||||||
|
date: string;
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
/** Used verbatim as the hero standfirst, the card excerpt, and the meta description. */
|
||||||
|
excerpt: string;
|
||||||
|
heroImage: string;
|
||||||
|
featured?: boolean;
|
||||||
|
sections: ArticleSection[];
|
||||||
|
stats: ArticleStat[];
|
||||||
|
takeaways: string[];
|
||||||
|
quote: { text: string; cite: string };
|
||||||
|
/** Derived from body length — never hand-written, so it can't drift from the copy. */
|
||||||
|
readTime: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArticleSeed = Omit<Article, 'readTime'>;
|
||||||
|
|
||||||
|
const SEEDS: ArticleSeed[] = [
|
||||||
|
{
|
||||||
|
slug: 'why-footfall-data-is-the-new-gold',
|
||||||
|
n: '01',
|
||||||
|
tag: 'Analytics',
|
||||||
|
date: 'June 2026',
|
||||||
|
author: 'Loyaly.ai Team',
|
||||||
|
title: 'Why Footfall Data is the New Gold',
|
||||||
|
excerpt:
|
||||||
|
"In an era where every online click is tracked, offline retailers are sitting on untapped behavioural gold, and most don't even know it exists.",
|
||||||
|
heroImage: '/assets/images/stock/blog-article.jpg',
|
||||||
|
featured: true,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'The data gap in offline retail',
|
||||||
|
body: 'E-commerce has always had a structural advantage, and it has nothing to do with convenience. Every click, scroll, hesitation and abandoned cart is recorded, timestamped and available for analysis by the following morning. An online retailer does not wonder why conversion fell last week; it opens a funnel report and sees the precise step where people left. Physical retail has never had that luxury. For most of its history, a store has known two numbers with confidence — what came in through the till, and what went out through the stockroom — and almost nothing about the space between them. Ask a typical store manager why yesterday was worse than the day before and you will get a hypothesis: the weather, the football, a competitor promotion, a member of staff off sick. These are not answers. They are stories told in the absence of data, and they are unfalsifiable, which is precisely what makes them so comfortable. The gap is not one of intelligence or effort. It is one of instrumentation. Online, observation is free — it is a side effect of how the medium works. Offline, observation is a decision somebody has to make and fund. That asymmetry, not the internet itself, is the real reason digital retail has out-learned physical retail for two decades.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'What footfall data actually tells you',
|
||||||
|
body: 'The phrase "footfall data" undersells it, because it suggests a head count — and a head count is nearly useless on its own. Knowing that four hundred people entered on Saturday tells you almost nothing you can act on. Knowing that a hundred and twenty of them turned left, that forty stopped at the second display for more than a minute, that eleven queued for over four minutes and that six of those abandoned the queue entirely — that is a different kind of knowledge. It describes behaviour rather than volume. A properly instrumented store surfaces movement paths, dwell zones, dead space nobody walks into, bottlenecks that form predictably at four o\'clock, and the difference between a browser drifting past a fixture and a shopper who has stopped, turned, and started deciding. Layer loyalty data over the top — who this person is, what they bought last time, how long since their previous visit — and the picture closes. You stop asking how many people came in and start asking why the ones who left empty-handed did so. The count gives you a number to report. The pattern gives you a reason to act, and reasons are the only things you can actually fix.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Turning insights into revenue',
|
||||||
|
body: 'Insight that terminates in a dashboard is a cost centre wearing a nice suit. The value appears only when observation shortens the distance to a decision, and in a physical store that distance is measured in seconds, not weeks. Consider a shopper who has spent ten minutes in front of a considered purchase — a laptop, a mattress, a coat above their usual spend. They have not left, which means they have not decided against it. They are stuck. That hesitation is the most valuable signal your store will produce all day, and it has a shelf life of roughly ninety seconds. Handled well, it becomes a timely offer, an associate appearing with the answer to the question they were about to leave and google, or a size check that saves a trip they were not going to make. Handled the way most stores handle it, it becomes nothing at all, and the shopper walks out to think about it. Loyaly\'s Behavision AI exists to close that specific gap: it recognises the shape of hesitation while the person is still standing in the aisle, and turns it into an intervention rather than a line in next month\'s report. The insight is not the product. The latency is.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Why the advantage compounds',
|
||||||
|
body: 'Footfall intelligence is not a feature you switch on and benefit from evenly. It compounds, which makes the timing of the decision more consequential than it first appears. The first month tells you where people go. The first quarter tells you what is normal, which is the prerequisite for noticing anything abnormal. The first year gives you seasonality, and seasonality is what separates a genuine problem from a Tuesday in February. A retailer two years into this has a baseline their competitor cannot buy, borrow, or catch up on by spending more — because the thing being accumulated is history, and history only accrues in real time. This is also why the cost of waiting is routinely underestimated. A visit you did not record is not deferred; it is destroyed. You cannot go back and analyse last autumn once you have decided autumn mattered. Every day without instrumentation permanently removes a day from the dataset you will eventually wish you had. The encouraging part is that the barrier has collapsed. Footfall analytics used to require an enterprise budget, a systems integrator and a nine-month rollout. It now requires a decision.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Where to start',
|
||||||
|
body: 'Start narrow and finish something. The most common failure in this category is not a bad vendor or a wrong model — it is a rollout so ambitious it never reaches the point of producing an answer. Pick one store and one question you genuinely cannot answer today, and make sure it is a question with a decision attached: why does this department convert at half the rate of the one beside it? Instrument enough to answer it and no more. Run it long enough to establish what normal looks like, because a week of data will confidently tell you things that are not true. Then act on exactly one finding and measure whether acting helped, ideally against a store you deliberately left alone. That last step is the one everybody skips, and it is the one that turns an interesting chart into evidence. Do this once and you will have something more useful than a platform: proof, in your own estate, that observation changes an outcome. That proof is what makes the second store easy, and the tenth inevitable.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{ val: '60', suffix: '%', lbl: 'browse without converting' },
|
||||||
|
{ val: '12', suffix: ' min', lbl: 'average dwell before intent' },
|
||||||
|
{ val: '5', suffix: '×', lbl: 'cost to acquire vs. retain' },
|
||||||
|
],
|
||||||
|
takeaways: [
|
||||||
|
'Footfall data turns a head count into a behavioural map of your store.',
|
||||||
|
'Overlaying loyalty data closes the loop between a visit and a purchase.',
|
||||||
|
'Intent signals are only valuable if you act on them in real time.',
|
||||||
|
],
|
||||||
|
quote: { text: 'Every day without footfall data is a day of lost insight.', cite: 'Loyaly.ai, 2026' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
slug: 'the-end-of-blind-discounts',
|
||||||
|
n: '02',
|
||||||
|
tag: 'Strategy',
|
||||||
|
date: 'June 2026',
|
||||||
|
author: 'Loyaly.ai Team',
|
||||||
|
title: 'The End of Blind Discounts',
|
||||||
|
excerpt:
|
||||||
|
"Discounting without data is just burning margin. Here's how AI is changing the way smart retailers think about price reductions.",
|
||||||
|
heroImage: '/assets/images/stock/app-hero.jpg',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Why the blanket discount survives',
|
||||||
|
body: 'The storewide sale endures because it is easy, not because it works. It requires no segmentation, no model, no data infrastructure and no uncomfortable conversation with anyone about who deserves what. It can be decided in a meeting and live in the window by Friday. Best of all, it reliably produces a number that looks like success: revenue up, units up, footfall up, a graph pointing the right way just in time for the Monday review. Every one of those numbers is real. None of them answers the only question that matters, which is how many of those buyers would have paid full price anyway. That number is never in the report, because measuring it requires deliberately not discounting to a comparable group — and nobody wants to be the person who suggests leaving money on the table to find out. So the sale gets repeated, the graph goes up again, and the organisation slowly convinces itself that a reflex is a strategy. The uncomfortable truth is that a blanket discount is not a decision about pricing at all. It is a decision to stop thinking about pricing, purchased with margin.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'What a discount actually costs you',
|
||||||
|
body: 'Every price cut is spent twice, and the second payment is the one that ruins you. The first cost is visible and accepted: margin surrendered on the units you sold. It appears in the P&L, it was forecast, and everyone has made their peace with it. The second cost is invisible and cumulative — you have just taught your best customers to wait. Shoppers are excellent pattern matchers. A sale every six weeks is not a promotion; it is a schedule, and once it is learned, full-price demand quietly migrates into the discount window. The sale stops creating incremental demand and starts harvesting demand you already had. This is the trap that makes discounting so hard to escape: the more reliably you run sales, the more of your baseline they absorb, and the worse full-price weeks look by comparison — which makes the next sale feel necessary. Each round is individually defensible and collectively fatal. Eventually the discount is no longer a lever you pull to change behaviour. It is a tax you pay on your own calendar, levied by customers who have simply learned your habits better than you have.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Targeting the margin, not the crowd',
|
||||||
|
body: 'A discount is only worth giving to someone whose behaviour it changes. Stated plainly it sounds obvious; applied honestly it eliminates most of what retailers actually do. In any given sale, your buyers fall into two groups. The first was always going to buy — they had chosen, they were walking to the till, and the discount simply reduced what they handed over. For them, the offer bought nothing. It was a rebate on a decision already made. The second group was genuinely undecided: the hesitant, the lapsed, the price-sensitive, the shopper standing in front of something above their usual spend who needs one reason to commit. That group is where discounting earns its keep, and it is invariably far smaller than the group you gave the discount to. Behavioural data is what separates them, and the signals are not subtle once you can see them — dwell without purchase, repeat visits to the same fixture without a basket, a lapsed regular back in the store for the first time in months. None of this requires a perfect model. It requires only that you stop treating everyone in the building as though they were identical, which is precisely what a storewide sale does by definition.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Building a policy that compounds',
|
||||||
|
body: 'Replace the calendar with rules. A rule has three parts and fails if any one is missing. First, a trigger tied to observed behaviour rather than a date: a dwell that ends without a purchase, a regular who has not returned in ninety days, a basket about to walk. Second, a guardrail — the floor beneath which the offer destroys more value than it creates, set once, in advance, by someone who is not under pressure to hit a weekly number. Third, and non-negotiably, a holdout: a comparable group who never receive the offer at all. Without the holdout you can prove only that people like cheaper things, which was never in dispute. The holdout is what converts "the sale went well" into "the sale caused eleven percent incremental units, and here is the group that proves it." Retailers resist this because it feels like deliberately forgoing revenue, and for a small slice it is. It is also the only mechanism that will ever tell you which of your promotions are working, which means it pays for itself the first time it kills one that never did.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{ val: '25', suffix: '%', lbl: 'of offers change a decision' },
|
||||||
|
{ val: '3', suffix: '×', lbl: 'margin cost of a blanket sale' },
|
||||||
|
{ val: '1', suffix: '%', lbl: 'price cut, outsized profit hit' },
|
||||||
|
],
|
||||||
|
takeaways: [
|
||||||
|
'A discount given to someone already buying is a rebate, not a strategy.',
|
||||||
|
'Blanket sales train your best customers to wait for the next one.',
|
||||||
|
'Without a holdout group, you cannot prove a discount did anything at all.',
|
||||||
|
],
|
||||||
|
quote: { text: 'A discount that changes nothing is just a donation.', cite: 'Loyaly.ai, 2026' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
slug: 'ai-in-offline-retail',
|
||||||
|
n: '03',
|
||||||
|
tag: 'Technology',
|
||||||
|
date: 'May 2026',
|
||||||
|
author: 'Loyaly.ai Team',
|
||||||
|
title: 'AI in Offline Retail',
|
||||||
|
excerpt:
|
||||||
|
'From footfall heatmaps to purchase prediction, a deep look at how artificial intelligence is rewriting the rules for physical stores.',
|
||||||
|
heroImage: '/assets/images/stock/blog-main.jpg',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'The sensing layer',
|
||||||
|
body: 'Every AI system in a physical store begins with the same unglamorous question: what can this thing actually perceive? Not what does the vendor deck claim, but what signal genuinely arrives, how often, and how reliably at four o\'clock on a Saturday when the store is full and half the sensors are looking at the back of somebody\'s coat. In practice the answer is a small and boring list — overhead vision at the entrance and over the main aisles, shelf or queue sensors where they earn their place, the point-of-sale ledger, and whatever the loyalty system already knows. Taken individually, not one of these is interesting. A door counter is a turnstile with ambition. A camera over an aisle is a very expensive motion detector. A POS record is a receipt. Retailers routinely buy one of these, discover it tells them nothing they did not already suspect, and conclude the technology does not work. The technology worked fine; the architecture was wrong. Value in retail AI does not live inside any single sensor. It lives in the correlation between them — the moment an entry, a dwell and a transaction stop being three unrelated facts and become one story about one person on one afternoon. Everything downstream depends on whether you built for that join or not.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'From pixels to patterns',
|
||||||
|
body: 'The modelling layer turns raw signal into structure: paths across the floor, dwell zones, queue lengths, the distinction between a shopper drifting past a fixture and one who has stopped, turned and begun deciding. Technically this is the most impressive part of the stack, and commercially it is where most deployments quietly die. The reason is a category error that is very easy to make: a heatmap feels like an answer. It is colourful, it is obviously derived from real data, it can be shown to a board, and it confirms things people already believed. But a heatmap is a picture, not a decision. Knowing that the front-right corner runs hot does not tell you whether to move the fixture, restaff the aisle, or leave it alone because hot is exactly what that corner is supposed to be. A dashboard that surfaces a pattern without prescribing an action has not removed the guesswork from your business; it has merely relocated it from the shop floor to a meeting room, where it is now slower and better dressed. The test for this layer is brutally simple and worth applying to any vendor: when it detects something, what specifically happens next, and who does it?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Prediction, not reporting',
|
||||||
|
body: 'Reporting tells you what happened last week. Prediction tells you what is about to happen while there is still time to do something about it. That shift — from rear-view mirror to windscreen — is the entire justification for putting AI in a store, and it is the line most analytics products never cross. A weekly report on conversion by department is genuinely useful for planning and completely useless to the shopper currently standing in front of a mattress, unable to decide, ninety seconds away from leaving to think about it. She is the opportunity. She will not appear in the report until Monday, by which point she is a statistic rather than a sale. A model that recognises the shape of intent as it forms can trigger an offer, route an associate, or open a second till at the exact moment those actions still change the outcome. This is why latency is the metric that matters most and gets discussed least. An insight delivered in a week is history. The same insight in four seconds is revenue. The difference between them is not accuracy or sophistication — it is architecture, and it has to be designed in from the start.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'The integration problem nobody budgets for',
|
||||||
|
body: 'The hardest part of retail AI is not the model. It is that the answer has to arrive somewhere a human being can act on it, and that place is almost never a dashboard. It is a handset in an associate\'s pocket, a queue-management screen, a shelf label, a message that reaches the customer while she is still in the building. Every one of those endpoints belongs to a different system, owned by a different team, procured in a different decade, and connected — if at all — by a nightly batch job that somebody wrote and left. This is the work that gets left off the plan, and it is the reason so many pilots that "succeed" never reach a second store. The pilot proved the model could detect intent. It did not prove the organisation could do anything about it. Before signing anything, trace the full path from signal to action and name the system and the person at every hop. If any leg of that path is a nightly export, you do not have a real-time system. You have a reporting tool with a real-time model bolted to the front, and you will be paying for the model while getting the value of the report.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Doing it responsibly',
|
||||||
|
body: 'None of this requires knowing who anyone is, and it is worth being precise about why. Well-built retail AI counts and characterises rather than identifies. It operates on anonymous patterns, processes and discards imagery at the edge rather than shipping faces to a server, aggregates before it stores, and treats identity as something a customer hands over deliberately through loyalty — never something inferred from their body or their face without their knowledge. There is a compliance argument for this, and it is real, but the durability argument is stronger. A system that quietly over-collects is one regulatory shift, one leak, or one local news story away from being switched off entirely, and the investment goes with it. The retailers who will still be running these systems in five years are the ones who can explain, in a single plain sentence a customer would accept, exactly what is being collected and why. If that sentence is hard to write, the problem is not your messaging. It is your design, and it is cheaper to fix now than after it appears in a headline.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'What good looks like',
|
||||||
|
body: 'A mature deployment is unglamorous and disappointingly specific. It watches a small number of things extremely well rather than everything badly. Every signal it collects terminates in a named action owned by a named role, and anything that does not is switched off rather than kept in case it becomes interesting. It is measured against a control — stores or periods deliberately left alone — because without one you cannot distinguish your intervention from the weather. It degrades sensibly when a camera fails, because cameras fail. And it can survive a direct question from a customer about what it is doing. None of that is a technology problem, which is precisely why so much retail AI underdelivers despite the models being fine. The organisations that win are not the ones with the best model. They are the ones that decided, before buying anything, what they would do differently once they knew.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{ val: '4', suffix: '', lbl: 'signal sources in a typical store' },
|
||||||
|
{ val: '90', suffix: '%', lbl: 'of value sits in correlation' },
|
||||||
|
{ val: '0', suffix: '', lbl: 'identity needed to see intent' },
|
||||||
|
],
|
||||||
|
takeaways: [
|
||||||
|
'Individual sensors are near-worthless; correlation is where the value is.',
|
||||||
|
'A heatmap that ends in a dashboard has only relocated the guesswork.',
|
||||||
|
'Anonymous-by-default is a design decision, not a compliance checkbox.',
|
||||||
|
],
|
||||||
|
quote: { text: 'A camera that only counts is a very expensive turnstile.', cite: 'Loyaly.ai, 2026' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
slug: 'how-to-increase-repeat-customers',
|
||||||
|
n: '04',
|
||||||
|
tag: 'Growth',
|
||||||
|
date: 'May 2026',
|
||||||
|
author: 'Loyaly.ai Team',
|
||||||
|
title: 'How to Increase Repeat Customers',
|
||||||
|
excerpt:
|
||||||
|
'Acquiring a new customer costs far more than retaining one. The playbook for building genuine loyalty in offline retail.',
|
||||||
|
heroImage: '/assets/images/stock/use-cases.jpg',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'Retention is a maths problem',
|
||||||
|
body: 'Acquisition buys you a transaction. Retention buys you a slope, and slopes compound. This is why two shops with identical footfall, identical average baskets and identical margins can drift into completely different businesses within eighteen months on the strength of a few percentage points of repeat rate. One is refilling a leaking bucket every month at full cost; the other is filling a bucket that mostly holds. From the outside, on any given Saturday, they look the same. The uncomfortable implication is that retention is not the reward for good acquisition — it is the entire point of acquisition. A funnel that never loops is not a funnel; it is a very expensive introduction service. Yet retention consistently loses the budget argument, because acquisition is legible and immediate while retention is diffuse and slow. You can attribute a campaign. It is much harder to attribute the absence of a departure. So the money goes where the attribution is clean, which is a good description of how a business can optimise itself into steadily rising costs and flat revenue while every individual decision looks defensible.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'The second visit is the hardest',
|
||||||
|
body: 'Almost every retention programme is aimed at people who already come back. Tiers, points, birthday rewards, VIP nights — these are mechanisms for rewarding a habit that already exists, which is pleasant but rarely decisive. The moment that actually determines your repeat rate is earlier and much lonelier: the gap between the first visit and the second. At that point the shopper has no habit, no history, no accumulated balance, and no particular reason to choose you over the equivalent shop two hundred metres away. Nothing about the first transaction tells you whether a second will happen — the receipt looks identical whether they return next week or never think about you again. This is also the measurement trap. If you study loyalty among your loyal customers, you will conclude your loyalty scheme is working, because you have selected for the outcome you wanted to observe. The population that matters is the one that came once. They are harder to find, they are not in your VIP list, and they are where your growth is currently leaking away unmeasured.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Designing the return trigger',
|
||||||
|
body: 'A return trigger has three parts, and getting two right is the same as getting none. Timing: too early and it is noise arriving before the memory of the visit has settled; too late and the habit has already formed somewhere else. Relevance: generic contact reads as spam no matter how elegantly it is timed, and reads as spam precisely because it is — it demonstrates that you know nothing about the person you are contacting. And a reason that survives scrutiny: something the recipient would agree is a fair thing to say to them. The triggers that work reference something true. The thing they looked at and did not buy. The category they always buy. The visit they actually made, on the day they actually made it. This is why behavioural data outperforms every demographic segment ever built: relevance is not a clever inference about who someone probably is. It is evidence that you were paying attention to what they actually did. Customers can tell the difference immediately, and they are far more forgiving of a company that noticed than one that guessed.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Loyalty is a habit, not a balance',
|
||||||
|
body: 'Points schemes are routinely mistaken for loyalty programmes. They are closer to a discount with a delay attached, and they purchase exactly as much devotion as a discount does: none that survives a better offer across the street. A points balance is a switching cost, and switching costs work right up until the moment someone offers to pay it. Real loyalty is duller and far more durable. It is habit — a store that is easy, familiar, reliably has the thing, and does not waste your time. Nobody defects from that for five percent, because the alternative is not a lower price; it is unfamiliarity, and unfamiliarity has a cost that never appears on a competitor\'s poster. This is where behavioural data earns its keep. Not by calculating a more sophisticated reward, but by making each visit fractionally better than the last — the size in stock, the queue that did not form, the offer that happened to be for something you actually wanted. Points reward the transaction and are forgotten at the door. Habit rewards the relationship and walks back in on its own.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{ val: '5', suffix: '×', lbl: 'cost to acquire vs. retain' },
|
||||||
|
{ val: '2', suffix: 'nd', lbl: 'visit is the decisive one' },
|
||||||
|
{ val: '30', suffix: '%', lbl: 'of repeat intent is timing' },
|
||||||
|
],
|
||||||
|
takeaways: [
|
||||||
|
'A small lift in repeat rate compounds; a one-off sale does not.',
|
||||||
|
'The gap between visit one and visit two is where retention is won or lost.',
|
||||||
|
'Points buy transactions. Relevance buys habits.',
|
||||||
|
],
|
||||||
|
quote: { text: "Loyalty isn't bought with points. It's earned with relevance.", cite: 'Loyaly.ai, 2026' },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
slug: 'the-future-of-connected-commerce',
|
||||||
|
n: '05',
|
||||||
|
tag: 'Future',
|
||||||
|
date: 'April 2026',
|
||||||
|
author: 'Loyaly.ai Team',
|
||||||
|
title: 'The Future of Connected Commerce',
|
||||||
|
excerpt:
|
||||||
|
'Online and offline are no longer separate worlds. The retailers who win will be the ones who blur the line completely.',
|
||||||
|
heroImage: '/assets/images/stock/solutions.jpg',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
heading: 'The channel divide was always artificial',
|
||||||
|
body: 'No customer has ever once thought of themselves as an online customer or an in-store customer. They research on a phone in bed, check stock on the train, decide in an aisle, and return the thing through whichever door involves the least argument. From where they sit there is no channel — there is a shop that is sometimes glass and sometimes glowing. The divide was never a description of customer behaviour. It was an accident of org charts, budgets, incentives and the software each team happened to buy in the year they were formed. It persists because it is load-bearing internally: someone owns digital, someone owns retail, and they have different targets that can be hit at each other\'s expense. Customers stopped respecting that boundary well over a decade ago. Most retail stacks still enforce it rigorously, which is why the experience of buying online and returning in store so often feels like being handed between two companies who have heard of each other. Every one of those seams is a place a competitor can win, and none of them exist for a reason the customer would accept.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'The connected store',
|
||||||
|
body: 'A connected store is not a futuristic concept. It is simply a store that knows what the rest of the business already knows. It recognises that the person standing at the shelf is the one who abandoned a basket last night. It knows the size they want is in the stockroom rather than letting them leave believing it is gone. It knows they have shopped here for three years, and it does not ask them to prove it. Notice that none of this requires invention. The abandoned basket is recorded. The stock position is in a system. The three-year history is sitting in the loyalty database. The data almost always exists — it simply terminates somewhere the shop floor cannot see, usually behind a nightly export and an integration nobody owns. That is a plumbing failure, and plumbing failures are unglamorous, which is exactly why they get reframed as strategy problems and put on a roadmap instead of being fixed. The gap between a normal store and a connected one is rarely a new capability. It is a join that was never built because it was nobody\'s job.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'Identity is the connective tissue',
|
||||||
|
body: 'Nothing connects without a thread that survives the trip between channels, and there are only two ways to obtain one. You can infer it — stitch together devices, locations, faces and probabilities into a guess about who this person might be — or you can be given it. The inferred version is seductive because it requires nothing from the customer, and it is a trap. It is brittle, breaking the moment someone changes phone or stands in an unusual place. It is faintly hostile, in that its entire premise is knowing something the person did not agree to tell you. And it is steadily becoming illegal in more places every year. The given version is slower to build and enormously more durable: a loyalty identity, handed over deliberately in exchange for something obviously worth having, becomes the join key for the entire system. It survives device changes. It survives regulation. It survives the customer asking what you know about them, which is the test that ultimately matters. Ask and earn, and the connection holds for years. Infer, and you are building on something that can be taken away by a browser update.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'What to build now',
|
||||||
|
body: 'The practical order almost never changes, and most failures are a result of attempting it out of sequence. First, instrument the store, because behaviour you cannot observe cannot be connected to anything. Second, give customers a reason to identify themselves that they would choose freely if they read the terms — not a dark pattern, not a mandatory account, something they would take. Third, join those two to the transaction record, which is the boring integration work that everybody defers and which is, in fact, the entire product. Only then automate. Automation applied to a disconnected stack does not produce intelligence; it produces the wrong decision faster and at scale, with more confidence and less human oversight than the manual process it replaced. The sequence is unforgiving in that respect: every step depends on the one before it, and skipping to the interesting part is the most common way to spend a large budget on a system that recommends nonsense politely.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: 'The next five years',
|
||||||
|
body: 'The prediction worth making is not about technology. Nothing described here needs a breakthrough — every component exists today and most of it is affordable. The change will be organisational, and it will look like the quiet disappearance of a job title. The Head of Digital and the Head of Retail will stop being separate people with competing targets, because the boundary they were hired to manage stopped describing anything real long before the technology caught up. Retailers who complete that sequence will not have an online strategy and an offline strategy. They will have a customer strategy, and channels will become what they always should have been: delivery mechanisms, chosen per customer, per moment, without ceremony. The ones who do not will keep running two businesses that share a logo, a warehouse and a growing suspicion that their customers prefer someone else\'s.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stats: [
|
||||||
|
{ val: '3', suffix: '', lbl: 'systems the average store cannot join' },
|
||||||
|
{ val: '1', suffix: '', lbl: 'identity thread that connects them' },
|
||||||
|
{ val: '0', suffix: '', lbl: 'customers who think in channels' },
|
||||||
|
],
|
||||||
|
takeaways: [
|
||||||
|
'The online/offline split describes org charts, not customer behaviour.',
|
||||||
|
'The data usually exists — it just never reaches the shop floor.',
|
||||||
|
'Identity must be offered by the customer, not inferred about them.',
|
||||||
|
],
|
||||||
|
quote: {
|
||||||
|
text: "The winners won't have an online strategy and an offline strategy. They'll have a customer strategy.",
|
||||||
|
cite: 'Loyaly.ai, 2026',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** ~200 wpm, rounded up — derived so the badge can never contradict the copy. */
|
||||||
|
function readTimeFor(a: ArticleSeed): string {
|
||||||
|
const words = a.sections.reduce((n, s) => n + s.heading.split(/\s+/).length + s.body.split(/\s+/).length, 0);
|
||||||
|
return `${Math.max(1, Math.round(words / 200))} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ARTICLES: Article[] = SEEDS.map(a => ({ ...a, readTime: readTimeFor(a) }));
|
||||||
|
|
||||||
|
export const getArticle = (slug: string) => ARTICLES.find(a => a.slug === slug);
|
||||||
|
|
||||||
|
/** Up to `limit` other articles, in publication order. */
|
||||||
|
export const relatedTo = (slug: string, limit = 3) => ARTICLES.filter(a => a.slug !== slug).slice(0, limit);
|
||||||
|
|
||||||
|
export const articleHref = (slug: string) => `/blog/${slug}`;
|
||||||
Reference in New Issue
Block a user