Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
36ac682
feat(explore): Medium-style explore topics surface
tsahimatsliah Jun 3, 2026
2a0a7dc
feat(explore): tier-1 UX polish — search, a11y, canonical, skeletons
tsahimatsliah Jun 3, 2026
aa8315e
refactor(explore): centered editorial layout, glossary-hub style
tsahimatsliah Jun 3, 2026
0e3667a
refactor(explore): flat trending/popular/recent lists above the direc…
tsahimatsliah Jun 3, 2026
1a450ec
feat(explore): alphabetical A–Z directory with a letter filter
tsahimatsliah Jun 3, 2026
04d51b8
feat(explore): per-letter limits, letter-only view, Tag label
tsahimatsliah Jun 3, 2026
01d9683
feat(explore): full-width topic content + polished entity cards
tsahimatsliah Jun 3, 2026
f654238
feat(explore): unified Explore top nav across lobby and topic pages
tsahimatsliah Jun 3, 2026
36bf9c8
feat(explore): /tags canonical URL, richer cards, rail fades, alignment
tsahimatsliah Jun 3, 2026
046ea09
feat(explore): signup conversion banner on topic pages
tsahimatsliah Jun 3, 2026
3dc92eb
feat(explore): unified Explore hub header across discovery surfaces
tsahimatsliah Jun 3, 2026
028cd30
feat(explore): lobby follow controls, live search, canonical cards, h…
tsahimatsliah Jun 3, 2026
e368c5a
revert(explore): drop the unified hub header, keep surfaces separate
tsahimatsliah Jun 3, 2026
8a3e08f
feat(explore): size who-to-follow & sources cards like feed grid cards
tsahimatsliah Jun 3, 2026
6d29c93
feat(explore): tabbed page header matching the Squad directory
tsahimatsliah Jun 3, 2026
d37e98a
fix(explore): brand-color follow check, drop emojis, full-width topic…
tsahimatsliah Jun 3, 2026
132519b
fix(explore): center the topic page hero cover
tsahimatsliah Jun 3, 2026
9df8a9c
fix(explore): drop the letter-heading rule when filtered to one letter
tsahimatsliah Jun 3, 2026
ae9fec3
fix(explore): reword copy from "topics" to "tags"
tsahimatsliah Jun 3, 2026
2646f08
refactor(explore): simplify the tag-page signup banner
tsahimatsliah Jun 3, 2026
7b12d0c
feat(explore): signup-first auth hero above the tag page
tsahimatsliah Jun 3, 2026
1c9de62
feat(explore): compact feed-native signup CTA (replaces auth hero)
tsahimatsliah Jun 4, 2026
bba8339
feat(explore): branded, value-led signup CTA with social proof
tsahimatsliah Jun 4, 2026
f36b184
feat(explore): explicit, contained signup card (ExploreSignupCard)
tsahimatsliah Jun 4, 2026
59444bb
refactor(explore): compact signup strip (title + one line)
tsahimatsliah Jun 4, 2026
3fd0475
refactor(explore): move signup strip under actions, roadmap into a se…
tsahimatsliah Jun 4, 2026
4bd2bf9
refactor(explore): cap header tags at 5
tsahimatsliah Jun 4, 2026
6d14401
feat(explore): auth banner at the bottom of the tags lobby
tsahimatsliah Jun 4, 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
81 changes: 81 additions & 0 deletions packages/shared/src/components/explore/ExploreCategorySection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type { ReactElement } from 'react';
import React, { useState } from 'react';
import classNames from 'classnames';
import type { TagCategory } from '../../graphql/feedSettings';
import { ExploreTagListItem } from './ExploreTagListItem';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../typography/Typography';
import { ClickableText } from '../buttons/ClickableText';

const COLLAPSED_COUNT = 8;

interface ExploreCategorySectionProps {
category: TagCategory;
followedTags: Set<string>;
onToggleFollow: (tag: string) => void;
className?: string;
}

// One column block in the Explore directory: an emoji + title heading above a
// vertical list of topic rows with follow controls.
export function ExploreCategorySection({
category,
followedTags,
onToggleFollow,
className,
}: ExploreCategorySectionProps): ReactElement | null {
const [expanded, setExpanded] = useState(false);

if (!category.tags?.length) {
return null;
}

const visibleTags = expanded
? category.tags
: category.tags.slice(0, COLLAPSED_COUNT);
const hasMore = category.tags.length > COLLAPSED_COUNT;

return (
<section
id={`explore-category-${category.id}`}
className={classNames(
'mb-8 flex scroll-mt-24 break-inside-avoid flex-col gap-3',
className,
)}
>
<Typography
tag={TypographyTag.H2}
type={TypographyType.Title3}
color={TypographyColor.Primary}
bold
>
{category.emoji ? `${category.emoji} ` : ''}
{category.title}
</Typography>
<ul className="flex flex-col">
{visibleTags.map((tag) => (
<ExploreTagListItem
key={tag}
tag={tag}
isFollowed={followedTags.has(tag)}
onToggleFollow={onToggleFollow}
/>
))}
</ul>
{hasMore && (
<ClickableText
tag="button"
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="w-fit"
>
{expanded ? 'Show less' : 'More'}
</ClickableText>
)}
</section>
);
}
41 changes: 41 additions & 0 deletions packages/shared/src/components/explore/ExploreHeader.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { TestBootProvider } from '../../../__tests__/helpers/boot';
import { ExploreHeader } from './ExploreHeader';

const renderComponent = (props: Partial<{ activeTag: string }> = {}) =>
render(
<TestBootProvider client={new QueryClient()}>
<ExploreHeader recommendedTags={['vue', 'react']} {...props} />
</TestBootProvider>,
);

describe('ExploreHeader', () => {
it('renders an Explore tab linking back to the lobby', () => {
renderComponent();

expect(screen.getByText('Explore').closest('a')).toHaveAttribute(
'href',
expect.stringContaining('tags'),
);
});

it('renders a tab per recommended topic', () => {
renderComponent();

expect(screen.getByText('#vue').closest('a')).toHaveAttribute(
'href',
expect.stringContaining('tags/vue'),
);
});

it('marks the active topic tab as current', () => {
renderComponent({ activeTag: 'react' });

expect(screen.getByText('#react').closest('a')).toHaveAttribute(
'aria-current',
'page',
);
});
});
79 changes: 79 additions & 0 deletions packages/shared/src/components/explore/ExploreHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { ReactElement } from 'react';
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { ButtonSize } from '../buttons/Button';
import { pageHeaderClassName } from '../layout/PageHeader';
import {
SquadDirectoryNavbar,
SquadDirectoryNavbarItem,
} from '../squads/layout/SquadDirectoryNavbar';
import useFeedSettings from '../../hooks/useFeedSettings';
import { getTagPageLink } from '../../lib/links';
import { webappUrl } from '../../lib/constants';

interface ExploreHeaderProps {
// The tag currently being viewed (topic page) or undefined on the lobby.
activeTag?: string;
// Related / recommended tag names to surface after the followed ones.
recommendedTags?: string[];
className?: string;
}

const exploreUrl = `${webappUrl}tags`;
// Keep the header short — show a handful of relevant tabs rather than a long
// horizontally-scrolling list.
const MAX_TAGS = 5;

// The Explore page header, built with the same tabbed page-header strip as the
// Squad directory (pageHeaderClassName + SquadDirectoryNavbar). An "Explore"
// tab returns to the lobby, followed by the user's tags and recommendations;
// the current topic is the active tab. Shared by the lobby and topic pages.
export function ExploreHeader({
activeTag,
recommendedTags = [],
className,
}: ExploreHeaderProps): ReactElement {
const { feedSettings } = useFeedSettings();

const tags = useMemo(() => {
const followed = feedSettings?.includeTags ?? [];
const followedSet = new Set(followed);
const ordered =
activeTag && !followedSet.has(activeTag)
? [activeTag, ...followed]
: followed;
const rec = recommendedTags.filter(
(tag) => tag && !followedSet.has(tag) && tag !== activeTag,
);
return Array.from(new Set([...ordered, ...rec])).slice(0, MAX_TAGS);
}, [feedSettings?.includeTags, recommendedTags, activeTag]);

return (
<header
className={classNames(pageHeaderClassName, 'gap-4 !py-0', className)}
>
<SquadDirectoryNavbar
aria-label="Explore navigation"
className="!mx-0 min-w-0 flex-1 !border-0 !px-0"
>
<SquadDirectoryNavbarItem
buttonSize={ButtonSize.Small}
isActive={!activeTag}
label="Explore"
path={exploreUrl}
ariaLabel="Explore tags"
/>
{tags.map((tag) => (
<SquadDirectoryNavbarItem
key={tag}
buttonSize={ButtonSize.Small}
isActive={tag === activeTag}
label={`#${tag}`}
path={getTagPageLink(tag)}
ariaLabel={`#${tag}`}
/>
))}
</SquadDirectoryNavbar>
</header>
);
}
78 changes: 78 additions & 0 deletions packages/shared/src/components/explore/ExploreSignupCard.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { useAuthContext } from '../../contexts/AuthContext';
import { useLogContext } from '../../contexts/LogContext';
import { AuthTriggers } from '../../lib/auth';
import { LogEvent, TargetType } from '../../lib/log';
import { ExploreSignupCard } from './ExploreSignupCard';

jest.mock('../../contexts/AuthContext', () => ({
useAuthContext: jest.fn(),
}));
jest.mock('../../contexts/LogContext', () => ({
useLogContext: jest.fn(),
}));

const showLogin = jest.fn();
const logEvent = jest.fn();

const mockAuth = (overrides: Record<string, unknown> = {}) =>
(useAuthContext as jest.Mock).mockReturnValue({
isAuthReady: true,
isLoggedIn: false,
showLogin,
...overrides,
});

beforeEach(() => {
jest.clearAllMocks();
(useLogContext as jest.Mock).mockReturnValue({ logEvent });
});

describe('ExploreSignupCard', () => {
it('makes the tag value explicit and logs a tag_page impression', () => {
mockAuth();
render(<ExploreSignupCard tag="claude" />);

expect(
screen.getByRole('heading', { name: /Get every new #claude post/ }),
).toBeInTheDocument();
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.Impression,
target_type: TargetType.SignupButton,
target_id: 'tag_page',
});
});

it('uses lobby copy and a tags_lobby target when no tag is given', () => {
mockAuth();
render(<ExploreSignupCard />);

expect(
screen.getByRole('heading', { name: /Your personalized developer feed/ }),
).toBeInTheDocument();
expect(logEvent).toHaveBeenCalledWith({
event_name: LogEvent.Impression,
target_type: TargetType.SignupButton,
target_id: 'tags_lobby',
});
});

it('opens registration on signup click', () => {
mockAuth();
render(<ExploreSignupCard tag="claude" />);

fireEvent.click(screen.getByRole('button', { name: /Sign up/ }));
expect(showLogin).toHaveBeenCalledWith({
trigger: AuthTriggers.Onboarding,
options: { isLogin: false },
});
});

it('renders nothing for logged-in users', () => {
mockAuth({ isLoggedIn: true });
const { container } = render(<ExploreSignupCard tag="claude" />);

expect(container).toBeEmptyDOMElement();
});
});
Loading
Loading