shop full
This commit is contained in:
@@ -148,7 +148,7 @@ export default function CourseDetail({
|
||||
setVideoFields(getAcademyCourses.data.courses);
|
||||
setSelectedVideo(getAcademyCourses.data.courses[0])
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academy.course.loadError"));
|
||||
} finally {
|
||||
}
|
||||
@@ -164,20 +164,16 @@ export default function CourseDetail({
|
||||
`/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر
|
||||
);
|
||||
|
||||
console.log(getCoursespayment);
|
||||
|
||||
if (getCoursespayment?.success) {
|
||||
if (getCoursespayment.isPurchased) {
|
||||
console.log("دوره خریداری شده است");
|
||||
setIsPurchased(true)
|
||||
} else {
|
||||
console.log("دوره خریداری نشده است");
|
||||
setIsPurchased(false)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academy.course.loadError"));
|
||||
}
|
||||
};
|
||||
@@ -363,7 +359,7 @@ export default function CourseDetail({
|
||||
setCourses([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academy.course.loadError"));
|
||||
setCourses([]);
|
||||
} finally {
|
||||
@@ -484,13 +480,7 @@ export default function CourseDetail({
|
||||
|
||||
// نمایش لودینگ
|
||||
loadingToastId = toast.loading(t("academy.course.connectingPayment"));
|
||||
|
||||
console.log("ارسال درخواست پرداخت:", {
|
||||
planDuration: plan.duration,
|
||||
planLabel: plan.label,
|
||||
price: plan.price
|
||||
});
|
||||
|
||||
|
||||
const response = await request(
|
||||
"POST",
|
||||
"/academy/academy/course/payment-web",
|
||||
@@ -501,9 +491,7 @@ export default function CourseDetail({
|
||||
|
||||
// بستن لودینگ
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
console.log("پاسخ کامل سرور:", response);
|
||||
|
||||
|
||||
// پردازش پاسخ (ساختارهای مختلف احتمالی)
|
||||
let authority = null;
|
||||
let paymentUrl = null;
|
||||
@@ -521,7 +509,6 @@ export default function CourseDetail({
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
console.log("هدایت به درگاه:", paymentUrl);
|
||||
window.open(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academy.course.redirectedToPayment"));
|
||||
|
||||
|
||||
@@ -18,10 +18,12 @@ function SuccessProject() {
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("projectId");
|
||||
const paymentType = searchParams.get("type");
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [completionSubmitted, setCompletionSubmitted] = useState(false);
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal" || projectType === "free") {
|
||||
@@ -54,8 +56,8 @@ function SuccessProject() {
|
||||
}
|
||||
};
|
||||
void fetchAd();
|
||||
void fetchStates();
|
||||
}, [projectId, request]);
|
||||
if (!paymentType) void fetchStates();
|
||||
}, [projectId, request, paymentType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (project?.project_type && typeList) {
|
||||
@@ -66,6 +68,38 @@ function SuccessProject() {
|
||||
}
|
||||
}, [project, typeList]);
|
||||
|
||||
// پرداخت مرحله تکمیل پروژه: نظر و امتیازی که قبل از رفتن به درگاه ذخیره شده بود حالا ثبت میشود
|
||||
useEffect(() => {
|
||||
if (paymentType !== "completion" || !projectId || completionSubmitted) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const key = `project-done-${projectId}`;
|
||||
const stashed = sessionStorage.getItem(key);
|
||||
if (!stashed) {
|
||||
setCompletionSubmitted(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const submitDone = async () => {
|
||||
try {
|
||||
const { comment, rate, user_id } = JSON.parse(stashed);
|
||||
const body: Record<string, unknown> = {
|
||||
project_id: projectId,
|
||||
comment,
|
||||
user_id,
|
||||
};
|
||||
if (rate) body.rate = rate;
|
||||
await request("POST", "/projects/done/web", body);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
sessionStorage.removeItem(key);
|
||||
setCompletionSubmitted(true);
|
||||
}
|
||||
};
|
||||
void submitDone();
|
||||
}, [paymentType, projectId, completionSubmitted, request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
@@ -84,10 +118,12 @@ function SuccessProject() {
|
||||
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="mt-4 flex flex-col items-center gap-4">
|
||||
<RoundedDiv className="w-full p-2">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
{!paymentType && (
|
||||
<RoundedDiv className="w-full p-2">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
)}
|
||||
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
|
||||
179
src/app/gift/[userId]/page.tsx
Normal file
179
src/app/gift/[userId]/page.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
|
||||
const CUSTOM_MIN = 5000;
|
||||
const CUSTOM_MAX = 100000000;
|
||||
|
||||
interface GiftPageProps {
|
||||
params: Promise<{ userId: string }>;
|
||||
}
|
||||
|
||||
function GiftPage({ params }: GiftPageProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { userId } = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(
|
||||
PRESET_AMOUNTS[0]
|
||||
);
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${userId}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
}, [userId, request]);
|
||||
|
||||
const finalAmount = useMemo(() => {
|
||||
if (isCustom) return Number(customAmount) || 0;
|
||||
return selectedAmount || 0;
|
||||
}, [isCustom, customAmount, selectedAmount]);
|
||||
|
||||
const isCustomValid =
|
||||
!isCustom ||
|
||||
(Number(customAmount) >= CUSTOM_MIN && Number(customAmount) <= CUSTOM_MAX);
|
||||
|
||||
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/gifts/initiate",
|
||||
{ receiverId: userId, amount: finalAmount, message }
|
||||
);
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message2 =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("giftPage.paymentStartError");
|
||||
toast.error(message2);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("giftPage.title")}</PageTitle>
|
||||
|
||||
{userDetail ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.is_verified}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
user_name={userDetail?.user_name}
|
||||
noLink
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-8 flex w-full max-w-md flex-wrap justify-center gap-2">
|
||||
{PRESET_AMOUNTS.map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setSelectedAmount(amount);
|
||||
}}
|
||||
className={filterChipClass(!isCustom && selectedAmount === amount)}
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustom(true)}
|
||||
className={filterChipClass(isCustom)}
|
||||
>
|
||||
{t("giftPage.customAmount")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom ? (
|
||||
<div className="mx-auto mt-4 w-full max-w-md">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("giftPage.customAmountPlaceholder", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
/>
|
||||
{!isCustomValid && customAmount ? (
|
||||
<p className="mt-2 text-center text-xs text-red-500">
|
||||
{t("giftPage.customAmountInvalid", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-6 w-full max-w-md">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t("giftPage.messagePlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="w-full resize-none rounded-3xl border border-border-secondary-light bg-secondary-light p-4 font-medium dark:border-border-secondary-dark dark:bg-secondary-dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="px-8 py-2"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{t("giftPage.payButton")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftPage;
|
||||
16
src/app/gift/layout.tsx
Normal file
16
src/app/gift/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function GiftLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
41
src/app/gift/payment/failed/page.tsx
Normal file
41
src/app/gift/payment/failed/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function GiftPaymentFailed() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="mb-10 mt-20 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
|
||||
{t("giftPage.paymentFailed.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt="failed icon"
|
||||
src="/images/icons/failed.svg"
|
||||
className="mb-8 pb-1"
|
||||
/>
|
||||
<span>{t("giftPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
{receiverId ? (
|
||||
<Link className="mt-8 flex flex-col items-center" href={`/gift/${receiverId}`}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
{t("giftPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftPaymentFailed;
|
||||
51
src/app/gift/payment/success/page.tsx
Normal file
51
src/app/gift/payment/success/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function GiftPaymentSuccess() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
const receiverUsername = searchParams.get("receiverUsername");
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<h6 className="mb-4 mt-10 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
|
||||
{t("giftPage.paymentSuccess.title")}
|
||||
</h6>
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt="success icon"
|
||||
src="/images/icons/success.svg"
|
||||
className="mb-5 pb-1"
|
||||
/>
|
||||
<p className="my-5 px-4 text-center">
|
||||
{t("giftPage.paymentSuccess.message")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center gap-4">
|
||||
{receiverId && receiverUsername ? (
|
||||
<Link href={`/settings/chats/${receiverUsername}/${receiverId}`}>
|
||||
<RoundedButton variant="primary" className="h-9 w-36">
|
||||
{t("giftPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/">
|
||||
<RoundedButton variant="primary" className="h-9 w-36">
|
||||
{t("giftPage.paymentSuccess.later")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftPaymentSuccess;
|
||||
@@ -674,10 +674,10 @@ select {
|
||||
|
||||
@keyframes nearby-radar-sweep {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
hasSeenPostLocationNotice,
|
||||
markPostLocationNoticeSeen,
|
||||
} from "@/lib/postLocationNotice";
|
||||
import { canEditPostWithinWindow } from "@/lib/postEditWindow";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import Cookies from "js-cookie";
|
||||
@@ -81,6 +80,10 @@ export default function NewPostPage() {
|
||||
const [selectedBillboards, setSelectedBillboards] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [selectedShops, setSelectedShops] = useState<SelectedPostLink[]>([]);
|
||||
const [selectedShopProducts, setSelectedShopProducts] = useState<
|
||||
SelectedPostLink[]
|
||||
>([]);
|
||||
const [storyOverlays, setStoryOverlays] = useState<StoryTextOverlay[]>([]);
|
||||
const [videoCover, setVideoCover] = useState<File | null>(null);
|
||||
const [existingVideoPath, setExistingVideoPath] = useState<string | null>(null);
|
||||
@@ -111,11 +114,6 @@ export default function NewPostPage() {
|
||||
const token = Cookies.get("token") || "";
|
||||
const post = await fetchPostById(editPostId, token);
|
||||
if (cancelled || !post) return;
|
||||
if (!canEditPostWithinWindow(post.createdAt)) {
|
||||
toast.error(t("posts.editPostExpired"));
|
||||
router.replace("/");
|
||||
return;
|
||||
}
|
||||
setMode(post.type === "video" ? "video" : "image");
|
||||
setDescription(post.caption || "");
|
||||
const imageFiles = (post.files || []).filter((f) => f.type !== "video");
|
||||
@@ -310,7 +308,9 @@ export default function NewPostPage() {
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
captionLang
|
||||
captionLang,
|
||||
selectedShops,
|
||||
selectedShopProducts
|
||||
);
|
||||
|
||||
if (caption.length > POST_CAPTION_MAX_LENGTH) {
|
||||
@@ -440,6 +440,8 @@ export default function NewPostPage() {
|
||||
linked_course_id: selectedPackages[0]?._id ?? null,
|
||||
linked_project_id: selectedProjects[0]?._id ?? null,
|
||||
linked_billboard_id: selectedBillboards[0]?._id ?? null,
|
||||
linked_shop_id: selectedShops[0]?._id ?? null,
|
||||
linked_shop_product_id: selectedShopProducts[0]?._id ?? null,
|
||||
}, { noToast: true });
|
||||
toast.success(t("posts.postPublished"), { id: "post-upload" });
|
||||
router.push("/");
|
||||
@@ -490,7 +492,9 @@ export default function NewPostPage() {
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
captionLang
|
||||
captionLang,
|
||||
selectedShops,
|
||||
selectedShopProducts
|
||||
);
|
||||
|
||||
const isStory = mode === "story";
|
||||
@@ -668,9 +672,13 @@ export default function NewPostPage() {
|
||||
selectedPackages={selectedPackages}
|
||||
selectedProjects={selectedProjects}
|
||||
selectedBillboards={selectedBillboards}
|
||||
selectedShops={selectedShops}
|
||||
selectedShopProducts={selectedShopProducts}
|
||||
onPackagesChange={setSelectedPackages}
|
||||
onProjectsChange={setSelectedProjects}
|
||||
onBillboardsChange={setSelectedBillboards}
|
||||
onShopsChange={setSelectedShops}
|
||||
onShopProductsChange={setSelectedShopProducts}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -90,8 +90,6 @@ const WalletDashboard = () => {
|
||||
`/academy/course/getAllAcademyPayments?status=success`
|
||||
);
|
||||
|
||||
console.log("پرداختهای موفق:", response?.data?.payments);
|
||||
|
||||
if (response?.data?.payments && Array.isArray(response.data.payments)) {
|
||||
const payments: Payment[] = response.data.payments;
|
||||
|
||||
@@ -115,7 +113,7 @@ const WalletDashboard = () => {
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log("error fetching successful payments:", err);
|
||||
console.error("error fetching successful payments:", err);
|
||||
toast.error(t("settings.academy.wallet.fetchSalesError"));
|
||||
return [];
|
||||
}
|
||||
@@ -129,8 +127,6 @@ const WalletDashboard = () => {
|
||||
`/academy/course/getAllAcademyPayments?status=settled`
|
||||
);
|
||||
|
||||
console.log("پرداختهای تسویه شده:", response?.data?.payments);
|
||||
|
||||
if (response?.data?.payments && Array.isArray(response.data.payments)) {
|
||||
const payments: Payment[] = response.data.payments;
|
||||
|
||||
@@ -156,7 +152,7 @@ const WalletDashboard = () => {
|
||||
}
|
||||
return [];
|
||||
} catch (err) {
|
||||
console.log("error fetching settled payments:", err);
|
||||
console.error("error fetching settled payments:", err);
|
||||
toast.error(t("settings.academy.wallet.fetchSettledError"));
|
||||
return [];
|
||||
}
|
||||
@@ -193,7 +189,7 @@ const WalletDashboard = () => {
|
||||
setTotalBalance(balance);
|
||||
|
||||
} catch (err) {
|
||||
console.log("error fetching all data:", err);
|
||||
console.error("error fetching all data:", err);
|
||||
toast.error(t("settings.academy.wallet.fetchError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -5,10 +5,12 @@ import Container from "@/components/elements/Container";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
getChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import GiftInChatModal from "@/components/chat/GiftInChatModal";
|
||||
import MultiImageModal from "@/components/chat/MultiImageModal";
|
||||
import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
@@ -61,8 +63,11 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { username, id: chatPartnerId } = resolvedParams;
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [showGiftModal, setShowGiftModal] = useState(false);
|
||||
const [userTwoDetail, setUserTwoDetail] = useState<User>();
|
||||
const [pendingImages, setPendingImages] = useState<File[]>([]);
|
||||
const [showMultiModal, setShowMultiModal] = useState(false);
|
||||
@@ -228,6 +233,36 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
);
|
||||
};
|
||||
|
||||
const sendGiftMessage = useCallback(
|
||||
(gift: { _id: string; amount: number; message: string | null }) => {
|
||||
const content = JSON.stringify({
|
||||
sharedGift: {
|
||||
giftId: gift._id,
|
||||
amount: gift.amount,
|
||||
message: gift.message,
|
||||
},
|
||||
});
|
||||
void processSendMessage(null, undefined, content);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[userTwoDetail, receiverId, chatPartnerId, selfDestructSeconds, viewOnceMedia]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const giftId = searchParams.get("giftId");
|
||||
if (!giftId) return;
|
||||
const giftAmount = Number(searchParams.get("giftAmount") || 0);
|
||||
const giftMessage = searchParams.get("giftMessage") || "";
|
||||
sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("giftId");
|
||||
params.delete("giftAmount");
|
||||
params.delete("giftMessage");
|
||||
const query = params.toString();
|
||||
router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sendMessage = () => {
|
||||
if (pendingImages.length > 0) {
|
||||
void sendAllImages();
|
||||
@@ -692,10 +727,19 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
onSelfDestructChange={setSelfDestructSeconds}
|
||||
viewOnceMedia={viewOnceMedia}
|
||||
onViewOnceChange={setViewOnceMedia}
|
||||
onGiftClick={() => setShowGiftModal(true)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<GiftInChatModal
|
||||
open={showGiftModal}
|
||||
onClose={() => setShowGiftModal(false)}
|
||||
receiverId={String(userTwoDetail?._id ?? receiverId ?? chatPartnerId)}
|
||||
returnPath={`/settings/chats/${username}/${chatPartnerId}`}
|
||||
onWalletGiftSent={sendGiftMessage}
|
||||
/>
|
||||
|
||||
<MultiImageModal
|
||||
isOpen={showMultiModal}
|
||||
files={pendingImages}
|
||||
|
||||
@@ -324,8 +324,7 @@ export default function NearbyFriendsPage() {
|
||||
{t("nearbyFriends.permissionDesc")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-4 w-full max-w-none"
|
||||
className="!border-transparent mt-4 h-9 w-full max-w-none !bg-sky-100 text-sky-600"
|
||||
onClick={requestBrowserLocation}
|
||||
>
|
||||
{t("nearbyFriends.retryLocation")}
|
||||
@@ -339,8 +338,7 @@ export default function NearbyFriendsPage() {
|
||||
{errorMessage || t("nearbyFriends.loadError")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-4 w-full max-w-none"
|
||||
className="!border-transparent mt-4 h-9 w-full max-w-none !bg-sky-100 text-sky-600"
|
||||
onClick={requestBrowserLocation}
|
||||
>
|
||||
{t("nearbyFriends.retryLocation")}
|
||||
@@ -398,7 +396,7 @@ export default function NearbyFriendsPage() {
|
||||
)}
|
||||
|
||||
<RoundedButton
|
||||
className="mx-auto mt-4 max-w-none"
|
||||
className="!border-transparent mx-auto mt-4 h-9 w-full max-w-none !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={requestBrowserLocation}
|
||||
>
|
||||
{t("nearbyFriends.refresh")}
|
||||
|
||||
@@ -21,8 +21,9 @@ import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { isUserOnline, resolveOnlineLabel } from "@/lib/chat/onlineStatus";
|
||||
import { isUserOnline } from "@/lib/chat/onlineStatus";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { E2EE_LOCKED_PREVIEW } from "@/lib/e2ee";
|
||||
|
||||
export interface IMessage {
|
||||
first_name: string;
|
||||
@@ -143,7 +144,7 @@ function Chats() {
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#0095f6]/15">
|
||||
<BoldIcon name="messages-2" size={24} className="text-[#0095f6]" tinted />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">{t("chats.chatRoom")}</span>
|
||||
<span className="truncate text-[11px] text-neutral-500">
|
||||
{t("chats.chatRoomDesc")}
|
||||
@@ -166,7 +167,7 @@ function Chats() {
|
||||
className="dark:invert"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">
|
||||
{t("nearbyFriends.title")}
|
||||
</span>
|
||||
@@ -203,6 +204,9 @@ function Chats() {
|
||||
<span className="truncate font-semibold">
|
||||
{item.display_name}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{t("chats.shopChatSubtitle")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pr-2">
|
||||
@@ -255,7 +259,7 @@ function Chats() {
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-0.5">
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">
|
||||
{item.display_name ||
|
||||
`${item.first_name} ${item.last_name}`.trim()}
|
||||
@@ -266,23 +270,15 @@ function Chats() {
|
||||
</span>
|
||||
<VerificationBadge isVerified={item.is_verified} />
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
isOnline
|
||||
? "text-[#22c55e]"
|
||||
: "text-neutral-400 dark:text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{resolveOnlineLabel(
|
||||
item.last_online,
|
||||
onlineLabel,
|
||||
offlineLabel
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 pr-2">
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{isOnline ? onlineLabel : item.last_online || offlineLabel}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-400 dark:text-neutral-500">
|
||||
{E2EE_LOCKED_PREVIEW}
|
||||
</span>
|
||||
{item.unread_messages_count ? (
|
||||
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
|
||||
{item.unread_messages_count}
|
||||
|
||||
@@ -3,19 +3,29 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import Header from "@/components/main/Header";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill } from "@/components/chat/ChatHeader";
|
||||
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import {
|
||||
type BubbleGroupPosition,
|
||||
getBubbleRadius,
|
||||
getMessageRowSpacing,
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopChatMessage = {
|
||||
_id: string;
|
||||
senderRole: "shop" | "buyer";
|
||||
content: string;
|
||||
content: string | null;
|
||||
file?: string | null;
|
||||
fileType?: "image" | "video" | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -24,6 +34,24 @@ type Counterpart = {
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
const MAX_VIDEO_SIZE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
function getGroupPosition(
|
||||
messages: ShopChatMessage[],
|
||||
index: number
|
||||
): BubbleGroupPosition {
|
||||
const current = messages[index];
|
||||
const sameAsPrev =
|
||||
index > 0 && messages[index - 1].senderRole === current.senderRole;
|
||||
const sameAsNext =
|
||||
index < messages.length - 1 &&
|
||||
messages[index + 1].senderRole === current.senderRole;
|
||||
if (!sameAsPrev && !sameAsNext) return "single";
|
||||
if (!sameAsPrev && sameAsNext) return "first";
|
||||
if (sameAsPrev && sameAsNext) return "middle";
|
||||
return "last";
|
||||
}
|
||||
|
||||
export default function ShopChatPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
@@ -37,6 +65,7 @@ export default function ShopChatPage() {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadMessages = () => {
|
||||
request<{ messages: ShopChatMessage[]; counterpart: Counterpart; isOwner: boolean }>(
|
||||
@@ -83,89 +112,199 @@ export default function ShopChatPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
const fileType: "image" | "video" = file.type.startsWith("video/") ? "video" : "image";
|
||||
if (fileType === "video" && file.size > MAX_VIDEO_SIZE_BYTES) {
|
||||
alert(t("chats.toast.shopChatVideoTooLarge"));
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("buyerId", buyerId);
|
||||
formData.append("fileType", fileType);
|
||||
formData.append("file", file);
|
||||
const res = await request<{ message: ShopChatMessage }>(
|
||||
"POST",
|
||||
`/shop-chat/${shopId}/messages/file`,
|
||||
formData
|
||||
);
|
||||
if (res?.message) {
|
||||
setMessages((prev) => [...(prev || []), res.message]);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const myRole = isOwner ? "shop" : "buyer";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-24">
|
||||
<div className="mx-auto flex h-[calc(100dvh-140px)] w-full max-w-md flex-col">
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-neutral-200 py-3 dark:border-neutral-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
aria-label={t("common.back")}
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center"
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={20} className="block dark:invert" />
|
||||
</button>
|
||||
<ProfileAvatar
|
||||
src={counterpart?.logo}
|
||||
alt={counterpart?.name}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="truncate text-sm font-semibold">
|
||||
<LocalePageShell>
|
||||
<header className="pointer-events-none fixed left-0 right-0 top-0 z-[100] bg-transparent px-2 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] sm:px-3">
|
||||
<div
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className={chatHeaderCircleBtn}
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<div dir="ltr" className={chatHeaderInfoPill}>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
<span className="block truncate text-[15px] font-semibold leading-tight text-neutral-900 dark:text-neutral-50">
|
||||
{counterpart?.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs leading-tight text-neutral-500 dark:text-neutral-400">
|
||||
{t("chats.shopChatSubtitle")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-3">
|
||||
{messages === null ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("shops.noChatMessagesYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
const mine = m.senderRole === myRole;
|
||||
return (
|
||||
<div
|
||||
key={m._id}
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm break-words",
|
||||
mine
|
||||
? "self-end bg-[#387E65] text-white"
|
||||
: "self-start bg-neutral-100 dark:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-neutral-200 pt-3 dark:border-neutral-700">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSend();
|
||||
}}
|
||||
placeholder={t("chats.placeholder.message")}
|
||||
className="flex-1 rounded-full border border-neutral-300 bg-transparent px-4 py-2 text-sm outline-none dark:border-neutral-600"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSend()}
|
||||
className="shrink-0 rounded-full bg-[#387E65] px-4 py-2 text-xs font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{t("posts.send")}
|
||||
</button>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={counterpart?.logo}
|
||||
alt={counterpart?.name || ""}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
className="h-[34px] w-[34px] shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Container className="!px-0">
|
||||
<div className="mx-auto flex h-[100dvh] max-h-[100dvh] w-full max-w-lg flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-28 pt-[calc(3.75rem+env(safe-area-inset-top))]">
|
||||
{messages === null ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<IOSSpinner />
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("shops.noChatMessagesYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{messages.map((m, index) => {
|
||||
const mine = m.senderRole === myRole;
|
||||
const position = getGroupPosition(messages, index);
|
||||
const groupedWithPrev =
|
||||
index > 0 && messages[index - 1].senderRole === m.senderRole;
|
||||
const radius = getBubbleRadius(mine, position);
|
||||
return (
|
||||
<div
|
||||
key={m._id}
|
||||
dir="rtl"
|
||||
className={cn(
|
||||
"flex w-full",
|
||||
getMessageRowSpacing(position, groupedWithPrev),
|
||||
mine ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{m.fileType === "video" && m.file ? (
|
||||
<VideoMessageBubble
|
||||
src={`${IMAGE_BASE_URL}${m.file}`}
|
||||
isOutgoing={mine}
|
||||
borderRadius={radius}
|
||||
/>
|
||||
) : m.fileType === "image" && m.file ? (
|
||||
<div
|
||||
className="max-w-[min(280px,85vw)] overflow-hidden"
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${IMAGE_BASE_URL}${m.file}`}
|
||||
alt=""
|
||||
className="max-h-96 w-full min-w-[200px] bg-black/5 object-cover"
|
||||
style={{ borderRadius: radius }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"chat-text-bubble max-w-[75%] px-3 py-2 text-[15px] leading-snug break-words",
|
||||
mine ? "chat-bubble-out" : "chat-bubble-in"
|
||||
)}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
<p className="chat-message-text select-text">{m.content}</p>
|
||||
<span
|
||||
className={cn(
|
||||
"message-time-below mt-0.5 block text-[10px] tabular-nums",
|
||||
mine ? "text-left" : "text-right"
|
||||
)}
|
||||
>
|
||||
{formatBubbleTime(m.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex flex-col items-center bg-transparent px-3 sm:px-4">
|
||||
<div
|
||||
dir="ltr"
|
||||
className="pointer-events-auto relative flex w-full max-w-lg items-end gap-2 pb-5 sm:gap-2.5"
|
||||
>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => void handleFileSelected(e)}
|
||||
/>
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending}
|
||||
onClick={() => attachmentInputRef.current?.click()}
|
||||
className={cn(chatHeaderCircleBtn, "gentle-transition disabled:opacity-40")}
|
||||
aria-label={t("chats.aria.attach")}
|
||||
>
|
||||
<ChatBoldIcon name="attachment" size={22} className="text-current" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<textarea
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}
|
||||
}}
|
||||
placeholder={t("chats.placeholder.message")}
|
||||
className="min-h-[1.5rem] max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!text.trim() || sending}
|
||||
onClick={() => void handleSend()}
|
||||
className={cn(chatHeaderCircleBtn, "gentle-transition active:scale-90 disabled:opacity-40")}
|
||||
aria-label={t("chats.aria.send")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/app/settings/edit/activity/academy-comments/page.tsx
Normal file
82
src/app/settings/edit/activity/academy-comments/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentedCourse = {
|
||||
_id: string;
|
||||
comment?: string;
|
||||
course_id?: { _id: string; cuorse_name?: string } | null;
|
||||
};
|
||||
|
||||
function AcademyCommentsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<CommentedCourse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: { comments: CommentedCourse[] } }>(
|
||||
"GET",
|
||||
"/academy/user/comments?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data?.comments || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.academyComments")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.course_id)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/explore/${item.course_id?._id}`}
|
||||
className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<p className="truncate text-sm font-bold">
|
||||
{item.course_id?.cuorse_name}
|
||||
</p>
|
||||
{item.comment ? (
|
||||
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
||||
{item.comment}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AcademyCommentsPage;
|
||||
76
src/app/settings/edit/activity/academy-likes/page.tsx
Normal file
76
src/app/settings/edit/activity/academy-likes/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LikedCourse = {
|
||||
_id: string;
|
||||
course_id?: { _id: string; cuorse_name?: string; price?: string } | null;
|
||||
};
|
||||
|
||||
function AcademyLikesPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<LikedCourse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: LikedCourse[] }>(
|
||||
"GET",
|
||||
"/academy/user/liked-courses?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.academyLikes")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.course_id)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/explore/${item.course_id?._id}`}
|
||||
className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span className="truncate text-sm font-bold">
|
||||
{item.course_id?.cuorse_name}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default AcademyLikesPage;
|
||||
82
src/app/settings/edit/activity/billboard-comments/page.tsx
Normal file
82
src/app/settings/edit/activity/billboard-comments/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type CommentedBillboard = {
|
||||
_id: string;
|
||||
text?: string;
|
||||
advertisingId?: { _id: string; title?: string } | null;
|
||||
};
|
||||
|
||||
function BillboardCommentsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<CommentedBillboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: CommentedBillboard[] }>(
|
||||
"GET",
|
||||
"/advertising/user-comments?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.billboardComments")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.advertisingId)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/billboards/${item.advertisingId?._id}/x`}
|
||||
className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<p className="truncate text-sm font-bold">
|
||||
{item.advertisingId?.title}
|
||||
</p>
|
||||
{item.text ? (
|
||||
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
||||
{item.text}
|
||||
</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardCommentsPage;
|
||||
76
src/app/settings/edit/activity/billboard-likes/page.tsx
Normal file
76
src/app/settings/edit/activity/billboard-likes/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LikedBillboard = {
|
||||
_id: string;
|
||||
advertisingId?: { _id: string; title?: string } | null;
|
||||
};
|
||||
|
||||
function BillboardLikesPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [items, setItems] = useState<LikedBillboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ data: LikedBillboard[] }>(
|
||||
"GET",
|
||||
"/advertising/user-liked?page=1&limit=50",
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setItems(res?.data || []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.billboardLikes")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.advertisingId)
|
||||
.map((item) => (
|
||||
<li key={item._id}>
|
||||
<Link
|
||||
href={`/billboards/${item.advertisingId?._id}/x`}
|
||||
className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<span className="truncate text-sm font-bold">
|
||||
{item.advertisingId?.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillboardLikesPage;
|
||||
24
src/app/settings/edit/activity/comments/page.tsx
Normal file
24
src/app/settings/edit/activity/comments/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ActivityPostGrid from "@/components/settings/ActivityPostGrid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ActivityCommentsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.comments")}</PageTitle>
|
||||
<div className="my-6">
|
||||
<ActivityPostGrid endpoint="/posts/my-comments" />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityCommentsPage;
|
||||
24
src/app/settings/edit/activity/likes/page.tsx
Normal file
24
src/app/settings/edit/activity/likes/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ActivityPostGrid from "@/components/settings/ActivityPostGrid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ActivityLikesPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.activity.items.likes")}</PageTitle>
|
||||
<div className="my-6">
|
||||
<ActivityPostGrid endpoint="/posts/my-likes" />
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityLikesPage;
|
||||
48
src/app/settings/edit/activity/page.tsx
Normal file
48
src/app/settings/edit/activity/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
const ACTIVITY_ITEMS = [
|
||||
{ key: "likes", href: "/settings/edit/activity/likes" },
|
||||
{ key: "comments", href: "/settings/edit/activity/comments" },
|
||||
{ key: "academyLikes", href: "/settings/edit/activity/academy-likes" },
|
||||
{ key: "academyComments", href: "/settings/edit/activity/academy-comments" },
|
||||
{ key: "billboardLikes", href: "/settings/edit/activity/billboard-likes" },
|
||||
{ key: "billboardComments", href: "/settings/edit/activity/billboard-comments" },
|
||||
];
|
||||
|
||||
function ActivityHubPage() {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.activity")}</PageTitle>
|
||||
<div className="my-6 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{ACTIVITY_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>{t(`settings.activity.items.${item.key}`)}</span>
|
||||
<BoldIcon
|
||||
name="arrow-right-2"
|
||||
size={18}
|
||||
tinted
|
||||
className="text-neutral-400 rtl:rotate-180"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityHubPage;
|
||||
174
src/app/settings/edit/archive/page.tsx
Normal file
174
src/app/settings/edit/archive/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Post } from "@/types/types";
|
||||
import { getPostMedia } from "@/lib/explore/postMedia";
|
||||
import ExploreVideoThumb from "@/components/explore/ExploreVideoThumb";
|
||||
import { buildPostPath } from "@/lib/postSlug";
|
||||
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < postId.length; i++) {
|
||||
hash = (hash + postId.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function ArchiveCardSkeleton({ seed }: { seed: number }) {
|
||||
const aspect = ASPECT_RATIOS[seed % ASPECT_RATIOS.length];
|
||||
return (
|
||||
<div className={`mb-2 w-full break-inside-avoid overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800 ${aspect} animate-pulse`} />
|
||||
);
|
||||
}
|
||||
|
||||
function ArchivePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const user = useUser();
|
||||
const router = useRouter();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (pageNum: number) => {
|
||||
const res = await request<{ posts: Post[]; totalPages: number }>(
|
||||
"GET",
|
||||
`/account/archived-posts?page=${pageNum}&limit=12`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
return res;
|
||||
},
|
||||
[request]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadPage(1)
|
||||
.then((res) => {
|
||||
setPosts(res?.posts || []);
|
||||
setHasMore((res?.totalPages || 1) > 1);
|
||||
setPage(1);
|
||||
})
|
||||
.catch(() => setPosts([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadPage]);
|
||||
|
||||
const fetchMore = useCallback(() => {
|
||||
if (fetchingMore || !hasMore) return;
|
||||
setFetchingMore(true);
|
||||
const nextPage = page + 1;
|
||||
loadPage(nextPage)
|
||||
.then((res) => {
|
||||
setPosts((prev) => [...prev, ...(res?.posts || [])]);
|
||||
setHasMore(nextPage < (res?.totalPages || 1));
|
||||
setPage(nextPage);
|
||||
})
|
||||
.catch(() => setHasMore(false))
|
||||
.finally(() => setFetchingMore(false));
|
||||
}, [fetchingMore, hasMore, page, loadPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
window.innerHeight + window.scrollY >=
|
||||
document.body.offsetHeight - 800
|
||||
) {
|
||||
fetchMore();
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [fetchMore]);
|
||||
|
||||
const openPost = (post: Post) => {
|
||||
const withAuthor: Post = {
|
||||
...post,
|
||||
user_name: user?.user_name || post.user_name,
|
||||
first_name: user?.first_name || post.first_name,
|
||||
last_name: user?.last_name || post.last_name,
|
||||
profile_image: user?.profile_image || post.profile_image,
|
||||
};
|
||||
cacheReelsSeedPost(withAuthor);
|
||||
router.push(buildPostPath(post._id, withAuthor));
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.archive")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="columns-2 gap-2">
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<ArchiveCardSkeleton key={i} seed={i} />
|
||||
))}
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="columns-2 gap-2">
|
||||
{posts.map((post) => {
|
||||
const media = getPostMedia(post);
|
||||
if (!media) return null;
|
||||
const aspect = hashAspect(post._id);
|
||||
return (
|
||||
<button
|
||||
key={post._id}
|
||||
type="button"
|
||||
onClick={() => openPost(post)}
|
||||
className={`gentle-transition relative mb-2 block w-full overflow-hidden rounded-2xl bg-neutral-200 text-right dark:bg-neutral-900 active:scale-[0.98] ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<ExploreVideoThumb src={media.src} poster={media.poster} />
|
||||
) : (
|
||||
<Image
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{fetchingMore
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<ArchiveCardSkeleton key={`more-${i}`} seed={i} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArchivePage;
|
||||
103
src/app/settings/edit/blocked-users/page.tsx
Normal file
103
src/app/settings/edit/blocked-users/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type BlockedUser = {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
};
|
||||
|
||||
function BlockedUsersPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [users, setUsers] = useState<BlockedUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ users: BlockedUser[] }>("GET", "/users/blocked/list", null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => setUsers(res?.users || []))
|
||||
.catch(() => setUsers([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request]);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.blockedUsers")}</PageTitle>
|
||||
<div className="my-6">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex animate-pulse items-center gap-3 rounded-xl px-2 py-2"
|
||||
>
|
||||
<div className="h-11 w-11 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-1/3 rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-1/4 rounded bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{users.map((u) => {
|
||||
const name =
|
||||
[u.first_name, u.last_name].filter(Boolean).join(" ") ||
|
||||
u.user_name;
|
||||
return (
|
||||
<li key={u._id}>
|
||||
<Link
|
||||
href={`/users/${u.user_name}`}
|
||||
className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
<Image
|
||||
src={
|
||||
u.profile_image
|
||||
? buildStorageUrl(u.profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">{name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
@{u.user_name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default BlockedUsersPage;
|
||||
@@ -9,6 +9,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -29,6 +30,9 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/public-relations": "publicRelations",
|
||||
"/shaba": "shaba",
|
||||
"/location": "location",
|
||||
"/blocked-users": "blockedUsers",
|
||||
"/archive": "archive",
|
||||
"/activity": "activity",
|
||||
};
|
||||
|
||||
function EditPage() {
|
||||
@@ -46,6 +50,7 @@ function EditPage() {
|
||||
<PageTitle>{t("settings.nav.edit")}</PageTitle>
|
||||
<div>
|
||||
<UserDetails />
|
||||
<AccountAnalyticsWidget />
|
||||
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
|
||||
92
src/app/settings/gifts/page.tsx
Normal file
92
src/app/settings/gifts/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import GiftItem from "@/components/settings/gifts/GiftItem";
|
||||
import GiftReplyModal from "@/components/settings/gifts/GiftReplyModal";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { IGift } from "@/types/types";
|
||||
import React, { useState } from "react";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const FILTER_SENT = "sent";
|
||||
const FILTER_RECEIVED = "received";
|
||||
|
||||
function GiftsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const [filter, setFilter] = useState<string>(FILTER_SENT);
|
||||
const [showReplyModal, setShowReplyModal] = useState(false);
|
||||
const [itemToReply, setItemToReply] = useState<IGift | null>(null);
|
||||
|
||||
const { data, isFetchingNextPage, refetch } = useInfiniteScroll({
|
||||
endpoint: "/gifts",
|
||||
queryKey: ["gifts", filter],
|
||||
params: { filter },
|
||||
});
|
||||
|
||||
const actionHandler = (item: IGift) => {
|
||||
if (filter === FILTER_RECEIVED) {
|
||||
setItemToReply(item);
|
||||
setShowReplyModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.gifts.title")}</PageTitle>
|
||||
<div className="text-xs md:text-sm">
|
||||
<UserDetails />
|
||||
<div className="mx-auto mt-8 grid w-full max-w-md grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_SENT), "h-9")}
|
||||
onClick={() => setFilter(FILTER_SENT)}
|
||||
>
|
||||
{t("settings.gifts.given")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(filter === FILTER_RECEIVED), "h-9")}
|
||||
onClick={() => setFilter(FILTER_RECEIVED)}
|
||||
>
|
||||
{t("settings.gifts.received")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
<div className="mt-10 w-full">
|
||||
{data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.gifts?.length === 0 && !isFetchingNextPage) ? (
|
||||
<p className="text-center text-gray-500">
|
||||
{t("settings.gifts.empty")}
|
||||
</p>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
{page?.gifts?.map((item: IGift) => (
|
||||
<GiftItem
|
||||
key={item?._id}
|
||||
item={item}
|
||||
mode={filter === FILTER_RECEIVED ? "received" : "sent"}
|
||||
actionHandler={actionHandler}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GiftReplyModal
|
||||
isOpen={showReplyModal}
|
||||
onClose={() => setShowReplyModal(false)}
|
||||
item={itemToReply}
|
||||
onReplied={() => void refetch()}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftsPage;
|
||||
@@ -62,13 +62,12 @@ export default function ShopSettingsPage() {
|
||||
|
||||
const { data: sellerOrdersData, isLoading: sellerOrdersLoading } = useInfiniteScroll({
|
||||
endpoint: "/orders",
|
||||
queryKey: ["seller-orders", primaryShopId || "pending", search],
|
||||
params: { role: "seller", shopId: primaryShopId || "", ...(search ? { q: search } : {}) },
|
||||
enabled: Boolean(primaryShopId),
|
||||
queryKey: ["seller-orders", search],
|
||||
params: { role: "seller", ...(search ? { q: search } : {}) },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sellerOrders: any[] =
|
||||
filter === FILTER_SELLER && primaryShopId
|
||||
filter === FILTER_SELLER
|
||||
? sellerOrdersData?.pages.flatMap((page) => page.docs || []) || []
|
||||
: [];
|
||||
|
||||
|
||||
@@ -16,11 +16,15 @@ import { useTranslation } from "react-i18next";
|
||||
const BUCKET_SHOP = "shop";
|
||||
const BUCKET_GIFT = "gift";
|
||||
const BUCKET_CHARGED = "charged";
|
||||
const BUCKET_ACADEMY = "academy";
|
||||
const BUCKET_PROJECT = "project";
|
||||
|
||||
type WalletSummary = {
|
||||
shop: { available: number; pending: number };
|
||||
gift: { available: number; minWithdrawal: number };
|
||||
charged: { available: number };
|
||||
academy: { available: number };
|
||||
project: { available: number };
|
||||
};
|
||||
|
||||
export default function WalletSettingsPage() {
|
||||
@@ -51,7 +55,9 @@ export default function WalletSettingsPage() {
|
||||
const totalBalance =
|
||||
(summary?.shop.available || 0) +
|
||||
(summary?.gift.available || 0) +
|
||||
(summary?.charged.available || 0);
|
||||
(summary?.charged.available || 0) +
|
||||
(summary?.academy.available || 0) +
|
||||
(summary?.project.available || 0);
|
||||
|
||||
const handleWithdraw = async () => {
|
||||
const amount = Number(withdrawAmount);
|
||||
@@ -120,6 +126,18 @@ export default function WalletSettingsPage() {
|
||||
>
|
||||
{t("shops.walletChargedTab")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(bucket === BUCKET_ACADEMY), "h-9 text-xs")}
|
||||
onClick={() => setBucket(BUCKET_ACADEMY)}
|
||||
>
|
||||
{t("shops.walletAcademyTab")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className={cn(toggleBtnClass(bucket === BUCKET_PROJECT), "h-9 text-xs")}
|
||||
onClick={() => setBucket(BUCKET_PROJECT)}
|
||||
>
|
||||
{t("shops.walletProjectTab")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-6 max-w-md text-sm">
|
||||
@@ -188,6 +206,60 @@ export default function WalletSettingsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{bucket === BUCKET_ACADEMY && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("shops.academyBalance")}</span>
|
||||
<span className="font-bold">
|
||||
{summary.academy.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.withdrawAmountPlaceholder")}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bucket === BUCKET_PROJECT && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span>{t("shops.projectBalance")}</span>
|
||||
<span className="font-bold">
|
||||
{summary.project.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("shops.withdrawAmountPlaceholder")}
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bucket === BUCKET_CHARGED && summary && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -35,6 +35,7 @@ function ProductNamePage() {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [catalogProductId, setCatalogProductId] = useState<string | null>(null);
|
||||
const [sourceListingId, setSourceListingId] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<CatalogProduct[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -75,6 +76,7 @@ function ProductNamePage() {
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
setCatalogProductId(null);
|
||||
setSourceListingId(null);
|
||||
};
|
||||
|
||||
const handlePick = (product: CatalogProduct) => {
|
||||
@@ -82,6 +84,15 @@ function ProductNamePage() {
|
||||
setDescription(product.description || "");
|
||||
setCatalogProductId(product._id);
|
||||
setSuggestions([]);
|
||||
setSourceListingId(null);
|
||||
request<{ listings: { _id: string }[] }>(
|
||||
"GET",
|
||||
`/shop-products/catalog/${product._id}/listings`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setSourceListingId(res?.listings?.[0]?._id || null))
|
||||
.catch(() => setSourceListingId(null));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -107,6 +118,7 @@ function ProductNamePage() {
|
||||
catalogProductId: catalogProductId || undefined,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
cloneFromListingId: sourceListingId || undefined,
|
||||
}
|
||||
);
|
||||
if (response?.listing?._id) {
|
||||
@@ -157,6 +169,12 @@ function ProductNamePage() {
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
{sourceListingId && (
|
||||
<p className="mt-3 w-full max-w-sm text-center text-xs text-neutral-500">
|
||||
{t("shops.prefillFromSimilarProductHint")}
|
||||
</p>
|
||||
)}
|
||||
</AuthPageContent>
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
type OrderDetail = {
|
||||
_id: string;
|
||||
createdAt: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_amount: number;
|
||||
@@ -25,6 +26,10 @@ type OrderDetail = {
|
||||
images: string[];
|
||||
primaryImageIndex: number;
|
||||
};
|
||||
shop: {
|
||||
name: string;
|
||||
estimated_delivery_text?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CheckoutPage() {
|
||||
@@ -72,6 +77,8 @@ export default function CheckoutPage() {
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
|
||||
const itemsSubtotal = order.total_amount - order.shipping_cost;
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
@@ -92,38 +99,43 @@ export default function CheckoutPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{order.listing.title}</p>
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{order.listing.title}{" "}
|
||||
<span className="font-normal text-neutral-500">× {order.quantity}</span>
|
||||
</p>
|
||||
{variantLabel && (
|
||||
<p className="text-xs text-neutral-500">{variantLabel}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t("shops.quantityLabel")}: {order.quantity}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{order.shop.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("shops.itemsTotal")}</span>
|
||||
<div className="rounded-2xl border border-neutral-200 p-4 text-sm dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.itemsTotal")}</span>
|
||||
<span>
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
{itemsSubtotal.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingTimeLabel")}</span>
|
||||
<span>{order.shop.estimated_delivery_text || "—"}</span>
|
||||
</div>
|
||||
{order.shipping_method && (
|
||||
<div className="flex justify-between text-neutral-500">
|
||||
<span>
|
||||
{t("shops.shippingPaidAtDelivery", {
|
||||
method: t(`shops.shippingMethods.${order.shipping_method}`),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{order.shipping_cost.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingTypeLabel")}</span>
|
||||
<span>{t(`shops.shippingMethods.${order.shipping_method}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex justify-between border-t border-neutral-200 pt-2 font-bold dark:border-neutral-700">
|
||||
<span>{t("shops.payOnlineTotal")}</span>
|
||||
<div className="flex items-center justify-between border-b border-neutral-100 py-2 dark:border-neutral-800">
|
||||
<span className="text-neutral-500">{t("shops.shippingCostLabel")}</span>
|
||||
<span>
|
||||
{order.shipping_cost.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="font-semibold">{t("shops.payOnlineTotal")}</span>
|
||||
<span className="font-bold text-green-600">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,11 @@ export default function ShopDiscoverPage() {
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{t("shops.discoverTitle")}</PageTitle>
|
||||
<ShopProductGrid listings={listings} isLoading={isLoading} />
|
||||
<ShopProductGrid
|
||||
listings={listings}
|
||||
isLoading={isLoading}
|
||||
getHref={(listing) => `/shops/discover/reel/${listing._id}`}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/shops/discover" />
|
||||
|
||||
36
src/app/shops/discover/reel/[listingId]/page.tsx
Normal file
36
src/app/shops/discover/reel/[listingId]/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import ShopReelsView from "@/components/shops/ShopReelsView";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ShopDiscoverReelPage() {
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request } = useAxios();
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
"/shop-products/discover?limit=100"
|
||||
)
|
||||
.then((res) => setListings(res?.docs || []))
|
||||
.catch(() => setListings([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (listings === null) {
|
||||
return <PageLoader className="min-h-[100dvh] bg-black" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShopReelsView
|
||||
listings={listings}
|
||||
initialListingId={params.listingId}
|
||||
backHref="/shops/discover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
487
src/app/shops/listing/[listingId]/ListingDetailClient.tsx
Normal file
487
src/app/shops/listing/[listingId]/ListingDetailClient.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing, IShopProductVariant } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShippingMethod = { method: string; cost: number; enabled: boolean };
|
||||
type ShopHeader = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
shipping_methods?: ShippingMethod[];
|
||||
};
|
||||
|
||||
function uniqueValues(values: (string | null | undefined)[]): string[] {
|
||||
return Array.from(new Set(values.filter((v): v is string => Boolean(v))));
|
||||
}
|
||||
|
||||
function SpecRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2 rounded-3xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<span className="shrink-0 text-sm font-bold">{label}</span>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ListingDetailClient() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
const [shippingMethod, setShippingMethod] = useState<string>("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
const [favorite, setFavorite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ listing: IShopProductListing }>("GET", `/shop-products/${params.listingId}`).then(
|
||||
(res) => {
|
||||
const data = res?.listing || null;
|
||||
setListing(data);
|
||||
const firstVariant = data?.variants?.[0];
|
||||
if (firstVariant) {
|
||||
setSelectedColor(firstVariant.color || null);
|
||||
setSelectedSize(firstVariant.size || null);
|
||||
setSelectedWeight(firstVariant.weight || null);
|
||||
}
|
||||
if (data) setFavorite(isFavorite(data._id));
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.listingId]);
|
||||
|
||||
const shopInfo: ShopHeader | null =
|
||||
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
|
||||
|
||||
const colors = useMemo(
|
||||
() => uniqueValues(listing?.variants.map((v) => v.color) || []),
|
||||
[listing]
|
||||
);
|
||||
const sizes = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) => colors.length === 0 || v.color === selectedColor
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.size));
|
||||
}, [listing, colors, selectedColor]);
|
||||
const weights = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize)
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.weight));
|
||||
}, [listing, colors, sizes, selectedColor, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sizes.length > 0 && (!selectedSize || !sizes.includes(selectedSize))) {
|
||||
setSelectedSize(sizes[0]);
|
||||
}
|
||||
}, [sizes, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (weights.length > 0 && (!selectedWeight || !weights.includes(selectedWeight))) {
|
||||
setSelectedWeight(weights[0]);
|
||||
}
|
||||
}, [weights, selectedWeight]);
|
||||
|
||||
const matchedVariant: IShopProductVariant | null = useMemo(() => {
|
||||
if (!listing) return null;
|
||||
return (
|
||||
listing.variants.find(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize) &&
|
||||
(weights.length === 0 || v.weight === selectedWeight)
|
||||
) ||
|
||||
listing.variants[0] ||
|
||||
null
|
||||
);
|
||||
}, [listing, colors, sizes, weights, selectedColor, selectedSize, selectedWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(1);
|
||||
}, [matchedVariant?._id]);
|
||||
|
||||
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
|
||||
|
||||
const categoryLine = listing
|
||||
? [listing.category, listing.sub_category, listing.sub_sub_category]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
: "";
|
||||
|
||||
const discountPercent =
|
||||
matchedVariant?.discount_price && matchedVariant.discount_price < matchedVariant.price
|
||||
? Math.round(
|
||||
((matchedVariant.price - matchedVariant.discount_price) / matchedVariant.price) * 100
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleImageScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveImageIndex(index);
|
||||
};
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
if (enabledShippingMethods.length > 0 && !shippingMethod) {
|
||||
toast.error(t("shops.shippingMethodRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", {
|
||||
listingId: listing._id,
|
||||
variantId: matchedVariant._id,
|
||||
quantity,
|
||||
shippingMethod: shippingMethod || undefined,
|
||||
});
|
||||
if (response?.order?._id) {
|
||||
router.push(`/shops/checkout/${response.order._id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: { code?: string; message?: string } } })
|
||||
?.response?.data;
|
||||
if (data?.code === "ADDRESS_REQUIRED") {
|
||||
setShowAddressModal(true);
|
||||
return;
|
||||
}
|
||||
toast.error(data?.message || t("shops.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
addToCart(listing._id, matchedVariant._id, quantity);
|
||||
toast.success(t("shops.addToCartSuccess"));
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!listing) return;
|
||||
const next = toggleFavorite(listing._id);
|
||||
setFavorite(next);
|
||||
toast.success(next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites"));
|
||||
};
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const images = listing.images || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
{shopInfo?._id ? (
|
||||
<Link
|
||||
href={`/shops/profile/${shopInfo._id}`}
|
||||
className="flex items-center justify-end gap-2"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && (
|
||||
<span className="relative flex h-11 w-11 shrink-0 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-lg bg-red-500" />
|
||||
<span className="absolute inset-0 rotate-[22.5deg] rounded-lg bg-red-500" />
|
||||
<span className="relative z-10 text-sm font-extrabold text-white">
|
||||
{t("shops.discountPercentBadge", { percent: discountPercent })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={handleImageScroll}
|
||||
className="flex w-full snap-x snap-mandatory gap-0 overflow-x-auto rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
className="relative aspect-[4/3] w-full shrink-0 snap-center overflow-hidden bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{activeImageIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-bold">{t("shops.specsTitle")}</p>
|
||||
|
||||
{categoryLine && (
|
||||
<SpecRow label={t("shops.categorySpecLabel")}>
|
||||
<span className="text-xs">{categoryLine}</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<SpecRow label={t("shops.colorLabel")}>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedColor === color
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{sizes.length > 0 && (
|
||||
<SpecRow label={t("shops.sizeLabel")}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedSize === size
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{weights.length > 0 && (
|
||||
<SpecRow label={t("shops.weightLabel")}>
|
||||
{weights.map((weight) => (
|
||||
<button
|
||||
key={weight}
|
||||
type="button"
|
||||
onClick={() => setSelectedWeight(weight)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedWeight === weight
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{weight}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{enabledShippingMethods.length > 0 && (
|
||||
<SpecRow
|
||||
label={`${t("shops.shippingTitle")} *`}
|
||||
>
|
||||
{enabledShippingMethods.map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
type="button"
|
||||
onClick={() => setShippingMethod(m.method)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
shippingMethod === m.method
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{t(`shops.shippingMethods.${m.method}`)}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{listing.description && (
|
||||
<SpecRow label={t("shops.descriptionLabel")}>
|
||||
<span className="text-xs leading-relaxed">
|
||||
{descriptionExpanded || listing.description.length <= 50
|
||||
? listing.description
|
||||
: `${listing.description.slice(0, 50)}...`}
|
||||
{listing.description.length > 50 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((v) => !v)}
|
||||
className="mr-1 font-bold text-pink-500"
|
||||
>
|
||||
{descriptionExpanded ? t("shops.showLess") : t("shops.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBuy()}
|
||||
disabled={loading || !matchedVariant}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{t("shops.placeOrderButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuantity((q) =>
|
||||
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{matchedVariant && (
|
||||
<SpecRow label={t("shops.priceSpecLabel")}>
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{(matchedVariant.discount_price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFavorite}
|
||||
className="flex-1 rounded-3xl bg-sky-500 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/edit/location")}
|
||||
className="w-full rounded-3xl bg-pink-500 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.goToLocationSettings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 block w-full text-xs text-neutral-500"
|
||||
onClick={() => setShowAddressModal(false)}
|
||||
>
|
||||
{t("auth.skip")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductImageLightbox
|
||||
images={images}
|
||||
initialIndex={activeImageIndex}
|
||||
alt={listing.title}
|
||||
open={showLightbox}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,475 +1,86 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { fetchApiJson } from "@/lib/api/fetchApiJson";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { getSiteSeoMeta } from "@/lib/i18n/seo";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import ListingDetailClient from "./ListingDetailClient";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ProductImageLightbox from "@/components/shops/ProductImageLightbox";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { addToCart, isFavorite, toggleFavorite } from "@/lib/shops/localCart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing, IShopProductVariant } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
interface IListingPageProps {
|
||||
params: Promise<{ listingId: string }>;
|
||||
}
|
||||
|
||||
type ShippingMethod = { method: string; cost: number; enabled: boolean };
|
||||
type ShopHeader = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
shipping_methods?: ShippingMethod[];
|
||||
};
|
||||
type ShopInfo = { _id: string; name: string; logo?: string | null };
|
||||
|
||||
type ListingResponse = { listing?: IShopProductListing & { shop?: ShopInfo | string } };
|
||||
|
||||
function uniqueValues(values: (string | null | undefined)[]): string[] {
|
||||
return Array.from(new Set(values.filter((v): v is string => Boolean(v))));
|
||||
}
|
||||
|
||||
function SpecRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2 rounded-3xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
|
||||
<span className="shrink-0 text-sm font-bold">{label}</span>
|
||||
<div className="flex flex-1 flex-wrap items-center justify-end gap-2 text-right">
|
||||
{children}
|
||||
</div>
|
||||
<BoldIcon name="arrow-down" size={16} tinted className="shrink-0 text-neutral-400" />
|
||||
</div>
|
||||
async function loadListing(listingId: string) {
|
||||
const { data } = await fetchApiJson<ListingResponse>(`/shop-products/${listingId}`);
|
||||
return data?.listing ?? null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: IListingPageProps): Promise<Metadata> {
|
||||
const { listingId } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const path = `/shops/listing/${listingId}`;
|
||||
|
||||
const listing = await loadListing(listingId);
|
||||
const shop =
|
||||
listing?.shop && typeof listing.shop === "object" ? listing.shop : null;
|
||||
|
||||
if (!listing) {
|
||||
const meta = generatePageMetadata({
|
||||
title: `کالا یافت نشد | ${site.titleSuffix}`,
|
||||
description: site.defaultDescription,
|
||||
path,
|
||||
index: false,
|
||||
lang,
|
||||
});
|
||||
return { ...meta, title: { absolute: `کالا یافت نشد | ${site.titleSuffix}` } };
|
||||
}
|
||||
|
||||
const title = `${listing.title}${shop?.name ? ` - در فروشگاه (${shop.name})` : ""} | ${site.titleSuffix}`;
|
||||
|
||||
const colors = uniqueValues(listing.variants?.map((v) => v.color));
|
||||
const sizes = uniqueValues(listing.variants?.map((v) => v.size));
|
||||
const weights = uniqueValues(listing.variants?.map((v) => v.weight));
|
||||
const prices = (listing.variants || []).map((v) =>
|
||||
v.discount_price != null ? v.discount_price : v.price
|
||||
);
|
||||
const cheapest = prices.length ? Math.min(...prices) : null;
|
||||
const priceText = cheapest != null ? `${cheapest.toLocaleString("fa-IR")} تومان` : "";
|
||||
|
||||
const description = [
|
||||
listing.description,
|
||||
colors.length ? colors.join("، ") : null,
|
||||
sizes.length ? sizes.join("، ") : null,
|
||||
weights.length ? weights.join("، ") : null,
|
||||
priceText || null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - ") || site.defaultDescription;
|
||||
|
||||
const primaryImage = listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
|
||||
const meta = generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
type: "website",
|
||||
imageUrl: primaryImage ? buildStorageUrl(primaryImage) : undefined,
|
||||
imageAlt: listing.title,
|
||||
lang,
|
||||
});
|
||||
|
||||
return { ...meta, title: { absolute: title } };
|
||||
}
|
||||
|
||||
export default function ListingDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ listingId: string }>();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [listing, setListing] = useState<IShopProductListing | null>(null);
|
||||
const [selectedColor, setSelectedColor] = useState<string | null>(null);
|
||||
const [selectedSize, setSelectedSize] = useState<string | null>(null);
|
||||
const [selectedWeight, setSelectedWeight] = useState<string | null>(null);
|
||||
const [shippingMethod, setShippingMethod] = useState<string>("");
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [showAddressModal, setShowAddressModal] = useState(false);
|
||||
const [showLightbox, setShowLightbox] = useState(false);
|
||||
const [favorite, setFavorite] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ listing: IShopProductListing }>("GET", `/shop-products/${params.listingId}`).then(
|
||||
(res) => {
|
||||
const data = res?.listing || null;
|
||||
setListing(data);
|
||||
const firstVariant = data?.variants?.[0];
|
||||
if (firstVariant) {
|
||||
setSelectedColor(firstVariant.color || null);
|
||||
setSelectedSize(firstVariant.size || null);
|
||||
setSelectedWeight(firstVariant.weight || null);
|
||||
}
|
||||
if (data) setFavorite(isFavorite(data._id));
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params.listingId]);
|
||||
|
||||
const shopInfo: ShopHeader | null =
|
||||
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
|
||||
|
||||
const colors = useMemo(
|
||||
() => uniqueValues(listing?.variants.map((v) => v.color) || []),
|
||||
[listing]
|
||||
);
|
||||
const sizes = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) => colors.length === 0 || v.color === selectedColor
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.size));
|
||||
}, [listing, colors, selectedColor]);
|
||||
const weights = useMemo(() => {
|
||||
if (!listing) return [];
|
||||
const relevant = listing.variants.filter(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize)
|
||||
);
|
||||
return uniqueValues(relevant.map((v) => v.weight));
|
||||
}, [listing, colors, sizes, selectedColor, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sizes.length > 0 && (!selectedSize || !sizes.includes(selectedSize))) {
|
||||
setSelectedSize(sizes[0]);
|
||||
}
|
||||
}, [sizes, selectedSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (weights.length > 0 && (!selectedWeight || !weights.includes(selectedWeight))) {
|
||||
setSelectedWeight(weights[0]);
|
||||
}
|
||||
}, [weights, selectedWeight]);
|
||||
|
||||
const matchedVariant: IShopProductVariant | null = useMemo(() => {
|
||||
if (!listing) return null;
|
||||
return (
|
||||
listing.variants.find(
|
||||
(v) =>
|
||||
(colors.length === 0 || v.color === selectedColor) &&
|
||||
(sizes.length === 0 || v.size === selectedSize) &&
|
||||
(weights.length === 0 || v.weight === selectedWeight)
|
||||
) ||
|
||||
listing.variants[0] ||
|
||||
null
|
||||
);
|
||||
}, [listing, colors, sizes, weights, selectedColor, selectedSize, selectedWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuantity(1);
|
||||
}, [matchedVariant?._id]);
|
||||
|
||||
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
|
||||
|
||||
const categoryLine = listing
|
||||
? [listing.category, listing.sub_category, listing.sub_sub_category]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
: "";
|
||||
|
||||
const discountPercent =
|
||||
matchedVariant?.discount_price && matchedVariant.discount_price < matchedVariant.price
|
||||
? Math.round(
|
||||
((matchedVariant.price - matchedVariant.discount_price) / matchedVariant.price) * 100
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleImageScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = event.currentTarget;
|
||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveImageIndex(index);
|
||||
};
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
try {
|
||||
const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", {
|
||||
listingId: listing._id,
|
||||
variantId: matchedVariant._id,
|
||||
quantity,
|
||||
shippingMethod: shippingMethod || undefined,
|
||||
});
|
||||
if (response?.order?._id) {
|
||||
router.push(`/shops/checkout/${response.order._id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: { code?: string; message?: string } } })
|
||||
?.response?.data;
|
||||
if (data?.code === "ADDRESS_REQUIRED") {
|
||||
setShowAddressModal(true);
|
||||
return;
|
||||
}
|
||||
toast.error(data?.message || t("shops.unknownError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!listing || !matchedVariant) return;
|
||||
addToCart(listing._id, matchedVariant._id, quantity);
|
||||
toast.success(t("shops.addToCartSuccess"));
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (!listing) return;
|
||||
const next = toggleFavorite(listing._id);
|
||||
setFavorite(next);
|
||||
toast.success(next ? t("shops.addedToFavorites") : t("shops.removedFromFavorites"));
|
||||
};
|
||||
|
||||
if (!listing) return null;
|
||||
|
||||
const images = listing.images || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm font-bold">{shopInfo?.name}</span>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shopInfo?.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopInfo.logo}
|
||||
alt={shopInfo.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-3xl border border-neutral-200 p-4 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-right text-xl font-bold">{listing.title}</h1>
|
||||
{discountPercent != null && (
|
||||
<span className="relative flex h-11 w-11 shrink-0 items-center justify-center">
|
||||
<span className="absolute inset-0 rounded-lg bg-red-500" />
|
||||
<span className="absolute inset-0 rotate-[22.5deg] rounded-lg bg-red-500" />
|
||||
<span className="relative z-10 text-sm font-extrabold text-white">
|
||||
{t("shops.discountPercentBadge", { percent: discountPercent })}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="relative">
|
||||
<div
|
||||
onScroll={handleImageScroll}
|
||||
className="flex w-full snap-x snap-mandatory gap-0 overflow-x-auto rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{images.map((img) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setShowLightbox(true)}
|
||||
className="relative aspect-[4/3] w-full shrink-0 snap-center overflow-hidden bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + img}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="400px"
|
||||
unoptimized
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{images.length > 1 && (
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{activeImageIndex + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm font-bold">{t("shops.specsTitle")}</p>
|
||||
|
||||
{categoryLine && (
|
||||
<SpecRow label={t("shops.categorySpecLabel")}>
|
||||
<span className="text-xs">{categoryLine}</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{colors.length > 0 && (
|
||||
<SpecRow label={t("shops.colorLabel")}>
|
||||
{colors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
onClick={() => setSelectedColor(color)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedColor === color
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{color}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{sizes.length > 0 && (
|
||||
<SpecRow label={t("shops.sizeLabel")}>
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
type="button"
|
||||
onClick={() => setSelectedSize(size)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedSize === size
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{weights.length > 0 && (
|
||||
<SpecRow label={t("shops.weightLabel")}>
|
||||
{weights.map((weight) => (
|
||||
<button
|
||||
key={weight}
|
||||
type="button"
|
||||
onClick={() => setSelectedWeight(weight)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
selectedWeight === weight
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{weight}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{enabledShippingMethods.length > 0 && (
|
||||
<SpecRow label={t("shops.shippingTitle")}>
|
||||
{enabledShippingMethods.map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
type="button"
|
||||
onClick={() => setShippingMethod(m.method)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs",
|
||||
shippingMethod === m.method
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
>
|
||||
{t(`shops.shippingMethods.${m.method}`)}
|
||||
</button>
|
||||
))}
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
{listing.description && (
|
||||
<SpecRow label={t("shops.descriptionLabel")}>
|
||||
<span className="text-xs leading-relaxed">
|
||||
{descriptionExpanded || listing.description.length <= 50
|
||||
? listing.description
|
||||
: `${listing.description.slice(0, 50)}...`}
|
||||
{listing.description.length > 50 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDescriptionExpanded((v) => !v)}
|
||||
className="mr-1 font-bold text-pink-500"
|
||||
>
|
||||
{descriptionExpanded ? t("shops.showLess") : t("shops.showMore")}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</SpecRow>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleBuy()}
|
||||
disabled={loading || !matchedVariant}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{t("shops.placeOrderButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 shrink-0 text-center text-sm font-bold">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setQuantity((q) =>
|
||||
matchedVariant ? Math.min(matchedVariant.stock, q + 1) : q + 1
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-green-600 text-lg font-bold text-white"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{matchedVariant && (
|
||||
<SpecRow label={t("shops.priceSpecLabel")}>
|
||||
{matchedVariant.discount_price ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-400 line-through">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-red-500">
|
||||
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm font-bold">
|
||||
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
)}
|
||||
</SpecRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 rounded-3xl bg-green-600 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.addToCartButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleFavorite}
|
||||
className="flex-1 rounded-3xl bg-sky-500 py-3 text-sm font-bold text-white"
|
||||
>
|
||||
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/settings/edit/location")}
|
||||
className="w-full rounded-3xl bg-pink-500 py-2.5 text-sm font-bold text-white"
|
||||
>
|
||||
{t("shops.goToLocationSettings")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 block w-full text-xs text-neutral-500"
|
||||
onClick={() => setShowAddressModal(false)}
|
||||
>
|
||||
{t("auth.skip")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductImageLightbox
|
||||
images={images}
|
||||
initialIndex={activeImageIndex}
|
||||
alt={listing.title}
|
||||
open={showLightbox}
|
||||
onClose={() => setShowLightbox(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <ListingDetailClient />;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import ShopInfoModal from "@/components/shops/orders/ShopInfoModal";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -45,7 +45,9 @@ type OrderDetail = {
|
||||
tracking_code?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
isPaymentExpired?: boolean;
|
||||
buyer: string;
|
||||
variantId: string;
|
||||
returnWindowExpiresAt?: string | null;
|
||||
variantSnapshot: { color?: string | null; size?: string | null; weight?: string | null };
|
||||
buyerAddressSnapshot?: {
|
||||
@@ -58,10 +60,11 @@ type OrderDetail = {
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
} | null;
|
||||
listing: { title: string; images: string[]; primaryImageIndex: number };
|
||||
listing: { _id: string; title: string; images: string[]; primaryImageIndex: number };
|
||||
shop: {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
owner: string;
|
||||
estimated_delivery_text?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
@@ -76,15 +79,20 @@ type OrderDetail = {
|
||||
export default function OrderDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const params = useParams<{ orderId: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
const [repurchasing, setRepurchasing] = useState(false);
|
||||
|
||||
const [order, setOrder] = useState<OrderDetail | null>(null);
|
||||
const [report, setReport] = useState<OrderReport | null>(null);
|
||||
const [returnRequest, setReturnRequest] = useState<ReturnRequest | null>(null);
|
||||
const [shopRating, setShopRating] = useState<ShopRating | null>(null);
|
||||
const [trackingCode, setTrackingCode] = useState("");
|
||||
const [printMode, setPrintMode] = useState<"invoice" | null>(null);
|
||||
const [printMode, setPrintMode] = useState<"invoice" | "label-a5" | "label-a6" | null>(
|
||||
null
|
||||
);
|
||||
const [showRating, setShowRating] = useState(false);
|
||||
const [ratingScore, setRatingScore] = useState(0);
|
||||
const [ratingComment, setRatingComment] = useState("");
|
||||
@@ -128,6 +136,13 @@ export default function OrderDetailPage() {
|
||||
|
||||
const isSeller = Boolean(order && user && order.shop.owner === user._id);
|
||||
const isBuyer = Boolean(order && user && order.buyer === user._id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBuyer && searchParams.get("autoDownload") === "1") {
|
||||
setPrintMode("invoice");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isBuyer]);
|
||||
const canConfirmReceipt =
|
||||
isBuyer && order && ["shipped", "ready_for_pickup"].includes(order.status);
|
||||
const canRequestReturn =
|
||||
@@ -135,6 +150,28 @@ export default function OrderDetailPage() {
|
||||
order?.returnWindowExpiresAt &&
|
||||
new Date() < new Date(order.returnWindowExpiresAt);
|
||||
|
||||
const handleRepurchase = async () => {
|
||||
if (!order) return;
|
||||
setRepurchasing(true);
|
||||
try {
|
||||
const response = await request<{ order: { _id: string } }>("POST", "/orders/draft", {
|
||||
listingId: order.listing._id,
|
||||
variantId: order.variantId,
|
||||
quantity: order.quantity,
|
||||
shippingMethod: order.shipping_method || undefined,
|
||||
});
|
||||
if (response?.order?._id) {
|
||||
router.push(`/shops/checkout/${response.order._id}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const data = (err as { response?: { data?: { code?: string; message?: string } } })
|
||||
?.response?.data;
|
||||
toast.error(data?.message || t("shops.unknownError"));
|
||||
} finally {
|
||||
setRepurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReceipt = async () => {
|
||||
if (!order) return;
|
||||
try {
|
||||
@@ -372,6 +409,31 @@ export default function OrderDetailPage() {
|
||||
</AuthNextButton>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
<AuthNextButton type="button" onClick={() => setPrintMode("invoice")}>
|
||||
{t("shops.downloadInvoice")}
|
||||
</AuthNextButton>
|
||||
)}
|
||||
|
||||
{isSeller && (
|
||||
<div className="flex gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={() => setPrintMode("label-a5")}
|
||||
>
|
||||
{t("shops.printLabelA5")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={() => setPrintMode("label-a6")}
|
||||
>
|
||||
{t("shops.printLabelA6")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSeller && (
|
||||
<>
|
||||
<div>
|
||||
@@ -435,7 +497,24 @@ export default function OrderDetailPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBuyer && (
|
||||
{isBuyer && order.status === "pending_payment" && (
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={repurchasing}
|
||||
disabled={repurchasing}
|
||||
onClick={() =>
|
||||
order.isPaymentExpired
|
||||
? handleRepurchase()
|
||||
: router.push(`/shops/checkout/${order._id}`)
|
||||
}
|
||||
>
|
||||
{order.isPaymentExpired
|
||||
? t("shops.repurchase")
|
||||
: t("shops.completePurchase")}
|
||||
</AuthNextButton>
|
||||
)}
|
||||
|
||||
{isBuyer && order.status !== "pending_payment" && (
|
||||
<>
|
||||
{canConfirmReceipt && (
|
||||
<AuthNextButton type="button" onClick={handleConfirmReceipt}>
|
||||
@@ -593,12 +672,32 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(printMode === "label-a5" || printMode === "label-a6") && (
|
||||
<div className="print-only">
|
||||
<div className="p-6 text-sm">
|
||||
<p className="mb-4 text-lg font-bold">{order.shop.name}</p>
|
||||
<p>{t("shops.buyerInfoTitle")}:</p>
|
||||
<p>{buyerName}</p>
|
||||
<p>
|
||||
{order.buyerAddressSnapshot?.province?.name}{" "}
|
||||
{order.buyerAddressSnapshot?.city?.name}
|
||||
</p>
|
||||
<p>{order.buyerAddressSnapshot?.address}</p>
|
||||
{order.tracking_code && (
|
||||
<p className="mt-4">
|
||||
{t("shops.trackingCodeLabel")}: {order.tracking_code}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.print-only { display: none; }
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.print-only { display: block !important; }
|
||||
@page { size: A5; }
|
||||
@page { size: ${printMode === "label-a6" ? "A6" : "A5"}; }
|
||||
}
|
||||
`}</style>
|
||||
</Container>
|
||||
|
||||
@@ -5,6 +5,7 @@ import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -14,6 +15,23 @@ export default function ShopPaymentFailedPage() {
|
||||
const { request, loading } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const orderId = searchParams.get("orderId");
|
||||
const [shopId, setShopId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
request<{ order: { shop: { _id: string } | string } }>(
|
||||
"GET",
|
||||
`/orders/${orderId}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => {
|
||||
const shop = res?.order?.shop;
|
||||
setShopId(typeof shop === "object" ? shop._id : shop || null);
|
||||
})
|
||||
.catch(() => setShopId(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderId]);
|
||||
|
||||
const retry = async () => {
|
||||
if (!orderId) return;
|
||||
@@ -56,6 +74,14 @@ export default function ShopPaymentFailedPage() {
|
||||
>
|
||||
{t("shops.retryPayment")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-32 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() =>
|
||||
router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover")
|
||||
}
|
||||
>
|
||||
{t("shops.backToShopList")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
@@ -39,6 +39,12 @@ export default function ShopPaymentSuccessPage() {
|
||||
>
|
||||
{t("shops.viewOrder")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-48 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
{t("shops.backToHome")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -41,9 +43,11 @@ export default function ProductComparisonPage() {
|
||||
: "";
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{productName || t("shops.comparisonTitle")}</PageTitle>
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{productName || t("shops.comparisonTitle")}</PageTitle>
|
||||
|
||||
{listings === null ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
@@ -68,7 +72,7 @@ export default function ProductComparisonPage() {
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() => router.push(`/shops/listing/${listing._id}`)}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700"
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700"
|
||||
>
|
||||
{primaryImage && (
|
||||
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
|
||||
@@ -82,7 +86,7 @@ export default function ProductComparisonPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="truncate text-xs font-semibold text-neutral-500">
|
||||
{shop?.name}
|
||||
</p>
|
||||
@@ -94,12 +98,17 @@ export default function ProductComparisonPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-[#387E65] px-4 py-2 text-xs font-bold text-white">
|
||||
{t("shops.viewAndBuyButton")}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/shops/discover" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
384
src/app/shops/profile/[shopId]/ShopProfileClient.tsx
Normal file
384
src/app/shops/profile/[shopId]/ShopProfileClient.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ExpandableBio from "@/components/profile/ExpandableBio";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
|
||||
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LocationModal = dynamic(
|
||||
() => import("@/components/models/ModelPage/LocationModal"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type PublicShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name: string } | null;
|
||||
city?: { name: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
contact?: {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
} | null;
|
||||
response_schedule?: { day: string; start_time?: string | null; end_time?: string | null }[];
|
||||
owner?: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
} | null;
|
||||
productCount: number;
|
||||
rating: { average: number; count: number };
|
||||
isFollowing: boolean;
|
||||
followedAt?: string | null;
|
||||
isOwner: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type PerformanceTier = "weak" | "average" | "good" | "veryGood" | "excellent";
|
||||
|
||||
function getPerformanceTier(percent: number): PerformanceTier {
|
||||
if (percent < 60) return "weak";
|
||||
if (percent < 70) return "average";
|
||||
if (percent < 80) return "good";
|
||||
if (percent < 90) return "veryGood";
|
||||
return "excellent";
|
||||
}
|
||||
|
||||
function monthsSince(dateStr: string): number {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30));
|
||||
}
|
||||
|
||||
export default function ShopProfileClient() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
const shopId = params.shopId;
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
const currentUser = useUser();
|
||||
|
||||
const [shop, setShop] = useState<PublicShop | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [followedAt, setFollowedAt] = useState<string | null>(null);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
const [showOrdersModal, setShowOrdersModal] = useState(false);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
|
||||
.then((res) => {
|
||||
const data = res?.shop || null;
|
||||
setShop(data);
|
||||
setIsFollowing(Boolean(data?.isFollowing));
|
||||
setFollowedAt(data?.followedAt || null);
|
||||
})
|
||||
.catch(() => setShop(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
|
||||
const { data, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/shop-products",
|
||||
queryKey: ["shop-products", shopId],
|
||||
params: { shopId },
|
||||
});
|
||||
const listings: IShopProductListing[] =
|
||||
data?.pages.flatMap((page) => page.docs || []) || [];
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!requireAuth() || !shop || followLoading) return;
|
||||
const nextFollowing = !isFollowing;
|
||||
setIsFollowing(nextFollowing);
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
const res = await request<{ is_following: boolean; followed_at: string | null }>(
|
||||
"POST",
|
||||
`/shops/${shop._id}/follow`,
|
||||
{}
|
||||
);
|
||||
setIsFollowing(res?.is_following ?? nextFollowing);
|
||||
setFollowedAt(res?.followed_at || null);
|
||||
toast.success(
|
||||
(res?.is_following ?? nextFollowing)
|
||||
? t("shops.shopFollowSuccess")
|
||||
: t("shops.shopUnfollowSuccess")
|
||||
);
|
||||
} catch {
|
||||
setIsFollowing(!nextFollowing);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
if (!requireAuth() || !shop || !currentUser?._id) return;
|
||||
router.push(`/settings/chats/shop/${shop._id}/${currentUser._id}`);
|
||||
};
|
||||
|
||||
if (!shop) return null;
|
||||
|
||||
const percent =
|
||||
shop.rating.count > 0 ? Math.round((shop.rating.average / 5) * 100) : null;
|
||||
const performanceTier = percent != null ? getPerformanceTier(percent) : null;
|
||||
const hasDescription = Boolean(shop.description?.trim());
|
||||
|
||||
const shopTenureLabel = shop.createdAt
|
||||
? monthsSince(shop.createdAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(shop.createdAt) })
|
||||
: t("shops.followButton");
|
||||
|
||||
const followLabel = isFollowing
|
||||
? followedAt
|
||||
? monthsSince(followedAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(followedAt) })
|
||||
: t("shops.unfollowButton")
|
||||
: shopTenureLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mx-auto w-full max-w-md" dir={isFa ? "ltr" : "rtl"}>
|
||||
<div className="flex w-full items-center justify-between py-2">
|
||||
<div className="flex flex-col items-center text-xs md:text-sm">
|
||||
<div className="mt-2 flex gap-5">
|
||||
<div className="flex flex-col items-center font-semibold max-sm:text-[10px]">
|
||||
<span>{shop.productCount}</span>
|
||||
<span>{t("shops.productsLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>
|
||||
{performanceTier
|
||||
? t(`shops.performance.${performanceTier}`)
|
||||
: t("shops.underReview")}
|
||||
</span>
|
||||
<span>{t("shops.performanceLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{percent != null ? `${percent}%` : t("shops.newShop")}</span>
|
||||
<span>{t("shops.satisfactionLabel")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={shop.logo}
|
||||
alt={shop.name}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full flex-col",
|
||||
isFa ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"max-w-full truncate whitespace-nowrap",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-2 text-xs font-semibold md:text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-3",
|
||||
hasDescription
|
||||
? "items-baseline justify-between"
|
||||
: "items-center justify-end"
|
||||
)}
|
||||
>
|
||||
{hasDescription ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 leading-6",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
<ExpandableBio
|
||||
bio={shop.description}
|
||||
className={cn("leading-6", isFa ? "text-right" : "text-left")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 md:mt-4 md:gap-4">
|
||||
<button type="button" onClick={handleMessage} className={profileActionBtnClass}>
|
||||
{t("shops.sendMessage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOrdersModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.myOrdersFromShop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowContactModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.contactInfoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleFollow()}
|
||||
disabled={followLoading}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{shop.has_physical_location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.showLocation")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.share.title")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
|
||||
{listings.map((listing) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return (
|
||||
<button
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(`/shops/profile/${shopId}/reel/${listing._id}`)
|
||||
}
|
||||
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
|
||||
>
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
<ShareShopModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
shopLogo={shop.logo}
|
||||
responseSchedule={shop.response_schedule}
|
||||
/>
|
||||
<ShopContactInfoModal
|
||||
open={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
contactInfo={shop.contact || {}}
|
||||
/>
|
||||
<ShopMyOrdersModal
|
||||
open={showOrdersModal}
|
||||
onClose={() => setShowOrdersModal(false)}
|
||||
shopId={shop._id}
|
||||
/>
|
||||
{showLocationModal && shop.has_physical_location && (
|
||||
<LocationModal
|
||||
isOpen={showLocationModal}
|
||||
onClose={() => setShowLocationModal(false)}
|
||||
location={{
|
||||
address: shop.address || undefined,
|
||||
lat: shop.lat || undefined,
|
||||
lng: shop.lng || undefined,
|
||||
city: shop.city || undefined,
|
||||
province: shop.province || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,16 @@
|
||||
"use client";
|
||||
import type { Metadata } from "next";
|
||||
import { fetchApiJson } from "@/lib/api/fetchApiJson";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { getSiteSeoMeta } from "@/lib/i18n/seo";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ShopProfileClient from "./ShopProfileClient";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ExpandableBio from "@/components/profile/ExpandableBio";
|
||||
import ShareShopModal from "@/components/shops/ShareShopModal";
|
||||
import ShopContactInfoModal from "@/components/shops/ShopContactInfoModal";
|
||||
import ShopMyOrdersModal from "@/components/shops/ShopMyOrdersModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import { profileActionBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IShopProductListing } from "@/types/types";
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LocationModal = dynamic(
|
||||
() => import("@/components/models/ModelPage/LocationModal"),
|
||||
{ ssr: false }
|
||||
);
|
||||
interface IShopProfilePageProps {
|
||||
params: Promise<{ shopId: string }>;
|
||||
}
|
||||
|
||||
type PublicShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
description?: string | null;
|
||||
@@ -38,347 +18,59 @@ type PublicShop = {
|
||||
province?: { name: string } | null;
|
||||
city?: { name: string } | null;
|
||||
address?: string | null;
|
||||
lat?: string | null;
|
||||
lng?: string | null;
|
||||
contact?: {
|
||||
mobile?: string | null;
|
||||
landline?: string | null;
|
||||
telegram?: string | null;
|
||||
whatsapp?: string | null;
|
||||
instagram?: string | null;
|
||||
} | null;
|
||||
response_schedule?: { day: string; start_time?: string | null; end_time?: string | null }[];
|
||||
owner?: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
} | null;
|
||||
productCount: number;
|
||||
rating: { average: number; count: number };
|
||||
isFollowing: boolean;
|
||||
followedAt?: string | null;
|
||||
isOwner: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
type PerformanceTier = "weak" | "average" | "good" | "veryGood" | "excellent";
|
||||
|
||||
function getPerformanceTier(percent: number): PerformanceTier {
|
||||
if (percent < 60) return "weak";
|
||||
if (percent < 70) return "average";
|
||||
if (percent < 80) return "good";
|
||||
if (percent < 90) return "veryGood";
|
||||
return "excellent";
|
||||
async function loadShop(shopId: string) {
|
||||
const { data } = await fetchApiJson<{ shop?: PublicShop }>(`/shops/public/${shopId}`);
|
||||
return data?.shop ?? null;
|
||||
}
|
||||
|
||||
function monthsSince(dateStr: string): number {
|
||||
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30));
|
||||
}
|
||||
export async function generateMetadata({ params }: IShopProfilePageProps): Promise<Metadata> {
|
||||
const { shopId } = await params;
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const path = `/shops/profile/${shopId}`;
|
||||
|
||||
export default function ShopPublicProfilePage() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const isFa = (i18n.language || "fa").toLowerCase().startsWith("fa");
|
||||
const router = useRouter();
|
||||
const params = useParams<{ shopId: string }>();
|
||||
const shopId = params.shopId;
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
const currentUser = useUser();
|
||||
const shop = await loadShop(shopId);
|
||||
|
||||
const [shop, setShop] = useState<PublicShop | null>(null);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [followedAt, setFollowedAt] = useState<string | null>(null);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [showContactModal, setShowContactModal] = useState(false);
|
||||
const [showOrdersModal, setShowOrdersModal] = useState(false);
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
if (!shop) {
|
||||
const title = `فروشگاه یافت نشد | ${site.titleSuffix}`;
|
||||
const meta = generatePageMetadata({
|
||||
title,
|
||||
description: site.defaultDescription,
|
||||
path,
|
||||
index: false,
|
||||
lang,
|
||||
});
|
||||
return { ...meta, title: { absolute: title } };
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
request<{ shop: PublicShop }>("GET", `/shops/public/${shopId}`)
|
||||
.then((res) => {
|
||||
const data = res?.shop || null;
|
||||
setShop(data);
|
||||
setIsFollowing(Boolean(data?.isFollowing));
|
||||
setFollowedAt(data?.followedAt || null);
|
||||
})
|
||||
.catch(() => setShop(null));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
const title = [shop.name, shop.province?.name, shop.city?.name]
|
||||
.filter(Boolean)
|
||||
.join(" - ") + ` | ${site.titleSuffix}`;
|
||||
|
||||
const { data, isLoading } = useInfiniteScroll({
|
||||
endpoint: "/shop-products",
|
||||
queryKey: ["shop-products", shopId],
|
||||
params: { shopId },
|
||||
const description =
|
||||
[
|
||||
shop.description,
|
||||
shop.address,
|
||||
shop.has_physical_location ? "حضوری" : "اینترنتی",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - ") || site.defaultDescription;
|
||||
|
||||
const meta = generatePageMetadata({
|
||||
title,
|
||||
description,
|
||||
path,
|
||||
type: "website",
|
||||
imageUrl: shop.logo ? buildStorageUrl(shop.logo) : undefined,
|
||||
imageAlt: shop.name,
|
||||
lang,
|
||||
});
|
||||
const listings: IShopProductListing[] =
|
||||
data?.pages.flatMap((page) => page.docs || []) || [];
|
||||
|
||||
const toggleFollow = async () => {
|
||||
if (!requireAuth() || !shop || followLoading) return;
|
||||
const nextFollowing = !isFollowing;
|
||||
setIsFollowing(nextFollowing);
|
||||
setFollowLoading(true);
|
||||
try {
|
||||
const res = await request<{ is_following: boolean; followed_at: string | null }>(
|
||||
"POST",
|
||||
`/shops/${shop._id}/follow`,
|
||||
{}
|
||||
);
|
||||
setIsFollowing(res?.is_following ?? nextFollowing);
|
||||
setFollowedAt(res?.followed_at || null);
|
||||
toast.success(
|
||||
(res?.is_following ?? nextFollowing)
|
||||
? t("shops.shopFollowSuccess")
|
||||
: t("shops.shopUnfollowSuccess")
|
||||
);
|
||||
} catch {
|
||||
setIsFollowing(!nextFollowing);
|
||||
} finally {
|
||||
setFollowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessage = () => {
|
||||
if (!requireAuth() || !shop || !currentUser?._id) return;
|
||||
router.push(`/settings/chats/shop/${shop._id}/${currentUser._id}`);
|
||||
};
|
||||
|
||||
if (!shop) return null;
|
||||
|
||||
const percent =
|
||||
shop.rating.count > 0 ? Math.round((shop.rating.average / 5) * 100) : null;
|
||||
const performanceTier = percent != null ? getPerformanceTier(percent) : null;
|
||||
const hasDescription = Boolean(shop.description?.trim());
|
||||
|
||||
const shopTenureLabel = shop.createdAt
|
||||
? monthsSince(shop.createdAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(shop.createdAt) })
|
||||
: t("shops.followButton");
|
||||
|
||||
const followLabel = isFollowing
|
||||
? followedAt
|
||||
? monthsSince(followedAt) < 1
|
||||
? t("shops.lessThanOneMonth")
|
||||
: t("shops.monthsCount", { count: monthsSince(followedAt) })
|
||||
: t("shops.unfollowButton")
|
||||
: shopTenureLabel;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mx-auto w-full max-w-md" dir={isFa ? "ltr" : "rtl"}>
|
||||
<div className="flex w-full items-center justify-between py-2">
|
||||
<div className="flex flex-col items-center text-xs md:text-sm">
|
||||
<div className="mt-2 flex gap-5">
|
||||
<div className="flex flex-col items-center font-semibold max-sm:text-[10px]">
|
||||
<span>{shop.productCount}</span>
|
||||
<span>{t("shops.productsLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#0C8002] max-sm:text-[10px]">
|
||||
<span>
|
||||
{performanceTier
|
||||
? t(`shops.performance.${performanceTier}`)
|
||||
: t("shops.underReview")}
|
||||
</span>
|
||||
<span>{t("shops.performanceLabel")}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center font-semibold text-[#3A59A9] max-sm:text-[10px]">
|
||||
<span>{percent != null ? `${percent}%` : t("shops.newShop")}</span>
|
||||
<span>{t("shops.satisfactionLabel")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={shop.logo}
|
||||
alt={shop.name}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full grid-cols-3 items-end py-2 text-xs font-semibold md:text-sm">
|
||||
<div className="flex items-center">
|
||||
<span>{shop.rating.count > 0 ? shop.rating.average.toFixed(1) : "0"}</span>
|
||||
<Image width={22} height={22} alt="star icon" src="/images/icons/star1.png" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span>{shop.rating.count}</span>
|
||||
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full flex-col",
|
||||
isFa ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
<h3
|
||||
className={cn(
|
||||
"max-w-full truncate whitespace-nowrap",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full py-2 text-xs font-semibold md:text-sm">
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-3",
|
||||
hasDescription
|
||||
? "items-baseline justify-between"
|
||||
: "items-center justify-end"
|
||||
)}
|
||||
>
|
||||
{hasDescription ? (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1 leading-6",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
<ExpandableBio
|
||||
bio={shop.description}
|
||||
className={cn("leading-6", isFa ? "text-right" : "text-left")}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 leading-6 text-[#387E65]",
|
||||
isFa ? "text-right" : "text-left"
|
||||
)}
|
||||
>
|
||||
{shop.has_physical_location ? t("shops.inPerson") : t("shops.onlineOnly")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-3 gap-1 md:mt-4 md:gap-4">
|
||||
<button type="button" onClick={handleMessage} className={profileActionBtnClass}>
|
||||
{t("shops.sendMessage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOrdersModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.myOrdersFromShop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowContactModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.contactInfoButton")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleFollow()}
|
||||
disabled={followLoading}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{followLabel}
|
||||
</button>
|
||||
{shop.has_physical_location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.showLocation")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowShareModal(true)}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("shops.share.title")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : listings.length === 0 ? (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("shops.noProductsYet")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
|
||||
{listings.map((listing) => {
|
||||
const primaryImage =
|
||||
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return (
|
||||
<button
|
||||
key={listing._id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(`/shops/profile/${shopId}/reel/${listing._id}`)
|
||||
}
|
||||
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
|
||||
>
|
||||
{primaryImage && (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
alt={listing.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
<ShareShopModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
shopId={shop._id}
|
||||
shopName={shop.name}
|
||||
shopLogo={shop.logo}
|
||||
responseSchedule={shop.response_schedule}
|
||||
/>
|
||||
<ShopContactInfoModal
|
||||
open={showContactModal}
|
||||
onClose={() => setShowContactModal(false)}
|
||||
contactInfo={shop.contact || {}}
|
||||
/>
|
||||
<ShopMyOrdersModal
|
||||
open={showOrdersModal}
|
||||
onClose={() => setShowOrdersModal(false)}
|
||||
shopId={shop._id}
|
||||
/>
|
||||
{showLocationModal && shop.has_physical_location && (
|
||||
<LocationModal
|
||||
isOpen={showLocationModal}
|
||||
onClose={() => setShowLocationModal(false)}
|
||||
location={{
|
||||
address: shop.address || undefined,
|
||||
lat: shop.lat || undefined,
|
||||
lng: shop.lng || undefined,
|
||||
city: shop.city || undefined,
|
||||
province: shop.province || undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return { ...meta, title: { absolute: title } };
|
||||
}
|
||||
|
||||
export default function ShopProfilePage() {
|
||||
return <ShopProfileClient />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import AuthSessionSync from "@/components/auth/AuthSessionSync";
|
||||
import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
|
||||
import PwaBootSplash from "@/components/pwa/PwaBootSplash";
|
||||
import PwaHead from "@/components/PwaHead";
|
||||
import NotificationBridge from "@/components/notifications/NotificationBridge";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -29,6 +30,7 @@ function Layout({ children }: ILayoutProps) {
|
||||
<AuthSessionSync />
|
||||
<PwaInstallPrompt />
|
||||
<BackgroundPrefetch />
|
||||
<NotificationBridge />
|
||||
{children}
|
||||
</ReactQueryProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Step1 from "./Step1";
|
||||
import Step2 from "./Step2";
|
||||
import Step3 from "./Step3";
|
||||
import Step4 from "./Step4";
|
||||
import Step5 from "./Step5";
|
||||
|
||||
const MultiStepForm = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col items-center w-full">
|
||||
{step === 1 && <Step1 nextStep={() => setStep(2)} />}
|
||||
{step === 2 && <Step2 nextStep={() => setStep(3)} />}
|
||||
{step === 3 && <Step3 nextStep={() => setStep(4)} />}
|
||||
{step === 4 && <Step4 nextStep={() => setStep(5)} />}
|
||||
{step === 5 && <Step5 />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiStepForm;
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import LocationSelector from "./step1/LocationSelector";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import SelectBox from "@/components/elements/SelectBox";
|
||||
import { IAdvertisingCategory } from "@/types/types";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import ImageUploader from "./step1/ImageUploader";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Step1 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const [categories, setCategories] = useState<IAdvertisingCategory[]>([]);
|
||||
const { request } = useAxios();
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
stateId: Yup.string().required(t("billboards.form.validation.provinceRequired")),
|
||||
cityId: Yup.string().required(t("billboards.form.validation.cityRequired")),
|
||||
categoryId: Yup.string().required(t("billboards.form.validation.categoryRequired")),
|
||||
adsTitle: Yup.string().required(t("billboards.form.validation.titleRequired")),
|
||||
description: Yup.string().required(t("billboards.form.validation.descriptionRequired")),
|
||||
neighbourhood: Yup.string().required(t("billboards.form.validation.neighbourhoodRequired")),
|
||||
address: Yup.string().required(t("billboards.form.validation.addressRequired")),
|
||||
images: Yup.array()
|
||||
.min(1, t("billboards.form.validation.minImages"))
|
||||
.max(5, t("billboards.form.validation.maxImages"))
|
||||
.required(t("billboards.form.validation.imagesRequired")),
|
||||
markerCoordinate: Yup.array()
|
||||
.of(Yup.number().required())
|
||||
.length(2, t("billboards.form.validation.mapLocationRequired"))
|
||||
.required(t("billboards.form.validation.mapLocationRequired")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ categories: IAdvertisingCategory[] }>(
|
||||
"GET",
|
||||
"/advertising/categories"
|
||||
);
|
||||
setCategories(response?.categories);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [request]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
cityId: formData.cityId || "",
|
||||
stateId: formData.stateId || "",
|
||||
adsTitle: formData.adsTitle || "",
|
||||
categoryId: formData.categoryId || "",
|
||||
images: formData.images || [],
|
||||
address: formData.address || "",
|
||||
neighbourhood: formData.neighbourhood || "",
|
||||
description: formData.description || "",
|
||||
markerCoordinate: formData.markerCoordinate || null,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
<SelectBox
|
||||
className={`max-w-full bg-secondary-light dark:bg-secondary-dark ${
|
||||
formik.touched.categoryId && formik.errors.categoryId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
value={formik.values.categoryId}
|
||||
onChange={(e) => formik.setFieldValue("categoryId", e.target.value)}
|
||||
>
|
||||
<option className="bg-secondary-light dark:bg-secondary-dark" disabled value="">
|
||||
{t("billboards.category")}
|
||||
</option>
|
||||
{categories?.map((item: IAdvertisingCategory) => (
|
||||
<option
|
||||
className="bg-secondary-light dark:bg-secondary-dark"
|
||||
key={item._id}
|
||||
value={item.title}
|
||||
>
|
||||
{item?.title}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.categoryId && formik.errors.categoryId && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.categoryId}</p>
|
||||
)}
|
||||
<hr />
|
||||
<RoundedInput
|
||||
className={`max-w-full ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.title")}
|
||||
{...formik.getFieldProps("adsTitle")}
|
||||
/>
|
||||
{formik.touched.adsTitle && formik.errors.adsTitle && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.adsTitle}</p>
|
||||
)}
|
||||
<textarea
|
||||
className={`w-full p-4 mt-2 h-24 rounded-3xl border
|
||||
${
|
||||
formik.touched.description && formik.errors.description
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}
|
||||
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 border font-medium`}
|
||||
placeholder={t("billboards.description")}
|
||||
{...formik.getFieldProps("description")}
|
||||
/>
|
||||
{formik.touched.description && formik.errors.description && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.description}</p>
|
||||
)}
|
||||
|
||||
<hr className="w-full my-2" />
|
||||
<LocationSelector formik={formik} />
|
||||
<ImageUploader formik={formik} />
|
||||
{formik.touched.images && formik.errors.images && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.images}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step1;
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Service } from "@/types/types";
|
||||
import ServiceItem from "@/components/main/Services/ServiceItem";
|
||||
import AddServiceModal from "@/components/main/Services/AddServiceModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Step2 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
services: Yup.array().min(1, t("billboards.form.validation.minServices")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
services: formData.services || [],
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
},
|
||||
});
|
||||
|
||||
const services = formik.values.services;
|
||||
|
||||
const handleAddService = (newService: Service) => {
|
||||
formik.setFieldValue("services", [...services, newService]);
|
||||
};
|
||||
|
||||
const handleDeleteService = (id: string) => {
|
||||
formik.setFieldValue(
|
||||
"services",
|
||||
services.filter((service) => service.id !== id)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm pb-28"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
<div className="gap-4 min-h-96 w-full">
|
||||
{services.map((service) => (
|
||||
<ServiceItem
|
||||
onDelete={handleDeleteService}
|
||||
key={service.id}
|
||||
service={service}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<hr className="w-full" />
|
||||
<div className="sticky bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-20 flex w-full justify-center gap-4 bg-background/95 py-3 backdrop-blur-sm">
|
||||
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
|
||||
type="button"
|
||||
>
|
||||
{t("billboards.add")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onAdd={handleAddService}
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step2;
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface IFeatureFetch {
|
||||
title?: string;
|
||||
value: boolean;
|
||||
_id: string;
|
||||
}
|
||||
|
||||
const Step3 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const { request } = useAxios();
|
||||
const [features, setFeatures] = useState<IFeatureFetch[]>([]);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
selectedFeatures: Yup.array().min(1, t("billboards.form.validation.minFeatures")),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ features: IFeatureFetch[] }>(
|
||||
"GET",
|
||||
"/advertising/features"
|
||||
);
|
||||
setFeatures(response.features);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [request]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedFeatures: formData.selectedFeatures || [],
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
},
|
||||
enableReinitialize: true,
|
||||
});
|
||||
|
||||
const handleSelect = (
|
||||
featureId: string,
|
||||
value: boolean,
|
||||
title: string | undefined
|
||||
) => {
|
||||
formik.setFieldValue("selectedFeatures", [
|
||||
...formik.values.selectedFeatures.filter(
|
||||
(feature) => feature._id !== featureId
|
||||
),
|
||||
{ _id: featureId, value, title: title },
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-4 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
|
||||
<div className="flex flex-col w-full gap-3">
|
||||
{features.map((feature: IFeatureFetch) => {
|
||||
const isSelected = formik.values.selectedFeatures.some(
|
||||
(featureItem) =>
|
||||
featureItem._id === feature._id && featureItem.value === true
|
||||
);
|
||||
const isDeselected = formik.values.selectedFeatures.some(
|
||||
(featureItem) =>
|
||||
featureItem._id === feature._id && featureItem.value === false
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={feature._id}
|
||||
className="flex justify-between items-center w-full p-4 border-b border-gray-300"
|
||||
>
|
||||
<span className="text-lg">{feature.title}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`feature-${feature._id}`}
|
||||
value="true"
|
||||
checked={isSelected}
|
||||
onChange={() =>
|
||||
handleSelect(feature._id, true, feature?.title)
|
||||
}
|
||||
className="hidden"
|
||||
/>
|
||||
<span>{t("billboards.form.has")}</span>
|
||||
<div
|
||||
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-green-500`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#01A168"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`feature-${feature._id}`}
|
||||
value="false"
|
||||
checked={isDeselected}
|
||||
onChange={() =>
|
||||
handleSelect(feature._id, false, feature?.title)
|
||||
}
|
||||
className="hidden"
|
||||
/>
|
||||
<span>{t("billboards.form.hasNot")}</span>
|
||||
<div
|
||||
className={`w-6 h-6 border-2 rounded-full flex items-center justify-center border-red-500`}
|
||||
>
|
||||
{isDeselected && (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#FF0000"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M20 6L9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{formik.touched.selectedFeatures &&
|
||||
formik.errors.selectedFeatures &&
|
||||
typeof formik.errors.selectedFeatures === "string" && (
|
||||
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step3;
|
||||
@@ -1,193 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
landline: Yup.string(),
|
||||
mobile: Yup.string(),
|
||||
telegram: Yup.string(),
|
||||
whatsapp: Yup.string(),
|
||||
instagram: Yup.string(),
|
||||
saveData: Yup.boolean(),
|
||||
});
|
||||
|
||||
interface ContactInfo {
|
||||
landline?: string;
|
||||
mobile?: string;
|
||||
telegram?: string;
|
||||
whatsapp?: string;
|
||||
instagram?: string;
|
||||
saveData?: boolean;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
contactInfo: ContactInfo;
|
||||
};
|
||||
}
|
||||
|
||||
const Step4 = ({ nextStep }: { nextStep: () => void }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData, updateForm } = useBillboardForm();
|
||||
const { request } = useAxios();
|
||||
const [isCheckedOne, setIsCheckedOne] = useState<boolean>(false);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
landline: formData.landline || "",
|
||||
mobile: formData.mobile || "",
|
||||
telegram: formData.telegram || "",
|
||||
whatsapp: formData.whatsapp || "",
|
||||
instagram: formData.instagram || "",
|
||||
saveData: formData.saveData || false,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: (values) => {
|
||||
if (
|
||||
!formik?.values?.landline &&
|
||||
!formik?.values?.mobile &&
|
||||
!formik?.values?.telegram &&
|
||||
!formik?.values?.whatsapp &&
|
||||
!formik?.values?.instagram
|
||||
) {
|
||||
toast.error(t("billboards.form.contactRequired"));
|
||||
} else {
|
||||
updateForm(values);
|
||||
nextStep();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<ApiResponse>(
|
||||
"GET",
|
||||
"/users/contact-info"
|
||||
);
|
||||
const contactInfo = response?.data?.contactInfo;
|
||||
|
||||
if (contactInfo) {
|
||||
formik.setFieldValue("landline", contactInfo.landline || "");
|
||||
formik.setFieldValue("mobile", contactInfo.mobile || "");
|
||||
formik.setFieldValue("telegram", contactInfo.telegram || "");
|
||||
formik.setFieldValue("whatsapp", contactInfo.whatsapp || "");
|
||||
formik.setFieldValue("instagram", contactInfo.instagram || "");
|
||||
formik.setFieldValue("saveData", contactInfo.saveData || false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching contact info:", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [request]);
|
||||
|
||||
const toggleCheckBox = () => {
|
||||
formik.setFieldValue("saveData", !isCheckedOne);
|
||||
setIsCheckedOne(!isCheckedOne);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.createTitle")}</h6>
|
||||
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.landline && formik.errors.landline
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
maxLength={11}
|
||||
placeholder={t("billboards.form.landline")}
|
||||
{...formik.getFieldProps("landline")}
|
||||
/>
|
||||
{formik.touched.landline && formik.errors.landline && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.landline}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
maxLength={11}
|
||||
placeholder={t("billboards.form.mobile")}
|
||||
{...formik.getFieldProps("mobile")}
|
||||
/>
|
||||
{formik.touched.mobile && formik.errors.mobile && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.mobile}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.telegram && formik.errors.telegram
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.form.telegram")}
|
||||
{...formik.getFieldProps("telegram")}
|
||||
/>
|
||||
{formik.touched.telegram && formik.errors.telegram && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.telegram}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.whatsapp && formik.errors.whatsapp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.form.whatsapp")}
|
||||
maxLength={11}
|
||||
{...formik.getFieldProps("whatsapp")}
|
||||
/>
|
||||
{formik.touched.whatsapp && formik.errors.whatsapp && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.whatsapp}</p>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full mt-2 ${
|
||||
formik.touched.instagram && formik.errors.instagram
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.form.instagram")}
|
||||
{...formik.getFieldProps("instagram")}
|
||||
/>
|
||||
{formik.touched.instagram && formik.errors.instagram && (
|
||||
<p className="text-red-500 text-xs">{formik.errors.instagram}</p>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 justify-start w-full pr-4 mt-4">
|
||||
<input
|
||||
{...formik.getFieldProps("stateId")}
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span>{t("billboards.form.saveContactInfo")}</span>
|
||||
</label>
|
||||
|
||||
<hr />
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{t("billboards.submitContinue")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step4;
|
||||
@@ -1,268 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useFormik } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IAdvertisingType } from "@/types/types";
|
||||
import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import { selectionCardClass } from "@/lib/ui/buttonStyles";
|
||||
import { useRouter } from "next/navigation";
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import { dataURLtoBlob } from "@/helpers/helpers";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { getBillboardDisplayTypeOptionLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CreateAdvertisingResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Step5 = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const { formData, updateForm, isEditing } = useBillboardForm();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [showDiscount, setShowDiscount] = useState<boolean>(false);
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() =>
|
||||
Yup.object({
|
||||
selectedType: Yup.string().required(
|
||||
t("billboards.form.validation.displayTypeRequired")
|
||||
),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const {
|
||||
adsTitle,
|
||||
categoryId,
|
||||
description,
|
||||
stateId,
|
||||
cityId,
|
||||
neighbourhood,
|
||||
address,
|
||||
markerCoordinate,
|
||||
services,
|
||||
selectedFeatures,
|
||||
landline,
|
||||
mobile,
|
||||
telegram,
|
||||
whatsapp,
|
||||
instagram,
|
||||
saveData,
|
||||
images,
|
||||
} = formData;
|
||||
|
||||
const toggleCheckBox = () => {
|
||||
setShowDiscount(!showDiscount);
|
||||
};
|
||||
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ advertisingTypes: IAdvertisingType[] }>(
|
||||
"GET",
|
||||
"/advertising/types"
|
||||
);
|
||||
setTypeList(response?.advertisingTypes || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: formData.selectedType || "",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async (values) => {
|
||||
updateForm(values);
|
||||
|
||||
const data = new FormData();
|
||||
data.append("title", adsTitle);
|
||||
data.append("category", categoryId);
|
||||
if (description) {
|
||||
data.append("description", description);
|
||||
}
|
||||
data.append("province", stateId ? stateId : "");
|
||||
data.append("city", cityId ? cityId : "");
|
||||
if (neighbourhood) {
|
||||
data.append("neighbourhood", neighbourhood);
|
||||
}
|
||||
if (address) {
|
||||
data.append("address", address);
|
||||
}
|
||||
if (markerCoordinate) {
|
||||
data.append("lat", markerCoordinate[0]);
|
||||
data.append("lng", markerCoordinate[1]);
|
||||
}
|
||||
data.append("services", JSON.stringify(services));
|
||||
data.append("features", JSON.stringify(selectedFeatures));
|
||||
if (landline) {
|
||||
data.append("contactInfo[phone]", landline);
|
||||
}
|
||||
if (mobile) {
|
||||
data.append("contactInfo[mobile]", mobile);
|
||||
}
|
||||
if (telegram) {
|
||||
data.append("contactInfo[telegramLink]", telegram);
|
||||
}
|
||||
if (whatsapp) {
|
||||
data.append("contactInfo[whatsappNumber]", whatsapp);
|
||||
}
|
||||
if (instagram) {
|
||||
data.append("contactInfo[instagramLink]", instagram);
|
||||
}
|
||||
if (saveData !== null && typeof saveData === "boolean") {
|
||||
data.append("contactInfo[saveInfoForNextAds]", String(saveData));
|
||||
}
|
||||
|
||||
if (showDiscount !== null && typeof showDiscount === "boolean") {
|
||||
data.append("showDiscount", String(showDiscount));
|
||||
}
|
||||
data.append("type", selectedType);
|
||||
|
||||
const mostDiscountPercentage =
|
||||
services.length > 0
|
||||
? services.reduce<number>((max, service) => {
|
||||
const discount =
|
||||
typeof service.discountPercentage === "number"
|
||||
? service.discountPercentage
|
||||
: 0;
|
||||
|
||||
return discount > max ? discount : max;
|
||||
}, 0)
|
||||
: 0;
|
||||
|
||||
if (mostDiscountPercentage) {
|
||||
data.append("mostDiscountPercentage", String(mostDiscountPercentage));
|
||||
}
|
||||
|
||||
images?.forEach((image) => {
|
||||
if (typeof image === "string" && image.startsWith("/advertising/")) {
|
||||
data.append("existingImages[]", image);
|
||||
} else {
|
||||
const blob = dataURLtoBlob(image);
|
||||
data.append("images", blob);
|
||||
}
|
||||
});
|
||||
|
||||
services.forEach((service) => {
|
||||
if (
|
||||
typeof service.image === "string" &&
|
||||
service.image.startsWith("/services/")
|
||||
) {
|
||||
data.append(`existingServiceImages[${service.id}]`, service.image);
|
||||
} else if (service.image) {
|
||||
const blob = dataURLtoBlob(service.image);
|
||||
data.append(
|
||||
`serviceImages[${service.id}]`,
|
||||
blob,
|
||||
`service-${service.id}.jpg`
|
||||
);
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (isEditing) {
|
||||
await request<{ id: string; message: string }>(
|
||||
"POST",
|
||||
`/advertising/edit/${formData?._id}`,
|
||||
data
|
||||
);
|
||||
localStorage.removeItem("billboardForm");
|
||||
router.push(`/settings/my-billboards`);
|
||||
} else {
|
||||
const response = await request<CreateAdvertisingResponse>(
|
||||
"POST",
|
||||
"/advertising/create",
|
||||
data
|
||||
);
|
||||
localStorage.removeItem("billboardForm");
|
||||
router.push(`/billboards/new/${response.data.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && formik.values.selectedType) {
|
||||
formik.submitForm();
|
||||
}
|
||||
}, [isEditing, formik.values.selectedType]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex flex-col gap-2 w-full items-center text-sm"
|
||||
>
|
||||
<h6 className="font-bold text-xl mb-2">{t("billboards.displayTypeTitle")}</h6>
|
||||
<p>{t("billboards.displayTypeHint")}</p>
|
||||
<div className="flex items-center gap-2 mt-10">
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">{t("billboards.showDiscount")}</span>
|
||||
</div>
|
||||
{typeList?.map((item: IAdvertisingType) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedType(item?.name);
|
||||
formik.setFieldValue("selectedType", item?.name);
|
||||
}}
|
||||
className="w-full flex flex-col items-center"
|
||||
key={item?._id}
|
||||
>
|
||||
<MainBillboardCard
|
||||
adStatus=""
|
||||
billboard={
|
||||
item?.name == "special"
|
||||
? sampleAdsSpecial
|
||||
: item?.name == "highlight"
|
||||
? sampleAdsHighlight
|
||||
: sampleAds
|
||||
}
|
||||
/>
|
||||
<RoundedDiv
|
||||
className={selectionCardClass(selectedType == item?.name)}
|
||||
>
|
||||
{getBillboardDisplayTypeOptionLabel(t, item.name)}:
|
||||
{item.price !== 0
|
||||
? !showDiscount
|
||||
? Number(item.price).toLocaleString() + t("settings.toman")
|
||||
: (Number(item.price) + 20000).toLocaleString() +
|
||||
t("settings.toman")
|
||||
: ` ${t("billboards.free")}`}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{formik.touched.selectedType && formik.errors.selectedType && (
|
||||
<p className="text-red-500">{formik.errors.selectedType}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{t("billboards.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Step5;
|
||||
@@ -1,90 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ImageUploaderProps = {
|
||||
formik: any;
|
||||
};
|
||||
|
||||
const ImageUploader: React.FC<ImageUploaderProps> = ({ formik }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const [images, setImages] = useState<string[]>(formik.values.images || []);
|
||||
|
||||
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
if (images.length + files.length > 5) {
|
||||
alert(t("billboards.form.maxImagesAlert"));
|
||||
return;
|
||||
}
|
||||
|
||||
const newImages: string[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
newImages.push(reader.result);
|
||||
if (newImages.length === files.length) {
|
||||
const updatedImages = [...images, ...newImages];
|
||||
setImages(updatedImages);
|
||||
formik.setFieldValue("images", updatedImages);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveImage = (index: number) => {
|
||||
const newImages = images.filter((_, i) => i !== index);
|
||||
setImages(newImages);
|
||||
formik.setFieldValue("images", newImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 w-full mt-5">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={crypto.randomUUID()}
|
||||
className="relative w-24 h-24 rounded-lg overflow-hidden border"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
images[index].slice(0, 12) === "/advertising"
|
||||
? IMAGE_BASE_URL + image
|
||||
: image
|
||||
}
|
||||
alt="uploaded"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1 right-1 bg-black bg-opacity-50 p-1 px-2.5 rounded-full text-white"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < 5 && (
|
||||
<label className="w-24 h-24 flex items-center justify-center border-dashed border-2 cursor-pointer rounded-lg">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
+
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploader;
|
||||
@@ -1,202 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import { ICity, IProvince } from "@/types/types";
|
||||
import SelectBox from "@/components/elements/SelectBox";
|
||||
import { useState, useEffect } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import Map, { GeolocateControl, Marker } from "react-map-gl";
|
||||
import "mapbox-gl/dist/mapbox-gl.css";
|
||||
import Image from "next/image";
|
||||
import { useBillboardForm } from "@/contexts/BillboardFormContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LocationSelectorProps {
|
||||
formik: any;
|
||||
}
|
||||
|
||||
const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { formData } = useBillboardForm();
|
||||
|
||||
const { request } = useAxios();
|
||||
const [allStates, setAllStates] = useState<IProvince[] | null>(null);
|
||||
const [cities, setCities] = useState<ICity[] | null>(null);
|
||||
const [selectedLocation, setSelectedLocation] = useState<{
|
||||
lat: number;
|
||||
lng: number;
|
||||
} | null>(null);
|
||||
|
||||
const fetchStates = async () => {
|
||||
try {
|
||||
const response = await request<{ provinces: IProvince[] }>(
|
||||
"GET",
|
||||
"/provinces"
|
||||
);
|
||||
setAllStates(response?.provinces || null);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCities = async (provinceId: string) => {
|
||||
try {
|
||||
const response = await request<{ cities: ICity[] }>(
|
||||
"GET",
|
||||
`/cities/${provinceId}`
|
||||
);
|
||||
setCities(response?.cities || []);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.values.stateId) {
|
||||
fetchCities(formik.values.stateId);
|
||||
}
|
||||
|
||||
if (formData?.markerCoordinate && formData?.markerCoordinate[1]) {
|
||||
setSelectedLocation({
|
||||
lat: Number(formData?.markerCoordinate[1]),
|
||||
lng: Number(formData?.markerCoordinate[0]),
|
||||
});
|
||||
}
|
||||
}, [formik?.values?.markerCoordinate]);
|
||||
|
||||
const handleProvinceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedProvinceId = e.target.value;
|
||||
formik.setFieldValue("stateId", selectedProvinceId);
|
||||
fetchCities(selectedProvinceId);
|
||||
};
|
||||
|
||||
const handleMapClick = (event: any) => {
|
||||
const { lngLat } = event;
|
||||
setSelectedLocation({
|
||||
lat: lngLat.lat,
|
||||
lng: lngLat.lng,
|
||||
});
|
||||
formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectBox
|
||||
className={`max-w-full ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
value={formik.values.stateId}
|
||||
onChange={handleProvinceChange}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t("billboards.province")}
|
||||
</option>
|
||||
{allStates?.map((item: IProvince) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item?.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.stateId && formik.errors.stateId && (
|
||||
<small className="text-red-500 block text-center">
|
||||
{formik.errors.stateId}
|
||||
</small>
|
||||
)}
|
||||
|
||||
<SelectBox
|
||||
className={`max-w-full ${
|
||||
formik.touched.cityId && formik.errors.cityId
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.cityId}
|
||||
onChange={(e) => formik.setFieldValue("cityId", e.target.value)}
|
||||
>
|
||||
<option disabled value="">
|
||||
{t("billboards.city")}
|
||||
</option>
|
||||
{cities?.map((city: ICity) => (
|
||||
<option key={city.id} value={city.id}>
|
||||
{city.name}
|
||||
</option>
|
||||
))}
|
||||
</SelectBox>
|
||||
{formik.touched.cityId && formik.errors.cityId && (
|
||||
<small className="text-red-500 block text-center">
|
||||
{formik.errors.cityId}
|
||||
</small>
|
||||
)}
|
||||
<RoundedInput
|
||||
className={`max-w-full ${
|
||||
formik.touched.neighbourhood && formik.errors.neighbourhood
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}`}
|
||||
type="text"
|
||||
placeholder={t("billboards.neighbourhood")}
|
||||
{...formik.getFieldProps("neighbourhood")}
|
||||
/>
|
||||
|
||||
{formik.touched.neighbourhood && formik.errors.neighbourhood && (
|
||||
<p className="text-red-500">{formik.errors.neighbourhood}</p>
|
||||
)}
|
||||
<textarea
|
||||
className={`w-full p-4 mt-2 h-24 rounded-3xl border
|
||||
${
|
||||
formik.touched.address && formik.errors.address
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
}
|
||||
border-neutral-950 text-neutral-900 dark:text-neutral-50 dark:border-neutral-400 font-medium`}
|
||||
placeholder={t("billboards.address")}
|
||||
{...formik.getFieldProps("address")}
|
||||
></textarea>
|
||||
{formik.touched.address && formik.errors.address && (
|
||||
<p className="text-red-500">{formik.errors.address}</p>
|
||||
)}
|
||||
<div className="w-full rounded-2xl overflow-hidden mt-2">
|
||||
<Map
|
||||
style={{ height: "240px" }}
|
||||
initialViewState={{
|
||||
longitude: 51.375433528216654,
|
||||
latitude: 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
mapStyle="mapbox://styles/mapbox/streets-v11"
|
||||
onClick={handleMapClick}
|
||||
>
|
||||
<GeolocateControl />
|
||||
{selectedLocation && (
|
||||
<Marker
|
||||
latitude={selectedLocation.lat}
|
||||
longitude={selectedLocation.lng}
|
||||
>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
width={25}
|
||||
height={25}
|
||||
src={"/images/icons/location.svg"}
|
||||
/>
|
||||
</Marker>
|
||||
)}
|
||||
</Map>
|
||||
{formik.touched.markerCoordinate && formik.errors.markerCoordinate && (
|
||||
<p className="text-red-500 text-xs">
|
||||
{formik.errors.markerCoordinate as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationSelector;
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import SharedPostBubble, { parseSharedPost } from "./SharedPostBubble";
|
||||
import SharedProductBubble, { parseSharedProduct } from "./SharedProductBubble";
|
||||
import SharedGiftBubble, { parseSharedGift } from "./SharedGiftBubble";
|
||||
import SharedStoryBubble, {
|
||||
parseSharedStory,
|
||||
extractStoryReactionText,
|
||||
@@ -320,6 +321,7 @@ const ChatMessageCard = ({
|
||||
message.forwardedFrom || parseForwarded(message.content || "");
|
||||
const sharedPost = parseSharedPost(message.content || "");
|
||||
const sharedProduct = parseSharedProduct(message.content || "");
|
||||
const sharedGift = parseSharedGift(message.content || "");
|
||||
const sharedStory = parseSharedStory(message.content || "");
|
||||
const storyReactionText = sharedStory
|
||||
? extractStoryReactionText(message.content || "")
|
||||
@@ -334,6 +336,7 @@ const ChatMessageCard = ({
|
||||
!hasLocation &&
|
||||
!sharedPost &&
|
||||
!sharedProduct &&
|
||||
!sharedGift &&
|
||||
!sharedStory &&
|
||||
!(message.viewOnce && isViewOnceMediaType(message.fileType))
|
||||
) {
|
||||
@@ -351,7 +354,7 @@ const ChatMessageCard = ({
|
||||
: detectChatFileType(message.file || "", message.fileType);
|
||||
|
||||
const textContent =
|
||||
sharedPost || sharedStory
|
||||
sharedPost || sharedProduct || sharedGift || sharedStory
|
||||
? ""
|
||||
: getBubbleTextContent(message.content);
|
||||
|
||||
@@ -748,6 +751,16 @@ const ChatMessageCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharedGift && (
|
||||
<div
|
||||
className={cn("p-1", isSender ? "chat-bubble-out" : "chat-bubble-in")}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
<SharedGiftBubble data={sharedGift} isSender={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTextBubble && !message.file && emojiOnly && (
|
||||
<p className="px-1 py-0.5 text-[2.5rem] leading-none select-text">{textContent}</p>
|
||||
)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import ChatRoomMemberPicker, {
|
||||
ChatRoomMember,
|
||||
} from "@/components/chat/ChatRoomMemberPicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { ensureIdentityKeys, ensureRoomAesKey } from "@/lib/e2ee";
|
||||
@@ -36,6 +37,7 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
const [cityId, setCityId] = useState("");
|
||||
const [expertise, setExpertise] = useState("");
|
||||
const [invitedMembers, setInvitedMembers] = useState<ChatRoomMember[]>([]);
|
||||
const [idleTimeoutDays, setIdleTimeoutDays] = useState(30);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [provinces, setProvinces] = useState<IProvince[]>([]);
|
||||
@@ -51,6 +53,7 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
setCityId("");
|
||||
setExpertise("");
|
||||
setInvitedMembers([]);
|
||||
setIdleTimeoutDays(30);
|
||||
setImageFile(null);
|
||||
setImagePreview(null);
|
||||
|
||||
@@ -113,6 +116,7 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
JSON.stringify(invitedMembers.map((member) => member._id))
|
||||
);
|
||||
}
|
||||
form.append("idle_timeout_days", String(idleTimeoutDays));
|
||||
if (imageFile) form.append("room_image", imageFile);
|
||||
|
||||
const created = await request<{ room?: { _id?: string } }>(
|
||||
@@ -193,6 +197,27 @@ export default function CreateChatRoomModal({ open, onClose, onCreated }: Props)
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<p className="mb-2 text-center text-xs text-neutral-500">
|
||||
{t("chats.modal.idleTimeout")}
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{[1, 2, 7, 14, 30].map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
onClick={() => setIdleTimeoutDays(days)}
|
||||
className={filterChipClass(idleTimeoutDays === days)}
|
||||
>
|
||||
{t("chats.modal.idleTimeoutDays", { count: days })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 px-2 text-center text-[11px] leading-5 text-neutral-500">
|
||||
{t("chats.modal.idleTimeoutHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 w-full">
|
||||
<p className="mb-2 text-center text-xs text-neutral-500">
|
||||
{t("chats.modal.roomType")}
|
||||
|
||||
180
src/components/chat/GiftInChatModal.tsx
Normal file
180
src/components/chat/GiftInChatModal.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { filterChipClass } from "@/lib/ui/buttonStyles";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
|
||||
const CUSTOM_MIN = 5000;
|
||||
const CUSTOM_MAX = 100000000;
|
||||
|
||||
type GiftChatResponse = {
|
||||
paidFromWallet: boolean;
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
gift?: { _id: string; amount: number; message: string | null };
|
||||
};
|
||||
|
||||
type GiftInChatModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
receiverId: string;
|
||||
returnPath: string;
|
||||
onWalletGiftSent: (gift: { _id: string; amount: number; message: string | null }) => void;
|
||||
};
|
||||
|
||||
export default function GiftInChatModal({
|
||||
open,
|
||||
onClose,
|
||||
receiverId,
|
||||
returnPath,
|
||||
onWalletGiftSent,
|
||||
}: GiftInChatModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [selectedAmount, setSelectedAmount] = useState<number | null>(PRESET_AMOUNTS[0]);
|
||||
const [customAmount, setCustomAmount] = useState("");
|
||||
const [isCustom, setIsCustom] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const finalAmount = useMemo(() => {
|
||||
if (isCustom) return Number(customAmount) || 0;
|
||||
return selectedAmount || 0;
|
||||
}, [isCustom, customAmount, selectedAmount]);
|
||||
|
||||
const isCustomValid =
|
||||
!isCustom ||
|
||||
(Number(customAmount) >= CUSTOM_MIN && Number(customAmount) <= CUSTOM_MAX);
|
||||
|
||||
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
|
||||
|
||||
const reset = () => {
|
||||
setSelectedAmount(PRESET_AMOUNTS[0]);
|
||||
setCustomAmount("");
|
||||
setIsCustom(false);
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await request<GiftChatResponse>(
|
||||
"POST",
|
||||
"/gifts/chat/initiate",
|
||||
{ receiverId, amount: finalAmount, message, return_path: returnPath }
|
||||
);
|
||||
if (response?.paidFromWallet && response.gift) {
|
||||
toast.success(t("chats.gift.sentSuccess"));
|
||||
onWalletGiftSent(response.gift);
|
||||
reset();
|
||||
onClose();
|
||||
} else if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else {
|
||||
toast.error(t("giftPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message || t("giftPage.paymentStartError");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!p-0 max-h-[85dvh] w-full overflow-y-auto"
|
||||
>
|
||||
<div className="px-4 pb-6 pt-5">
|
||||
<h3 className="mb-4 text-center text-sm font-bold">
|
||||
{t("chats.gift.modalTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-sm flex-wrap justify-center gap-2">
|
||||
{PRESET_AMOUNTS.map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsCustom(false);
|
||||
setSelectedAmount(amount);
|
||||
}}
|
||||
className={filterChipClass(!isCustom && selectedAmount === amount)}
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCustom(true)}
|
||||
className={filterChipClass(isCustom)}
|
||||
>
|
||||
{t("giftPage.customAmount")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isCustom ? (
|
||||
<div className="mx-auto mt-4 w-full max-w-sm">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={t("giftPage.customAmountPlaceholder", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
/>
|
||||
{!isCustomValid && customAmount ? (
|
||||
<p className="mt-2 text-center text-xs text-red-500">
|
||||
{t("giftPage.customAmountInvalid", {
|
||||
min: CUSTOM_MIN.toLocaleString(),
|
||||
max: CUSTOM_MAX.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mx-auto mt-4 w-full max-w-sm">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder={t("giftPage.messagePlaceholder")}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
className="w-full resize-none rounded-3xl border border-border-secondary-light bg-secondary-light p-4 text-sm font-medium dark:border-border-secondary-dark dark:bg-secondary-dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-center">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-10 w-40"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
{submitting ? t("chats.gift.sending") : t("giftPage.payButton")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
124
src/components/chat/GiftRevealModal.tsx
Normal file
124
src/components/chat/GiftRevealModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const PARTICLES = ["🎉", "🎊", "✨", "🎈", "🎁"];
|
||||
|
||||
type Particle = {
|
||||
id: number;
|
||||
emoji: string;
|
||||
x: number;
|
||||
y: number;
|
||||
rotate: number;
|
||||
delay: number;
|
||||
};
|
||||
|
||||
function buildParticles(count: number): Particle[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
|
||||
const distance = 90 + Math.random() * 60;
|
||||
return {
|
||||
id: i,
|
||||
emoji: PARTICLES[i % PARTICLES.length],
|
||||
x: Math.cos(angle) * distance,
|
||||
y: Math.sin(angle) * distance,
|
||||
rotate: Math.random() * 360 - 180,
|
||||
delay: Math.random() * 0.15,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
type GiftRevealModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
amount: number;
|
||||
message?: string | null;
|
||||
};
|
||||
|
||||
export default function GiftRevealModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
amount,
|
||||
message,
|
||||
}: GiftRevealModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const particles = useMemo(() => buildParticles(14), [isOpen]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!bg-transparent !shadow-none !border-none max-w-sm w-full"
|
||||
>
|
||||
<div className="relative flex flex-col items-center gap-4 py-10">
|
||||
<div className="pointer-events-none absolute left-1/2 top-16 -translate-x-1/2">
|
||||
<AnimatePresence>
|
||||
{isOpen &&
|
||||
particles.map((p) => (
|
||||
<motion.span
|
||||
key={p.id}
|
||||
className="absolute text-2xl"
|
||||
initial={{ x: 0, y: 0, opacity: 1, scale: 0.4, rotate: 0 }}
|
||||
animate={{
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
opacity: 0,
|
||||
scale: 1,
|
||||
rotate: p.rotate,
|
||||
}}
|
||||
transition={{ duration: 1.4, delay: p.delay, ease: "easeOut" }}
|
||||
>
|
||||
{p.emoji}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.3, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 16 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-b from-pink-400 to-purple-500 text-4xl shadow-lg"
|
||||
>
|
||||
🎁
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 220, damping: 14 }}
|
||||
className="text-2xl font-extrabold text-white drop-shadow"
|
||||
>
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</motion.p>
|
||||
|
||||
{message ? (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.35 }}
|
||||
className="max-w-[260px] text-center text-sm text-white/90"
|
||||
>
|
||||
{message}
|
||||
</motion.p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-white/70">{t("chats.gift.creditedHint")}</p>
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="!border-transparent mt-2 h-9 w-32 !bg-white/15 text-white"
|
||||
>
|
||||
{t("posts.close")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ interface MessageInputProps {
|
||||
onSelfDestructChange?: (seconds: number | null) => void;
|
||||
viewOnceMedia?: boolean;
|
||||
onViewOnceChange?: (value: boolean) => void;
|
||||
onGiftClick?: () => void;
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -56,15 +57,20 @@ const MessageInput = ({
|
||||
onSelfDestructChange,
|
||||
viewOnceMedia = false,
|
||||
onViewOnceChange,
|
||||
onGiftClick,
|
||||
}: MessageInputProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [dragProgress, setDragProgress] = useState(0);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const dragStartYRef = useRef<number | null>(null);
|
||||
const LOCK_THRESHOLD_PX = 80;
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -125,6 +131,9 @@ const MessageInput = ({
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsRecording(false);
|
||||
setIsLocked(false);
|
||||
setDragProgress(0);
|
||||
dragStartYRef.current = null;
|
||||
if (!shouldSend) {
|
||||
audioChunksRef.current = [];
|
||||
}
|
||||
@@ -164,12 +173,30 @@ const MessageInput = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerDown = () => {
|
||||
if (!hasText && !blocked_you) startRecording();
|
||||
const handleMicPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!hasText && !blocked_you) {
|
||||
dragStartYRef.current = e.clientY;
|
||||
setIsLocked(false);
|
||||
setDragProgress(0);
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!isRecording || isLocked || dragStartYRef.current == null) return;
|
||||
const delta = dragStartYRef.current - e.clientY;
|
||||
setDragProgress(Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX)));
|
||||
if (delta >= LOCK_THRESHOLD_PX) {
|
||||
setIsLocked(true);
|
||||
dragStartYRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerUp = () => {
|
||||
if (isRecording) stopRecording(true);
|
||||
if (isRecording && !isLocked) stopRecording(true);
|
||||
dragStartYRef.current = null;
|
||||
setDragProgress(0);
|
||||
};
|
||||
|
||||
const handleAttachmentSelect = (type: AttachmentType) => {
|
||||
@@ -296,6 +323,17 @@ const MessageInput = ({
|
||||
onSelect={handleAttachmentSelect}
|
||||
anchorRef={attachBtnRef}
|
||||
/>
|
||||
{onGiftClick && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={blocked_you || isRecording}
|
||||
onClick={onGiftClick}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
|
||||
aria-label={t("chats.aria.gift")}
|
||||
>
|
||||
<BoldIcon name="gift" size={20} tinted className="text-current" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Glass input box */}
|
||||
@@ -335,14 +373,35 @@ const MessageInput = ({
|
||||
<span className="font-mono text-xs font-bold">
|
||||
{formatTime(recordingTime)}
|
||||
</span>
|
||||
{!isLocked && (
|
||||
<span className="text-[10px] font-normal text-neutral-400">
|
||||
{t("chats.voice.slideUpToLock")}
|
||||
</span>
|
||||
)}
|
||||
{isLocked && (
|
||||
<BoldIcon name="lock" size={13} tinted className="text-neutral-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(false)}
|
||||
className="rounded-full p-1"
|
||||
aria-label={t("chats.aria.cancelVoice")}
|
||||
>
|
||||
<BoldIcon name="trash" size={18} tinted className="text-current" />
|
||||
</button>
|
||||
{isLocked && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(true)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#0095f6] text-white"
|
||||
aria-label={t("chats.aria.sendVoice")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={14} className="text-current -mr-0.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(false)}
|
||||
className="rounded-full p-1"
|
||||
>
|
||||
<BoldIcon name="stop" size={18} tinted className="text-current" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
@@ -389,24 +448,37 @@ const MessageInput = ({
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
key="mic"
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.6, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
type="button"
|
||||
disabled={blocked_you}
|
||||
onPointerDown={handleMicPointerDown}
|
||||
onPointerUp={handleMicPointerUp}
|
||||
onPointerLeave={() => isRecording && stopRecording(true)}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition ${
|
||||
isRecording ? "scale-110 !bg-red-500 text-white" : ""
|
||||
} active:scale-90`}
|
||||
aria-label={t("chats.aria.recordVoice")}
|
||||
>
|
||||
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
|
||||
</motion.button>
|
||||
<motion.div key="mic" className="relative">
|
||||
{isRecording && !isLocked && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-full right-1/2 mb-2 flex translate-x-1/2 flex-col items-center gap-1"
|
||||
style={{ opacity: 0.4 + dragProgress * 0.6 }}
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm">
|
||||
<BoldIcon name="lock" size={14} tinted className="text-white" />
|
||||
</span>
|
||||
<BoldIcon name="arrow-up-2" size={12} tinted className="text-neutral-400" />
|
||||
</div>
|
||||
)}
|
||||
<motion.button
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.6, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
type="button"
|
||||
disabled={blocked_you}
|
||||
onPointerDown={handleMicPointerDown}
|
||||
onPointerMove={handleMicPointerMove}
|
||||
onPointerUp={handleMicPointerUp}
|
||||
onPointerLeave={() => isRecording && !isLocked && stopRecording(true)}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition ${
|
||||
isRecording ? "scale-110 !bg-red-500 text-white" : ""
|
||||
} active:scale-90`}
|
||||
aria-label={t("chats.aria.recordVoice")}
|
||||
>
|
||||
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
67
src/components/chat/SharedGiftBubble.tsx
Normal file
67
src/components/chat/SharedGiftBubble.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import GiftRevealModal from "./GiftRevealModal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SharedGiftPayload {
|
||||
giftId: string;
|
||||
amount: number;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export function parseSharedGift(content: string): SharedGiftPayload | null {
|
||||
if (!content) return null;
|
||||
try {
|
||||
const line = content.split("\n")[0];
|
||||
const j = JSON.parse(line);
|
||||
if (j?.e2ee) return null;
|
||||
return j.sharedGift ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function SharedGiftBubble({
|
||||
data,
|
||||
isSender,
|
||||
}: {
|
||||
data: SharedGiftPayload;
|
||||
isSender: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const handleOpen = () => {
|
||||
setRevealed(true);
|
||||
if (!isSender && data.giftId) {
|
||||
void request("POST", `/gifts/${data.giftId}/open`, {}, { noToast: true }).catch(
|
||||
() => {}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
className="flex w-[210px] flex-col items-center gap-2 rounded-2xl border border-white/20 bg-gradient-to-b from-pink-500/25 to-purple-500/25 px-4 py-5 text-center active:scale-[0.98]"
|
||||
>
|
||||
<BoldIcon name="gift" size={36} tinted className="text-pink-300" />
|
||||
<span className="text-xs font-semibold opacity-90">
|
||||
{t("chats.gift.tapToOpen")}
|
||||
</span>
|
||||
</button>
|
||||
<GiftRevealModal
|
||||
isOpen={revealed}
|
||||
onClose={() => setRevealed(false)}
|
||||
amount={data.amount}
|
||||
message={data.message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -107,8 +107,6 @@ export function CourseManager() {
|
||||
`/academy/academy/get/course?page=${page}&limit=${itemsPerPage}`
|
||||
);
|
||||
|
||||
console.log("پاسخ سرور:", response);
|
||||
|
||||
// توجه: با توجه به ساختار دیتای شما
|
||||
if (response?.data?.courses) {
|
||||
setCourses(response.data.courses);
|
||||
@@ -126,7 +124,7 @@ export function CourseManager() {
|
||||
toast.error(t("academyDashboard.fetchStructureError"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("get error:", err);
|
||||
console.error("get error:", err);
|
||||
toast.error(t("academyDashboard.fetchError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -212,7 +210,7 @@ export function CourseManager() {
|
||||
setSelectedCourse(undefined);
|
||||
await fetchCourses(currentPage);
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
console.error("Upload error:", err);
|
||||
toast.error(t("academyCourse.saveError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -240,13 +238,6 @@ export function CourseManager() {
|
||||
}
|
||||
// اگر عکس جدیدی انتخاب نشده، فیلد course_image رو ارسال نکن
|
||||
|
||||
console.log("دادههای ارسالی برای ویرایش:", {
|
||||
courseId: data._id,
|
||||
name: data.cuorse_name,
|
||||
price: data.price,
|
||||
hasNewImage: !!fileInput?.files?.[0],
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
"PATCH",
|
||||
"/academy/academy/update/course",
|
||||
@@ -258,14 +249,12 @@ export function CourseManager() {
|
||||
}
|
||||
);
|
||||
|
||||
console.log("پاسخ سرور:", response);
|
||||
|
||||
toast.success(t("academyCourse.updateSuccess"));
|
||||
setIsDialogOpen(false);
|
||||
setSelectedCourse(undefined);
|
||||
await fetchCourses(currentPage);
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
console.error("Upload error:", err);
|
||||
toast.error(t("academyCourse.updateError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -285,7 +274,7 @@ export function CourseManager() {
|
||||
// بعد از حذف، صفحه جاری را مجدداً fetch کن
|
||||
fetchCourses(currentPage);
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
console.error("Upload error:", err);
|
||||
toast.error(t("academyCourse.deleteError"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -322,13 +311,6 @@ export function CourseManager() {
|
||||
// نمایش لودینگ
|
||||
loadingToastId = toast.loading(t("academyCourse.connectingPayment"));
|
||||
|
||||
console.log("ارسال درخواست پرداخت:", {
|
||||
courseId: selectedCourseId,
|
||||
planDuration: plan.duration,
|
||||
planLabel: plan.label,
|
||||
price: plan.price,
|
||||
});
|
||||
|
||||
const response = await request(
|
||||
"POST",
|
||||
"/academy/academy/course/payment",
|
||||
@@ -341,8 +323,6 @@ export function CourseManager() {
|
||||
// بستن لودینگ
|
||||
toast.dismiss(loadingToastId);
|
||||
|
||||
console.log("پاسخ کامل سرور:", response);
|
||||
|
||||
// پردازش پاسخ (ساختارهای مختلف احتمالی)
|
||||
let authority = null;
|
||||
let paymentUrl = null;
|
||||
@@ -360,7 +340,6 @@ export function CourseManager() {
|
||||
}
|
||||
|
||||
if (paymentUrl) {
|
||||
console.log("هدایت به درگاه:", paymentUrl);
|
||||
window.open(paymentUrl, "_blank", "noopener,noreferrer");
|
||||
toast.success(t("academyCourse.redirectedToPayment"));
|
||||
|
||||
@@ -422,8 +401,6 @@ export function CourseManager() {
|
||||
setSelectedCourseId(courseId);
|
||||
};
|
||||
const handleAddVideo = async (data: any) => {
|
||||
console.log("دادههای دریافتی:", data);
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
@@ -563,7 +540,7 @@ export function CourseManager() {
|
||||
<SubscriptionCard
|
||||
benefits={[t("academyCourse.highlightBenefit")]}
|
||||
plans={subscriptionPlans}
|
||||
currency={t("constants.toman")}
|
||||
currency={t("settings.toman")}
|
||||
onPurchase={handlePurchase}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -108,6 +108,15 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const requireAuth = useRequireAuth();
|
||||
const goToGift = () => {
|
||||
if (isOwnProfile) {
|
||||
router.push("/settings/gifts");
|
||||
return;
|
||||
}
|
||||
if (!requireAuth()) return;
|
||||
if (!user?._id) return;
|
||||
router.push(`/gift/${user._id}`);
|
||||
};
|
||||
const sendMsgHandler = async () => {
|
||||
if (!requireAuth()) return;
|
||||
if (!user?.user_name) return;
|
||||
@@ -276,7 +285,7 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
|
||||
profileImage={user?.profile_image}
|
||||
/>
|
||||
{user?.user_type == "user" ? (
|
||||
<div className="grid grid-cols-3 gap-1 md:gap-4">
|
||||
<div className="grid grid-cols-4 gap-1 md:gap-4">
|
||||
{isOwnProfile ? (
|
||||
<>
|
||||
<button
|
||||
@@ -314,6 +323,13 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToGift}
|
||||
className={profileActionBtnClass}
|
||||
>
|
||||
{t("models.actions.specialGift")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModelDetailModal(!showModelDetailModal)}
|
||||
|
||||
75
src/components/notifications/NotificationBridge.tsx
Normal file
75
src/components/notifications/NotificationBridge.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
acquireChatSocket,
|
||||
joinUserRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
|
||||
type IncomingNotification = {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Always-mounted, app-wide bridge: asks for browser Notification permission
|
||||
* once per user, then shows every live 'notification' socket event (emitted
|
||||
* server-side alongside NotificationModel.create) as an OS-level Notification.
|
||||
*/
|
||||
export default function NotificationBridge() {
|
||||
const user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
if (typeof Notification === "undefined") return;
|
||||
if (Notification.permission === "default") {
|
||||
void Notification.requestPermission();
|
||||
}
|
||||
}, [user?._id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
|
||||
const socket = acquireChatSocket();
|
||||
const uid = String(user._id);
|
||||
const join = () => joinUserRoom(uid);
|
||||
socket.on("connect", join);
|
||||
if (socket.connected) join();
|
||||
|
||||
const handleNotification = (payload: IncomingNotification) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
|
||||
if (
|
||||
typeof Notification === "undefined" ||
|
||||
Notification.permission !== "granted"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browserNotif = new Notification(payload.title || "مدستاگرام", {
|
||||
body: payload.description || "",
|
||||
icon: "/favicon.png",
|
||||
});
|
||||
browserNotif.onclick = () => window.focus();
|
||||
} catch {
|
||||
// برخی مرورگرها/وبویوها از Notification API پشتیبانی نمیکنند
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("notification", handleNotification);
|
||||
|
||||
return () => {
|
||||
socket.off("connect", join);
|
||||
socket.off("notification", handleNotification);
|
||||
releaseChatSocket();
|
||||
};
|
||||
}, [user?._id, queryClient]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,20 +4,30 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Course, IAdvertising, Project } from "@/types/types";
|
||||
import { Course, IAdvertising, IShopProductListing, Project } from "@/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { SelectedPostLink } from "@/lib/postLinkCaption";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MyShop = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
};
|
||||
|
||||
interface PostLinkPickerProps {
|
||||
selectedPackages: SelectedPostLink[];
|
||||
selectedProjects: SelectedPostLink[];
|
||||
selectedBillboards: SelectedPostLink[];
|
||||
selectedShops: SelectedPostLink[];
|
||||
selectedShopProducts: SelectedPostLink[];
|
||||
onPackagesChange: (links: SelectedPostLink[]) => void;
|
||||
onProjectsChange: (links: SelectedPostLink[]) => void;
|
||||
onBillboardsChange: (links: SelectedPostLink[]) => void;
|
||||
onShopsChange: (links: SelectedPostLink[]) => void;
|
||||
onShopProductsChange: (links: SelectedPostLink[]) => void;
|
||||
}
|
||||
|
||||
function LinkListItem({
|
||||
@@ -144,6 +154,157 @@ function LinkSection({
|
||||
);
|
||||
}
|
||||
|
||||
function shopProductImage(listing: IShopProductListing) {
|
||||
const img = listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
|
||||
return img ? IMAGE_BASE_URL + img : null;
|
||||
}
|
||||
|
||||
function ShopLinkSection({
|
||||
shops,
|
||||
loading,
|
||||
selectedShopIds,
|
||||
selectedProductIds,
|
||||
onToggleShop,
|
||||
onToggleProduct,
|
||||
}: {
|
||||
shops: MyShop[];
|
||||
loading: boolean;
|
||||
selectedShopIds: string[];
|
||||
selectedProductIds: string[];
|
||||
onToggleShop: (shop: MyShop) => void;
|
||||
onToggleProduct: (shopId: string, product: { _id: string; title: string }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [expandedShopId, setExpandedShopId] = useState<string | null>(null);
|
||||
const [productsByShop, setProductsByShop] = useState<
|
||||
Record<string, IShopProductListing[]>
|
||||
>({});
|
||||
const [loadingProducts, setLoadingProducts] = useState(false);
|
||||
|
||||
const toggleExpand = async (shopId: string) => {
|
||||
if (expandedShopId === shopId) {
|
||||
setExpandedShopId(null);
|
||||
return;
|
||||
}
|
||||
setExpandedShopId(shopId);
|
||||
if (productsByShop[shopId]) return;
|
||||
setLoadingProducts(true);
|
||||
try {
|
||||
const res = await request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
`/shop-products?shopId=${shopId}&limit=100`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setProductsByShop((prev) => ({ ...prev, [shopId]: res?.docs || [] }));
|
||||
} catch {
|
||||
setProductsByShop((prev) => ({ ...prev, [shopId]: [] }));
|
||||
} finally {
|
||||
setLoadingProducts(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{t("posts.shop")}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400">{t("common.loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (shops.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{t("posts.shop")} ({t("posts.optional")})
|
||||
</p>
|
||||
<p className="rounded-xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700">
|
||||
{t("posts.noShop")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
|
||||
{t("posts.shop")} ({t("posts.optionalMulti")})
|
||||
</p>
|
||||
<ul className="max-h-64 space-y-1.5 overflow-y-auto rounded-xl border border-neutral-200 p-2 dark:border-neutral-700">
|
||||
{shops.map((shop) => {
|
||||
const isExpanded = expandedShopId === shop._id;
|
||||
const products = productsByShop[shop._id] || [];
|
||||
return (
|
||||
<li key={shop._id}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<LinkListItem
|
||||
title={shop.name}
|
||||
imageSrc={shop.logo ? IMAGE_BASE_URL + shop.logo : null}
|
||||
fallbackIcon="shop"
|
||||
isSelected={selectedShopIds.includes(shop._id)}
|
||||
onToggle={() => onToggleShop(shop)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleExpand(shop._id)}
|
||||
aria-label={t("posts.viewProducts")}
|
||||
className="shrink-0 p-2"
|
||||
>
|
||||
<BoldIcon
|
||||
name="arrow-down-1"
|
||||
size={14}
|
||||
tinted
|
||||
className={cn(
|
||||
"text-neutral-400 transition-transform",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="mr-6 mt-1 space-y-1 border-r-2 border-neutral-100 pr-2 dark:border-neutral-800">
|
||||
{loadingProducts && !productsByShop[shop._id] ? (
|
||||
<li className="p-1 text-xs text-neutral-400">
|
||||
{t("common.loading")}
|
||||
</li>
|
||||
) : products.length === 0 ? (
|
||||
<li className="p-1 text-xs text-neutral-400">
|
||||
{t("shops.noProductsYet")}
|
||||
</li>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<LinkListItem
|
||||
key={product._id}
|
||||
title={product.title}
|
||||
imageSrc={shopProductImage(product)}
|
||||
fallbackIcon="box"
|
||||
isSelected={selectedProductIds.includes(product._id)}
|
||||
onToggle={() =>
|
||||
onToggleProduct(shop._id, {
|
||||
_id: product._id,
|
||||
title: product.title,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function courseImageSrc(course: Course) {
|
||||
if (!course.course_image) return null;
|
||||
return course.course_image.startsWith("http")
|
||||
@@ -173,9 +334,13 @@ export default function PostLinkPicker({
|
||||
selectedPackages,
|
||||
selectedProjects,
|
||||
selectedBillboards,
|
||||
selectedShops,
|
||||
selectedShopProducts,
|
||||
onPackagesChange,
|
||||
onProjectsChange,
|
||||
onBillboardsChange,
|
||||
onShopsChange,
|
||||
onShopProductsChange,
|
||||
}: PostLinkPickerProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
@@ -184,9 +349,11 @@ export default function PostLinkPicker({
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [billboards, setBillboards] = useState<IAdvertising[]>([]);
|
||||
const [shops, setShops] = useState<MyShop[]>([]);
|
||||
const [loadingCourses, setLoadingCourses] = useState(true);
|
||||
const [loadingProjects, setLoadingProjects] = useState(true);
|
||||
const [loadingBillboards, setLoadingBillboards] = useState(true);
|
||||
const [loadingShops, setLoadingShops] = useState(true);
|
||||
|
||||
const loadCourses = useCallback(async () => {
|
||||
setLoadingCourses(true);
|
||||
@@ -262,6 +429,23 @@ export default function PostLinkPicker({
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const loadShops = useCallback(async () => {
|
||||
setLoadingShops(true);
|
||||
try {
|
||||
const response = await request<{ shops?: MyShop[] }>(
|
||||
"GET",
|
||||
"/shops/mine",
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setShops(Array.isArray(response?.shops) ? response.shops : []);
|
||||
} catch {
|
||||
setShops([]);
|
||||
} finally {
|
||||
setLoadingShops(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCourses();
|
||||
}, [loadCourses]);
|
||||
@@ -274,6 +458,10 @@ export default function PostLinkPicker({
|
||||
void loadBillboards();
|
||||
}, [loadBillboards]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadShops();
|
||||
}, [loadShops]);
|
||||
|
||||
const courseItems = courses
|
||||
.map((c) => ({
|
||||
_id: String(c._id || c.id || ""),
|
||||
@@ -342,6 +530,18 @@ export default function PostLinkPicker({
|
||||
}}
|
||||
fallbackIcon="flash-circle"
|
||||
/>
|
||||
<ShopLinkSection
|
||||
shops={shops}
|
||||
loading={loadingShops}
|
||||
selectedShopIds={selectedShops.map((s) => s._id)}
|
||||
selectedProductIds={selectedShopProducts.map((s) => s._id)}
|
||||
onToggleShop={(shop) =>
|
||||
onShopsChange(toggleLink(selectedShops, "shop", { _id: shop._id, title: shop.name }))
|
||||
}
|
||||
onToggleProduct={(_shopId, product) =>
|
||||
onShopProductsChange(toggleLink(selectedShopProducts, "shopproduct", product))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { canEditPostWithinWindow } from "@/lib/postEditWindow";
|
||||
|
||||
const REPORT_REASON_IDS = [
|
||||
"inappropriate",
|
||||
@@ -42,7 +41,7 @@ export default function ReelsPostOptionsMenu({
|
||||
const { request } = useAxios();
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const canEdit = isOwnPost && canEditPostWithinWindow(postData.createdAt);
|
||||
const canEdit = isOwnPost;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false);
|
||||
@@ -50,11 +49,16 @@ export default function ReelsPostOptionsMenu({
|
||||
const [reportText, setReportText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(Boolean(postData.is_pinned));
|
||||
const [isArchived, setIsArchived] = useState(Boolean(postData.is_archived));
|
||||
|
||||
useEffect(() => {
|
||||
setIsPinned(Boolean(postData.is_pinned));
|
||||
}, [postData.is_pinned, postData._id]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsArchived(Boolean(postData.is_archived));
|
||||
}, [postData.is_archived, postData._id]);
|
||||
|
||||
const submitReport = async () => {
|
||||
if (!reportReason.trim()) {
|
||||
toast.error(t("posts.selectReportReason"));
|
||||
@@ -109,6 +113,31 @@ export default function ReelsPostOptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
try {
|
||||
const res = await request<{ is_archived?: boolean; message?: string }>(
|
||||
"POST",
|
||||
"/account/archive-post",
|
||||
{ postId: postData._id },
|
||||
{ noToast: true }
|
||||
);
|
||||
const archived = Boolean(res?.is_archived);
|
||||
setIsArchived(archived);
|
||||
toast.success(archived ? t("posts.postArchived") : t("posts.postUnarchived"));
|
||||
setOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["posts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["archived-posts"] });
|
||||
if (archived) {
|
||||
router.back();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = (
|
||||
err as { response?: { data?: { message?: string } } }
|
||||
)?.response?.data?.message;
|
||||
toast.error(message || t("posts.archiveFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -195,6 +224,13 @@ export default function ReelsPostOptionsMenu({
|
||||
>
|
||||
{isPinned ? t("posts.unpinPost") : t("posts.pinPost")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleArchive()}
|
||||
className="px-5 py-3.5 text-right text-sm active:bg-neutral-100 dark:active:bg-neutral-800"
|
||||
>
|
||||
{isArchived ? t("posts.publishPost") : t("posts.archivePost")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -103,24 +103,23 @@ export default function ShareProfileModal({
|
||||
{profileUrl || "—"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mx-auto grid w-full max-w-sm grid-cols-2 gap-4">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="!border-transparent h-9 w-full !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{t("models.shareProfile.close")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => void handleCopy()}
|
||||
className="!border-transparent h-9 w-full !bg-sky-100 text-sky-600"
|
||||
>
|
||||
{copied
|
||||
? t("models.shareProfile.copied")
|
||||
: t("models.shareProfile.copyLink")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
{t("models.shareProfile.close")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -39,7 +39,7 @@ function ProjectRequestsAction({
|
||||
|
||||
const acceptHandler = async () => {
|
||||
try {
|
||||
await request<{ type?: string }>(
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/projects/payment-request-step-one/web",
|
||||
{
|
||||
@@ -47,7 +47,11 @@ function ProjectRequestsAction({
|
||||
projectId,
|
||||
}
|
||||
);
|
||||
router.refresh();
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
@@ -56,17 +56,33 @@ function ProjectWorkroomRate({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
project_id: projectId,
|
||||
comment: commentText,
|
||||
user_id: targetUserId,
|
||||
};
|
||||
if (!hasRatedUser && rating) body.rate = rating;
|
||||
// نظر و امتیاز تا برگشت از درگاه پرداخت نگه داشته میشود تا بعد از پرداخت باقیمانده ثبت شود
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem(
|
||||
`project-done-${projectId}`,
|
||||
JSON.stringify({
|
||||
comment: commentText,
|
||||
rate: !hasRatedUser && rating ? rating : undefined,
|
||||
user_id: targetUserId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await request("POST", "/projects/done/web", body);
|
||||
router.push("/settings/workroom");
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/projects/initiate-completion-payment/web",
|
||||
{ projectId }
|
||||
);
|
||||
if (response?.paymentUrl) {
|
||||
router.push(response.paymentUrl);
|
||||
} else if (response?.authority) {
|
||||
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("shops.unknownError");
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,6 +126,9 @@ function ProjectWorkroomRate({
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
)}
|
||||
<p className="max-w-sm text-center text-[11px] text-neutral-500">
|
||||
{t("settings.workroom.payRemainingHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 w-full max-w-sm gap-4 text-sm">
|
||||
<RoundedButton
|
||||
onClick={doneHandler}
|
||||
|
||||
141
src/components/settings/AccountAnalyticsWidget.tsx
Normal file
141
src/components/settings/AccountAnalyticsWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SeriesPoint = {
|
||||
date: string;
|
||||
posts: number;
|
||||
followers: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
};
|
||||
|
||||
type AnalyticsResponse = {
|
||||
range: string;
|
||||
series: SeriesPoint[];
|
||||
totals: {
|
||||
posts: number;
|
||||
followers: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
};
|
||||
};
|
||||
|
||||
const RANGE_OPTIONS = ["7d", "30d", "90d", "all"] as const;
|
||||
type RangeOption = (typeof RANGE_OPTIONS)[number];
|
||||
|
||||
export default function AccountAnalyticsWidget() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [range, setRange] = useState<RangeOption>("30d");
|
||||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
request<AnalyticsResponse>(
|
||||
"GET",
|
||||
`/account/analytics?range=${range}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
)
|
||||
.then((res) => setData(res || null))
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [request, range]);
|
||||
|
||||
const totals = data?.totals;
|
||||
const series = data?.series || [];
|
||||
|
||||
return (
|
||||
<div className="my-6 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-bold">{t("settings.analytics.title")}</h3>
|
||||
<div className="flex gap-1 rounded-full bg-neutral-100 p-1 dark:bg-neutral-800">
|
||||
{RANGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => setRange(opt)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold transition ${
|
||||
range === opt
|
||||
? "bg-white text-neutral-900 shadow dark:bg-neutral-700 dark:text-white"
|
||||
: "text-neutral-500"
|
||||
}`}
|
||||
>
|
||||
{t(`settings.analytics.range.${opt}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-4 gap-2 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.posts ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.posts")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.followers ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.followers")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.likes ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.likes")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold">{totals?.comments ?? 0}</p>
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
{t("settings.analytics.metrics.comments")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-56 w-full animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800" />
|
||||
) : series.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-neutral-500">
|
||||
{t("settings.analytics.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="h-56 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={series} margin={{ top: 5, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<Legend
|
||||
formatter={(value) =>
|
||||
t(`settings.analytics.metrics.${value}`)
|
||||
}
|
||||
wrapperStyle={{ fontSize: 11 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="posts" stroke="#6366f1" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="followers" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="likes" stroke="#ec4899" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="comments" stroke="#f59e0b" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/components/settings/ActivityPostGrid.tsx
Normal file
165
src/components/settings/ActivityPostGrid.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Post } from "@/types/types";
|
||||
import { getPostMedia } from "@/lib/explore/postMedia";
|
||||
import ExploreVideoThumb from "@/components/explore/ExploreVideoThumb";
|
||||
import { buildPostPath } from "@/lib/postSlug";
|
||||
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < postId.length; i++) {
|
||||
hash = (hash + postId.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function ActivityCardSkeleton({ seed }: { seed: number }) {
|
||||
const aspect = ASPECT_RATIOS[seed % ASPECT_RATIOS.length];
|
||||
return (
|
||||
<div
|
||||
className={`mb-2 w-full break-inside-avoid overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800 ${aspect} animate-pulse`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export default function ActivityPostGrid({ endpoint }: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [fetchingMore, setFetchingMore] = useState(false);
|
||||
|
||||
const loadPage = useCallback(
|
||||
async (pageNum: number) => {
|
||||
const res = await request<{ posts: Post[]; totalPages: number }>(
|
||||
"GET",
|
||||
`${endpoint}?page=${pageNum}&limit=12`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
return res;
|
||||
},
|
||||
[request, endpoint]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
loadPage(1)
|
||||
.then((res) => {
|
||||
setPosts(res?.posts || []);
|
||||
setHasMore((res?.totalPages || 1) > 1);
|
||||
setPage(1);
|
||||
})
|
||||
.catch(() => setPosts([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [loadPage]);
|
||||
|
||||
const fetchMore = useCallback(() => {
|
||||
if (fetchingMore || !hasMore) return;
|
||||
setFetchingMore(true);
|
||||
const nextPage = page + 1;
|
||||
loadPage(nextPage)
|
||||
.then((res) => {
|
||||
setPosts((prev) => [...prev, ...(res?.posts || [])]);
|
||||
setHasMore(nextPage < (res?.totalPages || 1));
|
||||
setPage(nextPage);
|
||||
})
|
||||
.catch(() => setHasMore(false))
|
||||
.finally(() => setFetchingMore(false));
|
||||
}, [fetchingMore, hasMore, page, loadPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (
|
||||
window.innerHeight + window.scrollY >=
|
||||
document.body.offsetHeight - 800
|
||||
) {
|
||||
fetchMore();
|
||||
}
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [fetchMore]);
|
||||
|
||||
const openPost = (post: Post) => {
|
||||
cacheReelsSeedPost(post);
|
||||
router.push(buildPostPath(post._id, post));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="columns-2 gap-2">
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<ActivityCardSkeleton key={i} seed={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
{t("models.nothingFound")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="columns-2 gap-2">
|
||||
{posts.map((post) => {
|
||||
const media = getPostMedia(post);
|
||||
if (!media) return null;
|
||||
const aspect = hashAspect(post._id);
|
||||
return (
|
||||
<button
|
||||
key={post._id}
|
||||
type="button"
|
||||
onClick={() => openPost(post)}
|
||||
className={`gentle-transition relative mb-2 block w-full overflow-hidden rounded-2xl bg-neutral-200 text-right dark:bg-neutral-900 active:scale-[0.98] ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<ExploreVideoThumb src={media.src} poster={media.poster} />
|
||||
) : (
|
||||
<Image
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="50vw"
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{fetchingMore
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<ActivityCardSkeleton key={`more-${i}`} seed={i} />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,7 +169,7 @@ export default function FavoritesGrid() {
|
||||
if (!posts.length) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-neutral-500">
|
||||
{t("settings.favoritesEmpty")}
|
||||
{t("settings.favorites.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
const SETTINGS_NAV = [
|
||||
{ key: "wallet", href: "/wallet", icon: "card.svg" },
|
||||
{ key: "workroom", href: "/workroom", icon: "box.svg" },
|
||||
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
|
||||
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg" },
|
||||
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg" },
|
||||
{ key: "workroom", href: "/workroom", icon: "box.svg" },
|
||||
{ key: "offers", href: "/offers", icon: "offer.svg" },
|
||||
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
|
||||
{ key: "favorites", href: "/favorites", icon: "bookmark-post.svg" },
|
||||
{ key: "chats", href: "/chats", icon: "sms.svg" },
|
||||
{ key: "notifications", href: "/notifications", icon: "notification.svg" },
|
||||
@@ -55,8 +55,8 @@ export default function SettingsClient() {
|
||||
<span>{t(`settings.nav.${item.key}`)}</span>
|
||||
</Link>
|
||||
))}
|
||||
<Roules />
|
||||
<Suggestions />
|
||||
<Roules />
|
||||
<Link href="/about-us" className="flex items-center gap-2">
|
||||
<Image
|
||||
width={24}
|
||||
|
||||
@@ -14,7 +14,8 @@ export default function SettingsShell({
|
||||
const pathname = usePathname();
|
||||
const isChatThread =
|
||||
/^\/settings\/chats\/[^/]+\/[^/]+$/.test(pathname ?? "") ||
|
||||
/^\/settings\/chats\/rooms\/[^/]+$/.test(pathname ?? "");
|
||||
/^\/settings\/chats\/rooms\/[^/]+$/.test(pathname ?? "") ||
|
||||
/^\/settings\/chats\/shop\/[^/]+\/[^/]+$/.test(pathname ?? "");
|
||||
|
||||
return (
|
||||
<section className={cn(!isChatThread && "pb-24")}>
|
||||
|
||||
69
src/components/settings/gifts/GiftItem.tsx
Normal file
69
src/components/settings/gifts/GiftItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import { IGift, IReceiver, ISender } from "@/types/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function counterpartyUser(item: IGift, mode: "sent" | "received"): ISender | IReceiver {
|
||||
return mode === "received" ? item.sender : item.receiver;
|
||||
}
|
||||
|
||||
function GiftItem({
|
||||
item,
|
||||
mode = "sent",
|
||||
actionHandler,
|
||||
}: {
|
||||
item: IGift;
|
||||
mode?: "sent" | "received";
|
||||
actionHandler?: (item: IGift) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const user = counterpartyUser(item, mode);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mb-4 block w-full rounded-3xl border border-border-primary-light p-4 font-semibold"
|
||||
onClick={() => {
|
||||
if (item && actionHandler) actionHandler(item);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
|
||||
<UserInfo
|
||||
profile_image={user?.profile_image}
|
||||
user_level={user?.user_level}
|
||||
first_name={user?.first_name}
|
||||
last_name={user?.last_name}
|
||||
user_name={user?.user_name}
|
||||
is_verified={user?.is_verified}
|
||||
/>
|
||||
<div className="hidden w-full h-[1px] max-[350px]:flex dark:bg-white/30 bg-black/30"></div>
|
||||
<div className="flex flex-col items-end max-[350px]:w-full max-[350px]:flex-row max-[350px]:items-center max-[350px]:justify-between max-sm:items-center max-sm:text-[11px]">
|
||||
<span>{item?.createdAt}</span>
|
||||
<div className="mt-3 flex items-center gap-1 max-[350px]:mt-0">
|
||||
{mode === "sent" ? (
|
||||
<span className="text-[#008D0E]">
|
||||
{Number(item.amount).toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-neutral-500">
|
||||
{item.reply ? t("settings.gifts.replied") : t("settings.gifts.tapToReply")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.message ? (
|
||||
<p className="mt-3 rounded-2xl bg-neutral-100 p-3 text-xs font-normal text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{item.message}
|
||||
</p>
|
||||
) : null}
|
||||
{item.reply ? (
|
||||
<p className="mt-2 rounded-2xl bg-sky-50 p-3 text-xs font-normal text-sky-700 dark:bg-sky-950/40 dark:text-sky-300">
|
||||
{t("settings.gifts.yourReply")}: {item.reply}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftItem;
|
||||
74
src/components/settings/gifts/GiftReplyModal.tsx
Normal file
74
src/components/settings/gifts/GiftReplyModal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IGift } from "@/types/types";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type GiftReplyModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
item: IGift | null;
|
||||
onReplied: () => void;
|
||||
};
|
||||
|
||||
function GiftReplyModal({ isOpen, onClose, item, onReplied }: GiftReplyModalProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [replyText, setReplyText] = useState(item?.reply || "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!replyText.trim()) {
|
||||
toast.error(t("settings.gifts.replyRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request("POST", "/gifts/reply", {
|
||||
giftId: item?._id,
|
||||
reply: replyText,
|
||||
});
|
||||
toast.success(t("settings.gifts.replySubmitted"));
|
||||
onReplied();
|
||||
onClose();
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("shops.unknownError");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal height="fit" isOpen={isOpen} onClose={onClose}>
|
||||
<div className="flex flex-col items-center gap-4 py-5">
|
||||
<span className="text-sm font-bold">{t("settings.gifts.replyTitle")}</span>
|
||||
{item?.message ? (
|
||||
<p className="w-full rounded-2xl bg-neutral-100 p-3 text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
{item.message}
|
||||
</p>
|
||||
) : null}
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
className="h-24 w-full rounded-3xl border border-border-secondary-light bg-secondary-light p-4 font-medium dark:border-border-secondary-dark dark:bg-secondary-dark"
|
||||
/>
|
||||
<RoundedButton
|
||||
className="mt-2 h-9 w-36"
|
||||
disabled={submitting}
|
||||
onClick={() => void submitReply()}
|
||||
>
|
||||
{t("settings.gifts.submitReply")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default GiftReplyModal;
|
||||
@@ -18,10 +18,10 @@ export default function ShopProductAuthor({
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shopLogo}
|
||||
alt={shopName}
|
||||
className="h-5 w-5 shrink-0 rounded-full object-cover"
|
||||
className="h-8 w-8 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-5 w-5 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
<span className="h-8 w-8 shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{shopName}
|
||||
|
||||
@@ -22,6 +22,25 @@ function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
|
||||
}
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
|
||||
const SKELETON_COUNT = 8;
|
||||
|
||||
function hashAspect(id: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash + id.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function defaultHref(listing: IShopProductListing): string {
|
||||
const catalogId =
|
||||
typeof listing.catalogProduct === "string"
|
||||
@@ -30,10 +49,12 @@ function defaultHref(listing: IShopProductListing): string {
|
||||
return `/shops/product/${catalogId}`;
|
||||
}
|
||||
|
||||
function ProductCardSkeleton() {
|
||||
function ProductCardSkeleton({ aspect }: { aspect: string }) {
|
||||
return (
|
||||
<article className="mb-2 break-inside-avoid">
|
||||
<div className="relative aspect-square w-full animate-pulse overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div
|
||||
className={`relative w-full animate-pulse overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800 ${aspect}`}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 px-0.5 py-1.5">
|
||||
<div className="h-3 w-20 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="h-3 w-14 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
@@ -56,8 +77,8 @@ export default function ShopProductGrid({
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<div className="columns-2 gap-2">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<ProductCardSkeleton key={index} />
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
|
||||
<ProductCardSkeleton key={index} aspect={ASPECT_RATIOS[index % ASPECT_RATIOS.length]} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,6 +104,7 @@ export default function ShopProductGrid({
|
||||
typeof listing.shop === "object" && listing.shop
|
||||
? listing.shop
|
||||
: null;
|
||||
const aspect = hashAspect(listing._id);
|
||||
|
||||
return (
|
||||
<article key={listing._id} className="mb-2 break-inside-avoid">
|
||||
@@ -91,7 +113,9 @@ export default function ShopProductGrid({
|
||||
onClick={() => router.push(getHref(listing))}
|
||||
className="gentle-transition block w-full text-right active:scale-[0.98]"
|
||||
>
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900">
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900 ${aspect}`}
|
||||
>
|
||||
{primaryImage ? (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + primaryImage}
|
||||
@@ -124,6 +148,11 @@ export default function ShopProductGrid({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{listing.shop_count != null && listing.shop_count > 1 && (
|
||||
<span className="text-[11px] font-medium text-[#387E65]">
|
||||
{t("shops.availableInShopsCount", { count: listing.shop_count })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -19,8 +19,13 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ShopReelsViewProps = {
|
||||
shopId: string;
|
||||
/** Single-shop mode: fetches that shop's products. Omit when passing `listings` directly (cross-shop mode). */
|
||||
shopId?: string;
|
||||
initialListingId: string;
|
||||
/** Cross-shop mode: a pre-fetched, already-ordered list of listings (e.g. from the discover feed). */
|
||||
listings?: IShopProductListing[];
|
||||
/** Overrides the top-right close button destination (defaults to the shop profile in single-shop mode, or the discover feed otherwise). */
|
||||
backHref?: string;
|
||||
};
|
||||
|
||||
function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
@@ -28,7 +33,20 @@ function cheapestPrice(listing: IShopProductListing): number | null {
|
||||
return Math.min(...listing.variants.map((v) => v.discount_price || v.price));
|
||||
}
|
||||
|
||||
export default function ShopReelsView({ shopId, initialListingId }: ShopReelsViewProps) {
|
||||
function catalogHref(listing: IShopProductListing): string {
|
||||
const catalogId =
|
||||
typeof listing.catalogProduct === "string"
|
||||
? listing.catalogProduct
|
||||
: listing.catalogProduct?._id;
|
||||
return `/shops/product/${catalogId}`;
|
||||
}
|
||||
|
||||
export default function ShopReelsView({
|
||||
shopId,
|
||||
initialListingId,
|
||||
listings: providedListings,
|
||||
backHref,
|
||||
}: ShopReelsViewProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const requireAuth = useRequireAuth();
|
||||
@@ -36,7 +54,9 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
useReelsSnapScroll(scrollRef);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(null);
|
||||
const [listings, setListings] = useState<IShopProductListing[] | null>(
|
||||
providedListings || null
|
||||
);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
|
||||
const [shareListing, setShareListing] = useState<IShopProductListing | null>(null);
|
||||
@@ -44,6 +64,14 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
const [commentListingId, setCommentListingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (providedListings) {
|
||||
setListings(providedListings);
|
||||
setFavoriteIds(
|
||||
new Set(providedListings.filter((l) => isFavorite(l._id)).map((l) => l._id))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!shopId) return;
|
||||
request<{ docs: IShopProductListing[] }>(
|
||||
"GET",
|
||||
`/shop-products?shopId=${shopId}&limit=100`
|
||||
@@ -57,7 +85,7 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
})
|
||||
.catch(() => setListings([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shopId]);
|
||||
}, [shopId, providedListings]);
|
||||
|
||||
const handleToggleFavorite = (listingId: string) => {
|
||||
const next = toggleFavorite(listingId);
|
||||
@@ -127,7 +155,7 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
return (
|
||||
<div className="relative min-h-[100dvh] bg-black">
|
||||
<Link
|
||||
href={`/shops/profile/${shopId}`}
|
||||
href={backHref || (shopId ? `/shops/profile/${shopId}` : "/shops/discover")}
|
||||
aria-label={t("common.close")}
|
||||
className="absolute right-4 top-4 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
@@ -224,6 +252,11 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4 pb-8 text-white">
|
||||
{typeof listing.shop === "object" && listing.shop?.name && (
|
||||
<p className="mb-1 text-xs font-semibold text-neutral-300">
|
||||
{listing.shop.name}
|
||||
</p>
|
||||
)}
|
||||
<p className="mb-1 line-clamp-1 text-base font-bold">{listing.title}</p>
|
||||
{price != null && (
|
||||
<p className="mb-3 text-sm text-neutral-200">
|
||||
@@ -232,7 +265,7 @@ export default function ShopReelsView({ shopId, initialListingId }: ShopReelsVie
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/shops/listing/${listing._id}`}
|
||||
href={catalogHref(listing)}
|
||||
className="inline-block rounded-3xl bg-white px-5 py-2 text-sm font-bold text-black"
|
||||
>
|
||||
{t("shops.viewProduct")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -59,11 +60,6 @@ export default function BuyerOrderItem({ order }: { order: BuyerOrder }) {
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
{order.order_number && (
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
|
||||
@@ -79,6 +75,22 @@ export default function BuyerOrderItem({ order }: { order: BuyerOrder }) {
|
||||
{order.tracking_code}
|
||||
</span>
|
||||
)}
|
||||
{order.order_number && (
|
||||
<span className="mt-1 text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/shops/orders/${order._id}?autoDownload=1`);
|
||||
}}
|
||||
className="mt-2 flex items-center gap-1 text-[11px] text-[#0095f6]"
|
||||
>
|
||||
<BoldIcon name="document-download" size={14} tinted className="text-current" />
|
||||
{t("shops.downloadInvoice")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ type SellerOrder = {
|
||||
tracking_code?: string | null;
|
||||
buyerAddressSnapshot?: { first_name?: string | null; last_name?: string | null } | null;
|
||||
listing?: { title: string; images: string[]; primaryImageIndex: number } | string;
|
||||
shop?: { name: string } | string;
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -33,6 +34,7 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const listing = typeof order.listing === "object" ? order.listing : null;
|
||||
const shopName = typeof order.shop === "object" ? order.shop?.name : "";
|
||||
const primaryImage =
|
||||
listing?.images?.[listing.primaryImageIndex] || listing?.images?.[0];
|
||||
const buyerName = [
|
||||
@@ -62,14 +64,12 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{listing?.title}</p>
|
||||
<p className="text-xs text-neutral-500">{buyerName}</p>
|
||||
{shopName && (
|
||||
<p className="text-[11px] text-neutral-400">{shopName}</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
{order.total_amount.toLocaleString()} {t("settings.toman")}
|
||||
</p>
|
||||
{order.order_number && (
|
||||
<p className="text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end max-sm:items-center max-sm:text-[11px]">
|
||||
@@ -86,6 +86,11 @@ export default function SellerOrderItem({ order }: { order: SellerOrder }) {
|
||||
{order.tracking_code ? `: ${order.tracking_code}` : ""}
|
||||
</span>
|
||||
)}
|
||||
{order.order_number && (
|
||||
<span className="mt-1 text-[11px] text-neutral-400">
|
||||
{t("shops.orderNumberLabel")}: {order.order_number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -10,6 +12,7 @@ import { useTranslation } from "react-i18next";
|
||||
type ShopInfo = {
|
||||
_id: string;
|
||||
name: string;
|
||||
logo?: string | null;
|
||||
has_physical_location?: boolean;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
@@ -58,10 +61,34 @@ export default function ShopInfoModal({ isOpen, onClose, shop }: ShopInfoModalPr
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-neutral-300">
|
||||
<p className="text-base font-semibold text-gray-800 dark:text-neutral-100">
|
||||
<Link
|
||||
href={`/shops/profile/${shop._id}`}
|
||||
className="mb-3 flex items-center gap-2"
|
||||
>
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800">
|
||||
{shop.logo ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={IMAGE_BASE_URL + shop.logo}
|
||||
alt={shop.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={staticIconUrl("/images/icons/vuesax/bold/shop.svg")}
|
||||
width={18}
|
||||
height={18}
|
||||
alt=""
|
||||
className="dark:invert"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-base font-bold text-gray-800 dark:text-neutral-100">
|
||||
{shop.name}
|
||||
</p>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-neutral-300">
|
||||
{shop.has_physical_location && (
|
||||
<>
|
||||
<p className="mt-2">
|
||||
|
||||
@@ -133,6 +133,21 @@ export const editUserNavLinks = [
|
||||
href: "/location",
|
||||
icon: "location.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.blockedUsers",
|
||||
href: "/blocked-users",
|
||||
icon: "user-remove.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.archive",
|
||||
href: "/archive",
|
||||
icon: "archive-book.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.activity",
|
||||
href: "/activity",
|
||||
icon: "activity.svg",
|
||||
},
|
||||
];
|
||||
|
||||
export const editEmployerNavLinks = [
|
||||
|
||||
@@ -51,11 +51,8 @@ export function buildProfileSeo(
|
||||
user.bio ||
|
||||
translateCommon(lang, "profileSeo.defaultBio", { siteName });
|
||||
|
||||
const cityPart = city
|
||||
? translateCommon(lang, "profileSeo.inCity", { city })
|
||||
: "";
|
||||
|
||||
const titleCore = [fullName || username, expertise, cityPart]
|
||||
const nameSegments = fullName ? [fullName, username] : [username];
|
||||
const titleCore = [...nameSegments, expertise, city]
|
||||
.filter(Boolean)
|
||||
.join(" – ");
|
||||
|
||||
@@ -69,7 +66,7 @@ export function buildProfileSeo(
|
||||
: "";
|
||||
|
||||
return {
|
||||
title: formatLocalizedSeoTitle(titleCore, lang),
|
||||
title: `${titleCore} | ${siteName}`,
|
||||
description,
|
||||
path,
|
||||
h1: fullName
|
||||
|
||||
@@ -13,11 +13,13 @@ export function buildFullPostCaption(
|
||||
packages: SelectedPostLink[],
|
||||
projects: SelectedPostLink[],
|
||||
billboards: SelectedPostLink[],
|
||||
lang?: AppLanguage
|
||||
lang?: AppLanguage,
|
||||
shops: SelectedPostLink[] = [],
|
||||
shopProducts: SelectedPostLink[] = []
|
||||
): string {
|
||||
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
|
||||
const linkHeader = buildPostLinkCaptionHeader(
|
||||
[...packages, ...projects, ...billboards],
|
||||
[...packages, ...projects, ...billboards, ...shops, ...shopProducts],
|
||||
lang
|
||||
);
|
||||
return [description.trim(), tagLine, linkHeader].filter(Boolean).join("\n");
|
||||
@@ -29,7 +31,9 @@ export function countPostCaptionLength(
|
||||
packages: SelectedPostLink[],
|
||||
projects: SelectedPostLink[],
|
||||
billboards: SelectedPostLink[],
|
||||
lang?: AppLanguage
|
||||
lang?: AppLanguage,
|
||||
shops: SelectedPostLink[] = [],
|
||||
shopProducts: SelectedPostLink[] = []
|
||||
): number {
|
||||
return buildFullPostCaption(
|
||||
description,
|
||||
@@ -37,6 +41,8 @@ export function countPostCaptionLength(
|
||||
packages,
|
||||
projects,
|
||||
billboards,
|
||||
lang
|
||||
lang,
|
||||
shops,
|
||||
shopProducts
|
||||
).length;
|
||||
}
|
||||
|
||||
@@ -55,14 +55,19 @@ export async function getDmConversationKey(
|
||||
const cached = dmKeyCache.get(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const { privateKey } = await ensureIdentityKeys(me);
|
||||
const peerPubB64 = await fetchPeerPublicKey(peer);
|
||||
if (!peerPubB64) return null;
|
||||
try {
|
||||
const { privateKey } = await ensureIdentityKeys(me);
|
||||
const peerPubB64 = await fetchPeerPublicKey(peer);
|
||||
if (!peerPubB64) return null;
|
||||
|
||||
const peerPub = await importPublicKeySpkiB64(peerPubB64);
|
||||
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
|
||||
dmKeyCache.set(ck, key);
|
||||
return key;
|
||||
const peerPub = await importPublicKeySpkiB64(peerPubB64);
|
||||
const key = await deriveDmAesKey(privateKey, peerPub, me, peer);
|
||||
dmKeyCache.set(ck, key);
|
||||
return key;
|
||||
} catch {
|
||||
// شبکه/IndexedDB میتواند گذرا خطا بدهد — نباید کل صفحه رمزگشایی را خراب کند
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearDmKeyCache(): void {
|
||||
@@ -95,15 +100,20 @@ export async function decryptDmContent(
|
||||
const remembered = recallOutboundPlaintext(raw);
|
||||
if (remembered) return remembered;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
try {
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return E2EE_LOCKED_PREVIEW;
|
||||
|
||||
const pt = await decryptText(key, raw);
|
||||
if (pt != null) {
|
||||
rememberOutboundPlaintext(raw, pt);
|
||||
return pt;
|
||||
const pt = await decryptText(key, raw);
|
||||
if (pt != null) {
|
||||
rememberOutboundPlaintext(raw, pt);
|
||||
return pt;
|
||||
}
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
} catch {
|
||||
// خطای گذرا در رمزگشایی یک پیام نباید بقیه پیامهای صفحه را قفل نشان دهد
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptDmMessageList<
|
||||
@@ -120,26 +130,38 @@ export async function decryptDmMessageList<
|
||||
): Promise<T[]> {
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
// خطای رمزگشایی یک پیام نباید کل صفحه را از حالت رمزگشاییشده خارج کند —
|
||||
// هر پیام مستقل پردازش میشود و در بدترین حالت فقط همان یکی قفل نمایش داده میشود
|
||||
let next = msg;
|
||||
const content = normalizeMessageContent(msg.content);
|
||||
if (content && isE2eeEnvelope(content)) {
|
||||
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
|
||||
next = { ...next, content: decrypted };
|
||||
} else if (typeof msg.content !== "string" && content) {
|
||||
next = { ...next, content };
|
||||
try {
|
||||
const content = normalizeMessageContent(msg.content);
|
||||
if (content && isE2eeEnvelope(content)) {
|
||||
const decrypted = await decryptDmContent(myUserId, peerUserId, content);
|
||||
next = { ...next, content: decrypted };
|
||||
} else if (typeof msg.content !== "string" && content) {
|
||||
next = { ...next, content };
|
||||
}
|
||||
} catch {
|
||||
next = { ...next, content: E2EE_LOCKED_PREVIEW };
|
||||
}
|
||||
if (next.replyTo?.content) {
|
||||
const replyRaw = normalizeMessageContent(next.replyTo.content);
|
||||
if (isE2eeEnvelope(replyRaw)) {
|
||||
const replyContent = await decryptDmContent(
|
||||
myUserId,
|
||||
peerUserId,
|
||||
replyRaw
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
try {
|
||||
if (next.replyTo?.content) {
|
||||
const replyRaw = normalizeMessageContent(next.replyTo.content);
|
||||
if (isE2eeEnvelope(replyRaw)) {
|
||||
const replyContent = await decryptDmContent(
|
||||
myUserId,
|
||||
peerUserId,
|
||||
replyRaw
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (next.replyTo) {
|
||||
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
|
||||
}
|
||||
}
|
||||
out.push(next);
|
||||
|
||||
@@ -144,7 +144,10 @@ export function buildLocalizedMetadataFields(
|
||||
const finalDescription = options.description || site.defaultDescription;
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
// {absolute} skips the root layout's "%s | siteName" template — finalTitle
|
||||
// already carries the site-name suffix, so applying the template again
|
||||
// would duplicate it.
|
||||
title: { absolute: finalTitle },
|
||||
description: finalDescription,
|
||||
keywords: options.keywords ?? site.keywords,
|
||||
alternates: buildHreflangAlternates(options.path),
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const POST_EDIT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function canEditPostWithinWindow(
|
||||
createdAt?: string | Date | null
|
||||
): boolean {
|
||||
if (!createdAt) return false;
|
||||
const ts = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(ts)) return false;
|
||||
return Date.now() - ts < POST_EDIT_WINDOW_MS;
|
||||
}
|
||||
|
||||
export function postEditRemainingMs(
|
||||
createdAt?: string | Date | null
|
||||
): number {
|
||||
if (!createdAt) return 0;
|
||||
const ts = new Date(createdAt).getTime();
|
||||
if (Number.isNaN(ts)) return 0;
|
||||
return Math.max(0, POST_EDIT_WINDOW_MS - (Date.now() - ts));
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { DEFAULT_LANGUAGE, type AppLanguage } from "@/lib/i18n/registry";
|
||||
|
||||
export type PostLinkKind = "pkg" | "project" | "billboard";
|
||||
export type PostLinkKind = "pkg" | "project" | "billboard" | "shop" | "shopproduct";
|
||||
|
||||
/** مارکر لینک داخل کپشن */
|
||||
export const POST_LINK_TOKEN_REGEX =
|
||||
/\[\[(pkg|project|billboard):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
|
||||
/\[\[(pkg|project|billboard|shop|shopproduct):([a-f0-9]{24})\]\]([\s\S]*?)\[\[\/\1\]\]/gi;
|
||||
|
||||
/** @deprecated use POST_LINK_TOKEN_REGEX */
|
||||
export const POST_PACKAGE_TOKEN_REGEX =
|
||||
@@ -19,12 +19,16 @@ const FA_LINK_LABELS = {
|
||||
pkg: "پکیج آموزشی",
|
||||
project: "پروژه",
|
||||
billboard: "بیلبورد",
|
||||
shop: "فروشگاه",
|
||||
shopproduct: "کالا",
|
||||
} as const;
|
||||
|
||||
const EN_LINK_LABELS = {
|
||||
pkg: "Training package",
|
||||
project: "Project",
|
||||
billboard: "Billboard",
|
||||
shop: "Shop",
|
||||
shopproduct: "Product",
|
||||
} as const;
|
||||
|
||||
export function getPostLinkLabels(lang: AppLanguage = DEFAULT_LANGUAGE) {
|
||||
@@ -53,6 +57,10 @@ export function buildPostLinkPath(
|
||||
return `/projects/${id}/${name}`;
|
||||
case "billboard":
|
||||
return `/billboards/${id}/${name}`;
|
||||
case "shop":
|
||||
return `/shops/profile/${id}`;
|
||||
case "shopproduct":
|
||||
return `/shops/listing/${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +85,15 @@ export function buildPostLinkCaptionHeader(
|
||||
pkg: [],
|
||||
project: [],
|
||||
billboard: [],
|
||||
shop: [],
|
||||
shopproduct: [],
|
||||
};
|
||||
for (const link of list) {
|
||||
groups[link.kind].push(link);
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
(["pkg", "project", "billboard"] as const).forEach((kind) => {
|
||||
(["pkg", "project", "billboard", "shop", "shopproduct"] as const).forEach((kind) => {
|
||||
if (!groups[kind].length) return;
|
||||
const tokens = groups[kind]
|
||||
.map((l) => buildPostLinkCaptionToken(l.kind, l._id, l.title))
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"user": "User",
|
||||
"retry": "Try again",
|
||||
"search": "Search…",
|
||||
"clear": "Clear"
|
||||
"clear": "Clear",
|
||||
"error": "Error",
|
||||
"close": "Close"
|
||||
},
|
||||
"verificationBadge": {
|
||||
"licenseAlt": "License badge",
|
||||
@@ -145,6 +147,19 @@
|
||||
"ratingOptional": "Rating (optional)",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"gifts": {
|
||||
"title": "Special gift",
|
||||
"given": "Gifts given",
|
||||
"received": "Gifts received",
|
||||
"empty": "No gifts yet.",
|
||||
"replied": "Replied",
|
||||
"tapToReply": "Tap to reply",
|
||||
"yourReply": "Your reply",
|
||||
"replyTitle": "Reply to gift",
|
||||
"replyRequired": "Reply text is required",
|
||||
"replySubmitted": "Your reply was submitted",
|
||||
"submitReply": "Submit reply"
|
||||
},
|
||||
"notifications": {
|
||||
"searchPlaceholder": "Search notifications",
|
||||
"empty": "No notifications yet.",
|
||||
@@ -235,6 +250,7 @@
|
||||
"empty": "No projects yet.",
|
||||
"projectRequestsHeading": "Users who applied for this project",
|
||||
"fetchError": "Could not load data",
|
||||
"payRemainingHint": "Completing the project will charge the remaining 70% of the project amount.",
|
||||
"drafts": "Drafts",
|
||||
"draftsEmpty": "No drafts yet.",
|
||||
"draftStep": "Step {{step}}",
|
||||
@@ -251,6 +267,32 @@
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"items": {
|
||||
"likes": "Likes",
|
||||
"comments": "Comments",
|
||||
"academyLikes": "Academy likes",
|
||||
"academyComments": "Academy comments",
|
||||
"billboardLikes": "Billboard likes",
|
||||
"billboardComments": "Billboard comments"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"empty": "No data to show",
|
||||
"range": {
|
||||
"7d": "7 days",
|
||||
"30d": "30 days",
|
||||
"90d": "90 days",
|
||||
"all": "All time"
|
||||
},
|
||||
"metrics": {
|
||||
"posts": "Posts",
|
||||
"followers": "Followers",
|
||||
"likes": "Likes",
|
||||
"comments": "Comments"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
@@ -272,6 +314,9 @@
|
||||
"publicRelations": "Public relations",
|
||||
"shaba": "Identity Information",
|
||||
"location": "Location",
|
||||
"blockedUsers": "Blocked users",
|
||||
"archive": "Archive",
|
||||
"activity": "Your activity",
|
||||
"bio": "Bio"
|
||||
},
|
||||
"bio": {
|
||||
@@ -755,6 +800,11 @@
|
||||
"postUnpinned": "Post unpinned",
|
||||
"pinFailed": "Could not pin post (max 3)",
|
||||
"pinnedBadge": "Pinned",
|
||||
"archivePost": "Archive",
|
||||
"publishPost": "Publish post",
|
||||
"postArchived": "Post archived",
|
||||
"postUnarchived": "Post published again",
|
||||
"archiveFailed": "Could not archive post",
|
||||
"reportReasons": {
|
||||
"inappropriate": "Inappropriate content",
|
||||
"spam": "Spam",
|
||||
@@ -806,6 +856,9 @@
|
||||
"noProject": "No projects yet",
|
||||
"billboard": "Billboard",
|
||||
"noBillboard": "No billboards yet",
|
||||
"shop": "Shop",
|
||||
"noShop": "No shops yet",
|
||||
"viewProducts": "View products",
|
||||
"optional": "optional",
|
||||
"optionalMulti": "optional — multiple",
|
||||
"defaultPackageTitle": "Course package",
|
||||
@@ -1008,6 +1061,27 @@
|
||||
"retry": "Pay again"
|
||||
}
|
||||
},
|
||||
"giftPage": {
|
||||
"title": "Special gift",
|
||||
"customAmount": "Custom amount",
|
||||
"customAmountPlaceholder": "Between {{min}} and {{max}} Toman",
|
||||
"customAmountInvalid": "Amount must be between {{min}} and {{max}} Toman",
|
||||
"messagePlaceholder": "Write a message",
|
||||
"payButton": "Pay",
|
||||
"paymentInfoError": "Error retrieving payment info",
|
||||
"paymentStartError": "Error starting payment",
|
||||
"paymentSuccess": {
|
||||
"title": "Payment successful",
|
||||
"message": "Your gift was sent successfully",
|
||||
"sendMessage": "Send message",
|
||||
"later": "Later"
|
||||
},
|
||||
"paymentFailed": {
|
||||
"title": "Payment failed",
|
||||
"message": "Your payment encountered an error.",
|
||||
"retry": "Pay again"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"pageLoad": "Error loading page",
|
||||
"safariHint": "If you're using Safari, clear your browser cache or reload the page.",
|
||||
@@ -1346,6 +1420,7 @@
|
||||
"you": "You"
|
||||
},
|
||||
"empty": "No conversations yet.",
|
||||
"shopChatSubtitle": "Shop chat",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"onlineCount": "{{count}} online",
|
||||
@@ -1369,6 +1444,16 @@
|
||||
"searchInMessages": "Search messages…",
|
||||
"typing": "{{name}} is typing…",
|
||||
"mediaOptions": "Media send options",
|
||||
"voice": {
|
||||
"slideUpToLock": "Slide up to lock"
|
||||
},
|
||||
"gift": {
|
||||
"modalTitle": "Special gift",
|
||||
"tapToOpen": "Tap to open",
|
||||
"sentSuccess": "Your gift was sent",
|
||||
"sending": "Sending…",
|
||||
"creditedHint": "This amount was added to your wallet"
|
||||
},
|
||||
"timedBanner": "Timed message — auto-delete after {{duration}}",
|
||||
"viewOnceBanner": "{{label}} — recipient can view only once",
|
||||
"viewOnceBadge": "1",
|
||||
@@ -1424,6 +1509,9 @@
|
||||
"attach": "Attach",
|
||||
"send": "Send",
|
||||
"recordVoice": "Record voice",
|
||||
"cancelVoice": "Cancel recording",
|
||||
"sendVoice": "Send voice",
|
||||
"gift": "Special gift",
|
||||
"menu": "Menu",
|
||||
"createRoom": "Create chat room",
|
||||
"addUser": "Add user",
|
||||
@@ -1462,6 +1550,10 @@
|
||||
"createRoomTitle": "Create chat room",
|
||||
"roomImage": "Room image",
|
||||
"roomName": "Room name",
|
||||
"idleTimeout": "Room idle timeout",
|
||||
"idleTimeoutDays_one": "{{count}} day",
|
||||
"idleTimeoutDays_other": "{{count}} days",
|
||||
"idleTimeoutHint": "If no message is exchanged for this long, the room is automatically deleted with all its messages. Default: 1 month.",
|
||||
"roomType": "Room type",
|
||||
"public": "Public",
|
||||
"private": "Private",
|
||||
@@ -1499,6 +1591,7 @@
|
||||
"toast": {
|
||||
"fileTooLarge": "File size must not exceed 200 MB.",
|
||||
"fileTooLargeNamed": "{{name}}: exceeds 200 MB",
|
||||
"shopChatVideoTooLarge": "Video size must not exceed 100 MB.",
|
||||
"micDenied": "Microphone access was denied.",
|
||||
"viewOnceDeleteFailed": "Failed to delete view-once message",
|
||||
"viewOnceOpenFailed": "Can't open view-once message",
|
||||
@@ -2065,6 +2158,7 @@
|
||||
"unfollow": "Unfollow",
|
||||
"block": "Block",
|
||||
"unblock": "Unblock",
|
||||
"specialGift": "Special gift",
|
||||
"appearance": "Appearance details",
|
||||
"services": "Service list",
|
||||
"collaboration": "Collaboration request",
|
||||
@@ -2197,6 +2291,7 @@
|
||||
"categoryTitle": "Shop category",
|
||||
"categoryRequired": "Category, sub-category, and a display choice are required",
|
||||
"shippingTitle": "Shipping methods",
|
||||
"shippingMethodRequired": "Please select a shipping method",
|
||||
"shippingMethodRequired": "Select at least one shipping method",
|
||||
"activeShippingMethods": "Active shipping methods",
|
||||
"supplementarySettings": "Supplementary settings & services",
|
||||
@@ -2330,6 +2425,7 @@
|
||||
"productNamePlaceholder": "Enter product name",
|
||||
"productNameRequired": "Product name is required",
|
||||
"productDescriptionPlaceholder": "Product description (optional)",
|
||||
"prefillFromSimilarProductHint": "Category, images, and details from this similar product will be pre-filled in the next steps — you can edit them.",
|
||||
"productCategoryTitle": "Product category",
|
||||
"productCategoryRequired": "Selecting a main category, subcategory, and tertiary category is required",
|
||||
"selectMainCategory": "Select the main category",
|
||||
@@ -2376,10 +2472,12 @@
|
||||
"proceedToPayment": "Pay",
|
||||
"paymentSuccessTitle": "Payment successful",
|
||||
"paymentSuccessHint": "Your order has been placed and is being prepared",
|
||||
"viewOrder": "View order",
|
||||
"viewOrder": "Track order",
|
||||
"backToHome": "Back to home",
|
||||
"paymentFailedTitle": "Payment failed",
|
||||
"paymentFailedHint": "Your payment encountered an error. Please try again",
|
||||
"retryPayment": "Retry payment",
|
||||
"backToShopList": "Back to shops",
|
||||
"orderStatus": {
|
||||
"pending_payment": "Pending payment",
|
||||
"on_hold": "On hold",
|
||||
@@ -2396,6 +2494,7 @@
|
||||
"printLabelA5": "Print A5 label",
|
||||
"printLabelA6": "Print A6 label",
|
||||
"printInvoice": "Print invoice",
|
||||
"downloadInvoice": "Download invoice",
|
||||
"trackingCodeLabel": "Tracking code",
|
||||
"changeStatusLabel": "Change status",
|
||||
"trackingSaved": "Tracking code saved",
|
||||
@@ -2403,6 +2502,8 @@
|
||||
"statusUpdated": "Order status updated",
|
||||
"invoiceTitle": "Invoice",
|
||||
"confirmReceipt": "I received the item",
|
||||
"completePurchase": "Complete purchase",
|
||||
"repurchase": "Buy again",
|
||||
"receiptConfirmed": "Receipt confirmed",
|
||||
"rateShop": "Rate & review",
|
||||
"ratingCommentPlaceholder": "Your review of the shop (optional)",
|
||||
@@ -2419,6 +2520,8 @@
|
||||
"buyerMobileLabel": "Mobile:",
|
||||
"shopInfoTitle": "Shop info",
|
||||
"orderNumberLabel": "Order number",
|
||||
"viewAndBuyButton": "View and buy",
|
||||
"availableInShopsCount": "Available in {{count}} shops",
|
||||
"searchOrderPlaceholder": "Search order number",
|
||||
"provinceRequired": "Province is required",
|
||||
"cityRequired": "City is required",
|
||||
@@ -2444,8 +2547,12 @@
|
||||
"walletShopTab": "Shop",
|
||||
"walletGiftTab": "Gift",
|
||||
"walletChargedTab": "Charged",
|
||||
"walletAcademyTab": "Academy",
|
||||
"academyBalance": "Academy balance",
|
||||
"walletProjectTab": "Project collaboration",
|
||||
"projectBalance": "Project collaboration balance",
|
||||
"availableToWithdraw": "Available to withdraw",
|
||||
"pendingConfirmation": "Pending confirmation",
|
||||
"pendingConfirmation": "Registered orders amount",
|
||||
"withdrawAmountPlaceholder": "Amount (Toman)",
|
||||
"requestWithdrawal": "Request withdrawal",
|
||||
"giftBalance": "Gift balance",
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
"user": "کاربر",
|
||||
"retry": "تلاش مجدد",
|
||||
"search": "جستجو…",
|
||||
"clear": "پاک"
|
||||
"clear": "پاک",
|
||||
"error": "خطا",
|
||||
"close": "بستن"
|
||||
},
|
||||
"verificationBadge": {
|
||||
"licenseAlt": "تیک مجوز",
|
||||
@@ -145,6 +147,19 @@
|
||||
"ratingOptional": "امتیاز (اختیاری)",
|
||||
"submit": "ثبت"
|
||||
},
|
||||
"gifts": {
|
||||
"title": "هدیه ویژه",
|
||||
"given": "هدیه دادم",
|
||||
"received": "هدیه گرفتم",
|
||||
"empty": "هدیهای ثبت نشده است.",
|
||||
"replied": "پاسخ داده شد",
|
||||
"tapToReply": "برای پاسخ لمس کنید",
|
||||
"yourReply": "پاسخ شما",
|
||||
"replyTitle": "پاسخ به هدیه",
|
||||
"replyRequired": "متن پاسخ الزامی است",
|
||||
"replySubmitted": "پاسخ شما ثبت شد",
|
||||
"submitReply": "ثبت پاسخ"
|
||||
},
|
||||
"notifications": {
|
||||
"searchPlaceholder": "جستجو در اعلانها",
|
||||
"empty": "اعلانی ثبت نشده است.",
|
||||
@@ -235,6 +250,7 @@
|
||||
"empty": "پروژهای ثبت نشده است.",
|
||||
"projectRequestsHeading": "کاربرانی که برای این پروژه درخواست ارسال کرده اند",
|
||||
"fetchError": "خطا در دریافت اطلاعات",
|
||||
"payRemainingHint": "با تکمیل پروژه، ۷۰٪ باقیمانده مبلغ پروژه پرداخت میشود.",
|
||||
"drafts": "پیشنویسها",
|
||||
"draftsEmpty": "پیشنویسی وجود ندارد.",
|
||||
"draftStep": "مرحله {{step}}",
|
||||
@@ -251,6 +267,32 @@
|
||||
"cancelled": "کنسل شده"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"items": {
|
||||
"likes": "پسند",
|
||||
"comments": "نظرها",
|
||||
"academyLikes": "پسند آموزشگاه",
|
||||
"academyComments": "نظرات آموزشگاه",
|
||||
"billboardLikes": "پسند بیلبورد",
|
||||
"billboardComments": "نظرات بیلبورد"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "آنالیتیکس",
|
||||
"empty": "دادهای برای نمایش وجود ندارد",
|
||||
"range": {
|
||||
"7d": "۷ روز",
|
||||
"30d": "۳۰ روز",
|
||||
"90d": "۹۰ روز",
|
||||
"all": "همه"
|
||||
},
|
||||
"metrics": {
|
||||
"posts": "پستها",
|
||||
"followers": "دنبالکننده",
|
||||
"likes": "پسندها",
|
||||
"comments": "نظرها"
|
||||
}
|
||||
},
|
||||
"edit": {
|
||||
"save": "ویرایش",
|
||||
"add": "افزودن",
|
||||
@@ -272,6 +314,9 @@
|
||||
"publicRelations": "روابط عمومی",
|
||||
"shaba": "اطلاعات هویتی",
|
||||
"location": "لوکیشن",
|
||||
"blockedUsers": "لیست کاربران مسدود",
|
||||
"archive": "بایگانی",
|
||||
"activity": "فعالیت شما",
|
||||
"bio": "بیو"
|
||||
},
|
||||
"bio": {
|
||||
@@ -755,6 +800,11 @@
|
||||
"postUnpinned": "پین پست برداشته شد",
|
||||
"pinFailed": "پین کردن پست ناموفق بود (حداکثر ۳ پست)",
|
||||
"pinnedBadge": "پین شده",
|
||||
"archivePost": "بایگانی",
|
||||
"publishPost": "انتشار پست",
|
||||
"postArchived": "پست بایگانی شد",
|
||||
"postUnarchived": "پست دوباره منتشر شد",
|
||||
"archiveFailed": "بایگانی کردن پست ناموفق بود",
|
||||
"reportReasons": {
|
||||
"inappropriate": "محتوای نامناسب",
|
||||
"spam": "اسپم",
|
||||
@@ -806,6 +856,9 @@
|
||||
"noProject": "پروژهای ثبت نشده",
|
||||
"billboard": "بیلبورد",
|
||||
"noBillboard": "بیلبوردی ثبت نشده",
|
||||
"shop": "فروشگاه",
|
||||
"noShop": "فروشگاهی ثبت نشده",
|
||||
"viewProducts": "نمایش کالاها",
|
||||
"optional": "اختیاری",
|
||||
"optionalMulti": "اختیاری — چندتایی",
|
||||
"defaultPackageTitle": "پکیج آموزشی",
|
||||
@@ -1008,6 +1061,27 @@
|
||||
"retry": "پرداخت مجدد"
|
||||
}
|
||||
},
|
||||
"giftPage": {
|
||||
"title": "هدیه ویژه",
|
||||
"customAmount": "مبلغ دلخواه",
|
||||
"customAmountPlaceholder": "بین {{min}} تا {{max}} تومان",
|
||||
"customAmountInvalid": "مبلغ باید بین {{min}} تا {{max}} تومان باشد",
|
||||
"messagePlaceholder": "متنی بنویسید",
|
||||
"payButton": "پرداخت",
|
||||
"paymentInfoError": "خطا در دریافت اطلاعات پرداخت",
|
||||
"paymentStartError": "خطا در شروع پرداخت",
|
||||
"paymentSuccess": {
|
||||
"title": "پرداخت موفق",
|
||||
"message": "هدیه شما با موفقیت ارسال شد",
|
||||
"sendMessage": "ارسال پیام",
|
||||
"later": "بعدا"
|
||||
},
|
||||
"paymentFailed": {
|
||||
"title": "پرداخت ناموفق",
|
||||
"message": "پرداخت شما با خطا مواجه شد.",
|
||||
"retry": "پرداخت مجدد"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"pageLoad": "خطا در بارگذاری صفحه",
|
||||
"safariHint": "اگر در Safari هستید، کش مرورگر را پاک کنید یا صفحه را دوباره بارگذاری کنید.",
|
||||
@@ -1346,6 +1420,7 @@
|
||||
"you": "شما"
|
||||
},
|
||||
"empty": "هنوز مکالمهای ندارید.",
|
||||
"shopChatSubtitle": "چت فروشگاه",
|
||||
"online": "آنلاین",
|
||||
"offline": "آفلاین",
|
||||
"onlineCount": "{{count}} آنلاین",
|
||||
@@ -1369,6 +1444,16 @@
|
||||
"searchInMessages": "جستجو در پیامها…",
|
||||
"typing": "{{name}} در حال نوشتن…",
|
||||
"mediaOptions": "گزینههای ارسال مدیا",
|
||||
"voice": {
|
||||
"slideUpToLock": "برای قفل، بکشید بالا"
|
||||
},
|
||||
"gift": {
|
||||
"modalTitle": "هدیه ویژه",
|
||||
"tapToOpen": "برای باز کردن لمس کنید",
|
||||
"sentSuccess": "هدیه شما ارسال شد",
|
||||
"sending": "در حال ارسال…",
|
||||
"creditedHint": "این مبلغ به کیف پول شما اضافه شد"
|
||||
},
|
||||
"timedBanner": "پیام زماندار — حذف خودکار بعد از {{duration}}",
|
||||
"viewOnceBanner": "{{label}} — گیرنده فقط یکبار میتواند ببیند",
|
||||
"viewOnceBadge": "۱",
|
||||
@@ -1424,6 +1509,9 @@
|
||||
"attach": "پیوست",
|
||||
"send": "ارسال",
|
||||
"recordVoice": "ضبط ویس",
|
||||
"cancelVoice": "لغو ضبط",
|
||||
"sendVoice": "ارسال ویس",
|
||||
"gift": "هدیه ویژه",
|
||||
"menu": "منو",
|
||||
"createRoom": "ساخت اتاق چت روم",
|
||||
"addUser": "افزودن کاربر",
|
||||
@@ -1462,6 +1550,9 @@
|
||||
"createRoomTitle": "ساخت اتاق چت روم",
|
||||
"roomImage": "تصویر اتاق",
|
||||
"roomName": "نام اتاق",
|
||||
"idleTimeout": "زمان بیکاری اتاق",
|
||||
"idleTimeoutDays": "{{count}} روز",
|
||||
"idleTimeoutHint": "اگر بعد از این مدت پیامی رد و بدل نشود، اتاق بهطور خودکار با همه پیامهایش حذف میشود. پیشفرض: ۱ ماه.",
|
||||
"roomType": "نوع اتاق",
|
||||
"public": "عمومی",
|
||||
"private": "خصوصی",
|
||||
@@ -1499,6 +1590,7 @@
|
||||
"toast": {
|
||||
"fileTooLarge": "حجم فایل نباید بیشتر از 200 مگابایت باشد.",
|
||||
"fileTooLargeNamed": "{{name}}: حجم بیش از ۲۰۰ مگابایت",
|
||||
"shopChatVideoTooLarge": "حجم ویدیو نباید بیشتر از ۱۰۰ مگابایت باشد.",
|
||||
"micDenied": "دسترسی به میکروفون داده نشد.",
|
||||
"viewOnceDeleteFailed": "حذف پیام یکبارمصرف ناموفق بود",
|
||||
"viewOnceOpenFailed": "باز کردن پیام یکبارمصرف ممکن نیست",
|
||||
@@ -2065,6 +2157,7 @@
|
||||
"unfollow": "لغو دنبال کردن",
|
||||
"block": "مسدود کردن",
|
||||
"unblock": "رفع مسدودیت",
|
||||
"specialGift": "هدیه ویژه",
|
||||
"appearance": "مشخصات ظاهری",
|
||||
"services": "لیست خدمات",
|
||||
"collaboration": "درخواست همکاری",
|
||||
@@ -2197,6 +2290,7 @@
|
||||
"categoryTitle": "دستهبندی فروشگاه",
|
||||
"categoryRequired": "انتخاب دستهبندی، زیردسته و مورد نمایشی الزامی است",
|
||||
"shippingTitle": "روشهای ارسال",
|
||||
"shippingMethodRequired": "انتخاب روش ارسال الزامی است",
|
||||
"shippingMethodRequired": "حداقل یک روش ارسال را انتخاب کنید",
|
||||
"activeShippingMethods": "لیست روشهای ارسال فعال",
|
||||
"supplementarySettings": "تنظیمات مکمل و خدمات",
|
||||
@@ -2330,6 +2424,7 @@
|
||||
"productNamePlaceholder": "نام کالا را وارد کنید",
|
||||
"productNameRequired": "نام کالا الزامی است",
|
||||
"productDescriptionPlaceholder": "توضیحات کالا (اختیاری)",
|
||||
"prefillFromSimilarProductHint": "دستهبندی، تصاویر و مشخصات این کالای مشابه در مراحل بعد از قبل پر میشود؛ میتوانید ویرایش کنید.",
|
||||
"productCategoryTitle": "دستهبندی کالا",
|
||||
"productCategoryRequired": "انتخاب دستهبندی اصلی، زیرگروه و دستهبندی فرعی الزامی است",
|
||||
"selectMainCategory": "دستهبندی اصلی را انتخاب کنید",
|
||||
@@ -2376,10 +2471,12 @@
|
||||
"proceedToPayment": "پرداخت",
|
||||
"paymentSuccessTitle": "پرداخت با موفقیت انجام شد",
|
||||
"paymentSuccessHint": "سفارش شما ثبت شد و در حال آمادهسازی است",
|
||||
"viewOrder": "مشاهده سفارش",
|
||||
"viewOrder": "پیگیری سفارش",
|
||||
"backToHome": "بازگشت به خانه",
|
||||
"paymentFailedTitle": "پرداخت ناموفق بود",
|
||||
"paymentFailedHint": "پرداخت شما با خطا مواجه شد. لطفاً دوباره تلاش کنید",
|
||||
"retryPayment": "تلاش مجدد",
|
||||
"backToShopList": "بازگشت به فروشگاه",
|
||||
"orderStatus": {
|
||||
"pending_payment": "در انتظار پرداخت",
|
||||
"on_hold": "در انتظار بررسی",
|
||||
@@ -2396,6 +2493,7 @@
|
||||
"printLabelA5": "چاپ برچسب A5",
|
||||
"printLabelA6": "چاپ برچسب A6",
|
||||
"printInvoice": "چاپ فاکتور",
|
||||
"downloadInvoice": "دانلود فاکتور",
|
||||
"trackingCodeLabel": "کد رهگیری",
|
||||
"changeStatusLabel": "تغییر وضعیت",
|
||||
"trackingSaved": "کد رهگیری ثبت شد",
|
||||
@@ -2403,6 +2501,8 @@
|
||||
"statusUpdated": "وضعیت سفارش بهروزرسانی شد",
|
||||
"invoiceTitle": "فاکتور خرید",
|
||||
"confirmReceipt": "کالا را دریافت کردم",
|
||||
"completePurchase": "تکمیل خرید",
|
||||
"repurchase": "خرید مجدد",
|
||||
"receiptConfirmed": "دریافت کالا ثبت شد",
|
||||
"rateShop": "ثبت نظر و امتیاز",
|
||||
"ratingCommentPlaceholder": "نظر شما درباره فروشگاه (اختیاری)",
|
||||
@@ -2419,6 +2519,8 @@
|
||||
"buyerMobileLabel": "شماره موبایل:",
|
||||
"shopInfoTitle": "اطلاعات فروشگاه",
|
||||
"orderNumberLabel": "شماره سفارش",
|
||||
"viewAndBuyButton": "نمایش و خرید",
|
||||
"availableInShopsCount": "موجود در {{count}} فروشگاه",
|
||||
"searchOrderPlaceholder": "جستجوی شماره سفارش",
|
||||
"provinceRequired": "انتخاب استان الزامی است",
|
||||
"cityRequired": "انتخاب شهر الزامی است",
|
||||
@@ -2444,8 +2546,12 @@
|
||||
"walletShopTab": "فروشگاه",
|
||||
"walletGiftTab": "هدیه",
|
||||
"walletChargedTab": "شارژ شده",
|
||||
"availableToWithdraw": "قابل تسویه",
|
||||
"pendingConfirmation": "در انتظار تایید",
|
||||
"walletAcademyTab": "آموزشگاه",
|
||||
"academyBalance": "موجودی آموزشگاه",
|
||||
"walletProjectTab": "همکاری پروژه",
|
||||
"projectBalance": "موجودی همکاری پروژه",
|
||||
"availableToWithdraw": "قابل برداشت",
|
||||
"pendingConfirmation": "مبلغ سفارشهای ثبتشده",
|
||||
"withdrawAmountPlaceholder": "مبلغ (تومان)",
|
||||
"requestWithdrawal": "درخواست تسویه",
|
||||
"giftBalance": "موجودی هدیه",
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface IShopProductListing {
|
||||
variants: IShopProductVariant[];
|
||||
status: "pending_shop_approval" | "active" | "inactive";
|
||||
createdAt?: string;
|
||||
/** Number of active listings across all shops sharing this catalog product (discover feed only). */
|
||||
shop_count?: number;
|
||||
}
|
||||
export interface LastPost {
|
||||
_id: string;
|
||||
@@ -370,6 +372,7 @@ export interface Project {
|
||||
selected_user: string | null | SelectedUser;
|
||||
final_price: number | null;
|
||||
final_time: number | null;
|
||||
completionShareCredited?: boolean;
|
||||
reject_reason: string | null;
|
||||
installments: Installment[];
|
||||
ratings: string[];
|
||||
@@ -538,6 +541,7 @@ export interface Post {
|
||||
is_saved?: boolean;
|
||||
is_following?: boolean;
|
||||
is_pinned?: boolean;
|
||||
is_archived?: boolean;
|
||||
rate?: number;
|
||||
type?: string;
|
||||
files: PostFile[];
|
||||
@@ -593,6 +597,17 @@ export interface IReceiver {
|
||||
user_score: string | null;
|
||||
}
|
||||
|
||||
export interface IGift {
|
||||
_id: string;
|
||||
sender: ISender;
|
||||
receiver: IReceiver;
|
||||
amount: number;
|
||||
message: string | null;
|
||||
reply: string | null;
|
||||
repliedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface IProjectType {
|
||||
name: string;
|
||||
price: string | number;
|
||||
|
||||
@@ -31,7 +31,9 @@ export function generatePageMetadata(options: PageMetadataOptions): Metadata {
|
||||
|
||||
const ogTitle =
|
||||
options.ogTitle ||
|
||||
(typeof localized.title === "string" ? localized.title : options.title);
|
||||
(typeof localized.openGraph?.title === "string"
|
||||
? localized.openGraph.title
|
||||
: options.title);
|
||||
|
||||
return {
|
||||
...localized,
|
||||
|
||||
Reference in New Issue
Block a user