Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { listBusinesses } from "@/services/marketplace";
|
||||
import {
|
||||
getSpecialBusinesses,
|
||||
newCarousel,
|
||||
saveSpecialBusinesses,
|
||||
slugFromTitle,
|
||||
type SpecialBusinessesConfig,
|
||||
type SpecialCarousel,
|
||||
} from "@/services/special-businesses";
|
||||
import type { MarketplaceBusiness } from "@/types/business";
|
||||
import {
|
||||
getBusinessProfile,
|
||||
getMeshkeeBusiness,
|
||||
} from "@/services/meshkee-businesses";
|
||||
import styles from "./SpecialBusinessesPage.module.css";
|
||||
|
||||
type EnrichedBusiness = MarketplaceBusiness & {
|
||||
displayName: string;
|
||||
subtext: string;
|
||||
};
|
||||
|
||||
type CarouselFormMode = "create" | "edit";
|
||||
|
||||
async function enrichBusinesses(
|
||||
items: MarketplaceBusiness[],
|
||||
): Promise<EnrichedBusiness[]> {
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const id = String(item.meshkeeBusinessId);
|
||||
const [meshkee, profile] = await Promise.all([
|
||||
getMeshkeeBusiness(id),
|
||||
getBusinessProfile(id),
|
||||
]);
|
||||
return {
|
||||
...item,
|
||||
name: meshkee.name,
|
||||
displayName:
|
||||
profile.profile.nameEn ||
|
||||
profile.profile.nameFa ||
|
||||
meshkee.name ||
|
||||
item.slug,
|
||||
subtext: (profile.profile.title || "").trim(),
|
||||
logoUrl: profile.profile.logoUrl ?? meshkee.logoUrl ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...item,
|
||||
displayName: item.name || item.slug,
|
||||
subtext: "",
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function BusinessIdentity({ item }: { item: EnrichedBusiness }) {
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
{item.logoUrl ? (
|
||||
<img className={styles.logo} src={item.logoUrl} alt="" height={44} />
|
||||
) : (
|
||||
<span className={styles.logoMark} aria-hidden>
|
||||
{item.displayName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.listMain}>
|
||||
<span className={styles.listName}>{item.displayName}</span>
|
||||
{item.subtext ? (
|
||||
<span className={styles.listSubtext}>{item.subtext}</span>
|
||||
) : (
|
||||
<span className={styles.listSlug}>/{item.slug}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SpecialBusinessesPage() {
|
||||
const [config, setConfig] = useState<SpecialBusinessesConfig | null>(null);
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const [allBusinesses, setAllBusinesses] = useState<EnrichedBusiness[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [formMode, setFormMode] = useState<CarouselFormMode | null>(null);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formSubtext, setFormSubtext] = useState("");
|
||||
const [formError, setFormError] = useState("");
|
||||
const formTitleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [special, listings] = await Promise.all([
|
||||
getSpecialBusinesses(),
|
||||
listBusinesses({ status: "all", limit: 200 }),
|
||||
]);
|
||||
const enriched = await enrichBusinesses(listings.items);
|
||||
if (cancelled) return;
|
||||
setConfig(special);
|
||||
setActiveId(special.carousels[0]?.id ?? "");
|
||||
setAllBusinesses(enriched);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not load special businesses.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const active = useMemo(
|
||||
() => config?.carousels.find((item) => item.id === activeId) ?? null,
|
||||
[config, activeId],
|
||||
);
|
||||
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, EnrichedBusiness>();
|
||||
for (const item of allBusinesses) map.set(item.id, item);
|
||||
return map;
|
||||
}, [allBusinesses]);
|
||||
|
||||
const selectedBusinesses = useMemo(() => {
|
||||
if (!active) return [];
|
||||
return active.businessIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter(Boolean) as EnrichedBusiness[];
|
||||
}, [active, byId]);
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (!active) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length <= 2) return [];
|
||||
const selected = new Set(active.businessIds);
|
||||
return allBusinesses
|
||||
.filter((item) => !selected.has(item.id))
|
||||
.filter(
|
||||
(item) =>
|
||||
item.displayName.toLowerCase().includes(q) ||
|
||||
item.subtext.toLowerCase().includes(q) ||
|
||||
item.slug.toLowerCase().includes(q) ||
|
||||
item.id.includes(q),
|
||||
)
|
||||
.slice(0, 12);
|
||||
}, [active, allBusinesses, query]);
|
||||
|
||||
function updateCarousel(id: string, patch: Partial<SpecialCarousel>) {
|
||||
setConfig((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
carousels: prev.carousels.map((item) =>
|
||||
item.id === id ? { ...item, ...patch } : item,
|
||||
),
|
||||
};
|
||||
});
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
setFormMode("create");
|
||||
setFormName("");
|
||||
setFormSubtext("");
|
||||
setFormError("");
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
if (!active) return;
|
||||
setFormMode("edit");
|
||||
setFormName(active.title);
|
||||
setFormSubtext(active.subtext || "");
|
||||
setFormError("");
|
||||
}
|
||||
|
||||
function closeFormModal() {
|
||||
setFormMode(null);
|
||||
setFormError("");
|
||||
}
|
||||
|
||||
function submitFormModal() {
|
||||
const title = formName.trim();
|
||||
if (!title) {
|
||||
setFormError("Name is required.");
|
||||
return;
|
||||
}
|
||||
const subtext = formSubtext.trim();
|
||||
|
||||
if (formMode === "create") {
|
||||
const next = newCarousel(title, subtext);
|
||||
setConfig((prev) =>
|
||||
prev
|
||||
? { carousels: [...prev.carousels, next] }
|
||||
: { carousels: [next] },
|
||||
);
|
||||
setActiveId(next.id);
|
||||
setStatus("");
|
||||
closeFormModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (formMode === "edit" && active) {
|
||||
updateCarousel(active.id, {
|
||||
title,
|
||||
subtext,
|
||||
slug: slugFromTitle(title),
|
||||
});
|
||||
closeFormModal();
|
||||
}
|
||||
}
|
||||
|
||||
function removeCarousel(id: string) {
|
||||
setConfig((prev) => {
|
||||
if (!prev || prev.carousels.length <= 1) return prev;
|
||||
const carousels = prev.carousels.filter((item) => item.id !== id);
|
||||
if (activeId === id) setActiveId(carousels[0]?.id ?? "");
|
||||
return { carousels };
|
||||
});
|
||||
setStatus("");
|
||||
closeFormModal();
|
||||
}
|
||||
|
||||
function addBusiness(businessId: string) {
|
||||
if (!active) return;
|
||||
if (active.businessIds.includes(businessId)) return;
|
||||
updateCarousel(active.id, {
|
||||
businessIds: [...active.businessIds, businessId],
|
||||
});
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
function removeBusiness(businessId: string) {
|
||||
if (!active) return;
|
||||
updateCarousel(active.id, {
|
||||
businessIds: active.businessIds.filter((id) => id !== businessId),
|
||||
});
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const saved = await saveSpecialBusinesses(config);
|
||||
setConfig(saved);
|
||||
if (!saved.carousels.some((item) => item.id === activeId)) {
|
||||
setActiveId(saved.carousels[0]?.id ?? "");
|
||||
}
|
||||
setStatus("Saved");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Loading special businesses…</div>;
|
||||
}
|
||||
|
||||
if (!config || !active) {
|
||||
return (
|
||||
<div className={styles.loading} role="alert">
|
||||
{error || "Could not load special businesses."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>Carousels</h1>
|
||||
<p className={styles.lead}>
|
||||
Homepage carousels as tabs — each tab holds multiple businesses.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
{status ? (
|
||||
<span className={`${styles.status} ${styles.statusOk}`}>{status}</span>
|
||||
) : null}
|
||||
{error ? (
|
||||
<span className={`${styles.status} ${styles.statusError}`}>
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
onClick={openEditModal}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
onClick={openCreateModal}
|
||||
>
|
||||
Add carousel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => void onSave()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.card}>
|
||||
<div className={styles.tabs} role="tablist" aria-label="Carousels">
|
||||
{config.carousels.map((carousel) => (
|
||||
<button
|
||||
key={carousel.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={carousel.id === activeId}
|
||||
className={`${styles.tab} ${
|
||||
carousel.id === activeId ? styles.tabActive : ""
|
||||
}`}
|
||||
onClick={() => setActiveId(carousel.id)}
|
||||
>
|
||||
{carousel.title || "Untitled"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.panel} role="tabpanel">
|
||||
<div className={styles.activeMeta}>
|
||||
<h2 className={styles.activeTitle}>{active.title}</h2>
|
||||
{active.subtext ? (
|
||||
<p className={styles.activeSubtext}>{active.subtext}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.searchRow}>
|
||||
<label className={styles.field}>
|
||||
<span>Search businesses</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by name or slug"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{query.trim().length > 2 ? (
|
||||
searchResults.length ? (
|
||||
<div className={styles.results}>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className={styles.resultRow}>
|
||||
<BusinessIdentity item={item} />
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.inlineAddBtn}`}
|
||||
onClick={() => addBusiness(item.id)}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>No matching businesses to add.</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<div className={styles.listHead}>
|
||||
<h3 className={styles.listHeading}>
|
||||
Businesses in this carousel
|
||||
</h3>
|
||||
<span className={styles.listCount}>
|
||||
{selectedBusinesses.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.list}>
|
||||
{selectedBusinesses.length ? (
|
||||
selectedBusinesses.map((item) => (
|
||||
<div key={item.id} className={styles.listItem}>
|
||||
<BusinessIdentity item={item} />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
onClick={() => removeBusiness(item.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={styles.empty}>
|
||||
No businesses in this carousel yet. Search above to add some.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{formMode ? (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) closeFormModal();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={formTitleId}
|
||||
>
|
||||
<div className={styles.modalHead}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Carousels</p>
|
||||
<h2 id={formTitleId} className={styles.modalTitle}>
|
||||
{formMode === "create" ? "Add carousel" : "Edit carousel"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="Close"
|
||||
onClick={closeFormModal}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submitFormModal();
|
||||
}}
|
||||
>
|
||||
<label className={styles.field}>
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="Featured producers"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>Subtext</span>
|
||||
<input
|
||||
value={formSubtext}
|
||||
onChange={(event) => setFormSubtext(event.target.value)}
|
||||
placeholder="Short supporting line"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{formError ? (
|
||||
<p className={styles.formError} role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
{formMode === "edit" && config.carousels.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dangerBtn}
|
||||
onClick={() => removeCarousel(active.id)}
|
||||
>
|
||||
Remove carousel
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className={styles.modalActionsRight}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
onClick={closeFormModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
>
|
||||
{formMode === "create" ? "Add" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user