Initial commit: dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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;
|
||||
Reference in New Issue
Block a user