Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 93 additions & 70 deletions website/src/components/home/HomeBlogPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,27 @@ import Link from "next/link";
import { Container, Group, Text } from "@/components/ui";
import OptimisedImage from "@/components/ui/OptimisedImage";
import { cn } from "@/lib/utils";
import { colors, spacing, typography } from "@/designTokens";
import {
colors,
spacing,
typography,
} from "@/designTokens";
import { getPostsSorted } from "@/data/posts/postTransformers";
import type { BlogPost } from "@/types/blog";
getResearchItems,
type ResearchItem,
} from "@/data/posts/postTransformers";

/**
* Number of posts shown in the blog preview on the home page.
* 2 on the left (primary cards), 3 on the right (secondary cards).
*/
const LEFT_COUNT = 2;
const RIGHT_COUNT = 3;
const TOTAL_POSTS = LEFT_COUNT + RIGHT_COUNT;
const TOTAL_ITEMS = LEFT_COUNT + RIGHT_COUNT;

function getPostImageUrl(post: BlogPost): string {
if (!post.image) {
function getItemImageUrl(item: ResearchItem): string {
if (!item.image) {
return "";
}
if (post.image.startsWith("http")) {
return post.image;
if (item.image.startsWith("http")) {
return item.image;
}
return `/assets/posts/${post.image}`;
return `/assets/posts/${item.image}`;
}

function formatPostDate(dateStr: string): string {
// Append T12:00:00 to date-only strings to avoid UTC midnight timezone shift
function formatItemDate(dateStr: string): string {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(dateStr)
? `${dateStr}T12:00:00`
: dateStr;
Expand All @@ -42,23 +35,61 @@ function formatPostDate(dateStr: string): string {
});
}

function getItemHref(item: ResearchItem, countryId: string): string {
return item.isApp
? `/${item.countryId}/${item.slug}`
: `/${countryId}/research/${item.slug}`;
}

// Apps are served via Vercel rewrites (reverse proxy), so they need a full
// document request — Next.js client navigation would hit a 404.
function CardLink({
item,
countryId,
className,
style,
children,
}: {
item: ResearchItem;
countryId: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
const href = getItemHref(item, countryId);
if (item.isApp) {
return (
<a href={href} className={className} style={style}>
{children}
</a>
);
}
return (
<Link href={href} className={className} style={style}>
{children}
</Link>
);
}

/* ------------------------------------------------------------------ */
/* PrimaryCard */
/* ------------------------------------------------------------------ */

interface PrimaryCardProps {
post: BlogPost;
item: ResearchItem;
countryId: string;
flex?: number;
}

function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
const imageUrl = getPostImageUrl(post);
const date = formatPostDate(post.date);
function PrimaryCard({ item, countryId, flex }: PrimaryCardProps) {
const imageUrl = getItemImageUrl(item);
const date = formatItemDate(item.date);
const cta = item.isApp ? "Open" : "Read more";

return (
<Link
href={`/${countryId}/research/${post.slug}`}
<CardLink
item={item}
countryId={countryId}
className="tw:block tw:no-underline tw:text-inherit tw:group"
style={{ flex: flex ?? "none" }}
>
Expand All @@ -75,7 +106,7 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
<div className="tw:min-h-[200px] tw:flex-1 tw:overflow-hidden tw:bg-gray-100">
<OptimisedImage
src={imageUrl}
alt={post.title}
alt={item.title}
width={640}
className="tw:w-full tw:h-full tw:object-cover tw:block tw:transition-transform tw:duration-500 tw:group-hover:scale-[1.03]"
onError={(e) => {
Expand All @@ -91,19 +122,19 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
</p>

<p className="tw:text-2xl tw:font-bold tw:leading-tight tw:text-gray-900 tw:mb-md">
{post.title}
{item.title}
</p>

<p className="tw:text-sm tw:text-text-secondary tw:leading-relaxed tw:line-clamp-3">
{post.description}
{item.description}
</p>

<p className="tw:text-sm tw:font-semibold tw:text-primary-600 tw:mt-lg tw:transition-transform tw:duration-200 tw:group-hover:translate-x-1">
Read more &rarr;
{cta} &rarr;
</p>
</div>
</div>
</Link>
</CardLink>
);
}

Expand All @@ -112,17 +143,19 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
/* ------------------------------------------------------------------ */

interface SecondaryCardProps {
post: BlogPost;
item: ResearchItem;
countryId: string;
}

function SecondaryCard({ post, countryId }: SecondaryCardProps) {
const imageUrl = getPostImageUrl(post);
const date = formatPostDate(post.date);
function SecondaryCard({ item, countryId }: SecondaryCardProps) {
const imageUrl = getItemImageUrl(item);
const date = formatItemDate(item.date);
const cta = item.isApp ? "Open" : "Read";

return (
<Link
href={`/${countryId}/research/${post.slug}`}
<CardLink
item={item}
countryId={countryId}
className="tw:block tw:no-underline tw:text-inherit tw:h-full tw:group"
>
<div
Expand All @@ -138,8 +171,8 @@ function SecondaryCard({ post, countryId }: SecondaryCardProps) {
<div className="tw:h-[180px] tw:overflow-hidden tw:bg-gray-100 tw:shrink-0">
<OptimisedImage
src={imageUrl}
alt={post.title}
width={384}
alt={item.title}
width={640}
className="tw:w-full tw:h-full tw:object-cover tw:block tw:transition-transform tw:duration-500 tw:group-hover:scale-[1.03]"
onError={(e) => {
e.currentTarget.style.display = "none";
Expand All @@ -154,45 +187,41 @@ function SecondaryCard({ post, countryId }: SecondaryCardProps) {
</p>

<p className="tw:text-base tw:font-semibold tw:leading-snug tw:text-gray-900 tw:line-clamp-2 tw:mb-sm">
{post.title}
{item.title}
</p>

<p className="tw:text-sm tw:text-text-secondary tw:leading-normal tw:line-clamp-2 tw:flex-1">
{post.description}
{item.description}
</p>

<p className="tw:text-sm tw:font-semibold tw:text-primary-600 tw:mt-md tw:transition-transform tw:duration-200 tw:group-hover:translate-x-1">
Read &rarr;
{cta} &rarr;
</p>
</div>
</div>
</Link>
</CardLink>
);
}

/* ------------------------------------------------------------------ */
/* HomeBlogPreview */
/* ------------------------------------------------------------------ */

export default function HomeBlogPreview({
countryId,
}: {
countryId: string;
}) {
// getPostsSorted() returns posts sorted newest-first with slugs pre-computed
const relevantPosts = getPostsSorted()
export default function HomeBlogPreview({ countryId }: { countryId: string }) {
// Merged research feed: posts + apps with displayWithResearch, newest-first.
// Filter by country tag so /us shows US-relevant items and /uk shows UK.
const relevantItems = getResearchItems()
.filter(
(post: BlogPost) =>
post.tags.includes(countryId) || post.tags.includes("global"),
(item) => item.tags.includes(countryId) || item.tags.includes("global"),
)
.slice(0, TOTAL_POSTS);
.slice(0, TOTAL_ITEMS);

if (relevantPosts.length === 0) {
if (relevantItems.length === 0) {
return null;
}

const leftPosts = relevantPosts.slice(0, LEFT_COUNT);
const rightPosts = relevantPosts.slice(LEFT_COUNT, TOTAL_POSTS);
const leftItems = relevantItems.slice(0, LEFT_COUNT);
const rightItems = relevantItems.slice(LEFT_COUNT, TOTAL_ITEMS);

return (
<div
Expand Down Expand Up @@ -240,30 +269,24 @@ export default function HomeBlogPreview({
className="tw:grid tw:grid-cols-1 tw:md:grid-cols-2"
style={{ gap: spacing["2xl"] }}
>
{/* Left column: 2 posts stacked, filling equal height */}
<div
className="tw:flex tw:flex-col"
style={{ gap: spacing["2xl"] }}
>
{leftPosts.map((post: BlogPost) => (
{/* Left column: 2 items stacked, filling equal height */}
<div className="tw:flex tw:flex-col" style={{ gap: spacing["2xl"] }}>
{leftItems.map((item) => (
<PrimaryCard
key={post.slug}
post={post}
key={`${item.isApp ? "app" : "post"}-${item.slug}`}
item={item}
countryId={countryId}
flex={1}
/>
))}
</div>

{/* Right column: 3 smaller posts stacked */}
<div
className="tw:flex tw:flex-col"
style={{ gap: spacing["2xl"] }}
>
{rightPosts.map((post: BlogPost) => (
{/* Right column: 3 smaller items stacked */}
<div className="tw:flex tw:flex-col" style={{ gap: spacing["2xl"] }}>
{rightItems.map((item) => (
<SecondaryCard
key={post.slug}
post={post}
key={`${item.isApp ? "app" : "post"}-${item.slug}`}
item={item}
countryId={countryId}
/>
))}
Expand Down
87 changes: 53 additions & 34 deletions website/src/components/ui/OptimisedImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ImgHTMLAttributes } from 'react';
import type { ImgHTMLAttributes } from "react";

/**
* Wraps an <img> to serve images through Vercel's edge image optimisation.
Expand All @@ -17,7 +17,10 @@ interface StaticImageData {
width: number;
}

interface OptimisedImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
interface OptimisedImageProps extends Omit<
ImgHTMLAttributes<HTMLImageElement>,
"src"
> {
/** Desired display width in pixels — used for resizing on the edge. */
width?: number;
/** Image quality 1–100 (default 80). */
Expand All @@ -39,49 +42,65 @@ function snapWidth(w: number): number {
return ALLOWED_WIDTHS[ALLOWED_WIDTHS.length - 1];
}

function optimisedSrc(src: string, width?: number, quality = 80): string {
// Only optimise local paths served from the same origin
if (!src.startsWith('/')) {
return src;
}

// Skip _next/static paths — already optimized at build time by Next.js
if (src.startsWith('/_next/')) {
return src;
}

// Skip SVGs — vector images can't be raster-optimized
if (src.endsWith('.svg')) {
return src;
}

// Skip in dev — Vercel image API isn't available locally
if (process.env.NODE_ENV === 'development') {
return src;
}

// Vercel's image endpoint requires an explicit width.
// Fall back to the original asset path when callers omit one.
if (!width) {
return src;
}
/**
* True when the asset should bypass Vercel's image optimiser (external URLs,
* already-optimised Next.js static chunks, SVGs, local dev, or missing width).
*/
function shouldSkipOptimisation(src: string, width?: number): boolean {
if (!src.startsWith("/")) return true;
if (src.startsWith("/_next/")) return true;
if (src.endsWith(".svg")) return true;
if (process.env.NODE_ENV === "development") return true;
if (!width) return true;
return false;
}

const dpr = 1;
function optimisedSrc(src: string, width: number, quality = 80): string {
const params = new URLSearchParams({ url: src, q: String(quality) });
params.set('w', String(snapWidth(Math.round(width * dpr))));
params.set("w", String(snapWidth(Math.round(width))));
return `/_vercel/image?${params}`;
}

export default function OptimisedImage({
src,
width,
quality = 80,
alt = '',
alt = "",
...rest
}: OptimisedImageProps) {
// Resolve Next.js static imports (StaticImageData) to their .src string
const rawSrc = typeof src === 'object' && src !== null && 'src' in src ? src.src : src;
const resolvedSrc = typeof rawSrc === 'string' ? optimisedSrc(rawSrc, width, quality) : undefined;
const rawSrc =
typeof src === "object" && src !== null && "src" in src ? src.src : src;

if (typeof rawSrc !== "string") {
return (
<img {...rest} src={undefined} width={width} loading="lazy" alt={alt} />
);
}

if (shouldSkipOptimisation(rawSrc, width)) {
return (
<img {...rest} src={rawSrc} width={width} loading="lazy" alt={alt} />
);
}

// width is guaranteed by shouldSkipOptimisation above
const w = width!;
const src1x = optimisedSrc(rawSrc, w, quality);
const src2x = optimisedSrc(rawSrc, w * 2, quality);

// If 1x and 2x snap to the same allowed width, the browser would download
// the same file twice via srcset — skip the descriptor in that case.
const srcSet = src1x === src2x ? undefined : `${src1x} 1x, ${src2x} 2x`;

return <img {...rest} src={resolvedSrc} width={width} loading="lazy" alt={alt} />;
return (
<img
{...rest}
src={src1x}
srcSet={srcSet}
width={w}
loading="lazy"
alt={alt}
/>
);
}
Loading