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..1c6974c482 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, @@ -75,6 +84,9 @@ import { useUserStack } from '@dailydotdev/shared/src/features/profile/hooks/use 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'; +import { useUserCompaniesQuery } from '@dailydotdev/shared/src/hooks/userCompany'; import { useShareOrCopyLink } from '@dailydotdev/shared/src/hooks/useShareOrCopyLink'; import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; import { largeNumberFormat } from '@dailydotdev/shared/src/lib/numberFormat'; @@ -174,6 +186,7 @@ export interface ToolPageProps { takes: ToolTake[]; officialSource: ToolOfficialSource | null; alternatives: ToolAlternative[]; + claimedBy: ToolClaimedBy | null; } const SPARK_WIDTH = 400; @@ -271,18 +284,24 @@ 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 { 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 websiteHost = tool.url ? getDomainFromUrl(tool.url) : null; + const [copying, onShareOrCopy] = useShareOrCopyLink({ link: `${webappUrl}tools/${tool.slug}`, text: `Check out ${tool.title} on daily.dev`, @@ -389,6 +408,90 @@ 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, + // TODO(daily-api#4110): fold into TOOL_VOTE_STATE_QUERY (same + // datasetTool(slug) selection, also client-side/viewer-scoped) once the + // API ships, and narrow this catch to the unknown-field case so a real + // regression doesn't silently disappear. + queryFn: () => getToolViewerCanClaim(tool.slug).catch(() => false), + enabled: !!user && !claimedByState, + staleTime: StaleTime.Default, + }); + + // Best-effort match to the verified company whose email domain claims + // this tool; falls back to the viewer's first verified company for the + // confirm-dialog copy if the domains don't line up exactly. + const claimCompanyName = useMemo(() => { + const domain = websiteHost?.toLowerCase(); + const domainMatch = userCompanies.find( + (userCompany) => + !!domain && userCompany.email?.split('@')[1]?.toLowerCase() === domain, + ); + return ( + domainMatch?.company?.name ?? userCompanies[0]?.company?.name ?? null + ); + }, [userCompanies, websiteHost]); + + 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(async () => { + logEvent({ + event_name: LogEvent.ClickClaimTool, + target_type: TargetType.Tool, + target_id: tool.slug, + extra: JSON.stringify({ origin: Origin.ToolPage }), + }); + + const companyName = claimCompanyName ?? 'your company'; + const options: PromptOptions = { + title: `Claim this page for ${companyName}?`, + description: `This marks ${tool.title} as claimed by ${companyName} publicly, and can't be undone from the app.`, + okButton: { title: 'Claim page' }, + }; + const confirmed = await showPrompt(options); + + if (!confirmed) { + return; + } + + sendClaimTool(); + }, [ + logEvent, + tool.slug, + tool.title, + claimCompanyName, + showPrompt, + sendClaimTool, + ]); + const totalVotes = (voteState?.upvotes ?? 0) + (voteState?.downvotes ?? 0); const sentiment = totalVotes > 0 @@ -480,7 +583,6 @@ const ToolPage = ({ [logEvent], ); - const websiteHost = tool.url ? getDomainFromUrl(tool.url) : null; const sparklinePoints = useMemo( () => (adoption ? getSparklinePoints(adoption) : null), [adoption], @@ -556,6 +658,22 @@ const ToolPage = ({ )} + {claimedByState && ( + + + + Claimed by {claimedByState.name} + + + )} {websiteHost && ( )} + {!claimedByState && !!user && !!viewerCanClaim && ( + + )}