Add customer partnership form and admin requests list with detail modal.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 21:51:44 +03:30
co-authored by Cursor
parent f192085c1f
commit e687e4502e
9 changed files with 1101 additions and 12 deletions
+31 -5
View File
@@ -30,6 +30,13 @@ export default function CustomerHomePage() {
description: "اطلاعات حساب و آدرس‌ها",
icon: <ProfileIcon />,
},
{
href: "/account/partners",
title: "شروع همکاری",
titleEn: "Partnership",
description: "درخواست همکاری به‌عنوان تولیدکننده",
icon: <PartnersIcon />,
},
...(showAdmin
? [
{
@@ -104,13 +111,13 @@ function OrdersIcon() {
<path
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
stroke="currentColor"
strokeWidth="1.15"
strokeWidth="0.85"
strokeLinejoin="round"
/>
<path
d="M9 7a3 3 0 0 1 6 0"
stroke="currentColor"
strokeWidth="1.15"
strokeWidth="0.85"
strokeLinecap="round"
/>
</svg>
@@ -120,24 +127,43 @@ function OrdersIcon() {
function ProfileIcon() {
return (
<svg {...iconProps()}>
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="1.15" />
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="0.85" />
<path
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
stroke="currentColor"
strokeWidth="1.15"
strokeWidth="0.85"
strokeLinecap="round"
/>
</svg>
);
}
function PartnersIcon() {
return (
<svg {...iconProps()}>
<path
d="M8 12h8M12 8v8"
stroke="currentColor"
strokeWidth="0.85"
strokeLinecap="round"
/>
<path
d="M7 5.5h10a1.5 1.5 0 0 1 1.5 1.5v10a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 17V7A1.5 1.5 0 0 1 7 5.5Z"
stroke="currentColor"
strokeWidth="0.85"
strokeLinejoin="round"
/>
</svg>
);
}
function AdminIcon() {
return (
<svg {...iconProps()}>
<path
d="M4.5 10.5 12 4.5l7.5 6V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5.5a1 1 0 0 1-1-1v-8.5Z"
stroke="currentColor"
strokeWidth="1.15"
strokeWidth="0.85"
strokeLinejoin="round"
/>
</svg>
+410
View File
@@ -0,0 +1,410 @@
"use client";
import { FormEvent, useEffect, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import SearchableMultiSelect from "@/components/ui/SearchableMultiSelect";
import { ApiError } from "@/lib/api";
import { fetchProfile } from "@/lib/profile-api";
import {
createPartnershipRequest,
fetchPublicCategoryOptions,
uploadPartnershipImage,
type FlatCategoryOption,
} from "@/lib/partnership-api";
const PROVINCES = [
"تهران",
"اصفهان",
"فارس",
"خراسان رضوی",
"آذربایجان شرقی",
"آذربایجان غربی",
"گیلان",
"مازندران",
"خوزستان",
"کرمان",
"یزد",
"قم",
"البرز",
"همدان",
"کردستان",
"کرمانشاه",
"هرمزگان",
"بوشهر",
"سیستان و بلوچستان",
"گلستان",
"قزوین",
"زنجان",
"اردبیل",
"لرستان",
"مرکزی",
"سمنان",
"چهارمحال و بختیاری",
"کهگیلویه و بویراحمد",
"ایلام",
"خراسان شمالی",
"خراسان جنوبی",
];
const MAX_IMAGES = 6;
export default function AccountPartnersPage() {
const [categories, setCategories] = useState<FlatCategoryOption[]>([]);
const [loading, setLoading] = useState(true);
const [cellNumber, setCellNumber] = useState("");
const [businessNameFa, setBusinessNameFa] = useState("");
const [businessNameEn, setBusinessNameEn] = useState("");
const [selectedCategories, setSelectedCategories] = useState<number[]>([]);
const [description, setDescription] = useState("");
const [imageUrls, setImageUrls] = useState<string[]>([]);
const [landline, setLandline] = useState("");
const [instagram, setInstagram] = useState("");
const [city, setCity] = useState("");
const [province, setProvince] = useState("");
const [address, setAddress] = useState("");
const [postalCode, setPostalCode] = useState("");
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
setLoading(true);
try {
const [profile, cats] = await Promise.all([
fetchProfile(),
fetchPublicCategoryOptions(),
]);
if (cancelled) return;
setCellNumber(profile.cellNumber || "");
if (profile.instagram) setInstagram(profile.instagram);
setCategories(cats);
} catch (err) {
if (!cancelled) {
setError(
err instanceof ApiError
? err.message
: "بارگذاری فرم ناموفق بود",
);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleImagesSelected(
event: React.ChangeEvent<HTMLInputElement>,
) {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
if (!files.length) return;
const remaining = MAX_IMAGES - imageUrls.length;
if (remaining <= 0) {
setError(`حداکثر ${MAX_IMAGES} تصویر می‌توانید بارگذاری کنید`);
return;
}
setError(null);
setUploading(true);
try {
const uploaded: string[] = [];
for (const file of files.slice(0, remaining)) {
const result = await uploadPartnershipImage(file);
uploaded.push(result.url);
}
setImageUrls((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(
err instanceof ApiError ? err.message : "بارگذاری تصویر ناموفق بود",
);
} finally {
setUploading(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
if (!selectedCategories.length) {
setError("حداقل یک دسته‌بندی فعالیت انتخاب کنید");
return;
}
setSaving(true);
try {
await createPartnershipRequest({
businessNameFa: businessNameFa.trim(),
businessNameEn: businessNameEn.trim(),
categoryIds: selectedCategories,
description: description.trim(),
imageUrls,
landline: landline.trim() || undefined,
instagram: instagram.trim() || undefined,
city: city.trim(),
province,
address: address.trim(),
postalCode: postalCode.trim(),
});
setSent(true);
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ارسال درخواست ناموفق بود",
);
} finally {
setSaving(false);
}
}
return (
<ClothesShell
eyebrow="Partnership"
title="شروع همکاری"
description="اگر تولیدکننده‌اید، اطلاعات کسب‌وکار خود را ارسال کنید تا امکان همکاری را بررسی کنیم."
breadcrumbs={[
{ label: "حساب کاربری", href: "/account" },
{ label: "شروع همکاری" },
]}
>
{loading ? (
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
در حال بارگذاری…
</p>
) : sent ? (
<div className="rounded-lg border border-line bg-surface px-5 py-10 text-center">
<p className="font-doran text-xl text-ink">درخواست شما ثبت شد</p>
<p
dir="ltr"
className="mt-1 font-display text-[10px] uppercase tracking-[0.16em] text-muted"
>
Partnership request received
</p>
<p className="mt-3 text-sm leading-7 text-muted">
تیم مانا به‌زودی درخواست همکاری شما را بررسی می‌کند.
</p>
</div>
) : (
<form
onSubmit={(e) => void handleSubmit(e)}
className="dash-enter space-y-3"
dir="rtl"
>
{error ? (
<p className="rounded-lg border border-line bg-surface px-4 py-3 text-sm text-red-600">
{error}
</p>
) : null}
<section className="rounded-lg border border-line bg-surface p-4 sm:p-5">
<h2 className="mb-4 font-doran text-lg text-ink">اطلاعات کسب‌وکار</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="clothes-field sm:col-span-6">
<label htmlFor="partner-name-fa">نام کسب‌وکار (فارسی)</label>
<input
id="partner-name-fa"
required
value={businessNameFa}
onChange={(e) => setBusinessNameFa(e.target.value)}
placeholder="نام برند یا فروشگاه"
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="partner-name-en">Business name (EN)</label>
<input
id="partner-name-en"
dir="ltr"
value={businessNameEn}
onChange={(e) => setBusinessNameEn(e.target.value)}
placeholder="Brand or shop name"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label>دسته‌بندی فعالیت</label>
<SearchableMultiSelect
options={categories}
value={selectedCategories}
onChange={setSelectedCategories}
placeholder="انتخاب دسته‌بندی‌ها"
searchPlaceholder="جستجوی دسته‌بندی..."
/>
<span className="text-[0.65rem] text-muted">
می‌توانید چند مورد انتخاب کنید
</span>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="partner-desc">توضیحات</label>
<textarea
id="partner-desc"
required
rows={5}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="درباره محصولات، ظرفیت تولید و نحوه همکاری بنویسید..."
/>
</div>
<div className="sm:col-span-12">
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-sm text-ink">تصاویر</span>
<span className="text-xs text-muted">
{imageUrls.length}/{MAX_IMAGES}
</span>
</div>
{imageUrls.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-2">
{imageUrls.map((url) => (
<div
key={url}
className="relative 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"
/>
<button
type="button"
onClick={() =>
setImageUrls((prev) => prev.filter((u) => u !== url))
}
className="absolute start-1 top-1 rounded bg-ink/80 px-1.5 py-0.5 text-[10px] text-white"
>
حذف
</button>
</div>
))}
</div>
) : null}
{imageUrls.length < MAX_IMAGES ? (
<label className="inline-flex cursor-pointer items-center rounded border border-dashed border-line bg-white px-3 py-2.5 text-sm text-ink/80 transition-colors hover:border-ink hover:text-ink">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="sr-only"
disabled={uploading}
onChange={handleImagesSelected}
/>
{uploading ? "در حال بارگذاری…" : "افزودن تصویر"}
</label>
) : null}
</div>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4 sm:p-5">
<h2 className="mb-4 font-doran text-lg text-ink">اطلاعات تماس</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="clothes-field sm:col-span-4">
<label htmlFor="partner-cell">شماره موبایل</label>
<input
id="partner-cell"
dir="ltr"
className="!text-end font-montserrat profile-input-locked"
value={cellNumber}
readOnly
aria-readonly="true"
/>
<span className="text-[0.65rem] text-muted">قابل تغییر نیست</span>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor="partner-landline">تلفن ثابت</label>
<input
id="partner-landline"
dir="ltr"
value={landline}
onChange={(e) => setLandline(e.target.value)}
placeholder="021xxxxxxx"
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor="partner-ig">اینستاگرام</label>
<input
id="partner-ig"
dir="ltr"
value={instagram}
onChange={(e) => setInstagram(e.target.value)}
placeholder="@username"
/>
</div>
</div>
</section>
<section className="rounded-lg border border-line bg-surface p-4 sm:p-5">
<h2 className="mb-4 font-doran text-lg text-ink">آدرس</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="clothes-field sm:col-span-4">
<label htmlFor="partner-province">استان</label>
<select
id="partner-province"
required
value={province}
onChange={(e) => setProvince(e.target.value)}
>
<option value="">انتخاب استان</option>
{PROVINCES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor="partner-city">شهر</label>
<input
id="partner-city"
required
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="شهر"
/>
</div>
<div className="clothes-field sm:col-span-4">
<label htmlFor="partner-postal">کد پستی</label>
<input
id="partner-postal"
dir="ltr"
required
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="xxxxxxxxxx"
/>
</div>
<div className="clothes-field sm:col-span-12">
<label htmlFor="partner-address">آدرس</label>
<textarea
id="partner-address"
required
rows={3}
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="آدرس کامل"
/>
</div>
</div>
</section>
<div className="flex justify-end pt-1">
<button
type="submit"
disabled={saving || uploading}
className="inline-flex h-11 min-w-[12rem] items-center justify-center rounded-[5px] bg-ink px-5 text-sm text-white transition-opacity hover:opacity-90 disabled:opacity-50"
>
{saving ? "..." : "ارسال درخواست همکاری"}
</button>
</div>
</form>
)}
</ClothesShell>
);
}
+321 -4
View File
@@ -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>
);
}