mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user