Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 69 additions & 0 deletions packages/shared/src/graphql/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -95,6 +96,74 @@ export const getToolOfficialSource = async (
return result.datasetTool.officialSource;
};

export type ToolClaimedBy = Pick<Company, 'id' | 'name' | 'image'>;

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<ToolClaimedBy | null> => {
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<boolean> => {
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<ToolClaimResult> => {
const result = await gqlClient.request<{ claimTool: ToolClaimResult }>(
CLAIM_TOOL_MUTATION,
{ id },
);
return result.claimTool;
};

export interface ToolAlternative extends DatasetTool {
stackCount: number;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
140 changes: 138 additions & 2 deletions packages/webapp/pages/tools/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
AlsoStackedTool,
ToolAdoption,
ToolAlternative,
ToolClaimedBy,
ToolOfficialSource,
ToolPageTool,
ToolStacker,
Expand All @@ -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';
Expand All @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -174,6 +186,7 @@ export interface ToolPageProps {
takes: ToolTake[];
officialSource: ToolOfficialSource | null;
alternatives: ToolAlternative[];
claimedBy: ToolClaimedBy | null;
}

const SPARK_WIDTH = 400;
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the rollout-window shape is right, but please make sure it gets unwound once the API is deployed, otherwise it becomes permanent cost plus a permanent blind spot.

  1. viewerCanClaim can fold into the existing ToolVoteState query — same datasetTool(slug) selection, also client-side, also viewer-scoped — removing one extra GraphQL round trip per logged-in tool-page view.
  2. .catch(() => false) here and .catch(() => null) on getToolClaimedBy in getStaticProps swallow every error, not just the unknown-field error they exist for. After the API ships, a real regression (claim badge silently disappearing for all tools) fails completely silently.

A short comment or ticket referencing the API PR would be enough to make sure this gets cleaned up.

Reviewed by AI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on both, tracked as follow-up rather than done now — filed against daily-api#4110 with TODO markers in 367dc7f at the viewerCanClaim queryFn and the getToolClaimedBy .catch in getStaticProps.

Deliberately not folding viewerCanClaim into TOOL_VOTE_STATE_QUERY in this PR: the API for that field isn't deployed yet, and voteState is fetched with a placeholderData fallback seeded from the SSG payload with no .catch on the queryFn itself (unlike the other tool-page queries) — visitors rely on it rendering during the exact deploy window this PR ships into. Adding an unreleased field to that query would risk making the whole vote-state fetch fail on the current production API, which is precisely the failure the separate-query pattern (officialSource, alternatives, and now claimedBy/viewerCanClaim) exists to avoid. Once daily-api#4110 deploys, viewerCanClaim moves into TOOL_VOTE_STATE_QUERY and both catches narrow to the unknown-field/schema-mismatch case so a real regression won't be swallowed silently.

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(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (new in 367dc7f): two things about sourcing the dialog's company name from useUserCompaniesQuery.

  1. The fallback can name the wrong company. When the domain match misses, this falls back to userCompanies[0], so a viewer with several verified companies can be shown "Claim this page for {A}?" while the API claims it for {B} — on an action the dialog itself describes as impossible to undo from the app. Given the fallback already exists, I'd rather it degrade to the generic 'your company' than assert a specific wrong name: keep domainMatch?.company?.name ?? null and let the existing ?? 'your company' handle the miss. (The API resolves the company from Company.domains, which can hold several domains, so a miss here is not just theoretical.)
  2. It adds a request per pageview. useUserCompaniesQuery is enabled: isLoggedIn, so every signed-in visitor to any tool page now fetches their companies purely to populate copy in a dialog that almost none of them will open. That's a third viewer-scoped round trip on this page alongside ToolVoteState and ToolViewerCanClaim. Fetching it on demand inside handleClaimClick (queryClient.fetchQuery with the same key, so it still shares the cache with the profile pages) or at minimum gating the hook behind viewerCanClaim would keep the default path unchanged.

Reviewed by AI.

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
Expand Down Expand Up @@ -480,7 +583,6 @@ const ToolPage = ({
[logEvent],
);

const websiteHost = tool.url ? getDomainFromUrl(tool.url) : null;
const sparklinePoints = useMemo(
() => (adoption ? getSparklinePoints(adoption) : null),
[adoption],
Expand Down Expand Up @@ -556,6 +658,22 @@ const ToolPage = ({
</a>
</Link>
)}
{claimedByState && (
<Tooltip content={`Claimed by ${claimedByState.name}`}>
<span className="border-accent-avocado-default/40 flex items-center rounded-8 border bg-accent-avocado-subtlest px-2.5 py-0.5 font-bold text-text-primary typo-footnote">
<ProfilePicture
size={ProfileImageSize.Size16}
rounded="full"
className="!mr-1.5"
user={{
image: claimedByState.image,
id: claimedByState.name,
}}
/>
Claimed by {claimedByState.name}
</span>
</Tooltip>
)}
{websiteHost && (
<a
href={tool.url ?? undefined}
Expand All @@ -574,6 +692,18 @@ const ToolPage = ({
</Link>
)}
</div>
{!claimedByState && !!user && !!viewerCanClaim && (
<Button
variant={ButtonVariant.Subtle}
size={ButtonSize.Small}
loading={isClaiming}
disabled={isClaiming}
onClick={handleClaimClick}
className="self-start"
>
{websiteHost ? `Work at ${websiteHost}? ` : ''}Claim this page

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: this is a single click, no confirmation, and the result is irreversible — the API has no unclaimTool mutation and no admin route, so a mis-click permanently binds this tool page to the clicker's company (I raised the missing revocation path on the API PR). Given the affordance sits inline in the hero and the label ends in a fairly casual "Claim this page", an accidental tap on mobile is plausible. usePrompt/showPrompt is already used for comparable irreversible actions (e.g. settings/organization/[orgId]/members.tsx); a short confirm naming the company would be cheap insurance here.

Reviewed by AI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 367dc7f: handleClaimClick now calls showPrompt (the usePrompt pattern from settings/organization/[orgId]/members.tsx) before running the mutation — title "Claim this page for {company}?", description clarifies it marks the tool as claimed by that company publicly and can't be undone from the app. The confirmed company name comes from a best-effort match between the viewer's verified UserCompany email domain and the tool's site domain, falling back to their first verified company. sendClaimTool() only fires if the prompt resolves true.

Also worth flagging here since it's related: the paired API PR's blocking fix makes viewerCanClaim return false for already-claimed tools, which closes the stale-ISR dead-button window their reviewer flagged — even if the SSG claimedBy on this page is stale, the client-side viewerCanClaim query still resolves correctly and hides the affordance. And per your note, the API side is adding a moderator unclaimTool for contested cases, so the missing-revocation gap you raised is being addressed cross-repo.

</Button>
)}
</div>
<Button
variant={isInStack ? ButtonVariant.Secondary : ButtonVariant.Primary}
Expand Down Expand Up @@ -997,6 +1127,7 @@ export async function getStaticProps({
takes,
officialSource,
alternatives,
claimedBy,
] = await Promise.all([
getToolsAlsoStacked(tool.id),
getTopSquadsForTool({ toolId: tool.id, first: 3 }),
Expand All @@ -1010,6 +1141,10 @@ export async function getStaticProps({
getToolTakes(tool.id).catch(() => []),
getToolOfficialSource(slug).catch(() => null),
getToolAlternatives(tool.id, ALTERNATIVES_COUNT).catch(() => []),
// TODO(daily-api#4110): narrow this catch to the unknown-field case
// once the API deploys, so a real regression doesn't silently drop
// the claim badge for every tool.
getToolClaimedBy(slug).catch(() => null),
]);

const seoTitles = getPageSeoTitles(
Expand All @@ -1027,6 +1162,7 @@ export async function getStaticProps({
takes,
officialSource,
alternatives,
claimedBy,
seo: {
title: seoTitles.title,
openGraph: { ...seoTitles.openGraph, ...defaultOpenGraph },
Expand Down
Loading