432 lines
12 KiB
TypeScript
432 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||
import CategoryTreeView, {
|
||
type CategoryTreeNode,
|
||
} from "@/components/CategoryTreeView";
|
||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||
import {
|
||
categoryLabel,
|
||
createMeshkeeCategory,
|
||
deleteMeshkeeCategory,
|
||
searchMeshkeeCategories,
|
||
updateMeshkeeCategory,
|
||
type MeshkeeCategory,
|
||
} from "@/services/meshkee-categories";
|
||
import styles from "./BusinessCategoriesPage.module.css";
|
||
|
||
type Draft = {
|
||
id?: string;
|
||
parentId: string;
|
||
name: string;
|
||
nameFa: string;
|
||
description: string;
|
||
};
|
||
|
||
function emptyDraft(parentId = ""): Draft {
|
||
return {
|
||
parentId,
|
||
name: "",
|
||
nameFa: "",
|
||
description: "",
|
||
};
|
||
}
|
||
|
||
function toViewNodes(items: MeshkeeCategory[]): CategoryTreeNode[] {
|
||
const byId = new Map<string, CategoryTreeNode>();
|
||
for (const item of items) {
|
||
byId.set(item.id, {
|
||
id: item.id,
|
||
name: categoryLabel(item),
|
||
hint: item.slug ? `/${item.slug}` : undefined,
|
||
children: [],
|
||
});
|
||
}
|
||
const roots: CategoryTreeNode[] = [];
|
||
for (const item of items) {
|
||
const node = byId.get(item.id)!;
|
||
if (item.parentId && byId.has(item.parentId)) {
|
||
byId.get(item.parentId)!.children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
}
|
||
const sortNodes = (nodes: CategoryTreeNode[], source: MeshkeeCategory[]) => {
|
||
const order = new Map(source.map((item) => [item.id, item.sortOrder]));
|
||
nodes.sort(
|
||
(a, b) =>
|
||
(order.get(a.id) ?? 0) - (order.get(b.id) ?? 0) ||
|
||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||
);
|
||
for (const node of nodes) sortNodes(node.children, source);
|
||
};
|
||
sortNodes(roots, items);
|
||
return roots;
|
||
}
|
||
|
||
function collectDescendantIds(
|
||
rootId: string,
|
||
items: MeshkeeCategory[],
|
||
): Set<string> {
|
||
const childrenByParent = new Map<string, string[]>();
|
||
for (const item of items) {
|
||
if (!item.parentId) continue;
|
||
const list = childrenByParent.get(item.parentId) ?? [];
|
||
list.push(item.id);
|
||
childrenByParent.set(item.parentId, list);
|
||
}
|
||
const result = new Set<string>([rootId]);
|
||
const queue = [rootId];
|
||
while (queue.length) {
|
||
const current = queue.shift()!;
|
||
for (const childId of childrenByParent.get(current) ?? []) {
|
||
if (!result.has(childId)) {
|
||
result.add(childId);
|
||
queue.push(childId);
|
||
}
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
export default function BusinessCategoriesPage() {
|
||
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [draft, setDraft] = useState<Draft | null>(null);
|
||
const [removing, setRemoving] = useState<MeshkeeCategory | null>(null);
|
||
const [removeBusy, setRemoveBusy] = useState(false);
|
||
const tree = useMemo(() => toViewNodes(categories), [categories]);
|
||
const byId = useMemo(() => {
|
||
const map = new Map<string, MeshkeeCategory>();
|
||
for (const item of categories) map.set(item.id, item);
|
||
return map;
|
||
}, [categories]);
|
||
|
||
const parentOptions = useMemo(() => {
|
||
if (!draft?.id) return categories;
|
||
const blocked = collectDescendantIds(draft.id, categories);
|
||
return categories.filter((item) => !blocked.has(item.id));
|
||
}, [categories, draft?.id]);
|
||
|
||
const reload = useCallback(async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
setCategories(await searchMeshkeeCategories(""));
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Could not load categories.",
|
||
);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
}, [reload]);
|
||
|
||
function openCreate(parentId = "") {
|
||
setDraft(emptyDraft(parentId));
|
||
setError("");
|
||
}
|
||
|
||
function openEdit(id: string) {
|
||
const category = byId.get(id);
|
||
if (!category) return;
|
||
setDraft({
|
||
id: category.id,
|
||
parentId: category.parentId ?? "",
|
||
name: category.name,
|
||
nameFa: category.nameFa ?? "",
|
||
description: category.description ?? "",
|
||
});
|
||
setError("");
|
||
}
|
||
|
||
async function saveDraft() {
|
||
if (!draft) return;
|
||
if (!draft.name.trim()) {
|
||
setError("Enter an English name.");
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
setError("");
|
||
try {
|
||
const body = {
|
||
parentId: draft.parentId || null,
|
||
name: draft.name.trim(),
|
||
nameFa: draft.nameFa.trim(),
|
||
description: draft.description.trim(),
|
||
};
|
||
if (draft.id) {
|
||
const updated = await updateMeshkeeCategory(draft.id, body);
|
||
setCategories((current) =>
|
||
current.map((item) => (item.id === updated.id ? updated : item)),
|
||
);
|
||
} else {
|
||
const created = await createMeshkeeCategory(body);
|
||
setCategories((current) => [...current, created]);
|
||
}
|
||
setDraft(null);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Could not save category.",
|
||
);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function confirmRemove() {
|
||
if (!removing) return;
|
||
setRemoveBusy(true);
|
||
setError("");
|
||
try {
|
||
const removed = await deleteMeshkeeCategory(removing.id);
|
||
const deleted = new Set(
|
||
(removed.deletedIds ?? [removing.id]).map(String),
|
||
);
|
||
deleted.add(removing.id);
|
||
setCategories((current) =>
|
||
current.filter((item) => !deleted.has(item.id)),
|
||
);
|
||
setRemoving(null);
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : "Could not remove category.",
|
||
);
|
||
} finally {
|
||
setRemoveBusy(false);
|
||
}
|
||
}
|
||
|
||
const removingName = removing
|
||
? categoryLabel(removing)
|
||
: "this category";
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<header className={styles.head}>
|
||
<div>
|
||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||
<Link href="/">Home</Link>
|
||
<span className={styles.breadcrumbSep} aria-hidden>
|
||
/
|
||
</span>
|
||
<Link href="/businesses">Businesses</Link>
|
||
<span className={styles.breadcrumbSep} aria-hidden>
|
||
/
|
||
</span>
|
||
<span className={styles.breadcrumbCurrent}>Categories</span>
|
||
</nav>
|
||
<h1 className={styles.title}>Categories</h1>
|
||
<p className={styles.lead}>
|
||
Marketplace categories from Meshkee. Add, edit, or remove the tree.
|
||
</p>
|
||
</div>
|
||
<div className={styles.headActions}>
|
||
<p className={styles.count}>
|
||
{loading ? "Loading…" : `${categories.length} categories`}
|
||
</p>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.addBtn}`}
|
||
onClick={() => openCreate()}
|
||
>
|
||
Add category
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{error ? (
|
||
<p className={styles.error} role="alert">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
<section className={styles.card}>
|
||
{loading ? <p className={styles.empty}>Loading categories…</p> : null}
|
||
{!loading ? (
|
||
<CategoryTreeView
|
||
nodes={tree}
|
||
actions={{
|
||
onAddChild: openCreate,
|
||
onEdit: openEdit,
|
||
onRemove: (id) => {
|
||
const category = byId.get(id);
|
||
if (category) setRemoving(category);
|
||
},
|
||
}}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
|
||
{draft ? (
|
||
<div
|
||
className={styles.overlay}
|
||
role="presentation"
|
||
onMouseDown={(event) => {
|
||
if (event.target === event.currentTarget && !saving) {
|
||
setDraft(null);
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
className={styles.modal}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="category-draft-title"
|
||
>
|
||
<div className={styles.modalHead}>
|
||
<div>
|
||
<p className={styles.kicker}>Categories</p>
|
||
<h2 id="category-draft-title" className={styles.modalTitle}>
|
||
{draft.id ? "Edit category" : "Add category"}
|
||
</h2>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.closeBtn}
|
||
aria-label="Close"
|
||
disabled={saving}
|
||
onClick={() => setDraft(null)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<form
|
||
className={styles.form}
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void saveDraft();
|
||
}}
|
||
>
|
||
<CategoryTreeSelect
|
||
categories={parentOptions}
|
||
value={draft.parentId ? [draft.parentId] : []}
|
||
selection="single"
|
||
label="Parent"
|
||
placeholder="Top level"
|
||
disabled={saving}
|
||
onChange={(ids) =>
|
||
setDraft((current) =>
|
||
current
|
||
? { ...current, parentId: ids[0] ?? "" }
|
||
: current,
|
||
)
|
||
}
|
||
/>
|
||
{draft.parentId ? (
|
||
<button
|
||
type="button"
|
||
className={styles.clearParent}
|
||
disabled={saving}
|
||
onClick={() =>
|
||
setDraft((current) =>
|
||
current ? { ...current, parentId: "" } : current,
|
||
)
|
||
}
|
||
>
|
||
Top level
|
||
</button>
|
||
) : null}
|
||
|
||
<label className={styles.field}>
|
||
<span>Name EN</span>
|
||
<input
|
||
value={draft.name}
|
||
disabled={saving}
|
||
required
|
||
autoFocus
|
||
onChange={(event) =>
|
||
setDraft((current) =>
|
||
current
|
||
? { ...current, name: event.target.value }
|
||
: current,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className={styles.field}>
|
||
<span>Name FA</span>
|
||
<input
|
||
className="fa"
|
||
lang="fa"
|
||
dir="rtl"
|
||
value={draft.nameFa}
|
||
disabled={saving}
|
||
onChange={(event) =>
|
||
setDraft((current) =>
|
||
current
|
||
? { ...current, nameFa: event.target.value }
|
||
: current,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className={styles.field}>
|
||
<span>Description</span>
|
||
<textarea
|
||
rows={3}
|
||
value={draft.description}
|
||
disabled={saving}
|
||
onChange={(event) =>
|
||
setDraft((current) =>
|
||
current
|
||
? { ...current, description: event.target.value }
|
||
: current,
|
||
)
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<div className={styles.modalActions}>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.ghostBtn}`}
|
||
disabled={saving}
|
||
onClick={() => setDraft(null)}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className={`outline-btn ${styles.addBtn}`}
|
||
disabled={saving}
|
||
>
|
||
{saving ? "Saving…" : draft.id ? "Update" : "Add"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<ConfirmDialog
|
||
open={Boolean(removing)}
|
||
kicker="Remove category"
|
||
title={`Remove ${removingName}?`}
|
||
message="This category and its subcategories will be removed from the marketplace tree."
|
||
confirmLabel="Remove"
|
||
cancelLabel="Keep"
|
||
danger
|
||
busy={removeBusy}
|
||
onClose={() => {
|
||
if (!removeBusy) setRemoving(null);
|
||
}}
|
||
onConfirm={() => {
|
||
void confirmRemove();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|