Initial commit: dashboard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
.account {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 160px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-black);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
width: 7px;
|
||||
height: 10px;
|
||||
object-fit: contain;
|
||||
transform: rotate(90deg);
|
||||
opacity: 0.65;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
min-width: 196px;
|
||||
padding: 8px 0;
|
||||
background: var(--color-white);
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
background: none;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.item:hover,
|
||||
.item:focus-visible {
|
||||
color: var(--color-black);
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.itemMuted {
|
||||
color: #8e8e8e;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
getAdminDashboardOrigin,
|
||||
getCustomerDashboardOrigin,
|
||||
hostAppFor,
|
||||
} from "@/lib/host";
|
||||
import { openBusinessDashboardAsUser } from "@/lib/session-handoff";
|
||||
import { getFirstName } from "@/lib/userName";
|
||||
import styles from "./AccountMenu.module.css";
|
||||
|
||||
export default function AccountMenu() {
|
||||
const router = useRouter();
|
||||
const { user, logout, isBusinessOwner, isSuperAdmin } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const firstName = getFirstName(user);
|
||||
const app =
|
||||
typeof window !== "undefined" ? hostAppFor(window.location.host) : "admin";
|
||||
|
||||
const customerUrl = getCustomerDashboardOrigin();
|
||||
const adminUrl = getAdminDashboardOrigin();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className={styles.account} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
{firstName}
|
||||
<img
|
||||
className={`${styles.chevron} ${open ? styles.chevronOpen : ""}`}
|
||||
src="/images/icons/chevron.svg"
|
||||
alt=""
|
||||
width={7}
|
||||
height={12}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className={styles.menu} role="menu">
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${customerUrl}/profile`}
|
||||
className={styles.item}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
My profile
|
||||
</a>
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${customerUrl}/forgot-password`}
|
||||
className={styles.item}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Change password
|
||||
</a>
|
||||
{isBusinessOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.item}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
openBusinessDashboardAsUser("/profile");
|
||||
}}
|
||||
>
|
||||
{en.openBusinessDashboard}
|
||||
</button>
|
||||
) : null}
|
||||
{isSuperAdmin ? (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={adminUrl}
|
||||
className={styles.item}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Super admin
|
||||
</a>
|
||||
) : null}
|
||||
{app !== "customer" ? (
|
||||
<a
|
||||
role="menuitem"
|
||||
href={customerUrl}
|
||||
className={styles.item}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{en.openCustomerDashboard}
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${styles.item} ${styles.itemMuted}`}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
router.replace("/login");
|
||||
}}
|
||||
>
|
||||
{en.logOut}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 520px);
|
||||
max-height: min(90vh, 720px);
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.closeBtn:hover,
|
||||
.closeBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.switchBtn {
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.switchBtnActive {
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.modeHint {
|
||||
margin: 16px 0 22px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.searchBlock {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.latestBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.latestLabel {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.clearSelection {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.clearSelection:hover,
|
||||
.clearSelection:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.results {
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.resultsStatus,
|
||||
.resultsError {
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.resultsError {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.resultItem:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.resultItem:hover,
|
||||
.resultItem:focus-visible {
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.resultItem strong {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.resultName {
|
||||
display: inline;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.resultName span {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
letter-spacing: normal;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.resultSep {
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.resultFa {
|
||||
font-family: var(--font-farsi);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.resultItem > span,
|
||||
.resultMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.resultMeta :global(.fa) {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.selectedMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.selectedMeta p {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.selectedMeta strong {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.selectedMeta span {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.selectedMeta :global(.fa) {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.submitError {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 46px;
|
||||
padding: 14px 14px 10px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.field span {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 12px;
|
||||
background: #fff;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.field:focus-within span {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.field input {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
width: 100%;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
min-height: 34px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
border-color: #9e9e9e;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover,
|
||||
.secondaryBtn:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.modal {
|
||||
padding: 24px 18px 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
transition: border-color 0.35s ease, color 0.35s ease;
|
||||
}
|
||||
|
||||
.closeBtn:hover:not(:disabled),
|
||||
.closeBtn:focus-visible:not(:disabled) {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.field textarea:hover {
|
||||
border-color: #d8d8d8;
|
||||
}
|
||||
|
||||
.field textarea:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
min-height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
transition:
|
||||
color 0.35s ease,
|
||||
border-color 0.35s ease,
|
||||
background-color 0.35s ease,
|
||||
box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
border: 1px solid #d4d4d4;
|
||||
background: #fff;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.secondaryBtn:hover:not(:disabled),
|
||||
.secondaryBtn:focus-visible:not(:disabled) {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
border: 1px solid var(--color-black);
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.primaryBtn:hover:not(:disabled),
|
||||
.primaryBtn:focus-visible:not(:disabled) {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.secondaryBtn:disabled,
|
||||
.primaryBtn:disabled,
|
||||
.closeBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import aiStyles from "@/styles/ai.module.css";
|
||||
import styles from "./AiPromptPanel.module.css";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
busy?: boolean;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
onClose: () => void;
|
||||
onRun: (prompt: string) => void;
|
||||
};
|
||||
|
||||
export default function AiPromptPanel({
|
||||
open,
|
||||
title,
|
||||
initialPrompt,
|
||||
busy = false,
|
||||
error = "",
|
||||
disabled = false,
|
||||
onClose,
|
||||
onRun,
|
||||
}: Props) {
|
||||
const [prompt, setPrompt] = useState(initialPrompt);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setPrompt(initialPrompt);
|
||||
}, [open, initialPrompt]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.head}>
|
||||
<div>
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<p className={styles.subtitle}>
|
||||
Edit the prompt, then run AI to fill this field.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
disabled={busy}
|
||||
aria-label="Close"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Prompt</span>
|
||||
<textarea
|
||||
rows={10}
|
||||
value={prompt}
|
||||
disabled={busy || disabled}
|
||||
className={busy ? aiStyles.aiWorkingBorder : undefined}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.primaryBtn} ${aiStyles.aiTrigger}`}
|
||||
disabled={busy || disabled || prompt.trim().length < 10}
|
||||
onClick={() => onRun(prompt.trim())}
|
||||
>
|
||||
{busy ? "Generating…" : "Run AI"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FocusEvent } from "react";
|
||||
import styles from "./AuthShell.module.css";
|
||||
|
||||
function clearAutofillOnFocus(event: FocusEvent<HTMLInputElement>) {
|
||||
event.currentTarget.removeAttribute("readonly");
|
||||
}
|
||||
|
||||
export function AuthField({
|
||||
label,
|
||||
type = "text",
|
||||
name,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
required,
|
||||
disabled,
|
||||
dir,
|
||||
}: {
|
||||
label: string;
|
||||
type?: string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}) {
|
||||
return (
|
||||
<label className={styles.field}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type={type}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete ?? "off"}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
defaultValue=""
|
||||
readOnly
|
||||
onFocus={clearAutofillOnFocus}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthPasswordField({
|
||||
label,
|
||||
name,
|
||||
autoComplete,
|
||||
required,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={styles.passwordWrap}>
|
||||
<label className={styles.field}>
|
||||
<span>{label}</span>
|
||||
<input
|
||||
type={visible ? "text" : "password"}
|
||||
name={name}
|
||||
autoComplete={autoComplete ?? "off"}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
minLength={8}
|
||||
defaultValue=""
|
||||
readOnly
|
||||
onFocus={clearAutofillOnFocus}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.eyeBtn}
|
||||
aria-label={visible ? "Hide password" : "Show password"}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<img src="/images/icons/eye.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth, userCanAccessApp } from "@/components/AuthProvider";
|
||||
import { getAccessToken, getRefreshToken } from "@/lib/meshkee-api";
|
||||
import {
|
||||
getCustomerDashboardOrigin,
|
||||
hostAppFor,
|
||||
type HostApp,
|
||||
} from "@/lib/host";
|
||||
|
||||
function LoadingScreen({ message = "Loading" }: { message?: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#f6f6f6",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: "#8e8e8e",
|
||||
padding: 24,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
function handoffToWebsite(next: string) {
|
||||
try {
|
||||
const target = new URL(next);
|
||||
if (target.origin !== new URL(websiteUrl).origin) return false;
|
||||
const access = getAccessToken();
|
||||
const refresh = getRefreshToken();
|
||||
if (!access || !refresh) return false;
|
||||
const hash = new URLSearchParams({
|
||||
access,
|
||||
refresh,
|
||||
next: `${target.pathname}${target.search}${target.hash}` || "/register",
|
||||
}).toString();
|
||||
window.location.replace(`${target.origin}/auth/handoff#${hash}`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
requiredApp,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
requiredApp?: HostApp;
|
||||
}) {
|
||||
const { user, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const app =
|
||||
requiredApp ??
|
||||
hostAppFor(typeof window !== "undefined" ? window.location.host : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!user) {
|
||||
if (app === "business") {
|
||||
// Do not auto-bounce to admin (that was the manage-business loop).
|
||||
// Owners can sign in on the customer host.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("businessId")) {
|
||||
return;
|
||||
}
|
||||
window.location.replace(`${getCustomerDashboardOrigin()}/login`);
|
||||
return;
|
||||
}
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (!userCanAccessApp(user, app)) {
|
||||
if (app === "admin") {
|
||||
window.location.replace(`${getCustomerDashboardOrigin()}/`);
|
||||
return;
|
||||
}
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [isLoading, user, router, app]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (
|
||||
app === "business" &&
|
||||
typeof window !== "undefined" &&
|
||||
new URLSearchParams(window.location.search).has("businessId")
|
||||
) {
|
||||
return (
|
||||
<LoadingScreen message="Could not open this business. Go back and use Manage business again." />
|
||||
);
|
||||
}
|
||||
return <LoadingScreen message="Opening…" />;
|
||||
}
|
||||
|
||||
if (!userCanAccessApp(user, app)) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function GuestRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) return;
|
||||
|
||||
const nextParam = new URLSearchParams(window.location.search).get("next");
|
||||
if (nextParam && handoffToWebsite(nextParam)) return;
|
||||
|
||||
const app = hostAppFor(window.location.host);
|
||||
if (app === "admin") {
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
if (app === "business") {
|
||||
router.replace("/profile");
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading) return <LoadingScreen />;
|
||||
if (user) return null;
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { ApiError, getAccessToken, isAbortError, migrateTokensToSharedCookies } from "@/lib/meshkee-api";
|
||||
import {
|
||||
getAdminDashboardOrigin,
|
||||
getCustomerDashboardOrigin,
|
||||
hostAppFor,
|
||||
type HostApp,
|
||||
} from "@/lib/host";
|
||||
import {
|
||||
clearManagedBusinessId,
|
||||
getManagedBusinessId,
|
||||
setManagedBusinessId,
|
||||
} from "@/lib/managed-business";
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
register as registerRequest,
|
||||
type RegisterPayload,
|
||||
} from "@/services/auth";
|
||||
import type { AuthUser, DashboardType } from "@/types/auth";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
activeBusinessId: string | null;
|
||||
isBusinessOwner: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
login: (cellNumber: string, password: string) => Promise<DashboardType>;
|
||||
register: (payload: RegisterPayload) => Promise<DashboardType>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function pickActiveBusinessId(user: AuthUser): string | null {
|
||||
const owned = user.businesses.find((item) => item.isOwner);
|
||||
const first = owned ?? user.businesses[0];
|
||||
return first ? String(first.id) : null;
|
||||
}
|
||||
|
||||
export function userCanAccessApp(user: AuthUser, app: HostApp): boolean {
|
||||
if (app === "admin") return user.dashboard === "super_admin";
|
||||
if (app === "customer") {
|
||||
// Shared entry: customer, business owners, and superadmins all land here first.
|
||||
return (
|
||||
user.dashboard === "customer" ||
|
||||
user.dashboard === "business" ||
|
||||
user.dashboard === "super_admin"
|
||||
);
|
||||
}
|
||||
// Business dashboard: owners/staff, or superadmin (via managed business id).
|
||||
if (user.dashboard === "super_admin") return true;
|
||||
return Boolean(pickActiveBusinessId(user));
|
||||
}
|
||||
|
||||
function currentHost(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.location.host;
|
||||
}
|
||||
|
||||
/** Prefer ?businessId= on the URL, then stored managed id. */
|
||||
function syncManagedBusinessFromUrl() {
|
||||
if (typeof window === "undefined") return getManagedBusinessId();
|
||||
const fromUrl = new URLSearchParams(window.location.search)
|
||||
.get("businessId")
|
||||
?.trim();
|
||||
if (fromUrl) {
|
||||
setManagedBusinessId(fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
return getManagedBusinessId();
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [managedBusinessId, setManagedBusinessIdState] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
logoutRequest();
|
||||
clearManagedBusinessId();
|
||||
setManagedBusinessIdState(null);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function init() {
|
||||
// Handoff installs tokens via inline script and redirects — don't race it.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname === "/auth/handoff"
|
||||
) {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
migrateTokensToSharedCookies();
|
||||
setManagedBusinessIdState(syncManagedBusinessFromUrl());
|
||||
|
||||
if (!getAccessToken()) {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { user: currentUser } = await fetchCurrentUser(controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const app = hostAppFor(currentHost());
|
||||
if (!userCanAccessApp(currentUser, app)) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const managed = syncManagedBusinessFromUrl();
|
||||
setManagedBusinessIdState(managed);
|
||||
setUser(currentUser);
|
||||
} catch (err) {
|
||||
if (isAbortError(err) || controller.signal.aborted) return;
|
||||
// On business host, keep tokens so a transient /me failure doesn't
|
||||
// bounce manage-business back to admin.
|
||||
if (hostAppFor(currentHost()) === "business") {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
logout();
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
return () => controller.abort();
|
||||
}, [logout]);
|
||||
|
||||
const login = useCallback(async (cellNumber: string, password: string) => {
|
||||
const data = await loginRequest(cellNumber, password);
|
||||
const app = hostAppFor(currentHost());
|
||||
|
||||
if (!userCanAccessApp(data.user, app)) {
|
||||
logoutRequest();
|
||||
const message =
|
||||
app === "admin"
|
||||
? "This account does not have super admin access."
|
||||
: app === "business"
|
||||
? "No business is linked to this account."
|
||||
: "This account cannot use the customer dashboard.";
|
||||
throw new ApiError(message, 403);
|
||||
}
|
||||
|
||||
if (app === "business" && data.user.dashboard === "super_admin") {
|
||||
const managed = syncManagedBusinessFromUrl();
|
||||
setManagedBusinessIdState(managed);
|
||||
if (!managed && !pickActiveBusinessId(data.user)) {
|
||||
logoutRequest();
|
||||
throw new ApiError(
|
||||
"Open a business from the admin dashboard and use Manage business.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setManagedBusinessIdState(syncManagedBusinessFromUrl());
|
||||
setUser(data.user);
|
||||
return data.user.dashboard;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (payload: RegisterPayload) => {
|
||||
const data = await registerRequest(payload);
|
||||
|
||||
if (!data.accessToken || !data.refreshToken || !data.user) {
|
||||
throw new ApiError(
|
||||
data.message ||
|
||||
"Registration needs phone verification before you can continue.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const app = hostAppFor(currentHost());
|
||||
if (!userCanAccessApp(data.user, app)) {
|
||||
logoutRequest();
|
||||
throw new ApiError(
|
||||
"This account cannot use the customer dashboard.",
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
setManagedBusinessIdState(syncManagedBusinessFromUrl());
|
||||
setUser(data.user);
|
||||
return data.user.dashboard;
|
||||
}, []);
|
||||
|
||||
const isSuperAdmin = user?.dashboard === "super_admin";
|
||||
|
||||
/**
|
||||
* Shared business.* host — which Meshkee business?
|
||||
* - Superadmin: managed id from “Manage business” handoff (required for other businesses)
|
||||
* - Everyone else: first owned / linked business on the user
|
||||
*/
|
||||
const activeBusinessId = useMemo(() => {
|
||||
if (!user) return null;
|
||||
if (user.dashboard === "super_admin") {
|
||||
return managedBusinessId || pickActiveBusinessId(user);
|
||||
}
|
||||
return pickActiveBusinessId(user);
|
||||
}, [user, managedBusinessId]);
|
||||
|
||||
const isBusinessOwner = useMemo(
|
||||
() => Boolean(user?.businesses.some((item) => item.isOwner)),
|
||||
[user],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
activeBusinessId,
|
||||
isBusinessOwner,
|
||||
isSuperAdmin: Boolean(isSuperAdmin),
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
}),
|
||||
[
|
||||
user,
|
||||
isLoading,
|
||||
activeBusinessId,
|
||||
isBusinessOwner,
|
||||
isSuperAdmin,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* After login: stay on the host you signed into.
|
||||
* Superadmin can open the admin dashboard from customer home when needed.
|
||||
*/
|
||||
export function postLoginHref(dashboard: DashboardType): string {
|
||||
const app = hostAppFor(currentHost());
|
||||
|
||||
if (app === "admin") {
|
||||
return dashboard === "super_admin" ? "/" : `${getCustomerDashboardOrigin()}/`;
|
||||
}
|
||||
|
||||
if (app === "business") return "/profile";
|
||||
if (app === "customer") return "/";
|
||||
|
||||
if (dashboard === "super_admin") return `${getAdminDashboardOrigin()}/`;
|
||||
return `${getCustomerDashboardOrigin()}/`;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px var(--page-pad);
|
||||
background-color: #f6f6f6;
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
width: min(100%, 1148px);
|
||||
min-height: 660px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 344px) minmax(0, 1fr);
|
||||
grid-template-rows: 1fr;
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
box-shadow: 33px 25px 29.7px -14px rgba(0, 0, 0, 0.03);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.visual {
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
background: #efefef;
|
||||
border-radius: 5px 0 0 5px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ship {
|
||||
position: absolute;
|
||||
left: 8%;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: 72%;
|
||||
max-width: none;
|
||||
object-fit: contain;
|
||||
object-position: left bottom;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
filter: drop-shadow(0 18px 28px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 48px 40px;
|
||||
min-width: 0;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
width: min(100%, 468px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 69px;
|
||||
height: 69px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.brandText span {
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.brandText strong {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 46px;
|
||||
padding: 14px 14px 10px;
|
||||
border: 1px solid #f6f6f6;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.field span {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 12px;
|
||||
background: #fff;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.field:focus-within span {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.field input {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
width: 100%;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.passwordWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.passwordWrap .field {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.eyeBtn {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.eyeBtn img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 29px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #6a6a6a;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
text-decoration-thickness: 1px;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover,
|
||||
.secondaryBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.secondaryBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
min-width: 107px;
|
||||
min-height: 29px;
|
||||
margin-left: auto;
|
||||
padding: 0 18px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 5px;
|
||||
background: #f6f6f6;
|
||||
border: 1px solid #e8e8e8;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
color: #4a4a4a;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 28px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 400;
|
||||
color: #8e8e8e;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.note a,
|
||||
.noteLink {
|
||||
color: #8e8e8e;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note a:hover,
|
||||
.noteLink:hover {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #8e8e8e;
|
||||
}
|
||||
|
||||
.note em {
|
||||
font-style: normal;
|
||||
color: #cacaca;
|
||||
}
|
||||
|
||||
.rule {
|
||||
border: 0;
|
||||
border-top: 1px solid #ececec;
|
||||
margin: 20px 0 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: #8e8e8e;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.socials {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.socials img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.visual {
|
||||
min-height: 220px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
|
||||
.ship {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
width: auto;
|
||||
height: 88%;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32px 24px 28px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.form,
|
||||
.note,
|
||||
.rule,
|
||||
.links,
|
||||
.socials {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ReactNode } from "react";
|
||||
import styles from "./AuthShell.module.css";
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
export default function AuthShell({
|
||||
titleLine1,
|
||||
titleLine2,
|
||||
children,
|
||||
footerNote,
|
||||
}: {
|
||||
titleLine1: string;
|
||||
titleLine2: string;
|
||||
children: ReactNode;
|
||||
footerNote?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.visual}>
|
||||
<img className={styles.ship} src="/images/auth/ship.png" alt="" />
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<div className={styles.contentInner}>
|
||||
<div className={styles.brand}>
|
||||
<img src="/images/icons/logo.png" alt="" width={69} height={69} />
|
||||
<div className={styles.brandText}>
|
||||
<span>{titleLine1}</span>
|
||||
<strong>{titleLine2}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
|
||||
{footerNote ? <div className={styles.note}>{footerNote}</div> : null}
|
||||
|
||||
<hr className={styles.rule} />
|
||||
|
||||
<nav className={styles.links} aria-label="Auth footer">
|
||||
<a href={`${websiteUrl}/businesses`}>Categories</a>
|
||||
<span>|</span>
|
||||
<a href={`${websiteUrl}/#vision`}>Vision</a>
|
||||
<span>|</span>
|
||||
<a href={`${websiteUrl}/about`}>About us</a>
|
||||
<span>|</span>
|
||||
<a href={`${websiteUrl}/contact`}>Contact us</a>
|
||||
</nav>
|
||||
|
||||
<div className={styles.socials}>
|
||||
<a href="https://instagram.com" aria-label="Instagram">
|
||||
<img
|
||||
src="/images/icons/instagram.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</a>
|
||||
<a href="https://telegram.org" aria-label="Telegram">
|
||||
<img
|
||||
src="/images/icons/telegram.png"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</a>
|
||||
<a href="https://facebook.com" aria-label="Facebook">
|
||||
<img
|
||||
src="/images/icons/facebook.png"
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||||
import CategoryTreeView, {
|
||||
type CategoryTreeNode,
|
||||
} from "@/components/CategoryTreeView";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import type { MeshkeeCategory } from "@/services/meshkee-categories";
|
||||
import {
|
||||
createWebsiteBlogCategory,
|
||||
deleteWebsiteBlogCategory,
|
||||
listWebsiteBlogCategories,
|
||||
updateWebsiteBlogCategory,
|
||||
type WebsiteBlogCategory,
|
||||
} from "@/services/website-blog";
|
||||
import styles from "./BusinessCategoriesPage.module.css";
|
||||
|
||||
type Draft = {
|
||||
id?: string;
|
||||
parentId: string;
|
||||
name: string;
|
||||
nameFa: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
function emptyDraft(parentId = ""): Draft {
|
||||
return {
|
||||
parentId,
|
||||
name: "",
|
||||
nameFa: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
function categoryLabel(category: {
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
}) {
|
||||
if (category.nameFa && category.nameFa !== category.name) {
|
||||
return `${category.name} / ${category.nameFa}`;
|
||||
}
|
||||
return category.name;
|
||||
}
|
||||
|
||||
function toTreeSelect(items: WebsiteBlogCategory[]): MeshkeeCategory[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
parentId: item.parentId,
|
||||
name: item.name,
|
||||
nameFa: item.nameFa ?? null,
|
||||
slug: "",
|
||||
description: item.description ?? null,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
tags: [],
|
||||
}));
|
||||
}
|
||||
|
||||
function toViewNodes(items: WebsiteBlogCategory[]): CategoryTreeNode[] {
|
||||
const byId = new Map<string, CategoryTreeNode>();
|
||||
for (const item of items) {
|
||||
byId.set(item.id, {
|
||||
id: item.id,
|
||||
name: categoryLabel(item),
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const item of items) {
|
||||
const node = byId.get(item.id)!;
|
||||
if (item.parentId && byId.has(item.parentId)) {
|
||||
byId.get(item.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
const sortNodes = (nodes: CategoryTreeNode[]) => {
|
||||
nodes.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function collectDescendantIds(
|
||||
rootId: string,
|
||||
items: WebsiteBlogCategory[],
|
||||
): Set<string> {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const item of items) {
|
||||
if (!item.parentId) continue;
|
||||
const list = childrenByParent.get(item.parentId) ?? [];
|
||||
list.push(item.id);
|
||||
childrenByParent.set(item.parentId, list);
|
||||
}
|
||||
const result = new Set<string>([rootId]);
|
||||
const queue = [rootId];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const childId of childrenByParent.get(current) ?? []) {
|
||||
if (!result.has(childId)) {
|
||||
result.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function BlogCategoriesPage() {
|
||||
const [categories, setCategories] = useState<WebsiteBlogCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [removing, setRemoving] = useState<WebsiteBlogCategory | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const tree = useMemo(() => toViewNodes(categories), [categories]);
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, WebsiteBlogCategory>();
|
||||
for (const item of categories) map.set(item.id, item);
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
const source = !draft?.id
|
||||
? categories
|
||||
: categories.filter(
|
||||
(item) => !collectDescendantIds(draft.id, categories).has(item.id),
|
||||
);
|
||||
return toTreeSelect(source);
|
||||
}, [categories, draft?.id]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setCategories(await listWebsiteBlogCategories());
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
function openCreate(parentId = "") {
|
||||
setDraft(emptyDraft(parentId));
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const category = byId.get(id);
|
||||
if (!category) return;
|
||||
setDraft({
|
||||
id: category.id,
|
||||
parentId: category.parentId ?? "",
|
||||
name: category.name,
|
||||
nameFa: category.nameFa ?? "",
|
||||
description: category.description ?? "",
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) {
|
||||
setError("Enter an English name.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = {
|
||||
parentId: draft.parentId || null,
|
||||
name: draft.name.trim(),
|
||||
nameFa: draft.nameFa.trim(),
|
||||
description: draft.description.trim(),
|
||||
};
|
||||
if (draft.id) {
|
||||
await updateWebsiteBlogCategory(draft.id, body);
|
||||
} else {
|
||||
await createWebsiteBlogCategory(body);
|
||||
}
|
||||
setDraft(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not save category.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteWebsiteBlogCategory(removing.id);
|
||||
setRemoving(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not remove category.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const removingName = removing ? categoryLabel(removing) : "this category";
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<Link href="/website/blog">Blog</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>Categories</span>
|
||||
</nav>
|
||||
<h1 className={styles.title}>Categories</h1>
|
||||
<p className={styles.lead}>
|
||||
Blog categories. Add, edit, or remove the tree.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<p className={styles.count}>
|
||||
{loading ? "Loading…" : `${categories.length} categories`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
onClick={() => openCreate()}
|
||||
>
|
||||
Add category
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className={styles.card}>
|
||||
{loading ? <p className={styles.empty}>Loading categories…</p> : null}
|
||||
{!loading ? (
|
||||
<CategoryTreeView
|
||||
nodes={tree}
|
||||
actions={{
|
||||
onAddChild: openCreate,
|
||||
onEdit: openEdit,
|
||||
onRemove: (id) => {
|
||||
const category = byId.get(id);
|
||||
if (category) setRemoving(category);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{draft ? (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !saving) {
|
||||
setDraft(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="blog-category-draft-title"
|
||||
>
|
||||
<div className={styles.modalHead}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Categories</p>
|
||||
<h2 id="blog-category-draft-title" className={styles.modalTitle}>
|
||||
{draft.id ? "Edit category" : "Add category"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="Close"
|
||||
disabled={saving}
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void saveDraft();
|
||||
}}
|
||||
>
|
||||
<CategoryTreeSelect
|
||||
categories={parentOptions}
|
||||
value={draft.parentId ? [draft.parentId] : []}
|
||||
selection="single"
|
||||
label="Parent"
|
||||
placeholder="Top level"
|
||||
disabled={saving}
|
||||
onChange={(ids) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, parentId: ids[0] ?? "" }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{draft.parentId ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearParent}
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft((current) =>
|
||||
current ? { ...current, parentId: "" } : current,
|
||||
)
|
||||
}
|
||||
>
|
||||
Top level
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Name EN</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
disabled={saving}
|
||||
required
|
||||
autoFocus
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, name: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Name FA</span>
|
||||
<input
|
||||
className="fa"
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
value={draft.nameFa}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, nameFa: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={draft.description}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, description: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.ghostBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : draft.id ? "Update" : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
kicker="Remove category"
|
||||
title={`Remove ${removingName}?`}
|
||||
message="This category and its subcategories will be removed."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
.grid {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:has(.moreWrapOpen),
|
||||
.card:has(.moreWrap:hover),
|
||||
.card:has(.moreWrap:focus-within) {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.coverWrap {
|
||||
position: relative;
|
||||
height: 168px;
|
||||
overflow: hidden;
|
||||
border-radius: 5px 5px 0 0;
|
||||
background: var(--color-card);
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.coverFallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 5px 9px;
|
||||
border-radius: 3px;
|
||||
background: rgba(246, 246, 246, 0.92);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.statusLive {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.statusReview {
|
||||
background: rgba(232, 232, 232, 0.95);
|
||||
}
|
||||
|
||||
.statusDraft,
|
||||
.statusHidden,
|
||||
.statusRejected {
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-card);
|
||||
border-top: 0;
|
||||
border-radius: 0 0 5px 5px;
|
||||
padding: 0;
|
||||
background: var(--color-white);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.body:has(.moreWrapOpen),
|
||||
.body:has(.moreWrap:hover),
|
||||
.body:has(.moreWrap:focus-within) {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin: -40px 18px 0;
|
||||
background: var(--color-card);
|
||||
border-radius: 5px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Keep logo / name readable while the body wipe fills behind them */
|
||||
.body:global(.tile-hover):hover .info .name,
|
||||
.body:global(.tile-hover):focus-within .info .name {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.body:global(.tile-hover):hover .info .tagline,
|
||||
.body:global(.tile-hover):focus-within .info .tagline {
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.body:global(.tile-hover):hover .info .logoMark,
|
||||
.body:global(.tile-hover):focus-within .info .logoMark {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: auto;
|
||||
height: 52px;
|
||||
max-width: min(160px, 42%);
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.logoMark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
background: var(--color-white);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
margin-top: 3px;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: auto;
|
||||
padding: 14px 18px 16px;
|
||||
border-radius: 0 0 5px 5px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.bottom:has(.moreWrapOpen),
|
||||
.bottom:has(.moreWrap:hover),
|
||||
.bottom:has(.moreWrap:focus-within) {
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slug {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-body);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.featured {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 12px;
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.control {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 8px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.control + .control {
|
||||
border-left: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.control:hover,
|
||||
.control:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.body:hover .control,
|
||||
.body:focus-within .control,
|
||||
.body:hover .control:hover,
|
||||
.body:focus-within .control:hover,
|
||||
.body:hover .control:focus-visible,
|
||||
.body:focus-within .control:focus-visible,
|
||||
.body:hover .controlAccent,
|
||||
.body:focus-within .controlAccent,
|
||||
.body:hover .slug,
|
||||
.body:focus-within .slug,
|
||||
.body:hover .featured,
|
||||
.body:focus-within .featured {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.body:hover .controls,
|
||||
.body:focus-within .controls {
|
||||
border-top-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.body:hover .control + .control,
|
||||
.body:focus-within .control + .control {
|
||||
border-left-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.body:hover .moreWrap::before,
|
||||
.body:focus-within .moreWrap::before {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.body:hover .moreTrigger img,
|
||||
.body:focus-within .moreTrigger img {
|
||||
opacity: 1;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.control:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.controlAccent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.controlPrimary {
|
||||
color: var(--color-black);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.moreWrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.moreWrapOpen,
|
||||
.moreWrap:hover,
|
||||
.moreWrap:focus-within {
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.moreWrap::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: #e4e4e4;
|
||||
}
|
||||
|
||||
/* Invisible bridge so the cursor can reach the menu without losing hover */
|
||||
.moreWrap::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
width: 148px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.moreTrigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-left: 0 !important;
|
||||
}
|
||||
|
||||
.moreTrigger img {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
object-fit: contain;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.2s ease, filter 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.moreWrap:hover .moreTrigger,
|
||||
.moreWrap:focus-within .moreTrigger,
|
||||
.moreWrapOpen .moreTrigger {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.body:hover .moreWrap:hover .moreTrigger,
|
||||
.body:focus-within .moreWrap:hover .moreTrigger,
|
||||
.body:hover .moreWrap:focus-within .moreTrigger,
|
||||
.body:focus-within .moreWrap:focus-within .moreTrigger,
|
||||
.body:hover .moreWrapOpen .moreTrigger,
|
||||
.body:focus-within .moreWrapOpen .moreTrigger {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.moreWrap:hover .moreTrigger img,
|
||||
.moreWrap:focus-within .moreTrigger img,
|
||||
.moreWrapOpen .moreTrigger img {
|
||||
opacity: 1;
|
||||
filter: brightness(0);
|
||||
}
|
||||
|
||||
.body:hover .moreWrap:hover .moreTrigger img,
|
||||
.body:focus-within .moreWrap:hover .moreTrigger img,
|
||||
.body:hover .moreWrap:focus-within .moreTrigger img,
|
||||
.body:focus-within .moreWrap:focus-within .moreTrigger img,
|
||||
.body:hover .moreWrapOpen .moreTrigger img,
|
||||
.body:focus-within .moreWrapOpen .moreTrigger img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.moreMenu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
min-width: 132px;
|
||||
padding: 6px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease,
|
||||
visibility 0.16s ease;
|
||||
}
|
||||
|
||||
.moreWrap:hover .moreMenu,
|
||||
.moreWrap:focus-within .moreMenu,
|
||||
.moreWrapOpen .moreMenu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.moreWrapOpen::after,
|
||||
.moreWrap:hover::after,
|
||||
.moreWrap:focus-within::after {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.moreItem {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Beat global .tile-hover a { color: white } so Website stays readable */
|
||||
.body:global(.tile-hover):hover .moreItem,
|
||||
.body:global(.tile-hover):focus-within .moreItem,
|
||||
.body:hover .moreItem,
|
||||
.body:focus-within .moreItem {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.moreItem:hover,
|
||||
.moreItem:focus-visible,
|
||||
.body:global(.tile-hover):hover .moreItem:hover,
|
||||
.body:global(.tile-hover):focus-within .moreItem:hover,
|
||||
.body:global(.tile-hover):hover .moreItem:focus-visible,
|
||||
.body:global(.tile-hover):focus-within .moreItem:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.moreItemDanger:hover,
|
||||
.moreItemDanger:focus-visible,
|
||||
.body:global(.tile-hover):hover .moreItemDanger:hover,
|
||||
.body:global(.tile-hover):focus-within .moreItemDanger:hover,
|
||||
.body:global(.tile-hover):hover .moreItemDanger:focus-visible,
|
||||
.body:global(.tile-hover):focus-within .moreItemDanger:focus-visible {
|
||||
color: #b42318;
|
||||
background: #fff5f4;
|
||||
}
|
||||
|
||||
.moreItem:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty {
|
||||
min-height: 280px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 48px 28px;
|
||||
}
|
||||
|
||||
.emptyTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.emptyCopy {
|
||||
margin-top: 12px;
|
||||
max-width: 360px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.controls {
|
||||
gap: 8px 0;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { openBusinessDashboardAsAdmin } from "@/lib/session-handoff";
|
||||
import type { BusinessStatus, MarketplaceBusiness } from "@/types/business";
|
||||
import styles from "./BusinessCardList.module.css";
|
||||
|
||||
const STATUS_LABEL: Record<BusinessStatus, string> = {
|
||||
draft: "Draft",
|
||||
pending_review: "Review",
|
||||
published: "Published",
|
||||
hidden: "Hidden",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
function statusClass(status: BusinessStatus) {
|
||||
switch (status) {
|
||||
case "published":
|
||||
return styles.statusLive;
|
||||
case "pending_review":
|
||||
return styles.statusReview;
|
||||
case "draft":
|
||||
return styles.statusDraft;
|
||||
case "hidden":
|
||||
return styles.statusHidden;
|
||||
case "rejected":
|
||||
return styles.statusRejected;
|
||||
default:
|
||||
return styles.statusDraft;
|
||||
}
|
||||
}
|
||||
|
||||
function publicUrl(slug: string) {
|
||||
return `${websiteUrl}/businesses/${slug}`;
|
||||
}
|
||||
|
||||
export default function BusinessCardList({
|
||||
items,
|
||||
onApprove,
|
||||
onReject,
|
||||
onDisable,
|
||||
onRemove,
|
||||
onMetaTag,
|
||||
onMakeSpecial,
|
||||
busyId,
|
||||
emptyTitle = "No businesses yet",
|
||||
emptyCopy = "Marketplace listings will appear here once you link a Meshkee business.",
|
||||
}: {
|
||||
items: MarketplaceBusiness[];
|
||||
onApprove?: (business: MarketplaceBusiness) => void;
|
||||
onReject?: (business: MarketplaceBusiness) => void;
|
||||
onDisable?: (business: MarketplaceBusiness) => void;
|
||||
onRemove?: (business: MarketplaceBusiness) => void;
|
||||
onMetaTag?: (business: MarketplaceBusiness) => void;
|
||||
onMakeSpecial?: (business: MarketplaceBusiness) => void;
|
||||
busyId?: string | null;
|
||||
emptyTitle?: string;
|
||||
emptyCopy?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [openMoreId, setOpenMoreId] = useState<string | null>(null);
|
||||
const moreRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMoreId) return;
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const root = moreRefs.current.get(openMoreId);
|
||||
if (root && !root.contains(event.target as Node)) {
|
||||
setOpenMoreId(null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown);
|
||||
}, [openMoreId]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyTitle}>{emptyTitle}</p>
|
||||
<p className={styles.emptyCopy}>{emptyCopy}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.grid}>
|
||||
{items.map((business) => {
|
||||
const displayName = business.name || business.slug;
|
||||
const category = business.categoryName || "Uncategorized";
|
||||
const categoryTrail =
|
||||
business.categoryTrail || business.categoryName || "Uncategorized";
|
||||
const href = publicUrl(business.slug);
|
||||
const showHref = `/businesses/${business.id}`;
|
||||
const isHidden = business.status === "hidden";
|
||||
const isPending = business.status === "pending_review";
|
||||
const canPublish =
|
||||
business.status === "pending_review" ||
|
||||
business.status === "draft" ||
|
||||
business.status === "rejected";
|
||||
const isBusy = busyId === business.id;
|
||||
const moreOpen = openMoreId === business.id;
|
||||
|
||||
function openShow() {
|
||||
router.push(showHref);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={business.id}>
|
||||
<article
|
||||
className={styles.card}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`Show ${displayName}`}
|
||||
onClick={openShow}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openShow();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.coverWrap}>
|
||||
{business.bannerUrl ? (
|
||||
<img
|
||||
src={business.bannerUrl}
|
||||
alt=""
|
||||
className={styles.cover}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.coverFallback} aria-hidden />
|
||||
)}
|
||||
<span
|
||||
className={`${styles.status} ${statusClass(business.status)}`}
|
||||
>
|
||||
{STATUS_LABEL[business.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={`tile-hover ${styles.body}`}>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.identity}>
|
||||
{business.logoUrl ? (
|
||||
<img
|
||||
className={styles.logo}
|
||||
src={business.logoUrl}
|
||||
alt=""
|
||||
height={52}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.logoMark} aria-hidden>
|
||||
{displayName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.copy}>
|
||||
<h2 className={styles.name}>{displayName}</h2>
|
||||
<p className={styles.tagline}>{category}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottom}>
|
||||
<div className={styles.foot}>
|
||||
<span className={styles.slug}>{categoryTrail}</span>
|
||||
{business.isFeatured ? (
|
||||
<span className={styles.featured}>Featured</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.controls}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canPublish && onApprove ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.control} ${styles.controlPrimary}`}
|
||||
disabled={isBusy}
|
||||
onClick={() => onApprove(business)}
|
||||
>
|
||||
{isBusy ? "…" : "Approve"}
|
||||
</button>
|
||||
) : null}
|
||||
{isPending && onReject ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.control}
|
||||
disabled={isBusy}
|
||||
onClick={() => onReject(business)}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.control}
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
router.push(`/businesses/${business.id}/edit`)
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.control}
|
||||
disabled={isBusy || !business.meshkeeBusinessId}
|
||||
onClick={() =>
|
||||
openBusinessDashboardAsAdmin(
|
||||
business.meshkeeBusinessId,
|
||||
"/profile",
|
||||
)
|
||||
}
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.control}
|
||||
disabled={isBusy}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(href);
|
||||
} catch {
|
||||
window.prompt("Copy link", href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`${styles.moreWrap} ${moreOpen ? styles.moreWrapOpen : ""}`}
|
||||
ref={(node) => {
|
||||
if (node) moreRefs.current.set(business.id, node);
|
||||
else moreRefs.current.delete(business.id);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.control} ${styles.moreTrigger}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moreOpen}
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setOpenMoreId((id) =>
|
||||
id === business.id ? null : business.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
More
|
||||
<img
|
||||
src="/images/icons/add.svg"
|
||||
alt=""
|
||||
width={10}
|
||||
height={10}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className={styles.moreMenu} role="menu">
|
||||
<a
|
||||
role="menuitem"
|
||||
className={styles.moreItem}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => setOpenMoreId(null)}
|
||||
>
|
||||
Website
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.moreItem}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setOpenMoreId(null);
|
||||
onMetaTag?.(business);
|
||||
}}
|
||||
>
|
||||
Meta tag
|
||||
</button>
|
||||
{onMakeSpecial ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.moreItem}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setOpenMoreId(null);
|
||||
onMakeSpecial(business);
|
||||
}}
|
||||
>
|
||||
Make special
|
||||
</button>
|
||||
) : null}
|
||||
{!isPending ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.moreItem}
|
||||
disabled={isBusy || !onDisable}
|
||||
onClick={() => {
|
||||
setOpenMoreId(null);
|
||||
onDisable?.(business);
|
||||
}}
|
||||
>
|
||||
{isBusy ? "…" : isHidden ? "Enable" : "Disable"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${styles.moreItem} ${styles.moreItemDanger}`}
|
||||
disabled={isBusy || !onRemove}
|
||||
onClick={() => {
|
||||
setOpenMoreId(null);
|
||||
onRemove?.(business);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb a:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.headActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.addBtn::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.addBtn:hover,
|
||||
.addBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.ghostBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 520px);
|
||||
max-height: min(90vh, 720px);
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.modalHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn:hover,
|
||||
.closeBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.field input {
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
padding: 10px 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: #bbb;
|
||||
}
|
||||
|
||||
.clearParent {
|
||||
align-self: flex-start;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.clearParent:hover,
|
||||
.clearParent:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 5px;
|
||||
background: #fff5f5;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 8px 12px 12px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 28px 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.headActions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||||
import CategoryTreeView, {
|
||||
type CategoryTreeNode,
|
||||
} from "@/components/CategoryTreeView";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
categoryLabel,
|
||||
createMeshkeeCategory,
|
||||
deleteMeshkeeCategory,
|
||||
searchMeshkeeCategories,
|
||||
updateMeshkeeCategory,
|
||||
type MeshkeeCategory,
|
||||
} from "@/services/meshkee-categories";
|
||||
import styles from "./BusinessCategoriesPage.module.css";
|
||||
|
||||
type Draft = {
|
||||
id?: string;
|
||||
parentId: string;
|
||||
name: string;
|
||||
nameFa: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
function emptyDraft(parentId = ""): Draft {
|
||||
return {
|
||||
parentId,
|
||||
name: "",
|
||||
nameFa: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
function toViewNodes(items: MeshkeeCategory[]): CategoryTreeNode[] {
|
||||
const byId = new Map<string, CategoryTreeNode>();
|
||||
for (const item of items) {
|
||||
byId.set(item.id, {
|
||||
id: item.id,
|
||||
name: categoryLabel(item),
|
||||
hint: item.slug ? `/${item.slug}` : undefined,
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
const roots: CategoryTreeNode[] = [];
|
||||
for (const item of items) {
|
||||
const node = byId.get(item.id)!;
|
||||
if (item.parentId && byId.has(item.parentId)) {
|
||||
byId.get(item.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
const sortNodes = (nodes: CategoryTreeNode[], source: MeshkeeCategory[]) => {
|
||||
const order = new Map(source.map((item) => [item.id, item.sortOrder]));
|
||||
nodes.sort(
|
||||
(a, b) =>
|
||||
(order.get(a.id) ?? 0) - (order.get(b.id) ?? 0) ||
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children, source);
|
||||
};
|
||||
sortNodes(roots, items);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function collectDescendantIds(
|
||||
rootId: string,
|
||||
items: MeshkeeCategory[],
|
||||
): Set<string> {
|
||||
const childrenByParent = new Map<string, string[]>();
|
||||
for (const item of items) {
|
||||
if (!item.parentId) continue;
|
||||
const list = childrenByParent.get(item.parentId) ?? [];
|
||||
list.push(item.id);
|
||||
childrenByParent.set(item.parentId, list);
|
||||
}
|
||||
const result = new Set<string>([rootId]);
|
||||
const queue = [rootId];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const childId of childrenByParent.get(current) ?? []) {
|
||||
if (!result.has(childId)) {
|
||||
result.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function BusinessCategoriesPage() {
|
||||
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [removing, setRemoving] = useState<MeshkeeCategory | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const tree = useMemo(() => toViewNodes(categories), [categories]);
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, MeshkeeCategory>();
|
||||
for (const item of categories) map.set(item.id, item);
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
if (!draft?.id) return categories;
|
||||
const blocked = collectDescendantIds(draft.id, categories);
|
||||
return categories.filter((item) => !blocked.has(item.id));
|
||||
}, [categories, draft?.id]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setCategories(await searchMeshkeeCategories(""));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
function openCreate(parentId = "") {
|
||||
setDraft(emptyDraft(parentId));
|
||||
setError("");
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const category = byId.get(id);
|
||||
if (!category) return;
|
||||
setDraft({
|
||||
id: category.id,
|
||||
parentId: category.parentId ?? "",
|
||||
name: category.name,
|
||||
nameFa: category.nameFa ?? "",
|
||||
description: category.description ?? "",
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) {
|
||||
setError("Enter an English name.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const body = {
|
||||
parentId: draft.parentId || null,
|
||||
name: draft.name.trim(),
|
||||
nameFa: draft.nameFa.trim(),
|
||||
description: draft.description.trim(),
|
||||
};
|
||||
if (draft.id) {
|
||||
await updateMeshkeeCategory(draft.id, body);
|
||||
} else {
|
||||
await createMeshkeeCategory(body);
|
||||
}
|
||||
setDraft(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not save category.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await deleteMeshkeeCategory(removing.id);
|
||||
setRemoving(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not remove category.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const removingName = removing
|
||||
? categoryLabel(removing)
|
||||
: "this category";
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<Link href="/businesses">Businesses</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>Categories</span>
|
||||
</nav>
|
||||
<h1 className={styles.title}>Categories</h1>
|
||||
<p className={styles.lead}>
|
||||
Marketplace categories from Meshkee. Add, edit, or remove the tree.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<p className={styles.count}>
|
||||
{loading ? "Loading…" : `${categories.length} categories`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
onClick={() => openCreate()}
|
||||
>
|
||||
Add category
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className={styles.card}>
|
||||
{loading ? <p className={styles.empty}>Loading categories…</p> : null}
|
||||
{!loading ? (
|
||||
<CategoryTreeView
|
||||
nodes={tree}
|
||||
actions={{
|
||||
onAddChild: openCreate,
|
||||
onEdit: openEdit,
|
||||
onRemove: (id) => {
|
||||
const category = byId.get(id);
|
||||
if (category) setRemoving(category);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{draft ? (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !saving) {
|
||||
setDraft(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="category-draft-title"
|
||||
>
|
||||
<div className={styles.modalHead}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Categories</p>
|
||||
<h2 id="category-draft-title" className={styles.modalTitle}>
|
||||
{draft.id ? "Edit category" : "Add category"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="Close"
|
||||
disabled={saving}
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void saveDraft();
|
||||
}}
|
||||
>
|
||||
<CategoryTreeSelect
|
||||
categories={parentOptions}
|
||||
value={draft.parentId ? [draft.parentId] : []}
|
||||
selection="single"
|
||||
label="Parent"
|
||||
placeholder="Top level"
|
||||
disabled={saving}
|
||||
onChange={(ids) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, parentId: ids[0] ?? "" }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
{draft.parentId ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearParent}
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setDraft((current) =>
|
||||
current ? { ...current, parentId: "" } : current,
|
||||
)
|
||||
}
|
||||
>
|
||||
Top level
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Name EN</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
disabled={saving}
|
||||
required
|
||||
autoFocus
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, name: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Name FA</span>
|
||||
<input
|
||||
className="fa"
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
value={draft.nameFa}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, nameFa: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Description</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={draft.description}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, description: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.modalActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.ghostBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.addBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : draft.id ? "Update" : "Add"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
kicker="Remove category"
|
||||
title={`Remove ${removingName}?`}
|
||||
message="This category and its subcategories will be removed from the marketplace tree."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 32px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: inherit;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb a:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.headActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actionBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
margin-top: 24px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: #e8e8e8;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.heroImg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heroFallback {
|
||||
width: 100%;
|
||||
aspect-ratio: 18 / 6;
|
||||
background: linear-gradient(180deg, #ececec 0%, #dedede 100%);
|
||||
}
|
||||
|
||||
.profile {
|
||||
margin-top: -36px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0 clamp(20px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.profileBar {
|
||||
background: var(--color-white);
|
||||
border-radius: 5px;
|
||||
min-height: 110px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: auto;
|
||||
height: 56px;
|
||||
max-width: min(180px, 42%);
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
object-fit: contain;
|
||||
object-position: left center;
|
||||
}
|
||||
|
||||
.logoMark {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: clamp(15px, 1.4vw, 22px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: clamp(16px, 2.5vw, 36px);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.statWide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
.statLabelStack {
|
||||
font-size: 12px;
|
||||
line-height: 1.15;
|
||||
text-transform: uppercase;
|
||||
color: #969696;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: clamp(18px, 1.8vw, 26px);
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.statMonth {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-soft);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.statValueLg {
|
||||
font-size: clamp(28px, 2.6vw, 42px);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: 20px;
|
||||
max-width: 920px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
margin-top: 32px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: clamp(16px, 2vw, 32px);
|
||||
border-bottom: 1px solid #ececec;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tab,
|
||||
.tabActive {
|
||||
position: relative;
|
||||
padding: 0 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
font-weight: 500;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.tabActive::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: var(--color-black);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.productsSection {
|
||||
scroll-margin-top: 32px;
|
||||
}
|
||||
|
||||
.pills,
|
||||
.childPills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.childPills {
|
||||
margin-top: -6px;
|
||||
margin-bottom: 20px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.pillActive {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.pillActive {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.productGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.blogGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.blogCard {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.cardFootSolo .cardBody {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* Same bottom→top wipe when hovering the whole card */
|
||||
.card:hover .cardFoot:global(.tile-hover)::before,
|
||||
.card:focus-within .cardFoot:global(.tile-hover)::before,
|
||||
.blogCard:hover .cardFoot:global(.tile-hover)::before,
|
||||
.blogCard:focus-within .cardFoot:global(.tile-hover)::before,
|
||||
.galleryItem:hover .galleryFoot:global(.tile-hover)::before,
|
||||
.galleryItem:focus-within .galleryFoot:global(.tile-hover)::before {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
.card:hover .cardFoot:global(.tile-hover) :is(h3, p, span, button),
|
||||
.card:focus-within .cardFoot:global(.tile-hover) :is(h3, p, span, button),
|
||||
.blogCard:hover .cardFoot:global(.tile-hover) :is(h3, p, span, button),
|
||||
.blogCard:focus-within .cardFoot:global(.tile-hover) :is(h3, p, span, button),
|
||||
.galleryItem:hover .galleryFoot:global(.tile-hover) :is(button),
|
||||
.galleryItem:focus-within .galleryFoot:global(.tile-hover) :is(button) {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.card:hover .cardControls,
|
||||
.card:focus-within .cardControls,
|
||||
.blogCard:hover .cardControls,
|
||||
.blogCard:focus-within .cardControls,
|
||||
.galleryItem:hover .cardControls,
|
||||
.galleryItem:focus-within .cardControls {
|
||||
border-top-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.card:hover .cardControl + .cardControl,
|
||||
.card:focus-within .cardControl + .cardControl,
|
||||
.blogCard:hover .cardControl + .cardControl,
|
||||
.blogCard:focus-within .cardControl + .cardControl,
|
||||
.galleryItem:hover .cardControl + .cardControl,
|
||||
.galleryItem:focus-within .cardControl + .cardControl {
|
||||
border-left-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.cardMedia {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: #f3f3f3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cardControls {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: auto;
|
||||
padding: 14px 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.cardControl {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.cardControl:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.cardControl + .cardControl {
|
||||
border-left: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.cardControl:hover,
|
||||
.cardControl:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.card:hover .cardControl:hover,
|
||||
.card:focus-within .cardControl:hover,
|
||||
.blogCard:hover .cardControl:hover,
|
||||
.blogCard:focus-within .cardControl:hover,
|
||||
.galleryItem:hover .cardControl:hover,
|
||||
.galleryItem:focus-within .cardControl:hover {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.cardControlDanger:hover,
|
||||
.cardControlDanger:focus-visible {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.card:hover .cardControlDanger:hover,
|
||||
.card:focus-within .cardControlDanger:hover,
|
||||
.blogCard:hover .cardControlDanger:hover,
|
||||
.blogCard:focus-within .cardControlDanger:hover,
|
||||
.galleryItem:hover .cardControlDanger:hover,
|
||||
.galleryItem:focus-within .cardControlDanger:hover {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
.cardControl:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.galleryFoot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
padding: 0 16px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.galleryFoot .cardControls {
|
||||
margin-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.cardMedia img,
|
||||
.galleryItem img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.portfolioMedia img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mediaFallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
.cardBody,
|
||||
.blogBody {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cardBody h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.blogBody h3,
|
||||
.portfolioOverlay h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cardBody p {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: var(--color-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.blogBody p,
|
||||
.portfolioOverlay p {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
text-align: justify;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panelHint {
|
||||
margin: 0 0 14px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pageNumbers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pageBtn,
|
||||
.pageNum,
|
||||
.pageNumActive {
|
||||
min-height: 34px;
|
||||
min-width: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.pageNum,
|
||||
.pageNumActive {
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.pageNumActive {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled),
|
||||
.pageBtn:focus-visible:not(:disabled),
|
||||
.pageNum:hover:not(:disabled),
|
||||
.pageNum:focus-visible:not(:disabled) {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.pageBtn:disabled,
|
||||
.pageNum:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pageNumActive:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pageEllipsis {
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.contact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.contactRows {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.contactRow {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
padding: 16px 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.contactLabel {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.contactRow p {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.socialChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.socialChip em {
|
||||
font-style: normal;
|
||||
text-transform: capitalize;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.gallery {
|
||||
columns: 3;
|
||||
column-gap: 14px;
|
||||
}
|
||||
|
||||
.galleryItem {
|
||||
position: relative;
|
||||
break-inside: avoid;
|
||||
margin: 0 0 14px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.galleryTall img {
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.portfolioGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.portfolioCard {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portfolioCard .portfolioMedia {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.portfolioCard .cardFoot {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.portfolioCard .cardControls {
|
||||
padding: 12px 0 14px;
|
||||
}
|
||||
|
||||
.portfolioMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
padding: 12px 12px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.portfolioTitleEn {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.25;
|
||||
color: var(--color-black);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portfolioTitleFa {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
line-height: 1.35;
|
||||
color: var(--color-soft);
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portfolioCard:hover .cardFoot:global(.tile-hover)::before,
|
||||
.portfolioCard:focus-within .cardFoot:global(.tile-hover)::before {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
.portfolioCard:hover .cardFoot:global(.tile-hover) :is(h3, p, span, button),
|
||||
.portfolioCard:focus-within .cardFoot:global(.tile-hover) :is(h3, p, span, button) {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.portfolioCard:hover .cardControls,
|
||||
.portfolioCard:focus-within .cardControls {
|
||||
border-top-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.portfolioCard:hover .cardControl + .cardControl,
|
||||
.portfolioCard:focus-within .cardControl + .cardControl {
|
||||
border-left-color: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
|
||||
.portfolioCard:hover .cardControl:hover,
|
||||
.portfolioCard:focus-within .cardControl:hover {
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.portfolioCard:hover .cardControlDanger:hover,
|
||||
.portfolioCard:focus-within .cardControlDanger:hover {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
.portfolioMedia {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.portfolioMedia .mediaFallback {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.emptyTabs,
|
||||
.loading,
|
||||
.error {
|
||||
margin-top: 28px;
|
||||
padding: 36px 28px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
border: 1px solid #f0f0f0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.errorCopy {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
display: inline-block;
|
||||
margin-top: 16px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.productGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.blogGrid,
|
||||
.portfolioGrid,
|
||||
.contactRows {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gallery {
|
||||
columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.productGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.blogGrid,
|
||||
.portfolioGrid,
|
||||
.contactRows {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
columns: 1;
|
||||
}
|
||||
|
||||
.profile {
|
||||
padding: 0 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import BlogPanel from "@/components/catalog/BlogPanel";
|
||||
import PortfoliosPanel from "@/components/catalog/PortfoliosPanel";
|
||||
import ProductsPanel from "@/components/catalog/ProductsPanel";
|
||||
import { toPlainText } from "@/components/catalog/utils";
|
||||
import { openBusinessDashboardAsAdmin } from "@/lib/session-handoff";
|
||||
import { getMarketplaceBusiness } from "@/services/marketplace";
|
||||
import {
|
||||
getBusinessProfile,
|
||||
getMeshkeeBusiness,
|
||||
type BusinessAddress,
|
||||
type BusinessPhoneNumber,
|
||||
} from "@/services/meshkee-businesses";
|
||||
import {
|
||||
listMeshkeeBlogs,
|
||||
listMeshkeePortfolios,
|
||||
listMeshkeeProducts,
|
||||
} from "@/services/meshkee-content";
|
||||
import styles from "./BusinessDetailsPage.module.css";
|
||||
|
||||
type TabId = "products" | "contact" | "blog" | "portfolios";
|
||||
|
||||
const TAB_LABELS: Record<TabId, string> = {
|
||||
products: "Products",
|
||||
contact: "Contact info",
|
||||
blog: "Blog",
|
||||
portfolios: "Portfolios",
|
||||
};
|
||||
|
||||
const SOCIAL_ICONS: Record<string, string> = {
|
||||
instagram: "/images/icons/instagram.png",
|
||||
telegram: "/images/icons/telegram.png",
|
||||
facebook: "/images/icons/facebook.png",
|
||||
whatsapp: "/images/icons/telegram.png",
|
||||
linkedin: "/images/icons/facebook.png",
|
||||
youtube: "/images/icons/facebook.png",
|
||||
aparat: "/images/icons/facebook.png",
|
||||
};
|
||||
|
||||
function hasContactData(input: {
|
||||
addresses: BusinessAddress[];
|
||||
phones: BusinessPhoneNumber[];
|
||||
emails: string[];
|
||||
socials: { network: string; handle: string }[];
|
||||
}) {
|
||||
return (
|
||||
input.addresses.some(
|
||||
(item) =>
|
||||
item.address.trim() ||
|
||||
item.city.trim() ||
|
||||
item.province.trim() ||
|
||||
(item.landline ?? "").trim(),
|
||||
) ||
|
||||
input.phones.some((item) => item.number.trim()) ||
|
||||
input.emails.some((item) => item.trim()) ||
|
||||
input.socials.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export default function BusinessDetailsPage({
|
||||
listingId,
|
||||
}: {
|
||||
listingId: string;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [meshkeeId, setMeshkeeId] = useState("");
|
||||
const [tab, setTab] = useState<TabId>("products");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [categoryName, setCategoryName] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
|
||||
const [about, setAbout] = useState("");
|
||||
const [vision, setVision] = useState("");
|
||||
const [sinceMonth, setSinceMonth] = useState("");
|
||||
const [sinceYear, setSinceYear] = useState("");
|
||||
const [province, setProvince] = useState("");
|
||||
const [telephone, setTelephone] = useState("");
|
||||
|
||||
const [productTotal, setProductTotal] = useState(0);
|
||||
const [blogTotal, setBlogTotal] = useState(0);
|
||||
const [portfolioTotal, setPortfolioTotal] = useState(0);
|
||||
|
||||
const [addresses, setAddresses] = useState<BusinessAddress[]>([]);
|
||||
const [phones, setPhones] = useState<BusinessPhoneNumber[]>([]);
|
||||
const [emails, setEmails] = useState<string[]>([]);
|
||||
const [socials, setSocials] = useState<
|
||||
{ network: string; handle: string; icon: string }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const listing = await getMarketplaceBusiness(listingId);
|
||||
const id = String(listing.meshkeeBusinessId);
|
||||
|
||||
const [
|
||||
meshkee,
|
||||
profile,
|
||||
productsRes,
|
||||
blogsRes,
|
||||
portfoliosRes,
|
||||
] = await Promise.all([
|
||||
getMeshkeeBusiness(id),
|
||||
getBusinessProfile(id),
|
||||
listMeshkeeProducts(id, { page: 1, pageSize: 1 }).catch(() => ({
|
||||
total: 0,
|
||||
})),
|
||||
listMeshkeeBlogs(id, { page: 1, pageSize: 1 }).catch(() => ({
|
||||
total: 0,
|
||||
})),
|
||||
listMeshkeePortfolios(id, { page: 1, pageSize: 1 }).catch(() => ({
|
||||
total: 0,
|
||||
})),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const nextAddresses = profile.addresses ?? [];
|
||||
const nextPhones = profile.profile.phoneNumbers ?? [];
|
||||
const nextEmails = (profile.profile.emails ?? []).filter(Boolean);
|
||||
const socialMedia = profile.profile.socialMedia ?? {};
|
||||
const nextSocials = Object.entries(socialMedia)
|
||||
.filter(([, handle]) => Boolean(handle?.trim()))
|
||||
.map(([network, handle]) => ({
|
||||
network,
|
||||
handle: handle.trim(),
|
||||
icon: SOCIAL_ICONS[network] ?? "/images/icons/telegram.png",
|
||||
}));
|
||||
|
||||
setMeshkeeId(id);
|
||||
setName(meshkee.name || listing.slug);
|
||||
setSlug(listing.slug);
|
||||
setCategoryName(meshkee.categories[0]?.name ?? "");
|
||||
setLogoUrl(profile.profile.logoUrl ?? meshkee.logoUrl);
|
||||
setBannerUrl(profile.profile.bannerUrl ?? meshkee.bannerUrl);
|
||||
setAbout(profile.profile.about ?? "");
|
||||
setVision(profile.profile.vision ?? "");
|
||||
if (meshkee.createdAt) {
|
||||
const joined = new Date(meshkee.createdAt);
|
||||
setSinceMonth(
|
||||
joined.toLocaleDateString("en-US", { month: "long" }),
|
||||
);
|
||||
setSinceYear(String(joined.getFullYear()));
|
||||
} else {
|
||||
setSinceMonth("");
|
||||
setSinceYear("");
|
||||
}
|
||||
setProvince(nextAddresses[0]?.province ?? "");
|
||||
setTelephone(
|
||||
nextPhones.find((item) => item.number.trim())?.number ??
|
||||
nextAddresses.find((item) => (item.landline ?? "").trim())
|
||||
?.landline ??
|
||||
"",
|
||||
);
|
||||
setProductTotal(productsRes.total ?? 0);
|
||||
setBlogTotal(blogsRes.total ?? 0);
|
||||
setPortfolioTotal(portfoliosRes.total ?? 0);
|
||||
setAddresses(nextAddresses);
|
||||
setPhones(nextPhones as BusinessPhoneNumber[]);
|
||||
setEmails(nextEmails);
|
||||
setSocials(nextSocials);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not load business details.",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [listingId]);
|
||||
|
||||
const contactReady = hasContactData({
|
||||
addresses,
|
||||
phones,
|
||||
emails,
|
||||
socials,
|
||||
});
|
||||
|
||||
const availableTabs = useMemo(() => {
|
||||
const tabs: TabId[] = [];
|
||||
if (productTotal > 0) tabs.push("products");
|
||||
if (contactReady) tabs.push("contact");
|
||||
if (blogTotal > 0) tabs.push("blog");
|
||||
// Gallery disabled until a dedicated gallery section exists (media library ≠ gallery).
|
||||
if (portfolioTotal > 0) tabs.push("portfolios");
|
||||
return tabs;
|
||||
}, [productTotal, contactReady, blogTotal, portfolioTotal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableTabs.length) return;
|
||||
if (!availableTabs.includes(tab)) setTab(availableTabs[0]);
|
||||
}, [availableTabs, tab]);
|
||||
|
||||
const description = toPlainText(about) || toPlainText(vision);
|
||||
|
||||
function openBusinessDashboard(nextPath: string) {
|
||||
if (!meshkeeId) return;
|
||||
openBusinessDashboardAsAdmin(meshkeeId, nextPath);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Loading business details…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
<p className={styles.errorTitle}>Could not load details</p>
|
||||
<p className={styles.errorCopy}>{error}</p>
|
||||
<Link href="/businesses" className={styles.backLink}>
|
||||
Back to businesses
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<Link href="/businesses">Businesses</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>{name}</span>
|
||||
</nav>
|
||||
<div className={styles.headActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.actionBtn}`}
|
||||
disabled={!meshkeeId}
|
||||
onClick={() => openBusinessDashboard("/profile")}
|
||||
>
|
||||
Manage business
|
||||
</button>
|
||||
<Link
|
||||
href={`/businesses/${listingId}/edit`}
|
||||
className={`outline-btn ${styles.actionBtn}`}
|
||||
>
|
||||
Edit listing
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.hero}>
|
||||
{bannerUrl ? (
|
||||
<img src={bannerUrl} alt="" className={styles.heroImg} />
|
||||
) : (
|
||||
<div className={styles.heroFallback} aria-hidden />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={styles.profile}>
|
||||
<div className={styles.profileBar}>
|
||||
<div className={styles.identity}>
|
||||
{logoUrl ? (
|
||||
<img className={styles.logo} src={logoUrl} alt="" height={56} />
|
||||
) : (
|
||||
<span className={styles.logoMark} aria-hidden>
|
||||
{name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<h2 className={styles.name}>{name}</h2>
|
||||
<p className={styles.tagline}>{categoryName || `/${slug}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
{sinceMonth && sinceYear ? (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>since</span>
|
||||
<strong className={styles.statValue}>{sinceYear}</strong>
|
||||
<span className={styles.statMonth}>{sinceMonth}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{province ? (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>province</span>
|
||||
<strong className={styles.statValue}>{province}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.statWide}>
|
||||
<strong className={styles.statValueLg}>{portfolioTotal}</strong>
|
||||
<span className={styles.statLabelStack}>
|
||||
active
|
||||
<br />
|
||||
{portfolioTotal === 1 ? "portfolio" : "portfolios"}
|
||||
</span>
|
||||
</div>
|
||||
{telephone ? (
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statLabel}>Telephone</span>
|
||||
<strong className={styles.statValue}>{telephone}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{description ? (
|
||||
<p className={styles.description} dir="auto">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{availableTabs.length && meshkeeId ? (
|
||||
<>
|
||||
<nav className={styles.tabs} aria-label="Business sections">
|
||||
{availableTabs.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={tab === id ? styles.tabActive : styles.tab}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{TAB_LABELS[id]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className={styles.panel} aria-live="polite">
|
||||
{tab === "products" ? (
|
||||
<ProductsPanel
|
||||
meshkeeId={meshkeeId}
|
||||
controlsMode="manage"
|
||||
onManage={() => openBusinessDashboard("/products")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{tab === "contact" ? (
|
||||
<div className={styles.contact}>
|
||||
<div className={styles.contactRows}>
|
||||
{addresses.map((address, index) => (
|
||||
<div
|
||||
key={address.id ?? `${address.city}-${index}`}
|
||||
className={styles.contactRow}
|
||||
>
|
||||
<span className={styles.contactLabel}>
|
||||
{address.name || "Address"}
|
||||
</span>
|
||||
<p dir="auto">
|
||||
{[address.address, address.city, address.province]
|
||||
.filter(Boolean)
|
||||
.join(", ")}
|
||||
</p>
|
||||
{address.postalCode ? (
|
||||
<p>Postal: {address.postalCode}</p>
|
||||
) : null}
|
||||
{address.landline ? (
|
||||
<p>Landline: {address.landline}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{phones.length ? (
|
||||
<div className={styles.contactRow}>
|
||||
<span className={styles.contactLabel}>Phones</span>
|
||||
<p>
|
||||
{phones
|
||||
.filter((item) => item.number.trim())
|
||||
.map((item) => item.number)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{emails.length ? (
|
||||
<div className={styles.contactRow}>
|
||||
<span className={styles.contactLabel}>Email</span>
|
||||
<p>{emails.join(" · ")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{socials.length ? (
|
||||
<div className={styles.socials}>
|
||||
{socials.map((social) => (
|
||||
<span key={social.network} className={styles.socialChip}>
|
||||
<img src={social.icon} alt="" width={16} height={16} />
|
||||
<em>{social.network}</em> {social.handle}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.actionBtn}`}
|
||||
style={{ marginTop: 18 }}
|
||||
onClick={() => openBusinessDashboard("/profile")}
|
||||
>
|
||||
Manage business
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "blog" ? (
|
||||
<BlogPanel
|
||||
meshkeeId={meshkeeId}
|
||||
controlsMode="manage"
|
||||
onManage={() => openBusinessDashboard("/blog")}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "portfolios" ? (
|
||||
<PortfoliosPanel
|
||||
meshkeeId={meshkeeId}
|
||||
controlsMode="manage"
|
||||
onManage={() => openBusinessDashboard("/portfolios")}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.emptyTabs}>
|
||||
No catalog tabs yet — products, blog, portfolios, or contact data will
|
||||
appear here when Meshkee has them.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import AccountMenu from "@/components/AccountMenu";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { businessNavItems } from "@/data/business-nav";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
getAdminDashboardOrigin,
|
||||
getCustomerDashboardOrigin,
|
||||
} from "@/lib/host";
|
||||
import { customerDashboardHref } from "@/lib/session-handoff";
|
||||
import { getDisplayName } from "@/lib/userName";
|
||||
import { getBusinessProfile } from "@/services/meshkee-businesses";
|
||||
import styles from "./DashboardShell.module.css";
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
export default function BusinessShell({
|
||||
children,
|
||||
manageMeshkeeId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** When set, nav stays on admin host under /manage/[id]/… */
|
||||
manageMeshkeeId?: string;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout, isSuperAdmin, activeBusinessId } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [businessName, setBusinessName] = useState("");
|
||||
const [businessTitle, setBusinessTitle] = useState("");
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const displayName = getDisplayName(user);
|
||||
const linkedBusiness =
|
||||
user?.businesses.find((item) => item.isOwner) ?? user?.businesses[0];
|
||||
|
||||
const meshkeeId = manageMeshkeeId || activeBusinessId || null;
|
||||
|
||||
const manageBase = manageMeshkeeId
|
||||
? `/manage/${encodeURIComponent(manageMeshkeeId)}`
|
||||
: null;
|
||||
|
||||
const publicPath = manageBase
|
||||
? pathname.replace(manageBase, "") || "/profile"
|
||||
: pathname.startsWith("/business")
|
||||
? pathname.replace(/^\/business/, "") || "/"
|
||||
: pathname;
|
||||
|
||||
useEffect(() => {
|
||||
if (!meshkeeId) {
|
||||
setBusinessName(linkedBusiness?.name?.trim() || "");
|
||||
setBusinessTitle("");
|
||||
setLogoUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const profile = await getBusinessProfile(meshkeeId);
|
||||
if (cancelled) return;
|
||||
const name =
|
||||
profile.profile.nameEn?.trim() ||
|
||||
profile.profile.nameFa?.trim() ||
|
||||
linkedBusiness?.name?.trim() ||
|
||||
"";
|
||||
setBusinessName(name);
|
||||
setBusinessTitle(profile.profile.title?.trim() || "");
|
||||
setLogoUrl(profile.profile.logoUrl?.trim() || null);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setBusinessName(linkedBusiness?.name?.trim() || "");
|
||||
setBusinessTitle("");
|
||||
setLogoUrl(null);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId, linkedBusiness?.name]);
|
||||
|
||||
function navHref(itemHref: string) {
|
||||
if (!manageBase) return itemHref;
|
||||
return `${manageBase}${itemHref === "/" ? "/profile" : itemHref}`;
|
||||
}
|
||||
|
||||
const brandName = businessName || linkedBusiness?.name || "Business";
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<aside className={`${styles.sidebar} ${menuOpen ? styles.sidebarOpen : ""}`}>
|
||||
<div className={styles.brand}>
|
||||
{logoUrl ? (
|
||||
<img
|
||||
className={styles.brandLogo}
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.brandText}>
|
||||
<strong className={styles.brandBusiness} dir="auto" title={brandName}>
|
||||
{brandName}
|
||||
</strong>
|
||||
{businessTitle ? (
|
||||
<span
|
||||
className={styles.brandTitle}
|
||||
dir="auto"
|
||||
title={businessTitle}
|
||||
>
|
||||
{businessTitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav} aria-label="Business">
|
||||
{businessNavItems.map((item) => {
|
||||
const href = navHref(item.href);
|
||||
const active =
|
||||
publicPath === item.href ||
|
||||
publicPath.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={href}
|
||||
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<img src={item.icon} alt="" width={18} height={18} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={styles.sidebarFoot}>
|
||||
<p className={styles.userLabel}>{displayName}</p>
|
||||
{isSuperAdmin || manageMeshkeeId ? (
|
||||
<Link href="/businesses" className={styles.siteLink}>
|
||||
Admin dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<a href={customerDashboardHref("/")} className={styles.siteLink}>
|
||||
{en.openCustomerDashboard}
|
||||
</a>
|
||||
)}
|
||||
<a href={websiteUrl} className={styles.siteLink}>
|
||||
{en.viewWebsite}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.logoutBtn}`}
|
||||
onClick={() => {
|
||||
logout();
|
||||
window.location.href =
|
||||
isSuperAdmin || manageMeshkeeId
|
||||
? `${getAdminDashboardOrigin()}/login`
|
||||
: `${getCustomerDashboardOrigin()}/login`;
|
||||
}}
|
||||
>
|
||||
{en.logOut}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{menuOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backdrop}
|
||||
aria-label="Close menu"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={styles.main}>
|
||||
<header className={styles.topbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuBtn}
|
||||
aria-label="Open menu"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
|
||||
</button>
|
||||
<div className={styles.topbarCopy}>
|
||||
<p className={styles.eyebrow}>
|
||||
{manageMeshkeeId ? "Admin · managing business" : en.workspaceEyebrow}
|
||||
</p>
|
||||
<p className={styles.topbarHint}>{en.workspaceHint}</p>
|
||||
</div>
|
||||
<div className={styles.topbarActions}>
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: inherit;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb a:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
border: 1px solid #ececec;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.filter em {
|
||||
font-style: normal;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.filter:hover,
|
||||
.filter:focus-visible {
|
||||
color: var(--color-black);
|
||||
border-color: #d8d8d8;
|
||||
}
|
||||
|
||||
.filterActive {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.filterActive em {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.addBtn:hover,
|
||||
.addBtn:focus-visible {
|
||||
background: var(--color-black);
|
||||
}
|
||||
|
||||
.addBtn:hover img,
|
||||
.addBtn:focus-visible img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.addBtn img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
min-height: 220px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.errorCopy {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.errorHint {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.headActions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import AddBusinessModal from "@/components/AddBusinessModal";
|
||||
import BusinessCardList from "@/components/BusinessCardList";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import MakeSpecialModal from "@/components/MakeSpecialModal";
|
||||
import {
|
||||
deleteMarketplaceBusiness,
|
||||
listBusinesses,
|
||||
updateMarketplaceBusiness,
|
||||
} from "@/services/marketplace";
|
||||
import {
|
||||
getBusinessProfile,
|
||||
getMeshkeeBusiness,
|
||||
} from "@/services/meshkee-businesses";
|
||||
import {
|
||||
searchMeshkeeCategories,
|
||||
type MeshkeeCategory,
|
||||
} from "@/services/meshkee-categories";
|
||||
import type { BusinessStatus, MarketplaceBusiness } from "@/types/business";
|
||||
import styles from "./BusinessesPage.module.css";
|
||||
|
||||
type FilterKey = "all" | BusinessStatus;
|
||||
|
||||
const FILTERS: { key: FilterKey; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "pending_review", label: "Pending" },
|
||||
{ key: "published", label: "Published" },
|
||||
{ key: "draft", label: "Draft" },
|
||||
{ key: "hidden", label: "Hidden" },
|
||||
{ key: "rejected", label: "Rejected" },
|
||||
];
|
||||
|
||||
function categoryAncestors(
|
||||
categoryId: string | undefined,
|
||||
byId: Map<string, MeshkeeCategory>,
|
||||
): string[] {
|
||||
if (!categoryId) return [];
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(categoryId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
names.unshift(current.name);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function lastTwoCategoryLevels(trail: string[]) {
|
||||
if (trail.length === 0) return undefined;
|
||||
return trail.slice(-2).join(" / ");
|
||||
}
|
||||
|
||||
async function enrichListings(
|
||||
items: MarketplaceBusiness[],
|
||||
): Promise<MarketplaceBusiness[]> {
|
||||
const categories = await searchMeshkeeCategories("").catch(
|
||||
() => [] as MeshkeeCategory[],
|
||||
);
|
||||
const byId = new Map(categories.map((category) => [category.id, category]));
|
||||
|
||||
return Promise.all(
|
||||
items.map(async (item) => {
|
||||
try {
|
||||
const id = String(item.meshkeeBusinessId);
|
||||
const [meshkee, profile] = await Promise.all([
|
||||
getMeshkeeBusiness(id),
|
||||
getBusinessProfile(id),
|
||||
]);
|
||||
const primaryCategoryId =
|
||||
profile.profile.categoryIds[0] ?? meshkee.categories[0]?.id;
|
||||
const trail = categoryAncestors(primaryCategoryId, byId);
|
||||
return {
|
||||
...item,
|
||||
name: meshkee.name,
|
||||
categoryName: trail[trail.length - 1] ?? meshkee.categories[0]?.name,
|
||||
categoryTrail: lastTwoCategoryLevels(trail),
|
||||
logoUrl: profile.profile.logoUrl ?? meshkee.logoUrl ?? undefined,
|
||||
bannerUrl:
|
||||
profile.profile.bannerUrl ?? meshkee.bannerUrl ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return { ...item, name: item.slug };
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function isFilterKey(value: string | null): value is FilterKey {
|
||||
if (!value) return false;
|
||||
return FILTERS.some((item) => item.key === value);
|
||||
}
|
||||
|
||||
export default function BusinessesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialFilter = isFilterKey(searchParams.get("status"))
|
||||
? (searchParams.get("status") as FilterKey)
|
||||
: "all";
|
||||
|
||||
const [filter, setFilter] = useState<FilterKey>(initialFilter);
|
||||
const [items, setItems] = useState<MarketplaceBusiness[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [removing, setRemoving] = useState<MarketplaceBusiness | null>(null);
|
||||
const [rejecting, setRejecting] = useState<MarketplaceBusiness | null>(null);
|
||||
const [makingSpecial, setMakingSpecial] =
|
||||
useState<MarketplaceBusiness | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listBusinesses({ status: "all", limit: 100 });
|
||||
const enriched = await enrichListings(data.items);
|
||||
setItems(enriched);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not load businesses from the API.",
|
||||
);
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = isFilterKey(searchParams.get("status"))
|
||||
? (searchParams.get("status") as FilterKey)
|
||||
: "all";
|
||||
setFilter(next);
|
||||
}, [searchParams]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const next: Record<FilterKey, number> = {
|
||||
all: items.length,
|
||||
draft: 0,
|
||||
pending_review: 0,
|
||||
published: 0,
|
||||
hidden: 0,
|
||||
rejected: 0,
|
||||
};
|
||||
for (const item of items) {
|
||||
next[item.status] += 1;
|
||||
}
|
||||
return next;
|
||||
}, [items]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (filter === "all") return items;
|
||||
return items.filter((item) => item.status === filter);
|
||||
}, [items, filter]);
|
||||
|
||||
function selectFilter(next: FilterKey) {
|
||||
setFilter(next);
|
||||
const url = new URL(window.location.href);
|
||||
if (next === "all") url.searchParams.delete("status");
|
||||
else url.searchParams.set("status", next);
|
||||
router.replace(`${url.pathname}${url.search}`);
|
||||
}
|
||||
|
||||
async function setStatus(business: MarketplaceBusiness, status: BusinessStatus) {
|
||||
setBusyId(business.id);
|
||||
setError("");
|
||||
try {
|
||||
await updateMarketplaceBusiness(business.id, { status });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not update listing status.",
|
||||
);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const removingBusy = Boolean(removing && busyId === removing.id);
|
||||
const rejectingBusy = Boolean(rejecting && busyId === rejecting.id);
|
||||
const removingName = removing?.name || removing?.slug || "this business";
|
||||
const rejectingName = rejecting?.name || rejecting?.slug || "this business";
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<a href="/">Home</a>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>Businesses</span>
|
||||
</nav>
|
||||
<h1 className={styles.title}>Businesses</h1>
|
||||
<p className={styles.lead}>
|
||||
Review submissions, publish listings, and manage the marketplace
|
||||
gate.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<p className={styles.count}>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: filter === "all"
|
||||
? `${total} total`
|
||||
: `${visible.length} shown · ${total} total`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addBtn}
|
||||
aria-label="Add business"
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
<img src="/images/icons/add.svg" alt="" width={18} height={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.filters} role="tablist" aria-label="Listing status">
|
||||
{FILTERS.map((item) => {
|
||||
const active = filter === item.key;
|
||||
const count = counts[item.key];
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`${styles.filter} ${active ? styles.filterActive : ""}`}
|
||||
onClick={() => selectFilter(item.key)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<em>{loading ? "—" : count}</em>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.error} role="alert">
|
||||
<p className={styles.errorTitle}>API unavailable</p>
|
||||
<p className={styles.errorCopy}>{error}</p>
|
||||
<p className={styles.errorHint}>
|
||||
Start the marketplace backend on port 3001, then refresh.
|
||||
</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className={styles.loading}>Loading businesses…</div>
|
||||
) : (
|
||||
<BusinessCardList
|
||||
items={visible}
|
||||
busyId={busyId}
|
||||
emptyTitle={
|
||||
filter === "pending_review"
|
||||
? "No pending submissions"
|
||||
: "No businesses in this view"
|
||||
}
|
||||
emptyCopy={
|
||||
filter === "pending_review"
|
||||
? "New shop registrations will appear here for approval."
|
||||
: "Try another filter, or add a Meshkee business to the marketplace."
|
||||
}
|
||||
onApprove={(business) => void setStatus(business, "published")}
|
||||
onReject={setRejecting}
|
||||
onDisable={async (business) => {
|
||||
const nextStatus =
|
||||
business.status === "hidden" ? "published" : "hidden";
|
||||
await setStatus(business, nextStatus);
|
||||
}}
|
||||
onRemove={setRemoving}
|
||||
onMakeSpecial={setMakingSpecial}
|
||||
onMetaTag={(business) => {
|
||||
const lines = [
|
||||
`Name: ${business.name || business.slug}`,
|
||||
`Slug: /${business.slug}`,
|
||||
`Category: ${business.categoryName || "Uncategorized"}`,
|
||||
`Status: ${business.status}`,
|
||||
`Featured: ${business.isFeatured ? "yes" : "no"}`,
|
||||
].join("\n");
|
||||
window.alert(lines);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddBusinessModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConnected={() => {
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
|
||||
<MakeSpecialModal
|
||||
open={Boolean(makingSpecial)}
|
||||
business={makingSpecial}
|
||||
onClose={() => setMakingSpecial(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(rejecting)}
|
||||
kicker="Reject submission"
|
||||
title={`Reject ${rejectingName}?`}
|
||||
message="The Meshkee shop stays intact. The listing will be marked rejected and stay off the public site."
|
||||
confirmLabel="Reject"
|
||||
cancelLabel="Keep pending"
|
||||
danger
|
||||
busy={rejectingBusy}
|
||||
onClose={() => {
|
||||
if (!rejectingBusy) setRejecting(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!rejecting) return;
|
||||
await setStatus(rejecting, "rejected");
|
||||
setRejecting(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
kicker="Remove listing"
|
||||
title={`Remove ${removingName}?`}
|
||||
message="This unlists the business from the marketplace. The Meshkee business stays intact."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removingBusy}
|
||||
onClose={() => {
|
||||
if (!removingBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!removing) return;
|
||||
setBusyId(removing.id);
|
||||
setError("");
|
||||
try {
|
||||
await deleteMarketplaceBusiness(removing.id);
|
||||
setRemoving(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not remove listing.",
|
||||
);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 10px 36px 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.control input:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.caret {
|
||||
position: absolute;
|
||||
inset-inline-end: 10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.caret::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin: 4px auto 0;
|
||||
border-inline-end: 1.5px solid var(--color-soft);
|
||||
border-bottom: 1.5px solid var(--color-soft);
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.caretOpen::before {
|
||||
margin-top: 8px;
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% - 2px);
|
||||
inset-inline: 0;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dropdownInFlow {
|
||||
position: relative;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
max-height: 320px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.expand,
|
||||
.expandSpacer {
|
||||
flex: 0 0 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--color-soft);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.expand:hover,
|
||||
.expand:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.expandOpen {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px 6px 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.option:hover,
|
||||
.option:focus-visible {
|
||||
background: #f7f7f7;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.optionActive {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: 0 0 14px;
|
||||
width: 14px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionSlug {
|
||||
margin-inline-start: auto;
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 16px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-white);
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.chip:hover,
|
||||
.chip:focus-visible {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.chip:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
categoryLabel,
|
||||
type MeshkeeCategory,
|
||||
} from "@/services/meshkee-categories";
|
||||
import styles from "./CategoryTreeSelect.module.css";
|
||||
|
||||
type TreeNode = MeshkeeCategory & { children: TreeNode[] };
|
||||
|
||||
function buildTree(categories: MeshkeeCategory[]): TreeNode[] {
|
||||
const byId = new Map<string, TreeNode>();
|
||||
for (const category of categories) {
|
||||
byId.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: TreeNode[] = [];
|
||||
for (const node of byId.values()) {
|
||||
if (node.parentId && byId.has(node.parentId)) {
|
||||
byId.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: TreeNode[]) => {
|
||||
nodes.sort(
|
||||
(a, b) =>
|
||||
a.sortOrder - b.sortOrder ||
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function matchesQuery(category: MeshkeeCategory, q: string) {
|
||||
if (!q) return true;
|
||||
const hay = [category.name, category.nameFa ?? "", category.slug, ...category.tags]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
}
|
||||
|
||||
/** Keep ancestors of matches so the tree path stays visible while searching. */
|
||||
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
|
||||
if (!q) return nodes;
|
||||
const result: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = filterTree(node.children, q);
|
||||
if (matchesQuery(node, q) || children.length) {
|
||||
result.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectExpandIds(nodes: TreeNode[], into: Set<string>) {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length) {
|
||||
into.add(node.id);
|
||||
collectExpandIds(node.children, into);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
categories: MeshkeeCategory[];
|
||||
value: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
/** multi (default) or single select */
|
||||
selection?: "single" | "multiple";
|
||||
/** overlay floats; inFlow expands inside the parent (better in modals) */
|
||||
dropdownVariant?: "overlay" | "inFlow";
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function CategoryTreeSelect({
|
||||
categories,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label = "Category",
|
||||
selection = "multiple",
|
||||
dropdownVariant = "overlay",
|
||||
placeholder = "Search categories…",
|
||||
}: Props) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const single = selection === "single";
|
||||
|
||||
const tree = useMemo(() => buildTree(categories), [categories]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]);
|
||||
|
||||
const byId = useMemo(() => {
|
||||
const map = new Map<string, MeshkeeCategory>();
|
||||
for (const category of categories) map.set(category.id, category);
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
const selected = value
|
||||
.map((id) => byId.get(id))
|
||||
.filter((item): item is MeshkeeCategory => Boolean(item));
|
||||
const selectedLabel = selected[0] ? categoryLabel(selected[0]) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!q) return;
|
||||
const next = new Set<string>();
|
||||
collectExpandIds(visibleTree, next);
|
||||
setExpanded(next);
|
||||
}, [q, visibleTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
if (single) setQuery("");
|
||||
}
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
if (single) setQuery("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, single]);
|
||||
|
||||
function pick(id: string) {
|
||||
if (single) {
|
||||
onChange([id]);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
const next = value.includes(id)
|
||||
? value.filter((item) => item !== id)
|
||||
: [...value, id];
|
||||
onChange(next);
|
||||
}
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function renderNodes(nodes: TreeNode[], depth: number) {
|
||||
return nodes.map((node) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = q ? true : expanded.has(node.id);
|
||||
const active = value.includes(node.id);
|
||||
|
||||
return (
|
||||
<li key={node.id} className={styles.node}>
|
||||
<div
|
||||
className={styles.row}
|
||||
style={{ paddingInlineStart: `${8 + depth * 16}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.expand} ${isExpanded ? styles.expandOpen : ""}`}
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
tabIndex={-1}
|
||||
onClick={() => toggleExpand(node.id)}
|
||||
>
|
||||
<span aria-hidden>{isExpanded ? "−" : "+"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.expandSpacer} aria-hidden />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.option} ${active ? styles.optionActive : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => pick(node.id)}
|
||||
>
|
||||
{!single ? (
|
||||
<span className={styles.check} aria-hidden>
|
||||
{active ? "✓" : ""}
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.optionLabel}>{categoryLabel(node)}</span>
|
||||
{node.slug ? (
|
||||
<span className={styles.optionSlug}>/{node.slug}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isExpanded ? (
|
||||
<ul className={styles.list}>{renderNodes(node.children, depth + 1)}</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const inputValue = open || !single ? query : selectedLabel;
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<div className={styles.picker}>
|
||||
<label className={styles.field}>
|
||||
<span>{label}</span>
|
||||
<div className={styles.control}>
|
||||
<input
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
single && selectedLabel && !open ? selectedLabel : placeholder
|
||||
}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.caret} ${open ? styles.caretOpen : ""}`}
|
||||
aria-label={open ? "Close categories" : "Open categories"}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className={`${styles.dropdown} ${
|
||||
dropdownVariant === "inFlow" ? styles.dropdownInFlow : ""
|
||||
}`}
|
||||
role="listbox"
|
||||
aria-multiselectable={!single}
|
||||
>
|
||||
{visibleTree.length ? (
|
||||
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
|
||||
) : (
|
||||
<p className={styles.empty}>No categories found</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!single && selected.length ? (
|
||||
<div className={styles.chips}>
|
||||
{selected.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
className={styles.chip}
|
||||
disabled={disabled}
|
||||
title="Remove category"
|
||||
onClick={() => pick(category.id)}
|
||||
>
|
||||
{categoryLabel(category)}
|
||||
<span aria-hidden>×</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.node {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding-block: 6px;
|
||||
padding-inline-end: 8px;
|
||||
border-bottom: 1px solid #f3f3f3;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-inline-start: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 10px;
|
||||
min-height: 32px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions button:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.actions button + button {
|
||||
border-left: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.actions button:hover,
|
||||
.actions button:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.actions .danger:hover,
|
||||
.actions .danger:focus-visible {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.expand,
|
||||
.expandSpacer {
|
||||
flex: 0 0 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--color-soft);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expand:hover,
|
||||
.expand:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 28px 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import styles from "./CategoryTreeView.module.css";
|
||||
|
||||
export type CategoryTreeNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
hint?: string;
|
||||
children: CategoryTreeNode[];
|
||||
};
|
||||
|
||||
export type CategoryTreeActions = {
|
||||
onAddChild?: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onRemove?: (id: string) => void;
|
||||
};
|
||||
|
||||
export default function CategoryTreeView({
|
||||
nodes,
|
||||
emptyLabel = "No categories yet",
|
||||
actions,
|
||||
}: {
|
||||
nodes: CategoryTreeNode[];
|
||||
emptyLabel?: string;
|
||||
actions?: CategoryTreeActions;
|
||||
}) {
|
||||
if (!nodes.length) {
|
||||
return <p className={styles.empty}>{emptyLabel}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={styles.list}>
|
||||
{nodes.map((node) => (
|
||||
<TreeBranch key={node.id} node={node} depth={0} actions={actions} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeBranch({
|
||||
node,
|
||||
depth,
|
||||
actions,
|
||||
}: {
|
||||
node: CategoryTreeNode;
|
||||
depth: number;
|
||||
actions?: CategoryTreeActions;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const hasActions = Boolean(
|
||||
actions?.onAddChild || actions?.onEdit || actions?.onRemove,
|
||||
);
|
||||
|
||||
return (
|
||||
<li className={styles.node}>
|
||||
<div
|
||||
className={styles.row}
|
||||
style={{ paddingInlineStart: `${12 + depth * 56}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.expand} ${open ? styles.expandOpen : ""}`}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? "Collapse" : "Expand"}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
>
|
||||
<span aria-hidden>{open ? "−" : "+"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.expandSpacer} aria-hidden />
|
||||
)}
|
||||
<span className={styles.name} dir="auto">
|
||||
{node.name}
|
||||
</span>
|
||||
{node.hint ? <span className={styles.hint}>{node.hint}</span> : null}
|
||||
{hasActions ? (
|
||||
<div className={styles.actions}>
|
||||
{actions?.onAddChild ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => actions.onAddChild?.(node.id)}
|
||||
>
|
||||
Add sub
|
||||
</button>
|
||||
) : null}
|
||||
{actions?.onEdit ? (
|
||||
<button type="button" onClick={() => actions.onEdit?.(node.id)}>
|
||||
Edit
|
||||
</button>
|
||||
) : null}
|
||||
{actions?.onRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.danger}
|
||||
onClick={() => actions.onRemove?.(node.id)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{hasChildren && open ? (
|
||||
<ul className={styles.list}>
|
||||
{node.children.map((child) => (
|
||||
<TreeBranch
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
actions={actions}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: overlayIn 0.22s ease;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(100%, 420px);
|
||||
padding: 32px 30px 26px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 0 28px 64px rgba(0, 0, 0, 0.35);
|
||||
animation: dialogIn 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 14px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.confirmBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
border-color: #d4d4d4;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.cancelBtn::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.cancelBtn:hover,
|
||||
.cancelBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.confirmBtn {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.confirmDanger {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.confirmDanger:hover,
|
||||
.confirmDanger:focus-visible {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.confirmDanger::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.confirmDanger:hover,
|
||||
.confirmDanger:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.cancelBtn:disabled,
|
||||
.confirmBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes overlayIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialogIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.overlay,
|
||||
.dialog {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import styles from "./ConfirmDialog.module.css";
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
kicker = "Confirm",
|
||||
message,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
danger = false,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
kicker?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
cancelRef.current?.focus();
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busy) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, busy, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.dialog}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<p className={styles.kicker}>{kicker}</p>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.message}>{message}</p>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
ref={cancelRef}
|
||||
type="button"
|
||||
className={`outline-btn ${styles.cancelBtn}`}
|
||||
disabled={busy}
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.confirmBtn} ${danger ? styles.confirmDanger : ""}`}
|
||||
disabled={busy}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{busy ? "…" : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
max-width: 42ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cardCopy {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
margin-top: 18px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--color-black);
|
||||
border-radius: 5px;
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
background: transparent;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.row dt {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.row dd {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import { getAdminDashboardOrigin } from "@/lib/host";
|
||||
import { openBusinessDashboardAsUser } from "@/lib/session-handoff";
|
||||
import { getDisplayName, getFirstName } from "@/lib/userName";
|
||||
import styles from "./CustomerHome.module.css";
|
||||
|
||||
export default function CustomerHome() {
|
||||
const { user, isBusinessOwner, isSuperAdmin } = useAuth();
|
||||
const firstName = getFirstName(user);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>
|
||||
{en.customerHome.title}, {firstName}
|
||||
</h1>
|
||||
<p className={styles.lead}>{en.customerHome.lead}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>{getDisplayName(user)}</h2>
|
||||
<p className={styles.cardCopy}>{en.customerHome.lead}</p>
|
||||
</section>
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>{en.openAdminDashboard}</h2>
|
||||
<p className={styles.cardCopy}>{en.customerHome.adminLead}</p>
|
||||
<a
|
||||
href={`${getAdminDashboardOrigin()}/`}
|
||||
className={styles.cta}
|
||||
>
|
||||
{en.customerHome.adminCta}
|
||||
</a>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{isBusinessOwner ? (
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>{en.openBusinessDashboard}</h2>
|
||||
<p className={styles.cardCopy}>{en.customerHome.businessLead}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cta}
|
||||
onClick={() => openBusinessDashboardAsUser("/profile")}
|
||||
>
|
||||
{en.customerHome.businessCta}
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import { getDisplayName } from "@/lib/userName";
|
||||
import styles from "./CustomerHome.module.css";
|
||||
|
||||
export default function CustomerProfile() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{en.customerProfile.title}</h1>
|
||||
<p className={styles.lead}>{en.customerProfile.lead}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.card}>
|
||||
<dl className={styles.list}>
|
||||
<div className={styles.row}>
|
||||
<dt>{en.customerProfile.name}</dt>
|
||||
<dd>{getDisplayName(user)}</dd>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<dt>{en.customerProfile.phone}</dt>
|
||||
<dd dir="ltr">{user?.cellNumber || "—"}</dd>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<dt>{en.customerProfile.email}</dt>
|
||||
<dd>{user?.email?.trim() || en.customerProfile.emptyEmail}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import AccountMenu from "@/components/AccountMenu";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { customerNavItems } from "@/data/customer-nav";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import { getAdminDashboardOrigin } from "@/lib/host";
|
||||
import { openBusinessDashboardAsUser } from "@/lib/session-handoff";
|
||||
import { getDisplayName } from "@/lib/userName";
|
||||
import styles from "./DashboardShell.module.css";
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
export default function CustomerShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout, isBusinessOwner, isSuperAdmin } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const displayName = getDisplayName(user);
|
||||
|
||||
const publicPath = pathname.startsWith("/customer")
|
||||
? pathname.replace(/^\/customer/, "") || "/"
|
||||
: pathname;
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<aside className={`${styles.sidebar} ${menuOpen ? styles.sidebarOpen : ""}`}>
|
||||
<div className={styles.brand}>
|
||||
<img src="/images/icons/logo.png" alt="" width={48} height={48} />
|
||||
<div className={styles.brandText}>
|
||||
<span>{en.brandName}</span>
|
||||
<strong>{en.customerBrandSubtitle}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav} aria-label="Account">
|
||||
{customerNavItems.map((item) => {
|
||||
const active =
|
||||
item.href === "/"
|
||||
? publicPath === "/"
|
||||
: publicPath === item.href ||
|
||||
publicPath.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<img src={item.icon} alt="" width={18} height={18} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={styles.sidebarFoot}>
|
||||
<p className={styles.userLabel}>{displayName}</p>
|
||||
{isSuperAdmin ? (
|
||||
<a
|
||||
href={`${getAdminDashboardOrigin()}/`}
|
||||
className={styles.siteLink}
|
||||
>
|
||||
{en.openAdminDashboard}
|
||||
</a>
|
||||
) : null}
|
||||
{isBusinessOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.siteLink}
|
||||
onClick={() => openBusinessDashboardAsUser("/profile")}
|
||||
>
|
||||
{en.openBusinessDashboard}
|
||||
</button>
|
||||
) : null}
|
||||
<a href={websiteUrl} className={styles.siteLink}>
|
||||
{en.viewWebsite}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.logoutBtn}`}
|
||||
onClick={() => {
|
||||
logout();
|
||||
router.replace("/login");
|
||||
}}
|
||||
>
|
||||
{en.logOut}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{menuOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backdrop}
|
||||
aria-label="Close menu"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={styles.main}>
|
||||
<header className={styles.topbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuBtn}
|
||||
aria-label="Open menu"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
|
||||
</button>
|
||||
<div className={styles.topbarCopy}>
|
||||
<p className={styles.eyebrow}>{en.customerEyebrow}</p>
|
||||
<p className={styles.topbarHint}>{en.customerHint}</p>
|
||||
</div>
|
||||
<div className={styles.topbarActions}>
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
width: var(--sidebar-width);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28px 22px;
|
||||
background: var(--color-white);
|
||||
border-right: 1px solid #ececec;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 40px;
|
||||
padding: 0 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
flex: 0 0 auto;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
border-radius: 5px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.brand img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brandBusiness {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: none;
|
||||
color: var(--color-black);
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.brandTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: none;
|
||||
color: var(--color-muted);
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.brandText span:not(.brandTitle) {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.brandText strong {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
transition: background 0.25s ease, color 0.25s ease;
|
||||
}
|
||||
|
||||
.navItem img {
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.25s ease, filter 0.25s ease;
|
||||
}
|
||||
|
||||
.navItem:hover,
|
||||
.navItem:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.navItem:hover img,
|
||||
.navItem:focus-visible img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.navItemActive img {
|
||||
opacity: 1;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.navGroupBtn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navGroupActive {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.navGroupActive img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navChevron {
|
||||
margin-left: auto;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
opacity: 0.45;
|
||||
transform: rotate(90deg);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.navChevronOpen {
|
||||
transform: rotate(-90deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.navSub {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0 0 4px 18px;
|
||||
}
|
||||
|
||||
.navSubItem {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.navSubItem:hover,
|
||||
.navSubItem:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.navSubItemActive {
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.sidebarFoot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ececec;
|
||||
}
|
||||
|
||||
.userLabel {
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.siteLink {
|
||||
appearance: none;
|
||||
display: inline;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
padding: 0 6px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.siteLink:hover,
|
||||
.siteLink:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
min-height: 34px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: var(--sidebar-width);
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 22px var(--page-pad);
|
||||
background: var(--color-white);
|
||||
border-bottom: 1px solid #ececec;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.topbarCopy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbarHint {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.topbarActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
background: var(--color-card);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 36px var(--page-pad) 56px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
inset: 0 auto 0 0;
|
||||
width: min(86vw, var(--sidebar-width));
|
||||
transform: translateX(-105%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 18px 0 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.sidebarOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.menuBtn {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import AccountMenu from "@/components/AccountMenu";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { navItems, type NavItem } from "@/data/home";
|
||||
import { getDisplayName } from "@/lib/userName";
|
||||
import styles from "./DashboardShell.module.css";
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
function hrefMatches(href: string, pathname: string) {
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function activeChildHref(
|
||||
children: { href: string }[],
|
||||
pathname: string,
|
||||
) {
|
||||
let best = "";
|
||||
for (const child of children) {
|
||||
if (!hrefMatches(child.href, pathname)) continue;
|
||||
if (child.href.length > best.length) best = child.href;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function groupActive(item: Extract<NavItem, { type: "group" }>, pathname: string) {
|
||||
return activeChildHref(item.children, pathname) !== "";
|
||||
}
|
||||
|
||||
export default function DashboardShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, logout } = useAuth();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [openGroup, setOpenGroup] = useState<string | null>(() => {
|
||||
const active = navItems.find(
|
||||
(item) => item.type === "group" && groupActive(item, pathname),
|
||||
);
|
||||
return active?.type === "group" ? active.label : null;
|
||||
});
|
||||
const displayName = getDisplayName(user);
|
||||
|
||||
useEffect(() => {
|
||||
const active = navItems.find(
|
||||
(item) => item.type === "group" && groupActive(item, pathname),
|
||||
);
|
||||
if (active?.type === "group") setOpenGroup(active.label);
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<aside className={`${styles.sidebar} ${menuOpen ? styles.sidebarOpen : ""}`}>
|
||||
<div className={styles.brand}>
|
||||
<img src="/images/icons/logo.png" alt="" width={48} height={48} />
|
||||
<div className={styles.brandText}>
|
||||
<span>WilaSpace</span>
|
||||
<strong>Dashboard</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav} aria-label="Dashboard">
|
||||
{navItems.map((item) => {
|
||||
if (item.type === "group") {
|
||||
const active = groupActive(item, pathname);
|
||||
const expanded = openGroup === item.label;
|
||||
return (
|
||||
<div key={item.label} className={styles.navGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.navItem} ${styles.navGroupBtn} ${
|
||||
active ? styles.navGroupActive : ""
|
||||
}`}
|
||||
aria-expanded={expanded}
|
||||
onClick={() =>
|
||||
setOpenGroup((current) =>
|
||||
current === item.label ? null : item.label,
|
||||
)
|
||||
}
|
||||
>
|
||||
<img src={item.icon} alt="" width={18} height={18} />
|
||||
<span>{item.label}</span>
|
||||
<img
|
||||
className={`${styles.navChevron} ${
|
||||
expanded ? styles.navChevronOpen : ""
|
||||
}`}
|
||||
src="/images/icons/chevron.svg"
|
||||
alt=""
|
||||
width={5}
|
||||
height={9}
|
||||
/>
|
||||
</button>
|
||||
{expanded ? (
|
||||
<div className={styles.navSub}>
|
||||
{item.children.map((child) => {
|
||||
const childActive =
|
||||
child.href ===
|
||||
activeChildHref(item.children, pathname);
|
||||
return (
|
||||
<Link
|
||||
key={child.href}
|
||||
href={child.href}
|
||||
prefetch
|
||||
className={`${styles.navSubItem} ${
|
||||
childActive ? styles.navSubItemActive : ""
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
setMenuOpen(false);
|
||||
// Ensure navigation even if soft-route is flaky.
|
||||
if (
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
event.button !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
router.push(child.href);
|
||||
}}
|
||||
>
|
||||
{child.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: pathname === item.href ||
|
||||
pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
<img src={item.icon} alt="" width={18} height={18} />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className={styles.sidebarFoot}>
|
||||
<p className={styles.userLabel}>{displayName}</p>
|
||||
<a href={websiteUrl} className={styles.siteLink}>
|
||||
View website
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.logoutBtn}`}
|
||||
onClick={() => {
|
||||
logout();
|
||||
router.replace("/login");
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{menuOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backdrop}
|
||||
aria-label="Close menu"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={styles.main}>
|
||||
<header className={styles.topbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.menuBtn}
|
||||
aria-label="Open menu"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
>
|
||||
<img src="/images/icons/menu.svg" alt="" width={22} height={22} />
|
||||
</button>
|
||||
<div className={styles.topbarCopy}>
|
||||
<p className={styles.eyebrow}>Admin workspace</p>
|
||||
<p className={styles.topbarHint}>Marketplace control</p>
|
||||
</div>
|
||||
<div className={styles.topbarActions}>
|
||||
<AccountMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.content}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: inherit;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb a:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.headActions,
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.saveBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.ghostBtn {
|
||||
border-color: #d4d4d4;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.ghostBtn::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.ghostBtn:hover,
|
||||
.ghostBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.saveBtn:hover,
|
||||
.saveBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.saveBtn::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-white);
|
||||
border-radius: 5px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mediaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.logoBlock,
|
||||
.bannerBlock,
|
||||
.tagBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blockHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.inlineActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.linkBtn:hover,
|
||||
.linkBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.linkBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.aspectRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aspectBtn {
|
||||
min-width: 44px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.aspectBtn:hover,
|
||||
.aspectBtn:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.aspectBtnActive {
|
||||
border-color: var(--color-black);
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.aspectBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.logoFrame,
|
||||
.bannerFrame {
|
||||
width: 100%;
|
||||
border: 1px dashed #d8d8d8;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-body);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logoFrame {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.logoFrame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bannerFrame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.twoCol {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.identityRow {
|
||||
display: grid;
|
||||
grid-template-columns: 4fr 3fr 3fr 2fr;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.addressMetaRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1.2fr) repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.descriptionField {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.fieldHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.fieldHead > span {
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aiBtn {
|
||||
box-sizing: border-box;
|
||||
min-width: 36px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 26px;
|
||||
color: var(--color-muted);
|
||||
transition:
|
||||
border-color 0.35s ease,
|
||||
color 0.35s ease,
|
||||
background-color 0.35s ease,
|
||||
box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.aiBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.aiBtnActive {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.field > input,
|
||||
.field > textarea,
|
||||
.field > select,
|
||||
.phoneRow input,
|
||||
.phoneRow select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.field > select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.field > textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field > input:hover,
|
||||
.field > textarea:hover,
|
||||
.field > select:hover,
|
||||
.phoneRow input:hover,
|
||||
.phoneRow select:hover {
|
||||
border-color: #d8d8d8;
|
||||
}
|
||||
|
||||
.field > input:focus,
|
||||
.field > textarea:focus,
|
||||
.field > select:focus,
|
||||
.phoneRow input:focus,
|
||||
.phoneRow select:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
color: var(--color-muted);
|
||||
background: #fff;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.chipActive {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.tagRow,
|
||||
.phoneRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tagInput {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 6px 8px;
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.tagInput:hover {
|
||||
border-color: #d8d8d8;
|
||||
}
|
||||
|
||||
.tagInput:focus-within {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.tagInput input {
|
||||
flex: 1 1 120px;
|
||||
min-width: 120px;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px 6px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.phoneGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.phoneRow {
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.phoneRow select {
|
||||
min-width: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.phoneRow input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.addressCard {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.addressHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.addressHead strong {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.errorBox {
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
padding: 28px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
justify-content: flex-end;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mediaGrid,
|
||||
.twoCol,
|
||||
.identityRow,
|
||||
.addressMetaRow,
|
||||
.phoneGrid,
|
||||
.phoneRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import AuthShell from "@/components/AuthShell";
|
||||
import { AuthField } from "@/components/AuthFields";
|
||||
import styles from "@/components/AuthShell.module.css";
|
||||
|
||||
export default function ForgotPasswordForm() {
|
||||
return (
|
||||
<AuthShell titleLine1="Forgot" titleLine2="Password">
|
||||
<form
|
||||
className={styles.form}
|
||||
autoComplete="off"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<AuthField
|
||||
label="Phone Number"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<Link href="/login" className={`outline-btn ${styles.secondaryBtn}`}>
|
||||
Return to login
|
||||
</Link>
|
||||
<button type="submit" className={`outline-btn ${styles.primaryBtn}`}>
|
||||
Send code
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(180px, 260px);
|
||||
gap: 8px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.heroCopy,
|
||||
.heroPanel {
|
||||
background: var(--color-card);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 28px 32px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.36em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 14px;
|
||||
max-width: 520px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.heroPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: 28px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.heroPanel span {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.heroPanel strong {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statTile {
|
||||
min-height: 148px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statHint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--color-white);
|
||||
border-radius: 5px;
|
||||
padding: 28px 26px;
|
||||
}
|
||||
|
||||
.panelHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panelSub {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.panelBtn {
|
||||
min-height: 30px;
|
||||
padding: 0 14px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.list,
|
||||
.activity {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.listRow,
|
||||
.activityRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.listRow:first-child,
|
||||
.activityRow:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.rowSide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.statusLive {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.statusReview {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.statusDraft {
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.rowTime {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.errorNote {
|
||||
margin: 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #ececec;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.activityRow {
|
||||
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.activityDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-black);
|
||||
}
|
||||
|
||||
.actionsHead {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.actionGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actionTile {
|
||||
min-height: 132px;
|
||||
padding: 24px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.actionLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.actionDesc {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.split,
|
||||
.actionGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroPanel {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
white-space: normal;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panelHead {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { listBusinesses } from "@/services/marketplace";
|
||||
import {
|
||||
getMeshkeeBusiness,
|
||||
} from "@/services/meshkee-businesses";
|
||||
import { getFirstName } from "@/lib/userName";
|
||||
import type { MarketplaceBusiness } from "@/types/business";
|
||||
import styles from "./HomeDashboard.module.css";
|
||||
|
||||
type RecentRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
statusKey: MarketplaceBusiness["status"];
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<MarketplaceBusiness["status"], string> = {
|
||||
draft: "Draft",
|
||||
pending_review: "Review",
|
||||
published: "Published",
|
||||
hidden: "Hidden",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
export default function HomeDashboard() {
|
||||
const { user } = useAuth();
|
||||
const firstName = getFirstName(user);
|
||||
const [items, setItems] = useState<MarketplaceBusiness[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listBusinesses({ status: "all", limit: 100 });
|
||||
if (cancelled) return;
|
||||
|
||||
const sorted = [...data.items].sort((a, b) => {
|
||||
const aTime = Date.parse(a.updatedAt || a.createdAt || "") || 0;
|
||||
const bTime = Date.parse(b.updatedAt || b.createdAt || "") || 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
const pending = data.items.filter(
|
||||
(item) => item.status === "pending_review",
|
||||
);
|
||||
const enrichIds = new Set(
|
||||
[
|
||||
...sorted.slice(0, 4).map((item) => item.id),
|
||||
...pending.slice(0, 4).map((item) => item.id),
|
||||
],
|
||||
);
|
||||
|
||||
const enrichedEntries = await Promise.all(
|
||||
data.items
|
||||
.filter((item) => enrichIds.has(item.id))
|
||||
.map(async (item) => {
|
||||
try {
|
||||
const meshkee = await getMeshkeeBusiness(
|
||||
String(item.meshkeeBusinessId),
|
||||
);
|
||||
return [
|
||||
item.id,
|
||||
{
|
||||
...item,
|
||||
name: meshkee.name,
|
||||
categoryName: meshkee.categories[0]?.name,
|
||||
},
|
||||
] as const;
|
||||
} catch {
|
||||
return [item.id, { ...item, name: item.slug }] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const byId = new Map(enrichedEntries);
|
||||
setItems(data.items.map((item) => byId.get(item.id) ?? item));
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load marketplace data.",
|
||||
);
|
||||
setItems([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const published = items.filter((item) => item.status === "published").length;
|
||||
const pending = items.filter(
|
||||
(item) => item.status === "pending_review",
|
||||
).length;
|
||||
const draft = items.filter((item) => item.status === "draft").length;
|
||||
return [
|
||||
{
|
||||
id: "businesses",
|
||||
label: "Live",
|
||||
value: loading ? "…" : String(published),
|
||||
hint: "Published on marketplace",
|
||||
href: "/businesses?status=published",
|
||||
},
|
||||
{
|
||||
id: "pending",
|
||||
label: "Pending",
|
||||
value: loading ? "…" : String(pending),
|
||||
hint: "Awaiting review",
|
||||
href: "/businesses?status=pending_review",
|
||||
},
|
||||
{
|
||||
id: "draft",
|
||||
label: "Draft",
|
||||
value: loading ? "…" : String(draft),
|
||||
hint: "Not submitted yet",
|
||||
href: "/businesses?status=draft",
|
||||
},
|
||||
{
|
||||
id: "total",
|
||||
label: "Total",
|
||||
value: loading ? "…" : String(items.length),
|
||||
hint: "All linked listings",
|
||||
href: "/businesses",
|
||||
},
|
||||
];
|
||||
}, [items, loading]);
|
||||
|
||||
const recent: RecentRow[] = useMemo(
|
||||
() =>
|
||||
[...items]
|
||||
.sort((a, b) => {
|
||||
const aTime = Date.parse(a.updatedAt || a.createdAt || "") || 0;
|
||||
const bTime = Date.parse(b.updatedAt || b.createdAt || "") || 0;
|
||||
return bTime - aTime;
|
||||
})
|
||||
.slice(0, 4)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name || item.slug,
|
||||
category: item.categoryName || "Uncategorized",
|
||||
status: STATUS_LABEL[item.status],
|
||||
statusKey: item.status,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
const pendingItems = useMemo(
|
||||
() => items.filter((item) => item.status === "pending_review").slice(0, 4),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<section className={styles.hero} aria-label="Welcome">
|
||||
<div className={styles.heroCopy}>
|
||||
<p className={styles.kicker}>Overview · {firstName}</p>
|
||||
<h1 className={styles.title}>Marketplace command</h1>
|
||||
<p className={styles.lead}>
|
||||
Track listings, review submissions, and keep the public marketplace
|
||||
gate in sync.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.heroPanel} aria-hidden>
|
||||
<span>IranBay</span>
|
||||
<strong>Admin</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.stats} aria-label="Key metrics">
|
||||
{stats.map((stat) => (
|
||||
<a
|
||||
key={stat.id}
|
||||
href={stat.href}
|
||||
className={`tile-hover ${styles.statTile}`}
|
||||
>
|
||||
<p className={styles.statLabel}>{stat.label}</p>
|
||||
<p className={styles.statValue}>{stat.value}</p>
|
||||
<p className={styles.statHint}>{stat.hint}</p>
|
||||
</a>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.errorNote} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className={styles.split} id="businesses">
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panelHead}>
|
||||
<div>
|
||||
<h2 className={styles.panelTitle}>Recent businesses</h2>
|
||||
<p className={styles.panelSub}>Latest marketplace listings</p>
|
||||
</div>
|
||||
<a href="/businesses" className={`outline-btn ${styles.panelBtn}`}>
|
||||
View all
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul className={styles.list}>
|
||||
{loading && recent.length === 0 ? (
|
||||
<li className={styles.listRow}>
|
||||
<p className={styles.rowMeta}>Loading…</p>
|
||||
</li>
|
||||
) : null}
|
||||
{!loading && recent.length === 0 ? (
|
||||
<li className={styles.listRow}>
|
||||
<p className={styles.rowMeta}>No listings yet</p>
|
||||
</li>
|
||||
) : null}
|
||||
{recent.map((item) => (
|
||||
<li key={item.id} className={styles.listRow}>
|
||||
<div>
|
||||
<p className={styles.rowTitle}>{item.name}</p>
|
||||
<p className={styles.rowMeta}>{item.category}</p>
|
||||
</div>
|
||||
<div className={styles.rowSide}>
|
||||
<span
|
||||
className={`${styles.status} ${
|
||||
item.statusKey === "published"
|
||||
? styles.statusLive
|
||||
: item.statusKey === "pending_review"
|
||||
? styles.statusReview
|
||||
: styles.statusDraft
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className={styles.panel} id="activity">
|
||||
<div className={styles.panelHead}>
|
||||
<div>
|
||||
<h2 className={styles.panelTitle}>Pending review</h2>
|
||||
<p className={styles.panelSub}>Approve new shop submissions</p>
|
||||
</div>
|
||||
<a
|
||||
href="/businesses?status=pending_review"
|
||||
className={`outline-btn ${styles.panelBtn}`}
|
||||
>
|
||||
Review
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul className={styles.activity}>
|
||||
{loading && pendingItems.length === 0 ? (
|
||||
<li className={styles.activityRow}>
|
||||
<div className={styles.activityDot} aria-hidden />
|
||||
<div>
|
||||
<p className={styles.rowMeta}>Loading…</p>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
{!loading && pendingItems.length === 0 ? (
|
||||
<li className={styles.activityRow}>
|
||||
<div className={styles.activityDot} aria-hidden />
|
||||
<div>
|
||||
<p className={styles.rowTitle}>Queue clear</p>
|
||||
<p className={styles.rowMeta}>No submissions waiting</p>
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
{pendingItems.map((item) => (
|
||||
<li key={item.id} className={styles.activityRow}>
|
||||
<div className={styles.activityDot} aria-hidden />
|
||||
<div>
|
||||
<p className={styles.rowTitle}>
|
||||
{item.name || item.slug}
|
||||
</p>
|
||||
<p className={styles.rowMeta}>
|
||||
/{item.slug} · awaiting approval
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className={styles.rowTime}
|
||||
href={`/businesses/${item.id}`}
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.actions} id="settings" aria-label="Quick actions">
|
||||
<div className={styles.actionsHead}>
|
||||
<h2 className={styles.panelTitle}>Quick actions</h2>
|
||||
<p className={styles.panelSub}>Jump into common admin work</p>
|
||||
</div>
|
||||
<div className={styles.actionGrid}>
|
||||
<a
|
||||
href="/businesses?status=pending_review"
|
||||
className={`tile-hover ${styles.actionTile}`}
|
||||
>
|
||||
<p className={styles.actionLabel}>Review pending</p>
|
||||
<p className={styles.actionDesc}>Approve new marketplace listings</p>
|
||||
</a>
|
||||
<a href="/businesses" className={`tile-hover ${styles.actionTile}`}>
|
||||
<p className={styles.actionLabel}>Manage businesses</p>
|
||||
<p className={styles.actionDesc}>List, edit, publish, or hide</p>
|
||||
</a>
|
||||
<a
|
||||
href={
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000"
|
||||
}
|
||||
className={`tile-hover ${styles.actionTile}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<p className={styles.actionLabel}>Open website</p>
|
||||
<p className={styles.actionDesc}>Preview the public gate</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(720px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: min(52vh, 420px);
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
/* Light checker so transparent logos stay visible while cropping */
|
||||
.stageLight {
|
||||
background-color: #f3f3f3;
|
||||
background-image:
|
||||
linear-gradient(45deg, #e8e8e8 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e8e8e8 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e8e8e8 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e8e8e8 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
}
|
||||
|
||||
.stageLight :global(.reactEasyCrop_CropArea) {
|
||||
color: rgba(0, 0, 0, 0.28);
|
||||
border-color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.zoomRow {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.zoomBtn:hover:not(:disabled),
|
||||
.zoomBtn:focus-visible:not(:disabled) {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.zoomBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.zoomRange {
|
||||
width: 100%;
|
||||
accent-color: var(--color-black);
|
||||
}
|
||||
|
||||
.zoomValue {
|
||||
min-width: 44px;
|
||||
text-align: end;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cancelBtn,
|
||||
.applyBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.cancelBtn {
|
||||
border-color: #d4d4d4;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.cancelBtn::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.cancelBtn:hover,
|
||||
.cancelBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.applyBtn {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.applyBtn:hover,
|
||||
.applyBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.applyBtn::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.applyBtn:disabled,
|
||||
.cancelBtn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Cropper, { type Area } from "react-easy-crop";
|
||||
import { getCroppedImageFile } from "@/lib/cropImage";
|
||||
import styles from "./ImageCropDialog.module.css";
|
||||
|
||||
const MIN_ZOOM = 0.2;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_STEP = 0.05;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
imageSrc: string | null;
|
||||
title: string;
|
||||
aspect: number;
|
||||
/** Prefer PNG for logos with transparency */
|
||||
outputType?: "image/jpeg" | "image/png";
|
||||
fileName?: string;
|
||||
onCancel: () => void;
|
||||
onComplete: (file: File, previewUrl: string) => void;
|
||||
};
|
||||
|
||||
export default function ImageCropDialog({
|
||||
open,
|
||||
imageSrc,
|
||||
title,
|
||||
aspect,
|
||||
outputType = "image/jpeg",
|
||||
fileName = "image",
|
||||
onCancel,
|
||||
onComplete,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const transparentOutput = outputType === "image/png";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
setBusy(false);
|
||||
setError("");
|
||||
}, [open, imageSrc]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setCroppedAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
function clampZoom(value: number) {
|
||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
|
||||
}
|
||||
|
||||
if (!open || !imageSrc) return null;
|
||||
|
||||
async function apply() {
|
||||
if (!croppedAreaPixels || !imageSrc) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const file = await getCroppedImageFile(
|
||||
imageSrc,
|
||||
croppedAreaPixels,
|
||||
fileName,
|
||||
outputType,
|
||||
);
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
onComplete(file, previewUrl);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not crop image.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} role="dialog" aria-modal aria-label={title}>
|
||||
<div className={styles.dialog}>
|
||||
<div className={styles.head}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.hint}>
|
||||
Drag to move · zoom out to add padding around the image
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
transparentOutput
|
||||
? `${styles.stage} ${styles.stageLight}`
|
||||
: styles.stage
|
||||
}
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
minZoom={MIN_ZOOM}
|
||||
maxZoom={MAX_ZOOM}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={(value) => setZoom(clampZoom(value))}
|
||||
onCropComplete={onCropComplete}
|
||||
showGrid={false}
|
||||
objectFit="contain"
|
||||
restrictPosition={false}
|
||||
style={{
|
||||
containerStyle: {
|
||||
background: "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.zoomRow}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
disabled={busy || zoom <= MIN_ZOOM}
|
||||
aria-label="Zoom out"
|
||||
onClick={() => setZoom((value) => clampZoom(value - ZOOM_STEP))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<input
|
||||
className={styles.zoomRange}
|
||||
type="range"
|
||||
min={MIN_ZOOM}
|
||||
max={MAX_ZOOM}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
disabled={busy}
|
||||
aria-label="Zoom"
|
||||
onChange={(event) => setZoom(clampZoom(Number(event.target.value)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
disabled={busy || zoom >= MAX_ZOOM}
|
||||
aria-label="Zoom in"
|
||||
onClick={() => setZoom((value) => clampZoom(value + ZOOM_STEP))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className={styles.zoomValue}>{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.cancelBtn}`}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setError("");
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.applyBtn}`}
|
||||
disabled={busy || !croppedAreaPixels}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{busy ? "Applying…" : "Apply crop"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import AuthShell from "@/components/AuthShell";
|
||||
import { AuthField, AuthPasswordField } from "@/components/AuthFields";
|
||||
import { postLoginHref, useAuth } from "@/components/AuthProvider";
|
||||
import { ApiError, getAccessToken, getRefreshToken } from "@/lib/meshkee-api";
|
||||
import { toE164CellNumber } from "@/lib/cellNumber";
|
||||
import {
|
||||
getAdminDashboardOrigin,
|
||||
getCustomerDashboardOrigin,
|
||||
hostAppFor,
|
||||
} from "@/lib/host";
|
||||
import styles from "@/components/AuthShell.module.css";
|
||||
|
||||
const websiteUrl =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, "") ||
|
||||
"http://localhost:3000";
|
||||
|
||||
const tenantDomain =
|
||||
process.env.NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN?.trim() ||
|
||||
(() => {
|
||||
try {
|
||||
return new URL(websiteUrl).hostname;
|
||||
} catch {
|
||||
return "iranbay.com";
|
||||
}
|
||||
})();
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
function isSafeWebsiteReturn(next: string) {
|
||||
try {
|
||||
const url = new URL(next);
|
||||
const allowed = new URL(websiteUrl);
|
||||
return url.origin === allowed.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function websiteHandoffUrl(next: string) {
|
||||
const access = getAccessToken();
|
||||
const refresh = getRefreshToken();
|
||||
if (!access || !refresh) return null;
|
||||
|
||||
const target = new URL(next);
|
||||
const hash = new URLSearchParams({
|
||||
access,
|
||||
refresh,
|
||||
next: `${target.pathname}${target.search}${target.hash}` || "/register",
|
||||
}).toString();
|
||||
|
||||
return `${target.origin}/auth/handoff#${hash}`;
|
||||
}
|
||||
|
||||
function splitName(fullName: string) {
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
const firstName = parts[0] || "";
|
||||
const lastName = parts.length > 1 ? parts.slice(1).join(" ") : firstName;
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
async function finishAuth(
|
||||
dashboard: "super_admin" | "business" | "customer",
|
||||
returnToWebsite: string | null,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (returnToWebsite) {
|
||||
const handoff = websiteHandoffUrl(returnToWebsite);
|
||||
if (handoff) {
|
||||
window.location.href = handoff;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const href = postLoginHref(dashboard);
|
||||
if (href.startsWith("http")) {
|
||||
window.location.href = href;
|
||||
} else {
|
||||
router.push(href);
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { login, register } = useAuth();
|
||||
const [error, setError] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hostReady, setHostReady] = useState(false);
|
||||
const [app, setApp] = useState<"admin" | "customer" | "business">("admin");
|
||||
const [adminLoginHref, setAdminLoginHref] = useState("");
|
||||
|
||||
const nextParam = searchParams.get("next");
|
||||
const returnToWebsite =
|
||||
nextParam && isSafeWebsiteReturn(nextParam) ? nextParam : null;
|
||||
const modeParam = searchParams.get("mode");
|
||||
const [mode, setMode] = useState<AuthMode>(() =>
|
||||
modeParam === "register" ? "register" : "login",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const host = window.location.host;
|
||||
const next = hostAppFor(host);
|
||||
setApp(next);
|
||||
setHostReady(true);
|
||||
setAdminLoginHref(`${getAdminDashboardOrigin()}/login`);
|
||||
if (next === "business") {
|
||||
const loginUrl = new URL(`${getCustomerDashboardOrigin()}/login`);
|
||||
if (returnToWebsite) loginUrl.searchParams.set("next", returnToWebsite);
|
||||
if (mode === "register") loginUrl.searchParams.set("mode", "register");
|
||||
window.location.replace(loginUrl.toString());
|
||||
}
|
||||
}, [returnToWebsite, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modeParam === "register") setMode("register");
|
||||
if (modeParam === "login") setMode("login");
|
||||
}, [modeParam]);
|
||||
|
||||
if (!hostReady || app === "business") {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "#f6f6f6",
|
||||
fontSize: 12,
|
||||
letterSpacing: "0.2em",
|
||||
textTransform: "uppercase",
|
||||
color: "#8e8e8e",
|
||||
}}
|
||||
>
|
||||
Redirecting to login…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRegister = mode === "register" && app === "customer";
|
||||
const titleLine1 = isRegister ? "Create" : "Login To";
|
||||
const titleLine2 = app === "customer" ? "Account" : "WilaSpace";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
titleLine1={titleLine1}
|
||||
titleLine2={titleLine2}
|
||||
footerNote={
|
||||
isRegister ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.noteLink}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setMode("login");
|
||||
}}
|
||||
>
|
||||
Already have an account? Login
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/forgot-password">Forgot password?</Link>
|
||||
)
|
||||
}
|
||||
>
|
||||
<form
|
||||
className={styles.form}
|
||||
autoComplete="off"
|
||||
key={isRegister ? "register" : "login"}
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
const phone = String(form.get("phone") ?? "");
|
||||
const password = String(form.get("password") ?? "");
|
||||
|
||||
try {
|
||||
if (isRegister) {
|
||||
const name = String(form.get("name") ?? "");
|
||||
const email = String(form.get("email") ?? "").trim();
|
||||
const { firstName, lastName } = splitName(name);
|
||||
|
||||
if (firstName.length < 2) {
|
||||
throw new ApiError("Please enter your full name.", 400);
|
||||
}
|
||||
|
||||
const dashboard = await register({
|
||||
cellNumber: toE164CellNumber(phone),
|
||||
password,
|
||||
firstName,
|
||||
lastName: lastName.length >= 2 ? lastName : firstName,
|
||||
email: email || undefined,
|
||||
domain: tenantDomain,
|
||||
});
|
||||
await finishAuth(dashboard, returnToWebsite, router);
|
||||
} else {
|
||||
const dashboard = await login(toE164CellNumber(phone), password);
|
||||
await finishAuth(dashboard, returnToWebsite, router);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(
|
||||
isRegister
|
||||
? "Unable to create account. Is Meshkee API running?"
|
||||
: "Unable to sign in. Is Meshkee API running?",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
{app === "customer" &&
|
||||
adminLoginHref &&
|
||||
error.includes("super admin access") ? (
|
||||
<>
|
||||
{" "}
|
||||
<a href={adminLoginHref}>Go to admin login</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{returnToWebsite ? (
|
||||
<p className={styles.hint}>
|
||||
{isRegister
|
||||
? "Create an account to continue business registration"
|
||||
: "Sign in to continue business registration"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isRegister ? (
|
||||
<AuthField
|
||||
label="Name"
|
||||
name="name"
|
||||
autoComplete="name"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AuthField
|
||||
label={isRegister ? "Cell Number" : "Phone Number"}
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
{isRegister ? (
|
||||
<AuthField
|
||||
label="Email Address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AuthPasswordField
|
||||
label="Password"
|
||||
name="password"
|
||||
autoComplete={isRegister ? "new-password" : "current-password"}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
|
||||
<div className={styles.actions}>
|
||||
{app === "customer" ? (
|
||||
isRegister ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={isSubmitting}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setMode("login");
|
||||
}}
|
||||
>
|
||||
Login instead
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
disabled={isSubmitting}
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setMode("register");
|
||||
}}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.primaryBtn}`}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "…" : isRegister ? "REGISTER" : "LOGIN"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 480px);
|
||||
max-height: min(90vh, 720px);
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn:hover,
|
||||
.closeBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.item:hover:not(:disabled),
|
||||
.item:focus-visible:not(:disabled) {
|
||||
border-color: #ddd;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.itemMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.itemSubtext {
|
||||
font-size: 12px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.itemMeta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.error,
|
||||
.status {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-soft);
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 12px;
|
||||
color: #1f6b3a;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import {
|
||||
getSpecialBusinesses,
|
||||
saveSpecialBusinesses,
|
||||
type SpecialCarousel,
|
||||
} from "@/services/special-businesses";
|
||||
import type { MarketplaceBusiness } from "@/types/business";
|
||||
import styles from "./MakeSpecialModal.module.css";
|
||||
|
||||
export default function MakeSpecialModal({
|
||||
business,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
business: MarketplaceBusiness | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const [carousels, setCarousels] = useState<SpecialCarousel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const config = await getSpecialBusinesses();
|
||||
if (!cancelled) setCarousels(config.carousels);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not load carousels.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !savingId) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, savingId, onClose]);
|
||||
|
||||
if (!open || !business) return null;
|
||||
|
||||
const name = business.name || business.slug;
|
||||
|
||||
async function pickCarousel(carousel: SpecialCarousel) {
|
||||
if (!business) return;
|
||||
setSavingId(carousel.id);
|
||||
setError("");
|
||||
setStatus("");
|
||||
try {
|
||||
const config = await getSpecialBusinesses();
|
||||
const nextCarousels = config.carousels.map((item) => {
|
||||
if (item.id !== carousel.id) return item;
|
||||
if (item.businessIds.includes(business.id)) return item;
|
||||
return {
|
||||
...item,
|
||||
businessIds: [...item.businessIds, business.id],
|
||||
};
|
||||
});
|
||||
await saveSpecialBusinesses({ carousels: nextCarousels });
|
||||
setStatus(`Added to ${carousel.title}`);
|
||||
onSaved?.();
|
||||
window.setTimeout(() => onClose(), 500);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not add business to carousel.",
|
||||
);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget && !savingId) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={styles.head}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Make special</p>
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
Choose a carousel
|
||||
</h2>
|
||||
<p className={styles.hint}>Add {name} to one of the homepage carousels.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="Close"
|
||||
disabled={Boolean(savingId)}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className={styles.empty}>Loading carousels…</p>
|
||||
) : error && !carousels.length ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : carousels.length ? (
|
||||
<ul className={styles.list}>
|
||||
{carousels.map((carousel) => {
|
||||
const alreadyIn = carousel.businessIds.includes(business.id);
|
||||
return (
|
||||
<li key={carousel.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.item}
|
||||
disabled={Boolean(savingId) || alreadyIn}
|
||||
onClick={() => void pickCarousel(carousel)}
|
||||
>
|
||||
<span className={styles.itemMain}>
|
||||
<span className={styles.itemTitle}>
|
||||
{carousel.title || "Untitled"}
|
||||
</span>
|
||||
{carousel.subtext ? (
|
||||
<span className={styles.itemSubtext}>
|
||||
{carousel.subtext}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.itemMeta}>
|
||||
{savingId === carousel.id
|
||||
? "Adding…"
|
||||
: alreadyIn
|
||||
? "Already in"
|
||||
: `${carousel.businessIds.length} businesses`}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className={styles.empty}>
|
||||
No carousels yet. Create one under Website → Carousels.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && carousels.length ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{status ? <p className={styles.status}>{status}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.head:has(.label) {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolSep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 2px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.tool {
|
||||
box-sizing: border-box;
|
||||
min-width: 32px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 26px;
|
||||
color: var(--color-muted);
|
||||
transition:
|
||||
border-color 0.35s ease,
|
||||
color 0.35s ease,
|
||||
background-color 0.35s ease,
|
||||
box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.tool:hover,
|
||||
.tool:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.aiTool:hover:not(:disabled),
|
||||
.aiTool:focus-visible:not(:disabled) {
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toolActive {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
background: #f4f4f4;
|
||||
}
|
||||
|
||||
.aiTool {
|
||||
min-width: 36px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.toolEnd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-inline-start: 10px;
|
||||
padding-inline-start: 10px;
|
||||
border-inline-start: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.tool:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolSelectWrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
max-height: 28px;
|
||||
}
|
||||
|
||||
.toolSelect {
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
margin: 0;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background-color: #fff;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%238e8e8e' d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: 10px 6px;
|
||||
padding: 0 22px 0 8px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 26px;
|
||||
color: var(--color-muted);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
transition: border-color 0.35s ease, color 0.35s ease;
|
||||
}
|
||||
|
||||
.toolSelect:hover,
|
||||
.toolSelect:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.toolSelect:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.editor {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.65;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
transition: border-color 0.35s ease, box-shadow 0.35s ease;
|
||||
}
|
||||
|
||||
.editor:hover {
|
||||
border-color: #d8d8d8;
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.editor :global(ul) {
|
||||
margin: 0.4em 0 0.4em 1.2em;
|
||||
}
|
||||
|
||||
.editor :global(p) {
|
||||
margin: 0 0 0.6em;
|
||||
}
|
||||
|
||||
.editor :global(img) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0.7em 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.imageError {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor :global(font[size="2"]) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor :global(font[size="3"]) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.editor :global(font[size="4"]) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.editor :global(font[size="5"]) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: "Write here…";
|
||||
color: var(--color-body);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import aiStyles from "@/styles/ai.module.css";
|
||||
import styles from "./RichTextArea.module.css";
|
||||
|
||||
const FONT_SIZES = [
|
||||
{ value: "2", label: "S" },
|
||||
{ value: "3", label: "M" },
|
||||
{ value: "4", label: "L" },
|
||||
{ value: "5", label: "XL" },
|
||||
] as const;
|
||||
|
||||
function escapeAttr(value: string) {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
|
||||
export default function RichTextArea({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
minHeight = 200,
|
||||
toolbarEnd,
|
||||
aiWorking = false,
|
||||
onUploadImage,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
disabled?: boolean;
|
||||
minHeight?: number;
|
||||
toolbarEnd?: ReactNode;
|
||||
aiWorking?: boolean;
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
|
||||
const [fontSize, setFontSize] = useState("3");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [imageError, setImageError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
if (node.innerHTML !== value) {
|
||||
node.innerHTML = value || "";
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function emit() {
|
||||
onChange(ref.current?.innerHTML ?? "");
|
||||
}
|
||||
|
||||
function exec(command: string, commandValue?: string) {
|
||||
if (disabled) return;
|
||||
ref.current?.focus();
|
||||
document.execCommand(command, false, commandValue);
|
||||
emit();
|
||||
}
|
||||
|
||||
function applyFontSize(size: string) {
|
||||
setFontSize(size);
|
||||
exec("fontSize", size);
|
||||
}
|
||||
|
||||
function applyDirection(next: "ltr" | "rtl") {
|
||||
setDirection(next);
|
||||
const node = ref.current;
|
||||
if (!node || disabled) return;
|
||||
node.setAttribute("dir", next);
|
||||
node.focus();
|
||||
emit();
|
||||
}
|
||||
|
||||
async function insertImage(file: File) {
|
||||
if (!onUploadImage || disabled) return;
|
||||
setUploadingImage(true);
|
||||
setImageError("");
|
||||
try {
|
||||
const url = await onUploadImage(file);
|
||||
if (
|
||||
!url.startsWith("/") &&
|
||||
!url.startsWith("http://") &&
|
||||
!url.startsWith("https://")
|
||||
) {
|
||||
throw new Error("Could not add image.");
|
||||
}
|
||||
ref.current?.focus();
|
||||
document.execCommand(
|
||||
"insertHTML",
|
||||
false,
|
||||
`<img src="${escapeAttr(url)}" alt="" />`,
|
||||
);
|
||||
emit();
|
||||
} catch (err) {
|
||||
setImageError(
|
||||
err instanceof Error ? err.message : "Could not add image.",
|
||||
);
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.head}>
|
||||
{label ? <span className={styles.label}>{label}</span> : null}
|
||||
<div className={styles.tools}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Bold"
|
||||
onClick={() => exec("bold")}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Italic"
|
||||
onClick={() => exec("italic")}
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Bullet list"
|
||||
onClick={() => exec("insertUnorderedList")}
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
{onUploadImage ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled || uploadingImage}
|
||||
title="Insert image"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
>
|
||||
{uploadingImage ? "…" : "Image"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<span className={styles.toolSep} aria-hidden />
|
||||
|
||||
<label className={styles.toolSelectWrap}>
|
||||
<span className={styles.srOnly}>Font size</span>
|
||||
<select
|
||||
className={styles.toolSelect}
|
||||
value={fontSize}
|
||||
disabled={disabled}
|
||||
title="Font size"
|
||||
onChange={(event) => applyFontSize(event.target.value)}
|
||||
>
|
||||
{FONT_SIZES.map((size) => (
|
||||
<option key={size.value} value={size.value}>
|
||||
Size {size.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span className={styles.toolSep} aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tool} ${direction === "ltr" ? styles.toolActive : ""}`}
|
||||
disabled={disabled}
|
||||
title="Left to right"
|
||||
onClick={() => applyDirection("ltr")}
|
||||
>
|
||||
LTR
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.tool} ${direction === "rtl" ? styles.toolActive : ""}`}
|
||||
disabled={disabled}
|
||||
title="Right to left"
|
||||
onClick={() => applyDirection("rtl")}
|
||||
>
|
||||
RTL
|
||||
</button>
|
||||
|
||||
<span className={styles.toolSep} aria-hidden />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Align left"
|
||||
onClick={() => exec("justifyLeft")}
|
||||
>
|
||||
Left
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Align center"
|
||||
onClick={() => exec("justifyCenter")}
|
||||
>
|
||||
Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Align right"
|
||||
onClick={() => exec("justifyRight")}
|
||||
>
|
||||
Right
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tool}
|
||||
disabled={disabled}
|
||||
title="Justify"
|
||||
onClick={() => exec("justifyFull")}
|
||||
>
|
||||
Justify
|
||||
</button>
|
||||
|
||||
{toolbarEnd ? (
|
||||
<div className={styles.toolEnd}>{toolbarEnd}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{imageError ? <p className={styles.imageError}>{imageError}</p> : null}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${styles.editor}${aiWorking ? ` ${aiStyles.aiWorkingBorder}` : ""}`}
|
||||
style={{ minHeight }}
|
||||
contentEditable={!disabled}
|
||||
suppressContentEditableWarning
|
||||
dir={direction}
|
||||
onInput={emit}
|
||||
onBlur={emit}
|
||||
/>
|
||||
{onUploadImage ? (
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className={styles.fileInput}
|
||||
disabled={disabled || uploadingImage}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) void insertImage(file);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 22px 28px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.headActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.saveBtn,
|
||||
.addBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.saveBtn::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.saveBtn:hover,
|
||||
.saveBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-white);
|
||||
border-radius: 5px;
|
||||
padding: 20px 22px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.tab:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.activeMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activeTitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.activeSubtext {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: #bbb;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.linkBtn:hover,
|
||||
.linkBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.searchRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.resultRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.resultRow:hover {
|
||||
border-color: #ddd;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.inlineAddBtn {
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.listHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.listCount {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.logoMark {
|
||||
width: 72px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.listMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listName {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.listSubtext,
|
||||
.listSlug {
|
||||
font-size: 12px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.listSubtext {
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 28px 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-soft);
|
||||
border: 1px dashed #e4e4e4;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
color: #1f6b3a;
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 480px);
|
||||
max-height: min(90vh, 720px);
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.modalHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn:hover,
|
||||
.closeBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modalActionsRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dangerBtn {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #8a1f11;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.dangerBtn:hover,
|
||||
.dangerBtn:focus-visible {
|
||||
color: #5c120a;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.head,
|
||||
.searchRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modalActions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modalActionsRight {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.beside {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.beside {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||||
import {
|
||||
FeaturedImageField,
|
||||
TagsField,
|
||||
type LocalImage,
|
||||
} from "@/components/catalog/CatalogFormFields";
|
||||
import RichTextArea from "@/components/RichTextArea";
|
||||
import type { MeshkeeCategory } from "@/services/meshkee-categories";
|
||||
import { uploadWebsiteMedia } from "@/services/website-media";
|
||||
import {
|
||||
createWebsiteBlogPost,
|
||||
listWebsiteBlogCategories,
|
||||
type WebsiteBlogCategory,
|
||||
} from "@/services/website-blog";
|
||||
import styles from "@/components/catalog/CatalogForm.module.css";
|
||||
import local from "./WebsiteBlogForm.module.css";
|
||||
|
||||
export default function WebsiteBlogForm() {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<WebsiteBlogCategory[]>([]);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [abstract, setAbstract] = useState("");
|
||||
const [mainTextHtml, setMainTextHtml] = useState("");
|
||||
const [image, setImage] = useState<LocalImage | null>(null);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const items = await listWebsiteBlogCategories();
|
||||
if (!cancelled) setCategories(items);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function onSubmit(event?: React.FormEvent) {
|
||||
event?.preventDefault();
|
||||
if (!title.trim()) {
|
||||
setError("Enter a title.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (!categoryId) {
|
||||
setError("Choose a category.");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let imageUrl: string | undefined;
|
||||
if (image?.file) {
|
||||
imageUrl = await uploadWebsiteMedia(image.file);
|
||||
}
|
||||
|
||||
await createWebsiteBlogPost({
|
||||
title: title.trim(),
|
||||
categoryId,
|
||||
abstract: abstract.trim() || undefined,
|
||||
mainTextHtml: mainTextHtml || undefined,
|
||||
imageUrl,
|
||||
tags,
|
||||
});
|
||||
router.push("/website/blog");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not create post.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const busy = saving;
|
||||
const treeCategories: MeshkeeCategory[] = categories.map((item) => ({
|
||||
id: item.id,
|
||||
parentId: item.parentId,
|
||||
name: item.name,
|
||||
nameFa: null,
|
||||
slug: "",
|
||||
description: null,
|
||||
icon: null,
|
||||
sortOrder: 0,
|
||||
tags: [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<form className={styles.page} onSubmit={onSubmit}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<Link href="/website/blog">Blog</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>Add</span>
|
||||
</nav>
|
||||
<h1 className={styles.title}>Add blog post</h1>
|
||||
<p className={styles.lead}>
|
||||
Publish a story on the IranBay website.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<Link href="/website/blog" className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Back
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={busy}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.errorBox} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Post</h2>
|
||||
<div className={local.beside}>
|
||||
<FeaturedImageField
|
||||
label="Image"
|
||||
value={image}
|
||||
aspect={3 / 2}
|
||||
disabled={busy}
|
||||
onChange={setImage}
|
||||
/>
|
||||
<div className={styles.stack}>
|
||||
<CategoryTreeSelect
|
||||
categories={treeCategories}
|
||||
value={categoryId ? [categoryId] : []}
|
||||
selection="single"
|
||||
label="Category"
|
||||
placeholder="Select category"
|
||||
disabled={busy}
|
||||
onChange={(ids) => setCategoryId(ids[0] ?? "")}
|
||||
/>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Title</span>
|
||||
<input
|
||||
value={title}
|
||||
disabled={busy}
|
||||
required
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Abstract</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={abstract}
|
||||
disabled={busy}
|
||||
onChange={(event) => setAbstract(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Content</h2>
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.richField}>
|
||||
<RichTextArea
|
||||
label="Main text"
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
disabled={busy}
|
||||
minHeight={280}
|
||||
onUploadImage={uploadWebsiteMedia}
|
||||
/>
|
||||
</div>
|
||||
<TagsField tags={tags} disabled={busy} onChange={setTags} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.footerActions}>
|
||||
<Link href="/website/blog" className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={busy}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: inherit;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb a:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.headActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 5px;
|
||||
background: #fff5f5;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media {
|
||||
aspect-ratio: 3 / 2;
|
||||
background: #f3f3f3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media img,
|
||||
.fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
.foot {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.card:hover .foot:global(.tile-hover)::before,
|
||||
.card:focus-within .foot:global(.tile-hover)::before {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.category {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.body h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.body p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
color: var(--color-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-top: auto;
|
||||
padding: 12px 0 14px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.remove {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove:hover,
|
||||
.remove:focus-visible {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.card:hover .controls {
|
||||
border-top-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.card:hover .remove:hover,
|
||||
.card:focus-within .remove:hover {
|
||||
color: #ffb4a8;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 22px 18px;
|
||||
}
|
||||
|
||||
.headActions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
|
||||
import { excerpt, formatDate } from "@/components/catalog/utils";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import {
|
||||
deleteWebsiteBlogPost,
|
||||
listWebsiteBlogPosts,
|
||||
type WebsiteBlogPost,
|
||||
} from "@/services/website-blog";
|
||||
import styles from "./WebsiteBlogListPage.module.css";
|
||||
|
||||
export default function WebsiteBlogListPage() {
|
||||
const [items, setItems] = useState<WebsiteBlogPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [removing, setRemoving] = useState<WebsiteBlogPost | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listWebsiteBlogPosts();
|
||||
if (!cancelled) setItems(data.items);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load blog posts.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
try {
|
||||
await deleteWebsiteBlogPost(removing.id);
|
||||
setItems((prev) => prev.filter((item) => item.id !== removing.id));
|
||||
setRemoving(null);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not remove blog post.",
|
||||
);
|
||||
setRemoving(null);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href="/">Home</Link>
|
||||
<span className={styles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={styles.breadcrumbCurrent}>Blog</span>
|
||||
</nav>
|
||||
<h1 className={styles.title}>Blog</h1>
|
||||
<p className={styles.lead}>
|
||||
Stories published on the IranBay website.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<p className={styles.count}>
|
||||
{loading ? "Loading…" : `${items.length} posts`}
|
||||
</p>
|
||||
<Link href="/website/blog/new" className={`outline-btn ${styles.addBtn}`}>
|
||||
Add
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading && items.length === 0 ? (
|
||||
<p className={styles.hint}>Loading blog…</p>
|
||||
) : null}
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
<CatalogEmptyState
|
||||
icon="/images/icons/gallery.svg"
|
||||
title="No blog posts yet"
|
||||
lead="Add the first story for the website."
|
||||
actionHref="/website/blog/new"
|
||||
actionLabel="Add"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{items.length ? (
|
||||
<div className={styles.grid}>
|
||||
{items.map((post) => {
|
||||
const text = post.abstract ? excerpt(post.abstract, 160) : "";
|
||||
return (
|
||||
<article key={post.id} className={styles.card}>
|
||||
<div className={styles.media}>
|
||||
{post.imageUrl ? (
|
||||
<img src={post.imageUrl} alt="" />
|
||||
) : (
|
||||
<div className={styles.fallback} aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className={`tile-hover ${styles.foot}`}>
|
||||
<div className={styles.body}>
|
||||
<p className={styles.category}>{post.categoryName}</p>
|
||||
<h2 dir="auto">{post.title}</h2>
|
||||
{text ? <p dir="auto">{text}</p> : null}
|
||||
{post.createdAt ? (
|
||||
<span className={styles.meta}>
|
||||
{formatDate(post.createdAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.remove}
|
||||
onClick={() => setRemoving(post)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
danger
|
||||
busy={removeBusy}
|
||||
kicker="Blog"
|
||||
title="Remove post"
|
||||
message="This deletes the blog post. This cannot be undone."
|
||||
confirmLabel="Remove"
|
||||
onConfirm={() => void confirmRemove()}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 22px 28px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.headActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
color: #1f6b3a;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--color-white);
|
||||
border-radius: 5px;
|
||||
padding: 22px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sectionMeta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 8px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.groupDivider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
min-height: 100%;
|
||||
background: #e4e4e4;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.groupBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.groupSize {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-soft);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupItems {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Checker so transparent product PNGs read clearly in admin. */
|
||||
.thumbTransparent {
|
||||
background-color: #f3f3f3;
|
||||
background-image:
|
||||
linear-gradient(45deg, #e8e8e8 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e8e8e8 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e8e8e8 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e8e8e8 75%);
|
||||
background-size: 14px 14px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 7px,
|
||||
7px -7px,
|
||||
-7px 0;
|
||||
}
|
||||
|
||||
/* Match website product cutouts (categories / hero objects). */
|
||||
.thumbContain img {
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
padding: 6% 8%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.thumbCover img {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.thumbEmpty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-style: dashed;
|
||||
border-color: #d8d8d8;
|
||||
background: #fff;
|
||||
color: var(--color-soft);
|
||||
transition: border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.thumbEmpty:hover,
|
||||
.thumbEmpty:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.plus {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.cellCompact {
|
||||
width: 56px;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.thumbCompact {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.cellIndex {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 3px;
|
||||
color: var(--color-soft);
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.iconBtn svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.iconBtn:hover,
|
||||
.iconBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.iconBtnDanger:hover,
|
||||
.iconBtnDanger:focus-visible {
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.loading {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 560px);
|
||||
max-height: min(90vh, 780px);
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
|
||||
padding: 28px 28px 24px;
|
||||
}
|
||||
|
||||
.modalHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.modalHint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn:hover,
|
||||
.closeBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.imageBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.imageLabelRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.imageLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.cropPreviewWrap {
|
||||
width: min(100%, 360px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cropPreview {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.cropPreview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cropPreviewTransparent {
|
||||
background-color: #f3f3f3;
|
||||
background-image:
|
||||
linear-gradient(45deg, #e8e8e8 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #e8e8e8 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #e8e8e8 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #e8e8e8 75%);
|
||||
background-size: 14px 14px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 7px,
|
||||
7px -7px,
|
||||
-7px 0;
|
||||
}
|
||||
|
||||
.cropPreviewContain img {
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
padding: 6% 8%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cropPreviewCover img {
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.changeImage {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.changeImage:hover,
|
||||
.changeImage:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.pickImage {
|
||||
width: min(100%, 360px);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px dashed #d8d8d8;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
color: var(--color-soft);
|
||||
transition: border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.pickImage:hover,
|
||||
.pickImage:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.pickPlus {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.pickMeta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.linkRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.linkField {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 46px;
|
||||
padding: 14px 14px 10px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
transition: border-color 0.25s ease;
|
||||
}
|
||||
|
||||
.field span {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 12px;
|
||||
background: #fff;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.field:focus-within span {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field textarea {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
width: 100%;
|
||||
color: var(--color-black);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
min-height: 72px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modalError {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
min-height: 34px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
border-color: #9e9e9e;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover,
|
||||
.secondaryBtn:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 24px 18px 20px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,829 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ImageCropDialog from "@/components/ImageCropDialog";
|
||||
import WebsiteLinkGenerator from "@/components/WebsiteLinkGenerator";
|
||||
import {
|
||||
getWebsiteHomeMedia,
|
||||
newMediaItem,
|
||||
saveWebsiteHomeMedia,
|
||||
uploadWebsiteMedia,
|
||||
type WebsiteHomeMedia,
|
||||
type WebsiteMediaItem,
|
||||
} from "@/services/website-media";
|
||||
import styles from "./WebsiteImagesPage.module.css";
|
||||
|
||||
type SectionKey =
|
||||
| "slider"
|
||||
| "standingBanners"
|
||||
| "categories"
|
||||
| "aboutUs"
|
||||
| "banners";
|
||||
|
||||
type PixelSize = { width: number; height: number };
|
||||
|
||||
type SlotGroup = {
|
||||
size: PixelSize;
|
||||
indexes: number[];
|
||||
};
|
||||
|
||||
type SectionDef = {
|
||||
key: SectionKey;
|
||||
label: string;
|
||||
kind: "list" | "fixed";
|
||||
itemCount?: number;
|
||||
/** Default upload / preview size */
|
||||
size: PixelSize;
|
||||
/** Per-index size overrides when not using groups */
|
||||
sizes?: PixelSize[];
|
||||
/** Same-row size groups (e.g. homepage banners) */
|
||||
groups?: SlotGroup[];
|
||||
fit: "contain" | "cover";
|
||||
};
|
||||
|
||||
type EditTarget = {
|
||||
key: SectionKey;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const SECTIONS: SectionDef[] = [
|
||||
{
|
||||
key: "slider",
|
||||
label: "Slider",
|
||||
kind: "list",
|
||||
size: { width: 450, height: 450 },
|
||||
fit: "contain",
|
||||
},
|
||||
{
|
||||
key: "standingBanners",
|
||||
label: "Standing banners",
|
||||
kind: "fixed",
|
||||
itemCount: 2,
|
||||
size: { width: 600, height: 600 },
|
||||
fit: "contain",
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
label: "Categories",
|
||||
kind: "list",
|
||||
size: { width: 600, height: 600 },
|
||||
fit: "contain",
|
||||
},
|
||||
{
|
||||
key: "aboutUs",
|
||||
label: "About us",
|
||||
kind: "fixed",
|
||||
itemCount: 1,
|
||||
size: { width: 979, height: 800 },
|
||||
fit: "contain",
|
||||
},
|
||||
{
|
||||
key: "banners",
|
||||
label: "Banners",
|
||||
kind: "fixed",
|
||||
itemCount: 5,
|
||||
size: { width: 650, height: 650 },
|
||||
// Matches homepage: wide featured · 3 small cards · export strip
|
||||
groups: [
|
||||
{ size: { width: 650, height: 650 }, indexes: [0] },
|
||||
{ size: { width: 900, height: 900 }, indexes: [1, 2, 3] },
|
||||
{ size: { width: 593, height: 640 }, indexes: [4] },
|
||||
],
|
||||
fit: "contain",
|
||||
},
|
||||
];
|
||||
|
||||
function formatResolution(size: PixelSize) {
|
||||
return `${size.width} × ${size.height}`;
|
||||
}
|
||||
|
||||
function sizeForItem(section: SectionDef, index: number): PixelSize {
|
||||
const fromGroup = section.groups?.find((group) =>
|
||||
group.indexes.includes(index),
|
||||
);
|
||||
if (fromGroup) return fromGroup.size;
|
||||
return section.sizes?.[index] ?? section.size;
|
||||
}
|
||||
|
||||
function previewWidth(size: PixelSize) {
|
||||
const aspect = size.width / size.height;
|
||||
if (aspect >= 1.4) return 220;
|
||||
if (aspect >= 0.9) return 140;
|
||||
return 110;
|
||||
}
|
||||
|
||||
function getItems(media: WebsiteHomeMedia, key: SectionKey): WebsiteMediaItem[] {
|
||||
if (key === "aboutUs") return [media.aboutUs];
|
||||
return media[key];
|
||||
}
|
||||
|
||||
function withItems(
|
||||
media: WebsiteHomeMedia,
|
||||
key: SectionKey,
|
||||
items: WebsiteMediaItem[],
|
||||
): WebsiteHomeMedia {
|
||||
if (key === "aboutUs") {
|
||||
return { ...media, aboutUs: items[0] ?? media.aboutUs };
|
||||
}
|
||||
return { ...media, [key]: items };
|
||||
}
|
||||
|
||||
function emptySlot(partial?: Partial<WebsiteMediaItem>): WebsiteMediaItem {
|
||||
return newMediaItem({
|
||||
imageUrl: "",
|
||||
linkUrl: "/",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
cta: "",
|
||||
...partial,
|
||||
});
|
||||
}
|
||||
|
||||
function padFixed(items: WebsiteMediaItem[], count: number) {
|
||||
const next = items.slice(0, count);
|
||||
while (next.length < count) next.push(emptySlot());
|
||||
return next;
|
||||
}
|
||||
|
||||
export default function WebsiteImagesPage() {
|
||||
const [media, setMedia] = useState<WebsiteHomeMedia | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<EditTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await getWebsiteHomeMedia();
|
||||
if (!cancelled) setMedia(data);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Could not load website images.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function persist(next: WebsiteHomeMedia) {
|
||||
setSaving(true);
|
||||
setStatus("");
|
||||
setError("");
|
||||
try {
|
||||
const saved = await saveWebsiteHomeMedia(next);
|
||||
setMedia(saved);
|
||||
setStatus("Saved");
|
||||
return saved;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save.");
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveItem(
|
||||
target: EditTarget,
|
||||
values: {
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cta: string;
|
||||
linkUrl: string;
|
||||
},
|
||||
) {
|
||||
if (!media) return;
|
||||
const section = SECTIONS.find((row) => row.key === target.key);
|
||||
if (!section) return;
|
||||
|
||||
let items = [...getItems(media, target.key)];
|
||||
if (section.kind === "fixed" && section.itemCount) {
|
||||
items = padFixed(items, section.itemCount);
|
||||
}
|
||||
|
||||
const existing = items[target.index];
|
||||
const nextItem: WebsiteMediaItem = {
|
||||
...(existing ?? emptySlot()),
|
||||
imageUrl: values.imageUrl,
|
||||
title: values.title,
|
||||
subtitle: values.subtitle,
|
||||
cta: values.cta,
|
||||
linkUrl: values.linkUrl || "/",
|
||||
};
|
||||
|
||||
if (section.kind === "list" && !existing) {
|
||||
items.push(nextItem);
|
||||
} else {
|
||||
items[target.index] = nextItem;
|
||||
}
|
||||
|
||||
if (section.kind === "fixed" && section.itemCount) {
|
||||
items = padFixed(items, section.itemCount);
|
||||
}
|
||||
|
||||
const next = withItems(media, target.key, items);
|
||||
setMedia(next);
|
||||
await persist(next);
|
||||
setEditTarget(null);
|
||||
}
|
||||
|
||||
async function handleRemove(target: EditTarget) {
|
||||
if (!media) return;
|
||||
const section = SECTIONS.find((row) => row.key === target.key);
|
||||
if (!section) return;
|
||||
|
||||
let items = [...getItems(media, target.key)];
|
||||
|
||||
if (section.kind === "list") {
|
||||
items = items.filter((_, index) => index !== target.index);
|
||||
if (target.key === "slider" && items.length === 0) {
|
||||
items = [emptySlot({ title: "New slide", cta: "browse producers" })];
|
||||
}
|
||||
} else {
|
||||
if (section.itemCount) items = padFixed(items, section.itemCount);
|
||||
items[target.index] = emptySlot({ id: items[target.index]?.id });
|
||||
}
|
||||
|
||||
const next = withItems(media, target.key, items);
|
||||
setMedia(next);
|
||||
setRemoveTarget(null);
|
||||
await persist(next);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className={styles.loading}>Loading website images…</div>;
|
||||
}
|
||||
|
||||
if (!media) {
|
||||
return (
|
||||
<div className={styles.loading} role="alert">
|
||||
{error || "Could not load website images."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const editingSection = editTarget
|
||||
? SECTIONS.find((row) => row.key === editTarget.key)
|
||||
: null;
|
||||
const editingItems = editTarget ? getItems(media, editTarget.key) : [];
|
||||
const editingItem = editTarget ? editingItems[editTarget.index] : null;
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>Website images</h1>
|
||||
<p className={styles.lead}>
|
||||
Homepage assets at their website pixel sizes — add or replace via
|
||||
cropper.
|
||||
</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}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{SECTIONS.map((section) => {
|
||||
const raw = getItems(media, section.key);
|
||||
const items =
|
||||
section.kind === "fixed" && section.itemCount
|
||||
? padFixed(raw, section.itemCount)
|
||||
: raw;
|
||||
const addSize = sizeForItem(section, items.length);
|
||||
const addWidth = previewWidth(addSize);
|
||||
const showListPlus = section.kind === "list";
|
||||
const compactPlus = showListPlus && items.length > 0;
|
||||
const thumbFitClass = `${styles.thumbTransparent} ${
|
||||
section.fit === "contain" ? styles.thumbContain : styles.thumbCover
|
||||
}`;
|
||||
const groups = section.groups;
|
||||
const hasGroups = Boolean(groups?.length);
|
||||
|
||||
function renderSlot(index: number, item: WebsiteMediaItem) {
|
||||
const filled = Boolean(item.imageUrl?.trim());
|
||||
const size = sizeForItem(section, index);
|
||||
const width = previewWidth(size);
|
||||
const aspect = `${size.width} / ${size.height}`;
|
||||
return (
|
||||
<div
|
||||
key={`${section.key}-${item.id}-${index}`}
|
||||
className={styles.cell}
|
||||
style={{ width }}
|
||||
>
|
||||
{filled ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.thumb} ${thumbFitClass}`}
|
||||
style={{ aspectRatio: aspect }}
|
||||
aria-label={`Edit ${section.label} ${index + 1}`}
|
||||
disabled={saving}
|
||||
onClick={() => setEditTarget({ key: section.key, index })}
|
||||
>
|
||||
<img src={item.imageUrl} alt="" />
|
||||
</button>
|
||||
<div className={styles.footer}>
|
||||
<p className={styles.cellIndex}>{index + 1}</p>
|
||||
<div className={styles.controls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconBtn}
|
||||
aria-label="Edit"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setEditTarget({ key: section.key, index })
|
||||
}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
|
||||
aria-label="Remove"
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setRemoveTarget({ key: section.key, index })
|
||||
}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.thumb} ${styles.thumbEmpty}`}
|
||||
style={{ aspectRatio: aspect }}
|
||||
aria-label={`Add ${section.label} ${index + 1}`}
|
||||
disabled={saving}
|
||||
onClick={() => setEditTarget({ key: section.key, index })}
|
||||
>
|
||||
<span className={styles.plus}>+</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={section.key} className={styles.section}>
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 className={styles.sectionTitle}>{section.label}</h2>
|
||||
<p className={styles.sectionMeta}>
|
||||
{section.key}
|
||||
{" · "}
|
||||
{section.kind === "list"
|
||||
? "Duplicatable"
|
||||
: `Fixed · ${section.itemCount}`}
|
||||
{!hasGroups ? ` · ${formatResolution(section.size)}` : null}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.track}>
|
||||
{showListPlus ? (
|
||||
<div
|
||||
className={`${styles.cell} ${compactPlus ? styles.cellCompact : ""}`}
|
||||
style={compactPlus ? undefined : { width: addWidth }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.thumb} ${styles.thumbEmpty} ${
|
||||
compactPlus ? styles.thumbCompact : ""
|
||||
}`}
|
||||
style={
|
||||
compactPlus
|
||||
? {
|
||||
minHeight: Math.round(
|
||||
addWidth / (addSize.width / addSize.height),
|
||||
),
|
||||
}
|
||||
: {
|
||||
aspectRatio: `${addSize.width} / ${addSize.height}`,
|
||||
}
|
||||
}
|
||||
aria-label={`Add ${section.label}`}
|
||||
disabled={saving}
|
||||
onClick={() =>
|
||||
setEditTarget({ key: section.key, index: items.length })
|
||||
}
|
||||
>
|
||||
<span className={styles.plus}>+</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasGroups
|
||||
? groups!.map((group, groupIndex) => (
|
||||
<div key={`group-${groupIndex}`} className={styles.group}>
|
||||
{groupIndex > 0 ? (
|
||||
<div className={styles.groupDivider} aria-hidden />
|
||||
) : null}
|
||||
<div className={styles.groupBody}>
|
||||
<p className={styles.groupSize}>
|
||||
{formatResolution(group.size)}
|
||||
</p>
|
||||
<div className={styles.groupItems}>
|
||||
{group.indexes.map((index) => {
|
||||
const item = items[index] ?? emptySlot();
|
||||
return renderSlot(index, item);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: items.map((item, index) => renderSlot(index, item))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<WebsiteMediaEditModal
|
||||
open={Boolean(editTarget && editingSection)}
|
||||
sectionLabel={editingSection?.label ?? ""}
|
||||
size={
|
||||
editTarget && editingSection
|
||||
? sizeForItem(editingSection, editTarget.index)
|
||||
: { width: 600, height: 600 }
|
||||
}
|
||||
fit={editingSection?.fit ?? "contain"}
|
||||
initial={editingItem}
|
||||
busy={saving}
|
||||
onClose={() => {
|
||||
if (!saving) setEditTarget(null);
|
||||
}}
|
||||
onSave={async (values) => {
|
||||
if (!editTarget) return;
|
||||
await handleSaveItem(editTarget, values);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removeTarget)}
|
||||
title="Remove image"
|
||||
message="Clear this slot? You can add a new image later."
|
||||
confirmLabel="Remove"
|
||||
danger
|
||||
busy={saving}
|
||||
onClose={() => {
|
||||
if (!saving) setRemoveTarget(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (removeTarget) void handleRemove(removeTarget);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteMediaEditModal({
|
||||
open,
|
||||
sectionLabel,
|
||||
size,
|
||||
fit,
|
||||
initial,
|
||||
busy,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
sectionLabel: string;
|
||||
size: PixelSize;
|
||||
fit: "contain" | "cover";
|
||||
initial: WebsiteMediaItem | null | undefined;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: {
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cta: string;
|
||||
linkUrl: string;
|
||||
}) => Promise<void>;
|
||||
}) {
|
||||
const resolution = formatResolution(size);
|
||||
const aspect = size.width / size.height;
|
||||
const titleId = useId();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [subtitle, setSubtitle] = useState("");
|
||||
const [cta, setCta] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [cropSrc, setCropSrc] = useState<string | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [localError, setLocalError] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTitle(initial?.title ?? "");
|
||||
setSubtitle(initial?.subtitle ?? "");
|
||||
setCta(initial?.cta ?? "");
|
||||
setLinkUrl(initial?.linkUrl ?? "");
|
||||
setImageUrl(initial?.imageUrl ?? "");
|
||||
setPendingFile(null);
|
||||
setLocalError("");
|
||||
setCropSrc(null);
|
||||
setCropOpen(false);
|
||||
}, [open, initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !busy && !cropOpen) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, busy, cropOpen, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!imageUrl && !pendingFile) {
|
||||
setLocalError("Add an image first.");
|
||||
return;
|
||||
}
|
||||
setLocalError("");
|
||||
setUploading(true);
|
||||
try {
|
||||
let nextUrl = imageUrl;
|
||||
if (pendingFile) {
|
||||
nextUrl = await uploadWebsiteMedia(pendingFile);
|
||||
}
|
||||
await onSave({
|
||||
imageUrl: nextUrl,
|
||||
title: title.trim(),
|
||||
subtitle: subtitle.trim(),
|
||||
cta: cta.trim(),
|
||||
linkUrl: linkUrl.trim() || "/",
|
||||
});
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : "Could not save.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const working = busy || uploading;
|
||||
|
||||
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.modalHead}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Website image</p>
|
||||
<h2 id={titleId} className={styles.modalTitle}>
|
||||
{initial?.imageUrl ? "Edit image" : "Add image"}
|
||||
</h2>
|
||||
<p className={styles.modalHint}>
|
||||
{sectionLabel} · {resolution}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="Close"
|
||||
disabled={working}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className={styles.form} onSubmit={(event) => void submit(event)}>
|
||||
<div className={styles.imageBlock}>
|
||||
<div className={styles.imageLabelRow}>
|
||||
<span className={styles.imageLabel}>Image</span>
|
||||
<span className={styles.imageLabel}>{resolution}</span>
|
||||
</div>
|
||||
|
||||
{imageUrl ? (
|
||||
<div className={styles.cropPreviewWrap}>
|
||||
<div
|
||||
className={`${styles.cropPreview} ${styles.cropPreviewTransparent} ${
|
||||
fit === "contain"
|
||||
? styles.cropPreviewContain
|
||||
: styles.cropPreviewCover
|
||||
}`}
|
||||
style={{ aspectRatio: `${size.width} / ${size.height}` }}
|
||||
>
|
||||
<img src={imageUrl} alt="" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.changeImage}
|
||||
disabled={working}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
Change image
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pickImage}
|
||||
style={{ aspectRatio: `${size.width} / ${size.height}` }}
|
||||
disabled={working}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<span className={styles.pickPlus}>+</span>
|
||||
<span className={styles.pickMeta}>{resolution}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className={styles.fileInput}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
setCropSrc(url);
|
||||
setCropOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Title EN</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Title"
|
||||
disabled={working}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Subtext</span>
|
||||
<textarea
|
||||
value={subtitle}
|
||||
onChange={(event) => setSubtitle(event.target.value)}
|
||||
placeholder="Short supporting text"
|
||||
disabled={working}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Link text</span>
|
||||
<input
|
||||
value={cta}
|
||||
onChange={(event) => setCta(event.target.value)}
|
||||
placeholder="browse producers"
|
||||
disabled={working}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.linkRow}>
|
||||
<label className={`${styles.field} ${styles.linkField}`}>
|
||||
<span>Link value</span>
|
||||
<input
|
||||
value={linkUrl}
|
||||
onChange={(event) => setLinkUrl(event.target.value)}
|
||||
placeholder="/businesses"
|
||||
dir="ltr"
|
||||
disabled={working}
|
||||
/>
|
||||
</label>
|
||||
<WebsiteLinkGenerator
|
||||
disabled={working}
|
||||
onPick={(url) => setLinkUrl(url)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{localError ? (
|
||||
<p className={styles.modalError} role="alert">
|
||||
{localError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.secondaryBtn}`}
|
||||
disabled={working}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.primaryBtn}`}
|
||||
disabled={working || (!imageUrl && !pendingFile)}
|
||||
>
|
||||
{working ? "…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImageCropDialog
|
||||
open={cropOpen}
|
||||
imageSrc={cropSrc}
|
||||
title={`Crop · ${resolution}`}
|
||||
aspect={aspect}
|
||||
outputType="image/png"
|
||||
fileName={`${sectionLabel.toLowerCase().replace(/\s+/g, "-")}-image`}
|
||||
onCancel={() => {
|
||||
if (cropSrc?.startsWith("blob:")) URL.revokeObjectURL(cropSrc);
|
||||
setCropOpen(false);
|
||||
setCropSrc(null);
|
||||
}}
|
||||
onComplete={(file, previewUrl) => {
|
||||
if (cropSrc?.startsWith("blob:")) URL.revokeObjectURL(cropSrc);
|
||||
setPendingFile(file);
|
||||
setImageUrl(previewUrl);
|
||||
setCropOpen(false);
|
||||
setCropSrc(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
.trigger {
|
||||
width: 46px;
|
||||
min-height: 46px;
|
||||
flex: 0 0 auto;
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
color: var(--color-soft);
|
||||
transition: border-color 0.2s ease, color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.trigger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 95;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px var(--page-pad);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(100%, 720px);
|
||||
max-height: min(90vh, 760px);
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--color-white);
|
||||
box-shadow: 24px 28px 48px rgba(0, 0, 0, 0.12);
|
||||
padding: 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.closeBtn:hover,
|
||||
.closeBtn:focus-visible {
|
||||
background: #f6f6f6;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.kindSwitch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 5px;
|
||||
background: #f6f6f6;
|
||||
}
|
||||
|
||||
.kindBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.kindBtnActive {
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.status,
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--color-soft);
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #8a1f11;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.preview span {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.preview code {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
color: var(--color-black);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.secondaryBtn,
|
||||
.primaryBtn {
|
||||
min-height: 34px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
border-color: #9e9e9e;
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover,
|
||||
.secondaryBtn:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
min-width: 112px;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import CategoryTreeSelect from "@/components/CategoryTreeSelect";
|
||||
import {
|
||||
searchMeshkeeCategories,
|
||||
type MeshkeeCategory,
|
||||
} from "@/services/meshkee-categories";
|
||||
import styles from "./WebsiteLinkGenerator.module.css";
|
||||
|
||||
export type WebsiteLinkKind = "businesses" | "blog";
|
||||
|
||||
function buildCategoryLink(kind: WebsiteLinkKind, category: MeshkeeCategory) {
|
||||
const path = kind === "blog" ? "/blog" : "/businesses";
|
||||
const query = category.slug
|
||||
? `category=${encodeURIComponent(category.slug)}`
|
||||
: `categoryId=${encodeURIComponent(category.id)}`;
|
||||
return `${path}?${query}`;
|
||||
}
|
||||
|
||||
function LinkIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l1.92-1.92a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54L4.54 12.38a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebsiteLinkGenerator({
|
||||
onPick,
|
||||
disabled = false,
|
||||
}: {
|
||||
onPick: (url: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [kind, setKind] = useState<WebsiteLinkKind>("businesses");
|
||||
const [categories, setCategories] = useState<MeshkeeCategory[]>([]);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setKind("businesses");
|
||||
setCategoryId("");
|
||||
setError("");
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void searchMeshkeeCategories("", controller.signal)
|
||||
.then((items) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCategories(items);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCategories([]);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [open]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => categories.find((item) => item.id === categoryId) ?? null,
|
||||
[categories, categoryId],
|
||||
);
|
||||
const preview = selected ? buildCategoryLink(kind, selected) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Generate link"
|
||||
title="Generate link"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<LinkIcon />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="presentation"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className={styles.modal}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.head}>
|
||||
<div>
|
||||
<p className={styles.kicker}>Link generator</p>
|
||||
<h3 id={titleId} className={styles.title}>
|
||||
Build category link
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
aria-label="Close"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.kindSwitch} role="tablist" aria-label="Link type">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={kind === "businesses"}
|
||||
className={`${styles.kindBtn} ${
|
||||
kind === "businesses" ? styles.kindBtnActive : ""
|
||||
}`}
|
||||
onClick={() => setKind("businesses")}
|
||||
>
|
||||
Businesses category
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={kind === "blog"}
|
||||
className={`${styles.kindBtn} ${
|
||||
kind === "blog" ? styles.kindBtnActive : ""
|
||||
}`}
|
||||
onClick={() => setKind("blog")}
|
||||
>
|
||||
Blog category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className={styles.status}>Loading categories…</p>
|
||||
) : error ? (
|
||||
<p className={styles.error}>{error}</p>
|
||||
) : (
|
||||
<CategoryTreeSelect
|
||||
categories={categories}
|
||||
value={categoryId ? [categoryId] : []}
|
||||
onChange={(ids) => setCategoryId(ids[0] ?? "")}
|
||||
selection="single"
|
||||
dropdownVariant="inFlow"
|
||||
label="Category"
|
||||
placeholder="Search categories…"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.preview}>
|
||||
<span>Preview</span>
|
||||
<code dir="ltr">{preview || "Select a category"}</code>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.secondaryBtn}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.primaryBtn}`}
|
||||
disabled={!preview}
|
||||
onClick={() => {
|
||||
if (!preview) return;
|
||||
onPick(preview);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Use link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import styles from "@/components/BusinessDetailsPage.module.css";
|
||||
|
||||
export function BusinessMeshkeeGate({
|
||||
children,
|
||||
}: {
|
||||
children: (meshkeeId: string) => React.ReactNode;
|
||||
}) {
|
||||
const { activeBusinessId, isLoading, isSuperAdmin } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className={styles.loading}>Loading…</div>;
|
||||
}
|
||||
|
||||
if (!activeBusinessId) {
|
||||
return (
|
||||
<div className={styles.error} role="alert">
|
||||
<p className={styles.errorTitle}>
|
||||
{isSuperAdmin ? "No business selected" : "No business linked"}
|
||||
</p>
|
||||
<p className={styles.errorCopy}>
|
||||
{isSuperAdmin
|
||||
? "Open a listing in the admin dashboard and use Manage business."
|
||||
: "This account has no Meshkee business. Contact support."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children(activeBusinessId)}</>;
|
||||
}
|
||||
|
||||
export function BusinessListHeader({
|
||||
title,
|
||||
lead,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
lead: string;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.lead}>{lead}</p>
|
||||
</div>
|
||||
{action ? <div className={styles.headActions}>{action}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
FeaturedImageField,
|
||||
TagsField,
|
||||
type LocalImage,
|
||||
} from "@/components/catalog/CatalogFormFields";
|
||||
import CatalogCategorySelect from "@/components/catalog/CatalogCategorySelect";
|
||||
import RichTextArea from "@/components/RichTextArea";
|
||||
import {
|
||||
createMeshkeeBlog,
|
||||
listMeshkeeEntityCategories,
|
||||
uploadMeshkeeMediaFile,
|
||||
type MeshkeeEntityCategory,
|
||||
} from "@/services/meshkee-catalog";
|
||||
import styles from "./CatalogForm.module.css";
|
||||
|
||||
const BLOG_TYPES = [
|
||||
{ value: "blog", label: "Blog" },
|
||||
{ value: "article", label: "Article" },
|
||||
{ value: "news", label: "News" },
|
||||
] as const;
|
||||
|
||||
export default function AddBlogForm({
|
||||
meshkeeId,
|
||||
listHref,
|
||||
title,
|
||||
lead,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
listHref: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [type, setType] = useState<"blog" | "article" | "news">("blog");
|
||||
const [postTitle, setPostTitle] = useState("");
|
||||
const [abstract, setAbstract] = useState("");
|
||||
const [mainTextHtml, setMainTextHtml] = useState("");
|
||||
const [titleImage, setTitleImage] = useState<LocalImage | null>(null);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [seoMetaTitle, setSeoMetaTitle] = useState("");
|
||||
const [seoMetaDescription, setSeoMetaDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const items = await listMeshkeeEntityCategories(meshkeeId, "blog");
|
||||
if (!cancelled) setCategories(items);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
async function onSubmit(event?: React.FormEvent) {
|
||||
event?.preventDefault();
|
||||
if (!postTitle.trim()) {
|
||||
setError("Enter a title.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let featuredMediaId: string | undefined;
|
||||
if (titleImage?.file) {
|
||||
featuredMediaId = (
|
||||
await uploadMeshkeeMediaFile(meshkeeId, titleImage.file)
|
||||
).id;
|
||||
}
|
||||
|
||||
await createMeshkeeBlog(meshkeeId, {
|
||||
title: postTitle.trim(),
|
||||
type,
|
||||
abstract: abstract.trim() || undefined,
|
||||
mainTextHtml: mainTextHtml || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
featuredMediaId,
|
||||
tags,
|
||||
seoMetaTitle: seoMetaTitle.trim() || null,
|
||||
seoMetaDescription: seoMetaDescription.trim() || null,
|
||||
});
|
||||
router.push(listHref);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not create blog post.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={styles.page} onSubmit={onSubmit}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.lead}>{lead}</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Back
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.errorBox} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Media</h2>
|
||||
<div className={styles.mediaGrid}>
|
||||
<FeaturedImageField
|
||||
label="Title image"
|
||||
value={titleImage}
|
||||
aspect={3 / 2}
|
||||
disabled={saving}
|
||||
onChange={setTitleImage}
|
||||
/>
|
||||
<div className={styles.identityRow}>
|
||||
<label className={styles.field}>
|
||||
<span>Type</span>
|
||||
<select
|
||||
value={type}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setType(event.target.value as "blog" | "article" | "news")
|
||||
}
|
||||
>
|
||||
{BLOG_TYPES.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<CatalogCategorySelect
|
||||
categories={categories}
|
||||
value={categoryId}
|
||||
disabled={saving}
|
||||
optional
|
||||
placeholder="No category"
|
||||
onChange={setCategoryId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Identity</h2>
|
||||
<label className={styles.field}>
|
||||
<span>Title</span>
|
||||
<input
|
||||
value={postTitle}
|
||||
disabled={saving}
|
||||
required
|
||||
onChange={(event) => setPostTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Content</h2>
|
||||
<div className={styles.stack}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
Abstract <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={abstract}
|
||||
disabled={saving}
|
||||
onChange={(event) => setAbstract(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.richField}>
|
||||
<RichTextArea
|
||||
label="Content"
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
disabled={saving}
|
||||
minHeight={260}
|
||||
/>
|
||||
</div>
|
||||
<TagsField tags={tags} disabled={saving} onChange={setTags} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>SEO</h2>
|
||||
<div className={styles.identityRow}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
SEO title <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={seoMetaTitle}
|
||||
maxLength={70}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSeoMetaTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
SEO description <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={seoMetaDescription}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSeoMetaDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.footerActions}>
|
||||
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
FeaturedImageField,
|
||||
GalleryImagesField,
|
||||
TagsField,
|
||||
type LocalImage,
|
||||
} from "@/components/catalog/CatalogFormFields";
|
||||
import CatalogCategorySelect from "@/components/catalog/CatalogCategorySelect";
|
||||
import RichTextArea from "@/components/RichTextArea";
|
||||
import {
|
||||
createMeshkeePortfolio,
|
||||
listMeshkeeEntityCategories,
|
||||
uploadMeshkeeMediaFile,
|
||||
type MeshkeeEntityCategory,
|
||||
} from "@/services/meshkee-catalog";
|
||||
import styles from "./CatalogForm.module.css";
|
||||
|
||||
export default function AddPortfolioForm({
|
||||
meshkeeId,
|
||||
listHref,
|
||||
title,
|
||||
lead,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
listHref: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [titleFa, setTitleFa] = useState("");
|
||||
const [titleEn, setTitleEn] = useState("");
|
||||
const [abstract, setAbstract] = useState("");
|
||||
const [projectUrl, setProjectUrl] = useState("");
|
||||
const [mainTextHtml, setMainTextHtml] = useState("");
|
||||
const [titleImage, setTitleImage] = useState<LocalImage | null>(null);
|
||||
const [gallery, setGallery] = useState<LocalImage[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [seoMetaTitle, setSeoMetaTitle] = useState("");
|
||||
const [seoMetaDescription, setSeoMetaDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const items = await listMeshkeeEntityCategories(meshkeeId, "portfolio");
|
||||
if (!cancelled) setCategories(items);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
async function onSubmit(event?: React.FormEvent) {
|
||||
event?.preventDefault();
|
||||
if (!titleFa.trim()) {
|
||||
setError("Enter a Persian title.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let featuredMediaId: string | undefined;
|
||||
if (titleImage?.file) {
|
||||
featuredMediaId = (
|
||||
await uploadMeshkeeMediaFile(meshkeeId, titleImage.file)
|
||||
).id;
|
||||
}
|
||||
|
||||
const galleryMediaIds: string[] = [];
|
||||
for (const item of gallery) {
|
||||
if (item.mediaId) {
|
||||
galleryMediaIds.push(item.mediaId);
|
||||
continue;
|
||||
}
|
||||
if (item.file) {
|
||||
galleryMediaIds.push(
|
||||
(await uploadMeshkeeMediaFile(meshkeeId, item.file)).id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await createMeshkeePortfolio(meshkeeId, {
|
||||
titleFa: titleFa.trim(),
|
||||
titleEn: titleEn.trim() || undefined,
|
||||
abstract: abstract.trim() || undefined,
|
||||
projectUrl: projectUrl.trim() || undefined,
|
||||
mainTextHtml: mainTextHtml || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
featuredMediaId,
|
||||
galleryMediaIds,
|
||||
tags,
|
||||
seoMetaTitle: seoMetaTitle.trim() || null,
|
||||
seoMetaDescription: seoMetaDescription.trim() || null,
|
||||
});
|
||||
router.push(listHref);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not create portfolio.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={styles.page} onSubmit={onSubmit}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.lead}>{lead}</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Back
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.errorBox} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Media</h2>
|
||||
<div className={styles.mediaGrid}>
|
||||
<FeaturedImageField
|
||||
label="Title image"
|
||||
value={titleImage}
|
||||
aspect={3 / 2}
|
||||
disabled={saving}
|
||||
onChange={setTitleImage}
|
||||
/>
|
||||
<GalleryImagesField
|
||||
items={gallery}
|
||||
disabled={saving}
|
||||
onChange={setGallery}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Identity</h2>
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.identityRow3}>
|
||||
<CatalogCategorySelect
|
||||
categories={categories}
|
||||
value={categoryId}
|
||||
disabled={saving}
|
||||
optional
|
||||
placeholder="No category"
|
||||
onChange={setCategoryId}
|
||||
/>
|
||||
<label className={styles.field}>
|
||||
<span>Title FA</span>
|
||||
<input
|
||||
className="fa"
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
value={titleFa}
|
||||
disabled={saving}
|
||||
required
|
||||
onChange={(event) => setTitleFa(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
Title EN <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={titleEn}
|
||||
disabled={saving}
|
||||
onChange={(event) => setTitleEn(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
Project URL <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
dir="ltr"
|
||||
value={projectUrl}
|
||||
disabled={saving}
|
||||
placeholder="https://"
|
||||
onChange={(event) => setProjectUrl(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Content</h2>
|
||||
<div className={styles.stack}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
Abstract <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={abstract}
|
||||
disabled={saving}
|
||||
onChange={(event) => setAbstract(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.richField}>
|
||||
<RichTextArea
|
||||
label="Content"
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
disabled={saving}
|
||||
minHeight={220}
|
||||
/>
|
||||
</div>
|
||||
<TagsField tags={tags} disabled={saving} onChange={setTags} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>SEO</h2>
|
||||
<div className={styles.identityRow}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
SEO title <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={seoMetaTitle}
|
||||
maxLength={70}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSeoMetaTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
SEO description <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={seoMetaDescription}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSeoMetaDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.footerActions}>
|
||||
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
FeaturedImageField,
|
||||
GalleryImagesField,
|
||||
TagsField,
|
||||
type LocalImage,
|
||||
} from "@/components/catalog/CatalogFormFields";
|
||||
import CatalogCategorySelect from "@/components/catalog/CatalogCategorySelect";
|
||||
import RichTextArea from "@/components/RichTextArea";
|
||||
import {
|
||||
createMeshkeeProduct,
|
||||
listMeshkeeEntityCategories,
|
||||
uploadMeshkeeMediaFile,
|
||||
type MeshkeeEntityCategory,
|
||||
} from "@/services/meshkee-catalog";
|
||||
import styles from "./CatalogForm.module.css";
|
||||
|
||||
export default function AddProductForm({
|
||||
meshkeeId,
|
||||
listHref,
|
||||
title,
|
||||
lead,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
listHref: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [nameFa, setNameFa] = useState("");
|
||||
const [nameEn, setNameEn] = useState("");
|
||||
const [summary, setSummary] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [thumbnail, setThumbnail] = useState<LocalImage | null>(null);
|
||||
const [gallery, setGallery] = useState<LocalImage[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [seoMetaTitle, setSeoMetaTitle] = useState("");
|
||||
const [seoMetaDescription, setSeoMetaDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const items = await listMeshkeeEntityCategories(meshkeeId, "product");
|
||||
if (!cancelled) setCategories(items);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
async function onSubmit(event?: React.FormEvent) {
|
||||
event?.preventDefault();
|
||||
if (!nameEn.trim() && !nameFa.trim()) {
|
||||
setError("Enter a product name.");
|
||||
return;
|
||||
}
|
||||
if (!categoryId) {
|
||||
setError("Select a category.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
let featuredMediaId: string | undefined;
|
||||
if (thumbnail?.file) {
|
||||
featuredMediaId = (
|
||||
await uploadMeshkeeMediaFile(meshkeeId, thumbnail.file)
|
||||
).id;
|
||||
} else if (thumbnail?.mediaId) {
|
||||
featuredMediaId = thumbnail.mediaId;
|
||||
}
|
||||
|
||||
const galleryMediaIds: string[] = [];
|
||||
for (const item of gallery) {
|
||||
if (item.mediaId) {
|
||||
galleryMediaIds.push(item.mediaId);
|
||||
continue;
|
||||
}
|
||||
if (item.file) {
|
||||
galleryMediaIds.push(
|
||||
(await uploadMeshkeeMediaFile(meshkeeId, item.file)).id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await createMeshkeeProduct(meshkeeId, {
|
||||
title: nameEn.trim() || nameFa.trim(),
|
||||
nameFa: nameFa.trim() || undefined,
|
||||
summary: summary.trim() || undefined,
|
||||
descriptionHtml: description || undefined,
|
||||
categoryId,
|
||||
featuredMediaId,
|
||||
galleryMediaIds,
|
||||
tags,
|
||||
seoMetaTitle: seoMetaTitle.trim() || null,
|
||||
seoMetaDescription: seoMetaDescription.trim() || null,
|
||||
});
|
||||
router.push(listHref);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not create product.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className={styles.page} onSubmit={onSubmit}>
|
||||
<header className={styles.head}>
|
||||
<div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.lead}>{lead}</p>
|
||||
</div>
|
||||
<div className={styles.headActions}>
|
||||
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Back
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className={styles.errorBox} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.layout}>
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Media</h2>
|
||||
<div className={styles.mediaGrid}>
|
||||
<FeaturedImageField
|
||||
label="Thumbnail"
|
||||
value={thumbnail}
|
||||
disabled={saving}
|
||||
onChange={setThumbnail}
|
||||
/>
|
||||
<div className={styles.stack}>
|
||||
<GalleryImagesField
|
||||
items={gallery}
|
||||
disabled={saving}
|
||||
onChange={setGallery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Identity</h2>
|
||||
<div className={styles.identityRow3}>
|
||||
<CatalogCategorySelect
|
||||
categories={categories}
|
||||
value={categoryId}
|
||||
disabled={saving}
|
||||
onChange={setCategoryId}
|
||||
/>
|
||||
<label className={styles.field}>
|
||||
<span>Name EN</span>
|
||||
<input
|
||||
value={nameEn}
|
||||
disabled={saving}
|
||||
required
|
||||
onChange={(event) => setNameEn(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>Name FA</span>
|
||||
<input
|
||||
className="fa"
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
value={nameFa}
|
||||
disabled={saving}
|
||||
onChange={(event) => setNameFa(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>Content</h2>
|
||||
<div className={styles.stack}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
Summary <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={summary}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.richField}>
|
||||
<RichTextArea
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
disabled={saving}
|
||||
minHeight={200}
|
||||
/>
|
||||
</div>
|
||||
<TagsField tags={tags} disabled={saving} onChange={setTags} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<h2 className={styles.sectionTitle}>SEO</h2>
|
||||
<div className={styles.identityRow}>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
SEO title <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={seoMetaTitle}
|
||||
maxLength={70}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSeoMetaTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<span>
|
||||
SEO description <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
value={seoMetaDescription}
|
||||
maxLength={160}
|
||||
disabled={saving}
|
||||
onChange={(event) => setSeoMetaDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={styles.footerActions}>
|
||||
<Link href={listHref} className={`outline-btn ${styles.ghostBtn}`}>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className={`outline-btn ${styles.saveBtn}`}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import CatalogCardControls, {
|
||||
type CatalogControlsMode,
|
||||
} from "@/components/catalog/CatalogCardControls";
|
||||
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
|
||||
import CatalogPagination from "@/components/catalog/CatalogPagination";
|
||||
import {
|
||||
BLOG_PAGE_SIZE,
|
||||
blogImage,
|
||||
excerpt,
|
||||
formatDate,
|
||||
toPlainText,
|
||||
} from "@/components/catalog/utils";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import styles from "@/components/BusinessDetailsPage.module.css";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
deleteMeshkeeBlog,
|
||||
listMeshkeeBlogs,
|
||||
type MeshkeeBlog,
|
||||
} from "@/services/meshkee-content";
|
||||
|
||||
export default function BlogPanel({
|
||||
meshkeeId,
|
||||
emptyLabel = en.blog.empty,
|
||||
addHref,
|
||||
controlsMode = "edit-remove",
|
||||
onManage,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
emptyLabel?: string;
|
||||
addHref?: string;
|
||||
controlsMode?: CatalogControlsMode;
|
||||
onManage?: () => void;
|
||||
}) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<MeshkeeBlog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [removing, setRemoving] = useState<MeshkeeBlog | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await listMeshkeeBlogs(meshkeeId, {
|
||||
page: 1,
|
||||
pageSize: BLOG_PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(1);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load blog posts.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
function scrollToSection() {
|
||||
const node = sectionRef.current;
|
||||
if (!node) return;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
node.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPage(next: number) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await listMeshkeeBlogs(meshkeeId, {
|
||||
page: next,
|
||||
pageSize: BLOG_PAGE_SIZE,
|
||||
});
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
scrollToSection();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
try {
|
||||
await deleteMeshkeeBlog(meshkeeId, removing.id);
|
||||
setItems((prev) => prev.filter((item) => item.id !== removing.id));
|
||||
setTotal((value) => Math.max(0, value - 1));
|
||||
setRemoving(null);
|
||||
} catch (err) {
|
||||
window.alert(
|
||||
err instanceof Error ? err.message : "Could not remove blog post.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className={styles.panelHint}>{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className={styles.productsSection}>
|
||||
{busy ? <p className={styles.panelHint}>Loading blog…</p> : null}
|
||||
<div className={styles.blogGrid}>
|
||||
{items.map((post) => {
|
||||
const image = blogImage(post);
|
||||
const text = post.abstract
|
||||
? excerpt(toPlainText(post.abstract), 160)
|
||||
: "";
|
||||
return (
|
||||
<article key={post.id} className={styles.blogCard}>
|
||||
<div className={styles.cardMedia}>
|
||||
{image ? (
|
||||
<img src={image} alt="" />
|
||||
) : (
|
||||
<div className={styles.mediaFallback} aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className={`tile-hover ${styles.cardFoot}`}>
|
||||
<div className={styles.blogBody}>
|
||||
<h3 dir="auto">{post.title}</h3>
|
||||
{text ? <p dir="auto">{text}</p> : null}
|
||||
{post.createdAt ? (
|
||||
<span className={styles.meta}>{formatDate(post.createdAt)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CatalogCardControls
|
||||
mode={controlsMode}
|
||||
busy={removeBusy}
|
||||
onManage={onManage}
|
||||
onEdit={() =>
|
||||
window.alert(`Edit blog coming soon: ${post.title}`)
|
||||
}
|
||||
onRemove={() => setRemoving(post)}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!items.length && !busy ? (
|
||||
<CatalogEmptyState
|
||||
icon="/images/icons/calendar.svg"
|
||||
title={emptyLabel}
|
||||
lead={en.blog.emptyLead}
|
||||
actionHref={addHref}
|
||||
actionLabel={en.blog.add}
|
||||
/>
|
||||
) : null}
|
||||
<CatalogPagination
|
||||
page={page}
|
||||
total={total}
|
||||
pageSize={BLOG_PAGE_SIZE}
|
||||
busy={busy}
|
||||
onChange={(next) => void loadPage(next)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={controlsMode === "edit-remove" && Boolean(removing)}
|
||||
kicker="Remove"
|
||||
title={`Remove ${removing?.title ?? "post"}?`}
|
||||
message="This deletes the blog post from Meshkee. This cannot be undone."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import styles from "@/components/BusinessDetailsPage.module.css";
|
||||
|
||||
export type CatalogControlsMode = "edit-remove" | "manage";
|
||||
|
||||
export default function CatalogCardControls({
|
||||
mode = "edit-remove",
|
||||
onEdit,
|
||||
onRemove,
|
||||
onManage,
|
||||
busy,
|
||||
}: {
|
||||
mode?: CatalogControlsMode;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
onManage?: () => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
if (mode === "manage") {
|
||||
return (
|
||||
<div className={styles.cardControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cardControl}
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onManage?.();
|
||||
}}
|
||||
>
|
||||
Manage business
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.cardControls}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cardControl}
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.cardControl} ${styles.cardControlDanger}`}
|
||||
disabled={busy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRemove?.();
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.optional {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: none;
|
||||
color: var(--color-body);
|
||||
}
|
||||
|
||||
.control {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 10px 36px 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.control input:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.caret {
|
||||
position: absolute;
|
||||
inset-inline-end: 10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.caret::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin: 4px auto 0;
|
||||
border-inline-end: 1.5px solid var(--color-soft);
|
||||
border-bottom: 1.5px solid var(--color-soft);
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.caretOpen::before {
|
||||
margin-top: 8px;
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
inset-inline: 0;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.list,
|
||||
.branch {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.branch {
|
||||
margin-left: 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.node {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
margin: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rowActive {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.expand,
|
||||
.expandSpacer {
|
||||
flex: 0 0 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
color: var(--color-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expand:hover,
|
||||
.expand:focus-visible {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.expandOpen {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.leafMark {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.leafMark::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 1px;
|
||||
background: #e4e4e4;
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 8px 6px 2px;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.option:hover,
|
||||
.option:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.optionLabel {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clearOption {
|
||||
display: block;
|
||||
width: calc(100% - 12px);
|
||||
margin: 6px 6px 0;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clearOption:hover,
|
||||
.clearOption:focus-visible {
|
||||
background: #f7f7f7;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
padding: 16px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-body);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
categoryOptionLabel,
|
||||
type MeshkeeEntityCategory,
|
||||
} from "@/services/meshkee-catalog";
|
||||
import styles from "./CatalogCategorySelect.module.css";
|
||||
|
||||
type TreeNode = MeshkeeEntityCategory & { children: TreeNode[] };
|
||||
|
||||
function buildTree(categories: MeshkeeEntityCategory[]): TreeNode[] {
|
||||
const byId = new Map<string, TreeNode>();
|
||||
for (const category of categories) {
|
||||
byId.set(category.id, { ...category, children: [] });
|
||||
}
|
||||
|
||||
const roots: TreeNode[] = [];
|
||||
for (const node of byId.values()) {
|
||||
if (node.parentId && byId.has(node.parentId)) {
|
||||
byId.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortNodes = (nodes: TreeNode[]) => {
|
||||
nodes.sort((a, b) =>
|
||||
categoryOptionLabel(a).localeCompare(categoryOptionLabel(b), undefined, {
|
||||
sensitivity: "base",
|
||||
}),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
};
|
||||
sortNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function matchesQuery(category: MeshkeeEntityCategory, q: string) {
|
||||
if (!q) return true;
|
||||
const hay = [category.name, category.nameFa ?? "", category.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
}
|
||||
|
||||
function filterTree(nodes: TreeNode[], q: string): TreeNode[] {
|
||||
if (!q) return nodes;
|
||||
const result: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = filterTree(node.children, q);
|
||||
if (matchesQuery(node, q) || children.length) {
|
||||
result.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectExpandIds(nodes: TreeNode[], into: Set<string>) {
|
||||
for (const node of nodes) {
|
||||
if (node.children.length) {
|
||||
into.add(node.id);
|
||||
collectExpandIds(node.children, into);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function CatalogCategorySelect({
|
||||
categories,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label = "Category",
|
||||
optional = false,
|
||||
placeholder = "Select category",
|
||||
}: {
|
||||
categories: MeshkeeEntityCategory[];
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
optional?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const tree = useMemo(() => buildTree(categories), [categories]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const visibleTree = useMemo(() => filterTree(tree, q), [tree, q]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => categories.find((item) => item.id === value) ?? null,
|
||||
[categories, value],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!q) return;
|
||||
const next = new Set<string>();
|
||||
collectExpandIds(visibleTree, next);
|
||||
setExpanded(next);
|
||||
}, [q, visibleTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!rootRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
}
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function select(id: string) {
|
||||
onChange(id);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function renderNodes(nodes: TreeNode[], depth: number) {
|
||||
return nodes.map((node) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = q ? true : expanded.has(node.id);
|
||||
const active = value === node.id;
|
||||
|
||||
return (
|
||||
<li key={node.id} className={styles.node}>
|
||||
<div
|
||||
className={`${styles.row}${active ? ` ${styles.rowActive}` : ""}`}
|
||||
style={{ paddingInlineStart: `${10 + depth * 18}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.expand} ${isExpanded ? styles.expandOpen : ""}`}
|
||||
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||
tabIndex={-1}
|
||||
onClick={() => toggleExpand(node.id)}
|
||||
>
|
||||
<span aria-hidden>{isExpanded ? "−" : "+"}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={`${styles.expandSpacer}${depth > 0 ? ` ${styles.leafMark}` : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.option}
|
||||
disabled={disabled}
|
||||
onClick={() => select(node.id)}
|
||||
>
|
||||
<span className={styles.optionLabel} dir="auto">
|
||||
{categoryOptionLabel(node)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{hasChildren && isExpanded ? (
|
||||
<ul className={styles.branch}>{renderNodes(node.children, depth + 1)}</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<span className={styles.label}>
|
||||
{label}
|
||||
{optional ? (
|
||||
<em className={styles.optional}> (optional)</em>
|
||||
) : null}
|
||||
</span>
|
||||
<div className={styles.control}>
|
||||
<input
|
||||
value={open ? query : selected ? categoryOptionLabel(selected) : ""}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
onFocus={() => {
|
||||
setOpen(true);
|
||||
setQuery("");
|
||||
}}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.caret} ${open ? styles.caretOpen : ""}`}
|
||||
aria-label={open ? "Close categories" : "Open categories"}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpen((prev) => !prev);
|
||||
setQuery("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className={styles.dropdown} role="listbox">
|
||||
{optional ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.clearOption}${!value ? ` ${styles.rowActive}` : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => select("")}
|
||||
>
|
||||
No category
|
||||
</button>
|
||||
) : null}
|
||||
{visibleTree.length ? (
|
||||
<ul className={styles.list}>{renderNodes(visibleTree, 0)}</ul>
|
||||
) : (
|
||||
<p className={styles.empty}>No categories found</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
margin: 8px 0 20px;
|
||||
padding: 48px 28px;
|
||||
border: 1px solid #ececec;
|
||||
border-radius: 6px;
|
||||
background-color: #f7f7f7;
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconWrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
border: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.iconWrap img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1.55;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.cta {
|
||||
margin-top: 6px;
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import styles from "./CatalogEmptyState.module.css";
|
||||
|
||||
export default function CatalogEmptyState({
|
||||
icon,
|
||||
title,
|
||||
lead,
|
||||
actionHref,
|
||||
actionLabel,
|
||||
onAction,
|
||||
}: {
|
||||
icon: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
actionHref?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}) {
|
||||
const showAction = Boolean(actionLabel && (actionHref || onAction));
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.iconWrap} aria-hidden>
|
||||
<img src={icon} alt="" width={22} height={22} />
|
||||
</div>
|
||||
<div className={styles.copy}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.lead}>{lead}</p>
|
||||
</div>
|
||||
{showAction && actionHref ? (
|
||||
<Link href={actionHref} className={`outline-btn ${styles.cta}`}>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
{showAction && !actionHref && onAction ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${styles.cta}`}
|
||||
onClick={onAction}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 5px;
|
||||
background-color: var(--color-card);
|
||||
background-image: var(--page-title-pattern);
|
||||
background-size: var(--page-title-pattern-size);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: inherit;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover,
|
||||
.breadcrumb a:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(20px, 2.2vw, 28px);
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.headActions,
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footerActionsEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.saveBtn {
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.ghostBtn {
|
||||
border-color: #d4d4d4;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.ghostBtn::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.ghostBtn:hover,
|
||||
.ghostBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
background: var(--color-black);
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.saveBtn:hover,
|
||||
.saveBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.saveBtn::before {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.saveBtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--color-white);
|
||||
border-radius: 5px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mediaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.thumbBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blockHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.inlineActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.linkBtn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkBtn:hover,
|
||||
.linkBtn:focus-visible {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.linkBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.thumbFrame {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 1px dashed #d8d8d8;
|
||||
border-radius: 5px;
|
||||
background: #fafafa;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-body);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumbFrameWide {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
|
||||
.thumbFrame img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.identityRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.identityRow3 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field span,
|
||||
.field > label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.optional {
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: none;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.field > input,
|
||||
.field > textarea,
|
||||
.field > select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
outline: none;
|
||||
transition: border-color 0.35s ease;
|
||||
}
|
||||
|
||||
.field > textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.field > input:hover,
|
||||
.field > textarea:hover,
|
||||
.field > select:hover {
|
||||
border-color: #d8d8d8;
|
||||
}
|
||||
|
||||
.field > input:focus,
|
||||
.field > textarea:focus,
|
||||
.field > select:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.richField {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.galleryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.galleryItem {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.galleryItem img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.galleryItem button {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.galleryAdd {
|
||||
align-self: flex-start;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.galleryAdd:hover {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-muted);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tag button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tagRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagRow input {
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
padding: 10px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tagRow input:focus {
|
||||
border-color: var(--color-black);
|
||||
}
|
||||
|
||||
.tagAdd {
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tagAdd:hover {
|
||||
border-color: var(--color-black);
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
padding: 14px 16px;
|
||||
border-radius: 5px;
|
||||
background: #fff5f5;
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mediaGrid,
|
||||
.identityRow,
|
||||
.identityRow3,
|
||||
.galleryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import ImageCropDialog from "@/components/ImageCropDialog";
|
||||
import styles from "./CatalogForm.module.css";
|
||||
|
||||
export type LocalImage = {
|
||||
key: string;
|
||||
file?: File;
|
||||
previewUrl: string;
|
||||
mediaId?: string;
|
||||
};
|
||||
|
||||
export function FeaturedImageField({
|
||||
label = "Thumbnail",
|
||||
value,
|
||||
aspect = 1,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
label?: string;
|
||||
value: LocalImage | null;
|
||||
aspect?: number;
|
||||
disabled?: boolean;
|
||||
onChange: (value: LocalImage | null) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [cropSource, setCropSource] = useState<string | null>(null);
|
||||
const [cropName, setCropName] = useState("image.jpg");
|
||||
const wide = aspect !== 1;
|
||||
|
||||
return (
|
||||
<div className={styles.thumbBlock}>
|
||||
<div className={styles.blockHead}>
|
||||
<span>{label}</span>
|
||||
<div className={styles.inlineActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{value ? "Replace" : "Upload"}
|
||||
</button>
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (value.previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(value.previewUrl);
|
||||
}
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.thumbFrame}${wide ? ` ${styles.thumbFrameWide}` : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{value?.previewUrl ? (
|
||||
<img src={value.previewUrl} alt="" />
|
||||
) : (
|
||||
<span>Add {label.toLowerCase()}</span>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className={styles.fileInput}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
setCropName(file.name || "image.jpg");
|
||||
setCropSource(url);
|
||||
}}
|
||||
/>
|
||||
<ImageCropDialog
|
||||
open={Boolean(cropSource)}
|
||||
imageSrc={cropSource}
|
||||
title={`Crop ${label.toLowerCase()}`}
|
||||
aspect={aspect}
|
||||
outputType="image/jpeg"
|
||||
fileName={cropName.replace(/\.[^.]+$/, "") || "image"}
|
||||
onCancel={() => {
|
||||
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
|
||||
setCropSource(null);
|
||||
}}
|
||||
onComplete={(file, previewUrl) => {
|
||||
if (cropSource?.startsWith("blob:")) URL.revokeObjectURL(cropSource);
|
||||
if (value?.previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(value.previewUrl);
|
||||
}
|
||||
setCropSource(null);
|
||||
onChange({
|
||||
key: `${Date.now()}`,
|
||||
file,
|
||||
previewUrl,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryImagesField({
|
||||
items,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
items: LocalImage[];
|
||||
disabled?: boolean;
|
||||
onChange: (items: LocalImage[]) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.blockHead}>
|
||||
<span>
|
||||
Gallery images <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkBtn}
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Add images
|
||||
</button>
|
||||
</div>
|
||||
{items.length ? (
|
||||
<div className={styles.galleryGrid}>
|
||||
{items.map((item) => (
|
||||
<div key={item.key} className={styles.galleryItem}>
|
||||
<img src={item.previewUrl} alt="" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (item.previewUrl.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(item.previewUrl);
|
||||
}
|
||||
onChange(items.filter((row) => row.key !== item.key));
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.galleryAdd}
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
Add images
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className={styles.fileInput}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []).filter((file) =>
|
||||
file.type.startsWith("image/"),
|
||||
);
|
||||
event.target.value = "";
|
||||
if (!files.length) return;
|
||||
onChange([
|
||||
...items,
|
||||
...files.map((file) => ({
|
||||
key: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
|
||||
file,
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
})),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagsField({
|
||||
tags,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
tags: string[];
|
||||
disabled?: boolean;
|
||||
onChange: (tags: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
function addTag() {
|
||||
const next = draft.trim();
|
||||
if (!next) return;
|
||||
if (tags.some((tag) => tag.toLowerCase() === next.toLowerCase())) {
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
onChange([...tags, next]);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<span>
|
||||
Tags <em className={styles.optional}>(optional)</em>
|
||||
</span>
|
||||
{tags.length ? (
|
||||
<div className={styles.tags}>
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={() => onChange(tags.filter((item) => item !== tag))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.tagRow}>
|
||||
<input
|
||||
value={draft}
|
||||
disabled={disabled}
|
||||
placeholder="Add a tag"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tagAdd}
|
||||
disabled={disabled}
|
||||
onClick={addTag}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { buildPageItems } from "@/components/catalog/utils";
|
||||
import styles from "@/components/BusinessDetailsPage.module.css";
|
||||
|
||||
export default function CatalogPagination({
|
||||
page,
|
||||
total,
|
||||
pageSize,
|
||||
onChange,
|
||||
busy,
|
||||
}: {
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
onChange: (page: number) => void;
|
||||
busy?: boolean;
|
||||
}) {
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
if (total <= pageSize) return null;
|
||||
const items = buildPageItems(page, pageCount);
|
||||
|
||||
return (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={busy || page <= 1}
|
||||
onClick={() => onChange(page - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<div className={styles.pageNumbers}>
|
||||
{items.map((item, index) =>
|
||||
item === "…" ? (
|
||||
<span key={`ellipsis-${index}`} className={styles.pageEllipsis}>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={item === page ? styles.pageNumActive : styles.pageNum}
|
||||
disabled={busy || item === page}
|
||||
onClick={() => onChange(item)}
|
||||
aria-current={item === page ? "page" : undefined}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pageBtn}
|
||||
disabled={busy || page >= pageCount}
|
||||
onClick={() => onChange(page + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 148px;
|
||||
padding: 28px 20px;
|
||||
border: 1px dashed #d4d4d4;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, #ececec 1px, transparent 0) 0 0 / 18px 18px,
|
||||
#fafafa;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzoneActive {
|
||||
border-color: var(--color-black);
|
||||
background-color: #f5f5f5;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.dropzoneBusy {
|
||||
pointer-events: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.dropIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dropTitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dropHint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error,
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.masonry {
|
||||
columns: 3;
|
||||
column-gap: 14px;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
break-inside: avoid;
|
||||
margin: 0 0 14px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
|
||||
.item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
min-height: 140px;
|
||||
background: linear-gradient(135deg, #efefef, #e2e2e2);
|
||||
}
|
||||
|
||||
.itemFoot {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.item:hover .itemFoot,
|
||||
.item:focus-within .itemFoot {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
background: #111;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.removeBtn:hover {
|
||||
background: #b42318;
|
||||
}
|
||||
|
||||
.removeBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.masonry {
|
||||
columns: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.masonry {
|
||||
columns: 1;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
min-height: 120px;
|
||||
padding: 22px 16px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
|
||||
import { GALLERY_PAGE_SIZE, galleryImage } from "@/components/catalog/utils";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
addMeshkeeGalleryItems,
|
||||
deleteMeshkeeGalleryItem,
|
||||
listMeshkeeGallery,
|
||||
type MeshkeeMediaItem,
|
||||
} from "@/services/meshkee-content";
|
||||
import styles from "./GalleryPanel.module.css";
|
||||
|
||||
const ACCEPT = "image/jpeg,image/png,image/webp,image/gif";
|
||||
|
||||
function isImageFile(file: File) {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
export default function GalleryPanel({
|
||||
meshkeeId,
|
||||
emptyLabel = en.gallery.empty,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [items, setItems] = useState<MeshkeeMediaItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [removing, setRemoving] = useState<MeshkeeMediaItem | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function reload(nextPage = 1) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await listMeshkeeGallery(meshkeeId, {
|
||||
page: nextPage,
|
||||
pageSize: GALLERY_PAGE_SIZE,
|
||||
});
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load gallery.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (busy || uploading) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const nextPage = page + 1;
|
||||
const res = await listMeshkeeGallery(meshkeeId, {
|
||||
page: nextPage,
|
||||
pageSize: GALLERY_PAGE_SIZE,
|
||||
});
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((item) => item.id));
|
||||
return [
|
||||
...prev,
|
||||
...res.items.filter((item) => !seen.has(item.id)),
|
||||
];
|
||||
});
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not load gallery.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await listMeshkeeGallery(meshkeeId, {
|
||||
page: 1,
|
||||
pageSize: GALLERY_PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(1);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load gallery.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
async function uploadFiles(fileList: FileList | File[]) {
|
||||
const files = Array.from(fileList).filter(isImageFile);
|
||||
if (!files.length) {
|
||||
setError("Choose image files (JPG, PNG, WebP, or GIF).");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await addMeshkeeGalleryItems(meshkeeId, { files });
|
||||
const uploaded = res.items ?? [];
|
||||
if (uploaded.length) {
|
||||
setItems((prev) => {
|
||||
const seen = new Set(uploaded.map((item) => item.id));
|
||||
return [...uploaded, ...prev.filter((item) => !seen.has(item.id))];
|
||||
});
|
||||
setTotal((value) => value + uploaded.length);
|
||||
} else {
|
||||
await reload(1);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not upload images.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
try {
|
||||
await deleteMeshkeeGalleryItem(meshkeeId, removing.id);
|
||||
setItems((prev) => prev.filter((item) => item.id !== removing.id));
|
||||
setTotal((value) => Math.max(0, value - 1));
|
||||
setRemoving(null);
|
||||
} catch (err) {
|
||||
window.alert(
|
||||
err instanceof Error ? err.message : "Could not remove gallery item.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = page * GALLERY_PAGE_SIZE < total;
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<label
|
||||
className={[
|
||||
styles.dropzone,
|
||||
dragOver ? styles.dropzoneActive : "",
|
||||
uploading ? styles.dropzoneBusy : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node)) {
|
||||
return;
|
||||
}
|
||||
setDragOver(false);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
if (uploading) return;
|
||||
void uploadFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={styles.dropIcon}
|
||||
src="/images/icons/gallery.svg"
|
||||
alt=""
|
||||
width={36}
|
||||
height={36}
|
||||
/>
|
||||
<p className={styles.dropTitle}>
|
||||
{uploading ? en.gallery.uploading : en.gallery.dropTitle}
|
||||
</p>
|
||||
<p className={styles.dropHint}>{en.gallery.dropHint}</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.fileInput}
|
||||
type="file"
|
||||
accept={ACCEPT}
|
||||
multiple
|
||||
disabled={uploading}
|
||||
onChange={(event) => {
|
||||
const files = event.target.files;
|
||||
if (files?.length) void uploadFiles(files);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? <p className={styles.error}>{error}</p> : null}
|
||||
{busy && !items.length ? (
|
||||
<p className={styles.hint}>Loading gallery…</p>
|
||||
) : null}
|
||||
|
||||
{items.length ? (
|
||||
<div className={styles.masonry}>
|
||||
{items.map((item) => {
|
||||
const image = galleryImage(item);
|
||||
const label = item.altText || item.fileName || "image";
|
||||
return (
|
||||
<figure key={item.id} className={styles.item}>
|
||||
{image ? (
|
||||
<img src={image} alt={item.altText ?? ""} loading="lazy" />
|
||||
) : (
|
||||
<div className={styles.fallback} aria-hidden />
|
||||
)}
|
||||
<div className={styles.itemFoot}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeBtn}
|
||||
disabled={removeBusy}
|
||||
onClick={() => setRemoving(item)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<span className="sr-only">{label}</span>
|
||||
</figure>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!items.length && !busy ? (
|
||||
<CatalogEmptyState
|
||||
icon="/images/icons/gallery.svg"
|
||||
title={emptyLabel}
|
||||
lead={en.gallery.emptyLead}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="outline-btn"
|
||||
disabled={busy}
|
||||
onClick={() => void loadMore()}
|
||||
>
|
||||
{busy ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
kicker="Remove"
|
||||
title={`Remove ${removing?.altText || removing?.fileName || "image"}?`}
|
||||
message="This removes the image from the business gallery. This cannot be undone."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
.page {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.listCard {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(520px, 100%);
|
||||
max-height: min(90vh, 720px);
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dialogHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dialogTitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.closeBtn:hover {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-left: 18px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rowChild::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
background: #e4e4e4;
|
||||
}
|
||||
|
||||
.rowCopy {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rowMeta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.rowActions button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rowActions button:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.rowActions button + button {
|
||||
border-left: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.rowActions button:hover {
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.rowActions .danger:hover,
|
||||
.danger:hover {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
padding: 28px 12px;
|
||||
}
|
||||
|
||||
.seoCategory {
|
||||
margin: -4px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
|
||||
import formStyles from "@/components/catalog/CatalogForm.module.css";
|
||||
import SeoMetaFields from "@/components/catalog/SeoMetaFields";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
categoryOptionLabel,
|
||||
createMeshkeeEntityCategory,
|
||||
deleteMeshkeeEntityCategory,
|
||||
listMeshkeeEntityCategories,
|
||||
updateMeshkeeEntityCategory,
|
||||
type CatalogEntityType,
|
||||
type MeshkeeEntityCategory,
|
||||
type UpsertCategoryInput,
|
||||
} from "@/services/meshkee-catalog";
|
||||
import styles from "./ManageCategoriesPage.module.css";
|
||||
|
||||
type Draft = UpsertCategoryInput & { id?: string };
|
||||
|
||||
type SeoDraft = {
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa?: string | null;
|
||||
description?: string | null;
|
||||
parentId: string | null;
|
||||
seoMetaTitle: string;
|
||||
seoMetaDescription: string;
|
||||
};
|
||||
|
||||
function emptyDraft(parentId = ""): Draft {
|
||||
return {
|
||||
parentId: parentId || null,
|
||||
name: "",
|
||||
nameFa: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ManageCategoriesPage({
|
||||
meshkeeId,
|
||||
entityType,
|
||||
title,
|
||||
lead,
|
||||
listHref,
|
||||
listLabel,
|
||||
homeHref = "/",
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
entityType: CatalogEntityType;
|
||||
title: string;
|
||||
lead: string;
|
||||
listHref: string;
|
||||
listLabel: string;
|
||||
homeHref?: string;
|
||||
}) {
|
||||
const [categories, setCategories] = useState<MeshkeeEntityCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
const [seoDraft, setSeoDraft] = useState<SeoDraft | null>(null);
|
||||
const [removing, setRemoving] = useState<MeshkeeEntityCategory | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
|
||||
const roots = useMemo(
|
||||
() => categories.filter((item) => item.parentId == null),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const childrenByParent = useMemo(() => {
|
||||
const map = new Map<string, MeshkeeEntityCategory[]>();
|
||||
for (const item of categories) {
|
||||
if (!item.parentId) continue;
|
||||
const list = map.get(item.parentId) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.parentId, list);
|
||||
}
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const items = await listMeshkeeEntityCategories(meshkeeId, entityType);
|
||||
setCategories(items);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load categories.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [meshkeeId, entityType]);
|
||||
|
||||
async function saveDraft() {
|
||||
if (!draft) return;
|
||||
if (!draft.name.trim()) {
|
||||
setError("Enter an English name.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
if (draft.id) {
|
||||
await updateMeshkeeEntityCategory(meshkeeId, draft.id, draft);
|
||||
} else {
|
||||
await createMeshkeeEntityCategory(meshkeeId, entityType, draft);
|
||||
}
|
||||
setDraft(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not save category.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeo() {
|
||||
if (!seoDraft) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
await updateMeshkeeEntityCategory(meshkeeId, seoDraft.id, {
|
||||
name: seoDraft.name,
|
||||
nameFa: seoDraft.nameFa ?? "",
|
||||
description: seoDraft.description ?? "",
|
||||
parentId: seoDraft.parentId,
|
||||
seoMetaTitle: seoDraft.seoMetaTitle,
|
||||
seoMetaDescription: seoDraft.seoMetaDescription,
|
||||
});
|
||||
setSeoDraft(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save SEO.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
try {
|
||||
await deleteMeshkeeEntityCategory(meshkeeId, removing.id);
|
||||
setRemoving(null);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
window.alert(
|
||||
err instanceof Error ? err.message : "Could not delete category.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(category: MeshkeeEntityCategory) {
|
||||
setDraft({
|
||||
id: category.id,
|
||||
parentId: category.parentId,
|
||||
name: category.name,
|
||||
nameFa: category.nameFa ?? "",
|
||||
description: category.description ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function openSeo(category: MeshkeeEntityCategory) {
|
||||
setSeoDraft({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
nameFa: category.nameFa,
|
||||
description: category.description,
|
||||
parentId: category.parentId,
|
||||
seoMetaTitle: category.seoMetaTitle ?? "",
|
||||
seoMetaDescription: category.seoMetaDescription ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
const parentOptions = categories.filter((item) => item.id !== draft?.id);
|
||||
|
||||
return (
|
||||
<div className={`${formStyles.page} ${styles.page}`}>
|
||||
<header className={formStyles.head}>
|
||||
<div>
|
||||
<nav className={formStyles.breadcrumb} aria-label="Breadcrumb">
|
||||
<Link href={homeHref}>{en.nav.home}</Link>
|
||||
<span className={formStyles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<Link href={listHref}>{listLabel}</Link>
|
||||
<span className={formStyles.breadcrumbSep} aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<span className={formStyles.breadcrumbCurrent}>Categories</span>
|
||||
</nav>
|
||||
<h1 className={formStyles.title}>{title}</h1>
|
||||
<p className={formStyles.lead}>{lead}</p>
|
||||
</div>
|
||||
<div className={formStyles.headActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${formStyles.saveBtn}`}
|
||||
onClick={() => setDraft(emptyDraft())}
|
||||
>
|
||||
Add category
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className={formStyles.errorBox} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? <p className={styles.hint}>Loading categories…</p> : null}
|
||||
|
||||
{!loading && !categories.length ? (
|
||||
<CatalogEmptyState
|
||||
icon="/images/icons/category.svg"
|
||||
title="No categories yet"
|
||||
lead="Create categories to organize this catalog."
|
||||
actionLabel="Add category"
|
||||
onAction={() => setDraft(emptyDraft())}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!loading && categories.length ? (
|
||||
<section className={`${formStyles.card} ${styles.listCard}`}>
|
||||
<h2 className={formStyles.sectionTitle}>Categories</h2>
|
||||
<div className={styles.tree}>
|
||||
{roots.map((root) => (
|
||||
<CategoryBranch
|
||||
key={root.id}
|
||||
category={root}
|
||||
childrenByParent={childrenByParent}
|
||||
onAddChild={(parentId) => setDraft(emptyDraft(parentId))}
|
||||
onEdit={openEdit}
|
||||
onSeo={openSeo}
|
||||
onRemove={setRemoving}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{draft ? (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
onClick={() => {
|
||||
if (!saving) setDraft(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.dialog}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.dialogHead}>
|
||||
<h2 className={styles.dialogTitle}>
|
||||
{draft.id ? "Edit category" : "Add category"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
disabled={saving}
|
||||
onClick={() => setDraft(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className={formStyles.field}>
|
||||
<span>
|
||||
Parent <em className={formStyles.optional}>(optional)</em>
|
||||
</span>
|
||||
<select
|
||||
value={draft.parentId ?? ""}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
parentId: event.target.value || null,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">Top level</option>
|
||||
{parentOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{categoryOptionLabel(item)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={formStyles.field}>
|
||||
<span>Name EN</span>
|
||||
<input
|
||||
value={draft.name}
|
||||
disabled={saving}
|
||||
required
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current ? { ...current, name: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={formStyles.field}>
|
||||
<span>
|
||||
Name FA <em className={formStyles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
className="fa"
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
value={draft.nameFa ?? ""}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, nameFa: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={formStyles.field}>
|
||||
<span>
|
||||
Description <em className={formStyles.optional}>(optional)</em>
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={draft.description ?? ""}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
setDraft((current) =>
|
||||
current
|
||||
? { ...current, description: event.target.value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={formStyles.footerActionsEnd}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${formStyles.ghostBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => setDraft(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${formStyles.saveBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => void saveDraft()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{seoDraft ? (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
onClick={() => {
|
||||
if (!saving) setSeoDraft(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.dialog}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={styles.dialogHead}>
|
||||
<h2 className={styles.dialogTitle}>SEO</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.closeBtn}
|
||||
disabled={saving}
|
||||
onClick={() => setSeoDraft(null)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className={styles.seoCategory} dir="auto">
|
||||
{categoryOptionLabel({
|
||||
id: seoDraft.id,
|
||||
name: seoDraft.name,
|
||||
nameFa: seoDraft.nameFa,
|
||||
parentId: seoDraft.parentId,
|
||||
})}
|
||||
</p>
|
||||
<SeoMetaFields
|
||||
seoMetaTitle={seoDraft.seoMetaTitle}
|
||||
seoMetaDescription={seoDraft.seoMetaDescription}
|
||||
disabled={saving}
|
||||
showHeading={false}
|
||||
onTitleChange={(value) =>
|
||||
setSeoDraft((current) =>
|
||||
current ? { ...current, seoMetaTitle: value } : current,
|
||||
)
|
||||
}
|
||||
onDescriptionChange={(value) =>
|
||||
setSeoDraft((current) =>
|
||||
current
|
||||
? { ...current, seoMetaDescription: value }
|
||||
: current,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={formStyles.footerActionsEnd}>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${formStyles.ghostBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => setSeoDraft(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`outline-btn ${formStyles.saveBtn}`}
|
||||
disabled={saving}
|
||||
onClick={() => void saveSeo()}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(removing)}
|
||||
kicker="Remove"
|
||||
title={`Remove ${removing ? categoryOptionLabel(removing) : "category"}?`}
|
||||
message="This deletes the category and its subcategories from Meshkee. This cannot be undone."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBranch({
|
||||
category,
|
||||
childrenByParent,
|
||||
depth = 0,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onSeo,
|
||||
onRemove,
|
||||
}: {
|
||||
category: MeshkeeEntityCategory;
|
||||
childrenByParent: Map<string, MeshkeeEntityCategory[]>;
|
||||
depth?: number;
|
||||
onAddChild: (parentId: string) => void;
|
||||
onEdit: (category: MeshkeeEntityCategory) => void;
|
||||
onSeo: (category: MeshkeeEntityCategory) => void;
|
||||
onRemove: (category: MeshkeeEntityCategory) => void;
|
||||
}) {
|
||||
const children = childrenByParent.get(category.id) ?? [];
|
||||
|
||||
return (
|
||||
<div className={styles.branch}>
|
||||
<CategoryRow
|
||||
category={category}
|
||||
child={depth > 0}
|
||||
onAddChild={() => onAddChild(category.id)}
|
||||
onEdit={() => onEdit(category)}
|
||||
onSeo={() => onSeo(category)}
|
||||
onRemove={() => onRemove(category)}
|
||||
/>
|
||||
{children.length ? (
|
||||
<div className={styles.children}>
|
||||
{children.map((child) => (
|
||||
<CategoryBranch
|
||||
key={child.id}
|
||||
category={child}
|
||||
childrenByParent={childrenByParent}
|
||||
depth={depth + 1}
|
||||
onAddChild={onAddChild}
|
||||
onEdit={onEdit}
|
||||
onSeo={onSeo}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryRow({
|
||||
category,
|
||||
child = false,
|
||||
onAddChild,
|
||||
onEdit,
|
||||
onSeo,
|
||||
onRemove,
|
||||
}: {
|
||||
category: MeshkeeEntityCategory;
|
||||
child?: boolean;
|
||||
onAddChild: () => void;
|
||||
onEdit: () => void;
|
||||
onSeo: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${styles.row}${child ? ` ${styles.rowChild}` : ""}`}>
|
||||
<div className={styles.rowCopy}>
|
||||
<p className={styles.rowName} dir="auto">
|
||||
{categoryOptionLabel(category)}
|
||||
</p>
|
||||
{category.description ? (
|
||||
<p className={styles.rowMeta}>{category.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.rowActions}>
|
||||
<button type="button" onClick={onAddChild}>
|
||||
Add sub
|
||||
</button>
|
||||
<button type="button" onClick={onEdit}>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={onSeo}>
|
||||
SEO
|
||||
</button>
|
||||
<button type="button" className={styles.danger} onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import CatalogCardControls, {
|
||||
type CatalogControlsMode,
|
||||
} from "@/components/catalog/CatalogCardControls";
|
||||
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
|
||||
import CatalogPagination from "@/components/catalog/CatalogPagination";
|
||||
import {
|
||||
PORTFOLIO_PAGE_SIZE,
|
||||
portfolioImage,
|
||||
portfolioTitle,
|
||||
portfolioTitleEn,
|
||||
portfolioTitleFa,
|
||||
} from "@/components/catalog/utils";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import styles from "@/components/BusinessDetailsPage.module.css";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
deleteMeshkeePortfolio,
|
||||
listMeshkeePortfolios,
|
||||
type MeshkeePortfolio,
|
||||
} from "@/services/meshkee-content";
|
||||
|
||||
export default function PortfoliosPanel({
|
||||
meshkeeId,
|
||||
emptyLabel = en.portfolios.empty,
|
||||
addHref,
|
||||
controlsMode = "edit-remove",
|
||||
onManage,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
emptyLabel?: string;
|
||||
addHref?: string;
|
||||
controlsMode?: CatalogControlsMode | "none";
|
||||
onManage?: () => void;
|
||||
}) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const [items, setItems] = useState<MeshkeePortfolio[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [removing, setRemoving] = useState<MeshkeePortfolio | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await listMeshkeePortfolios(meshkeeId, {
|
||||
page: 1,
|
||||
pageSize: PORTFOLIO_PAGE_SIZE,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(1);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load portfolios.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
function scrollToSection() {
|
||||
const node = sectionRef.current;
|
||||
if (!node) return;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
node.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPage(next: number) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await listMeshkeePortfolios(meshkeeId, {
|
||||
page: next,
|
||||
pageSize: PORTFOLIO_PAGE_SIZE,
|
||||
});
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setPage(res.page);
|
||||
scrollToSection();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
try {
|
||||
await deleteMeshkeePortfolio(meshkeeId, removing.id);
|
||||
setItems((prev) => prev.filter((item) => item.id !== removing.id));
|
||||
setTotal((value) => Math.max(0, value - 1));
|
||||
setRemoving(null);
|
||||
} catch (err) {
|
||||
window.alert(
|
||||
err instanceof Error ? err.message : "Could not remove portfolio.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className={styles.panelHint}>{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className={styles.productsSection}>
|
||||
{busy ? <p className={styles.panelHint}>Loading portfolios…</p> : null}
|
||||
<div className={styles.portfolioGrid}>
|
||||
{items.map((item) => {
|
||||
const image = portfolioImage(item);
|
||||
const title = portfolioTitle(item);
|
||||
const titleEn = portfolioTitleEn(item);
|
||||
const titleFa = portfolioTitleFa(item);
|
||||
return (
|
||||
<article key={item.id} className={styles.portfolioCard}>
|
||||
<div className={styles.portfolioMedia}>
|
||||
{image ? (
|
||||
<img src={image} alt="" />
|
||||
) : (
|
||||
<div className={styles.mediaFallback} aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.portfolioMeta}>
|
||||
<h3 className={styles.portfolioTitleEn}>
|
||||
{titleEn || title}
|
||||
</h3>
|
||||
{titleFa ? (
|
||||
<p className={`fa ${styles.portfolioTitleFa}`} lang="fa">
|
||||
{titleFa}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{controlsMode !== "none" ? (
|
||||
<div className={`tile-hover ${styles.cardFoot} ${styles.cardFootSolo}`}>
|
||||
<CatalogCardControls
|
||||
mode={controlsMode}
|
||||
busy={removeBusy}
|
||||
onManage={onManage}
|
||||
onEdit={() =>
|
||||
window.alert(`Edit portfolio coming soon: ${title}`)
|
||||
}
|
||||
onRemove={() => setRemoving(item)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!items.length && !busy ? (
|
||||
<CatalogEmptyState
|
||||
icon="/images/icons/gear.svg"
|
||||
title={emptyLabel}
|
||||
lead={en.portfolios.emptyLead}
|
||||
actionHref={addHref}
|
||||
actionLabel={en.portfolios.add}
|
||||
/>
|
||||
) : null}
|
||||
<CatalogPagination
|
||||
page={page}
|
||||
total={total}
|
||||
pageSize={PORTFOLIO_PAGE_SIZE}
|
||||
busy={busy}
|
||||
onChange={(next) => void loadPage(next)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={controlsMode === "edit-remove" && Boolean(removing)}
|
||||
kicker="Remove"
|
||||
title={`Remove ${removing ? portfolioTitle(removing) : "portfolio"}?`}
|
||||
message="This deletes the portfolio from Meshkee. This cannot be undone."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import CatalogCardControls, {
|
||||
type CatalogControlsMode,
|
||||
} from "@/components/catalog/CatalogCardControls";
|
||||
import CatalogEmptyState from "@/components/catalog/CatalogEmptyState";
|
||||
import CatalogPagination from "@/components/catalog/CatalogPagination";
|
||||
import {
|
||||
PRODUCT_PAGE_SIZE,
|
||||
productImage,
|
||||
productSubtitle,
|
||||
} from "@/components/catalog/utils";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import styles from "@/components/BusinessDetailsPage.module.css";
|
||||
import { en } from "@/lib/i18n/en";
|
||||
import {
|
||||
collectCategorySubtreeIds,
|
||||
deleteMeshkeeProduct,
|
||||
listMeshkeeBusinessCategories,
|
||||
listMeshkeeProducts,
|
||||
type MeshkeeBusinessCategory,
|
||||
type MeshkeeProduct,
|
||||
} from "@/services/meshkee-content";
|
||||
|
||||
export default function ProductsPanel({
|
||||
meshkeeId,
|
||||
emptyLabel = en.products.empty,
|
||||
addHref,
|
||||
controlsMode = "edit-remove",
|
||||
onManage,
|
||||
}: {
|
||||
meshkeeId: string;
|
||||
emptyLabel?: string;
|
||||
addHref?: string;
|
||||
controlsMode?: CatalogControlsMode;
|
||||
onManage?: () => void;
|
||||
}) {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const [categories, setCategories] = useState<MeshkeeBusinessCategory[]>([]);
|
||||
const [parentCategoryId, setParentCategoryId] = useState<string | null>(null);
|
||||
const [childCategoryId, setChildCategoryId] = useState<string | null>(null);
|
||||
const [products, setProducts] = useState<MeshkeeProduct[]>([]);
|
||||
const [productTotal, setProductTotal] = useState(0);
|
||||
const [productPage, setProductPage] = useState(1);
|
||||
const [productBusy, setProductBusy] = useState(false);
|
||||
const [productCatalog, setProductCatalog] = useState<MeshkeeProduct[] | null>(
|
||||
null,
|
||||
);
|
||||
const [catalogBusy, setCatalogBusy] = useState(false);
|
||||
const [removing, setRemoving] = useState<MeshkeeProduct | null>(null);
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const rootCategories = useMemo(
|
||||
() => categories.filter((item) => item.parentId == null),
|
||||
[categories],
|
||||
);
|
||||
const childCategories = useMemo(() => {
|
||||
if (!parentCategoryId) return [];
|
||||
return categories.filter((item) => item.parentId === parentCategoryId);
|
||||
}, [categories, parentCategoryId]);
|
||||
const activeCategoryId = childCategoryId ?? parentCategoryId;
|
||||
|
||||
const filteredCatalog = useMemo(() => {
|
||||
if (!productCatalog || !activeCategoryId) return productCatalog;
|
||||
const ids = collectCategorySubtreeIds(categories, activeCategoryId);
|
||||
return productCatalog.filter(
|
||||
(item) => item.categoryId && ids.has(item.categoryId),
|
||||
);
|
||||
}, [productCatalog, activeCategoryId, categories]);
|
||||
|
||||
const pagedFiltered = useMemo(() => {
|
||||
if (!filteredCatalog) return null;
|
||||
const start = (productPage - 1) * PRODUCT_PAGE_SIZE;
|
||||
return filteredCatalog.slice(start, start + PRODUCT_PAGE_SIZE);
|
||||
}, [filteredCatalog, productPage]);
|
||||
|
||||
const displayed = activeCategoryId ? (pagedFiltered ?? products) : products;
|
||||
const displayedTotal = activeCategoryId
|
||||
? (filteredCatalog?.length ?? productTotal)
|
||||
: productTotal;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
setProductBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const [productsRes, categoriesRes] = await Promise.all([
|
||||
listMeshkeeProducts(meshkeeId, {
|
||||
page: 1,
|
||||
pageSize: PRODUCT_PAGE_SIZE,
|
||||
}),
|
||||
listMeshkeeBusinessCategories(meshkeeId).catch(
|
||||
() => [] as MeshkeeBusinessCategory[],
|
||||
),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setProducts(productsRes.items);
|
||||
setProductTotal(productsRes.total);
|
||||
setProductPage(1);
|
||||
setCategories(categoriesRes);
|
||||
setProductCatalog(null);
|
||||
setParentCategoryId(null);
|
||||
setChildCategoryId(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load products.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setProductBusy(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [meshkeeId]);
|
||||
|
||||
function scrollToSection() {
|
||||
const node = sectionRef.current;
|
||||
if (!node) return;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
node.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCatalog() {
|
||||
if (productCatalog) return productCatalog;
|
||||
setCatalogBusy(true);
|
||||
try {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
let total = Infinity;
|
||||
const all: MeshkeeProduct[] = [];
|
||||
while (all.length < total) {
|
||||
const res = await listMeshkeeProducts(meshkeeId, { page, pageSize });
|
||||
total = res.total;
|
||||
all.push(...res.items);
|
||||
if (!res.items.length) break;
|
||||
page += 1;
|
||||
}
|
||||
setProductCatalog(all);
|
||||
return all;
|
||||
} finally {
|
||||
setCatalogBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(page: number) {
|
||||
if (activeCategoryId) {
|
||||
setProductPage(page);
|
||||
scrollToSection();
|
||||
return;
|
||||
}
|
||||
setProductBusy(true);
|
||||
try {
|
||||
const res = await listMeshkeeProducts(meshkeeId, {
|
||||
page,
|
||||
pageSize: PRODUCT_PAGE_SIZE,
|
||||
});
|
||||
setProducts(res.items);
|
||||
setProductTotal(res.total);
|
||||
setProductPage(res.page);
|
||||
scrollToSection();
|
||||
} finally {
|
||||
setProductBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectParent(id: string | null) {
|
||||
setParentCategoryId(id);
|
||||
setChildCategoryId(null);
|
||||
setProductPage(1);
|
||||
if (id) await ensureCatalog();
|
||||
scrollToSection();
|
||||
}
|
||||
|
||||
async function selectChild(id: string | null) {
|
||||
setChildCategoryId(id);
|
||||
setProductPage(1);
|
||||
if (id || parentCategoryId) await ensureCatalog();
|
||||
scrollToSection();
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removing) return;
|
||||
setRemoveBusy(true);
|
||||
try {
|
||||
await deleteMeshkeeProduct(meshkeeId, removing.id);
|
||||
setProducts((prev) => prev.filter((item) => item.id !== removing.id));
|
||||
setProductCatalog((prev) =>
|
||||
prev ? prev.filter((item) => item.id !== removing.id) : prev,
|
||||
);
|
||||
setProductTotal((total) => Math.max(0, total - 1));
|
||||
setRemoving(null);
|
||||
} catch (err) {
|
||||
window.alert(
|
||||
err instanceof Error ? err.message : "Could not remove product.",
|
||||
);
|
||||
} finally {
|
||||
setRemoveBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <p className={styles.panelHint}>{error}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={sectionRef} className={styles.productsSection}>
|
||||
{productTotal > 0 ? (
|
||||
<div className={styles.pills}>
|
||||
<button
|
||||
type="button"
|
||||
className={!parentCategoryId ? styles.pillActive : styles.pill}
|
||||
onClick={() => void selectParent(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{rootCategories.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
parentCategoryId === item.id ? styles.pillActive : styles.pill
|
||||
}
|
||||
onClick={() => void selectParent(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{productTotal > 0 && childCategories.length ? (
|
||||
<div className={styles.childPills}>
|
||||
<button
|
||||
type="button"
|
||||
className={!childCategoryId ? styles.pillActive : styles.pill}
|
||||
onClick={() => void selectChild(null)}
|
||||
>
|
||||
All in category
|
||||
</button>
|
||||
{childCategories.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
childCategoryId === item.id ? styles.pillActive : styles.pill
|
||||
}
|
||||
onClick={() => void selectChild(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalogBusy || productBusy ? (
|
||||
<p className={styles.panelHint}>Loading products…</p>
|
||||
) : null}
|
||||
|
||||
<div className={styles.productGrid}>
|
||||
{displayed.map((product) => {
|
||||
const image = productImage(product);
|
||||
const subtitle = productSubtitle(product);
|
||||
return (
|
||||
<article key={product.id} className={styles.card}>
|
||||
<div className={styles.cardMedia}>
|
||||
{image ? (
|
||||
<img src={image} alt="" />
|
||||
) : (
|
||||
<div className={styles.mediaFallback} aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`tile-hover ${styles.cardFoot}${
|
||||
subtitle ? "" : ` ${styles.cardFootSolo}`
|
||||
}`}
|
||||
>
|
||||
<div className={styles.cardBody}>
|
||||
<h3 dir="auto">{product.title}</h3>
|
||||
{subtitle ? <p>{subtitle}</p> : null}
|
||||
</div>
|
||||
<CatalogCardControls
|
||||
mode={controlsMode}
|
||||
busy={removeBusy}
|
||||
onManage={onManage}
|
||||
onEdit={() =>
|
||||
window.alert(`Edit product coming soon: ${product.title}`)
|
||||
}
|
||||
onRemove={() => setRemoving(product)}
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!displayed.length && !catalogBusy && !productBusy ? (
|
||||
<CatalogEmptyState
|
||||
icon="/images/icons/category.svg"
|
||||
title={
|
||||
activeCategoryId ? en.products.emptyFiltered : emptyLabel
|
||||
}
|
||||
lead={
|
||||
activeCategoryId
|
||||
? en.products.emptyFilteredLead
|
||||
: en.products.emptyLead
|
||||
}
|
||||
actionHref={activeCategoryId ? undefined : addHref}
|
||||
actionLabel={activeCategoryId ? undefined : en.products.add}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<CatalogPagination
|
||||
page={productPage}
|
||||
total={displayedTotal}
|
||||
pageSize={PRODUCT_PAGE_SIZE}
|
||||
busy={productBusy || catalogBusy}
|
||||
onChange={(page) => void loadPage(page)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={controlsMode === "edit-remove" && Boolean(removing)}
|
||||
kicker="Remove"
|
||||
title={`Remove ${removing?.title ?? "product"}?`}
|
||||
message="This deletes the product from Meshkee. This cannot be undone."
|
||||
confirmLabel="Remove"
|
||||
cancelLabel="Keep"
|
||||
danger
|
||||
busy={removeBusy}
|
||||
onClose={() => {
|
||||
if (!removeBusy) setRemoving(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
void confirmRemove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.blockWithRule {
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #efefef;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-black);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-soft);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import formStyles from "@/components/catalog/CatalogForm.module.css";
|
||||
import styles from "./SeoMetaFields.module.css";
|
||||
|
||||
const TITLE_MAX = 70;
|
||||
const DESCRIPTION_MAX = 160;
|
||||
|
||||
/** Optional SEO overrides — same control used by Meshkee category/content forms. */
|
||||
export default function SeoMetaFields({
|
||||
seoMetaTitle,
|
||||
seoMetaDescription,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
disabled = false,
|
||||
showHeading = true,
|
||||
}: {
|
||||
seoMetaTitle: string;
|
||||
seoMetaDescription: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
showHeading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.block}>
|
||||
{showHeading ? <h3 className={styles.heading}>SEO</h3> : null}
|
||||
<p className={styles.hint}>
|
||||
Optional overrides for search results. Leave blank to use the category
|
||||
name and description.
|
||||
</p>
|
||||
<label className={formStyles.field}>
|
||||
<span>
|
||||
SEO title <em className={formStyles.optional}>(optional)</em>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={seoMetaTitle}
|
||||
disabled={disabled}
|
||||
maxLength={TITLE_MAX}
|
||||
placeholder="Custom title for search engines"
|
||||
onChange={(event) =>
|
||||
onTitleChange(event.target.value.slice(0, TITLE_MAX))
|
||||
}
|
||||
/>
|
||||
<span className={styles.counter}>
|
||||
{seoMetaTitle.length} / {TITLE_MAX}
|
||||
</span>
|
||||
</label>
|
||||
<label className={formStyles.field}>
|
||||
<span>
|
||||
SEO description <em className={formStyles.optional}>(optional)</em>
|
||||
</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={seoMetaDescription}
|
||||
disabled={disabled}
|
||||
maxLength={DESCRIPTION_MAX}
|
||||
placeholder="Custom description for search engines"
|
||||
onChange={(event) =>
|
||||
onDescriptionChange(event.target.value.slice(0, DESCRIPTION_MAX))
|
||||
}
|
||||
/>
|
||||
<span className={styles.counter}>
|
||||
{seoMetaDescription.length} / {DESCRIPTION_MAX}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
MeshkeeBlog,
|
||||
MeshkeeMediaItem,
|
||||
MeshkeePortfolio,
|
||||
MeshkeeProduct,
|
||||
} from "@/services/meshkee-content";
|
||||
|
||||
export function toPlainText(value: string) {
|
||||
if (!value.includes("<")) return value;
|
||||
return value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function excerpt(value: string, max = 160) {
|
||||
const plain = toPlainText(value).replace(/\s+/g, " ").trim();
|
||||
if (plain.length <= max) return plain;
|
||||
return `${plain.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function productImage(product: MeshkeeProduct) {
|
||||
return product.thumbnailUrl || product.thumbnail || product.image || "";
|
||||
}
|
||||
|
||||
export function productSubtitle(product: MeshkeeProduct) {
|
||||
const raw = product.categoryName?.trim() ?? "";
|
||||
if (!raw) return "";
|
||||
if (raw.toLowerCase() === product.title.trim().toLowerCase()) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function blogImage(post: MeshkeeBlog) {
|
||||
return post.titleImageUrl || "";
|
||||
}
|
||||
|
||||
export function portfolioImage(item: MeshkeePortfolio) {
|
||||
return item.titleImageUrl || item.coverUrl || item.image || "";
|
||||
}
|
||||
|
||||
export function galleryImage(item: MeshkeeMediaItem) {
|
||||
return item.publicUrl || "";
|
||||
}
|
||||
|
||||
export function portfolioTitleEn(item: MeshkeePortfolio) {
|
||||
return (
|
||||
item.titleEn?.trim() ||
|
||||
item.nameEn?.trim() ||
|
||||
item.title?.trim() ||
|
||||
item.name?.trim() ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
export function portfolioTitleFa(item: MeshkeePortfolio) {
|
||||
return item.titleFa?.trim() || item.nameFa?.trim() || "";
|
||||
}
|
||||
|
||||
export function portfolioTitle(item: MeshkeePortfolio) {
|
||||
const candidates = [
|
||||
item.titleEn,
|
||||
item.titleFa,
|
||||
item.title,
|
||||
item.nameEn,
|
||||
item.nameFa,
|
||||
item.name,
|
||||
];
|
||||
for (const value of candidates) {
|
||||
const text = value?.trim();
|
||||
if (text) return text;
|
||||
}
|
||||
const slug = item.slug?.trim();
|
||||
if (slug) {
|
||||
return slug
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
return "Untitled portfolio";
|
||||
}
|
||||
|
||||
export function buildPageItems(
|
||||
page: number,
|
||||
pageCount: number,
|
||||
): (number | "…")[] {
|
||||
if (pageCount <= 7) {
|
||||
return Array.from({ length: pageCount }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const items: (number | "…")[] = [1];
|
||||
const start = Math.max(2, page - 1);
|
||||
const end = Math.min(pageCount - 1, page + 1);
|
||||
|
||||
if (start > 2) items.push("…");
|
||||
for (let n = start; n <= end; n += 1) items.push(n);
|
||||
if (end < pageCount - 1) items.push("…");
|
||||
items.push(pageCount);
|
||||
return items;
|
||||
}
|
||||
|
||||
export const PRODUCT_PAGE_SIZE = 12;
|
||||
export const BLOG_PAGE_SIZE = 9;
|
||||
export const GALLERY_PAGE_SIZE = 16;
|
||||
export const PORTFOLIO_PAGE_SIZE = 12;
|
||||
export const CONTENT_PAGE_SIZE = 12;
|
||||
Reference in New Issue
Block a user