From bf58cff48b4c91ad8f6a94f7830b3397188f4cd5 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 20 Aug 2026 14:19:03 +0200 Subject: [PATCH 1/2] feat: add tool claim UI to the tool page Adds a "Verified by {company}" badge in the hero chip row when a tool is claimed, and a subtle "Work at {host}? Claim this page" affordance for eligible logged-in viewers, backed by the paired daily-api feat/tool-claim mutation (claimTool) and DatasetTool fields (claimedBy, viewerCanClaim). claimedBy is fetched via a separate SSG query with .catch(() => null), matching the officialSource deploy-window pattern. viewerCanClaim is viewer-scoped so it's fetched client-side only, tolerant of the not-yet-deployed API. Claim success/failure surface via toast, and the badge updates in place without a reload. Co-Authored-By: Claude Fable 5 --- packages/shared/src/graphql/tools.ts | 69 +++++++++++++++++++ packages/shared/src/lib/log.ts | 2 + packages/webapp/pages/tools/[slug].tsx | 93 +++++++++++++++++++++++++- 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/graphql/tools.ts b/packages/shared/src/graphql/tools.ts index 3c601d5e73..bbb3e8dc7e 100644 --- a/packages/shared/src/graphql/tools.ts +++ b/packages/shared/src/graphql/tools.ts @@ -2,6 +2,7 @@ import { gql } from 'graphql-request'; import { gqlClient } from './common'; import type { DatasetTool } from './user/userStack'; import type { Source } from './sources'; +import type { Company } from '../lib/userCompany'; export interface ToolPageTool extends DatasetTool { url: string | null; @@ -95,6 +96,74 @@ export const getToolOfficialSource = async ( return result.datasetTool.officialSource; }; +export type ToolClaimedBy = Pick; + +const TOOL_CLAIMED_BY_QUERY = gql` + query ToolClaimedBy($slug: String!) { + datasetTool(slug: $slug) { + claimedBy { + id + name + image + } + } + } +`; + +// Fetched separately from DATASET_TOOL_QUERY so a not-yet-deployed API +// (missing this field) can't 500 the whole page during the rollout window. +export const getToolClaimedBy = async ( + slug: string, +): Promise => { + const result = await gqlClient.request<{ + datasetTool: { claimedBy: ToolClaimedBy | null }; + }>(TOOL_CLAIMED_BY_QUERY, { slug }); + return result.datasetTool.claimedBy; +}; + +const TOOL_VIEWER_CAN_CLAIM_QUERY = gql` + query ToolViewerCanClaim($slug: String!) { + datasetTool(slug: $slug) { + viewerCanClaim + } + } +`; + +// Viewer-scoped, so it must never be baked into the anonymous SSG payload; +// callers fetch this client-side only, gated to logged-in users. +export const getToolViewerCanClaim = async (slug: string): Promise => { + const result = await gqlClient.request<{ + datasetTool: { viewerCanClaim: boolean }; + }>(TOOL_VIEWER_CAN_CLAIM_QUERY, { slug }); + return result.datasetTool.viewerCanClaim; +}; + +export interface ToolClaimResult { + claimedBy: ToolClaimedBy | null; + viewerCanClaim: boolean; +} + +const CLAIM_TOOL_MUTATION = gql` + mutation ClaimTool($id: ID!) { + claimTool(id: $id) { + claimedBy { + id + name + image + } + viewerCanClaim + } + } +`; + +export const claimTool = async (id: string): Promise => { + const result = await gqlClient.request<{ claimTool: ToolClaimResult }>( + CLAIM_TOOL_MUTATION, + { id }, + ); + return result.claimTool; +}; + export interface ToolAlternative extends DatasetTool { stackCount: number; } diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index dbb743f2f5..9e76c7763f 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -473,6 +473,8 @@ export enum LogEvent { RemoveToolUpvote = 'remove tool upvote', DownvoteTool = 'downvote tool', RemoveToolDownvote = 'remove tool downvote', + ClickClaimTool = 'click claim tool', + ClaimTool = 'claim tool', // Hot Takes StartAddHotTake = 'start add hot take', AddHotTake = 'add hot take', diff --git a/packages/webapp/pages/tools/[slug].tsx b/packages/webapp/pages/tools/[slug].tsx index 35d04f30c3..a27c70a1f3 100644 --- a/packages/webapp/pages/tools/[slug].tsx +++ b/packages/webapp/pages/tools/[slug].tsx @@ -13,6 +13,7 @@ import type { AlsoStackedTool, ToolAdoption, ToolAlternative, + ToolClaimedBy, ToolOfficialSource, ToolPageTool, ToolStacker, @@ -21,16 +22,19 @@ import type { ToolVoteState, } from '@dailydotdev/shared/src/graphql/tools'; import { + claimTool, getDatasetTool, getToolAdoption, getToolAlternatives, getToolCategoryAnchor, + getToolClaimedBy, getToolOfficialSource, getToolsAlsoStacked, getToolStackers, getToolStackersFollowing, getToolTakes, getToolTopPosts, + getToolViewerCanClaim, getToolVoteState, voteTool, } from '@dailydotdev/shared/src/graphql/tools'; @@ -47,8 +51,13 @@ import type { AddUserStackInput, } from '@dailydotdev/shared/src/graphql/user/userStack'; import { getTopSquadsForTool } from '@dailydotdev/shared/src/graphql/user/userStack'; -import { ApiError } from '@dailydotdev/shared/src/graphql/common'; +import type { ApiErrorResult } from '@dailydotdev/shared/src/graphql/common'; +import { + ApiError, + DEFAULT_ERROR, +} from '@dailydotdev/shared/src/graphql/common'; import type { GraphQLError } from '@dailydotdev/shared/src/lib/errors'; +import { Tooltip } from '@dailydotdev/shared/src/components/tooltip/Tooltip'; import { Typography, TypographyColor, @@ -174,6 +183,7 @@ export interface ToolPageProps { takes: ToolTake[]; officialSource: ToolOfficialSource | null; alternatives: ToolAlternative[]; + claimedBy: ToolClaimedBy | null; } const SPARK_WIDTH = 400; @@ -271,12 +281,14 @@ const ToolPage = ({ takes, officialSource, alternatives, + claimedBy, }: ToolPageProps): ReactElement => { const { user, showLogin } = useAuthContext(); const { stackItems, add } = useUserStack(user as PublicProfile); const { displayToast } = useToastNotification(); const { logEvent } = useLogContext(); const [isModalOpen, setIsModalOpen] = useState(false); + const [claimedByState, setClaimedByState] = useState(claimedBy); const isInStack = useMemo( () => stackItems.some((item) => item.tool.id === tool.id), @@ -389,6 +401,54 @@ const ToolPage = ({ [user, showLogin, sendVote, voteState?.userVote, logEvent, tool.slug], ); + const viewerCanClaimKey = generateQueryKey( + RequestKey.UserTools, + user, + 'tool-viewer-can-claim', + tool.id, + ); + const { data: viewerCanClaim } = useQuery({ + queryKey: viewerCanClaimKey, + // Viewer-scoped and unreleased server-side, so a schema mismatch on a + // not-yet-deployed API must never surface as a page error. + queryFn: () => getToolViewerCanClaim(tool.slug).catch(() => false), + enabled: !!user && !claimedByState, + staleTime: StaleTime.Default, + }); + + const { mutate: sendClaimTool, isPending: isClaiming } = useMutation({ + mutationFn: () => claimTool(tool.id), + onSuccess: (result) => { + setClaimedByState(result.claimedBy); + queryClient.setQueryData(viewerCanClaimKey, result.viewerCanClaim); + if (!result.claimedBy) { + return; + } + displayToast(`Page claimed for ${result.claimedBy.name}`); + logEvent({ + event_name: LogEvent.ClaimTool, + target_type: TargetType.Tool, + target_id: tool.slug, + extra: JSON.stringify({ origin: Origin.ToolPage }), + }); + }, + onError: (error) => { + const message = (error as unknown as ApiErrorResult)?.response + ?.errors?.[0]?.message; + displayToast(message ?? DEFAULT_ERROR); + }, + }); + + const handleClaimClick = useCallback(() => { + logEvent({ + event_name: LogEvent.ClickClaimTool, + target_type: TargetType.Tool, + target_id: tool.slug, + extra: JSON.stringify({ origin: Origin.ToolPage }), + }); + sendClaimTool(); + }, [logEvent, sendClaimTool, tool.slug]); + const totalVotes = (voteState?.upvotes ?? 0) + (voteState?.downvotes ?? 0); const sentiment = totalVotes > 0 @@ -556,6 +616,22 @@ const ToolPage = ({ )} + {claimedByState && ( + + + + Verified by {claimedByState.name} + + + )} {websiteHost && ( )} + {!claimedByState && !!user && !!viewerCanClaim && ( + + )}