515 lines
15 KiB
TypeScript
515 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|