Add customer partnership form and admin requests list with detail modal.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f192085c1f
commit
e687e4502e
@@ -1,20 +1,337 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
import Modal from "@/components/ui/Modal";
|
||||
import Pagination from "@/components/ui/Pagination";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type PartnershipCategory = {
|
||||
id: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
};
|
||||
|
||||
type PartnershipItem = {
|
||||
id: string;
|
||||
businessNameFa: string;
|
||||
businessNameEn: string;
|
||||
cellNumber: string;
|
||||
landline: string | null;
|
||||
instagram: string | null;
|
||||
city: string;
|
||||
province: string;
|
||||
address: string;
|
||||
postalCode: string;
|
||||
description: string;
|
||||
imageUrls: string[];
|
||||
status: string;
|
||||
createdAt: string;
|
||||
categories: PartnershipCategory[];
|
||||
user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
cellNumber: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ListResponse = {
|
||||
items: PartnershipItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: "در انتظار",
|
||||
reviewed: "بررسیشده",
|
||||
accepted: "پذیرفته",
|
||||
rejected: "رد شده",
|
||||
};
|
||||
|
||||
function formatDate(value: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("fa-IR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-1 border-b border-line/70 py-2.5 last:border-b-0 sm:grid-cols-[8rem_1fr] sm:gap-3">
|
||||
<dt className="text-xs text-muted">{label}</dt>
|
||||
<dd className="text-sm text-ink">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebsiteRequestsPage() {
|
||||
const [items, setItems] = useState<PartnershipItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<PartnershipItem | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async (nextPage: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api<ListResponse>(
|
||||
`/partnership-requests?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||
);
|
||||
setItems(data.items ?? []);
|
||||
setPage(data.page ?? nextPage);
|
||||
setTotalPages(data.totalPages ?? 1);
|
||||
setTotalItems(data.total ?? 0);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "بارگذاری درخواستها ناموفق بود",
|
||||
);
|
||||
setItems([]);
|
||||
setTotalItems(0);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(page);
|
||||
}, [load, page]);
|
||||
|
||||
async function openDetail(item: PartnershipItem) {
|
||||
setSelected(item);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const fresh = await api<PartnershipItem>(
|
||||
`/partnership-requests/${item.id}`,
|
||||
);
|
||||
setSelected(fresh);
|
||||
} catch {
|
||||
/* keep list payload if detail fetch fails */
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ClothesShell
|
||||
eyebrow="Requests"
|
||||
title="درخواستها"
|
||||
description="پیامها و درخواستهای ارسالشده از وبسایت."
|
||||
description="درخواستهای همکاری ارسالشده از وبسایت."
|
||||
breadcrumbs={[
|
||||
{ label: "داشبورد", href: "/" },
|
||||
{ label: "وبسایت", href: "/website" },
|
||||
{ label: "درخواستها" },
|
||||
]}
|
||||
>
|
||||
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
|
||||
بهزودی درخواستهای کاربران اینجا نمایش داده میشود.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
|
||||
در حال بارگذاری…
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
|
||||
هنوز درخواستی ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border border-line bg-surface">
|
||||
<div className="hidden grid-cols-[1.4fr_1fr_1fr_7rem_8rem] gap-3 border-b border-line bg-[#f7f7f5] px-4 py-2.5 text-xs text-muted sm:grid">
|
||||
<span>کسبوکار</span>
|
||||
<span>متقاضی</span>
|
||||
<span>شهر</span>
|
||||
<span>وضعیت</span>
|
||||
<span>تاریخ</span>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-line">
|
||||
{items.map((item, i) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDetail(item)}
|
||||
className="grid w-full grid-cols-1 gap-2 px-4 py-3.5 text-right transition-colors hover:bg-[#fafafa] sm:grid-cols-[1.4fr_1fr_1fr_7rem_8rem] sm:items-center sm:gap-3 dash-enter"
|
||||
style={{ animationDelay: `${50 + i * 40}ms` }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-ink">
|
||||
{item.businessNameFa}
|
||||
</p>
|
||||
{item.businessNameEn ? (
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-0.5 truncate font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{item.businessNameEn}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-ink/80">
|
||||
{item.user.firstName} {item.user.lastName}
|
||||
</p>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-0.5 truncate font-montserrat text-[10px] text-muted"
|
||||
>
|
||||
{item.cellNumber}
|
||||
</p>
|
||||
</div>
|
||||
<p className="truncate text-sm text-ink/75">
|
||||
{item.province}، {item.city}
|
||||
</p>
|
||||
<span className="inline-flex w-fit rounded bg-ink/5 px-2 py-0.5 text-[11px] text-ink/70">
|
||||
{STATUS_LABELS[item.status] ?? item.status}
|
||||
</span>
|
||||
<time
|
||||
dateTime={item.createdAt}
|
||||
className="text-xs text-muted"
|
||||
>
|
||||
{formatDate(item.createdAt)}
|
||||
</time>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={Boolean(selected)}
|
||||
title={selected?.businessNameFa || "جزئیات درخواست"}
|
||||
subtitle={selected?.businessNameEn || "Partnership request"}
|
||||
onClose={() => setSelected(null)}
|
||||
wide="xl"
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn-ghost"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{selected ? (
|
||||
detailLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted">
|
||||
در حال بارگذاری…
|
||||
</p>
|
||||
) : (
|
||||
<dl className="space-y-0" dir="rtl">
|
||||
<DetailRow label="وضعیت">
|
||||
{STATUS_LABELS[selected.status] ?? selected.status}
|
||||
</DetailRow>
|
||||
<DetailRow label="تاریخ">
|
||||
{formatDate(selected.createdAt)}
|
||||
</DetailRow>
|
||||
<DetailRow label="متقاضی">
|
||||
{selected.user.firstName} {selected.user.lastName}
|
||||
</DetailRow>
|
||||
<DetailRow label="موبایل">
|
||||
<span dir="ltr" className="font-montserrat">
|
||||
{selected.cellNumber}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="تلفن ثابت">
|
||||
{selected.landline ? (
|
||||
<span dir="ltr" className="font-montserrat">
|
||||
{selected.landline}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="اینستاگرام">
|
||||
{selected.instagram ? (
|
||||
<span dir="ltr">{selected.instagram}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label="دستهبندیها">
|
||||
{selected.categories.length
|
||||
? selected.categories.map((c) => c.nameFa).join("، ")
|
||||
: "—"}
|
||||
</DetailRow>
|
||||
<DetailRow label="آدرس">
|
||||
{selected.province}، {selected.city}
|
||||
<br />
|
||||
{selected.address}
|
||||
{selected.postalCode ? (
|
||||
<>
|
||||
<br />
|
||||
<span dir="ltr" className="font-montserrat text-xs text-muted">
|
||||
{selected.postalCode}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</DetailRow>
|
||||
<DetailRow label="توضیحات">
|
||||
<p className="whitespace-pre-wrap leading-7">
|
||||
{selected.description}
|
||||
</p>
|
||||
</DetailRow>
|
||||
{selected.imageUrls.length > 0 ? (
|
||||
<DetailRow label="تصاویر">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.imageUrls.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block overflow-hidden rounded border border-line bg-[#f7f7f5]"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className="h-28 w-auto max-w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</DetailRow>
|
||||
) : null}
|
||||
</dl>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
</ClothesShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user