Initial commit: dashboard

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-03 23:17:36 +03:30
co-authored by Cursor
commit 3db32212ad
177 changed files with 29988 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
FeaturedImageField,
TagsField,
type LocalImage,
} from "@/components/catalog/CatalogFormFields";
import CatalogCategorySelect from "@/components/catalog/CatalogCategorySelect";
import RichTextArea from "@/components/RichTextArea";
import {
createMeshkeeBlog,
listMeshkeeEntityCategories,
uploadMeshkeeMediaFile,
type MeshkeeEntityCategory,
} from "@/services/meshkee-catalog";
import styles from "./CatalogForm.module.css";
const BLOG_TYPES = [
{ value: "blog", label: "Blog" },
{ value: "article", label: "Article" },
{ value: "news", label: "News" },
] as const;
export default function AddBlogForm({
meshkeeId,
listHref,
title,
lead,
}: {
meshkeeId: string;
listHref: string;
title: string;
lead: string;
}) {
const router = useRouter();
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
const [categoryId, setCategoryId] = useState("");
const [type, setType] = useState<"blog" | "article" | "news">("blog");
const [postTitle, setPostTitle] = useState("");
const [abstract, setAbstract] = useState("");
const [mainTextHtml, setMainTextHtml] = useState("");
const [titleImage, setTitleImage] = useState<LocalImage | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [seoMetaTitle, setSeoMetaTitle] = useState("");
const [seoMetaDescription, setSeoMetaDescription] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const items = await listMeshkeeEntityCategories(meshkeeId, "blog");
if (!cancelled) setCategories(items);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
}
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
async function onSubmit(event?: React.FormEvent) {
event?.preventDefault();
if (!postTitle.trim()) {
setError("Enter a title.");
return;
}
setSaving(true);
setError("");
try {
let featuredMediaId: string | undefined;
if (titleImage?.file) {
featuredMediaId = (
await uploadMeshkeeMediaFile(meshkeeId, titleImage.file)
).id;
}
await createMeshkeeBlog(meshkeeId, {
title: postTitle.trim(),
type,
abstract: abstract.trim() || undefined,
mainTextHtml: mainTextHtml || undefined,
categoryId: categoryId || undefined,
featuredMediaId,
tags,
seoMetaTitle: seoMetaTitle.trim() || null,
seoMetaDescription: seoMetaDescription.trim() || null,
});
router.push(listHref);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not create blog post.");
} finally {
setSaving(false);
}
}
return (
<form className={styles.page} onSubmit={onSubmit}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>{title}</h1>
<p className={styles.lead}>{lead}</p>
</div>
<div className={styles.headActions}>
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
Back
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</header>
{error ? (
<div className={styles.errorBox} role="alert">
{error}
</div>
) : null}
<div className={styles.layout}>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Media</h2>
<div className={styles.mediaGrid}>
<FeaturedImageField
label="Title image"
value={titleImage}
aspect={3 / 2}
disabled={saving}
onChange={setTitleImage}
/>
<div className={styles.identityRow}>
<label className={styles.field}>
<span>Type</span>
<select
value={type}
disabled={saving}
onChange={(event) =>
setType(event.target.value as "blog" | "article" | "news")
}
>
{BLOG_TYPES.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<CatalogCategorySelect
categories={categories}
value={categoryId}
disabled={saving}
optional
placeholder="No category"
onChange={setCategoryId}
/>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Identity</h2>
<label className={styles.field}>
<span>Title</span>
<input
value={postTitle}
disabled={saving}
required
onChange={(event) => setPostTitle(event.target.value)}
/>
</label>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Content</h2>
<div className={styles.stack}>
<label className={styles.field}>
<span>
Abstract <em className={styles.optional}>(optional)</em>
</span>
<textarea
rows={3}
value={abstract}
disabled={saving}
onChange={(event) => setAbstract(event.target.value)}
/>
</label>
<div className={styles.richField}>
<RichTextArea
label="Content"
value={mainTextHtml}
onChange={setMainTextHtml}
disabled={saving}
minHeight={260}
/>
</div>
<TagsField tags={tags} disabled={saving} onChange={setTags} />
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>SEO</h2>
<div className={styles.identityRow}>
<label className={styles.field}>
<span>
SEO title <em className={styles.optional}>(optional)</em>
</span>
<input
value={seoMetaTitle}
maxLength={70}
disabled={saving}
onChange={(event) => setSeoMetaTitle(event.target.value)}
/>
</label>
<label className={styles.field}>
<span>
SEO description <em className={styles.optional}>(optional)</em>
</span>
<input
value={seoMetaDescription}
maxLength={160}
disabled={saving}
onChange={(event) => setSeoMetaDescription(event.target.value)}
/>
</label>
</div>
</section>
<div className={styles.footerActions}>
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
Cancel
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</form>
);
}
+288
View File
@@ -0,0 +1,288 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
FeaturedImageField,
GalleryImagesField,
TagsField,
type LocalImage,
} from "@/components/catalog/CatalogFormFields";
import CatalogCategorySelect from "@/components/catalog/CatalogCategorySelect";
import RichTextArea from "@/components/RichTextArea";
import {
createMeshkeePortfolio,
listMeshkeeEntityCategories,
uploadMeshkeeMediaFile,
type MeshkeeEntityCategory,
} from "@/services/meshkee-catalog";
import styles from "./CatalogForm.module.css";
export default function AddPortfolioForm({
meshkeeId,
listHref,
title,
lead,
}: {
meshkeeId: string;
listHref: string;
title: string;
lead: string;
}) {
const router = useRouter();
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
const [categoryId, setCategoryId] = useState("");
const [titleFa, setTitleFa] = useState("");
const [titleEn, setTitleEn] = useState("");
const [abstract, setAbstract] = useState("");
const [projectUrl, setProjectUrl] = useState("");
const [mainTextHtml, setMainTextHtml] = useState("");
const [titleImage, setTitleImage] = useState<LocalImage | null>(null);
const [gallery, setGallery] = useState<LocalImage[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [seoMetaTitle, setSeoMetaTitle] = useState("");
const [seoMetaDescription, setSeoMetaDescription] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const items = await listMeshkeeEntityCategories(meshkeeId, "portfolio");
if (!cancelled) setCategories(items);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
}
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
async function onSubmit(event?: React.FormEvent) {
event?.preventDefault();
if (!titleFa.trim()) {
setError("Enter a Persian title.");
return;
}
setSaving(true);
setError("");
try {
let featuredMediaId: string | undefined;
if (titleImage?.file) {
featuredMediaId = (
await uploadMeshkeeMediaFile(meshkeeId, titleImage.file)
).id;
}
const galleryMediaIds: string[] = [];
for (const item of gallery) {
if (item.mediaId) {
galleryMediaIds.push(item.mediaId);
continue;
}
if (item.file) {
galleryMediaIds.push(
(await uploadMeshkeeMediaFile(meshkeeId, item.file)).id,
);
}
}
await createMeshkeePortfolio(meshkeeId, {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
abstract: abstract.trim() || undefined,
projectUrl: projectUrl.trim() || undefined,
mainTextHtml: mainTextHtml || undefined,
categoryId: categoryId || undefined,
featuredMediaId,
galleryMediaIds,
tags,
seoMetaTitle: seoMetaTitle.trim() || null,
seoMetaDescription: seoMetaDescription.trim() || null,
});
router.push(listHref);
router.refresh();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not create portfolio.",
);
} finally {
setSaving(false);
}
}
return (
<form className={styles.page} onSubmit={onSubmit}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>{title}</h1>
<p className={styles.lead}>{lead}</p>
</div>
<div className={styles.headActions}>
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
Back
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</header>
{error ? (
<div className={styles.errorBox} role="alert">
{error}
</div>
) : null}
<div className={styles.layout}>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Media</h2>
<div className={styles.mediaGrid}>
<FeaturedImageField
label="Title image"
value={titleImage}
aspect={3 / 2}
disabled={saving}
onChange={setTitleImage}
/>
<GalleryImagesField
items={gallery}
disabled={saving}
onChange={setGallery}
/>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Identity</h2>
<div className={styles.stack}>
<div className={styles.identityRow3}>
<CatalogCategorySelect
categories={categories}
value={categoryId}
disabled={saving}
optional
placeholder="No category"
onChange={setCategoryId}
/>
<label className={styles.field}>
<span>Title FA</span>
<input
className="fa"
lang="fa"
dir="rtl"
value={titleFa}
disabled={saving}
required
onChange={(event) => setTitleFa(event.target.value)}
/>
</label>
<label className={styles.field}>
<span>
Title EN <em className={styles.optional}>(optional)</em>
</span>
<input
value={titleEn}
disabled={saving}
onChange={(event) => setTitleEn(event.target.value)}
/>
</label>
</div>
<label className={styles.field}>
<span>
Project URL <em className={styles.optional}>(optional)</em>
</span>
<input
dir="ltr"
value={projectUrl}
disabled={saving}
placeholder="https://"
onChange={(event) => setProjectUrl(event.target.value)}
/>
</label>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Content</h2>
<div className={styles.stack}>
<label className={styles.field}>
<span>
Abstract <em className={styles.optional}>(optional)</em>
</span>
<textarea
rows={3}
value={abstract}
disabled={saving}
onChange={(event) => setAbstract(event.target.value)}
/>
</label>
<div className={styles.richField}>
<RichTextArea
label="Content"
value={mainTextHtml}
onChange={setMainTextHtml}
disabled={saving}
minHeight={220}
/>
</div>
<TagsField tags={tags} disabled={saving} onChange={setTags} />
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>SEO</h2>
<div className={styles.identityRow}>
<label className={styles.field}>
<span>
SEO title <em className={styles.optional}>(optional)</em>
</span>
<input
value={seoMetaTitle}
maxLength={70}
disabled={saving}
onChange={(event) => setSeoMetaTitle(event.target.value)}
/>
</label>
<label className={styles.field}>
<span>
SEO description <em className={styles.optional}>(optional)</em>
</span>
<input
value={seoMetaDescription}
maxLength={160}
disabled={saving}
onChange={(event) => setSeoMetaDescription(event.target.value)}
/>
</label>
</div>
</section>
<div className={styles.footerActions}>
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
Cancel
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</form>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import {
FeaturedImageField,
GalleryImagesField,
TagsField,
type LocalImage,
} from "@/components/catalog/CatalogFormFields";
import CatalogCategorySelect from "@/components/catalog/CatalogCategorySelect";
import RichTextArea from "@/components/RichTextArea";
import {
createMeshkeeProduct,
listMeshkeeEntityCategories,
uploadMeshkeeMediaFile,
type MeshkeeEntityCategory,
} from "@/services/meshkee-catalog";
import styles from "./CatalogForm.module.css";
export default function AddProductForm({
meshkeeId,
listHref,
title,
lead,
}: {
meshkeeId: string;
listHref: string;
title: string;
lead: string;
}) {
const router = useRouter();
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
const [categoryId, setCategoryId] = useState("");
const [nameFa, setNameFa] = useState("");
const [nameEn, setNameEn] = useState("");
const [summary, setSummary] = useState("");
const [description, setDescription] = useState("");
const [thumbnail, setThumbnail] = useState<LocalImage | null>(null);
const [gallery, setGallery] = useState<LocalImage[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [seoMetaTitle, setSeoMetaTitle] = useState("");
const [seoMetaDescription, setSeoMetaDescription] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const items = await listMeshkeeEntityCategories(meshkeeId, "product");
if (!cancelled) setCategories(items);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
}
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
async function onSubmit(event?: React.FormEvent) {
event?.preventDefault();
if (!nameEn.trim() && !nameFa.trim()) {
setError("Enter a product name.");
return;
}
if (!categoryId) {
setError("Select a category.");
return;
}
setSaving(true);
setError("");
try {
let featuredMediaId: string | undefined;
if (thumbnail?.file) {
featuredMediaId = (
await uploadMeshkeeMediaFile(meshkeeId, thumbnail.file)
).id;
} else if (thumbnail?.mediaId) {
featuredMediaId = thumbnail.mediaId;
}
const galleryMediaIds: string[] = [];
for (const item of gallery) {
if (item.mediaId) {
galleryMediaIds.push(item.mediaId);
continue;
}
if (item.file) {
galleryMediaIds.push(
(await uploadMeshkeeMediaFile(meshkeeId, item.file)).id,
);
}
}
await createMeshkeeProduct(meshkeeId, {
title: nameEn.trim() || nameFa.trim(),
nameFa: nameFa.trim() || undefined,
summary: summary.trim() || undefined,
descriptionHtml: description || undefined,
categoryId,
featuredMediaId,
galleryMediaIds,
tags,
seoMetaTitle: seoMetaTitle.trim() || null,
seoMetaDescription: seoMetaDescription.trim() || null,
});
router.push(listHref);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not create product.");
} finally {
setSaving(false);
}
}
return (
<form className={styles.page} onSubmit={onSubmit}>
<header className={styles.head}>
<div>
<h1 className={styles.title}>{title}</h1>
<p className={styles.lead}>{lead}</p>
</div>
<div className={styles.headActions}>
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
Back
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</header>
{error ? (
<div className={styles.errorBox} role="alert">
{error}
</div>
) : null}
<div className={styles.layout}>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Media</h2>
<div className={styles.mediaGrid}>
<FeaturedImageField
label="Thumbnail"
value={thumbnail}
disabled={saving}
onChange={setThumbnail}
/>
<div className={styles.stack}>
<GalleryImagesField
items={gallery}
disabled={saving}
onChange={setGallery}
/>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Identity</h2>
<div className={styles.identityRow3}>
<CatalogCategorySelect
categories={categories}
value={categoryId}
disabled={saving}
onChange={setCategoryId}
/>
<label className={styles.field}>
<span>Name EN</span>
<input
value={nameEn}
disabled={saving}
required
onChange={(event) => setNameEn(event.target.value)}
/>
</label>
<label className={styles.field}>
<span>Name FA</span>
<input
className="fa"
lang="fa"
dir="rtl"
value={nameFa}
disabled={saving}
onChange={(event) => setNameFa(event.target.value)}
/>
</label>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>Content</h2>
<div className={styles.stack}>
<label className={styles.field}>
<span>
Summary <em className={styles.optional}>(optional)</em>
</span>
<textarea
rows={3}
value={summary}
disabled={saving}
onChange={(event) => setSummary(event.target.value)}
/>
</label>
<div className={styles.richField}>
<RichTextArea
label="Description"
value={description}
onChange={setDescription}
disabled={saving}
minHeight={200}
/>
</div>
<TagsField tags={tags} disabled={saving} onChange={setTags} />
</div>
</section>
<section className={styles.card}>
<h2 className={styles.sectionTitle}>SEO</h2>
<div className={styles.identityRow}>
<label className={styles.field}>
<span>
SEO title <em className={styles.optional}>(optional)</em>
</span>
<input
value={seoMetaTitle}
maxLength={70}
disabled={saving}
onChange={(event) => setSeoMetaTitle(event.target.value)}
/>
</label>
<label className={styles.field}>
<span>
SEO description <em className={styles.optional}>(optional)</em>
</span>
<input
value={seoMetaDescription}
maxLength={160}
disabled={saving}
onChange={(event) => setSeoMetaDescription(event.target.value)}
/>
</label>
</div>
</section>
<div className={styles.footerActions}>
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
Cancel
</Link>
<button
type="submit"
className={`outline-btn ${styles.saveBtn}`}
disabled={saving}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</form>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useEffect, useRef, useState } from "react";
import CatalogCardControls, {
type CatalogControlsMode,
} from "@/components/catalog/CatalogCardControls";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import CatalogPagination from "@/components/catalog/CatalogPagination";
import {
BLOG_PAGE_SIZE,
blogImage,
excerpt,
formatDate,
toPlainText,
} from "@/components/catalog/utils";
import ConfirmDialog from "@/components/ConfirmDialog";
import styles from "@/components/BusinessDetailsPage.module.css";
import { en } from "@/lib/i18n/en";
import {
deleteMeshkeeBlog,
listMeshkeeBlogs,
type MeshkeeBlog,
} from "@/services/meshkee-content";
export default function BlogPanel({
meshkeeId,
emptyLabel = en.blog.empty,
addHref,
controlsMode = "edit-remove",
onManage,
}: {
meshkeeId: string;
emptyLabel?: string;
addHref?: string;
controlsMode?: CatalogControlsMode;
onManage?: () => void;
}) {
const sectionRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<MeshkeeBlog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [busy, setBusy] = useState(false);
const [removing, setRemoving] = useState<MeshkeeBlog | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
setBusy(true);
setError("");
try {
const res = await listMeshkeeBlogs(meshkeeId, {
page: 1,
pageSize: BLOG_PAGE_SIZE,
});
if (cancelled) return;
setItems(res.items);
setTotal(res.total);
setPage(1);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load blog posts.",
);
}
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
function scrollToSection() {
const node = sectionRef.current;
if (!node) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
node.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
async function loadPage(next: number) {
setBusy(true);
try {
const res = await listMeshkeeBlogs(meshkeeId, {
page: next,
pageSize: BLOG_PAGE_SIZE,
});
setItems(res.items);
setTotal(res.total);
setPage(res.page);
scrollToSection();
} finally {
setBusy(false);
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeeBlog(meshkeeId, removing.id);
setItems((prev) => prev.filter((item) => item.id !== removing.id));
setTotal((value) => Math.max(0, value - 1));
setRemoving(null);
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not remove blog post.",
);
} finally {
setRemoveBusy(false);
}
}
if (error) {
return <p className={styles.panelHint}>{error}</p>;
}
return (
<div ref={sectionRef} className={styles.productsSection}>
{busy ? <p className={styles.panelHint}>Loading blog…</p> : null}
<div className={styles.blogGrid}>
{items.map((post) => {
const image = blogImage(post);
const text = post.abstract
? excerpt(toPlainText(post.abstract), 160)
: "";
return (
<article key={post.id} className={styles.blogCard}>
<div className={styles.cardMedia}>
{image ? (
<img src={image} alt="" />
) : (
<div className={styles.mediaFallback} aria-hidden />
)}
</div>
<div className={`tile-hover ${styles.cardFoot}`}>
<div className={styles.blogBody}>
<h3 dir="auto">{post.title}</h3>
{text ? <p dir="auto">{text}</p> : null}
{post.createdAt ? (
<span className={styles.meta}>{formatDate(post.createdAt)}</span>
) : null}
</div>
<CatalogCardControls
mode={controlsMode}
busy={removeBusy}
onManage={onManage}
onEdit={() =>
window.alert(`Edit blog coming soon: ${post.title}`)
}
onRemove={() => setRemoving(post)}
/>
</div>
</article>
);
})}
</div>
{!items.length && !busy ? (
<CatalogEmptyState
icon="/images/icons/calendar.svg"
title={emptyLabel}
lead={en.blog.emptyLead}
actionHref={addHref}
actionLabel={en.blog.add}
/>
) : null}
<CatalogPagination
page={page}
total={total}
pageSize={BLOG_PAGE_SIZE}
busy={busy}
onChange={(next) => void loadPage(next)}
/>
<ConfirmDialog
open={controlsMode === "edit-remove" && Boolean(removing)}
kicker="Remove"
title={`Remove ${removing?.title ?? "post"}?`}
message="This deletes the blog post from Meshkee. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
@@ -0,0 +1,64 @@
"use client";
import styles from "@/components/BusinessDetailsPage.module.css";
export type CatalogControlsMode = "edit-remove" | "manage";
export default function CatalogCardControls({
mode = "edit-remove",
onEdit,
onRemove,
onManage,
busy,
}: {
mode?: CatalogControlsMode;
onEdit?: () => void;
onRemove?: () => void;
onManage?: () => void;
busy?: boolean;
}) {
if (mode === "manage") {
return (
<div className={styles.cardControls}>
<button
type="button"
className={styles.cardControl}
disabled={busy}
onClick={(event) => {
event.stopPropagation();
onManage?.();
}}
>
Manage business
</button>
</div>
);
}
return (
<div className={styles.cardControls}>
<button
type="button"
className={styles.cardControl}
disabled={busy}
onClick={(event) => {
event.stopPropagation();
onEdit?.();
}}
>
Edit
</button>
<button
type="button"
className={`${styles.cardControl} ${styles.cardControlDanger}`}
disabled={busy}
onClick={(event) => {
event.stopPropagation();
onRemove?.();
}}
>
Remove
</button>
</div>
);
}
@@ -0,0 +1,221 @@
.root {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.label {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.optional {
font-style: normal;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: none;
color: var(--color-body);
}
.control {
position: relative;
display: flex;
align-items: center;
}
.control input {
width: 100%;
min-height: 42px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 10px 36px 10px 12px;
font: inherit;
font-size: 14px;
font-weight: 400;
color: var(--color-black);
outline: none;
}
.control input:focus {
border-color: var(--color-black);
}
.caret {
position: absolute;
inset-inline-end: 10px;
width: 18px;
height: 18px;
border: none;
background: transparent;
cursor: pointer;
}
.caret::before {
content: "";
display: block;
width: 7px;
height: 7px;
margin: 4px auto 0;
border-inline-end: 1.5px solid var(--color-soft);
border-bottom: 1.5px solid var(--color-soft);
transform: rotate(45deg);
transition: transform 0.15s ease;
}
.caretOpen::before {
margin-top: 8px;
transform: rotate(225deg);
}
.dropdown {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
inset-inline: 0;
max-height: 300px;
overflow: auto;
border: 1px solid #ececec;
border-radius: 5px;
background: #fff;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
}
.list,
.branch {
list-style: none;
margin: 0;
padding: 0;
}
.list {
padding: 6px 0;
}
.branch {
margin-left: 16px;
padding-left: 12px;
border-left: 1px solid #e4e4e4;
}
.node {
margin: 0;
}
.row {
display: flex;
align-items: center;
gap: 4px;
min-height: 36px;
margin: 2px 6px;
border-radius: 4px;
}
.rowActive {
background: #f5f5f5;
}
.expand,
.expandSpacer {
flex: 0 0 20px;
width: 20px;
height: 20px;
}
.expand {
display: inline-grid;
place-items: center;
border: 1px solid #e4e4e4;
border-radius: 3px;
background: #fff;
color: var(--color-soft);
font-size: 13px;
font-weight: 400;
line-height: 1;
cursor: pointer;
}
.expand:hover,
.expand:focus-visible {
border-color: var(--color-black);
color: var(--color-black);
}
.expandOpen {
color: var(--color-muted);
}
.leafMark {
position: relative;
}
.leafMark::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 10px;
height: 1px;
background: #e4e4e4;
}
.option {
flex: 1;
min-width: 0;
border: none;
background: transparent;
padding: 6px 8px 6px 2px;
text-align: start;
font: inherit;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
cursor: pointer;
}
.option:hover,
.option:focus-visible {
color: var(--color-black);
}
.optionLabel {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clearOption {
display: block;
width: calc(100% - 12px);
margin: 6px 6px 0;
padding: 8px 10px;
border: none;
border-radius: 4px;
background: transparent;
text-align: start;
font: inherit;
font-size: 13px;
font-weight: 400;
color: var(--color-soft);
cursor: pointer;
}
.clearOption:hover,
.clearOption:focus-visible {
background: #f7f7f7;
color: var(--color-black);
}
.empty {
margin: 0;
padding: 16px 14px;
font-size: 13px;
font-weight: 400;
color: var(--color-body);
}
@@ -0,0 +1,248 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
categoryOptionLabel,
type MeshkeeEntityCategory,
} from "@/services/meshkee-catalog";
import styles from "./CatalogCategorySelect.module.css";
type TreeNode = MeshkeeEntityCategory & { children: TreeNode[] };
function buildTree(categories: MeshkeeEntityCategory[]): 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) =>
categoryOptionLabel(a).localeCompare(categoryOptionLabel(b), undefined, {
sensitivity: "base",
}),
);
for (const node of nodes) sortNodes(node.children);
};
sortNodes(roots);
return roots;
}
function matchesQuery(category: MeshkeeEntityCategory, q: string) {
if (!q) return true;
const hay = [category.name, category.nameFa ?? "", category.description ?? ""]
.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);
}
}
}
export default function CatalogCategorySelect({
categories,
value,
onChange,
disabled = false,
label = "Category",
optional = false,
placeholder = "Select category",
}: {
categories: MeshkeeEntityCategory[];
value: string;
onChange: (id: string) => void;
disabled?: boolean;
label?: string;
optional?: boolean;
placeholder?: string;
}) {
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 selected = useMemo(
() => categories.find((item) => item.id === value) ?? null,
[categories, value],
);
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);
setQuery("");
}
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function select(id: string) {
onChange(id);
setOpen(false);
setQuery("");
}
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 === node.id;
return (
<li key={node.id} className={styles.node}>
<div
className={`${styles.row}${active ? ` ${styles.rowActive}` : ""}`}
style={{ paddingInlineStart: `${10 + depth * 18}px` }}
>
{hasChildren ? (
<button
type="button"
className={`${styles.expand} ${isExpanded ? styles.expandOpen : ""}`}
aria-label={isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
onClick={() => toggleExpand(node.id)}
>
<span aria-hidden>{isExpanded ? "−" : "+"}</span>
</button>
) : (
<span
className={`${styles.expandSpacer}${depth > 0 ? ` ${styles.leafMark}` : ""}`}
aria-hidden
/>
)}
<button
type="button"
className={styles.option}
disabled={disabled}
onClick={() => select(node.id)}
>
<span className={styles.optionLabel} dir="auto">
{categoryOptionLabel(node)}
</span>
</button>
</div>
{hasChildren && isExpanded ? (
<ul className={styles.branch}>{renderNodes(node.children, depth + 1)}</ul>
) : null}
</li>
);
});
}
return (
<div className={styles.root} ref={rootRef}>
<span className={styles.label}>
{label}
{optional ? (
<em className={styles.optional}> (optional)</em>
) : null}
</span>
<div className={styles.control}>
<input
value={open ? query : selected ? categoryOptionLabel(selected) : ""}
disabled={disabled}
placeholder={placeholder}
aria-expanded={open}
aria-haspopup="listbox"
onFocus={() => {
setOpen(true);
setQuery("");
}}
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}
onClick={() => {
setOpen((prev) => !prev);
setQuery("");
}}
/>
</div>
{open ? (
<div className={styles.dropdown} role="listbox">
{optional ? (
<button
type="button"
className={`${styles.clearOption}${!value ? ` ${styles.rowActive}` : ""}`}
disabled={disabled}
onClick={() => select("")}
>
No category
</button>
) : null}
{visibleTree.length ? (
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
) : (
<p className={styles.empty}>No categories found</p>
)}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,65 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
width: 100%;
min-height: 280px;
margin: 8px 0 20px;
padding: 48px 28px;
border: 1px solid #ececec;
border-radius: 6px;
background-color: #f7f7f7;
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
text-align: center;
}
.iconWrap {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 5px;
background: #fff;
border: 1px solid #e4e4e4;
}
.iconWrap img {
width: 22px;
height: 22px;
opacity: 0.55;
}
.copy {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 340px;
}
.title {
margin: 0;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-black);
}
.lead {
margin: 0;
font-size: 13px;
font-weight: 400;
line-height: 1.55;
color: var(--color-muted);
}
.cta {
margin-top: 6px;
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import styles from "./CatalogEmptyState.module.css";
export default function CatalogEmptyState({
icon,
title,
lead,
actionHref,
actionLabel,
onAction,
}: {
icon: string;
title: string;
lead: string;
actionHref?: string;
actionLabel?: string;
onAction?: () => void;
}) {
const showAction = Boolean(actionLabel && (actionHref || onAction));
return (
<div className={styles.root}>
<div className={styles.iconWrap} aria-hidden>
<img src={icon} alt="" width={22} height={22} />
</div>
<div className={styles.copy}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.lead}>{lead}</p>
</div>
{showAction && actionHref ? (
<Link href={actionHref} className={`outline-btn ${styles.cta}`}>
{actionLabel}
</Link>
) : null}
{showAction && !actionHref && onAction ? (
<button
type="button"
className={`outline-btn ${styles.cta}`}
onClick={onAction}
>
{actionLabel}
</button>
) : null}
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
.page {
display: flex;
flex-direction: column;
gap: 24px;
width: 100%;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
border-radius: 5px;
background-color: var(--color-card);
background-image: var(--page-title-pattern);
background-size: var(--page-title-pattern-size);
}
.breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--color-soft);
}
.breadcrumb a {
color: inherit;
transition: color 0.2s ease;
}
.breadcrumb a:hover,
.breadcrumb a:focus-visible {
color: var(--color-black);
}
.breadcrumbSep {
opacity: 0.45;
}
.breadcrumbCurrent {
color: var(--color-muted);
}
.title {
font-size: clamp(20px, 2.2vw, 28px);
font-weight: 400;
letter-spacing: 0.22em;
text-transform: uppercase;
line-height: 1.2;
}
.lead {
margin-top: 10px;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.headActions,
.footerActions {
display: flex;
align-items: center;
gap: 10px;
}
.footerActionsEnd {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.ghostBtn,
.saveBtn {
min-height: 38px;
padding: 0 18px;
font-size: 11px;
letter-spacing: 0.16em;
}
.ghostBtn {
border-color: #d4d4d4;
color: var(--color-soft);
}
.ghostBtn::before {
content: none;
}
.ghostBtn:hover,
.ghostBtn:focus-visible {
color: var(--color-black);
border-color: var(--color-black);
background: transparent;
}
.saveBtn {
background: var(--color-black);
border-color: var(--color-black);
color: var(--color-white);
}
.saveBtn:hover,
.saveBtn:focus-visible {
color: var(--color-black);
}
.saveBtn::before {
background: #fff;
}
.saveBtn:disabled {
opacity: 0.55;
cursor: wait;
}
.layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
background: var(--color-white);
border-radius: 5px;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.sectionTitle {
margin: 0;
font-size: 13px;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.mediaGrid {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.thumbBlock {
display: flex;
flex-direction: column;
gap: 10px;
}
.blockHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.inlineActions {
display: flex;
gap: 10px;
}
.linkBtn {
border: 0;
background: transparent;
padding: 0;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
cursor: pointer;
}
.linkBtn:hover,
.linkBtn:focus-visible {
color: var(--color-black);
}
.linkBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.thumbFrame {
width: 100%;
aspect-ratio: 1;
border: 1px dashed #d8d8d8;
border-radius: 5px;
background: #fafafa;
overflow: hidden;
display: grid;
place-items: center;
color: var(--color-body);
font-size: 12px;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.thumbFrameWide {
aspect-ratio: 3 / 2;
}
.thumbFrame img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.fileInput {
display: none;
}
.identityRow {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.identityRow3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.fieldFull {
grid-column: 1 / -1;
}
.field span,
.field > label {
font-size: 12px;
font-weight: 400;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-soft);
}
.optional {
letter-spacing: 0.08em;
text-transform: none;
color: var(--color-muted);
}
.field > input,
.field > textarea,
.field > select {
width: 100%;
min-height: 42px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
padding: 10px 12px;
font: inherit;
font-size: 14px;
font-weight: 400;
color: var(--color-black);
outline: none;
transition: border-color 0.35s ease;
}
.field > textarea {
min-height: 88px;
resize: vertical;
}
.field > input:hover,
.field > textarea:hover,
.field > select:hover {
border-color: #d8d8d8;
}
.field > input:focus,
.field > textarea:focus,
.field > select:focus {
border-color: var(--color-black);
}
.richField {
margin-top: 4px;
}
.galleryGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.galleryItem {
position: relative;
aspect-ratio: 1;
border-radius: 5px;
overflow: hidden;
background: #f3f3f3;
border: 1px solid #f0f0f0;
}
.galleryItem img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.galleryItem button {
position: absolute;
top: 6px;
right: 6px;
border: 0;
border-radius: 3px;
padding: 4px 8px;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #fff;
background: rgba(0, 0, 0, 0.75);
cursor: pointer;
}
.galleryAdd {
align-self: flex-start;
min-height: 34px;
padding: 0 12px;
border: 1px solid #e4e4e4;
border-radius: 4px;
background: #fff;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-muted);
cursor: pointer;
}
.galleryAdd:hover {
border-color: var(--color-black);
color: var(--color-black);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 5px 8px;
font-size: 12px;
color: var(--color-muted);
background: #fff;
}
.tag button {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.tagRow {
display: flex;
gap: 8px;
}
.tagRow input {
flex: 1;
min-height: 42px;
border: 1px solid #f0f0f0;
border-radius: 5px;
padding: 10px 12px;
font: inherit;
font-size: 14px;
font-weight: 400;
outline: none;
}
.tagRow input:focus {
border-color: var(--color-black);
}
.tagAdd {
min-height: 42px;
padding: 0 14px;
border: 1px solid #e4e4e4;
border-radius: 4px;
background: #fff;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-muted);
cursor: pointer;
}
.tagAdd:hover {
border-color: var(--color-black);
color: var(--color-black);
}
.errorBox {
padding: 14px 16px;
border-radius: 5px;
background: #fff5f5;
color: #b42318;
font-size: 13px;
font-weight: 400;
}
@media (max-width: 860px) {
.head {
flex-direction: column;
align-items: stretch;
}
.mediaGrid,
.identityRow,
.identityRow3,
.galleryGrid {
grid-template-columns: 1fr;
}
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useRef, useState } from "react";
import ImageCropDialog from "@/components/ImageCropDialog";
import styles from "./CatalogForm.module.css";
export type LocalImage = {
key: string;
file?: File;
previewUrl: string;
mediaId?: string;
};
export function FeaturedImageField({
label = "Thumbnail",
value,
aspect = 1,
disabled,
onChange,
}: {
label?: string;
value: LocalImage | null;
aspect?: number;
disabled?: boolean;
onChange: (value: LocalImage | null) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [cropSource, setCropSource] = useState<string | null>(null);
const [cropName, setCropName] = useState("image.jpg");
const wide = aspect !== 1;
return (
<div className={styles.thumbBlock}>
<div className={styles.blockHead}>
<span>{label}</span>
<div className={styles.inlineActions}>
<button
type="button"
className={styles.linkBtn}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{value ? "Replace" : "Upload"}
</button>
{value ? (
<button
type="button"
className={styles.linkBtn}
disabled={disabled}
onClick={() => {
if (value.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(value.previewUrl);
}
onChange(null);
}}
>
Remove
</button>
) : null}
</div>
</div>
<button
type="button"
className={`${styles.thumbFrame}${wide ? ` ${styles.thumbFrameWide}` : ""}`}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{value?.previewUrl ? (
<img src={value.previewUrl} alt="" />
) : (
<span>Add {label.toLowerCase()}</span>
)}
</button>
<input
ref={inputRef}
type="file"
accept="image/*"
className={styles.fileInput}
disabled={disabled}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const url = URL.createObjectURL(file);
setCropName(file.name || "image.jpg");
setCropSource(url);
}}
/>
<ImageCropDialog
open={Boolean(cropSource)}
imageSrc={cropSource}
title={`Crop ${label.toLowerCase()}`}
aspect={aspect}
outputType="image/jpeg"
fileName={cropName.replace(/\.[^.]+$/, "") || "image"}
onCancel={() => {
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
setCropSource(null);
}}
onComplete={(file, previewUrl) => {
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
if (value?.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(value.previewUrl);
}
setCropSource(null);
onChange({
key: `${Date.now()}`,
file,
previewUrl,
});
}}
/>
</div>
);
}
export function GalleryImagesField({
items,
disabled,
onChange,
}: {
items: LocalImage[];
disabled?: boolean;
onChange: (items: LocalImage[]) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className={styles.stack}>
<div className={styles.blockHead}>
<span>
Gallery images <em className={styles.optional}>(optional)</em>
</span>
<button
type="button"
className={styles.linkBtn}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
Add images
</button>
</div>
{items.length ? (
<div className={styles.galleryGrid}>
{items.map((item) => (
<div key={item.key} className={styles.galleryItem}>
<img src={item.previewUrl} alt="" />
<button
type="button"
disabled={disabled}
onClick={() => {
if (item.previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(item.previewUrl);
}
onChange(items.filter((row) => row.key !== item.key));
}}
>
Remove
</button>
</div>
))}
</div>
) : (
<button
type="button"
className={styles.galleryAdd}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
Add images
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className={styles.fileInput}
disabled={disabled}
onChange={(event) => {
const files = Array.from(event.target.files ?? []).filter((file) =>
file.type.startsWith("image/"),
);
event.target.value = "";
if (!files.length) return;
onChange([
...items,
...files.map((file) => ({
key: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
file,
previewUrl: URL.createObjectURL(file),
})),
]);
}}
/>
</div>
);
}
export function TagsField({
tags,
disabled,
onChange,
}: {
tags: string[];
disabled?: boolean;
onChange: (tags: string[]) => void;
}) {
const [draft, setDraft] = useState("");
function addTag() {
const next = draft.trim();
if (!next) return;
if (tags.some((tag) => tag.toLowerCase() === next.toLowerCase())) {
setDraft("");
return;
}
onChange([...tags, next]);
setDraft("");
}
return (
<div className={styles.field}>
<span>
Tags <em className={styles.optional}>(optional)</em>
</span>
{tags.length ? (
<div className={styles.tags}>
{tags.map((tag) => (
<span key={tag} className={styles.tag}>
{tag}
<button
type="button"
disabled={disabled}
aria-label={`Remove ${tag}`}
onClick={() => onChange(tags.filter((item) => item !== tag))}
>
×
</button>
</span>
))}
</div>
) : null}
<div className={styles.tagRow}>
<input
value={draft}
disabled={disabled}
placeholder="Add a tag"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
addTag();
}
}}
/>
<button
type="button"
className={styles.tagAdd}
disabled={disabled}
onClick={addTag}
>
Add
</button>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { buildPageItems } from "@/components/catalog/utils";
import styles from "@/components/BusinessDetailsPage.module.css";
export default function CatalogPagination({
page,
total,
pageSize,
onChange,
busy,
}: {
page: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
busy?: boolean;
}) {
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (total <= pageSize) return null;
const items = buildPageItems(page, pageCount);
return (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={busy || page <= 1}
onClick={() => onChange(page - 1)}
aria-label="Previous page"
>
Prev
</button>
<div className={styles.pageNumbers}>
{items.map((item, index) =>
item === "…" ? (
<span key={`ellipsis-${index}`} className={styles.pageEllipsis}>
…
</span>
) : (
<button
key={item}
type="button"
className={item === page ? styles.pageNumActive : styles.pageNum}
disabled={busy || item === page}
onClick={() => onChange(item)}
aria-current={item === page ? "page" : undefined}
>
{item}
</button>
),
)}
</div>
<button
type="button"
className={styles.pageBtn}
disabled={busy || page >= pageCount}
onClick={() => onChange(page + 1)}
aria-label="Next page"
>
Next
</button>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
.root {
display: flex;
flex-direction: column;
gap: 22px;
}
.dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 148px;
padding: 28px 20px;
border: 1px dashed #d4d4d4;
border-radius: 6px;
background:
radial-gradient(circle at 1px 1px, #ececec 1px, transparent 0) 0 0 / 18px 18px,
#fafafa;
color: var(--color-muted);
cursor: pointer;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
}
.dropzone:hover,
.dropzoneActive {
border-color: var(--color-black);
background-color: #f5f5f5;
color: var(--color-black);
}
.dropzoneBusy {
pointer-events: none;
opacity: 0.72;
}
.dropIcon {
width: 36px;
height: 36px;
opacity: 0.55;
}
.dropTitle {
margin: 0;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.dropHint {
margin: 0;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
color: var(--color-soft);
}
.fileInput {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.error,
.hint {
margin: 0;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
.error {
color: #b42318;
}
.masonry {
columns: 3;
column-gap: 14px;
}
.item {
position: relative;
break-inside: avoid;
margin: 0 0 14px;
border-radius: 5px;
overflow: hidden;
background: #f3f3f3;
}
.item img {
display: block;
width: 100%;
height: auto;
vertical-align: top;
}
.fallback {
min-height: 140px;
background: linear-gradient(135deg, #efefef, #e2e2e2);
}
.itemFoot {
position: absolute;
inset: auto 0 0 0;
display: flex;
justify-content: flex-end;
padding: 10px;
opacity: 0;
transition: opacity 0.15s ease;
}
.item:hover .itemFoot,
.item:focus-within .itemFoot {
opacity: 1;
}
.removeBtn {
border: 0;
border-radius: 4px;
padding: 6px 10px;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #fff;
background: #111;
cursor: pointer;
}
.removeBtn:hover {
background: #b42318;
}
.removeBtn:disabled {
opacity: 0.6;
cursor: wait;
}
@media (max-width: 960px) {
.masonry {
columns: 2;
}
}
@media (max-width: 640px) {
.masonry {
columns: 1;
}
.dropzone {
min-height: 120px;
padding: 22px 16px;
}
}
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useEffect, useRef, useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import { GALLERY_PAGE_SIZE, galleryImage } from "@/components/catalog/utils";
import { en } from "@/lib/i18n/en";
import {
addMeshkeeGalleryItems,
deleteMeshkeeGalleryItem,
listMeshkeeGallery,
type MeshkeeMediaItem,
} from "@/services/meshkee-content";
import styles from "./GalleryPanel.module.css";
const ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
function isImageFile(file: File) {
return file.type.startsWith("image/");
}
export default function GalleryPanel({
meshkeeId,
emptyLabel = en.gallery.empty,
}: {
meshkeeId: string;
emptyLabel?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [items, setItems] = useState<MeshkeeMediaItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [busy, setBusy] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [removing, setRemoving] = useState<MeshkeeMediaItem | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const [error, setError] = useState("");
async function reload(nextPage = 1) {
setBusy(true);
setError("");
try {
const res = await listMeshkeeGallery(meshkeeId, {
page: nextPage,
pageSize: GALLERY_PAGE_SIZE,
});
setItems(res.items);
setTotal(res.total);
setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load gallery.");
} finally {
setBusy(false);
}
}
async function loadMore() {
if (busy || uploading) return;
setBusy(true);
setError("");
try {
const nextPage = page + 1;
const res = await listMeshkeeGallery(meshkeeId, {
page: nextPage,
pageSize: GALLERY_PAGE_SIZE,
});
setItems((prev) => {
const seen = new Set(prev.map((item) => item.id));
return [
...prev,
...res.items.filter((item) => !seen.has(item.id)),
];
});
setTotal(res.total);
setPage(res.page);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not load gallery.");
} finally {
setBusy(false);
}
}
useEffect(() => {
let cancelled = false;
void (async () => {
setBusy(true);
setError("");
try {
const res = await listMeshkeeGallery(meshkeeId, {
page: 1,
pageSize: GALLERY_PAGE_SIZE,
});
if (cancelled) return;
setItems(res.items);
setTotal(res.total);
setPage(1);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load gallery.",
);
}
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
async function uploadFiles(fileList: FileList | File[]) {
const files = Array.from(fileList).filter(isImageFile);
if (!files.length) {
setError("Choose image files (JPG, PNG, WebP, or GIF).");
return;
}
setUploading(true);
setError("");
try {
const res = await addMeshkeeGalleryItems(meshkeeId, { files });
const uploaded = res.items ?? [];
if (uploaded.length) {
setItems((prev) => {
const seen = new Set(uploaded.map((item) => item.id));
return [...uploaded, ...prev.filter((item) => !seen.has(item.id))];
});
setTotal((value) => value + uploaded.length);
} else {
await reload(1);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Could not upload images.");
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = "";
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeeGalleryItem(meshkeeId, removing.id);
setItems((prev) => prev.filter((item) => item.id !== removing.id));
setTotal((value) => Math.max(0, value - 1));
setRemoving(null);
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not remove gallery item.",
);
} finally {
setRemoveBusy(false);
}
}
const hasMore = page * GALLERY_PAGE_SIZE < total;
return (
<div className={styles.root}>
<label
className={[
styles.dropzone,
dragOver ? styles.dropzoneActive : "",
uploading ? styles.dropzoneBusy : "",
]
.filter(Boolean)
.join(" ")}
onDragEnter={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragOver={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragLeave={(event) => {
event.preventDefault();
if (event.currentTarget.contains(event.relatedTarget as Node)) {
return;
}
setDragOver(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
if (uploading) return;
void uploadFiles(event.dataTransfer.files);
}}
>
<img
className={styles.dropIcon}
src="/images/icons/gallery.svg"
alt=""
width={36}
height={36}
/>
<p className={styles.dropTitle}>
{uploading ? en.gallery.uploading : en.gallery.dropTitle}
</p>
<p className={styles.dropHint}>{en.gallery.dropHint}</p>
<input
ref={inputRef}
className={styles.fileInput}
type="file"
accept={ACCEPT}
multiple
disabled={uploading}
onChange={(event) => {
const files = event.target.files;
if (files?.length) void uploadFiles(files);
}}
/>
</label>
{error ? <p className={styles.error}>{error}</p> : null}
{busy && !items.length ? (
<p className={styles.hint}>Loading gallery…</p>
) : null}
{items.length ? (
<div className={styles.masonry}>
{items.map((item) => {
const image = galleryImage(item);
const label = item.altText || item.fileName || "image";
return (
<figure key={item.id} className={styles.item}>
{image ? (
<img src={image} alt={item.altText ?? ""} loading="lazy" />
) : (
<div className={styles.fallback} aria-hidden />
)}
<div className={styles.itemFoot}>
<button
type="button"
className={styles.removeBtn}
disabled={removeBusy}
onClick={() => setRemoving(item)}
>
Remove
</button>
</div>
<span className="sr-only">{label}</span>
</figure>
);
})}
</div>
) : null}
{!items.length && !busy ? (
<CatalogEmptyState
icon="/images/icons/gallery.svg"
title={emptyLabel}
lead={en.gallery.emptyLead}
/>
) : null}
{hasMore ? (
<button
type="button"
className="outline-btn"
disabled={busy}
onClick={() => void loadMore()}
>
{busy ? "Loading…" : "Load more"}
</button>
) : null}
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove"
title={`Remove ${removing?.altText || removing?.fileName || "image"}?`}
message="This removes the image from the business gallery. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
@@ -0,0 +1,174 @@
.page {
gap: 0;
}
.listCard {
margin-top: 8px;
}
.overlay {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.35);
}
.dialog {
width: min(520px, 100%);
max-height: min(90vh, 720px);
overflow: auto;
border-radius: 6px;
background: #fff;
padding: 22px;
display: flex;
flex-direction: column;
gap: 14px;
}
.dialogHead {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.dialogTitle {
margin: 0;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.closeBtn {
border: 0;
background: transparent;
font-size: 18px;
line-height: 1;
color: var(--color-soft);
cursor: pointer;
}
.closeBtn:hover {
color: var(--color-black);
}
.tree {
display: flex;
flex-direction: column;
gap: 8px;
}
.branch {
display: flex;
flex-direction: column;
gap: 8px;
}
.children {
display: flex;
flex-direction: column;
gap: 8px;
margin-left: 18px;
padding-left: 20px;
border-left: 1px solid #e4e4e4;
}
.row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #f0f0f0;
border-radius: 5px;
background: #fff;
}
.rowChild::before {
content: "";
position: absolute;
left: -20px;
top: 50%;
width: 16px;
height: 1px;
background: #e4e4e4;
}
.rowCopy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rowName {
margin: 0;
font-size: 14px;
font-weight: 400;
color: var(--color-black);
}
.rowMeta {
margin: 0;
font-size: 12px;
font-weight: 400;
color: var(--color-soft);
}
.rowActions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0;
flex: 0 0 auto;
}
.rowActions button {
border: 0;
background: transparent;
padding: 0 10px;
font-size: 11px;
font-weight: 400;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-soft);
cursor: pointer;
}
.rowActions button:first-child {
padding-left: 0;
}
.rowActions button + button {
border-left: 1px solid #e8e8e8;
}
.rowActions button:hover {
color: var(--color-black);
}
.rowActions .danger:hover,
.danger:hover {
color: #b42318;
}
.hint {
margin: 0;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
text-align: center;
padding: 28px 12px;
}
.seoCategory {
margin: -4px 0 0;
font-size: 13px;
font-weight: 400;
color: var(--color-muted);
}
+569
View File
@@ -0,0 +1,569 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import ConfirmDialog from "@/components/ConfirmDialog";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import formStyles from "@/components/catalog/CatalogForm.module.css";
import SeoMetaFields from "@/components/catalog/SeoMetaFields";
import { en } from "@/lib/i18n/en";
import {
categoryOptionLabel,
createMeshkeeEntityCategory,
deleteMeshkeeEntityCategory,
listMeshkeeEntityCategories,
updateMeshkeeEntityCategory,
type CatalogEntityType,
type MeshkeeEntityCategory,
type UpsertCategoryInput,
} from "@/services/meshkee-catalog";
import styles from "./ManageCategoriesPage.module.css";
type Draft = UpsertCategoryInput & { id?: string };
type SeoDraft = {
id: string;
name: string;
nameFa?: string | null;
description?: string | null;
parentId: string | null;
seoMetaTitle: string;
seoMetaDescription: string;
};
function emptyDraft(parentId = ""): Draft {
return {
parentId: parentId || null,
name: "",
nameFa: "",
description: "",
};
}
export default function ManageCategoriesPage({
meshkeeId,
entityType,
title,
lead,
listHref,
listLabel,
homeHref = "/",
}: {
meshkeeId: string;
entityType: CatalogEntityType;
title: string;
lead: string;
listHref: string;
listLabel: string;
homeHref?: string;
}) {
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [draft, setDraft] = useState<Draft | null>(null);
const [seoDraft, setSeoDraft] = useState<SeoDraft | null>(null);
const [removing, setRemoving] = useState<MeshkeeEntityCategory | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const roots = useMemo(
() => categories.filter((item) => item.parentId == null),
[categories],
);
const childrenByParent = useMemo(() => {
const map = new Map<string, MeshkeeEntityCategory[]>();
for (const item of categories) {
if (!item.parentId) continue;
const list = map.get(item.parentId) ?? [];
list.push(item);
map.set(item.parentId, list);
}
return map;
}, [categories]);
async function reload() {
setLoading(true);
setError("");
try {
const items = await listMeshkeeEntityCategories(meshkeeId, entityType);
setCategories(items);
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not load categories.",
);
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, [meshkeeId, entityType]);
async function saveDraft() {
if (!draft) return;
if (!draft.name.trim()) {
setError("Enter an English name.");
return;
}
setSaving(true);
setError("");
try {
if (draft.id) {
await updateMeshkeeEntityCategory(meshkeeId, draft.id, draft);
} else {
await createMeshkeeEntityCategory(meshkeeId, entityType, draft);
}
setDraft(null);
await reload();
} catch (err) {
setError(
err instanceof Error ? err.message : "Could not save category.",
);
} finally {
setSaving(false);
}
}
async function saveSeo() {
if (!seoDraft) return;
setSaving(true);
setError("");
try {
await updateMeshkeeEntityCategory(meshkeeId, seoDraft.id, {
name: seoDraft.name,
nameFa: seoDraft.nameFa ?? "",
description: seoDraft.description ?? "",
parentId: seoDraft.parentId,
seoMetaTitle: seoDraft.seoMetaTitle,
seoMetaDescription: seoDraft.seoMetaDescription,
});
setSeoDraft(null);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save SEO.");
} finally {
setSaving(false);
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeeEntityCategory(meshkeeId, removing.id);
setRemoving(null);
await reload();
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not delete category.",
);
} finally {
setRemoveBusy(false);
}
}
function openEdit(category: MeshkeeEntityCategory) {
setDraft({
id: category.id,
parentId: category.parentId,
name: category.name,
nameFa: category.nameFa ?? "",
description: category.description ?? "",
});
}
function openSeo(category: MeshkeeEntityCategory) {
setSeoDraft({
id: category.id,
name: category.name,
nameFa: category.nameFa,
description: category.description,
parentId: category.parentId,
seoMetaTitle: category.seoMetaTitle ?? "",
seoMetaDescription: category.seoMetaDescription ?? "",
});
}
const parentOptions = categories.filter((item) => item.id !== draft?.id);
return (
<div className={`${formStyles.page} ${styles.page}`}>
<header className={formStyles.head}>
<div>
<nav className={formStyles.breadcrumb} aria-label="Breadcrumb">
<Link href={homeHref}>{en.nav.home}</Link>
<span className={formStyles.breadcrumbSep} aria-hidden>
/
</span>
<Link href={listHref}>{listLabel}</Link>
<span className={formStyles.breadcrumbSep} aria-hidden>
/
</span>
<span className={formStyles.breadcrumbCurrent}>Categories</span>
</nav>
<h1 className={formStyles.title}>{title}</h1>
<p className={formStyles.lead}>{lead}</p>
</div>
<div className={formStyles.headActions}>
<button
type="button"
className={`outline-btn ${formStyles.saveBtn}`}
onClick={() => setDraft(emptyDraft())}
>
Add category
</button>
</div>
</header>
{error ? (
<div className={formStyles.errorBox} role="alert">
{error}
</div>
) : null}
{loading ? <p className={styles.hint}>Loading categories…</p> : null}
{!loading && !categories.length ? (
<CatalogEmptyState
icon="/images/icons/category.svg"
title="No categories yet"
lead="Create categories to organize this catalog."
actionLabel="Add category"
onAction={() => setDraft(emptyDraft())}
/>
) : null}
{!loading && categories.length ? (
<section className={`${formStyles.card} ${styles.listCard}`}>
<h2 className={formStyles.sectionTitle}>Categories</h2>
<div className={styles.tree}>
{roots.map((root) => (
<CategoryBranch
key={root.id}
category={root}
childrenByParent={childrenByParent}
onAddChild={(parentId) => setDraft(emptyDraft(parentId))}
onEdit={openEdit}
onSeo={openSeo}
onRemove={setRemoving}
/>
))}
</div>
</section>
) : null}
{draft ? (
<div
className={styles.overlay}
role="dialog"
aria-modal
onClick={() => {
if (!saving) setDraft(null);
}}
>
<div
className={styles.dialog}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.dialogHead}>
<h2 className={styles.dialogTitle}>
{draft.id ? "Edit category" : "Add category"}
</h2>
<button
type="button"
className={styles.closeBtn}
disabled={saving}
onClick={() => setDraft(null)}
aria-label="Close"
>
×
</button>
</div>
<label className={formStyles.field}>
<span>
Parent <em className={formStyles.optional}>(optional)</em>
</span>
<select
value={draft.parentId ?? ""}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? {
...current,
parentId: event.target.value || null,
}
: current,
)
}
>
<option value="">Top level</option>
{parentOptions.map((item) => (
<option key={item.id} value={item.id}>
{categoryOptionLabel(item)}
</option>
))}
</select>
</label>
<label className={formStyles.field}>
<span>Name EN</span>
<input
value={draft.name}
disabled={saving}
required
onChange={(event) =>
setDraft((current) =>
current ? { ...current, name: event.target.value } : current,
)
}
/>
</label>
<label className={formStyles.field}>
<span>
Name FA <em className={formStyles.optional}>(optional)</em>
</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={formStyles.field}>
<span>
Description <em className={formStyles.optional}>(optional)</em>
</span>
<textarea
rows={3}
value={draft.description ?? ""}
disabled={saving}
onChange={(event) =>
setDraft((current) =>
current
? { ...current, description: event.target.value }
: current,
)
}
/>
</label>
<div className={formStyles.footerActionsEnd}>
<button
type="button"
className={`outline-btn ${formStyles.ghostBtn}`}
disabled={saving}
onClick={() => setDraft(null)}
>
Cancel
</button>
<button
type="button"
className={`outline-btn ${formStyles.saveBtn}`}
disabled={saving}
onClick={() => void saveDraft()}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
) : null}
{seoDraft ? (
<div
className={styles.overlay}
role="dialog"
aria-modal
onClick={() => {
if (!saving) setSeoDraft(null);
}}
>
<div
className={styles.dialog}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.dialogHead}>
<h2 className={styles.dialogTitle}>SEO</h2>
<button
type="button"
className={styles.closeBtn}
disabled={saving}
onClick={() => setSeoDraft(null)}
aria-label="Close"
>
×
</button>
</div>
<p className={styles.seoCategory} dir="auto">
{categoryOptionLabel({
id: seoDraft.id,
name: seoDraft.name,
nameFa: seoDraft.nameFa,
parentId: seoDraft.parentId,
})}
</p>
<SeoMetaFields
seoMetaTitle={seoDraft.seoMetaTitle}
seoMetaDescription={seoDraft.seoMetaDescription}
disabled={saving}
showHeading={false}
onTitleChange={(value) =>
setSeoDraft((current) =>
current ? { ...current, seoMetaTitle: value } : current,
)
}
onDescriptionChange={(value) =>
setSeoDraft((current) =>
current
? { ...current, seoMetaDescription: value }
: current,
)
}
/>
<div className={formStyles.footerActionsEnd}>
<button
type="button"
className={`outline-btn ${formStyles.ghostBtn}`}
disabled={saving}
onClick={() => setSeoDraft(null)}
>
Cancel
</button>
<button
type="button"
className={`outline-btn ${formStyles.saveBtn}`}
disabled={saving}
onClick={() => void saveSeo()}
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
) : null}
<ConfirmDialog
open={Boolean(removing)}
kicker="Remove"
title={`Remove ${removing ? categoryOptionLabel(removing) : "category"}?`}
message="This deletes the category and its subcategories from Meshkee. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
function CategoryBranch({
category,
childrenByParent,
depth = 0,
onAddChild,
onEdit,
onSeo,
onRemove,
}: {
category: MeshkeeEntityCategory;
childrenByParent: Map<string, MeshkeeEntityCategory[]>;
depth?: number;
onAddChild: (parentId: string) => void;
onEdit: (category: MeshkeeEntityCategory) => void;
onSeo: (category: MeshkeeEntityCategory) => void;
onRemove: (category: MeshkeeEntityCategory) => void;
}) {
const children = childrenByParent.get(category.id) ?? [];
return (
<div className={styles.branch}>
<CategoryRow
category={category}
child={depth > 0}
onAddChild={() => onAddChild(category.id)}
onEdit={() => onEdit(category)}
onSeo={() => onSeo(category)}
onRemove={() => onRemove(category)}
/>
{children.length ? (
<div className={styles.children}>
{children.map((child) => (
<CategoryBranch
key={child.id}
category={child}
childrenByParent={childrenByParent}
depth={depth + 1}
onAddChild={onAddChild}
onEdit={onEdit}
onSeo={onSeo}
onRemove={onRemove}
/>
))}
</div>
) : null}
</div>
);
}
function CategoryRow({
category,
child = false,
onAddChild,
onEdit,
onSeo,
onRemove,
}: {
category: MeshkeeEntityCategory;
child?: boolean;
onAddChild: () => void;
onEdit: () => void;
onSeo: () => void;
onRemove: () => void;
}) {
return (
<div className={`${styles.row}${child ? ` ${styles.rowChild}` : ""}`}>
<div className={styles.rowCopy}>
<p className={styles.rowName} dir="auto">
{categoryOptionLabel(category)}
</p>
{category.description ? (
<p className={styles.rowMeta}>{category.description}</p>
) : null}
</div>
<div className={styles.rowActions}>
<button type="button" onClick={onAddChild}>
Add sub
</button>
<button type="button" onClick={onEdit}>
Edit
</button>
<button type="button" onClick={onSeo}>
SEO
</button>
<button type="button" className={styles.danger} onClick={onRemove}>
Remove
</button>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useEffect, useRef, useState } from "react";
import CatalogCardControls, {
type CatalogControlsMode,
} from "@/components/catalog/CatalogCardControls";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import CatalogPagination from "@/components/catalog/CatalogPagination";
import {
PORTFOLIO_PAGE_SIZE,
portfolioImage,
portfolioTitle,
portfolioTitleEn,
portfolioTitleFa,
} from "@/components/catalog/utils";
import ConfirmDialog from "@/components/ConfirmDialog";
import styles from "@/components/BusinessDetailsPage.module.css";
import { en } from "@/lib/i18n/en";
import {
deleteMeshkeePortfolio,
listMeshkeePortfolios,
type MeshkeePortfolio,
} from "@/services/meshkee-content";
export default function PortfoliosPanel({
meshkeeId,
emptyLabel = en.portfolios.empty,
addHref,
controlsMode = "edit-remove",
onManage,
}: {
meshkeeId: string;
emptyLabel?: string;
addHref?: string;
controlsMode?: CatalogControlsMode | "none";
onManage?: () => void;
}) {
const sectionRef = useRef<HTMLDivElement>(null);
const [items, setItems] = useState<MeshkeePortfolio[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [busy, setBusy] = useState(false);
const [removing, setRemoving] = useState<MeshkeePortfolio | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
void (async () => {
setBusy(true);
setError("");
try {
const res = await listMeshkeePortfolios(meshkeeId, {
page: 1,
pageSize: PORTFOLIO_PAGE_SIZE,
});
if (cancelled) return;
setItems(res.items);
setTotal(res.total);
setPage(1);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load portfolios.",
);
}
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
function scrollToSection() {
const node = sectionRef.current;
if (!node) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
node.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
async function loadPage(next: number) {
setBusy(true);
try {
const res = await listMeshkeePortfolios(meshkeeId, {
page: next,
pageSize: PORTFOLIO_PAGE_SIZE,
});
setItems(res.items);
setTotal(res.total);
setPage(res.page);
scrollToSection();
} finally {
setBusy(false);
}
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeePortfolio(meshkeeId, removing.id);
setItems((prev) => prev.filter((item) => item.id !== removing.id));
setTotal((value) => Math.max(0, value - 1));
setRemoving(null);
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not remove portfolio.",
);
} finally {
setRemoveBusy(false);
}
}
if (error) {
return <p className={styles.panelHint}>{error}</p>;
}
return (
<div ref={sectionRef} className={styles.productsSection}>
{busy ? <p className={styles.panelHint}>Loading portfolios…</p> : null}
<div className={styles.portfolioGrid}>
{items.map((item) => {
const image = portfolioImage(item);
const title = portfolioTitle(item);
const titleEn = portfolioTitleEn(item);
const titleFa = portfolioTitleFa(item);
return (
<article key={item.id} className={styles.portfolioCard}>
<div className={styles.portfolioMedia}>
{image ? (
<img src={image} alt="" />
) : (
<div className={styles.mediaFallback} aria-hidden />
)}
</div>
<div className={styles.portfolioMeta}>
<h3 className={styles.portfolioTitleEn}>
{titleEn || title}
</h3>
{titleFa ? (
<p className={`fa ${styles.portfolioTitleFa}`} lang="fa">
{titleFa}
</p>
) : null}
</div>
{controlsMode !== "none" ? (
<div className={`tile-hover ${styles.cardFoot} ${styles.cardFootSolo}`}>
<CatalogCardControls
mode={controlsMode}
busy={removeBusy}
onManage={onManage}
onEdit={() =>
window.alert(`Edit portfolio coming soon: ${title}`)
}
onRemove={() => setRemoving(item)}
/>
</div>
) : null}
</article>
);
})}
</div>
{!items.length && !busy ? (
<CatalogEmptyState
icon="/images/icons/gear.svg"
title={emptyLabel}
lead={en.portfolios.emptyLead}
actionHref={addHref}
actionLabel={en.portfolios.add}
/>
) : null}
<CatalogPagination
page={page}
total={total}
pageSize={PORTFOLIO_PAGE_SIZE}
busy={busy}
onChange={(next) => void loadPage(next)}
/>
<ConfirmDialog
open={controlsMode === "edit-remove" && Boolean(removing)}
kicker="Remove"
title={`Remove ${removing ? portfolioTitle(removing) : "portfolio"}?`}
message="This deletes the portfolio from Meshkee. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
+347
View File
@@ -0,0 +1,347 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import CatalogCardControls, {
type CatalogControlsMode,
} from "@/components/catalog/CatalogCardControls";
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
import CatalogPagination from "@/components/catalog/CatalogPagination";
import {
PRODUCT_PAGE_SIZE,
productImage,
productSubtitle,
} from "@/components/catalog/utils";
import ConfirmDialog from "@/components/ConfirmDialog";
import styles from "@/components/BusinessDetailsPage.module.css";
import { en } from "@/lib/i18n/en";
import {
collectCategorySubtreeIds,
deleteMeshkeeProduct,
listMeshkeeBusinessCategories,
listMeshkeeProducts,
type MeshkeeBusinessCategory,
type MeshkeeProduct,
} from "@/services/meshkee-content";
export default function ProductsPanel({
meshkeeId,
emptyLabel = en.products.empty,
addHref,
controlsMode = "edit-remove",
onManage,
}: {
meshkeeId: string;
emptyLabel?: string;
addHref?: string;
controlsMode?: CatalogControlsMode;
onManage?: () => void;
}) {
const sectionRef = useRef<HTMLDivElement>(null);
const [categories, setCategories] = useState<MeshkeeBusinessCategory[]>([]);
const [parentCategoryId, setParentCategoryId] = useState<string | null>(null);
const [childCategoryId, setChildCategoryId] = useState<string | null>(null);
const [products, setProducts] = useState<MeshkeeProduct[]>([]);
const [productTotal, setProductTotal] = useState(0);
const [productPage, setProductPage] = useState(1);
const [productBusy, setProductBusy] = useState(false);
const [productCatalog, setProductCatalog] = useState<MeshkeeProduct[] | null>(
null,
);
const [catalogBusy, setCatalogBusy] = useState(false);
const [removing, setRemoving] = useState<MeshkeeProduct | null>(null);
const [removeBusy, setRemoveBusy] = useState(false);
const [error, setError] = useState("");
const rootCategories = useMemo(
() => categories.filter((item) => item.parentId == null),
[categories],
);
const childCategories = useMemo(() => {
if (!parentCategoryId) return [];
return categories.filter((item) => item.parentId === parentCategoryId);
}, [categories, parentCategoryId]);
const activeCategoryId = childCategoryId ?? parentCategoryId;
const filteredCatalog = useMemo(() => {
if (!productCatalog || !activeCategoryId) return productCatalog;
const ids = collectCategorySubtreeIds(categories, activeCategoryId);
return productCatalog.filter(
(item) => item.categoryId && ids.has(item.categoryId),
);
}, [productCatalog, activeCategoryId, categories]);
const pagedFiltered = useMemo(() => {
if (!filteredCatalog) return null;
const start = (productPage - 1) * PRODUCT_PAGE_SIZE;
return filteredCatalog.slice(start, start + PRODUCT_PAGE_SIZE);
}, [filteredCatalog, productPage]);
const displayed = activeCategoryId ? (pagedFiltered ?? products) : products;
const displayedTotal = activeCategoryId
? (filteredCatalog?.length ?? productTotal)
: productTotal;
useEffect(() => {
let cancelled = false;
void (async () => {
setProductBusy(true);
setError("");
try {
const [productsRes, categoriesRes] = await Promise.all([
listMeshkeeProducts(meshkeeId, {
page: 1,
pageSize: PRODUCT_PAGE_SIZE,
}),
listMeshkeeBusinessCategories(meshkeeId).catch(
() => [] as MeshkeeBusinessCategory[],
),
]);
if (cancelled) return;
setProducts(productsRes.items);
setProductTotal(productsRes.total);
setProductPage(1);
setCategories(categoriesRes);
setProductCatalog(null);
setParentCategoryId(null);
setChildCategoryId(null);
} catch (err) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load products.",
);
}
} finally {
if (!cancelled) setProductBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [meshkeeId]);
function scrollToSection() {
const node = sectionRef.current;
if (!node) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
node.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
async function ensureCatalog() {
if (productCatalog) return productCatalog;
setCatalogBusy(true);
try {
const pageSize = 100;
let page = 1;
let total = Infinity;
const all: MeshkeeProduct[] = [];
while (all.length < total) {
const res = await listMeshkeeProducts(meshkeeId, { page, pageSize });
total = res.total;
all.push(...res.items);
if (!res.items.length) break;
page += 1;
}
setProductCatalog(all);
return all;
} finally {
setCatalogBusy(false);
}
}
async function loadPage(page: number) {
if (activeCategoryId) {
setProductPage(page);
scrollToSection();
return;
}
setProductBusy(true);
try {
const res = await listMeshkeeProducts(meshkeeId, {
page,
pageSize: PRODUCT_PAGE_SIZE,
});
setProducts(res.items);
setProductTotal(res.total);
setProductPage(res.page);
scrollToSection();
} finally {
setProductBusy(false);
}
}
async function selectParent(id: string | null) {
setParentCategoryId(id);
setChildCategoryId(null);
setProductPage(1);
if (id) await ensureCatalog();
scrollToSection();
}
async function selectChild(id: string | null) {
setChildCategoryId(id);
setProductPage(1);
if (id || parentCategoryId) await ensureCatalog();
scrollToSection();
}
async function confirmRemove() {
if (!removing) return;
setRemoveBusy(true);
try {
await deleteMeshkeeProduct(meshkeeId, removing.id);
setProducts((prev) => prev.filter((item) => item.id !== removing.id));
setProductCatalog((prev) =>
prev ? prev.filter((item) => item.id !== removing.id) : prev,
);
setProductTotal((total) => Math.max(0, total - 1));
setRemoving(null);
} catch (err) {
window.alert(
err instanceof Error ? err.message : "Could not remove product.",
);
} finally {
setRemoveBusy(false);
}
}
if (error) {
return <p className={styles.panelHint}>{error}</p>;
}
return (
<div ref={sectionRef} className={styles.productsSection}>
{productTotal > 0 ? (
<div className={styles.pills}>
<button
type="button"
className={!parentCategoryId ? styles.pillActive : styles.pill}
onClick={() => void selectParent(null)}
>
All
</button>
{rootCategories.map((item) => (
<button
key={item.id}
type="button"
className={
parentCategoryId === item.id ? styles.pillActive : styles.pill
}
onClick={() => void selectParent(item.id)}
>
{item.name}
</button>
))}
</div>
) : null}
{productTotal > 0 && childCategories.length ? (
<div className={styles.childPills}>
<button
type="button"
className={!childCategoryId ? styles.pillActive : styles.pill}
onClick={() => void selectChild(null)}
>
All in category
</button>
{childCategories.map((item) => (
<button
key={item.id}
type="button"
className={
childCategoryId === item.id ? styles.pillActive : styles.pill
}
onClick={() => void selectChild(item.id)}
>
{item.name}
</button>
))}
</div>
) : null}
{catalogBusy || productBusy ? (
<p className={styles.panelHint}>Loading products…</p>
) : null}
<div className={styles.productGrid}>
{displayed.map((product) => {
const image = productImage(product);
const subtitle = productSubtitle(product);
return (
<article key={product.id} className={styles.card}>
<div className={styles.cardMedia}>
{image ? (
<img src={image} alt="" />
) : (
<div className={styles.mediaFallback} aria-hidden />
)}
</div>
<div
className={`tile-hover ${styles.cardFoot}${
subtitle ? "" : ` ${styles.cardFootSolo}`
}`}
>
<div className={styles.cardBody}>
<h3 dir="auto">{product.title}</h3>
{subtitle ? <p>{subtitle}</p> : null}
</div>
<CatalogCardControls
mode={controlsMode}
busy={removeBusy}
onManage={onManage}
onEdit={() =>
window.alert(`Edit product coming soon: ${product.title}`)
}
onRemove={() => setRemoving(product)}
/>
</div>
</article>
);
})}
</div>
{!displayed.length && !catalogBusy && !productBusy ? (
<CatalogEmptyState
icon="/images/icons/category.svg"
title={
activeCategoryId ? en.products.emptyFiltered : emptyLabel
}
lead={
activeCategoryId
? en.products.emptyFilteredLead
: en.products.emptyLead
}
actionHref={activeCategoryId ? undefined : addHref}
actionLabel={activeCategoryId ? undefined : en.products.add}
/>
) : null}
<CatalogPagination
page={productPage}
total={displayedTotal}
pageSize={PRODUCT_PAGE_SIZE}
busy={productBusy || catalogBusy}
onChange={(page) => void loadPage(page)}
/>
<ConfirmDialog
open={controlsMode === "edit-remove" && Boolean(removing)}
kicker="Remove"
title={`Remove ${removing?.title ?? "product"}?`}
message="This deletes the product from Meshkee. This cannot be undone."
confirmLabel="Remove"
cancelLabel="Keep"
danger
busy={removeBusy}
onClose={() => {
if (!removeBusy) setRemoving(null);
}}
onConfirm={() => {
void confirmRemove();
}}
/>
</div>
);
}
@@ -0,0 +1,34 @@
.block {
display: flex;
flex-direction: column;
gap: 12px;
}
.blockWithRule {
padding-top: 14px;
border-top: 1px solid #efefef;
}
.heading {
margin: 0;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-black);
}
.hint {
margin: 0;
font-size: 12px;
font-weight: 400;
line-height: 1.45;
color: var(--color-soft);
}
.counter {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.04em;
color: var(--color-soft);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import formStyles from "@/components/catalog/CatalogForm.module.css";
import styles from "./SeoMetaFields.module.css";
const TITLE_MAX = 70;
const DESCRIPTION_MAX = 160;
/** Optional SEO overrides — same control used by Meshkee category/content forms. */
export default function SeoMetaFields({
seoMetaTitle,
seoMetaDescription,
onTitleChange,
onDescriptionChange,
disabled = false,
showHeading = true,
}: {
seoMetaTitle: string;
seoMetaDescription: string;
onTitleChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
disabled?: boolean;
showHeading?: boolean;
}) {
return (
<div className={styles.block}>
{showHeading ? <h3 className={styles.heading}>SEO</h3> : null}
<p className={styles.hint}>
Optional overrides for search results. Leave blank to use the category
name and description.
</p>
<label className={formStyles.field}>
<span>
SEO title <em className={formStyles.optional}>(optional)</em>
</span>
<input
type="text"
value={seoMetaTitle}
disabled={disabled}
maxLength={TITLE_MAX}
placeholder="Custom title for search engines"
onChange={(event) =>
onTitleChange(event.target.value.slice(0, TITLE_MAX))
}
/>
<span className={styles.counter}>
{seoMetaTitle.length} / {TITLE_MAX}
</span>
</label>
<label className={formStyles.field}>
<span>
SEO description <em className={formStyles.optional}>(optional)</em>
</span>
<textarea
rows={3}
value={seoMetaDescription}
disabled={disabled}
maxLength={DESCRIPTION_MAX}
placeholder="Custom description for search engines"
onChange={(event) =>
onDescriptionChange(event.target.value.slice(0, DESCRIPTION_MAX))
}
/>
<span className={styles.counter}>
{seoMetaDescription.length} / {DESCRIPTION_MAX}
</span>
</label>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import type {
MeshkeeBlog,
MeshkeeMediaItem,
MeshkeePortfolio,
MeshkeeProduct,
} from "@/services/meshkee-content";
export function toPlainText(value: string) {
if (!value.includes("<")) return value;
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function excerpt(value: string, max = 160) {
const plain = toPlainText(value).replace(/\s+/g, " ").trim();
if (plain.length <= max) return plain;
return `${plain.slice(0, max - 1).trimEnd()}…`;
}
export function formatDate(value?: string) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
}
export function productImage(product: MeshkeeProduct) {
return product.thumbnailUrl || product.thumbnail || product.image || "";
}
export function productSubtitle(product: MeshkeeProduct) {
const raw = product.categoryName?.trim() ?? "";
if (!raw) return "";
if (raw.toLowerCase() === product.title.trim().toLowerCase()) return "";
return raw;
}
export function blogImage(post: MeshkeeBlog) {
return post.titleImageUrl || "";
}
export function portfolioImage(item: MeshkeePortfolio) {
return item.titleImageUrl || item.coverUrl || item.image || "";
}
export function galleryImage(item: MeshkeeMediaItem) {
return item.publicUrl || "";
}
export function portfolioTitleEn(item: MeshkeePortfolio) {
return (
item.titleEn?.trim() ||
item.nameEn?.trim() ||
item.title?.trim() ||
item.name?.trim() ||
""
);
}
export function portfolioTitleFa(item: MeshkeePortfolio) {
return item.titleFa?.trim() || item.nameFa?.trim() || "";
}
export function portfolioTitle(item: MeshkeePortfolio) {
const candidates = [
item.titleEn,
item.titleFa,
item.title,
item.nameEn,
item.nameFa,
item.name,
];
for (const value of candidates) {
const text = value?.trim();
if (text) return text;
}
const slug = item.slug?.trim();
if (slug) {
return slug
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (char) => char.toUpperCase());
}
return "Untitled portfolio";
}
export function buildPageItems(
page: number,
pageCount: number,
): (number | "…")[] {
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, i) => i + 1);
}
const items: (number | "…")[] = [1];
const start = Math.max(2, page - 1);
const end = Math.min(pageCount - 1, page + 1);
if (start > 2) items.push("…");
for (let n = start; n <= end; n += 1) items.push(n);
if (end < pageCount - 1) items.push("…");
items.push(pageCount);
return items;
}
export const PRODUCT_PAGE_SIZE = 12;
export const BLOG_PAGE_SIZE = 9;
export const GALLERY_PAGE_SIZE = 16;
export const PORTFOLIO_PAGE_SIZE = 12;
export const CONTENT_PAGE_SIZE = 12;