Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:27 +03:30
co-authored by Cursor
commit 1153e8e78f
178 changed files with 30517 additions and 0 deletions
+431
View File
@@ -0,0 +1,431 @@
"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>
);
}