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
+444
View File
@@ -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>
);
}