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.
|
// Fetched at build time; refreshed by the scheduled landing rebuild.
|
||||||
const starCount = formatCompact(await getStarCount());
|
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: "Enterprise", href: "/#enterprise" },
|
||||||
{ label: "Pricing", href: "/#pricing" },
|
{ 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: "Docs", href: "https://docs.snapotter.com", external: true },
|
||||||
{ label: "Talk to a human", href: "/contact" },
|
{ label: "Talk to a human", href: "/contact" },
|
||||||
];
|
];
|
||||||
@@ -24,7 +32,31 @@ const links = [
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Desktop nav links -->
|
<!-- 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) => (
|
{links.map((item) => (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -65,7 +97,7 @@ const links = [
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu toggle (CSS-only) -->
|
<!-- 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-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>
|
<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>
|
</label>
|
||||||
@@ -75,8 +107,8 @@ const links = [
|
|||||||
<input type="checkbox" id="mobile-menu-toggle" class="peer hidden" aria-hidden="true" />
|
<input type="checkbox" id="mobile-menu-toggle" class="peer hidden" aria-hidden="true" />
|
||||||
|
|
||||||
<!-- Mobile menu panel -->
|
<!-- Mobile menu panel -->
|
||||||
<div class="hidden border-t border-border bg-surface px-6 py-4 peer-checked:block min-[1120px]:hidden">
|
<div class="hidden border-t border-border bg-surface px-6 py-4 peer-checked:block lg:hidden">
|
||||||
{links.map((item) => (
|
{[...productLinks, ...links].map((item) => (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="block py-2.5 text-sm font-medium text-muted transition-colors hover:text-foreground"
|
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 }) => {
|
test("navbar renders brand and navigation links", async ({ page }) => {
|
||||||
|
const nav = page.locator("nav");
|
||||||
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
await expect(page.getByText("SnapOtter").first()).toBeVisible();
|
||||||
for (const name of [
|
await expect(nav.getByRole("button", { name: "Product" })).toBeVisible();
|
||||||
"Enterprise",
|
for (const name of ["Enterprise", "Pricing", "Docs", "Talk to a human"]) {
|
||||||
"Pricing",
|
await expect(nav.getByRole("link", { name }).first()).toBeVisible();
|
||||||
"Alternatives",
|
|
||||||
"Developers",
|
|
||||||
"Docs",
|
|
||||||
"Talk to a human",
|
|
||||||
]) {
|
|
||||||
await expect(page.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 }) => {
|
test("navbar renders Book a Demo CTA", async ({ page }) => {
|
||||||
await expect(page.getByRole("link", { name: "Book a Demo" }).first()).toBeVisible();
|
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 }) => {
|
test("footer renders all column titles", async ({ page }) => {
|
||||||
|
const footer = page.locator("footer");
|
||||||
for (const col of ["Product", "Solutions", "Resources", "Community", "Legal"]) {
|
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