Skip to content

Commit f5b2fab

Browse files
committed
chore: add sponsors section
Show paid sponsors with a direct sponsorship CTA and keep the Carbon Cover placement desktop-only with a compact mobile fallback.
1 parent 4bfd715 commit f5b2fab

6 files changed

Lines changed: 247 additions & 68 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("@/features/marketing/components/hero-section", () => ({
5+
HeroSection: () => <section data-testid="hero-section" />,
6+
}));
7+
8+
vi.mock("@/features/marketing/components/home-deploy-section", () => ({
9+
HomeDeploySection: () => <section data-testid="home-deploy" />,
10+
}));
11+
12+
vi.mock("@/features/marketing/components/home-discover-section", () => ({
13+
HomeDiscoverSection: () => <section data-testid="home-discover" />,
14+
}));
15+
16+
vi.mock("@/features/marketing/components/home-faq-section", () => ({
17+
HomeFaqSection: () => <section data-testid="home-faq" />,
18+
}));
19+
20+
vi.mock("@/features/marketing/components/home-features-section", () => ({
21+
HomeFeaturesSection: () => <section data-testid="home-features" />,
22+
}));
23+
24+
vi.mock("@/features/marketing/components/home-use-cases-section", () => ({
25+
HomeUseCasesSection: () => <section data-testid="home-use-cases" />,
26+
}));
27+
28+
vi.mock("@/features/marketing/components/sponsors-section", () => ({
29+
SponsorsSection: () => <section data-testid="home-sponsors" />,
30+
}));
31+
32+
import HomePage from "./page";
33+
34+
describe("HomePage", () => {
35+
it("places sponsors between the FAQ and the final start CTA", () => {
36+
render(<HomePage />);
37+
38+
const faq = screen.getByTestId("home-faq");
39+
const sponsors = screen.getByTestId("home-sponsors");
40+
const start = document.getElementById("start");
41+
42+
expect(start).not.toBeNull();
43+
expect(faq.nextElementSibling).toBe(sponsors);
44+
expect(sponsors.nextElementSibling).toBe(start);
45+
});
46+
});

src/app/(public)/(site)/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { HomeDiscoverSection } from "@/features/marketing/components/home-discov
88
import { HomeFaqSection } from "@/features/marketing/components/home-faq-section";
99
import { HomeFeaturesSection } from "@/features/marketing/components/home-features-section";
1010
import { HomeUseCasesSection } from "@/features/marketing/components/home-use-cases-section";
11+
import { SponsorsSection } from "@/features/marketing/components/sponsors-section";
1112
import { buildDefaultOpenGraphImages, DEFAULT_OG_IMAGE } from "@/shared/lib/seo";
1213

1314
const PRINCIPLES = [
@@ -122,8 +123,9 @@ export default function HomePage() {
122123
<HomeUseCasesSection />
123124
<HomeDiscoverSection />
124125
<HomeFaqSection />
126+
<SponsorsSection />
125127

126-
<section id="start" className="border-t border-zinc-200 bg-white py-16 dark:border-white/10 dark:bg-zinc-950 sm:py-20 lg:py-24">
128+
<section id="start" className="bg-white py-16 dark:bg-zinc-950 sm:py-20 lg:py-24">
127129
<div className="site-container grid gap-8 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:gap-12">
128130
<div>
129131
<p className="text-xs font-semibold tracking-[0.18em] text-brand-700 uppercase dark:text-brand-300">Start here</p>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import { COMMUNITY_SPONSORS, FEATURED_SPONSORS } from "@/shared/data/sponsors";
4+
5+
vi.mock("@/shared/ui/carbon-ad-card", () => ({
6+
CarbonAdCard: ({ desktopOnly, variant }: { desktopOnly?: boolean; variant?: string }) => (
7+
<aside data-testid="carbon-ad" data-desktop-only={desktopOnly || undefined} data-variant={variant} />
8+
),
9+
}));
10+
11+
import { SponsorsSection } from "./sponsors-section";
12+
13+
describe("SponsorsSection", () => {
14+
it("introduces the current backers and invites new sponsors", () => {
15+
render(<SponsorsSection />);
16+
17+
expect(screen.getByRole("heading", { name: "These teams support Memos." })).toBeInTheDocument();
18+
19+
const sponsorCta = screen.getByRole("link", { name: "Become a sponsor" });
20+
expect(sponsorCta).toHaveAttribute("href", "https://github.com/sponsors/usememos");
21+
expect(sponsorCta).toHaveAttribute("target", "_blank");
22+
expect(sponsorCta.getAttribute("rel")?.split(/\s+/)).toEqual(expect.arrayContaining(["noopener", "noreferrer"]));
23+
});
24+
25+
it("shows every paid featured sponsor as a safe external link", () => {
26+
render(<SponsorsSection />);
27+
28+
for (const sponsor of FEATURED_SPONSORS) {
29+
const link = screen.getAllByRole("link").find((candidate) => candidate.getAttribute("href") === sponsor.url);
30+
31+
expect(link, `${sponsor.name} sponsor link`).toBeDefined();
32+
expect(link).toHaveAttribute("target", "_blank");
33+
expect(link?.getAttribute("rel")?.split(/\s+/)).toEqual(expect.arrayContaining(["noopener", "noreferrer"]));
34+
expect(link?.querySelector(`img[alt="${sponsor.name} logo"]`)).not.toBeNull();
35+
}
36+
37+
for (const sponsor of COMMUNITY_SPONSORS) {
38+
expect(screen.queryByRole("link", { name: new RegExp(sponsor.name, "i") })).not.toBeInTheDocument();
39+
}
40+
});
41+
42+
it("places the Carbon ad after every sponsor logo", () => {
43+
const { container } = render(<SponsorsSection />);
44+
const carbonAd = screen.getByTestId("carbon-ad");
45+
const logos = Array.from(container.querySelectorAll<HTMLImageElement>("img"));
46+
47+
expect(logos.length).toBeGreaterThanOrEqual(FEATURED_SPONSORS.length);
48+
expect(carbonAd).toHaveAttribute("data-variant", "sponsor");
49+
expect(carbonAd).toHaveAttribute("data-desktop-only", "true");
50+
51+
for (const logo of logos) {
52+
expect(logo.compareDocumentPosition(carbonAd) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
53+
}
54+
});
55+
});

src/features/marketing/components/sponsors-section.tsx

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,78 @@
1-
import { HeartIcon } from "lucide-react";
2-
import Link from "next/link";
1+
import { ArrowUpRightIcon } from "lucide-react";
32
import { FEATURED_SPONSORS } from "@/shared/data/sponsors";
43
import { cn } from "@/shared/lib/utils";
54
import { CarbonAdCard } from "@/shared/ui/carbon-ad-card";
65

6+
const SQUARE_LOGO_SPONSORS = new Set(["SSD Nodes"]);
7+
78
export function SponsorsSection() {
89
return (
9-
<section className="bg-transparent py-14 sm:py-18 lg:py-20">
10+
<section className="bg-stone-50/70 py-10 dark:bg-zinc-900/35 lg:py-12">
1011
<div className="site-container">
11-
<div className="grid gap-10 border-t border-stone-300/60 pt-10 dark:border-white/10 lg:grid-cols-[minmax(0,21rem)_minmax(0,1fr)] lg:gap-14 lg:pt-14">
12-
<div className="lg:sticky lg:top-24 lg:self-start">
13-
<div className="mb-4 inline-flex items-center gap-3 text-sm font-medium text-stone-600 dark:text-stone-300">
14-
<span className="inline-flex h-5 w-5 items-center justify-center text-stone-700 dark:text-stone-200">
15-
<HeartIcon className="h-4 w-4" />
16-
</span>
17-
<span className="tracking-[0.18em] uppercase text-stone-500 dark:text-stone-400">Community</span>
18-
</div>
19-
<h2 className="max-w-[12ch] text-balance font-serif text-3xl font-semibold tracking-[-0.03em] text-stone-950 dark:text-stone-100 sm:text-4xl lg:text-[3.15rem]">
20-
Supported by people who want Memos to last.
12+
<div className="grid gap-7 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] lg:items-center lg:gap-10">
13+
<div>
14+
<p className="text-xs font-semibold tracking-[0.18em] text-brand-700 uppercase dark:text-brand-300">Sponsors</p>
15+
<h2 className="mt-4 max-w-[14ch] text-balance font-serif text-3xl font-semibold tracking-tight text-zinc-950 dark:text-zinc-100 sm:text-4xl">
16+
These teams support Memos.
2117
</h2>
22-
<p className="mt-4 max-w-md text-balance text-base leading-7 text-stone-600 dark:text-stone-300 sm:text-lg">
23-
A small group of sponsors helps keep the project shipping, documented, and available to everyone.
24-
</p>
18+
<a
19+
href="https://github.com/sponsors/usememos"
20+
target="_blank"
21+
rel="noopener noreferrer"
22+
className="group mt-4 inline-flex items-center gap-2 text-sm font-semibold text-zinc-950 transition-colors hover:text-brand-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-4 focus-visible:ring-offset-stone-50 dark:text-zinc-100 dark:hover:text-brand-300 dark:focus-visible:ring-offset-zinc-900"
23+
>
24+
Become a sponsor
25+
<ArrowUpRightIcon
26+
aria-hidden="true"
27+
className="size-4 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5 motion-reduce:transition-none"
28+
/>
29+
</a>
2530
</div>
2631

27-
<div>
28-
<div className="grid lg:grid-cols-2">
29-
{FEATURED_SPONSORS.map((sponsor) => (
30-
<a
31-
key={sponsor.name}
32-
href={sponsor.url}
33-
target="_blank"
34-
rel="noopener noreferrer"
35-
className="group flex min-h-[12rem] flex-col justify-between gap-6 border-b border-stone-300/60 px-6 py-6 transition-colors dark:border-white/10 sm:px-8 sm:py-8 lg:[&:nth-last-child(-n+2)]:border-b-0 lg:[&:nth-child(odd)]:border-r lg:[&:nth-child(odd)]:border-stone-300/60 dark:lg:[&:nth-child(odd)]:border-white/10"
36-
>
37-
<div className="flex items-center justify-start">
32+
<div className="grid min-w-0 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)] lg:items-center lg:gap-8">
33+
<div className="grid grid-cols-2 gap-x-6 gap-y-4 min-[360px]:grid-cols-3 min-[360px]:gap-x-4 sm:gap-x-8">
34+
{FEATURED_SPONSORS.map((sponsor) => {
35+
const logoClassName = cn(
36+
"h-auto w-auto max-w-[88%] object-contain transition-opacity duration-200 group-hover:opacity-100 motion-reduce:transition-none",
37+
SQUARE_LOGO_SPONSORS.has(sponsor.name) ? "max-h-12 opacity-90 sm:max-h-14 lg:max-h-16" : "max-h-7 opacity-80 sm:max-h-8",
38+
);
39+
40+
return (
41+
<a
42+
key={sponsor.name}
43+
href={sponsor.url}
44+
target="_blank"
45+
rel="noopener noreferrer"
46+
className="group flex min-h-16 items-center justify-center rounded-lg last:col-span-2 last:mx-auto last:w-1/2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-4 focus-visible:ring-offset-stone-50 min-[360px]:last:col-span-1 min-[360px]:last:mx-0 min-[360px]:last:w-auto dark:focus-visible:ring-offset-zinc-900"
47+
>
3848
<img
3949
src={sponsor.logo}
4050
alt={`${sponsor.name} logo`}
41-
className={cn("h-10 w-auto max-w-full object-contain sm:h-12", sponsor.logoDark && "dark:hidden")}
51+
loading="lazy"
52+
decoding="async"
53+
className={cn(logoClassName, sponsor.logoDark && "dark:hidden")}
4254
/>
4355
{sponsor.logoDark && (
4456
<img
4557
src={sponsor.logoDark}
4658
alt={`${sponsor.name} logo`}
47-
className="hidden h-10 w-auto max-w-full object-contain dark:block sm:h-12"
59+
loading="lazy"
60+
decoding="async"
61+
className={cn(logoClassName, "hidden dark:block")}
4862
/>
4963
)}
50-
</div>
51-
{sponsor.description && (
52-
<p className="max-w-sm border-t border-stone-300/60 pt-4 text-balance text-sm leading-7 text-stone-600 dark:border-white/10 dark:text-stone-300 sm:text-base">
53-
{sponsor.description}
54-
</p>
55-
)}
56-
</a>
57-
))}
58-
<div className="border-b border-stone-300/60 px-6 py-6 dark:border-white/10 sm:px-8 sm:py-8 lg:col-span-2 lg:border-b-0 lg:border-t lg:border-stone-300/60 dark:lg:border-white/10">
59-
<CarbonAdCard variant="sponsor" />
60-
</div>
64+
</a>
65+
);
66+
})}
6167
</div>
6268

63-
<div className="mt-6">
64-
<Link
65-
href="/sponsors"
66-
className="inline-flex items-center gap-2 text-sm font-medium text-stone-900 transition-colors hover:text-stone-700 dark:text-stone-100 dark:hover:text-stone-200 sm:text-base"
67-
>
68-
View all sponsors and backers
69-
<span aria-hidden="true"></span>
70-
</Link>
69+
<div className="border-t border-zinc-200 pt-5 dark:border-white/10 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-8">
70+
<p className="hidden text-[0.6875rem] font-semibold tracking-[0.16em] text-zinc-500 uppercase dark:text-zinc-400 lg:block">
71+
Sponsored
72+
</p>
73+
<div className="lg:mt-3">
74+
<CarbonAdCard desktopOnly variant="sponsor" />
75+
</div>
7176
</div>
7277
</div>
7378
</div>

src/shared/ui/carbon-ad-card.test.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ vi.mock("next/navigation", () => ({
1313
usePathname: () => mocks.pathname,
1414
}));
1515

16-
function CarbonTestTree({ cards = 1 }: { cards?: number }) {
16+
function CarbonTestTree({
17+
cards = 1,
18+
desktopOnly,
19+
variant,
20+
}: {
21+
cards?: number;
22+
desktopOnly?: boolean;
23+
variant?: "compact" | "default" | "sponsor";
24+
}) {
1725
return (
1826
<>
1927
<CarbonAdsController />
2028
{Array.from({ length: cards }, (_, index) => (
21-
<CarbonAdCard key={index} />
29+
<CarbonAdCard key={index} desktopOnly={desktopOnly} variant={variant} />
2230
))}
2331
</>
2432
);
@@ -47,6 +55,7 @@ describe("CarbonAdCard", () => {
4755

4856
afterEach(() => {
4957
resetCarbonAdRuntimeForTests();
58+
vi.unstubAllGlobals();
5059
});
5160

5261
it("keeps the compact sponsor fallback while the Carbon creative is unavailable", () => {
@@ -63,6 +72,36 @@ describe("CarbonAdCard", () => {
6372
expect(link.className).toMatch(/\bleading-5\b/);
6473
});
6574

75+
it("keeps the homepage sponsor fallback concise", () => {
76+
render(<CarbonTestTree variant="sponsor" />);
77+
78+
expect(screen.getByRole("link", { name: /Sponsor Memos Support the project and feature your logo here\./ })).toHaveAttribute(
79+
"href",
80+
"https://github.com/sponsors/usememos",
81+
);
82+
expect(screen.queryByText(/continued development/i)).not.toBeInTheDocument();
83+
});
84+
85+
it("keeps the homepage ad inactive with a compact fallback on mobile", () => {
86+
vi.stubGlobal(
87+
"matchMedia",
88+
vi.fn(() => ({
89+
addEventListener: vi.fn(),
90+
matches: false,
91+
media: "(min-width: 1024px)",
92+
removeEventListener: vi.fn(),
93+
})),
94+
);
95+
96+
render(<CarbonTestTree desktopOnly variant="sponsor" />);
97+
98+
const region = screen.getByRole("complementary", { name: "Sponsored content" });
99+
expect(region).toHaveAttribute("data-carbon-status", "inactive");
100+
expect(region.className).toContain("min-h-16");
101+
expect(region.className).toContain("lg:min-h-[200px]");
102+
expect(document.querySelector("#_carbonads_js")).not.toBeInTheDocument();
103+
});
104+
66105
it("loads the dashboard tag once without treating script load as an impression", () => {
67106
const refresh = vi.fn();
68107
window._carbonads = { refresh };

0 commit comments

Comments
 (0)