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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/MiezOnboarding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/MiezOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function MiezOnboarding() {
className="w-full"
onClick={() => {
dismiss("cta");
void navigate({ to: "/feed" });
void navigate({ to: "/" });
}}
>
{t("onboarding.miez.cta")}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/bookmark-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export default function BookmarkButton({
interactionContext,
size = "default",
className,
redirectTo = "/feed",
redirectTo = "/",
}: BookmarkButtonProps) {
const t = useT();
const { isAuthenticated } = useConvexAuth();
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/components/feed/event-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,20 @@ const EventCard = ({
<Link
to="/event/$slug"
params={{ slug: event.slug }}
search={returnToFeed ? { returnToFeed: "1" } : undefined}
onClick={
returnToFeed
? () => {
// 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}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
} from "@/components/ui/sheet";

const allLinks = [
{ to: "/feed", key: "tabs.feed", icon: Newspaper },
{ to: "/", key: "tabs.feed", icon: Newspaper },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Feed tab is always active: currentPath.startsWith("/") matches every route.

Changing to from "/feed" to "/" at line 27 breaks the isActive check at lines 72 and 129. currentPath.startsWith(${to}/) becomes currentPath.startsWith("/"), which is true for every path. The feed tab will appear active on /quiz, /salvate, /activitate, /profil, and event pages simultaneously with the actual active tab.

As per path instructions for apps/web/**: verify proper React patterns and check TanStack Router usage patterns.

🐛 Proposed fix: special-case the root path in `isActive`
 const isActive =
-  currentPath === to || currentPath.startsWith(`${to}/`);
+  to === "/"
+    ? currentPath === "/"
+    : currentPath === to || currentPath.startsWith(`${to}/`);

Apply this fix at both line 72 (desktop nav) and line 129 (mobile nav).

Also applies to: 72-72, 129-129

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/header.tsx` at line 27, Update the isActive checks in
the desktop and mobile navigation to special-case the root route: treat “/” as
active only when currentPath exactly equals “/”, while preserving the existing
startsWith behavior for non-root destinations. Apply the same logic in both
navigation locations so the Feed tab is not active on other routes.

Source: Path instructions

{ to: "/quiz", key: "tabs.quiz", icon: BrainCircuit },
{ to: "/salvate", key: "tabs.saved", icon: Bookmark },
{ to: "/activitate", key: "tabs.activity", icon: LayoutDashboard },
Expand Down Expand Up @@ -55,7 +55,7 @@ export default function Header() {
<div className="flex h-14 items-center justify-between px-4">
{/* Logo */}
<Link
to="/feed"
to="/"
aria-label={BRAND_NAME}
className="flex items-center rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
>
Expand Down
34 changes: 24 additions & 10 deletions apps/web/src/components/layout/MobileTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
{ to: "/quiz", key: "tabs.quiz", icon: BrainCircuit },
{ to: "/salvate", key: "tabs.saved", icon: Bookmark },
Expand Down Expand Up @@ -60,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 =
Expand All @@ -86,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 (
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/quiz-hidden.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");
}
});
});
2 changes: 1 addition & 1 deletion apps/web/src/lib/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
8 changes: 6 additions & 2 deletions apps/web/src/lib/i18n/getLocaleFromMatches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html lang="ro"> 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";
}
40 changes: 39 additions & 1 deletion apps/web/src/lib/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <title>/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.
Expand All @@ -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 } : {}),
};
}
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/lib/syndication.ts
Original file line number Diff line number Diff line change
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}

/** Shape returned by `api.events.getSyndicationEvents`. */
export type SyndicationEvent = {
slug: string;
title: string;
summary: string;
firstPublishedAt: number;
lastUpdatedAt: number;
};
Loading
Loading