All files / src/components/projects ProjectDetailContainer.tsx

98.63% Statements 72/73
95.58% Branches 65/68
90.9% Functions 10/11
100% Lines 70/70

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 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257                                                                                                      181x 181x 181x   181x 181x               181x   181x 61x 22x   39x     181x           181x       181x 181x     28x 26x             181x 103x 28x   75x       181x     181x   181x 11x                     181x 5x 5x 5x 3x 3x   2x 2x   5x         181x 13x 13x 13x 4x 2x 2x 9x 2x 1x 7x 2x 2x   5x 3x 3x 5x   5x               5x 1x 1x       5x   13x       181x   181x 181x   181x 60x                   121x 5x                     116x                               181x   181x                                                     13x 13x 13x   2x          
// Smart container: wiring and mutations for the project detail page.
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useMemo, useState } from "react";
import { useProjectDetail } from "../../hooks/queries/useProjectQueries";
import { usePublishReadiness } from "../../hooks/queries/usePublishReadiness";
import { ApiError, getErrorMessage } from "../../lib/api";
import { notify } from "../../lib/notify";
import { usePro } from "../../lib/pro-context";
import { queryKeys } from "../../lib/query-keys";
import { ConfirmDialog } from "../ui/confirm-dialog";
import { Skeleton } from "../ui/skeleton";
import {
	createAdminApiAdapter,
	createProApiAdapter,
	enhanceAdminAdapterWithRooms,
} from "./api-adapters";
import { ProjectDetailContent } from "./ProjectDetailContent";
import {
	PROJECT_ACTION_CONFIRM,
	type ProjectActionId,
} from "./project-actions";
import type { ProjectApiAdapter, ProjectDetailContainerProps } from "./types";
 
/**
 * 5.4 — THE DATA PATH IS REACT QUERY NOW.
 *
 * This file used to load the project with raw `useState`/`useEffect`: a
 * `loadProject` callback, a `hasLoadedOnce` ref to suppress the spinner on
 * refresh, and a manual error string. That is a direct violation of the React
 * Query rule in `apps/portal/CLAUDE.md` ("NEVER use raw useState/useEffect for
 * API data fetching"), and it is why every navigation into a project showed a
 * spinner: the route's `prefetchProjectDetail` loader was warming a cache
 * nothing read.
 *
 * Now the project, the pro (admin context) and the publish bar are three
 * queries. A refetch after a mutation keeps the previous data on screen, so
 * the "is this the first load" ref disappeared with the problem it solved.
 *
 * WHY THE ADAPTER STAYS
 * `/projects/$projectId` and `/admin/projects/$projectId` are the same screen
 * over two different APIs. The adapter is what makes that one component;
 * `useProjectDetail` takes its `getProject` so the key, the staleTime and the
 * unwrapping are still decided once.
 */
export function ProjectDetailContainer({
	projectId,
	context,
	onDelete,
	proId: overrideProId,
	initialTab,
}: ProjectDetailContainerProps) {
	const { proId: contextProId, isLoading: proLoading } = usePro();
	const proId = overrideProId || contextProId;
	const queryClient = useQueryClient();
 
	const [isSaving, setIsSaving] = useState(false);
	const [pendingAction, setPendingAction] = useState<ProjectActionId | null>(
		null,
	);
	/**
	 * True once the server has refused a publish for this project. Kept so the
	 * Visibility card prints the refusal even if the local photo count says the
	 * bar is clear — the server is the authority, not the page.
	 */
	const [publishRefused, setPublishRefused] = useState(false);
 
	const baseAdapter = useMemo((): ProjectApiAdapter => {
		if (context === "pro" && proId) {
			return createProApiAdapter(proId);
		}
		return createAdminApiAdapter();
	}, [context, proId]);
 
	const projectQuery = useProjectDetail({
		proId,
		projectId,
		context,
		getProject: baseAdapter.getProject,
	});
	const project = projectQuery.data ?? null;
 
	// Admin opens a project it does not yet know the owner of, so the pro is a
	// second query keyed off the project's own `proId`.
	const projectProId = project?.proId ? String(project.proId) : null;
	const proQuery = useQuery({
		queryKey: queryKeys.pro.profile(projectProId ?? ""),
		queryFn: async () => {
			const response = await baseAdapter.getPro?.(projectProId as string);
			return response?.data ?? null;
		},
		enabled: context === "admin" && Boolean(baseAdapter.getPro) && !!projectProId,
	});
 
	// Room and media operations need the owning pro's id, which admin only has
	// once the project has loaded.
	const apiAdapter = useMemo((): ProjectApiAdapter => {
		if (context === "admin" && projectProId) {
			return enhanceAdminAdapterWithRooms(baseAdapter, projectProId);
		}
		return baseAdapter;
	}, [baseAdapter, context, projectProId]);
 
	// The publish bar. Studio-scoped, so only the pro screen asks for it.
	const readinessQuery = usePublishReadiness(
		context === "pro" ? proId : null,
	);
	const photoMinimum = readinessQuery.data?.photoMinimum;
 
	const invalidateProject = useCallback(async () => {
		await Promise.all([
			queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() }),
			queryClient.invalidateQueries({
				queryKey: queryKeys.projects.details(),
			}),
			queryClient.invalidateQueries({
				queryKey: queryKeys.pro.publishReadiness(proId ?? ""),
			}),
		]);
	}, [queryClient, proId]);
 
	const handleSave = async (updates: Parameters<typeof baseAdapter.updateProject>[1]) => {
		setIsSaving(true);
		try {
			await apiAdapter.updateProject(projectId, updates);
			notify.success("Project updated successfully");
			await invalidateProject();
		} catch (err) {
			notify.error(getErrorMessage(err));
			throw err;
		} finally {
			setIsSaving(false);
		}
	};
 
	/** The one place every `[ ⋯ ]` / Visibility action is performed. */
	const runAction = async (id: ProjectActionId) => {
		setIsSaving(true);
		try {
			if (id === "publish") {
				await apiAdapter.publishProject(projectId);
				setPublishRefused(false);
				notify.success("Project published");
			} else if (id === "archive") {
				await apiAdapter.archiveProject(projectId);
				notify.success("Project archived");
			} else if (id === "move-to-draft" || id === "unarchive") {
				await apiAdapter.unarchiveProject?.(projectId);
				notify.success("Project moved to draft");
			} else {
				await apiAdapter.deleteProject(projectId);
				await invalidateProject();
				onDelete?.();
				return;
			}
			await invalidateProject();
		} catch (err) {
			// The server refuses a publish that fails the one blocking
			// requirement (400 PUBLISH_NOT_READY) and names it in `message`,
			// which `getErrorMessage` surfaces verbatim. Record the refusal so
			// the Visibility card keeps printing the blocking requirement
			// item-by-item, and re-read the bar — the page's photo count was
			// stale enough to offer a publish the server would not accept.
			if (err instanceof ApiError && err.code === "PUBLISH_NOT_READY") {
				setPublishRefused(true);
				await queryClient.invalidateQueries({
					queryKey: queryKeys.pro.publishReadiness(proId ?? ""),
				});
			}
			notify.error(getErrorMessage(err));
		} finally {
			setIsSaving(false);
		}
	};
 
	const handleAction = (id: ProjectActionId) => setPendingAction(id);
 
	const backLink = context === "pro" ? "/projects" : "/admin/projects";
	const supportsRooms = Boolean(apiAdapter.getRooms);
 
	if ((context === "pro" && proLoading) || projectQuery.isPending) {
		return (
			<div className="space-y-4">
				<Skeleton className="h-4 w-20" />
				<Skeleton className="h-8 w-64" />
				<Skeleton className="h-px w-full" />
				<Skeleton className="h-64 w-full" />
			</div>
		);
	}
 
	if (!project) {
		return (
			<div className="py-12 text-center">
				<p className="mb-4 text-error">
					{projectQuery.error
						? getErrorMessage(projectQuery.error)
						: "Project not found"}
				</p>
			</div>
		);
	}
 
	const confirmFor = pendingAction
		? PROJECT_ACTION_CONFIRM[pendingAction]
		: null;
 
	/**
	 * The requirements that CAN refuse a publish, with the server's own labels.
	 *
	 * Taken from `requirements` rather than `failing` on purpose: `failing` is
	 * studio-scoped (the endpoint takes no projectId, so it measures the
	 * best-stocked already-published project), while this page is about ONE
	 * project. The server owns which requirements block and what they are
	 * called; whether THIS project clears the photo minimum is counted here,
	 * from this project's own media. One definition of the bar, applied to the
	 * project in front of the pro.
	 */
	const blockingRequirements =
		readinessQuery.data?.requirements.filter((item) => item.blocks) ?? [];
 
	return (
		<>
			<ProjectDetailContent
				project={project}
				pro={proQuery.data ?? null}
				context={context}
				isLoading={false}
				isSaving={isSaving}
				onSave={handleSave}
				onAction={handleAction}
				photoMinimum={photoMinimum}
				blockingRequirements={blockingRequirements}
				publishRefused={publishRefused}
				canMoveToDraft={Boolean(apiAdapter.unarchiveProject)}
				canDelete={context === "admin" ? Boolean(onDelete) : true}
				supportsRooms={supportsRooms}
				apiAdapter={apiAdapter}
				backLink={backLink}
				initialTab={initialTab}
			/>
			<ConfirmDialog
				open={pendingAction !== null}
				title={confirmFor?.title ?? ""}
				description={confirmFor?.description ?? ""}
				confirmLabel={confirmFor?.confirmLabel ?? "Confirm"}
				variant={confirmFor?.destructive ? "destructive" : "default"}
				onConfirm={() => {
					const id = pendingAction;
					setPendingAction(null);
					Eif (id) void runAction(id);
				}}
				onCancel={() => setPendingAction(null)}
			/>
		</>
	);
}