feat(catalog): improve visual manager search and offer discovery
This commit is contained in:
1 parent
08321df2aa
commit
93a7e9a7c2
9 files changed
+505
-72
No files matched your search
@@ -82,6 +82,11 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
||||||
|
import {
|
||||||
|
discoverOffers,
|
||||||
|
OFFER_FILTERS,
|
||||||
|
type OfferFilter,
|
||||||
|
} from "@/features/catalog/domain/offer-discovery";
|
||||||
import { useServerAction } from "@/hooks/use-server-action";
|
import { useServerAction } from "@/hooks/use-server-action";
|
||||||
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -146,6 +151,7 @@ function SortableRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogItemsTableProps {
|
interface CatalogItemsTableProps {
|
||||||
|
initialSearch?: string;
|
||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
items: CatalogItemData[];
|
items: CatalogItemData[];
|
||||||
@@ -158,6 +164,7 @@ interface CatalogItemsTableProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CatalogItemsTable({
|
export function CatalogItemsTable({
|
||||||
|
initialSearch = "",
|
||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
items,
|
items,
|
||||||
@@ -170,6 +177,7 @@ export function CatalogItemsTable({
|
|||||||
}: CatalogItemsTableProps) {
|
}: CatalogItemsTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||||
|
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||||
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
||||||
CatalogItemData[] | null
|
CatalogItemData[] | null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -186,20 +194,14 @@ export function CatalogItemsTable({
|
|||||||
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
|
||||||
|
|
||||||
// ── Search / Filter ─────────────────────────────────────────────
|
// ── Search / Filter ─────────────────────────────────────────────
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||||
|
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
|
||||||
const filteredItems = useMemo(() => {
|
const { items: filteredItems, counts: filterCounts } = useMemo(
|
||||||
if (!searchQuery.trim()) return items;
|
() => discoverOffers(items, searchQuery, offerFilter),
|
||||||
const q = searchQuery.toLowerCase();
|
[items, searchQuery, offerFilter],
|
||||||
return items.filter(
|
);
|
||||||
(item) =>
|
const hasDiscoveryFilter =
|
||||||
item.catalogName.toLowerCase().includes(q) ||
|
Boolean(searchQuery.trim()) || offerFilter !== "all";
|
||||||
item.baseItemName.toLowerCase().includes(q) ||
|
|
||||||
item.baseName.toLowerCase().includes(q) ||
|
|
||||||
item.itemIds.includes(q) ||
|
|
||||||
String(item.id).includes(q),
|
|
||||||
);
|
|
||||||
}, [items, searchQuery]);
|
|
||||||
|
|
||||||
// ── Move items dialog ───────────────────────────────────────────
|
// ── Move items dialog ───────────────────────────────────────────
|
||||||
const [moveOpen, setMoveOpen] = useState(false);
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
@@ -242,13 +244,36 @@ export function CatalogItemsTable({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const allVisibleSelected =
|
||||||
|
filteredItems.length > 0 &&
|
||||||
|
filteredItems.every((item) => selected.has(item.id));
|
||||||
|
const someVisibleSelected = filteredItems.some((item) =>
|
||||||
|
selected.has(item.id),
|
||||||
|
);
|
||||||
const toggleAll = useCallback(() => {
|
const toggleAll = useCallback(() => {
|
||||||
setSelected((prev) =>
|
setSelected((previous) => {
|
||||||
prev.size === filteredItems.length
|
const next = new Set(previous);
|
||||||
? new Set()
|
const allSelected = filteredItems.every((item) => previous.has(item.id));
|
||||||
: new Set(filteredItems.map((i) => i.id)),
|
for (const item of filteredItems) {
|
||||||
);
|
if (allSelected) next.delete(item.id);
|
||||||
|
else next.add(item.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, [filteredItems]);
|
}, [filteredItems]);
|
||||||
|
function updateSearch(query: string) {
|
||||||
|
setSearchQuery(query);
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
function updateOfferFilter(value: string) {
|
||||||
|
setOfferFilter(value as OfferFilter);
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
function resetDiscovery() {
|
||||||
|
setSearchQuery("");
|
||||||
|
setOfferFilter("all");
|
||||||
|
setSelected(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Inline edits ─────────────────────────────────────────────────
|
// ── Inline edits ─────────────────────────────────────────────────
|
||||||
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
|
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
|
||||||
@@ -947,15 +972,16 @@ export function CatalogItemsTable({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
Items ({items.length})
|
Items ({items.length})
|
||||||
{searchQuery && filteredItems.length !== items.length && (
|
{hasDiscoveryFilter &&
|
||||||
<span className="text-muted-foreground font-normal text-sm ml-2">
|
filteredItems.length !== items.length && (
|
||||||
({filteredItems.length} shown)
|
<span className="text-muted-foreground font-normal text-sm ml-2">
|
||||||
</span>
|
{discovery("shown", { count: filteredItems.length })}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{selected.size} selected
|
{discovery("selected", { count: selected.size })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1149,14 +1175,51 @@ export function CatalogItemsTable({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search bar */}
|
{/* Offer discovery */}
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<SearchInput
|
<div className="space-y-2">
|
||||||
placeholder="Filter by name, classname, item ID..."
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
value={searchQuery}
|
<div className="min-w-0 flex-1">
|
||||||
onChange={setSearchQuery}
|
<SearchInput
|
||||||
onClear={() => setSearchQuery("")}
|
placeholder={discovery("searchPlaceholder")}
|
||||||
/>
|
value={searchQuery}
|
||||||
|
onChange={updateSearch}
|
||||||
|
onClear={() => updateSearch("")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={offerFilter} onValueChange={updateOfferFilter}>
|
||||||
|
<SelectTrigger
|
||||||
|
aria-label={discovery("filterLabel")}
|
||||||
|
className="w-full sm:w-52"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{discovery(`filters.${offerFilter}`)} (
|
||||||
|
{filterCounts[offerFilter]})
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OFFER_FILTERS.map((filter) => (
|
||||||
|
<SelectItem key={filter} value={filter}>
|
||||||
|
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
||||||
|
)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{hasDiscoveryFilter && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
||||||
|
{discovery("reset")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{discovery("results", {
|
||||||
|
count: filteredItems.length,
|
||||||
|
total: items.length,
|
||||||
|
})}
|
||||||
|
{canEdit && ` ${discovery("selectionHint")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -1178,7 +1241,7 @@ export function CatalogItemsTable({
|
|||||||
</div>
|
</div>
|
||||||
) : filteredItems.length === 0 ? (
|
) : filteredItems.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
No items match "{searchQuery}".
|
{discovery("noResults")}
|
||||||
</p>
|
</p>
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<ItemsShopPreview
|
<ItemsShopPreview
|
||||||
@@ -1204,12 +1267,12 @@ export function CatalogItemsTable({
|
|||||||
{canEdit && (
|
{canEdit && (
|
||||||
<TableHead className="w-[40px] px-2">
|
<TableHead className="w-[40px] px-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={allVisibleSelected}
|
||||||
filteredItems.length > 0 &&
|
indeterminate={
|
||||||
selected.size === filteredItems.length
|
someVisibleSelected && !allVisibleSelected
|
||||||
}
|
}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label={discovery("selectVisible")}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
@@ -1237,7 +1300,7 @@ export function CatalogItemsTable({
|
|||||||
const modified = isItemModified(item);
|
const modified = isItemModified(item);
|
||||||
const isSaving = savingItemId === item.id;
|
const isSaving = savingItemId === item.id;
|
||||||
const furniInfo = getFurniInfo(item);
|
const furniInfo = getFurniInfo(item);
|
||||||
const sortableDisabled = !canEdit || !!searchQuery;
|
const sortableDisabled = !canEdit || hasDiscoveryFilter;
|
||||||
return (
|
return (
|
||||||
<SortableRow
|
<SortableRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutGrid, X } from "lucide-react";
|
import { LayoutGrid, Search, X } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createCatalogPage } from "@/actions/catalog";
|
import { createCatalogPage } from "@/actions/catalog";
|
||||||
@@ -12,6 +12,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||||||
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
|
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
|
||||||
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||||
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
||||||
|
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
||||||
|
import type { CatalogSearchResult } from "@/features/catalog/domain/search";
|
||||||
import type { RootTab } from "@/types/catalog";
|
import type { RootTab } from "@/types/catalog";
|
||||||
import { BreadcrumbBar } from "./breadcrumb-bar";
|
import { BreadcrumbBar } from "./breadcrumb-bar";
|
||||||
import { InlineEditor, type PageUpdateData } from "./inline-editor";
|
import { InlineEditor, type PageUpdateData } from "./inline-editor";
|
||||||
@@ -161,12 +163,20 @@ function CatalogManagerInner({
|
|||||||
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
||||||
const catQs = catalogTreeQs(catalogType);
|
const catQs = catalogTreeQs(catalogType);
|
||||||
const rootRequests = useLatestRequest();
|
const rootRequests = useLatestRequest();
|
||||||
|
const searchNavigation = useLatestRequest();
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [searchTarget, setSearchTarget] = useState<{
|
||||||
|
offerId: number | null;
|
||||||
|
key: number;
|
||||||
|
}>({ offerId: null, key: 0 });
|
||||||
const tActions = useTranslations("pages.admin.actions");
|
const tActions = useTranslations("pages.admin.actions");
|
||||||
const t = useTranslations("pages.admin.catalog.workspace");
|
const t = useTranslations("pages.admin.catalog.workspace");
|
||||||
const mayLeave = useCallback(
|
const mayLeave = useCallback(
|
||||||
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
|
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
|
||||||
[state.editorDirty, tActions],
|
[state.editorDirty, tActions],
|
||||||
);
|
);
|
||||||
|
const mayLeaveRef = useRef(mayLeave);
|
||||||
|
mayLeaveRef.current = mayLeave;
|
||||||
const restoreSelection = useCallback(
|
const restoreSelection = useCallback(
|
||||||
(selection: CatalogSelection) => {
|
(selection: CatalogSelection) => {
|
||||||
dispatch({ type: "RESTORE_SELECTION", ...selection });
|
dispatch({ type: "RESTORE_SELECTION", ...selection });
|
||||||
@@ -196,6 +206,11 @@ function CatalogManagerInner({
|
|||||||
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
|
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
|
||||||
return;
|
return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (searchOpen) {
|
||||||
|
searchNavigation.cancel();
|
||||||
|
setSearchOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
};
|
};
|
||||||
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
const onBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
@@ -210,7 +225,7 @@ function CatalogManagerInner({
|
|||||||
window.removeEventListener("keydown", onKey);
|
window.removeEventListener("keydown", onKey);
|
||||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
};
|
};
|
||||||
}, [close, state.editorDirty, embedded]);
|
}, [close, state.editorDirty, embedded, searchOpen, searchNavigation]);
|
||||||
|
|
||||||
const loadRootPages = useCallback(async () => {
|
const loadRootPages = useCallback(async () => {
|
||||||
const request = rootRequests.start();
|
const request = rootRequests.start();
|
||||||
@@ -253,10 +268,12 @@ function CatalogManagerInner({
|
|||||||
}, [open, dispatch]);
|
}, [open, dispatch]);
|
||||||
|
|
||||||
const handleTabClick = (id: number) => {
|
const handleTabClick = (id: number) => {
|
||||||
|
searchNavigation.cancel();
|
||||||
if (id === state.activeTabId) return;
|
if (id === state.activeTabId) return;
|
||||||
if (!mayLeave()) {
|
if (!mayLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSearchTarget((previous) => ({ ...previous, offerId: null }));
|
||||||
saveSelectionUrl({ rootId: id, pageId: null });
|
saveSelectionUrl({ rootId: id, pageId: null });
|
||||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||||
};
|
};
|
||||||
@@ -285,17 +302,21 @@ function CatalogManagerInner({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectPage = (id: number) => {
|
const handleSelectPage = (id: number) => {
|
||||||
|
searchNavigation.cancel();
|
||||||
if (id === state.selectedPageId) return;
|
if (id === state.selectedPageId) return;
|
||||||
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
|
if (!mayLeave()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSearchTarget((previous) => ({ ...previous, offerId: null }));
|
||||||
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
|
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
|
||||||
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
|
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditTab = (id: number) => {
|
const handleEditTab = (id: number) => {
|
||||||
|
searchNavigation.cancel();
|
||||||
if (id === state.selectedPageId && id === state.activeTabId) return;
|
if (id === state.selectedPageId && id === state.activeTabId) return;
|
||||||
if (!mayLeave()) return;
|
if (!mayLeave()) return;
|
||||||
|
setSearchTarget((previous) => ({ ...previous, offerId: null }));
|
||||||
saveSelectionUrl({ rootId: id, pageId: id });
|
saveSelectionUrl({ rootId: id, pageId: id });
|
||||||
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
|
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||||
dispatch({ type: "SELECT_PAGE", id });
|
dispatch({ type: "SELECT_PAGE", id });
|
||||||
@@ -316,6 +337,49 @@ function CatalogManagerInner({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function openSearchResult(result: CatalogSearchResult) {
|
||||||
|
const request = searchNavigation.start();
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/catalog/tree?ancestors=${result.pageId}${catQs}`,
|
||||||
|
{ signal: request.signal },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw Error("Navigation failed");
|
||||||
|
const data = await response.json();
|
||||||
|
if (!request.isCurrent() || !mayLeaveRef.current()) return;
|
||||||
|
const rootId = Number(data.ancestors?.[0]?.id ?? result.pageId);
|
||||||
|
dispatch({ type: "RESTORE_SELECTION", rootId, pageId: result.pageId });
|
||||||
|
saveSelectionUrl({ rootId, pageId: result.pageId });
|
||||||
|
setSearchTarget((previous) => ({
|
||||||
|
offerId: result.kind === "offer" ? result.id : null,
|
||||||
|
key: previous.key + 1,
|
||||||
|
}));
|
||||||
|
setSearchOpen(false);
|
||||||
|
} catch {
|
||||||
|
if (request.isCurrent()) toast.error(t("searchFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
!(event.ctrlKey || event.metaKey) ||
|
||||||
|
!event.shiftKey ||
|
||||||
|
event.key.toLowerCase() !== "f"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const dialog = (event.target as HTMLElement).closest?.('[role="dialog"]');
|
||||||
|
if (
|
||||||
|
dialog &&
|
||||||
|
dialog.getAttribute("aria-labelledby") !==
|
||||||
|
"visual-catalog-manager-title"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
setSearchOpen(true);
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
const heading =
|
const heading =
|
||||||
catalogType === "bc"
|
catalogType === "bc"
|
||||||
? "BC Visual Catalog Manager"
|
? "BC Visual Catalog Manager"
|
||||||
@@ -334,19 +398,44 @@ function CatalogManagerInner({
|
|||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
{heading}
|
{heading}
|
||||||
</h2>
|
</h2>
|
||||||
{!embedded && (
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="icon"
|
aria-label={t("globalSearch")}
|
||||||
className="h-8 w-8 shrink-0"
|
aria-expanded={searchOpen}
|
||||||
onClick={close}
|
onClick={() => {
|
||||||
aria-label="Close"
|
searchNavigation.cancel();
|
||||||
|
setSearchOpen((value) => !value);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<Search className="size-4" />
|
||||||
|
<span className="hidden sm:inline">{t("globalSearch")}</span>
|
||||||
|
<kbd className="hidden lg:inline text-xs">Ctrl Shift F</kbd>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
{!embedded && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{searchOpen && (
|
||||||
|
<div className="shrink-0 max-h-[45vh] overflow-y-auto border-b p-2">
|
||||||
|
<CatalogSearch
|
||||||
|
catalogType={catalogType}
|
||||||
|
onNavigate={openSearchResult}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<BreadcrumbBar onSelectPage={handleSelectPage} />
|
<BreadcrumbBar onSelectPage={handleSelectPage} />
|
||||||
|
|
||||||
@@ -387,6 +476,8 @@ function CatalogManagerInner({
|
|||||||
{t("backToCategories")}
|
{t("backToCategories")}
|
||||||
</Button>
|
</Button>
|
||||||
<InlineEditor
|
<InlineEditor
|
||||||
|
key={`${state.selectedPageId}:${searchTarget.key}`}
|
||||||
|
initialOfferId={searchTarget.offerId}
|
||||||
pageId={state.selectedPageId}
|
pageId={state.selectedPageId}
|
||||||
onSaved={handleEditorSaved}
|
onSaved={handleEditorSaved}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export interface PageUpdateData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface InlineEditorProps {
|
interface InlineEditorProps {
|
||||||
|
initialOfferId?: number | null;
|
||||||
pageId: number | null;
|
pageId: number | null;
|
||||||
onSaved: (updated: PageUpdateData) => void;
|
onSaved: (updated: PageUpdateData) => void;
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,11 @@ export function InlineEditor(props: InlineEditorProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
function InlineEditorSession({
|
||||||
|
pageId,
|
||||||
|
onSaved,
|
||||||
|
initialOfferId,
|
||||||
|
}: InlineEditorProps) {
|
||||||
const pageRequests = useLatestRequest();
|
const pageRequests = useLatestRequest();
|
||||||
const itemRequests = useLatestRequest();
|
const itemRequests = useLatestRequest();
|
||||||
const saveRequests = useLatestRequest();
|
const saveRequests = useLatestRequest();
|
||||||
@@ -135,7 +140,9 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
const [form, setForm] = useState<FormState>({});
|
const [form, setForm] = useState<FormState>({});
|
||||||
const [original, setOriginal] = useState<FormState>({});
|
const [original, setOriginal] = useState<FormState>({});
|
||||||
const [showTexts, setShowTexts] = useState(false);
|
const [showTexts, setShowTexts] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState("settings");
|
const [activeTab, setActiveTab] = useState(
|
||||||
|
initialOfferId ? "items" : "settings",
|
||||||
|
);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
|
||||||
// Items/translate data (lazy loaded)
|
// Items/translate data (lazy loaded)
|
||||||
@@ -247,7 +254,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
loadPage(pageId);
|
loadPage(pageId);
|
||||||
setItemsData(null);
|
setItemsData(null);
|
||||||
setItemsLoaded(false);
|
setItemsLoaded(false);
|
||||||
setActiveTab("settings");
|
setActiveTab(initialOfferId ? "items" : "settings");
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
} else {
|
} else {
|
||||||
setPage(null);
|
setPage(null);
|
||||||
@@ -256,7 +263,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
setItemsData(null);
|
setItemsData(null);
|
||||||
setItemsLoaded(false);
|
setItemsLoaded(false);
|
||||||
}
|
}
|
||||||
}, [pageId, loadPage]);
|
}, [pageId, loadPage, initialOfferId]);
|
||||||
|
|
||||||
// Lazy-load items (shop catalog only — BC items live on the detail page)
|
// Lazy-load items (shop catalog only — BC items live on the detail page)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -360,14 +367,14 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CatalogIcon
|
<CatalogIcon
|
||||||
iconImage={(form.iconImage as number) ?? page.iconImage}
|
iconImage={(form.iconImage as number) ?? page.iconImage}
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-sm font-semibold">
|
||||||
{(form.caption as string) || page.caption}
|
{(form.caption as string) || page.caption}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -379,13 +386,13 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
>
|
>
|
||||||
{form.pageLayout as string}
|
{form.pageLayout as string}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isDirty && (
|
{(isDirty || offersDirty) && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
|
className="h-4 px-1.5 text-[9px] text-[var(--admin-warning)] border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
|
<AlertCircle className="h-2.5 w-2.5 mr-0.5" />
|
||||||
unsaved
|
{t("unsaved")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -397,9 +404,13 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={pageHref} target="_blank">
|
<Link href={pageHref} target="_blank">
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs gap-1 text-[var(--admin-text)]"
|
||||||
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
Full editor
|
{t("fullEditor")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,13 +419,7 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
{isDirty && (
|
{isDirty && (
|
||||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
|
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--admin-warning-subtle)] border-b border-[var(--admin-warning-border)] text-xs text-[var(--admin-warning)] shrink-0">
|
||||||
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="flex-1">
|
<span className="flex-1">{t("saveHint")}</span>
|
||||||
Unsaved changes — press{" "}
|
|
||||||
<kbd className="rounded border bg-background px-1 font-mono text-[10px]">
|
|
||||||
Ctrl+S
|
|
||||||
</kbd>{" "}
|
|
||||||
to save
|
|
||||||
</span>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -768,6 +773,9 @@ function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
|
|
||||||
{/* Items table */}
|
{/* Items table */}
|
||||||
<CatalogItemsTable
|
<CatalogItemsTable
|
||||||
|
initialSearch={
|
||||||
|
initialOfferId ? `#${initialOfferId}` : undefined
|
||||||
|
}
|
||||||
onDirtyChange={setOffersDirty}
|
onDirtyChange={setOffersDirty}
|
||||||
onRefresh={reloadItems}
|
onRefresh={reloadItems}
|
||||||
items={itemsData.items}
|
items={itemsData.items}
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ import { BulkOfferEditor } from "./bulk-offer-editor";
|
|||||||
export function CatalogSearch({
|
export function CatalogSearch({
|
||||||
catalogType = "normal",
|
catalogType = "normal",
|
||||||
canEdit = false,
|
canEdit = false,
|
||||||
|
onNavigate,
|
||||||
|
autoFocus = false,
|
||||||
}: {
|
}: {
|
||||||
catalogType?: SearchCatalogKind;
|
catalogType?: SearchCatalogKind;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
onNavigate?: (result: CatalogSearchResult) => void | Promise<void>;
|
||||||
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.catalog.workspace");
|
const t = useTranslations("pages.admin.catalog.workspace");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -30,6 +34,10 @@ export function CatalogSearch({
|
|||||||
"idle",
|
"idle",
|
||||||
);
|
);
|
||||||
const requests = useLatestRequest();
|
const requests = useLatestRequest();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) inputRef.current?.focus();
|
||||||
|
}, [autoFocus]);
|
||||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
@@ -135,6 +143,7 @@ export function CatalogSearch({
|
|||||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
aria-label={t("globalSearch")}
|
aria-label={t("globalSearch")}
|
||||||
placeholder={t("searchPlaceholder")}
|
placeholder={t("searchPlaceholder")}
|
||||||
value={query}
|
value={query}
|
||||||
@@ -230,13 +239,25 @@ export function CatalogSearch({
|
|||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={result.href}
|
href={result.href}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (
|
||||||
|
onNavigate &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
!event.altKey
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
void onNavigate(result);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="block min-w-0 flex-1 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">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t(result.kind)}
|
{t(result.kind)}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium break-words">
|
<span className="font-medium break-words text-[var(--admin-text)]">
|
||||||
{result.caption}
|
{result.caption}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { discoverOffers } from "./offer-discovery";
|
||||||
|
|
||||||
|
const offers = [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
catalogName: "Oak chair",
|
||||||
|
baseItemName: "chair_oak",
|
||||||
|
baseName: "Wooden chair",
|
||||||
|
itemIds: "10",
|
||||||
|
costCredits: 0,
|
||||||
|
costPoints: 0,
|
||||||
|
limitedStack: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
catalogName: "Chair pair",
|
||||||
|
baseItemName: "",
|
||||||
|
baseName: "",
|
||||||
|
itemIds: "10;11",
|
||||||
|
costCredits: 0,
|
||||||
|
costPoints: 5,
|
||||||
|
limitedStack: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
catalogName: "Limited table",
|
||||||
|
baseItemName: "table_oak",
|
||||||
|
baseName: "Wooden table",
|
||||||
|
itemIds: "12",
|
||||||
|
costCredits: 15,
|
||||||
|
costPoints: 0,
|
||||||
|
limitedStack: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
catalogName: "Welcome bundle",
|
||||||
|
baseItemName: "",
|
||||||
|
baseName: "",
|
||||||
|
itemIds: " 10 ; 12 ",
|
||||||
|
costCredits: 0,
|
||||||
|
costPoints: 0,
|
||||||
|
limitedStack: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("offer discovery", () => {
|
||||||
|
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
|
||||||
|
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
|
||||||
|
expect(
|
||||||
|
discoverOffers(rows, " #7 ", "all").items.map((item) => item.id),
|
||||||
|
).toEqual([7]);
|
||||||
|
});
|
||||||
|
it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "", "free").items.map((item) => item.id),
|
||||||
|
).toEqual([7, 10]);
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "", "paid").items.map((item) => item.id),
|
||||||
|
).toEqual([8, 9]);
|
||||||
|
});
|
||||||
|
it("recognizes semicolon bundles without requiring a single base item", () => {
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "", "bundles").items.map((item) => item.id),
|
||||||
|
).toEqual([8, 10]);
|
||||||
|
expect(
|
||||||
|
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
it("identifies limited editions by stock even without sold quantities", () => {
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "", "limited").items.map((item) => item.id),
|
||||||
|
).toEqual([9]);
|
||||||
|
});
|
||||||
|
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
|
||||||
|
const result = discoverOffers(offers, " CHAIR ", "paid");
|
||||||
|
expect(result.items.map((item) => item.id)).toEqual([8]);
|
||||||
|
expect(result.counts).toEqual({
|
||||||
|
all: 2,
|
||||||
|
free: 1,
|
||||||
|
paid: 1,
|
||||||
|
bundles: 1,
|
||||||
|
limited: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("searches public names, classnames, offer IDs and furniture references", () => {
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "Wooden", "all").items.map((item) => item.id),
|
||||||
|
).toEqual([7, 9]);
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "table_oak", "all").items.map((item) => item.id),
|
||||||
|
).toEqual([9]);
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "7", "all").items.map((item) => item.id),
|
||||||
|
).toEqual([7]);
|
||||||
|
expect(
|
||||||
|
discoverOffers(offers, "11", "all").items.map((item) => item.id),
|
||||||
|
).toEqual([8]);
|
||||||
|
});
|
||||||
|
it("keeps source ordering and returns empty counts when the query has no matches", () => {
|
||||||
|
const copy = structuredClone(offers);
|
||||||
|
expect(discoverOffers(offers, " ", "all").items).toEqual(offers);
|
||||||
|
expect(discoverOffers(offers, "absent", "all")).toEqual({
|
||||||
|
items: [],
|
||||||
|
counts: { all: 0, free: 0, paid: 0, bundles: 0, limited: 0 },
|
||||||
|
});
|
||||||
|
expect(offers).toEqual(copy);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||||
|
|
||||||
|
export const OFFER_FILTERS = [
|
||||||
|
"all",
|
||||||
|
"free",
|
||||||
|
"paid",
|
||||||
|
"bundles",
|
||||||
|
"limited",
|
||||||
|
] as const;
|
||||||
|
export type OfferFilter = (typeof OFFER_FILTERS)[number];
|
||||||
|
type DiscoverableOffer = Pick<
|
||||||
|
CatalogItemData,
|
||||||
|
| "id"
|
||||||
|
| "catalogName"
|
||||||
|
| "baseItemName"
|
||||||
|
| "baseName"
|
||||||
|
| "itemIds"
|
||||||
|
| "costCredits"
|
||||||
|
| "costPoints"
|
||||||
|
| "limitedStack"
|
||||||
|
>;
|
||||||
|
|
||||||
|
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
|
||||||
|
switch (filter) {
|
||||||
|
case "all":
|
||||||
|
return true;
|
||||||
|
case "free":
|
||||||
|
return item.costCredits === 0 && item.costPoints === 0;
|
||||||
|
case "paid":
|
||||||
|
return item.costCredits > 0 || item.costPoints > 0;
|
||||||
|
case "bundles":
|
||||||
|
return (
|
||||||
|
item.itemIds.split(";").filter((part) => part.trim().length > 0)
|
||||||
|
.length > 1
|
||||||
|
);
|
||||||
|
case "limited":
|
||||||
|
return item.limitedStack > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Facet counts apply to the text search so changing a facet never hides its available matches. */
|
||||||
|
export function discoverOffers<T extends DiscoverableOffer>(
|
||||||
|
items: readonly T[],
|
||||||
|
query: string,
|
||||||
|
filter: OfferFilter,
|
||||||
|
) {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
|
||||||
|
const matches =
|
||||||
|
exactId !== null
|
||||||
|
? items.filter((item) => String(item.id) === exactId)
|
||||||
|
: normalized
|
||||||
|
? items.filter((item) =>
|
||||||
|
[
|
||||||
|
item.catalogName,
|
||||||
|
item.baseItemName,
|
||||||
|
item.baseName,
|
||||||
|
item.itemIds,
|
||||||
|
String(item.id),
|
||||||
|
].some((value) => value.toLowerCase().includes(normalized)),
|
||||||
|
)
|
||||||
|
: [...items];
|
||||||
|
const counts = {
|
||||||
|
all: matches.length,
|
||||||
|
free: 0,
|
||||||
|
paid: 0,
|
||||||
|
bundles: 0,
|
||||||
|
limited: 0,
|
||||||
|
};
|
||||||
|
for (const item of matches)
|
||||||
|
for (const facet of OFFER_FILTERS) {
|
||||||
|
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: matches.filter((item) => matchesFilter(item, filter)),
|
||||||
|
counts,
|
||||||
|
};
|
||||||
|
}
|
||||||
+22
-1
@@ -1966,7 +1966,10 @@
|
|||||||
"suggested": "Suggested",
|
"suggested": "Suggested",
|
||||||
"all": "All categories",
|
"all": "All categories",
|
||||||
"draftBlocked": "Save or discard category edits before duplicating."
|
"draftBlocked": "Save or discard category edits before duplicating."
|
||||||
}
|
},
|
||||||
|
"unsaved": "Unsaved changes",
|
||||||
|
"fullEditor": "Full editor",
|
||||||
|
"saveHint": "Category changes are not saved. Use Save or Ctrl+S."
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"previewCount": "{count} of {total} offers will change.",
|
"previewCount": "{count} of {total} offers will change.",
|
||||||
@@ -2024,6 +2027,24 @@
|
|||||||
"back": "Edit changes",
|
"back": "Edit changes",
|
||||||
"selectionLimit": "Select at most 500 offers for one operation.",
|
"selectionLimit": "Select at most 500 offers for one operation.",
|
||||||
"refreshing": "Refreshing updated offers…"
|
"refreshing": "Refreshing updated offers…"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
|
||||||
|
"filterLabel": "Filter offers",
|
||||||
|
"filters": {
|
||||||
|
"all": "All offers",
|
||||||
|
"free": "Free",
|
||||||
|
"paid": "Paid",
|
||||||
|
"bundles": "Bundles",
|
||||||
|
"limited": "Limited editions"
|
||||||
|
},
|
||||||
|
"shown": "({count} shown)",
|
||||||
|
"selected": "{count} selected",
|
||||||
|
"reset": "Reset filters",
|
||||||
|
"results": "Showing {count} of {total} offers.",
|
||||||
|
"selectionHint": "Changing the search or filter clears the selection.",
|
||||||
|
"noResults": "No offers match the current search and filter.",
|
||||||
|
"selectVisible": "Select all visible offers"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shop": {
|
"shop": {
|
||||||
|
|||||||
+22
-1
@@ -1885,7 +1885,10 @@
|
|||||||
"suggested": "Suggerite",
|
"suggested": "Suggerite",
|
||||||
"all": "Tutte le categorie",
|
"all": "Tutte le categorie",
|
||||||
"draftBlocked": "Salva o annulla le modifiche alla categoria prima di duplicarla."
|
"draftBlocked": "Salva o annulla le modifiche alla categoria prima di duplicarla."
|
||||||
}
|
},
|
||||||
|
"unsaved": "Modifiche non salvate",
|
||||||
|
"fullEditor": "Editor completo",
|
||||||
|
"saveHint": "Le modifiche alla categoria non sono salvate. Usa Salva o Ctrl+S."
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"previewCount": "Verranno modificate {count} offerte su {total}.",
|
"previewCount": "Verranno modificate {count} offerte su {total}.",
|
||||||
@@ -1943,6 +1946,24 @@
|
|||||||
"back": "Rivedi le modifiche",
|
"back": "Rivedi le modifiche",
|
||||||
"selectionLimit": "Seleziona al massimo 500 offerte per operazione.",
|
"selectionLimit": "Seleziona al massimo 500 offerte per operazione.",
|
||||||
"refreshing": "Aggiornamento delle offerte modificate…"
|
"refreshing": "Aggiornamento delle offerte modificate…"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"searchPlaceholder": "Nome, classname, ID furni o #ID offerta…",
|
||||||
|
"filterLabel": "Filtra offerte",
|
||||||
|
"filters": {
|
||||||
|
"all": "Tutte le offerte",
|
||||||
|
"free": "Gratuite",
|
||||||
|
"paid": "A pagamento",
|
||||||
|
"bundles": "Pacchetti",
|
||||||
|
"limited": "Edizioni limitate"
|
||||||
|
},
|
||||||
|
"shown": "({count} visibili)",
|
||||||
|
"selected": "{count} selezionate",
|
||||||
|
"reset": "Reimposta filtri",
|
||||||
|
"results": "{count} offerte visibili su {total}.",
|
||||||
|
"selectionHint": "Cambiare ricerca o filtro azzera la selezione.",
|
||||||
|
"noResults": "Nessuna offerta corrisponde alla ricerca e al filtro attuali.",
|
||||||
|
"selectVisible": "Seleziona tutte le offerte visibili"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shop": {
|
"shop": {
|
||||||
|
|||||||
+22
-1
@@ -1715,7 +1715,10 @@
|
|||||||
"suggested": "Voorgesteld",
|
"suggested": "Voorgesteld",
|
||||||
"all": "Alle categorieën",
|
"all": "Alle categorieën",
|
||||||
"draftBlocked": "Sla categoriewijzigingen op of annuleer ze voordat je dupliceert."
|
"draftBlocked": "Sla categoriewijzigingen op of annuleer ze voordat je dupliceert."
|
||||||
}
|
},
|
||||||
|
"unsaved": "Niet-opgeslagen wijzigingen",
|
||||||
|
"fullEditor": "Volledige editor",
|
||||||
|
"saveHint": "Categoriewijzigingen zijn niet opgeslagen. Gebruik Opslaan of Ctrl+S."
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"previewCount": "{count} van {total} aanbiedingen worden gewijzigd.",
|
"previewCount": "{count} van {total} aanbiedingen worden gewijzigd.",
|
||||||
@@ -1773,6 +1776,24 @@
|
|||||||
"back": "Wijzigingen aanpassen",
|
"back": "Wijzigingen aanpassen",
|
||||||
"selectionLimit": "Selecteer maximaal 500 aanbiedingen per bewerking.",
|
"selectionLimit": "Selecteer maximaal 500 aanbiedingen per bewerking.",
|
||||||
"refreshing": "Bijgewerkte aanbiedingen vernieuwen…"
|
"refreshing": "Bijgewerkte aanbiedingen vernieuwen…"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"searchPlaceholder": "Naam, classname, meubel-ID of #aanbiedings-ID…",
|
||||||
|
"filterLabel": "Aanbiedingen filteren",
|
||||||
|
"filters": {
|
||||||
|
"all": "Alle aanbiedingen",
|
||||||
|
"free": "Gratis",
|
||||||
|
"paid": "Betaald",
|
||||||
|
"bundles": "Bundels",
|
||||||
|
"limited": "Gelimiteerde edities"
|
||||||
|
},
|
||||||
|
"shown": "({count} zichtbaar)",
|
||||||
|
"selected": "{count} geselecteerd",
|
||||||
|
"reset": "Filters wissen",
|
||||||
|
"results": "{count} van {total} aanbiedingen zichtbaar.",
|
||||||
|
"selectionHint": "De selectie wordt gewist wanneer je de zoekopdracht of het filter wijzigt.",
|
||||||
|
"noResults": "Geen aanbiedingen passen bij de huidige zoekopdracht en het filter.",
|
||||||
|
"selectVisible": "Alle zichtbare aanbiedingen selecteren"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"shop": {
|
"shop": {
|
||||||
|
|||||||
Reference in new issue
Block a user