vzxipsule: Replace the featured rail with an auto-rotating FeaturedCarousel

This commit is contained in:
2026-10-19 22:30:16 -06:00
parent 5e391ae678
commit ce1a3e2eaa
11 changed files with 425 additions and 49 deletions
+6 -6
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { fetchDiscover } from "@/lib/discover-client";
@@ -18,9 +18,9 @@ const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
});
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
describe("FeaturedExpertsRail", () => {
it("renders a card with name, byline, rating and reviews", () => {
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
describe("FeaturedCarousel (static render)", () => {
it("renders the center expert with name, byline, rating and reviews", () => {
const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
expect(out).toContain("<h2");
expect(out).toContain("Ann Lee");
expect(out).toContain("Growth coach");
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).toContain("<img");
});
it("falls back to initials and omits missing fields", () => {
const out = html(createElement(FeaturedExpertsRail, {
const out = html(createElement(FeaturedCarousel, {
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
}));
expect(out).not.toContain("<img");
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
expect(out).not.toContain("review");
});
it("renders nothing when empty", () => {
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
});
});
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
import { FEATURED_INTERVAL_MS, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
import type { DiscoverExpert } from "@/lib/discover";
const expert = (i: number): DiscoverExpert => ({
id: i, slug: `e${i}`, displayName: `Expert ${i}`, byline: `Byline ${i}`, pictureUrl: i % 2 ? `https://x/${i}.jpg` : null,
rating: 4.5, reviewsCount: i, available: true, location: { city: null, country: null },
});
const experts = (n: number) => Array.from({ length: n }, (_, i) => expert(i + 1));
describe("featuredSlot", () => {
it("places items by offset from the current index, wrapping round", () => {
const slots = Array.from({ length: 8 }, (_, i) => featuredSlot(i, 0, 8));
expect(slots).toEqual(["c0", "r1", "r2", "r3", "r3", "l3", "l2", "l1"]);
expect(featuredSlot(0, 7, 8)).toBe("r1");
expect(featuredSlot(6, 7, 8)).toBe("l1");
});
it("degrades with one or two experts: no empty neighbors", () => {
expect(featuredSlot(0, 0, 1)).toBe("c0");
expect([featuredSlot(0, 0, 2), featuredSlot(1, 0, 2)]).toEqual(["c0", "r1"]);
expect([featuredSlot(0, 1, 2), featuredSlot(1, 1, 2)]).toEqual(["r1", "c0"]);
expect([0, 1, 2].map((i) => featuredSlot(i, 0, 3))).toEqual(["c0", "r1", "l1"]);
});
});
describe("startFeaturedAutoplay", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("advances every 2.8s and wraps to the start", () => {
const seen: number[] = [];
const ctl = startFeaturedAutoplay({ count: 3, onChange: (i) => seen.push(i) });
expect(FEATURED_INTERVAL_MS).toBe(2800);
vi.advanceTimersByTime(2799);
expect(seen).toEqual([]);
vi.advanceTimersByTime(1);
expect(seen).toEqual([1]);
vi.advanceTimersByTime(2800 * 2);
expect(seen).toEqual([1, 2, 0]);
expect(ctl.index).toBe(0);
ctl.stop();
});
it("does not advance while paused and resumes after", () => {
const onChange = vi.fn();
const ctl = startFeaturedAutoplay({ count: 5, onChange });
ctl.pause();
expect(ctl.paused).toBe(true);
vi.advanceTimersByTime(4000 * 3);
expect(onChange).not.toHaveBeenCalled();
expect(ctl.index).toBe(0);
ctl.resume();
vi.advanceTimersByTime(2800);
expect(onChange).toHaveBeenCalledWith(1);
ctl.stop();
});
it("never rotates a single expert and stops cleanly", () => {
const one = vi.fn();
startFeaturedAutoplay({ count: 1, onChange: one });
vi.advanceTimersByTime(10_000);
expect(one).not.toHaveBeenCalled();
const many = vi.fn();
const ctl = startFeaturedAutoplay({ count: 4, onChange: many });
ctl.stop();
vi.advanceTimersByTime(10_000);
expect(many).not.toHaveBeenCalled();
});
});
describe("FeaturedCarousel markup", () => {
it("has a polite live region, hides non-center items and shows the counter and dots", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
expect(out).toContain('aria-live="polite"');
expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
expect(out).toContain("featured-c0");
expect(out).toContain(">1 / 8<");
expect(out.match(/class="featured-dot( featured-dot-on)?"/g)).toHaveLength(8);
expect(out.match(/featured-dot-on/g)).toHaveLength(1);
expect(out).toContain("--cfw:96px");
});
it("takes a size prop for mobile", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(3), size: 156 }));
expect(out).toContain("--cfw:156px");
});
it("shows no dots for a single expert", () => {
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
expect(out).not.toContain("featured-dot ");
expect(out).toContain(">1 / 1<");
});
});