rnxcmkidd: Add the viewport-fit bento shell and a dev feed preview with fixtures

This commit is contained in:
2026-10-19 22:08:21 -06:00
parent 9aeb9a379f
commit 89064e88c1
15 changed files with 638 additions and 120 deletions
+73
View File
@@ -0,0 +1,73 @@
"use client";
// Dev-only: the real signed-in shell with fixture data and no network calls.
// Likes, search and the composer work on local state so the panels can be
// checked by hand. Used by src/app/dev/feed/page.tsx.
import { useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch";
import { FeedView } from "@/components/FeedView";
import { toCardPosts, type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => toCardPosts(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
// With no query or category, show every fixture result so the list scrolls.
const results = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? FIXTURE_SEARCH_RESULTS.filter((e) => e.displayName.toLowerCase().includes(q)) : FIXTURE_SEARCH_RESULTS;
}, [query]);
const toggleLike = (post: CardPost) =>
setPosts((cur) =>
cur.map((p) =>
p.key === post.key ? { ...p, isLiked: !p.isLiked, likesCount: p.likesCount + (p.isLiked ? -1 : 1) } : p,
),
);
return (
<AppShell
person={FIXTURE_PERSON}
search={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
feed={
<FeedView
posts={posts}
status="ready"
loadingMore={false}
loadMoreFailed={false}
newCount={0}
onRetry={() => {}}
onShowNew={() => {}}
onToggleLike={toggleLike}
feedUserId={FIXTURE_PERSON.feedUserId}
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
composer={{ value: draft, busy: false, error: false, onChange: setDraft, onSubmit: () => setDraft("") }}
now={FIXTURE_NOW}
/>
}
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
/>
);
}
+182
View File
@@ -0,0 +1,182 @@
// Fixture data for the dev-only preview routes (/dev/feed). Nothing here is
// real: no Minnect person, post, token or URL. Images are inline SVG data URIs
// so the preview never calls the network. Later tasks extend this file.
import type { Person } from "@/lib/auth-client";
import type { DiscoverCategory, DiscoverData, DiscoverExpert, DiscoverMinnect } from "@/lib/discover";
import type { ExpertResult } from "@/lib/experts-search";
/** Fixed "now" so relative times in the preview are stable. */
export const FIXTURE_NOW = Date.parse("2026-10-19T12:00:00Z");
/** A 16:9 gradient placeholder image, drawn with hsl() so no fetch happens. */
export function placeholderImage(hue: number, label = ""): string {
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">` +
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
`<stop offset="0" stop-color="hsl(${hue} 45% 32%)"/><stop offset="1" stop-color="hsl(${(hue + 40) % 360} 40% 14%)"/>` +
`</linearGradient></defs><rect width="1280" height="720" fill="url(#g)"/>` +
(label
? `<text x="64" y="660" font-family="system-ui,sans-serif" font-size="56" font-weight="700" fill="hsl(0 0% 100% / .85)">${label}</text>`
: "") +
`</svg>`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export const FIXTURE_FEED_USER_ID = "dev-user-1";
export const FIXTURE_PERSON: Person = {
name: "Jordan Rivera",
email: "jordan.rivera@example.com",
pictureUrl: null,
feedUserId: FIXTURE_FEED_USER_ID,
};
const author = (id: string, displayName: string, picture: string | null = null) => ({
id,
displayName,
profilePictureUrl: picture,
});
/** Raw feed items in the shape /api/feed returns; run them through toCardPosts. */
export const FIXTURE_FEED_ITEMS: unknown[] = [
{
type: "post",
publishedAt: "2026-10-19T11:42:00Z",
likesCount: 4,
commentsCount: 1,
post: {
id: "p1",
content: "Shipped the new onboarding flow this morning. The best feedback so far came from a five minute call, not a survey.",
author: author(FIXTURE_FEED_USER_ID, "Jordan Rivera"),
mediaItems: [],
},
},
{
type: "post",
publishedAt: "2026-10-19T10:15:00Z",
likesCount: 128,
commentsCount: 23,
isLiked: true,
post: {
id: "p2",
content: "Three questions I ask before every pricing change:\n• Who pays more?\n• Who leaves?\n• What do we learn either way?",
author: author("a2", "Priya Natarajan"),
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(352, "Pricing review"), order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-19T08:30:00Z",
likesCount: 56,
commentsCount: 7,
post: {
id: "p3",
content: "A short walkthrough of how we run weekly reviews with a remote team.",
author: author("a3", "Marcus Bell"),
mediaItems: [{ mediaType: "video", thumbnailUrl: placeholderImage(210, "Weekly reviews"), order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-18T19:05:00Z",
likesCount: 9,
commentsCount: 0,
post: {
id: "p4",
content: "Reading list for anyone moving from engineering into product: start with the customer interviews, then the numbers.",
author: author("a4", "Elena Kovac"),
mediaItems: [],
},
},
{
type: "post",
publishedAt: "2026-10-18T14:40:00Z",
likesCount: 312,
commentsCount: 41,
post: {
id: "p5",
content: "Fundraising tip: send the update even when the month was bad. Especially when the month was bad.",
author: author("a5", "Samuel Okafor"),
mediaItems: [{ mediaType: "image", imageUrl: placeholderImage(30, "Monthly update"), order: 0 }],
},
},
{
type: "post",
publishedAt: "2026-10-17T09:00:00Z",
likesCount: 17,
commentsCount: 3,
post: {
id: "p6",
content: "Your first ten customers will tell you what the product is. Listen before you build the next feature.",
author: author("a6", "Hannah Lindqvist"),
mediaItems: [],
},
},
];
const expert = (id: string, displayName: string, byline: string, rating: number, reviewsCount: number): DiscoverExpert => ({
id,
slug: displayName.replace(/\s+/g, ""),
displayName,
byline,
pictureUrl: null,
rating,
reviewsCount,
available: true,
location: { city: null, country: null },
});
export const FIXTURE_FEATURED: DiscoverExpert[] = [
expert("e1", "Priya Natarajan", "Pricing and growth advisor", 4.9, 212),
expert("e2", "Marcus Bell", "Remote team coach", 4.8, 96),
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
];
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
id,
question,
views,
thumbnailUrl: placeholderImage(hue),
media: null,
expert: { slug: who.slug, displayName: who.displayName, pictureUrl: null, byline: who.byline, rating: who.rating },
});
export const FIXTURE_TRENDING: DiscoverMinnect[] = [
minnect("m1", FIXTURE_FEATURED[0], "How do I raise prices without losing my best customers?", 12840, 352),
minnect("m2", FIXTURE_FEATURED[3], "What should a pre-seed deck show in the first three slides?", 9310, 30),
minnect("m3", FIXTURE_FEATURED[1], "How often should a remote team meet in person?", 6120, 210),
minnect("m4", FIXTURE_FEATURED[2], "When is the right time to hire a first product manager?", 4470, 270),
minnect("m5", FIXTURE_FEATURED[4], "How many customer interviews are enough before building?", 2015, 150),
];
export const FIXTURE_CATEGORIES: DiscoverCategory[] = [
{ id: 1, name: "Business", icon: null, sortOrder: 1 },
{ id: 2, name: "Coaching", icon: null, sortOrder: 2 },
{ id: 3, name: "Finance", icon: null, sortOrder: 3 },
{ id: 4, name: "Product", icon: null, sortOrder: 4 },
{ id: 5, name: "Media", icon: null, sortOrder: 5 },
];
export const FIXTURE_DISCOVER: DiscoverData = {
featuredExperts: FIXTURE_FEATURED,
trendingMinnects: FIXTURE_TRENDING,
categories: FIXTURE_CATEGORIES,
};
/** Find experts results: every featured expert plus a few more, filterable by name. */
export const FIXTURE_SEARCH_RESULTS: ExpertResult[] = [
...FIXTURE_FEATURED,
expert("e6", "Daniel Moreau", "Sales operations", 4.5, 21),
expert("e7", "Aiko Tanaka", "Brand strategy", 4.8, 77),
expert("e8", "Rafael Duarte", "Engineering management", 4.4, 18),
].map((e) => ({
id: e.id,
slug: e.slug,
displayName: e.displayName,
byline: e.byline,
pictureUrl: e.pictureUrl,
rating: e.rating,
categories: [],
}));