All files / src/pages/admin room-categories.tsx

100% Statements 25/25
95% Branches 38/40
100% Functions 9/9
100% Lines 22/22

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                                                    7x 8x 8x 8x   3x         16x 9x 6x                           13x 4x   9x 3x   6x                 9x   9x 9x 13x     9x                                                 6x                                                                           13x                               13x   13x                                                                                                                                              
import { Link } from "@tanstack/react-router";
import { Camera } from "lucide-react";
 
import { Card } from "../../components/ui/card";
import { Button } from "../../components/ui/button";
import { EmptyState } from "../../components/ui/empty-state";
import { getImageUrl } from "../../lib/api";
import type { RoomCategoryWithStats } from "../../lib/api";
import { useAdminRoomCategories } from "../../hooks/queries/useAdminQueries";
import {
	Table,
	TableBody,
	TableCaption,
	TableCell,
	TableHead,
	TableHeader,
	TableRow,
} from "../../components/ui/table";
import { AdminBand, AdminPageHead } from "../../components/admin/AdminPageHead";
import { Alert } from "../../components/ui/alert";
import { StatusBadge } from "../../components/ui/status-badge";
 
// D1B: sort order — needs-attention first (null cover + photoCount > 5), then curated, then empty
function sortCategories(
	categories: RoomCategoryWithStats[],
): RoomCategoryWithStats[] {
	return [...categories].sort((a, b) => {
		const rankA = getSortRank(a);
		const rankB = getSortRank(b);
		if (rankA !== rankB) return rankA - rankB;
		// Within each group, sort by taxonomy sortOrder
		return a.sortOrder - b.sortOrder;
	});
}
 
function getSortRank(cat: RoomCategoryWithStats): number {
	if (cat.coverMediaId === null && cat.photoCount > 5) return 0; // needs attention
	if (cat.coverMediaId !== null) return 1; // curated
	return 2; // empty / few photos
}
 
/**
 * Where a category's cover came from is a state, so it is a printed code —
 * 2px radius, `.label` caps, status ink on its own tint. It used to be a
 * coloured bullet glyph (`● Curated` in success ink, `○ Algorithmic` in
 * warning), which carries the state in a decoration rather than in the word.
 */
function CoverSourceBadge({
	source,
}: {
	source: RoomCategoryWithStats["coverSource"];
}) {
	if (source === "curated") {
		return <StatusBadge status="Curated" variant="success" />;
	}
	if (source === "algorithmic") {
		return <StatusBadge status="Algorithmic" variant="warning" />;
	}
	return (
		<StatusBadge
			status={source === "empty" ? "Empty" : "Pending"}
			variant="default"
		/>
	);
}
 
export function AdminRoomCategoriesPage() {
	const { data: rawCategories, isLoading, isError } = useAdminRoomCategories();
 
	const categories = rawCategories ? sortCategories(rawCategories) : [];
	const curatedCount = categories.filter(
		(c) => c.coverSource === "curated",
	).length;
 
	return (
		<div className="space-y-6">
			<AdminPageHead
				title="Room categories"
				description="Pick the cover image shown for each room category on the marketplace homepage and rooms index. Categories without a curated cover fall back to algorithmic selection."
				actions={
					!isLoading && !isError ? (
						<span className="text-xs font-semibold uppercase tracking-wider tabular-nums text-foreground-muted">
							{categories.length} categories · {curatedCount} with curated
							covers
						</span>
					) : undefined
				}
			/>
 
			<section className="space-y-2">
				<AdminBand
					title="All room categories"
					count={isLoading || isError ? undefined : categories.length}
					quiet
				/>
 
				{isLoading ? (
					<div className="space-y-px">
						{[1, 2, 3, 4, 5, 6].map((i) => (
							<div key={i} className="h-14 animate-pulse bg-background-muted" />
						))}
					</div>
				) : isError ? (
					<Alert variant="error">
						Failed to load room categories. Please refresh to try again.
					</Alert>
				) : categories.length === 0 ? (
					<EmptyState
						icon={Camera}
						title="No room categories found"
						description="Room categories are seeded with the platform taxonomy."
					/>
				) : (
					<Card className="overflow-hidden">
						<Table density="dense">
							<TableCaption>
								Room categories, with their marketplace copy and photos.
							</TableCaption>
							<TableHeader>
								<TableRow>
									<TableHead style={{ width: 120 }}>Cover</TableHead>
									<TableHead>Category</TableHead>
									<TableHead
										numeric
										className="hidden md:table-cell"
										style={{ width: 160 }}
									>
										Photos available
									</TableHead>
									<TableHead style={{ width: 130 }}>Source</TableHead>
									<TableHead className="text-right" style={{ width: 130 }}>
										Action
									</TableHead>
								</TableRow>
							</TableHeader>
							<TableBody>
								{categories.map((cat) => (
									<RoomCategoryListRow key={cat.code} category={cat} />
								))}
							</TableBody>
						</Table>
					</Card>
				)}
			</section>
		</div>
	);
}
 
type RoomCategoryListRowProps = {
	category: RoomCategoryWithStats;
};
 
function RoomCategoryListRow({ category }: RoomCategoryListRowProps) {
	const hasPhotos = category.photoCount > 0;
 
	return (
		<TableRow className="cursor-pointer">
			{/* Cover thumbnail */}
			<TableCell className="pr-4">
				{category.coverMediaUrl ? (
					<img
						src={getImageUrl(category.coverMediaUrl)}
						alt={category.coverMediaAlt || category.label}
						className="w-22 h-16 rounded object-cover border border-border-default"
						loading="lazy"
					/>
				) : (
					<div
						// The missing-cover hatch, on tokens — the Golden Rule applies to
						// a gradient as much as to a background.
						className="flex h-16 w-22 items-center justify-center rounded-sm border border-border-default"
						style={{
							background:
								"repeating-linear-gradient(45deg, var(--background-muted), var(--background-muted) 8px, var(--background-subtle) 8px, var(--background-subtle) 16px)",
						}}
					>
						<Camera className="h-5 w-5 text-foreground-subtle" />
					</div>
				)}
			</TableCell>
 
			{/* Category name + project count */}
			<TableCell>
				<div className="font-semibold text-foreground-default">
					{category.label}
				</div>
				<div className="mt-0.5 text-xs tabular-nums text-foreground-muted">
					{category.projectCount} projects
				</div>
			</TableCell>
 
			{/* Photos available (hidden on small screens) */}
			<TableCell
				numeric
				className="hidden whitespace-nowrap text-foreground-muted md:table-cell"
			>
				{category.photoCount > 0 ? `${category.photoCount} photos` : "—"}
			</TableCell>
 
			{/* Source badge */}
			<TableCell>
				<CoverSourceBadge source={category.coverSource} />
			</TableCell>
 
			{/* Action */}
			<TableCell className="text-right">
				{hasPhotos ? (
					<Link to={`/admin/room-categories/${category.code}`}>
						<Button variant="outline" size="sm">
							{category.coverMediaId ? "Edit cover" : "Pick cover"}
						</Button>
					</Link>
				) : (
					<Button
						variant="ghost"
						size="sm"
						disabled
						title="No photos available for this category yet"
					>
						Pick cover
					</Button>
				)}
			</TableCell>
		</TableRow>
	);
}