Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ada9831
💄 Redesign /products index + third-party brand card tokens
hveraus Aug 13, 2026
8c0f6bc
⚡ perf: shrink the YakShaver gradient asset and match its declared size
hveraus Aug 13, 2026
b6c255c
💄 Refine /products card treatment and fix the no-URL card regression
hveraus Aug 13, 2026
255b83d
📝 Update the TinaCMS product card description
hveraus Aug 13, 2026
bb333ca
Merge remote-tracking branch 'origin/main' into Refine-products-index…
hveraus Aug 13, 2026
8f72845
💄 Add capability tags to /products cards and soften their chrome
hveraus Aug 14, 2026
b1549ea
Merge remote-tracking branch 'origin/main' into Refine-products-index…
Copilot Aug 19, 2026
f6aff09
fix: restore tina-lock.json with tags field lost in merge conflict re…
Copilot Aug 19, 2026
01f7f01
fix: restore events presenter role in tina lock
Copilot Aug 19, 2026
4837779
🐛 fix: regenerate tina-lock.json so its schema matches the source
hveraus Aug 19, 2026
73b0897
📝 Reorder and update /products card tags
hveraus Aug 20, 2026
1c1c1f3
💄 Cap /products cards to two visible tags
hveraus Aug 20, 2026
cb0f27f
🐛 fix: pin Turbopack workspace root for local dev
hveraus Aug 20, 2026
4609e59
📝 Tighten product descriptions and fix Rewards URL
hveraus Aug 20, 2026
2e57dc8
♻️ Address PR review comments on /products
hveraus Aug 20, 2026
ec18b52
Merge remote-tracking branch 'origin/main' into Refine-products-index…
Copilot Aug 20, 2026
fb33bc3
🐛 fix: regenerate tina-lock.json after Copilot's main merge
hveraus Aug 20, 2026
e096cf5
Merge branch 'main' into Refine-products-index-page
hveraus Aug 21, 2026
b725e3b
🔥 Remove "More products coming" placeholder from /products
hveraus Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions __tests__/components/products/productCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { ProductCard } from "@/components/products/productCard";
import { render, screen } from "@testing-library/react";
// This repo leaves setupFilesAfterEnv commented out in jest.config.ts, so the
// jest-dom matchers are not registered globally. Import them here.
import "@testing-library/jest-dom";

// tinacms/dist/react is published as ESM and Jest does not transform
// node_modules, so importing it here fails to parse. These tests pass no
// tinaNode, which means tinaField is never actually called - the mock exists
// only to keep the module graph resolvable.
jest.mock("tinacms/dist/react", () => ({
tinaField: () => undefined,
}));

// Regression guard for the card shell.
//
// CustomLink returns a bare fragment when href is falsy, which drops the element
// carrying the card's chrome classes. Because `url` is optional on productsList
// in tina/collections/products.tsx, an editor can save a product without one,
// and the card's children would then be emitted as siblings - becoming loose
// grid items in the products grid instead of one card. ProductCardShell renders
// a plain div in that case; these tests pin both halves of that behaviour.

const withUrl = {
name: "SugarLearning",
url: "https://sugarlearning.com/",
description: "Induction and onboarding.",
};

const withoutUrl = {
name: "SugarLearning",
description: "Induction and onboarding.",
};

// The chrome that must land on the card's single root element either way.
const SHELL_CLASSES = ["flex", "h-full", "flex-col", "rounded-card"];

describe("ProductCard shell", () => {
test("renders an anchor carrying the card chrome when the product has a url", () => {
const { container } = render(<ProductCard product={withUrl} />);

const root = container.firstElementChild as HTMLElement;
expect(root.tagName).toBe("A");
expect(root).toHaveAttribute("href", withUrl.url);
SHELL_CLASSES.forEach((c) => expect(root).toHaveClass(c));
});

test("still renders ONE root element carrying the card chrome when url is missing", () => {
const { container } = render(<ProductCard product={withoutUrl} />);

// The regression this guards against was the children being emitted as
// siblings, so assert on the child count before anything else.
expect(container.children).toHaveLength(1);

const root = container.firstElementChild as HTMLElement;
expect(root.tagName).toBe("DIV");
expect(root).not.toHaveAttribute("href");
SHELL_CLASSES.forEach((c) => expect(root).toHaveClass(c));
});

test("keeps the name and description inside that root element", () => {
const { container } = render(<ProductCard product={withoutUrl} />);
const root = container.firstElementChild as HTMLElement;

expect(root).toContainElement(
screen.getByRole("heading", { name: withoutUrl.name })
);
expect(root).toContainElement(screen.getByText(withoutUrl.description));
});

test("shows the destination hostname only when there is a url", () => {
const { unmount } = render(<ProductCard product={withUrl} />);
expect(screen.getByText("sugarlearning.com")).toBeInTheDocument();
unmount();

// destinationLabel returns "" for a missing url and the footer only renders
// the label when truthy, so no hostname should appear.
render(<ProductCard product={withoutUrl} />);
expect(screen.queryByText("sugarlearning.com")).toBeNull();
});
});

// Tags come from a Tina `list: true` string field, so the component has to cope
// with the array being absent, holding blanks an editor left behind, or holding
// more entries than a card can show without changing height.
describe("ProductCard tags", () => {
const tagNames = (list: HTMLElement) =>
Array.from(list.querySelectorAll("li")).map((li) => li.textContent);

test("renders each tag as a pill", () => {
render(
<ProductCard
product={{ ...withUrl, tags: ["Onboarding", "Induction"] }}
/>
);

const list = screen.getByRole("list");
expect(tagNames(list)).toEqual(["Onboarding", "Induction"]);

// The chip's own type scale has to land on the element, not just its colour.
// An earlier revision used the custom `text-xxs` key, which tailwind-merge
// silently dropped as a conflict with the text-colour class beside it, so
// the chips rendered at the inherited size; this pins the size class as a
// cheap guard against that class of regression coming back.
expect(list.querySelector("li")).toHaveClass("text-xs");
});

test("renders no list at all when there are no tags", () => {
const { unmount } = render(<ProductCard product={withUrl} />);
expect(screen.queryByRole("list")).toBeNull();
unmount();

// Blank entries are dropped, so a list of only blanks is the same as none -
// an empty <ul> would otherwise add its gap to the card for nothing.
render(<ProductCard product={{ ...withUrl, tags: ["", " "] }} />);
expect(screen.queryByRole("list")).toBeNull();
});

test("shows at most two tags so cards keep a predictable height", () => {
render(
<ProductCard
product={{ ...withUrl, tags: ["One", "Two", "Three", "Four"] }}
/>
);

expect(tagNames(screen.getByRole("list"))).toEqual(["One", "Two"]);
expect(screen.queryByText("Three")).toBeNull();
expect(screen.queryByText("Four")).toBeNull();
});
});
140 changes: 117 additions & 23 deletions app/(about)/products/products-index.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,131 @@
import { PageCard } from "@/components/blocks/pageCards";
import { HomeThemeShell } from "@/components/layout/homeTheme";
import { ProductCard } from "@/components/products/productCard";
import { TinaProductCard } from "@/components/products/tinaProductCard";
import { YakShaverProductCard } from "@/components/products/yakShaverProductCard";
import { Container } from "@/components/util/container";
import { Breadcrumbs } from "app/components/breadcrumb";
import { FC } from "react";
import { tinaField } from "tinacms/dist/react";

type BrandCardProps = {
product: {
name?: string;
url?: string;
description?: string;
tags?: string[];
};
tinaNode?: Record<string, unknown>;
};

// The two products whose owners' media kits require their own card surface,
// keyed by lowercase name so the treatment follows the product if an editor
// reorders the CMS list.
const BRAND_CARD_COMPONENTS: Record<string, FC<BrandCardProps>> = {
tinacms: TinaProductCard,
yakshaver: YakShaverProductCard,
};

const brandCardFor = (name?: string) =>
BRAND_CARD_COMPONENTS[(name ?? "").trim().toLowerCase()];

export default function ProductsIndexContent({ props }) {
const node = props.productsIndex;

// Brand cards are pinned to the front of the render order, independent of
// where the CMS list actually puts them. `.sort` is stable, so this only
// reorders brand cards ahead of standard ones and otherwise preserves the
// CMS order on both sides of that split.
const products = [...(node?.productsList ?? [])].sort(
(a, b) => Number(!!brandCardFor(b?.name)) - Number(!!brandCardFor(a?.name))
);

return (
<>
<Container className="mb-10 flex-1 pt-2">
<Breadcrumbs path={"/products"} title={"Products"} />
{props.productsIndex.title && (
// min-h-screen, not min-h-full: PageLayout's <main> carries an
// unconditional bg-white, so any shortfall would show as a white band
// beneath the themed content in dark mode.
//
// bg-sunken-glow is the same page background /consulting uses, so the two
// index pages match: the sunken surface (#fafafa light, black dark) plus a
// faint red glow bleeding in from the top-right. The flat colour is baked
// into that token as a second gradient layer precisely so this works as a
// single class — pairing a `bg-*` colour with a `bg-*` image in one cn()
// makes tailwind-merge drop the colour.
<HomeThemeShell className="min-h-screen bg-sunken-glow">
{/* Geometry deliberately identical to /consulting's page wrapper
(app/consulting/index.tsx): max-w-8xl, px-6 / max-md:px-3, and the same
vertical padding, so the two index pages line their breadcrumb, title
and content edges up exactly. size/width="custom" switch off
Container's own defaults (max-w-9xl and py-12), which is what differed
before — this page was 3rem wider with different gutters. */}
<Container
size="custom"
width="custom"
padding="px-6 max-md:px-3"
className="max-w-8xl pb-16 pt-4 max-md:pb-12 max-md:pt-3"
>
<div className="min-h-12">
<Breadcrumbs path={"/products"} title={"Products"} />
</div>

{node?.title && (
<h1
props-tina-field={tinaField(props.productsIndex, "title")}
className="mb-0 py-0 text-3xl"
// data-tina-field, not props-tina-field: Tina's visual editing
// looks for the data- attribute, so the previous spelling never
// registered a click target.
data-tina-field={tinaField(node, "title")}
// Type scale and box model both kept in step with /consulting's h1
// (the `headingClass` constant in app/consulting/index.tsx), so the
// two index pages render the title identically.
//
// `m-0 p-0`, not `mb-0 py-0`: styles.css gives every h1 `my-4 pb-5
// pt-15`, and zeroing only the bottom/vertical parts left an 18px
// top margin that /consulting does not have — enough to push this
// title out of alignment with theirs. `leading-tight` is omitted
// because styles.css already applies it to every h1-h5.
//
// `max-md:mt-2` reproduces the 9px that /consulting's title sits
// lower by below md, where its h1 lives inside a sticky chip-row
// wrapper carrying `max-md:pt-2`. There is no chip row here, so the
// offset has to be stated directly to keep the two titles aligned.
className="m-0 p-0 text-xl font-semibold text-foreground max-md:mt-2 max-md:text-lg xl:text-2xl"
>
{props.productsIndex.title}
{node.title}
</h1>
)}
{props.productsIndex.subTitle && (
<h2
props-tina-field={tinaField(props.productsIndex, "subTitle")}
className="mb-4 text-base"
>
{props.productsIndex.subTitle}
</h2>
)}
<div className="flex flex-col md:flex-row">
<div className="grid w-full grid-cols-1 gap-2 lg:grid-cols-2">
{props.productsIndex.productsList?.map((product, index) => (
<PageCard page={product} key={index} />
))}
</div>
<div
// grid-cols-N in Tailwind is already repeat(N, minmax(0, 1fr)), which
// is what keeps a wide logo from blowing out a track.
//
// Breakpoints: the design asks for 4-up at 1240px and 2-up at 760px.
// Those aren't breakpoints this theme defines, so this uses the
// nearest ones it does — xl (1280) and md (768) — rather than adding
// two one-off screens to the config.
//
// mt-8 replaces the gap the subtitle's own bottom margin used to
// provide, so the grid doesn't butt up against the title.
className="mt-8 grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 xl:grid-cols-4"
>
{products.map((product, index) => {
const key = `${product?.name ?? "product"}-${index}`;
const BrandCard = brandCardFor(product?.name);

// Brand cards span two columns from md up. The base tier stays at
// span 1: a col-span-2 in the single-column grid would add an
// implicit second column and cause horizontal scroll.
if (BrandCard) {
return (
<div key={key} className="md:col-span-2">
<BrandCard product={product} tinaNode={product} />
</div>
);
}

return (
<ProductCard key={key} product={product} tinaNode={product} />
);
})}
</div>
</Container>
</>
</HomeThemeShell>
);
}
2 changes: 1 addition & 1 deletion components/layout/homeTheme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const DEFAULT_THEME: HomeThemeMode = "dark";
// Single source of truth for which routes opt into home theming — read by
// isThemedRoute below, baked into PRE_PAINT_SCRIPT, and re-exported for
// MegaMenuWrapper, so the list only needs updating in one place.
export const THEMED_ROUTES = ["/", "/consulting"];
export const THEMED_ROUTES = ["/", "/consulting", "/products"];

export const isThemedRoute = (pathname: string) =>
THEMED_ROUTES.includes(pathname);
Expand Down
Loading
Loading