refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
Save,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateCatalogPage } from "@/actions/catalog";
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
|
||||
import {
|
||||
CATALOG_LAYOUTS,
|
||||
type CatalogLayout,
|
||||
@@ -106,7 +108,21 @@ interface InlineEditorProps {
|
||||
|
||||
type FormState = Record<string, string | number | null>;
|
||||
|
||||
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
export function InlineEditor(props: InlineEditorProps) {
|
||||
const { catalogType } = useCatalogManager();
|
||||
return (
|
||||
<InlineEditorSession key={`${catalogType}:${props.pageId}`} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function InlineEditorSession({ pageId, onSaved }: InlineEditorProps) {
|
||||
const pageRequests = useLatestRequest();
|
||||
const itemRequests = useLatestRequest();
|
||||
const saveRequests = useLatestRequest();
|
||||
const tActions = useTranslations("pages.admin.actions");
|
||||
const tNavigation = useTranslations("pages.admin.navigation");
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [offersDirty, setOffersDirty] = useState(false);
|
||||
const { dispatch, catalogType, canEdit } = useCatalogManager();
|
||||
const catQs = catalogTreeQs(catalogType);
|
||||
const pageHref =
|
||||
@@ -126,6 +142,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
const [itemsData, setItemsData] = useState<ItemsData | null>(null);
|
||||
const [itemsLoading, setItemsLoading] = useState(false);
|
||||
const [itemsLoaded, setItemsLoaded] = useState(false);
|
||||
const [itemsError, setItemsError] = useState(false);
|
||||
|
||||
// ── Dirty tracking ──────────────────────────────────────────
|
||||
const isDirty = useMemo(() => {
|
||||
@@ -133,8 +150,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
}, [form, original]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "SET_DIRTY", dirty: isDirty });
|
||||
}, [isDirty, dispatch]);
|
||||
dispatch({ type: "SET_DIRTY", dirty: isDirty || offersDirty });
|
||||
}, [isDirty, offersDirty, dispatch]);
|
||||
|
||||
// ── Ctrl+S handler ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -151,11 +168,16 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (id: number) => {
|
||||
const request = pageRequests.start();
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
|
||||
const res = await fetch(
|
||||
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
|
||||
{ signal: request.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
if (!request.isCurrent()) return;
|
||||
const p = data.page as PageDetail;
|
||||
setPage(p);
|
||||
const formData: FormState = {
|
||||
@@ -163,8 +185,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
pageLayout: p.pageLayout,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
minRank: p.minRank ?? 0,
|
||||
clubOnly: String(p.clubOnly ?? "0"),
|
||||
...(catalogType === "normal"
|
||||
? { minRank: p.minRank ?? 0, clubOnly: String(p.clubOnly ?? "0") }
|
||||
: {}),
|
||||
orderNum: p.orderNum,
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
@@ -179,29 +202,41 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
setForm(formData);
|
||||
setOriginal(formData);
|
||||
} catch {
|
||||
if (!request.isCurrent()) return;
|
||||
toast.error("Failed to load page");
|
||||
setPage(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (request.isCurrent()) setLoading(false);
|
||||
}
|
||||
},
|
||||
[catQs],
|
||||
[catQs, catalogType, pageRequests],
|
||||
);
|
||||
|
||||
const loadItemsData = useCallback(async (id: number) => {
|
||||
setItemsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
setItemsData(data as ItemsData);
|
||||
setItemsLoaded(true);
|
||||
} catch {
|
||||
toast.error("Failed to load items");
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
const loadItemsData = useCallback(
|
||||
async (id: number) => {
|
||||
const request = itemRequests.start();
|
||||
setItemsError(false);
|
||||
setItemsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/items?pageId=${id}`, {
|
||||
signal: request.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
if (!request.isCurrent()) return;
|
||||
setItemsData(data as ItemsData);
|
||||
setItemsLoaded(true);
|
||||
} catch {
|
||||
if (!request.isCurrent()) return;
|
||||
setItemsLoaded(true);
|
||||
setItemsError(true);
|
||||
toast.error("Failed to load items");
|
||||
} finally {
|
||||
if (request.isCurrent()) setItemsLoading(false);
|
||||
}
|
||||
},
|
||||
[itemRequests],
|
||||
);
|
||||
|
||||
const reloadItems = useCallback(() => {
|
||||
if (pageId && pageId > 0) loadItemsData(pageId);
|
||||
@@ -250,15 +285,21 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!page || !canEdit) return;
|
||||
if (!page || !canEdit || saving) return;
|
||||
const request = saveRequests.start();
|
||||
setSaving(true);
|
||||
try {
|
||||
const result =
|
||||
catalogType === "bc"
|
||||
? await updateBcPage({ id: page.id, ...form })
|
||||
: await updateCatalogPage({ id: page.id, ...form });
|
||||
? await updateBcPage({ id: page.id, ...form, expected: original })
|
||||
: await updateCatalogPage({
|
||||
id: page.id,
|
||||
...form,
|
||||
expected: original,
|
||||
});
|
||||
if (!request.isCurrent()) return;
|
||||
if (!result.ok) throw new Error(result.error || "Failed to save");
|
||||
toast.success("Page updated & catalog synced");
|
||||
toast.success(tActions("saved"));
|
||||
setOriginal({ ...form });
|
||||
setPage((prev) =>
|
||||
prev
|
||||
@@ -281,9 +322,10 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
iconImage: (form.iconImage as number) ?? page.iconImage,
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save");
|
||||
if (request.isCurrent())
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
if (request.isCurrent()) setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +345,14 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!page) return null;
|
||||
if (!page)
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Button variant="outline" onClick={() => loadPage(pageId)}>
|
||||
{t("retryPage")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const layoutColor =
|
||||
LAYOUT_COLORS[String(form.pageLayout)] || "bg-muted text-muted-foreground";
|
||||
@@ -386,7 +435,12 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
onValueChange={(tab) => {
|
||||
if (tab === activeTab) return;
|
||||
if (offersDirty && !window.confirm(tActions("leaveUnsaved"))) return;
|
||||
setOffersDirty(false);
|
||||
setActiveTab(tab);
|
||||
}}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="mx-4 mt-2 shrink-0">
|
||||
@@ -490,17 +544,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Min Rank</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.minRank as number}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{catalogType === "normal" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Min Rank</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.minRank as number}
|
||||
onChange={(e) =>
|
||||
update("minRank", parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Icon</Label>
|
||||
<IconPicker
|
||||
@@ -526,13 +582,15 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
checked={form.visible === "1"}
|
||||
onChange={(v) => update("visible", v ? "1" : "0")}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="ie-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
/>
|
||||
{catalogType === "normal" && (
|
||||
<ToggleRow
|
||||
id="ie-club"
|
||||
label="Club Only"
|
||||
description="Requires HC subscription"
|
||||
checked={form.clubOnly === "1"}
|
||||
onChange={(v) => update("clubOnly", v ? "1" : "0")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headline image picker */}
|
||||
@@ -641,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save & Sync
|
||||
{tNavigation("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -662,7 +720,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : itemsLoading ? (
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
{t("retryItems")}
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
@@ -704,6 +768,8 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
|
||||
{/* Items table */}
|
||||
<CatalogItemsTable
|
||||
onDirtyChange={setOffersDirty}
|
||||
onRefresh={reloadItems}
|
||||
items={itemsData.items}
|
||||
pageId={page.id}
|
||||
pageLayout={page.pageLayout}
|
||||
@@ -725,7 +791,13 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
||||
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
Translate tools are available for the shop catalog only.
|
||||
</div>
|
||||
) : itemsLoading ? (
|
||||
) : itemsError && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Button variant="outline" onClick={reloadItems}>
|
||||
{t("retryItems")}
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading && !itemsData ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user