All files / src/hooks/queries usePublishReadiness.ts

100% Statements 3/3
100% Branches 4/4
100% Functions 2/2
100% Lines 3/3

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118                                                                                                                                                                                                                196x     36x     8x              
import { useQuery } from "@tanstack/react-query";
import { request } from "../../lib/api/base";
import { queryKeys } from "../../lib/query-keys";
import { STALE_1MIN } from "../../lib/stale-times";
 
/**
 * The publish bar, read from the one place that defines it.
 *
 * ONE HOOK, ONE QUERY KEY, ONE TYPE — AND WHY THAT MATTERS HERE
 * The bar (a complete profile plus one published project with at least
 * `photoMinimum` photos) is defined exactly once, in
 * `apps/api/src/lib/publish-readiness.ts`. That definition drives the project
 * publish gate, the `draft → published` auto-publish transition, and every
 * surface in the portal that says whether a studio is live.
 *
 * The portal briefly had THREE hooks for it — one in `useProjectQueries.ts`,
 * one in `useTodayQueries.ts`, one in `pages/onboarding/` — each caching the
 * same endpoint under a different query key, each with its own copy of the
 * types. That is not a tidiness problem: `ProjectDetailContainer` invalidates
 * the key after a publish, and with three keys the invalidation reached one
 * reader out of three. Today's phase rail and `StudioCard` went on printing
 * "not live yet" to a pro who had just gone live, which is precisely the
 * failure the single server-side definition exists to prevent.
 *
 * So: every reader imports from here, and `queryKeys.pro.publishReadiness` is
 * the only key this response is ever cached under. A second hook for this
 * endpoint is a bug, not a convenience.
 *
 * WHY THE TYPES ARE MIRRORED AND NOT IMPORTED
 * `packages/api-types` mirrors this wire shape as `PublishReadinessResponse`,
 * but the portal does not depend on that workspace (it takes
 * `@interioring/design-system` and `@interioring/utils`). Adding the
 * dependency is an `apps/portal/package.json` edit plus a `bun install` in a
 * shared worktree. The types below are therefore the portal's single mirror —
 * one copy, not three — and they follow the API module if the bar changes.
 *
 * READ `photoMinimum`; NEVER HARDCODE THE NUMBER. It is `3` today, and the
 * whole point of the server owning the bar is that it does not have to be.
 *
 * STALENESS
 * One minute, the tightest of the three windows this replaced. The answer
 * changes the moment a photo is uploaded, a project is published or the
 * profile is saved, and a stale "not live yet" is the one thing these screens
 * must not print after the pro has just fixed it. Mutations that move the bar
 * should still invalidate `queryKeys.pro.publishReadiness(proId)` rather than
 * wait this out.
 */
 
/** The five requirements, in the order the server returns them. */
export type PublishRequirementId =
	| "profile_basics"
	| "profile_services"
	| "profile_location"
	| "project_published"
	| "project_photo_minimum";
 
/** Which half of the bar a requirement belongs to. Server-owned. */
export type PublishRequirementGroup = "profile" | "portfolio";
 
/**
 * One evaluated requirement, exactly as `apps/api/src/lib/publish-readiness.ts`
 * puts it on the wire. `label` is server-owned — render it, never restate it.
 */
export interface PublishRequirement {
	id: PublishRequirementId;
	group: PublishRequirementGroup;
	/** Server-owned copy, so every surface says the same thing. */
	label: string;
	passes: boolean;
	/** True for the one requirement that refuses a publish when it fails. */
	blocks: boolean;
}
 
/** `GET /api/pro/:proId/publish-readiness`, inside the standard envelope. */
export interface PublishReadiness {
	/** Every requirement passes — the studio has cleared the bar. */
	ready: boolean;
	/** The profile half of the bar passes. The one definition of "complete". */
	profileComplete: boolean;
	/** A blocking requirement fails — a publish request will be refused. */
	blocked: boolean;
	/** `PROJECT_PHOTO_MINIMUM`. Read this; never hardcode 3. */
	photoMinimum: number;
	requirements: PublishRequirement[];
	/** The subset of `requirements` that does not pass, in the same order. */
	failing: PublishRequirement[];
	/** Current `pros.status`, so a caller knows if the studio is already live. */
	studioStatus: "draft" | "published" | "archived";
}
 
/**
 * Fetch the bar for a studio.
 *
 * Note the scope: the endpoint takes **no** projectId. It answers "is this
 * studio over the bar", scoping the photo minimum to the best-stocked
 * already-published project. A specific draft's photo count is counted on the
 * page; `photoMinimum` is what this supplies.
 *
 * `retry: false` because a pro who has not finished onboarding may have no
 * studio row for the bar to evaluate (404), and no caller treats this query as
 * the reason a page fails to render — each falls back to its own "we do not
 * know yet" state.
 */
export function usePublishReadiness(proId: string | null | undefined) {
	return useQuery({
		queryKey: queryKeys.pro.publishReadiness(proId ?? ""),
		queryFn: async (): Promise<PublishReadiness | null> => {
			const response = await request<PublishReadiness>(
				`/api/pro/${proId}/publish-readiness`,
			);
			return response.data ?? null;
		},
		enabled: !!proId,
		retry: false,
		staleTime: STALE_1MIN,
	});
}