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
131 changes: 44 additions & 87 deletions src/components/ui/footer.tsx
Original file line number Diff line number Diff line change
@@ -1,101 +1,58 @@
import Link from "next/link";
import { ECOSYSTEM_PILLARS, SOLON_GITHUB_URL } from "@/lib/config/ecosystem";
import { FOOTER_SECTIONS, footerLinkLabel } from "@/lib/site-config";

// Only routes that actually exist belong here — a footer link to a 404 is a lie.
// That is now structural rather than a promise: every destination below comes
// from NAV_ITEMS, so a page removed from the nav cannot survive in the footer.
export default function Footer() {
const siblings = ECOSYSTEM_PILLARS.filter((p) => p.key !== "solon");

return (
<footer className="border-t border-subtle bg-surface-public">
<div className="section-shell py-14">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
<div>
<div className="text-xs font-medium uppercase tracking-caps text-fg-tertiary">Platform</div>
<ul className="mt-4 space-y-2.5 text-sm text-fg-secondary">
<li>
<Link href="/features" className="transition-colors hover:text-fg-primary">
Features
</Link>
</li>
<li>
<Link href="/security" className="transition-colors hover:text-fg-primary">
Security
</Link>
</li>
<li>
<Link href="/integration" className="transition-colors hover:text-fg-primary">
Integration
</Link>
</li>
</ul>
</div>
<div>
<div className="text-xs font-medium uppercase tracking-caps text-fg-tertiary">Governance</div>
<ul className="mt-4 space-y-2.5 text-sm text-fg-secondary">
<li>
<Link href="/governance/voting" className="transition-colors hover:text-fg-primary">
Voting
</Link>
</li>
<li>
<Link href="/governance/audit" className="transition-colors hover:text-fg-primary">
Audit Trail
</Link>
</li>
<li>
<Link href="/treasury/bitcoin" className="transition-colors hover:text-fg-primary">
Bitcoin Treasury
</Link>
</li>
</ul>
</div>
<div>
<div className="text-xs font-medium uppercase tracking-caps text-fg-tertiary">Ecosystem</div>
<ul className="mt-4 space-y-2.5 text-sm text-fg-secondary">
<li>
<Link href="/ecosystem" className="transition-colors hover:text-fg-primary">
Three Pillars
</Link>
</li>
{siblings.map((p) => (
<li key={p.key}>
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-fg-primary"
>
{p.name} — {p.role}
</a>
</li>
))}
</ul>
</div>
<div>
<div className="text-xs font-medium uppercase tracking-caps text-fg-tertiary">Resources</div>
<ul className="mt-4 space-y-2.5 text-sm text-fg-secondary">
<li>
<Link href="/about" className="transition-colors hover:text-fg-primary">
About
</Link>
</li>
<li>
<Link href="/integration" className="transition-colors hover:text-fg-primary">
API
</Link>
</li>
<li>
<a
href={SOLON_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-fg-primary"
>
Source Code
</a>
</li>
</ul>
</div>
{FOOTER_SECTIONS.map((section) => (
<div key={section.title}>
<div className="text-xs font-medium uppercase tracking-caps text-fg-tertiary">
{section.title}
</div>
<ul className="mt-4 space-y-2.5 text-sm text-fg-secondary">
{section.links.map((link) => (
<li key={`${section.title}:${link.href}`}>
<Link href={link.href} className="transition-colors hover:text-fg-primary">
{footerLinkLabel(link)}
</Link>
</li>
))}
{section.title === "Ecosystem" &&
siblings.map((p) => (
<li key={p.key}>
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-fg-primary"
>
{p.name} — {p.role}
</a>
</li>
))}
{section.title === "Resources" && (
<li>
<a
href={SOLON_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="transition-colors hover:text-fg-primary"
>
Source Code
</a>
</li>
)}
</ul>
</div>
))}
</div>

<div className="mt-12 flex flex-col items-center justify-between gap-3 border-t border-subtle pt-6 sm:flex-row">
Expand Down
79 changes: 79 additions & 0 deletions src/lib/__tests__/site-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* The footer may only point at routes the nav says exist.
*
* `NAV_ITEMS` carries the comment "only routes that actually exist belong here
* — a nav link to a 404 is a lie", and the footer carried the same sentence
* about itself. But the footer restated six of those routes as its own literal
* `<Link>`s, and nothing tied the two lists together: removing a page from the
* nav left the footer pointing at it, with no way to notice. Both files
* promised the same thing and only one could keep it.
*
* `FOOTER_SECTIONS` now names hrefs instead of repeating them, and this test is
* what makes that structural rather than a convention.
*/
import { describe, it, expect } from "vitest";
import {
NAV_ITEMS,
NAV_CHILDREN,
FOOTER_SECTIONS,
footerLinkLabel,
} from "@/lib/site-config";

const internal = (href: string) => href.startsWith("/");

describe("FOOTER_SECTIONS", () => {
it("only points at routes NAV_ITEMS declares", () => {
const known = new Set(NAV_CHILDREN.filter((c) => internal(c.href)).map((c) => c.href));
const unknown = FOOTER_SECTIONS.flatMap((s) =>
s.links.filter((l) => !known.has(l.href)).map((l) => `${s.title} → ${l.href}`),
);
expect(unknown, `not in NAV_ITEMS:\n${unknown.join("\n")}`).toEqual([]);
});

it("inherits the nav's wording unless it deliberately overrides it", () => {
// /features carries no label, so it must read exactly as the nav does. If
// someone renames it in NAV_ITEMS, the footer follows without an edit.
const navFeatures = NAV_CHILDREN.find((c) => c.href === "/features");
const footFeatures = FOOTER_SECTIONS.flatMap((s) => s.links).find(
(l) => l.href === "/features" && !l.label,
);
expect(navFeatures).toBeDefined();
expect(footFeatures).toBeDefined();
expect(footerLinkLabel(footFeatures!)).toBe(navFeatures!.title);
});

it("keeps the deliberate short labels", () => {
// The footer says "Voting" where the nav says "How voting works". That is a
// choice, not drift — so it is written down as an override rather than as a
// second copy of the route.
const voting = FOOTER_SECTIONS.flatMap((s) => s.links).find(
(l) => l.href === "/governance/voting",
);
expect(voting?.label).toBe("Voting");
expect(footerLinkLabel(voting!)).toBe("Voting");
});

it("has no duplicate href within one footer section", () => {
for (const section of FOOTER_SECTIONS) {
const hrefs = section.links.map((l) => l.href);
expect(new Set(hrefs).size, `duplicate in ${section.title}`).toBe(hrefs.length);
}
});
});

describe("NAV_ITEMS", () => {
it("declares no duplicate internal href", () => {
const hrefs = NAV_CHILDREN.filter((c) => internal(c.href)).map((c) => c.href);
// /integration is deliberately reachable as both "Integration" and "API",
// so compare the (href,title) pair rather than the href alone.
const pairs = NAV_CHILDREN.filter((c) => internal(c.href)).map((c) => `${c.href}|${c.title}`);
expect(new Set(pairs).size).toBe(pairs.length);
expect(hrefs.length).toBeGreaterThan(0);
});

it("gives every section a title", () => {
for (const section of NAV_ITEMS) {
expect(section.title.trim()).not.toBe("");
}
});
});
62 changes: 62 additions & 0 deletions src/lib/site-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,65 @@ export const HERO_CTAS = {
primary: { href: '/dashboard/voting', labelKey: 'cta_primary' },
secondary: { href: '/treasury/bitcoin', labelKey: 'cta_secondary' },
} as const;

/** Every internal nav destination, flattened — the set of routes that exist. */
export const NAV_CHILDREN: NavChildItem[] = NAV_ITEMS.flatMap((s) => s.children ?? []);

/**
* The footer, derived from NAV_ITEMS rather than hand-written beside it.
*
* The footer used to restate six of these routes as its own literal <Link>s.
* Nothing tied the two lists together, so removing a page from the nav left the
* footer pointing at it — the exact "a footer link to a 404 is a lie" the
* footer's own comment warns about, with no way to notice.
*
* A footer entry names an href that must already exist in NAV_ITEMS. `label`
* is optional and only for the places the footer deliberately says something
* shorter than the nav does ("Voting", not "How voting works"). Everything
* else inherits, so a rename in NAV_ITEMS reaches the footer for free.
*
* Enforced by src/lib/__tests__/site-config.test.ts: an href here that is not
* in NAV_ITEMS fails the suite.
*/
export interface FooterLink {
href: string;
/** Only when the footer deliberately differs from the nav's wording. */
label?: string;
}

export interface FooterSection {
title: string;
links: FooterLink[];
}

export const FOOTER_SECTIONS: FooterSection[] = [
{
title: 'Platform',
links: [{ href: '/features' }, { href: '/security' }, { href: '/integration' }],
},
{
title: 'Governance',
links: [
{ href: '/governance/voting', label: 'Voting' },
{ href: '/governance/audit' },
{ href: '/treasury/bitcoin' },
],
},
{
title: 'Ecosystem',
// The sibling products come from ECOSYSTEM_PILLARS in the component — one
// pillar SSOT, not restated here.
links: [{ href: '/ecosystem' }],
},
{
title: 'Resources',
links: [{ href: '/about' }, { href: '/integration', label: 'API' }],
},
];

/** The label a footer link shows: its override, else the nav's own title. */
export function footerLinkLabel(link: FooterLink): string {
if (link.label) return link.label;
const item = NAV_CHILDREN.find((c) => c.href === link.href);
return item?.title ?? link.href;
}