Fix shop UI RTL/layout consistency and tab reselect behavior

- Align shop header layout (logo, name, verification badge, share button)
  and RTL/LTR positioning across shop, listing, billboard, wallet, gift,
  and academy pages with the Android app's equivalent screens
- Fix TabNavigation's tab-reselect scroll-to-top to only fire on the
  exact tab route, not any active sub-route
- Add verification badge to shop owner name; add brand icon assets

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
payacom
2026-09-15 17:29:24 +03:30
parent 9d48c0534e
commit 9310062616
40 changed files with 505 additions and 255 deletions

View File

@@ -524,9 +524,11 @@ export default function CourseDetail({
}
if (paymentUrl) {
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academy.course.redirectedToPayment"));
// window.open بعدِ یه await در سافاری (دسکتاپ و خصوصاً iOS) پاپ‌آپ‌بلاک می‌شه —
// چون دیگه synchronously به همون کلیکِ کاربر متصل نیست. window.location.href هم
// مطمئن‌تره، هم با بقیه‌ی مسیرهای پرداختِ این اپ (booking، پروژه، بیلبورد) یکی می‌مونه
window.location.href = paymentUrl;
return;
} else {
console.error("ساختار پاسخ نامعتبر:", response);
toast.error(

View File

@@ -82,7 +82,9 @@ function FailedProject() {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -66,7 +66,9 @@ function BillboardPayment({ params }: IBillboardProps) {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -56,7 +56,9 @@ function FailedBillboard() {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);
@@ -80,7 +82,9 @@ function FailedBillboard() {
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -9,7 +9,6 @@ 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";
@@ -25,7 +24,6 @@ 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>(
@@ -67,10 +65,12 @@ function GiftPage({ params }: GiftPageProps) {
"/gifts/initiate",
{ receiverId: userId, amount: finalAmount, message }
);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
if (response?.paymentUrl) {
router.push(response.paymentUrl);
window.location.href = response.paymentUrl;
} else if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
} else {
toast.error(t("giftPage.paymentInfoError"));
}

View File

@@ -158,13 +158,14 @@ function TicketChat({ params }: ITicketChatProps) {
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
window.open(data.paymentUrl, "_blank", "noopener,noreferrer");
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت می‌کنه، نه تبِ
// اصلی — پس با _blank دو تب باز می‌موند. window.location.href هم امن‌تره
// هم با بقیه‌ی مسیرهای پرداختِ این اپ یکی می‌مونه
window.location.href = data.paymentUrl;
} else if (data.authority) {
window.open(
`https://www.zarinpal.com/pg/StartPay/${data.authority}`,
"_blank",
"noopener,noreferrer"
);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
toast.error(t("offerPage.paymentInfoError"));
}

View File

@@ -8,8 +8,9 @@ import Header from "@/components/main/Header";
import ShopListCard from "@/components/shops/ShopListCard";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
type MyShop = {
@@ -28,15 +29,19 @@ export default function ShopSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const { request } = useAxios();
const [myShops, setMyShops] = useState<MyShop[] | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
request<{ shops: MyShop[] }>("GET", "/shops/mine")
.then((res) => setMyShops(res?.shops || []))
.catch(() => setMyShops([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// طبق خواسته‌ی کاربر: بعدِ برگشت از ویرایشِ فروشگاه بدونِ رفرشِ دستی تغییرات دیده
// بشن — قبلاً با useEffect(..., []) فقط یه‌بار موقعِ اولین mount فچ می‌شد؛ عینِ
// useInfiniteScroll (لیستِ کالاها) با refetchOnMount:"always" هر بار برگردیم به
// این صفحه دوباره فچ می‌شه
const { data } = useQuery({
queryKey: ["shops-mine"],
queryFn: () => request<{ shops: MyShop[] }>("GET", "/shops/mine"),
staleTime: 0,
refetchOnMount: "always",
});
const myShops = data?.shops ?? null;
const filteredShops = useMemo(() => {
if (!myShops) return [];

View File

@@ -8,7 +8,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -31,7 +31,6 @@ type WalletSummary = {
export default function WalletSettingsPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
@@ -88,7 +87,9 @@ export default function WalletSettingsPage() {
{ amount }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (err: unknown) {
const message =

View File

@@ -8,6 +8,7 @@ import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ShareShopModal from "@/components/shops/ShareShopModal";
import ShopProductListRow from "@/components/shops/ShopProductListRow";
import PageTitle from "@/components/settings/PageTitle";
import VerificationBadge from "@/components/main/VerificationBadge";
import BoldIcon from "@/components/ui/BoldIcon";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
@@ -25,10 +26,12 @@ type ShopHeader = {
_id: string;
name: string;
logo?: string | null;
owner?: { verify_badge?: string | null } | null;
};
export default function ShopProductsPage() {
const { t } = useTranslation("common");
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;
@@ -73,43 +76,55 @@ export default function ShopProductsPage() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowShareModal(true)}
aria-label={t("shops.share.title")}
className="flex h-8 w-8 shrink-0 items-center justify-center"
{/* طبق خواسته‌ی کاربر: لوگو راست (فرزند اول → فیزیکی راست زیرِ RTL)، بعدش نامِ
فروشگاه (فرزند دوم، flex-1 باقی فضا رو پر می‌کنه و کلیک‌پذیره مثلِ لوگو)،
و اشتراک‌گذاری سمتِ چپِ کادر و روبروی نام (فرزند سوم/آخر → فیزیکی چپ‌ترین) —
عینِ اندروید (ShopProductsScreen.tsx) */}
<div className="flex items-center gap-2">
{shopId && (
<Link
href={`/shops/profile/${shopId}`}
aria-label={t("shops.viewPublicProfile")}
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-100 dark:bg-neutral-800"
>
<BoldIcon name="share" size={18} tinted />
</button>
{shopId && (
<Link
href={`/shops/profile/${shopId}`}
aria-label={t("shops.viewPublicProfile")}
className="flex h-8 w-8 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={16}
height={16}
alt=""
className="dark:invert"
/>
)}
</Link>
)}
</div>
{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={16}
height={16}
alt=""
className="dark:invert"
/>
)}
</Link>
)}
<span className="truncate text-sm font-semibold">{shop?.name}</span>
<Link
href={`/shops/profile/${shopId}`}
className={cn(
"flex flex-1 items-center gap-1 truncate text-sm font-semibold",
isFa ? "text-right" : "text-left"
)}
>
{shop?.name}
<VerificationBadge verifyBadge={shop?.owner?.verify_badge} size={13} />
</Link>
<button
type="button"
onClick={() => setShowShareModal(true)}
aria-label={t("shops.share.title")}
className="flex h-8 w-8 shrink-0 items-center justify-center"
>
<BoldIcon name="share" size={18} tinted />
</button>
</div>
<PageTitle>{t("shops.myProductsTitle")}</PageTitle>

View File

@@ -11,6 +11,7 @@ import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { saveProductWizardId } from "@/hooks/useProductWizardId";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
import { IShopProductListing, IShopProductVariant } from "@/types/types";
import { useQueryClient } from "@tanstack/react-query";
import Image from "next/image";
@@ -197,6 +198,23 @@ export default function ProductManagePage() {
router.push(`/shops/${shopId}/products/new/name?edit=1`);
};
// طبق خواسته‌ی کاربر: اشتراک‌گذاری لینکِ خودِ کالا — عینِ ListingDetailClient.tsx
const shareProduct = async () => {
if (!listing?._id) return;
const productUrl = `https://modstagram.com/shops/listing/${listing._id}`;
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({ url: productUrl, title: listing.title });
return;
} catch {
return;
}
}
const ok = await copyTextToClipboard(productUrl);
if (ok) toast.success(t("shops.share.copied"));
else toast.error(t("shops.share.copyFailed"));
};
if (!listing) return null;
const images = listing.images || [];
@@ -213,33 +231,52 @@ export default function ProductManagePage() {
<Header />
<LocalePageShell>
<Container className="pb-28">
<div className="flex items-center justify-between gap-2">
<div className="flex w-full">
<BackButton />
<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 w-full items-center justify-between gap-2">
<div className="flex items-center 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>
</div>
<button
type="button"
onClick={() => void shareProduct()}
aria-label={t("shops.share.title")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="share" size={18} tinted />
</button>
</div>
{/* گردی گوشه‌های کادر ۱۶px (rounded-2xl) عینِ اندروید (ProductDetailScreen.tsx:
card.borderRadius=16) — قبلاً rounded-3xl (۲۴px) بود */}
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-2xl border border-neutral-200 p-4 dark:border-neutral-700">
{/* عینِ اندروید: عنوان راست (فرزند اول)، بجِ تخفیف چپ (فرزند آخر) — قبلاً
برعکس بود (بج قبل از عنوان) */}
<div className="flex items-center justify-center gap-2">
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
<h1 className="text-xl font-bold">{listing.title}</h1>
{discountPercent != null && <DiscountBadge percent={discountPercent} />}
</div>
{images.length > 0 && (

View File

@@ -138,7 +138,11 @@ function ProductNamePage() {
<LocalePageShell>
<AuthPageLayout>
<div className="flex w-full max-w-sm justify-start">
<BackButton />
{/* پیش‌فرضِ router.back() اینجا کاربر رو برمی‌گردوند به هرجایی که ازش وارد این
ویزارد شده — که برای ویرایش، صفحه‌ی نمایشِ کالا
(shops/[shopId]/products/[listingId]) بود، نه لیستِ کالاها. طبقِ خواسته‌ی
کاربر همیشه صریحاً می‌ره به لیستِ کالاهای همین فروشگاه */}
<BackButton href={`/shops/${shopId}`} />
</div>
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.productNameTitle")}</span>

View File

@@ -297,6 +297,7 @@ function ProductVariantsPage() {
loading={loading}
disabled={loading || rows.length === 0}
onClick={handleSubmit}
className="border-[#ef4444] bg-[#ef4444] text-white hover:bg-[#ef4444]/90 dark:bg-[#ef4444] dark:hover:bg-[#ef4444]/90"
>
{t("shops.saveProduct")}
</AuthNextButton>

View File

@@ -6,7 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useParams, useRouter } from "next/navigation";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import toast from "react-hot-toast";
@@ -34,7 +34,6 @@ type OrderDetail = {
export default function CheckoutPage() {
const { t } = useTranslation("common");
const router = useRouter();
const params = useParams<{ orderId: string }>();
const { request, loading } = useAxios();
const [order, setOrder] = useState<OrderDetail | null>(null);
@@ -54,7 +53,10 @@ export default function CheckoutPage() {
{ orderId: params.orderId }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
// router.push (next/navigation) برای URLهای بیرونی رفتارِ تضمین‌شده‌ای نداره —
// window.location.href مطمئن‌ترین راهه (خصوصاً سافاری) و با بقیه‌ی مسیرهای
// پرداختِ این اپ (booking، آکادمی) هم یکی می‌مونه
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (err: unknown) {
const message =

View File

@@ -10,7 +10,9 @@ import BackButton from "@/components/ui/BackButton";
import DiscountBadge from "@/components/ui/DiscountBadge";
import TabNavigation from "@/components/TabNavigation";
import useAxios from "@/hooks/useAxios";
import { copyTextToClipboard } from "@/lib/chat/getCopyableMessageText";
import { cn } from "@/lib/utils";
import { btnDefault, btnPrimary } from "@/lib/ui/buttonStyles";
import { IShopProductListing, IShopProductVariant } from "@/types/types";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
@@ -94,6 +96,24 @@ export default function ListingDetailClient() {
const shopInfo: ShopHeader | null =
listing && typeof listing.shop === "object" ? (listing.shop as ShopHeader) : null;
// طبق خواسته‌ی کاربر: اشتراک‌گذاری لینکِ خودِ کالا، نه فروشگاه — عینِ shareProduct
// توی ProductDetailScreen.tsx اندروید (Web Share API با fallback به کپیِ لینک)
const shareProduct = async () => {
if (!listing?._id) return;
const productUrl = `https://modstagram.com/shops/listing/${listing._id}`;
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({ url: productUrl, title: listing.title });
return;
} catch {
return;
}
}
const ok = await copyTextToClipboard(productUrl);
if (ok) toast.success(t("shops.share.copied"));
else toast.error(t("shops.share.copyFailed"));
};
useEffect(() => {
if (!listing?._id) return;
const token = Cookies.get("token") || "";
@@ -164,6 +184,10 @@ export default function ListingDetailClient() {
}, [matchedVariant?._id]);
const enabledShippingMethods = shopInfo?.shipping_methods?.filter((m) => m.enabled) || [];
// هزینه‌ی روشِ ارسالِ انتخاب‌شده — قبلاً هیچ‌جا خونده نمی‌شد، پس انتخابِ روشِ ارسال
// هیچ تاثیری روی قیمتِ نمایش‌داده‌شده نداشت (فقط موقعِ ثبتِ سفارش/handleBuy چک می‌شد)
const shippingCost =
enabledShippingMethods.find((m) => m.method === shippingMethod)?.cost || 0;
const categoryLine = listing
? [listing.category, listing.sub_category, listing.sub_sub_category]
@@ -256,34 +280,47 @@ export default function ListingDetailClient() {
<div className="flex w-full">
<BackButton />
</div>
{/* طبق خواسته‌ی کاربر: دکمه‌ی اشتراک‌گذاریِ خودِ کالا (نه فروشگاه) روبروی نام
فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید */}
{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>
<div className="flex w-full items-center justify-between gap-2">
<Link
href={`/shops/profile/${shopInfo._id}`}
className="flex items-center 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>
<button
type="button"
onClick={() => void shareProduct()}
aria-label={t("shops.share.title")}
className="flex h-9 w-9 shrink-0 items-center justify-center"
>
<BoldIcon name="share" size={18} tinted />
</button>
</div>
) : 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">
{/* گردی گوشه‌های کادر ۱۶px (rounded-2xl) عینِ اندروید — قبلاً rounded-3xl (۲۴px) بود */}
<div className="mx-auto mt-4 flex w-full max-w-md flex-col gap-4 rounded-2xl 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 && (
@@ -432,14 +469,14 @@ export default function ListingDetailClient() {
</SpecRow>
)}
{/* عین ProductDetailScreen.tsx اندروید: دکمه‌ی ثبت خرید با colors.success (#387E65)،
{/* عین ProductDetailScreen.tsx اندروید: دکمه‌ی ثبت خرید با colors.danger (#ef4444)،
و اسپینر تعداد یک پیل بوردردار خنثی با دکمه‌های +/- متنی، نه دو دکمه‌ی دایره‌ای رنگی */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleBuy()}
disabled={loading || !matchedVariant}
className="flex-1 rounded-2xl bg-[#387E65] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
className="flex-1 rounded-2xl bg-[#ef4444] py-2.5 text-center text-sm font-bold text-white disabled:opacity-50"
>
{t("shops.placeOrderButton")}
</button>
@@ -471,15 +508,15 @@ export default function ListingDetailClient() {
{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")}
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
<span className="text-sm font-bold text-red-500">
{(matchedVariant.discount_price * quantity).toLocaleString()} {t("settings.toman")}
{(matchedVariant.discount_price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
</span>
) : (
<span className="text-sm font-bold">
{(matchedVariant.price * quantity).toLocaleString()} {t("settings.toman")}
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
</span>
)}
</SpecRow>
@@ -487,19 +524,19 @@ export default function ListingDetailClient() {
</div>
<div className="mx-auto mb-6 mt-8 flex w-full max-w-md gap-3">
{/* عین ProductDetailScreen.tsx اندروید: سبد خرید با colors.info (#17337B) توپر،
علاقه‌مندی فقط حاشیه‌دارِ همون رنگ (نه پرشده) */}
{/* طبق خواسته‌ی کاربر: همون رنگ/استایلِ دکمه‌های اعمال/پاک‌کردنِ فیلترها —
سبد خرید مثل «اعمال» (btnPrimary)، علاقه‌مندی مثل «پاک کردن» (btnDefault) */}
<button
type="button"
onClick={handleAddToCart}
className="flex-1 rounded-2xl bg-[#17337B] py-3 text-sm font-bold text-white"
className={cn(btnPrimary, "flex-1 py-3 text-sm")}
>
{t("shops.addToCartButton")}
</button>
<button
type="button"
onClick={handleToggleFavorite}
className="flex-1 rounded-2xl border border-[#17337B] py-3 text-sm font-bold text-[#17337B]"
className={cn(btnDefault, "flex-1 py-3 text-sm")}
>
{favorite ? t("shops.removeFromFavoritesButton") : t("shops.addToFavoritesButton")}
</button>

View File

@@ -129,8 +129,62 @@ function ShopLocationPage() {
<AuthPageContent>
<span className="text-xl font-bold mb-4">{t("shops.locationTitle")}</span>
{/* طبق خواسته‌ی کاربر: استان/شهر همیشه باز و اجباری‌ان (چه حضوری داشته باشه چه
نه)، فقط نقشه/محله/آدرسِ دقیق مخصوصِ حالتِ حضوریه و مخفی می‌شن؛ تیکِ «فروشگاه
حضوری ندارم» هم رفت زیرِ آدرس — عینِ اندروید (NewShopLocationScreen.tsx) */}
<div className="flex w-full max-w-sm flex-col gap-2">
<label className="mb-2 flex items-center gap-2">
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
lockToUserCountry
/>
{hasPhysicalLocation && (
<>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</>
)}
<label className="mt-2 flex items-center gap-2">
<input
type="checkbox"
className="scale-125"
@@ -139,53 +193,6 @@ function ShopLocationPage() {
/>
<span className="text-sm">{t("shops.noPhysicalShop")}</span>
</label>
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
lockToUserCountry
/>
<div className="mt-2 w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "220px" }}
initialViewState={{
longitude: marker?.lng || 51.375433528216654,
latitude: marker?.lat || 35.73356434056531,
zoom: 11,
}}
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={handleMapClick}
>
<GeolocateControl />
{marker && (
<Marker latitude={marker.lat} longitude={marker.lng}>
<Image
alt="location icon"
className="-mt-5"
width={25}
height={25}
src="/images/icons/location.svg"
/>
</Marker>
)}
</Map>
</div>
<RoundedInput
type="text"
className="mt-2 text-xs"
placeholder={t("shops.neighbourhoodPlaceholder")}
value={neighbourhood}
onChange={(e) => setNeighbourhood(e.target.value)}
/>
<textarea
className="mt-2 h-24 w-full rounded-3xl border border-neutral-950 bg-white p-4 font-medium dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
placeholder={t("shops.addressPlaceholder")}
value={address}
onChange={(e) => setAddress(e.target.value)}
/>
</div>
</AuthPageContent>
<AuthFormFooter>

View File

@@ -46,8 +46,17 @@ function ShopLogoPage() {
const goNext = () => router.push("/shops/new/shipping");
const handleUpload = async () => {
if (!logoFile || !shopId) {
toast.error(t("shops.logoRequired"));
if (!shopId) return;
// اگه کاربر فایلِ جدید انتخاب نکرده ولی از قبل لوگو داره (ویرایشِ فروشگاهِ
// موجود، logoPreview از GET /shops/:id پر شده)، نباید پیامِ «لطفاً لوگو رو
// انتخاب کنید» نشون داده بشه — قبلاً این‌جا فقط logoFile چک می‌شد نه
// logoPreview، پس با وجودِ لوگوی از قبل‌ثبت‌شده هم گیر می‌کرد
if (!logoFile) {
if (logoPreview) {
goNext();
} else {
toast.error(t("shops.logoRequired"));
}
return;
}
const formData = new FormData();

View File

@@ -43,7 +43,9 @@ export default function ShopPaymentFailedPage() {
{ orderId }
);
if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch {
// toast already shown by the axios interceptor

View File

@@ -90,7 +90,7 @@ export default function ProductComparisonPage() {
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-1 top-1"
className="absolute left-1 top-1"
/>
)}
</div>

View File

@@ -11,6 +11,7 @@ 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 VerificationBadge from "@/components/main/VerificationBadge";
import useAxios from "@/hooks/useAxios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useUser } from "@/hooks/useUser";
@@ -52,6 +53,7 @@ type PublicShop = {
owner?: {
_id: string;
user_name: string;
verify_badge?: string | null;
} | null;
productCount: number;
rating: { average: number; count: number };
@@ -205,45 +207,49 @@ export default function ShopProfileClient() {
/>
</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" />
{/* طبق خواسته‌ی کاربر: توضیحاتِ فروشگاه درست زیرِ نام، و برچسبِ نوعِ فروش
(«حضوری»/«آنلاین») درست زیرِ ریت‌وامتیاز — یعنی دو ستونِ عمودی کنارِ هم
به‌جای یک ردیفِ مشترک؛ عینِ اندروید (ShopProfileScreen.tsx: identityRow) */}
<div className="flex w-full items-start justify-between gap-2 py-2 text-xs font-semibold md:text-sm">
<div className="flex min-w-0 shrink-0 flex-col gap-1">
<div className="flex items-center gap-2">
<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">
<span>{shop.rating.count}</span>
<Image width={22} height={22} alt="rate icon" src="/images/icons/medal-star.png" />
</div>
</div>
<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
className={cn(
"flex min-w-0 max-w-full flex-col",
"flex min-w-0 max-w-full flex-col gap-1",
isFa ? "items-end" : "items-start"
)}
>
<h3
className={cn(
"max-w-full truncate whitespace-nowrap",
"flex max-w-full items-center gap-1 truncate whitespace-nowrap",
isFa ? "text-right" : "text-left"
)}
>
{shop.name}
<VerificationBadge verifyBadge={shop.owner?.verify_badge} size={16} />
</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",
"min-w-0 max-w-full leading-6",
isFa ? "text-right" : "text-left"
)}
>
@@ -253,14 +259,6 @@ export default function ShopProfileClient() {
/>
</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>
@@ -318,7 +316,15 @@ export default function ShopProfileClient() {
{t("shops.noProductsYet")}
</p>
) : (
<div className="grid grid-cols-3 gap-0.5 sm:gap-1">
// چیدمانِ این صفحه ambient dir معکوسیه (dir={isFa?"ltr":"rtl"} چند خط
// بالاتر، برای بخش‌های دیگه‌ی همین صفحه). گریدِ عکسِ کالاها باید عینِ
// اندروید (چیدمانِ عادی/غیرمعکوس، راست‌به‌چپ برای فارسی) باشه، پس اینجا
// با dir عادی override می‌شه. گردی هم قبلاً responsive (۶px زیرِ sm، ۱۶px
// بالاش) بود، الان همیشه ۱۶px — عینِ tileImg توی ShopProfileScreen.tsx
<div
className="grid grid-cols-3 gap-0.5 sm:gap-1"
dir={isFa ? "rtl" : "ltr"}
>
{listings.map((listing) => {
const primaryImage =
listing.images?.[listing.primaryImageIndex] || listing.images?.[0];
@@ -330,7 +336,7 @@ export default function ShopProfileClient() {
onClick={() =>
router.push(`/shops/profile/${shopId}/reel/${tileId}`)
}
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-2xl dark:bg-neutral-800"
className="relative aspect-square overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800"
>
{listing.isTaggedPost && (
<span className="absolute right-1 top-1 z-10 rounded-full bg-black/50 px-1.5 py-0.5 text-[9px] font-semibold text-white">

View File

@@ -51,16 +51,25 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
(currentPage.startsWith("/projects") ||
currentPage === "/new-project")) ||
(tab.href === "/academy" && currentPage.startsWith("/academy"));
// isActive فقط برای رنگ/هایلایتِ آیکونه — شاملِ زیرصفحه‌ها هم می‌شه (مثلاً
// /settings/wallet). اگه کلیک روی خودِ همین isActive شرط بشه، وقتی از یه
// زیرصفحه‌ی تنظیمات روی آیکونِ تنظیمات بزنی، چون isActive از قبل true هست
// preventDefault صدا زده می‌شه و Link اصلاً navigate نمی‌کنه — کاربر مجبور
// می‌شد اول یه تبِ دیگه بزنه بعد دوباره تنظیمات، تا واقعاً برگرده به /settings.
// اینترسپت (برای اسکرول‌به‌بالا/رویدادِ تب) فقط وقتی درسته که دقیقاً همون
// مسیرِ رَوتِ تب باشیم؛ وگرنه باید بذاریم Link عادی navigate کنه
const isExactRoot = currentPage === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
onClick={(e) => {
// وقتی تبی که از قبل توش هستیم دوباره زده می‌شه، Next.js چون مسیر عوض
// نمی‌شه هیچ اسکرول/رفرشی انجام نمی‌ده — طبق درخواست کاربر، مثل اینستاگرام
// باید صفحه بره بالا و (برای تبِ خانه) جدیدترین پست‌ها هم لود بشن
if (isActive) {
// وقتی تبی که از قبل توش هستیم (دقیقاً همون صفحه‌ی روتش) دوباره زده
// می‌شه، Next.js چون مسیر عوض نمی‌شه هیچ اسکرول/رفرشی انجام نمی‌ده —
// طبق درخواست کاربر، مثل اینستاگرام باید صفحه بره بالا و (برای تبِ
// خانه) جدیدترین پست‌ها هم لود بشن
if (isExactRoot) {
e.preventDefault();
window.dispatchEvent(
new CustomEvent("app:tab-reselect", {

View File

@@ -1,7 +1,6 @@
"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";
@@ -38,7 +37,6 @@ export default function GiftInChatModal({
}: GiftInChatModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [selectedAmount, setSelectedAmount] = useState<number | null>(PRESET_AMOUNTS[0]);
const [customAmount, setCustomAmount] = useState("");
@@ -79,7 +77,9 @@ export default function GiftInChatModal({
reset();
onClose();
} else if (response?.paymentUrl) {
router.push(response.paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = response.paymentUrl;
} else {
toast.error(t("giftPage.paymentInfoError"));
}

View File

@@ -326,12 +326,9 @@ export function CourseManager() {
}
if (paymentUrl) {
window.open(paymentUrl, "_blank", "noopener,noreferrer");
toast.success(t("academyCourse.redirectedToPayment"));
setTimeout(() => {
setIsDialogOpen1?.(false);
}, 1000);
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست — window.location.href مطمئن‌تره
window.location.href = paymentUrl;
} else {
console.error("ساختار پاسخ نامعتبر:", response);
toast.error(

View File

@@ -199,16 +199,14 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
>
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
</button>
{/* طبق خواسته‌ی صریح کاربر: فعلاً که تعداد فروشگاه‌ها کمه این آیکون مخفیه؛
هر وقت تعداد فروشگاه‌ها زیاد شد از کامنت درش بیارید */}
{/* <button
<button
type="button"
onClick={() => router.push("/shops/discover")}
aria-label={t("filters.toolbar.shop")}
className={toolbarIconButtonClass}
>
<BoldIcon name="shop" size={TOOLBAR_ICON_SIZE} className="block" />
</button> */}
</button>
</div>
{showFilterModal && (
<FilterModal

View File

@@ -8,7 +8,6 @@ import ScoreRateStats from "@/components/main/ScoreRateStats";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { IProjectRequest } from "@/types/types";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import BoldIcon from "@/components/ui/BoldIcon";
@@ -24,7 +23,6 @@ function ProjectRequestsAction({
}) {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [itemToAccept, setItemToAccept] = useState<IProjectRequest>();
const [viewerUrl, setViewerUrl] = useState<string | null>(null);
@@ -47,10 +45,12 @@ function ProjectRequestsAction({
projectId,
}
);
// لینک خارجی — router.push (next/navigation) رفتارِ تضمین‌شده‌ای برای URLهای
// بیرونی نداره؛ window.location.href مطمئن‌تره (خصوصاً سافاری)
if (response?.paymentUrl) {
router.push(response.paymentUrl);
window.location.href = response.paymentUrl;
} else if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -39,7 +39,9 @@ function ProjectWorkroomActions({
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -73,10 +73,13 @@ function ProjectWorkroomRate({
"/projects/initiate-completion-payment/web",
{ projectId }
);
// لینک خارجی — router.push (next/navigation) رفتارِ تضمین‌شده‌ای برای URLهای
// بیرونی نداره؛ window.location.href مطمئن‌تره (خصوصاً سافاری). sessionStorage
// بالا هنوز کار می‌کنه چون همین تب navigate می‌شه، نه یه تبِ جدید
if (response?.paymentUrl) {
router.push(response.paymentUrl);
window.location.href = response.paymentUrl;
} else if (response?.authority) {
router.push(`https://www.zarinpal.com/pg/StartPay/${response.authority}`);
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
} catch (error: unknown) {
const message =

View File

@@ -42,7 +42,9 @@ function MyBillboardStatusActions({
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);
@@ -65,7 +67,9 @@ function MyBillboardStatusActions({
} else {
const authority = response.authority;
const paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`;
router.push(paymentUrl);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = paymentUrl;
}
} catch (error: unknown) {
console.log(error);

View File

@@ -54,7 +54,9 @@ export default function ProductCategoryPicker({
type="button"
className={cn(
toggleBtnClass(category === item.category),
"min-h-9 p-1.5 text-[11px] leading-tight"
// گردی toggleBtnClass پیش‌فرض rounded-3xl(24px) هست؛ اینجا عینِ pill
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override می‌شه
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
)}
onClick={() => onCategorySelect(item.category)}
>
@@ -76,7 +78,9 @@ export default function ProductCategoryPicker({
type="button"
className={cn(
toggleBtnClass(subCategory === item.name),
"min-h-9 p-1.5 text-[11px] leading-tight"
// گردی toggleBtnClass پیش‌فرض rounded-3xl(24px) هست؛ اینجا عینِ pill
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override می‌شه
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
)}
onClick={() => onSubCategorySelect(item.name)}
>
@@ -100,7 +104,9 @@ export default function ProductCategoryPicker({
type="button"
className={cn(
toggleBtnClass(subSubCategory === item.name),
"min-h-9 p-1.5 text-[11px] leading-tight"
// گردی toggleBtnClass پیش‌فرض rounded-3xl(24px) هست؛ اینجا عینِ pill
// توی NewProductCategoryScreen.tsx اندروید (borderRadius:16) override می‌شه
"min-h-9 p-1.5 text-[11px] leading-tight !rounded-2xl"
)}
onClick={() => onSubSubCategorySelect(item.name)}
>

View File

@@ -114,7 +114,7 @@ export default function ShopCartTab() {
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-1 top-1"
className="absolute left-1 top-1"
/>
)}
</button>

View File

@@ -26,22 +26,32 @@ const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
contactInfo,
}) => {
const { t } = useTranslation("common");
// عین ShopProfileScreen.tsx اندروید: آیکونِ برندِ رنگی (نه آیکونِ عمومیِ تک‌رنگِ
// قبلی که با dark:invert معکوس می‌شد)
const contactMethods = [
{ key: "mobile", icon: "mobile.svg", link: `tel:${contactInfo?.mobile}` },
{ key: "landline", icon: "phone.svg", link: `tel:${contactInfo?.landline}` },
{
key: "mobile",
icon: "brand/phone-brand.svg",
link: `tel:${contactInfo?.mobile}`,
},
{
key: "landline",
icon: "brand/phone-brand.svg",
link: `tel:${contactInfo?.landline}`,
},
{
key: "whatsapp",
icon: "whatsapp.svg",
icon: "brand/whatsapp-brand.svg",
link: `https://wa.me/${contactInfo?.whatsapp}`,
},
{
key: "telegram",
icon: "telegram.svg",
icon: "brand/telegram-brand.svg",
link: `https://t.me/${contactInfo?.telegram}`,
},
{
key: "instagram",
icon: "instagram.svg",
icon: "brand/instagram-brand.svg",
link: `https://instagram.com/${contactInfo?.instagram}`,
},
];
@@ -64,11 +74,11 @@ const ShopContactInfoModal: React.FC<IShopContactInfoModalProps> = ({
<RoundedDiv className="h-9 w-full gap-2">
<span>{contactInfo[key as keyof IShopContactInfo]}</span>
<Image
width={24}
height={24}
width={22}
height={22}
alt={icon}
src={`/images/icons/${icon}`}
className="dark:invert"
unoptimized
/>
</RoundedDiv>
</a>

View File

@@ -85,7 +85,7 @@ export default function ShopFavoritesTab() {
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-2 top-2"
className="absolute left-2 top-2"
/>
)}
<button

View File

@@ -1,15 +1,18 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
type ShopProductAuthorProps = {
shopName: string;
shopLogo?: string | null;
shopVerifyBadge?: string | null;
};
export default function ShopProductAuthor({
shopName,
shopLogo,
shopVerifyBadge,
}: ShopProductAuthorProps) {
return (
<span className="flex min-w-0 items-center gap-1.5">
@@ -23,8 +26,9 @@ export default function ShopProductAuthor({
) : (
<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">
<span className="flex min-w-0 items-center gap-1 truncate text-xs font-medium text-neutral-600 dark:text-neutral-300">
{shopName}
<VerificationBadge verifyBadge={shopVerifyBadge} size={12} />
</span>
</span>
);

View File

@@ -16,6 +16,7 @@ type ShopProductGridProps = {
/** Fallback shop name/logo used when `listing.shop` isn't a populated object (single-shop pages). */
shopName?: string;
shopLogo?: string | null;
shopVerifyBadge?: string | null;
isLoading?: boolean;
/** Defaults to the Torob-style price-comparison page for the listing's catalog product. */
getHref?: (listing: IShopProductListing) => string;
@@ -71,6 +72,7 @@ export default function ShopProductGrid({
listings,
shopName,
shopLogo,
shopVerifyBadge,
isLoading,
getHref = defaultHref,
}: ShopProductGridProps) {
@@ -147,7 +149,7 @@ export default function ShopProductGrid({
{discountPercent != null && (
<DiscountBadge
percent={discountPercent}
className="absolute right-2 top-2"
className="absolute left-2 top-2"
/>
)}
</div>
@@ -156,6 +158,7 @@ export default function ShopProductGrid({
<ShopProductAuthor
shopName={cardShop?.name || shopName || ""}
shopLogo={cardShop?.logo ?? shopLogo}
shopVerifyBadge={cardShop?.owner?.verify_badge ?? shopVerifyBadge}
/>
<span className="truncate text-xs font-semibold">
{listing.title}

View File

@@ -1,6 +1,7 @@
"use client";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BoldIcon from "@/components/ui/BoldIcon";
import DiscountBadge from "@/components/ui/DiscountBadge";
import { cn } from "@/lib/utils";
import { bestDiscountPercent } from "@/lib/shopDiscount";
@@ -47,10 +48,18 @@ export default function ShopProductListRow({ listing, shopId }: ShopProductListR
const inStock = isInStock(listing);
const category = leafCategory(listing);
// طبق خواسته‌ی کاربر: کالاهایی که هنوز تاییدِ ادمین نگرفتن (pending_shop_approval)
// مخفی نشن، فقط کل کارت کمرنگ بشه تا مشخص باشه، و یه تیکِ «در دست بررسی» کنارِ
// عکسِ کالا نمایش داده بشه — عینِ اندروید (ShopProductListRow.tsx)
const isPendingApproval = listing.status === "pending_shop_approval";
return (
<Link
href={`/shops/${shopId}/products/${listing._id}`}
className="gentle-transition flex items-center gap-3 rounded-[28px] border border-neutral-200 p-3 active:scale-[0.99] dark:border-neutral-700"
className={cn(
"gentle-transition flex items-center gap-3 rounded-[28px] border border-neutral-200 p-3 active:scale-[0.99] dark:border-neutral-700",
listing.status !== "active" && "opacity-50"
)}
>
<div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
{primaryImage && (
@@ -64,7 +73,12 @@ export default function ShopProductListRow({ listing, shopId }: ShopProductListR
/>
)}
{discountPercent != null && (
<DiscountBadge percent={discountPercent} className="absolute right-1 top-1" />
<DiscountBadge percent={discountPercent} className="absolute left-1 top-1" />
)}
{isPendingApproval && (
<span className="absolute right-1 top-1 flex h-[22px] w-[22px] items-center justify-center rounded-full bg-amber-600 text-white">
<BoldIcon name="clock" size={14} tinted />
</span>
)}
</div>

View File

@@ -60,10 +60,11 @@ export function selectionCardClass(active: boolean, className?: string) {
);
}
/** Compact profile / grid action buttons */
/** Compact profile / grid action buttons — گردیِ ۱۲px، عینِ actionBtn توی
* ShopProfileScreen.tsx اندروید (borderRadius:12) */
export const profileActionBtnClass = cn(
btnDefault,
"flex h-7 md:h-8 w-full max-w-full items-center justify-center rounded-3xl px-2 text-center text-[12px] font-semibold md:text-sm max-sm:text-[8px]"
"flex h-7 md:h-8 w-full max-w-full items-center justify-center rounded-xl px-2 text-center text-[12px] font-semibold md:text-sm max-sm:text-[8px]"
);
export const btnHeightSm = "h-9";

View File

@@ -114,7 +114,12 @@ export interface IShopProductVariant {
export interface IShopProductListing {
_id: string;
shop: string | { _id: string; name: string; logo?: string | null };
shop: string | {
_id: string;
name: string;
logo?: string | null;
owner?: { verify_badge?: string | null } | null;
};
catalogProduct: string | { _id: string; name: string; description?: string | null };
title: string;
description?: string | null;