Files
dashboards/components/SpecialBusinessesPage.tsx
T

515 lines
15 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, 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>
);
}