Promote to main: Feed browser luxury redesign #7
+8
-2
@@ -95,7 +95,8 @@
|
|||||||
|
|
||||||
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
|
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
|
||||||
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
|
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
|
||||||
the light sweep on buttons, and stops snap and smooth scrolling. State
|
the light sweep on buttons, the featured glow, caption fade and slide (its
|
||||||
|
autoplay is stopped in FeaturedCarousel via matchMedia), and stops snap and smooth scrolling. State
|
||||||
changes still happen instantly. The loading spinner is kept, only slowed,
|
changes still happen instantly. The loading spinner is kept, only slowed,
|
||||||
because it is the only sign that something is loading. */
|
because it is the only sign that something is loading. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -111,7 +112,12 @@
|
|||||||
.btn-gloss::before,
|
.btn-gloss::before,
|
||||||
.btn-gloss::after,
|
.btn-gloss::after,
|
||||||
.btn-sweep::before,
|
.btn-sweep::before,
|
||||||
.account-dot { animation: none !important; }
|
.account-dot,
|
||||||
|
.featured-cf::before,
|
||||||
|
.featured-cap { animation: none !important; }
|
||||||
|
.featured-item,
|
||||||
|
.featured-ring,
|
||||||
|
.featured-dot { transition: none !important; }
|
||||||
.btn-sweep::before,
|
.btn-sweep::before,
|
||||||
.btn-gloss::after { transition: none !important; }
|
.btn-gloss::after { transition: none !important; }
|
||||||
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
|
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import "../styles/avatar.css";
|
|||||||
import "../styles/signin.css";
|
import "../styles/signin.css";
|
||||||
import "../styles/post.css";
|
import "../styles/post.css";
|
||||||
import "../styles/composer.css";
|
import "../styles/composer.css";
|
||||||
|
import "../styles/featured.css";
|
||||||
import "../styles/feed.css";
|
import "../styles/feed.css";
|
||||||
import "../styles/legacy.css";
|
import "../styles/legacy.css";
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { DiscoverData } from "@/lib/discover";
|
import type { DiscoverData } from "@/lib/discover";
|
||||||
import { fetchDiscover } from "@/lib/discover-client";
|
import { fetchDiscover } from "@/lib/discover-client";
|
||||||
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
|
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||||
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
||||||
|
|
||||||
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
|
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FeaturedExpertsRail experts={data.featuredExperts} />
|
<FeaturedCarousel experts={data.featuredExperts} />
|
||||||
<TrendingMinnectsRail minnects={data.trendingMinnects} />
|
<TrendingMinnectsRail minnects={data.trendingMinnects} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
|
import { initials } from "@/lib/feed-model";
|
||||||
|
import { toneFor } from "./HexAvatar";
|
||||||
|
import { featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
|
||||||
|
|
||||||
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
||||||
|
|
||||||
|
function useReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||||
|
const mq = window.matchMedia(REDUCED_MOTION);
|
||||||
|
setReduced(mq.matches);
|
||||||
|
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||||
|
mq.addEventListener("change", onChange);
|
||||||
|
return () => mq.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Star() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
|
||||||
|
const name = expert.displayName || "Expert";
|
||||||
|
if (expert.pictureUrl) {
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
|
||||||
|
<span aria-hidden="true">{initials(name)}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeaturedCarouselProps {
|
||||||
|
experts: DiscoverExpert[];
|
||||||
|
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
|
||||||
|
const count = experts.length;
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const reduced = useReducedMotion();
|
||||||
|
const autoplay = useRef<FeaturedAutoplay | null>(null);
|
||||||
|
const hovered = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (count < 2 || reduced) return;
|
||||||
|
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
|
||||||
|
if (hovered.current) ctl.pause();
|
||||||
|
autoplay.current = ctl;
|
||||||
|
return () => { ctl.stop(); autoplay.current = null; };
|
||||||
|
// The index only seeds a restart; ticking must not recreate the timer.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [count, reduced]);
|
||||||
|
|
||||||
|
if (count === 0) return null;
|
||||||
|
const current = index % count;
|
||||||
|
const cap = experts[current];
|
||||||
|
const style = { "--cfw": `${size}px` } as CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="tile panel rail rail-featured featured"
|
||||||
|
aria-labelledby="featured-title"
|
||||||
|
onMouseEnter={() => { hovered.current = true; autoplay.current?.pause(); }}
|
||||||
|
onMouseLeave={() => { hovered.current = false; autoplay.current?.resume(); }}
|
||||||
|
>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
||||||
|
<Star />Featured experts
|
||||||
|
</h2>
|
||||||
|
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="featured-cf" style={style} aria-live="polite" data-index={current}>
|
||||||
|
{experts.map((e, i) => {
|
||||||
|
const slot = featuredSlot(i, current, count);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(e.id)}
|
||||||
|
className={`featured-item featured-${slot}`}
|
||||||
|
aria-hidden={slot === "c0" ? undefined : true}
|
||||||
|
data-testid="featured-item"
|
||||||
|
>
|
||||||
|
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="featured-cap" key={current}>
|
||||||
|
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
|
||||||
|
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
|
||||||
|
{cap.rating !== null || cap.reviewsCount !== null ? (
|
||||||
|
<span className="featured-rate">
|
||||||
|
{cap.rating !== null ? (
|
||||||
|
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
|
||||||
|
) : null}
|
||||||
|
{cap.reviewsCount !== null ? (
|
||||||
|
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{count > 1 ? (
|
||||||
|
<div className="featured-dots" aria-hidden="true">
|
||||||
|
{experts.map((e, i) => (
|
||||||
|
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <div className="featured-dots-gap" />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { HexAvatar } from "@/components/HexAvatar";
|
|
||||||
import type { DiscoverExpert } from "@/lib/discover";
|
|
||||||
|
|
||||||
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
|
|
||||||
if (rating === null && reviews === null) return null;
|
|
||||||
return (
|
|
||||||
<span className="rail-meta">
|
|
||||||
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
|
|
||||||
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
|
|
||||||
if (experts.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<section className="tile panel rail rail-featured" aria-labelledby="rail-experts">
|
|
||||||
<div className="panel-head">
|
|
||||||
<h2 id="rail-experts" className="type-title type-title-strong">Featured experts</h2>
|
|
||||||
</div>
|
|
||||||
<ul className="rail-list panel-scroll fade-edges">
|
|
||||||
{experts.map((e) => (
|
|
||||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
|
||||||
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
|
||||||
<div className="rail-text">
|
|
||||||
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
|
||||||
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
|
||||||
<Rating rating={e.rating} reviews={e.reviewsCount} />
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** Rotation timing from the spec ("Featured experts carousel"). */
|
||||||
|
export const FEATURED_INTERVAL_MS = 2800;
|
||||||
|
|
||||||
|
export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where item `i` sits when `current` is in the center: by its signed offset
|
||||||
|
* from the current index, wrapped so the shorter way round wins. With two
|
||||||
|
* items the other one is a single right neighbor, never an empty pair.
|
||||||
|
*/
|
||||||
|
export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
|
||||||
|
let off = (((i - current) % count) + count) % count;
|
||||||
|
if (off > count / 2) off -= count;
|
||||||
|
if (off === 0) return "c0";
|
||||||
|
if (off === 1) return "r1";
|
||||||
|
if (off === -1) return "l1";
|
||||||
|
if (off === 2) return "r2";
|
||||||
|
if (off === -2) return "l2";
|
||||||
|
return off > 0 ? "r3" : "l3";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeaturedAutoplay {
|
||||||
|
readonly index: number;
|
||||||
|
readonly paused: boolean;
|
||||||
|
pause(): void;
|
||||||
|
resume(): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances the index every `intervalMs` while not paused. A single item never
|
||||||
|
* rotates. The caller stops it on unmount.
|
||||||
|
*/
|
||||||
|
export function startFeaturedAutoplay(opts: {
|
||||||
|
count: number;
|
||||||
|
onChange: (index: number) => void;
|
||||||
|
intervalMs?: number;
|
||||||
|
start?: number;
|
||||||
|
}): FeaturedAutoplay {
|
||||||
|
const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
|
||||||
|
let index = opts.start ?? 0;
|
||||||
|
let paused = false;
|
||||||
|
const timer =
|
||||||
|
count > 1
|
||||||
|
? setInterval(() => {
|
||||||
|
if (paused) return;
|
||||||
|
index = (index + 1) % count;
|
||||||
|
onChange(index);
|
||||||
|
}, intervalMs)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
get index() { return index; },
|
||||||
|
get paused() { return paused; },
|
||||||
|
pause() { paused = true; },
|
||||||
|
resume() { paused = false; },
|
||||||
|
stop() { if (timer) clearInterval(timer); },
|
||||||
|
};
|
||||||
|
}
|
||||||
+14
-2
@@ -127,12 +127,19 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const expert = (id: string, displayName: string, byline: string, rating: number, reviewsCount: number): DiscoverExpert => ({
|
const expert = (
|
||||||
|
id: string,
|
||||||
|
displayName: string,
|
||||||
|
byline: string,
|
||||||
|
rating: number,
|
||||||
|
reviewsCount: number,
|
||||||
|
pictureUrl: string | null = null,
|
||||||
|
): DiscoverExpert => ({
|
||||||
id,
|
id,
|
||||||
slug: displayName.replace(/\s+/g, ""),
|
slug: displayName.replace(/\s+/g, ""),
|
||||||
displayName,
|
displayName,
|
||||||
byline,
|
byline,
|
||||||
pictureUrl: null,
|
pictureUrl,
|
||||||
rating,
|
rating,
|
||||||
reviewsCount,
|
reviewsCount,
|
||||||
available: true,
|
available: true,
|
||||||
@@ -145,6 +152,11 @@ export const FIXTURE_FEATURED: DiscoverExpert[] = [
|
|||||||
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
|
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
|
||||||
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
|
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
|
||||||
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
|
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
|
||||||
|
// Photo placeholders exercise the image path of the featured carousel; the
|
||||||
|
// rest show initials.
|
||||||
|
expert("e9", "Leila Haddad", "Marketplace operations", 4.9, 181, placeholderImage(10)),
|
||||||
|
expert("e10", "Tomas Ferreira", "Hiring and team design", 4.7, 64, placeholderImage(190)),
|
||||||
|
expert("e11", "Grace Whitfield", "Brand and storytelling", 4.8, 102, placeholderImage(300)),
|
||||||
];
|
];
|
||||||
|
|
||||||
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
|
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/* Featured experts carousel (ADR 0001, section 6; spec "Featured experts
|
||||||
|
carousel"). Ported from the mockup's .cf rules; colors are tokens only.
|
||||||
|
--cfw is the center photo width, set by the component's size prop. */
|
||||||
|
|
||||||
|
.featured { flex: none; }
|
||||||
|
.featured-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.featured-title svg { width: 16px; height: 16px; color: var(--accent); }
|
||||||
|
.featured-count { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.featured-cf {
|
||||||
|
--cfw: 96px;
|
||||||
|
position: relative;
|
||||||
|
height: calc(var(--cfw) * 1.155 + 6px);
|
||||||
|
overflow: hidden;
|
||||||
|
overflow: clip;
|
||||||
|
overflow-clip-margin: 24px;
|
||||||
|
}
|
||||||
|
.featured-cf::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
width: calc(var(--cfw) * 1.9);
|
||||||
|
height: calc(var(--cfw) * 1.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: radial-gradient(closest-side, color-mix(in oklab, var(--accent), transparent 70%), transparent);
|
||||||
|
filter: blur(12px);
|
||||||
|
animation: featured-breathe 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes featured-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||||||
|
|
||||||
|
.featured-item {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
width: var(--cfw);
|
||||||
|
margin-left: calc(var(--cfw) / -2);
|
||||||
|
transition: transform 1.1s var(--ease), opacity 1.1s, filter 1.1s;
|
||||||
|
}
|
||||||
|
.featured-ring {
|
||||||
|
display: block;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--line-2);
|
||||||
|
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||||
|
transition: background .8s;
|
||||||
|
}
|
||||||
|
.featured-photo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: .866;
|
||||||
|
object-fit: cover;
|
||||||
|
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||||
|
}
|
||||||
|
/* Photos sit on the tile while loading; initials keep their HexAvatar tone. */
|
||||||
|
img.featured-photo { background: var(--tile-3); }
|
||||||
|
.featured-initials {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--avatar-ink);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
font-size: calc(var(--cfw) * .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured-c0 { z-index: 5; }
|
||||||
|
.featured-c0 .featured-ring { background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo)); }
|
||||||
|
.featured-r1 { transform: translateX(76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
|
||||||
|
.featured-l1 { transform: translateX(-76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
|
||||||
|
.featured-r2 { transform: translateX(132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
|
||||||
|
.featured-l2 { transform: translateX(-132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
|
||||||
|
.featured-r3 { transform: translateX(170%) scale(.3); opacity: 0; z-index: 1; }
|
||||||
|
.featured-l3 { transform: translateX(-170%) scale(.3); opacity: 0; z-index: 1; }
|
||||||
|
|
||||||
|
.featured-cap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 12px 16px 2px;
|
||||||
|
animation: featured-cap .8s var(--ease) both;
|
||||||
|
}
|
||||||
|
@keyframes featured-cap { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
.featured-name { font-size: 23px; line-height: 1.1; }
|
||||||
|
.featured-by {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.featured-rate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.featured-rate > span { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.featured-rate svg { width: 13px; height: 13px; color: var(--accent); }
|
||||||
|
.featured-rate em { font-style: normal; font-weight: 500; color: var(--ink-3); }
|
||||||
|
|
||||||
|
.featured-dots { display: flex; justify-content: center; gap: 5px; padding: 10px 0 16px; }
|
||||||
|
.featured-dots-gap { height: 16px; }
|
||||||
|
.featured-dot {
|
||||||
|
display: block;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--line-2);
|
||||||
|
transition: width .5s var(--ease), background-color .5s;
|
||||||
|
}
|
||||||
|
.featured-dot-on { width: 18px; background: var(--accent); }
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
.bento-left .expert-search { flex: 1 1 0; }
|
.bento-left .expert-search { flex: 1 1 0; }
|
||||||
.bento-center .feed { flex: 1 1 0; }
|
.bento-center .feed { flex: 1 1 0; }
|
||||||
.bento-right { justify-content: flex-start; }
|
.bento-right { justify-content: flex-start; }
|
||||||
.bento-right .rail-featured { flex: 0 1 auto; max-height: 40%; }
|
.bento-right .rail-featured { flex: none; }
|
||||||
.bento-right .rail-trending { flex: 1 1 0; }
|
.bento-right .rail-trending { flex: 1 1 0; }
|
||||||
|
|
||||||
/* Panel contents keep their inner padding off the scroll edge. */
|
/* Panel contents keep their inner padding off the scroll edge. */
|
||||||
@@ -107,7 +107,6 @@
|
|||||||
@container app (max-width: 1000px) {
|
@container app (max-width: 1000px) {
|
||||||
.bento-col .panel { flex: 0 0 auto; }
|
.bento-col .panel { flex: 0 0 auto; }
|
||||||
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
||||||
.bento-right .rail-featured { max-height: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Account panel ---------------------------------------------------------- */
|
/* Account panel ---------------------------------------------------------- */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
|
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
||||||
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
|
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
|
||||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||||
import { fetchDiscover } from "@/lib/discover-client";
|
import { fetchDiscover } from "@/lib/discover-client";
|
||||||
@@ -18,9 +18,9 @@ const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
|
|||||||
});
|
});
|
||||||
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
||||||
|
|
||||||
describe("FeaturedExpertsRail", () => {
|
describe("FeaturedCarousel (static render)", () => {
|
||||||
it("renders a card with name, byline, rating and reviews", () => {
|
it("renders the center expert with name, byline, rating and reviews", () => {
|
||||||
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
|
const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
|
||||||
expect(out).toContain("<h2");
|
expect(out).toContain("<h2");
|
||||||
expect(out).toContain("Ann Lee");
|
expect(out).toContain("Ann Lee");
|
||||||
expect(out).toContain("Growth coach");
|
expect(out).toContain("Growth coach");
|
||||||
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
|
|||||||
expect(out).toContain("<img");
|
expect(out).toContain("<img");
|
||||||
});
|
});
|
||||||
it("falls back to initials and omits missing fields", () => {
|
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 })],
|
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
|
||||||
}));
|
}));
|
||||||
expect(out).not.toContain("<img");
|
expect(out).not.toContain("<img");
|
||||||
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
|
|||||||
expect(out).not.toContain("review");
|
expect(out).not.toContain("review");
|
||||||
});
|
});
|
||||||
it("renders nothing when empty", () => {
|
it("renders nothing when empty", () => {
|
||||||
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
|
expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user