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 ( + {alt} + ); + } + + if (shouldSkipOptimisation(rawSrc, width)) { + return ( + {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 {alt}; + return ( + {alt} + ); }