feed: intersperse mobile-only 300x250 ads every 4 articles

Mobile readers previously saw zero ads above the bottom of the feed
(header leaderboard is hidden md:block, sidebar stack pushes below the
feed at <lg breakpoints). This adds a 300x250 ad block after every 4th
article in <ArticleFeed>, wrapped in md:hidden so tablet/desktop are
untouched (they already get SidebarAdStack to the right of the feed).

- Ad pool: rotateAll('300x250') from src/lib/ads.ts (DB-backed, falls
  back to the 7-ad hardcoded pool)
- Cycles through the pool if the feed is longer than the pool
- Skips the insert after the very last article
- Labelled "Sponsored", divide-y separator inherited from parent

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 01:07:32 +00:00
parent 4c0ba55743
commit d31d9ce212

View File

@@ -1,10 +1,14 @@
"use client";
import React, { useState, useRef, useId, useEffect, useCallback } from "react";
import React, { Fragment, useState, useRef, useId, useEffect, useCallback, useMemo } from "react";
import AppImage from "@/components/ui/AppImage";
import SidebarAdStack from "@/components/SidebarAdStack";
import AdImage from "@/components/AdImage";
import { rotateAll, type Ad } from "@/lib/ads";
import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons";
import Link from "next/link";
const MOBILE_AD_EVERY_N = 4;
interface ArticleItem {
title: string;
@@ -86,6 +90,10 @@ export default function ArticleFeed() {
const sentinelRef = useRef<HTMLDivElement>(null);
const feedId = useId();
// Shuffled 300x250 pool for mobile in-feed inserts (md:hidden).
// Tablet/desktop already get the SidebarAdStack to the right of the feed.
const mobileInfeedAds = useMemo<Ad[]>(() => rotateAll("300x250"), []);
// Load preference from localStorage
useEffect(() => {
try {
@@ -550,8 +558,18 @@ export default function ArticleFeed() {
: `/articles/${article?.slug}`;
const linkProps = isImported ? { target: "_blank", rel: "noopener noreferrer" } : {};
// Mobile in-feed ad after every Nth article (skip if last item).
const isAdSlot =
(i + 1) % MOBILE_AD_EVERY_N === 0 &&
i + 1 < displayedArticles.length &&
mobileInfeedAds.length > 0;
const inFeedAd = isAdSlot
? mobileInfeedAds[Math.floor((i + 1) / MOBILE_AD_EVERY_N - 1) % mobileInfeedAds.length]
: null;
return (
<article key={i} className="article-card flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2">
<Fragment key={`feed-${i}`}>
<article className="article-card flex gap-3 md:gap-4 py-4 md:py-5 group cursor-pointer rounded-sm px-2 -mx-2">
<Link
href={articleHref}
{...linkProps}
@@ -604,6 +622,18 @@ export default function ArticleFeed() {
</div>
</div>
</article>
{inFeedAd && (
<div
className="md:hidden py-5 flex flex-col items-center bg-[#0a0a0a]"
aria-label="Advertisement"
>
<span className="text-[9px] font-mono uppercase tracking-wider text-[#555] mb-2">
Sponsored
</span>
<AdImage ad={inFeedAd} />
</div>
)}
</Fragment>
);
})}
</div>