diff --git a/package-lock.json b/package-lock.json index 02ac110..3242a1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,9 @@ "@react-oauth/google": "^0.13.5", "@shadcn/ui": "^0.0.4", "@tabler/icons-react": "^3.34.1", + "@tanstack/query-sync-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.87.1", + "@tanstack/react-query-persist-client": "^5.102.8", "@tanstack/react-table": "^8.21.3", "@vitalets/google-translate-api": "^9.2.1", "axios": "^1.7.9", @@ -4105,22 +4107,49 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.2", - "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.101.2.tgz", - "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "version": "5.102.8", + "resolved": "https://registry.npmmirror.com/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tanstack/react-query": { - "version": "5.101.2", - "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.101.2.tgz", - "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "node_modules/@tanstack/query-persist-client-core": { + "version": "5.102.8", + "resolved": "https://registry.npmmirror.com/@tanstack/query-persist-client-core/-/query-persist-client-core-5.102.8.tgz", + "integrity": "sha512-t3v4/D6ejo/BrPzM5gm/AT3c4CWPCxFa+0cIjAeHAeanGPG6rCjFS1hZnPZH/rf55qe3qYYgx5U6iBZMY1PQjA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.2" + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-sync-storage-persister": { + "version": "5.102.8", + "resolved": "https://registry.npmmirror.com/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-5.102.8.tgz", + "integrity": "sha512-UvIpYl6jipKJHfITAieQaXMvVPoJh2IkQkWDYZNRPycoGbNYdqB+UdRVoXkZagB3EdeKnlC7sA61grsEC87Wfw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8", + "@tanstack/query-persist-client-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" }, "funding": { "type": "github", @@ -4130,6 +4159,23 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-persist-client": { + "version": "5.102.8", + "resolved": "https://registry.npmmirror.com/@tanstack/react-query-persist-client/-/react-query-persist-client-5.102.8.tgz", + "integrity": "sha512-WOZReC+m9klekPtG7eeZ3txJeMA9lg2R0RytSG3yXcZgJItQAVIyoobJdbdIQtlpgcU2tSzsOEBtJacs2vlCOA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-persist-client-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.102.8", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", diff --git a/package.json b/package.json index ab80f45..513a898 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "@react-oauth/google": "^0.13.5", "@shadcn/ui": "^0.0.4", "@tabler/icons-react": "^3.34.1", + "@tanstack/query-sync-storage-persister": "^5.102.8", "@tanstack/react-query": "^5.87.1", + "@tanstack/react-query-persist-client": "^5.102.8", "@tanstack/react-table": "^8.21.3", "@vitalets/google-translate-api": "^9.2.1", "axios": "^1.7.9", diff --git a/src/api/fetchTopHashtags.ts b/src/api/fetchTopHashtags.ts new file mode 100644 index 0000000..2b07947 --- /dev/null +++ b/src/api/fetchTopHashtags.ts @@ -0,0 +1,18 @@ +import { getApiBaseUrl } from "@/components/main/BaseUrl"; + +export type TopHashtag = { tag: string; viewsCount: number }; + +// ۱۰ هشتگ برتر بر اساس مجموع بازدید در ۳۰ روز جاری — همون endpoint‌ای که اپ اندروید +// برای فیلتر «هشتگ‌های برتر ماه» توی اکسپلور استفاده می‌کنه +export async function fetchTopHashtags(limit = 10): Promise { + try { + const res = await fetch( + `${getApiBaseUrl()}/users/explore/top-hashtags?limit=${limit}` + ); + if (!res.ok) return []; + const data = (await res.json()) as { hashtags?: TopHashtag[] }; + return data.hashtags ?? []; + } catch { + return []; + } +} diff --git a/src/app/(projects)/academy/payment/failed/page.tsx b/src/app/(projects)/academy/payment/failed/page.tsx index bb311a1..9999722 100644 --- a/src/app/(projects)/academy/payment/failed/page.tsx +++ b/src/app/(projects)/academy/payment/failed/page.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react"; import RoundedDiv from "@/components/elements/RoundedDiv"; import RoundedButton from "@/components/elements/RoundedButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; import MainProjectCard from "@/components/projects/MainProjectCard"; @@ -92,10 +92,7 @@ function FailedProject() { return ( - -
- {t("academy.payment.failed")} -
+ {t("academy.payment.failed")}
- -
- {t("academy.payment.success")} -
+ {t("academy.payment.success")}
- -
- {t("projects.payment.title")} -
+ {t("projects.payment.title")}
{project && }
diff --git a/src/app/(projects)/projects/payment/failed/page.tsx b/src/app/(projects)/projects/payment/failed/page.tsx index 69d9da2..e6e695f 100644 --- a/src/app/(projects)/projects/payment/failed/page.tsx +++ b/src/app/(projects)/projects/payment/failed/page.tsx @@ -11,7 +11,7 @@ import Image from "next/image"; import MainProjectCard from "@/components/projects/MainProjectCard"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import toast from "react-hot-toast"; function FailedProject() { @@ -100,10 +100,7 @@ function FailedProject() { return ( - -
- {t("projects.payment.failed")} -
+ {t("projects.payment.failed")}
- -
- {t("projects.payment.success")} -
+ {t("projects.payment.success")}
-
- -

- {t("projects.specialists.title")} -

- -
+ + {t("projects.specialists.title")} + {phase === "filter" || showFilter ? ( - -
- {t("billboards.payment.title")} -
+ {t("billboards.payment.title")}
{advertising && }
diff --git a/src/app/billboards/payment/failed/page.tsx b/src/app/billboards/payment/failed/page.tsx index 2bb057a..03eb0c3 100644 --- a/src/app/billboards/payment/failed/page.tsx +++ b/src/app/billboards/payment/failed/page.tsx @@ -8,7 +8,7 @@ import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBil import RoundedDiv from "@/components/elements/RoundedDiv"; import RoundedButton from "@/components/elements/RoundedButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; @@ -90,10 +90,7 @@ function FailedBillboard() { return ( - -
- {t("billboards.payment.failed")} -
+ {t("billboards.payment.failed")}
- -
- {t("billboards.payment.success")} -
+ {t("billboards.payment.success")}
{ + setActiveFilter("all"); + setCoords(null); + setSearchInput(`#${tag}`); + setSearchQuery({ hashtag: tag }); + }, []); + const exploreSeo = getSeoPage("explore", language); return ( @@ -97,6 +104,7 @@ export default function ExplorePage() { activeFilter={activeFilter} coords={coords} searchQuery={searchQuery} + onSelectTopHashtag={handleSelectTopHashtag} /> diff --git a/src/app/gift/payment/failed/page.tsx b/src/app/gift/payment/failed/page.tsx index a821ad5..54a0562 100644 --- a/src/app/gift/payment/failed/page.tsx +++ b/src/app/gift/payment/failed/page.tsx @@ -3,7 +3,7 @@ import Container from "@/components/elements/Container"; import RoundedButton from "@/components/elements/RoundedButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; @@ -17,10 +17,7 @@ function GiftPaymentFailed() { return ( - -
- {t("giftPage.paymentFailed.title")} -
+ {t("giftPage.paymentFailed.title")}
- -
- {t("giftPage.paymentSuccess.title")} -
+ {t("giftPage.paymentSuccess.title")}
) => { + const onPickMain = async (e: React.ChangeEvent) => { const picked = e.target.files?.[0]; + e.target.value = ""; if (!picked) return; if (mode === "video" && !picked.type.startsWith("video/")) { @@ -220,6 +221,11 @@ export default function NewPostPage() { return; } + if (picked.type.startsWith("image/") && !(await isImageAspectRatioAllowed(picked))) { + toast.error(t("posts.aspectRatioTooExtreme")); + return; + } + if (mode === "image") { const item: PostGalleryItem = { id: `new-${Date.now()}`, @@ -532,23 +538,25 @@ export default function NewPostPage() {
{/* Header — Instagram style */} -
- - +
+ + {loading || loadingEdit ? "…" : t("posts.shareButton")} + + } + > {isEditMode ? t("posts.editPostTitle") : isStory ? t("posts.newStory") : t("posts.newPost")} - - +
{/* Mode tabs */} diff --git a/src/app/offer/payment/failed/page.tsx b/src/app/offer/payment/failed/page.tsx index 3fa6246..efa0f3c 100644 --- a/src/app/offer/payment/failed/page.tsx +++ b/src/app/offer/payment/failed/page.tsx @@ -4,7 +4,7 @@ import Container from "@/components/elements/Container"; import React from "react"; import RoundedButton from "@/components/elements/RoundedButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import { useSearchParams } from "next/navigation"; import Image from "next/image"; import Link from "next/link"; @@ -18,10 +18,7 @@ function FailedBillboard() { return ( - -
- {t("offerPage.paymentFailed.title")} -
+ {t("offerPage.paymentFailed.title")}
- -
- {t("offerPage.paymentSuccess.title")} -
+ {t("offerPage.paymentSuccess.title")}
- -

- {t("settings.academy.storeProfile.title")} -

+ {t("settings.academy.storeProfile.title")}
{ export default function FindPartnerPage() { const { t } = useTranslation("common"); - usePageTitle(t("findPartner.title")); const { request } = useAxios(); const [filters, setFilters] = useState(EMPTY_PARTNER_FILTERS); @@ -159,10 +157,7 @@ export default function FindPartnerPage() { return ( -
- -

{t("findPartner.title")}

-
+ {t("findPartner.title")}

{t("findPartner.hint")}

diff --git a/src/app/settings/chats/nearby/page.tsx b/src/app/settings/chats/nearby/page.tsx index ab6cb2b..4a6822c 100644 --- a/src/app/settings/chats/nearby/page.tsx +++ b/src/app/settings/chats/nearby/page.tsx @@ -2,19 +2,46 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import Map, { Marker } from "react-map-gl"; +import "mapbox-gl/dist/mapbox-gl.css"; import Container from "@/components/elements/Container"; import ProfileAvatar from "@/components/main/ProfileAvatar"; import VerificationBadge from "@/components/main/VerificationBadge"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import RoundedButton from "@/components/elements/RoundedButton"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import IOSSpinner from "@/components/ui/IOSSpinner"; +import BoldIcon from "@/components/ui/BoldIcon"; import useAxios from "@/hooks/useAxios"; import { useUser } from "@/hooks/useUser"; -import { usePageTitle } from "@/hooks/usePageTitle"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { formatFullName } from "@/lib/formatFullName"; +const MAPBOX_ACCESS_TOKEN = + "pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"; +const METERS_PER_DEGREE_LAT = 111320; + +// بک‌اند به‌عمد مختصاتِ دقیقِ کاربرهای دیگه رو نمی‌فرسته (حریمِ خصوصی — فقط +// distanceMeters) — همون منطقِ زاویه‌دهیِ رادار رو روی نقشه‌ی واقعی هم به‌کار +// می‌بریم: نقطه‌ای تقریبی با همون فاصله‌ی واقعی از موقعیتِ خودم، نه مختصاتِ واقعیِ +// طرفِ مقابل (عیناً مثلِ approximateCoordinate توی نسخه‌ی اندروید) +function approximateCoordinate( + myLat: number, + myLng: number, + distanceMeters: number, + index: number, + total: number +): [number, number] { + const angleDeg = (index / Math.max(total, 1)) * 360 - 90; + const angleRad = (angleDeg * Math.PI) / 180; + const dLat = (distanceMeters * Math.cos(angleRad)) / METERS_PER_DEGREE_LAT; + const metersPerDegreeLng = + METERS_PER_DEGREE_LAT * Math.cos((myLat * Math.PI) / 180); + const dLng = (distanceMeters * Math.sin(angleRad)) / metersPerDegreeLng; + return [myLng + dLng, myLat + dLat]; +} + type NearbyUser = { _id: string; user_name: string; @@ -35,11 +62,11 @@ type NearbyGate = | "ready" | "error"; -const NEARBY_RADIUS_METERS = 5000; +const DEFAULT_RADIUS_METERS = 5000; +const RADIUS_OPTIONS_KM = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50] as const; const RADAR_SIZE = 340; -const RING_METERS = [1000, 2000, 3000, 4000, 5000] as const; -function formatDistance(meters: number, t: (key: string, opts?: object) => string) { +function formatDistance(meters: number, t: TFunction) { if (meters < 1000) { return t("nearbyFriends.distanceMeters", { count: meters }); } @@ -48,9 +75,14 @@ function formatDistance(meters: number, t: (key: string, opts?: object) => strin } /** Place users around the radar: closer = inner ring, angle by index. */ -function radarPosition(index: number, total: number, distanceMeters: number) { - const clamped = Math.min(Math.max(distanceMeters, 120), NEARBY_RADIUS_METERS); - const ring = 0.18 + (clamped / NEARBY_RADIUS_METERS) * 0.72; +function radarPosition( + index: number, + total: number, + distanceMeters: number, + radiusMeters: number +) { + const clamped = Math.min(Math.max(distanceMeters, 120), radiusMeters); + const ring = 0.18 + (clamped / radiusMeters) * 0.72; const angle = ((index / Math.max(total, 1)) * 360 - 90) * (Math.PI / 180); const x = 50 + Math.cos(angle) * ring * 50; const y = 50 + Math.sin(angle) * ring * 50; @@ -59,7 +91,6 @@ function radarPosition(index: number, total: number, distanceMeters: number) { export default function NearbyFriendsPage() { const { t } = useTranslation("common"); - usePageTitle(t("nearbyFriends.title")); const user = useUser(); const { request } = useAxios(); @@ -69,19 +100,26 @@ export default function NearbyFriendsPage() { const [coords, setCoords] = useState<{ lat: number; lng: number } | null>( null ); + const [showMap, setShowMap] = useState(false); + const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_METERS / 1000); const radarUsers = useMemo(() => users.slice(0, 12), [users]); const radarActive = gate === "locating" || gate === "checking" || gate === "ready"; + const radiusMeters = radiusKm * 1000; + const ringMeters = useMemo( + () => [1, 2, 3, 4, 5].map((n) => (radiusMeters / 5) * n), + [radiusMeters] + ); const loadNearby = useCallback( - async (lat: number, lng: number) => { + async (lat: number, lng: number, radius: number) => { setGate("locating"); setErrorMessage(""); try { const res = await request<{ users?: NearbyUser[] }>( "GET", - `/users/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`, + `/users/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}&radius=${radius}`, null, { noToast: true } ); @@ -125,7 +163,7 @@ export default function NearbyFriendsPage() { navigator.geolocation.getCurrentPosition( (pos) => { - void loadNearby(pos.coords.latitude, pos.coords.longitude); + void loadNearby(pos.coords.latitude, pos.coords.longitude, radiusMeters); }, (err) => { if (err.code === err.PERMISSION_DENIED) { @@ -137,7 +175,18 @@ export default function NearbyFriendsPage() { }, { enableHighAccuracy: true, timeout: 20000, maximumAge: 15000 } ); - }, [loadNearby, t]); + }, [loadNearby, radiusMeters, t]); + + // تغییر شعاع، اگه مختصات از قبل داریم، بدون گرفتن دوباره‌ی GPS دوباره از سرور بخون + const handleRadiusChange = useCallback( + (km: number) => { + setRadiusKm(km); + if (coords) { + void loadNearby(coords.lat, coords.lng, km * 1000); + } + }, + [coords, loadNearby] + ); useEffect(() => { if (!user) return; @@ -153,15 +202,9 @@ export default function NearbyFriendsPage() { return ( -
- -

- {t("nearbyFriends.title")} -

-
+ + {t("nearbyFriends.title")} +
{/* distance rings + labels */} - {RING_METERS.map((meters) => { - const pct = (meters / NEARBY_RADIUS_METERS) * 100; + {ringMeters.map((meters) => { + const pct = (meters / radiusMeters) * 100; const inset = `${(100 - pct) / 2}%`; return (
@@ -202,7 +245,7 @@ export default function NearbyFriendsPage() { top: `calc(${inset} - 1px)`, }} > - {meters} + {Math.round(meters / 1000)} کیلومتر
); @@ -249,7 +292,8 @@ export default function NearbyFriendsPage() { const pos = radarPosition( index, radarUsers.length, - item.distanceMeters + item.distanceMeters, + radiusMeters ); return (

- {t("nearbyFriends.radiusHint")} + {t("nearbyFriends.radiusHintValue", { km: radiusKm })}

+ + {/* عین NearbyFriendsScreen.tsx اندروید: ردیف افقی چیپ‌های شعاع، از ۵ تا ۵۰ + کیلومتر با فواصل ۵ کیلومتری */} +
+ {RADIUS_OPTIONS_KM.map((km) => ( + + ))} +
{gate === "checking" || gate === "locating" ? ( @@ -391,15 +457,109 @@ export default function NearbyFriendsPage() { )) )} - - {t("nearbyFriends.refresh")} - + {/* عین footerRow اندروید: بروزرسانی (primary) و نمایش نقشه (default) کنار + هم، هر دو flex-1 */} +
+ + {t("nearbyFriends.refresh")} + + + {coords ? ( + setShowMap(true)} + > + {t("nearbyFriends.showOnMap")} + + ) : null} +
) : null}
+ + {showMap && coords ? ( +
+ + + + +
+
+ +
+ + {t("nearbyFriends.you")} + +
+
+ + {users.map((item, index) => { + const [lng, lat] = approximateCoordinate( + coords.lat, + coords.lng, + item.distanceMeters, + index, + users.length + ); + return ( + + +
+ +
+ + {item.user_name} + + +
+ ); + })} +
+ +

+ {t("nearbyFriends.mapPrivacyHint")} +

+
+ ) : null}
); } diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx index f1d0bca..5471013 100644 --- a/src/app/settings/chats/rooms/[roomId]/page.tsx +++ b/src/app/settings/chats/rooms/[roomId]/page.tsx @@ -19,6 +19,7 @@ import { import MessageInput from "@/components/chat/MessageInput"; import ChatActionBar from "@/components/chat/ChatActionBar"; import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal"; +import ChatRoomMembersModal from "@/components/chat/ChatRoomMembersModal"; import MultiImageModal from "@/components/chat/MultiImageModal"; import MediaPreviewModal from "@/components/chat/MediaPreviewModal"; import VoiceMessagePlayer from "@/components/chat/VoiceMessagePlayer"; @@ -328,6 +329,13 @@ export default function ChatRoomThreadPage({ const user = useUser(); const [room, setRoom] = useState(null); const [messages, setMessages] = useState([]); + // این صفحه با تغییرِ فقط پارامترِ roomId دوباره mount نمی‌شه (رفتارِ App Router)، + // پس یه درخواستِ کندِ اتاقِ قبلی که هنوز در حال رفتنه می‌تونه بعد از عوض‌شدنِ اتاق + // resolve بشه و پیام‌های اتاقِ فعلی رو با پیام‌های اتاقِ قبلی جایگزین کنه (mergeRoomMessages + // اساساً از incoming پر می‌شه). roomIdRef همیشه آخرین roomId رو داره تا پاسخ‌های + // دیرهنگامِ مربوط به یه roomId قدیمی نادیده گرفته بشن + const roomIdRef = useRef(roomId); + roomIdRef.current = roomId; const [newMessage, setNewMessage] = useState(""); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); @@ -335,6 +343,7 @@ export default function ChatRoomThreadPage({ const [selectedMessage, setSelectedMessage] = useState(null); const [actionMode, setActionMode] = useState(false); const [showAddMembers, setShowAddMembers] = useState(false); + const [showMembersModal, setShowMembersModal] = useState(false); const [pendingImages, setPendingImages] = useState([]); const [showMultiModal, setShowMultiModal] = useState(false); const [pendingMediaPreview, setPendingMediaPreview] = useState<{ @@ -368,6 +377,7 @@ export default function ChatRoomThreadPage({ }, [t]); const loadRoom = useCallback(async () => { + const requestedRoomId = roomId; await request("POST", `/chat-rooms/${roomId}/join`, {}, { noToast: true }); const detail = await request<{ room: RoomDetail }>( "GET", @@ -375,16 +385,19 @@ export default function ChatRoomThreadPage({ null, { noToast: true } ); + if (roomIdRef.current !== requestedRoomId) return; setRoom(detail.room); }, [request, roomId]); const loadMessages = useCallback(async () => { + const requestedRoomId = roomId; const res = await request<{ messages?: RoomMessage[] }>( "GET", `/chat-rooms/${roomId}/messages?limit=80`, null, { noToast: true } ); + if (roomIdRef.current !== requestedRoomId) return; const incoming = res?.messages ?? []; const uid = String(user?._id ?? getStoredUserId()); let decrypted = incoming; @@ -397,10 +410,15 @@ export default function ChatRoomThreadPage({ decrypted = incoming; } } + if (roomIdRef.current !== requestedRoomId) return; setMessages((prev) => mergeRoomMessages(prev, decrypted)); }, [request, roomId, user?._id]); useEffect(() => { + // اتاقِ قبلی رو پاک می‌کنیم تا برای یه لحظه هم محتوای اتاقِ قبلی زیرِ روم‌آیدیِ + // جدید دیده نشه، تا جوابِ تازه برسه + setRoom(null); + setMessages([]); (async () => { setLoading(true); try { @@ -714,10 +732,11 @@ export default function ChatRoomThreadPage({ void processSendMessage(null); }; - const canAddMembers = + const canAddMembers = Boolean( room?.visibility === "private" && - room?.createdBy && - String(room.createdBy) === String(user?._id); + room?.createdBy && + String(room.createdBy) === String(user?._id) + ); const openActionFor = (msg: RoomMessage) => { if (String(msg._id).startsWith("temp-")) return; @@ -771,80 +790,58 @@ export default function ChatRoomThreadPage({ return ( + {/* هدر شناور شیشه‌ای — عین ChatRoomThreadScreen.tsx اندروید: خودِ نوارِ هدر + پس‌زمینه نداره، فقط سه‌تا پیل/دکمه‌ی جدا (بازگشت، مرکزی، اعضا) هر کدوم + پس‌زمینه‌ی شیشه‌ایِ خودشون رو دارن. لیستِ اعضا دیگه به‌صورت نوارِ افقیِ + زیرِ هدر نیست — با دکمه‌ی اعضا توی یه مودال باز می‌شه */}
-
+
-
+ +
-
-
-

- {room?.title ?? t("chats.chatRoom")} -

- {room?.onlineCount != null ? ( - - {t("chats.onlineCount", { count: room.onlineCount })} - - ) : null} -
- {room?.description ? ( -

- {room.description} -

- ) : null} +
+

+ {room?.title ?? t("chats.chatRoom")} +

+

+ {t("chats.groupStatusLine", { count: room?.onlineCount ?? 0 })} +

- {canAddMembers ? ( - - ) : ( - - )} + +
- {room?.members?.length ? ( -
- {room.members.slice(0, 12).map((member) => ( - - - - {member.user_name} - - - ))} -
- ) : null}
+ setShowMembersModal(false)} + members={room?.members ?? []} + canAddMembers={canAddMembers} + onAddMembers={() => setShowAddMembers(true)} + /> +
([]); @@ -87,20 +85,20 @@ export default function ChatRoomsPage() { return ( -
- -

- {t("chats.chatRoom")} -

- -
+ setShowCreateModal(true)} + className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#0095f6] text-white shadow-md active:scale-95" + aria-label={t("chats.aria.createRoom")} + > + + + } + > + {t("chats.chatRoom")} +
{loading ? ( diff --git a/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx b/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx index 362dd34..d7e7aa3 100644 --- a/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx +++ b/src/app/settings/chats/shop/[shopId]/[buyerId]/page.tsx @@ -66,8 +66,15 @@ export default function ShopChatPage() { const [sending, setSending] = useState(false); const bottomRef = useRef(null); const attachmentInputRef = useRef(null); + // این صفحه با تغییرِ فقط shopId/buyerId دوباره mount نمی‌شه — یه پاسخِ کندِ گفتگوی + // قبلی می‌تونه بعد از سوییچ به یه گفتگوی دیگه resolve بشه و محتوای فعلی رو با + // پیام‌ها/نامِ طرفِ مقابلِ گفتگوی قبلی جایگزین کنه. این ref همیشه شناسه‌ی فعلی رو + // نگه می‌داره تا پاسخ‌های دیرهنگامِ مربوط به یه گفتگوی قدیمی نادیده گرفته بشن + const threadKeyRef = useRef(`${shopId}:${buyerId}`); + threadKeyRef.current = `${shopId}:${buyerId}`; const loadMessages = () => { + const requestedKey = `${shopId}:${buyerId}`; request<{ messages: ShopChatMessage[]; counterpart: Counterpart; isOwner: boolean }>( "GET", `/shop-chat/${shopId}/messages?buyerId=${buyerId}`, @@ -75,15 +82,21 @@ export default function ShopChatPage() { { noToast: true } ) .then((res) => { + if (threadKeyRef.current !== requestedKey) return; setMessages(res?.messages || []); setCounterpart(res?.counterpart || null); setIsOwner(Boolean(res?.isOwner)); }) - .catch(() => setMessages([])); + .catch(() => { + if (threadKeyRef.current !== requestedKey) return; + setMessages([]); + }); }; useEffect(() => { if (!shopId || !buyerId) return; + setMessages(null); + setCounterpart(null); loadMessages(); const id = window.setInterval(loadMessages, 8000); return () => window.clearInterval(id); diff --git a/src/app/settings/financial/page.tsx b/src/app/settings/financial/page.tsx index d5fed2c..0b266a7 100644 --- a/src/app/settings/financial/page.tsx +++ b/src/app/settings/financial/page.tsx @@ -33,10 +33,23 @@ export default function Financial() { data?.pages.flatMap((page) => page.financial || []) ?? []; const getTransactionType = (type: string) => { - if (type === "offer") return t("settings.financial.types.offer"); - if (type === "advertising") return t("settings.financial.types.advertising"); - if (type === "shop_order") return t("settings.financial.types.shop_order"); - return type || t("settings.financial.types.other"); + const known = [ + "offer", + "advertising", + "advertising-republish", + "shop_order", + "create", + "installment", + "gift", + "booking_deposit", + "wallet_withdrawal", + "pro", + "course", + ]; + if (type && known.includes(type)) { + return t(`settings.financial.types.${type}`); + } + return t("settings.financial.types.other"); }; const getStatusText = (status: string) => { diff --git a/src/app/settings/my-billboards/[id]/[title]/page.tsx b/src/app/settings/my-billboards/[id]/[title]/page.tsx index 418e592..c98bba7 100644 --- a/src/app/settings/my-billboards/[id]/[title]/page.tsx +++ b/src/app/settings/my-billboards/[id]/[title]/page.tsx @@ -9,7 +9,7 @@ import MainBillboardCardActions from "@/components/billboards/MainBillboardCard/ import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDetails"; import MyBillboardStatusActions from "@/components/settings/my-billboards/MyBillboardStatusActions"; import BillboardLocationLabels from "@/components/settings/my-billboards/BillboardLocationLabels"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; interface IBillboardProps { params: Promise<{ id: string; title: string }>; @@ -33,14 +33,13 @@ async function BillboardPage({ params }: IBillboardProps) { return ( - - -

+ + {billboard?.title} -

- + +
diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx index 9b1a7ef..04694bd 100644 --- a/src/app/settings/notifications/page.tsx +++ b/src/app/settings/notifications/page.tsx @@ -147,6 +147,17 @@ function Notifications() { billboard_rating: `/settings/my-billboards/${item?.project_post_id}/b`, "reject-user": "/settings/edit/Authentication", verify: "/settings/edit/Authentication", + // این ۹ مورد قبلاً توی route map نبودن و کلیک روش‌ها هیچ‌کاری نمی‌کرد (href="#") + "accept-post": `/posts/${item?.project_post_id}`, + "reject-post": `/posts/${item?.project_post_id}`, + wallet_withdrawal: "/settings/wallet", + shop_approval: "/settings/shop", + shop_product_approval: "/settings/shop", + shop_order: `/shops/orders/${item?.project_post_id}`, + shop_product_comment: `/shops/product/${item?.project_post_id}`, + booking: "/settings/booking/reservations", + project: workroomPath, + new_post_from_followed: `/posts/${item?.project_post_id}`, }; return routes[item?.type] || "#"; diff --git a/src/app/shops/[shopId]/products/[listingId]/page.tsx b/src/app/shops/[shopId]/products/[listingId]/page.tsx index c84b8d0..89eacc2 100644 --- a/src/app/shops/[shopId]/products/[listingId]/page.tsx +++ b/src/app/shops/[shopId]/products/[listingId]/page.tsx @@ -363,25 +363,27 @@ export default function ProductManagePage() { {t("shops.manageStockButton")} - - - {matchedVariant?.stock ?? 0} - - +
+ + + {matchedVariant?.stock ?? 0} + + +
{matchedVariant && ( diff --git a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx index 2e1d496..4e19784 100644 --- a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx +++ b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx @@ -432,34 +432,38 @@ export default function ListingDetailClient() { )} + {/* عین ProductDetailScreen.tsx اندروید: دکمه‌ی ثبت خرید با colors.success (#387E65)، + و اسپینر تعداد یک پیل بوردردار خنثی با دکمه‌های +/- متنی، نه دو دکمه‌ی دایره‌ای رنگی */}
- - {quantity} - +
+ + {quantity} + +
{matchedVariant && ( @@ -483,17 +487,19 @@ export default function ListingDetailClient() {
+ {/* عین ProductDetailScreen.tsx اندروید: سبد خرید با colors.info (#17337B) توپر، + علاقه‌مندی فقط حاشیه‌دارِ همون رنگ (نه پرشده) */} diff --git a/src/app/shops/listing/[listingId]/page.tsx b/src/app/shops/listing/[listingId]/page.tsx index a5305ca..dbc8cb6 100644 --- a/src/app/shops/listing/[listingId]/page.tsx +++ b/src/app/shops/listing/[listingId]/page.tsx @@ -7,6 +7,8 @@ import { getSiteSeoMeta } from "@/lib/i18n/seo"; import { buildStorageUrl } from "@/components/main/BaseUrl"; import { buildProductJsonLd } from "@/lib/buildShopSeo"; import { SITE_URL } from "@/config/pageSeo"; +import { translateCommon } from "@/lib/i18n/translate"; +import { safeJsonLdString } from "@/lib/seo/safeJsonLd"; import { IShopProductListing } from "@/types/types"; import ListingDetailClient from "./ListingDetailClient"; @@ -39,14 +41,15 @@ export async function generateMetadata({ params }: IListingPageProps): Promise )} diff --git a/src/app/shops/orders/[orderId]/page.tsx b/src/app/shops/orders/[orderId]/page.tsx index a8eed30..51bc082 100644 --- a/src/app/shops/orders/[orderId]/page.tsx +++ b/src/app/shops/orders/[orderId]/page.tsx @@ -15,7 +15,7 @@ import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl"; import useAxios from "@/hooks/useAxios"; import { useUser } from "@/hooks/useUser"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -97,6 +97,12 @@ export default function OrderDetailPage() { const [printMode, setPrintMode] = useState<"invoice" | "label-a5" | "label-a6" | null>( null ); + // این صفحه با تغییرِ فقط orderId دوباره mount نمی‌شه — بدونِ این گارد، وقتی کاربر + // سریع از یه سفارش به سفارشِ دیگه می‌ره، جوابِ کندِ سفارشِ قبلی می‌تونه بعد از + // عوض‌شدنِ orderId برسه و کل صفحه (شاملِ دکمه‌های فعالِ کنسل/تغییرِ وضعیت که + // order._id فعلی رو استفاده می‌کنن) رو با سفارشِ اشتباه پر کنه + const orderIdRef = useRef(params.orderId); + orderIdRef.current = params.orderId; const [showRating, setShowRating] = useState(false); const [ratingScore, setRatingScore] = useState(0); const [ratingComment, setRatingComment] = useState(""); @@ -108,6 +114,7 @@ export default function OrderDetailPage() { const [showShopInfo, setShowShopInfo] = useState(false); const loadOrder = () => { + const requestedOrderId = params.orderId; request<{ order: OrderDetail; report?: OrderReport | null; @@ -115,16 +122,23 @@ export default function OrderDetailPage() { shopRating?: ShopRating | null; }>("GET", `/orders/${params.orderId}`) .then((res) => { + if (orderIdRef.current !== requestedOrderId) return; setOrder(res?.order || null); setTrackingCode(res?.order?.tracking_code || ""); setReport(res?.report || null); setReturnRequest(res?.returnRequest || null); setShopRating(res?.shopRating || null); }) - .catch(() => setOrder(null)); + .catch(() => { + if (orderIdRef.current !== requestedOrderId) return; + setOrder(null); + }); }; useEffect(() => { + // سفارشِ صفحه‌ی قبلی رو پاک می‌کنیم تا برای یه لحظه هم دکمه‌های فعالِ سفارشِ + // قبلی زیرِ orderId جدید نمونن، تا جوابِ تازه برسه + setOrder(null); loadOrder(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [params.orderId]); diff --git a/src/app/shops/payment/failed/page.tsx b/src/app/shops/payment/failed/page.tsx index ae0d9f5..48fe514 100644 --- a/src/app/shops/payment/failed/page.tsx +++ b/src/app/shops/payment/failed/page.tsx @@ -3,7 +3,7 @@ import Container from "@/components/elements/Container"; import RoundedButton from "@/components/elements/RoundedButton"; import LocalePageShell from "@/components/i18n/LocalePageShell"; -import BackButton from "@/components/ui/BackButton"; +import PageTitle from "@/components/settings/PageTitle"; import useAxios from "@/hooks/useAxios"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -53,10 +53,7 @@ export default function ShopPaymentFailedPage() { return ( - -
- {t("shops.paymentFailedTitle")} -
+ {t("shops.paymentFailedTitle")}
- -
- {t("shops.paymentSuccessTitle")} -
+ {t("shops.paymentSuccessTitle")}
)} diff --git a/src/app/users/[username]/page.tsx b/src/app/users/[username]/page.tsx index b4dea8f..2d5c0c2 100644 --- a/src/app/users/[username]/page.tsx +++ b/src/app/users/[username]/page.tsx @@ -9,6 +9,7 @@ import ProfileVisitTracker from "@/components/explore/ProfileVisitTracker"; import { Metadata } from "next"; import { generatePageMetadata } from "@/utils/generatePageMetadata"; import { fetchApiJson } from "@/lib/api/fetchApiJson"; +import { safeJsonLdString } from "@/lib/seo/safeJsonLd"; import { buildProfileJsonLd, buildProfileSeo, @@ -138,7 +139,7 @@ async function UserPage({ params }: IUserProps) { ` تگِ اسکریپتِ جیسون-الدی رو زودتر + * می‌بنده و باقیِ محتوا رو به‌عنوان اسکریپتِ واقعی اجرا می‌کنه (XSS ذخیره‌شده، چون + * توکنِ auth این اپ عمداً httpOnly نیست و از جاوااسکریپت قابل‌خوندنه). escapeِ + * کاراکترِ `<` (استانداردترین راهِ رفعِ این کلاس از حمله برای JSON-LD) این مسیر رو + * می‌بنده، بدون اینکه ساختارِ خودِ جیسون رو خراب کنه. + */ +export function safeJsonLdString(data: unknown): string { + return JSON.stringify(data).replace(/{children} - ); -} +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; +import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; +import React, { ReactNode, useState } from "react"; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +export function ReactQueryProvider({ children }: { children: ReactNode }) { + // یک instance واحد برای عمر کامپوننت — نه ماژول‌سطح، چون createSyncStoragePersister + // به window.localStorage نیاز داره که توی SSR وجود نداره (باید فقط سمت کلاینت ساخته بشه) + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, + }) + ); + const [persister] = useState(() => + typeof window === "undefined" + ? null + : createSyncStoragePersister({ + storage: window.localStorage, + key: "MODSTAGRAM_QUERY_CACHE", + throttleTime: 1000, + }) + ); + + // مثل اندروید: کش برای همه‌ی صفحات فعاله (نه فقط یه بخش خاص) تا آفلاین‌شدن یا + // رفرش صفحه، محتوای قبلاً دیده‌شده رو خالی نکنه — این تابع پیش‌فرض کتابخونه رو + // کاملاً جایگزین می‌کنه (نه AND می‌کنه)، پس چک status رو خودمون انجام می‌دیم + // وگرنه کوئری‌های pending/error هم پرسیست می‌شن + // سمت سرور (SSR) و اولین رندر سمت کلاینت قبل از mount شدنِ persister — همون + // Provider ساده بدون پرسیست، وگرنه هیچ QueryClientProvider ای در دسترس نیست + if (!persister) { + return ( + {children} + ); + } + + return ( + query.state.status === "success", + }, + }} + > + {children} + + ); +}