diff --git a/website/src/components/home/HomeBlogPreview.tsx b/website/src/components/home/HomeBlogPreview.tsx
index 950195c5a..964a9050c 100644
--- a/website/src/components/home/HomeBlogPreview.tsx
+++ b/website/src/components/home/HomeBlogPreview.tsx
@@ -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;
@@ -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 (
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
+
/* ------------------------------------------------------------------ */
/* 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 (
-
@@ -75,7 +106,7 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
{
@@ -91,19 +122,19 @@ function PrimaryCard({ post, countryId, flex }: PrimaryCardProps) {
- {post.title}
+ {item.title}
- {post.description}
+ {item.description}
- Read more →
+ {cta} →
-
+
);
}
@@ -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 (
-
{
e.currentTarget.style.display = "none";
@@ -154,19 +187,19 @@ function SecondaryCard({ post, countryId }: SecondaryCardProps) {
- {post.title}
+ {item.title}
- {post.description}
+ {item.description}
- Read →
+ {cta} →
-
+
);
}
@@ -174,25 +207,21 @@ function SecondaryCard({ post, countryId }: SecondaryCardProps) {
/* 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 (
- {/* Left column: 2 posts stacked, filling equal height */}
-
- {leftPosts.map((post: BlogPost) => (
+ {/* Left column: 2 items stacked, filling equal height */}
+
+ {leftItems.map((item) => (
))}
- {/* Right column: 3 smaller posts stacked */}
-
- {rightPosts.map((post: BlogPost) => (
+ {/* Right column: 3 smaller items stacked */}
+
+ {rightItems.map((item) => (
))}
diff --git a/website/src/components/ui/OptimisedImage.tsx b/website/src/components/ui/OptimisedImage.tsx
index eda12932e..234c6be45 100644
--- a/website/src/components/ui/OptimisedImage.tsx
+++ b/website/src/components/ui/OptimisedImage.tsx
@@ -1,4 +1,4 @@
-import type { ImgHTMLAttributes } from 'react';
+import type { ImgHTMLAttributes } from "react";
/**
* Wraps an
![]()
to serve images through Vercel's edge image optimisation.
@@ -17,7 +17,10 @@ interface StaticImageData {
width: number;
}
-interface OptimisedImageProps extends Omit
, 'src'> {
+interface OptimisedImageProps extends Omit<
+ ImgHTMLAttributes,
+ "src"
+> {
/** Desired display width in pixels — used for resizing on the edge. */
width?: number;
/** Image quality 1–100 (default 80). */
@@ -39,36 +42,22 @@ 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}`;
}
@@ -76,12 +65,42 @@ 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 (
+
+ );
+ }
+
+ if (shouldSkipOptimisation(rawSrc, width)) {
+ return (
+
+ );
+ }
+
+ // 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
;
+ return (
+
+ );
}