Files
dashboards/components/AddBusinessModal.tsx
T

620 lines
19 KiB
TypeScript
Raw 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 { 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>
);
}