feat(landing): add Product dropdown to nav (#475)

Adds a CSS-only Product dropdown (Developers, Self-hosted tools, Alternatives) to the top nav and restores the lg breakpoint. Mobile menu lists items flat. Updated homepage e2e (navbar + dropdown-hover tests, footer scope). Verified at 1024/1280px, 63/63 landing e2e.
This commit is contained in:
SnapOtter
2026-07-10 20:09:57 +08:00
committed by GitHub
parent a08afbc80a
commit 3d1744aec8
2 changed files with 53 additions and 17 deletions
+39 -7
View File
@@ -5,11 +5,19 @@ import { formatCompact, getStarCount } from "@/lib/stats";
// Fetched at build time; refreshed by the scheduled landing rebuild.
const starCount = formatCompact(await getStarCount());
const links = [
type NavItem = { label: string; href: string; external?: boolean; desc?: string };
const productLinks: NavItem[] = [
{
label: "Developers",
href: "/self-hosted/file-conversion-api",
desc: "REST API, pipelines, headless",
},
{ label: "Self-hosted tools", href: "/self-hosted", desc: "Private, on your own infrastructure" },
{ label: "Alternatives", href: "/alternatives", desc: "vs CloudConvert, TinyPNG, Otter.ai" },
];
const links: NavItem[] = [
{ label: "Enterprise", href: "/#enterprise" },
{ label: "Pricing", href: "/#pricing" },
{ label: "Alternatives", href: "/alternatives" },
{ label: "Developers", href: "/self-hosted/file-conversion-api" },
{ label: "Docs", href: "https://docs.snapotter.com", external: true },
{ label: "Talk to a human", href: "/contact" },
];
@@ -24,7 +32,31 @@ const links = [
</a>
<!-- Desktop nav links -->
<div class="hidden items-center gap-6 min-[1120px]:flex">
<div class="hidden items-center gap-6 lg:flex">
<!-- Product dropdown (CSS-only: opens on hover and on keyboard focus) -->
<div class="group relative">
<button
type="button"
class="flex items-center gap-1 whitespace-nowrap text-sm font-medium text-muted transition-colors hover:text-foreground group-focus-within:text-foreground"
aria-haspopup="menu"
>
Product
<svg class="h-4 w-4 transition-transform group-hover:rotate-180 group-focus-within:rotate-180" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="absolute left-0 top-full hidden pt-3 group-hover:block group-focus-within:block">
<div class="w-64 rounded-xl border border-border bg-surface p-2 shadow-lg">
{productLinks.map((item) => (
<a
href={item.href}
class="block rounded-lg px-3 py-2 transition-colors hover:bg-background-alt"
>
<span class="block text-sm font-semibold text-foreground">{item.label}</span>
<span class="mt-0.5 block text-xs text-muted">{item.desc}</span>
</a>
))}
</div>
</div>
</div>
{links.map((item) => (
<a
href={item.href}
@@ -65,7 +97,7 @@ const links = [
</div>
<!-- Mobile menu toggle (CSS-only) -->
<label for="mobile-menu-toggle" class="cursor-pointer text-muted min-[1120px]:hidden" aria-label="Toggle menu">
<label for="mobile-menu-toggle" class="cursor-pointer text-muted lg:hidden" aria-label="Toggle menu">
<svg class="menu-open h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
<svg class="menu-close hidden h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</label>
@@ -75,8 +107,8 @@ const links = [
<input type="checkbox" id="mobile-menu-toggle" class="peer hidden" aria-hidden="true" />
<!-- Mobile menu panel -->
<div class="hidden border-t border-border bg-surface px-6 py-4 peer-checked:block min-[1120px]:hidden">
{links.map((item) => (
<div class="hidden border-t border-border bg-surface px-6 py-4 peer-checked:block lg:hidden">
{[...productLinks, ...links].map((item) => (
<a
href={item.href}
class="block py-2.5 text-sm font-medium text-muted transition-colors hover:text-foreground"
+14 -10
View File
@@ -10,19 +10,22 @@ test.describe("Landing Homepage", () => {
});
test("navbar renders brand and navigation links", async ({ page }) => {
const nav = page.locator("nav");
await expect(page.getByText("SnapOtter").first()).toBeVisible();
for (const name of [
"Enterprise",
"Pricing",
"Alternatives",
"Developers",
"Docs",
"Talk to a human",
]) {
await expect(page.getByRole("link", { name }).first()).toBeVisible();
await expect(nav.getByRole("button", { name: "Product" })).toBeVisible();
for (const name of ["Enterprise", "Pricing", "Docs", "Talk to a human"]) {
await expect(nav.getByRole("link", { name }).first()).toBeVisible();
}
});
test("Product dropdown reveals its items on hover", async ({ page }) => {
const nav = page.locator("nav");
await nav.getByRole("button", { name: "Product" }).hover();
await expect(nav.getByRole("link", { name: /Developers/ })).toBeVisible();
await expect(nav.getByRole("link", { name: /Self-hosted tools/ })).toBeVisible();
await expect(nav.getByRole("link", { name: /Alternatives/ })).toBeVisible();
});
test("navbar renders Book a Demo CTA", async ({ page }) => {
await expect(page.getByRole("link", { name: "Book a Demo" }).first()).toBeVisible();
});
@@ -137,8 +140,9 @@ test.describe("Landing Homepage", () => {
});
test("footer renders all column titles", async ({ page }) => {
const footer = page.locator("footer");
for (const col of ["Product", "Solutions", "Resources", "Community", "Legal"]) {
await expect(page.getByText(col, { exact: true })).toBeVisible();
await expect(footer.getByText(col, { exact: true })).toBeVisible();
}
});