diff --git a/apps/edr-freight-web/portal/src/components/PublicNavbar.tsx b/apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
new file mode 100644
index 000000000..7ee7fa422
--- /dev/null
+++ b/apps/edr-freight-web/portal/src/components/PublicNavbar.tsx
@@ -0,0 +1,101 @@
+import { ArrowRight, Menu, TrainFront } from "lucide-react";
+import { Link } from "react-router-dom";
+
+/**
+ * Top-level navigation for the public marketing pages. Entries beginning with
+ * `#` scroll within the landing page; entries beginning with `/` are real
+ * routes and need router navigation.
+ */
+export const navLinks = [
+ { label: "Features", href: "#features" },
+ { label: "Live ops", href: "#showcase" },
+ { label: "Corridors", href: "#corridors" },
+ { label: "How it works", href: "#how" },
+ { label: "Publications", href: "/publications" },
+ { label: "Contact", href: "#contact" },
+];
+
+/**
+ * The dark navbar shared by every public page that is not behind the app
+ * shell — the landing page and /publications. Extracted from the landing page
+ * so the two cannot drift: a link added here shows up on both.
+ *
+ * The anchor entries only resolve on the landing page itself, so away from it
+ * they are rendered as links back to the homepage's section instead of as
+ * same-page anchors that would go nowhere.
+ */
+export function PublicNavbar({ onLanding = false }: { onLanding?: boolean }) {
+ return (
+
+
+
+
+
+
+
+
+ EDR Freight
+
+ Rail Logistics Platform
+
+
+
+
+
+ {navLinks.map((link) => {
+ const className =
+ "text-sm font-medium text-slate-300 transition hover:text-white";
+
+ // A route always navigates. An anchor only works on the landing
+ // page; elsewhere it has to go home first, or clicking it does
+ // nothing at all.
+ if (link.href.startsWith("/")) {
+ return (
+
+ {link.label}
+
+ );
+ }
+
+ return onLanding ? (
+
+ {link.label}
+
+ ) : (
+
+ {link.label}
+
+ );
+ })}
+
+
+
+
+ Log in
+
+
+
+ Get started
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default PublicNavbar;
diff --git a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx
index ef1daac18..94e44f0d2 100644
--- a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx
@@ -14,7 +14,6 @@ import {
Mail,
Map as MapIcon,
MapPin,
- Menu,
Package,
PackageSearch,
Phone,
@@ -30,6 +29,8 @@ import {
Truck,
} from "lucide-react";
+import { PublicNavbar } from "@/components/PublicNavbar";
+
/* ------------------------------------------------------------------ */
/* Motion helpers */
/* ------------------------------------------------------------------ */
@@ -137,15 +138,6 @@ function CountUp({
/* Content */
/* ------------------------------------------------------------------ */
-const navLinks = [
- { label: "Features", href: "#features" },
- { label: "Live ops", href: "#showcase" },
- { label: "Corridors", href: "#corridors" },
- { label: "How it works", href: "#how" },
- { label: "Publications", href: "/publications" },
- { label: "Contact", href: "#contact" },
-];
-
const heroTrust = [
"Telebirr & CBE Birr payments",
"Fayda ID verified",
@@ -518,72 +510,7 @@ export default function EDRFreightLandingPage() {
- {/* Navbar */}
-
-
-
-
-
-
-
-
- EDR Freight
-
- Rail Logistics Platform
-
-
-
-
-
- {navLinks.map((link) =>
- // Same-page anchors (#features) scroll; a route (/publications)
- // needs router navigation instead.
- link.href.startsWith("/") ? (
-
- {link.label}
-
- ) : (
-
- {link.label}
-
- ),
- )}
-
-
-
-
- Log in
-
-
-
- Get started
-
-
-
-
-
-
-
-
-
+
{/* Hero */}
diff --git a/apps/edr-freight-web/portal/src/pages/publications/PublicationsPage.tsx b/apps/edr-freight-web/portal/src/pages/publications/PublicationsPage.tsx
index 99c00d478..210a0ca20 100644
--- a/apps/edr-freight-web/portal/src/pages/publications/PublicationsPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/publications/PublicationsPage.tsx
@@ -1,38 +1,55 @@
import type { PublicationSummary } from "@edr/types";
import { useFileViewer } from "@edr/ui-common";
-import { FileText, Presentation, Download, X } from "lucide-react";
-import { useState } from "react";
+import {
+ Download,
+ FileText,
+ Library,
+ Presentation,
+ Search,
+ X,
+} from "lucide-react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { PublicNavbar } from "@/components/PublicNavbar";
import { publicationFileUrl } from "@/constants/apiConfig";
import { usePublications } from "@/hooks/usePublications";
import { Markdown } from "../support/Markdown";
-import { DocShell } from "../support/DocShell";
+import { DocFooter } from "../support/DocShell";
const MARKDOWN_MIMES = new Set(["text/markdown", "text/x-markdown"]);
-function isMarkdown(pub: PublicationSummary): boolean {
- return MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md");
+type PublicationKind = "pdf" | "markdown" | "slides" | "other";
+
+function kindOf(pub: PublicationSummary): PublicationKind {
+ if (MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md")) {
+ return "markdown";
+ }
+ if (pub.fileMimeType === "application/pdf") return "pdf";
+ if (
+ pub.fileMimeType.includes("powerpoint") ||
+ pub.fileMimeType.includes("presentationml")
+ ) {
+ return "slides";
+ }
+ return "other";
}
-function fileKindIcon(pub: PublicationSummary) {
- if (isMarkdown(pub)) return FileText;
- if (pub.fileMimeType.includes("powerpoint") || pub.fileMimeType.includes("presentationml")) {
- return Presentation;
- }
- return FileText;
-}
-
-function fileKindLabel(pub: PublicationSummary): string {
- if (pub.fileMimeType === "application/pdf") return "PDF";
- if (isMarkdown(pub)) return "Markdown";
- if (pub.fileMimeType.includes("powerpoint") || pub.fileMimeType.includes("presentationml")) {
- return "PowerPoint";
- }
- return "Document";
-}
+const KIND_META: Record<
+ PublicationKind,
+ { label: string; icon: typeof FileText; accent: string }
+> = {
+ pdf: { label: "PDF", icon: FileText, accent: "from-rose-500/15 to-rose-500/5" },
+ markdown: { label: "Markdown", icon: FileText, accent: "from-sky-500/15 to-sky-500/5" },
+ slides: {
+ label: "PowerPoint",
+ icon: Presentation,
+ accent: "from-amber-500/15 to-amber-500/5",
+ },
+ other: { label: "Document", icon: FileText, accent: "from-slate-500/15 to-slate-500/5" },
+};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -40,31 +57,235 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
+function formatDate(value: string | null): string | null {
+ if (!value) return null;
+ return new Date(value).toLocaleDateString(undefined, {
+ month: "short",
+ year: "numeric",
+ });
+}
+
+/** True once the element has scrolled into view — and stays true afterwards. */
+function useInView() {
+ const ref = useRef(null);
+ const [seen, setSeen] = useState(false);
+
+ useEffect(() => {
+ const node = ref.current;
+ if (!node || seen) return;
+ if (typeof IntersectionObserver === "undefined") {
+ setSeen(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setSeen(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "200px" },
+ );
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [seen]);
+
+ return { ref, seen };
+}
+
+/**
+ * The card's preview panel.
+ *
+ * A PDF renders its own first page in a muted, non-interactive iframe, and a
+ * Markdown file shows the opening lines of its actual text. Both only load
+ * once the card is near the viewport — a grid of publications would otherwise
+ * pull every file on first paint.
+ *
+ * Slides get a drawn cover rather than a real thumbnail: the Office Online
+ * viewer is the only thing that can rasterise a .pptx here, and embedding it
+ * per card is far too heavy for a listing. Clicking through still opens the
+ * real thing.
+ */
+function PublicationPreview({ pub }: { pub: PublicationSummary }) {
+ const kind = kindOf(pub);
+ const { ref, seen } = useInView();
+ const [excerpt, setExcerpt] = useState(null);
+
+ useEffect(() => {
+ if (kind !== "markdown" || !seen || excerpt !== null) return;
+ let cancelled = false;
+
+ void fetch(publicationFileUrl(pub.id))
+ .then((response) => response.text())
+ .then((text) => {
+ if (!cancelled) setExcerpt(text.slice(0, 600));
+ })
+ .catch(() => {
+ // A failed preview is cosmetic — the card still opens and downloads.
+ if (!cancelled) setExcerpt("");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [kind, seen, excerpt, pub.id]);
+
+ const { icon: Icon, accent } = KIND_META[kind];
+
+ return (
+
+ {kind === "pdf" && seen ? (
+
+ ) : kind === "markdown" ? (
+
+ {excerpt ? (
+
{excerpt}
+ ) : (
+
+ {[92, 78, 85, 60].map((width, index) => (
+
+ ))}
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+ {/* Fades the preview into the card body so a clipped page doesn't end
+ on a hard edge. */}
+
+
+ );
+}
+
+function PublicationCard({
+ pub,
+ onOpen,
+}: {
+ pub: PublicationSummary;
+ onOpen: (pub: PublicationSummary) => void;
+}) {
+ const kind = kindOf(pub);
+ const meta = KIND_META[kind];
+ const published = formatDate(pub.publishedAt);
+
+ return (
+
+
+
+
+
+
+
+ {meta.label}
+
+ {pub.category ? {pub.category} : null}
+
+
+
+ {pub.title}
+
+
+ {pub.description ? (
+
+ {pub.description}
+
+ ) : null}
+
+
+ {formatSize(pub.fileSizeBytes)}
+ {published ? (
+ <>
+ ·
+ {published}
+ >
+ ) : null}
+
+
+
+
onOpen(pub)}>
+ {kind === "markdown" ? "Read" : "Preview"}
+
+
+
+
+
+
+
+
+
+ );
+}
+
/**
* Public library of platform documentation: PDFs, Markdown write-ups and
- * PowerPoint decks, curated from the backoffice. No login required — same
- * chrome as /help, /faq and the legal pages.
+ * PowerPoint decks, curated from the backoffice. No login required.
*
- * Markdown gets a real in-page reader (`Markdown`, the same renderer the
- * legal pages use) rather than routing through the generic `FileViewerModal`,
- * whose "text" kind is a raw iframe with no markdown rendering. PDF and
- * PowerPoint use that shared viewer as-is.
+ * Carries the marketing navbar rather than {@link DocShell}'s plain doc
+ * header — this page is something a prospect is pointed at, so it should sit
+ * inside the same chrome as the landing page it is linked from.
+ *
+ * Markdown opens in an in-page reader using the same renderer the legal pages
+ * use; everything else goes through the shared `FileViewerModal`, whose
+ * "text" kind is a raw iframe with no markdown rendering.
*/
export default function PublicationsPage() {
const { data: publications, isLoading } = usePublications();
const { view, viewer } = useFileViewer();
const [reading, setReading] = useState(null);
const [markdownText, setMarkdownText] = useState(null);
+ const [query, setQuery] = useState("");
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase();
+ if (!q || !publications) return publications ?? [];
+ return publications.filter(
+ (pub) =>
+ pub.title.toLowerCase().includes(q) ||
+ (pub.description ?? "").toLowerCase().includes(q) ||
+ (pub.category ?? "").toLowerCase().includes(q),
+ );
+ }, [publications, query]);
const openMarkdown = async (pub: PublicationSummary) => {
setReading(pub);
setMarkdownText(null);
- const response = await fetch(publicationFileUrl(pub.id));
- setMarkdownText(await response.text());
+ try {
+ const response = await fetch(publicationFileUrl(pub.id));
+ setMarkdownText(await response.text());
+ } catch {
+ setMarkdownText("Sorry — this document could not be loaded. Try downloading it.");
+ }
};
const open = (pub: PublicationSummary) => {
- if (isMarkdown(pub)) {
+ if (kindOf(pub) === "markdown") {
void openMarkdown(pub);
return;
}
@@ -75,80 +296,118 @@ export default function PublicationsPage() {
});
};
+ // Escape closes the markdown reader, like the shared viewer's modal.
+ useEffect(() => {
+ if (!reading) return;
+ const onKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") setReading(null);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [reading]);
+
return (
-
- {isLoading ? (
- Loading…
- ) : !publications || publications.length === 0 ? (
- Nothing published yet — check back soon.
- ) : (
-
- {publications.map((pub) => {
- const Icon = fileKindIcon(pub);
- return (
-
-
-
-
-
-
{fileKindLabel(pub)}
-
+
+
-
{pub.title}
- {pub.description ? (
-
- {pub.description}
-
- ) : null}
+ {/* Hero, in the landing page's dark band so the navbar sits on the tone
+ it was designed for. */}
+
+
+
+
+ Resource library
+
-
- {pub.category ? {pub.category} : null}
- {pub.category ? · : null}
- {formatSize(pub.fileSizeBytes)}
-
+
+ Publications
+
+
+ Guides, reports and presentations about the EDR Freight platform —
+ read them here or download a copy.
+
-
-
open(pub)}>
- {isMarkdown(pub) ? "Read" : "View"}
-
-
-
-
-
-
-
-
- );
- })}
+
+
+ setQuery(event.target.value)}
+ placeholder="Search publications…"
+ aria-label="Search publications"
+ className="w-full rounded-xl border border-white/15 bg-white/5 py-3 pl-11 pr-4 text-sm text-white placeholder:text-slate-400 focus:border-edr-primary focus:outline-none"
+ />
+
- )}
+
- {/* PDF / office-file preview for everything except Markdown. */}
+
+ {isLoading ? (
+
+ {Array.from({ length: 6 }).map((_, index) => (
+
+ ))}
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {query.trim() ? "No publications match that search." : "Nothing published yet."}
+
+
+ {query.trim() ? "Try a different word." : "Check back soon."}
+
+
+ ) : (
+
+ {filtered.map((pub) => (
+
+ ))}
+
+ )}
+
+
+
+
+ {/* PDF / slide preview for everything except Markdown. */}
{viewer}
- {/* In-page markdown reader — mirrors the legal pages' rendering. */}
{reading ? (
-
-
-
-
{reading.title}
-
setReading(null)}
- aria-label="Close"
- className="rounded-full p-1 hover:bg-accent"
- >
-
-
+
setReading(null)}
+ >
+
event.stopPropagation()}
+ >
+
+
{reading.title}
+
+
+
+
+ Download
+
+
+
setReading(null)}
+ aria-label="Close"
+ className="rounded-full p-1.5 transition hover:bg-accent"
+ >
+
+
+
-
+
+
{markdownText === null ? (
Loading…
) : (
@@ -158,6 +417,6 @@ export default function PublicationsPage() {
) : null}
-
+
);
}
diff --git a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx
index 767b6c31d..b958511de 100644
--- a/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx
+++ b/apps/edr-freight-web/portal/src/pages/support/DocShell.tsx
@@ -79,26 +79,37 @@ export function DocShell({
)}
-
+
);
}
+/**
+ * Footer shared by every public doc page, cross-linking the others. Exported
+ * so /publications can carry it under the marketing navbar without also
+ * inheriting {@link DocShell}'s own header.
+ */
+export function DocFooter({ current }: { current: string }) {
+ return (
+
+ );
+}
+
/**
* Renders a legal document's numbered sections. Bodies are markdown, so the
* paragraph and bullet arrays this used to walk are one string now — keyed by