Files
dashboards/components/BusinessCategoriesPage.tsx
T

432 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}