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
34 changes: 34 additions & 0 deletions apps/web/__tests__/unit/homepage-demo-accessibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { JSDOM } from "jsdom";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DesktopDemo } from "@/components/pages/HomeTwo/demo/DesktopDemo";

describe("homepage demo keyboard controls", () => {
it("keeps hidden controls inert until the visitor starts the tour", () => {
const dom = new JSDOM(renderToStaticMarkup(createElement(DesktopDemo)));
try {
const buttons = [...dom.window.document.querySelectorAll("button")];
for (const label of [
"Jump to Instant Mode",
"Jump to Studio Mode",
"Jump to The Editor",
"Skip demo",
"Restart demo",
"Start recording",
"Stop recording",
"Export the recording",
]) {
const button = buttons.find(
(candidate) =>
(candidate.getAttribute("aria-label") ??
candidate.textContent?.trim()) === label,
);
expect(button, label).toBeDefined();
expect(button?.closest("[inert]"), label).not.toBeNull();
}
} finally {
dom.window.close();
}
});
});
84 changes: 84 additions & 0 deletions apps/web/__tests__/unit/homepage-seo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { readFileSync } from "node:fs";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import robots from "@/app/robots";
import { homePageMetadata } from "@/components/pages/HomeTwo/metadata";
import { HomeTwoSchema } from "@/components/pages/HomeTwo/Schema";
import { homepageSchema, homepageSeo } from "@/components/pages/HomeTwo/seo";
import { PRICING } from "@/data/pricing";

describe("homepage SEO", () => {
it("uses the public root as the crawlable canonical", async () => {
expect(homePageMetadata.alternates?.canonical).toBe("https://cap.so/");
expect(homePageMetadata.robots).toMatchObject({
index: true,
follow: true,
});
const policy = await robots();
const rules = Array.isArray(policy.rules) ? policy.rules : [policy.rules];
for (const rule of rules) {
if (rule.userAgent !== "*") continue;
const disallowed = Array.isArray(rule.disallow)
? rule.disallow
: [rule.disallow];
expect(disallowed).not.toContain("/");
expect(disallowed).not.toContain("/home");
}
});

it("connects the website, page, software, and publisher without invented ratings", () => {
const graph = homepageSchema["@graph"];
const ids = new Set(graph.map((entity) => entity["@id"]));
expect(ids.size).toBe(graph.length);
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit);
} else if (value && typeof value === "object") {
if ("@id" in value) expect(ids.has(String(value["@id"]))).toBe(true);
Object.values(value).forEach(visit);
}
};
visit(graph);
const serialized = JSON.stringify(homepageSchema);
expect(serialized).not.toContain("aggregateRating");
expect(serialized).not.toContain("reviewRating");
expect(serialized).not.toContain("FAQPage");
expect(serialized).not.toContain("priceValidUntil");
});

it("uses the displayed plan prices and supported desktop platforms", () => {
const software = homepageSchema["@graph"].find(
(entity) => entity["@type"] === "SoftwareApplication",
);
expect(software?.operatingSystem).toEqual(["macOS", "Windows", "Linux"]);
expect(software?.offers).toMatchObject([
{ name: "Cap Free", price: 0 },
{ name: "Desktop License", price: PRICING.commercial.lifetime },
{ name: "Cap Pro", price: PRICING.pro.monthly },
]);
});

it("describes the actual logo dimensions", () => {
const logo = homepageSchema["@graph"].find(
(entity) => entity["@type"] === "Organization",
)?.logo;
const image = readFileSync(
new URL("../../public/cap-logo.png", import.meta.url),
);
expect(logo).toMatchObject({
width: image.readUInt32BE(16),
height: image.readUInt32BE(20),
});
});

it("renders valid JSON-LD with the same description as the search metadata", () => {
const html = renderToStaticMarkup(createElement(HomeTwoSchema));
const json = html.match(
/<script type="application\/ld\+json">(.*?)<\/script>/,
)?.[1];
expect(json).toBeDefined();
expect(JSON.parse(json ?? "")).toEqual(homepageSchema);
expect(homePageMetadata.description).toBe(homepageSeo.description);
});
});
86 changes: 70 additions & 16 deletions apps/web/app/(site)/DesktopNavLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { navigationMenuTriggerStyle } from "@cap/ui";
import { classNames } from "@cap/utils";
import { navigationMenuTriggerStyle } from "@cap/ui/navigation-menu";
import { classNames } from "@cap/utils/helpers";
import { ChevronDown, Clapperboard, Zap } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
Expand Down Expand Up @@ -126,10 +126,46 @@ const dropdownStyle = (width: number | undefined): CSSProperties => ({
maxWidth: "calc(100vw - 2rem)",
});

const BUBBLE_CSS = `
.ht-nav-bubble {
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1),
width 300ms cubic-bezier(0.22, 1, 0.36, 1),
height 300ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 180ms ease;
animation: ht-nav-bubble-in 260ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ht-nav-bubble-in {
from { opacity: 0; scale: 0.86; }
to { opacity: 1; scale: 1; }
}
`;

export function DesktopNavLinks() {
const pathname = usePathname();
const previousPathname = useRef(pathname);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const [bubble, setBubble] = useState<{
x: number;
y: number;
w: number;
h: number;
} | null>(null);
const [bubbleOn, setBubbleOn] = useState(false);

const showBubble = (item: HTMLElement) => {
const list = listRef.current;
if (!list) return;
const rect = item.getBoundingClientRect();
const base = list.getBoundingClientRect();
setBubble({
x: rect.left - base.left,
y: rect.top - base.top,
w: rect.width,
h: rect.height,
});
setBubbleOn(true);
};

useEffect(() => {
if (previousPathname.current === pathname) {
Expand Down Expand Up @@ -160,15 +196,34 @@ export function DesktopNavLinks() {

return (
<nav aria-label="Main">
<ul className="flex items-center px-0 space-x-0 list-none">
<ul
ref={listRef}
className="relative flex items-center gap-0.5 px-0 list-none"
onMouseLeave={() => setBubbleOn(false)}
>
{bubble ? (
<span
aria-hidden="true"
className="ht-nav-bubble pointer-events-none absolute left-0 top-0 z-0 rounded-[8px] bg-gray-3"
style={{
transform: `translate(${bubble.x}px, ${bubble.y}px)`,
width: bubble.w,
height: bubble.h,
opacity: bubbleOn ? 1 : 0,
}}
/>
) : null}
{Links.map((link) => {
const isOpen = openDropdown === link.label;

return (
<li
key={link.label}
className="relative"
onMouseEnter={() => setOpenDropdown(link.label)}
className="relative z-10"
onMouseEnter={(event) => {
showBubble(event.currentTarget);
setOpenDropdown(link.label);
}}
onMouseLeave={() =>
setOpenDropdown((current) =>
current === link.label ? null : current,
Expand All @@ -186,34 +241,31 @@ export function DesktopNavLinks() {
onClick={() => setOpenDropdown(link.label)}
className={classNames(
navigationMenuTriggerStyle(),
"flex gap-1 items-center px-2 py-0 text-sm font-medium text-gray-10 transition-colors hover:text-blue-9 focus:text-blue-9",
isOpen && "text-blue-9",
"bg-transparent hover:bg-transparent focus:bg-transparent data-[state=open]:bg-transparent",
"flex gap-1.5 items-center px-2.5 py-2 text-[14.5px] font-medium text-[rgba(17,17,17,0.85)] transition-colors hover:text-[#111111] focus:text-[#111111] xl:px-3 xl:text-[15.5px]",
isOpen && "text-[#111111]",
)}
>
{link.label}
<ChevronDown
className={classNames(
"size-3.5 transition-transform duration-200 ease-out",
"size-[15px] transition-transform duration-200 ease-out",
isOpen && "rotate-180",
)}
strokeWidth={2.25}
strokeWidth={2}
aria-hidden="true"
/>
</button>
<div
className={classNames(
"absolute top-full left-1/2 z-50 -translate-x-1/2 pt-3 transition duration-150",
"absolute top-full left-0 z-50 pt-3 transition duration-150",
isOpen
? "visible block opacity-100"
: "invisible hidden opacity-0",
)}
>
<div className="relative" style={dropdownStyle(link.width)}>
<span
className="absolute -top-[7px] left-1/2 z-10 size-3.5 -translate-x-1/2 rotate-45 rounded-tl-[4px] border-t border-l border-zinc-200/70 bg-white"
aria-hidden="true"
/>
<div className="overflow-hidden relative bg-white rounded-2xl border shadow-xl border-zinc-200/70">
<div className="overflow-hidden relative bg-white rounded-2xl border border-zinc-200/70">
<ul className="grid grid-cols-2 gap-1.5 p-3 list-none">
{link.dropdown.map((sublink) => (
<li key={sublink.href}>
Expand Down Expand Up @@ -243,7 +295,8 @@ export function DesktopNavLinks() {
onClick={closeDropdown}
className={classNames(
navigationMenuTriggerStyle(),
"px-2 py-0 text-sm font-medium text-gray-10 hover:text-blue-9 focus:text-blue-9",
"bg-transparent hover:bg-transparent focus:bg-transparent data-[state=open]:bg-transparent",
"px-2.5 py-2 text-[14.5px] font-medium text-[rgba(17,17,17,0.85)] hover:text-[#111111] focus:text-[#111111] xl:px-3 xl:text-[15.5px]",
)}
>
{link.label}
Expand All @@ -253,6 +306,7 @@ export function DesktopNavLinks() {
);
})}
</ul>
<style>{BUBBLE_CSS}</style>
</nav>
);
}
12 changes: 5 additions & 7 deletions apps/web/app/(site)/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
"use client";

import { Logo } from "@cap/ui";
import {
faDiscord,
faLinkedinIn,
faXTwitter,
} from "@fortawesome/free-brands-svg-icons";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
import { Logo } from "@cap/ui/logo";
import { faDiscord } from "@fortawesome/free-brands-svg-icons/faDiscord";
import { faLinkedinIn } from "@fortawesome/free-brands-svg-icons/faLinkedinIn";
import { faXTwitter } from "@fortawesome/free-brands-svg-icons/faXTwitter";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
import type { ComponentProps, ReactNode } from "react";
Expand Down
Loading
Loading