shop full

This commit is contained in:
payacom
2026-08-04 21:12:03 +03:30
parent dc4467d43d
commit 89320d57b9
89 changed files with 5761 additions and 2386 deletions

View File

@@ -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"));

View File

@@ -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">

View 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
View 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>
);
}

View 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;

View 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;

View File

@@ -674,10 +674,10 @@ select {
@keyframes nearby-radar-sweep {
from {
transform: rotate(0deg);
transform: rotate(360deg);
}
to {
transform: rotate(360deg);
transform: rotate(0deg);
}
}

View File

@@ -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>
)}

View File

@@ -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);

View File

@@ -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}

View File

@@ -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")}

View File

@@ -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}

View File

@@ -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>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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

View 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;

View File

@@ -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 || []) || []
: [];

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

View File

@@ -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" />

View 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"
/>
);
}

View 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)}
/>
</>
);
}

View File

@@ -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 />;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />
</>
);
}

View 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,
}}
/>
)}
</>
);
}

View File

@@ -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 />;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
)}

View File

@@ -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")}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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}
/>
</>
);
}

View File

@@ -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>

View File

@@ -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)}

View 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;
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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")}>

View 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;

View 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;

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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")}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 = [

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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),

View File

@@ -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));
}

View File

@@ -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))

View File

@@ -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",

View File

@@ -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": "موجودی هدیه",

View File

@@ -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;

View File

@@ -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,