Files

1110 lines
38 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/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>
);
}