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:51 +03:30
co-authored by Cursor
commit 42aff5aee9
162 changed files with 16828 additions and 0 deletions
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
categoryLabel,
type MeshkeeCategory,
} from "@/lib/meshkee-categories";
import styles from "./CategoryMultiSelect.module.css";
type TreeNode = MeshkeeCategory & { children: TreeNode[] };
function buildTree(categories: MeshkeeCategory[]): TreeNode[] {
const byId = new Map<string, TreeNode>();
for (const category of categories) {
byId.set(category.id, { ...category, children: [] });
}
const roots: TreeNode[] = [];
for (const node of byId.values()) {
if (node.parentId && byId.has(node.parentId)) {
byId.get(node.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
const sortNodes = (nodes: TreeNode[]) => {
nodes.sort(
(a, b) =>
a.sortOrder - b.sortOrder ||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(roots);
return roots;
}
function matchesQuery(category: MeshkeeCategory, q: string) {
if (!q) return true;
const hay = [category.name, category.nameFa ?? "", category.slug, ...category.tags]
.join(" ")
.toLowerCase();
return hay.includes(q);
}
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
if (!q) return nodes;
const result: TreeNode[] = [];
for (const node of nodes) {
const children = filterTree(node.children, q);
if (matchesQuery(node, q) || children.length) {
result.push({ ...node, children });
}
}
return result;
}
function collectExpandIds(nodes: TreeNode[], into: Set<string>) {
for (const node of nodes) {
if (node.children.length) {
into.add(node.id);
collectExpandIds(node.children, into);
}
}
}
type Props = {
categories: MeshkeeCategory[];
value: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
loading?: boolean;
error?: string;
label?: string;
};
export default function CategoryMultiSelect({
categories,
value,
onChange,
disabled = false,
loading = false,
error = "",
label = "Category",
}: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const tree = useMemo(() => buildTree(categories), [categories]);
const q = query.trim().toLowerCase();
const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]);
const byId = useMemo(() => {
const map = new Map<string, MeshkeeCategory>();
for (const category of categories) map.set(category.id, category);
return map;
}, [categories]);
const selected = value
.map((id) => byId.get(id))
.filter((item): item is MeshkeeCategory => Boolean(item));
useEffect(() => {
if (!q) return;
const next = new Set<string>();
collectExpandIds(visibleTree, next);
setExpanded(next);
}, [q, visibleTree]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function toggle(id: string) {
const next = value.includes(id)
? value.filter((item) => item !== id)
: [...value, id];
onChange(next);
}
function toggleExpand(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function renderNodes(nodes: TreeNode[], depth: number) {
return nodes.map((node) => {
const hasChildren = node.children.length > 0;
const isExpanded = q ? true : expanded.has(node.id);
const active = value.includes(node.id);
return (
<li key={node.id} className={styles.node}>
<div
className={styles.row}
style={{ paddingInlineStart: `${8 + depth * 16}px` }}
>
{hasChildren ? (
<button
type="button"
className={styles.expand}
aria-label={isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
onClick={() => toggleExpand(node.id)}
>
<span aria-hidden>{isExpanded ? "−" : "+"}</span>
</button>
) : (
<span className={styles.expandSpacer} aria-hidden />
)}
<button
type="button"
className={`${styles.option} ${active ? styles.optionActive : ""}`}
disabled={disabled}
onClick={() => toggle(node.id)}
>
<span className={styles.check} aria-hidden>
{active ? "✓" : ""}
</span>
<span className={styles.optionLabel}>{categoryLabel(node)}</span>
</button>
</div>
{hasChildren && isExpanded ? (
<ul className={styles.list}>{renderNodes(node.children, depth + 1)}</ul>
) : null}
</li>
);
});
}
const placeholder = loading
? "Loading categories…"
: selected.length
? `${selected.length} selected — search to add more`
: "Search categories…";
return (
<div className={styles.root} ref={rootRef}>
<div className={styles.field}>
<span className={styles.label}>{label}</span>
<div className={styles.control}>
<input
value={query}
disabled={disabled || loading}
placeholder={placeholder}
aria-expanded={open}
aria-haspopup="listbox"
onFocus={() => setOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
onClick={() => setOpen(true)}
/>
<button
type="button"
className={`${styles.caret} ${open ? styles.caretOpen : ""}`}
aria-label={open ? "Close categories" : "Open categories"}
disabled={disabled || loading}
onClick={() => setOpen((prev) => !prev)}
>
<img src="/images/icons/chevron.svg" alt="" />
</button>
</div>
{open ? (
<div className={styles.dropdown} role="listbox" aria-multiselectable>
{loading ? (
<p className={styles.status}>Loading categories…</p>
) : visibleTree.length ? (
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
) : (
<p className={styles.empty}>
{error || "No categories found"}
</p>
)}
</div>
) : null}
</div>
{error && !open ? <p className={styles.error}>{error}</p> : null}
{selected.length ? (
<div className={styles.chips}>
{selected.map((category) => (
<button
key={category.id}
type="button"
className={styles.chip}
disabled={disabled}
title="Remove category"
onClick={() => toggle(category.id)}
>
{categoryLabel(category)}
<span aria-hidden>×</span>
</button>
))}
</div>
) : null}
</div>
);
}