All files / src/components/projects VisibilityPanel.tsx

100% Statements 15/15
100% Branches 27/27
100% Functions 8/8
100% Lines 15/15

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                                                                                                                  94x 48x   46x 2x   44x                       94x   94x 94x   94x                                       1x                                       9x                                                       1x                 1x                       1x                     1x                    
import { Button } from "../ui/button";
import { StatusBadge } from "../ui/status-badge";
import type { PublishRequirement } from "../../hooks/queries/usePublishReadiness";
import type { ProjectActionId } from "./project-actions";
import { projectStatusCode } from "./project-status";
 
/**
 * The Visibility card — `project-detail-details.html`, right column.
 *
 * It answers one question in one sentence ("Live on the marketplace, your
 * website and search") and carries the status-appropriate controls next to the
 * answer.
 *
 * THE PUBLISH GATE IS THE SERVER'S, NOT A SECOND OPINION
 * `PublishChecklist.tsx` used to compute its own idea of "ready" in the
 * client, print a Required/Recommended list, and then offer **Publish
 * Anyway** — a button that bypassed the gate the list had just described. The
 * bar is now defined once, server-side
 * (`apps/api/src/lib/publish-readiness.ts`): exactly one requirement blocks,
 * `project_photo_minimum`, and the route refuses a publish that fails it with
 * a 400. So:
 *
 *   - the Publish button is **disabled** when the project is under
 *     `photoMinimum` photos, with the reason printed beside it;
 *   - the reason is rendered **item by item from the server's own
 *     `requirements`**, whose `label` is server-owned — the portal renders it
 *     and never restates it;
 *   - "Publish Anyway" is gone. With a real gate behind it, it could only ever
 *     have produced an error.
 *
 * The four advisory requirements are NOT here. They are advice about how often
 * a project is found, so they live in `RankBetterPanel`, which says so.
 */
 
export interface VisibilityPanelProps {
	status: string;
	/** Images on this project. Videos are not photos. */
	photoCount: number;
	/** `PROJECT_PHOTO_MINIMUM`, off the wire. Undefined while in flight. */
	photoMinimum?: number;
	/**
	 * The server's refusal, item by item. Set from the readiness response's
	 * `failing` array, or from a `PUBLISH_NOT_READY` 400's `requirements`.
	 */
	blockingRequirements?: PublishRequirement[];
	/**
	 * The server has already refused a publish for this project. The page then
	 * prints the refusal even if its own photo count says the bar is clear —
	 * the server is the authority, the page is a render of it.
	 */
	publishRefused?: boolean;
	isBusy?: boolean;
	onAction: (id: ProjectActionId) => void;
}
 
/** What the project's status means, in one sentence. */
function visibilitySentence(status: string): string {
	if (status === "published") {
		return "on the marketplace, your website and search.";
	}
	if (status === "archived") {
		return "— hidden everywhere until you bring it back.";
	}
	return "— not visible to anyone yet.";
}
 
export function VisibilityPanel({
	status,
	photoCount,
	photoMinimum,
	blockingRequirements,
	publishRefused,
	isBusy,
	onAction,
}: VisibilityPanelProps) {
	const code = projectStatusCode(status);
	const shortBy =
		photoMinimum === undefined ? 0 : Math.max(photoMinimum - photoCount, 0);
	const blocked = shortBy > 0 || Boolean(publishRefused);
 
	return (
		<section className="rounded-lg bg-background-elevated shadow-card">
			<div className="flex items-baseline justify-between gap-4 border-b border-border-strong px-4 py-3">
				<h2 className="text-xs font-semibold uppercase tracking-wider text-foreground-muted">
					Visibility
				</h2>
			</div>
 
			<div className="space-y-3 px-4 py-3">
				<p className="text-sm text-foreground-default">
					<StatusBadge status={code.label} variant={code.tone} />{" "}
					{visibilitySentence(status)}
				</p>
 
				{status !== "published" && (
					<div className="space-y-2">
						<Button
							size="sm"
							className="h-8 w-full"
							disabled={blocked || isBusy}
							onClick={() => onAction("publish")}
						>
							Publish
						</Button>
 
						{blocked && (
							<div className="space-y-1.5">
								{shortBy > 0 ? (
									<p className="text-sm text-foreground-muted">
										<span className="tabular-nums">{shortBy}</span> more{" "}
										{shortBy === 1 ? "photo" : "photos"} and this can go live.
									</p>
								) : (
									<p className="text-sm text-foreground-muted">
										This project is not over the publish bar yet.
									</p>
								)}
								{blockingRequirements && blockingRequirements.length > 0 && (
									<ul className="space-y-1">
										{blockingRequirements.map((requirement) => (
											<li
												key={requirement.id}
												className="flex items-start gap-2 text-sm text-foreground-muted"
											>
												<span
													aria-hidden="true"
													className="mt-0.5 w-4 shrink-0 text-center text-foreground-subtle"
												>
													·
												</span>
												<span>{requirement.label}</span>
											</li>
										))}
									</ul>
								)}
							</div>
						)}
					</div>
				)}
 
				<div className="flex flex-wrap gap-2">
					{status === "published" && (
						<>
							<Button
								variant="outline"
								size="sm"
								className="h-8"
								disabled={isBusy}
								onClick={() => onAction("move-to-draft")}
							>
								Move to draft
							</Button>
							<Button
								variant="outline"
								size="sm"
								className="h-8"
								disabled={isBusy}
								onClick={() => onAction("archive")}
							>
								Archive
							</Button>
						</>
					)}
					{status === "archived" && (
						<Button
							variant="outline"
							size="sm"
							className="h-8"
							disabled={isBusy}
							onClick={() => onAction("unarchive")}
						>
							Unarchive
						</Button>
					)}
					{status === "draft" && (
						<Button
							variant="outline"
							size="sm"
							className="h-8"
							disabled={isBusy}
							onClick={() => onAction("archive")}
						>
							Archive
						</Button>
					)}
				</div>
			</div>
		</section>
	);
}