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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 102 102" id="instagram"><defs><radialGradient id="a" cx="6.601" cy="99.766" r="129.502" gradientUnits="userSpaceOnUse"><stop offset=".09" stop-color="#fa8f21"></stop><stop offset=".78" stop-color="#d82d7e"></stop></radialGradient><radialGradient id="b" cx="70.652" cy="96.49" r="113.963" gradientUnits="userSpaceOnUse"><stop offset=".64" stop-color="#8c3aaa" stop-opacity="0"></stop><stop offset="1" stop-color="#8c3aaa"></stop></radialGradient></defs><path fill="url(#a)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="url(#b)" d="M25.865,101.639A34.341,34.341,0,0,1,14.312,99.5a19.329,19.329,0,0,1-7.154-4.653A19.181,19.181,0,0,1,2.5,87.694,34.341,34.341,0,0,1,.364,76.142C.061,69.584,0,67.617,0,51s.067-18.577.361-25.14A34.534,34.534,0,0,1,2.5,14.312,19.4,19.4,0,0,1,7.154,7.154,19.206,19.206,0,0,1,14.309,2.5,34.341,34.341,0,0,1,25.862.361C32.422.061,34.392,0,51,0s18.577.067,25.14.361A34.534,34.534,0,0,1,87.691,2.5a19.254,19.254,0,0,1,7.154,4.653A19.267,19.267,0,0,1,99.5,14.309a34.341,34.341,0,0,1,2.14,11.553c.3,6.563.361,8.528.361,25.14s-.061,18.577-.361,25.14A34.5,34.5,0,0,1,99.5,87.694,20.6,20.6,0,0,1,87.691,99.5a34.342,34.342,0,0,1-11.553,2.14c-6.557.3-8.528.361-25.14.361s-18.577-.058-25.134-.361"></path><path fill="#fff" d="M461.114,477.413a12.631,12.631,0,1,1,12.629,12.632,12.631,12.631,0,0,1-12.629-12.632m-6.829,0a19.458,19.458,0,1,0,19.458-19.458,19.457,19.457,0,0,0-19.458,19.458m35.139-20.229a4.547,4.547,0,1,0,4.549-4.545h0a4.549,4.549,0,0,0-4.547,4.545m-30.99,51.074a20.943,20.943,0,0,1-7.037-1.3,12.547,12.547,0,0,1-7.193-7.19,20.923,20.923,0,0,1-1.3-7.037c-.184-3.994-.22-5.194-.22-15.313s.04-11.316.22-15.314a21.082,21.082,0,0,1,1.3-7.037,12.54,12.54,0,0,1,7.193-7.193,20.924,20.924,0,0,1,7.037-1.3c3.994-.184,5.194-.22,15.309-.22s11.316.039,15.314.221a21.082,21.082,0,0,1,7.037,1.3,12.541,12.541,0,0,1,7.193,7.193,20.926,20.926,0,0,1,1.3,7.037c.184,4,.22,5.194.22,15.314s-.037,11.316-.22,15.314a21.023,21.023,0,0,1-1.3,7.037,12.547,12.547,0,0,1-7.193,7.19,20.925,20.925,0,0,1-7.037,1.3c-3.994.184-5.194.22-15.314.22s-11.316-.037-15.309-.22m-.314-68.509a27.786,27.786,0,0,0-9.2,1.76,19.373,19.373,0,0,0-11.083,11.083,27.794,27.794,0,0,0-1.76,9.2c-.187,4.04-.229,5.332-.229,15.623s.043,11.582.229,15.623a27.793,27.793,0,0,0,1.76,9.2,19.374,19.374,0,0,0,11.083,11.083,27.813,27.813,0,0,0,9.2,1.76c4.042.184,5.332.229,15.623.229s11.582-.043,15.623-.229a27.8,27.8,0,0,0,9.2-1.76,19.374,19.374,0,0,0,11.083-11.083,27.716,27.716,0,0,0,1.76-9.2c.184-4.043.226-5.332.226-15.623s-.043-11.582-.226-15.623a27.786,27.786,0,0,0-1.76-9.2,19.379,19.379,0,0,0-11.08-11.083,27.748,27.748,0,0,0-9.2-1.76c-4.041-.185-5.332-.229-15.621-.229s-11.583.043-15.626.229" transform="translate(-422.637 -426.196)"></path></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill:#00FF00;}
.st1{fill:#FFFFFF;}
</style>
<path class="st0" d="M9.3,48h29.3c5.2,0,9.3-4.2,9.3-9.3V9.3C48,4.2,43.8,0,38.7,0H9.3C4.2,0,0,4.2,0,9.3v29.3C0,43.8,4.2,48,9.3,48
z"/>
<path class="st1" d="M39.3,33.4l-6.1-4.1c-0.8-0.5-1.7-0.5-2.4,0c0,0,0,0-2.6,1.5c-0.4,0.2-0.7,0.2-1,0.2c-0.3-0.1-0.6-0.3-0.6-0.3
c-1.4-1.1-3.1-2.5-4.9-4.3c-1.8-1.8-3.2-3.5-4.3-4.9c0,0-0.2-0.3-0.3-0.6c0-0.3,0-0.6,0.2-1c1.5-2.6,1.5-2.6,1.5-2.6
c0.4-0.8,0.4-1.7,0-2.4l-4.1-6.1c-0.5-0.8-1.5-1-2.3-0.4c0,0-1.9,1.3-2.6,1.8c-1,0.7-1.3,1.8-1.3,3.3c0,2.1,1.8,8.8,9.6,16.6l0,0
l0,0l0,0l0,0c7.8,7.8,14.5,9.6,16.6,9.6c1.5,0,2.6-0.3,3.3-1.3c0.4-0.6,1.8-2.6,1.8-2.6C40.2,34.9,40,33.9,39.3,33.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 953 B

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill:#29B6F6;}
.st1{fill:#FFFFFF;}
.st2{fill:#B0BEC5;}
.st3{fill:#CFD8DC;}
</style>
<path class="st0" d="M24,0C10.7,0,0,10.7,0,24s10.7,24,24,24s24-10.7,24-24S37.3,0,24,0z"/>
<path class="st1" d="M35.9,13.2l-4.5,23c0,0-0.2,1-1.5,1c-0.7,0-1-0.3-1-0.3l-9.7-8.1l-4.8-2.4l-6.1-1.6c0,0-1.1-0.3-1.1-1.2
c0-0.8,1.1-1.1,1.1-1.1l25.6-10.2c0,0,0.8-0.3,1.4-0.3c0.4,0,0.8,0.1,0.8,0.6C36,12.9,35.9,13.2,35.9,13.2z"/>
<path class="st2" d="M22.8,31.8l-4.1,4c0,0-0.2,0.1-0.4,0.1c-0.1,0-0.2,0-0.3-0.1l1.2-7.2L22.8,31.8z"/>
<path class="st3" d="M31.1,17c-0.2-0.3-0.6-0.3-0.8-0.1l-15.8,9.5c0,0,2.5,7.1,2.9,8.3c0.4,1.2,0.7,1.3,0.7,1.3l1.2-7.2L31,17.9
C31.2,17.7,31.3,17.3,31.1,17z"/>
</svg>

After

Width:  |  Height:  |  Size: 955 B

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#CFD8DC;}
.st2{fill-rule:evenodd;clip-rule:evenodd;fill:#40C351;}
</style>
<path class="st0" d="M1.3,46.8l3.2-11.7c-2-3.4-3-7.3-3-11.3c0-12.4,10.1-22.5,22.6-22.5c6,0,11.7,2.4,16,6.6
c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5c0,0,0,0,0,0h0c-3.8,0-7.5-0.9-10.8-2.7L1.3,46.8z"/>
<path class="st0" d="M1.3,47.4c-0.2,0-0.3-0.1-0.4-0.2c-0.1-0.2-0.2-0.4-0.2-0.6l3.1-11.4c-1.9-3.5-3-7.4-3-11.4
c0-12.8,10.4-23.1,23.2-23.1c6.2,0,12,2.4,16.4,6.8c4.4,4.4,6.8,10.2,6.8,16.4C47.2,36.6,36.9,47,24.1,47c-3.8,0-7.5-0.9-10.9-2.7
L1.5,47.4C1.4,47.4,1.4,47.4,1.3,47.4z"/>
<path class="st1" d="M24.1,1.3c6,0,11.7,2.4,16,6.6c4.3,4.3,6.6,9.9,6.6,16c0,12.4-10.1,22.5-22.6,22.5h0c-3.8,0-7.5-0.9-10.8-2.7
l-12,3.1l3.2-11.7c-2-3.4-3-7.3-3-11.3C1.5,11.4,11.7,1.3,24.1,1.3 M24.1,46.4L24.1,46.4L24.1,46.4 M24.1,46.4L24.1,46.4L24.1,46.4
M24.1,0.1L24.1,0.1C11,0.1,0.4,10.8,0.3,23.9c0,4,1,7.9,2.9,11.4L0.2,46.5c-0.1,0.4,0,0.8,0.3,1.1C0.7,47.9,1,48,1.3,48
c0.1,0,0.2,0,0.3,0l11.5-3c3.4,1.7,7.1,2.7,10.9,2.7c13.1,0,23.7-10.6,23.8-23.7c0-6.3-2.5-12.3-6.9-16.8
C36.4,2.6,30.4,0.1,24.1,0.1L24.1,0.1z"/>
<path class="st2" d="M37.4,10.6c-3.5-3.5-8.2-5.5-13.3-5.5c-10.3,0-18.8,8.4-18.8,18.7c0,3.5,1,7,2.9,10l0.4,0.7l-1.9,6.9l7.1-1.9
l0.7,0.4c2.9,1.7,6.2,2.6,9.5,2.6h0c10.3,0,18.7-8.4,18.7-18.7C42.8,18.9,40.9,14.2,37.4,10.6z"/>
<path class="st0" d="M18.5,14.4c-0.4-0.9-0.9-1-1.3-1c-0.3,0-0.7,0-1.1,0c-0.4,0-1,0.1-1.5,0.7c-0.5,0.6-2,1.9-2,4.7s2,5.5,2.3,5.8
s3.9,6.2,9.6,8.5c4.8,1.9,5.7,1.5,6.8,1.4c1-0.1,3.3-1.4,3.8-2.7c0.5-1.3,0.5-2.4,0.3-2.7c-0.1-0.2-0.5-0.4-1.1-0.7
c-0.6-0.3-3.3-1.6-3.9-1.8s-0.9-0.3-1.3,0.3c-0.4,0.6-1.5,1.8-1.8,2.2c-0.3,0.4-0.7,0.4-1.2,0.1c-0.6-0.3-2.4-0.9-4.5-2.8
c-1.7-1.5-2.8-3.3-3.1-3.9c-0.3-0.6,0-0.9,0.2-1.2c0.3-0.3,0.6-0.7,0.8-1c0.3-0.3,0.4-0.6,0.6-0.9c0.2-0.4,0.1-0.7,0-1
C20.1,18.3,19,15.5,18.5,14.4z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

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;