Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
"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 type { MeshkeeCategory } from "@/services/meshkee-categories";
|
||||
import {
|
||||
createWebsiteBlogCategory,
|
||||
deleteWebsiteBlogCategory,
|
||||
listWebsiteBlogCategories,
|
||||
updateWebsiteBlogCategory,
|
||||
type WebsiteBlogCategory,
|
||||
} from "@/services/website-blog";
|
||||
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 categoryLabel(category: {
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
}) {
|
||||
if (category.nameFa && category.nameFa !== category.name) {
|
||||
return `${category.name} / ${category.nameFa}`;
|
||||
}
|
||||
return category.name;
|
||||
}
|
||||
|
||||
function toTreeSelect(items: WebsiteBlogCategory[]): MeshkeeCategory[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
parentId: item.parentId,
|
||||
name: item.name,
|
||||
nameFa: item.nameFa ?? null,
|
||||
slug: "",
|
||||
description: item.description ?? null,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
tags: [],
|
||||
}));
|
||||
}
|
||||
|
||||
function toViewNodes(items: WebsiteBlogCategory[]): CategoryTreeNode[] {
|
||||
const byId = new Map<string, CategoryTreeNode>();
|
||||
for (const item of items) {
|
||||
byId.set(item.id, {
|
||||
id: item.id,
|
||||
name: categoryLabel(item),
|
||||
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[]) => {
|
||||
nodes.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function collectDescendantIds(
|
||||
rootId: string,
|
||||
items: WebsiteBlogCategory[],
|
||||
): 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 BlogCategoriesPage() {
|
||||
const [categories, setCategories] = useState<WebsiteBlogCategory[]>([]);
|
||||
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<WebsiteBlogCategory | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const tree = useMemo(() => toViewNodes(categories), [categories]);
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, WebsiteBlogCategory>();
|
||||
for (const item of categories) map.set(item.id, item);
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
const draftId = draft?.id;
|
||||
const source = !draftId
|
||||
? categories
|
||||
: categories.filter(
|
||||
(item) => !collectDescendantIds(draftId, categories).has(item.id),
|
||||
);
|
||||
return toTreeSelect(source);
|
||||
}, [categories, draft?.id]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setCategories(await listWebsiteBlogCategories());
|
||||
} 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) {
|
||||
await updateWebsiteBlogCategory(draft.id, body);
|
||||
} else {
|
||||
await createWebsiteBlogCategory(body);
|
||||
}
|
||||
setDraft(null);
|
||||
await reload();
|
||||
} 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 {
|
||||
await deleteWebsiteBlogCategory(removing.id);
|
||||
setRemoving(null);
|
||||
await reload();
|
||||
} 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="/website/blog">Blog</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}>
|
||||
Blog categories. 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="blog-category-draft-title"
|
||||
>
|
||||
<div className={styles.modalHead}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Categories</p>
|
||||
<h2 id="blog-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."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user