feat(catalog): add reviewed bulk edits and complete category duplication

This commit is contained in:
Simo committed 2026-09-06 20:25:19 +02:00
1 parent 0bedc04692
commit 55a47949f1
26 files changed
+2504 -85

No files matched your search

@@ -1,19 +1,27 @@
"use client";
import { Loader2, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { requestCatalogNavigation } from "../client/use-catalog-navigation";
import { useLatestRequest } from "../client/use-latest-request";
import {
type CatalogSearchResult,
type SearchCatalogKind,
validSearchQuery,
} from "../domain/search";
import { BulkOfferEditor } from "./bulk-offer-editor";
export function CatalogSearch({
catalogType = "normal",
canEdit = false,
}: {
catalogType?: SearchCatalogKind;
canEdit?: boolean;
}) {
const t = useTranslations("pages.admin.catalog.workspace");
const [query, setQuery] = useState("");
@@ -22,6 +30,69 @@ export function CatalogSearch({
"idle",
);
const requests = useLatestRequest();
const bulk = useTranslations("pages.admin.catalog.bulk");
const router = useRouter();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pages, setPages] = useState<{ id: number; caption: string }[] | null>(
null,
);
const [destinationsLoading, setDestinationsLoading] = useState(false);
const [selectionError, setSelectionError] = useState<string | null>(null);
const destinationRequest = useRef(0);
const destinationBusy = useRef(false);
const [refreshKey, setRefreshKey] = useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
useEffect(() => {
destinationRequest.current += 1;
destinationBusy.current = false;
setSelected(new Set());
setPages(null);
setDestinationsLoading(false);
setSelectionError(null);
return () => {
destinationRequest.current += 1;
};
}, [catalogType]);
async function loadDestinations() {
if (destinationBusy.current) return;
destinationBusy.current = true;
const request = ++destinationRequest.current;
setDestinationsLoading(true);
setSelectionError(null);
try {
const result = await getBulkOfferDestinations();
if (request !== destinationRequest.current) return;
if (!result.ok) {
setSelectionError(result.error);
return;
}
setPages(result.data.pages);
} catch {
if (request === destinationRequest.current)
setSelectionError(bulk("requestFailed"));
} finally {
if (request === destinationRequest.current) {
destinationBusy.current = false;
setDestinationsLoading(false);
}
}
}
function mayEdit() {
if (requestCatalogNavigation()) return true;
setSelectionError(bulk("disabledReason"));
return false;
}
function toggleOffer(id: number) {
if (!mayEdit()) return;
setSelected((previous) => {
const next = new Set(previous);
if (next.has(id)) next.delete(id);
else if (next.size < 500) next.add(id);
return next;
});
if (!pages) void loadDestinations();
}
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
useEffect(() => {
const request = requests.start();
setResults([]);
@@ -52,7 +123,7 @@ export function CatalogSearch({
clearTimeout(timer);
requests.cancel();
};
}, [query, catalogType, requests]);
}, [query, catalogType, requests, refreshKey]);
return (
<section
aria-label={t("globalSearch")}
@@ -72,6 +143,53 @@ export function CatalogSearch({
className="min-w-0"
/>
</div>
{canEdit && catalogType === "normal" && selected.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
<span className="text-sm">
{bulk("selectedCount", { count: selected.size })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
>
{bulk("clearSelection")}
</Button>
{pages ? (
<BulkOfferEditor
ids={[...selected]}
allPages={pages}
beforeEdit={mayEdit}
onApplied={() => {
setSelected(new Set());
setRefreshKey((value) => value + 1);
router.refresh();
}}
/>
) : (
<Button
variant="outline"
size="sm"
disabled={destinationsLoading}
onClick={() => void loadDestinations()}
>
{destinationsLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
{bulk(
destinationsLoading
? "loadingDestinations"
: "retryDestinations",
)}
</Button>
)}
</div>
)}
{selectionError && (
<p role="alert" className="text-sm text-[var(--admin-error)]">
{selectionError}
</p>
)}
<div aria-live="polite">
{status === "loading" && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -93,10 +211,26 @@ export function CatalogSearch({
{results.length > 0 && (
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
{results.map((result) => (
<li key={result.kind + result.id}>
<li
key={result.kind + result.id}
className="flex items-center gap-2"
>
{canEdit &&
catalogType === "normal" &&
result.kind === "offer" && (
<Checkbox
aria-label={bulk("selectOffer", {
name: result.caption,
id: result.id,
})}
checked={selected.has(result.id)}
disabled={selected.size >= 500 && !selected.has(result.id)}
onCheckedChange={() => toggleOffer(result.id)}
/>
)}
<Link
href={result.href}
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-xs text-muted-foreground">