diff --git a/packages/shared/src/components/charm/CharmEmptyState.tsx b/packages/shared/src/components/charm/CharmEmptyState.tsx index 3ce35799767..12b2d024c65 100644 --- a/packages/shared/src/components/charm/CharmEmptyState.tsx +++ b/packages/shared/src/components/charm/CharmEmptyState.tsx @@ -15,6 +15,7 @@ import Link from '../utilities/Link'; interface CharmEmptyStateActionBase { label: string; icon?: ButtonProps<'button'>['icon']; + loading?: boolean; } /** Exactly one of `href` (renders a link) or `onClick` (renders a button). */ @@ -102,6 +103,7 @@ export function CharmEmptyState({ variant={ButtonVariant.Primary} size={ButtonSize.Medium} icon={action.icon} + loading={action.loading} onClick={action.onClick} className="mt-5" > diff --git a/packages/shared/src/components/tools/ToolLogo.tsx b/packages/shared/src/components/tools/ToolLogo.tsx index 9f94cb7e2eb..83b2ab8ec2f 100644 --- a/packages/shared/src/components/tools/ToolLogo.tsx +++ b/packages/shared/src/components/tools/ToolLogo.tsx @@ -11,8 +11,14 @@ interface ToolLogoProps { url?: string | null; /** Pixel size to request from the icon service. */ size?: number; - /** Styles the tile: size, radius, background, border, padding. */ + /** Styles the tile: size, radius, border. */ className?: string; + /** + * Plate styles (background, inset) applied only while a real logo renders. + * The initial fallback stays on the ambient surface so its token color + * keeps contrast in both themes. + */ + plateClassName?: string; /** Replaces the initial when there is no logo to show. */ fallback?: ReactNode; } @@ -26,12 +32,14 @@ export const ToolLogo = ({ url, size, className, + plateClassName, fallback, }: ToolLogoProps): ReactElement => { const [hasFailed, setHasFailed] = useState(false); const src = faviconUrl || (url ? getSiteIconUrl({ url, size }) : null); + const showsLogo = !!src && !hasFailed; - if (fallback && (!src || hasFailed)) { + if (fallback && !showsLogo) { return <>{fallback}; } @@ -40,9 +48,10 @@ export const ToolLogo = ({ className={classNames( 'grid flex-none place-items-center overflow-hidden', className, + showsLogo && plateClassName, )} > - {!src || hasFailed ? ( + {!showsLogo ? ( {title.charAt(0).toUpperCase()} diff --git a/packages/shared/src/features/profile/components/stack/UserStackItem.tsx b/packages/shared/src/features/profile/components/stack/UserStackItem.tsx index 956b4e45b7a..251ed55b849 100644 --- a/packages/shared/src/features/profile/components/stack/UserStackItem.tsx +++ b/packages/shared/src/features/profile/components/stack/UserStackItem.tsx @@ -176,8 +176,11 @@ function UserStackItemBody({ url={sponsoredCreative ? null : tool.url} className={classNames( 'size-6 typo-footnote', - sponsoredCreative ? 'rounded-full bg-white p-0.5' : 'rounded', + sponsoredCreative ? 'rounded-full' : 'rounded', )} + plateClassName={ + sponsoredCreative ? 'bg-white p-0.5' : undefined + } /> {!!title && (
diff --git a/packages/shared/src/graphql/tools.ts b/packages/shared/src/graphql/tools.ts index 672f315ffe3..53cb7418af5 100644 --- a/packages/shared/src/graphql/tools.ts +++ b/packages/shared/src/graphql/tools.ts @@ -396,9 +396,19 @@ export interface DirectoryTool { stackCount: number; } -const TOP_TOOLS_QUERY = gql` - query TopTools($first: Int, $category: String, $trending: Boolean) { - topTools(first: $first, category: $category, trending: $trending) { +export const TOP_TOOLS_QUERY = gql` + query TopTools( + $first: Int + $category: String + $trending: Boolean + $query: String + ) { + topTools( + first: $first + category: $category + trending: $trending + query: $query + ) { id title slug @@ -414,14 +424,16 @@ export const getTopTools = async ({ first = 6, category, trending, + query, }: { first?: number; category?: string; trending?: boolean; + query?: string; } = {}): Promise => { const result = await gqlClient.request<{ topTools: DirectoryTool[] }>( TOP_TOOLS_QUERY, - { first, category, trending }, + { first, category, trending, query }, ); return result.topTools; }; @@ -431,7 +443,7 @@ export interface ToolCategoryStat { toolCount: number; } -const TOOL_CATEGORIES_QUERY = gql` +export const TOOL_CATEGORIES_QUERY = gql` query ToolCategories { toolCategories { category diff --git a/packages/shared/src/graphql/user/userStack.ts b/packages/shared/src/graphql/user/userStack.ts index a411bd86d9d..60f1e9c67be 100644 --- a/packages/shared/src/graphql/user/userStack.ts +++ b/packages/shared/src/graphql/user/userStack.ts @@ -22,6 +22,7 @@ export interface ToolTopSquad { name: string; handle: string; image: string; + description: string | null; membersCount: number; } @@ -151,6 +152,7 @@ const TOP_SQUADS_FOR_TOOL_QUERY = gql` name handle image + description membersCount } } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index cf05154e6a2..01756f6f551 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -138,6 +138,7 @@ export enum LogEvent { Impression = 'impression', ManageTags = 'click manage tags', SearchTags = 'search tags', + SearchTools = 'search tools', ClickFeedTagChip = 'click feed tag chip', ClickOnboardingBack = 'click onboarding back', ClickOnboardingNext = 'click onboarding next', diff --git a/packages/webapp/__tests__/ToolPage.spec.tsx b/packages/webapp/__tests__/ToolPage.spec.tsx index 19f3fc2e304..cfd0e45e823 100644 --- a/packages/webapp/__tests__/ToolPage.spec.tsx +++ b/packages/webapp/__tests__/ToolPage.spec.tsx @@ -5,10 +5,13 @@ import type { RenderResult } from '@testing-library/react'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import type { LoggedUser } from '@dailydotdev/shared/src/lib/user'; +import loggedUser from '@dailydotdev/shared/__tests__/fixture/loggedUser'; import SettingsContext from '@dailydotdev/shared/src/contexts/SettingsContext'; import { settingsContext } from '@dailydotdev/shared/__tests__/helpers/boot'; import { SourceType } from '@dailydotdev/shared/src/graphql/sources'; import { apiUrl } from '@dailydotdev/shared/src/lib/config'; +import { formatDataTileValue } from '@dailydotdev/shared/src/lib/numberFormat'; import type { ToolPageProps } from '../pages/tools/[slug]'; import ToolPage from '../pages/tools/[slug]'; @@ -57,6 +60,7 @@ const defaultProps: ToolPageProps = { name: 'Platform crew', handle: 'platform', image: 'https://daily.dev/squad.png', + description: null, membersCount: 42, }, ], @@ -124,12 +128,16 @@ const defaultProps: ToolPageProps = { facts: [], }; -const renderComponent = (props: ToolPageProps = defaultProps): RenderResult => +const renderComponent = ( + props: ToolPageProps = defaultProps, + user?: LoggedUser, +): RenderResult => render( { renderComponent(); expect(await screen.findByText('In stacks')).toBeInTheDocument(); - expect(screen.getByText('1,200')).toBeInTheDocument(); + expect(screen.getByText(formatDataTileValue(1200))).toBeInTheDocument(); expect(screen.getByText('Dev sentiment')).toBeInTheDocument(); expect(screen.getByText('80%')).toBeInTheDocument(); expect(screen.getByText('Top 3%')).toBeInTheDocument(); @@ -201,6 +209,7 @@ it('should skip sections without data', async () => { const headings = await screen.findAllByRole('heading', { level: 2 }); expect(headings.map((heading) => heading.textContent)).toEqual([ + 'Adoption on daily.dev', 'Discussion', ]); }); @@ -218,3 +227,45 @@ it('should show the real logo for tools without one in the dataset', async () => 'https://daily.dev/docker.png', ); }); + +it('should show the discussion empty state when nobody commented yet', async () => { + renderComponent(); + + expect(await screen.findByText('No comments yet')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Start the discussion' }), + ).toBeInTheDocument(); +}); + +it('should hold the discussion behind the verification check when logged in', async () => { + renderComponent(defaultProps, loggedUser); + + await screen.findByRole('heading', { level: 1, name: 'Docker' }); + + // The companies query never resolves here, so the placeholder wins. + expect(screen.queryByText('No comments yet')).not.toBeInTheDocument(); +}); + +it('should render squads with the directory card details', async () => { + const props: ToolPageProps = { + ...defaultProps, + topSquads: [ + { + id: 's1', + name: 'Platform crew', + handle: 'platform', + image: 'https://daily.dev/squad.png', + description: 'Where the platform folks hang out.', + membersCount: 42, + }, + ], + }; + renderComponent(props); + + expect(await screen.findByText('Platform crew')).toBeInTheDocument(); + expect( + screen.getByText('Where the platform folks hang out.'), + ).toBeInTheDocument(); + expect(screen.getByText(/@platform/)).toBeInTheDocument(); + expect(screen.getByText('42 members')).toBeInTheDocument(); +}); diff --git a/packages/webapp/__tests__/ToolsDirectoryPage.spec.tsx b/packages/webapp/__tests__/ToolsDirectoryPage.spec.tsx new file mode 100644 index 00000000000..ceafac3d604 --- /dev/null +++ b/packages/webapp/__tests__/ToolsDirectoryPage.spec.tsx @@ -0,0 +1,263 @@ +import React from 'react'; +import nock from 'nock'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; +import SettingsContext from '@dailydotdev/shared/src/contexts/SettingsContext'; +import { settingsContext } from '@dailydotdev/shared/__tests__/helpers/boot'; +import { mockGraphQL } from '@dailydotdev/shared/__tests__/helpers/graphql'; +import type { DirectoryTool } from '@dailydotdev/shared/src/graphql/tools'; +import { + TOOL_CATEGORIES_QUERY, + TOP_TOOLS_QUERY, +} from '@dailydotdev/shared/src/graphql/tools'; +import ToolsDirectoryPage, { getStaticProps } from '../pages/tools/index'; + +jest.mock('next/router', () => ({ + useRouter: jest.fn().mockImplementation(() => ({ + isFallback: false, + pathname: '/tools', + query: {}, + })), +})); + +beforeEach(() => { + nock.cleanAll(); + jest.clearAllMocks(); +}); + +const tool = ( + id: string, + title: string, + category: string | null, + url: string, +): DirectoryTool => ({ + id, + title, + slug: id, + faviconUrl: null, + url, + category, + stackCount: 100, +}); + +const tools: DirectoryTool[] = [ + tool('react', 'React', 'Frameworks', 'https://react.dev'), + tool('postgresql', 'PostgreSQL', 'Databases', 'https://www.postgresql.org'), + tool('mongodb', 'MongoDB', 'Databases', 'https://www.mongodb.com'), +]; + +const defaultProps = { + tools, + trendingIds: ['react'], + sections: [ + { category: 'Frameworks', toolIds: ['react'] }, + { category: 'Databases', toolIds: ['postgresql', 'mongodb'] }, + ], + fallbackTopIds: [], +}; + +const renderComponent = ( + props: typeof defaultProps = defaultProps, +): RenderResult => + render( + + + + + + + , + ); + +const searchFor = (query: string): void => { + fireEvent.input(screen.getByLabelText('Search all tools'), { + target: { value: query }, + }); +}; + +const mockSearch = (query: string, result: DirectoryTool[]): void => { + mockGraphQL({ + request: { + query: TOP_TOOLS_QUERY, + variables: { first: 100, query }, + }, + result: { data: { topTools: result } }, + }); +}; + +it('should render the category sections until a search starts', async () => { + renderComponent(); + + expect( + await screen.findByRole('heading', { level: 2, name: 'Frameworks' }), + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { level: 2, name: 'Databases' }), + ).toBeInTheDocument(); + + mockSearch('postgres', [ + tool('postgresql', 'PostgreSQL', 'Databases', 'https://www.postgresql.org'), + ]); + searchFor('postgres'); + await screen.findByText('Results for “postgres”'); + + expect(screen.queryByText('Rising this quarter')).not.toBeInTheDocument(); + expect(await screen.findByText('PostgreSQL')).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'React, 100 in stacks' }), + ).not.toBeInTheDocument(); +}); + +it('should surface tools from the API beyond the build-time list', async () => { + renderComponent(); + + mockSearch('grafana', [ + tool('grafana', 'Grafana', null, 'https://grafana.com'), + ]); + searchFor('grafana'); + + expect(await screen.findByText('Grafana')).toBeInTheDocument(); +}); + +it('should show the no-results state when the search API errors', async () => { + renderComponent(); + + mockGraphQL({ + request: { + query: TOP_TOOLS_QUERY, + variables: { first: 100, query: 'react' }, + }, + result: () => ({ errors: [{ message: 'search unavailable' }] }), + }); + searchFor('react'); + + expect(await screen.findByText('No tools match “react”')).toBeInTheDocument(); +}); + +it('should offer to clear a search with no matches', async () => { + renderComponent(); + + mockSearch('zzzzz', []); + searchFor('zzzzz'); + expect(await screen.findByText('No tools match “zzzzz”')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })); + + await waitFor(() => + expect( + screen.queryByText('No tools match “zzzzz”'), + ).not.toBeInTheDocument(), + ); + expect(screen.getByText('Rising this quarter')).toBeInTheDocument(); + expect(screen.getByLabelText('Search all tools')).toHaveValue(''); +}); + +it('should expand a category section past the first six tools', async () => { + const frameworkTools = Array.from({ length: 8 }, (_, index) => + tool(`tool-${index}`, `Tool ${index}`, 'Frameworks', 'https://example.com'), + ); + renderComponent({ + tools: frameworkTools, + trendingIds: [], + sections: [ + { category: 'Frameworks', toolIds: frameworkTools.map(({ id }) => id) }, + ], + fallbackTopIds: [], + }); + + await screen.findByRole('heading', { level: 2, name: 'Frameworks' }); + expect(screen.getByText('Tool 5')).toBeInTheDocument(); + expect(screen.queryByText('Tool 6')).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Show all 8 Frameworks tools' }), + ); + + expect(screen.getByText('Tool 7')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Show all 8 Frameworks tools' }), + ).not.toBeInTheDocument(); +}); + +it('should collect uncategorized tools into an Other section below the categories', async () => { + mockGraphQL({ + request: { query: TOOL_CATEGORIES_QUERY }, + result: { + data: { toolCategories: [{ category: 'Databases', toolCount: 1 }] }, + }, + }); + mockGraphQL({ + request: { + query: TOP_TOOLS_QUERY, + variables: { first: 6, trending: true }, + }, + result: { data: { topTools: [] } }, + }); + mockGraphQL({ + request: { query: TOP_TOOLS_QUERY, variables: { first: 100 } }, + result: { + data: { + topTools: [ + tool( + 'postgresql', + 'PostgreSQL', + 'Databases', + 'https://www.postgresql.org', + ), + tool('nextjs', 'Next.js', null, 'https://nextjs.org'), + tool('vite', 'Vite', null, 'https://vitejs.dev'), + ], + }, + }, + }); + mockGraphQL({ + request: { + query: TOP_TOOLS_QUERY, + variables: { first: 100, category: 'Databases' }, + }, + result: { + data: { + topTools: [ + tool( + 'postgresql', + 'PostgreSQL', + 'Databases', + 'https://www.postgresql.org', + ), + ], + }, + }, + }); + + const result = (await getStaticProps()) as { + props: { sections: { category: string; toolIds: string[] }[] }; + }; + + expect(result.props.sections.map(({ category }) => category)).toEqual([ + 'Databases', + 'Other', + ]); + expect( + result.props.sections.find(({ category }) => category === 'Other')?.toolIds, + ).toEqual(['nextjs', 'vite']); +}); diff --git a/packages/webapp/components/tools/ToolCard.tsx b/packages/webapp/components/tools/ToolCard.tsx index 3cfd5d856c8..55a15390092 100644 --- a/packages/webapp/components/tools/ToolCard.tsx +++ b/packages/webapp/components/tools/ToolCard.tsx @@ -1,11 +1,19 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { Typography, TypographyColor, TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PlusIcon, VIcon } from '@dailydotdev/shared/src/components/icons'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; import { ToolLogo } from '@dailydotdev/shared/src/components/tools/ToolLogo'; @@ -21,22 +29,37 @@ export interface ToolCardTool { interface ToolCardProps { tool: ToolCardTool; onClick?: () => void; + isInStack?: boolean; + onAddToStack?: (tool: ToolCardTool) => void; } -export const ToolCard = ({ tool, onClick }: ToolCardProps): ReactElement => ( - - +// The anchor stretches over the card; the stack button is a sibling above it. +export const ToolCard = ({ + tool, + onClick, + isInStack = false, + onAddToStack, +}: ToolCardProps): ReactElement => { + const formattedCount = largeNumberFormat(tool.stackCount) ?? tool.stackCount; + + return ( +
+ + + - + {tool.title} @@ -44,9 +67,36 @@ export const ToolCard = ({ tool, onClick }: ToolCardProps): ReactElement => ( type={TypographyType.Footnote} color={TypographyColor.Tertiary} > - {largeNumberFormat(tool.stackCount) ?? tool.stackCount} in stacks + {formattedCount} in stacks - - -); + {onAddToStack && ( + +
+ ); +}; diff --git a/packages/webapp/components/tools/ToolDirectorySearch.tsx b/packages/webapp/components/tools/ToolDirectorySearch.tsx new file mode 100644 index 00000000000..3f5f78f1ade --- /dev/null +++ b/packages/webapp/components/tools/ToolDirectorySearch.tsx @@ -0,0 +1,74 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { SearchField } from '@dailydotdev/shared/src/components/fields/SearchField'; +import useDebounceFn from '@dailydotdev/shared/src/hooks/useDebounceFn'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import type { ToolCardTool } from './ToolCard'; + +interface ToolDirectorySearchProps { + value: string; + onValueChange: (value: string) => void; + onQueryChange: (query: string) => void; + recommendedTools?: ToolCardTool[]; + className?: string; +} + +export function ToolDirectorySearch({ + value, + onValueChange, + onQueryChange, + recommendedTools = [], + className, +}: ToolDirectorySearchProps): ReactElement { + const [debouncedReport] = useDebounceFn((next?: string) => { + onQueryChange((next ?? '').trim()); + }, 150); + + const handleChange = (next: string): void => { + onValueChange(next); + debouncedReport(next); + }; + + return ( +
+ + {!value && recommendedTools.length > 0 && ( +
+ + Recommended: + + {recommendedTools.map((tool) => ( + + + {tool.title} + + + ))} +
+ )} +
+ ); +} diff --git a/packages/webapp/components/tools/ToolDiscussion.tsx b/packages/webapp/components/tools/ToolDiscussion.tsx index 97dfa8d6cfd..7cd5dc5e76c 100644 --- a/packages/webapp/components/tools/ToolDiscussion.tsx +++ b/packages/webapp/components/tools/ToolDiscussion.tsx @@ -25,6 +25,9 @@ import { TypographyType, } from '@dailydotdev/shared/src/components/typography/Typography'; import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; +import { CharmEmptyState } from '@dailydotdev/shared/src/components/charm/CharmEmptyState'; +import { cloudinaryCharmNoComments } from '@dailydotdev/shared/src/lib/image'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons'; const CommentInputOrModal = dynamic( () => @@ -66,7 +69,7 @@ export const ToolDiscussion = ({ toolTitle, discussionPostId, }: ToolDiscussionProps): ReactElement => { - const { user, showLogin } = useAuthContext(); + const { isLoggedIn, showLogin } = useAuthContext(); const { isVerified, isLoading: isCompaniesLoading } = useUserCompaniesQuery(); const { displayToast } = useToastNotification(); const commentRef = useRef(null); @@ -106,16 +109,20 @@ export const ToolDiscussion = ({ // anyway, so the composer is replaced before the user ever gets there. // While the companies query is still resolving, treat replies as blocked // too rather than briefly allowing a composer that then gets pulled away. - const isCheckingVerification = !!user && isCompaniesLoading; - const isGated = !!user && !isCompaniesLoading && !isVerified; - const canReply = !user || (!isCompaniesLoading && isVerified); + const isCheckingVerification = isLoggedIn && isCompaniesLoading; + const isGated = isLoggedIn && !isCompaniesLoading && !isVerified; + const canReply = !isLoggedIn || (!isCompaniesLoading && isVerified); const handleReplyBlocked = useCallback(() => { displayToast(VERIFIED_GATE_MESSAGE); }, [displayToast]); const handleStart = (): void => { - if (!user) { + if (isStarting) { + return; + } + + if (!isLoggedIn) { showLogin({ trigger: AuthTriggers.Comment }); return; } @@ -168,18 +175,34 @@ export const ToolDiscussion = ({ return ; } + const emptyState = ( + , + loading: isStarting, + onClick: handleStart, + } + } + /> + ); + if (isGated) { - return ; + return ( +
+ {emptyState} + +
+ ); } - return ( - - ); + return emptyState; }; diff --git a/packages/webapp/components/tools/ToolSection.tsx b/packages/webapp/components/tools/ToolSection.tsx index 1a1d479488b..9bf1c278464 100644 --- a/packages/webapp/components/tools/ToolSection.tsx +++ b/packages/webapp/components/tools/ToolSection.tsx @@ -9,9 +9,6 @@ interface ToolSectionProps { id?: string; } -// Flat section block used across the tools pages: a heading (plus an optional -// trailing action) above its content, with the divider coming from the parent -// `divide-y` stack rather than a card border. export const ToolSection = ({ title, action, @@ -19,10 +16,11 @@ export const ToolSection = ({ id, }: ToolSectionProps): ReactElement => (
-
+
{title} +
{action}
{children} diff --git a/packages/webapp/components/tools/ToolSquadCard.tsx b/packages/webapp/components/tools/ToolSquadCard.tsx new file mode 100644 index 00000000000..4b0cd3c0cb9 --- /dev/null +++ b/packages/webapp/components/tools/ToolSquadCard.tsx @@ -0,0 +1,68 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import Link from '@dailydotdev/shared/src/components/utilities/Link'; +import { + Image, + ImageType, +} from '@dailydotdev/shared/src/components/image/Image'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '@dailydotdev/shared/src/components/typography/Typography'; +import { Separator } from '@dailydotdev/shared/src/components/cards/common/common'; +import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; +import type { ToolTopSquad } from '@dailydotdev/shared/src/graphql/user/userStack'; + +interface ToolSquadCardProps { + squad: ToolTopSquad; + onClick?: () => void; +} + +export const ToolSquadCard = ({ + squad, + onClick, +}: ToolSquadCardProps): ReactElement => ( + + + {`${squad.name} + + {squad.name} + + {squad.description && ( + + {squad.description} + + )} + + @{squad.handle} + {largeNumberFormat(squad.membersCount)} members + + + +); diff --git a/packages/webapp/components/tools/useAddToolToStack.tsx b/packages/webapp/components/tools/useAddToolToStack.tsx new file mode 100644 index 00000000000..a4fa2a16ebb --- /dev/null +++ b/packages/webapp/components/tools/useAddToolToStack.tsx @@ -0,0 +1,80 @@ +import type { ReactElement } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; +import type { PublicProfile } from '@dailydotdev/shared/src/lib/user'; +import { useUserStack } from '@dailydotdev/shared/src/features/profile/hooks/useUserStack'; +import { UserStackModal } from '@dailydotdev/shared/src/features/profile/components/stack/UserStackModal'; +import type { AddUserStackInput } from '@dailydotdev/shared/src/graphql/user/userStack'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; +import type { Origin } from '@dailydotdev/shared/src/lib/log'; +import { LogEvent } from '@dailydotdev/shared/src/lib/log'; + +export interface StackableTool { + id: string; + title: string; + slug: string; +} + +interface UseAddToolToStack { + stackedToolIds: Set; + openAddModal: (tool: StackableTool) => void; + modal: ReactElement | null; +} + +export function useAddToolToStack(origin: Origin): UseAddToolToStack { + const { user, showLogin } = useAuthContext(); + const { displayToast } = useToastNotification(); + const { logEvent } = useLogContext(); + const { stackItems, add } = useUserStack( + (user ?? null) as PublicProfile | null, + ); + const [pendingTool, setPendingTool] = useState(null); + + const stackedToolIds = useMemo( + () => new Set(stackItems.map((item) => item.tool.id)), + [stackItems], + ); + + const openAddModal = useCallback( + (tool: StackableTool) => { + if (!user) { + showLogin({ trigger: AuthTriggers.AddToStack }); + return; + } + logEvent({ + event_name: LogEvent.StartAddUserStack, + target_id: tool.slug, + extra: JSON.stringify({ origin }), + }); + setPendingTool(tool); + }, + [user, showLogin, logEvent, origin], + ); + + const handleAdd = useCallback( + async (input: AddUserStackInput) => { + try { + await add(input); + displayToast('Added to your stack'); + } catch (error) { + displayToast('Failed to add item'); + throw error; + } + }, + [add, displayToast], + ); + + const modal = pendingTool ? ( + setPendingTool(null)} + onSubmit={handleAdd} + defaultTitle={pendingTool.title} + modalTitle="Add stack/tool to profile" + /> + ) : null; + + return { stackedToolIds, openAddModal, modal }; +} diff --git a/packages/webapp/pages/tools/[slug].tsx b/packages/webapp/pages/tools/[slug].tsx index 8b3f1244146..e33850d725b 100644 --- a/packages/webapp/pages/tools/[slug].tsx +++ b/packages/webapp/pages/tools/[slug].tsx @@ -47,11 +47,9 @@ import { generateQueryKey, RequestKey, StaleTime, + OtherFeedPage, } from '@dailydotdev/shared/src/lib/query'; -import type { - ToolTopSquad, - AddUserStackInput, -} from '@dailydotdev/shared/src/graphql/user/userStack'; +import type { ToolTopSquad } from '@dailydotdev/shared/src/graphql/user/userStack'; import { getTopSquadsForTool } from '@dailydotdev/shared/src/graphql/user/userStack'; import type { ApiErrorResult } from '@dailydotdev/shared/src/graphql/common'; import { @@ -72,7 +70,6 @@ import { ButtonSize, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; -import { CardAction } from '@dailydotdev/shared/src/components/buttons/CardAction'; import { DataTile } from '@dailydotdev/shared/src/components/DataTile'; import { DiscussIcon, @@ -86,9 +83,6 @@ import { import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; -import { useUserStack } from '@dailydotdev/shared/src/features/profile/hooks/useUserStack'; -import { UserStackModal } from '@dailydotdev/shared/src/features/profile/components/stack/UserStackModal'; -import type { PublicProfile } from '@dailydotdev/shared/src/lib/user'; import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; import type { PromptOptions } from '@dailydotdev/shared/src/hooks/usePrompt'; import { usePrompt } from '@dailydotdev/shared/src/hooks/usePrompt'; @@ -107,13 +101,19 @@ import { ProfilePictureGroup } from '@dailydotdev/shared/src/components/ProfileP import { ToolLogo } from '@dailydotdev/shared/src/components/tools/ToolLogo'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log'; -import classNames from 'classnames'; +import { ActiveFeedNameContext } from '@dailydotdev/shared/src/contexts'; +import { FeedLayoutProvider } from '@dailydotdev/shared/src/contexts/FeedContext'; +import { TAG_FEED_QUERY } from '@dailydotdev/shared/src/graphql/feed'; +import HorizontalFeed from '@dailydotdev/shared/src/components/feeds/HorizontalFeed'; +import { EntityRailWithFade } from '@dailydotdev/shared/src/components/entity/EntityRailWithFade'; import { getLayout } from '../../components/layouts/MainLayout'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { defaultOpenGraph, noindexSeoProps } from '../../next-seo'; import { getPageSeoTitles } from '../../components/layouts/utils'; import { getAppOrigin } from '../../lib/seo'; import { ToolDiscussion } from '../../components/tools/ToolDiscussion'; +import { useAddToolToStack } from '../../components/tools/useAddToolToStack'; +import { ToolSquadCard } from '../../components/tools/ToolSquadCard'; import { ToolCard } from '../../components/tools/ToolCard'; import { ToolPageNavbar } from '../../components/tools/ToolPageNavbar'; import { ToolSection } from '../../components/tools/ToolSection'; @@ -131,6 +131,9 @@ const getToolPageJsonLd = ( topPosts: ToolTopPost[], ): string => { const toolUrl = `${appOrigin}/tools/${tool.slug}`; + // Mirrors the server-rendered "Top reads" list exactly - structured data + // must describe content that is actually in the HTML. + const linkablePosts = topPosts.filter((post) => !!post.title); const breadcrumbItems = [ { name: 'Tools', item: `${appOrigin}/tools` }, ...(tool.category @@ -165,17 +168,17 @@ const getToolPageJsonLd = ( item: item.item, })), }, - ...(topPosts.length + ...(linkablePosts.length ? [ { '@type': 'ItemList', '@id': `${toolUrl}#posts`, - numberOfItems: topPosts.length, - itemListElement: topPosts.map((post, index) => ({ + numberOfItems: linkablePosts.length, + itemListElement: linkablePosts.map((post, index) => ({ '@type': 'ListItem', position: index + 1, url: `${appOrigin}/posts/${post.slug || post.id}`, - name: post.title || '', + name: post.title, })), }, ] @@ -298,11 +301,8 @@ const applyOptimisticVote = ( return { ...state, upvotes, downvotes, userVote: vote === 0 ? null : vote }; }; -// Row chrome shared by the tool page's lists — the filled, hoverable row the -// profile page uses for stack items and hot takes. const rowClassName = - 'flex items-center gap-4 rounded-16 bg-surface-float p-4 transition-colors'; -const linkRowClassName = classNames(rowClassName, 'hover:bg-surface-hover'); + 'flex items-center gap-4 rounded-16 border border-border-subtlest-tertiary p-4 transition-colors'; const MetaSeparator = (): ReactElement => ·; @@ -320,21 +320,25 @@ const ToolPage = ({ facts, }: ToolPageProps): ReactElement => { const { user, showLogin } = useAuthContext(); - const { stackItems, add } = useUserStack(user as PublicProfile); + const { + stackedToolIds, + openAddModal, + modal: stackModal, + } = useAddToolToStack(Origin.ToolPage); const { displayToast } = useToastNotification(); const { logEvent } = useLogContext(); const { showPrompt } = usePrompt(); const { userCompanies } = useUserCompaniesQuery(); - const [isModalOpen, setIsModalOpen] = useState(false); const [claimedByState, setClaimedByState] = useState(claimedBy); - const isInStack = useMemo( - () => stackItems.some((item) => item.tool.id === tool.id), - [stackItems, tool.id], - ); + const isInStack = stackedToolIds.has(tool.id); const websiteHost = tool.url ? getDomainFromUrl(tool.url) : null; + const topPostsQueryVariables = useMemo( + () => ({ tag: tool.keyword, ranking: 'POPULARITY' }), + [tool.keyword], + ); const descriptionFact = useMemo( () => getToolFact(facts, 'description'), [facts], @@ -541,36 +545,14 @@ const ToolPage = ({ sendClaimTool, ]); - const totalVotes = (voteState?.upvotes ?? 0) + (voteState?.downvotes ?? 0); + const upvoteCount = voteState?.upvotes ?? 0; + const totalVotes = upvoteCount + (voteState?.downvotes ?? 0); const sentiment = - totalVotes > 0 - ? Math.round(((voteState?.upvotes ?? 0) / totalVotes) * 100) - : null; + totalVotes > 0 ? Math.round((upvoteCount / totalVotes) * 100) : null; - const handleAddClick = useCallback(() => { - if (!user) { - showLogin({ trigger: AuthTriggers.AddToStack }); - return; - } - logEvent({ - event_name: LogEvent.StartAddUserStack, - target_id: tool.slug, - extra: JSON.stringify({ origin: Origin.ToolPage }), - }); - setIsModalOpen(true); - }, [user, showLogin, logEvent, tool.slug]); - - const handleAdd = useCallback( - async (input: AddUserStackInput) => { - try { - await add(input); - displayToast('Added to your stack'); - } catch (error) { - displayToast('Failed to add item'); - throw error; - } - }, - [add, displayToast], + const handleAddClick = useCallback( + () => openAddModal({ id: tool.id, title: tool.title, slug: tool.slug }), + [openAddModal, tool.id, tool.title, tool.slug], ); const handleDiscussClick = useCallback(() => { @@ -593,18 +575,6 @@ const ToolPage = ({ [logEvent], ); - const handleTopPostClick = useCallback( - (post: ToolTopPost) => { - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.Post, - target_id: post.id, - extra: JSON.stringify({ origin: Origin.ToolPage }), - }); - }, - [logEvent], - ); - const handleOfficialSourceClick = useCallback(() => { if (!officialSource) { return; @@ -699,7 +669,8 @@ const ToolPage = ({ faviconUrl={tool.faviconUrl} url={tool.url} size={160} - className="size-20 rounded-16 border border-border-subtlest-tertiary p-3 typo-title2" + className="size-20 rounded-16 border border-border-subtlest-tertiary typo-title2" + plateClassName="bg-white p-3" /> : } disabled={isInStack} onClick={handleAddClick} > {isInStack ? 'In your stack' : 'Add to my stack'} -
- } - iconPressed={} - color={ButtonColor.Avocado} - pressed={voteState?.userVote === 1} - count={voteState?.upvotes} - onClick={() => handleVote(1)} - /> - } - iconPressed={} - color={ButtonColor.Ketchup} - pressed={voteState?.userVote === -1} - onClick={() => handleVote(-1)} - /> -
+ +
- {isModalOpen && ( - setIsModalOpen(false)} - onSubmit={handleAdd} - defaultTitle={tool.title} - modalTitle="Add stack/tool to profile" - /> - )} + {stackModal} ); diff --git a/packages/webapp/pages/tools/index.tsx b/packages/webapp/pages/tools/index.tsx index 5be0b230119..754a4f15eca 100644 --- a/packages/webapp/pages/tools/index.tsx +++ b/packages/webapp/pages/tools/index.tsx @@ -1,14 +1,21 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import type { GetStaticPropsResult } from 'next'; import Head from 'next/head'; import type { NextSeoProps } from 'next-seo'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import type { DirectoryTool } from '@dailydotdev/shared/src/graphql/tools'; import { getToolCategories, getToolCategoryAnchor, getTopTools, } from '@dailydotdev/shared/src/graphql/tools'; +import { StaleTime } from '@dailydotdev/shared/src/lib/query'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; import { Typography, TypographyColor, @@ -17,6 +24,9 @@ import { } from '@dailydotdev/shared/src/components/typography/Typography'; import { useLogContext } from '@dailydotdev/shared/src/contexts/LogContext'; import { LogEvent, Origin, TargetType } from '@dailydotdev/shared/src/lib/log'; +import { CharmEmptyState } from '@dailydotdev/shared/src/components/charm/CharmEmptyState'; +import { MIN_SEARCH_QUERY_LENGTH } from '@dailydotdev/shared/src/hooks/useTagSearch'; +import { cloudinaryCharmSearchNoResults } from '@dailydotdev/shared/src/lib/image'; import { getLayout } from '../../components/layouts/MainLayout'; import { getLayout as getFooterNavBarLayout } from '../../components/layouts/FooterNavBarLayout'; import { defaultOpenGraph, noindexSeoProps } from '../../next-seo'; @@ -25,12 +35,27 @@ import { getAppOrigin } from '../../lib/seo'; import { ToolCard } from '../../components/tools/ToolCard'; import { ToolPageNavbar } from '../../components/tools/ToolPageNavbar'; import { ToolSection } from '../../components/tools/ToolSection'; +import { ToolDirectorySearch } from '../../components/tools/ToolDirectorySearch'; +import { useAddToolToStack } from '../../components/tools/useAddToolToStack'; const TOOLS_PER_SECTION = 6; const TRENDING_COUNT = 6; +// Matches the API's topTools cap, so a category at the limit means tools +// were actually dropped. +const CATEGORY_FETCH_LIMIT = 100; +const SEARCH_RESULTS_LIMIT = 100; +const RECOMMENDED_COUNT = 5; +// Catch-all section for stacked tools without a curated category, rendered +// below the curated ones. Only sees tools inside the overall top-N fetch. +const OTHER_CATEGORY = 'Other'; const appOrigin = getAppOrigin(); +interface CategorySection { + category: string; + tools: DirectoryTool[]; +} + const getToolsDirectoryJsonLd = (sections: CategorySection[]): string => { const directoryUrl = `${appOrigin}/tools`; const tools = sections.flatMap(({ tools: sectionTools }) => sectionTools); @@ -66,45 +91,124 @@ const getToolsDirectoryJsonLd = (sections: CategorySection[]): string => { }); }; -interface CategorySection { - category: string; - tools: DirectoryTool[]; -} - interface ToolsDirectoryProps { - trending: DirectoryTool[]; - sections: CategorySection[]; - fallbackTop: DirectoryTool[]; + tools: DirectoryTool[]; + trendingIds: string[]; + sections: { category: string; toolIds: string[] }[]; + fallbackTopIds: string[]; } -const ToolGrid = ({ tools }: { tools: DirectoryTool[] }): ReactElement => { +const ToolsDirectoryPage = ({ + tools, + trendingIds, + sections, + fallbackTopIds, +}: ToolsDirectoryProps): ReactElement => { const { logEvent } = useLogContext(); + const { stackedToolIds, openAddModal, modal } = useAddToolToStack( + Origin.ToolsDirectory, + ); + const [inputValue, setInputValue] = useState(''); + const [search, setSearch] = useState(''); + const [expandedCategories, setExpandedCategories] = useState>( + () => new Set(), + ); - return ( -
- {tools.map((tool) => ( - - logEvent({ - event_name: LogEvent.Click, - target_type: TargetType.Tool, - target_id: tool.slug, - extra: JSON.stringify({ origin: Origin.ToolsDirectory }), - }) - } - /> - ))} -
+ const toolsById = useMemo( + () => new Map(tools.map((tool) => [tool.id, tool])), + [tools], + ); + const pickTools = useCallback( + (ids: string[]): DirectoryTool[] => + ids.flatMap((id) => toolsById.get(id) ?? []), + [toolsById], + ); + + const trending = useMemo( + () => pickTools(trendingIds), + [pickTools, trendingIds], + ); + const categorySections = useMemo( + () => + sections.map(({ category, toolIds }) => ({ + category, + tools: pickTools(toolIds), + })), + [sections, pickTools], + ); + const fallbackTop = useMemo( + () => pickTools(fallbackTopIds), + [pickTools, fallbackTopIds], + ); + + const normalizedSearch = search.trim(); + const isSearching = normalizedSearch.length >= MIN_SEARCH_QUERY_LENGTH; + const { data: apiResults, isPending: isSearchPending } = useQuery({ + queryKey: ['toolsDirectorySearch', normalizedSearch.toLowerCase()], + // Logged inside queryFn (like useTagSearch) so the logged term and result + // count always come from the same response. + queryFn: async () => { + const result = await getTopTools({ + query: normalizedSearch, + first: SEARCH_RESULTS_LIMIT, + }); + logEvent({ + event_name: LogEvent.SearchTools, + extra: JSON.stringify({ + query: normalizedSearch.toLowerCase(), + resultCount: result.length, + }), + }); + return result; + }, + enabled: isSearching, + staleTime: StaleTime.Default, + placeholderData: keepPreviousData, + }); + + const searchResults = useMemo( + () => (isSearching ? apiResults ?? [] : []), + [isSearching, apiResults], + ); + const isSearchLoading = isSearching && isSearchPending; + + const clearSearch = useCallback(() => { + setInputValue(''); + setSearch(''); + }, []); + + const renderGrid = useCallback( + (gridTools: DirectoryTool[], extraProps?: Record) => ( +
+ {gridTools.map((tool) => ( + + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.Tool, + target_id: tool.slug, + extra: JSON.stringify({ + origin: Origin.ToolsDirectory, + ...extraProps, + }), + }) + } + /> + ))} +
+ ), + [stackedToolIds, openAddModal, logEvent], + ); + + const recommendedTools = useMemo( + () => trending.slice(0, RECOMMENDED_COUNT), + [trending], ); -}; -const ToolsDirectoryPage = ({ - trending, - sections, - fallbackTop, -}: ToolsDirectoryProps): ReactElement => { return ( <> @@ -114,7 +218,7 @@ const ToolsDirectoryPage = ({ type="application/ld+json" // eslint-disable-next-line react/no-danger dangerouslySetInnerHTML={{ - __html: getToolsDirectoryJsonLd(sections), + __html: getToolsDirectoryJsonLd(categorySections), }} /> @@ -134,9 +238,16 @@ const ToolsDirectoryPage = ({ The tools developers actually run — ranked by real stacks on daily.dev, not vendor pitches. - {sections.length > 1 && ( + + {!isSearching && categorySections.length > 1 && (
- {sections.map(({ category }) => ( + {categorySections.map(({ category }) => ( -
+ {isSearching && !isSearchLoading && searchResults.length === 0 && ( + + )} -
- {trending.length > 0 && ( - - - - )} + {isSearching && searchResults.length > 0 && ( + + {renderGrid(searchResults, { searched: true })} + + )} - {sections.map(({ category, tools }) => ( - - - - ))} - - {sections.length === 0 && fallbackTop.length > 0 && ( - - - - )} -
+ {!isSearching && ( +
+ {trending.length > 0 && ( + + {renderGrid(trending)} + + )} + + {categorySections.map(({ category, tools: categoryTools }) => { + const isExpanded = expandedCategories.has(category); + const visibleTools = isExpanded + ? categoryTools + : categoryTools.slice(0, TOOLS_PER_SECTION); + + return ( + + {renderGrid(visibleTools)} + {!isExpanded && + categoryTools.length > visibleTools.length && ( + + )} + + ); + })} + + {categorySections.length === 0 && fallbackTop.length > 0 && ( + + {renderGrid(fallbackTop)} + + )} +
+ )} + + {modal} ); @@ -194,21 +346,57 @@ export async function getStaticProps(): Promise< // social queries), so a failure here should fail the revalidation and let // Next keep serving the last good ISR output, rather than caching an // empty page. - const [categories, trending, fallbackTop] = await Promise.all([ + const [categories, trending, allTop] = await Promise.all([ getToolCategories(), getTopTools({ first: TRENDING_COUNT, trending: true }), - getTopTools({ first: 12 }), + getTopTools({ first: CATEGORY_FETCH_LIMIT }), ]); + const fallbackTop = allTop.slice(0, 12); - const sections = ( + const fullCategories = ( await Promise.all( categories.map(async ({ category }) => ({ category, - tools: await getTopTools({ first: TOOLS_PER_SECTION, category }), + tools: await getTopTools({ first: CATEGORY_FETCH_LIMIT, category }), })), ) ).filter(({ tools }) => tools.length > 0); + const uncategorized = allTop.filter((tool) => !tool.category); + if (uncategorized.length > 0) { + fullCategories.push({ category: OTHER_CATEGORY, tools: uncategorized }); + } + if (allTop.length >= CATEGORY_FETCH_LIMIT) { + // eslint-disable-next-line no-console + console.warn( + `tools directory: the overall top-tools fetch hit the limit; the "${OTHER_CATEGORY}" section may be missing uncategorized tools`, + ); + } + + fullCategories.forEach(({ category, tools }) => { + if (category !== OTHER_CATEGORY && tools.length >= CATEGORY_FETCH_LIMIT) { + // eslint-disable-next-line no-console + console.warn( + `tools directory: category "${category}" hit the fetch limit; some tools are not listed`, + ); + } + }); + + const tools = Array.from( + new Map( + [ + ...fullCategories.flatMap(({ tools: categoryTools }) => categoryTools), + ...trending, + ...allTop, + ].map((tool) => [tool.id, tool]), + ).values(), + ).sort((a, b) => a.title.localeCompare(b.title)); + + const sections = fullCategories.map(({ category, tools: categoryTools }) => ({ + category, + toolIds: categoryTools.map(({ id }) => id), + })); + const seoTitles = getPageSeoTitles( 'Developer tools directory — ranked by real stacks', ); @@ -216,9 +404,10 @@ export async function getStaticProps(): Promise< return { props: { - trending, + tools, + trendingIds: trending.map(({ id }) => id), sections, - fallbackTop, + fallbackTopIds: fallbackTop.map(({ id }) => id), seo: { title: seoTitles.title, openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph },