1110 lines
38 KiB
TypeScript
1110 lines
38 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { useEffect, useRef, useState } from "react";
|
||
import AiPromptPanel from "@/components/AiPromptPanel";
|
||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||
import ImageCropDialog from "@/components/ImageCropDialog";
|
||
import RichTextArea from "@/components/RichTextArea";
|
||
import { getMarketplaceBusiness, updateMarketplaceBusiness } from "@/services/marketplace";
|
||
import {
|
||
buildBusinessAboutAiPrompt,
|
||
buildBusinessVisionAiPrompt,
|
||
fillBusinessProfileFieldByAi,
|
||
type BusinessProfileAiField,
|
||
} from "@/services/meshkee-business-ai";
|
||
import {
|
||
getBusinessProfile,
|
||
updateBusinessProfile,
|
||
uploadBusinessMedia,
|
||
type BusinessAddress,
|
||
type BusinessPhoneNumber,
|
||
} from "@/services/meshkee-businesses";
|
||
import {
|
||
cityLabel,
|
||
listCitiesInProvince,
|
||
listIranProvinces,
|
||
matchCityName,
|
||
type MeshkeeCity,
|
||
} from "@/services/meshkee-cities";
|
||
import {
|
||
categoryLabel,
|
||
searchMeshkeeCategories,
|
||
type MeshkeeCategory,
|
||
} from "@/services/meshkee-categories";
|
||
import styles from "./EditBusinessPage.module.css";
|
||
import richStyles from "./RichTextArea.module.css";
|
||
import aiStyles from "@/styles/ai.module.css";
|
||
|
||
const BANNER_ASPECT = 18 / 6;
|
||
|
||
const LOGO_ASPECTS = [
|
||
{ id: "1:1", label: "1:1", value: 1 },
|
||
{ id: "3:2", label: "3:2", value: 3 / 2 },
|
||
{ id: "16:9", label: "16:9", value: 16 / 9 },
|
||
] as const;
|
||
|
||
type LogoAspectId = (typeof LOGO_ASPECTS)[number]["id"];
|
||
|
||
function closestLogoAspectId(width: number, height: number): LogoAspectId {
|
||
if (!width || !height) return "1:1";
|
||
const ratio = width / height;
|
||
let best: LogoAspectId = LOGO_ASPECTS[0].id;
|
||
let bestDist = Number.POSITIVE_INFINITY;
|
||
for (const option of LOGO_ASPECTS) {
|
||
const dist = Math.abs(Math.log(ratio / option.value));
|
||
if (dist < bestDist) {
|
||
bestDist = dist;
|
||
best = option.id;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
function detectLogoAspectId(src: string): Promise<LogoAspectId> {
|
||
return new Promise((resolve) => {
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
resolve(closestLogoAspectId(image.naturalWidth, image.naturalHeight));
|
||
};
|
||
image.onerror = () => resolve("1:1");
|
||
image.src = src;
|
||
});
|
||
}
|
||
|
||
function slugify(value: string) {
|
||
return value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 100);
|
||
}
|
||
|
||
function emptyAddress(): BusinessAddress {
|
||
return {
|
||
name: "",
|
||
province: "",
|
||
city: "",
|
||
address: "",
|
||
postalCode: "",
|
||
landline: "",
|
||
};
|
||
}
|
||
|
||
function emptyPhone(): BusinessPhoneNumber {
|
||
return { type: "cell", number: "" };
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function uniqueTags(categories: MeshkeeCategory[], ids: string[]) {
|
||
const selected = new Set(ids);
|
||
const tags = new Set<string>();
|
||
for (const category of categories) {
|
||
if (!selected.has(category.id)) continue;
|
||
for (const tag of category.tags) {
|
||
if (tag.trim()) tags.add(tag.trim());
|
||
}
|
||
}
|
||
return [...tags];
|
||
}
|
||
|
||
export default function EditBusinessPage({
|
||
listingId,
|
||
meshkeeBusinessId,
|
||
variant = "admin",
|
||
}: {
|
||
listingId?: string;
|
||
meshkeeBusinessId?: string;
|
||
variant?: "admin" | "business";
|
||
}) {
|
||
const router = useRouter();
|
||
const logoRef = useRef<HTMLInputElement>(null);
|
||
const bannerRef = useRef<HTMLInputElement>(null);
|
||
const isBusiness = variant === "business";
|
||
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [meshkeeId, setMeshkeeId] = useState("");
|
||
const [slug, setSlug] = useState("");
|
||
|
||
const [nameEn, setNameEn] = useState("");
|
||
const [nameFa, setNameFa] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [about, setAbout] = useState("");
|
||
const [vision, setVision] = useState("");
|
||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [tagInput, setTagInput] = useState("");
|
||
const [phones, setPhones] = useState<BusinessPhoneNumber[]>([emptyPhone()]);
|
||
const [addresses, setAddresses] = useState<BusinessAddress[]>([emptyAddress()]);
|
||
const [provinces, setProvinces] = useState<MeshkeeCity[]>([]);
|
||
const [citiesByProvinceId, setCitiesByProvinceId] = useState<
|
||
Record<string, MeshkeeCity[]>
|
||
>({});
|
||
|
||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||
const [logoMediaId, setLogoMediaId] = useState<string | null>(null);
|
||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||
const [logoRemoved, setLogoRemoved] = useState(false);
|
||
|
||
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
|
||
const [bannerMediaId, setBannerMediaId] = useState<string | null>(null);
|
||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||
const [bannerRemoved, setBannerRemoved] = useState(false);
|
||
|
||
const [cropTarget, setCropTarget] = useState<"logo" | "banner" | null>(null);
|
||
const [cropSource, setCropSource] = useState<string | null>(null);
|
||
const [cropFileName, setCropFileName] = useState("image");
|
||
const [logoAspectId, setLogoAspectId] = useState<LogoAspectId>("1:1");
|
||
|
||
const logoAspect =
|
||
LOGO_ASPECTS.find((option) => option.id === logoAspectId)?.value ?? 1;
|
||
|
||
const [aiField, setAiField] = useState<BusinessProfileAiField | null>(null);
|
||
const [aiBusy, setAiBusy] = useState(false);
|
||
const [aiError, setAiError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!logoUrl) {
|
||
setLogoAspectId("1:1");
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
void detectLogoAspectId(logoUrl).then((id) => {
|
||
if (!cancelled) setLogoAspectId(id);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [logoUrl]);
|
||
|
||
function closeCropper() {
|
||
setCropTarget(null);
|
||
setCropFileName("image");
|
||
setCropSource((prev) => {
|
||
if (prev?.startsWith("blob:")) {
|
||
requestAnimationFrame(() => URL.revokeObjectURL(prev));
|
||
}
|
||
return null;
|
||
});
|
||
}
|
||
|
||
function openCropper(target: "logo" | "banner", file: File) {
|
||
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
|
||
setCropTarget(target);
|
||
setCropSource(URL.createObjectURL(file));
|
||
setCropFileName(file.name || target);
|
||
}
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void (async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
let resolvedMeshkeeId = meshkeeBusinessId?.trim() ?? "";
|
||
let listingSlug = "";
|
||
|
||
if (!resolvedMeshkeeId) {
|
||
if (!listingId) {
|
||
throw new Error("Missing business id.");
|
||
}
|
||
const listing = await getMarketplaceBusiness(listingId);
|
||
if (cancelled) return;
|
||
resolvedMeshkeeId = String(listing.meshkeeBusinessId);
|
||
listingSlug = listing.slug;
|
||
}
|
||
|
||
const [profile, cats, iranProvinces] = await Promise.all([
|
||
getBusinessProfile(resolvedMeshkeeId),
|
||
searchMeshkeeCategories(""),
|
||
listIranProvinces(),
|
||
]);
|
||
if (cancelled) return;
|
||
|
||
setMeshkeeId(resolvedMeshkeeId);
|
||
setSlug(listingSlug);
|
||
setCategories(cats);
|
||
setProvinces(iranProvinces);
|
||
setNameEn(profile.profile.nameEn);
|
||
setNameFa(profile.profile.nameFa);
|
||
setTitle(profile.profile.title ?? "");
|
||
setAbout(toPlainText(profile.profile.about));
|
||
setVision(profile.profile.vision);
|
||
setCategoryIds(profile.profile.categoryIds);
|
||
setTags(uniqueTags(cats, profile.profile.categoryIds));
|
||
setPhones(
|
||
profile.profile.phoneNumbers.length
|
||
? (profile.profile.phoneNumbers as BusinessPhoneNumber[])
|
||
: [emptyPhone()],
|
||
);
|
||
const nextAddresses = profile.addresses.length
|
||
? profile.addresses
|
||
: [emptyAddress()];
|
||
setAddresses(nextAddresses);
|
||
|
||
const cityCache: Record<string, MeshkeeCity[]> = {};
|
||
await Promise.all(
|
||
nextAddresses.map(async (address) => {
|
||
const province = matchCityName(iranProvinces, address.province);
|
||
if (!province || cityCache[province.id]) return;
|
||
cityCache[province.id] = await listCitiesInProvince(province.id);
|
||
}),
|
||
);
|
||
if (cancelled) return;
|
||
setCitiesByProvinceId(cityCache);
|
||
setLogoUrl(profile.profile.logoUrl);
|
||
setLogoMediaId(profile.profile.logoMediaId);
|
||
setBannerUrl(profile.profile.bannerUrl);
|
||
setBannerMediaId(profile.profile.bannerMediaId);
|
||
} catch (err) {
|
||
if (cancelled) return;
|
||
setError(
|
||
err instanceof Error ? err.message : "Could not load business.",
|
||
);
|
||
} finally {
|
||
if (!cancelled) setLoading(false);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [listingId, meshkeeBusinessId]);
|
||
|
||
function setCategoriesAndTags(ids: string[]) {
|
||
setCategoryIds(ids);
|
||
setTags(uniqueTags(categories, ids));
|
||
}
|
||
|
||
const selectedCategoryNames = categories
|
||
.filter((category) => categoryIds.includes(category.id))
|
||
.map((category) => categoryLabel(category))
|
||
.join(", ");
|
||
|
||
const aboutAiPrompt = buildBusinessAboutAiPrompt({
|
||
nameEn,
|
||
nameFa,
|
||
categories: selectedCategoryNames,
|
||
});
|
||
|
||
const visionAiPrompt = buildBusinessVisionAiPrompt({
|
||
nameEn,
|
||
nameFa,
|
||
categories: selectedCategoryNames,
|
||
});
|
||
|
||
async function runAiFill(field: BusinessProfileAiField, prompt: string) {
|
||
if (!meshkeeId) return;
|
||
if (!nameEn.trim() && !nameFa.trim()) {
|
||
setAiError("Enter a business name before using AI.");
|
||
return;
|
||
}
|
||
setAiBusy(true);
|
||
setAiError("");
|
||
try {
|
||
const result = await fillBusinessProfileFieldByAi(meshkeeId, {
|
||
field,
|
||
prompt,
|
||
nameEn: nameEn.trim() || undefined,
|
||
nameFa: nameFa.trim() || undefined,
|
||
categories: selectedCategoryNames || undefined,
|
||
});
|
||
if (field === "about") {
|
||
setAbout(result.text ?? "");
|
||
} else {
|
||
setVision(result.html ?? "");
|
||
}
|
||
setAiField(null);
|
||
} catch (err) {
|
||
setAiError(err instanceof Error ? err.message : "AI request failed.");
|
||
} finally {
|
||
setAiBusy(false);
|
||
}
|
||
}
|
||
|
||
function patchAddress(index: number, patch: Partial<BusinessAddress>) {
|
||
setAddresses((prev) =>
|
||
prev.map((item, i) => (i === index ? { ...item, ...patch } : item)),
|
||
);
|
||
}
|
||
|
||
async function ensureCitiesForProvince(provinceId: string) {
|
||
if (citiesByProvinceId[provinceId]) return citiesByProvinceId[provinceId];
|
||
const cities = await listCitiesInProvince(provinceId);
|
||
setCitiesByProvinceId((prev) => ({ ...prev, [provinceId]: cities }));
|
||
return cities;
|
||
}
|
||
|
||
async function onProvinceChange(index: number, provinceNameEn: string) {
|
||
const province = matchCityName(provinces, provinceNameEn);
|
||
patchAddress(index, { province: provinceNameEn, city: "" });
|
||
if (province) {
|
||
await ensureCitiesForProvince(province.id);
|
||
}
|
||
}
|
||
|
||
function addTag(raw: string) {
|
||
const tag = raw.trim();
|
||
if (!tag) return;
|
||
setTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
|
||
const matches = categories.filter((category) =>
|
||
category.tags.some((item) => item.toLowerCase() === tag.toLowerCase()),
|
||
);
|
||
if (matches.length) {
|
||
setCategoryIds((prev) => {
|
||
const next = new Set(prev);
|
||
for (const match of matches) next.add(match.id);
|
||
return [...next];
|
||
});
|
||
}
|
||
setTagInput("");
|
||
}
|
||
|
||
function removeTag(tag: string) {
|
||
setTags((prev) => prev.filter((item) => item !== tag));
|
||
}
|
||
|
||
async function onSave() {
|
||
if (!meshkeeId) return;
|
||
setSaving(true);
|
||
setError("");
|
||
try {
|
||
let nextLogoId: number | null | undefined = undefined;
|
||
let nextBannerId: number | null | undefined = undefined;
|
||
|
||
if (logoFile) {
|
||
const uploaded = await uploadBusinessMedia(meshkeeId, logoFile);
|
||
nextLogoId = Number(uploaded.id);
|
||
} else if (logoRemoved) {
|
||
nextLogoId = null;
|
||
}
|
||
|
||
if (bannerFile) {
|
||
const uploaded = await uploadBusinessMedia(meshkeeId, bannerFile);
|
||
nextBannerId = Number(uploaded.id);
|
||
} else if (bannerRemoved) {
|
||
nextBannerId = null;
|
||
}
|
||
|
||
const cleanPhones = phones
|
||
.map((phone) => ({
|
||
type: phone.type,
|
||
number: phone.number.trim(),
|
||
}))
|
||
.filter((phone) => phone.number.length >= 3);
|
||
|
||
const cleanAddresses = addresses
|
||
.map((address) => ({
|
||
id: address.id,
|
||
name: address.name.trim(),
|
||
province: address.province.trim(),
|
||
city: address.city.trim(),
|
||
address: address.address.trim(),
|
||
postalCode: address.postalCode.trim(),
|
||
landline: address.landline?.trim() || null,
|
||
}))
|
||
.filter(
|
||
(address) =>
|
||
address.province &&
|
||
address.city &&
|
||
address.address &&
|
||
address.postalCode,
|
||
);
|
||
|
||
await updateBusinessProfile(meshkeeId, {
|
||
nameEn: nameEn.trim(),
|
||
nameFa: nameFa.trim(),
|
||
title: title.trim(),
|
||
about,
|
||
vision,
|
||
categoryIds: categoryIds.map(Number),
|
||
phoneNumbers: cleanPhones,
|
||
addresses: cleanAddresses,
|
||
...(nextLogoId !== undefined ? { logoMediaId: nextLogoId } : {}),
|
||
...(nextBannerId !== undefined ? { bannerMediaId: nextBannerId } : {}),
|
||
});
|
||
|
||
if (!isBusiness) {
|
||
if (!listingId) {
|
||
throw new Error("Missing listing id.");
|
||
}
|
||
const nextSlug = slugify(slug);
|
||
if (!nextSlug) {
|
||
throw new Error(
|
||
"Enter a valid slug (lowercase letters, numbers, hyphens).",
|
||
);
|
||
}
|
||
await updateMarketplaceBusiness(listingId, {
|
||
slug: nextSlug,
|
||
name: nameEn.trim() || nameFa.trim() || undefined,
|
||
});
|
||
setSlug(nextSlug);
|
||
router.push("/businesses");
|
||
router.refresh();
|
||
} else {
|
||
setSaved(true);
|
||
window.setTimeout(() => setSaved(false), 2500);
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Could not save business.");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className={styles.page}>
|
||
<header className={styles.head}>
|
||
<div>
|
||
{isBusiness ? (
|
||
<>
|
||
<h1 className={styles.title}>Profile</h1>
|
||
<p className={styles.lead}>
|
||
Update how your business appears on Meshkee.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||
<Link href="/">Home</Link>
|
||
<span className={styles.breadcrumbSep} aria-hidden>
|
||
/
|
||
</span>
|
||
<Link href="/businesses">Businesses</Link>
|
||
<span className={styles.breadcrumbSep} aria-hidden>
|
||
/
|
||
</span>
|
||
<span className={styles.breadcrumbCurrent}>Edit</span>
|
||
</nav>
|
||
<h1 className={styles.title}>Edit business</h1>
|
||
<p className={styles.lead}>
|
||
{slug ? `/${slug}` : "Update Meshkee profile for this listing."}
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className={styles.headActions}>
|
||
{!isBusiness ? (
|
||
<Link href="/businesses" className={`outline-btn ${styles.ghostBtn}`}>
|
||
Back
|
||
</Link>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.saveBtn}`}
|
||
disabled={loading || saving || !meshkeeId}
|
||
onClick={() => void onSave()}
|
||
>
|
||
{saving ? "Saving…" : saved ? "Saved" : "Save"}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{loading ? (
|
||
<div className={styles.loading}>Loading profile…</div>
|
||
) : error && !meshkeeId ? (
|
||
<div className={styles.errorBox} role="alert">
|
||
{error}
|
||
</div>
|
||
) : (
|
||
<div className={styles.layout}>
|
||
<section className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Media</h2>
|
||
<div className={styles.mediaGrid}>
|
||
<div className={styles.logoBlock}>
|
||
<div className={styles.blockHead}>
|
||
<span>Logo</span>
|
||
<div className={styles.inlineActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => logoRef.current?.click()}
|
||
disabled={saving}
|
||
>
|
||
{logoUrl ? "Replace" : "Upload"}
|
||
</button>
|
||
{logoUrl ? (
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving}
|
||
onClick={() => {
|
||
setLogoFile(null);
|
||
setLogoUrl(null);
|
||
setLogoMediaId(null);
|
||
setLogoRemoved(true);
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div
|
||
className={styles.aspectRow}
|
||
role="group"
|
||
aria-label="Logo aspect ratio"
|
||
>
|
||
{LOGO_ASPECTS.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
className={
|
||
option.id === logoAspectId
|
||
? `${styles.aspectBtn} ${styles.aspectBtnActive}`
|
||
: styles.aspectBtn
|
||
}
|
||
aria-pressed={option.id === logoAspectId}
|
||
disabled={saving}
|
||
onClick={() => setLogoAspectId(option.id)}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.logoFrame}
|
||
style={{ aspectRatio: String(logoAspect) }}
|
||
onClick={() => logoRef.current?.click()}
|
||
disabled={saving}
|
||
>
|
||
{logoUrl ? (
|
||
<img src={logoUrl} alt="" />
|
||
) : (
|
||
<span>Add logo</span>
|
||
)}
|
||
</button>
|
||
<input
|
||
ref={logoRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className={styles.fileInput}
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file) return;
|
||
openCropper("logo", file);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className={styles.bannerBlock}>
|
||
<div className={styles.blockHead}>
|
||
<span>Banner · 18:6</span>
|
||
<div className={styles.inlineActions}>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
onClick={() => bannerRef.current?.click()}
|
||
disabled={saving}
|
||
>
|
||
{bannerUrl ? "Replace" : "Upload"}
|
||
</button>
|
||
{bannerUrl ? (
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving}
|
||
onClick={() => {
|
||
setBannerFile(null);
|
||
setBannerUrl(null);
|
||
setBannerMediaId(null);
|
||
setBannerRemoved(true);
|
||
}}
|
||
>
|
||
Remove
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.bannerFrame}
|
||
style={{ aspectRatio: String(BANNER_ASPECT) }}
|
||
onClick={() => bannerRef.current?.click()}
|
||
disabled={saving}
|
||
>
|
||
{bannerUrl ? (
|
||
<img src={bannerUrl} alt="" />
|
||
) : (
|
||
<span>Add banner</span>
|
||
)}
|
||
</button>
|
||
<input
|
||
ref={bannerRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className={styles.fileInput}
|
||
onChange={(event) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file) return;
|
||
openCropper("banner", file);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<ImageCropDialog
|
||
open={Boolean(cropTarget && cropSource)}
|
||
imageSrc={cropSource}
|
||
title={cropTarget === "logo" ? "Crop logo" : "Crop banner"}
|
||
aspect={cropTarget === "logo" ? logoAspect : BANNER_ASPECT}
|
||
outputType={cropTarget === "logo" ? "image/png" : "image/jpeg"}
|
||
fileName={cropFileName}
|
||
onCancel={closeCropper}
|
||
onComplete={(file, previewUrl) => {
|
||
if (cropTarget === "logo") {
|
||
if (logoUrl?.startsWith("blob:")) URL.revokeObjectURL(logoUrl);
|
||
setLogoFile(file);
|
||
setLogoRemoved(false);
|
||
setLogoUrl(previewUrl);
|
||
} else {
|
||
if (bannerUrl?.startsWith("blob:")) {
|
||
URL.revokeObjectURL(bannerUrl);
|
||
}
|
||
setBannerFile(file);
|
||
setBannerRemoved(false);
|
||
setBannerUrl(previewUrl);
|
||
}
|
||
closeCropper();
|
||
}}
|
||
/>
|
||
|
||
<section className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Identity</h2>
|
||
<div className={styles.identityRow}>
|
||
<CategoryTreeSelect
|
||
categories={categories}
|
||
value={categoryIds}
|
||
onChange={setCategoriesAndTags}
|
||
disabled={saving}
|
||
/>
|
||
<label className={styles.field}>
|
||
<span>Name FA</span>
|
||
<input
|
||
className="fa"
|
||
lang="fa"
|
||
dir="rtl"
|
||
value={nameFa}
|
||
onChange={(event) => setNameFa(event.target.value)}
|
||
disabled={saving}
|
||
required
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Name EN</span>
|
||
<input
|
||
value={nameEn}
|
||
onChange={(event) => setNameEn(event.target.value)}
|
||
disabled={saving}
|
||
required
|
||
/>
|
||
</label>
|
||
{!isBusiness ? (
|
||
<label className={styles.field}>
|
||
<span>Slug</span>
|
||
<input
|
||
dir="ltr"
|
||
value={slug}
|
||
spellCheck={false}
|
||
disabled={saving}
|
||
placeholder="business-slug"
|
||
onChange={(event) => setSlug(event.target.value)}
|
||
onBlur={() => setSlug((value) => slugify(value) || value)}
|
||
required
|
||
/>
|
||
</label>
|
||
) : null}
|
||
</div>
|
||
|
||
<label className={styles.field}>
|
||
<span>Title</span>
|
||
<input
|
||
value={title}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
disabled={saving}
|
||
maxLength={255}
|
||
placeholder="e.g. E-Commerce and Programming team"
|
||
/>
|
||
</label>
|
||
|
||
<div className={styles.stack}>
|
||
<div className={styles.field}>
|
||
<div className={styles.fieldHead}>
|
||
<span>Abstract</span>
|
||
<button
|
||
type="button"
|
||
className={`${styles.aiBtn} ${aiStyles.aiTrigger} ${aiField === "about" ? styles.aiBtnActive : ""}`}
|
||
disabled={saving || aiBusy}
|
||
onClick={() => {
|
||
setAiError("");
|
||
setAiField((current) =>
|
||
current === "about" ? null : "about",
|
||
);
|
||
}}
|
||
>
|
||
AI
|
||
</button>
|
||
</div>
|
||
<AiPromptPanel
|
||
open={aiField === "about"}
|
||
title="Generate abstract with AI"
|
||
initialPrompt={aboutAiPrompt}
|
||
busy={aiBusy}
|
||
error={aiField === "about" ? aiError : ""}
|
||
disabled={saving}
|
||
onClose={() => {
|
||
if (!aiBusy) {
|
||
setAiField(null);
|
||
setAiError("");
|
||
}
|
||
}}
|
||
onRun={(prompt) => void runAiFill("about", prompt)}
|
||
/>
|
||
<textarea
|
||
rows={5}
|
||
value={about}
|
||
onChange={(event) => setAbout(event.target.value)}
|
||
disabled={saving || aiBusy}
|
||
placeholder="Write here…"
|
||
className={
|
||
aiBusy && aiField === "about"
|
||
? aiStyles.aiWorkingBorder
|
||
: undefined
|
||
}
|
||
/>
|
||
</div>
|
||
<div className={`${styles.field} ${styles.descriptionField}`}>
|
||
<AiPromptPanel
|
||
open={aiField === "vision"}
|
||
title="Generate description with AI"
|
||
initialPrompt={visionAiPrompt}
|
||
busy={aiBusy}
|
||
error={aiField === "vision" ? aiError : ""}
|
||
disabled={saving}
|
||
onClose={() => {
|
||
if (!aiBusy) {
|
||
setAiField(null);
|
||
setAiError("");
|
||
}
|
||
}}
|
||
onRun={(prompt) => void runAiFill("vision", prompt)}
|
||
/>
|
||
<RichTextArea
|
||
label="Description"
|
||
value={vision}
|
||
onChange={setVision}
|
||
disabled={saving || aiBusy}
|
||
minHeight={220}
|
||
aiWorking={aiBusy && aiField === "vision"}
|
||
toolbarEnd={
|
||
<button
|
||
type="button"
|
||
className={`${richStyles.tool} ${richStyles.aiTool} ${aiStyles.aiTrigger} ${aiField === "vision" ? richStyles.toolActive : ""}`}
|
||
disabled={saving || aiBusy}
|
||
title="Generate with AI"
|
||
onClick={() => {
|
||
setAiError("");
|
||
setAiField((current) =>
|
||
current === "vision" ? null : "vision",
|
||
);
|
||
}}
|
||
>
|
||
AI
|
||
</button>
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className={styles.card}>
|
||
<h2 className={styles.sectionTitle}>Tags</h2>
|
||
<div className={styles.tagBlock}>
|
||
<div className={styles.tagRow}>
|
||
<div className={styles.tagInput}>
|
||
{tags.map((tag) => (
|
||
<button
|
||
key={tag}
|
||
type="button"
|
||
className={`${styles.chip} ${styles.chipActive}`}
|
||
disabled={saving}
|
||
onClick={() => removeTag(tag)}
|
||
title="Remove tag"
|
||
>
|
||
{tag} ×
|
||
</button>
|
||
))}
|
||
<input
|
||
value={tagInput}
|
||
onChange={(event) => setTagInput(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
addTag(tagInput);
|
||
}
|
||
}}
|
||
placeholder={tags.length ? "" : "Add tag…"}
|
||
disabled={saving}
|
||
/>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving || !tagInput.trim()}
|
||
onClick={() => addTag(tagInput)}
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className={styles.card}>
|
||
<div className={styles.blockHead}>
|
||
<h2 className={styles.sectionTitle}>Phone numbers</h2>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving}
|
||
onClick={() => setPhones((prev) => [...prev, emptyPhone()])}
|
||
>
|
||
+ Add
|
||
</button>
|
||
</div>
|
||
<div className={styles.phoneGrid}>
|
||
{phones.map((phone, index) => (
|
||
<div key={index} className={styles.phoneRow}>
|
||
<select
|
||
value={phone.type}
|
||
disabled={saving}
|
||
onChange={(event) => {
|
||
const type = event.target
|
||
.value as BusinessPhoneNumber["type"];
|
||
setPhones((prev) =>
|
||
prev.map((item, i) =>
|
||
i === index ? { ...item, type } : item,
|
||
),
|
||
);
|
||
}}
|
||
>
|
||
<option value="cell">Cell</option>
|
||
<option value="landline">Landline</option>
|
||
</select>
|
||
<input
|
||
dir="ltr"
|
||
value={phone.number}
|
||
placeholder="+98912…"
|
||
disabled={saving}
|
||
onChange={(event) => {
|
||
const number = event.target.value;
|
||
setPhones((prev) =>
|
||
prev.map((item, i) =>
|
||
i === index ? { ...item, number } : item,
|
||
),
|
||
);
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving || phones.length <= 1}
|
||
onClick={() =>
|
||
setPhones((prev) => prev.filter((_, i) => i !== index))
|
||
}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className={styles.card}>
|
||
<div className={styles.blockHead}>
|
||
<h2 className={styles.sectionTitle}>Addresses</h2>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving}
|
||
onClick={() =>
|
||
setAddresses((prev) => [...prev, emptyAddress()])
|
||
}
|
||
>
|
||
+ Add
|
||
</button>
|
||
</div>
|
||
<div className={styles.stack}>
|
||
{addresses.map((address, index) => {
|
||
const province = matchCityName(provinces, address.province);
|
||
const cities = province
|
||
? (citiesByProvinceId[province.id] ?? [])
|
||
: [];
|
||
|
||
return (
|
||
<div
|
||
key={address.id ?? `new-${index}`}
|
||
className={styles.addressCard}
|
||
>
|
||
<div className={styles.addressHead}>
|
||
<strong>Address {index + 1}</strong>
|
||
<button
|
||
type="button"
|
||
className={styles.linkBtn}
|
||
disabled={saving || addresses.length <= 1}
|
||
onClick={() =>
|
||
setAddresses((prev) =>
|
||
prev.filter((_, i) => i !== index),
|
||
)
|
||
}
|
||
>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
<div className={styles.addressMetaRow}>
|
||
<label className={styles.field}>
|
||
<span>Name</span>
|
||
<input
|
||
value={address.name}
|
||
placeholder="HQ, Warehouse…"
|
||
disabled={saving}
|
||
onChange={(event) =>
|
||
patchAddress(index, { name: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Province</span>
|
||
<select
|
||
value={province?.nameEn ?? address.province}
|
||
disabled={saving || !provinces.length}
|
||
onChange={(event) => {
|
||
void onProvinceChange(index, event.target.value);
|
||
}}
|
||
>
|
||
<option value="">Select…</option>
|
||
{!province && address.province ? (
|
||
<option value={address.province}>
|
||
{address.province}
|
||
</option>
|
||
) : null}
|
||
{provinces.map((item) => (
|
||
<option key={item.id} value={item.nameEn}>
|
||
{cityLabel(item)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>City</span>
|
||
<select
|
||
value={
|
||
matchCityName(cities, address.city)?.nameEn ??
|
||
address.city
|
||
}
|
||
disabled={saving || !address.province}
|
||
onChange={(event) =>
|
||
patchAddress(index, { city: event.target.value })
|
||
}
|
||
>
|
||
<option value="">Select…</option>
|
||
{!matchCityName(cities, address.city) &&
|
||
address.city ? (
|
||
<option value={address.city}>{address.city}</option>
|
||
) : null}
|
||
{cities.map((item) => (
|
||
<option key={item.id} value={item.nameEn}>
|
||
{cityLabel(item)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Postal code</span>
|
||
<input
|
||
dir="ltr"
|
||
value={address.postalCode}
|
||
disabled={saving}
|
||
onChange={(event) =>
|
||
patchAddress(index, {
|
||
postalCode: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Landline</span>
|
||
<input
|
||
dir="ltr"
|
||
value={address.landline ?? ""}
|
||
disabled={saving}
|
||
onChange={(event) =>
|
||
patchAddress(index, {
|
||
landline: event.target.value,
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label className={styles.field}>
|
||
<span>Address</span>
|
||
<input
|
||
value={address.address}
|
||
disabled={saving}
|
||
onChange={(event) =>
|
||
patchAddress(index, { address: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
{error ? (
|
||
<p className={styles.errorBox} role="alert">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className={styles.footerActions}>
|
||
<Link href="/businesses" className={`outline-btn ${styles.ghostBtn}`}>
|
||
Cancel
|
||
</Link>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.saveBtn}`}
|
||
disabled={saving}
|
||
onClick={() => void onSave()}
|
||
>
|
||
{saving ? "Saving…" : "Save changes"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|