From 71a2a891e93026c65ae12f5453b769a13b3a7735 Mon Sep 17 00:00:00 2001 From: flavius Date: Sun, 12 Jul 2026 16:34:22 +0300 Subject: [PATCH 1/2] feat: SEO & AI visibility --- .../src/components/MiezOnboarding.test.tsx | 2 +- apps/web/src/components/MiezOnboarding.tsx | 2 +- apps/web/src/components/bookmark-button.tsx | 2 +- apps/web/src/components/feed/event-card.tsx | 15 +- apps/web/src/components/header.tsx | 4 +- .../src/components/layout/MobileTabBar.tsx | 6 +- apps/web/src/components/quiz-hidden.test.tsx | 2 +- apps/web/src/lib/feature-flags.ts | 2 +- apps/web/src/lib/i18n/getLocaleFromMatches.ts | 8 +- apps/web/src/lib/seo.ts | 40 +- apps/web/src/routeTree.gen.ts | 42 + apps/web/src/router.tsx | 2 +- apps/web/src/routes/__root.tsx | 10 +- apps/web/src/routes/activitate.tsx | 4 +- apps/web/src/routes/event.$slug.tsx | 64 +- apps/web/src/routes/feed.tsx | 954 +----------------- apps/web/src/routes/index.tsx | 936 ++++++++++++++++- apps/web/src/routes/llms[.]txt.ts | 2 +- apps/web/src/routes/news-sitemap[.]xml.ts | 94 ++ apps/web/src/routes/quiz.tsx | 4 +- apps/web/src/routes/robots[.]txt.ts | 1 + apps/web/src/routes/rss[.]xml.ts | 108 ++ apps/web/src/routes/salvate.tsx | 2 +- apps/web/src/routes/sitemap[.]xml.ts | 2 +- apps/web/src/routes/source.$sourceId.tsx | 6 +- docs/seo-batch-2-runbook.md | 80 ++ packages/backend/convex/events.ts | 41 + packages/backend/convex/sitemap.ts | 3 +- packages/i18n/src/strings.ts | 4 +- 29 files changed, 1458 insertions(+), 984 deletions(-) create mode 100644 apps/web/src/routes/news-sitemap[.]xml.ts create mode 100644 apps/web/src/routes/rss[.]xml.ts create mode 100644 docs/seo-batch-2-runbook.md diff --git a/apps/web/src/components/MiezOnboarding.test.tsx b/apps/web/src/components/MiezOnboarding.test.tsx index 3069656..e90098b 100644 --- a/apps/web/src/components/MiezOnboarding.test.tsx +++ b/apps/web/src/components/MiezOnboarding.test.tsx @@ -63,7 +63,7 @@ describe("MiezOnboarding (MIEZ-8)", () => { test("the CTA dismisses and navigates into the feed", () => { renderOnboarding(); fireEvent.click(screen.getByText(getString("ro", "onboarding.miez.cta"))); - expect(navigate).toHaveBeenCalledWith({ to: "/feed" }); + expect(navigate).toHaveBeenCalledWith({ to: "/" }); expect(window.localStorage.getItem(KEY)).toBe("1"); }); diff --git a/apps/web/src/components/MiezOnboarding.tsx b/apps/web/src/components/MiezOnboarding.tsx index 04e3cdf..5468d8e 100644 --- a/apps/web/src/components/MiezOnboarding.tsx +++ b/apps/web/src/components/MiezOnboarding.tsx @@ -123,7 +123,7 @@ export function MiezOnboarding() { className="w-full" onClick={() => { dismiss("cta"); - void navigate({ to: "/feed" }); + void navigate({ to: "/" }); }} > {t("onboarding.miez.cta")} diff --git a/apps/web/src/components/bookmark-button.tsx b/apps/web/src/components/bookmark-button.tsx index 5532885..ad81696 100644 --- a/apps/web/src/components/bookmark-button.tsx +++ b/apps/web/src/components/bookmark-button.tsx @@ -30,7 +30,7 @@ export default function BookmarkButton({ interactionContext, size = "default", className, - redirectTo = "/feed", + redirectTo = "/", }: BookmarkButtonProps) { const t = useT(); const { isAuthenticated } = useConvexAuth(); diff --git a/apps/web/src/components/feed/event-card.tsx b/apps/web/src/components/feed/event-card.tsx index f5127f7..5c93d29 100644 --- a/apps/web/src/components/feed/event-card.tsx +++ b/apps/web/src/components/feed/event-card.tsx @@ -292,7 +292,20 @@ const EventCard = ({ { + // SEO-8: record "came from feed" client-side instead of via a + // ?returnToFeed URL param, so the crawlable href stays the + // clean canonical /event/$slug. + try { + window.sessionStorage.setItem("miez-return-to-feed", "1"); + } catch { + // Ignore unavailable/blocked sessionStorage. + } + } + : undefined + } className="group block min-w-0 flex-1 rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring" > {rowContent} diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 32407ba..7846571 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -24,7 +24,7 @@ import { } from "@/components/ui/sheet"; const allLinks = [ - { to: "/feed", key: "tabs.feed", icon: Newspaper }, + { to: "/", key: "tabs.feed", icon: Newspaper }, { to: "/quiz", key: "tabs.quiz", icon: BrainCircuit }, { to: "/salvate", key: "tabs.saved", icon: Bookmark }, { to: "/activitate", key: "tabs.activity", icon: LayoutDashboard }, @@ -55,7 +55,7 @@ export default function Header() {
{/* Logo */} diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index 2a7fc26..1ed9d37 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -22,13 +22,11 @@ type TabDefinition = TabItem & { key: TabKey }; const allTabDefinitions: readonly TabDefinition[] = [ { - to: "/feed", + to: "/", key: "tabs.feed", icon: Newspaper, isActive: (pathname: string) => - pathname === "/feed" || - pathname.startsWith("/feed/") || - pathname.startsWith("/event/"), + pathname === "/" || pathname.startsWith("/event/"), }, { to: "/quiz", key: "tabs.quiz", icon: BrainCircuit }, { to: "/salvate", key: "tabs.saved", icon: Bookmark }, diff --git a/apps/web/src/components/quiz-hidden.test.tsx b/apps/web/src/components/quiz-hidden.test.tsx index d9d1ad0..17da509 100644 --- a/apps/web/src/components/quiz-hidden.test.tsx +++ b/apps/web/src/components/quiz-hidden.test.tsx @@ -52,7 +52,7 @@ describe("quiz feature flag (BIV-802)", () => { const redirectTo = (thrown as { options?: { to?: string }; to?: string }).options?.to ?? (thrown as { to?: string }).to; - expect(redirectTo).toBe("/feed"); + expect(redirectTo).toBe("/"); } }); }); diff --git a/apps/web/src/lib/feature-flags.ts b/apps/web/src/lib/feature-flags.ts index 117b429..060b535 100644 --- a/apps/web/src/lib/feature-flags.ts +++ b/apps/web/src/lib/feature-flags.ts @@ -20,6 +20,6 @@ export const FEATURE_FLAGS = { */ export function guardQuizRoute() { if (!FEATURE_FLAGS.quiz) { - throw redirect({ to: "/feed", replace: true }); + throw redirect({ to: "/", replace: true }); } } diff --git a/apps/web/src/lib/i18n/getLocaleFromMatches.ts b/apps/web/src/lib/i18n/getLocaleFromMatches.ts index c91373b..45c9c06 100644 --- a/apps/web/src/lib/i18n/getLocaleFromMatches.ts +++ b/apps/web/src/lib/i18n/getLocaleFromMatches.ts @@ -5,13 +5,17 @@ const SUPPORTED_LOCALES = ["ro", "en"] as const satisfies readonly Locale[]; export function getLocaleFromMatches( matches: ReadonlyArray<{ context?: unknown }>, ): Locale { + // Romanian-first product (see resolveLocale): when the root context has not + // resolved a locale — e.g. a first-hit crawler with no cookie/?lang — meta + // must default to Romanian to match and the content, not + // leak English titles/descriptions on indexable pages (SEO-2). const rootContext = matches[0]?.context; if (!rootContext || typeof rootContext !== "object") { - return "en"; + return "ro"; } const locale = "locale" in rootContext ? (rootContext.locale as string | undefined) : null; - return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "en"; + return SUPPORTED_LOCALES.includes(locale as Locale) ? (locale as Locale) : "ro"; } diff --git a/apps/web/src/lib/seo.ts b/apps/web/src/lib/seo.ts index 5a875c6..a0d5805 100644 --- a/apps/web/src/lib/seo.ts +++ b/apps/web/src/lib/seo.ts @@ -18,6 +18,9 @@ export const SITE = { title: getString("en", "seo.siteTitle"), description: getString("en", "seo.siteDescription"), ogImage: "https://www.miez.news/og-image.jpg", + // Romanian alt for the default share card (SEO-2). Event pages override with + // their own imageAlt when a per-event photo is present. + ogImageAlt: "Miez - știri din ambele tabere", ogImageType: "image/jpeg", ogImageWidth: 1200, ogImageHeight: 630, @@ -27,6 +30,39 @@ export function absoluteSiteUrl(pathname: string): string { return new URL(pathname, SITE.url).toString(); } +/** + * Truncate text at a word boundary, never mid-word, appending an ellipsis when + * (and only when) the text was actually cut. Trailing whitespace/punctuation + * before the ellipsis is stripped so descriptions never read "…word ,…" or end + * with a dangling space before the closing quote (SEO-6). Whitespace is also + * collapsed so multi-line summaries render as a single clean meta line. + */ +export function truncateAtWordBoundary(text: string, maxLen: number): string { + const normalized = text.replace(/\s+/g, " ").trim(); + if (normalized.length <= maxLen) return normalized; + const slice = normalized.slice(0, maxLen); + const lastSpace = slice.lastIndexOf(" "); + const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice; + // Drop any trailing space or dangling punctuation before the ellipsis. + const trimmed = cut.replace(/[\s.,;:!?…·•\-–—]+$/u, ""); + return `${trimmed}…`; +} + +/** + * Short, stable canonical headline for /og:title/twitter:title, RSS + * items and the news sitemap (SEO-5). Event titles are concatenated source + * headlines (200+ chars); take the first segment before the " / " join and cap + * it at ~65 chars on a word boundary. The long compound title stays on the + * page as the <h1>. Falls back to the raw title when there is no separator. + */ +export function deriveShortTitle(title: string, maxLen = 65): string { + const firstSegment = title.split(/\s*\/\s*/)[0]?.trim(); + return truncateAtWordBoundary( + firstSegment && firstSegment.length > 0 ? firstSegment : title, + maxLen, + ); +} + /** * Official social/entity profiles for JSON-LD `sameAs`. Extend as profiles * are created; never list a profile that doesn't exist yet. @@ -45,7 +81,9 @@ function organizationEntity(): JsonLd { "@type": "NewsMediaOrganization", name: SITE.name, url: SITE.url, - logo: absoluteSiteUrl("/favicon.svg"), + // Structured-data logo must be a raster image (Google rejects SVG for the + // publisher logo); logo-mark.png is 512×512 (SEO-7). + logo: absoluteSiteUrl("/logo-mark.png"), ...(SOCIAL_PROFILES.length > 0 ? { sameAs: SOCIAL_PROFILES } : {}), }; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 207b01e..ee31c69 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as SurseleNoastreRouteImport } from './routes/sursele-noastre' import { Route as SurseRouteImport } from './routes/surse' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' import { Route as SalvateRouteImport } from './routes/salvate' +import { Route as RssDotxmlRouteImport } from './routes/rss[.]xml' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as ResetPasswordRouteImport } from './routes/reset-password' import { Route as QuizRouteImport } from './routes/quiz' @@ -22,6 +23,7 @@ import { Route as PublishersRouteImport } from './routes/publishers' import { Route as ProfilRouteImport } from './routes/profil' import { Route as PoliticaConfidentialitateRouteImport } from './routes/politica-confidentialitate' import { Route as ParteneriRouteImport } from './routes/parteneri' +import { Route as NewsSitemapDotxmlRouteImport } from './routes/news-sitemap[.]xml' import { Route as MetodologieRouteImport } from './routes/metodologie' import { Route as LlmsDottxtRouteImport } from './routes/llms[.]txt' import { Route as FinantareRouteImport } from './routes/finantare' @@ -73,6 +75,11 @@ const SalvateRoute = SalvateRouteImport.update({ path: '/salvate', getParentRoute: () => rootRouteImport, } as any) +const RssDotxmlRoute = RssDotxmlRouteImport.update({ + id: '/rss.xml', + path: '/rss.xml', + getParentRoute: () => rootRouteImport, +} as any) const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ id: '/robots.txt', path: '/robots.txt', @@ -109,6 +116,11 @@ const ParteneriRoute = ParteneriRouteImport.update({ path: '/parteneri', getParentRoute: () => rootRouteImport, } as any) +const NewsSitemapDotxmlRoute = NewsSitemapDotxmlRouteImport.update({ + id: '/news-sitemap.xml', + path: '/news-sitemap.xml', + getParentRoute: () => rootRouteImport, +} as any) const MetodologieRoute = MetodologieRouteImport.update({ id: '/metodologie', path: '/metodologie', @@ -223,6 +235,7 @@ export interface FileRoutesByFullPath { '/finantare': typeof FinantareRoute '/llms.txt': typeof LlmsDottxtRoute '/metodologie': typeof MetodologieRoute + '/news-sitemap.xml': typeof NewsSitemapDotxmlRoute '/parteneri': typeof ParteneriRoute '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute '/profil': typeof ProfilRoute @@ -230,6 +243,7 @@ export interface FileRoutesByFullPath { '/quiz': typeof QuizRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute + '/rss.xml': typeof RssDotxmlRoute '/salvate': typeof SalvateRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/surse': typeof SurseRoute @@ -258,6 +272,7 @@ export interface FileRoutesByTo { '/finantare': typeof FinantareRoute '/llms.txt': typeof LlmsDottxtRoute '/metodologie': typeof MetodologieRoute + '/news-sitemap.xml': typeof NewsSitemapDotxmlRoute '/parteneri': typeof ParteneriRoute '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute '/profil': typeof ProfilRoute @@ -265,6 +280,7 @@ export interface FileRoutesByTo { '/quiz': typeof QuizRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute + '/rss.xml': typeof RssDotxmlRoute '/salvate': typeof SalvateRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/surse': typeof SurseRoute @@ -294,6 +310,7 @@ export interface FileRoutesById { '/finantare': typeof FinantareRoute '/llms.txt': typeof LlmsDottxtRoute '/metodologie': typeof MetodologieRoute + '/news-sitemap.xml': typeof NewsSitemapDotxmlRoute '/parteneri': typeof ParteneriRoute '/politica-confidentialitate': typeof PoliticaConfidentialitateRoute '/profil': typeof ProfilRoute @@ -301,6 +318,7 @@ export interface FileRoutesById { '/quiz': typeof QuizRoute '/reset-password': typeof ResetPasswordRoute '/robots.txt': typeof RobotsDottxtRoute + '/rss.xml': typeof RssDotxmlRoute '/salvate': typeof SalvateRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/surse': typeof SurseRoute @@ -331,6 +349,7 @@ export interface FileRouteTypes { | '/finantare' | '/llms.txt' | '/metodologie' + | '/news-sitemap.xml' | '/parteneri' | '/politica-confidentialitate' | '/profil' @@ -338,6 +357,7 @@ export interface FileRouteTypes { | '/quiz' | '/reset-password' | '/robots.txt' + | '/rss.xml' | '/salvate' | '/sitemap.xml' | '/surse' @@ -366,6 +386,7 @@ export interface FileRouteTypes { | '/finantare' | '/llms.txt' | '/metodologie' + | '/news-sitemap.xml' | '/parteneri' | '/politica-confidentialitate' | '/profil' @@ -373,6 +394,7 @@ export interface FileRouteTypes { | '/quiz' | '/reset-password' | '/robots.txt' + | '/rss.xml' | '/salvate' | '/sitemap.xml' | '/surse' @@ -401,6 +423,7 @@ export interface FileRouteTypes { | '/finantare' | '/llms.txt' | '/metodologie' + | '/news-sitemap.xml' | '/parteneri' | '/politica-confidentialitate' | '/profil' @@ -408,6 +431,7 @@ export interface FileRouteTypes { | '/quiz' | '/reset-password' | '/robots.txt' + | '/rss.xml' | '/salvate' | '/sitemap.xml' | '/surse' @@ -437,6 +461,7 @@ export interface RootRouteChildren { FinantareRoute: typeof FinantareRoute LlmsDottxtRoute: typeof LlmsDottxtRoute MetodologieRoute: typeof MetodologieRoute + NewsSitemapDotxmlRoute: typeof NewsSitemapDotxmlRoute ParteneriRoute: typeof ParteneriRoute PoliticaConfidentialitateRoute: typeof PoliticaConfidentialitateRoute ProfilRoute: typeof ProfilRoute @@ -444,6 +469,7 @@ export interface RootRouteChildren { QuizRoute: typeof QuizRoute ResetPasswordRoute: typeof ResetPasswordRoute RobotsDottxtRoute: typeof RobotsDottxtRoute + RssDotxmlRoute: typeof RssDotxmlRoute SalvateRoute: typeof SalvateRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute SurseRoute: typeof SurseRoute @@ -504,6 +530,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SalvateRouteImport parentRoute: typeof rootRouteImport } + '/rss.xml': { + id: '/rss.xml' + path: '/rss.xml' + fullPath: '/rss.xml' + preLoaderRoute: typeof RssDotxmlRouteImport + parentRoute: typeof rootRouteImport + } '/robots.txt': { id: '/robots.txt' path: '/robots.txt' @@ -553,6 +586,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ParteneriRouteImport parentRoute: typeof rootRouteImport } + '/news-sitemap.xml': { + id: '/news-sitemap.xml' + path: '/news-sitemap.xml' + fullPath: '/news-sitemap.xml' + preLoaderRoute: typeof NewsSitemapDotxmlRouteImport + parentRoute: typeof rootRouteImport + } '/metodologie': { id: '/metodologie' path: '/metodologie' @@ -709,6 +749,7 @@ const rootRouteChildren: RootRouteChildren = { FinantareRoute: FinantareRoute, LlmsDottxtRoute: LlmsDottxtRoute, MetodologieRoute: MetodologieRoute, + NewsSitemapDotxmlRoute: NewsSitemapDotxmlRoute, ParteneriRoute: ParteneriRoute, PoliticaConfidentialitateRoute: PoliticaConfidentialitateRoute, ProfilRoute: ProfilRoute, @@ -716,6 +757,7 @@ const rootRouteChildren: RootRouteChildren = { QuizRoute: QuizRoute, ResetPasswordRoute: ResetPasswordRoute, RobotsDottxtRoute: RobotsDottxtRoute, + RssDotxmlRoute: RssDotxmlRoute, SalvateRoute: SalvateRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, SurseRoute: SurseRoute, diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 861dedf..ee7066e 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -26,7 +26,7 @@ function NotFound() { return ( <div className="container mx-auto max-w-4xl px-4 py-16 text-center"> <h1 className="mb-2 text-2xl font-semibold">{t("router.notFound")}</h1> - <Link to="/feed" className="text-primary underline"> + <Link to="/" className="text-primary underline"> {t("router.backToFeed")} </Link> </div> diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 6c52a57..01dc94f 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense } from "react"; import { Toaster } from "@/components/ui/sonner"; -import { SITE } from "@/lib/seo"; +import { SITE, absoluteSiteUrl } from "@/lib/seo"; import { Footer } from "@/components/layout/Footer"; import { MobileTabBar } from "@/components/layout/MobileTabBar"; import { MiezOnboarding } from "@/components/MiezOnboarding"; @@ -127,6 +127,14 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({ links: [ { rel: "stylesheet", href: appCss }, { rel: "manifest", href: "/manifest.webmanifest" }, + // Site-wide RSS discovery (SEO-3): feed readers and crawlers pick this + // up from any page. + { + rel: "alternate", + type: "application/rss+xml", + title: SITE.name, + href: absoluteSiteUrl("/rss.xml"), + }, // SVG mark first (modern browsers); PNG kept as the legacy fallback. { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, { rel: "icon", type: "image/png", href: "/logo-mark.png" }, diff --git a/apps/web/src/routes/activitate.tsx b/apps/web/src/routes/activitate.tsx index de540ad..b351a23 100644 --- a/apps/web/src/routes/activitate.tsx +++ b/apps/web/src/routes/activitate.tsx @@ -315,7 +315,7 @@ function AuthorizedDashboard({ </p> </div> <Button asChild variant="ghost" size="sm"> - <Link to="/feed" className="gap-1"> + <Link to="/" className="gap-1"> {t("tabs.feed")} <ChevronRight className="size-4" /> </Link> @@ -413,7 +413,7 @@ function AuthorizedDashboard({ {/* Quick Actions */} <div className="grid gap-3 sm:grid-cols-2"> <Link - to="/feed" + to="/" className="group flex items-center gap-4 rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/50 hover:bg-primary/5" > <div className="flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground"> diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx index 568f2d1..5173a2f 100644 --- a/apps/web/src/routes/event.$slug.tsx +++ b/apps/web/src/routes/event.$slug.tsx @@ -4,11 +4,10 @@ import { notFound, useNavigate, } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useConvexAuth, useQuery } from "convex/react"; import { api } from "@news-app/backend/convex/_generated/api"; import { useConvexMutation } from "@convex-dev/react-query"; -import { z } from "zod"; import { Button } from "@/components/ui/button"; import ArticlesList from "@/components/feed/articles-list"; import { EventDetailTabs } from "@/components/feed/event-detail-tabs"; @@ -29,11 +28,18 @@ import { type Locale, type StringKey, } from "@/lib/i18n/strings"; -import { SITE, absoluteSiteUrl, jsonLdScript } from "@/lib/seo"; +import { + SITE, + absoluteSiteUrl, + deriveShortTitle, + jsonLdScript, + truncateAtWordBoundary, +} from "@/lib/seo"; -const searchSchema = z.object({ - returnToFeed: z.string().optional(), -}); +// SEO-8: "came from feed" is carried in sessionStorage (set on the feed link +// click), not a ?returnToFeed URL param, so crawlers only ever see the clean +// canonical /event/$slug URL with no parameter variants. +const RETURN_TO_FEED_KEY = "miez-return-to-feed"; function getPluralizedCountLabel( locale: Locale, @@ -59,7 +65,6 @@ function getPluralizedCountLabel( } export const Route = createFileRoute("/event/$slug")({ - validateSearch: searchSchema, loader: async ({ context, params }) => { const httpClient = context.convexQueryClient.serverHttpClient; let data; @@ -93,13 +98,21 @@ export const Route = createFileRoute("/event/$slug")({ notFoundComponent: EventNotFound, head: ({ loaderData, params, matches }) => { const locale = getLocaleFromMatches(matches); - const title = loaderData?.event?.title - ? `${loaderData.event.title} | ${SITE.name}` + // Short, single-headline canonical title for <title>/og/twitter cards + // (SEO-5); the full compound title stays as the on-page <h1>. + const shortTitle = loaderData?.event?.title + ? deriveShortTitle(loaderData.event.title) + : null; + const title = shortTitle + ? `${shortTitle} | ${SITE.name}` : getString(locale, "event.metaTitle"); - const description = - loaderData?.event?.perspectiveSummaries?.neutral?.slice(0, 155) ?? - loaderData?.event?.globalImpact?.slice(0, 155) ?? - getString(locale, "event.metaDescription"); + // Word-boundary truncation, never mid-word, single ellipsis (SEO-6). + const rawDescription = + loaderData?.event?.perspectiveSummaries?.neutral?.trim() || + loaderData?.event?.globalImpact?.trim(); + const description = rawDescription + ? truncateAtWordBoundary(rawDescription, 155) + : getString(locale, "event.metaDescription"); const imageUrl = loaderData?.event?.shareImageUrl ?? loaderData?.event?.imageUrl; @@ -219,7 +232,8 @@ export const Route = createFileRoute("/event/$slug")({ "@type": "NewsMediaOrganization", name: SITE.name, url: SITE.url, - logo: absoluteSiteUrl("/favicon.svg"), + // Raster logo (512×512) — Google rejects an SVG here (SEO-7). + logo: absoluteSiteUrl("/logo-mark.png"), }, ...(loaderData.event.imageUrl ? { image: [loaderData.event.imageUrl] } @@ -244,7 +258,7 @@ function EventNotFound() { <h1 className="mb-2 text-2xl font-semibold">{t("event.notFound")}</h1> <p className="mb-4 text-muted-foreground">{t("event.notFoundBody")}</p> <Button asChild> - <Link to="/feed">{t("event.backToFeed")}</Link> + <Link to="/">{t("event.backToFeed")}</Link> </Button> </div> </div> @@ -256,7 +270,6 @@ function EventDetailPage() { const t = useT(); const { slug } = Route.useParams(); const loaderData = Route.useLoaderData(); - const search = Route.useSearch(); const eventData = useQuery(api.events.getEventBySlug, { slug }) ?? loaderData; // L4 — per-sentence source attribution for the summary tabs. const grounding = useQuery( @@ -266,15 +279,28 @@ function EventDetailPage() { const { isAuthenticated } = useConvexAuth(); const logInteractionFn = useConvexMutation(api.interactions.logInteraction); const navigate = useNavigate(); - const returnToFeed = search.returnToFeed === "1"; + // Set by the feed card on click (SEO-8). Read once and clear, so a reload of + // a directly-shared event URL falls back to navigating to the feed instead of + // popping unrelated history. + const [cameFromFeed, setCameFromFeed] = useState(false); + useEffect(() => { + try { + if (window.sessionStorage.getItem(RETURN_TO_FEED_KEY) === "1") { + setCameFromFeed(true); + window.sessionStorage.removeItem(RETURN_TO_FEED_KEY); + } + } catch { + // Ignore unavailable/blocked sessionStorage. + } + }, [slug]); const handleBackToFeed = () => { - if (returnToFeed && window.history.length > 1) { + if (cameFromFeed && window.history.length > 1) { window.history.back(); return; } - void navigate({ to: "/feed" }); + void navigate({ to: "/" }); }; useEffect(() => { diff --git a/apps/web/src/routes/feed.tsx b/apps/web/src/routes/feed.tsx index ad11755..4a6b358 100644 --- a/apps/web/src/routes/feed.tsx +++ b/apps/web/src/routes/feed.tsx @@ -1,936 +1,26 @@ -import { - useEffect, - useMemo, - useRef, - useState, - type ComponentProps, -} from "react"; -import { createFileRoute, Link, notFound } from "@tanstack/react-router"; -import { z } from "zod"; -import { api } from "@news-app/backend/convex/_generated/api"; -import type { Id } from "@news-app/backend/convex/_generated/dataModel"; -import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react"; -import { buildFeedQueryArgs } from "@/lib/feed-query"; -import { CheckIcon, ChevronDownIcon, FilterIcon, XIcon } from "lucide-react"; -import { QuizCta } from "@/components/quiz-cta"; -import EventCard from "@/components/feed/event-card"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { SectionTitle } from "@/components/ui/section-title"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Drawer, - DrawerClose, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "@/components/ui/drawer"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { useIsMobile } from "@/components/ui/use-mobile"; -import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; -import { useT } from "@/lib/i18n/LocaleContext"; -import { getString } from "@/lib/i18n/strings"; -import { cn } from "@/lib/utils"; -import { - SITE, - absoluteSiteUrl, - jsonLdScript, - organizationJsonLd, - softwareApplicationJsonLd, -} from "@/lib/seo"; - -// The event shape EventCard consumes. Loader/query results feed straight -// into it; annotating the .map callbacks explicitly keeps the typecheck -// stable even when the generated loader types resolve loosely in CI. -type FeedEventCardData = ComponentProps<typeof EventCard>["event"]; - -// Crawl archive: fixed pages behind ?page=N so a no-JS crawler can reach -// every published event through real anchors (Googlebot does not scroll). -const ARCHIVE_PAGE_SIZE = 20; -// Matches the client-side default page size so hydration swaps the SSR list -// for the live subscription without a visible jump. -const SSR_FEED_PAGE_SIZE = 6; - -const feedSearchSchema = z.object({ - page: z.coerce.number().int().min(1).optional().catch(undefined), -}); +import { createFileRoute } from "@tanstack/react-router"; + +// SEO-1: the feed now renders at the root URL (/). /feed is kept only as a +// permanent (308) redirect so old links, bookmarks and previously indexed +// URLs resolve to the canonical root. Query params (e.g. the ?page=N archive) +// are preserved: /feed?page=2 -> /?page=2. 308 (not 307/301) keeps the method +// and tells crawlers the move is permanent so they transfer signals to /. +function buildRedirectResponse(url: string) { + const search = new URL(url).search; + return new Response(null, { + status: 308, + headers: { + location: `/${search}`, + "cache-control": "public, max-age=3600", + }, + }); +} export const Route = createFileRoute("/feed")({ - validateSearch: feedSearchSchema, - loaderDeps: ({ search }) => ({ page: search.page }), - loader: async ({ context, deps }) => { - const client = - context.convexQueryClient.serverHttpClient ?? context.convexClient; - - if (deps.page !== undefined) { - let archive; - try { - archive = await client.query(api.events.getPublishedEventsArchivePage, { - page: deps.page, - pageSize: ARCHIVE_PAGE_SIZE, - }); - } catch (error) { - console.error( - `[Route loader] Failed to load feed archive page ${deps.page}:`, - error, - ); - return { archive: null }; - } - if (archive.events.length === 0 && deps.page > 1) { - // Out-of-range page numbers must 404, not mirror another page. - throw notFound(); - } - return { archive }; - } - - // Interactive feed: server-render the first page of events so the feed - // is not a loading shell for crawlers; the live subscription takes over - // after hydration. The anonymous trending snapshot keeps this cheap. - try { - const first = await client.query(api.events.getPublishedEvents, { - ...buildFeedQueryArgs("all", "trending"), - paginationOpts: { numItems: SSR_FEED_PAGE_SIZE, cursor: null }, - }); - return { initialEvents: first.page }; - } catch (error) { - console.error("[Route loader] Failed to load initial feed page:", error); - return { initialEvents: [] }; - } - }, - head: ({ matches, loaderData }) => { - const locale = getLocaleFromMatches(matches); - const archivePage = - loaderData && "archive" in loaderData - ? (loaderData.archive?.page ?? null) - : null; - const baseTitle = getString(locale, "feed.meta.title"); - const title = archivePage - ? `${baseTitle} — ${getString(locale, "feed.archive.page").replace("{page}", String(archivePage))}` - : baseTitle; - const description = getString(locale, "feed.meta.description"); - // Paginated archive pages self-canonicalize. - const canonicalPath = archivePage ? `/feed?page=${archivePage}` : "/feed"; - - return { - meta: [ - { title }, - { name: "description", content: description }, - { property: "og:title", content: title }, - { property: "og:description", content: description }, - { property: "og:site_name", content: SITE.name }, - { name: "twitter:title", content: title }, - { name: "twitter:description", content: description }, - { property: "og:image", content: SITE.ogImage }, - { name: "twitter:image", content: SITE.ogImage }, - { property: "og:url", content: absoluteSiteUrl(canonicalPath) }, - { property: "og:type", content: "website" }, - { name: "twitter:card", content: "summary_large_image" }, - { property: "og:locale", content: locale === "ro" ? "ro_RO" : "en_US" }, - ], - links: [{ rel: "canonical", href: absoluteSiteUrl(canonicalPath) }], - // Org + app schema only on the de-facto landing page (/ redirects - // here), not on every archive page. - scripts: archivePage - ? [] - : [ - jsonLdScript(organizationJsonLd()), - jsonLdScript(softwareApplicationJsonLd(description)), - ], - }; + server: { + handlers: { + HEAD: ({ request }) => buildRedirectResponse(request.url), + GET: ({ request }) => buildRedirectResponse(request.url), + }, }, - component: FeedComponent, }); - -function TopicFilterContent({ - topics, - selectedTopic, - onSelect, -}: { - topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined; - selectedTopic: Id<"topics"> | "all"; - onSelect: (topic: Id<"topics"> | "all") => void; -}) { - const t = useT(); - - return ( - <Command className="w-full"> - <CommandInput - aria-label={t("feed.topic.search")} - placeholder={t("feed.topic.search")} - /> - <CommandList className="max-h-75"> - <CommandEmpty>{t("feed.topic.empty")}</CommandEmpty> - <CommandGroup> - <CommandItem - value="all-topics" - onSelect={() => onSelect("all")} - className="flex items-center justify-between gap-2" - > - <span>{t("feed.topic.all")}</span> - {selectedTopic === "all" && ( - <CheckIcon className="size-4 text-primary" /> - )} - </CommandItem> - {topics?.map((topic) => ( - <CommandItem - key={topic._id} - value={topic.displayName} - onSelect={() => onSelect(topic._id)} - className="flex items-center justify-between gap-2" - > - <span>{topic.displayName}</span> - {selectedTopic === topic._id && ( - <CheckIcon className="size-4 text-primary" /> - )} - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - ); -} - -function TopicFilter({ - topics, - selectedTopic, - onSelect, -}: { - topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined; - selectedTopic: Id<"topics"> | "all"; - onSelect: (topic: Id<"topics"> | "all") => void; -}) { - const t = useT(); - const [open, setOpen] = useState(false); - const isMobile = useIsMobile(); - - const selectedLabel = useMemo(() => { - if (selectedTopic === "all") return t("feed.topic.all"); - return ( - topics?.find((topic) => topic._id === selectedTopic)?.displayName ?? - t("feed.topic.single") - ); - }, [selectedTopic, t, topics]); - - const handleSelect = (topic: Id<"topics"> | "all") => { - onSelect(topic); - setOpen(false); - }; - - const triggerButton = ( - <Button - variant="outline" - size="sm" - className={cn( - "w-full max-w-52 min-w-0 justify-between gap-1.5 rounded-full px-3 sm:max-w-60 md:min-w-35 md:max-w-64 md:gap-2", - selectedTopic !== "all" && "border-primary/50 bg-primary/5", - )} - aria-label={t("feed.topic.filter")} - > - <span className="flex min-w-0 flex-1 items-center gap-2"> - <FilterIcon className="size-3.5 shrink-0" /> - <span className="truncate">{selectedLabel}</span> - </span> - <ChevronDownIcon className="size-3.5 shrink-0 opacity-50" /> - </Button> - ); - - if (isMobile) { - return ( - <div className="flex items-center gap-2"> - <Drawer open={open} onOpenChange={setOpen}> - <DrawerTrigger asChild>{triggerButton}</DrawerTrigger> - <DrawerContent> - <DrawerHeader className="border-b border-border pb-4"> - <div className="flex items-center justify-between"> - <div> - <DrawerTitle>{t("feed.topic.drawerTitle")}</DrawerTitle> - <DrawerDescription> - {t("feed.topic.drawerBody")} - </DrawerDescription> - </div> - <DrawerClose asChild> - <Button - variant="ghost" - size="icon" - className="size-8 rounded-full" - aria-label={t("feed.close")} - > - <XIcon className="size-4" /> - <span className="sr-only">{t("feed.close")}</span> - </Button> - </DrawerClose> - </div> - </DrawerHeader> - <div className="p-4"> - <TopicFilterContent - topics={topics} - selectedTopic={selectedTopic} - onSelect={handleSelect} - /> - </div> - </DrawerContent> - </Drawer> - - {selectedTopic !== "all" && ( - <Button - variant="ghost" - size="icon" - className="size-8 rounded-full" - onClick={() => onSelect("all")} - aria-label={t("feed.filter.clear")} - > - <XIcon className="size-4" /> - </Button> - )} - </div> - ); - } - - return ( - <div className="flex items-center gap-2"> - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger asChild>{triggerButton}</PopoverTrigger> - <PopoverContent className="w-60 p-0" align="start"> - <TopicFilterContent - topics={topics} - selectedTopic={selectedTopic} - onSelect={handleSelect} - /> - </PopoverContent> - </Popover> - - {selectedTopic !== "all" && ( - <Button - variant="ghost" - size="icon" - className="size-8 rounded-full" - onClick={() => onSelect("all")} - aria-label={t("feed.filter.clear")} - > - <XIcon className="size-4" /> - </Button> - )} - </div> - ); -} - -function useTopicNamesById() { - const topics = useQuery(api.topics.getTopics); - const topicNamesById = useMemo(() => { - const map: Record<string, string> = {}; - topics?.forEach((topic) => { - map[topic._id] = topic.displayName; - }); - return map; - }, [topics]); - return { topics, topicNamesById }; -} - -function FeedComponent() { - const { page } = Route.useSearch(); - if (page !== undefined) { - return <FeedArchive />; - } - return <FeedContent />; -} - -/** - * Static, crawlable slice of the feed (/feed?page=N): server-rendered event - * list in stable recent order with real previous/next anchors. Infinite - * scroll on /feed stays the interactive experience layered on top. - */ -function FeedArchive() { - const t = useT(); - const loaderData = Route.useLoaderData(); - const { topicNamesById } = useTopicNamesById(); - const archive = - loaderData && "archive" in loaderData ? loaderData.archive : null; - - if (!archive) { - return ( - <div className="container mx-auto max-w-4xl px-4 py-8"> - <p - role="status" - aria-live="polite" - className="text-sm text-muted-foreground" - > - {t("feed.loading")} - </p> - </div> - ); - } - - return ( - <div className="bg-background"> - <div className="container mx-auto max-w-4xl px-4 py-6 sm:py-10"> - <div className="flex flex-col gap-6"> - <header className="flex flex-col gap-2 border-b border-border pb-4"> - <SectionTitle>{t("feed.archive.title")}</SectionTitle> - <p className="text-sm text-muted-foreground"> - {t("feed.archive.page").replace("{page}", String(archive.page))} - </p> - <Link - to="/feed" - className="text-sm text-muted-foreground underline hover:text-foreground" - > - {t("feed.archive.backToFeed")} - </Link> - </header> - - {archive.events.length === 0 ? ( - <p className="py-8 text-sm text-muted-foreground"> - {t("feed.archive.empty")} - </p> - ) : ( - <div className="flex flex-col divide-y divide-border"> - {archive.events.map((event: FeedEventCardData) => ( - <div key={event._id} className="py-5"> - <EventCard event={event} topicNamesById={topicNamesById} /> - </div> - ))} - </div> - )} - - <nav - aria-label={t("feed.archive.title")} - className="flex items-center justify-between border-t border-border pt-4 text-sm" - > - {archive.page > 1 ? ( - <Link - to="/feed" - search={{ page: archive.page - 1 }} - className="text-muted-foreground underline hover:text-foreground" - > - ← {t("feed.archive.prev")} - </Link> - ) : ( - <Link - to="/feed" - className="text-muted-foreground underline hover:text-foreground" - > - ← {t("feed.archive.backToFeed")} - </Link> - )} - {archive.hasMore && ( - <Link - to="/feed" - search={{ page: archive.page + 1 }} - className="text-muted-foreground underline hover:text-foreground" - > - {t("feed.archive.next")} → - </Link> - )} - </nav> - </div> - </div> - </div> - ); -} - -function FeedContent() { - const t = useT(); - const loaderData = Route.useLoaderData(); - const initialEvents = - loaderData && "initialEvents" in loaderData ? loaderData.initialEvents : []; - const { isAuthenticated } = useConvexAuth(); - const { topics, topicNamesById } = useTopicNamesById(); - const currentUser = useQuery( - api.user.getCurrentUser, - isAuthenticated ? {} : "skip", - ); - const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig); - const rawPageSize = Number(runtimeConfig?.feedPageSize); - const MAX_FEED_PAGE_SIZE = 50; - const pageSize = Number.isFinite(rawPageSize) - ? Math.min(MAX_FEED_PAGE_SIZE, Math.max(1, Math.floor(rawPageSize))) - : 6; - - const [selectedTopic, setSelectedTopic] = useState<Id<"topics"> | "all">( - "all", - ); - const [feedSort, setFeedSort] = useState<"recent" | "trending">("trending"); - const [searchInput, setSearchInput] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const isSearching = debouncedSearch.length >= 2; - const [isSearchFocused, setIsSearchFocused] = useState(false); - const [recentSearches, setRecentSearches] = useState<string[]>([]); - const searchInputRef = useRef<HTMLInputElement | null>(null); - const loadMoreTriggerRef = useRef<HTMLDivElement | null>(null); - const isLoadingMoreRef = useRef(false); - - useEffect(() => { - try { - const raw = window.localStorage.getItem("miez-recent-event-searches"); - if (!raw) return; - const parsed = JSON.parse(raw); - if (Array.isArray(parsed)) { - setRecentSearches( - parsed - .filter((value): value is string => typeof value === "string") - .slice(0, 5), - ); - } - } catch { - // Ignore malformed localStorage. - } - }, []); - - useEffect(() => { - const timeout = window.setTimeout(() => { - setDebouncedSearch(searchInput.trim()); - }, 250); - - return () => window.clearTimeout(timeout); - }, [searchInput]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "/") return; - if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { - return; - } - - const target = event.target as HTMLElement | null; - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - target?.isContentEditable - ) { - return; - } - - event.preventDefault(); - searchInputRef.current?.focus(); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - const { - results: liveEvents, - status, - loadMore, - } = usePaginatedQuery( - api.events.getPublishedEvents, - buildFeedQueryArgs(selectedTopic, feedSort), - { initialNumItems: pageSize }, - ); - // Until the live subscription delivers its first page, fall back to the - // loader's server-fetched events so the initial (and crawler-visible) - // HTML contains real content instead of a loading shell. - const events = - status === "LoadingFirstPage" && liveEvents.length === 0 - ? initialEvents - : liveEvents; - const searchResults = useQuery( - api.events.searchPublishedEvents, - isSearching - ? { - query: debouncedSearch, - limit: pageSize, - topicId: selectedTopic === "all" ? undefined : selectedTopic, - } - : "skip", - ); - const canLoadMore = !isSearching && status === "CanLoadMore"; - const isLoadingMore = !isSearching && status === "LoadingMore"; - const loadMoreRef = useRef(loadMore); - - const preferredTopicIds = useMemo(() => { - if (!topics || !currentUser?.privateContext?.interests?.length) { - return []; - } - - const preferredNames = currentUser.privateContext.interests.map( - (interest) => interest.trim().toLowerCase(), - ); - - return topics - .filter((topic) => { - const candidates = [topic.displayName, ...(topic.aliases ?? [])].map( - (value) => value.trim().toLowerCase(), - ); - return candidates.some((candidate) => - preferredNames.includes(candidate), - ); - }) - .map((topic) => topic._id); - }, [currentUser?.privateContext?.interests, topics]); - - const fallbackEvents = useQuery( - api.events.getPublishedEventsByTopicIds, - isSearching && - searchResults !== undefined && - searchResults.length === 0 && - preferredTopicIds.length > 0 - ? { topicIds: preferredTopicIds, limit: 5 } - : "skip", - ); - - useEffect(() => { - loadMoreRef.current = loadMore; - }, [loadMore]); - - useEffect(() => { - if (status !== "LoadingMore") { - isLoadingMoreRef.current = false; - } - }, [status]); - - useEffect(() => { - if (!canLoadMore) { - return; - } - - const target = loadMoreTriggerRef.current; - if (!target) { - return; - } - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (!entry?.isIntersecting || isLoadingMoreRef.current) { - return; - } - - isLoadingMoreRef.current = true; - loadMoreRef.current(pageSize); - }, - { - rootMargin: "1200px 0px", - }, - ); - - observer.observe(target); - - return () => observer.disconnect(); - }, [canLoadMore, pageSize]); - - useEffect(() => { - if ( - debouncedSearch.length < 2 || - searchResults === undefined || - searchResults.length === 0 - ) { - return; - } - - const next = [ - debouncedSearch, - ...recentSearches.filter( - (entry) => entry.toLowerCase() !== debouncedSearch.toLowerCase(), - ), - ].slice(0, 5); - const isUnchanged = - next.length === recentSearches.length && - next.every((value, index) => value === recentSearches[index]); - if (isUnchanged) { - return; - } - setRecentSearches(() => next); - window.localStorage.setItem( - "miez-recent-event-searches", - JSON.stringify(next), - ); - }, [debouncedSearch, recentSearches, searchResults]); - - const featuredEvent = events?.[0]; - const remainingEvents = featuredEvent ? events.slice(1) : events; - const featuredSearchEvent = searchResults?.[0]; - const remainingSearchEvents = featuredSearchEvent - ? searchResults.slice(1) - : searchResults; - const shouldShowThresholdHint = - isSearchFocused && searchInput.trim().length < 2; - const shouldShowRecentSearches = - isSearchFocused && - searchInput.trim().length === 0 && - recentSearches.length > 0; - - return ( - <div className="bg-background"> - <div className="container mx-auto max-w-4xl px-4 py-6 sm:py-10"> - <div className="flex flex-col gap-6 sm:gap-8"> - {/* Feed controls: flat, in-flow — no floating glass, no - scroll-linked motion (BIV-807, native DESIGN_LOG). */} - <header className="flex flex-col gap-3 border-b border-border pb-4"> - <div className="relative"> - <Input - ref={searchInputRef} - type="search" - value={searchInput} - onChange={(event) => setSearchInput(event.target.value)} - onFocus={() => setIsSearchFocused(true)} - onBlur={() => { - window.setTimeout(() => setIsSearchFocused(false), 100); - }} - placeholder={t("feed.search.placeholder")} - className="h-10 pr-11 text-base" - aria-label={t("feed.search.label")} - /> - {searchInput.length > 0 && ( - <Button - type="button" - variant="ghost" - size="icon" - className="absolute right-1 top-1 size-8" - onClick={() => { - setSearchInput(""); - setDebouncedSearch(""); - }} - aria-label={t("feed.search.clear")} - > - <XIcon className="size-4" /> - </Button> - )} - </div> - <div className="flex items-center justify-between gap-3"> - <div className="min-w-0 w-full max-w-52 sm:max-w-60 md:max-w-64"> - <TopicFilter - topics={topics} - selectedTopic={selectedTopic} - onSelect={setSelectedTopic} - /> - </div> - {!isSearching && ( - /* Plain-text segmented control: weight + color, not pills. */ - <div className="flex shrink-0 items-center gap-4 text-sm"> - <button - type="button" - className={cn( - "transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", - feedSort === "recent" - ? "font-semibold text-foreground" - : "font-medium text-muted-foreground hover:text-foreground", - )} - onClick={() => setFeedSort("recent")} - aria-pressed={feedSort === "recent"} - > - {t("feed.sort.recent")} - </button> - <button - type="button" - className={cn( - "transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", - feedSort === "trending" - ? "font-semibold text-foreground" - : "font-medium text-muted-foreground hover:text-foreground", - )} - onClick={() => setFeedSort("trending")} - aria-pressed={feedSort === "trending"} - > - {t("feed.sort.trending")} - </button> - </div> - )} - </div> - {shouldShowThresholdHint && ( - <p className="text-xs text-muted-foreground"> - {t("feed.search.threshold")} - </p> - )} - {shouldShowRecentSearches && ( - <div className="flex flex-wrap gap-2"> - {recentSearches.map((recentSearch) => ( - <Button - key={recentSearch} - type="button" - variant="outline" - size="sm" - className="rounded-full" - onMouseDown={(event) => event.preventDefault()} - onClick={() => { - setSearchInput(recentSearch); - setDebouncedSearch(recentSearch); - searchInputRef.current?.focus(); - }} - > - {recentSearch} - </Button> - ))} - </div> - )} - {isSearching && ( - <p - className="text-xs text-muted-foreground" - role="status" - aria-live="polite" - aria-atomic="true" - > - {t("feed.search.indexed").replace("{query}", debouncedSearch)} - </p> - )} - </header> - - {!isSearching && <QuizCta variant="feed" />} - - <div className="flex flex-col gap-8"> - {isSearching && searchResults === undefined && ( - <p - role="status" - aria-live="polite" - className="py-8 text-sm text-muted-foreground" - > - {t("feed.searching")} - </p> - )} - {status === "LoadingFirstPage" && events.length === 0 && ( - <p - role="status" - aria-live="polite" - className="py-8 text-sm text-muted-foreground" - > - {t("feed.loading")} - </p> - )} - - {!isSearching && featuredEvent ? ( - <section className="flex flex-col gap-4 border-b border-border pb-8"> - <SectionTitle> - {feedSort === "recent" - ? t("feed.leadStory") - : t("feed.trendingStory")} - </SectionTitle> - <EventCard - event={featuredEvent} - topicNamesById={topicNamesById} - variant="feature" - returnToFeed - /> - </section> - ) : null} - - {isSearching && featuredSearchEvent ? ( - <section className="flex flex-col gap-4 border-b border-border pb-8"> - <SectionTitle>{t("feed.topSearch")}</SectionTitle> - <EventCard - event={featuredSearchEvent} - topicNamesById={topicNamesById} - variant="feature" - searchQuery={debouncedSearch} - returnToFeed - /> - </section> - ) : null} - - {(!isSearching && remainingEvents && remainingEvents.length > 0) || - (isSearching && - remainingSearchEvents && - remainingSearchEvents.length > 0) ? ( - <section className="flex flex-col gap-2"> - <SectionTitle> - {isSearching ? t("feed.moreSearch") : t("feed.moreEvents")} - </SectionTitle> - <div className="flex flex-col divide-y divide-border"> - {(isSearching ? remainingSearchEvents : remainingEvents)?.map( - (event: FeedEventCardData) => ( - <div key={event._id} className="py-5"> - <EventCard - event={event} - topicNamesById={topicNamesById} - searchQuery={ - isSearching ? debouncedSearch : undefined - } - returnToFeed - /> - </div> - ), - )} - </div> - </section> - ) : null} - - {isSearching && searchResults?.length === 0 && ( - <section className="flex flex-col gap-6"> - <div className="py-4 text-sm text-muted-foreground"> - <p>{t("feed.noMatch").replace("{query}", debouncedSearch)}</p> - <p className="mt-2">{t("feed.tryFewer")}</p> - </div> - {fallbackEvents && fallbackEvents.length > 0 && ( - <div className="flex flex-col gap-2"> - <SectionTitle>{t("feed.preferredTopics")}</SectionTitle> - <div className="flex flex-col divide-y divide-border"> - {fallbackEvents.map((event) => ( - <div key={event._id} className="py-5"> - <EventCard - event={event} - topicNamesById={topicNamesById} - returnToFeed - /> - </div> - ))} - </div> - </div> - )} - </section> - )} - - {!isSearching && - status !== "LoadingFirstPage" && - (!events || events.length === 0) && ( - <p - role="status" - aria-live="polite" - className="py-8 text-sm text-muted-foreground" - > - {t("feed.none")} - </p> - )} - </div> - - {!isSearching && (canLoadMore || isLoadingMore) && ( - <div className="flex flex-col items-center gap-3 py-2"> - <div - ref={loadMoreTriggerRef} - aria-hidden="true" - className="h-px w-full" - /> - {isLoadingMore && ( - <div - role="status" - aria-live="polite" - className="text-sm text-muted-foreground" - > - {t("feed.loading")} - </div> - )} - </div> - )} - - {/* Crawlable entry into the paginated archive: a real anchor a - no-JS crawler can follow, since it cannot trigger the - infinite-scroll observer above. */} - {!isSearching && ( - <nav - aria-label={t("feed.archive.title")} - className="border-t border-border pt-4" - > - <Link - to="/feed" - search={{ page: 1 }} - className="text-sm text-muted-foreground underline hover:text-foreground" - > - {t("feed.archive.browse")} → - </Link> - </nav> - )} - </div> - </div> - </div> - ); -} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 3d8ce5b..31582b9 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,7 +1,937 @@ -import { createFileRoute, redirect } from "@tanstack/react-router"; +import { + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, +} from "react"; +import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { z } from "zod"; +import { api } from "@news-app/backend/convex/_generated/api"; +import type { Id } from "@news-app/backend/convex/_generated/dataModel"; +import { useConvexAuth, usePaginatedQuery, useQuery } from "convex/react"; +import { buildFeedQueryArgs } from "@/lib/feed-query"; +import { CheckIcon, ChevronDownIcon, FilterIcon, XIcon } from "lucide-react"; +import { QuizCta } from "@/components/quiz-cta"; +import EventCard from "@/components/feed/event-card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SectionTitle } from "@/components/ui/section-title"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useIsMobile } from "@/components/ui/use-mobile"; +import { getLocaleFromMatches } from "@/lib/i18n/getLocaleFromMatches"; +import { useT } from "@/lib/i18n/LocaleContext"; +import { getString } from "@/lib/i18n/strings"; +import { cn } from "@/lib/utils"; +import { + SITE, + absoluteSiteUrl, + jsonLdScript, + organizationJsonLd, + softwareApplicationJsonLd, +} from "@/lib/seo"; + +// The event shape EventCard consumes. Loader/query results feed straight +// into it; annotating the .map callbacks explicitly keeps the typecheck +// stable even when the generated loader types resolve loosely in CI. +type FeedEventCardData = ComponentProps<typeof EventCard>["event"]; + +// Crawl archive: fixed pages behind ?page=N so a no-JS crawler can reach +// every published event through real anchors (Googlebot does not scroll). +const ARCHIVE_PAGE_SIZE = 20; +// Matches the client-side default page size so hydration swaps the SSR list +// for the live subscription without a visible jump. +const SSR_FEED_PAGE_SIZE = 6; + +const feedSearchSchema = z.object({ + page: z.coerce.number().int().min(1).optional().catch(undefined), +}); export const Route = createFileRoute("/")({ - beforeLoad: () => { - throw redirect({ to: "/feed", replace: true }); + validateSearch: feedSearchSchema, + loaderDeps: ({ search }) => ({ page: search.page }), + loader: async ({ context, deps }) => { + const client = + context.convexQueryClient.serverHttpClient ?? context.convexClient; + + if (deps.page !== undefined) { + let archive; + try { + archive = await client.query(api.events.getPublishedEventsArchivePage, { + page: deps.page, + pageSize: ARCHIVE_PAGE_SIZE, + }); + } catch (error) { + console.error( + `[Route loader] Failed to load feed archive page ${deps.page}:`, + error, + ); + return { archive: null }; + } + if (archive.events.length === 0 && deps.page > 1) { + // Out-of-range page numbers must 404, not mirror another page. + throw notFound(); + } + return { archive }; + } + + // Interactive feed: server-render the first page of events so the feed + // is not a loading shell for crawlers; the live subscription takes over + // after hydration. The anonymous trending snapshot keeps this cheap. + try { + const first = await client.query(api.events.getPublishedEvents, { + ...buildFeedQueryArgs("all", "trending"), + paginationOpts: { numItems: SSR_FEED_PAGE_SIZE, cursor: null }, + }); + return { initialEvents: first.page }; + } catch (error) { + console.error("[Route loader] Failed to load initial feed page:", error); + return { initialEvents: [] }; + } + }, + head: ({ matches, loaderData }) => { + const locale = getLocaleFromMatches(matches); + const archivePage = + loaderData && "archive" in loaderData + ? (loaderData.archive?.page ?? null) + : null; + const baseTitle = getString(locale, "feed.meta.title"); + const title = archivePage + ? `${baseTitle} — ${getString(locale, "feed.archive.page").replace("{page}", String(archivePage))}` + : baseTitle; + const description = getString(locale, "feed.meta.description"); + // The feed is served at the root URL; paginated archive pages + // self-canonicalize to /?page=N. + const canonicalPath = archivePage ? `/?page=${archivePage}` : "/"; + + return { + meta: [ + { title }, + { name: "description", content: description }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:site_name", content: SITE.name }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { property: "og:image", content: SITE.ogImage }, + { property: "og:image:alt", content: SITE.ogImageAlt }, + { name: "twitter:image", content: SITE.ogImage }, + { property: "og:url", content: absoluteSiteUrl(canonicalPath) }, + { property: "og:type", content: "website" }, + { name: "twitter:card", content: "summary_large_image" }, + { property: "og:locale", content: locale === "ro" ? "ro_RO" : "en_US" }, + ], + links: [{ rel: "canonical", href: absoluteSiteUrl(canonicalPath) }], + // Org + app schema only on the landing page (the feed root), not on + // every paginated archive page. + scripts: archivePage + ? [] + : [ + jsonLdScript(organizationJsonLd()), + jsonLdScript(softwareApplicationJsonLd(description)), + ], + }; }, + component: FeedComponent, }); + +function TopicFilterContent({ + topics, + selectedTopic, + onSelect, +}: { + topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined; + selectedTopic: Id<"topics"> | "all"; + onSelect: (topic: Id<"topics"> | "all") => void; +}) { + const t = useT(); + + return ( + <Command className="w-full"> + <CommandInput + aria-label={t("feed.topic.search")} + placeholder={t("feed.topic.search")} + /> + <CommandList className="max-h-75"> + <CommandEmpty>{t("feed.topic.empty")}</CommandEmpty> + <CommandGroup> + <CommandItem + value="all-topics" + onSelect={() => onSelect("all")} + className="flex items-center justify-between gap-2" + > + <span>{t("feed.topic.all")}</span> + {selectedTopic === "all" && ( + <CheckIcon className="size-4 text-primary" /> + )} + </CommandItem> + {topics?.map((topic) => ( + <CommandItem + key={topic._id} + value={topic.displayName} + onSelect={() => onSelect(topic._id)} + className="flex items-center justify-between gap-2" + > + <span>{topic.displayName}</span> + {selectedTopic === topic._id && ( + <CheckIcon className="size-4 text-primary" /> + )} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + ); +} + +function TopicFilter({ + topics, + selectedTopic, + onSelect, +}: { + topics: Array<{ _id: Id<"topics">; displayName: string }> | undefined; + selectedTopic: Id<"topics"> | "all"; + onSelect: (topic: Id<"topics"> | "all") => void; +}) { + const t = useT(); + const [open, setOpen] = useState(false); + const isMobile = useIsMobile(); + + const selectedLabel = useMemo(() => { + if (selectedTopic === "all") return t("feed.topic.all"); + return ( + topics?.find((topic) => topic._id === selectedTopic)?.displayName ?? + t("feed.topic.single") + ); + }, [selectedTopic, t, topics]); + + const handleSelect = (topic: Id<"topics"> | "all") => { + onSelect(topic); + setOpen(false); + }; + + const triggerButton = ( + <Button + variant="outline" + size="sm" + className={cn( + "w-full max-w-52 min-w-0 justify-between gap-1.5 rounded-full px-3 sm:max-w-60 md:min-w-35 md:max-w-64 md:gap-2", + selectedTopic !== "all" && "border-primary/50 bg-primary/5", + )} + aria-label={t("feed.topic.filter")} + > + <span className="flex min-w-0 flex-1 items-center gap-2"> + <FilterIcon className="size-3.5 shrink-0" /> + <span className="truncate">{selectedLabel}</span> + </span> + <ChevronDownIcon className="size-3.5 shrink-0 opacity-50" /> + </Button> + ); + + if (isMobile) { + return ( + <div className="flex items-center gap-2"> + <Drawer open={open} onOpenChange={setOpen}> + <DrawerTrigger asChild>{triggerButton}</DrawerTrigger> + <DrawerContent> + <DrawerHeader className="border-b border-border pb-4"> + <div className="flex items-center justify-between"> + <div> + <DrawerTitle>{t("feed.topic.drawerTitle")}</DrawerTitle> + <DrawerDescription> + {t("feed.topic.drawerBody")} + </DrawerDescription> + </div> + <DrawerClose asChild> + <Button + variant="ghost" + size="icon" + className="size-8 rounded-full" + aria-label={t("feed.close")} + > + <XIcon className="size-4" /> + <span className="sr-only">{t("feed.close")}</span> + </Button> + </DrawerClose> + </div> + </DrawerHeader> + <div className="p-4"> + <TopicFilterContent + topics={topics} + selectedTopic={selectedTopic} + onSelect={handleSelect} + /> + </div> + </DrawerContent> + </Drawer> + + {selectedTopic !== "all" && ( + <Button + variant="ghost" + size="icon" + className="size-8 rounded-full" + onClick={() => onSelect("all")} + aria-label={t("feed.filter.clear")} + > + <XIcon className="size-4" /> + </Button> + )} + </div> + ); + } + + return ( + <div className="flex items-center gap-2"> + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild>{triggerButton}</PopoverTrigger> + <PopoverContent className="w-60 p-0" align="start"> + <TopicFilterContent + topics={topics} + selectedTopic={selectedTopic} + onSelect={handleSelect} + /> + </PopoverContent> + </Popover> + + {selectedTopic !== "all" && ( + <Button + variant="ghost" + size="icon" + className="size-8 rounded-full" + onClick={() => onSelect("all")} + aria-label={t("feed.filter.clear")} + > + <XIcon className="size-4" /> + </Button> + )} + </div> + ); +} + +function useTopicNamesById() { + const topics = useQuery(api.topics.getTopics); + const topicNamesById = useMemo(() => { + const map: Record<string, string> = {}; + topics?.forEach((topic) => { + map[topic._id] = topic.displayName; + }); + return map; + }, [topics]); + return { topics, topicNamesById }; +} + +function FeedComponent() { + const { page } = Route.useSearch(); + if (page !== undefined) { + return <FeedArchive />; + } + return <FeedContent />; +} + +/** + * Static, crawlable slice of the feed (/?page=N): server-rendered event + * list in stable recent order with real previous/next anchors. Infinite + * scroll on / stays the interactive experience layered on top. + */ +function FeedArchive() { + const t = useT(); + const loaderData = Route.useLoaderData(); + const { topicNamesById } = useTopicNamesById(); + const archive = + loaderData && "archive" in loaderData ? loaderData.archive : null; + + if (!archive) { + return ( + <div className="container mx-auto max-w-4xl px-4 py-8"> + <p + role="status" + aria-live="polite" + className="text-sm text-muted-foreground" + > + {t("feed.loading")} + </p> + </div> + ); + } + + return ( + <div className="bg-background"> + <div className="container mx-auto max-w-4xl px-4 py-6 sm:py-10"> + <div className="flex flex-col gap-6"> + <header className="flex flex-col gap-2 border-b border-border pb-4"> + <SectionTitle>{t("feed.archive.title")}</SectionTitle> + <p className="text-sm text-muted-foreground"> + {t("feed.archive.page").replace("{page}", String(archive.page))} + </p> + <Link + to="/" + className="text-sm text-muted-foreground underline hover:text-foreground" + > + {t("feed.archive.backToFeed")} + </Link> + </header> + + {archive.events.length === 0 ? ( + <p className="py-8 text-sm text-muted-foreground"> + {t("feed.archive.empty")} + </p> + ) : ( + <div className="flex flex-col divide-y divide-border"> + {archive.events.map((event: FeedEventCardData) => ( + <div key={event._id} className="py-5"> + <EventCard event={event} topicNamesById={topicNamesById} /> + </div> + ))} + </div> + )} + + <nav + aria-label={t("feed.archive.title")} + className="flex items-center justify-between border-t border-border pt-4 text-sm" + > + {archive.page > 1 ? ( + <Link + to="/" + search={{ page: archive.page - 1 }} + className="text-muted-foreground underline hover:text-foreground" + > + ← {t("feed.archive.prev")} + </Link> + ) : ( + <Link + to="/" + className="text-muted-foreground underline hover:text-foreground" + > + ← {t("feed.archive.backToFeed")} + </Link> + )} + {archive.hasMore && ( + <Link + to="/" + search={{ page: archive.page + 1 }} + className="text-muted-foreground underline hover:text-foreground" + > + {t("feed.archive.next")} → + </Link> + )} + </nav> + </div> + </div> + </div> + ); +} + +function FeedContent() { + const t = useT(); + const loaderData = Route.useLoaderData(); + const initialEvents = + loaderData && "initialEvents" in loaderData ? loaderData.initialEvents : []; + const { isAuthenticated } = useConvexAuth(); + const { topics, topicNamesById } = useTopicNamesById(); + const currentUser = useQuery( + api.user.getCurrentUser, + isAuthenticated ? {} : "skip", + ); + const runtimeConfig = useQuery(api.config.getPublicRuntimeConfig); + const rawPageSize = Number(runtimeConfig?.feedPageSize); + const MAX_FEED_PAGE_SIZE = 50; + const pageSize = Number.isFinite(rawPageSize) + ? Math.min(MAX_FEED_PAGE_SIZE, Math.max(1, Math.floor(rawPageSize))) + : 6; + + const [selectedTopic, setSelectedTopic] = useState<Id<"topics"> | "all">( + "all", + ); + const [feedSort, setFeedSort] = useState<"recent" | "trending">("trending"); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const isSearching = debouncedSearch.length >= 2; + const [isSearchFocused, setIsSearchFocused] = useState(false); + const [recentSearches, setRecentSearches] = useState<string[]>([]); + const searchInputRef = useRef<HTMLInputElement | null>(null); + const loadMoreTriggerRef = useRef<HTMLDivElement | null>(null); + const isLoadingMoreRef = useRef(false); + + useEffect(() => { + try { + const raw = window.localStorage.getItem("miez-recent-event-searches"); + if (!raw) return; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + setRecentSearches( + parsed + .filter((value): value is string => typeof value === "string") + .slice(0, 5), + ); + } + } catch { + // Ignore malformed localStorage. + } + }, []); + + useEffect(() => { + const timeout = window.setTimeout(() => { + setDebouncedSearch(searchInput.trim()); + }, 250); + + return () => window.clearTimeout(timeout); + }, [searchInput]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "/") return; + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { + return; + } + + const target = event.target as HTMLElement | null; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target?.isContentEditable + ) { + return; + } + + event.preventDefault(); + searchInputRef.current?.focus(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const { + results: liveEvents, + status, + loadMore, + } = usePaginatedQuery( + api.events.getPublishedEvents, + buildFeedQueryArgs(selectedTopic, feedSort), + { initialNumItems: pageSize }, + ); + // Until the live subscription delivers its first page, fall back to the + // loader's server-fetched events so the initial (and crawler-visible) + // HTML contains real content instead of a loading shell. + const events = + status === "LoadingFirstPage" && liveEvents.length === 0 + ? initialEvents + : liveEvents; + const searchResults = useQuery( + api.events.searchPublishedEvents, + isSearching + ? { + query: debouncedSearch, + limit: pageSize, + topicId: selectedTopic === "all" ? undefined : selectedTopic, + } + : "skip", + ); + const canLoadMore = !isSearching && status === "CanLoadMore"; + const isLoadingMore = !isSearching && status === "LoadingMore"; + const loadMoreRef = useRef(loadMore); + + const preferredTopicIds = useMemo(() => { + if (!topics || !currentUser?.privateContext?.interests?.length) { + return []; + } + + const preferredNames = currentUser.privateContext.interests.map( + (interest) => interest.trim().toLowerCase(), + ); + + return topics + .filter((topic) => { + const candidates = [topic.displayName, ...(topic.aliases ?? [])].map( + (value) => value.trim().toLowerCase(), + ); + return candidates.some((candidate) => + preferredNames.includes(candidate), + ); + }) + .map((topic) => topic._id); + }, [currentUser?.privateContext?.interests, topics]); + + const fallbackEvents = useQuery( + api.events.getPublishedEventsByTopicIds, + isSearching && + searchResults !== undefined && + searchResults.length === 0 && + preferredTopicIds.length > 0 + ? { topicIds: preferredTopicIds, limit: 5 } + : "skip", + ); + + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + + useEffect(() => { + if (status !== "LoadingMore") { + isLoadingMoreRef.current = false; + } + }, [status]); + + useEffect(() => { + if (!canLoadMore) { + return; + } + + const target = loadMoreTriggerRef.current; + if (!target) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (!entry?.isIntersecting || isLoadingMoreRef.current) { + return; + } + + isLoadingMoreRef.current = true; + loadMoreRef.current(pageSize); + }, + { + rootMargin: "1200px 0px", + }, + ); + + observer.observe(target); + + return () => observer.disconnect(); + }, [canLoadMore, pageSize]); + + useEffect(() => { + if ( + debouncedSearch.length < 2 || + searchResults === undefined || + searchResults.length === 0 + ) { + return; + } + + const next = [ + debouncedSearch, + ...recentSearches.filter( + (entry) => entry.toLowerCase() !== debouncedSearch.toLowerCase(), + ), + ].slice(0, 5); + const isUnchanged = + next.length === recentSearches.length && + next.every((value, index) => value === recentSearches[index]); + if (isUnchanged) { + return; + } + setRecentSearches(() => next); + window.localStorage.setItem( + "miez-recent-event-searches", + JSON.stringify(next), + ); + }, [debouncedSearch, recentSearches, searchResults]); + + const featuredEvent = events?.[0]; + const remainingEvents = featuredEvent ? events.slice(1) : events; + const featuredSearchEvent = searchResults?.[0]; + const remainingSearchEvents = featuredSearchEvent + ? searchResults.slice(1) + : searchResults; + const shouldShowThresholdHint = + isSearchFocused && searchInput.trim().length < 2; + const shouldShowRecentSearches = + isSearchFocused && + searchInput.trim().length === 0 && + recentSearches.length > 0; + + return ( + <div className="bg-background"> + <div className="container mx-auto max-w-4xl px-4 py-6 sm:py-10"> + <div className="flex flex-col gap-6 sm:gap-8"> + {/* Feed controls: flat, in-flow — no floating glass, no + scroll-linked motion (BIV-807, native DESIGN_LOG). */} + <header className="flex flex-col gap-3 border-b border-border pb-4"> + <div className="relative"> + <Input + ref={searchInputRef} + type="search" + value={searchInput} + onChange={(event) => setSearchInput(event.target.value)} + onFocus={() => setIsSearchFocused(true)} + onBlur={() => { + window.setTimeout(() => setIsSearchFocused(false), 100); + }} + placeholder={t("feed.search.placeholder")} + className="h-10 pr-11 text-base" + aria-label={t("feed.search.label")} + /> + {searchInput.length > 0 && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-1 top-1 size-8" + onClick={() => { + setSearchInput(""); + setDebouncedSearch(""); + }} + aria-label={t("feed.search.clear")} + > + <XIcon className="size-4" /> + </Button> + )} + </div> + <div className="flex items-center justify-between gap-3"> + <div className="min-w-0 w-full max-w-52 sm:max-w-60 md:max-w-64"> + <TopicFilter + topics={topics} + selectedTopic={selectedTopic} + onSelect={setSelectedTopic} + /> + </div> + {!isSearching && ( + /* Plain-text segmented control: weight + color, not pills. */ + <div className="flex shrink-0 items-center gap-4 text-sm"> + <button + type="button" + className={cn( + "transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", + feedSort === "recent" + ? "font-semibold text-foreground" + : "font-medium text-muted-foreground hover:text-foreground", + )} + onClick={() => setFeedSort("recent")} + aria-pressed={feedSort === "recent"} + > + {t("feed.sort.recent")} + </button> + <button + type="button" + className={cn( + "transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", + feedSort === "trending" + ? "font-semibold text-foreground" + : "font-medium text-muted-foreground hover:text-foreground", + )} + onClick={() => setFeedSort("trending")} + aria-pressed={feedSort === "trending"} + > + {t("feed.sort.trending")} + </button> + </div> + )} + </div> + {shouldShowThresholdHint && ( + <p className="text-xs text-muted-foreground"> + {t("feed.search.threshold")} + </p> + )} + {shouldShowRecentSearches && ( + <div className="flex flex-wrap gap-2"> + {recentSearches.map((recentSearch) => ( + <Button + key={recentSearch} + type="button" + variant="outline" + size="sm" + className="rounded-full" + onMouseDown={(event) => event.preventDefault()} + onClick={() => { + setSearchInput(recentSearch); + setDebouncedSearch(recentSearch); + searchInputRef.current?.focus(); + }} + > + {recentSearch} + </Button> + ))} + </div> + )} + {isSearching && ( + <p + className="text-xs text-muted-foreground" + role="status" + aria-live="polite" + aria-atomic="true" + > + {t("feed.search.indexed").replace("{query}", debouncedSearch)} + </p> + )} + </header> + + {!isSearching && <QuizCta variant="feed" />} + + <div className="flex flex-col gap-8"> + {isSearching && searchResults === undefined && ( + <p + role="status" + aria-live="polite" + className="py-8 text-sm text-muted-foreground" + > + {t("feed.searching")} + </p> + )} + {status === "LoadingFirstPage" && events.length === 0 && ( + <p + role="status" + aria-live="polite" + className="py-8 text-sm text-muted-foreground" + > + {t("feed.loading")} + </p> + )} + + {!isSearching && featuredEvent ? ( + <section className="flex flex-col gap-4 border-b border-border pb-8"> + <SectionTitle> + {feedSort === "recent" + ? t("feed.leadStory") + : t("feed.trendingStory")} + </SectionTitle> + <EventCard + event={featuredEvent} + topicNamesById={topicNamesById} + variant="feature" + returnToFeed + /> + </section> + ) : null} + + {isSearching && featuredSearchEvent ? ( + <section className="flex flex-col gap-4 border-b border-border pb-8"> + <SectionTitle>{t("feed.topSearch")}</SectionTitle> + <EventCard + event={featuredSearchEvent} + topicNamesById={topicNamesById} + searchQuery={debouncedSearch} + returnToFeed + /> + </section> + ) : null} + + {(!isSearching && remainingEvents && remainingEvents.length > 0) || + (isSearching && + remainingSearchEvents && + remainingSearchEvents.length > 0) ? ( + <section className="flex flex-col gap-2"> + <SectionTitle> + {isSearching ? t("feed.moreSearch") : t("feed.moreEvents")} + </SectionTitle> + <div className="flex flex-col divide-y divide-border"> + {(isSearching ? remainingSearchEvents : remainingEvents)?.map( + (event: FeedEventCardData) => ( + <div key={event._id} className="py-5"> + <EventCard + event={event} + topicNamesById={topicNamesById} + searchQuery={ + isSearching ? debouncedSearch : undefined + } + returnToFeed + /> + </div> + ), + )} + </div> + </section> + ) : null} + + {isSearching && searchResults?.length === 0 && ( + <section className="flex flex-col gap-6"> + <div className="py-4 text-sm text-muted-foreground"> + <p>{t("feed.noMatch").replace("{query}", debouncedSearch)}</p> + <p className="mt-2">{t("feed.tryFewer")}</p> + </div> + {fallbackEvents && fallbackEvents.length > 0 && ( + <div className="flex flex-col gap-2"> + <SectionTitle>{t("feed.preferredTopics")}</SectionTitle> + <div className="flex flex-col divide-y divide-border"> + {fallbackEvents.map((event) => ( + <div key={event._id} className="py-5"> + <EventCard + event={event} + topicNamesById={topicNamesById} + returnToFeed + /> + </div> + ))} + </div> + </div> + )} + </section> + )} + + {!isSearching && + status !== "LoadingFirstPage" && + (!events || events.length === 0) && ( + <p + role="status" + aria-live="polite" + className="py-8 text-sm text-muted-foreground" + > + {t("feed.none")} + </p> + )} + </div> + + {!isSearching && (canLoadMore || isLoadingMore) && ( + <div className="flex flex-col items-center gap-3 py-2"> + <div + ref={loadMoreTriggerRef} + aria-hidden="true" + className="h-px w-full" + /> + {isLoadingMore && ( + <div + role="status" + aria-live="polite" + className="text-sm text-muted-foreground" + > + {t("feed.loading")} + </div> + )} + </div> + )} + + {/* Crawlable entry into the paginated archive: a real anchor a + no-JS crawler can follow, since it cannot trigger the + infinite-scroll observer above. */} + {!isSearching && ( + <nav + aria-label={t("feed.archive.title")} + className="border-t border-border pt-4" + > + <Link + to="/" + search={{ page: 1 }} + className="text-sm text-muted-foreground underline hover:text-foreground" + > + {t("feed.archive.browse")} → + </Link> + </nav> + )} + </div> + </div> + </div> + ); +} diff --git a/apps/web/src/routes/llms[.]txt.ts b/apps/web/src/routes/llms[.]txt.ts index 04d2938..f614094 100644 --- a/apps/web/src/routes/llms[.]txt.ts +++ b/apps/web/src/routes/llms[.]txt.ts @@ -12,7 +12,7 @@ const LLMS_TXT = `# ${BRAND_NAME} ## Key pages -- [News feed](${absoluteSiteUrl("/feed")}): Live stream of clustered news events; each event aggregates and summarizes multiple sources. +- [News feed](${absoluteSiteUrl("/")}): Live stream of clustered news events; each event aggregates and summarizes multiple sources. - [How it works](${absoluteSiteUrl("/cum-functioneaza")}): How ${BRAND_NAME} clusters articles into events and produces balanced, multi-perspective summaries. - [Methodology](${absoluteSiteUrl("/metodologie")}): Source-rating and bias-balancing methodology. - [Our sources](${absoluteSiteUrl("/surse")}): The Romanian publications ${BRAND_NAME} monitors. diff --git a/apps/web/src/routes/news-sitemap[.]xml.ts b/apps/web/src/routes/news-sitemap[.]xml.ts new file mode 100644 index 0000000..cfabb32 --- /dev/null +++ b/apps/web/src/routes/news-sitemap[.]xml.ts @@ -0,0 +1,94 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@news-app/backend/convex/_generated/api"; +import { SITE, absoluteSiteUrl, deriveShortTitle } from "@/lib/seo"; + +const convexUrl = process.env.VITE_CONVEX_URL!; + +// Google News only wants articles from roughly the last two days. +const NEWS_WINDOW_MS = 48 * 60 * 60 * 1000; +const NEWS_ITEM_LIMIT = 100; + +function escapeXml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function buildNewsSitemapHeaders() { + return { + "content-type": "application/xml; charset=utf-8", + "cache-control": "public, max-age=600, s-maxage=600", + }; +} + +type SyndicationEvent = { + slug: string; + title: string; + summary: string; + firstPublishedAt: number; + lastUpdatedAt: number; +}; + +function buildNewsSitemapXml(events: SyndicationEvent[]) { + const cutoff = Date.now() - NEWS_WINDOW_MS; + const urls = events + .filter((event) => event.firstPublishedAt >= cutoff) + .map((event) => { + const loc = absoluteSiteUrl(`/event/${event.slug}`); + const title = deriveShortTitle(event.title); + const publicationDate = new Date(event.firstPublishedAt).toISOString(); + return [ + "<url>", + `<loc>${escapeXml(loc)}</loc>`, + "<news:news>", + "<news:publication>", + `<news:name>${escapeXml(SITE.name)}</news:name>`, + "<news:language>ro</news:language>", + "</news:publication>", + `<news:publication_date>${publicationDate}</news:publication_date>`, + `<news:title>${escapeXml(title)}</news:title>`, + "</news:news>", + "</url>", + ].join(""); + }) + .join(""); + + return [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9">', + urls, + "</urlset>", + ].join(""); +} + +export const Route = createFileRoute("/news-sitemap.xml")({ + server: { + handlers: { + HEAD: async () => + new Response(null, { + status: 200, + headers: buildNewsSitemapHeaders(), + }), + GET: async () => { + const client = new ConvexHttpClient(convexUrl); + let events: SyndicationEvent[] = []; + try { + events = await client.query(api.events.getSyndicationEvents, { + limit: NEWS_ITEM_LIMIT, + }); + } catch (error) { + console.error("Failed to load events for /news-sitemap.xml:", error); + } + + return new Response(buildNewsSitemapXml(events), { + status: 200, + headers: buildNewsSitemapHeaders(), + }); + }, + }, + }, +}); diff --git a/apps/web/src/routes/quiz.tsx b/apps/web/src/routes/quiz.tsx index 412c3d9..6755a20 100644 --- a/apps/web/src/routes/quiz.tsx +++ b/apps/web/src/routes/quiz.tsx @@ -161,7 +161,7 @@ function QuizRoute() { </p> </div> <Button asChild> - <Link to="/feed">{t("quiz.empty.action")}</Link> + <Link to="/">{t("quiz.empty.action")}</Link> </Button> </CardContent> </Card> @@ -510,7 +510,7 @@ function QuizExperience({ {t("quiz.share.action")} </Button> <Button asChild variant="outline"> - <Link to="/feed">{t("quiz.backToFeed")}</Link> + <Link to="/">{t("quiz.backToFeed")}</Link> </Button> </div> </CardContent> diff --git a/apps/web/src/routes/robots[.]txt.ts b/apps/web/src/routes/robots[.]txt.ts index 12e0e61..3bc7649 100644 --- a/apps/web/src/routes/robots[.]txt.ts +++ b/apps/web/src/routes/robots[.]txt.ts @@ -16,6 +16,7 @@ Disallow: /api/ Disallow: /unsubscribe Sitemap: ${absoluteSiteUrl("/sitemap.xml")} +Sitemap: ${absoluteSiteUrl("/news-sitemap.xml")} `; function buildRobotsHeaders() { diff --git a/apps/web/src/routes/rss[.]xml.ts b/apps/web/src/routes/rss[.]xml.ts new file mode 100644 index 0000000..8bb1047 --- /dev/null +++ b/apps/web/src/routes/rss[.]xml.ts @@ -0,0 +1,108 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ConvexHttpClient } from "convex/browser"; +import { api } from "@news-app/backend/convex/_generated/api"; +import { + SITE, + absoluteSiteUrl, + deriveShortTitle, + truncateAtWordBoundary, +} from "@/lib/seo"; +import { getString } from "@/lib/i18n/strings"; + +const convexUrl = process.env.VITE_CONVEX_URL!; + +// Latest N summarized events (thin-page gated in Convex). +const RSS_ITEM_LIMIT = 50; + +function escapeXml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function buildRssHeaders() { + return { + "content-type": "application/rss+xml; charset=utf-8", + // Short cache: feed readers/crawlers poll frequently; keep it fresh. + "cache-control": "public, max-age=600, s-maxage=600", + }; +} + +type SyndicationEvent = { + slug: string; + title: string; + summary: string; + firstPublishedAt: number; + lastUpdatedAt: number; +}; + +function buildRssXml(events: SyndicationEvent[]) { + const feedTitle = SITE.name; + const feedDescription = getString("ro", "feed.meta.description"); + const selfHref = absoluteSiteUrl("/rss.xml"); + const homeHref = absoluteSiteUrl("/"); + const lastBuildDate = new Date( + events[0]?.firstPublishedAt ?? Date.now(), + ).toUTCString(); + + const items = events + .map((event) => { + const link = absoluteSiteUrl(`/event/${event.slug}`); + const title = deriveShortTitle(event.title); + const description = truncateAtWordBoundary(event.summary, 400); + const pubDate = new Date(event.firstPublishedAt).toUTCString(); + return [ + "<item>", + `<title>${escapeXml(title)}`, + `${escapeXml(link)}`, + `${escapeXml(link)}`, + `${pubDate}`, + `${escapeXml(description)}`, + "", + ].join(""); + }) + .join(""); + + return [ + '', + '', + "", + `${escapeXml(feedTitle)}`, + `${escapeXml(homeHref)}`, + `${escapeXml(feedDescription)}`, + "ro", + ``, + `${lastBuildDate}`, + items, + "", + "", + ].join(""); +} + +export const Route = createFileRoute("/rss.xml")({ + server: { + handlers: { + HEAD: async () => + new Response(null, { status: 200, headers: buildRssHeaders() }), + GET: async () => { + const client = new ConvexHttpClient(convexUrl); + let events: SyndicationEvent[] = []; + try { + events = await client.query(api.events.getSyndicationEvents, { + limit: RSS_ITEM_LIMIT, + }); + } catch (error) { + console.error("Failed to load events for /rss.xml:", error); + } + + return new Response(buildRssXml(events), { + status: 200, + headers: buildRssHeaders(), + }); + }, + }, + }, +}); diff --git a/apps/web/src/routes/salvate.tsx b/apps/web/src/routes/salvate.tsx index d7e1a88..3957d6f 100644 --- a/apps/web/src/routes/salvate.tsx +++ b/apps/web/src/routes/salvate.tsx @@ -117,7 +117,7 @@ function SalvateContent() {

) : ( diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts index 4b96176..55f8b2c 100644 --- a/apps/web/src/routes/sitemap[.]xml.ts +++ b/apps/web/src/routes/sitemap[.]xml.ts @@ -30,7 +30,7 @@ function buildSitemapHeaders() { } function buildFallbackSitemapXml() { - const entries = [toSitemapUrl("/"), toSitemapUrl("/feed")]; + const entries = [toSitemapUrl("/")]; return [ '', '', diff --git a/apps/web/src/routes/source.$sourceId.tsx b/apps/web/src/routes/source.$sourceId.tsx index bf59f56..bbde896 100644 --- a/apps/web/src/routes/source.$sourceId.tsx +++ b/apps/web/src/routes/source.$sourceId.tsx @@ -144,7 +144,7 @@ function SourceNotFound() {

{t("source.notFound")}

{t("source.notFoundBody")}

@@ -212,7 +212,7 @@ function SourceProfileContent({ sourceId }: { sourceId: Id<"sources"> }) {
@@ -431,7 +431,7 @@ function InvalidSourceId() {

{t("source.notFound")}

{t("source.invalidBody")}

diff --git a/docs/seo-batch-2-runbook.md b/docs/seo-batch-2-runbook.md new file mode 100644 index 0000000..6527a40 --- /dev/null +++ b/docs/seo-batch-2-runbook.md @@ -0,0 +1,80 @@ +# SEO batch 2 (SEO-1..11) — runbook & verification + +Branch: `feat/seo-batch-2`. Implements SEO-1, 2, 5, 6, 3, 4, 7, 8, 10, 11. +**SEO-9 (per-event dynamic OG images) was intentionally skipped** — it conflicts +with the standing "custom event share/OG images stay OFF" decision (event OG +keeps falling back to the original event photo). + +## What changed (per ticket) + +- **SEO-1** — Feed now renders at `/` (moved from `feed.tsx` into `index.tsx`, + incl. the `?page=N` crawl archive). `/feed` is a **308** redirect to `/` that + preserves query params (`/feed?page=2 → /?page=2`). Every internal + `to="/feed"` link, the header/mobile nav, `bookmark-button` default, + `feature-flags` guard, `llms.txt`, and the sitemap fallback now point to `/`. +- **SEO-2** — Root cause of English meta on a `lang="ro"` site: `getLocaleFromMatches` + defaulted to `"en"` while the product default is `"ro"`. Fixed the fallback to + `"ro"` (fixes meta on **every** route at once). Homepage title/description/ + og:image:alt set to the Romanian launch copy. +- **SEO-5** — `deriveShortTitle()` in `lib/seo.ts`: first headline segment before + the ` / ` join, capped ~65 chars on a word boundary. Used in event + ``/og:title/twitter:title, RSS items, and the news sitemap. The long + compound title stays as the on-page `<h1>`. +- **SEO-6** — `truncateAtWordBoundary()` in `lib/seo.ts`: ~155-char word-boundary + cut, single trailing `…`, trailing whitespace/punctuation stripped. Applied to + the event meta/og description and the NewsArticle JSON-LD description. +- **SEO-3** — `/rss.xml` (RSS 2.0, latest ~50 summarized events, short titles, + neutral-summary descriptions, RFC-822 `pubDate`, permalink guid). Declared + site-wide via `<link rel="alternate" type="application/rss+xml">` in `__root`. + `content-type: application/rss+xml`, 10-min cache. +- **SEO-4** — `/news-sitemap.xml` (Google News namespace, only `<48h` events, + `Miez`/`ro` publication, short titles). Added to `robots.txt` alongside the + main sitemap. +- **SEO-7** — Structured-data logo switched from `favicon.svg` to the existing + 512×512 `logo-mark.png` (raster) in both `organizationEntity` and the event + NewsArticle `publisher.logo`. +- **SEO-8** — Removed `?returnToFeed=1` from event hrefs. "Came from feed" is now + carried in `sessionStorage` (set on the feed card click, read+cleared on the + event page), so crawlers only see the clean canonical `/event/$slug`. +- **SEO-10** — `manifest.webmanifest` already existed, is linked, and ships a + 512×512 PNG icon → installability already satisfied. No change needed. + +## Local verification (dev server, `feat/seo-batch-2`) + +- `curl -sI /feed` → `308`, `location: /` +- `curl /feed?page=2` → `308 → /?page=2` +- `curl -sI /` → `200` +- Homepage meta: title/description/og/twitter/og:image:alt all Romanian; + `canonical = https://www.miez.news/`; RSS alternate link present. +- `robots.txt` lists both `sitemap.xml` and `news-sitemap.xml`. +- `llms.txt` "News feed" → `/` (no `/feed`). +- `/rss.xml` → 200 `application/rss+xml`, valid channel; `/news-sitemap.xml` → 200 + `application/xml`, valid news namespace. +- Event page: `<title>` 70 chars (≤75), description ends on a word boundary with + `…`, `publisher.logo = /logo-mark.png`, NewsArticle JSON-LD intact. +- `logo-mark.png` → `image/png`; `manifest.webmanifest` → `application/manifest+json`. +- Typecheck clean; 83/83 unit tests pass; production build succeeds. + +> RSS/news feeds returned **0 items** locally only because the new Convex query +> `events.getSyndicationEvents` is not deployed to the dev deployment yet (the +> routes catch the error and return a valid empty feed). See deploy steps. + +## Deploy-time dependencies (do in order) + +1. **Deploy `packages/backend` to prod Convex** — adds `events.getSyndicationEvents` + (consumed by `/rss.xml` + `/news-sitemap.xml`) and removes `/feed` from the + sitemap `STATIC_PATHS`. ⚠️ Watch the Convex CLI cwd gotcha — deploy from the + backend package dir; a wrong-cwd push can wipe dev functions. +2. **Rebuild the public sitemap snapshot** (`internal.sitemap.rebuildPublicSitemapSnapshot`) + so `/feed` drops out of the cached `sitemap.xml`. Until then the snapshot + still advertises `/feed` (which now 308s — harmless but not ideal). +3. **Deploy web** so `/`, the 308, the two feeds, and the meta fixes go live. + +## SEO-11 close-out (manual, in Google Search Console — cannot be automated here) + +- [ ] Resubmit `sitemap.xml` and `news-sitemap.xml`. +- [ ] URL Inspection → "Request indexing" on `/`, `/despre`, `/surse`, and 2–3 events. +- [ ] Confirm both sitemaps report "Success" on the next read. +- [ ] Rich Results Test on 3 event URLs → zero errors (logo now PNG). +- [ ] Re-run the prod audit: root `200`, `/feed` `308`, zero English meta, + RSS valid, `grep -c returnToFeed` on homepage HTML = 0. diff --git a/packages/backend/convex/events.ts b/packages/backend/convex/events.ts index 4d3624a..7432d14 100644 --- a/packages/backend/convex/events.ts +++ b/packages/backend/convex/events.ts @@ -366,6 +366,47 @@ export const getSitemapPublishedEvents = query({ }, }); +/** + * Recent published events for syndication feeds (/rss.xml and + * /news-sitemap.xml). Newest first by publication time, thin-page gated: an + * event with no neutral AI summary is mostly third-party RSS text and must not + * be syndicated (mirrors the sitemap discipline). Returns the fields the feeds + * need — the web layer derives the short headline and truncates copy. + */ +export const getSyndicationEvents = query({ + args: { limit: v.optional(v.number()) }, + handler: async (ctx, args) => { + const safeLimit = Math.min(Math.max(Math.floor(args.limit ?? 50), 1), 100); + // Over-fetch, then keep only summarized events until we have `safeLimit`. + const rows = await ctx.db + .query("publicEventPreviews") + .withIndex("by_first_published_at") + .order("desc") + .take(safeLimit * 4); + + const events: Array<{ + slug: string; + title: string; + summary: string; + firstPublishedAt: number; + lastUpdatedAt: number; + }> = []; + for (const event of rows) { + const neutral = event.perspectiveSummaries?.neutral?.trim(); + if (!neutral) continue; + events.push({ + slug: event.slug, + title: event.title, + summary: neutral, + firstPublishedAt: event.firstPublishedAt, + lastUpdatedAt: event.lastUpdatedAt, + }); + if (events.length >= safeLimit) break; + } + return events; + }, +}); + export const backfillPublicPreviewReadModels = internalMutation({ args: { cursor: v.optional(v.string()), diff --git a/packages/backend/convex/sitemap.ts b/packages/backend/convex/sitemap.ts index d98f927..4e2503e 100644 --- a/packages/backend/convex/sitemap.ts +++ b/packages/backend/convex/sitemap.ts @@ -12,8 +12,9 @@ const SITEMAP_PAGE_SIZE = 1000; // Public indexable routes without per-row lastmod. const STATIC_PATHS = [ + // The feed is served at the root (SEO-1); /feed only 308-redirects here, so + // it must not appear as its own indexable URL. "/", - "/feed", "/surse", "/cum-functioneaza", "/sursele-noastre", diff --git a/packages/i18n/src/strings.ts b/packages/i18n/src/strings.ts index f07f9d2..e8e3c71 100644 --- a/packages/i18n/src/strings.ts +++ b/packages/i18n/src/strings.ts @@ -292,9 +292,9 @@ const ro = { // MIEZ-7: methodology + funding transparency pages. "footer.methodology": "Metodologie", "footer.funding": "Cine finanțează Miez", - "feed.meta.title": `Feed — ${BRAND_NAME}`, + "feed.meta.title": `${BRAND_NAME} - știri din ambele tabere`, "feed.meta.description": - "Explorează subiectele importante ale zilei din perspective multiple. Filtrează după topic și urmărește același eveniment în mai multe surse.", + "Urmărește știrile zilei din ambele tabere, reformistă și suveranistă. Același eveniment, toate sursele, fără cont.", "feed.topic.search": "Caută topicuri...", "feed.topic.empty": "Nu am găsit topicuri.", "feed.topic.all": "Toate topicurile", From c1c43e790126947f9b3fbc0d6ed6bd44514090b4 Mon Sep 17 00:00:00 2001 From: flavius <flaviuscojocaru19@gmail.com> Date: Sun, 12 Jul 2026 16:53:14 +0300 Subject: [PATCH 2/2] fix: address SEO batch-2 review findings - MobileTabBar: keep Feed tab inactive on the /?page=N archive and route taps back to a clean "/" instead of a scroll-to-top no-op - event.$slug: reset cameFromFeed at the start of the slug effect so client-side jumps don't inherit the previous event's flag - feed archive: show an error state with retry (router.invalidate) when the loader query throws, instead of a permanent loading spinner - search no-results: add role=status/aria-live=polite - dedupe escapeXml + SyndicationEvent into @/lib/syndication across the rss/sitemap/news-sitemap routes - feed.meta.title: use em dash to match the other meta titles Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .../src/components/layout/MobileTabBar.tsx | 28 +++++++++++--- apps/web/src/lib/syndication.ts | 22 +++++++++++ apps/web/src/routes/event.$slug.tsx | 3 ++ apps/web/src/routes/index.tsx | 38 ++++++++++++++----- apps/web/src/routes/news-sitemap[.]xml.ts | 18 +-------- apps/web/src/routes/rss[.]xml.ts | 18 +-------- apps/web/src/routes/sitemap[.]xml.ts | 10 +---- packages/i18n/src/strings.ts | 6 ++- 8 files changed, 84 insertions(+), 59 deletions(-) create mode 100644 apps/web/src/lib/syndication.ts diff --git a/apps/web/src/components/layout/MobileTabBar.tsx b/apps/web/src/components/layout/MobileTabBar.tsx index 1ed9d37..9145640 100644 --- a/apps/web/src/components/layout/MobileTabBar.tsx +++ b/apps/web/src/components/layout/MobileTabBar.tsx @@ -58,8 +58,18 @@ function matchesPath(pathname: string, to: string, allowPrefix = false) { */ export function MobileTabBar() { const t = useT(); - const pathname = useRouterState({ - select: (state) => state.location.pathname, + // Track the archive variant of the feed (/?page=N) separately: its pathname + // is still "/", but it's not the live Feed, so the Feed tab must stay inactive + // there and tapping it must navigate back to a clean "/" rather than no-op. + const { pathname, isArchiveRoot } = useRouterState({ + select: (state) => { + const search = state.location.search as { page?: unknown }; + return { + pathname: state.location.pathname, + isArchiveRoot: + state.location.pathname === "/" && search.page !== undefined, + }; + }, }); const handleTabClick = @@ -84,10 +94,16 @@ export function MobileTabBar() { <div className={cn("grid", gridColsClass)}> {tabDefinitions.map( ({ to, key, icon: Icon, isActive: customIsActive }) => { - const isActive = customIsActive - ? customIsActive(pathname) - : matchesPath(pathname, to); - const isSameDestination = pathname === to; + const onArchiveRoot = to === "/" && isArchiveRoot; + const isActive = onArchiveRoot + ? false + : customIsActive + ? customIsActive(pathname) + : matchesPath(pathname, to); + // On the archive (/?page=N) the Feed link still points at "/", so + // treat it as a real navigation (clean "/") instead of a same-page + // scroll-to-top no-op. + const isSameDestination = pathname === to && !onArchiveRoot; const label = t(key); return ( diff --git a/apps/web/src/lib/syndication.ts b/apps/web/src/lib/syndication.ts new file mode 100644 index 0000000..8049a07 --- /dev/null +++ b/apps/web/src/lib/syndication.ts @@ -0,0 +1,22 @@ +// Shared helpers for the web syndication routes (rss.xml, sitemap.xml, +// news-sitemap.xml). Kept in one place so the XML-escaping order and the +// event shape stay identical across feeds. + +/** Escape the five XML special characters. Order matters: `&` first. */ +export function escapeXml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** Shape returned by `api.events.getSyndicationEvents`. */ +export type SyndicationEvent = { + slug: string; + title: string; + summary: string; + firstPublishedAt: number; + lastUpdatedAt: number; +}; diff --git a/apps/web/src/routes/event.$slug.tsx b/apps/web/src/routes/event.$slug.tsx index 5173a2f..b110a96 100644 --- a/apps/web/src/routes/event.$slug.tsx +++ b/apps/web/src/routes/event.$slug.tsx @@ -284,6 +284,9 @@ function EventDetailPage() { // popping unrelated history. const [cameFromFeed, setCameFromFeed] = useState(false); useEffect(() => { + // Start each event navigation clean so a client-side jump to an event that + // wasn't opened from the feed doesn't inherit the previous event's flag. + setCameFromFeed(false); try { if (window.sessionStorage.getItem(RETURN_TO_FEED_KEY) === "1") { setCameFromFeed(true); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 31582b9..1b202b0 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -5,7 +5,12 @@ import { useState, type ComponentProps, } from "react"; -import { createFileRoute, Link, notFound } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + notFound, + useRouter, +} from "@tanstack/react-router"; import { z } from "zod"; import { api } from "@news-app/backend/convex/_generated/api"; import type { Id } from "@news-app/backend/convex/_generated/dataModel"; @@ -356,21 +361,32 @@ function FeedComponent() { */ function FeedArchive() { const t = useT(); + const router = useRouter(); const loaderData = Route.useLoaderData(); const { topicNamesById } = useTopicNamesById(); const archive = loaderData && "archive" in loaderData ? loaderData.archive : null; + // The loader returns { archive: null } only when the Convex query threw, so + // this is an error state (not a first paint) — surface it with a retry that + // re-runs the route loader instead of a spinner that never resolves. if (!archive) { return ( <div className="container mx-auto max-w-4xl px-4 py-8"> - <p - role="status" - aria-live="polite" - className="text-sm text-muted-foreground" - > - {t("feed.loading")} - </p> + <div role="alert" className="flex flex-col items-start gap-3"> + <p className="text-sm text-muted-foreground"> + {t("feed.archive.error")} + </p> + <Button + variant="outline" + size="sm" + onClick={() => { + void router.invalidate(); + }} + > + {t("feed.archive.retry")} + </Button> + </div> </div> ); } @@ -857,7 +873,11 @@ function FeedContent() { ) : null} {isSearching && searchResults?.length === 0 && ( - <section className="flex flex-col gap-6"> + <section + role="status" + aria-live="polite" + className="flex flex-col gap-6" + > <div className="py-4 text-sm text-muted-foreground"> <p>{t("feed.noMatch").replace("{query}", debouncedSearch)}</p> <p className="mt-2">{t("feed.tryFewer")}</p> diff --git a/apps/web/src/routes/news-sitemap[.]xml.ts b/apps/web/src/routes/news-sitemap[.]xml.ts index cfabb32..6fae49f 100644 --- a/apps/web/src/routes/news-sitemap[.]xml.ts +++ b/apps/web/src/routes/news-sitemap[.]xml.ts @@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { ConvexHttpClient } from "convex/browser"; import { api } from "@news-app/backend/convex/_generated/api"; import { SITE, absoluteSiteUrl, deriveShortTitle } from "@/lib/seo"; +import { escapeXml, type SyndicationEvent } from "@/lib/syndication"; const convexUrl = process.env.VITE_CONVEX_URL!; @@ -9,15 +10,6 @@ const convexUrl = process.env.VITE_CONVEX_URL!; const NEWS_WINDOW_MS = 48 * 60 * 60 * 1000; const NEWS_ITEM_LIMIT = 100; -function escapeXml(value: string) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function buildNewsSitemapHeaders() { return { "content-type": "application/xml; charset=utf-8", @@ -25,14 +17,6 @@ function buildNewsSitemapHeaders() { }; } -type SyndicationEvent = { - slug: string; - title: string; - summary: string; - firstPublishedAt: number; - lastUpdatedAt: number; -}; - function buildNewsSitemapXml(events: SyndicationEvent[]) { const cutoff = Date.now() - NEWS_WINDOW_MS; const urls = events diff --git a/apps/web/src/routes/rss[.]xml.ts b/apps/web/src/routes/rss[.]xml.ts index 8bb1047..de3f606 100644 --- a/apps/web/src/routes/rss[.]xml.ts +++ b/apps/web/src/routes/rss[.]xml.ts @@ -8,21 +8,13 @@ import { truncateAtWordBoundary, } from "@/lib/seo"; import { getString } from "@/lib/i18n/strings"; +import { escapeXml, type SyndicationEvent } from "@/lib/syndication"; const convexUrl = process.env.VITE_CONVEX_URL!; // Latest N summarized events (thin-page gated in Convex). const RSS_ITEM_LIMIT = 50; -function escapeXml(value: string) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function buildRssHeaders() { return { "content-type": "application/rss+xml; charset=utf-8", @@ -31,14 +23,6 @@ function buildRssHeaders() { }; } -type SyndicationEvent = { - slug: string; - title: string; - summary: string; - firstPublishedAt: number; - lastUpdatedAt: number; -}; - function buildRssXml(events: SyndicationEvent[]) { const feedTitle = SITE.name; const feedDescription = getString("ro", "feed.meta.description"); diff --git a/apps/web/src/routes/sitemap[.]xml.ts b/apps/web/src/routes/sitemap[.]xml.ts index 55f8b2c..bbf5fba 100644 --- a/apps/web/src/routes/sitemap[.]xml.ts +++ b/apps/web/src/routes/sitemap[.]xml.ts @@ -2,18 +2,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { ConvexHttpClient } from "convex/browser"; import { api } from "@news-app/backend/convex/_generated/api"; import { absoluteSiteUrl } from "@/lib/seo"; +import { escapeXml } from "@/lib/syndication"; const convexUrl = process.env.VITE_CONVEX_URL!; -function escapeXml(value: string) { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - function toSitemapUrl(pathname: string, lastModifiedAt?: number) { const url = absoluteSiteUrl(pathname); const lastmod = lastModifiedAt diff --git a/packages/i18n/src/strings.ts b/packages/i18n/src/strings.ts index e8e3c71..e61ecbc 100644 --- a/packages/i18n/src/strings.ts +++ b/packages/i18n/src/strings.ts @@ -292,7 +292,7 @@ const ro = { // MIEZ-7: methodology + funding transparency pages. "footer.methodology": "Metodologie", "footer.funding": "Cine finanțează Miez", - "feed.meta.title": `${BRAND_NAME} - știri din ambele tabere`, + "feed.meta.title": `${BRAND_NAME} — știri din ambele tabere`, "feed.meta.description": "Urmărește știrile zilei din ambele tabere, reformistă și suveranistă. Același eveniment, toate sursele, fără cont.", "feed.topic.search": "Caută topicuri...", @@ -369,6 +369,8 @@ const ro = { "feed.archive.backToFeed": "Înapoi la feed", "feed.archive.browse": "Răsfoiește arhiva completă a știrilor", "feed.archive.empty": "Nu există evenimente pe această pagină.", + "feed.archive.error": "Nu am putut încărca arhiva.", + "feed.archive.retry": "Încearcă din nou", "event.general": "General", "event.summaryPending": "Rezumatul se pregătește...", "event.coveragePreview": @@ -1174,6 +1176,8 @@ const en: { [K in keyof BaseStrings]: string } = { "feed.archive.backToFeed": "Back to feed", "feed.archive.browse": "Browse the full news archive", "feed.archive.empty": "No events on this page.", + "feed.archive.error": "We couldn't load the archive.", + "feed.archive.retry": "Try again", "event.general": "General", "event.summaryPending": "Summary is on the way...", "event.coveragePreview":