Skip to content

Commit ff25b5a

Browse files
Merge pull request #2218 from CapSoftware/improve/homepage-promotion
feat: launch the new homepage with accurate SEO
2 parents d9493a8 + a87c7c8 commit ff25b5a

86 files changed

Lines changed: 13998 additions & 135 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { JSDOM } from "jsdom";
2+
import { createElement } from "react";
3+
import { renderToStaticMarkup } from "react-dom/server";
4+
import { describe, expect, it } from "vitest";
5+
import { DesktopDemo } from "@/components/pages/HomeTwo/demo/DesktopDemo";
6+
7+
describe("homepage demo keyboard controls", () => {
8+
it("keeps hidden controls inert until the visitor starts the tour", () => {
9+
const dom = new JSDOM(renderToStaticMarkup(createElement(DesktopDemo)));
10+
try {
11+
const buttons = [...dom.window.document.querySelectorAll("button")];
12+
for (const label of [
13+
"Jump to Instant Mode",
14+
"Jump to Studio Mode",
15+
"Jump to The Editor",
16+
"Skip demo",
17+
"Restart demo",
18+
"Start recording",
19+
"Stop recording",
20+
"Export the recording",
21+
]) {
22+
const button = buttons.find(
23+
(candidate) =>
24+
(candidate.getAttribute("aria-label") ??
25+
candidate.textContent?.trim()) === label,
26+
);
27+
expect(button, label).toBeDefined();
28+
expect(button?.closest("[inert]"), label).not.toBeNull();
29+
}
30+
} finally {
31+
dom.window.close();
32+
}
33+
});
34+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { readFileSync } from "node:fs";
2+
import { createElement } from "react";
3+
import { renderToStaticMarkup } from "react-dom/server";
4+
import { describe, expect, it } from "vitest";
5+
import robots from "@/app/robots";
6+
import { homePageMetadata } from "@/components/pages/HomeTwo/metadata";
7+
import { HomeTwoSchema } from "@/components/pages/HomeTwo/Schema";
8+
import { homepageSchema, homepageSeo } from "@/components/pages/HomeTwo/seo";
9+
import { PRICING } from "@/data/pricing";
10+
11+
describe("homepage SEO", () => {
12+
it("uses the public root as the crawlable canonical", async () => {
13+
expect(homePageMetadata.alternates?.canonical).toBe("https://cap.so/");
14+
expect(homePageMetadata.robots).toMatchObject({
15+
index: true,
16+
follow: true,
17+
});
18+
const policy = await robots();
19+
const rules = Array.isArray(policy.rules) ? policy.rules : [policy.rules];
20+
for (const rule of rules) {
21+
if (rule.userAgent !== "*") continue;
22+
const disallowed = Array.isArray(rule.disallow)
23+
? rule.disallow
24+
: [rule.disallow];
25+
expect(disallowed).not.toContain("/");
26+
expect(disallowed).not.toContain("/home");
27+
}
28+
});
29+
30+
it("connects the website, page, software, and publisher without invented ratings", () => {
31+
const graph = homepageSchema["@graph"];
32+
const ids = new Set(graph.map((entity) => entity["@id"]));
33+
expect(ids.size).toBe(graph.length);
34+
const visit = (value: unknown) => {
35+
if (Array.isArray(value)) {
36+
value.forEach(visit);
37+
} else if (value && typeof value === "object") {
38+
if ("@id" in value) expect(ids.has(String(value["@id"]))).toBe(true);
39+
Object.values(value).forEach(visit);
40+
}
41+
};
42+
visit(graph);
43+
const serialized = JSON.stringify(homepageSchema);
44+
expect(serialized).not.toContain("aggregateRating");
45+
expect(serialized).not.toContain("reviewRating");
46+
expect(serialized).not.toContain("FAQPage");
47+
expect(serialized).not.toContain("priceValidUntil");
48+
});
49+
50+
it("uses the displayed plan prices and supported desktop platforms", () => {
51+
const software = homepageSchema["@graph"].find(
52+
(entity) => entity["@type"] === "SoftwareApplication",
53+
);
54+
expect(software?.operatingSystem).toEqual(["macOS", "Windows", "Linux"]);
55+
expect(software?.offers).toMatchObject([
56+
{ name: "Cap Free", price: 0 },
57+
{ name: "Desktop License", price: PRICING.commercial.lifetime },
58+
{ name: "Cap Pro", price: PRICING.pro.monthly },
59+
]);
60+
});
61+
62+
it("describes the actual logo dimensions", () => {
63+
const logo = homepageSchema["@graph"].find(
64+
(entity) => entity["@type"] === "Organization",
65+
)?.logo;
66+
const image = readFileSync(
67+
new URL("../../public/cap-logo.png", import.meta.url),
68+
);
69+
expect(logo).toMatchObject({
70+
width: image.readUInt32BE(16),
71+
height: image.readUInt32BE(20),
72+
});
73+
});
74+
75+
it("renders valid JSON-LD with the same description as the search metadata", () => {
76+
const html = renderToStaticMarkup(createElement(HomeTwoSchema));
77+
const json = html.match(
78+
/<script type="application\/ld\+json">(.*?)<\/script>/,
79+
)?.[1];
80+
expect(json).toBeDefined();
81+
expect(JSON.parse(json ?? "")).toEqual(homepageSchema);
82+
expect(homePageMetadata.description).toBe(homepageSeo.description);
83+
});
84+
});

apps/web/app/(site)/DesktopNavLinks.tsx

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

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

129+
const BUBBLE_CSS = `
130+
.ht-nav-bubble {
131+
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1),
132+
width 300ms cubic-bezier(0.22, 1, 0.36, 1),
133+
height 300ms cubic-bezier(0.22, 1, 0.36, 1),
134+
opacity 180ms ease;
135+
animation: ht-nav-bubble-in 260ms cubic-bezier(0.22, 1, 0.36, 1);
136+
}
137+
@keyframes ht-nav-bubble-in {
138+
from { opacity: 0; scale: 0.86; }
139+
to { opacity: 1; scale: 1; }
140+
}
141+
`;
142+
129143
export function DesktopNavLinks() {
130144
const pathname = usePathname();
131145
const previousPathname = useRef(pathname);
132146
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
147+
const listRef = useRef<HTMLUListElement | null>(null);
148+
const [bubble, setBubble] = useState<{
149+
x: number;
150+
y: number;
151+
w: number;
152+
h: number;
153+
} | null>(null);
154+
const [bubbleOn, setBubbleOn] = useState(false);
155+
156+
const showBubble = (item: HTMLElement) => {
157+
const list = listRef.current;
158+
if (!list) return;
159+
const rect = item.getBoundingClientRect();
160+
const base = list.getBoundingClientRect();
161+
setBubble({
162+
x: rect.left - base.left,
163+
y: rect.top - base.top,
164+
w: rect.width,
165+
h: rect.height,
166+
});
167+
setBubbleOn(true);
168+
};
133169

134170
useEffect(() => {
135171
if (previousPathname.current === pathname) {
@@ -160,15 +196,34 @@ export function DesktopNavLinks() {
160196

161197
return (
162198
<nav aria-label="Main">
163-
<ul className="flex items-center px-0 space-x-0 list-none">
199+
<ul
200+
ref={listRef}
201+
className="relative flex items-center gap-0.5 px-0 list-none"
202+
onMouseLeave={() => setBubbleOn(false)}
203+
>
204+
{bubble ? (
205+
<span
206+
aria-hidden="true"
207+
className="ht-nav-bubble pointer-events-none absolute left-0 top-0 z-0 rounded-[8px] bg-gray-3"
208+
style={{
209+
transform: `translate(${bubble.x}px, ${bubble.y}px)`,
210+
width: bubble.w,
211+
height: bubble.h,
212+
opacity: bubbleOn ? 1 : 0,
213+
}}
214+
/>
215+
) : null}
164216
{Links.map((link) => {
165217
const isOpen = openDropdown === link.label;
166218

167219
return (
168220
<li
169221
key={link.label}
170-
className="relative"
171-
onMouseEnter={() => setOpenDropdown(link.label)}
222+
className="relative z-10"
223+
onMouseEnter={(event) => {
224+
showBubble(event.currentTarget);
225+
setOpenDropdown(link.label);
226+
}}
172227
onMouseLeave={() =>
173228
setOpenDropdown((current) =>
174229
current === link.label ? null : current,
@@ -186,34 +241,31 @@ export function DesktopNavLinks() {
186241
onClick={() => setOpenDropdown(link.label)}
187242
className={classNames(
188243
navigationMenuTriggerStyle(),
189-
"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",
190-
isOpen && "text-blue-9",
244+
"bg-transparent hover:bg-transparent focus:bg-transparent data-[state=open]:bg-transparent",
245+
"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]",
246+
isOpen && "text-[#111111]",
191247
)}
192248
>
193249
{link.label}
194250
<ChevronDown
195251
className={classNames(
196-
"size-3.5 transition-transform duration-200 ease-out",
252+
"size-[15px] transition-transform duration-200 ease-out",
197253
isOpen && "rotate-180",
198254
)}
199-
strokeWidth={2.25}
255+
strokeWidth={2}
200256
aria-hidden="true"
201257
/>
202258
</button>
203259
<div
204260
className={classNames(
205-
"absolute top-full left-1/2 z-50 -translate-x-1/2 pt-3 transition duration-150",
261+
"absolute top-full left-0 z-50 pt-3 transition duration-150",
206262
isOpen
207263
? "visible block opacity-100"
208264
: "invisible hidden opacity-0",
209265
)}
210266
>
211267
<div className="relative" style={dropdownStyle(link.width)}>
212-
<span
213-
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"
214-
aria-hidden="true"
215-
/>
216-
<div className="overflow-hidden relative bg-white rounded-2xl border shadow-xl border-zinc-200/70">
268+
<div className="overflow-hidden relative bg-white rounded-2xl border border-zinc-200/70">
217269
<ul className="grid grid-cols-2 gap-1.5 p-3 list-none">
218270
{link.dropdown.map((sublink) => (
219271
<li key={sublink.href}>
@@ -243,7 +295,8 @@ export function DesktopNavLinks() {
243295
onClick={closeDropdown}
244296
className={classNames(
245297
navigationMenuTriggerStyle(),
246-
"px-2 py-0 text-sm font-medium text-gray-10 hover:text-blue-9 focus:text-blue-9",
298+
"bg-transparent hover:bg-transparent focus:bg-transparent data-[state=open]:bg-transparent",
299+
"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]",
247300
)}
248301
>
249302
{link.label}
@@ -253,6 +306,7 @@ export function DesktopNavLinks() {
253306
);
254307
})}
255308
</ul>
309+
<style>{BUBBLE_CSS}</style>
256310
</nav>
257311
);
258312
}

apps/web/app/(site)/Footer.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"use client";
22

3-
import { Logo } from "@cap/ui";
4-
import {
5-
faDiscord,
6-
faLinkedinIn,
7-
faXTwitter,
8-
} from "@fortawesome/free-brands-svg-icons";
9-
import { faChevronDown } from "@fortawesome/free-solid-svg-icons";
3+
import { Logo } from "@cap/ui/logo";
4+
import { faDiscord } from "@fortawesome/free-brands-svg-icons/faDiscord";
5+
import { faLinkedinIn } from "@fortawesome/free-brands-svg-icons/faLinkedinIn";
6+
import { faXTwitter } from "@fortawesome/free-brands-svg-icons/faXTwitter";
7+
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
108
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
119
import Link from "next/link";
1210
import type { ComponentProps, ReactNode } from "react";

0 commit comments

Comments
 (0)