All files / src/components/projects RankBetterPanel.tsx

100% Statements 7/7
100% Branches 19/19
100% Functions 5/5
100% Lines 6/6

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                                                                                                93x                                                                                 85x 425x   85x                         425x                                                         2x                                  
import { Check } from "lucide-react";
import type { Project } from "../../lib/api";
import { cn } from "../../lib/utils";
 
/**
 * "Make it rank better" — `project-detail-details.html`, right column.
 *
 * WHY THIS PANEL EXISTS
 * These five items used to sit inside the publish popover, half of them under
 * a heading that said "Recommended", directly above a button that said
 * **Publish Anyway**. A pro reading that could reasonably conclude one of two
 * wrong things: that the advice was a gate, or that the gate was advice.
 *
 * The bar is now the server's and exactly one requirement blocks it
 * (`project_photo_minimum` — see `VisibilityPanel`). Everything here is
 * advice, it is persistent rather than hidden behind a click, and the card's
 * foot says what it is: **"These do not block publishing — they decide how
 * often it is found."**
 *
 * These are *project* SEO facts (description, scope, locality, budget), not
 * the server's studio-level `requirements` — a different list, about a
 * different thing, which is why the labels here are the portal's and the
 * labels in `VisibilityPanel` are the server's.
 */
 
export interface RankBetterItem {
	id: string;
	label: string;
	passes: boolean;
	/** Printed on the right of a passing row — e.g. the photo count. */
	figure?: number;
	/** Which read-only card's modal fixes it. */
	section?: "basic" | "scope" | "location" | "budget";
}
 
export interface RankBetterPanelProps {
	project: Project;
	/** Images on this project. */
	photoCount: number;
	/** Opens the read-only card's edit modal for the named section. */
	onEditSection?: (section: NonNullable<RankBetterItem["section"]>) => void;
}
 
/** The five advisory facts, in the mockup's order. */
export function rankBetterItems(
	project: Project,
	photoCount: number,
): RankBetterItem[] {
	return [
		{
			id: "photos",
			label: "Photos",
			passes: photoCount > 0,
			figure: photoCount,
		},
		{
			id: "description",
			label: "Description",
			passes: Boolean(project.description),
			section: "basic",
		},
		{
			id: "locality",
			label: "Locality",
			passes: Boolean(project.localityId),
			section: "location",
		},
		{
			id: "scope",
			label: "Scope or worked area",
			passes:
				(project.scope?.length ?? 0) > 0 ||
				(project.workedAreaIds?.length ?? 0) > 0,
			section: "scope",
		},
		{
			id: "budget",
			label: "Budget range",
			passes: Boolean(project.budgetRange),
			section: "budget",
		},
	];
}
 
export function RankBetterPanel({
	project,
	photoCount,
	onEditSection,
}: RankBetterPanelProps) {
	const items = rankBetterItems(project, photoCount);
	const done = items.filter((item) => item.passes).length;
 
	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">
					Make it rank better
				</h2>
				<span className="text-xs font-semibold tabular-nums text-foreground-muted">
					{done}/{items.length}
				</span>
			</div>
 
			<ul className="px-4">
				{items.map((item) => (
					<li
						key={item.id}
						className={cn(
							"flex items-center gap-2 border-t border-border-default py-2 text-sm first:border-t-0",
							item.passes ? "text-foreground-default" : "text-foreground-muted",
						)}
					>
						<span className="w-4 shrink-0 text-center">
							{item.passes ? (
								<Check
									aria-hidden="true"
									className="inline size-4 text-success"
								/>
							) : (
								<span aria-hidden="true" className="text-foreground-subtle">
									·
								</span>
							)}
							<span className="sr-only">{item.passes ? "Done" : "To do"}</span>
						</span>
						<span className="grow">{item.label}</span>
						{item.figure !== undefined && item.passes && (
							<span className="shrink-0 tabular-nums text-foreground-muted">
								{item.figure}
							</span>
						)}
						{!item.passes && item.section && onEditSection && (
							<button
								type="button"
								onClick={() => onEditSection(item.section as NonNullable<RankBetterItem["section"]>)}
								className="shrink-0 text-sm font-semibold text-action underline-offset-4 hover:underline"
							>
								Add <span aria-hidden="true">▸</span>
								<span className="sr-only">{item.label}</span>
							</button>
						)}
					</li>
				))}
			</ul>
 
			<p className="border-t border-border-default px-4 py-3 text-sm text-foreground-muted">
				These do not block publishing — they decide how often it is found.
			</p>
		</section>
	);
}