620 lines
19 KiB
TypeScript
620 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useId, useRef, useState } from "react";
|
||
import { ApiError, isAbortError } from "@/lib/meshkee-api";
|
||
import { createMarketplaceBusiness } from "@/services/marketplace";
|
||
import { searchMeshkeeCategories } from "@/services/meshkee-categories";
|
||
import {
|
||
createMeshkeeBusiness,
|
||
getMeshkeeBusiness,
|
||
listLatestMeshkeeBusinesses,
|
||
searchMeshkeeBusinesses,
|
||
type MeshkeeBusinessDetail,
|
||
type MeshkeeBusinessSearchItem,
|
||
} from "@/services/meshkee-businesses";
|
||
import { PhoneLtr, withFaSpans } from "@/lib/faText";
|
||
import styles from "./AddBusinessModal.module.css";
|
||
|
||
export type AddBusinessMode = "connect" | "create";
|
||
|
||
function slugify(value: string) {
|
||
return value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "")
|
||
.slice(0, 80);
|
||
}
|
||
|
||
function ownerParts(detail: MeshkeeBusinessDetail | null, fallback?: {
|
||
ownerName?: string | null;
|
||
ownerCellNumber?: string | null;
|
||
}): { name: string; cell: string } {
|
||
if (detail?.owner) {
|
||
const name = [detail.owner.firstName, detail.owner.lastName]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.trim();
|
||
return { name, cell: detail.owner.cellNumber || "" };
|
||
}
|
||
return {
|
||
name: fallback?.ownerName?.trim() || "",
|
||
cell: fallback?.ownerCellNumber?.trim() || "",
|
||
};
|
||
}
|
||
|
||
function BusinessPickRow({
|
||
item,
|
||
onSelect,
|
||
}: {
|
||
item: MeshkeeBusinessSearchItem;
|
||
onSelect: (item: MeshkeeBusinessSearchItem) => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={styles.resultItem}
|
||
role="option"
|
||
onClick={() => onSelect(item)}
|
||
>
|
||
<strong className={styles.resultName}>
|
||
<span>{item.name}</span>
|
||
{item.nameFa && item.nameFa !== item.name ? (
|
||
<>
|
||
<span className={styles.resultSep} aria-hidden>
|
||
{" "}
|
||
/{" "}
|
||
</span>
|
||
<span className={`fa ${styles.resultFa}`} lang="fa">
|
||
{item.nameFa}
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</strong>
|
||
<span className={styles.resultMeta}>
|
||
/{item.slug}
|
||
{item.ownerName ? (
|
||
<>
|
||
{" · "}
|
||
{withFaSpans(item.ownerName)}
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export default function AddBusinessModal({
|
||
open,
|
||
onClose,
|
||
onConnected,
|
||
}: {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
onConnected?: () => void;
|
||
}) {
|
||
const titleId = useId();
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
const [mode, setMode] = useState<AddBusinessMode>("connect");
|
||
const [query, setQuery] = useState("");
|
||
const [results, setResults] = useState<MeshkeeBusinessSearchItem[]>([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [searchError, setSearchError] = useState("");
|
||
const [latest, setLatest] = useState<MeshkeeBusinessSearchItem[]>([]);
|
||
const [latestLoading, setLatestLoading] = useState(false);
|
||
const [latestError, setLatestError] = useState("");
|
||
const [selected, setSelected] = useState<MeshkeeBusinessSearchItem | null>(
|
||
null,
|
||
);
|
||
const [selectedDetail, setSelectedDetail] =
|
||
useState<MeshkeeBusinessDetail | null>(null);
|
||
const [detailLoading, setDetailLoading] = useState(false);
|
||
|
||
const [businessName, setBusinessName] = useState("");
|
||
const [ownerFirstName, setOwnerFirstName] = useState("");
|
||
const [ownerLastName, setOwnerLastName] = useState("");
|
||
const [ownerCell, setOwnerCell] = useState("+98");
|
||
const [ownerPassword, setOwnerPassword] = useState("");
|
||
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [submitError, setSubmitError] = useState("");
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
closeRef.current?.focus();
|
||
|
||
const onKey = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") onClose();
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
window.removeEventListener("keydown", onKey);
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setMode("connect");
|
||
setQuery("");
|
||
setResults([]);
|
||
setSearchError("");
|
||
setLatest([]);
|
||
setLatestError("");
|
||
setSelected(null);
|
||
setSelectedDetail(null);
|
||
setBusinessName("");
|
||
setOwnerFirstName("");
|
||
setOwnerLastName("");
|
||
setOwnerCell("+98");
|
||
setOwnerPassword("");
|
||
setSubmitError("");
|
||
setSubmitting(false);
|
||
}
|
||
}, [open]);
|
||
|
||
useEffect(() => {
|
||
if (!open || mode !== "connect") return;
|
||
|
||
const controller = new AbortController();
|
||
setLatestLoading(true);
|
||
setLatestError("");
|
||
void listLatestMeshkeeBusinesses(5, controller.signal)
|
||
.then((data) => {
|
||
if (controller.signal.aborted) return;
|
||
setLatest(data.items);
|
||
})
|
||
.catch((err) => {
|
||
if (isAbortError(err) || controller.signal.aborted) return;
|
||
setLatest([]);
|
||
setLatestError(
|
||
err instanceof ApiError
|
||
? err.message
|
||
: "Unable to load latest businesses.",
|
||
);
|
||
})
|
||
.finally(() => {
|
||
if (!controller.signal.aborted) setLatestLoading(false);
|
||
});
|
||
|
||
return () => controller.abort();
|
||
}, [open, mode]);
|
||
|
||
useEffect(() => {
|
||
if (!open || mode !== "connect") return;
|
||
|
||
const trimmed = query.trim();
|
||
if (trimmed.length < 2 || selected) {
|
||
setResults([]);
|
||
setSearching(false);
|
||
setSearchError("");
|
||
return;
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(async () => {
|
||
setSearching(true);
|
||
setSearchError("");
|
||
try {
|
||
const data = await searchMeshkeeBusinesses(trimmed, controller.signal);
|
||
if (controller.signal.aborted) return;
|
||
setResults(data.items);
|
||
} catch (err) {
|
||
if (isAbortError(err) || controller.signal.aborted) return;
|
||
setResults([]);
|
||
setSearchError(
|
||
err instanceof ApiError
|
||
? err.message
|
||
: "Unable to search Meshkee businesses.",
|
||
);
|
||
} finally {
|
||
if (!controller.signal.aborted) setSearching(false);
|
||
}
|
||
}, 250);
|
||
|
||
return () => {
|
||
controller.abort();
|
||
window.clearTimeout(timer);
|
||
};
|
||
}, [open, mode, query, selected]);
|
||
|
||
async function selectBusiness(item: MeshkeeBusinessSearchItem) {
|
||
setSelected(item);
|
||
setQuery(item.label || item.name);
|
||
setResults([]);
|
||
setSubmitError("");
|
||
setDetailLoading(true);
|
||
setSelectedDetail(null);
|
||
try {
|
||
const detail = await getMeshkeeBusiness(String(item.id));
|
||
setSelectedDetail(detail);
|
||
} catch (err) {
|
||
setSelectedDetail(null);
|
||
setSubmitError(
|
||
err instanceof Error ? err.message : "Could not load business details.",
|
||
);
|
||
} finally {
|
||
setDetailLoading(false);
|
||
}
|
||
}
|
||
|
||
const showSearchResults = !selected && query.trim().length >= 2;
|
||
const showLatestShortcut = !selected && query.trim().length < 2;
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<div className={styles.overlay} role="presentation" onClick={onClose}>
|
||
<div
|
||
className={styles.modal}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={titleId}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className={styles.header}>
|
||
<div>
|
||
<p className={styles.kicker}>Marketplace</p>
|
||
<h2 id={titleId} className={styles.title}>
|
||
Add business
|
||
</h2>
|
||
</div>
|
||
<button
|
||
ref={closeRef}
|
||
type="button"
|
||
className={styles.closeBtn}
|
||
aria-label="Close"
|
||
onClick={onClose}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className={styles.switch}
|
||
role="tablist"
|
||
aria-label="Add business mode"
|
||
>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mode === "connect"}
|
||
className={`${styles.switchBtn} ${mode === "connect" ? styles.switchBtnActive : ""}`}
|
||
onClick={() => setMode("connect")}
|
||
>
|
||
Connect Meshkee
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mode === "create"}
|
||
className={`${styles.switchBtn} ${mode === "create" ? styles.switchBtnActive : ""}`}
|
||
onClick={() => setMode("create")}
|
||
>
|
||
Create new
|
||
</button>
|
||
</div>
|
||
|
||
<p className={styles.modeHint}>
|
||
{mode === "connect"
|
||
? "Choose an existing Meshkee business to list on the marketplace."
|
||
: "Create a Meshkee business with an owner, then list it here."}
|
||
</p>
|
||
|
||
{mode === "connect" ? (
|
||
<form
|
||
className={styles.form}
|
||
onSubmit={async (event) => {
|
||
event.preventDefault();
|
||
if (!selected) {
|
||
setSubmitError("Select a Meshkee business from search first.");
|
||
return;
|
||
}
|
||
|
||
setSubmitting(true);
|
||
setSubmitError("");
|
||
try {
|
||
await createMarketplaceBusiness({
|
||
meshkeeBusinessId: String(selected.id),
|
||
slug: selected.slug,
|
||
name: selected.name,
|
||
status: "draft",
|
||
});
|
||
onConnected?.();
|
||
onClose();
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Could not connect business.",
|
||
);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}}
|
||
>
|
||
<div className={styles.searchBlock}>
|
||
<label className={styles.field}>
|
||
<span>Search Meshkee</span>
|
||
<input
|
||
value={selected ? selected.name : query}
|
||
onChange={(event) => {
|
||
setSelected(null);
|
||
setSelectedDetail(null);
|
||
setQuery(event.target.value);
|
||
setSubmitError("");
|
||
}}
|
||
placeholder="Type at least 2 characters…"
|
||
autoComplete="off"
|
||
disabled={submitting}
|
||
/>
|
||
</label>
|
||
|
||
{selected ? (
|
||
<button
|
||
type="button"
|
||
className={styles.clearSelection}
|
||
onClick={() => {
|
||
setSelected(null);
|
||
setSelectedDetail(null);
|
||
setQuery("");
|
||
}}
|
||
>
|
||
Clear selection
|
||
</button>
|
||
) : null}
|
||
|
||
{showSearchResults ? (
|
||
<div className={styles.results} role="listbox">
|
||
{searching ? (
|
||
<p className={styles.resultsStatus}>Searching…</p>
|
||
) : searchError ? (
|
||
<p className={styles.resultsError}>{searchError}</p>
|
||
) : results.length === 0 ? (
|
||
<p className={styles.resultsStatus}>No matches</p>
|
||
) : (
|
||
results.map((item) => (
|
||
<BusinessPickRow
|
||
key={String(item.id)}
|
||
item={item}
|
||
onSelect={(picked) => void selectBusiness(picked)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{showLatestShortcut ? (
|
||
<div className={styles.latestBlock}>
|
||
<p className={styles.latestLabel}>Latest on Meshkee</p>
|
||
<div className={styles.results} role="listbox">
|
||
{latestLoading ? (
|
||
<p className={styles.resultsStatus}>Loading…</p>
|
||
) : latestError ? (
|
||
<p className={styles.resultsError}>{latestError}</p>
|
||
) : latest.length === 0 ? (
|
||
<p className={styles.resultsStatus}>No businesses yet</p>
|
||
) : (
|
||
latest.map((item) => (
|
||
<BusinessPickRow
|
||
key={String(item.id)}
|
||
item={item}
|
||
onSelect={(picked) => void selectBusiness(picked)}
|
||
/>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{selected ? (
|
||
<div className={styles.selectedMeta}>
|
||
<p>
|
||
<strong>Business</strong>
|
||
<span>
|
||
{selected.name}
|
||
{selected.nameFa && selected.nameFa !== selected.name ? (
|
||
<>
|
||
{" / "}
|
||
<span className="fa" lang="fa">
|
||
{selected.nameFa}
|
||
</span>
|
||
</>
|
||
) : null}
|
||
</span>
|
||
</p>
|
||
<p>
|
||
<strong>Owner</strong>
|
||
<span>
|
||
{detailLoading ? (
|
||
"Loading…"
|
||
) : (
|
||
(() => {
|
||
const { name, cell } = ownerParts(
|
||
selectedDetail,
|
||
selected,
|
||
);
|
||
if (!name && !cell) return "No owner linked";
|
||
return (
|
||
<>
|
||
{name ? withFaSpans(name) : null}
|
||
{name && cell ? " · " : null}
|
||
{cell ? <PhoneLtr value={cell} /> : null}
|
||
</>
|
||
);
|
||
})()
|
||
)}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{submitError ? (
|
||
<p className={styles.submitError} role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.secondaryBtn}`}
|
||
onClick={onClose}
|
||
disabled={submitting}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className={`outline-btn ${styles.primaryBtn}`}
|
||
disabled={submitting || !selected || detailLoading}
|
||
>
|
||
{submitting ? "…" : "Connect"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<form
|
||
className={styles.form}
|
||
onSubmit={async (event) => {
|
||
event.preventDefault();
|
||
setSubmitting(true);
|
||
setSubmitError("");
|
||
try {
|
||
const categories = await searchMeshkeeCategories("other");
|
||
const other =
|
||
categories.find((c) => c.slug === "other") ?? categories[0];
|
||
if (!other) {
|
||
throw new Error("Meshkee “Other” category not found.");
|
||
}
|
||
|
||
const name = businessName.trim();
|
||
const slug = slugify(name);
|
||
if (!slug) throw new Error("Enter a valid business name.");
|
||
|
||
const created = await createMeshkeeBusiness({
|
||
name,
|
||
nameFa: name,
|
||
slug,
|
||
categoryIds: [Number(other.id)],
|
||
ownerFirstName: ownerFirstName.trim(),
|
||
ownerLastName: ownerLastName.trim(),
|
||
ownerFirstNameEn: ownerFirstName.trim(),
|
||
ownerLastNameEn: ownerLastName.trim(),
|
||
ownerCellNumber: ownerCell.trim(),
|
||
ownerPassword,
|
||
});
|
||
|
||
await createMarketplaceBusiness({
|
||
meshkeeBusinessId: created.id,
|
||
slug: created.slug || slug,
|
||
name: created.name || name,
|
||
status: "draft",
|
||
});
|
||
|
||
onConnected?.();
|
||
onClose();
|
||
} catch (err) {
|
||
setSubmitError(
|
||
err instanceof Error
|
||
? err.message
|
||
: "Could not create business.",
|
||
);
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}}
|
||
>
|
||
<label className={styles.field}>
|
||
<span>Business name</span>
|
||
<input
|
||
value={businessName}
|
||
onChange={(event) => setBusinessName(event.target.value)}
|
||
placeholder="Business name"
|
||
required
|
||
autoComplete="organization"
|
||
disabled={submitting}
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Owner first name</span>
|
||
<input
|
||
value={ownerFirstName}
|
||
onChange={(event) => setOwnerFirstName(event.target.value)}
|
||
required
|
||
autoComplete="given-name"
|
||
disabled={submitting}
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Owner last name</span>
|
||
<input
|
||
value={ownerLastName}
|
||
onChange={(event) => setOwnerLastName(event.target.value)}
|
||
required
|
||
autoComplete="family-name"
|
||
disabled={submitting}
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Owner cell</span>
|
||
<input
|
||
value={ownerCell}
|
||
onChange={(event) => setOwnerCell(event.target.value)}
|
||
placeholder="+98912…"
|
||
pattern="^\+[1-9]\d{6,14}$"
|
||
required
|
||
autoComplete="tel"
|
||
inputMode="tel"
|
||
dir="ltr"
|
||
disabled={submitting}
|
||
/>
|
||
</label>
|
||
<label className={styles.field}>
|
||
<span>Owner password</span>
|
||
<input
|
||
type="password"
|
||
value={ownerPassword}
|
||
onChange={(event) => setOwnerPassword(event.target.value)}
|
||
minLength={8}
|
||
required
|
||
autoComplete="new-password"
|
||
disabled={submitting}
|
||
/>
|
||
</label>
|
||
|
||
{submitError ? (
|
||
<p className={styles.submitError} role="alert">
|
||
{submitError}
|
||
</p>
|
||
) : null}
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
type="button"
|
||
className={`outline-btn ${styles.secondaryBtn}`}
|
||
onClick={onClose}
|
||
disabled={submitting}
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className={`outline-btn ${styles.primaryBtn}`}
|
||
disabled={submitting}
|
||
>
|
||
{submitting ? "…" : "Create"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|