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
@@ -30,6 +30,13 @@ export default function CustomerHomePage() {
|
|||||||
description: "اطلاعات حساب و آدرسها",
|
description: "اطلاعات حساب و آدرسها",
|
||||||
icon: <ProfileIcon />,
|
icon: <ProfileIcon />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/account/partners",
|
||||||
|
title: "شروع همکاری",
|
||||||
|
titleEn: "Partnership",
|
||||||
|
description: "درخواست همکاری بهعنوان تولیدکننده",
|
||||||
|
icon: <PartnersIcon />,
|
||||||
|
},
|
||||||
...(showAdmin
|
...(showAdmin
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -104,13 +111,13 @@ function OrdersIcon() {
|
|||||||
<path
|
<path
|
||||||
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
|
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.15"
|
strokeWidth="0.85"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M9 7a3 3 0 0 1 6 0"
|
d="M9 7a3 3 0 0 1 6 0"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.15"
|
strokeWidth="0.85"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -120,24 +127,43 @@ function OrdersIcon() {
|
|||||||
function ProfileIcon() {
|
function ProfileIcon() {
|
||||||
return (
|
return (
|
||||||
<svg {...iconProps()}>
|
<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
|
<path
|
||||||
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
|
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.15"
|
strokeWidth="0.85"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</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() {
|
function AdminIcon() {
|
||||||
return (
|
return (
|
||||||
<svg {...iconProps()}>
|
<svg {...iconProps()}>
|
||||||
<path
|
<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"
|
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"
|
stroke="currentColor"
|
||||||
strokeWidth="1.15"
|
strokeWidth="0.85"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
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() {
|
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 (
|
return (
|
||||||
<ClothesShell
|
<ClothesShell
|
||||||
eyebrow="Requests"
|
eyebrow="Requests"
|
||||||
title="درخواستها"
|
title="درخواستها"
|
||||||
description="پیامها و درخواستهای ارسالشده از وبسایت."
|
description="درخواستهای همکاری ارسالشده از وبسایت."
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "داشبورد", href: "/" },
|
{ label: "داشبورد", href: "/" },
|
||||||
{ label: "وبسایت", href: "/website" },
|
{ label: "وبسایت", href: "/website" },
|
||||||
{ label: "درخواستها" },
|
{ label: "درخواستها" },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
|
{error ? (
|
||||||
بهزودی درخواستهای کاربران اینجا نمایش داده میشود.
|
<p className="mb-2 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-red-600">
|
||||||
</p>
|
{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>
|
</ClothesShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
readReturnToParam,
|
||||||
|
redirectWithAuthHandoff,
|
||||||
|
} from "@/lib/auth-handoff";
|
||||||
|
|
||||||
const PUBLIC_PATHS = ["/login"];
|
const PUBLIC_PATHS = ["/login"];
|
||||||
const CUSTOMER_HOME = "/account";
|
const CUSTOMER_HOME = "/account";
|
||||||
@@ -39,7 +43,10 @@ export default function RequireAuth({
|
|||||||
if (loading) return;
|
if (loading) return;
|
||||||
// Already signed in — leave the login page.
|
// Already signed in — leave the login page.
|
||||||
if (isPublic) {
|
if (isPublic) {
|
||||||
if (user) router.replace(CUSTOMER_HOME);
|
if (user) {
|
||||||
|
if (redirectWithAuthHandoff(readReturnToParam())) return;
|
||||||
|
router.replace(CUSTOMER_HOME);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
import ManaLogo from "@/components/ui/ManaLogo";
|
import ManaLogo from "@/components/ui/ManaLogo";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/auth";
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
readReturnToParam,
|
||||||
|
redirectWithAuthHandoff,
|
||||||
|
} from "@/lib/auth-handoff";
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
type LoginMethod = "password" | "otp";
|
type LoginMethod = "password" | "otp";
|
||||||
@@ -35,6 +39,7 @@ function AuthFormInner() {
|
|||||||
const isLogin = mode === "login";
|
const isLogin = mode === "login";
|
||||||
|
|
||||||
function afterAuth(_role: string) {
|
function afterAuth(_role: string) {
|
||||||
|
if (redirectWithAuthHandoff(readReturnToParam())) return;
|
||||||
router.replace("/account");
|
router.replace("/account");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ const ADMIN_LINKS: NavLink[] = [
|
|||||||
const CUSTOMER_LINKS: NavLink[] = [
|
const CUSTOMER_LINKS: NavLink[] = [
|
||||||
{ href: "/account/orders", label: "سفارشهای من" },
|
{ href: "/account/orders", label: "سفارشهای من" },
|
||||||
{ href: "/account/profile", label: "پروفایل من" },
|
{ href: "/account/profile", label: "پروفایل من" },
|
||||||
|
{ href: "/account/partners", label: "شروع همکاری" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SITE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ||
|
||||||
|
"http://mana-collection.com:3000";
|
||||||
|
|
||||||
function canAccessAdmin(role: string | undefined) {
|
function canAccessAdmin(role: string | undefined) {
|
||||||
return role === "STORE_ADMIN" || role === "SUPER_ADMIN";
|
return role === "STORE_ADMIN" || role === "SUPER_ADMIN";
|
||||||
}
|
}
|
||||||
@@ -44,7 +49,6 @@ export default function DashboardHeader({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const onCustomerArea = isCustomerPath(pathname);
|
const onCustomerArea = isCustomerPath(pathname);
|
||||||
const isAdmin = canAccessAdmin(user?.role);
|
const isAdmin = canAccessAdmin(user?.role);
|
||||||
const homeHref = onCustomerArea ? "/account" : "/";
|
|
||||||
const profileHref = onCustomerArea ? "/account/profile" : "/profile";
|
const profileHref = onCustomerArea ? "/account/profile" : "/profile";
|
||||||
const navLinks = onCustomerArea
|
const navLinks = onCustomerArea
|
||||||
? [
|
? [
|
||||||
@@ -100,7 +104,7 @@ export default function DashboardHeader({
|
|||||||
<MenuIcon open={menuOpen} />
|
<MenuIcon open={menuOpen} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ManaLogo href={homeHref} height={31} variant="black" />
|
<ManaLogo href={SITE_URL} height={31} variant="black" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={userRef} className="relative">
|
<div ref={userRef} className="relative">
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type SearchableOption = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
depth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchableMultiSelectProps = {
|
||||||
|
options: SearchableOption[];
|
||||||
|
value: number[];
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
emptyLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SearchableMultiSelect({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "انتخاب کنید",
|
||||||
|
searchPlaceholder = "جستجو...",
|
||||||
|
emptyLabel = "موردی یافت نشد",
|
||||||
|
disabled = false,
|
||||||
|
}: SearchableMultiSelectProps) {
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const selected = useMemo(() => {
|
||||||
|
const map = new Map(options.map((o) => [o.id, o]));
|
||||||
|
return value.map((id) => map.get(id)).filter(Boolean) as SearchableOption[];
|
||||||
|
}, [options, value]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return options;
|
||||||
|
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||||
|
}, [options, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
if (!rootRef.current?.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDoc);
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
if (value.includes(id)) onChange(value.filter((x) => x !== id));
|
||||||
|
else onChange([...value, id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id: number) {
|
||||||
|
onChange(value.filter((x) => x !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="flex min-h-10 w-full flex-wrap items-center gap-1.5 rounded-md border border-line bg-white px-2.5 py-1.5 text-right text-sm text-ink outline-none transition-colors hover:border-ink/40 focus:border-ink disabled:cursor-not-allowed disabled:bg-[#f5f5f3]"
|
||||||
|
>
|
||||||
|
{selected.length === 0 ? (
|
||||||
|
<span className="px-1 py-1 text-muted">{placeholder}</span>
|
||||||
|
) : (
|
||||||
|
selected.map((item) => (
|
||||||
|
<span
|
||||||
|
key={item.id}
|
||||||
|
className="inline-flex max-w-full items-center gap-1 rounded bg-[#eee] px-2 py-1 text-xs text-ink"
|
||||||
|
>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`حذف ${item.label}`}
|
||||||
|
className="shrink-0 text-muted hover:text-ink"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
remove(item.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
remove(item.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<span className="ms-auto shrink-0 px-1 text-muted" aria-hidden>
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="absolute inset-x-0 top-[calc(100%+4px)] z-30 overflow-hidden rounded-md border border-line bg-white shadow-[0_12px_40px_rgba(0,0,0,0.12)]">
|
||||||
|
<div className="border-b border-line p-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className="h-9 w-full rounded border border-line bg-white px-3 text-sm outline-none focus:border-ink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
aria-multiselectable
|
||||||
|
className="max-h-56 overflow-y-auto py-1"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<li className="px-3 py-3 text-center text-sm text-muted">
|
||||||
|
{emptyLabel}
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
filtered.map((opt) => {
|
||||||
|
const checked = value.includes(opt.id);
|
||||||
|
return (
|
||||||
|
<li key={opt.id} role="option" aria-selected={checked}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(opt.id)}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-right text-sm hover:bg-[#f7f7f5] ${
|
||||||
|
checked ? "bg-[#f7f7f5]" : ""
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
paddingInlineStart: `${12 + (opt.depth ?? 0) * 12}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-4 shrink-0 items-center justify-center rounded border text-[10px] ${
|
||||||
|
checked
|
||||||
|
? "border-ink bg-ink text-white"
|
||||||
|
: "border-line bg-white text-transparent"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* After auth on the store app, send the user back to an external page
|
||||||
|
* (e.g. website /partners) with tokens in the URL hash for cross-origin handoff.
|
||||||
|
* Returns true if a handoff redirect was started.
|
||||||
|
*/
|
||||||
|
export function redirectWithAuthHandoff(returnTo: string | null | undefined): boolean {
|
||||||
|
if (typeof window === "undefined" || !returnTo) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = new URL(returnTo);
|
||||||
|
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same-origin return — no token handoff needed.
|
||||||
|
if (target.origin === window.location.origin) {
|
||||||
|
window.location.href = target.toString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = localStorage.getItem("mana_access_token");
|
||||||
|
const refresh = localStorage.getItem("mana_refresh_token");
|
||||||
|
const user = localStorage.getItem("mana_user");
|
||||||
|
if (!access || !refresh) return false;
|
||||||
|
|
||||||
|
const hash = new URLSearchParams({
|
||||||
|
access_token: access,
|
||||||
|
refresh_token: refresh,
|
||||||
|
...(user ? { user } : {}),
|
||||||
|
});
|
||||||
|
target.hash = hash.toString();
|
||||||
|
window.location.href = target.toString();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readReturnToParam(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return new URLSearchParams(window.location.search).get("returnTo");
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { API_URL, ApiError, api } from "@/lib/api";
|
||||||
|
|
||||||
|
const STORE_SLUG =
|
||||||
|
process.env.NEXT_PUBLIC_STORE_SLUG?.trim() || "mana-collection";
|
||||||
|
|
||||||
|
export type PublicCategoryNode = {
|
||||||
|
id: number;
|
||||||
|
parentId: number | null;
|
||||||
|
nameFa: string;
|
||||||
|
nameEn: string;
|
||||||
|
children: PublicCategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlatCategoryOption = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
depth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePartnershipInput = {
|
||||||
|
businessNameFa: string;
|
||||||
|
businessNameEn?: string;
|
||||||
|
categoryIds: number[];
|
||||||
|
description: string;
|
||||||
|
imageUrls: string[];
|
||||||
|
landline?: string;
|
||||||
|
instagram?: string;
|
||||||
|
city: string;
|
||||||
|
province: string;
|
||||||
|
address: string;
|
||||||
|
postalCode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flattenCategories(
|
||||||
|
nodes: PublicCategoryNode[],
|
||||||
|
depth = 0,
|
||||||
|
): FlatCategoryOption[] {
|
||||||
|
const out: FlatCategoryOption[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
out.push({
|
||||||
|
id: node.id,
|
||||||
|
label: node.nameFa || node.nameEn || String(node.id),
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
if (node.children?.length) {
|
||||||
|
out.push(...flattenCategories(node.children, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicCategoryOptions(): Promise<
|
||||||
|
FlatCategoryOption[]
|
||||||
|
> {
|
||||||
|
const data = await api<{ categories: PublicCategoryNode[] }>(
|
||||||
|
`/public/categories?storeSlug=${encodeURIComponent(STORE_SLUG)}`,
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
return flattenCategories(data.categories ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPartnershipRequest(body: CreatePartnershipInput) {
|
||||||
|
return api("/partnership-requests", { method: "POST", body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPartnershipImage(file: File): Promise<{
|
||||||
|
url: string;
|
||||||
|
}> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const token =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? localStorage.getItem("mana_access_token")
|
||||||
|
: null;
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/partnership-requests/images`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
const message =
|
||||||
|
(body && typeof body === "object" && "message" in body
|
||||||
|
? Array.isArray((body as { message: unknown }).message)
|
||||||
|
? (body as { message: string[] }).message.join("، ")
|
||||||
|
: String((body as { message: unknown }).message)
|
||||||
|
: null) ||
|
||||||
|
res.statusText ||
|
||||||
|
"بارگذاری تصویر ناموفق بود";
|
||||||
|
throw new ApiError(res.status, message, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as { url: string };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user