Update carousels, card hover, and IranBay branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-04 13:21:27 +03:30
co-authored by Cursor
commit 1153e8e78f
178 changed files with 30517 additions and 0 deletions
+619
View File
@@ -0,0 +1,619 @@
"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>
);
}